Skip to content

Commit c2c2bfe

Browse files
chaxusclaude
andcommitted
test(v9): image insert + save for xlsx/pptx; font-cache regression guard (second open served from cache), added to the production smoke set
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a2a4010 commit c2c2bfe

5 files changed

Lines changed: 173 additions & 0 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,6 @@ gh workflow run nightly-corpus.yml -f limit=300 # 手动触发夜间
205205
- 新增:docx/pptx 运行时只读切换(format-parity)、入口路径 `?file=`/`open-url`/`?open=local`
206206
(entry-paths.spec,跨域真实 fetch);corpus 视觉失败时落盘两张截图,旧二进制输入
207207
阈值 10% 标 `ok-legacy`(.ppt→pptx 行距略紧,目测内容一致,P3)。
208+
- 新增 image-insert.spec(xlsx/pptx URL 插图后保存含 media)、font-cache.spec(第二次打开
209+
字体全部走缓存,回归守护另一会话修的线上字体无缓存问题;进线上冒烟集);
210+
pdf-route.spec 也进线上冒烟集。

‎docs/test-matrix.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ embed-save-default.spec、`HX` = html-as-xls.spec、`FN` = filename-matrix.spec
5353
| embed(iframe) | ER / EA / 其余全部 |
5454
| Docker 镜像 | 同套 E2E(`test:e2e:docker`) |
5555
| **线上站(edit.chaxus.com)** | `playwright.prod.config.ts` + `.github/workflows/prod-smoke.yml`(每日 04:47 + 手动;核心 8 个 spec,1 worker);也可 `E2E_BASE_URL=https://edit.chaxus.com` 跑任意 spec;首跑发现慢链路首存超时(已修) |
56+
| 字体交付(缓存) | `FC` = font-cache.spec:同页第二次打开所有 `/fonts/NNN` 必须 SW 缓存或 CDN HIT 且 <60s(线上曾无缓存头 + SWR 每次重下 → PPT 打开数分钟;另一会话修 `_headers`/sw.js);本地与线上冒烟都跑 |
5657
| Chromium | 全部 |
5758
| WebKit / Firefox | ⬜ |
5859
| 视觉基线(L3) | ⬜ |

‎playwright.prod.config.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export default defineConfig({
1919
'html-as-xls.spec.ts',
2020
'sw-warm.spec.ts',
2121
'format-parity.spec.ts',
22+
'font-cache.spec.ts',
23+
'pdf-route.spec.ts',
2224
],
2325
timeout: 300_000,
2426
expect: { timeout: 10_000 },

‎test/e2e/font-cache.spec.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { buildPptx, toBase64 } from './lib/ooxml';
2+
import { expect, test } from './lib/l0';
3+
import { settleEditor } from './lib/visual';
4+
5+
declare function post(type: string, payload?: Record<string, unknown>): Promise<any>;
6+
7+
/**
8+
* Font delivery regression guard (found live on edit.chaxus.com: the indexed
9+
* font catalog /fonts/NNN had no cache rule and the service worker's
10+
* stale-while-revalidate re-downloaded every multi-MB font on every open --
11+
* a deck's serial font queue took minutes). On the SECOND open in the same
12+
* page every font response must come from the service worker cache (or the
13+
* CDN cache when run against production), and the open must be quick.
14+
* Runs locally and in the production smoke.
15+
*/
16+
test('fonts are served from cache on the second open', async ({ page }) => {
17+
test.setTimeout(240_000);
18+
await page.goto('/embed-demo.html');
19+
await expect(page.locator('#status')).toHaveText('ready', { timeout: 60_000 });
20+
21+
const open = async () => {
22+
const t0 = Date.now();
23+
await page.evaluate(
24+
async (b64) => {
25+
const bin = atob(b64);
26+
const bytes = new Uint8Array(bin.length);
27+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
28+
await post('document:open-buffer', { fileName: 'fonts.pptx', buffer: bytes.buffer, readonly: false });
29+
},
30+
toBase64(buildPptx('Font cache 字体缓存')),
31+
);
32+
await settleEditor(page, 1000, 180_000);
33+
return Date.now() - t0;
34+
};
35+
36+
// First open: cold, fonts come from the network (or CDN); just settle.
37+
await open();
38+
39+
// Second open: observe every font response.
40+
const fonts: Array<{ url: string; sw: boolean; cf: string | null; status: number }> = [];
41+
const listener = (res: import('@playwright/test').Response) => {
42+
if (/\/fonts\/\d{3}(\?|$)/.test(res.url())) {
43+
fonts.push({
44+
url: res.url(),
45+
sw: res.fromServiceWorker(),
46+
cf: res.headers()['cf-cache-status'] ?? null,
47+
status: res.status(),
48+
});
49+
}
50+
};
51+
page.on('response', listener);
52+
const secondMs = await open();
53+
page.off('response', listener);
54+
55+
test.info().annotations.push({
56+
type: 'font-cache',
57+
description: `${fonts.length} font responses on second open in ${secondMs} ms; sw=${fonts.filter((f) => f.sw).length}, cf-hit=${fonts.filter((f) => f.cf === 'HIT').length}`,
58+
});
59+
console.log(
60+
`FONT-CACHE second open ${secondMs}ms, ${fonts.length} font responses`,
61+
JSON.stringify(fonts.slice(0, 5)),
62+
);
63+
64+
const uncached = fonts.filter((f) => !(f.sw || f.cf === 'HIT'));
65+
expect(uncached, `font responses not served from cache: ${JSON.stringify(uncached.slice(0, 5))}`).toEqual([]);
66+
expect(secondMs).toBeLessThan(60_000);
67+
});

‎test/e2e/image-insert.spec.ts‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { buildPptx, toBase64, zipEntryNames } from './lib/ooxml';
2+
import { expect, test } from './lib/l0';
3+
4+
declare const XLSX: any;
5+
declare function post(type: string, payload?: Record<string, unknown>): Promise<any>;
6+
7+
/**
8+
* Image insertion + save for the spreadsheet and presentation editors
9+
* (matrix section A "插图后保存"; the word editor case lives in
10+
* embed-regression). The serverless image pipeline (prepareEditorIframe
11+
* guard 4) must register the image and the saved package must carry the
12+
* media bytes -- the historical failure was a permanent main-thread hang.
13+
*/
14+
test.describe('image insert + save: xlsx / pptx (real editor)', () => {
15+
test.describe.configure({ timeout: 150_000 });
16+
17+
test.beforeEach(async ({ page }) => {
18+
await page.goto('/embed-demo.html');
19+
await expect(page.locator('#status')).toHaveText('ready', { timeout: 60_000 });
20+
});
21+
22+
// Installed into the page once per test: insert /img/64.png through the
23+
// editor API and save; returns the saved bytes as base64.
24+
const installHelper = (page: import('@playwright/test').Page) =>
25+
page.evaluate(() => {
26+
(window as any).__insertAndSave = async (kind: string) => {
27+
const findApi = (): any => {
28+
const visit = (win: Window): any => {
29+
try {
30+
const a = (win as any).Asc?.editor;
31+
if (a && a.isDocumentLoadComplete && a.isLoadFullApi) return a;
32+
} catch {
33+
/* cross-origin */
34+
}
35+
for (let i = 0; i < win.frames.length; i++) {
36+
const f = visit(win.frames[i]);
37+
if (f) return f;
38+
}
39+
return null;
40+
};
41+
return visit(window);
42+
};
43+
const start = Date.now();
44+
let api = findApi();
45+
while (!api && Date.now() - start < 60_000) {
46+
await new Promise((r) => setTimeout(r, 300));
47+
api = findApi();
48+
}
49+
if (!api) return { error: 'no api' };
50+
const url = location.origin + '/img/64.png';
51+
if (kind === 'pptx') api.AddImageUrlAction(url);
52+
else api.asc_addImageDrawingObject([url]);
53+
await new Promise((r) => setTimeout(r, 6000));
54+
const saved = await post('document:save', {});
55+
const out = new Uint8Array(await saved.file.arrayBuffer());
56+
let s = '';
57+
for (let i = 0; i < out.length; i += 0x8000)
58+
s += String.fromCharCode.apply(null, Array.from(out.subarray(i, i + 0x8000)));
59+
return { name: saved.file.name, b64: btoa(s) };
60+
};
61+
});
62+
63+
test.beforeEach(async ({ page }) => {
64+
await installHelper(page);
65+
});
66+
67+
test('xlsx: an image inserted by URL is in the saved workbook', async ({ page }) => {
68+
const result = await page.evaluate(async () => {
69+
const wb = XLSX.utils.book_new();
70+
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([['img', 1]]), 'S');
71+
const data = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
72+
await post('document:open-buffer', {
73+
fileName: 'img.xlsx',
74+
buffer: new Uint8Array(data).buffer,
75+
readonly: false,
76+
});
77+
return (window as any).__insertAndSave('xlsx');
78+
});
79+
expect(result.error).toBeUndefined();
80+
const names = zipEntryNames(new Uint8Array(Buffer.from(result.b64, 'base64')));
81+
expect(names.some((n) => /^xl\/media\//.test(n))).toBe(true);
82+
expect(names.some((n) => /^xl\/drawings\/drawing\d+\.xml$/.test(n))).toBe(true);
83+
});
84+
85+
test('pptx: an image inserted by URL is in the saved deck', async ({ page }) => {
86+
const result = await page.evaluate(
87+
async ({ pptxB64 }) => {
88+
const bin = atob(pptxB64);
89+
const bytes = new Uint8Array(bin.length);
90+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
91+
await post('document:open-buffer', { fileName: 'img.pptx', buffer: bytes.buffer, readonly: false });
92+
return (window as any).__insertAndSave('pptx');
93+
},
94+
{ pptxB64: toBase64(buildPptx('image slide')) },
95+
);
96+
expect(result.error).toBeUndefined();
97+
const names = zipEntryNames(new Uint8Array(Buffer.from(result.b64, 'base64')));
98+
expect(names.some((n) => /^ppt\/media\//.test(n))).toBe(true);
99+
});
100+
});

0 commit comments

Comments
 (0)