/** * @fileoverview File Viewer server-side search (COD-341). * * The browser module is loaded as the real CodemanApp mixin in a VM. The fake * DOM intentionally implements only the element contract used by the File * Viewer, while fetch responses and timers remain controllable so races can be * exercised without jsdom. */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import vm from 'node:vm'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8'); const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8'); const settingsJs = readFileSync(resolve(PUBLIC, 'settings-ui.js'), 'utf8'); type ClickHandler = () => void; interface FakeClassList { add: (...names: string[]) => void; remove: (...names: string[]) => void; contains: (name: string) => boolean; toggle: (name: string, force?: boolean) => boolean; } interface FakeRow { dataset: Record; addEventListener: (type: string, handler: ClickHandler) => void; click: () => void; } interface FakeElement { innerHTML: string; textContent: string; value: string; disabled: boolean; style: Record; attrs: Record; classList: FakeClassList; setAttribute: (name: string, value: string) => void; querySelector: (selector: string) => FakeElement | null; querySelectorAll: (selector: string) => FakeRow[]; } function decodeHtml(value: string): string { return value .replaceAll('"', '"') .replaceAll(''', "'") .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('&', '&'); } function escapeHtml(value: unknown): string { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function fakeElement(initialClasses: string[] = []): FakeElement { const classes = new Set(initialClasses); const attrs: Record = {}; const rows: FakeRow[] = []; let html = ''; const classList: FakeClassList = { add: (...names) => names.forEach((name) => classes.add(name)), remove: (...names) => names.forEach((name) => classes.delete(name)), contains: (name) => classes.has(name), toggle(name, force) { const on = force === undefined ? !classes.has(name) : force; if (on) classes.add(name); else classes.delete(name); return on; }, }; return { get innerHTML() { return html; }, set innerHTML(value: string) { html = value; rows.length = 0; const rowPattern = /
]*)>/g; for (const match of value.matchAll(rowPattern)) { const attributes = match[1]; const dataset: Record = {}; for (const attr of attributes.matchAll(/data-([a-z-]+)="([^"]*)"/g)) { const key = attr[1].replace(/-([a-z])/g, (_whole, letter: string) => letter.toUpperCase()); dataset[key] = decodeHtml(attr[2]); } let clickHandler: ClickHandler | null = null; rows.push({ dataset, addEventListener(type, handler) { if (type === 'click') clickHandler = handler; }, click() { clickHandler?.(); }, }); } }, textContent: '', value: '', disabled: false, style: {}, attrs, classList, setAttribute(name, value) { attrs[name] = value; }, querySelector() { return null; }, querySelectorAll(selector) { return selector === '.file-tree-item' ? rows : []; }, }; } function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject }; } interface FakeResponse { ok: boolean; status: number; json: () => Promise; } function response(body: unknown, ok = true): FakeResponse { return { ok, status: ok ? 200 : 500, json: async () => body }; } function loadPanel(options: { sessionId?: string | null; showHidden?: boolean } = {}) { const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record; const elements: Record = { fileBrowserPanel: fakeElement(['visible']), fileBrowserTree: fakeElement(), fileBrowserStatus: fakeElement(), fileBrowserSearch: fakeElement(), fileBrowserExpandBtn: fakeElement(), fileBrowserHiddenBtn: fakeElement(), }; const pending: Array<{ url: string; reply: ReturnType> }> = []; const context = vm.createContext({ CodemanApp, console, escapeHtml, localStorage: { getItem: () => null, setItem: vi.fn() }, document: { getElementById: (id: string) => elements[id] ?? null, addEventListener: vi.fn(), removeEventListener: vi.fn(), querySelector: vi.fn(), }, window: { addEventListener: vi.fn() }, setTimeout, clearTimeout, fetch: (url: string) => { const reply = deferred(); pending.push({ url, reply }); return reply.promise; }, }); vm.runInContext(settingsJs, context, { filename: 'settings-ui.js' }); vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' }); const app = new CodemanApp() as Record; app.$ = (id: string) => elements[id] ?? null; app.activeSessionId = options.sessionId === undefined ? 'session/A' : options.sessionId; app.fileBrowserData = null; app.fileBrowserExpandedDirs = new Set(); app.fileBrowserFilter = ''; app.fileBrowserAllExpanded = false; app.fileBrowserShowHidden = options.showHidden ?? false; app.openFilePreview = vi.fn(); app.loadAppSettingsFromStorage = () => ({ showFileBrowser: false }); app.getDefaultSettings = () => ({}); app.saveAppSettingsToStorage = vi.fn(); return { app, elements, pending }; } async function startSearch(app: Record, query = 'widget') { app.filterFileBrowser(query); await vi.advanceTimersByTimeAsync(250); } async function settleSearch( app: Record, pending: Array<{ url: string; reply: ReturnType> }>, body: unknown, ok = true ) { await startSearch(app); pending[0].reply.resolve(response(body, ok)); await vi.advanceTimersByTimeAsync(0); } function successfulData(overrides: Record = {}) { return { success: true, data: { mode: 'search', matches: [], truncated: false, matchCount: 0, ...overrides, }, }; } function successfulTree(name: string, overrides: Record = {}) { return { success: true, data: { tree: [{ name, path: name, type: 'file', size: 1, extension: 'ts' }], totalFiles: 1, totalDirectories: 0, truncated: false, ...overrides, }, }; } describe('File Viewer server search', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('debounces for 250ms and encodes the owner and exact trimmed query', async () => { const { app, pending } = loadPanel(); app.filterFileBrowser('src & docs'); await vi.advanceTimersByTimeAsync(249); expect(pending).toHaveLength(0); await vi.advanceTimersByTimeAsync(1); expect(pending).toHaveLength(1); expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs'); }); it('is a safe no-op without a panel or session and can lazily adopt a later active owner', async () => { const { app, elements, pending } = loadPanel({ sessionId: null }); app.filterFileBrowser('before-session'); await vi.advanceTimersByTimeAsync(250); expect(pending).toHaveLength(0); app.activeSessionId = 'session/B'; app.filterFileBrowser('after-session'); await vi.advanceTimersByTimeAsync(250); expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session'); pending[0].reply.resolve(response(successfulData())); await vi.advanceTimersByTimeAsync(0); expect(elements.fileBrowserTree.innerHTML).toContain('No matches'); delete elements.fileBrowserPanel; app.filterFileBrowser('without-panel'); await vi.advanceTimersByTimeAsync(250); expect(pending).toHaveLength(1); }); it('does not schedule when the active session is missing or differs from the retained owner', async () => { const { app, elements, pending } = loadPanel(); app._ensureFileBrowserState(); app.activeSessionId = null; app.filterFileBrowser('missing-active'); await vi.advanceTimersByTimeAsync(250); expect(pending).toHaveLength(0); expect(elements.fileBrowserTree.innerHTML).toBe(''); expect(app._fileBrowserState.inFlight).toBeNull(); app.activeSessionId = 'session/B'; app.filterFileBrowser('wrong-active'); await vi.advanceTimersByTimeAsync(250); expect(pending).toHaveLength(0); expect(elements.fileBrowserTree.innerHTML).toBe(''); expect(app._fileBrowserState.inFlight).toBeNull(); }); it('cancels the prior debounce and clears a deferred directory target on every input', async () => { const { app, pending } = loadPanel(); app._ensureFileBrowserState().deferredDirectoryTarget = { ownerSessionId: 'session/A', path: 'old-directory', }; app.filterFileBrowser('first query'); await vi.advanceTimersByTimeAsync(200); app._fileBrowserState.deferredDirectoryTarget = { ownerSessionId: 'session/A', path: 'another-directory', }; app.filterFileBrowser('second query'); expect(app._fileBrowserState.deferredDirectoryTarget).toBeNull(); await vi.advanceTimersByTimeAsync(249); expect(pending).toHaveLength(0); await vi.advanceTimersByTimeAsync(1); expect(pending.map(({ url }) => url)).toEqual([ '/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query', ]); }); it('renders flat file and directory matches with captured-owner actions and status', async () => { const { app, elements, pending } = loadPanel(); await settleSearch( app, pending, successfulData({ matches: [ { name: 'widget.ts', path: 'src/widget.ts', type: 'file', size: 1536, extension: 'ts' }, { name: 'widgets', path: 'docs/widgets', type: 'directory' }, ], matchCount: 2, truncated: true, }) ); expect(elements.fileBrowserTree.innerHTML).toContain('class="file-tree-name">widget.ts'); expect(elements.fileBrowserTree.innerHTML).toContain('class="file-tree-name directory">widgets'); expect(elements.fileBrowserTree.innerHTML).toContain('class="file-tree-size">1.5 KB'); expect(elements.fileBrowserTree.innerHTML).toContain('📘'); expect(elements.fileBrowserTree.innerHTML).toContain('📁'); expect(elements.fileBrowserTree.innerHTML).toContain('data-path="src/widget.ts"'); expect(elements.fileBrowserTree.innerHTML).not.toContain('class="file-tree-path"'); expect(elements.fileBrowserTree.innerHTML).toContain( 'href="/api/sessions/session%2FA/file-raw?path=src%2Fwidget.ts&download=true"' ); expect(elements.fileBrowserStatus.textContent).toBe('2 matches (truncated)'); expect(elements.fileBrowserExpandBtn.disabled).toBe(true); app.activeSessionId = 'later-session'; const rows = elements.fileBrowserTree.querySelectorAll('.file-tree-item'); rows[0].click(); rows[1].click(); expect(app.openFilePreview).toHaveBeenCalledWith('src/widget.ts', 'session/A'); expect(app._fileBrowserState.deferredDirectoryTarget).toEqual({ ownerSessionId: 'session/A', path: 'docs/widgets', }); }); it('uses the match list length when matchCount is omitted and renders no matches', async () => { const { app, elements, pending } = loadPanel(); await settleSearch(app, pending, successfulData({ matchCount: undefined })); expect(elements.fileBrowserTree.innerHTML).toContain('No matches'); expect(elements.fileBrowserStatus.textContent).toBe('0 matches'); }); it('rejects deferred results after any captured search context changes', async () => { const scenarios: Array<{ name: string; mutate: (app: Record, elements: Record) => void; }> = [ { name: 'raw input', mutate: (app) => { app._fileBrowserState.filter = 'changed raw input'; }, }, { name: 'search epoch', mutate: (app) => { app._fileBrowserState.searchEpoch++; }, }, { name: 'owner', mutate: (app) => { app._fileBrowserState.ownerSessionId = 'another-owner'; }, }, { name: 'active session', mutate: (app) => { app.activeSessionId = 'another-active-session'; }, }, { name: 'hidden preference', mutate: (app) => { app.fileBrowserShowHidden = true; }, }, { name: 'panel visibility', mutate: (_app, elements) => { elements.fileBrowserPanel.classList.remove('visible'); }, }, ]; for (const scenario of scenarios) { const { app, elements, pending } = loadPanel(); await startSearch(app); expect(app._fileBrowserState.inFlight.treeEpoch, scenario.name).toBe(0); scenario.mutate(app, elements); pending[0].reply.resolve( response( successfulData({ matches: [{ name: `late-${scenario.name}.ts`, path: 'late.ts', type: 'file' }], matchCount: 1, }) ) ); await vi.advanceTimersByTimeAsync(0); expect(elements.fileBrowserTree.innerHTML, scenario.name).toContain('Searching'); expect(elements.fileBrowserTree.innerHTML, scenario.name).not.toContain(`late-${scenario.name}.ts`); expect(app._fileBrowserState.inFlight, scenario.name).toBeNull(); } }); it('keeps query B rendered when its response beats an already-launched query A', async () => { const { app, elements, pending } = loadPanel(); app.filterFileBrowser('query A'); await vi.advanceTimersByTimeAsync(250); app.filterFileBrowser('query B'); await vi.advanceTimersByTimeAsync(250); expect(pending).toHaveLength(2); pending[1].reply.resolve( response( successfulData({ matches: [{ name: 'result-B.ts', path: 'result-B.ts', type: 'file' }], matchCount: 1, }) ) ); await vi.advanceTimersByTimeAsync(0); expect(elements.fileBrowserTree.innerHTML).toContain('result-B.ts'); pending[0].reply.resolve( response( successfulData({ matches: [{ name: 'result-A.ts', path: 'result-A.ts', type: 'file' }], matchCount: 1, }) ) ); await vi.advanceTimersByTimeAsync(0); expect(elements.fileBrowserTree.innerHTML).toContain('result-B.ts'); expect(elements.fileBrowserTree.innerHTML).not.toContain('result-A.ts'); expect(app._fileBrowserState.matches[0].name).toBe('result-B.ts'); }); it('clears a blank query and restores the compatible cached tree immediately without fetching', () => { const { app, elements, pending } = loadPanel(); app.fileBrowserData = { tree: [{ name: 'cached.ts', path: 'src/cached.ts', type: 'file', size: 4, extension: 'ts' }], totalFiles: 1, totalDirectories: 0, truncated: false, }; app.filterFileBrowser('cache'); expect(elements.fileBrowserTree.innerHTML).toContain('Searching'); app.filterFileBrowser(' '); expect(pending).toHaveLength(0); expect(elements.fileBrowserTree.innerHTML).toContain('cached.ts'); expect(elements.fileBrowserStatus.textContent).toBe('1 files, 0 dirs'); expect(elements.fileBrowserExpandBtn.disabled).toBe(false); }); it('binds normal-tree previews and encoded downloads to the captured owner', () => { const { app, elements } = loadPanel(); app.fileBrowserData = { tree: [ { name: 'normal & safe.ts', path: 'src/normal & safe.ts', type: 'file', size: 7, extension: 'ts', }, ], totalFiles: 1, totalDirectories: 0, truncated: false, }; app.renderFileBrowserTree(); expect(elements.fileBrowserTree.innerHTML).toContain( 'href="/api/sessions/session%2FA/file-raw?path=src%2Fnormal%20%26%20safe.ts&download=true"' ); app.activeSessionId = 'later-session'; elements.fileBrowserTree.querySelectorAll('.file-tree-item')[0].click(); expect(app.openFilePreview).toHaveBeenCalledWith('src/normal & safe.ts', 'session/A'); }); it('accepts 256 trimmed characters', async () => { const { app, pending } = loadPanel(); app.filterFileBrowser(` ${'x'.repeat(256)} `); await vi.advanceTimersByTimeAsync(250); expect(pending).toHaveLength(1); expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}`)).toBe(true); }); it('rejects 257 trimmed characters without a request', async () => { const { app, elements, pending } = loadPanel(); app.filterFileBrowser('x'.repeat(257)); await vi.advanceTimersByTimeAsync(1_000); expect(pending).toHaveLength(0); expect(elements.fileBrowserStatus.textContent).toBe('Search queries are limited to 256 characters'); expect(app._fileBrowserState.matches).toEqual([]); expect(app._fileBrowserState.view).toBe('query-error'); }); it('fails closed on a non-2xx response', async () => { const { app, elements, pending } = loadPanel(); await settleSearch(app, pending, { success: true }, false); expect(elements.fileBrowserTree.innerHTML).toBe('
Search failed
'); expect(elements.fileBrowserStatus.textContent).toBe('Search failed'); expect(app._fileBrowserState.view).toBe('search-error'); }); it('fails closed on an unsuccessful envelope', async () => { const { app, elements, pending } = loadPanel(); await settleSearch(app, pending, { success: false, error: 'server detail' }); expect(elements.fileBrowserTree.innerHTML).toBe('
Search failed
'); expect(elements.fileBrowserTree.innerHTML).not.toContain('server detail'); }); it.each([ ['mode is not search', successfulData({ mode: 'tree' })], ['matches is not an array', successfulData({ matches: {} })], ['a match name is not a string', successfulData({ matches: [{ name: 1, path: 'x', type: 'file' }] })], ['a match path is not a string', successfulData({ matches: [{ name: 'x', path: null, type: 'file' }] })], ['a match type is unknown', successfulData({ matches: [{ name: 'x', path: 'x', type: 'symlink' }] })], [ 'a supplied size is not a number', successfulData({ matches: [{ name: 'x', path: 'x', type: 'file', size: '1' }] }), ], [ 'a supplied extension is not a string', successfulData({ matches: [{ name: 'x', path: 'x', type: 'file', extension: 1 }] }), ], ['truncated is not boolean', successfulData({ truncated: 'false' })], ['matchCount is negative', successfulData({ matchCount: -1 })], ['matchCount is not finite', successfulData({ matchCount: Number.POSITIVE_INFINITY })], ])('rejects a malformed successful envelope when %s', async (_case, body) => { const { app, elements, pending } = loadPanel(); app.fileBrowserData = { tree: [{ name: 'cached-safe.ts', path: 'cached-safe.ts', type: 'file' }], totalFiles: 1, totalDirectories: 0, truncated: false, }; const malformed = structuredClone(body); if (_case === 'matchCount is not finite') { (malformed as { data: { matchCount: number } }).data.matchCount = Number.POSITIVE_INFINITY; } await settleSearch(app, pending, malformed); expect(elements.fileBrowserTree.innerHTML).toBe('
Search failed
'); expect(elements.fileBrowserTree.innerHTML).not.toContain('cached-safe.ts'); expect(app.fileBrowserData.tree[0].name).toBe('cached-safe.ts'); }); it('escapes metacharacters in displayed values, attributes, and links', async () => { const owner = 'session/"<&\''; const path = 'src/"<&\'/evil.ts'; const name = ' &\''; const { app, elements, pending } = loadPanel({ sessionId: owner }); await settleSearch( app, pending, successfulData({ matches: [{ name, path, type: 'file', extension: 'ts' }], matchCount: 1 }) ); const html = elements.fileBrowserTree.innerHTML; expect(html).toContain('<img src=x onerror="boom"> &''); expect(html).toContain('src/"<&'/evil.ts'); expect(html).toContain('data-path="src/"<&'/evil.ts"'); expect(html).toContain( 'href="/api/sessions/session%2F%22%3C%26'/file-raw?path=src%2F%22%3C%26'%2Fevil.ts&download=true"' ); expect(html).not.toContain(' { it('deduplicates compatible in-flight loads and reuses compatible ready and error states', async () => { const ready = loadPanel(); ready.app._completeDeferredFileBrowserDirectory = vi.fn(); const first = ready.app.loadFileBrowser('session/A'); const duplicate = ready.app.loadFileBrowser('session/A'); expect(duplicate).toBe(first); expect(ready.pending).toHaveLength(1); ready.pending[0].reply.resolve(response(successfulTree('ready.ts'))); await first; expect(ready.app._fileBrowserState.normalState.phase).toBe('ready'); expect(ready.elements.fileBrowserTree.innerHTML).toContain('ready.ts'); expect(ready.app._completeDeferredFileBrowserDirectory).toHaveBeenCalledWith( ready.app._fileBrowserState.normalState ); ready.elements.fileBrowserTree.innerHTML = ''; const reusedReady = ready.app.loadFileBrowser('session/A'); expect(ready.pending).toHaveLength(1); expect(typeof reusedReady.then).toBe('function'); await reusedReady; expect(ready.elements.fileBrowserTree.innerHTML).toContain('ready.ts'); const failed = loadPanel(); failed.app._completeDeferredFileBrowserDirectory = vi.fn(); const failedLoad = failed.app.loadFileBrowser('session/A'); failed.pending[0].reply.reject(new Error('')); await failedLoad; expect(failed.app._fileBrowserState.normalState.phase).toBe('error'); expect(failed.elements.fileBrowserTree.innerHTML).toContain('<img src=x onerror=boom>'); expect(failed.elements.fileBrowserTree.innerHTML).not.toContain(''); expect(failed.elements.fileBrowserStatus.innerHTML).toBe(''); expect(failed.app._completeDeferredFileBrowserDirectory).toHaveBeenCalledWith( failed.app._fileBrowserState.normalState ); failed.elements.fileBrowserTree.innerHTML = ''; await failed.app.loadFileBrowser('session/A'); expect(failed.pending).toHaveLength(1); expect(failed.elements.fileBrowserTree.innerHTML).toContain('<img src=x onerror=boom>'); }); it('refresh invalidates both work classes, clears navigation state, and forces an epoch-safe replacement', async () => { const { app, elements, pending } = loadPanel(); const stale = app.loadFileBrowser('session/A'); app.filterFileBrowser('old query'); app.fileBrowserExpandedDirs.add('src'); app.fileBrowserAllExpanded = true; app._fileBrowserState.deferredDirectoryTarget = { ownerSessionId: 'session/A', path: 'src' }; const beforeSearchEpoch = app._fileBrowserState.searchEpoch; const beforeTreeEpoch = app._fileBrowserState.treeEpoch; const replacement = app.refreshFileBrowser(); expect(app._fileBrowserState.searchEpoch).toBe(beforeSearchEpoch + 1); expect(app._fileBrowserState.treeEpoch).toBe(beforeTreeEpoch + 1); expect(app._fileBrowserState.filter).toBe(''); expect(app._fileBrowserState.matches).toEqual([]); expect(app._fileBrowserState.deferredDirectoryTarget).toBeNull(); expect(app._fileBrowserState.view).toBe('normal'); expect(app.fileBrowserExpandedDirs.size).toBe(0); expect(app.fileBrowserAllExpanded).toBe(false); expect(elements.fileBrowserExpandBtn.disabled).toBe(false); expect(elements.fileBrowserSearch.value).toBe(''); expect(elements.fileBrowserTree.innerHTML).toContain('Loading files'); expect(pending).toHaveLength(2); pending[0].reply.resolve(response(successfulTree('stale.ts'))); await stale; expect(elements.fileBrowserTree.innerHTML).not.toContain('stale.ts'); expect(app._fileBrowserState.treeInFlight.promise).toBe(replacement); pending[1].reply.resolve(response(successfulTree('fresh.ts'))); await replacement; expect(elements.fileBrowserTree.innerHTML).toContain('fresh.ts'); expect(elements.fileBrowserTree.innerHTML).not.toContain('stale.ts'); }); it('ignores stale pre-refresh failures while the replacement remains loading', async () => { const { app, elements, pending } = loadPanel(); const stale = app.loadFileBrowser('session/A'); const replacement = app.refreshFileBrowser(); pending[0].reply.reject(new Error('stale failure')); await stale; expect(elements.fileBrowserTree.innerHTML).toContain('Loading files'); expect(elements.fileBrowserTree.innerHTML).not.toContain('stale failure'); pending[1].reply.resolve(response(successfulTree('replacement.ts'))); await replacement; expect(elements.fileBrowserTree.innerHTML).toContain('replacement.ts'); }); it('isolates overlapping owners and does not reuse an unresolved A request after A to B to A', async () => { const { app, elements, pending } = loadPanel(); const firstA = app.loadFileBrowser('session/A'); app._fileBrowserState.ownerSessionId = 'session/B'; app.activeSessionId = 'session/B'; const loadB = app.loadFileBrowser('session/B'); app._fileBrowserState.ownerSessionId = 'session/A'; app.activeSessionId = 'session/A'; const secondA = app.loadFileBrowser('session/A'); expect(secondA).not.toBe(firstA); expect(pending).toHaveLength(3); pending[0].reply.resolve(response(successfulTree('stale-A.ts'))); await firstA; expect(app.fileBrowserData).toBeNull(); expect(elements.fileBrowserTree.innerHTML).toContain('Loading files'); expect(elements.fileBrowserTree.innerHTML).not.toContain('stale-A.ts'); pending[1].reply.reject(new Error('late B failure')); await loadB; expect(elements.fileBrowserTree.innerHTML).not.toContain('late B failure'); pending[2].reply.resolve(response(successfulTree('current-A.ts'))); await secondA; expect(elements.fileBrowserTree.innerHTML).toContain('current-A.ts'); }); it('shows B loading after clearing search while B is unsettled, never cached A data', async () => { const { app, elements, pending } = loadPanel(); const firstA = app.loadFileBrowser('session/A'); pending[0].reply.resolve(response(successfulTree('cached-A.ts'))); await firstA; expect(elements.fileBrowserTree.innerHTML).toContain('cached-A.ts'); app._fileBrowserState.ownerSessionId = 'session/B'; app.activeSessionId = 'session/B'; const loadB = app.loadFileBrowser('session/B'); app.filterFileBrowser('temporary'); app.filterFileBrowser(''); expect(elements.fileBrowserTree.innerHTML).toContain('Loading files'); expect(elements.fileBrowserTree.innerHTML).not.toContain('cached-A.ts'); pending[1].reply.resolve(response(successfulTree('current-B.ts'))); await loadB; expect(elements.fileBrowserTree.innerHTML).toContain('current-B.ts'); }); it('settles a compatible tree behind search without repainting and keeps tree/search epochs independent', async () => { const { app, elements, pending } = loadPanel(); const treeLoad = app.loadFileBrowser('session/A'); app.filterFileBrowser('query A'); await vi.advanceTimersByTimeAsync(250); app.filterFileBrowser('query B'); await vi.advanceTimersByTimeAsync(250); expect(pending).toHaveLength(3); pending[0].reply.resolve(response(successfulTree('behind-search.ts'))); await treeLoad; expect(app._fileBrowserState.normalState.phase).toBe('ready'); expect(app.fileBrowserData.tree[0].name).toBe('behind-search.ts'); expect(elements.fileBrowserTree.innerHTML).toContain('Searching'); expect(elements.fileBrowserTree.innerHTML).not.toContain('behind-search.ts'); app._fileBrowserState.treeEpoch++; pending[2].reply.resolve( response(successfulData({ matches: [{ name: 'query-B.ts', path: 'query-B.ts', type: 'file' }] })) ); await vi.advanceTimersByTimeAsync(0); expect(elements.fileBrowserTree.innerHTML).toContain('query-B.ts'); pending[1].reply.resolve( response(successfulData({ matches: [{ name: 'query-A.ts', path: 'query-A.ts', type: 'file' }] })) ); await vi.advanceTimersByTimeAsync(0); expect(elements.fileBrowserTree.innerHTML).not.toContain('query-A.ts'); }); it('suppresses invalidated tree responses and hidden-panel repainting', async () => { const scenarios = [ { name: 'owner', mutate(app: Record) { app._fileBrowserState.ownerSessionId = 'session/B'; }, }, { name: 'tree epoch', mutate(app: Record) { app._fileBrowserState.treeEpoch++; }, }, { name: 'hidden preference', mutate(app: Record) { app.fileBrowserShowHidden = true; }, }, ]; for (const scenario of scenarios) { const { app, elements, pending } = loadPanel(); const load = app.loadFileBrowser('session/A'); scenario.mutate(app); pending[0].reply.resolve(response(successfulTree(`late-${scenario.name}.ts`))); await load; expect(app.fileBrowserData, scenario.name).toBeNull(); expect(elements.fileBrowserTree.innerHTML, scenario.name).not.toContain(`late-${scenario.name}.ts`); } const hidden = loadPanel(); const hiddenLoad = hidden.app.loadFileBrowser('session/A'); hidden.elements.fileBrowserPanel.classList.remove('visible'); hidden.pending[0].reply.resolve(response(successfulTree('hidden-ready.ts'))); await hiddenLoad; expect(hidden.app._fileBrowserState.normalState.phase).toBe('ready'); expect(hidden.elements.fileBrowserTree.innerHTML).toContain('Loading files'); expect(hidden.elements.fileBrowserTree.innerHTML).not.toContain('hidden-ready.ts'); }); it('compare-and-clears only the exact stale request and preserves replacement deduplication', async () => { const { app, pending } = loadPanel(); const stale = app.loadFileBrowser('session/A'); const replacement = app.loadFileBrowser('session/A', { force: true }); const replacementRecord = app._fileBrowserState.treeInFlight; pending[0].reply.resolve(response(successfulTree('stale.ts'))); await stale; expect(app._fileBrowserState.treeInFlight).toBe(replacementRecord); const reused = app.loadFileBrowser('session/A'); expect(reused).toBe(replacement); expect(pending).toHaveLength(2); pending[1].reply.resolve(response(successfulTree('replacement.ts'))); await replacement; }); it.each([ ['tree is not an array', successfulTree('x.ts', { tree: {} })], ['totalFiles is not finite', successfulTree('x.ts', { totalFiles: Number.POSITIVE_INFINITY })], ['the envelope is search-discriminated', successfulTree('x.ts', { mode: 'search', matches: [], matchCount: 0 })], [ 'a nested node is malformed', successfulTree('x.ts', { tree: [{ name: 'dir', path: 'dir', type: 'directory', children: [{}] }] }), ], ])('stores an escaped error instead of malformed tree data when %s', async (_case, body) => { const { app, elements, pending } = loadPanel(); const load = app.loadFileBrowser('session/A'); pending[0].reply.resolve(response(body)); await load; expect(app.fileBrowserData).toBeNull(); expect(app._fileBrowserState.normalState.phase).toBe('error'); expect(elements.fileBrowserTree.innerHTML).toContain('Failed to load files'); }); it('does not adopt a different owner or fetch without a usable tree surface', () => { const wrongOwner = loadPanel(); wrongOwner.app._ensureFileBrowserState().ownerSessionId = 'session/B'; expect(wrongOwner.app.loadFileBrowser('session/A')).toBeUndefined(); expect(wrongOwner.pending).toHaveLength(0); expect(wrongOwner.app._fileBrowserState.ownerSessionId).toBe('session/B'); const missingTree = loadPanel(); delete missingTree.elements.fileBrowserTree; expect(missingTree.app.loadFileBrowser('session/A')).toBeUndefined(); expect(missingTree.pending).toHaveLength(0); }); }); describe('session ownership lifecycle', () => { it('activates the real selectSession owner before any terminal await can fail and retains the idle load', () => { const selectStart = appJs.indexOf('async selectSession(sessionId, options = {})'); const activeAssignment = appJs.indexOf('this.activeSessionId = sessionId;', selectStart); const activation = appJs.indexOf('this._activateFileBrowserSession?.(sessionId);', activeAssignment); const firstTerminalAwait = appJs.indexOf('await ', activeAssignment); const retainedIdleLoad = appJs.indexOf('this.loadFileBrowser(sessionId);', firstTerminalAwait); expect(selectStart).toBeGreaterThanOrEqual(0); expect(appJs.slice(activeAssignment, activation + 53)).toMatch( /this\.activeSessionId = sessionId;\s*this\._activateFileBrowserSession\?\.\(sessionId\);/ ); expect(activeAssignment).toBeLessThan(activation); expect(activation).toBeLessThan(firstTerminalAwait); expect(firstTerminalAwait).toBeLessThan(retainedIdleLoad); }); it('synchronously resets A state, owns B, and starts one visible B tree load', async () => { const { app, elements, pending } = loadPanel(); app.fileBrowserData = successfulTree('old-A.ts').data; app.renderFileBrowserTree('session/A'); app.filterFileBrowser('old query'); app.fileBrowserExpandedDirs.add('src'); app.fileBrowserAllExpanded = true; app._fileBrowserState.deferredDirectoryTarget = { ownerSessionId: 'session/A', path: 'src' }; const searchEpoch = app._fileBrowserState.searchEpoch; const treeEpoch = app._fileBrowserState.treeEpoch; app.activeSessionId = 'session/B'; app._activateFileBrowserSession('session/B'); expect(app._fileBrowserState.ownerSessionId).toBe('session/B'); expect(app._fileBrowserState.searchEpoch).toBe(searchEpoch + 1); expect(app._fileBrowserState.treeEpoch).toBe(treeEpoch + 1); expect(app._fileBrowserState.inFlight).toBeNull(); expect(app._fileBrowserState.normalState.phase).toBe('loading'); expect(app._fileBrowserState.matches).toEqual([]); expect(app._fileBrowserState.deferredDirectoryTarget).toBeNull(); expect(app._fileBrowserState.filter).toBe(''); expect(app._fileBrowserState.view).toBe('normal'); expect(app.fileBrowserData).toBeNull(); expect(app.fileBrowserFilter).toBe(''); expect(app.fileBrowserExpandedDirs.size).toBe(0); expect(app.fileBrowserAllExpanded).toBe(false); expect(elements.fileBrowserSearch.value).toBe(''); expect(elements.fileBrowserExpandBtn.disabled).toBe(false); expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229E'); expect(elements.fileBrowserTree.querySelectorAll('.file-tree-item')).toHaveLength(0); expect(elements.fileBrowserTree.innerHTML).toContain('Loading files'); expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']); await vi.advanceTimersByTimeAsync(250); expect(pending).toHaveLength(1); const activatedLoad = app._fileBrowserState.treeInFlight.promise; const retainedIdleLoad = app.loadFileBrowser('session/B'); expect(retainedIdleLoad).toBe(activatedLoad); expect(pending).toHaveLength(1); pending[0].reply.resolve(response(successfulTree('current-B.ts'))); await retainedIdleLoad; expect(elements.fileBrowserTree.innerHTML).toContain('current-B.ts'); }); it('does not load while hidden and tolerates missing session or elements', () => { const hidden = loadPanel(); hidden.elements.fileBrowserPanel.classList.remove('visible'); hidden.app.activeSessionId = 'session/B'; expect(() => hidden.app._activateFileBrowserSession('session/B')).not.toThrow(); expect(hidden.pending).toHaveLength(0); expect(hidden.app._fileBrowserState.ownerSessionId).toBe('session/B'); expect(hidden.app._fileBrowserState.normalState).toBeNull(); expect(hidden.elements.fileBrowserTree.innerHTML).toBe(''); expect(hidden.elements.fileBrowserStatus.textContent).toBe(''); const missing = loadPanel(); delete missing.elements.fileBrowserTree; delete missing.elements.fileBrowserStatus; delete missing.elements.fileBrowserSearch; delete missing.elements.fileBrowserExpandBtn; expect(() => missing.app._activateFileBrowserSession(null)).not.toThrow(); expect(() => missing.app._activateFileBrowserSession('session/B')).not.toThrow(); }); it('targets B when a query is entered before the activated B tree settles', async () => { const { app, pending } = loadPanel(); app.activeSessionId = 'session/B'; app._activateFileBrowserSession('session/B'); app.filterFileBrowser('during load'); await vi.advanceTimersByTimeAsync(250); expect(pending.map(({ url }) => url)).toEqual([ '/api/sessions/session%2FB/files?depth=5&showHidden=false', '/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load', ]); expect(pending.every(({ url }) => !url.includes('session%2FA'))).toBe(true); }); it('starts fresh A work on A to B to A and rejects the original A completion', async () => { const { app, elements, pending } = loadPanel(); const firstA = app.loadFileBrowser('session/A'); app.activeSessionId = 'session/B'; app._activateFileBrowserSession('session/B'); const loadB = app._fileBrowserState.treeInFlight.promise; app.activeSessionId = 'session/A'; app._activateFileBrowserSession('session/A'); const secondA = app._fileBrowserState.treeInFlight.promise; expect(secondA).not.toBe(firstA); expect(pending.map(({ url }) => url)).toEqual([ '/api/sessions/session%2FA/files?depth=5&showHidden=false', '/api/sessions/session%2FB/files?depth=5&showHidden=false', '/api/sessions/session%2FA/files?depth=5&showHidden=false', ]); pending[0].reply.resolve(response(successfulTree('stale-A.ts'))); await firstA; expect(app._fileBrowserState.treeInFlight.promise).toBe(secondA); expect(elements.fileBrowserTree.innerHTML).not.toContain('stale-A.ts'); pending[1].reply.reject(new Error('late B failure')); await loadB; expect(app._fileBrowserState.treeInFlight.promise).toBe(secondA); pending[2].reply.resolve(response(successfulTree('fresh-A.ts'))); await secondA; expect(elements.fileBrowserTree.innerHTML).toContain('fresh-A.ts'); }); }); describe('hide lifecycle', () => { it('the real settings hide branch resets once and does not churn epochs while already hidden', () => { const { app, elements } = loadPanel(); const state = app._ensureFileBrowserState(); const searchEpoch = state.searchEpoch; const treeEpoch = state.treeEpoch; app.applyMonitorVisibility(); expect(elements.fileBrowserPanel.classList.contains('visible')).toBe(false); expect(state.searchEpoch).toBe(searchEpoch + 1); expect(state.treeEpoch).toBe(treeEpoch + 1); app.applyMonitorVisibility(); expect(state.searchEpoch).toBe(searchEpoch + 1); expect(state.treeEpoch).toBe(treeEpoch + 1); }); it('cancels an unlaunched debounce and clears all hide-reset controls and navigation state', async () => { const { app, elements, pending } = loadPanel(); app.filterFileBrowser('not launched'); app.fileBrowserExpandedDirs.add('src'); app.fileBrowserAllExpanded = true; app._fileBrowserState.matches = [{ name: 'old.ts' }]; app._fileBrowserState.deferredDirectoryTarget = { ownerSessionId: 'session/A', path: 'src' }; elements.fileBrowserSearch.value = 'not launched'; elements.fileBrowserExpandBtn.disabled = true; elements.fileBrowserExpandBtn.innerHTML = '\u229F'; app.closeFileBrowserPanel(); await vi.advanceTimersByTimeAsync(250); expect(pending).toHaveLength(0); expect(app._fileBrowserState.view).toBe('normal'); expect(app._fileBrowserState.filter).toBe(''); expect(app.fileBrowserFilter).toBe(''); expect(app.fileBrowserExpandedDirs.size).toBe(0); expect(app.fileBrowserAllExpanded).toBe(false); expect(app._fileBrowserState.matches).toEqual([]); expect(app._fileBrowserState.deferredDirectoryTarget).toBeNull(); expect(elements.fileBrowserSearch.value).toBe(''); expect(elements.fileBrowserExpandBtn.disabled).toBe(false); expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229E'); }); it.each([ ['tree success', 'tree', false], ['tree failure', 'tree', true], ['search success', 'search', false], ['search failure', 'search', true], ])('explicit close blocks pending %s from updating state or UI', async (_name, kind, fail) => { const { app, elements, pending } = loadPanel(); const operation = kind === 'tree' ? app.loadFileBrowser('session/A') : (await startSearch(app), undefined); app.closeFileBrowserPanel(); expect(app._fileBrowserState.treeInFlight).toBeNull(); expect(app._fileBrowserState.inFlight).toBeNull(); expect(app._fileBrowserState.normalState).toBeNull(); expect(app._fileBrowserState.matches).toEqual([]); expect(elements.fileBrowserTree.innerHTML).toBe(''); expect(elements.fileBrowserStatus.textContent).toBe(''); if (fail) pending[0].reply.reject(new Error(`late ${kind} failure`)); else pending[0].reply.resolve( response( kind === 'tree' ? successfulTree('late.ts') : successfulData({ matches: [{ name: 'late.ts', path: 'late.ts', type: 'file' }], matchCount: 1, }) ) ); if (operation) await operation; await vi.advanceTimersByTimeAsync(0); expect(app.fileBrowserData).toBeNull(); expect(app._fileBrowserState.normalState).toBeNull(); expect(app._fileBrowserState.matches).toEqual([]); expect(elements.fileBrowserTree.innerHTML).toBe(''); expect(elements.fileBrowserStatus.textContent).toBe(''); }); it.each([ ['tree success', 'tree', false], ['tree failure', 'tree', true], ['search success', 'search', false], ['search failure', 'search', true], ])('settings-driven hide blocks pending %s from updating state or UI', async (_name, kind, fail) => { const { app, elements, pending } = loadPanel(); const operation = kind === 'tree' ? app.loadFileBrowser('session/A') : (await startSearch(app), undefined); app.applyMonitorVisibility(); expect(elements.fileBrowserPanel.classList.contains('visible')).toBe(false); if (fail) pending[0].reply.reject(new Error(`late ${kind} failure`)); else pending[0].reply.resolve( response( kind === 'tree' ? successfulTree('late.ts') : successfulData({ matches: [{ name: 'late.ts', path: 'late.ts', type: 'file' }], matchCount: 1, }) ) ); if (operation) await operation; await vi.advanceTimersByTimeAsync(0); expect(app.fileBrowserData).toBeNull(); expect(app._fileBrowserState.normalState).toBeNull(); expect(app._fileBrowserState.matches).toEqual([]); expect(elements.fileBrowserTree.innerHTML).toBe(''); expect(elements.fileBrowserStatus.textContent).toBe(''); }); it.each([ ['stale success', false], ['stale failure', true], ])('reopen after %s starts a replacement and stale settlement cannot clear it', async (_name, fail) => { const { app, elements, pending } = loadPanel(); const stale = app.loadFileBrowser('session/A'); app.closeFileBrowserPanel(); elements.fileBrowserPanel.classList.add('visible'); const replacement = app.loadFileBrowser('session/A'); const replacementRecord = app._fileBrowserState.treeInFlight; expect(replacement).not.toBe(stale); expect(pending).toHaveLength(2); if (fail) pending[0].reply.reject(new Error('stale failure')); else pending[0].reply.resolve(response(successfulTree('stale.ts'))); await stale; expect(app._fileBrowserState.treeInFlight).toBe(replacementRecord); expect(elements.fileBrowserTree.innerHTML).toContain('Loading files'); expect(elements.fileBrowserTree.innerHTML).not.toContain('stale.ts'); const reused = app.loadFileBrowser('session/A'); expect(reused).toBe(replacement); expect(pending).toHaveLength(2); pending[1].reply.resolve(response(successfulTree('replacement.ts'))); await replacement; expect(elements.fileBrowserTree.innerHTML).toContain('replacement.ts'); }); }); });