Files
maka/apps/desktop/e2e/slash-command-menu.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

352 lines
14 KiB
TypeScript

/*
* 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 { FAKE_HOLD_OPEN_PROMPT } from '@maka/runtime/test-only/fake-backend';
import { expect, test, COMPOSER_INPUT } from './fixtures';
test('shows only slash commands executable in the current session state', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.click();
await composer.pressSequentially('/');
const freshMenu = page.getByRole('listbox', { name: '命令和技能' });
const freshCommands = freshMenu.getByRole('group', { name: '命令' });
await expect(freshCommands.getByRole('option')).toHaveCount(2);
await expect(freshCommands.getByRole('option', { name: /使用 Graph.*\/graph/ })).toBeVisible();
await expect(freshCommands.getByRole('option', { name: /使用 Swarm.*\/swarm/ })).toBeVisible();
await composer.press('Escape');
await composer.fill('seed session');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: seed session')).toBeVisible();
await composer.click();
await composer.pressSequentially('/');
const menu = page.getByRole('listbox', { name: '命令和技能' });
const groups = menu.getByRole('group');
await expect(groups).toHaveCount(2);
await expect(groups.nth(0)).toHaveAttribute('aria-label', '命令');
await expect(groups.nth(1)).toHaveAttribute('aria-label', 'Skills');
await expect(groups.nth(0).getByRole('option')).toHaveCount(4);
});
test('compacts the active session', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('seed session');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: seed session')).toBeVisible();
const sessionId = await page.evaluate(async () => (await window.maka.sessions.list())[0]?.id);
expect(sessionId).toBeTruthy();
await page.evaluate((activeSessionId) => {
const testWindow = window as typeof window & {
__makaObservedCompactCompletion?: boolean;
};
testWindow.__makaObservedCompactCompletion = false;
window.maka.sessions.subscribeChanges((event) => {
if (event.reason === 'status-change' && event.sessionId === activeSessionId) {
testWindow.__makaObservedCompactCompletion = true;
}
});
}, sessionId!);
await composer.click();
await composer.pressSequentially('/');
const menu = page.getByRole('listbox', { name: '命令和技能' });
const compact = menu.getByRole('group', { name: '命令' }).getByRole('option', {
name: /压缩上下文.*\/compact/,
});
await compact.click();
await expect.poll(() => composer.textContent()).toBe('/compact ');
await expect(menu).not.toBeVisible();
await composer.press('Enter');
await expect
.poll(() =>
page.evaluate(
() =>
(window as typeof window & { __makaObservedCompactCompletion?: boolean })
.__makaObservedCompactCompletion,
),
)
.toBe(true);
await expect.poll(() => composer.textContent()).toBe('');
await expect(page.getByText('压缩失败')).toHaveCount(0);
// After the compact completes the composer clears and can remount. `fill()`
// can land before the contentEditable is focused again, so the draft never
// populates and Enter submits nothing — the flake in issue #3289. Type
// through the focused element and require the draft to have settled before
// dispatching, mirroring the running-turn spec below.
await composer.click();
await composer.pressSequentially('after compact');
await expect.poll(() => composer.textContent()).toBe('after compact');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: after compact')).toBeVisible();
await expect(page.getByText('Fake backend received: /compact')).toHaveCount(0);
});
test('offers commands only for the first token and keeps explicit Skill queries separate', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('seed session');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: seed session')).toBeVisible();
await composer.fill('explain /');
const inlineMenu = page.getByRole('listbox', { name: '命令和技能' });
await expect(inlineMenu.getByRole('group', { name: '命令' })).toHaveCount(0);
await expect(inlineMenu.getByRole('group', { name: 'Skills' })).toBeVisible();
await composer.fill('/skill:compact');
await expect(inlineMenu.getByRole('option', { name: /\/compact/ })).toHaveCount(0);
await composer.fill('/side');
// macOS maps Home to document scrolling rather than line-start movement in
// contentEditable. Put the caret at the same semantic position on every OS
// before checking that a slash with text after the caret is not a command.
await composer.press(process.platform === 'darwin' ? 'Meta+ArrowLeft' : 'Home');
await composer.press('ArrowRight');
await expect(inlineMenu.getByRole('group', { name: '命令' })).toHaveCount(0);
});
test('does not open the slash menu for path separators', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
const menu = page.getByRole('listbox', { name: '命令和技能' });
for (const prefix of ['帮我整理到/Users', 'path/to/file']) {
await composer.fill(prefix);
await composer.evaluate((editable) => {
// Chromium can put the next typed character in a new text node. Recreate
// that observed input shape without letting Playwright normalize the DOM.
const node = editable.appendChild(document.createTextNode('/'));
const range = document.createRange();
range.setStart(node, 1);
range.collapse(true);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
editable.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: '/',
inputType: 'insertText',
}));
});
await expect(menu).toHaveCount(0);
await expect.poll(() => composer.textContent()).toBe(`${prefix}/`);
await expect
.poll(() =>
composer.evaluate((editable) => {
const selection = window.getSelection();
if (!selection?.focusNode || !editable.contains(selection.focusNode)) return -1;
const range = document.createRange();
range.selectNodeContents(editable);
range.setEnd(selection.focusNode, selection.focusOffset);
return range.toString().length;
}),
)
.toBe(`${prefix}/`.length);
}
});
test('opens the slash menu after a DOM block break', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
const menu = page.getByRole('listbox', { name: '命令和技能' });
await composer.fill('first line');
await composer.evaluate((editable) => {
const block = document.createElement('div');
const slash = block.appendChild(document.createTextNode('/'));
editable.appendChild(block);
const range = document.createRange();
range.setStart(slash, 1);
range.collapse(true);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
editable.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: '/',
inputType: 'insertText',
}));
});
await expect.poll(() => composer.evaluate((editable) => editable.innerText)).toBe(
'first line\n/',
);
await expect(menu).toBeVisible();
});
test('dispatches /side instead of steering it into a running turn', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
const runningPrompt = FAKE_HOLD_OPEN_PROMPT;
await composer.fill(runningPrompt);
await composer.press('Enter');
await expect(page.locator('.maka-user-message', { hasText: runningPrompt })).toBeVisible();
await expect(page.getByRole('button', { name: '停止' })).toBeVisible();
await composer.click();
await composer.pressSequentially('/');
const menu = page.getByRole('listbox', { name: '命令和技能' });
const commands = menu.getByRole('group', { name: '命令' });
const side = commands.getByRole('option', { name: /打开侧聊.*\/side/ });
await expect(side).toBeVisible();
await expect(commands.getByRole('option', { name: /\/compact/ })).toHaveCount(0);
await side.click();
await expect.poll(() => composer.textContent()).toBe('/side ');
await expect(page.locator('.maka-quote-workbar-panel')).toHaveCount(0);
await composer.fill('/side discuss separately');
await composer.press('Enter');
await expect(page.locator('.maka-quote-workbar-panel')).toHaveCount(1);
await page.getByRole('button', { name: '停止' }).click();
});
test('an open menu keeps its container and skills group across projection refreshes', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('seed session');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: seed session')).toBeVisible();
await expect(page.getByRole('button', { name: '停止' })).toHaveCount(0);
// The completed turn publishes its own projection refresh. Wait on the
// composer's public loading state so that work cannot spill into the window
// this test is about.
await page.getByRole('button', { name: '添加上下文' }).click();
const contextMenu = page.getByRole('menu', { name: '添加上下文' });
await expect(contextMenu.getByRole('menuitem', { name: /选择技能/ })).not.toHaveAttribute(
'aria-busy',
'true',
);
await page.keyboard.press('Escape');
await expect(contextMenu).toHaveCount(0);
await composer.click();
await composer.pressSequentially('/');
const menu = page.getByRole('listbox', { name: '命令和技能' });
await expect(menu.getByRole('group', { name: 'Skills' })).toBeVisible();
// A thinking-level change publishes the session's 'updated' event and
// reloads the Skill projection without changing what the menu shows: the
// exact same-content refresh that used to alternate the popup (#2667).
const observation = await menu.evaluate(async (menuElement) => {
const sessions = await (
window as unknown as {
maka: { sessions: { list(): Promise<Array<{ id: string }>> } };
}
).maka.sessions.list();
const sessionId = sessions[0]?.id;
if (!sessionId) throw new Error('Session missing before projection refresh');
// Arm immediately before the refreshes and only on this popover. The
// document body also contains unrelated overlays whose teardown says
// nothing about this menu's identity.
const menuContainer = menuElement.parentElement;
const state = { menuRemovals: 0, skillsGroupRemovals: 0 };
const skillsGroup = menuElement.querySelector<HTMLElement>('[role="group"][aria-label="Skills"]');
if (!menuContainer) throw new Error('Slash menu container missing before projection refresh');
if (!skillsGroup) throw new Error('Skills group missing before projection refresh');
const recordRemoval = (
mutations: MutationRecord[],
watchedNode: Node,
key: 'menuRemovals' | 'skillsGroupRemovals',
) => {
for (const mutation of mutations) {
for (const node of mutation.removedNodes) {
if (node === watchedNode) state[key] += 1;
}
}
};
const menuObserver = new MutationObserver((mutations) => {
recordRemoval(mutations, menuElement, 'menuRemovals');
});
const skillsGroupObserver = new MutationObserver((mutations) => {
recordRemoval(mutations, skillsGroup, 'skillsGroupRemovals');
});
menuObserver.observe(menuContainer, { childList: true });
skillsGroupObserver.observe(menuElement, { childList: true });
const maka = (
window as unknown as {
maka: {
sessions: {
setThinkingLevel(id: string, level?: null): Promise<unknown>;
};
};
}
).maka;
const e2eControls = (
window as unknown as {
makaE2eLatch?: {
waitForInvocableSkillsCall(sessionId: string): Promise<void>;
};
}
).makaE2eLatch;
if (!e2eControls) throw new Error('E2E bridge controls missing before projection refresh');
try {
for (let round = 0; round < 3; round += 1) {
const projectionSettled = e2eControls.waitForInvocableSkillsCall(sessionId);
await maka.sessions.setThinkingLevel(sessionId, null);
await projectionSettled;
await new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
}
} finally {
// Drain the final queued batch before closing the exact refresh window;
// polling a monotonic counter cannot turn a failure into success.
recordRemoval(menuObserver.takeRecords(), menuElement, 'menuRemovals');
recordRemoval(skillsGroupObserver.takeRecords(), skillsGroup, 'skillsGroupRemovals');
menuObserver.disconnect();
skillsGroupObserver.disconnect();
}
return {
...state,
menuConnected: menuElement.isConnected,
skillsGroupConnected: skillsGroup.isConnected,
};
});
expect(observation).toEqual({
menuRemovals: 0,
skillsGroupRemovals: 0,
menuConnected: true,
skillsGroupConnected: true,
});
await expect(menu.getByRole('group', { name: 'Skills' })).toBeVisible();
});