552 lines
40 KiB
TypeScript
552 lines
40 KiB
TypeScript
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 '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<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.'] };
|
|
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]. <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'});
|
|
});
|
|
|
|
|
|
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();
|
|
});
|