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