Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
56a7856
feat(desktop): expose the Work Board store through main-process IPC
somewan820 Aug 17, 2026
0e05f60
feat(desktop): expose Work Board IPC through the preload bridge
somewan820 Aug 17, 2026
5a4e5b5
feat(desktop): add the Work Board panel as a workbar tab
somewan820 Aug 17, 2026
d9ca23c
docs(work-board): add the Phase 1 capture/list MVP page
somewan820 Aug 17, 2026
98645eb
fix(desktop): address Phase 1 review and refresh surface inventory
somewan820 Aug 17, 2026
86f17ee
docs(work-board): justify the dedicated store and resequence validation
somewan820 Aug 17, 2026
4f610fc
docs(work-board): record the assumption and the Phase 3 spike gate
somewan820 Aug 17, 2026
e78e543
fix(desktop): keep pagination, scope, and IME-safe inputs in the Work…
somewan820 Aug 17, 2026
f176412
fix(desktop): keep items visible and retry the same cursor on continu…
somewan820 Aug 17, 2026
8e0f946
fix(desktop): address Work Board review findings
somewan820 Aug 21, 2026
601c89b
fix(work-board): include relinked project aliases
somewan820 Aug 21, 2026
9af8d70
fix(work-board): freeze rename draft revision
somewan820 Aug 21, 2026
4670406
ci: retrigger Work Board checks
somewan820 Aug 21, 2026
a05df89
fix(work-board): bound relinked scope cursors
somewan820 Aug 22, 2026
3ba508a
style(work-board): format paginated store assertion
somewan820 Aug 22, 2026
3920be6
fix(desktop): sync workbar and composer contracts
somewan820 Aug 24, 2026
2d73c96
fix(desktop): keep Work Board data behind Workbar host
somewan820 Aug 24, 2026
79b7a1e
chore(ci): refresh Astryx surface inventory
somewan820 Aug 24, 2026
1c8d833
fix(desktop): tighten Work Board row and host contracts
somewan820 Aug 24, 2026
5d4481b
test(desktop): cover Work Board paginated refresh
somewan820 Aug 24, 2026
ddeb741
fix(desktop): close Work Board and Quote Companion review gaps
somewan820 Aug 24, 2026
1838a81
merge: sync PR 3135 with apache main
somewan820 Aug 24, 2026
8ebf4cf
fix(storage): recognize Work Board entrypoint consumer
somewan820 Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions apps/desktop/src/main/__tests__/use-work-board.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { strict as assert } from 'node:assert';
import { afterEach, describe, it } from 'node:test';
import { act, createElement } from 'react';
import type { WorkBoardItem } from '@maka/core/work-board';
import type {
WorkBoardChangedEvent,
WorkBoardIpcResult,
} from '../../shared/work-board-ipc.js';
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
import { useWorkBoard } from '../../renderer/use-work-board.js';

interface Harness {
listCalls: Array<{ cursor?: string; limit?: number }>;
emitChanged(): void;
}

function item(id: number): WorkBoardItem {
return {
schemaVersion: 1,
id: `item-${id}`,
revision: 1,
scope: { kind: 'inbox' },
title: `Item ${id}`,
state: 'todo',
creator: { kind: 'user' },
provenance: { kind: 'manual' },
createdAt: id,
updatedAt: id,
archived: false,
};
}

function installHarness(): Harness {
const allItems = Array.from({ length: 60 }, (_, index) => item(index));
const listCalls: Harness['listCalls'] = [];
let changed: ((event: WorkBoardChangedEvent) => void) | undefined;
const list = async (query?: { cursor?: string; limit?: number }): Promise<WorkBoardIpcResult<{
items: WorkBoardItem[];
nextCursor?: string;
}>> => {
listCalls.push({ cursor: query?.cursor, limit: query?.limit });
const start = query?.cursor ? Number(query.cursor) : 0;
const limit = query?.limit ?? 50;
const end = Math.min(start + limit, allItems.length);
return {
ok: true,
value: {
items: allItems.slice(start, end),
nextCursor: end < allItems.length ? String(end) : undefined,
},
};
};
const unexpectedMutation = async (): Promise<never> => {
throw new Error('mutation is not expected in this test');
};
(globalThis.window as unknown as { maka: unknown }).maka = {
workBoard: {
list,
create: unexpectedMutation,
update: unexpectedMutation,
archive: unexpectedMutation,
unarchive: unexpectedMutation,
remove: unexpectedMutation,
subscribeChanges(listener: (event: WorkBoardChangedEvent) => void) {
changed = listener;
return () => {
changed = undefined;
};
},
},
};
return {
listCalls,
emitChanged() {
changed?.({ type: 'work_board_changed', ts: 1 });
},
};
}

function Probe(props: { onValue(value: ReturnType<typeof useWorkBoard>): void }) {
props.onValue(useWorkBoard());
return null;
}

describe('useWorkBoard', () => {
afterEach(() => {
cleanupFakeDom();
});

it('preserves the loaded window when a mutation change signal refreshes the board', async () => {
const { root } = installReactRenderer();
const harness = installHarness();
let board: ReturnType<typeof useWorkBoard> | undefined;

await act(async () => {
root.render(createElement(Probe, { onValue: (value) => (board = value) }));
});
await act(async () => board?.loadMore());

assert.equal(board?.items.length, 60);
assert.equal(board?.nextCursor, undefined);
assert.deepEqual(harness.listCalls, [
{ cursor: undefined, limit: undefined },
{ cursor: '50', limit: undefined },
]);

await act(async () => harness.emitChanged());

assert.equal(board?.items.length, 60);
assert.equal(board?.nextCursor, undefined);
assert.deepEqual(harness.listCalls.at(-1), { cursor: undefined, limit: 60 });
});
});
227 changes: 227 additions & 0 deletions apps/desktop/src/main/__tests__/work-board-ipc-main.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, test } from 'node:test';
import type { IpcMain } from 'electron';
import {
registerWorkBoardIpc,
type WorkBoardChangedEvent,
type WorkBoardIpcResult,
} from '../work-board-ipc-main.js';

interface FakeIpcMain {
handle(channel: string, handler: (...args: unknown[]) => unknown): void;
invoke<T>(channel: string, ...args: unknown[]): Promise<T>;
}

function createFakeIpcMain(): FakeIpcMain & { readonly channels: string[] } {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
const channels: string[] = [];
return {
channels,
handle(channel, handler) {
channels.push(channel);
handlers.set(channel, handler);
},
invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
const handler = handlers.get(channel);
if (!handler) throw new Error(`No handler registered for ${channel}`);
return Promise.resolve(handler(undefined, ...args)) as Promise<T>;
},
};
}

function createFakeWindowController(): {
readonly events: Array<{ channel: string; args: unknown[] }>;
send(channel: string, ...args: unknown[]): void;
} {
const events: Array<{ channel: string; args: unknown[] }> = [];
return {
events,
send(channel, ...args) {
events.push({ channel, args });
},
};
}

function itemInput(): {
scope: { kind: 'inbox' };
title: string;
creator: { kind: 'user' };
provenance: { kind: 'manual' };
} {
return {
scope: { kind: 'inbox' },
title: 'Review auth',
creator: { kind: 'user' },
provenance: { kind: 'manual' },
};
}

async function withTempRoot(run: (root: string) => Promise<void>): Promise<void> {
const root = await mkdtemp(join(tmpdir(), 'maka-work-board-ipc-'));
try {
await run(root);
} finally {
await rm(root, { recursive: true, force: true });
}
}

describe('Work Board IPC', () => {
test('creates and lists items and emits change signals', async () => {
await withTempRoot(async (root) => {
const ipc = createFakeIpcMain();
const window = createFakeWindowController();
const registration = registerWorkBoardIpc({
ipcMain: ipc as unknown as Pick<IpcMain, 'handle'>,
workspaceRoot: root,
mainWindowController: window,
});
try {
const created = await ipc.invoke<WorkBoardIpcResult<{ id: string; revision: number }>>(
'workBoard:create',
itemInput(),
);
assert.equal(created.ok, true);
assert.ok(created.ok && created.value.id);

const page = await ipc.invoke<WorkBoardIpcResult<{ items: Array<{ id: string }> }>>(
'workBoard:list',
{},
);
assert.equal(page.ok, true);
assert.ok(page.ok);
assert.equal(page.value.items.length, 1);
assert.equal(page.value.items[0]?.id, created.ok ? created.value.id : undefined);

const changed = window.events.filter(
(event) => event.channel === 'workBoard:changed',
);
assert.equal(changed.length, 1);
const event = changed[0]?.args[0] as WorkBoardChangedEvent;
assert.equal(event.type, 'work_board_changed');
assert.ok(typeof event.ts === 'number');
assert.deepEqual(ipc.channels, [
'workBoard:list',
'workBoard:create',
'workBoard:update',
'workBoard:archive',
'workBoard:unarchive',
'workBoard:remove',
]);
} finally {
registration.close();
}
});
});

test('applies lifecycle mutations and fails closed on invalid input', async () => {
await withTempRoot(async (root) => {
const ipc = createFakeIpcMain();
const window = createFakeWindowController();
const registration = registerWorkBoardIpc({
ipcMain: ipc as unknown as Pick<IpcMain, 'handle'>,
workspaceRoot: root,
mainWindowController: window,
});
try {
const created = await ipc.invoke<WorkBoardIpcResult<{ id: string; revision: number }>>(
'workBoard:create',
itemInput(),
);
assert.ok(created.ok);
const id = created.ok ? created.value.id : '';

const renamed = await ipc.invoke<
WorkBoardIpcResult<{ title: string; revision: number; state: string }>
>('workBoard:update', id, { title: 'Review auth v2' });
assert.ok(renamed.ok);
assert.equal(renamed.ok && renamed.value.revision, 2);

const staleRename = await ipc.invoke<WorkBoardIpcResult<unknown>>(
'workBoard:update',
id,
{ title: 'stale write' },
{ expectedRevision: 1 },
);
assert.equal(staleRename.ok, false);
if (!staleRename.ok) assert.equal(staleRename.code, 'operation_conflict');

const removedBeforeArchive = await ipc.invoke<WorkBoardIpcResult<null>>(
'workBoard:remove',
id,
);
assert.equal(removedBeforeArchive.ok, false);
if (!removedBeforeArchive.ok) {
assert.equal(removedBeforeArchive.code, 'must_archive_first');
}

const archived = await ipc.invoke<
WorkBoardIpcResult<{ archived: boolean; revision: number }>
>('workBoard:archive', id);
assert.ok(archived.ok);
assert.equal(archived.ok && archived.value.archived, true);

const reopened = await ipc.invoke<
WorkBoardIpcResult<{ archived: boolean; revision: number }>
>('workBoard:unarchive', id);
assert.ok(reopened.ok);
assert.equal(reopened.ok && reopened.value.archived, false);

const invalidPatch = await ipc.invoke<WorkBoardIpcResult<unknown>>(
'workBoard:update',
id,
{ titel: 'x' },
);
assert.equal(invalidPatch.ok, false);
if (!invalidPatch.ok) assert.equal(invalidPatch.code, 'invalid_input');

const invalidCreate = await ipc.invoke<WorkBoardIpcResult<unknown>>(
'workBoard:create',
{ ...itemInput(), notes: null },
);
assert.equal(invalidCreate.ok, false);
if (!invalidCreate.ok) assert.equal(invalidCreate.code, 'invalid_input');

await ipc.invoke('workBoard:archive', id);
const removed = await ipc.invoke<WorkBoardIpcResult<null>>('workBoard:remove', id);
assert.ok(removed.ok);

const page = await ipc.invoke<WorkBoardIpcResult<{ items: unknown[] }>>(
'workBoard:list',
{},
);
assert.ok(page.ok);
assert.equal(page.ok && page.value.items.length, 0);

// create, update, archive, unarchive, archive, remove = 6 mutations
const changed = window.events.filter(
(event) => event.channel === 'workBoard:changed',
);
assert.equal(changed.length, 6);
} finally {
registration.close();
}
});
});
});
Loading