import type { ElectronApplication } from 'playwright-core'; import { completeSetup, expect, test } from './fixtures/electron'; interface CapturedSlashRequest { path: string; method: string; body?: Record; } async function readCapturedRequests( electronApp: ElectronApplication, ): Promise { const requests = await electronApp.evaluate(() => { type MainCapturedSlashRequest = { path: string; method: string; body?: Record; }; 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 { await electronApp.evaluate((value) => { type MainCapturedSlashRequest = { path: string; method: string; body?: Record; }; 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 { await electronApp.evaluate(async () => { const { ipcMain } = process.mainModule!.require( 'electron', ) as typeof import('electron'); type MainCapturedSlashRequest = { path: string; method: string; body?: Record; }; 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 agent = { id: 'game-development', avatarId: 'avatar-01', roleName: 'Game Development', name: 'Game Development', builtIn: false, enabled: true, model: 'niancode-user-models/qwen3.7-plus', skillIds: [], responsibility: { mission: 'Implement and verify project features.', owns: [], boundaries: [], collaborators: [], principles: [], }, prompt: '', archivedAt: null, pinned: false, }; const config = { schemaVersion: 1, projectType: 'custom', initialized: true, defaultModel: 'niancode-user-models/qwen3.7-plus', agents: [agent], knowledgeDirectory: 'knowledge', createdAt: '2026-07-18T00:00:00.000Z', updatedAt: '2026-07-18T00:00:00.000Z', }; 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 : 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: 'valid', config, knowledgeFiles: [], }); } if (path.startsWith('/api/opencode/projects/template?')) { return respond({ status: 'missing' }); } if (path.startsWith('/api/opencode/projects/conversations?')) { return respond({ state: { schemaVersion: 1, sessions: [{ sessionId: session.id, agentId: agent.id, archivedAt: null, unreadCount: 0, createdAt: '2026-07-18T00:00:00.000Z', updatedAt: '2026-07-18T00:00:00.000Z', }], updatedAt: '2026-07-18T00:00:00.000Z', }, }); } 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([{ id: 'niancode-user-models', vendorId: 'custom', label: 'Makelore Models', authMode: 'api_key', model: 'qwen3.7-plus', enabled: true, isDefault: true, createdAt: '2026-07-18T00:00:00.000Z', updatedAt: '2026-07-18T00:00:00.000Z', }]); } if (path === '/api/provider-accounts/key-info') { return respond([{ accountId: 'niancode-user-models', hasKey: true, keyMasked: 'sk-***', }]); } if (path === '/api/provider-vendors') { return respond([]); } if (path === '/api/provider-accounts/default') { return respond({ accountId: 'niancode-user-models' }); } 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-switcher-trigger').click(); await page.getByTestId('sidebar-module-programming').click(); await expect(page).toHaveURL(/\/opencode-chat$/); await page.getByTestId('project-agent-chat-game-development').click(); const composer = page.getByRole('textbox'); await expect(composer).toBeVisible(); await composer.fill('/comp'); await expect(page.getByRole('option', { name: /compact/ })).toBeVisible(); await composer.press('Enter'); await expect(composer).toHaveValue('/compact'); await expect( page.getByText('再次按 Enter 执行'), ).toBeVisible(); expect((await readCapturedRequests(electronApp)).some((request) => ( request.path.endsWith('/summarize') || request.path.endsWith('/command') || request.path.endsWith('/messages') ))).toBe(false); 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'); const commandCountBeforeFailure = (await readCapturedRequests(electronApp)) .filter((request) => request.path.endsWith('/command')).length; await composer.fill('/Rev'); await expect(page.getByRole('option', { name: /Review/i })).toBeVisible(); await composer.fill('/Review staged changes '); await composer.press('Enter'); await expect.poll(async () => ( await readCapturedRequests(electronApp) ).filter((request) => request.path.endsWith('/command')).length) .toBe(commandCountBeforeFailure + 1); await expect(composer).toHaveValue( '/Review staged changes ', ); 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(); const createPartnerDialog = page.getByRole('dialog', { name: '创建项目伙伴' }); await expect(createPartnerDialog).toBeVisible(); 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 - project.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); }); });