Files
maka/apps/desktop/e2e/composer-plus-menu-stability.spec.ts
agent-radar 7be535059e
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 19:05:14 +08:00

404 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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 { expect, test, COMPOSER_INPUT, waitForInvocableSkills } from './fixtures';
type LatchKey = 'newTasks.listInvocableSkills' | 'sessions.list';
declare global {
interface Window {
/** E2E-only preload affordance; see the MAKA_E2E block in preload.ts. */
makaE2eLatch?: {
arm(key: LatchKey, options?: { oneShot?: boolean }): void;
release(key: LatchKey): void;
reject(key: LatchKey, message: string): void;
};
}
}
/**
* Hold the next call (or every call) to one bridge method until released, so
* an IPC in-flight window can be observed deterministically instead of raced
* against the fake backend's near-instant replies. Installed by the preload
* under the isolated-E2E gate; its absence in an E2E window is a wiring bug,
* not a reason to skip.
*/
async function armBridgeLatch(
page: import('@playwright/test').Page,
key: LatchKey,
options?: { oneShot?: boolean },
): Promise<void> {
const armed = await page.evaluate(({ key: latchKey, options: latchOptions }) => {
if (!window.makaE2eLatch) return false;
window.makaE2eLatch.arm(latchKey, latchOptions);
return true;
}, { key, options });
expect(armed, 'the preload E2E latch is installed').toBe(true);
}
async function releaseBridgeLatch(
page: import('@playwright/test').Page,
key: LatchKey,
): Promise<void> {
await page.evaluate((latchKey) => window.makaE2eLatch?.release(latchKey), key);
}
async function rejectBridgeLatch(
page: import('@playwright/test').Page,
key: LatchKey,
): Promise<void> {
await page.evaluate(
(latchKey) => window.makaE2eLatch?.reject(latchKey, 'forced E2E bridge failure'),
key,
);
}
// The fake echo can paint before Runtime Host publishes terminal turn ownership.
// Use the catalog's known-empty live set as the barrier before latching its next read.
async function waitForSessionTurnToSettle(
page: import('@playwright/test').Page,
): Promise<void> {
await expect
.poll(async () =>
page.evaluate(async () => {
const sessions = await window.maka.sessions.list();
return sessions[0]?.runningTurnIds ?? null;
}),
)
.toEqual([]);
}
/**
* Toggling Plan from the + menu must not move the menu.
*
* The toggle changes the new chat's collaboration mode, which re-fetches the
* invocable-Skill projection. That refresh clears the list fail-closed for the
* `/` popup, and the regression this spec pins is the Skills row reading the
* transient `[]` as "no skills available": it grayed out and grew a
* description line for the length of the round trip, so the open menu's
* geometry blinked on every Plan click (MatrixA/fix-plan-click-flicker).
*
* The watcher is armed in-page BEFORE the click: the blink lives inside one
* IPC round trip and is gone by the time a polling assertion could look.
*/
interface PlusMenuWatch {
noSkillsTextAppeared: boolean;
skillsRowDisabled: boolean;
planRowDisabled: boolean;
heights: number[];
}
declare global {
interface Window {
__plusMenuWatch?: PlusMenuWatch;
__plusMenuWatchStop?: () => void;
}
}
test('toggling Plan keeps the + menu open, enabled and the same size', async ({
invocableSkillsWindow: page,
}) => {
await page.getByRole('button', { name: '添加上下文' }).click();
const menu = page.getByRole('menu', { name: '添加上下文' });
await expect(menu).toBeVisible();
const planRow = menu.getByRole('menuitemcheckbox', { name: 'Plan' });
const skillsRow = menu.getByRole('menuitem', { name: /选择技能/ });
await expect(planRow).toHaveAttribute('aria-checked', 'false');
// The seeded catalog has settled before the fixture yields the page, so the
// baseline is an enabled row with no caveat — what must survive the toggle.
await expect(skillsRow).not.toHaveAttribute('aria-disabled', 'true');
await expect(menu).not.toContainText('当前没有可用技能');
// The layer scales in on open (translate + scale 0.95 → 1), and a bounding
// box read mid-entrance is smaller than the resting one. Let the entrance
// finish so the recorded baseline is the height the menu must keep.
await menu.evaluate(async (menuElement) => {
const layer = menuElement.closest('[popover]') ?? menuElement;
await Promise.all(
layer.getAnimations().map((animation) => animation.finished.catch(() => {})),
);
});
await menu.evaluate((menuElement) => {
const watch: PlusMenuWatch = {
noSkillsTextAppeared: false,
skillsRowDisabled: false,
planRowDisabled: false,
heights: [menuElement.getBoundingClientRect().height],
};
const inspect = () => {
if (menuElement.textContent?.includes('当前没有可用技能')) {
watch.noSkillsTextAppeared = true;
}
for (const row of menuElement.querySelectorAll('[aria-disabled="true"]')) {
if (row.textContent?.includes('选择技能')) watch.skillsRowDisabled = true;
if (row.getAttribute('role') === 'menuitemcheckbox') watch.planRowDisabled = true;
}
const height = menuElement.getBoundingClientRect().height;
const last = watch.heights[watch.heights.length - 1] ?? height;
if (Math.abs(height - last) > 0.5) watch.heights.push(height);
};
const observer = new MutationObserver(inspect);
observer.observe(menuElement, {
subtree: true,
childList: true,
attributes: true,
characterData: true,
});
window.__plusMenuWatch = watch;
window.__plusMenuWatchStop = () => {
inspect();
observer.disconnect();
};
});
await planRow.click();
await expect(planRow).toHaveAttribute('aria-checked', 'true');
// The mode mark lands on the footer while the menu stays where it was.
await expect(page.locator('.maka-composer-mode-button[data-mode="plan"]')).toBeVisible();
await expect(menu).toBeVisible();
await planRow.click();
await expect(planRow).toHaveAttribute('aria-checked', 'false');
await expect(page.locator('.maka-composer-mode-button[data-mode="plan"]')).toHaveCount(0);
await expect(menu).toBeVisible();
// Both refreshes the two toggles kicked off have reached the backend once
// this resolves; the margin covers the renderer commit that follows.
await waitForInvocableSkills(page, ['project-only', 'workspace-only']);
await page.waitForTimeout(250);
const watch = await page.evaluate(() => {
window.__plusMenuWatchStop?.();
return window.__plusMenuWatch;
});
expect(watch, 'the in-page watcher survived the journey').toBeTruthy();
expect(watch?.noSkillsTextAppeared, 'no transient "no skills" line').toBe(false);
expect(watch?.skillsRowDisabled, 'the Skills row never grayed out').toBe(false);
expect(watch?.planRowDisabled, 'the Plan row never grayed out').toBe(false);
expect(watch?.heights, 'the menu kept one height throughout').toHaveLength(1);
});
test('a Skills click during the catalog refresh does nothing, then works settled', async ({
invocableSkillsWindow: page,
}) => {
// The row's enabled look mid-refresh is a held presentation of the previous
// catalog; acting on it would type a stray `/` against the fail-closed
// list. Hold the refresh open on a latch and click straight into it.
await armBridgeLatch(page, 'newTasks.listInvocableSkills');
const composer = page.locator(COMPOSER_INPUT);
await page.getByRole('button', { name: '添加上下文' }).click();
const menu = page.getByRole('menu', { name: '添加上下文' });
const planRow = menu.getByRole('menuitemcheckbox', { name: 'Plan' });
const skillsRow = menu.getByRole('menuitem', { name: /选择技能/ });
// The Plan toggle starts the (now latched) refresh; the row announces the
// held state — busy to assistive technology, a class for styling and tests
// — and a click inside the window has no effect at all.
await planRow.click();
await expect(skillsRow).toHaveClass(/maka-composer-skills-loading/);
await expect(skillsRow).toHaveAttribute('aria-busy', 'true');
await skillsRow.click();
await expect(menu).toBeVisible();
await expect(composer).toHaveText('');
await expect(page.getByRole('listbox', { name: /技能/ })).toHaveCount(0);
// Keyboard activation is the same no-op: Enter on the focused row neither
// closes the menu nor writes the slash.
await skillsRow.focus();
await page.keyboard.press('Enter');
await expect(menu).toBeVisible();
await expect(composer).toHaveText('');
await expect(page.getByRole('listbox', { name: /技能/ })).toHaveCount(0);
// Released, the same activation opens the `/` popup as usual. Re-focus the
// row first: the settle re-renders the composer input's trigger config,
// which takes focus back to the editor (long-standing behavior on every
// catalog refresh, independent of this journey).
await releaseBridgeLatch(page, 'newTasks.listInvocableSkills');
await expect(skillsRow).not.toHaveClass(/maka-composer-skills-loading/);
await expect(skillsRow).not.toHaveAttribute('aria-busy', 'true');
await skillsRow.focus();
await page.keyboard.press('Enter');
await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible();
});
test('a context switch re-enters loading instead of holding the old catalog', async ({
invocableSkillsWindow: page,
}) => {
// Populate and settle a session-scoped catalog first.
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('alpha-marker');
await composer.press('Enter');
await expect(page.getByText(/Fake backend received: alpha-marker/)).toBeVisible();
// Switching to a new chat is a context change: the new-task catalog is
// latched, so the Skills row must present as loading — deferring activation
// — rather than as the previous session's settled, actionable catalog.
await armBridgeLatch(page, 'newTasks.listInvocableSkills');
await page.getByRole('button', { name: '展开侧边栏' }).click();
const sidebar = page.getByRole('navigation', { name: '任务列表' });
await sidebar.getByRole('button', { name: '新任务', exact: true }).click();
await expect(composer).toHaveText('');
await page.getByRole('button', { name: '添加上下文' }).click();
const menu = page.getByRole('menu', { name: '添加上下文' });
const skillsRow = menu.getByRole('menuitem', { name: /选择技能/ });
await expect(skillsRow).toHaveAttribute('aria-busy', 'true');
await skillsRow.click();
await expect(menu).toBeVisible();
await expect(composer).toHaveText('');
await releaseBridgeLatch(page, 'newTasks.listInvocableSkills');
await expect(skillsRow).not.toHaveAttribute('aria-busy', 'true');
await skillsRow.click();
await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible();
});
test('two rapid Plan toggles land on the last requested state', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('alpha-marker');
await composer.press('Enter');
await expect(page.getByText(/Fake backend received: alpha-marker/)).toBeVisible();
await waitForSessionTurnToSettle(page);
await page.getByRole('button', { name: '添加上下文' }).click();
const menu = page.getByRole('menu', { name: '添加上下文' });
const planRow = menu.getByRole('menuitemcheckbox', { name: 'Plan' });
await expect(planRow).toHaveAttribute('aria-checked', 'false');
// The echo can land while the turn is still settling, and mode changes are
// refused mid-turn; wait for the row to become actionable.
await expect(planRow).not.toHaveAttribute('aria-disabled', 'true');
// The session-list refresh is the tail of a Plan commit: latching its next
// call keeps the commit pending — deterministically — after the mode has
// already landed and the row repainted checked. Armed only now, so the
// send's own refreshes above cannot consume the latch.
await armBridgeLatch(page, 'sessions.list', { oneShot: true });
await planRow.click();
await expect(planRow).toHaveAttribute('aria-checked', 'true');
// Second click while the first commit is still pending: the latest ask is
// OFF, and it must not be dropped just because the registry is busy.
await planRow.click();
// The queued intent drains after the in-flight commit settles: OFF wins.
await releaseBridgeLatch(page, 'sessions.list');
await expect(planRow).toHaveAttribute('aria-checked', 'false');
});
test('a failed catalog refresh keeps the committed Plan state visible', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('alpha-marker');
await composer.press('Enter');
await expect(page.getByText(/Fake backend received: alpha-marker/)).toBeVisible();
await waitForSessionTurnToSettle(page);
await page.getByRole('button', { name: '添加上下文' }).click();
const menu = page.getByRole('menu', { name: '添加上下文' });
const planRow = menu.getByRole('menuitemcheckbox', { name: 'Plan' });
await expect(planRow).toHaveAttribute('aria-checked', 'false');
await expect(planRow).not.toHaveAttribute('aria-disabled', 'true');
await armBridgeLatch(page, 'sessions.list', { oneShot: true });
await planRow.click();
await expect(planRow).toHaveAttribute('aria-checked', 'true');
await rejectBridgeLatch(page, 'sessions.list');
await expect(planRow).toHaveAttribute('aria-checked', 'true');
});
test('latest Plan intent still reaches the Host after a catalog refresh fails', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('alpha-marker');
await composer.press('Enter');
await expect(page.getByText(/Fake backend received: alpha-marker/)).toBeVisible();
await waitForSessionTurnToSettle(page);
await page.getByRole('button', { name: '添加上下文' }).click();
const planRow = page.getByRole('menu', { name: '添加上下文' })
.getByRole('menuitemcheckbox', { name: 'Plan' });
await expect(planRow).not.toHaveAttribute('aria-disabled', 'true');
await armBridgeLatch(page, 'sessions.list', { oneShot: true });
await planRow.click();
await expect(planRow).toHaveAttribute('aria-checked', 'true');
await planRow.click();
await rejectBridgeLatch(page, 'sessions.list');
await expect.poll(async () => page.evaluate(async () => {
const sessions = await window.maka.sessions.list();
return sessions[0]?.collaborationMode;
})).toBe('agent');
await expect(planRow).toHaveAttribute('aria-checked', 'false');
});
test('deleting the session while a toggle is pending settles clean', async ({
invocableSkillsWindow: page,
}) => {
// pending → cleanup → settle: the Session's renderer lifecycle ends while a
// mode commit (and a queued follow-up intent) is still in flight. Cleanup
// must drop the queued ask with the rest of the Session state, so the
// commit's tail has nothing to replay against the removed Session.
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('alpha-marker');
await composer.press('Enter');
await expect(page.getByText(/Fake backend received: alpha-marker/)).toBeVisible();
await waitForSessionTurnToSettle(page);
await page.getByRole('button', { name: '添加上下文' }).click();
const menu = page.getByRole('menu', { name: '添加上下文' });
const planRow = menu.getByRole('menuitemcheckbox', { name: 'Plan' });
// Mode changes are refused mid-turn; wait for the row to become actionable.
await expect(planRow).not.toHaveAttribute('aria-disabled', 'true');
await armBridgeLatch(page, 'sessions.list', { oneShot: true });
await planRow.click();
await expect(planRow).toHaveAttribute('aria-checked', 'true');
await planRow.click();
await page.keyboard.press('Escape');
await expect(menu).not.toBeVisible();
await page.getByRole('button', { name: '展开侧边栏' }).click();
const sidebar = page.getByRole('navigation', { name: '任务列表' });
const row = sidebar.locator('[data-maka-contract="session-row"]').first();
await row.hover();
await row.getByRole('button', { name: '任务操作' }).click();
await page.getByRole('menuitem', { name: '删除', exact: true }).click();
const confirm = page.getByRole('alertdialog');
await confirm.getByRole('button', { name: '删除', exact: true }).click();
await releaseBridgeLatch(page, 'sessions.list');
await expect(sidebar.locator('[data-maka-contract="session-row"]')).toHaveCount(0);
await expect(page.getByRole('alertdialog')).toHaveCount(0);
// The composer is back on a clean new chat: no error surfaced and no stale
// Plan state re-applied by the drained commit.
await expect(composer).toBeVisible();
await expect(page.locator('.maka-composer-mode-button[data-mode="plan"]')).toHaveCount(0);
});