Files
makelore/tests/e2e/opencode-slash-commands.spec.ts
inman f4113a872f
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
收口 Makelore 客户端变更
2026-08-12 22:43:09 +08:00

424 lines
14 KiB
TypeScript

import type { ElectronApplication } from 'playwright-core';
import { completeSetup, expect, test } from './fixtures/electron';
interface CapturedSlashRequest {
path: string;
method: string;
body?: Record<string, unknown>;
}
async function readCapturedRequests(
electronApp: ElectronApplication,
): Promise<CapturedSlashRequest[]> {
const requests = await electronApp.evaluate(() => {
type MainCapturedSlashRequest = {
path: string;
method: string;
body?: Record<string, unknown>;
};
type MainState = {
captured: MainCapturedSlashRequest[];
commandFailure: string | null;
};
const mainGlobal = globalThis as typeof globalThis & {
__niancodeSlashE2EState?: MainState;
};
return structuredClone(
mainGlobal.__niancodeSlashE2EState?.captured ?? [],
);
});
return requests.filter((request) => request.method !== 'GET');
}
async function setSlashCommandFailure(
electronApp: ElectronApplication,
message: string | null,
): Promise<void> {
await electronApp.evaluate((value) => {
type MainCapturedSlashRequest = {
path: string;
method: string;
body?: Record<string, unknown>;
};
type MainState = {
captured: MainCapturedSlashRequest[];
commandFailure: string | null;
};
const mainGlobal = globalThis as typeof globalThis & {
__niancodeSlashE2EState?: MainState;
};
if (!mainGlobal.__niancodeSlashE2EState) {
throw new Error('Slash E2E Main state is unavailable');
}
mainGlobal.__niancodeSlashE2EState.commandFailure = value;
}, message);
}
async function installSlashCommandHost(
electronApp: ElectronApplication,
): Promise<void> {
await electronApp.evaluate(async () => {
const { ipcMain } = process.mainModule!.require(
'electron',
) as typeof import('electron');
type MainCapturedSlashRequest = {
path: string;
method: string;
body?: Record<string, unknown>;
};
type MainState = {
captured: MainCapturedSlashRequest[];
commandFailure: string | null;
};
const mainGlobal = globalThis as typeof globalThis & {
__niancodeSlashE2EState?: MainState;
};
const state: MainState = {
captured: [],
commandFailure: null,
};
mainGlobal.__niancodeSlashE2EState = state;
const project = {
id: 'prj_slash_e2e',
path: 'D:/e2e/slash',
name: 'slash',
createdAt: '2026-07-18T00:00:00.000Z',
updatedAt: '2026-07-18T00:00:00.000Z',
lastOpenedAt: '2026-07-18T00:00:00.000Z',
};
const session = {
id: 'ses_slash_e2e',
title: 'Slash E2E',
agent: 'game-development',
};
const status = {
state: 'running',
port: 4096,
url: 'http://127.0.0.1:4096',
};
const respond = (json: unknown, responseStatus = 200) => ({
ok: true,
data: {
status: responseStatus,
ok: responseStatus >= 200 && responseStatus < 300,
json,
},
});
ipcMain.removeHandler('hostapi:fetch');
ipcMain.handle('hostapi:fetch', async (
_event,
request: {
path?: string;
method?: string;
body?: string | null;
},
) => {
const path = request.path ?? '';
const method = request.method ?? 'GET';
const body = request.body
? JSON.parse(request.body) as Record<string, unknown>
: undefined;
state.captured.push({
path,
method,
...(body ? { body } : {}),
});
if (path === '/api/opencode/status') return respond(status);
if (path === '/api/opencode/health') {
return respond({ ok: true, status });
}
if (
path === '/api/opencode/projects'
|| path.startsWith('/api/opencode/projects?')
) {
return respond({
projects: [project],
activeProject: project,
});
}
if (
path === '/api/opencode/projects/active'
&& method === 'GET'
) {
return respond({
projects: [project],
activeProject: project,
});
}
if (path.startsWith('/api/opencode/projects/config?')) {
return respond({
status: 'missing',
knowledgeFiles: [],
});
}
if (path.startsWith('/api/opencode/projects/template?')) {
return respond({ status: 'missing' });
}
if (path === '/api/opencode/config-summary') {
return respond({
model: 'niancode-user-models/qwen3.7-plus',
smallModel: null,
providerIds: ['niancode-user-models'],
enabledProviderIds: ['niancode-user-models'],
providerCount: 1,
});
}
if (path === '/api/provider-accounts') {
return respond([]);
}
if (path === '/api/provider-accounts/key-info') {
return respond([]);
}
if (path === '/api/provider-vendors') {
return respond([]);
}
if (path === '/api/provider-accounts/default') {
return respond({ accountId: null });
}
if (path === '/api/opencode/sessions') {
return respond({ sessions: [session] });
}
if (path === '/api/opencode/sessions/status') {
return respond({
statuses: {
ses_slash_e2e: { type: 'idle' },
},
});
}
if (
path
=== '/api/opencode/sessions/ses_slash_e2e/messages'
&& method === 'GET'
) {
return respond({ messages: [] });
}
if (
path
=== '/api/opencode/sessions/ses_slash_e2e/todos'
) {
return respond({ todos: [] });
}
if (
path
=== '/api/opencode/sessions/ses_slash_e2e/diff'
) {
return respond({ diffs: [] });
}
if (path === '/api/opencode/questions') {
return respond({ questions: [] });
}
if (path === '/api/opencode/permissions') {
return respond({ permissions: [] });
}
if (path === '/api/opencode/files/status') {
return respond({ files: [] });
}
if (path === '/api/opencode/commands') {
return respond({
commands: [{
name: 'Review',
hints: ['$ARGUMENTS'],
}],
shareEnabled: true,
});
}
if (
path
=== '/api/opencode/sessions/ses_slash_e2e/summarize'
&& method === 'POST'
) {
return respond({ success: true }, 202);
}
if (
path
=== '/api/opencode/sessions/ses_slash_e2e/command'
&& method === 'POST'
) {
return state.commandFailure
? respond(
{
success: false,
error: state.commandFailure,
},
500,
)
: respond({ success: true }, 202);
}
throw new Error(
`Unexpected hostapi request: ${method} ${path}`,
);
});
});
}
test.describe('OpenCode slash commands', () => {
test.afterEach(async ({ electronApp }) => {
await electronApp.evaluate(async () => {
const mainGlobal = globalThis as typeof globalThis & {
__niancodeSlashE2EState?: unknown;
};
delete mainGlobal.__niancodeSlashE2EState;
});
});
test('selects on first Enter, executes on second Enter, blocks unknown commands, and preserves failures', async ({
electronApp,
page,
}) => {
await completeSetup(page);
await installSlashCommandHost(electronApp);
await page.reload();
await page.getByTestId('sidebar-module-programming').click();
await expect(page).toHaveURL(/\/opencode-chat$/);
const composer = page.getByRole('textbox');
await expect(composer).toBeVisible();
await composer.fill('/comp');
await composer.press('Enter');
await expect(composer).toHaveValue('/compact');
await expect(
page.getByText('再次按 Enter 执行'),
).toBeVisible();
expect(await readCapturedRequests(electronApp)).toEqual([]);
await composer.press('Enter');
await expect(composer).toHaveValue('');
await expect.poll(async () => (
await readCapturedRequests(electronApp)
).some((request) => (
request.path.endsWith('/summarize')
))).toBe(true);
await composer.fill('/unknown do-not-send');
await composer.press('Enter');
await expect(
page.getByText('未知命令 /unknown'),
).toBeVisible();
await page
.getByTestId('opencode-message-composer')
.evaluate((form: HTMLFormElement) => form.requestSubmit());
expect((
await readCapturedRequests(electronApp)
).some((request) => (
request.path.endsWith('/messages')
&& request.method === 'POST'
))).toBe(false);
await expect(composer).toHaveValue(
'/unknown do-not-send',
);
await setSlashCommandFailure(electronApp, 'review failed');
await composer.fill('/Review staged changes ');
await composer.press('Enter');
await expect(page.getByText('review failed')).toBeVisible();
await expect(composer).toHaveValue(
'/Review staged changes ',
);
await setSlashCommandFailure(electronApp, null);
await composer.press('Enter');
await expect(composer).toHaveValue('');
const command = (await readCapturedRequests(electronApp))
.findLast((request) => request.path.endsWith('/command'));
expect(command?.body).toMatchObject({
command: 'Review',
arguments: ' staged changes ',
agent: 'game-development',
model: 'niancode-user-models/qwen3.7-plus',
});
});
test('keeps the project and conversation rails at the minimum width without covering the chat canvas', async ({
electronApp,
page,
}) => {
await completeSetup(page);
await installSlashCommandHost(electronApp);
await page.reload();
await page.setViewportSize({ width: 1280, height: 800 });
await page.getByTestId('sidebar-module-switcher-trigger').click();
await page.getByTestId('sidebar-module-programming').click();
await expect(page).toHaveURL(/\/opencode-chat$/);
await expect(page.getByTestId('opencode-message-composer')).toBeVisible();
const createPartnerButton = page.getByRole('button', { name: '创建伙伴' });
await expect(createPartnerButton).toHaveClass(/text-brand/);
await expect(createPartnerButton).toHaveAttribute('aria-expanded', 'false');
await createPartnerButton.click();
await expect(createPartnerButton).toHaveClass(/text-primary-foreground/);
await expect(createPartnerButton).toHaveAttribute('aria-expanded', 'true');
const createPartnerDialog = page.getByRole('dialog', { name: '创建项目伙伴' });
await expect(createPartnerDialog).toHaveCSS('z-index', '110');
await createPartnerDialog.getByLabel(/伙伴名称/).fill('E2E层级检查');
await page.getByRole('button', { name: '取消' }).click();
await expect(createPartnerButton).toHaveClass(/text-brand/);
await expect(page.getByRole('heading', { name: '我的项目空间' })).toHaveCount(0);
await page.getByTestId('agent-browser-panel-open').click();
await expect(page.getByTestId('agent-browser-panel')).toBeVisible();
const diagnostics = page.getByTestId('agent-browser-diagnostics');
await expect(diagnostics).toHaveAttribute('data-state', 'closed');
await page.getByRole('tab', { name: /Console/ }).click();
await expect(diagnostics).toHaveAttribute('data-state', 'open');
await expect(diagnostics).toHaveCSS('height', '260px');
await page.getByRole('button', { name: '收起调试面板' }).click();
await expect(diagnostics).toHaveAttribute('data-state', 'closed');
const rect = async (testId: string) => page.getByTestId(testId).evaluate((element) => {
const box = element.getBoundingClientRect();
return { top: box.top, left: box.left, right: box.right, width: box.width };
});
const [layout, project, conversations, conversationHeader, conversationContext, chat, browser] = await Promise.all([
rect('opencode-chat-layout'),
rect('sidebar'),
rect('agent-conversation-sidebar'),
rect('agent-conversation-sidebar-header'),
rect('agent-conversation-dialog-context'),
rect('opencode-chat-canvas'),
rect('agent-browser-panel'),
]);
const isHeaderInsideConversationSidebar = async () => page.getByTestId('agent-conversation-sidebar').evaluate((sidebar) => sidebar.contains(document.querySelector('[data-testid="agent-conversation-sidebar-header"]')));
expect(project.width).toBeGreaterThanOrEqual(255);
expect(project.width).toBeLessThan(257);
expect(conversations.width).toBeGreaterThanOrEqual(255);
expect(conversations.width).toBeLessThan(257);
expect(Math.abs(conversations.top)).toBeLessThan(2);
expect(Math.abs(conversationHeader.top)).toBeLessThan(2);
expect(Math.abs(conversationHeader.left - conversations.left)).toBeLessThan(2);
expect(Math.abs(conversationHeader.width - conversations.width)).toBeLessThan(2);
expect(Math.abs(conversationContext.top)).toBeLessThan(2);
expect(Math.abs(conversationContext.left - chat.left)).toBeLessThan(2);
expect(await isHeaderInsideConversationSidebar()).toBe(false);
expect(Math.abs(conversations.left - project.right)).toBeLessThan(2);
expect(Math.abs(chat.left - conversations.right)).toBeLessThan(2);
expect(Math.abs(browser.right - layout.right)).toBeLessThan(2);
expect(browser.left).toBeGreaterThanOrEqual(chat.right - 1);
const readRailAlignment = async () => {
const [collapsedProject, collapsedConversations] = await Promise.all([
rect('sidebar'),
rect('agent-conversation-sidebar'),
]);
return {
projectWidth: collapsedProject.width,
conversationLeft: collapsedConversations.left,
projectRight: collapsedProject.right,
alignmentDelta: Math.abs(collapsedConversations.left - collapsedProject.right),
};
};
await page.getByRole('button', { name: '折叠侧栏' }).click();
await expect.poll(async () => (await readRailAlignment()).projectWidth).toBeLessThan(1);
await expect.poll(async () => (await readRailAlignment()).conversationLeft).toBeLessThan(2);
await expect.poll(isHeaderInsideConversationSidebar).toBe(false);
await page.getByRole('button', { name: '展开侧栏' }).click();
await expect.poll(async () => (await readRailAlignment()).alignmentDelta).toBeLessThan(2);
await expect.poll(async () => (await readRailAlignment()).projectWidth).toBeGreaterThan(255);
await expect.poll(isHeaderInsideConversationSidebar).toBe(false);
});
});