Skip to content

Commit 7e99237

Browse files
liuxiaocs7M4n5ter
authored andcommitted
test(desktop): scope slash menu refresh observer
Limit the regression watcher to the open slash popover and the three projection refresh calls. Wait on each session Skill projection request before asserting that the original listbox and Skills group remain connected. Fixes #3727 Generated-by: OpenAI Codex
1 parent ef71012 commit 7e99237

2 files changed

Lines changed: 118 additions & 77 deletions

File tree

apps/desktop/e2e/slash-command-menu.spec.ts

Lines changed: 88 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -170,95 +170,109 @@ test('an open menu keeps its container and skills group across projection refres
170170
await composer.fill('seed session');
171171
await composer.press('Enter');
172172
await expect(page.getByText('Fake backend received: seed session')).toBeVisible();
173+
await expect(page.getByRole('button', { name: '停止' })).toHaveCount(0);
174+
175+
// The completed turn publishes its own projection refresh. Wait on the
176+
// composer's public loading state so that work cannot spill into the window
177+
// this test is about.
178+
await page.getByRole('button', { name: '添加上下文' }).click();
179+
const contextMenu = page.getByRole('menu', { name: '添加上下文' });
180+
await expect(contextMenu.getByRole('menuitem', { name: // })).not.toHaveAttribute(
181+
'aria-busy',
182+
'true',
183+
);
184+
await page.keyboard.press('Escape');
185+
await expect(contextMenu).toHaveCount(0);
173186

174187
await composer.click();
175188
await composer.pressSequentially('/');
176189
const menu = page.getByRole('listbox', { name: '命令和技能' });
177190
await expect(menu.getByRole('group', { name: 'Skills' })).toBeVisible();
178191

179-
// Armed before the refresh: the flicker was the skills group (and with it
180-
// the listbox geometry) being torn down and re-created when the projection
181-
// cleared and repopulated, so any removal during the refresh is the
182-
// regression (#2667).
183-
//
184-
// The watch is scoped to exactly that property: one observer on the menu
185-
// itself (subtree) catches a group being torn down inside it, and one on
186-
// its parent catches the whole listbox being replaced. Overlays elsewhere
187-
// in the document (toasts, tooltips, other popups) are outside the window.
188-
// The observers disconnect once the refresh rounds have settled, and the
189-
// count is read once afterwards - a monotonic counter cannot be polled
190-
// back to zero, so polling it only added latency (see #3727).
191-
await page.evaluate(() => {
192-
const menu = document.querySelector('[role="listbox"]');
193-
if (!(menu instanceof HTMLElement)) throw new Error('slash menu not found');
194-
const state = {
195-
removals: 0,
196-
observers: [] as MutationObserver[],
197-
};
198-
(globalThis as unknown as { __slashMenuWatch?: unknown }).__slashMenuWatch = state;
199-
const watch = (target: Node, childList: MutationObserverInit) => {
200-
const observer = new MutationObserver((mutations) => {
201-
for (const mutation of mutations) {
202-
for (const node of mutation.removedNodes) {
203-
if (
204-
node === menu ||
205-
(node instanceof HTMLElement && node.matches('[role="group"]'))
206-
) {
207-
state.removals += 1;
208-
}
209-
}
210-
}
211-
});
212-
observer.observe(target, childList);
213-
state.observers.push(observer);
214-
};
215-
if (menu.parentElement) watch(menu.parentElement, { childList: true });
216-
watch(menu, { childList: true, subtree: true });
217-
});
218-
219192
// A thinking-level change publishes the session's 'updated' event and
220193
// reloads the Skill projection without changing what the menu shows: the
221194
// exact same-content refresh that used to alternate the popup (#2667).
222-
const sessionId = await page.evaluate(async () => {
195+
const observation = await menu.evaluate(async (menuElement) => {
223196
const sessions = await (
224197
window as unknown as {
225198
maka: { sessions: { list(): Promise<Array<{ id: string }>> } };
226199
}
227200
).maka.sessions.list();
228-
return sessions[0]?.id;
229-
});
230-
for (let round = 0; round < 3; round += 1) {
231-
await page.evaluate(
232-
(id) =>
233-
(
234-
window as unknown as {
235-
maka: { sessions: { setThinkingLevel(id: string, level?: null): Promise<unknown> } };
236-
}
237-
).maka.sessions.setThinkingLevel(id!, null),
238-
sessionId,
239-
);
240-
}
241-
// The refresh round trip is IPC-fast; the visibility wait below gives it
242-
// room while asserting the menu never lost its skills group.
243-
await expect(menu.getByRole('group', { name: 'Skills' })).toBeVisible();
244-
// Settle one extra frame pair so any teardown triggered by the last
245-
// refresh lands inside the observation window before it is closed.
246-
await page.evaluate(
247-
() =>
248-
new Promise<void>((resolve) => {
249-
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
250-
}),
251-
);
252-
const removals = await page.evaluate(() => {
253-
const state = (
254-
globalThis as unknown as {
255-
__slashMenuWatch?: { removals: number; observers: MutationObserver[] };
201+
const sessionId = sessions[0]?.id;
202+
if (!sessionId) throw new Error('Session missing before projection refresh');
203+
204+
// Arm immediately before the refreshes and only on this popover. The
205+
// document body also contains unrelated overlays whose teardown says
206+
// nothing about this menu's identity.
207+
const menuContainer = menuElement.parentElement;
208+
const state = { menuRemovals: 0, skillsGroupRemovals: 0 };
209+
const skillsGroup = menuElement.querySelector<HTMLElement>('[role="group"][aria-label="Skills"]');
210+
if (!menuContainer) throw new Error('Slash menu container missing before projection refresh');
211+
if (!skillsGroup) throw new Error('Skills group missing before projection refresh');
212+
const recordRemoval = (
213+
mutations: MutationRecord[],
214+
watchedNode: Node,
215+
key: 'menuRemovals' | 'skillsGroupRemovals',
216+
) => {
217+
for (const mutation of mutations) {
218+
for (const node of mutation.removedNodes) {
219+
if (node === watchedNode) state[key] += 1;
220+
}
221+
}
222+
};
223+
const menuObserver = new MutationObserver((mutations) => {
224+
recordRemoval(mutations, menuElement, 'menuRemovals');
225+
});
226+
const skillsGroupObserver = new MutationObserver((mutations) => {
227+
recordRemoval(mutations, skillsGroup, 'skillsGroupRemovals');
228+
});
229+
menuObserver.observe(menuContainer, { childList: true });
230+
skillsGroupObserver.observe(menuElement, { childList: true });
231+
const maka = (
232+
window as unknown as {
233+
maka: {
234+
sessions: {
235+
setThinkingLevel(id: string, level?: null): Promise<unknown>;
236+
};
237+
};
256238
}
257-
).__slashMenuWatch;
258-
if (!state) throw new Error('slash menu watch was never armed');
259-
for (const observer of state.observers) observer.disconnect();
260-
return state.removals;
239+
).maka;
240+
const e2eControls = (
241+
window as unknown as {
242+
makaE2eLatch?: {
243+
waitForInvocableSkillsCall(sessionId: string): Promise<void>;
244+
};
245+
}
246+
).makaE2eLatch;
247+
if (!e2eControls) throw new Error('E2E bridge controls missing before projection refresh');
248+
try {
249+
for (let round = 0; round < 3; round += 1) {
250+
const projectionSettled = e2eControls.waitForInvocableSkillsCall(sessionId);
251+
await maka.sessions.setThinkingLevel(sessionId, null);
252+
await projectionSettled;
253+
await new Promise<void>((resolve) => {
254+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
255+
});
256+
}
257+
} finally {
258+
// Drain the final queued batch before closing the exact refresh window;
259+
// polling a monotonic counter cannot turn a failure into success.
260+
recordRemoval(menuObserver.takeRecords(), menuElement, 'menuRemovals');
261+
recordRemoval(skillsGroupObserver.takeRecords(), skillsGroup, 'skillsGroupRemovals');
262+
menuObserver.disconnect();
263+
skillsGroupObserver.disconnect();
264+
}
265+
return {
266+
...state,
267+
menuConnected: menuElement.isConnected,
268+
skillsGroupConnected: skillsGroup.isConnected,
269+
};
270+
});
271+
expect(observation).toEqual({
272+
menuRemovals: 0,
273+
skillsGroupRemovals: 0,
274+
menuConnected: true,
275+
skillsGroupConnected: true,
261276
});
262-
expect(removals).toBe(0);
263277
await expect(menu.getByRole('group', { name: 'Skills' })).toBeVisible();
264278
});

apps/desktop/src/preload/preload.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3165,18 +3165,20 @@ const makaBridge = {
31653165
},
31663166
} satisfies MakaBridge;
31673167

3168-
// E2E-only async latches. Real users never get these: the preload mirrors the
3168+
// E2E-only async controls. Real users never get these: the preload mirrors the
31693169
// main process's isolated-E2E gate (startup-context.ts) — MAKA_E2E alone is
31703170
// not enough without the throwaway profile dir. An armed latch holds the next
31713171
// bridge call or an explicitly gated renderer boundary until the test releases
3172-
// it, so Playwright gets a deterministic in-flight window instead of racing
3173-
// near-instant work. The wrappers must be installed BEFORE
3172+
// it, while a settled-call waiter exposes a deterministic completion boundary
3173+
// for work whose visible result may intentionally keep the same DOM identity.
3174+
// The wrappers must be installed BEFORE
31743175
// exposeInMainWorld: the bridge is cloned into the main world at expose time,
31753176
// and the exposed clone is sealed against later patching.
31763177
if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) {
31773178
type LatchKey = 'newTasks.listInvocableSkills' | 'sessions.list' | 'settings.chunk';
31783179
const gates = new Map<LatchKey, { promise: Promise<void>; oneShot: boolean }>();
31793180
const releases = new Map<LatchKey, { resolve: () => void; reject: (error: Error) => void }>();
3181+
const invocableSkillsWaiters = new Map<string, Array<() => void>>();
31803182
const waitForLatch = async (key: LatchKey): Promise<void> => {
31813183
const gate = gates.get(key);
31823184
if (!gate) return;
@@ -3198,6 +3200,24 @@ if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) {
31983200
makaBridge.sessions.list.bind(makaBridge.sessions),
31993201
'sessions.list',
32003202
);
3203+
const listInvocableSkills = makaBridge.skills.listInvocable.bind(makaBridge.skills);
3204+
makaBridge.skills.listInvocable = async (...args) => {
3205+
try {
3206+
return await listInvocableSkills(...args);
3207+
} finally {
3208+
const sessionId = args[0];
3209+
if (sessionId) {
3210+
const waiters = invocableSkillsWaiters.get(sessionId);
3211+
const resolve = waiters?.shift();
3212+
if (waiters?.length === 0) invocableSkillsWaiters.delete(sessionId);
3213+
if (resolve) {
3214+
// Let consumers of the bridge promise run their state updates before
3215+
// the test continues from the observed completion.
3216+
setTimeout(resolve, 0);
3217+
}
3218+
}
3219+
}
3220+
};
32013221
contextBridge.exposeInMainWorld('makaE2eLatch', {
32023222
arm(key: LatchKey, options?: { oneShot?: boolean }) {
32033223
let resolve: () => void = () => {};
@@ -3212,6 +3232,13 @@ if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) {
32123232
wait(key: 'settings.chunk') {
32133233
return waitForLatch(key);
32143234
},
3235+
waitForInvocableSkillsCall(sessionId: string) {
3236+
return new Promise<void>((resolve) => {
3237+
const waiters = invocableSkillsWaiters.get(sessionId) ?? [];
3238+
waiters.push(resolve);
3239+
invocableSkillsWaiters.set(sessionId, waiters);
3240+
});
3241+
},
32153242
release(key: LatchKey) {
32163243
releases.get(key)?.resolve();
32173244
releases.delete(key);

0 commit comments

Comments
 (0)