feat(preview): render XLSX spreadsheets in the file-preview overlay

xlsx files were download-only. Add a read-only, virtualized preview (sheet
tabs, number formats, merges, theme colours) parsed entirely in a browser
Web Worker with exceljs and fflate, loaded only when a spreadsheet is
opened. The workbook is checked against ZIP-bomb, entry and cell limits
before exceljs loads; cell text is written with textContent, formulas are
never evaluated and nothing referenced by the workbook is fetched. On the
server xlsx only joins the existing allowlist and classification, with a
10 MB cap on ?preview=true. xls and ods stay download-only.
This commit is contained in:
Aamer Akhter
2026-09-26 23:13:21 -04:00
parent 45ea2e1d32
commit 0b122e2c76
27 changed files with 3765 additions and 13 deletions
+13 -2
View File
@@ -46,10 +46,17 @@ function lockedVersions(lock: PackageLock, packageName: string): string[] {
return [...versions].sort();
}
function expectEveryLockedVersionAtLeast(lock: PackageLock, packageName: string, minimum: string): void {
function expectEveryLockedVersionAtLeast(
lock: PackageLock,
packageName: string,
minimum: string,
/** Exact versions deliberately outside this policy; each call site says why. */
exempt: string[] = []
): void {
const versions = lockedVersions(lock, packageName);
expect(versions, `${packageName} should be present in package-lock.json`).not.toHaveLength(0);
for (const version of versions) {
if (exempt.includes(version)) continue;
expect(
compareVersions(version, minimum),
`${packageName}@${version} should be >= ${minimum}`
@@ -136,7 +143,11 @@ describe('dependency security policy', () => {
// <=10.1.1, so every 9.x is affected and the fix is only on the 10.x line.
expectEveryLockedVersionAtLeast(lock, '@fastify/static', '10.1.2');
expectEveryLockedVersionAtLeast(lock, 'ip-address', '10.2.0');
expectEveryLockedVersionAtLeast(lock, 'uuid', '14.0.0');
// Our own uuid stays >= 14. exceljs@4.4.0 (devDependency, vendored into the
// XLSX preview worker at build time) pins uuid@8.3.2 and only calls v4();
// GHSA-w5hq-g745-h8pq is MODERATE and covers v3/v5/v6 with a caller buffer,
// so it is outside this CRITICAL/HIGH policy and unreachable from exceljs.
expectEveryLockedVersionAtLeast(lock, 'uuid', '14.0.0', ['8.3.2']);
// ⚠️ Floor stays 8.20.1, NOT 8.21.0. Production ws is already 8.21.0 and clear of
// GHSA-96hv-2xvq-fx4p, but @remotion/renderer bundles its own ws@8.20.1 and remotion
// is pinned to 4.0.473 on purpose (the compositor refuses to start on a version
@@ -556,6 +556,52 @@ describe('file-routes attachment path guard (COD-53)', () => {
});
});
// ===== XLSX: client-side preview, same guard and routes =====
// xlsx joins the extension allowlist so the overlay can preview a workbook
// outside the workspace by id. Nothing else about the pipeline changes; only
// `?preview=true` adds a tighter size cap because the browser parses it.
describe('xlsx attachments', () => {
async function registerXlsx(size: number) {
mockedStat.mockResolvedValue({ size, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/tmp/report.xlsx', notify: false },
});
expect(res.statusCode).toBe(200);
return JSON.parse(res.body).data as { attachmentId: string; attachmentType: string };
}
it('registers an xlsx as a spreadsheet attachment', async () => {
const data = await registerXlsx(2048);
expect(data.attachmentType).toBe('spreadsheet');
});
it('still refuses xls and ods (download-only, no preview)', async () => {
for (const path of ['/tmp/legacy.xls', '/tmp/open.ods']) {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path, notify: false },
});
expect(res.statusCode, path).toBe(400);
}
});
it('caps a ?preview=true fetch at 10 MB but still serves a plain download', async () => {
const { attachmentId } = await registerXlsx(11 * 1024 * 1024);
const base = `/api/sessions/${harness.ctx._sessionId}/attachments/${attachmentId}/raw`;
const preview = await harness.app.inject({ method: 'GET', url: `${base}?preview=true` });
expect(preview.statusCode).toBe(413);
expect(JSON.parse(preview.body).error).toMatch(/too large to preview/i);
mockedCreateReadStream.mockReturnValue(Readable.from([Buffer.from('PK')]) as never);
const download = await harness.app.inject({ method: 'GET', url: `${base}?preview=true&download=true` });
expect(download.statusCode).toBe(200);
});
});
// ===== Quiet registration (click-to-preview) =====
// The file-preview overlay registers a clicked out-of-workspace path to mint
// an id it can render by. It is already putting the file on screen, so the
+42 -2
View File
@@ -695,12 +695,12 @@ describe('file-routes', () => {
expect(body.data.url).toContain('file-raw');
});
it('flags known-binary extensions (e.g. xlsx) instead of dumping mojibake', async () => {
it('flags known-binary extensions (e.g. xls) instead of dumping mojibake', async () => {
mockedStat.mockResolvedValue({ size: 4096 } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.xlsx`,
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.xls`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
@@ -709,6 +709,30 @@ describe('file-routes', () => {
expect(body.data.content).toBeUndefined();
});
it('classifies xlsx as a client-side spreadsheet preview, never a text body', async () => {
mockedStat.mockResolvedValue({ size: 4096 } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.xlsx`,
});
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.type).toBe('spreadsheet');
expect(body.data.url).toBe(`/api/sessions/${harness.ctx._sessionId}/file-raw?path=sheet.xlsx`);
expect(body.data.content).toBeUndefined();
});
it('keeps ods (and xls) download-only binaries', async () => {
mockedStat.mockResolvedValue({ size: 4096 } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.ods`,
});
expect(JSON.parse(res.body).data.type).toBe('binary');
});
it('sniffs NUL bytes and flags binary content for unknown extensions', async () => {
const binary = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x01, 0x02]);
mockedReadFile.mockResolvedValue(binary as never);
@@ -868,6 +892,22 @@ describe('file-routes', () => {
expect(res.statusCode).toBe(413);
expect(JSON.parse(res.body).error).toContain('CODEMAN_MAX_DOWNLOAD_BYTES');
});
it('caps an xlsx ?preview=true fetch at 10 MB, leaving downloads and small previews alone', async () => {
mockedStat.mockResolvedValue({ size: 11 * 1024 * 1024 } as never);
const url = `/api/sessions/${harness.ctx._sessionId}/file-raw?path=book.xlsx`;
const preview = await harness.app.inject({ method: 'GET', url: `${url}&preview=true` });
expect(preview.statusCode).toBe(413);
expect(JSON.parse(preview.body).error).toMatch(/too large to preview/i);
const download = await harness.app.inject({ method: 'GET', url: `${url}&preview=true&download=true` });
expect(download.statusCode).toBe(200);
mockedStat.mockResolvedValue({ size: 2048 } as never);
const small = await harness.app.inject({ method: 'GET', url: `${url}&preview=true` });
expect(small.statusCode).toBe(200);
});
});
// ========== DELETE /api/sessions/:id/tail-file/:streamId ==========
+84
View File
@@ -0,0 +1,84 @@
/**
* @fileoverview XLSX preview asset delivery contracts.
*
* The parser bundles (exceljs ~950 KB, fflate ~33 KB) must never cost a page
* load: the page loads only spreadsheet-preview.js, and the vendor files are
* pulled by the worker when a spreadsheet is actually opened. These checks pin
* that shape plus the pinned versions and the dev/prod vendoring steps.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const root = resolve(import.meta.dirname, '..');
const read = (path: string) => readFileSync(resolve(root, path), 'utf8');
describe('spreadsheet preview assets', () => {
it('pins the browser parser packages exactly, as build-time (dev) dependencies', () => {
const pkg = JSON.parse(read('package.json')) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
expect(pkg.devDependencies?.exceljs).toBe('4.4.0');
expect(pkg.devDependencies?.fflate).toBe('0.8.2');
// They are vendored into dist/ at build time; a runtime install never needs them.
expect(pkg.dependencies?.exceljs).toBeUndefined();
expect(pkg.dependencies?.fflate).toBeUndefined();
});
it('prepares the vendor bundles for development and production', () => {
const postinstall = read('scripts/postinstall.js');
const build = read('scripts/build.mjs');
const prepare = read('scripts/prepare-spreadsheet-assets.mjs');
expect(postinstall).toContain('prepare-spreadsheet-assets.mjs');
expect(build).toContain('node scripts/prepare-spreadsheet-assets.mjs dist/web/public/vendor');
expect(prepare).toContain('exceljs.min.js');
expect(prepare).toContain('fflate.min.js');
expect(prepare).toContain('sourceMappingURL');
// Output lands in a gitignored dir, so the ~1 MB never gets committed.
expect(read('.gitignore')).toMatch(/^src\/web\/public\/vendor\/$/m);
});
it('loads only the small renderer on the page; the worker pulls the parser on demand', () => {
const html = read('src/web/public/index.html');
expect(html).toContain('<script defer src="spreadsheet-preview.js"></script>');
for (const lazy of ['spreadsheet-preview-worker.js', 'spreadsheet-xlsx-core.js', 'exceljs', 'fflate']) {
expect(html, lazy).not.toContain(lazy);
}
const sw = read('src/web/public/sw.js');
expect(sw).not.toContain('exceljs');
const worker = read('src/web/public/spreadsheet-preview-worker.js');
// ExcelJS loads only after admission, inside loadWorkbook().
const admit = worker.indexOf('core.admitXlsx(');
const excel = worker.indexOf('vendor/exceljs.min.js');
expect(admit).toBeGreaterThan(-1);
expect(excel).toBeGreaterThan(admit);
const build = read('scripts/build.mjs');
expect(build).toContain("'spreadsheet-preview.js',");
// The worker is a stable URL busted by SPREADSHEET_ASSET_VERSION, not a hashed name.
expect(build).not.toContain("'spreadsheet-preview-worker.js'");
});
it('routes an agent-printed .xlsx path to the preview overlay, but not .xls/.ods', () => {
const constants = read('src/web/public/constants.js');
const previewList = /FILE_PREVIEW_EXTENSIONS = new Set\(\s*\('([^']+)'\)/.exec(constants)?.[1].split(' ') ?? [];
expect(previewList).toContain('xlsx');
expect(previewList).not.toContain('xls');
expect(previewList).not.toContain('ods');
expect(constants).toMatch(/\|pptx\|xlsx\|/);
});
it('checks the combined size budget and the content-derived asset version', () => {
const check = read('scripts/check-public-assets.mjs');
expect(check).toContain('SPREADSHEET_VENDOR_MAX_BYTES = 1_100_000');
expect(check).toContain('SPREADSHEET_ASSET_VERSION');
expect(check).toContain('spreadsheet-preview-worker.js');
expect(check).toContain('spreadsheet-xlsx-core.js');
expect(check).toContain("createHash('sha256')");
expect(read('src/web/public/spreadsheet-preview.js')).toMatch(/const SPREADSHEET_ASSET_VERSION = '[a-f0-9]{12}'/);
});
});
+237
View File
@@ -0,0 +1,237 @@
/** @fileoverview Worker-owned XLSX parse and viewport protocol. */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
import ExcelJS from 'exceljs';
import * as fflate from 'fflate';
const root = resolve(import.meta.dirname, '..');
const workerSource = readFileSync(resolve(root, 'src/web/public/spreadsheet-preview-worker.js'), 'utf8');
const coreSource = readFileSync(resolve(root, 'src/web/public/spreadsheet-xlsx-core.js'), 'utf8');
async function fixture(): Promise<ArrayBuffer> {
const workbook = new ExcelJS.Workbook();
const first = workbook.addWorksheet('Summary');
first.getCell('A1').value = 'Revenue';
first.getCell('B2').value = 1234.5;
first.getCell('B2').numFmt = '$#,##0.00';
first.getCell('C3').value = { formula: 'SUM(B2)', result: 1234.5 };
first.getCell('C3').numFmt = '$#,##0.00';
first.mergeCells('A4:C4');
first.getCell('A4').value = 'Merged';
first.getRow(2).height = 30;
first.getColumn(2).width = 18;
const second = workbook.addWorksheet('Details');
second.getCell('A1').value = 'Detail';
workbook.addWorksheet('Hidden').state = 'hidden';
const bytes = await workbook.xlsx.writeBuffer();
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
}
async function fixtureWithChartPart(): Promise<ArrayBuffer> {
const entries = fflate.unzipSync(new Uint8Array(await fixture()));
entries['xl/charts/chart1.xml'] = fflate.strToU8('<chart/>');
const bytes = fflate.zipSync(entries);
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
}
// Theme fills, indexed colours, and unfilled cells in one workbook.
// accent1 is forced to pure red so a resolved theme colour cannot be confused
// with the built-in default Office palette.
async function themedFixture(): Promise<ArrayBuffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Themed');
for (const ref of ['A1', 'A2', 'A3', 'A4']) sheet.getCell(ref).value = ref;
const entries = fflate.unzipSync(new Uint8Array(await workbook.xlsx.writeBuffer()));
entries['xl/theme/theme1.xml'] = fflate.strToU8(
'<?xml version="1.0"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">' +
'<a:themeElements><a:clrScheme name="Custom">' +
'<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>' +
'<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>' +
'<a:dk2><a:srgbClr val="1F497D"/></a:dk2><a:lt2><a:srgbClr val="EEECE1"/></a:lt2>' +
'<a:accent1><a:srgbClr val="FF0000"/></a:accent1><a:accent2><a:srgbClr val="C0504D"/></a:accent2>' +
'<a:accent3><a:srgbClr val="9BBB59"/></a:accent3><a:accent4><a:srgbClr val="8064A2"/></a:accent4>' +
'<a:accent5><a:srgbClr val="4BACC6"/></a:accent5><a:accent6><a:srgbClr val="F79646"/></a:accent6>' +
'<a:hlink><a:srgbClr val="0000FF"/></a:hlink><a:folHlink><a:srgbClr val="800080"/></a:folHlink>' +
'</a:clrScheme></a:themeElements></a:theme>'
);
entries['xl/styles.xml'] = fflate.strToU8(
'<?xml version="1.0"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
'<fonts count="5"><font><sz val="11"/></font><font><color theme="1"/></font><font><color indexed="9"/></font>' +
'<font><color rgb="FF000000"/></font><font><color rgb="FF11111B"/></font></fonts>' +
'<fills count="5"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill>' +
'<fill><patternFill patternType="solid"><fgColor theme="4"/></patternFill></fill>' +
'<fill><patternFill patternType="solid"><fgColor indexed="13"/></patternFill></fill>' +
'<fill><patternFill patternType="solid"><fgColor rgb="FF1A1A2E"/></patternFill></fill></fills>' +
'<borders count="1"><border/></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
'<cellXfs count="5"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>' +
'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>' +
'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>' +
'<xf numFmtId="0" fontId="3" fillId="0" borderId="0" xfId="0" applyFont="1"/>' +
'<xf numFmtId="0" fontId="4" fillId="4" borderId="0" xfId="0" applyFont="1" applyFill="1"/></cellXfs>' +
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>'
);
entries['xl/worksheets/sheet1.xml'] = fflate.strToU8(
fflate
.strFromU8(entries['xl/worksheets/sheet1.xml'])
.replace('<c r="A1"', '<c s="1" r="A1"')
.replace('<c r="A2"', '<c s="2" r="A2"')
.replace('<c r="A3"', '<c s="3" r="A3"')
.replace('<c r="A4"', '<c s="4" r="A4"')
);
const bytes = fflate.zipSync(entries);
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
}
function createHarness() {
const messages: unknown[] = [];
const imports: string[] = [];
const self: Record<string, unknown> = {
location: { href: 'http://localhost/spreadsheet-preview-worker.js?v=test' },
postMessage: (message: unknown) => messages.push(message),
};
const context = vm.createContext({
self,
globalThis: self,
URL,
Uint8Array,
ArrayBuffer,
DataView,
TextDecoder,
Date,
Math,
Number,
String,
Object,
Map,
Set,
console,
importScripts: (...urls: string[]) => {
imports.push(...urls);
for (const url of urls) {
if (url.includes('fflate')) Object.assign(self, { fflate });
if (url.includes('spreadsheet-xlsx-core')) vm.runInContext(coreSource, context);
if (url.includes('exceljs')) Object.assign(self, { ExcelJS });
}
},
});
vm.runInContext(workerSource, context);
return {
messages,
imports,
send: async (data: unknown) => {
await (self.onmessage as (event: { data: unknown }) => Promise<void>)({ data });
},
};
}
describe('spreadsheet preview worker', () => {
it('admits before lazy ExcelJS loading and returns visible sheets in workbook order', async () => {
const harness = createHarness();
expect(harness.messages).toEqual([{ type: 'ready' }]);
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
await harness.send({ type: 'load', bytes: await fixture() });
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(true);
const metadata = harness.messages.at(-1) as Record<string, any>;
expect(metadata.type).toBe('metadata');
expect(metadata.sheets.map((sheet: { name: string }) => sheet.name)).toEqual(['Summary', 'Details']);
expect(metadata.sheets[0]).toMatchObject({ rows: 4, cols: 3 });
expect(metadata.sheets[0].rowOverrides).toContainEqual([2, 40]);
expect(metadata.sheets[0].columnOverrides[0][0]).toBe(2);
expect(metadata.styles.length).toBeLessThanOrEqual(5000);
});
it('returns bounded intersecting tiles and echoes request and sheet identity', async () => {
const harness = createHarness();
await harness.send({ type: 'load', bytes: await fixture() });
const metadata = harness.messages.at(-1) as Record<string, any>;
const sheetId = metadata.sheets[0].id;
await harness.send({ type: 'tile', requestId: 7, sheetId, range: { r1: 2, c1: 2, r2: 4, c2: 3 } });
const tile = harness.messages.at(-1) as Record<string, any>;
expect(tile).toMatchObject({ type: 'tile', requestId: 7, sheetId });
expect(tile.cells).toEqual(
expect.arrayContaining([
expect.objectContaining({ row: 2, col: 2, text: '$1,234.50' }),
expect.objectContaining({ row: 3, col: 3, text: '$1,234.50' }),
])
);
expect(tile.merges).toContain('A4:C4');
expect(tile.cells).toContainEqual(expect.objectContaining({ row: 4, col: 1, text: 'Merged' }));
expect(tile.cells.length).toBeLessThanOrEqual(9);
});
it('rejects malformed input without loading ExcelJS', async () => {
const harness = createHarness();
await harness.send({ type: 'load', bytes: Uint8Array.from([1, 2, 3]).buffer });
expect(harness.messages.at(-1)).toMatchObject({ type: 'error', code: 'malformed' });
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
});
it('resolves theme and indexed colours and emits contrast-safe colour pairs', async () => {
const harness = createHarness();
await harness.send({ type: 'load', bytes: await themedFixture() });
const metadata = harness.messages.at(-1) as Record<string, any>;
const sheetId = metadata.sheets[0].id;
await harness.send({ type: 'tile', requestId: 1, sheetId, range: { r1: 1, c1: 1, r2: 4, c2: 1 } });
const tile = harness.messages.at(-1) as Record<string, any>;
const pairs = new Map<number, { fill?: string; color?: string }>();
for (const cell of tile.cells as Array<{ row: number; styleId: number }>) {
const style = metadata.styles[cell.styleId];
pairs.set(cell.row, { fill: style.fill, color: style.font?.color });
}
// Theme fill (workbook's own accent1 = red) survives instead of being dropped.
expect(pairs.get(1)).toEqual({ fill: '#ff0000', color: '#000000' });
// Indexed white-on-yellow is illegible, so the foreground flips to black.
expect(pairs.get(2)).toEqual({ fill: '#ffff00', color: '#000000' });
// A black font with no fill renders on the implicit white sheet background.
expect(pairs.get(3)).toEqual({ fill: '#ffffff', color: '#000000' });
// Near-black on near-black flips the other way.
expect(pairs.get(4)).toEqual({ fill: '#1a1a2e', color: '#ffffff' });
});
it('loads its scripts by RELATIVE url so a --base-url mount resolves them under the prefix', async () => {
const harness = createHarness();
await harness.send({ type: 'load', bytes: await fixture() });
expect(harness.imports.length).toBeGreaterThan(0);
for (const url of harness.imports) {
expect(url.startsWith('/'), url).toBe(false);
expect(url).toContain('?v=test');
}
});
it('never evaluates formulas: an uncached formula is shown as its source text', async () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Formulas');
sheet.getCell('A1').value = { formula: 'WEBSERVICE("http://example.invalid/")' } as ExcelJS.CellFormulaValue;
const written = await workbook.xlsx.writeBuffer();
const harness = createHarness();
await harness.send({
type: 'load',
bytes: written.buffer.slice(written.byteOffset, written.byteOffset + written.byteLength),
});
const metadata = harness.messages.at(-1) as Record<string, any>;
await harness.send({
type: 'tile',
requestId: 1,
sheetId: metadata.sheets[0].id,
range: { r1: 1, c1: 1, r2: 1, c2: 1 },
});
const tile = harness.messages.at(-1) as Record<string, any>;
expect(tile.cells).toEqual([
expect.objectContaining({ row: 1, col: 1, text: '=WEBSERVICE("http://example.invalid/")' }),
]);
expect(tile.warnings).toContain('Formula has no cached result');
});
it('carries detected unsupported features into workbook metadata', async () => {
const harness = createHarness();
await harness.send({ type: 'load', bytes: await fixtureWithChartPart() });
expect(harness.messages.at(-1)).toMatchObject({ type: 'metadata', warnings: ['charts'] });
});
});
+131
View File
@@ -0,0 +1,131 @@
/** @fileoverview XLSX preview in real Chromium: Worker + vendor bundles load same-origin under a strict CSP. */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser } from 'playwright';
import ExcelJS from 'exceljs';
const root = resolve(import.meta.dirname, '..');
const publicRoot = resolve(root, 'src/web/public');
let browser: Browser;
async function workbookBytes(): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const summary = workbook.addWorksheet('Summary');
summary.getCell('A1').value = 'Local workbook';
summary.getCell('B2').value = 42;
summary.mergeCells('A3:C3');
summary.getCell('A3').value = 'Merged cells';
summary.getCell('A100').value = 'Far row';
summary.getCell('Z1').value = 'Far column';
const details = workbook.addWorksheet('Details');
details.getCell('A1').value = 'Second sheet';
return Buffer.from(await workbook.xlsx.writeBuffer());
}
describe('spreadsheet preview browser boundary', () => {
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
});
afterAll(async () => {
await browser?.close();
});
it('renders through same-origin versioned Worker assets under CSP without external requests', async () => {
const page = await browser.newPage({ viewport: { width: 390, height: 760 }, hasTouch: true });
const workbook = await workbookBytes();
const requests: string[] = [];
page.on('request', (request) => requests.push(request.url()));
await page.route('https://codeman.test/**', async (route) => {
const url = new URL(route.request().url());
if (url.pathname === '/') {
await route.fulfill({
contentType: 'text/html',
headers: {
'Content-Security-Policy':
"default-src 'self'; script-src 'self'; worker-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'",
},
body:
'<!doctype html><html><head><link rel="stylesheet" href="/styles.css"><link rel="stylesheet" href="/mobile.css"></head>' +
'<body><div id="preview" style="width:370px;height:650px"></div>' +
'<script src="/spreadsheet-preview.js"></script><script src="/start.js"></script></body></html>',
});
return;
}
if (url.pathname === '/start.js') {
await route.fulfill({
contentType: 'application/javascript',
body: `window.previewHandle = window.CodemanSpreadsheetPreview.open({container:document.querySelector('#preview'),url:'/api/book.xlsx?preview=true',size:${workbook.length}});`,
});
return;
}
if (url.pathname === '/api/book.xlsx') {
await route.fulfill({
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
body: workbook,
});
return;
}
const assetPath = resolve(publicRoot, `.${url.pathname}`);
if (!assetPath.startsWith(publicRoot)) throw new Error('Unexpected asset path');
await route.fulfill({
contentType: url.pathname.endsWith('.js')
? 'application/javascript'
: url.pathname.endsWith('.css')
? 'text/css'
: 'application/octet-stream',
body: readFileSync(assetPath),
});
});
await page.goto('https://codeman.test/');
await expect.poll(() => page.locator('.spreadsheet-sheet-tab').count()).toBe(2);
await expect.poll(() => page.locator('.spreadsheet-cell', { hasText: 'Local workbook' }).count()).toBe(1);
const initialGeometry = await page.locator('.spreadsheet-grid').evaluate((grid) => {
const cell = grid.querySelector('.spreadsheet-cell');
if (!cell) throw new Error('Missing rendered spreadsheet cell');
const gridRect = grid.getBoundingClientRect();
const cellRect = cell.getBoundingClientRect();
return { left: cellRect.left - gridRect.left, top: cellRect.top - gridRect.top };
});
expect(initialGeometry.left).toBeGreaterThanOrEqual(36);
expect(initialGeometry.top).toBeGreaterThanOrEqual(20);
await page.locator('.spreadsheet-grid').evaluate((grid) => {
grid.scrollTop = 400;
grid.scrollLeft = 400;
grid.dispatchEvent(new Event('scroll'));
});
await expect
.poll(() =>
page.locator('.spreadsheet-grid').evaluate((grid) => {
const bounds = grid.getBoundingClientRect();
return [...grid.querySelectorAll('.spreadsheet-row-heading, .spreadsheet-column-heading')].filter(
(heading) => {
const rect = heading.getBoundingClientRect();
return (
rect.right > bounds.left &&
rect.left < bounds.right &&
rect.bottom > bounds.top &&
rect.top < bounds.bottom
);
}
).length;
})
)
.toBeGreaterThan(0);
await page.getByRole('tab', { name: 'Details' }).click();
await expect.poll(() => page.locator('.spreadsheet-cell', { hasText: 'Second sheet' }).count()).toBe(1);
expect(await page.locator('.spreadsheet-grid').evaluate((element) => getComputedStyle(element).overflow)).toBe(
'auto'
);
const origins = new Set(requests.map((request) => new URL(request).origin));
expect(origins).toEqual(new Set(['https://codeman.test']));
expect(requests.some((request) => /spreadsheet-preview-worker\.js\?v=[a-f0-9]{12}/.test(request))).toBe(true);
expect(requests.some((request) => /vendor\/exceljs\.min\.js\?v=[a-f0-9]{12}/.test(request))).toBe(true);
expect(requests.some((request) => /vendor\/fflate\.min\.js\?v=[a-f0-9]{12}/.test(request))).toBe(true);
await page.close();
});
});
+339
View File
@@ -0,0 +1,339 @@
/**
* @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.
*/
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 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('<!doctype html><body></body>', { 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<typeof vi.fn>) {
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<string, unknown>): { dispose(): void } };
}
function metadata() {
return {
type: 'metadata',
sheets: [
{
id: '1',
name: '<Summary>',
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 = '<div id="preview"></div>';
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: `<b>${index}</b>`,
styleId: 0,
})),
});
expect(document.querySelectorAll('.spreadsheet-cell')).toHaveLength(2500);
expect(document.querySelector('.spreadsheet-cell')?.textContent).toBe('<b>0</b>');
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');
});
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('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');
});
});
/**
* 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<string, unknown>) {
const handles: Array<{ dispose: ReturnType<typeof vi.fn> }> = [];
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<string, any>;
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 =
'<div id="filePreviewOverlay"></div><div id="filePreviewTitle"></div><div id="filePreviewBody"></div>' +
'<div id="filePreviewFooter"></div>';
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');
});
});
+306
View File
@@ -0,0 +1,306 @@
/** @fileoverview XLSX admission, formatting, and sparse geometry contracts. */
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import * as fflate from 'fflate';
type Core = {
LIMITS: Record<string, number>;
XlsxPreviewError: new (code: string, message: string) => Error & { code: string };
inspectZipDirectory(bytes: Uint8Array, limits?: Record<string, number>): { entries: Array<{ name: string }> };
admitXlsx(bytes: Uint8Array, zip: typeof fflate, limits?: Record<string, number>): unknown;
parseCellRef(ref: string): { row: number; col: number } | null;
deriveExtent(cells: string[], merges: string[]): { rows: number; cols: number };
createSparseAxis(count: number, defaultSize: number, overrides: Array<[number, number]>): unknown;
axisOffset(axis: unknown, index: number): number;
axisIndexAt(axis: unknown, offset: number): number;
computeViewport(axis: unknown, offset: number, viewportSize: number, overscan?: number): [number, number];
intersectingMerges(merges: string[], range: { r1: number; c1: number; r2: number; c2: number }): string[];
formatCellValue(value: unknown, format: string, date1904?: boolean): { text: string; warning?: string };
DEFAULT_THEME_PALETTE: string[];
INDEXED_PALETTE: string[];
parseThemePalette(xml?: string): string[];
resolveColor(color: unknown, palette?: string[]): string | undefined;
contrastRatio(a: string, b: string): number;
ensureContrast(foreground: string, background: string, minRatio?: number): string;
resolveCellColors(
fillColor: unknown,
fontColor: unknown,
palette?: string[]
): { background?: string; foreground?: string };
};
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/spreadsheet-xlsx-core.js'), 'utf8');
const context = vm.createContext({ Uint8Array, DataView, TextDecoder, Date, Math, Number, String, Object, Map, Set });
vm.runInContext(source, context);
const core = (context as unknown as { CodemanSpreadsheetXlsxCore: Core }).CodemanSpreadsheetXlsxCore;
const workbookZip = (sheet = '<worksheet><sheetData><row><c r="A1"/></row></sheetData><mergeCells/></worksheet>') =>
fflate.zipSync({
'[Content_Types].xml': fflate.strToU8('<Types/>'),
'xl/workbook.xml': fflate.strToU8('<workbook><sheets><sheet name="Sheet1"/></sheets></workbook>'),
'xl/styles.xml': fflate.strToU8('<styleSheet><cellXfs count="1"><xf/></cellXfs></styleSheet>'),
'xl/worksheets/sheet1.xml': fflate.strToU8(sheet),
});
describe('spreadsheet XLSX core', () => {
it('rejects encrypted OLE files, malformed ZIPs, and ZIP64 sentinels', () => {
expect(() => core.inspectZipDirectory(Uint8Array.from([0xd0, 0xcf, 0x11, 0xe0]))).toThrowError(/encrypted/i);
expect(() => core.inspectZipDirectory(Uint8Array.from([1, 2, 3]))).toThrowError(/malformed/i);
const zip = workbookZip();
const eocd = zip.length - 22;
zip[eocd + 10] = 0xff;
zip[eocd + 11] = 0xff;
expect(() => core.inspectZipDirectory(zip)).toThrowError(/ZIP64/i);
});
it('rejects divergent local-header and central-directory entry names', () => {
const zip = workbookZip();
const localNameLength = new DataView(zip.buffer, zip.byteOffset, zip.byteLength).getUint16(26, true);
expect(localNameLength).toBeGreaterThan(0);
zip[30] = zip[30] === 120 ? 121 : 120;
expect(() => core.inspectZipDirectory(zip)).toThrowError(/names do not match/i);
});
it('rejects central-directory entries that were not streamed through admission', () => {
class IncompleteUnzip {
constructor(private readonly onFile: (file: any) => void) {}
register() {}
push(_bytes: Uint8Array, final: boolean) {
if (!final) return;
const file: Record<string, any> = {
name: '[Content_Types].xml',
start: () => file.ondata(null, new Uint8Array(), true),
};
this.onFile(file);
}
}
const incomplete = { Unzip: IncompleteUnzip, UnzipInflate: class {} } as unknown as typeof fflate;
expect(() => core.admitXlsx(workbookZip(), incomplete)).toThrowError(/not streamed/i);
});
it('uses actual streamed output for entry, total, ratio, and XML-count limits', () => {
const inflated = 'x'.repeat(20_000);
const bomb = fflate.zipSync({ 'xl/worksheets/sheet1.xml': fflate.strToU8(inflated) }, { level: 9 });
expect(() => core.admitXlsx(bomb, fflate, { maxEntryBytes: 10_000 })).toThrowError(/entry/i);
expect(() => core.admitXlsx(bomb, fflate, { maxInflatedBytes: 10_000 })).toThrowError(/inflated/i);
expect(() => core.admitXlsx(bomb, fflate, { maxCompressionRatio: 2 })).toThrowError(/compression ratio/i);
const cells = '<worksheet><sheetData>' + '<c r="A1"/>'.repeat(4) + '</sheetData></worksheet>';
expect(() => core.admitXlsx(workbookZip(cells), fflate, { maxCellsPerSheet: 3 })).toThrowError(/cells/i);
expect(() => core.admitXlsx(workbookZip(), fflate, { maxEntries: 2 })).toThrowError(/entries/i);
});
it('detects OOXML feature parts and counts worksheets, merges, styles, and cells', () => {
const zip = fflate.zipSync({
...fflate.unzipSync(
workbookZip('<worksheet><sheetData><c r="A1"/></sheetData><mergeCell ref="A1:B2"/></worksheet>')
),
'xl/charts/chart1.xml': fflate.strToU8('<chart/>'),
'xl/externalLinks/externalLink1.xml': fflate.strToU8('<externalLink/>'),
});
const result = core.admitXlsx(zip, fflate) as {
counts: { worksheets: number; cells: number; merges: number; styles: number };
features: string[];
};
expect(result.counts).toEqual({ worksheets: 1, cells: 1, merges: 1, styles: 1 });
expect(result.features).toEqual(expect.arrayContaining(['charts', 'externalLinks']));
});
it('derives bounded extents from real cells and merges', () => {
expect(core.parseCellRef('XFD1048576')).toEqual({ row: 1_048_576, col: 16_384 });
expect(core.parseCellRef('XFE1')).toBeNull();
expect(core.deriveExtent(['B3'], ['D5:F9'])).toEqual({ rows: 9, cols: 6 });
});
it('maps sparse axes and virtual viewports without dense allocation', () => {
const axis = core.createSparseAxis(1_000_000, 20, [
[2, 0],
[10, 40],
]);
expect(core.axisOffset(axis, 3)).toBe(20);
expect(core.axisIndexAt(axis, 20)).toBe(3);
const [start, end] = core.computeViewport(axis, 199, 60, 1);
expect(start).toBeLessThanOrEqual(9);
expect(end - start).toBeLessThan(10);
});
it('returns intersecting merges even when their anchor is offscreen', () => {
expect(core.intersectingMerges(['A1:D4', 'Z1:Z2'], { r1: 3, c1: 3, r2: 6, c2: 6 })).toEqual(['A1:D4']);
});
it('formats common values and safely flags unknown formats', () => {
expect(core.formatCellValue(0.125, '0.0%').text).toBe('12.5%');
expect(core.formatCellValue(1234.5, '#,##0.00').text).toBe('1,234.50');
expect(core.formatCellValue(10, '$#,##0.00').text).toBe('$10.00');
expect(core.formatCellValue(1, 'yyyy-mm-dd').text).toBe('1900-01-01');
expect(core.formatCellValue(0, 'yyyy-mm-dd', true).text).toBe('1904-01-01');
expect(core.formatCellValue(0.5, 'hh:mm:ss').text).toBe('12:00:00');
expect(core.formatCellValue(1.5, 'yyyy-mm-dd hh:mm').text).toBe('1900-01-01 12:00');
expect(core.formatCellValue(7, '[Red][<0]0.0')).toMatchObject({ text: '7', warning: expect.any(String) });
expect(core.formatCellValue({ formula: 'SUM(A1:A2)' }, 'General')).toMatchObject({ text: '=SUM(A1:A2)' });
});
});
const themeXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Custom"><a:themeElements>
<a:clrScheme name="Custom">
<a:dk1><a:sysClr val="windowText" lastClr="102030"/></a:dk1>
<a:lt1><a:sysClr val="window" lastClr="F0F1F2"/></a:lt1>
<a:dk2><a:srgbClr val="203040"/></a:dk2>
<a:lt2><a:srgbClr val="E0E1E2"/></a:lt2>
<a:accent1><a:srgbClr val="FF0000"/></a:accent1>
<a:accent2><a:srgbClr val="00FF00"/></a:accent2>
<a:accent3><a:srgbClr val="0000FF"/></a:accent3>
<a:accent4><a:srgbClr val="010203"/></a:accent4>
<a:accent5><a:srgbClr val="040506"/></a:accent5>
<a:accent6><a:srgbClr val="070809"/></a:accent6>
<a:hlink><a:srgbClr val="123456"/></a:hlink>
<a:folHlink><a:srgbClr val="654321"/></a:folHlink>
</a:clrScheme></a:themeElements></a:theme>`;
describe('spreadsheet XLSX colour resolution', () => {
it('parses a theme palette into styles.xml index order, swapping lt/dk against clrScheme order', () => {
const palette = core.parseThemePalette(themeXml);
expect(palette).toHaveLength(12);
// styles.xml order is lt1, dk1, lt2, dk2 while clrScheme lists dk1, lt1, dk2, lt2.
expect(palette[0]).toBe('#f0f1f2');
expect(palette[1]).toBe('#102030');
expect(palette[2]).toBe('#e0e1e2');
expect(palette[3]).toBe('#203040');
expect(palette.slice(4)).toEqual([
'#ff0000',
'#00ff00',
'#0000ff',
'#010203',
'#040506',
'#070809',
'#123456',
'#654321',
]);
expect(core.resolveColor({ theme: 0 }, palette)).toBe('#f0f1f2');
expect(core.resolveColor({ theme: 1 }, palette)).toBe('#102030');
expect(core.resolveColor({ theme: 12 }, palette)).toBeUndefined();
});
it('reads sysClr entries from lastClr and srgbClr entries from val', () => {
const sysOnly = core.parseThemePalette(
themeXml.replace('<a:srgbClr val="203040"/>', '<a:sysClr val="windowText" lastClr="ABCDEF"/>')
);
expect(sysOnly[3]).toBe('#abcdef');
// A sysClr with no lastClr is unusable and must not poison the whole palette.
const missing = core.parseThemePalette(
themeXml.replace('<a:sysClr val="window" lastClr="F0F1F2"/>', '<a:sysClr val="window"/>')
);
expect(missing[0]).toBe(core.DEFAULT_THEME_PALETTE[0]);
expect(missing[1]).toBe('#102030');
});
it('applies Excel tint to HSL luminance in both directions', () => {
const palette = core.parseThemePalette(themeXml);
expect(core.resolveColor({ theme: 4, tint: 0.5 }, palette)).toBe('#ff8080');
expect(core.resolveColor({ theme: 4, tint: -0.5 }, palette)).toBe('#800000');
expect(core.resolveColor({ theme: 4, tint: 0 }, palette)).toBe('#ff0000');
expect(core.resolveColor({ theme: 4, tint: 1 }, palette)).toBe('#ffffff');
expect(core.resolveColor({ theme: 4, tint: -1 }, palette)).toBe('#000000');
});
it('falls back to the default Office palette when theme XML is missing or unparseable', () => {
expect(core.DEFAULT_THEME_PALETTE).toHaveLength(12);
expect(core.DEFAULT_THEME_PALETTE[0]).toBe('#ffffff');
expect(core.DEFAULT_THEME_PALETTE[1]).toBe('#000000');
expect(core.DEFAULT_THEME_PALETTE[2]).toBe('#eeece1');
expect(core.DEFAULT_THEME_PALETTE[3]).toBe('#1f497d');
expect(core.DEFAULT_THEME_PALETTE[4]).toBe('#4f81bd');
expect(core.DEFAULT_THEME_PALETTE[11]).toBe('#800080');
expect(core.parseThemePalette('')).toEqual(core.DEFAULT_THEME_PALETTE);
expect(core.parseThemePalette(undefined)).toEqual(core.DEFAULT_THEME_PALETTE);
expect(core.parseThemePalette('<html>not a theme</html>')).toEqual(core.DEFAULT_THEME_PALETTE);
expect(core.resolveColor({ theme: 1 })).toBe('#000000');
expect(core.resolveColor({ theme: 0 })).toBe('#ffffff');
});
it('resolves legacy indexed palette entries and ignores the auto sentinels', () => {
expect(core.INDEXED_PALETTE).toHaveLength(64);
expect(core.resolveColor({ indexed: 0 })).toBe('#000000');
expect(core.resolveColor({ indexed: 9 })).toBe('#ffffff');
expect(core.resolveColor({ indexed: 13 })).toBe('#ffff00');
expect(core.resolveColor({ indexed: 22 })).toBe('#c0c0c0');
expect(core.resolveColor({ indexed: 63 })).toBe('#333333');
expect(core.resolveColor({ indexed: 64 })).toBeUndefined();
expect(core.resolveColor({ indexed: 65 })).toBeUndefined();
expect(core.resolveColor({ argb: 'FF1F497D' })).toBe('#1f497d');
expect(core.resolveColor({ argb: 'nope' })).toBeUndefined();
expect(core.resolveColor(undefined)).toBeUndefined();
});
it('computes WCAG contrast ratios and overrides with the correct polarity', () => {
expect(core.contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 5);
expect(core.contrastRatio('#ffffff', '#ffffff')).toBeCloseTo(1, 5);
expect(core.contrastRatio('#ffff00', '#ffffff')).toBeLessThan(4.5);
// White on yellow is unreadable -> black wins.
expect(core.ensureContrast('#ffffff', '#ffff00')).toBe('#000000');
// Near-black on black -> white wins.
expect(core.ensureContrast('#11111b', '#1a1a2e')).toBe('#ffffff');
// Already-legible pairs are preserved verbatim.
expect(core.ensureContrast('#1f497d', '#ffffff')).toBe('#1f497d');
});
it('pins an implicit white sheet background for a dark font with no resolvable fill', () => {
expect(core.resolveCellColors(undefined, { argb: 'FF000000' })).toEqual({
background: '#ffffff',
foreground: '#000000',
});
expect(core.resolveCellColors({ indexed: 64 }, { theme: 1 })).toEqual({
background: '#ffffff',
foreground: '#000000',
});
});
it('pins black text when a fill resolves but the font colour does not', () => {
expect(core.resolveCellColors({ theme: 0 }, undefined)).toEqual({
background: '#ffffff',
foreground: '#000000',
});
expect(core.resolveCellColors({ argb: 'FFFFF8E7' }, undefined)).toEqual({
background: '#fff8e7',
foreground: '#000000',
});
});
it('overrides light-on-light and dark-on-dark pairs while keeping legible authored pairs', () => {
expect(core.resolveCellColors({ indexed: 13 }, { indexed: 9 })).toEqual({
background: '#ffff00',
foreground: '#000000',
});
expect(core.resolveCellColors({ argb: 'FFFFFFFF' }, { argb: 'FFF5F5F5' })).toEqual({
background: '#ffffff',
foreground: '#000000',
});
expect(core.resolveCellColors({ argb: 'FF1A1A2E' }, { argb: 'FF11111B' })).toEqual({
background: '#1a1a2e',
foreground: '#ffffff',
});
expect(core.resolveCellColors({ argb: 'FFFFFFFF' }, { argb: 'FF1F497D' })).toEqual({
background: '#ffffff',
foreground: '#1f497d',
});
});
it('keeps theme fills and theme fonts instead of dropping them', () => {
const palette = core.parseThemePalette(themeXml);
expect(core.resolveCellColors({ theme: 4, tint: 0.6 }, { theme: 1 }, palette)).toEqual({
background: '#ff9999',
foreground: '#102030',
});
});
it('emits neither colour when nothing resolves, so the skin tokens stay paired', () => {
expect(core.resolveCellColors(undefined, undefined)).toEqual({});
expect(core.resolveCellColors({ indexed: 64 }, { theme: 99 })).toEqual({});
expect(core.resolveCellColors({ argb: 'bogus' }, { argb: '' })).toEqual({});
});
});