import { test, expect, type Page } from '@playwright/test'; // Exercises the real Monaco/UI with a deterministic IPC boundary. Native file // operations and reset safety are covered independently by the Rust tests. async function mockDesktop(page: Page) { page.on('pageerror', error => { throw error; }); await page.addInitScript(() => { const state = { version: 1, build_directory: '/sample/build', last_file: 'main.cpp', search_exclusions: [] as string[], clangd_path: 'clangd' }; const analysisStatus = { phase: 'ready', message: 'Indexing idle.', server: '22', indexing: false, diagnostics: {} }; const content = '#include \n\ntemplate\nconcept Number = requires(T value) {\n value + value;\n};\n\nint main() {\n return 0;\n}\n'; let context: Record | null = null; let llm = { base_url: 'http://localhost:1234/v1', model: 'test-model', api_key_env: '', context_bytes: 65536, max_output_tokens: 4096, token_parameter: 'max_completion_tokens', stream: true, selected_audience: 'expert', audiences: [{ id: 'expert', name: 'Expert', instructions: 'Explain precisely.' }, { id: 'custom', name: 'Custom', instructions: 'Custom instructions.' }] }; Object.assign(window, { isTauri: true, calls: [] as unknown[], __TAURI_INTERNALS__: { transformCallback: () => 1, unregisterCallback: () => {}, invoke: async (command: string, args: Record) => { (window as unknown as { calls: unknown[] }).calls.push({ command, args }); switch (command) { case 'load_theme': return localStorage.getItem('test-theme') || 'system'; case 'save_theme': localStorage.setItem('test-theme', args.theme as string); return null; case 'recent_projects': return (window as unknown as { recentProjects?: unknown[] }).recentProjects ?? []; case 'remember_recent': return null; case 'remove_recent': Object.assign(window, { recentProjects: [] }); return null; case 'inspect_workspace': if ((window as unknown as { missingProject?: boolean }).missingProject) throw new Error('Source directory does not exist.'); return { root: '/sample', settings: state, warning: null, can_reset: true }; case 'close_workspace': return null; case 'open_workspace': return { root: '/sample', settings: { ...state, build_directory: args.build, last_file: args.reset ? null : state.last_file } }; case 'list_directory': return args.path ? [{ name: 'concepts.hpp', path: 'include/concepts.hpp', directory: false }] : [{ name: 'include', path: 'include', directory: true }, { name: 'main.cpp', path: 'main.cpp', directory: false }]; case 'read_file': return { path: args.path, content }; case 'remember_file': state.last_file = args.path as string; return null; case 'search': return { hits: [{ path: 'main.cpp', line: 4, column: 9, end_column: 15, preview: 'concept Number = requires(T value) {' }], truncated: false, cancelled: false, skipped_files: 0 }; case 'cancel_search': return null; case 'save_search_exclusions': state.search_exclusions = args.exclusions as string[]; return null; case 'start_analysis': case 'analysis_status': return (window as unknown as { failAnalysis?: boolean }).failAnalysis ? { ...analysisStatus, phase: 'unavailable', message: 'Cannot start clangd: executable not found.' } : analysisStatus; case 'save_graph_options': return null; case 'graph_request': case 'analysis_request': if (command === 'graph_request' || args.kind === 'conceptgraph') { if ((window as unknown as {slowGraph?:boolean}).slowGraph) await new Promise(resolve => setTimeout(resolve, 900)); if ((window as unknown as { failGraph?: boolean }).failGraph) throw new Error('Select a concept or function name to view its graph.'); const location = (line: number, label: string) => ({ path: 'main.cpp', range: { start: { line, character: 8 }, end: { line, character: 14 } }, label }); const graph = { title: 'Number', nodes: [ { id: 'selected', label: 'Number', kind: 'selected', detail: 'concept Number = requires(T value) { value + value; };', location: location(3, 'Number') }, { id: 'requirement', label: 'requires(T value) { value + value; }', kind: 'requires', detail: 'requires(T value) { value + value; }', location: location(3, 'requires') }, { id: 'concept', label: 'Base', kind: 'concept', detail: 'Base', location: location(2, 'Base') }, { id: 'use', label: 'main', kind: 'usage', detail: 'used by main.cpp:8', location: location(7, 'main') }, ], edges: [{ from: 'concept', to: 'requirement', label: '' }, { from: 'requirement', to: 'selected', label: '' }, { from: 'selected', to: 'use', label: 'used by' }], warnings: ['Active build only.'] }; if ((window as unknown as {functionGraph?:boolean}).functionGraph) { graph.title='target'; graph.nodes[0].label='target'; graph.nodes[1].kind='function'; graph.nodes[1].label='helper'; graph.nodes[2].kind='function'; graph.nodes[2].label='leaf'; graph.nodes[3].kind='function'; graph.nodes[3].label='caller'; } if ((window as unknown as {typeGraph?:boolean}).typeGraph) { graph.title='demo::Box'; graph.nodes[0].label='demo::Box'; graph.nodes[1].kind='template-parameter'; graph.nodes[1].label='template T'; graph.nodes[2].kind='type'; graph.nodes[2].label='demo::Base'; graph.nodes[3].kind='function'; graph.nodes[3].label='demo::Box::run'; } return command === 'graph_request' ? graph : {graph}; } return { locations: (window as unknown as { multipleLocations?: boolean }).multipleLocations && ['callers', 'references'].includes(args.kind as string) ? [{ path: 'first.cpp', range: { start: { line: 3, character: 8 }, end: { line: 3, character: 14 } }, label: 'first' }, { path: 'second.cpp', range: { start: { line: 7, character: 4 }, end: { line: 7, character: 8 } }, label: 'second' }] : args.kind === 'folding' || args.kind === 'hover' ? [] : [{ path: args.kind === 'definition' ? 'lib.cpp' : 'main.cpp', range: { start: { line: 3, character: 8 }, end: { line: 3, character: 14 } }, label: args.kind === 'callers' ? 'main' : '' }], hover: 'concept Number', folds: [{ startLine: 3, endLine: 4 }, { startLine: 7, endLine: 8 }], evidence: { engine: 'clangd 22', build_directory: '/sample/build', coverage: 'Active build configuration only.' }, }; case 'get_llm_settings': return { settings: structuredClone(llm), has_session_key: false }; case 'save_llm_settings': llm = structuredClone(args.settings) as typeof llm; return null; case 'prepare_explanation': context = { available_callees: [{ id: 'C1', location: { path: 'a.cpp', label: 'helper_a', range: { start: { line: 1, character: 0 } } } }, { id: 'C2', location: { path: 'b.cpp', label: 'helper_b', range: { start: { line: 1, character: 0 } } } }], selected_callees: [], recursion_depth: 0, prompt_budget: 65536, id: 1, endpoint: `${llm.base_url}/chat/completions`, model: llm.model, audience: llm.selected_audience, prompt_bytes: 1024, messages: [{ role: 'system', content: 'Use source citations.' }, { role: 'user', content: `Reviewed evidence: ${args.question || 'Explain Number'}` }], bundle: { selected_path: 'main.cpp', sources: [{ id: 'S1', role: 'selected', path: 'main.cpp', start_line: 4, end_line: 6, enclosing: 'Number', code: content.split('\n').slice(3, 6).join('\n'), truncated: false }], usages: [], references_found: 2, callers_found: 0, callees_found: 0, omissions: ['Active build only.'], diagnostics: [] } }; if ((window as unknown as { conceptMode?: boolean }).conceptMode) { context.concept_dependencies = true; context.include_standard = false; context.available_callees = [{ id: 'C1', is_concept: true, is_standard: false, location: { path: 'concept.hpp', label: 'app::Composed', range: { start: { line: 1, character: 0 } } } }, { id: 'C2', is_concept: true, is_standard: true, location: { path: '/usr/include/concepts', label: 'std::copyable', range: { start: { line: 10, character: 0 } } } }]; } return structuredClone(context); case 'update_explanation_question': context!.messages[1].content = `Reviewed evidence: ${args.question}`; return structuredClone(context); case 'expand_explanation': context!.include_standard = args.includeStandard ?? false; context!.selected_callees = args.selected; context!.recursion_depth = args.depth; context!.prompt_bytes = 50000; return structuredClone(context); case 'send_explanation': { const channel = args.onDelta as { onmessage: (value: string) => void }; channel.onmessage('Partial explanation '); await new Promise(resolve => setTimeout(resolve, (window as unknown as { slowModel?: boolean }).slowModel ? 1000 : 50)); const text = '## Number\nAdds values [S1]. Unknown [S99]. [external](https://invalid.example)'; channel.onmessage(text); return { text, finish_reason: 'stop' }; } case 'cancel_explanation': return null; case 'plugin:dialog|message': return 'Ok'; default: throw new Error(`Unexpected command: ${command}`); } }, }, }); }); } async function openProject(page: Page) { await page.goto('/'); await page.getByRole('button', { name: 'Open a project', exact: true }).click(); await page.getByLabel('Source directory', { exact: true }).fill('/sample'); await page.getByLabel('Source directory', { exact: true }).press('Tab'); await expect(page.locator('#saved-state')).toContainText('Saved workspace found'); await expect(page.locator('#build')).toHaveValue('/sample/build'); await page.locator('#setup-submit').click(); await expect(page.locator('#setup')).not.toBeVisible(); } test('restores a workspace, browses lazily, and keeps Monaco read-only', async ({ page }) => { const errors: string[] = []; page.on('pageerror', error => errors.push(error.message)); await mockDesktop(page); await openProject(page); await expect(page.locator('#file-path')).toHaveText('main.cpp'); await expect(page.locator('.view-lines')).toContainText('concept Number'); await expect(page.locator('.monaco-editor .inputarea')).toHaveAttribute('readonly', 'true'); await page.getByRole('button', { name: '▸ include', exact: true }).click(); await page.getByRole('button', { name: '· concepts.hpp', exact: true }).click(); await expect(page.locator('#file-path')).toHaveText('include/concepts.hpp'); const fold = page.locator('.codicon-folding-expanded').first(); await expect(fold).toBeVisible(); await fold.click(); await expect(page.locator('.view-lines')).not.toContainText('value + value;'); await page.locator('.codicon-folding-collapsed').first().click(); await expect(page.locator('.view-lines')).toContainText('value + value;'); await page.getByRole('button', { name: 'Find in file' }).click(); await expect(page.locator('.find-widget')).toHaveClass(/visible/); await page.screenshot({ path: 'test-results/browser.png' }); expect(errors).toEqual([]); }); test('project search jumps to a matching source location', async ({ page }) => { await mockDesktop(page); await openProject(page); await page.getByRole('button', { name: 'Search', exact: true }).click(); await page.getByLabel('Search project text').fill('Number'); await expect(page.locator('#search-summary')).toHaveText('1 matches'); await page.locator('.result').click(); await expect(page.locator('#file-path')).toHaveText('main.cpp'); await expect(page.locator('#cursor')).toContainText('Ln 4'); }); test('force reset passes an explicit reset and clears the old file', async ({ page }) => { await mockDesktop(page); await openProject(page); await page.getByRole('button', { name: 'Workspace…' }).click(); await expect(page.locator('#saved-state')).toContainText('Saved workspace found'); await page.locator('#reset').check(); await page.locator('#setup-submit').click(); await expect(page.locator('#welcome')).toBeVisible(); const calls = await page.evaluate(() => (window as unknown as { calls: { command: string; args: { reset?: boolean } }[] }).calls); expect(calls.filter(call => call.command === 'open_workspace').at(-1)?.args.reset).toBe(true); }); test('search exclusions are saved and restored per workspace', async ({ page }) => { await mockDesktop(page); await openProject(page); await page.getByRole('button', { name: 'Search', exact: true }).click(); await page.locator('#exclusions').fill('docs/\n**/generated/**'); await page.getByRole('button', { name: 'Apply exclusions' }).click(); await expect(page.locator('#exclusion-status')).toContainText('saved'); await page.getByRole('button', { name: 'Workspace…' }).click(); await expect(page.locator('#saved-state')).toContainText('Saved workspace found'); await page.locator('#setup-submit').click(); await page.getByRole('button', { name: 'Search', exact: true }).click(); await expect(page.locator('#exclusions')).toHaveValue('docs/\n**/generated/**'); }); test('definition, back, references, callers, and type information use semantic results', async ({ page }) => { await mockDesktop(page); await openProject(page); await expect(page.locator('#file-path')).toHaveText('main.cpp'); await page.locator('#nav-definition').click(); await expect(page.locator('#file-path')).toHaveText('lib.cpp'); await page.locator('#nav-back').click(); await expect(page.locator('#file-path')).toHaveText('main.cpp'); await page.locator('#nav-references').click(); await expect(page.locator('#navigation-summary')).toHaveText('1 location'); await expect(page.locator('#navigation-evidence')).toContainText('clangd 22'); await page.locator('#nav-callers').click(); await expect(page.locator('#navigation-results strong')).toHaveText('main'); await page.locator('#navigation-results .result').click(); await expect(page.locator('#cursor')).toContainText('Ln 4'); await page.locator('#nav-hover').click(); await expect(page.locator('.type-info')).toHaveText('concept Number'); await page.screenshot({ path: 'test-results/navigation.png' }); }); test('missing clangd leaves browsing usable and explains recovery', async ({ page }) => { await mockDesktop(page); await page.addInitScript(() => Object.assign(window, { failAnalysis: true })); await openProject(page); await expect(page.locator('#file-path')).toHaveText('main.cpp'); await page.locator('#nav-definition').click(); await expect(page.locator('#navigation-summary')).toContainText('Cannot start clangd'); await expect(page.locator('#restart-analysis')).toBeEnabled(); }); test('explanations require reviewed context and render only verified source links', async ({ page }) => { await mockDesktop(page); await openProject(page); await page.locator('#nav-explain').click(); await expect(page.locator('#explain-status')).toContainText('Context ready'); await expect(page.locator('#context-summary')).toContainText('Active build only'); expect(await page.evaluate(() => (window as unknown as { calls: { command: string }[] }).calls.some(c => c.command === 'send_explanation'))).toBe(false); await page.locator('#send-explanation').click(); await expect(page.locator('#explain-status')).toContainText('Explanation complete'); await expect(page.locator('.source-citation')).toHaveCount(1); await expect(page.locator('#explanation-answer')).toContainText('[S99] (unknown source)'); await expect(page.locator('#explanation-answer img, #explanation-answer a')).toHaveCount(0); await page.locator('.source-citation').click(); await expect(page.locator('#cursor')).toContainText('Ln 4'); await expect(page.locator('#citation-status')).toContainText('Opened main.cpp:4'); await page.screenshot({ path: 'test-results/explanation.png' }); await page.locator('#explain-question').fill('How is this constrained?'); await expect(page.locator('#send-explanation')).toBeEnabled(); await expect(page.locator('#context-messages')).toContainText('How is this constrained?'); expect(await page.evaluate(() => (window as unknown as { calls: { command: string }[] }).calls.filter(c => c.command === 'prepare_explanation').length)).toBe(1); }); test('model settings save custom audiences and keep credentials separate', async ({ page }) => { await mockDesktop(page); await openProject(page); await page.locator('#nav-explain').click(); await expect(page.locator('#explain-status')).toContainText('Context ready'); await page.locator('#model-settings').click(); await page.locator('#llm-audience').selectOption('custom'); await page.locator('#llm-instructions').fill('Explain to a scientist.'); await page.locator('#llm-key').fill('session-secret'); await page.locator('#llm-save').click(); await expect(page.locator('#llm-settings')).not.toBeVisible(); await expect(page.locator('#send-explanation')).toBeDisabled(); const saved = await page.evaluate(() => (window as unknown as { calls: { command: string; args: { settings: unknown; apiKey: string } }[] }).calls.find(c => c.command === 'save_llm_settings')!.args); expect(saved.apiKey).toBe('session-secret'); expect(JSON.stringify(saved.settings)).not.toContain('session-secret'); await page.locator('#model-settings').click(); await expect(page.locator('#llm-audience')).toHaveValue('custom'); await expect(page.locator('#llm-instructions')).toHaveValue('Explain to a scientist.'); await expect(page.locator('#llm-key')).toHaveValue(''); }); test('cancelled model responses cannot overwrite the partial explanation', async ({ page }) => { await mockDesktop(page); await page.addInitScript(() => Object.assign(window, { slowModel: true })); await openProject(page); await page.locator('#nav-explain').click(); await expect(page.locator('#explain-status')).toContainText('Context ready'); await page.locator('#send-explanation').click(); await expect(page.locator('#explanation-answer')).toContainText('Partial explanation'); await page.locator('#cancel-explanation').click(); await page.waitForTimeout(1200); await expect(page.locator('#explain-status')).toContainText('Cancelled'); await expect(page.locator('#explanation-answer')).toHaveText('Partial explanation'); }); test('called bodies support individual selection, select all, and bounded recursion with size warnings', async ({ page }) => { await mockDesktop(page); await openProject(page); await page.locator('#nav-explain').click(); await expect(page.locator('#callee-list input')).toHaveCount(2); await page.locator('#callee-list input').first().check(); await expect(page.locator('#send-explanation')).toBeDisabled(); await page.locator('#apply-callees').click(); await expect(page.locator('#send-explanation')).toBeEnabled(); await expect(page.locator('#context-size-warning')).toContainText('Large context'); await page.locator('#callees-all').click(); await page.locator('#callee-depth').selectOption('2'); await page.locator('#apply-callees').click(); await expect(page.locator('#explain-status')).toContainText('Body selection applied'); const calls = await page.evaluate(() => (window as unknown as { calls: { command: string; args: { selected: string[]; depth: number } }[] }).calls.filter(c => c.command === 'expand_explanation')); expect(calls[0].args.selected).toEqual(['C1']); expect(calls[0].args.depth).toBe(1); expect(calls[1].args.selected).toEqual(['C1', 'C2']); expect(calls[1].args.depth).toBe(2); expect(await page.locator('#callee-depth option').evaluateAll(options => options.map(o => (o as HTMLOptionElement).value))).toEqual(['0', '1', '2']); await page.locator('#callees-none').click(); await page.locator('#apply-callees').click(); await expect(page.locator('#callee-list input:checked')).toHaveCount(0); }); test('right-click on a symbol opens inline semantic navigation and explanation actions', async ({ page }) => { await mockDesktop(page); await openProject(page); await page.locator('.view-line').filter({ hasText: 'concept Number' }).click({ button: 'right', position: { x: 95, y: 10 } }); for (const name of ['Go to Definition', 'Find References', 'Find Callers', 'Show Type Information', 'Explain Selected Code…']) { await expect(page.getByRole('menuitem', { name, exact: false })).toBeVisible(); } await page.getByRole('menuitem', { name: 'Find Callers', exact: false }).click({ delay: 150 }); // Monaco enables menu mouse-up after 100 ms. await expect(page.locator('#navigation-title')).toContainText('Callers'); }); test('Vim motions, counts, definition and back preserve read-only source', async ({ page }) => { await mockDesktop(page); await openProject(page); await page.keyboard.type('gg3j'); await expect(page.locator('#cursor')).toContainText('Ln 4'); await page.keyboard.type('0w'); await expect(page.locator('#cursor')).toContainText('Col 9'); await page.keyboard.type('gd'); await expect(page.locator('#file-path')).toHaveText('lib.cpp'); await page.keyboard.press('Control+o'); await expect(page.locator('#file-path')).toHaveText('main.cpp'); await page.keyboard.type('iaodcx'); await expect(page.locator('.monaco-editor .inputarea')).toHaveAttribute('readonly', 'true'); await page.keyboard.type('G'); await expect(page.locator('#cursor')).toContainText('Ln 11'); await page.keyboard.type('4gg'); await expect(page.locator('#cursor')).toContainText('Ln 4'); await page.keyboard.type('zc'); await expect(page.locator('.view-lines')).not.toContainText('value + value;'); await page.keyboard.type('zo'); await expect(page.locator('.view-lines')).toContainText('value + value;'); }); test('Vim gc and gr cycle cross-file results, while slash switches n to file search', async ({ page }) => { await mockDesktop(page); await page.addInitScript(() => Object.assign(window, { multipleLocations: true })); await openProject(page); await page.keyboard.type('gc'); await expect(page.locator('#file-path')).toHaveText('first.cpp'); await page.keyboard.type('n'); await expect(page.locator('#file-path')).toHaveText('second.cpp'); await page.keyboard.type('N'); await expect(page.locator('#file-path')).toHaveText('first.cpp'); await page.keyboard.type('gr'); await expect(page.locator('#navigation-title')).toContainText('References'); await page.keyboard.type('n'); await expect(page.locator('#file-path')).toHaveText('second.cpp'); await page.keyboard.type('/'); const find = page.getByRole('textbox', { name: 'Find', exact: true }); await expect(find).toBeFocused(); await find.fill('value'); await page.keyboard.press('Enter'); await expect(page.locator('.find-widget')).not.toHaveClass(/visible/); await expect(page.locator('#vim-status')).toContainText('n: search'); await page.keyboard.type('n'); await expect(page.locator('#file-path')).toHaveText('second.cpp'); await expect(page.locator('#cursor')).toContainText('Ln 5'); await page.getByRole('button', { name: 'Search', exact: true }).click(); await page.getByLabel('Search project text').fill('hjkl gd gc'); await expect(page.getByLabel('Search project text')).toHaveValue('hjkl gd gc'); }); test('landing page restores a recent project and records successful opens', async ({ page }) => { await mockDesktop(page); await page.addInitScript(() => Object.assign(window, { recentProjects: [{ root: '/sample', last_opened: 1788800000 }] })); await page.goto('/'); await expect(page.getByRole('heading', { name: 'Open Recent' })).toBeVisible(); await page.screenshot({ path: 'test-results/recents.png' }); await page.locator('.recent-project').click(); await expect(page.locator('#landing')).not.toBeVisible(); await expect(page.locator('#file-path')).toHaveText('main.cpp'); await expect.poll(() => page.evaluate(() => (window as unknown as { calls: { command: string }[] }).calls.some(c => c.command === 'remember_recent'))).toBe(true); }); test('recent entries can be removed without opening or resetting a workspace', async ({ page }) => { await mockDesktop(page); await page.addInitScript(() => Object.assign(window, { recentProjects: [{ root: '/sample', last_opened: 1788800000 }] })); await page.goto('/'); await page.getByRole('button', { name: 'Remove sample from recent projects' }).click(); await expect(page.locator('#recent-status')).toContainText('No recent projects yet'); expect(await page.evaluate(() => (window as unknown as { calls: { command: string }[] }).calls.some(c => c.command === 'open_workspace'))).toBe(false); }); test('missing recent projects leave setup available and do not record a successful open', async ({ page }) => { await mockDesktop(page); await page.addInitScript(() => Object.assign(window, { missingProject: true, recentProjects: [{ root: '/missing', last_opened: 1788800000 }] })); await page.goto('/'); await page.locator('.recent-project').click(); await expect(page.locator('#setup')).toBeVisible(); await expect(page.locator('#source')).toHaveValue('/missing'); await expect(page.locator('#saved-state')).toContainText('Source directory does not exist'); expect(await page.evaluate(() => (window as unknown as { calls: { command: string }[] }).calls.some(c => c.command === 'remember_recent'))).toBe(false); }); test('concept expansion excludes standard concepts by default, including Select all', async ({ page }) => { await mockDesktop(page); await page.addInitScript(() => Object.assign(window, { conceptMode: true })); await openProject(page); await page.locator('#nav-explain').click(); await expect(page.locator('#dependency-heading')).toHaveText('Concept dependencies'); await expect(page.locator('#include-standard')).not.toBeChecked(); await expect(page.locator('#callee-list input').nth(1)).toBeDisabled(); await page.locator('#callees-all').click(); await page.locator('#callee-depth').selectOption('2'); await page.locator('#apply-callees').click(); await expect(page.locator('#send-explanation')).toBeEnabled(); await page.locator('#include-standard').check(); await expect(page.locator('#send-explanation')).toBeDisabled(); await page.locator('#callees-all').click(); await page.locator('#apply-callees').click(); await expect(page.locator('#send-explanation')).toBeEnabled(); const calls = await page.evaluate(() => (window as unknown as { calls: { command: string; args: { selected: string[]; includeStandard: boolean; depth: number } }[] }).calls.filter(c => c.command === 'expand_explanation')); expect(calls[0].args.selected).toEqual(['C1']); expect(calls[0].args.includeStandard).toBe(false); expect(calls[0].args.depth).toBe(2); expect(calls[1].args.selected).toEqual(['C1', 'C2']); expect(calls[1].args.includeStandard).toBe(true); await page.locator('#include-standard').uncheck(); await expect(page.locator('#callee-list input').nth(1)).not.toBeChecked(); await expect(page.locator('#send-explanation')).toBeDisabled(); }); test('concept graph shares a collapsible tabbed sidebar with chat and opens source nodes', async ({ page }) => { await mockDesktop(page); await openProject(page); await page.locator('#nav-explain').click(); await page.locator('#explain-question').fill('Keep this chat question'); await expect(page.locator('#send-explanation')).toBeEnabled(); await page.getByRole('tab', { name: 'Graph', exact: true }).click(); await expect(page.locator('#graph-title')).toHaveText('Number'); await expect(page.locator('#graph-canvas [role=button]')).toHaveCount(4); const positions = await page.locator('#graph-canvas .graph-node').evaluateAll(nodes => nodes.map(n => ({ kind:n.getAttribute('class'), y:n.getBoundingClientRect().y }))); expect(positions.find(n => n.kind?.includes('requires'))!.y).toBeLessThan(positions.find(n => n.kind?.includes('selected'))!.y); expect(positions.find(n => n.kind?.includes('usage'))!.y).toBeGreaterThan(positions.find(n => n.kind?.includes('selected'))!.y); await page.locator('#graph-canvas .usage').click(); await page.getByRole('button', { name: 'Open source', exact: true }).click(); await expect(page.locator('#cursor')).toContainText('Ln 8'); await page.getByRole('button', { name: 'Collapse right sidebar' }).click(); await expect(page.locator('#explanation-pane')).not.toBeVisible(); await page.locator('#toggle-sidebar').click(); await expect(page.getByRole('tab', { name: 'Graph', exact: true })).toHaveAttribute('aria-selected','true'); await page.screenshot({ path: 'test-results/graph.png' }); await page.getByRole('tab', { name: 'Chat', exact: true }).click(); await expect(page.locator('#explain-question')).toHaveValue('Keep this chat question'); await page.getByRole('tab', { name: 'Graph', exact: true }).click(); await page.locator('#graph-canvas .concept').click(); await page.getByRole('button', { name: 'Focus this concept' }).click(); await expect.poll(() => page.evaluate(() => (window as unknown as {calls: {command:string;args:{kind?:string;position?:{line:number}}}[]}).calls.filter(c => c.command === 'graph_request').at(-1)?.args.position?.line)).toBe(2); }); test('graph reports unsupported selections and leaves chat available', async ({ page }) => { await mockDesktop(page); await page.addInitScript(() => Object.assign(window,{ failGraph:true })); await openProject(page); await page.locator('#nav-graph').click(); await expect(page.locator('#graph-status')).toContainText('Select a concept or function'); await expect(page.locator('#graph-refresh')).toBeEnabled(); await page.getByRole('tab',{name:'Chat',exact:true}).click(); await expect(page.locator('#explain-question')).toBeVisible(); }); test('themes follow the system, persist overrides, and update the editor and graph', async ({ page }) => { await page.emulateMedia({ colorScheme: 'dark' }); await mockDesktop(page); await openProject(page); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); await page.getByLabel('Color theme').selectOption('light'); await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); await expect(page.locator('.monaco-editor').first()).toHaveCSS('background-color', 'rgb(255, 255, 255)'); await page.locator('#nav-graph').click(); await expect(page.locator('.graph-node.selected rect')).toHaveCSS('fill', 'rgb(220, 238, 229)'); await page.screenshot({ path: 'test-results/theme-light.png', animations: 'disabled' }); await page.getByLabel('Color theme').selectOption('dark'); await expect(page.locator('.monaco-editor').first()).toHaveCSS('background-color', 'rgb(23, 28, 35)'); await page.screenshot({ path: 'test-results/theme-dark.png', animations: 'disabled' }); await page.getByLabel('Color theme').selectOption('light'); await page.reload(); await expect(page.getByLabel('Color theme')).toHaveValue('light'); await page.getByLabel('Color theme').selectOption('system'); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); await page.emulateMedia({ colorScheme: 'light' }); await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); await page.getByLabel('Color theme').selectOption('dark'); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); await expect(page.locator('#recent-open')).toHaveCSS('background-color', 'rgb(131, 216, 189)'); await page.screenshot({ path: 'test-results/theme-dark-landing.png', animations: 'disabled' }); }); test('graph options are separate from context and navigation supports history and zoom', async ({ page }) => { await mockDesktop(page); await openProject(page); await page.locator('#nav-graph').click(); await page.locator('#graph-options summary').click(); await expect(page.getByRole('button', {name:'Remove namespace std',exact:true})).toBeVisible(); await page.getByLabel('Graph depth', {exact:true}).selectOption('3'); await page.getByLabel('Excluded namespaces', {exact:true}).fill('vendor::detail'); await page.locator('#graph-add').click(); await page.getByRole('button', {name:'Remove namespace std',exact:true}).click(); await page.locator('#graph-apply').click(); await expect(page.locator('#graph-options-status')).toHaveText('Saved for this project.'); const options = await page.evaluate(() => (window as unknown as {calls:{command:string;args:Record}[]}).calls.filter(c => c.command==='graph_request').at(-1)?.args.options); expect(options).toEqual({depth:3,excluded_namespaces:['vendor::detail']}); await page.locator('#graph-options summary').click(); await page.locator('.graph-node.concept').dblclick(); await expect(page.locator('#graph-back')).toBeEnabled(); await page.locator('#graph-back').click(); await expect(page.locator('#graph-forward')).toBeEnabled(); await page.locator('#graph-center').click(); await expect(page.locator('#graph-zoom')).toHaveText('100%'); await page.locator('#graph-in').click(); await expect(page.locator('#graph-zoom')).toHaveText('125%'); await page.locator('#graph-expand').click(); await expect(page.locator('#explanation-pane')).toHaveClass(/graph-expanded/); await page.locator('#graph-fit').click(); await page.screenshot({path:'test-results/graph-navigation.png'}); }); test('function graphs navigate callees above and callers below using nodes and keyboard', async ({page}) => { await mockDesktop(page); await page.addInitScript(() => Object.assign(window,{functionGraph:true})); await openProject(page); await page.locator('#nav-graph').click(); await expect(page.locator('#graph-title')).toHaveText('target'); const root = page.getByRole('button',{name:'selected: target',exact:true}); const caller = page.getByRole('button',{name:'function: caller',exact:true}); const helper = page.getByRole('button',{name:'function: helper',exact:true}); expect((await helper.boundingBox())!.y).toBeLessThan((await root.boundingBox())!.y); expect((await caller.boundingBox())!.y).toBeGreaterThan((await root.boundingBox())!.y); await root.focus(); await root.press('ArrowUp'); await expect(helper).toBeFocused(); await page.getByRole('button',{name:'Focus this symbol',exact:true}).click(); await expect(page.locator('#graph-back')).toBeEnabled(); }); test('graph building indicator covers success and failure and clears on return to welcome', async ({page}) => { await mockDesktop(page); await page.addInitScript(() => Object.assign(window,{slowGraph:true})); await openProject(page); await page.locator('#nav-graph').click(); await expect(page.locator('#graph-progress')).toBeVisible(); await expect(page.locator('#graph-canvas')).toHaveAttribute('aria-busy','true'); await expect(page.locator('#graph-progress')).toBeHidden(); await page.evaluate(() => Object.assign(window,{failGraph:true})); await page.locator('#graph-refresh').click(); await expect(page.locator('#graph-progress')).toBeVisible(); await expect(page.locator('#graph-status')).toContainText('Select a concept or function'); await expect(page.locator('#graph-progress')).toBeHidden(); await page.evaluate(() => Object.assign(window,{failGraph:false})); await page.locator('#graph-refresh').click(); await expect(page.locator('#graph-progress')).toBeVisible(); await page.locator('#file-menu > summary').click(); await page.getByRole('button',{name:'Return to Welcome',exact:true}).click(); await expect(page.locator('#landing')).toBeVisible(); await expect(page.locator('#graph-progress')).toBeHidden(); await expect(page.locator('#explanation-pane')).toBeHidden(); }); test('File menu opens fresh projects, recent projects, and returns to Welcome', async ({page}) => { await mockDesktop(page); await page.addInitScript(() => Object.assign(window,{recentProjects:[{root:'/sample',last_opened:1700000000}]})); await openProject(page); await page.locator('#file-menu > summary').click(); await page.getByRole('button',{name:'Open Project…',exact:true}).click(); await expect(page.locator('#source')).toHaveValue(''); await expect(page.locator('#build')).toHaveValue(''); await page.locator('#setup-cancel').click(); await expect(page.locator('#file-path')).toHaveText('main.cpp'); await page.locator('#file-menu > summary').click(); await page.locator('#file-recents > summary').click(); await expect(page.locator('#file-recent-list button')).toHaveCount(1); await page.locator('#file-recent-list button').click(); await expect(page.locator('#setup')).toBeHidden(); await expect(page.locator('#file-menu')).not.toHaveAttribute('open',''); await page.locator('#file-menu > summary').click(); await page.getByRole('button',{name:'Return to Welcome',exact:true}).click(); await expect(page.locator('#landing')).toBeVisible(); await expect(page.locator('#project-name')).toHaveText('No project open'); await expect(page.locator('#toggle-sidebar')).toBeDisabled(); expect(await page.evaluate(() => (window as unknown as {calls:{command:string;args:unknown}[]}).calls.filter(c=>c.command==='close_workspace').at(-1)?.args)).toEqual({root:'/sample'}); await page.locator('.recent-project').click(); await expect(page.locator('#landing')).toBeHidden(); await expect(page.locator('#file-path')).toHaveText('main.cpp'); }); test('both sidebars resize with pointer and keyboard and keep widths across collapse', async ({page}) => { await mockDesktop(page); await openProject(page); await page.locator('#nav-graph').click(); const left=page.getByRole('separator',{name:'Resize left sidebar'}), right=page.getByRole('separator',{name:'Resize right sidebar'}); const initialLeft=(await page.locator('main > aside').boundingBox())!.width; const handle=(await left.boundingBox())!; await page.mouse.move(handle.x+4,handle.y+150);await page.mouse.down();await page.mouse.move(handle.x+64,handle.y+150);await page.mouse.up(); await expect.poll(async()=>(await page.locator('main > aside').boundingBox())!.width).toBeGreaterThan(initialLeft+45); const initialRight=(await page.locator('#explanation-pane').boundingBox())!.width; const r=(await right.boundingBox())!; await page.mouse.move(r.x+4,r.y+150);await page.mouse.down();await page.mouse.move(r.x-46,r.y+150);await page.mouse.up(); await expect.poll(async()=>(await page.locator('#explanation-pane').boundingBox())!.width).toBeGreaterThan(initialRight+35); await right.focus();await right.press('ArrowLeft'); const width=(await page.locator('#explanation-pane').boundingBox())!.width; await page.getByRole('button',{name:'Collapse right sidebar'}).click();await page.locator('#toggle-sidebar').click(); expect((await page.locator('#explanation-pane').boundingBox())!.width).toBeCloseTo(width,0); await page.locator('#graph-expand').click(); expect((await page.locator('#explanation-pane').boundingBox())!.width).toBeGreaterThan(width+100); await page.locator('#graph-expand').click(); expect((await page.locator('#explanation-pane').boundingBox())!.width).toBeCloseTo(width,0); await page.setViewportSize({width:950,height:850}); await expect(page.locator('#explanation-pane')).toHaveCSS('position','fixed'); await right.focus();await right.press('ArrowRight'); expect((await page.locator('#explanation-pane').boundingBox())!.width).toBeLessThan(width); }); test('type graph nodes can be refocused and member nodes remain navigable', async ({page}) => { await mockDesktop(page);await page.addInitScript(()=>Object.assign(window,{typeGraph:true})); await openProject(page);await page.locator('#nav-graph').click(); await expect(page.locator('#graph-title')).toHaveText('demo::Box'); await page.getByRole('button',{name:'type: demo::Base',exact:true}).click(); await page.getByRole('button',{name:'Focus this symbol',exact:true}).click(); await expect(page.locator('#graph-back')).toBeEnabled(); await page.getByRole('button',{name:'function: demo::Box::run',exact:true}).click(); await expect(page.getByRole('button',{name:'Open source',exact:true})).toBeVisible(); });