/** * @fileoverview Spreadsheet renderer lifecycle and virtualization, plus its * wiring into the file-preview overlay (panels-ui.js). * * The renderer runs against a standalone JSDOM window (like i18n-branding.test.ts) * rather than the `jsdom` vitest environment. Workbook strings are asserted to * land as text, never markup. * * What the user reads is the renderer's, not the worker's: each worker error * code is one sentence (the raw message goes to the console), feature ids are * words, and every one of those, the HTTP status line and the notice bar's * items read in Chinese through i18n.js's real t(); a number format's code is * workbook text and passes through as is. */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import vm from 'node:vm'; import { JSDOM } from 'jsdom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/spreadsheet-preview.js'), 'utf8'); const coreSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/spreadsheet-xlsx-core.js'), 'utf8'); const workerSource = readFileSync( resolve(import.meta.dirname, '../src/web/public/spreadsheet-preview-worker.js'), 'utf8' ); const i18nSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/i18n.js'), 'utf8'); const panelsSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8'); // A real origin: the renderer resolves its fetch URL against `location.href`. const dom = new JSDOM('', { url: 'http://localhost/' }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const window = dom.window as any; const document = window.document as Document; class WorkerMock { static instances: WorkerMock[] = []; onmessage: ((event: { data: any }) => void) | null = null; onerror: (() => void) | null = null; onmessageerror: (() => void) | null = null; postMessage = vi.fn(); terminate = vi.fn(); constructor(readonly url: string) { WorkerMock.instances.push(this); } emit(data: unknown) { this.onmessage?.({ data }); } } function loadRenderer(fetchMock: ReturnType) { const context = vm.createContext({ window, document, URL, AbortController, Worker: WorkerMock, ResizeObserver: undefined, fetch: fetchMock, setTimeout, clearTimeout, console, }); vm.runInContext(source, context); return (window as any).CodemanSpreadsheetPreview as { open(options: Record): { dispose(): void } }; } function metadata() { return { type: 'metadata', sheets: [ { id: '1', name: '', rows: 1_000_000, cols: 100, defaultRowHeight: 20, defaultColumnWidth: 64, rowOverrides: [], columnOverrides: [], }, { id: '2', name: 'Details', rows: 1, cols: 1, defaultRowHeight: 20, defaultColumnWidth: 64 }, ], }; } describe('spreadsheet preview renderer', () => { beforeEach(() => { WorkerMock.instances = []; document.body.innerHTML = '
'; delete (window as any).CodemanSpreadsheetPreview; window.requestAnimationFrame = (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0) as unknown as number; window.cancelAnimationFrame = (id: number) => clearTimeout(id); }); it('refuses oversized metadata before fetch or worker creation', () => { const fetchMock = vi.fn(); const renderer = loadRenderer(fetchMock); renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 10 * 1024 * 1024 + 1 }); expect(fetchMock).not.toHaveBeenCalled(); expect(WorkerMock.instances).toHaveLength(0); expect(document.body.textContent).toContain('10 MB limit'); }); it('fetches same-origin preview bytes, transfers them, and bounds rendered cells', async () => { const bytes = new ArrayBuffer(8); const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => bytes })); const renderer = loadRenderer(fetchMock); renderer.open({ container: document.querySelector('#preview'), url: '/api/book.xlsx', size: 8 }); const worker = WorkerMock.instances[0]; worker.emit({ type: 'ready' }); await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalledWith({ type: 'load', bytes }, [bytes])); expect(fetchMock).toHaveBeenCalledWith( '/api/book.xlsx?preview=true', expect.objectContaining({ signal: expect.anything() }) ); worker.emit(metadata()); const tileRequest = worker.postMessage.mock.calls.at(-1)?.[0]; expect(tileRequest.type).toBe('tile'); expect(tileRequest.range.r2 - tileRequest.range.r1).toBeLessThan(100); worker.emit({ type: 'tile', requestId: tileRequest.requestId, sheetId: '1', cells: Array.from({ length: 3000 }, (_, index) => ({ row: index + 1, col: 1, text: `${index}`, styleId: 0, })), }); expect(document.querySelectorAll('.spreadsheet-cell')).toHaveLength(2500); expect(document.querySelector('.spreadsheet-cell')?.textContent).toBe('0'); expect((document.querySelector('.spreadsheet-cell') as HTMLElement).style.top).toBe('20px'); expect((document.querySelector('.spreadsheet-cell') as HTMLElement).style.left).toBe('36px'); const grid = document.querySelector('.spreadsheet-grid') as HTMLElement; grid.scrollLeft = 400; grid.scrollTop = 300; grid.dispatchEvent(new window.Event('scroll')); expect((document.querySelector('.spreadsheet-row-heading') as HTMLElement).style.left).toBe('400px'); expect((document.querySelector('.spreadsheet-column-heading') as HTMLElement).style.top).toBe('300px'); expect(document.querySelector('.spreadsheet-sheet-tabs')?.hasAttribute('data-i18n-skip')).toBe(true); expect(document.querySelector('.spreadsheet-grid')?.hasAttribute('data-i18n-skip')).toBe(true); expect(Number.parseFloat((document.querySelector('.spreadsheet-grid-spacer') as HTMLElement).style.height)).toBe( 8_000_020 ); (document.querySelectorAll('[role="tab"]')[1] as HTMLButtonElement).click(); expect((document.querySelector('.spreadsheet-grid-spacer') as HTMLElement).style.height).toBe('40px'); }); it('drops stale tiles and aborts fetch plus terminates worker on dispose', async () => { const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) })); const renderer = loadRenderer(fetchMock); const handle = renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 }); const worker = WorkerMock.instances[0]; worker.emit({ type: 'ready' }); await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled()); worker.emit(metadata()); const request = worker.postMessage.mock.calls.at(-1)?.[0]; worker.emit({ type: 'tile', requestId: request.requestId - 1, sheetId: '1', cells: [{ row: 1, col: 1, text: 'stale' }], }); expect(document.body.textContent).not.toContain('stale'); const signal = fetchMock.mock.calls[0][1].signal as AbortSignal; handle.dispose(); expect(signal.aborted).toBe(true); expect(worker.terminate).toHaveBeenCalledTimes(1); }); it('shows feature warnings and reports worker failures after readiness', async () => { const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) })); const renderer = loadRenderer(fetchMock); renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 }); const worker = WorkerMock.instances[0]; worker.emit({ type: 'ready' }); await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled()); worker.emit({ ...metadata(), warnings: ['charts'] }); const request = worker.postMessage.mock.calls.at(-1)?.[0]; worker.emit({ type: 'tile', requestId: request.requestId, sheetId: '1', cells: [], warnings: [] }); expect(document.body.textContent).toContain('charts'); worker.onerror?.(); expect(document.body.textContent).toContain('Spreadsheet parser failed'); }); // The bar sits above the grid in a flex column; unbounded, enough warnings // pushed the grid out of view. it('clamps the notice bar height and scrolls its overflow', () => { const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8'); const rule = /\.spreadsheet-preview-notice\s*\{([^}]*)\}/.exec(css)?.[1] || ''; expect(rule).toMatch(/max-height:\s*\d/); expect(rule).toMatch(/overflow-y:\s*auto/); }); it('shows an explicit empty-sheet state without dropping workbook warnings', async () => { const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) })); const renderer = loadRenderer(fetchMock); renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 }); const worker = WorkerMock.instances[0]; worker.emit({ type: 'ready' }); await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled()); worker.emit({ type: 'metadata', warnings: ['charts'], styles: [], sheets: [ { id: '0', name: 'Data', rows: 1, cols: 1, defaultRowHeight: 20, defaultColumnWidth: 64, rowOverrides: [], columnOverrides: [], }, { id: '1', name: 'Empty', rows: 0, cols: 0, defaultRowHeight: 20, defaultColumnWidth: 64, rowOverrides: [], columnOverrides: [], }, ], }); const request = worker.postMessage.mock.calls.at(-1)?.[0]; worker.emit({ type: 'tile', requestId: request.requestId, sheetId: '0', cells: [{ row: 1, col: 1, text: 'old cell', styleId: 0 }], warnings: [], }); expect(document.body.textContent).toContain('old cell'); (document.querySelectorAll('[role="tab"]')[1] as HTMLButtonElement).click(); expect(document.body.textContent).toContain('This worksheet is empty.'); expect(document.body.textContent).toContain('charts'); expect(document.body.textContent).not.toContain('old cell'); }); it('sizes row and column headings from the same axis math as the cells', async () => { const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) })); const renderer = loadRenderer(fetchMock); renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 }); const worker = WorkerMock.instances[0]; worker.emit({ type: 'ready' }); await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled()); // Column B 18 wide and row 2 30pt tall, as the worker reports the fixture in // spreadsheet-preview-worker.test.ts; row 3 and column C hidden. worker.emit({ type: 'metadata', styles: [], sheets: [ { id: '1', name: 'Summary', rows: 4, cols: 4, defaultRowHeight: 20, defaultColumnWidth: 64, rowOverrides: [ [2, 40], [3, 0], ], columnOverrides: [ [2, 126], [3, 0], ], }, ], }); const request = worker.postMessage.mock.calls.at(-1)?.[0]; worker.emit({ type: 'tile', requestId: request.requestId, sheetId: '1', cells: [{ row: 2, col: 2, text: 'B2', styleId: 0 }], warnings: [], }); const cell = document.querySelector('.spreadsheet-cell') as HTMLElement; const heading = (selector: string, text: string) => [...document.querySelectorAll(selector)].find((element) => element.textContent === text) as | HTMLElement | undefined; const columnB = heading('.spreadsheet-column-heading', 'B'); const row2 = heading('.spreadsheet-row-heading', '2'); expect(columnB?.style.width).toBe(cell.style.width); expect(columnB?.style.left).toBe(cell.style.left); expect(row2?.style.height).toBe(cell.style.height); expect(row2?.style.top).toBe(cell.style.top); expect(heading('.spreadsheet-column-heading', 'A')?.style.width).toBe('64px'); expect(heading('.spreadsheet-row-heading', '1')?.style.height).toBe('20px'); // A hidden row or column has no size, so it gets no heading at all. expect(heading('.spreadsheet-column-heading', 'C')).toBeUndefined(); expect(heading('.spreadsheet-row-heading', '3')).toBeUndefined(); }); // Past MAX_SCROLL_PX only the scroll position may be scaled: dividing every // cell and heading by the scale drew a 1,048,576-row sheet 7.6 px a row. it('lays rows out at their real height on a sheet taller than the scroll cap', async () => { const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) })); const renderer = loadRenderer(fetchMock); renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 }); const worker = WorkerMock.instances[0]; worker.emit({ type: 'ready' }); await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled()); const sheet = { id: '1', name: 'Tall', rows: 1_048_576, cols: 1, defaultRowHeight: 20, defaultColumnWidth: 64 }; worker.emit({ type: 'metadata', styles: [], sheets: [{ ...sheet, rowOverrides: [], columnOverrides: [] }] }); const cellAt = (row: number) => document.querySelector(`.spreadsheet-cell[data-row="${row}"]`) as HTMLElement; const rowHeading = (row: number) => [...document.querySelectorAll('.spreadsheet-row-heading')].find( (element) => element.textContent === String(row) ) as HTMLElement; const px = (value: string) => Number.parseFloat(value); const first = worker.postMessage.mock.calls.at(-1)?.[0]; expect(first.range.r1).toBe(1); const cell = (row: number) => ({ row, col: 1, text: `A${row}`, styleId: 0 }); worker.emit({ type: 'tile', requestId: first.requestId, sheetId: '1', cells: [cell(1), cell(2)], warnings: [] }); expect(cellAt(1).style.top).toBe('20px'); expect(cellAt(1).style.height).toBe('20px'); expect(px(cellAt(2).style.top) - px(cellAt(1).style.top)).toBe(20); expect(rowHeading(2).style.height).toBe('20px'); // At scale 1 the first tile asks for about a viewport of rows, not a scaled one. expect(first.range.r2).toBeLessThan(40); // Scrolled to the end, the last row is requested, drawn at its real height, // and ends exactly at the bottom of the scroll area. const grid = document.querySelector('.spreadsheet-grid') as HTMLElement; const spacerHeight = px((document.querySelector('.spreadsheet-grid-spacer') as HTMLElement).style.height); grid.scrollTop = spacerHeight - 500; grid.dispatchEvent(new window.Event('scroll')); await vi.waitFor(() => expect(worker.postMessage.mock.calls.at(-1)?.[0].requestId).toBeGreaterThan(first.requestId) ); const last = worker.postMessage.mock.calls.at(-1)?.[0]; expect(last.range.r2).toBe(1_048_576); expect(last.range.r2 - last.range.r1).toBeLessThan(40); worker.emit({ type: 'tile', requestId: last.requestId, sheetId: '1', cells: [cell(1_048_576)], warnings: [] }); expect(cellAt(1_048_576).style.height).toBe('20px'); expect(rowHeading(1_048_576).style.height).toBe('20px'); expect(px(cellAt(1_048_576).style.top) + 20).toBeCloseTo(spacerHeight, 6); expect(px(rowHeading(1_048_575).style.top)).toBeCloseTo(px(cellAt(1_048_576).style.top) - 20, 6); }); it('keeps a merge spanning a too-tall sheet inside the scroll area', async () => { const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) })); const renderer = loadRenderer(fetchMock); renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 }); const worker = WorkerMock.instances[0]; worker.emit({ type: 'ready' }); await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled()); const sheet = { id: '1', name: 'Tall', rows: 1_048_576, cols: 1, defaultRowHeight: 20, defaultColumnWidth: 64 }; worker.emit({ type: 'metadata', styles: [], sheets: [{ ...sheet, rowOverrides: [], columnOverrides: [] }] }); const request = worker.postMessage.mock.calls.at(-1)?.[0]; worker.emit({ type: 'tile', requestId: request.requestId, sheetId: '1', cells: [{ row: 1, col: 1, text: 'whole column', styleId: 0 }], merges: ['A1:A1048576'], warnings: [], }); const merged = document.querySelector('.spreadsheet-cell') as HTMLElement; const spacerHeight = Number.parseFloat( (document.querySelector('.spreadsheet-grid-spacer') as HTMLElement).style.height ); expect(Number.parseFloat(merged.style.top) + Number.parseFloat(merged.style.height)).toBeLessThanOrEqual( spacerHeight ); }); it('emits colour and background together or not at all', async () => { const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) })); const renderer = loadRenderer(fetchMock); renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 }); const worker = WorkerMock.instances[0]; worker.emit({ type: 'ready' }); await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled()); worker.emit({ ...metadata(), styles: [ { font: { color: '#000000' }, fill: '#ffffff' }, { font: { bold: true, color: '#000000' }, fill: undefined }, { font: {}, fill: '#ffff00' }, { font: { italic: true }, alignment: 'center' }, ], }); const css = (document.querySelector('.spreadsheet-preview-shell style') as HTMLElement).textContent || ''; expect(css).toContain('.spreadsheet-style-0{color:#000000;background-color:#ffffff}'); // A half pair would strand the text on the skin's own background, so drop both. expect(css).toContain('.spreadsheet-style-1{font-weight:700}'); expect(css).toContain('.spreadsheet-style-2{}'); expect(css).toContain('.spreadsheet-style-3{font-style:italic;text-align:center}'); expect(css).not.toContain('background-color:#ffff00'); }); }); describe('spreadsheet preview text the user reads', () => { type Translate = (s: string) => string; const translator = (language: string): Translate => { const i18nDom = new JSDOM('', { runScripts: 'outside-only', url: 'http://localhost/', }); vm.runInContext(i18nSource, i18nDom.getInternalVMContext(), { filename: 'i18n.js' }); const api = (i18nDom.window as any).CodemanI18n as { t: Translate; configure(o: object): void }; api.configure({ language }); return api.t; }; const zh = translator('zh-CN'); const en = translator('en'); /** What may stay Latin in a translation: file formats, the URL acronym, digits. */ const leftover = (text: string) => text.replace(/\b(xlsx|xls|ZIP64|URL)\b/g, '').match(/[A-Za-z]+/g) ?? []; beforeEach(() => { WorkerMock.instances = []; document.body.innerHTML = '
'; delete (window as any).codemanT; window.requestAnimationFrame = (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0) as unknown as number; window.cancelAnimationFrame = (id: number) => clearTimeout(id); }); async function openLoaded() { document.body.innerHTML = '
'; const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) })); loadRenderer(fetchMock).open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 }); const worker = WorkerMock.instances.at(-1)!; worker.emit({ type: 'ready' }); await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled()); return worker; } const shown = () => document.querySelector('.spreadsheet-preview-message')?.textContent ?? ''; it('shows one sentence per worker error code, never the raw message, and each reads in Chinese', async () => { const codes = [ ...new Set( [...`${coreSource}\n${workerSource}`.matchAll(/(?:\bfail|XlsxPreviewError)\('([a-z0-9-]+)'/g)].map((m) => m[1]) ), ]; // Not vacuous: the refusals the core and the worker raise. expect(codes).toEqual(expect.arrayContaining(['encrypted', 'zip64', 'malformed', 'cell-limit', 'style-limit'])); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { const texts = new Map(); for (const code of [...codes, 'parse-failed']) { const worker = await openLoaded(); worker.emit({ type: 'error', code, message: `raw ${code} detail` }); texts.set(code, shown()); } expect([...texts.values()].filter((text) => /raw .* detail/.test(text))).toEqual([]); // Every code the core and worker raise has its own sentence; only an // unlisted one (ExcelJS's own failures) shows the generic failure. expect(codes.filter((code) => texts.get(code) === 'Spreadsheet preview failed')).toEqual([]); expect(texts.get('parse-failed')).toBe('Spreadsheet preview failed'); expect(texts.get('encrypted')).toBe( 'This workbook is password-protected or in the old .xls format, so it cannot be previewed.' ); // The developer detail is kept, in the console. expect(warn).toHaveBeenCalledWith(expect.stringContaining('raw encrypted detail')); const bad = [...new Set(texts.values())].filter((text) => { const t = zh(text); return t === text || leftover(t).length > 0 || en(text) !== text; }); expect(bad).toEqual([]); } finally { warn.mockRestore(); } }); it('words the notice bar: feature ids as words, each item translated, a format code as it is', async () => { const notice = async () => { const worker = await openLoaded(); worker.emit({ ...metadata(), warnings: ['pivotTables', 'externalLinks', 'charts', 'drawings', 'macros'] }); const request = worker.postMessage.mock.calls.at(-1)?.[0]; worker.emit({ type: 'tile', requestId: request.requestId, sheetId: '1', cells: [], warnings: [ 'View truncated to the first 2500 cells', 'Formula has no cached result', 'Unsupported cell value', 'Unsupported number format: {name}"x"', '3 unsupported number formats', ], }); return document.querySelector('.spreadsheet-preview-notice')!.textContent!; }; const english = await notice(); expect(english).toBe( 'Some workbook features are not shown: pivot tables, external links, charts, drawings, macros, ' + 'View truncated to the first 2500 cells, Formula has no cached result, Unsupported cell value, ' + 'Unsupported number format: {name}"x", 3 unsupported number formats' ); // The real English translator reads the feature words as plain words too, // never as their scoped keys. (window as any).codemanT = en; expect(await notice()).toBe(english); (window as any).codemanT = zh; const chinese = await notice(); // The format code is workbook text: kept as is ({name} is not a placeholder here). expect(chinese).toContain('不支持的数字格式: {name}"x"'); expect(leftover(chinese.replace('{name}"x"', ''))).toEqual([]); expect(chinese.startsWith(`${zh('Some workbook features are not shown')}: 数据透视表, 外部链接, 图表`)).toBe(true); // Through scoped keys only: the translator runs over the Files panel tree and // the case picker too, where a bare 'charts' or 'macros' key would rename a // Helm chart's charts/ or a dbt project's macros/ folder, or a case of that name. const words = ['charts', 'drawings', 'pivot tables', 'external links', 'macros']; expect(words.filter((word) => zh(word) !== word || zh(word.toUpperCase()) !== word.toUpperCase())).toEqual([]); }); it('keeps the notice bar away from the page translator, so a format code is never rewritten', async () => { // The real i18n.js in the renderer's own window. configure() walks the whole // body with t(), which fills `{name}` and rebrands "Codeman" in every text // node it reaches; the bar is written already translated, item by item, and // ends with workbook text. const { confirm, alert } = window; const context = vm.createContext({ window, document, Node: window.Node, NodeFilter: window.NodeFilter, MutationObserver: window.MutationObserver, console, }); vm.runInContext(i18nSource, context, { filename: 'i18n.js' }); try { const i18n = (window as any).CodemanI18n as { configure(o: object): void }; i18n.configure({ language: 'en', displayName: 'Acme' }); const worker = await openLoaded(); worker.emit({ ...metadata(), warnings: ['charts'] }); const request = worker.postMessage.mock.calls.at(-1)?.[0]; worker.emit({ type: 'tile', requestId: request.requestId, sheetId: '1', cells: [], warnings: ['Unsupported number format: {name}"Codeman"0'], }); const bar = document.querySelector('.spreadsheet-preview-notice')!; const written = 'Some workbook features are not shown: charts, Unsupported number format: {name}"Codeman"0'; expect(bar.textContent).toBe(written); // A language or display-name change re-walks the page. i18n.configure({ language: 'en', displayName: 'Acme' }); expect(bar.textContent).toBe(written); expect(bar.hasAttribute('data-i18n-skip')).toBe(true); } finally { delete (window as any).CodemanI18n; delete (window as any).codemanT; window.confirm = confirm; window.alert = alert; } }); it('the HTTP status line reads in Chinese with its status', async () => { const fetchMock = vi.fn(async () => ({ ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) })); loadRenderer(fetchMock).open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 }); WorkerMock.instances.at(-1)!.emit({ type: 'ready' }); await vi.waitFor(() => expect(shown()).toBe('Spreadsheet preview failed (404)')); expect(zh(shown())).toBe('电子表格预览失败(404)'); expect(en(shown())).toBe(shown()); expect(zh('Spreadsheet preview must use a same-origin URL')).toBe('电子表格预览必须使用同源 URL'); }); }); /** * The overlay wiring in panels-ui.js: which route the renderer is pointed at, * and that the worker/fetch lifecycle is torn down on close and on re-open. */ describe('file-preview overlay spreadsheet wiring', () => { function loadOverlay(fileContent: Record) { const handles: Array<{ dispose: ReturnType }> = []; const open = vi.fn(() => { const handle = { dispose: vi.fn() }; handles.push(handle); return handle; }); const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ success: true, data: fileContent }) })); const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record; const context = vm.createContext({ CodemanApp, console, escapeHtml: (value: unknown) => String(value ?? ''), CodemanBase: { base: '', url: (p: string) => p }, fetch: fetchMock, setTimeout, clearTimeout, confirm: () => true, document, window: { CodemanSpreadsheetPreview: { open }, addEventListener: vi.fn(), removeEventListener: vi.fn() }, }); vm.runInContext(panelsSource, context, { filename: 'panels-ui.js' }); document.body.innerHTML = '
' + '
'; const app = new CodemanApp(); app.$ = (id: string) => document.getElementById(id); app.sessions = new Map([['s1', { workingDir: '/work' }]]); app.formatFileSize = () => '8 KB'; return { app, open, handles, fetchMock }; } it('renders a workspace xlsx through the renderer, pointed at the confined file-raw route', async () => { const { app, open, handles } = loadOverlay({ type: 'spreadsheet', size: 8192, extension: 'xlsx', url: '/api/sessions/s1/file-raw?path=book.xlsx', }); await app.openFilePreview('book.xlsx', 's1'); const body = document.getElementById('filePreviewBody'); expect(open).toHaveBeenCalledTimes(1); expect(open).toHaveBeenCalledWith( expect.objectContaining({ container: body, url: '/api/sessions/s1/file-raw?path=book.xlsx', size: 8192 }) ); expect(document.getElementById('filePreviewFooter')?.textContent).toContain('xlsx'); app.closeFilePreview(); expect(handles[0].dispose).toHaveBeenCalledTimes(1); }); it('disposes the previous spreadsheet when another preview opens, and stale renders stop', async () => { const { app, open, handles } = loadOverlay({ type: 'spreadsheet', size: 1, extension: 'xlsx', url: '/api/sessions/s1/file-raw?path=a.xlsx', }); await app.openFilePreview('a.xlsx', 's1'); const firstIsCurrent = (open.mock.calls[0] as unknown as [{ isCurrent(): boolean }])[0].isCurrent; expect(firstIsCurrent()).toBe(true); await app.openFilePreview('b.xlsx', 's1'); expect(handles[0].dispose).toHaveBeenCalledTimes(1); expect(firstIsCurrent()).toBe(false); expect(handles[1].dispose).not.toHaveBeenCalled(); }); it('renders a registered xlsx attachment from its by-id raw route', async () => { const { app, open, fetchMock } = loadOverlay({}); await app.openFilePreview('book.xlsx', 's1', 'att-1'); expect(fetchMock).not.toHaveBeenCalled(); expect(open).toHaveBeenCalledWith(expect.objectContaining({ url: '/api/sessions/s1/attachments/att-1/raw' })); }); it('leaves xls/ods on the download-only binary path', async () => { const { app, open } = loadOverlay({ type: 'binary', size: 10, extension: 'xls' }); await app.openFilePreview('old.xls', 's1'); expect(open).not.toHaveBeenCalled(); expect(document.getElementById('filePreviewBody')?.textContent).toContain('Cannot preview'); }); });