Initial Commit

This commit is contained in:
2026-09-07 15:16:52 -04:00
commit e8ce633e5e
54 changed files with 13079 additions and 0 deletions

439
tests/browser.spec.ts Normal file
View File

@@ -0,0 +1,439 @@
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 <concepts>\n\ntemplate<class T>\nconcept Number = requires(T value) {\n value + value;\n};\n\nint main() {\n return 0;\n}\n';
let context: Record<string, any> | 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<string, unknown>) => {
(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 '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 { 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<T>', kind: 'concept', detail: 'Base<T>', 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.'] }; 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]. <img src="https://invalid.example/image" onerror="alert(1)"> [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<string,unknown>}[]}).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'});
});