Skip to content

Commit ce88cc9

Browse files
chaxusclaude
andcommitted
refactor(test): use ranuts createZip/readZipEntries/readZipEntry instead of a hand-rolled zip writer and reader
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 26523e9 commit ce88cc9

9 files changed

Lines changed: 45 additions & 135 deletions

‎docs/changelogs/2026-08-15-v9-regression-campaign.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,14 @@ gh workflow run nightly-corpus.yml -f limit=300 # 手动触发夜间
283283
`bin/build.sh` 重新同步 vendored 资产。核查发现入库的六个 IIFE 与 alpha.2 **逐字节相同**
284284
(alpha.3 的修复在 colorpicker/player/math 等未 vendored 的组件),变化的是 ES 主入口
285285
`dist/index.js`;ran-tokens 指纹不变。537 单测 + 71 条 E2E 全绿。
286+
- **ranuts 沉淀(用户要求)**:先"反向采用"——测试库 `test/e2e/lib/ooxml.ts` 里手写的
287+
crc32 / stored-zip 写入 / 中央目录读取 / inflate 全部删掉,改用 ranuts 既有的
288+
`createZip` / `readZipEntries` / `readZipEntry`(读取按中央目录取长度,比手写更稳)。
289+
再"正向沉淀"——`chaxus/ran` PR #374 给 ranuts 新增 `utils/binary`:
290+
`bytesToBase64`/`base64ToBytes`(分块,避免 `String.fromCharCode.apply` 在 ~100 KB
291+
以上 RangeError——ranuts 自身 str.ts 也有这个隐患)、`isGzip`/`gunzipMaybe`/
292+
`fetchMaybeGzip`(同一份 .gz 资源在不同托管下可能已被浏览器解码,只能按魔数判断)、
293+
`isZipContainer`/`isHtmlDocument`(HTML 伪装成 .xls 的嗅探)、`decodeTextBytes`
294+
(BOM→utf-8→gb18030→latin1 严格链)、`saveFileToDisk`(File System Access + 锚点兜底,
295+
取消对话框 resolve false)。17 条新单测,ranuts 全量 765 通过。
296+
发布后本仓再把 converter/E2E 里的对应实现换成 ranuts 版本。

‎test/e2e/corpus.spec.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,9 @@ test.describe('real-document corpus matrix', () => {
175175
// L2 for word/slide OOXML inputs: text of the input vs the saved output
176176
// (Node side; the page hands over base64). Capped to keep the transfer
177177
// sane; bigger files report "skipped".
178-
await page.exposeFunction('__corpusTextCoverage', (inB64: string, outB64: string) => {
179-
const before = ooxmlDocumentText(new Uint8Array(Buffer.from(inB64, 'base64')));
180-
const after = ooxmlDocumentText(new Uint8Array(Buffer.from(outB64, 'base64')));
178+
await page.exposeFunction('__corpusTextCoverage', async (inB64: string, outB64: string) => {
179+
const before = await ooxmlDocumentText(new Uint8Array(Buffer.from(inB64, 'base64')));
180+
const after = await ooxmlDocumentText(new Uint8Array(Buffer.from(outB64, 'base64')));
181181
return { ...textCoverage(before, after), inLen: before.length, outLen: after.length };
182182
});
183183

‎test/e2e/docx-features.spec.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ test.describe('docx feature round trips (real editor)', () => {
4242
const out = new Uint8Array(
4343
Buffer.from(await roundTrip(page, 'revisions.docx', toBase64(buildDocx('', body))), 'base64'),
4444
);
45-
const xml = zipEntryText(out, 'word/document.xml') || '';
45+
const xml = (await zipEntryText(out, 'word/document.xml')) || '';
4646
expect(xml).toMatch(/<w:ins\b[^>]*w:author="Reviewer"/);
4747
expect(xml).toMatch(/<w:del\b[^>]*w:author="Reviewer"/);
4848
expect(xml).toContain('<w:delText');
@@ -66,8 +66,8 @@ test.describe('docx feature round trips (real editor)', () => {
6666
const footer = names.find((n) => /^word\/footer\d+\.xml$/.test(n));
6767
expect(header, `no header part in ${names.join(',')}`).toBeTruthy();
6868
expect(footer, `no footer part in ${names.join(',')}`).toBeTruthy();
69-
expect(ooxmlText(zipEntryText(out, header!) || '')).toContain('Header 页眉');
70-
expect(ooxmlText(zipEntryText(out, footer!) || '')).toContain('Footer 页脚');
71-
expect(ooxmlText(zipEntryText(out, 'word/document.xml') || '')).toContain('body text');
69+
expect(ooxmlText((await zipEntryText(out, header!)) || '')).toContain('Header 页眉');
70+
expect(ooxmlText((await zipEntryText(out, footer!)) || '')).toContain('Footer 页脚');
71+
expect(ooxmlText((await zipEntryText(out, 'word/document.xml')) || '')).toContain('body text');
7272
});
7373
});

‎test/e2e/docx-ruby.spec.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ test('the base word of a ruby annotation survives open + save', async ({ page })
3737
toBase64(buildDocx('', body)),
3838
);
3939

40-
const text = ooxmlText(zipEntryText(new Uint8Array(Buffer.from(b64, 'base64')), 'word/document.xml') || '');
40+
const text = ooxmlText((await zipEntryText(new Uint8Array(Buffer.from(b64, 'base64')), 'word/document.xml')) || '');
4141
expect(text).toContain('東京');
4242
expect(text).toContain('Go to');
4343
expect(text).toContain('tomorrow');

‎test/e2e/entry-paths.spec.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ test.describe('entry paths (real editor)', () => {
6060
const download = await downloadPromise;
6161
expect(download.suggestedFilename()).toBe('url-open.docx');
6262
const bytes = new Uint8Array(readFileSync(await download.path()));
63-
expect(ooxmlText(zipEntryText(bytes, 'word/document.xml') || '')).toContain('opened from a URL');
63+
expect(ooxmlText((await zipEntryText(bytes, 'word/document.xml')) || '')).toContain('opened from a URL');
6464
});
6565

6666
test('embed document:open-url fetches, opens and round-trips the document', async ({ page }) => {

‎test/e2e/lib/ooxml.ts‎

Lines changed: 18 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -6,77 +6,16 @@
66
* and no binary fixture lives in the repo.
77
*/
88

9-
import { inflateRawSync } from 'node:zlib';
9+
import { createZip, readZipEntries, readZipEntry } from 'ranuts/utils';
1010

11-
const CRC_TABLE = (() => {
12-
const table = new Uint32Array(256);
13-
for (let n = 0; n < 256; n++) {
14-
let c = n;
15-
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
16-
table[n] = c >>> 0;
17-
}
18-
return table;
19-
})();
20-
21-
function crc32(bytes: Uint8Array): number {
22-
let c = 0xffffffff;
23-
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
24-
return (c ^ 0xffffffff) >>> 0;
25-
}
26-
27-
export function makeStoredZip(entries: Array<{ name: string; data: Uint8Array | string }>): Uint8Array {
28-
const enc = new TextEncoder();
29-
const chunks: number[] = [];
30-
const central: number[] = [];
31-
const u16 = (arr: number[], v: number) => arr.push(v & 0xff, (v >> 8) & 0xff);
32-
const u32 = (arr: number[], v: number) => arr.push(v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff);
33-
for (const { name, data } of entries) {
34-
const nameBytes = enc.encode(name);
35-
const bytes = typeof data === 'string' ? enc.encode(data) : data;
36-
const crc = crc32(bytes);
37-
const offset = chunks.length;
38-
u32(chunks, 0x04034b50);
39-
u16(chunks, 20);
40-
u16(chunks, 0);
41-
u16(chunks, 0);
42-
u32(chunks, 0);
43-
u32(chunks, crc);
44-
u32(chunks, bytes.length);
45-
u32(chunks, bytes.length);
46-
u16(chunks, nameBytes.length);
47-
u16(chunks, 0);
48-
chunks.push(...nameBytes, ...bytes);
49-
50-
u32(central, 0x02014b50);
51-
u16(central, 20);
52-
u16(central, 20);
53-
u16(central, 0);
54-
u16(central, 0);
55-
u32(central, 0);
56-
u32(central, crc);
57-
u32(central, bytes.length);
58-
u32(central, bytes.length);
59-
u16(central, nameBytes.length);
60-
u16(central, 0);
61-
u16(central, 0);
62-
u16(central, 0);
63-
u16(central, 0);
64-
u32(central, 0);
65-
u32(central, offset);
66-
central.push(...nameBytes);
67-
}
68-
const centralOffset = chunks.length;
69-
chunks.push(...central);
70-
u32(chunks, 0x06054b50);
71-
u16(chunks, 0);
72-
u16(chunks, 0);
73-
u16(chunks, entries.length);
74-
u16(chunks, entries.length);
75-
u32(chunks, central.length);
76-
u32(chunks, centralOffset);
77-
u16(chunks, 0);
78-
return new Uint8Array(chunks);
79-
}
11+
/**
12+
* Archive plumbing comes from ranuts (`createZip` / `readZipEntries` /
13+
* `readZipEntry`): ecosystem first, and its reader takes sizes from the
14+
* central directory, which is what makes it survive streaming-written
15+
* archives. Only the OOXML-shaped helpers live here.
16+
*/
17+
export const makeStoredZip = (entries: ReadonlyArray<{ name: string; data: Uint8Array | string }>): Uint8Array =>
18+
createZip(entries);
8019

8120
const XML_HEAD = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8221

@@ -269,59 +208,15 @@ export function buildPptx(title: string): Uint8Array {
269208
]);
270209
}
271210

272-
type ZipEntry = { name: string; method: number; compressedSize: number; localOffset: number };
273-
274-
function zipEntries(bytes: Uint8Array): ZipEntry[] {
275-
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
276-
let eocd = -1;
277-
for (let i = bytes.length - 22; i >= 0 && i >= bytes.length - 65557; i--) {
278-
if (view.getUint32(i, true) === 0x06054b50) {
279-
eocd = i;
280-
break;
281-
}
282-
}
283-
if (eocd < 0) return [];
284-
const count = view.getUint16(eocd + 10, true);
285-
let off = view.getUint32(eocd + 16, true);
286-
const entries: ZipEntry[] = [];
287-
const dec = new TextDecoder();
288-
for (let n = 0; n < count; n++) {
289-
if (view.getUint32(off, true) !== 0x02014b50) break;
290-
const method = view.getUint16(off + 10, true);
291-
const compressedSize = view.getUint32(off + 20, true);
292-
const nameLen = view.getUint16(off + 28, true);
293-
const extraLen = view.getUint16(off + 30, true);
294-
const commentLen = view.getUint16(off + 32, true);
295-
const localOffset = view.getUint32(off + 42, true);
296-
entries.push({
297-
name: dec.decode(bytes.subarray(off + 46, off + 46 + nameLen)),
298-
method,
299-
compressedSize,
300-
localOffset,
301-
});
302-
off += 46 + nameLen + extraLen + commentLen;
303-
}
304-
return entries;
305-
}
306-
307211
/** Entry names of a zip (central directory walk); enough for L1 checks. */
308212
export function zipEntryNames(bytes: Uint8Array): string[] {
309-
return zipEntries(bytes).map((e) => e.name);
213+
return readZipEntries(bytes).map((e) => e.name);
310214
}
311215

312216
/** Decoded text of one zip entry (stored or deflated) -- for L2 content checks on OOXML parts. */
313-
export function zipEntryText(bytes: Uint8Array, name: string): string | null {
314-
const entry = zipEntries(bytes).find((e) => e.name === name);
315-
if (!entry) return null;
316-
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
317-
const lo = entry.localOffset;
318-
if (view.getUint32(lo, true) !== 0x04034b50) return null;
319-
const nameLen = view.getUint16(lo + 26, true);
320-
const extraLen = view.getUint16(lo + 28, true);
321-
const start = lo + 30 + nameLen + extraLen;
322-
const raw = bytes.subarray(start, start + entry.compressedSize);
323-
const data = entry.method === 8 ? new Uint8Array(inflateRawSync(raw)) : raw;
324-
return new TextDecoder().decode(data);
217+
export async function zipEntryText(bytes: Uint8Array, name: string): Promise<string | null> {
218+
const data = await readZipEntry(bytes, name);
219+
return data ? new TextDecoder().decode(data) : null;
325220
}
326221

327222
const XML_ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
@@ -438,7 +333,7 @@ export function buildXlsx(opts: {
438333
* fingerprints and other invisible off-slide text boxes that OnlyOffice
439334
* drops on save (vendor behavior, not user-visible content).
440335
*/
441-
export function ooxmlDocumentText(bytes: Uint8Array): string {
336+
export async function ooxmlDocumentText(bytes: Uint8Array): Promise<string> {
442337
const names = zipEntryNames(bytes);
443338
const parts = names.includes('word/document.xml')
444339
? ['word/document.xml']
@@ -454,7 +349,10 @@ export function ooxmlDocumentText(bytes: Uint8Array): string {
454349
// Ruby guide text (<w:rt>) is a known loss on save (the base word is
455350
// preserved by preprocessDocxRuby); compare base text only.
456351
.replace(/<w:rt\b[\s\S]*?<\/w:rt>/g, '');
457-
return parts.map((n) => ooxmlText(withoutFields(withoutHidden(zipEntryText(bytes, n) || '')))).join('\n');
352+
const texts = await Promise.all(
353+
parts.map(async (n) => ooxmlText(withoutFields(withoutHidden((await zipEntryText(bytes, n)) || '')))),
354+
);
355+
return texts.join('\n');
458356
}
459357

460358
/**

‎test/e2e/main-site.spec.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ test.describe('standalone site (real editor)', () => {
6868
const saved = await download.path();
6969
const bytes = new Uint8Array(readFileSync(saved));
7070
expect(bytes[0]).toBe(0x50);
71-
expect(ooxmlText(zipEntryText(bytes, 'word/document.xml') || '')).toContain('main site paragraph typed');
71+
expect(ooxmlText((await zipEntryText(bytes, 'word/document.xml')) || '')).toContain('main site paragraph typed');
7272
});
7373

7474
test('New Excel from the hero creates a blank workbook that saves via Ctrl+S', async ({ page }) => {
@@ -91,6 +91,6 @@ test.describe('standalone site (real editor)', () => {
9191
expect(download.suggestedFilename()).toBe('New_Document.xlsx');
9292
const bytes = new Uint8Array(readFileSync(await download.path()));
9393
expect(bytes[0]).toBe(0x50);
94-
expect(zipEntryText(bytes, 'xl/sharedStrings.xml') || '').toContain('hello');
94+
expect((await zipEntryText(bytes, 'xl/sharedStrings.xml')) || '').toContain('hello');
9595
});
9696
});

‎test/e2e/resave-idempotence.spec.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ test.describe('re-open / re-save idempotence (real editor)', () => {
8787
const names = zipEntryNames(bytes);
8888
expect(names).toContain('word/document.xml');
8989
expect(names).toContain('[Content_Types].xml');
90-
expect(ooxmlText(zipEntryText(bytes, 'word/document.xml') || '')).toContain('idempotent paragraph 往返');
90+
expect(ooxmlText((await zipEntryText(bytes, 'word/document.xml')) || '')).toContain('idempotent paragraph 往返');
9191
});
9292

9393
test('pptx: two round trips keep the slide and its title', async ({ page }) => {
@@ -106,6 +106,6 @@ test.describe('re-open / re-save idempotence (real editor)', () => {
106106
expect(names).toContain('ppt/presentation.xml');
107107
const slides = names.filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n));
108108
expect(slides).toHaveLength(1);
109-
expect(ooxmlText(zipEntryText(bytes, slides[0]) || '')).toContain('Idempotent Title 标题');
109+
expect(ooxmlText((await zipEntryText(bytes, slides[0])) || '')).toContain('Idempotent Title 标题');
110110
});
111111
});

‎test/e2e/xlsx-panes.spec.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ test.describe('xlsx frozen panes / autofilter (real editor)', () => {
4747
return btoa(s);
4848
}, workbook());
4949
const bytes = new Uint8Array(Buffer.from(b64, 'base64'));
50-
const sheet = zipEntryText(bytes, 'xl/worksheets/sheet1.xml') || '';
50+
const sheet = (await zipEntryText(bytes, 'xl/worksheets/sheet1.xml')) || '';
5151
expect(sheet).toMatch(/<pane[^>]*state="frozen"/);
5252
expect(sheet).toMatch(/xSplit="1"/);
5353
expect(sheet).toMatch(/ySplit="1"/);
@@ -105,7 +105,8 @@ test.describe('xlsx frozen panes / autofilter (real editor)', () => {
105105
),
106106
);
107107
expect(result.error).toBeUndefined();
108-
const sheet = zipEntryText(new Uint8Array(Buffer.from(result.b64!, 'base64')), 'xl/worksheets/sheet1.xml') || '';
108+
const sheet =
109+
(await zipEntryText(new Uint8Array(Buffer.from(result.b64!, 'base64')), 'xl/worksheets/sheet1.xml')) || '';
109110
expect(sheet).toMatch(/<pane[^>]*state="frozen"/);
110111
});
111112
});

0 commit comments

Comments
 (0)