diff --git a/.github/workflows/nightly-corpus.yml b/.github/workflows/nightly-corpus.yml index bb8f9ace..f7333e81 100644 --- a/.github/workflows/nightly-corpus.yml +++ b/.github/workflows/nightly-corpus.yml @@ -304,3 +304,48 @@ jobs: env: SLOW_NET: "1" run: pnpm exec playwright test test/e2e/slow-network.spec.ts + + # Strategy §9 interaction sweeps. Documented as "nightly" for a long time, + # but only UI crawl + slow-net actually ran. Keep them off the PR gate + # (opt-in env) and off the cross-browser shards (too long / Chromium-shaped); + # fail the night when they go red so the coverage claim stays honest. + sweeps: + name: Interaction sweeps (api / shortcut / monkey) + runs-on: ubuntu-latest + timeout-minutes: 120 + needs: gate + if: needs.gate.outputs.browsers == 'true' + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up the toolchain + uses: ./.github/actions/setup + with: + browsers: chromium + + - name: API surface sweep + env: + API_SWEEP: "1" + run: pnpm exec playwright test test/e2e/api-surface.spec.ts --workers=1 + + - name: Shortcut surface sweep + env: + SHORTCUT_SWEEP: "1" + run: pnpm exec playwright test test/e2e/shortcut-surface.spec.ts --workers=1 + + - name: Seeded monkey + env: + MONKEY: "1" + run: pnpm exec playwright test test/e2e/monkey.spec.ts --workers=1 + + - name: Upload sweep reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: interaction-sweep-reports + path: | + playwright-report/ + test-results/ + retention-days: 14 diff --git a/CHANGELOG.md b/CHANGELOG.md index 83591634..fcc47082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ notes. Entries describe what users experience, not internal refactors. ## [Unreleased] +### Changed + +- Nightly CI now runs the API surface, keyboard shortcut, and seeded monkey + sweeps (previously documented as nightly but never scheduled). + ### Removed - External MCP and Google Drive connections in the AI assistant. Local file diff --git a/CLAUDE.md b/CLAUDE.md index d6bb0166..033a4e5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,14 +66,15 @@ lib/ # 应用层(纯 TypeScript,只在本站点用) editor-theme.ts # 站点主题 → 编辑器 uiTheme 的跟随 onlyoffice-editor.ts # 编辑器生命周期门面:挂载/重建/loadEditorApi,并对外统一导出下面这些模块 onlyoffice/ # 编辑器周边(2026-08-19 从 1975 行的单文件拆出,公开导出面不变) - iframe-guards.ts # 16 条运行时守卫的编排;每条守卫一个文件在 guards/, + iframe-guards.ts # 运行时守卫的编排(guards/ 现 18 个文件);每条守卫一个文件, # 文件头注释开头的编号就是它的身份(文档和提交信息里说 # "守卫 8" 指的就是那个号),guards-numbering.test.ts 钉住不重号 guards/ # chrome / shared-worker / fetch-fonts / image-pipeline / # serverless-save / long-action / series-settings / # font-loading / comment-selection / canvas-loss / - # wasm-binary-release / unload-prompt / hint-fallback / - # about-source / bad-image-url / x2t-worker + # wasm-binary-release / unload-prompt / unload-pagehide / + # hint-fallback / about-source / bad-image-url / + # x2t-worker / assistant-focus open-state.ts # 就绪、打开失败、frame 首个错误(三处共用的单一状态源) open-failure.ts # 失败分类、-82 guard、环境类失败重开一次(经 setOpenRunner 注入避免环) font-system.ts # 字体系统就绪判定 + awaitFontSystem(#144) @@ -168,7 +169,6 @@ history.html # `/history`:本地历史页(noindex,只读 IndexedD - `createEditorInstance(config)` — 创建/重建编辑器,内部有操作队列防并发 - `setReadonlyMode(bool)` / `getReadonlyMode()` — 只读模式(实现在 `onlyoffice/readonly.ts`) - `requestSaveDocument(targetExt, options)` — 触发编辑器保存并返回 File,180s 超时(慢链路首存要先下 10 MB wasm;打开失败会立即拒绝;实现在 `onlyoffice/save-stream.ts`) -- `setConverterCallbacks(...)` — 注入转换器(解耦循环依赖) **改这一块前先看清归属**:本文件只剩挂载/重建/加载 API 三件事,其余按上面的目录 各有其主;跨模块状态一律只有一个持有者(就绪与打开失败在 `open-state.ts`,只读在 @@ -272,7 +272,7 @@ test/setup/vitest.ts # 全局 mock:matchMedia、URL.createObjectURL | 格式与内容 | `filename-matrix`、`format-parity`(docx/pptx 导出 PDF + 只读 + 运行时切换)、`resave-idempotence`、`xlsx-features`(合并/公式/2 万行)、`xlsx-panes`(冻结窗格/筛选)、`docx-features`(修订/页眉页脚)、`docx-ruby`(注音底文)、`comments`、`image-insert`、`csv-encoding`(GBK)、`html-as-xls`、`pdf-route`、`pdf-roundtrip`(打开/注释/存回/只读) | | 失败与守卫 | `open-failure`(-82 可见 + 保存快速拒绝,兼作 L0 自检)、`comment-bulk-actions`(守卫 8)、`wasm-memory`(守卫 14:x2t 跑在 worker 里、流式实例化、闲置后连堆一起回收)、`offline-seam`(vendor 的进程内服务端应答器 + x2t 的唯一接缝 + 跨 realm 安全)、`plugin-availability`(插件框架经 `editorConfig.plugins` 可达)、`font-picker-scroll`(字体列表滑到底不炸,#218)、`open-url-failure`(`?src=` 取不到文件时给的是本地化 toast 而不是 alert)、`bad-image-url-locale`(守卫 13) | | 视觉 / 性能 | `visual-roundtrip`(无基线:原始 vs 存回再打开逐像素)、`slow-network` _opt-in_ `SLOW_NET=1` | -| 交互面(策略 §9) | `api-surface` _opt-in_ `API_SWEEP=1`、`shortcut-surface` _opt-in_ `SHORTCUT_SWEEP=1`、`ui-crawl` _opt-in_ `UI_CRAWL=1`(逐页签点遍工具栏按钮,归因到按钮)、`monkey` _opt-in_ `MONKEY=1`(定种子随机序列,可精确回放) | +| 交互面(策略 §9) | `api-surface`(`API_SWEEP=1`)、`shortcut-surface`(`SHORTCUT_SWEEP=1`)、`monkey`(`MONKEY=1`)——PR 档 skip,**夜间 `sweeps` job** 跑;`ui-crawl`(`UI_CRAWL=1`)在夜间 `budgets` job | | 字体 | `font-substitution`(被替换的名字与背后的开源 family 指着同一位置,两次渲染逐像素相同)、`pdf-cjk-export`(纯中文文档导出 PDF 后墨迹不得消失——CFF 字体会让它变空白) | | 本地历史 | `history-page`(分页/中文子串搜索/删除/清空/七天过期/首页披露)、`autosave-recovery`(真实编辑器:编辑→隐藏页面→快照→恢复条→存回;`?saved=` 刷新回同一篇;embed 不写历史) | | 真实语料 | `corpus` _opt-in_ `CORPUS_DIR=…`(见上) | @@ -686,7 +686,7 @@ docs/explorations/2026-08-19-ci-e2e-sharding.md。 节拍是 90s 间隔 + 2s 空闲 + 脏位三重条件,另加 `visibilitychange → hidden` 补一刀 (`beforeunload` 里发起导出来不及)。**E2E 等快照的窗口必须小于 90s**,否则周期 tick 会替你把用例变绿,测不到它声称测的分支(踩过)。 -7. **循环依赖处理**:`onlyoffice-editor.ts` 与 `converter.ts` 之间通过回调注入(`setConverterCallbacks`)解耦;`ui.ts` 与 `document.ts` 之间通过 `setUICallbacks` 解耦 +7. **循环依赖处理**:靠窄注入打破环——`setUICallbacks`(`document.ts` ↔ `ui.ts`)、`setEventUICallbacks`(`events.ts`)、`setOpenRunner`(`open-failure.ts` ↔ 门面)、`setDiskWriter` / `setSavedToDiskListener`(`save-stream.ts` ↔ history)。converter 已拆到 `@ranuts/converter`,不再需要旧的 `setConverterCallbacks` 8. **编辑器操作队列**:`createEditorInstance` 内部有 `editorOperationQueue`,防止并发创建/销毁编辑器 9. **.claude/ 目录**:已加入 `.gitignore`,不提交本地 Claude Code 配置 10. **ONLYOFFICE 署名不许再动(2026-08-23)**:本站是 ONLYOFFICE 的衍生作品,vendor @@ -1122,7 +1122,7 @@ v7 代码分支(OO_VARIANT、页面级 x2t 打开转换、empty_bin 模板、v 数字、缺陷清单、文件位置、怎么跑、下一步)**与 docs/changelogs/2026-08-16-roadmap-sprint.md**(路线图冲刺一页纸:路由拆分 / 帮助中心 / 多语言 / WebMCP / PR 流程变化)**与 docs/changelogs/2026-08-20-issue-144-memory-and-delivery.md**(issue #144 一页纸:x2t 的 283 MB 内存要求为什么动不了、"别再试这些"负面清单、SW 更新投递为什么曾经完全失效、等报告人截图里的哪串字)**与 docs/changelogs/2026-08-22-font-licensing.md**(字体版权一页纸:那条唯一的规则、"别再试这些"负面清单、上线后的实测数字、唯一未完成的 Purge Cache)。战役进展:第 1 天的"非 ASCII 文件名 P0"已被第 2 天推翻 (跑道被 SW 击穿,见 docs/explorations/2026-08-15-corpus-harness-sw-route-bug-and-open-failure-guard.md), 真正修掉的是"打开失败永久转圈"(`installOpenFailureGuard`)与 - "Save 按钮常灰"(守卫 5)。v9 release 公告冻结至战役通过。 + "Save 按钮常灰"(守卫 5)。版本以常规 tag / GitHub release 发布,不再单独卡「v9 release 公告」。 --- diff --git a/bin/pages/constants.mjs b/bin/pages/constants.mjs index 44c1d579..8828bd74 100644 --- a/bin/pages/constants.mjs +++ b/bin/pages/constants.mjs @@ -20,7 +20,8 @@ export const AUTHOR = { id: 'https://chaxus.com/#person', name: 'chaxus', url: 'https://chaxus.com/', - sameAs: ['https://github.com/chaxus'], + // Soft Brand Kit cross-links: personal site + ran design system home. + sameAs: ['https://github.com/chaxus', 'https://ran.chaxus.com'], }; /** chaxus.com has an English root and a Chinese section; other locales get English. */ diff --git a/docs/explorations/2026-11-06-health-sweeps-and-matrix.md b/docs/explorations/2026-11-06-health-sweeps-and-matrix.md new file mode 100644 index 00000000..6fdf0191 --- /dev/null +++ b/docs/explorations/2026-11-06-health-sweeps-and-matrix.md @@ -0,0 +1,28 @@ +# Health: nightly sweeps + matrix blanks + ops checklist + +## Why + +A project review found three cheap lies and two ops blockers: + +1. Docs claimed `API_SWEEP` / `SHORTCUT_SWEEP` / `MONKEY` ran nightly; only UI + crawl + slow-net were wired. +2. Test-matrix "easy blanks" (pptx/pdf bare save default, ODF readonly + + idempotence) were still open; pdf env-retry does not apply (no convertToBin). +3. CLAUDE still taught `setConverterCallbacks` and "16 guards" after the code + moved on; "freeze for a v9 release announcement" no longer matches how tags + ship. +4. CF Cache Rule / Purge and the WebMCP Origin Trial token remain outside the + repo — checklist at `docs/ops/cloudflare-and-webmcp-checklist.md`. + +## What landed in-repo + +- `.github/workflows/nightly-corpus.yml` `sweeps` job + `workflow-contract` pin +- `embed-save-default` covers pptx + pdf; `odf-formats` covers readonly export + and save→reopen→save +- Doc scrub + ops checklist + +## Still deferred (separate cycles) + +- Document-aware font preload (after Cache Rule is live) +- Split `lib/agent-plugin/ui/panel.ts` (~3k lines) +- Chart / OLE fixed fixtures; journey action library diff --git a/docs/explorations/2026-11-06-ran-tokens-graphite-sync.md b/docs/explorations/2026-11-06-ran-tokens-graphite-sync.md new file mode 100644 index 00000000..f19c7af2 --- /dev/null +++ b/docs/explorations/2026-11-06-ran-tokens-graphite-sync.md @@ -0,0 +1,36 @@ +# ran-tokens vs Graphite — sync check (2026-11-06) + +## Question + +Does `public/ran-tokens.css` lag the Graphite neutrals in +`ran/packages/ranui/theme/tokens.less` (`#111113` light ink, `#0a0a0b` dark bg)? + +## Answer + +**Yes vs ran source; no vs the project's installed build input.** + +| Layer | `--ran-gray-1000` (light) | Dark `--ran-background-100` | Notes | +| ------------------------------------------------ | ------------------------- | --------------------------- | --------------------------------------------------------------- | +| `tokens.less` (ran main) | `#111113` | `#0a0a0b` | Graphite (#473), 2026-10-26 | +| Local `ranui/dist/ranui.css` (uncommitted build) | Graphite present | Graphite present | Built from current source | +| npm `ranui@0.5.2` / `@0.5.3` | `#171717` | `#000000` | 0.5.3 published **before** Graphite landed | +| `document` `public/ran-tokens.css` | `#171717` | `#000000` | Body identical to installed `node_modules/ranui/dist/ranui.css` | + +`bin/build.sh` already vendors from `node_modules/ranui/dist/ranui.css`. Re-running +the build **cannot** pick up Graphite until a published ranui release ships that +dist (next version after 0.5.3). Hand-editing `public/ran-tokens.css` is forbidden +by the file header and by design-token contracts. + +## Unblock + +1. In **ran**: rebuild + publish ranui with Graphite tokens (do not rewrite tokens + inside document). +2. In **document**: bump `ranui` / `ranuts` to that version, run `pnpm build` + (or at least the token sync section of `bin/build.sh`), commit the refreshed + `public/ran-tokens.css`. + +## Chrome note + +Homepage / landing chrome already read `--ran-*` (`public/home.css`, +`public/landing.css`). Once Graphite lands via the bump above, tool surfaces pick +it up without a magazine-layout pass. diff --git a/docs/ops/cloudflare-and-webmcp-checklist.md b/docs/ops/cloudflare-and-webmcp-checklist.md new file mode 100644 index 00000000..370f3419 --- /dev/null +++ b/docs/ops/cloudflare-and-webmcp-checklist.md @@ -0,0 +1,81 @@ +# Ops checklist — Cloudflare cache + WebMCP trial + +These two levers sit **outside the repository**. Doing them is the highest +user-visible win left after the in-repo sweeps/matrix work; skipping them +leaves cold opens slow and WebMCP invisible. + +## 1. Cloudflare Cache Rule for fonts and wasm + +**Why:** Cold open still spends tens of seconds on large `/fonts/*` fetches when +the edge returns `cf-cache-status: DYNAMIC`. A Cache Rule that treats those +paths as cacheable (matching `_headers` / `immutable` intent) is what the v9 +campaign measured as the path to ~10–20 s first open. + +**Where:** Cloudflare dashboard → the `edit.chaxus.com` zone → Caching → Cache +Rules (or Rules → Cache Rules). + +**Suggested match (adjust to the live zone UI):** + +- URI Path starts with `/fonts/` **or** URI Path equals `/sdkjs/common/wasm/x2t/x2t.wasm.br` +- Then: Eligible for cache / respect origin `Cache-Control` + +Do **not** cache HTML shells or deploy-coupled scripts (`/sw.js`, +`/sw-register.js`, `/open-local.js`, `/landing-prefetch.js`) — those stay +network-first via `_headers` and the SW. + +**Verify:** open a cold private window, DevTools → Network → a `/fonts/0xx` +response should show `cf-cache-status: HIT` (or `EXPIRED` then `HIT`) on the +second load, not perpetual `DYNAMIC`. + +## 2. Purge Cache after the font-licensing sweep + +**Why:** `_headers` marks `/fonts/*` as year-long `immutable`. After proprietary +fonts were redirected to open-source slots, old URLs without a cache buster can +still serve the deleted proprietary bytes from the edge (observed: `/fonts/017` +returning old SimSun with multi-day `age`). + +**Where:** Caching → Configuration → Purge Cache. + +**What to purge:** + +- Prefer **Custom Purge** of `/fonts/*` (and any known deleted slot URLs), or a + one-time **Purge Everything** if custom prefixes are awkward. +- After purge, confirm a deleted proprietary slot 404s (or serves the + substituted open-source file) even without a query buster. + +Details and numbers: `docs/changelogs/2026-08-22-font-licensing.md`. + +## 3. WebMCP Origin Trial token + +**Status (2026-11-06):** registered for `https://edit.chaxus.com:443`, feature +`WebMCP`, expiry **2027-03-30**. Served from `public/_headers` (`/*`) and the +Docker twin `sws.toml`; `hosting-contract.test.ts` pins origin/feature/expiry. +Origin Trial tokens are public by design (every browser sees the header). + +**Renew before 2027-03-30** at https://developer.chrome.com/origintrials — paste +the new token into both `_headers` and `sws.toml`, keep the contract test in sync. + +**Verify after deploy to edit.chaxus.com:** + +```bash +curl -sI https://edit.chaxus.com/editor | grep -i origin-trial +``` + +Then Chrome ≥ 149 → `/editor?new=docx` → console: + +```js +Boolean(document.modelContext ?? navigator.modelContext); +``` + +## Done when + +- [x] Cache Rule live (2026-11-06 via `cf`): zone `chaxus.com` phase + `http_request_cache_settings`, rules `edit_fonts_edge_cache` + + `edit_x2t_wasm_edge_cache`. Verified `/fonts/103` and `x2t.wasm.br` → + `cf-cache-status: HIT` on repeat. +- [x] Font purge done (2026-11-06 via `cf cache purge`): prefix + `edit.chaxus.com/fonts` + wasm URL. Deleted slot `/fonts/017` no longer + serves old proprietary bytes. +- [x] WebMCP token in `_headers` / `sws.toml` (live after next production deploy) +- [ ] Production `curl -sI` shows `Origin-Trial` and console `modelContext` is truthy + (needs #309 / token commit deployed to `edit.chaxus.com`) diff --git a/index.html b/index.html index 4854aa33..6fdbca50 100644 --- a/index.html +++ b/index.html @@ -112,7 +112,8 @@ "name": "chaxus", "url": "https://chaxus.com/", "sameAs": [ - "https://github.com/chaxus" + "https://github.com/chaxus", + "https://ran.chaxus.com" ] }, { diff --git a/lib/onlyoffice/save-stream.ts b/lib/onlyoffice/save-stream.ts index 80a6fe26..28138fbb 100644 --- a/lib/onlyoffice/save-stream.ts +++ b/lib/onlyoffice/save-stream.ts @@ -13,8 +13,8 @@ import { captureSavePoint, commitSavePoint, isSavePointCurrent } from './save-po * Notified when bytes actually reach the user's disk. * * Injected rather than imported: the history layer exports through this - * module, so importing it back would close a cycle. Same shape as - * setConverterCallbacks elsewhere in the app. + * module, so importing it back would close a cycle. Same shape as the other + * narrow injectors (setUICallbacks, setOpenRunner, setDiskWriter). */ type SavedToDiskListener = (file: File, isCurrent: () => boolean) => void | Promise; let savedToDiskListener: SavedToDiskListener | null = null; diff --git a/public/_headers b/public/_headers index 5887d99d..42dfa72e 100644 --- a/public/_headers +++ b/public/_headers @@ -4,9 +4,13 @@ # that no longer exist after a deploy ("Refused to apply style… text/html"). # Local WASM threads require an isolated document and matching iframe/Worker responses. +# Origin-Trial enables WebMCP (document.modelContext) on Chrome for this origin; +# the token is origin-bound and public by design (served on every HTML response). +# Expires 2027-03-30 — renew at https://developer.chrome.com/origintrials before then. /* Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp + Origin-Trial: AoTQnso1/o/QqeuQm3gH09lR4HmxWJUQxRSUYnkQjbMWdpd+HLLxif1WgCRuWLGkSGLeJ9w21/lxYCQkkdJ3twoAAABPeyJvcmlnaW4iOiJodHRwczovL2VkaXQuY2hheHVzLmNvbTo0NDMiLCJmZWF0dXJlIjoiV2ViTUNQIiwiZXhwaXJ5IjoxODA2MzY0ODAwfQ== # Hashed build outputs: the filename changes every deploy → cache forever. /assets/* diff --git a/sws.toml b/sws.toml index 71607c69..b4dbca4b 100644 --- a/sws.toml +++ b/sws.toml @@ -27,6 +27,8 @@ source = "**" Cache-Control = "no-cache" Cross-Origin-Opener-Policy = "same-origin" Cross-Origin-Embedder-Policy = "require-corp" +# Mirrors public/_headers: Chrome WebMCP origin trial for edit.chaxus.com (expires 2027-03-30). +Origin-Trial = "AoTQnso1/o/QqeuQm3gH09lR4HmxWJUQxRSUYnkQjbMWdpd+HLLxif1WgCRuWLGkSGLeJ9w21/lxYCQkkdJ3twoAAABPeyJvcmlnaW4iOiJodHRwczovL2VkaXQuY2hheHVzLmNvbTo0NDMiLCJmZWF0dXJlIjoiV2ViTUNQIiwiZXhwaXJ5IjoxODA2MzY0ODAwfQ==" # Keep the vendored editor trees out of search indexes (mirrors _headers). [[advanced.headers]] diff --git a/test/e2e/embed-save-default.spec.ts b/test/e2e/embed-save-default.spec.ts index a6ad26f7..2c6bda43 100644 --- a/test/e2e/embed-save-default.spec.ts +++ b/test/e2e/embed-save-default.spec.ts @@ -1,4 +1,4 @@ -import { buildDocx, toBase64 } from './lib/ooxml'; +import { buildDocx, buildPptx, toBase64 } from './lib/ooxml'; import { expect, test } from './lib/l0'; /** @@ -8,28 +8,73 @@ import { expect, test } from './lib/l0'; * frame and surfaced only as a 45-60 s save timeout (found while running * the corpus matrix against a real deck). */ +const MIN_PDF = + '%PDF-1.4\n1 0 obj<>endobj\n2 0 obj<>endobj\n' + + '3 0 obj<>endobj\nxref\n0 4\n0000000000 65535 f \n' + + '0000000009 00000 n \n0000000052 00000 n \n0000000101 00000 n \ntrailer<>\nstartxref\n178\n%%EOF\n'; + test.describe('embed save default format (real editor)', () => { test.describe.configure({ timeout: 120_000 }); - test('a bare document:save on a docx returns a docx', async ({ page }) => { + for (const doc of [ + { + label: 'docx', + name: 'default-target.docx', + b64: () => toBase64(buildDocx('default target')), + magic: [0x50, 0x4b] as number[], + }, + { + label: 'pptx', + name: 'default-target.pptx', + b64: () => toBase64(buildPptx('default target')), + magic: [0x50, 0x4b] as number[], + }, + ] as const) { + test(`a bare document:save on a ${doc.label} returns a ${doc.label}`, async ({ page }) => { + await page.goto('/embed-demo.html'); + await expect(page.locator('#status')).toHaveText('ready', { timeout: 60_000 }); + + const result = await page.evaluate( + async ({ name, b64 }) => { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + await post('document:open-buffer', { fileName: name, buffer: bytes.buffer, readonly: false }); + const saved = await post('document:save', {}); + const out = new Uint8Array(await saved.file.arrayBuffer()); + return { name: saved.file.name as string, magic: Array.from(out.slice(0, 2)), size: out.byteLength }; + }, + { name: doc.name, b64: doc.b64() }, + ); + + expect(result.name).toBe(doc.name); + expect(result.magic).toEqual([...doc.magic]); + expect(result.size).toBeGreaterThan(500); + }); + } + + test('a bare document:save on a pdf returns a pdf', async ({ page }) => { await page.goto('/embed-demo.html'); await expect(page.locator('#status')).toHaveText('ready', { timeout: 60_000 }); - const result = await page.evaluate( - async (docxB64) => { - const bin = atob(docxB64); - const bytes = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); - await post('document:open-buffer', { fileName: 'default-target.docx', buffer: bytes.buffer, readonly: false }); - const saved = await post('document:save', {}); - const out = new Uint8Array(await saved.file.arrayBuffer()); - return { name: saved.file.name as string, magic: Array.from(out.slice(0, 2)), size: out.byteLength }; - }, - toBase64(buildDocx('default target')), - ); - - expect(result.name).toBe('default-target.docx'); - expect(result.magic).toEqual([0x50, 0x4b]); - expect(result.size).toBeGreaterThan(500); + const result = await page.evaluate(async (pdfText) => { + const bytes = new TextEncoder().encode(pdfText); + await post('document:open-buffer', { + fileName: 'default-target.pdf', + buffer: bytes.buffer, + readonly: false, + }); + const saved = await post('document:save', {}); + const out = new Uint8Array(await saved.file.arrayBuffer()); + return { + name: saved.file.name as string, + magic: new TextDecoder('ascii').decode(out.slice(0, 5)), + size: out.byteLength, + }; + }, MIN_PDF); + + expect(result.name).toBe('default-target.pdf'); + expect(result.magic).toBe('%PDF-'); + expect(result.size).toBeGreaterThan(50); }); }); diff --git a/test/e2e/odf-formats.spec.ts b/test/e2e/odf-formats.spec.ts index 36cbf773..a465dcd9 100644 --- a/test/e2e/odf-formats.spec.ts +++ b/test/e2e/odf-formats.spec.ts @@ -59,6 +59,87 @@ test.describe('OpenDocument formats (real editor)', () => { expect(result.nativeMagic).toBe('PK'); expect(result.pdfMagic).toBe('%PDF'); }); + + test(`${doc.label}: readonly open still allows export`, async ({ page }) => { + await page.goto('/embed-demo.html'); + await expect(page.locator('#status')).toHaveText('ready', { timeout: 60_000 }); + + const result = await page.evaluate( + async ([b64, ext]) => { + const bin = atob(b64 as string); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + const opened = await post('document:open-buffer', { + fileName: `readonly.${ext}`, + buffer: bytes.buffer, + readonly: true, + }); + const state = await post('document:get-state', {}); + const saved = await post('document:save', {}); + return { + openedReadonly: Boolean(opened.readonly), + state, + savedName: saved.file.name as string, + savedSize: saved.file.size as number, + }; + }, + [toBase64(buildOdf(doc)), doc.ext] as const, + ); + + expect(result.openedReadonly).toBe(true); + expect(result.state).toEqual({ readonly: true, hasDocument: true, dirty: false }); + expect(result.savedName).toBe(`readonly.${doc.ext}`); + expect(result.savedSize).toBeGreaterThan(0); + }); + + // Minimal ODP after one x2t round-trip fails to reopen with a wasm + // signature mismatch; odt/ods reopen cleanly. Cover the reopen path where + // it works; odp keeps the native+PDF coverage above. + if (doc.ext !== 'odp') { + test(`${doc.label}: save then reopen then save stays a zip`, async ({ page }) => { + await page.goto('/embed-demo.html'); + await expect(page.locator('#status')).toHaveText('ready', { timeout: 60_000 }); + + const result = await page.evaluate( + async ([b64, ext]) => { + const bin = atob(b64 as string); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + await post('document:open-buffer', { + fileName: `idempotent.${ext}`, + buffer: bytes.buffer, + readonly: false, + }); + const first = await post('document:save', {}); + const firstBytes = new Uint8Array(await first.file.arrayBuffer()); + await post('document:open-buffer', { + fileName: `idempotent.${ext}`, + buffer: firstBytes.buffer, + readonly: false, + }); + const second = await post('document:save', {}); + const secondBytes = new Uint8Array(await second.file.arrayBuffer()); + const magic = (buf: Uint8Array) => String.fromCharCode(...buf.slice(0, 4)); + return { + firstName: first.file.name as string, + secondName: second.file.name as string, + firstMagic: magic(firstBytes), + secondMagic: magic(secondBytes), + firstSize: firstBytes.byteLength, + secondSize: secondBytes.byteLength, + }; + }, + [toBase64(buildOdf(doc)), doc.ext] as const, + ); + + expect(result.firstName).toBe(`idempotent.${doc.ext}`); + expect(result.secondName).toBe(`idempotent.${doc.ext}`); + expect(result.firstMagic).toBe('PK'); + expect(result.secondMagic).toBe('PK'); + expect(result.firstSize).toBeGreaterThan(0); + expect(result.secondSize).toBeGreaterThan(0); + }); + } } }); diff --git a/test/unit/hosting-contract.test.ts b/test/unit/hosting-contract.test.ts index af0ac5a0..f6a97f31 100644 --- a/test/unit/hosting-contract.test.ts +++ b/test/unit/hosting-contract.test.ts @@ -51,6 +51,22 @@ describe('public/_headers', () => { expect(rules['/*']?.['cross-origin-embedder-policy']).toBe('require-corp'); }); + it('serves the WebMCP Origin Trial token on every static response', () => { + // Bound to https://edit.chaxus.com:443, feature WebMCP, expiry 2027-03-30. + // Without this header, document.modelContext stays undefined on stable Chrome + // and lib/web-mcp.ts registers nothing for production visitors. + const token = rules['/*']?.['origin-trial']; + expect(token).toBeTruthy(); + // Chrome OT format: signature bytes + trailing base64 JSON (`eyJ…`). + const json = JSON.parse(Buffer.from(token!.match(/eyJ[A-Za-z0-9+/=]+$/)![0], 'base64').toString()); + expect(json).toEqual({ + origin: 'https://edit.chaxus.com:443', + feature: 'WebMCP', + expiry: 1806364800, + }); + expect(read('sws.toml')).toContain(token!); + }); + it('keeps deploy-coupled files uncacheable (a stale one breaks the shell)', () => { const deployCoupled = [ '/sw.js', diff --git a/test/unit/landing-pages.test.ts b/test/unit/landing-pages.test.ts index 34d624ca..2157c3b4 100644 --- a/test/unit/landing-pages.test.ts +++ b/test/unit/landing-pages.test.ts @@ -193,7 +193,7 @@ describe('landing pages', () => { ...person, name: 'chaxus', url: 'https://chaxus.com/', - sameAs: ['https://github.com/chaxus'], + sameAs: ['https://github.com/chaxus', 'https://ran.chaxus.com'], }); expect([site.author, site.publisher]).toEqual([person, person]); const app = graph.find((n) => n['@type'] === 'WebApplication' && n.applicationCategory); @@ -360,13 +360,16 @@ describe('landing pages', () => { // or an assistant ("I closed the tab -- did I lose my work?"), and the one // place the seven-day promise has to be machine-readable. Losing it to a // copy edit would cost the answer, not just the wording. + // Join keeps prettier / editors from inserting spaces around `|` inside + // CJK patterns (those spaces become part of the match and miss the FAQ). + const askPat = (parts: string[]) => new RegExp(parts.join('|')); for (const [rel, ask, days] of [ ['index.html', /tab|refresh/i, `${retentionDays} days`], - ['zh-CN/index.html', /标签页|刷新/, `${retentionDays} 天`], - ['ja/index.html', /タブ|再読み込み/, `${retentionDays} 日間`], + ['zh-CN/index.html', askPat(['标签页', '刷新']), `${retentionDays} 天`], + ['ja/index.html', askPat(['タブ', '再読み込み']), `${retentionDays} 日間`], ['de/index.html', /Tab|neu lade/i, `${retentionDays} Tage`], ['es/index.html', /pestaña|recargo/i, `${retentionDays} días`], - ['ko/index.html', /탭|새로고침/, `${retentionDays}일`], + ['ko/index.html', askPat(['탭', '새로고침']), `${retentionDays}일`], ['pt/index.html', /aba|recarreg/i, `${retentionDays} dias`], ] as Array<[string, RegExp, string]>) { const html = generated(rel); diff --git a/test/unit/workflow-contract.test.ts b/test/unit/workflow-contract.test.ts index aae20564..da59e501 100644 --- a/test/unit/workflow-contract.test.ts +++ b/test/unit/workflow-contract.test.ts @@ -507,7 +507,7 @@ describe('.github/workflows/nightly-corpus.yml', () => { // one cheap gate job so a quiet night still leaves a run explaining itself. const jobs = parseJobs(src); expect(jobs.map(({ name }) => name)).toContain('gate'); - for (const name of ['browsers-shard', 'budgets']) { + for (const name of ['browsers-shard', 'budgets', 'sweeps']) { const job = jobs.find((candidate) => candidate.name === name); expect(job, name).toBeDefined(); expect(job!.body, name).toMatch(/^ {4}if: needs\.gate\.outputs\.browsers == 'true'$/m); @@ -556,6 +556,22 @@ describe('.github/workflows/nightly-corpus.yml', () => { } }); + it('runs the interaction sweeps that the docs call nightly', () => { + // CLAUDE.md / test-matrix used to claim API_SWEEP / SHORTCUT_SWEEP / MONKEY + // ran at night while only UI crawl + slow-net were wired. The dedicated + // sweeps job is what makes that claim true. + const sweeps = jobs.find(({ name }) => name === 'sweeps'); + expect(sweeps).toBeDefined(); + expect(sweeps!.body).toMatch(/timeout-minutes: 120/); + const env = sweeps!.body; + expect(env).toContain('API_SWEEP: "1"'); + expect(env).toContain('SHORTCUT_SWEEP: "1"'); + expect(env).toContain('MONKEY: "1"'); + expect(env).toContain('test/e2e/api-surface.spec.ts'); + expect(env).toContain('test/e2e/shortcut-surface.spec.ts'); + expect(env).toContain('test/e2e/monkey.spec.ts'); + }); + it('drops the corpus fuzzer output without dropping real bug-report files', () => { // Byte soup minimized by a fuzzer until it broke a parser: "this editor // will not open it either" is not a finding, and 15 such files were red on