diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 52a4c8ea..4ad14e01 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -2980,6 +2980,81 @@ Object.assign(CodemanApp.prototype, { btn.setAttribute('aria-label', label); }, + _ensureFileBrowserState() { + if (!this._fileBrowserState) { + const ownerSessionId = this.activeSessionId || null; + const showHidden = this.fileBrowserShowHidden === true; + this._fileBrowserState = { + treeEpoch: 0, + searchEpoch: 0, + ownerSessionId, + view: 'normal', + normalState: this.fileBrowserData + ? { ownerSessionId, showHidden, data: this.fileBrowserData } + : null, + inFlight: null, + matches: [], + deferredDirectoryTarget: null, + filter: typeof this.fileBrowserFilter === 'string' ? this.fileBrowserFilter : '', + }; + } + return this._fileBrowserState; + }, + + _setFileBrowserExpandDisabled(disabled) { + const btn = this.$('fileBrowserExpandBtn'); + if (btn) btn.disabled = disabled; + }, + + _renderFileBrowserNormalStatus(data, showHidden) { + const statusEl = this.$('fileBrowserStatus'); + if (!statusEl || !data) return; + const { totalFiles, totalDirectories, truncated } = data; + statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}${showHidden ? ' ยท hidden shown' : ''}`; + }, + + _validateFileBrowserSearchEnvelope(result) { + if (!result || typeof result !== 'object' || result.success !== true) return null; + const data = result.data; + if (!data || typeof data !== 'object' || data.mode !== 'search' || !Array.isArray(data.matches)) return null; + if (typeof data.truncated !== 'boolean') return null; + if ( + data.matchCount !== undefined && + (typeof data.matchCount !== 'number' || !Number.isFinite(data.matchCount) || data.matchCount < 0) + ) { + return null; + } + for (const match of data.matches) { + if (!match || typeof match !== 'object') return null; + if (typeof match.name !== 'string' || typeof match.path !== 'string') return null; + if (match.type !== 'file' && match.type !== 'directory') return null; + if (match.size !== undefined && (typeof match.size !== 'number' || !Number.isFinite(match.size))) return null; + if (match.extension !== undefined && typeof match.extension !== 'string') return null; + } + return data; + }, + + _canRenderFileBrowserSearch(request) { + const state = this._ensureFileBrowserState(); + const panel = this.$('fileBrowserPanel'); + return ( + state.searchEpoch === request.epoch && + state.ownerSessionId === request.ownerSessionId && + this.activeSessionId === request.ownerSessionId && + (this.fileBrowserShowHidden === true) === request.showHidden && + state.filter === request.rawInput && + panel?.classList.contains('visible') === true + ); + }, + + _renderFileBrowserSearchError() { + const treeEl = this.$('fileBrowserTree'); + const statusEl = this.$('fileBrowserStatus'); + const message = 'Search failed'; + if (treeEl) treeEl.innerHTML = `
${escapeHtml(message)}
`; + if (statusEl) statusEl.textContent = message; + }, + async toggleFileBrowserHidden() { this.fileBrowserShowHidden = !this.fileBrowserShowHidden; try { @@ -2994,6 +3069,9 @@ Object.assign(CodemanApp.prototype, { async loadFileBrowser(sessionId) { if (!sessionId) return; + const state = this._ensureFileBrowserState(); + state.treeEpoch++; + state.ownerSessionId = sessionId; const treeEl = this.$('fileBrowserTree'); const statusEl = this.$('fileBrowserStatus'); this._syncFileBrowserHiddenBtn(); @@ -3011,23 +3089,30 @@ Object.assign(CodemanApp.prototype, { if (!result.success) throw new Error(result.error || 'Failed to load files'); this.fileBrowserData = result.data; - this.renderFileBrowserTree(); + state.normalState = { ownerSessionId: sessionId, showHidden, data: result.data }; + if (state.filter.trim() === '') { + state.view = 'normal'; + state.matches = []; + this._setFileBrowserExpandDisabled(false); + this.renderFileBrowserTree(sessionId); + } // Update status - if (statusEl) { - const { totalFiles, totalDirectories, truncated } = result.data; - statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}${showHidden ? ' ยท hidden shown' : ''}`; - } + if (statusEl && state.view === 'normal') this._renderFileBrowserNormalStatus(result.data, showHidden); } catch (err) { console.error('Failed to load file browser:', err); treeEl.innerHTML = `
Failed to load files: ${escapeHtml(err.message)}
`; } }, - renderFileBrowserTree() { + renderFileBrowserTree(ownerSessionId) { const treeEl = this.$('fileBrowserTree'); if (!treeEl || !this.fileBrowserData) return; + const state = this._ensureFileBrowserState(); + const owner = ownerSessionId || state.normalState?.ownerSessionId || state.ownerSessionId || this.activeSessionId; + if (!owner) return; + const { tree } = this.fileBrowserData; if (!tree || tree.length === 0) { treeEl.innerHTML = '
No files found
'; @@ -3035,21 +3120,10 @@ Object.assign(CodemanApp.prototype, { } const html = []; - const filter = this.fileBrowserFilter.toLowerCase(); const renderNode = (node, depth) => { const isDir = node.type === 'directory'; const isExpanded = this.fileBrowserExpandedDirs.has(node.path); - const matchesFilter = !filter || node.name.toLowerCase().includes(filter); - - // For directories, check if any children match - let hasMatchingChildren = false; - if (isDir && filter && node.children) { - hasMatchingChildren = this.hasMatchingChild(node, filter); - } - - const shouldShow = matchesFilter || hasMatchingChildren; - const hiddenClass = !shouldShow && filter ? ' hidden-by-filter' : ''; const icon = isDir ? (isExpanded ? '\uD83D\uDCC2' : '\uD83D\uDCC1') @@ -3066,11 +3140,11 @@ Object.assign(CodemanApp.prototype, { const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name'; const downloadBtn = !isDir - ? `⬇` + ? `⬇` : ''; html.push(` -
+
${expandIcon} ${icon} ${escapeHtml(node.name)} @@ -3102,21 +3176,12 @@ Object.assign(CodemanApp.prototype, { if (type === 'directory') { this.toggleFileBrowserFolder(path); } else { - this.openFilePreview(path); + this.openFilePreview(path, owner); } }); }); }, - hasMatchingChild(node, filter) { - if (!node.children) return false; - for (const child of node.children) { - if (child.name.toLowerCase().includes(filter)) return true; - if (child.type === 'directory' && this.hasMatchingChild(child, filter)) return true; - } - return false; - }, - toggleFileBrowserFolder(path) { if (this.fileBrowserExpandedDirs.has(path)) { this.fileBrowserExpandedDirs.delete(path); @@ -3127,12 +3192,146 @@ Object.assign(CodemanApp.prototype, { }, filterFileBrowser(value) { - this.fileBrowserFilter = value; - // Auto-expand all if filtering - if (value) { - this.expandAllDirectories(this.fileBrowserData?.tree || []); + const state = this._ensureFileBrowserState(); + const rawInput = String(value ?? ''); + const query = rawInput.trim(); + state.searchEpoch++; + state.filter = rawInput; + state.deferredDirectoryTarget = null; + this.fileBrowserFilter = rawInput; + + if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) { + clearTimeout(state.inFlight.timer); + } + state.inFlight = null; + + if (!state.ownerSessionId && this.activeSessionId) state.ownerSessionId = this.activeSessionId; + const ownerSessionId = state.ownerSessionId || null; + if (!query) { + state.view = 'normal'; + state.matches = []; + this._setFileBrowserExpandDisabled(false); + const normal = state.normalState; + if ( + ownerSessionId && + normal?.ownerSessionId === ownerSessionId && + normal.showHidden === (this.fileBrowserShowHidden === true) + ) { + this.fileBrowserData = normal.data; + this.renderFileBrowserTree(ownerSessionId); + this._renderFileBrowserNormalStatus(normal.data, normal.showHidden); + } + return; + } + + if (query.length > 256) { + const message = 'Search queries are limited to 256 characters'; + state.view = 'query-error'; + state.matches = []; + this._setFileBrowserExpandDisabled(true); + const treeEl = this.$('fileBrowserTree'); + const statusEl = this.$('fileBrowserStatus'); + if (treeEl) treeEl.innerHTML = `
${escapeHtml(message)}
`; + if (statusEl) statusEl.textContent = message; + return; + } + + const panel = this.$('fileBrowserPanel'); + const treeEl = this.$('fileBrowserTree'); + if (!ownerSessionId || !panel || !treeEl) return; + + const request = { + epoch: state.searchEpoch, + ownerSessionId, + showHidden: this.fileBrowserShowHidden === true, + rawInput, + query, + timer: null, + }; + state.view = 'search-pending'; + state.matches = []; + state.inFlight = request; + this._setFileBrowserExpandDisabled(true); + treeEl.innerHTML = `
${escapeHtml('Searching...')}
`; + const statusEl = this.$('fileBrowserStatus'); + if (statusEl) statusEl.textContent = 'Searching...'; + + request.timer = setTimeout(async () => { + request.timer = null; + try { + const res = await fetch( + `/api/sessions/${encodeURIComponent(ownerSessionId)}/files?depth=5&showHidden=${request.showHidden}&q=${encodeURIComponent(query)}`, + ); + if (!res.ok) throw new Error('Search failed'); + const result = await res.json(); + const data = this._validateFileBrowserSearchEnvelope(result); + if (!data) throw new Error('Search failed'); + const canRender = this._canRenderFileBrowserSearch(request); + if (state.inFlight === request) state.inFlight = null; + if (!canRender) return; + state.view = 'search-results'; + state.matches = data.matches; + this._renderFileBrowserSearchResults(data.matches, ownerSessionId, data); + } catch (err) { + const canRender = this._canRenderFileBrowserSearch(request); + if (state.inFlight === request) state.inFlight = null; + if (!canRender) return; + console.error('Failed to search file browser:', err); + state.view = 'search-error'; + state.matches = []; + this._renderFileBrowserSearchError(); + } + }, 250); + }, + + _renderFileBrowserSearchResults(matches, ownerSessionId, data) { + const treeEl = this.$('fileBrowserTree'); + if (!treeEl || !ownerSessionId) return; + if (matches.length === 0) { + treeEl.innerHTML = `
${escapeHtml('No matches')}
`; + } else { + const ownerPath = encodeURIComponent(ownerSessionId); + treeEl.innerHTML = matches + .map(match => { + const isDir = match.type === 'directory'; + const icon = isDir ? '๐Ÿ“' : this.getFileIcon(match.extension || ''); + const sizeStr = !isDir && match.size !== undefined + ? `${this.formatFileSize(match.size)}` + : ''; + const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name'; + const downloadBtn = !isDir + ? `⬇` + : ''; + return ` +
+ + ${icon} + ${escapeHtml(match.name)} + ${escapeHtml(match.path)} + ${sizeStr} + ${downloadBtn} +
+ `; + }) + .join(''); + } + + treeEl.querySelectorAll('.file-tree-item').forEach(item => { + item.addEventListener('click', () => { + const path = item.dataset.path; + if (item.dataset.type === 'directory') { + this._ensureFileBrowserState().deferredDirectoryTarget = { ownerSessionId, path }; + } else { + this.openFilePreview(path, ownerSessionId); + } + }); + }); + + const statusEl = this.$('fileBrowserStatus'); + if (statusEl) { + const count = data.matchCount === undefined ? matches.length : data.matchCount; + statusEl.textContent = `${count} ${count === 1 ? 'match' : 'matches'}${data.truncated ? ' (truncated)' : ''}`; } - this.renderFileBrowserTree(); }, expandAllDirectories(nodes) { diff --git a/test/file-browser-search.test.ts b/test/file-browser-search.test.ts new file mode 100644 index 00000000..35d70148 --- /dev/null +++ b/test/file-browser-search.test.ts @@ -0,0 +1,427 @@ +/** + * @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 panelsJs = readFileSync(resolve(PUBLIC, 'panels-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; + attrs: Record; + classList: FakeClassList; + setAttribute: (name: string, value: string) => void; + 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, + attrs, + classList, + setAttribute(name, value) { + attrs[name] = value; + }, + 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: () => null, addEventListener: 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(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(); + + 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, + }, + }; +} + +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('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('src/widget.ts'); + 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('does not render a completion after the panel is hidden and releases its in-flight pointer', async () => { + const { app, elements, pending } = loadPanel(); + await startSearch(app); + elements.fileBrowserPanel.classList.remove('visible'); + + pending[0].reply.resolve( + response(successfulData({ matches: [{ name: 'late.ts', path: 'late.ts', type: 'file' }], matchCount: 1 })) + ); + await vi.advanceTimersByTimeAsync(0); + + expect(elements.fileBrowserTree.innerHTML).toContain('Searching'); + expect(elements.fileBrowserTree.innerHTML).not.toContain('late.ts'); + expect(app._fileBrowserState.inFlight).toBeNull(); + }); + + 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('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('