import type { ElectronApplication } from 'playwright-core'; import { completeSetup, expect, test } from './fixtures/electron'; interface CapturedSlashRequest { path: string; method: string; body?: Record; } type SlashCommandHostOptions = { holdSummarize?: boolean; seedTranscript?: boolean; }; type SlashRuntimeEvent = { type: 'message.part.updated' | 'session.compacted'; payload: 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 readSlashRuntimeState( page: import('@playwright/test').Page, electronApp: ElectronApplication, ): Promise<{ clientCount: number; summarizePending: boolean }> { const clientCount = await page.evaluate(() => { type ControlledEventSource = { readyState: number }; const sources = (globalThis as typeof globalThis & { __niancodeSlashE2ESources?: Set; }).__niancodeSlashE2ESources; return [...(sources ?? [])].filter((source) => source.readyState !== 2).length; }); const summarizePending = await electronApp.evaluate(() => { type MainState = { summarizeRelease?: () => void; }; const mainGlobal = globalThis as typeof globalThis & { __niancodeSlashE2EState?: MainState; }; const state = mainGlobal.__niancodeSlashE2EState; return Boolean(state?.summarizeRelease); }); return { clientCount, summarizePending }; } async function emitSlashRuntimeEvent( page: import('@playwright/test').Page, electronApp: ElectronApplication, event: SlashRuntimeEvent, ): Promise { await electronApp.evaluate((value) => { type MainState = { messages?: Array>; }; const mainGlobal = globalThis as typeof globalThis & { __niancodeSlashE2EState?: MainState; }; const state = mainGlobal.__niancodeSlashE2EState; if (value.type === 'session.compacted' && state.messages) { const assistant = state.messages.find((message) => ( (message.info as Record | undefined)?.id === 'msg_slash_e2e_assistant' )); const parts = Array.isArray(assistant?.parts) ? assistant.parts : []; if (assistant && !parts.some((part) => ( (part as Record)?.id === 'part_slash_e2e_compaction' ))) { assistant.parts = [ ...parts, { id: 'part_slash_e2e_compaction', sessionID: 'ses_slash_e2e', messageID: 'msg_slash_e2e_assistant', type: 'compaction', time: { start: 1_753_190_402_000, end: 1_753_190_403_000, }, }, ]; } } }, event); await page.evaluate((value) => { type ControlledEventSource = { readyState: number; dispatch: (type: string, payload: Record) => void; }; const sources = (globalThis as typeof globalThis & { __niancodeSlashE2ESources?: Set; }).__niancodeSlashE2ESources; if (!sources || sources.size === 0) { throw new Error('Slash E2E renderer event stream is unavailable'); } for (const source of sources) { if (source.readyState !== 2) source.dispatch(value.type, value.payload); } }, event); } async function releaseSlashSummarize( electronApp: ElectronApplication, ): Promise { await electronApp.evaluate(() => { type MainState = { summarizeRelease?: () => void; }; const mainGlobal = globalThis as typeof globalThis & { __niancodeSlashE2EState?: MainState; }; const release = mainGlobal.__niancodeSlashE2EState?.summarizeRelease; if (!release) { throw new Error('Slash E2E summarize request is not pending'); } release(); }); } 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, options: SlashCommandHostOptions = {}, ): 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; holdSummarize: boolean; messages: Array>; summarizeRelease?: () => void; }; const mainGlobal = globalThis as typeof globalThis & { __niancodeSlashE2EState?: MainState; }; 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 state: MainState = { captured: [], commandFailure: null, holdSummarize: false, messages: [], }; mainGlobal.__niancodeSlashE2EState = state; 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: state.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/skills') { return respond({ skills: [ { name: 'frontend-slides' }, { name: 'grilling' }, { name: 'planning-with-files' }, ], }); } if ( path === '/api/opencode/sessions/ses_slash_e2e/summarize' && method === 'POST' ) { if (state.holdSummarize) { await new Promise((resolve) => { state.summarizeRelease = resolve; }); state.summarizeRelease = undefined; } 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}`, ); }); }); if (options.holdSummarize || options.seedTranscript) { await configureSlashCompactionHost(electronApp); } } async function configureSlashCompactionHost( electronApp: ElectronApplication, ): Promise { await electronApp.evaluate(() => { type MainState = { holdSummarize: boolean; messages: Array>; }; const mainGlobal = globalThis as typeof globalThis & { __niancodeSlashE2EState?: MainState; }; const state = mainGlobal.__niancodeSlashE2EState; if (!state) { throw new Error('Slash E2E Main state is unavailable'); } state.holdSummarize = true; state.messages = [ { info: { id: 'msg_slash_e2e_user', sessionID: 'ses_slash_e2e', role: 'user', time: { created: 1_753_190_400_000 }, }, parts: [{ id: 'part_slash_e2e_user', sessionID: 'ses_slash_e2e', messageID: 'msg_slash_e2e_user', type: 'text', text: '已有的时间线消息', }], }, { info: { id: 'msg_slash_e2e_assistant', sessionID: 'ses_slash_e2e', role: 'assistant', time: { created: 1_753_190_401_000 }, }, parts: [{ id: 'part_slash_e2e_assistant', sessionID: 'ses_slash_e2e', messageID: 'msg_slash_e2e_assistant', type: 'text', text: '已有的助手回复', }], }, ]; }); } async function installSlashRendererEventSource( page: import('@playwright/test').Page, ): Promise { await page.addInitScript(() => { // The fixture's Main IPC stub does not own the Host API SSE server. Keep // this E2E at the renderer/store event boundary instead of relying on a // real OpenCode process or an unrelated localhost listener. type EventListener = (event: MessageEvent) => void; type ControlledEventSource = { readyState: number; dispatch: (type: string, payload: Record) => void; close: () => void; }; const sources = new Set(); class SlashEventSource implements ControlledEventSource { static readonly OPEN = 1; static readonly CLOSED = 2; readonly url: string; readyState = SlashEventSource.OPEN; onopen: (() => void) | null = null; onerror: ((event: Event) => void) | null = null; private readonly listeners = new Map>(); constructor(url: string) { this.url = url; sources.add(this); } addEventListener(type: string, listener: EventListener): void { const listeners = this.listeners.get(type) ?? new Set(); listeners.add(listener); this.listeners.set(type, listeners); } removeEventListener(type: string, listener: EventListener): void { this.listeners.get(type)?.delete(listener); } close(): void { this.readyState = SlashEventSource.CLOSED; sources.delete(this); } dispatch(type: string, payload: Record): void { const event = new MessageEvent('message', { data: JSON.stringify(payload), }); for (const listener of this.listeners.get(type) ?? []) listener(event); } } Object.defineProperty(window, 'EventSource', { configurable: true, writable: true, value: SlashEventSource, }); Object.defineProperty(globalThis, '__niancodeSlashE2ESources', { configurable: true, value: sources, }); }); } test.describe('OpenCode slash commands', () => { test.afterEach(async ({ electronApp }) => { await electronApp.evaluate(async () => { type MainState = { summarizeRelease?: () => void; }; const mainGlobal = globalThis as typeof globalThis & { __niancodeSlashE2EState?: MainState; }; const state = mainGlobal.__niancodeSlashE2EState; state?.summarizeRelease?.(); 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 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 manual compaction running at its timeline position, then completes it from native events', async ({ electronApp, page, }) => { await completeSetup(page); await installSlashRendererEventSource(page); await installSlashCommandHost(electronApp, { holdSummarize: true, seedTranscript: true, }); 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 transcript = page.getByTestId('opencode-transcript-scroll'); const existingAssistant = transcript.locator( '[data-chat-message-id="msg_slash_e2e_assistant"]', ); await expect(existingAssistant).toBeVisible(); const composer = page.getByRole('textbox'); await expect(composer).toBeVisible(); await composer.fill('/compact'); await composer.press('Enter'); await expect(composer).toHaveValue('/compact'); await composer.press('Enter'); await expect.poll(async () => ( await readCapturedRequests(electronApp) ).some((request) => request.path.endsWith('/summarize'))).toBe(true); await expect.poll(async () => ( await readSlashRuntimeState(page, electronApp) ).summarizePending).toBe(true); await expect.poll(async () => ( await readSlashRuntimeState(page, electronApp) ).clientCount).toBeGreaterThan(0); const compactionItem = transcript.getByTestId( 'opencode-compaction-timeline-item', ); await expect(compactionItem).toHaveCount(1); await expect(compactionItem).toHaveAttribute( 'data-compaction-source', 'manual', ); await expect(compactionItem).toHaveAttribute( 'data-compaction-status', 'running', ); await expect(compactionItem).toContainText('正在压缩上下文'); await expect(compactionItem.locator('span')).toHaveClass( /context-compaction-shimmer/, ); const [compactionTop, assistantTop] = await Promise.all([ compactionItem.evaluate((element) => element.getBoundingClientRect().top), existingAssistant.evaluate((element) => element.getBoundingClientRect().top), ]); expect(compactionTop).toBeGreaterThan(assistantTop); const compactionId = await compactionItem.getAttribute('data-compaction-id'); expect(compactionId).toBeTruthy(); await emitSlashRuntimeEvent(page, electronApp, { type: 'message.part.updated', payload: { sessionID: 'ses_slash_e2e', messageID: 'msg_slash_e2e_assistant', eventID: 'slash-compaction-part-updated', part: { id: 'part_slash_e2e_compaction', sessionID: 'ses_slash_e2e', messageID: 'msg_slash_e2e_assistant', type: 'compaction', source: 'manual', time: { start: 1_753_190_402_000 }, }, }, }); await expect(compactionItem).toHaveAttribute( 'data-compaction-status', 'running', ); await emitSlashRuntimeEvent(page, electronApp, { type: 'session.compacted', payload: { sessionID: 'ses_slash_e2e', eventID: 'slash-session-compacted', }, }); await expect(compactionItem).toHaveCount(1); await expect(compactionItem).toHaveAttribute( 'data-compaction-status', 'completed', ); await expect(compactionItem).toContainText('已压缩上下文'); await expect(compactionItem.locator('span')).not.toHaveClass( /context-compaction-shimmer/, ); expect(await compactionItem.getAttribute('data-compaction-id')) .toBe(compactionId); await releaseSlashSummarize(electronApp); await expect.poll(async () => ( await readSlashRuntimeState(page, electronApp) ).summarizePending).toBe(false); await expect(compactionItem).toBeVisible(); }); 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 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 expect(createPartnerDialog.getByLabel('绑定技能:项目演示')).not.toBeChecked(); await expect(createPartnerDialog.getByLabel('绑定技能:方案质询')).toBeChecked(); await expect(createPartnerDialog.getByLabel('绑定技能:项目规划')).toBeChecked(); await createPartnerDialog.getByLabel('绑定技能:方案质询').uncheck(); await expect(createPartnerDialog.getByLabel('绑定技能:方案质询')).not.toBeChecked(); 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); }); });