Files
makelore/tests/e2e/opencode-slash-commands.spec.ts
2026-07-29 17:22:35 +08:00

333 lines
9.4 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',
});
});
});