Skip to content

Commit b47d6b5

Browse files
chaxusclaude
andcommitted
test(v9): runtime readonly toggle for docx/pptx; entry paths (?file=url, document:open-url, ?open=local hand-off)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8a23aa5 commit b47d6b5

4 files changed

Lines changed: 188 additions & 12 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
@@ -202,3 +202,6 @@ gh workflow run nightly-corpus.yml -f limit=300 # 手动触发夜间
202202
5 个 docx 覆盖率不达标全是提取口径(`'`→`&apos;` 未解实体、无 preserve 的空白
203203
run、外部实体不展开)→ 提取器解实体、忽略空白、去 `<w:rt>`;
204204
`ExternalEntityInText.docx` 保留为预期项。旧 `.ppt/.doc` 的 L3 视觉差异待看截图。
205+
- 新增:docx/pptx 运行时只读切换(format-parity)、入口路径 `?file=`/`open-url`/`?open=local`
206+
(entry-paths.spec,跨域真实 fetch);corpus 视觉失败时落盘两张截图,旧二进制输入
207+
阈值 10% 标 `ok-legacy`(.ppt→pptx 行距略紧,目测内容一致,P3)。

‎docs/test-matrix.md‎

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,19 @@ embed-save-default.spec、`HX` = html-as-xls.spec、`FN` = filename-matrix.spec
4444

4545
## C. 运行环境
4646

47-
| 环境 | 覆盖 |
48-
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
49-
| 全新 profile 冷启动 | 每个 Playwright 用例即冷 profile |
50-
| SW 已控制页面(老用户) | `SW` = sw-warm.spec(第二次加载由 SW 控制页面与编辑器 frame,打开+保存往返);"SW 缓存旧构建"升级路径:策略改为新 SW 等待、无文档时才 SKIP_WAITING + 重载(`lib/sw-update.ts`,单测 `test/unit/sw-update.test.ts` + sw.js 契约);双构建端到端 ⬜ |
51-
| standalone 主站 | AS(加载 / manifest);编辑器路径 ⬜ |
52-
| embed(iframe) | ER / EA / 其余全部 |
53-
| Docker 镜像 | 同套 E2E(`test:e2e:docker`) |
54-
| **线上站(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;首跑发现慢链路首存超时(已修) |
55-
| Chromium | 全部 |
56-
| WebKit / Firefox | ⬜ |
57-
| 视觉基线(L3) | ⬜ |
58-
| 性能预算(L4) | ⬜(corpus 报告已含 load/save 耗时,未设阈值) |
47+
| 环境 | 覆盖 |
48+
| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
49+
| 全新 profile 冷启动 | 每个 Playwright 用例即冷 profile |
50+
| SW 已控制页面(老用户) | `SW` = sw-warm.spec(第二次加载由 SW 控制页面与编辑器 frame,打开+保存往返);"SW 缓存旧构建"升级路径:策略改为新 SW 等待、无文档时才 SKIP_WAITING + 重载(`lib/sw-update.ts`,单测 `test/unit/sw-update.test.ts` + sw.js 契约);双构建端到端 ⬜ |
51+
| standalone 主站 | AS(加载 / manifest);编辑器路径 ⬜ |
52+
| 入口路径:`?file=<url>` / embed `document:open-url` / 落地页 `?open=local`(IndexedDB 交接) | `EP` = entry-paths.spec(另起本地 HTTP 源做跨域真实 fetch) |
53+
| embed(iframe) | ER / EA / 其余全部 |
54+
| Docker 镜像 | 同套 E2E(`test:e2e:docker`) |
55+
| **线上站(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+
| Chromium | 全部 |
57+
| WebKit / Firefox | ⬜ |
58+
| 视觉基线(L3) | ⬜ |
59+
| 性能预算(L4) | ⬜(corpus 报告已含 load/save 耗时,未设阈值) |
5960

6061
## D. 交互入口(策略第 9 节)
6162

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

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { readFileSync } from 'node:fs';
2+
import { createServer, type Server } from 'node:http';
3+
import type { AddressInfo } from 'node:net';
4+
import { buildDocx, ooxmlText, zipEntryText } from './lib/ooxml';
5+
import { expect, test } from './lib/l0';
6+
import { settleEditor } from './lib/visual';
7+
8+
declare function post(type: string, payload?: Record<string, unknown>): Promise<any>;
9+
10+
/**
11+
* The remaining ways a document reaches the editor (matrix section C):
12+
* - main site `?file=<url>` (also `?src=`), i.e. links from other pages;
13+
* - embed API `document:open-url`;
14+
* - static landing pages' `?open=local` hand-off through IndexedDB.
15+
* The document is served by a throwaway HTTP server on another origin so the
16+
* fetch is a real cross-origin request (the service worker ignores it, and
17+
* page.route is not involved -- see the corpus harness lesson).
18+
*/
19+
let server: Server;
20+
let docUrl = '';
21+
const DOC = buildDocx('opened from a URL');
22+
23+
test.beforeAll(async () => {
24+
server = createServer((req, res) => {
25+
if (req.url?.startsWith('/url-open.docx')) {
26+
res.writeHead(200, {
27+
'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
28+
'Access-Control-Allow-Origin': '*',
29+
});
30+
res.end(Buffer.from(DOC));
31+
return;
32+
}
33+
res.writeHead(404);
34+
res.end();
35+
});
36+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
37+
docUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/url-open.docx`;
38+
});
39+
test.afterAll(async () => {
40+
await new Promise<void>((resolve) => server.close(() => resolve()));
41+
});
42+
43+
test.describe('entry paths (real editor)', () => {
44+
test.describe.configure({ timeout: 150_000 });
45+
46+
test.beforeEach(async ({ page }) => {
47+
await page.addInitScript(() => {
48+
Object.defineProperty(window, 'showSaveFilePicker', { value: undefined, configurable: true });
49+
});
50+
});
51+
52+
test('main site ?file=<url> opens the document and Ctrl+S saves it under its URL name', async ({ page }) => {
53+
await page.goto(`/?file=${encodeURIComponent(docUrl)}`);
54+
await settleEditor(page);
55+
const sdk = page.frameLocator('iframe[name="frameEditor"]').locator('#editor_sdk');
56+
await sdk.waitFor({ state: 'visible', timeout: 30_000 });
57+
await sdk.click({ position: { x: 300, y: 200 } });
58+
const downloadPromise = page.waitForEvent('download', { timeout: 60_000 });
59+
await page.keyboard.press('Control+s');
60+
const download = await downloadPromise;
61+
expect(download.suggestedFilename()).toBe('url-open.docx');
62+
const bytes = new Uint8Array(readFileSync(await download.path()));
63+
expect(ooxmlText(zipEntryText(bytes, 'word/document.xml') || '')).toContain('opened from a URL');
64+
});
65+
66+
test('embed document:open-url fetches, opens and round-trips the document', async ({ page }) => {
67+
await page.goto('/embed-demo.html');
68+
await expect(page.locator('#status')).toHaveText('ready', { timeout: 60_000 });
69+
const result = await page.evaluate(async (url) => {
70+
const opened = await post('document:open-url', { url, readonly: false });
71+
const saved = await post('document:save', {});
72+
const out = new Uint8Array(await saved.file.arrayBuffer());
73+
return { opened, name: saved.file.name as string, magic: Array.from(out.slice(0, 2)) };
74+
}, docUrl);
75+
expect(result.opened.readonly).toBe(false);
76+
expect(result.name).toBe('url-open.docx');
77+
expect(result.magic).toEqual([0x50, 0x4b]);
78+
});
79+
80+
test('?open=local consumes the file a static landing page stashed in IndexedDB', async ({ page }) => {
81+
// Same DB/store/key names as public/open-local.js and lib/pending-open.ts.
82+
await page.goto('/zh-CN/');
83+
await page.evaluate(async (b64) => {
84+
const bin = atob(b64);
85+
const bytes = new Uint8Array(bin.length);
86+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
87+
const file = new File([bytes], '落地页交接.docx', {
88+
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
89+
});
90+
await new Promise<void>((resolve, reject) => {
91+
const req = indexedDB.open('document-handoff', 1);
92+
req.onupgradeneeded = () => req.result.createObjectStore('files');
93+
req.onerror = () => reject(req.error);
94+
req.onsuccess = () => {
95+
const db = req.result;
96+
const tx = db.transaction('files', 'readwrite');
97+
tx.objectStore('files').put(file, 'pending');
98+
tx.oncomplete = () => {
99+
db.close();
100+
resolve();
101+
};
102+
tx.onerror = () => reject(tx.error);
103+
};
104+
});
105+
}, Buffer.from(DOC).toString('base64'));
106+
await page.goto('/?locale=zh-CN&open=local');
107+
await settleEditor(page);
108+
const sdk = page.frameLocator('iframe[name="frameEditor"]').locator('#editor_sdk');
109+
await sdk.waitFor({ state: 'visible', timeout: 30_000 });
110+
await sdk.click({ position: { x: 300, y: 200 } });
111+
const downloadPromise = page.waitForEvent('download', { timeout: 60_000 });
112+
await page.keyboard.press('Control+s');
113+
expect((await downloadPromise).suggestedFilename()).toBe('落地页交接.docx');
114+
});
115+
});

‎test/e2e/format-parity.spec.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,61 @@ test.describe('format parity: docx / pptx (real editor)', () => {
6767
expect(result.saveError).not.toBe('');
6868
});
6969
}
70+
71+
for (const doc of DOCS) {
72+
test(`${doc.label}: runtime readonly toggle locks and unlocks the live editor`, async ({ page }) => {
73+
const result = await page.evaluate(
74+
async ({ name, b64 }) => {
75+
const readRestriction = (): number | null => {
76+
const visit = (win: Window): number | null => {
77+
try {
78+
const api = (win as any).Asc?.editor;
79+
if (api && typeof api.asc_setRestriction === 'function' && typeof api.restrictions === 'number') {
80+
return api.restrictions;
81+
}
82+
} catch {
83+
/* cross-origin */
84+
}
85+
for (let i = 0; i < win.frames.length; i++) {
86+
const found = visit(win.frames[i]);
87+
if (found !== null) return found;
88+
}
89+
return null;
90+
};
91+
return visit(window);
92+
};
93+
const waitFor = async (expected: number) => {
94+
const start = Date.now();
95+
while (readRestriction() !== expected) {
96+
if (Date.now() - start > 30_000)
97+
throw new Error(`restriction did not become ${expected}, got ${readRestriction()}`);
98+
await new Promise((r) => setTimeout(r, 200));
99+
}
100+
};
101+
const bin = atob(b64);
102+
const bytes = new Uint8Array(bin.length);
103+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
104+
await post('document:open-buffer', { fileName: name, buffer: bytes.buffer, readonly: false });
105+
await waitFor(0);
106+
await post('document:set-readonly', { readonly: true });
107+
await waitFor(128);
108+
let lockedSaveError = '';
109+
try {
110+
await post('document:save', {});
111+
} catch (e) {
112+
lockedSaveError = String((e as Error).message || e);
113+
}
114+
await post('document:set-readonly', { readonly: false });
115+
await waitFor(0);
116+
const saved = await post('document:save', {});
117+
const out = new Uint8Array(await saved.file.arrayBuffer());
118+
return { lockedSaveError, name: saved.file.name as string, magic: Array.from(out.slice(0, 2)) };
119+
},
120+
{ name: doc.name, b64: doc.b64() },
121+
);
122+
expect(result.lockedSaveError).not.toBe('');
123+
expect(result.name).toBe(doc.name);
124+
expect(result.magic).toEqual([0x50, 0x4b]);
125+
});
126+
}
70127
});

0 commit comments

Comments
 (0)