mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
Merge #502: read-only XLSX spreadsheet preview in the file-preview overlay
# Conflicts: # CLAUDE.md
This commit is contained in:
@@ -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
|
||||
@@ -149,6 +160,10 @@ describe('dependency security policy', () => {
|
||||
expectEveryLockedVersionAtLeast(lock, 'find-my-way', '9.7.0');
|
||||
expectEveryLockedVersionAtLeast(lock, 'basic-ftp', '5.3.1');
|
||||
expectEveryLockedVersionAtLeast(lock, 'flatted', '3.4.2');
|
||||
// GHSA-px8p-9vwx-vf98 (unbounded loop on a ZIP64 marker in a local header)
|
||||
// covers <=0.8.2. The XLSX preview worker streams untrusted files through
|
||||
// fflate's Unzip before any admission callback runs.
|
||||
expectEveryLockedVersionAtLeast(lock, 'fflate', '0.8.3');
|
||||
expectNoVulnerableBraceExpansion(lock);
|
||||
expectNoVulnerableVite(lock);
|
||||
expectNoVulnerablePicomatch(lock);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -707,12 +707,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);
|
||||
@@ -721,6 +721,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);
|
||||
@@ -893,6 +917,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 ==========
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @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';
|
||||
import { DOCUMENT_ATTACHMENT_EXTENSIONS, isSupportedAttachmentExtension } from '../src/attachment-registry.js';
|
||||
|
||||
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.3');
|
||||
// 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}'/);
|
||||
});
|
||||
|
||||
it('names every accepted document type in the attachments panel help', () => {
|
||||
expect(DOCUMENT_ATTACHMENT_EXTENSIONS).toContain('xlsx');
|
||||
const help = /<div>Supports ([^<]+)<\/div>/.exec(read('src/web/public/panels-ui.js'))?.[1] || '';
|
||||
for (const extension of DOCUMENT_ATTACHMENT_EXTENSIONS) {
|
||||
expect(isSupportedAttachmentExtension(extension)).toBe(true);
|
||||
expect(help).toContain(`.${extension}`);
|
||||
}
|
||||
// The CLI's refusal text is built from the same list rather than restating it.
|
||||
expect(read('src/cli.ts')).toContain("DOCUMENT_ATTACHMENT_EXTENSIONS.join(', ')");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
||||
/** @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.getColumn(2).width = 18;
|
||||
summary.getRow(2).height = 30;
|
||||
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);
|
||||
// Headings line up with a custom-width column and a custom-height row.
|
||||
const headingFit = await page.locator('.spreadsheet-grid').evaluate((grid) => {
|
||||
const rect = (selector: string, text: string) => {
|
||||
const element = [...grid.querySelectorAll(selector)].find((node) => node.textContent === text);
|
||||
if (!element) throw new Error(`Missing ${selector} ${text}`);
|
||||
return element.getBoundingClientRect();
|
||||
};
|
||||
const cell = rect('.spreadsheet-cell', '42');
|
||||
const column = rect('.spreadsheet-column-heading', 'B');
|
||||
const row = rect('.spreadsheet-row-heading', '2');
|
||||
return {
|
||||
cell: [cell.left, cell.width, cell.top, cell.height],
|
||||
heading: [column.left, column.width, row.top, row.height],
|
||||
};
|
||||
});
|
||||
expect(headingFit.cell[1]).toBe(126);
|
||||
expect(headingFit.cell[3]).toBe(40);
|
||||
expect(headingFit.heading).toEqual(headingFit.cell);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* @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');
|
||||
});
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,805 @@
|
||||
/** @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;
|
||||
createXmlCounter(
|
||||
name: string,
|
||||
counts: { cells: number; merges: number; styles: number },
|
||||
limits: Record<string, number>
|
||||
): { push(chunk: Uint8Array, final: boolean): void };
|
||||
buildAdmittedArchive(admission: unknown, zip: typeof fflate): Uint8Array;
|
||||
excelJsEntryName(name: string): string;
|
||||
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 };
|
||||
foldWarnings(warnings: string[]): 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[];
|
||||
};
|
||||
// The 2x2 merge costs its four covered cells on top of the one real cell.
|
||||
// `elements` is every start tag across all six parts: 1 + 3 + 3 + 4 + 1 + 1.
|
||||
expect(result.counts).toEqual({ worksheets: 1, cells: 5, rows: 0, merges: 1, styles: 1, elements: 13 });
|
||||
expect(result.features).toEqual(expect.arrayContaining(['charts', 'externalLinks']));
|
||||
});
|
||||
|
||||
it('rebuilds a STORE-only archive from exactly the entries admission inflated', () => {
|
||||
const zip = workbookZip();
|
||||
const admission = core.admitXlsx(zip, fflate) as { entries: Record<string, Uint8Array>; inflatedBytes: number };
|
||||
expect(Object.keys(admission.entries).sort()).toEqual(Object.keys(fflate.unzipSync(zip)).sort());
|
||||
const rebuilt = core.buildAdmittedArchive(admission, fflate);
|
||||
const directory = core.inspectZipDirectory(rebuilt).entries as Array<{
|
||||
name: string;
|
||||
compressedSize: number;
|
||||
declaredSize: number;
|
||||
}>;
|
||||
for (const entry of directory) expect(entry.compressedSize).toBe(entry.declaredSize);
|
||||
const roundTrip = fflate.unzipSync(rebuilt);
|
||||
for (const [name, data] of Object.entries(admission.entries)) expect(roundTrip[name]).toEqual(data);
|
||||
});
|
||||
|
||||
it('refuses a local entry name streamed twice, since the rebuilt archive could hold only one', () => {
|
||||
const zip = workbookZip();
|
||||
class DuplicateUnzip {
|
||||
constructor(private readonly onFile: (file: any) => void) {}
|
||||
register() {}
|
||||
push(_bytes: Uint8Array, final: boolean) {
|
||||
if (!final) return;
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
const file: Record<string, any> = {
|
||||
name: 'xl/workbook.xml',
|
||||
start: () => file.ondata(null, new Uint8Array(1), true),
|
||||
};
|
||||
this.onFile(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
const duplicate = { Unzip: DuplicateUnzip, UnzipInflate: class {} } as unknown as typeof fflate;
|
||||
expect(() => core.admitXlsx(zip, duplicate)).toThrowError(/duplicate/i);
|
||||
});
|
||||
|
||||
it('names every entry the way JSZip and ExcelJS will see it', () => {
|
||||
expect(core.excelJsEntryName('xl/worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
|
||||
expect(core.excelJsEntryName('/xl/worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
|
||||
expect(core.excelJsEntryName('xl/./worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
|
||||
expect(core.excelJsEntryName('xl//worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
|
||||
expect(core.excelJsEntryName('xl/foo/../worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
|
||||
expect(core.excelJsEntryName('../xl/styles.xml')).toBe('xl/styles.xml');
|
||||
expect(core.excelJsEntryName('//xl/styles.xml')).toBe('xl/styles.xml');
|
||||
expect(core.excelJsEntryName('xl/media/')).toBe('xl/media/');
|
||||
});
|
||||
|
||||
it('refuses two entries that resolve to the same name', () => {
|
||||
const entries = fflate.unzipSync(workbookZip());
|
||||
const big = '<worksheet><sheetData>' + '<c r="A1"/>'.repeat(4) + '</sheetData></worksheet>';
|
||||
for (const twin of ['/xl/worksheets/sheet1.xml', 'xl/./worksheets/sheet1.xml', 'xl/x/../worksheets/sheet1.xml']) {
|
||||
const zip = fflate.zipSync({ ...entries, [twin]: fflate.strToU8(big) });
|
||||
expect(() => core.admitXlsx(zip, fflate), twin).toThrowError(/same name/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('counts and rebuilds entries under the names ExcelJS will see', () => {
|
||||
const cells = '<worksheet><sheetData>' + '<c r="A1"/>'.repeat(4) + '</sheetData></worksheet>';
|
||||
const renamed = (name: string) => {
|
||||
const entries = fflate.unzipSync(workbookZip(cells));
|
||||
const sheet = entries['xl/worksheets/sheet1.xml'];
|
||||
delete entries['xl/worksheets/sheet1.xml'];
|
||||
return fflate.zipSync({ ...entries, [name]: sheet });
|
||||
};
|
||||
for (const name of ['/xl/worksheets/sheet1.xml', 'xl/./worksheets/sheet1.xml', 'a/xl/worksheets/sheet2.xml.x']) {
|
||||
expect(() => core.admitXlsx(renamed(name), fflate, { maxCellsPerSheet: 3 }), name).toThrowError(/cells/i);
|
||||
expect(() => core.admitXlsx(renamed(name), fflate, { maxWorksheets: 0 }), name).toThrowError(/worksheet/i);
|
||||
}
|
||||
const admission = core.admitXlsx(renamed('/xl/./worksheets/sheet1.xml'), fflate) as {
|
||||
entries: Record<string, Uint8Array>;
|
||||
};
|
||||
expect(Object.keys(admission.entries)).toContain('xl/worksheets/sheet1.xml');
|
||||
expect(Object.keys(fflate.unzipSync(core.buildAdmittedArchive(admission, fflate)))).toContain(
|
||||
'xl/worksheets/sheet1.xml'
|
||||
);
|
||||
|
||||
// `/xl/styles.xml` is ExcelJS's `xl/styles.xml`, so its <xf> tags count.
|
||||
const styles = fflate.unzipSync(workbookZip());
|
||||
const xf = styles['xl/styles.xml'];
|
||||
delete styles['xl/styles.xml'];
|
||||
const leading = fflate.zipSync({ ...styles, '/xl/styles.xml': xf });
|
||||
expect(() => core.admitXlsx(leading, fflate, { maxStyles: 0 })).toThrowError(/styles limit/i);
|
||||
});
|
||||
|
||||
it('charges a merged range its full area and refuses one that does not parse', () => {
|
||||
const merged = (ref: string) =>
|
||||
workbookZip(
|
||||
`<worksheet><sheetData><row><c r="A1"/></row></sheetData><mergeCells><mergeCell ref="${ref}"/></mergeCells></worksheet>`
|
||||
);
|
||||
expect(() => core.admitXlsx(merged('A1:CV30000'), fflate)).toThrowError(/cells limit/i);
|
||||
// Reversed corners describe the same area.
|
||||
expect(() => core.admitXlsx(merged('CV30000:A1'), fflate)).toThrowError(/cells limit/i);
|
||||
expect(() => core.admitXlsx(merged('A1:XFE2'), fflate)).toThrowError(/merged range/i);
|
||||
expect(() => core.admitXlsx(merged('not-a-range'), fflate)).toThrowError(/merged range/i);
|
||||
const admitted = core.admitXlsx(merged('A1:J10'), fflate) as { counts: { cells: number } };
|
||||
expect(admitted.counts.cells).toBe(101);
|
||||
});
|
||||
|
||||
it('refuses a <col> whose min or max is past the last Excel column', () => {
|
||||
const cols = (attrs: string) =>
|
||||
workbookZip(
|
||||
`<worksheet><cols><col ${attrs} width="9"/></cols><sheetData><row><c r="A1"/></row></sheetData></worksheet>`
|
||||
);
|
||||
expect(() => core.admitXlsx(cols('min="1" max="3000000"'), fflate)).toThrowError(/column max/i);
|
||||
expect(() => core.admitXlsx(cols('min="16385" max="16385"'), fflate)).toThrowError(/column min/i);
|
||||
expect(() => core.admitXlsx(cols('min="1" max="1e9"'), fflate)).toThrowError(/column max/i);
|
||||
expect(() => core.admitXlsx(cols('min="1" max="16384"'), fflate)).not.toThrow();
|
||||
});
|
||||
|
||||
it('reads a merge or col tag whole even when a stream chunk boundary cuts through it', () => {
|
||||
// Markup on both sides, so a cut can land 128+ bytes past the tag's start.
|
||||
const pad = '<sheetView workbookViewId="0"/>'.repeat(12);
|
||||
const cases: Array<[string, RegExp]> = [
|
||||
[`<worksheet>${pad}<cols><col min="1" max="99999"/></cols>${pad}</worksheet>`, /column max/i],
|
||||
[`<worksheet>${pad}<cols><col x=">" min="1" max="99999"/></cols>${pad}</worksheet>`, /column max/i],
|
||||
[`<worksheet>${pad}<mergeCells><mergeCell ref="A1:CV30000"/></mergeCells>${pad}</worksheet>`, /cells limit/i],
|
||||
];
|
||||
for (const [xml, pattern] of cases) {
|
||||
const bytes = fflate.strToU8(xml);
|
||||
for (let cut = 1; cut < bytes.length; cut += 1) {
|
||||
const counter = core.createXmlCounter(
|
||||
'xl/worksheets/sheet1.xml',
|
||||
{ cells: 0, merges: 0, styles: 0 },
|
||||
core.LIMITS
|
||||
);
|
||||
expect(() => {
|
||||
counter.push(bytes.subarray(0, cut), false);
|
||||
counter.push(bytes.subarray(cut), true);
|
||||
}, `cut at ${cut}`).toThrowError(pattern);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('reads attributes in order, so a quoted value cannot hide or fake one', () => {
|
||||
const counter = () =>
|
||||
core.createXmlCounter(
|
||||
'xl/worksheets/sheet1.xml',
|
||||
{ cells: 0, merges: 0, styles: 0, rows: 0 } as never,
|
||||
core.LIMITS
|
||||
);
|
||||
const push = (xml: string) => counter().push(fflate.strToU8(xml), true);
|
||||
// A raw `>` or the other quote character is legal inside a value.
|
||||
expect(() => push(`<mergeCell x=' ref="A1"' ref="A1:CV30000"/>`)).toThrowError(/cells limit/i);
|
||||
expect(() => push('<col x=">" min="1" max="3000000"/>')).toThrowError(/column max/i);
|
||||
expect(() => push(`<col x=' max="1"' min="1" max="3000000"/>`)).toThrowError(/column max/i);
|
||||
// Anything the walk cannot read up to `>` is refused, as is a repeated name.
|
||||
expect(() => push('<mergeCell ref="A1:B2" junk/>')).toThrowError(/do not parse/i);
|
||||
expect(() => push('<mergeCell ref="A1" ref="A1:CV30000"/>')).toThrowError(/do not parse/i);
|
||||
expect(() => push('<cols><col min="1" max="3" width="9"></col></cols><mergeCell ref="A1:B2" />')).not.toThrow();
|
||||
});
|
||||
|
||||
it('counts every <row>, empty or not, against per-sheet and total caps', () => {
|
||||
const rows = (n: number) => '<worksheet><sheetData>' + '<row r="1"/>'.repeat(n) + '</sheetData></worksheet>';
|
||||
expect(() => core.admitXlsx(workbookZip(rows(4)), fflate, { maxRowsPerSheet: 3 })).toThrowError(/rows limit/i);
|
||||
expect(() => core.admitXlsx(workbookZip(rows(4)), fflate, { maxRows: 3 })).toThrowError(/rows limit/i);
|
||||
const admitted = core.admitXlsx(workbookZip(rows(3)), fflate, { maxRowsPerSheet: 3 }) as {
|
||||
counts: { rows: number };
|
||||
};
|
||||
expect(admitted.counts.rows).toBe(3);
|
||||
// <rowBreaks>/<rows...> style names are not rows.
|
||||
expect(
|
||||
(core.admitXlsx(workbookZip('<worksheet><rowBreaks/></worksheet>'), fflate) as { counts: { rows: number } })
|
||||
.counts.rows
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
// ExcelJS stores a row at `_rows[r - 1]` and walks `_rows` up to the largest
|
||||
// index on every eachRow and `sheet.model`, so the index a row CLAIMS is cost.
|
||||
it('refuses a <row r> that is not plain digits in 1-1048576 and a <row> whose attributes do not parse', () => {
|
||||
const sheet = (rowTag: string) =>
|
||||
workbookZip(`<worksheet><sheetData><row r="1"><c r="A1"/></row>${rowTag}</sheetData></worksheet>`);
|
||||
for (const r of ['50000000', '1048577', '0', '1a', '-1', '1e3', ' 2', '']) {
|
||||
expect(() => core.admitXlsx(sheet(`<row r="${r}"/>`), fflate), r).toThrowError(/row index/i);
|
||||
}
|
||||
expect(() => core.admitXlsx(sheet('<row r="2" junk/>'), fflate)).toThrowError(/do not parse/i);
|
||||
// A quoted fake `r` cannot stand in for the real one.
|
||||
expect(() => core.admitXlsx(sheet(`<row x=' r="2"' r="50000000"/>`), fflate)).toThrowError(/row index/i);
|
||||
for (const ok of ['<row r="1048576"/>', '<row/>', '<row spans="1:1"><c r="A2"/></row>']) {
|
||||
expect(() => core.admitXlsx(sheet(ok), fflate), ok).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
// ExcelJS stores a sheet at `_worksheets[sheetId]`, and the `worksheets`
|
||||
// getter slices and sorts that array, so a large id allocates a huge array.
|
||||
it('refuses a <sheet sheetId> in xl/workbook.xml that is not plain digits or is above the cap', () => {
|
||||
expect(core.LIMITS.maxSheetId).toBe(65535);
|
||||
const book = (sheets: string, name = 'xl/workbook.xml') => {
|
||||
const entries = fflate.unzipSync(workbookZip());
|
||||
delete entries['xl/workbook.xml'];
|
||||
entries[name] = fflate.strToU8(`<workbook><sheets>${sheets}</sheets></workbook>`);
|
||||
return fflate.zipSync(entries);
|
||||
};
|
||||
const one = (id: string) => `<sheet name="S" sheetId="${id}" r:id="rId1"/>`;
|
||||
for (const id of ['30000000', '65536', '1a', '-1', '1e3', ' 1', '']) {
|
||||
expect(() => core.admitXlsx(book(one(id)), fflate), id).toThrowError(/sheetId/i);
|
||||
}
|
||||
// Every <sheet> is read, not just the first; the name is the one ExcelJS sees.
|
||||
expect(() => core.admitXlsx(book(one('1') + one('30000000')), fflate)).toThrowError(/sheetId/i);
|
||||
expect(() => core.admitXlsx(book(one('30000000'), '/xl/./workbook.xml'), fflate)).toThrowError(/sheetId/i);
|
||||
expect(() => core.admitXlsx(book('<sheet name="S" sheetId="1" junk/>'), fflate)).toThrowError(/do not parse/i);
|
||||
expect(() => core.admitXlsx(book(`<sheet x=' sheetId="1"' sheetId="30000000"/>`), fflate)).toThrowError(/sheetId/i);
|
||||
// `<sheets>` is the container, never a sheet; an absent sheetId is harmless.
|
||||
expect(() => core.admitXlsx(book(one('1') + one('65535') + '<sheet name="S"/>'), fflate)).not.toThrow();
|
||||
// Only the workbook part is read this way.
|
||||
const counts = { cells: 0, merges: 0, styles: 0, rows: 0 };
|
||||
const other = core.createXmlCounter('xl/other.xml', counts as never, core.LIMITS);
|
||||
expect(() => other.push(fflate.strToU8(one('30000000')), true)).not.toThrow();
|
||||
});
|
||||
|
||||
// ExcelJS builds an object per element in every part it parses, not only
|
||||
// worksheets: each <r> run and <si> in sharedStrings.xml, each <font>, <fill>
|
||||
// and <border> in styles, comments, drawings, VML and tables.
|
||||
it('budgets every start tag outside xl/media, whatever part it sits in', () => {
|
||||
expect(core.LIMITS.maxElements).toBe(2_000_000);
|
||||
const withPart = (name: string, xml: string) =>
|
||||
fflate.zipSync({ ...fflate.unzipSync(workbookZip()), [name]: fflate.strToU8(xml) });
|
||||
const elementsOf = (zip: Uint8Array) =>
|
||||
(core.admitXlsx(zip, fflate) as { counts: { elements: number } }).counts.elements;
|
||||
const base = elementsOf(workbookZip());
|
||||
const runs = '<sst><si>' + '<r/>'.repeat(50) + '</si></sst>';
|
||||
const fonts = '<styleSheet><fonts>' + '<font/>'.repeat(50) + '</fonts></styleSheet>';
|
||||
const cases: Array<[string, string, number]> = [
|
||||
['xl/sharedStrings.xml', runs, 52],
|
||||
['xl/comments1.xml', '<comments>' + '<comment/>'.repeat(50) + '</comments>', 51],
|
||||
['xl/drawings/vmlDrawing1.vml', '<xml>' + '<v:shape/>'.repeat(50) + '</xml>', 51],
|
||||
// The name ExcelJS sees, so `/xl/./` cannot slip a part past the budget.
|
||||
['/xl/./sharedStrings.xml', runs, 52],
|
||||
];
|
||||
for (const [name, xml, added] of cases) {
|
||||
expect(elementsOf(withPart(name, xml)), name).toBe(base + added);
|
||||
let thrown: unknown;
|
||||
try {
|
||||
core.admitXlsx(withPart(name, xml), fflate, { maxElements: base + added - 1 });
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
expect((thrown as { code?: string })?.code, name).toBe('element-limit');
|
||||
}
|
||||
// styles.xml is counted on top of its own <xf>/<numFmt> checks.
|
||||
const styled = fflate.unzipSync(workbookZip());
|
||||
styled['xl/styles.xml'] = fflate.strToU8(fonts);
|
||||
expect(() => core.admitXlsx(fflate.zipSync(styled), fflate, { maxElements: 50 })).toThrowError(/element/i);
|
||||
// Processing instructions, comments and end tags are not elements; a tag
|
||||
// INSIDE a comment is still counted, which errs toward refusing.
|
||||
expect(elementsOf(withPart('xl/other.xml', '<?xml version="1.0"?><!-- c --><a></a><_b/>'))).toBe(base + 2);
|
||||
expect(elementsOf(withPart('xl/other.xml', '<!-- <a> --><a/>'))).toBe(base + 2);
|
||||
});
|
||||
|
||||
it('never budgets an xl/media part, which ExcelJS keeps as bytes', () => {
|
||||
// Stored, so the compression-ratio cap stays out of the way.
|
||||
const tags = fflate.strToU8('<r/>'.repeat(500));
|
||||
for (const name of ['xl/media/image1.png', '/xl/./media/image1.jpeg']) {
|
||||
const zip = fflate.zipSync({ ...fflate.unzipSync(workbookZip()), [name]: [tags, { level: 0 }] });
|
||||
expect(() => core.admitXlsx(zip, fflate, { maxElements: 100 }), name).not.toThrow();
|
||||
}
|
||||
// A name under xl/media/ that ExcelJS parses as XML (its patterns are
|
||||
// unanchored) is budgeted like any other part.
|
||||
for (const name of ['xl/media/xl/drawings/drawing1.xml', 'xl/media/xl/worksheets/sheet2.xml']) {
|
||||
const zip = fflate.zipSync({ ...fflate.unzipSync(workbookZip()), [name]: [tags, { level: 0 }] });
|
||||
expect(() => core.admitXlsx(zip, fflate, { maxElements: 100 }), name).toThrowError(/element/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('counts start tags exactly when a chunk boundary cuts through one', () => {
|
||||
const xml = '<sst><si>' + '<r/><t>x</t>'.repeat(20) + '</si></sst>';
|
||||
const bytes = fflate.strToU8(xml);
|
||||
for (const name of ['xl/sharedStrings.xml', 'xl/worksheets/sheet1.xml', 'xl/styles.xml']) {
|
||||
for (let cut = 1; cut < bytes.length; cut += 1) {
|
||||
const counts = { worksheets: 0, cells: 0, rows: 0, merges: 0, styles: 0, elements: 0 };
|
||||
const counter = core.createXmlCounter(name, counts as never, core.LIMITS);
|
||||
counter.push(bytes.subarray(0, cut), false);
|
||||
counter.push(bytes.subarray(cut), true);
|
||||
expect(counts.elements, `${name} cut at ${cut}`).toBe(42);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not carry long text or binary in a non-worksheet part as an oversized tag', () => {
|
||||
const counts = { worksheets: 0, cells: 0, rows: 0, merges: 0, styles: 0, elements: 0 };
|
||||
const text = fflate.strToU8('<sst><si><t>' + 'x'.repeat(600_000) + '</t></si></sst>');
|
||||
const strings = core.createXmlCounter('xl/sharedStrings.xml', counts as never, core.LIMITS);
|
||||
const binary = core.createXmlCounter('xl/embeddings/oleObject1.bin', counts as never, core.LIMITS);
|
||||
const zeros = new Uint8Array(600_000);
|
||||
expect(() => {
|
||||
for (let at = 0; at < text.length; at += 65_536) {
|
||||
strings.push(text.subarray(at, at + 65_536), at + 65_536 >= text.length);
|
||||
}
|
||||
for (let at = 0; at < zeros.length; at += 65_536) {
|
||||
binary.push(zeros.subarray(at, at + 65_536), at + 65_536 >= zeros.length);
|
||||
}
|
||||
}).not.toThrow();
|
||||
expect(counts.elements).toBe(3);
|
||||
});
|
||||
|
||||
it('counts every <xf> in styles.xml, so a </cellXfs> inside a comment cannot hide styles', () => {
|
||||
const counts = { cells: 0, merges: 0, styles: 0, rows: 0 };
|
||||
const counter = core.createXmlCounter('xl/styles.xml', counts as never, { ...core.LIMITS, maxStyles: 100 });
|
||||
const xml = '<styleSheet><cellXfs><!-- </cellXfs> -->' + '<xf/>'.repeat(200) + '</cellXfs></styleSheet>';
|
||||
expect(() => counter.push(fflate.strToU8(xml), true)).toThrowError(/styles limit/i);
|
||||
});
|
||||
|
||||
// ExcelJS's `_mergeCellsInternal` checks every new merge against every earlier
|
||||
// one on its sheet, so a sheet costs the SQUARE of its merge count: one sheet
|
||||
// at the old 5,000 cap took 1.6 s, and 20 such sheets ran past the page timeout.
|
||||
it('caps merges per sheet and across the workbook', () => {
|
||||
expect(core.LIMITS.maxMergesPerSheet).toBe(2000);
|
||||
expect(core.LIMITS.maxMerges).toBe(10000);
|
||||
const merges = (n: number, row = 1) =>
|
||||
Array.from({ length: n }, (_, i) => `<mergeCell ref="A${row + i * 2}:A${row + i * 2 + 1}"/>`).join('');
|
||||
const sheetXml = (n: number) => `<worksheet><sheetData/><mergeCells>${merges(n)}</mergeCells></worksheet>`;
|
||||
expect(() => core.admitXlsx(workbookZip(sheetXml(4)), fflate, { maxMergesPerSheet: 3 })).toThrowError(
|
||||
/merged ranges limit/i
|
||||
);
|
||||
expect(() => core.admitXlsx(workbookZip(sheetXml(3)), fflate, { maxMergesPerSheet: 3 })).not.toThrow();
|
||||
// Two sheets each under the per-sheet cap still trip the workbook-wide one.
|
||||
const twoSheets = fflate.zipSync({
|
||||
...fflate.unzipSync(workbookZip(sheetXml(3))),
|
||||
'xl/worksheets/sheet2.xml': fflate.strToU8(sheetXml(3)),
|
||||
});
|
||||
expect(() => core.admitXlsx(twoSheets, fflate, { maxMergesPerSheet: 3, maxMerges: 5 })).toThrowError(
|
||||
/merged ranges limit/i
|
||||
);
|
||||
expect(() => core.admitXlsx(twoSheets, fflate, { maxMergesPerSheet: 3, maxMerges: 6 })).not.toThrow();
|
||||
expect(() => core.admitXlsx(twoSheets, fflate, { maxMergesPerSheet: 3, maxMerges: 5 })).toThrowError(
|
||||
expect.objectContaining({ code: 'merge-limit' })
|
||||
);
|
||||
});
|
||||
|
||||
// ExcelJS runs `isDateFmt` once per numeric cell, and its first step,
|
||||
// `fmt.replace(/\[[^\]]*]/g, '')`, rescans to the end of the code for every
|
||||
// `[` with no later `]`. The code is also echoed into the notice bar.
|
||||
it('refuses a <numFmt> formatCode over 255 characters or with a [ after its last ]', () => {
|
||||
const styles = (numFmts: string) => {
|
||||
const counts = { cells: 0, merges: 0, styles: 0, rows: 0 };
|
||||
const counter = core.createXmlCounter('xl/styles.xml', counts as never, core.LIMITS);
|
||||
const xml = `<styleSheet><numFmts count="1">${numFmts}</numFmts><cellXfs count="1"><xf/></cellXfs></styleSheet>`;
|
||||
return () => counter.push(fflate.strToU8(xml), true);
|
||||
};
|
||||
const fmt = (code: string) => `<numFmt numFmtId="164" formatCode="${code}"/>`;
|
||||
expect(styles(fmt('0'.repeat(256)))).toThrowError(/longer than 255/i);
|
||||
expect(styles(fmt('0'.repeat(255)))).not.toThrow();
|
||||
for (const code of ['[', '0[', '[Red]0[', '[[[[', '[Red]0.00;[']) {
|
||||
expect(styles(fmt(code)), code).toThrowError(/unclosed bracket/i);
|
||||
}
|
||||
for (const code of ['[Red]0.00', '[$-409]mmm d, yyyy', '[h]:mm:ss', '0.00', '#,##0;[Red]-#,##0', ']', '[[]']) {
|
||||
expect(styles(fmt(code)), code).not.toThrow();
|
||||
}
|
||||
// The code is checked as ExcelJS decodes it: an entity cannot hide a `[`,
|
||||
// and an entity-heavy code is measured by its decoded length.
|
||||
expect(styles(fmt('0['))).toThrowError(/unclosed bracket/i);
|
||||
expect(styles(fmt('0['))).toThrowError(/unclosed bracket/i);
|
||||
expect(styles(fmt('"x"'.repeat(60)))).not.toThrow();
|
||||
expect(styles(fmt('&'.repeat(256)))).toThrowError(/longer than 255/i);
|
||||
// Attributes are read in order, so a quoted fake formatCode cannot mask the real one.
|
||||
expect(styles(`<numFmt x=' formatCode="0"' numFmtId="164" formatCode="0["/>`)).toThrowError(/unclosed bracket/i);
|
||||
expect(styles('<numFmt numFmtId="164" formatCode="0" junk/>')).toThrowError(/do not parse/i);
|
||||
// `<numFmts>` is the container, not a format.
|
||||
expect(styles('')).not.toThrow();
|
||||
// The refusal never echoes the code itself.
|
||||
for (const code of ['QZJX[', `${'QZJX'.repeat(64)}0`]) {
|
||||
expect(styles(fmt(code))).toThrowError(
|
||||
expect.objectContaining({ code: 'number-format', message: expect.not.stringContaining('QZJX') })
|
||||
);
|
||||
}
|
||||
// Every <numFmt> in the file is read, wherever it sits (dxfs carry them too).
|
||||
const counts = { cells: 0, merges: 0, styles: 0, rows: 0 };
|
||||
const counter = core.createXmlCounter('xl/styles.xml', counts as never, core.LIMITS);
|
||||
const dxf = `<styleSheet><dxfs count="1"><dxf>${fmt('0[')}</dxf></dxfs></styleSheet>`;
|
||||
expect(() => counter.push(fflate.strToU8(dxf), true)).toThrowError(/unclosed bracket/i);
|
||||
});
|
||||
|
||||
it('caps cell display text at 1,000 characters for every value shape', () => {
|
||||
const long = 'x'.repeat(50_000);
|
||||
const shapes: Array<[string, unknown]> = [
|
||||
['string', long],
|
||||
['rich text', { richText: [{ text: long }, { text: long }] }],
|
||||
['hyperlink', { text: long, hyperlink: 'https://example.invalid/' }],
|
||||
['rich hyperlink', { text: { richText: [{ text: long }] }, hyperlink: 'https://example.invalid/' }],
|
||||
['formula source', { formula: long }],
|
||||
['formula result', { formula: 'A1', result: long }],
|
||||
['error', { error: long }],
|
||||
];
|
||||
for (const [label, value] of shapes) {
|
||||
const { text } = core.formatCellValue(value, 'General');
|
||||
expect(text.length, label).toBeLessThanOrEqual(1000);
|
||||
expect(text.endsWith('…'), label).toBe(true);
|
||||
}
|
||||
expect(core.formatCellValue('y'.repeat(1000), 'General').text).toBe('y'.repeat(1000));
|
||||
expect(core.formatCellValue({ richText: [{ text: 'a' }, { text: 'b' }] }, 'General').text).toBe('ab');
|
||||
// A cut never leaves half of a surrogate pair.
|
||||
// 'aa' puts a high surrogate at index 998, exactly where a naive cut lands.
|
||||
const emoji = core.formatCellValue('aa' + '😀'.repeat(600), 'General').text;
|
||||
expect(emoji.length).toBeLessThanOrEqual(1000);
|
||||
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(emoji)).toBe(false);
|
||||
});
|
||||
|
||||
// An empty run adds no text, so stopping on the text length alone still
|
||||
// visited every run, once per cell referencing the shared string, per tile.
|
||||
it('visits at most maxCellTextChars + 1 rich-text runs, however many are empty', () => {
|
||||
const bound = core.LIMITS.maxCellTextChars + 1;
|
||||
const runs: Array<{ text: string }> = Array.from({ length: 5_000 }, () => ({ text: '' }));
|
||||
runs[0] = { text: 'head' };
|
||||
for (let index = bound; index < runs.length; index += 1) {
|
||||
Object.defineProperty(runs, index, {
|
||||
get() {
|
||||
throw new Error(`rich-text run ${index} visited`);
|
||||
},
|
||||
});
|
||||
}
|
||||
expect(core.formatCellValue({ richText: runs }, 'General')).toEqual({ text: 'head' });
|
||||
// A run inside the bound still contributes.
|
||||
runs[bound - 1] = { text: 'tail' };
|
||||
expect(core.formatCellValue({ richText: runs }, 'General').text).toBe('headtail');
|
||||
});
|
||||
|
||||
it('refuses an entry whose declared compressed size runs past the file', () => {
|
||||
const zip = workbookZip();
|
||||
const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength);
|
||||
const eocd = zip.length - 22;
|
||||
const centralStart = view.getUint32(eocd + 16, true);
|
||||
view.setUint32(centralStart + 20, 0x7fffffff, true);
|
||||
expect(() => core.inspectZipDirectory(zip)).toThrowError(/compressed bytes/i);
|
||||
});
|
||||
|
||||
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('matches a plain walk over the overrides at every index', () => {
|
||||
const overrides: Array<[number, number]> = [];
|
||||
for (let index = 3; index <= 400; index += 7) overrides.push([index, (index * 13) % 50]);
|
||||
const axis = core.createSparseAxis(500, 20, overrides);
|
||||
for (let index = 0; index <= 502; index += 1) {
|
||||
const bounded = Math.max(1, Math.min(501, index));
|
||||
let expected = (bounded - 1) * 20;
|
||||
for (const [at, size] of overrides) if (at < bounded) expected += size - 20;
|
||||
expect(core.axisOffset(axis, index), `index ${index}`).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
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)' });
|
||||
});
|
||||
|
||||
// Excel displays at most 15 significant digits; String() printed the binary noise.
|
||||
it('shows General and unsupported-format numbers at 15 significant digits, as Excel does', () => {
|
||||
expect(core.formatCellValue(0.1 + 0.2, 'General').text).toBe('0.3');
|
||||
expect(core.formatCellValue(10.1 * 3, 'General').text).toBe('30.3');
|
||||
expect(core.formatCellValue({ formula: '0.1+0.2', result: 0.1 + 0.2 }, 'General').text).toBe('0.3');
|
||||
expect(core.formatCellValue(0.1 + 0.2, '0.00E+00')).toEqual({
|
||||
text: '0.3',
|
||||
warning: 'Unsupported number format: 0.00E+00',
|
||||
});
|
||||
expect(core.formatCellValue(1234.5, 'General').text).toBe('1234.5');
|
||||
expect(core.formatCellValue(-42, 'General').text).toBe('-42');
|
||||
});
|
||||
|
||||
it('renders a time-only AM/PM format as a 12-hour time, not a date', () => {
|
||||
expect(core.formatCellValue(14.5 / 24, 'h:mm AM/PM')).toEqual({ text: '2:30 PM' });
|
||||
expect(core.formatCellValue(0, 'h:mm AM/PM').text).toBe('12:00 AM');
|
||||
expect(core.formatCellValue(0.5, 'h:mm AM/PM').text).toBe('12:00 PM');
|
||||
expect(core.formatCellValue(9.25 / 24, 'hh:mm:ss AM/PM').text).toBe('09:15:00 AM');
|
||||
// ExcelJS loads a time-formatted cell as a Date on the 1899-12-31 epoch day.
|
||||
expect(core.formatCellValue(new Date(Date.UTC(1899, 11, 31, 14, 30)), 'h:mm AM/PM')).toEqual({
|
||||
text: '2:30 PM',
|
||||
});
|
||||
});
|
||||
|
||||
it('folds unsupported number format warnings into one counted entry', () => {
|
||||
const many = Array.from({ length: 800 }, (_, index) => `Unsupported number format: 0.0${'0'.repeat(index)}E+0`);
|
||||
const folded = core.foldWarnings(['charts', ...many, 'Formula has no cached result']);
|
||||
expect(folded).toEqual(['charts', '800 unsupported number formats', 'Formula has no cached result']);
|
||||
expect(core.foldWarnings(['Unsupported number format: 0.00E+00'])).toEqual(['Unsupported number format: 0.00E+00']);
|
||||
expect(core.foldWarnings(['charts'])).toEqual(['charts']);
|
||||
});
|
||||
|
||||
// toLocaleString throws a RangeError above 100 fraction digits; Excel caps at 30.
|
||||
it('caps a number format at 30 decimals instead of throwing', () => {
|
||||
expect(core.formatCellValue(1.5, `0.${'0'.repeat(120)}`).text).toBe(`1.5${'0'.repeat(29)}`);
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
// The clrScheme and slot patterns rescan to the end of the text for every
|
||||
// unclosed opening tag, so a padded theme is quadratic.
|
||||
it('uses the default palette for a theme above 64 KB, even a 1 MB run of unclosed clrScheme tags', () => {
|
||||
const padded = '<a:clrScheme>'.repeat(Math.ceil((1024 * 1024) / 13));
|
||||
expect(padded.length).toBeGreaterThanOrEqual(1024 * 1024);
|
||||
const started = performance.now();
|
||||
expect(core.parseThemePalette(padded)).toEqual(core.DEFAULT_THEME_PALETTE);
|
||||
expect(performance.now() - started).toBeLessThan(1_000);
|
||||
|
||||
// The cap is on characters: a real theme padded to exactly 64 KB still parses, one more does not.
|
||||
const fill = (length: number) =>
|
||||
themeXml.replace('</a:theme>', `<!--${' '.repeat(length - themeXml.length - 7)}--></a:theme>`);
|
||||
expect(fill(64 * 1024)).toHaveLength(64 * 1024);
|
||||
expect(core.parseThemePalette(fill(64 * 1024))[4]).toBe('#ff0000');
|
||||
expect(core.parseThemePalette(fill(64 * 1024 + 1))).toEqual(core.DEFAULT_THEME_PALETTE);
|
||||
});
|
||||
|
||||
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({});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user