mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
- Normalize the value shapes ExcelJS loads before formatting: Date cells are formatted from their serial (UTC), so they no longer render as a local-time string a day early at negative UTC offsets; rich text joins its runs, hyperlinks show their text, error values show the error, and formula and shared-formula results (including error results) recurse. Excel serials are rounded to whole milliseconds so 00:05 no longer shows as 00:04. - sendTile() skips hidden rows and columns, and at the 2500-cell cap returns a truncated tile with a warning instead of failing the whole preview. - ExcelJS now parses a STORE-only archive rebuilt from exactly the entries admitXlsx() inflated and counted, never the fetched bytes. Admission walks local headers while JSZip reads the central directory, so overlapping entries could show the two readers different sheets. A duplicate local entry name is refused. The theme fallback reads the admitted entry too. - Row and column headings take their size from the same axis math as cells. - Document the admission, worker-only loading and SPREADSHEET_ASSET_VERSION rules in architecture-invariants, and list .xlsx in the attachments panel help and the `codeman attach` error text (built from the accepted list).
152 lines
6.6 KiB
TypeScript
152 lines
6.6 KiB
TypeScript
/** @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();
|
|
});
|
|
});
|