Skip to content

Commit f944945

Browse files
chaxusclaude
andcommitted
ci: e2e-pages job (bin/build.sh + wrangler pages dev reproduces Cloudflare Pages semantics); hosting contract sentinel; production smoke after every deploy of main
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 71dc686 commit f944945

8 files changed

Lines changed: 186 additions & 1 deletion

File tree

‎.github/workflows/ci.yml‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,47 @@ jobs:
8585
path: playwright-report/
8686
retention-days: 7
8787

88+
# Deploy artifact + host semantics: bin/build.sh output served by
89+
# `wrangler pages dev`, which reproduces Cloudflare Pages' index.html -> 308
90+
# redirects, _headers and _redirects. Catches "works on vite preview, broken
91+
# on the live site" (PDF loader stuck behind the 308; font cache headers).
92+
e2e-pages:
93+
name: E2E (Cloudflare Pages semantics)
94+
runs-on: ubuntu-latest
95+
needs: lint
96+
97+
steps:
98+
- name: Checkout code
99+
uses: actions/checkout@v6
100+
101+
- uses: pnpm/action-setup@v6
102+
name: Install pnpm
103+
with:
104+
run_install: false
105+
106+
- name: Setup Node.js
107+
uses: actions/setup-node@v6
108+
with:
109+
node-version: "lts/*"
110+
cache: "pnpm"
111+
112+
- name: Install dependencies
113+
run: pnpm install --frozen-lockfile
114+
115+
- name: Install Playwright browsers
116+
run: pnpm exec playwright install --with-deps chromium
117+
118+
- name: Run E2E against wrangler pages dev
119+
run: pnpm exec playwright test -c playwright.pages.config.ts
120+
121+
- name: Upload Playwright report
122+
if: failure()
123+
uses: actions/upload-artifact@v4
124+
with:
125+
name: playwright-report-pages
126+
path: playwright-report-pages/
127+
retention-days: 7
128+
88129
# The lint job only validates compose config and Dockerfile style; this job
89130
# actually builds the image and runs the full e2e suite against the running
90131
# container, proving the production image serves the editor correctly (this

‎.github/workflows/prod-smoke.yml‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
name: Production smoke
66

77
on:
8+
# After every push to main: wait until the live site serves this commit's
9+
# build (Cloudflare Pages deploys from the same push), then smoke it, so a
10+
# deploy-only breakage is known within minutes instead of the next morning.
11+
push:
12+
branches: [main]
813
workflow_dispatch:
914
inputs:
1015
url:
@@ -13,6 +18,10 @@ on:
1318
schedule:
1419
- cron: "47 20 * * *" # 04:47 Asia/Shanghai, after the nightly corpus
1520

21+
concurrency:
22+
group: prod-smoke
23+
cancel-in-progress: true
24+
1625
jobs:
1726
smoke:
1827
name: Core E2E against production
@@ -40,6 +49,25 @@ jobs:
4049
- name: Install Playwright browsers
4150
run: pnpm exec playwright install --with-deps chromium
4251

52+
# On push: build the same commit and wait until production serves its
53+
# hashed entry asset (Vite hashes are content-derived, so an identical
54+
# build here yields the same asset name as the Pages build). Skipped for
55+
# schedule/dispatch runs, which smoke whatever is live.
56+
- name: Wait for the deploy of this commit
57+
if: github.event_name == 'push'
58+
env:
59+
PROD_URL: ${{ github.event.inputs.url || 'https://edit.chaxus.com' }}
60+
run: |
61+
sh ./bin/build.sh >/dev/null 2>&1
62+
ASSET=$(grep -o 'assets/index-[A-Za-z0-9_-]*\.js' dist/index.html | head -1)
63+
echo "expecting $ASSET"
64+
for i in $(seq 1 60); do
65+
LIVE=$(curl -s "$PROD_URL/" | grep -o 'assets/index-[A-Za-z0-9_-]*\.js' | head -1)
66+
if [ "$LIVE" = "$ASSET" ]; then echo "live after $((i*20))s"; exit 0; fi
67+
sleep 20
68+
done
69+
echo "::warning::production still serves $LIVE after 20 min; smoking what is live"
70+
4371
- name: Smoke production
4472
env:
4573
PROD_URL: ${{ github.event.inputs.url || 'https://edit.chaxus.com' }}

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,5 @@ test-results-*/
2929
playwright-report-browsers/
3030
test-results-prod/
3131
playwright-report-prod/
32+
test-results-pages/
33+
playwright-report-pages/

‎CLAUDE.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,15 @@ fetchFonts 字体竞态,修复见 `lib/onlyoffice-editor.ts` 的 `prepareEdito
195195
抹掉对方的产物与结果,表现为假的 `-24 LoadingScriptError` /
196196
`ERR_CONNECTION_REFUSED` / 页面 60s 起不来 / 报告少行)。
197197

198+
**托管语义回归**(CI job `e2e-pages`,配置 `playwright.pages.config.ts`):
199+
`bin/build.sh` 真部署构建 + `wrangler pages dev` 服务 dist——复现 Cloudflare Pages
200+
的 `index.html`→目录 308、`_headers`、`_redirects`。"本地 vite preview 全绿、
201+
线上坏"的三条缺陷(PDF 被 308 卡在 vendor 加载器、字体目录无缓存头、慢链路
202+
超时)都属于这一层;`test/unit/hosting-contract.test.ts` 钉住 `_headers` 关键
203+
规则。**线上冒烟** `.github/workflows/prod-smoke.yml`:每次 push main 等部署
204+
上线后即跑 + 每日;`E2E_BASE_URL=<站点>` 可把任意 spec 打到部署站。CF 面板里
205+
的 Cache Rules 等不在仓库、CI 复现不了,只能靠冒烟兜底。
206+
198207
**Docker 镜像回归**(`pnpm run test:e2e:docker`,配置
199208
`playwright.docker.config.ts`):构建生产镜像后把同一套 test/e2e 全部 spec
200209
跑在容器(static-web-server)上,证明镜像端到端可用——正是这条链路抓出了

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,9 @@ gh workflow run nightly-corpus.yml -f limit=300 # 手动触发夜间
224224
for cache,才能让**首次**打开也从边缘命中。更长期:SDK 默认字体全家桶
225225
(20 文件)对纯浏览器版本是纯浪费,可评估在 `prepareEditorIframe`
226226
里关闭 `IsNeedDefaultFonts` 或裁减为文档实际引用的字体。
227+
- **"本地可以、部署后不行"的系统性堵法**(用户提问后落地):① CI 新增 `e2e-pages`
228+
job——`bin/build.sh` 真部署构建 + `wrangler pages dev`(复现 CF Pages 的 308 /
229+
`_headers` / `_redirects`),全套 spec;② `hosting-contract.test.ts` 钉 `_headers`
230+
关键规则;③ `prod-smoke.yml` 改为 push main 后等部署上线即冒烟(比对 index 资源
231+
哈希)+ 每日;④ 已知边界:CF 面板配置(Cache Rules)不在仓库,只能冒烟兜底。
232+
待做:节流网络(慢链路)预算 spec 进夜间;PR→preview→冒烟门禁需改工作流(用户决定)。

‎docs/test-matrix.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ embed-save-default.spec、`HX` = html-as-xls.spec、`FN` = filename-matrix.spec
5252
| 入口路径:`?file=<url>` / embed `document:open-url` / 落地页 `?open=local`(IndexedDB 交接) | `EP` = entry-paths.spec(另起本地 HTTP 源做跨域真实 fetch) |
5353
| embed(iframe) | ER / EA / 其余全部 |
5454
| 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;首跑发现慢链路首存超时(已修) |
55+
| **线上站(edit.chaxus.com)** | `playwright.prod.config.ts` + `.github/workflows/prod-smoke.yml`(**每次 push main 等部署上线后即冒烟** + 每日 04:47 + 手动;核心 spec,1 worker);也可 `E2E_BASE_URL=<站点>` 跑任意 spec;首跑发现慢链路首存超时与线上 PDF 打不开(均已修) |
56+
| **托管语义(CF Pages 模拟)** | `playwright.pages.config.ts` + CI job `e2e-pages`:`bin/build.sh` 真部署构建 + `wrangler pages dev`(复现 index.html→目录 308、`_headers`、`_redirects`),全套 PR 档 spec;托管契约单测 `test/unit/hosting-contract.test.ts` |
5657
| 字体交付(缓存) | `FC` = font-cache.spec:同页第二次打开所有 `/fonts/NNN` 必须 SW 缓存或 CDN HIT 且 <60s(线上曾无缓存头 + SWR 每次重下 → PPT 打开数分钟;另一会话修 `_headers`/sw.js);本地与线上冒烟都跑 |
5758
| Chromium | 全部 |
5859
| WebKit / Firefox | ⬜ |

‎playwright.pages.config.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { defineConfig } from '@playwright/test';
2+
import base from './playwright.config';
3+
4+
/**
5+
* The same suites against the DEPLOY artifact served with the host's own
6+
* semantics: `bin/build.sh` (the real deploy build, not a bare vite build)
7+
* served by `wrangler pages dev`, which reproduces Cloudflare Pages'
8+
* `/index.html` -> directory 308, `_headers` and `_redirects`. This is the
9+
* layer that would have caught "PDF blank on the live site" and "fonts
10+
* without a cache rule" before merge. PR CI job `e2e-pages`.
11+
* pnpm exec playwright test -c playwright.pages.config.ts
12+
*/
13+
const PORT = Number(process.env.PAGES_PORT || 8788);
14+
15+
export default defineConfig({
16+
...base,
17+
outputDir: 'test-results-pages',
18+
reporter: [['list'], ['html', { outputFolder: 'playwright-report-pages', open: 'never' }]],
19+
use: { ...base.use, baseURL: `http://127.0.0.1:${PORT}` },
20+
webServer: {
21+
command: `sh ./bin/build.sh && pnpm dlx wrangler@latest pages dev dist --port ${PORT} --ip 127.0.0.1`,
22+
url: `http://127.0.0.1:${PORT}`,
23+
reuseExistingServer: !process.env.CI,
24+
timeout: 300_000,
25+
},
26+
});

‎test/unit/hosting-contract.test.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { readFileSync } from 'node:fs';
2+
import { resolve } from 'node:path';
3+
import { describe, expect, it } from 'vitest';
4+
5+
/**
6+
* Hosting contract sentinel (Cloudflare Pages). Three of the campaign's
7+
* production-only defects lived in this layer (a vendor loader stuck behind
8+
* the index.html -> directory 308, the font catalog shipped without a cache
9+
* rule, sw.js/HTML that must never be cached). Pin the rules the app relies
10+
* on so an edit to public/_headers / _redirects / sw.js that drops one turns
11+
* red here first. The rules themselves are exercised for real by the
12+
* `e2e-pages` job (wrangler pages dev) and the production smoke.
13+
*/
14+
const ROOT = resolve(__dirname, '../..');
15+
const read = (rel: string) => readFileSync(resolve(ROOT, rel), 'utf8');
16+
17+
/** Parse the simple `_headers` format into { path: { header: value } }. */
18+
function parseHeaders(src: string): Record<string, Record<string, string>> {
19+
const out: Record<string, Record<string, string>> = {};
20+
let current: string | null = null;
21+
for (const raw of src.split('\n')) {
22+
const line = raw.replace(/#.*$/, '').trimEnd();
23+
if (!line.trim()) continue;
24+
if (!/^\s/.test(line)) {
25+
current = line.trim();
26+
out[current] = {};
27+
} else if (current) {
28+
const idx = line.indexOf(':');
29+
out[current][line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
30+
}
31+
}
32+
return out;
33+
}
34+
35+
describe('public/_headers', () => {
36+
const rules = parseHeaders(read('public/_headers'));
37+
const cc = (path: string) => rules[path]?.['cache-control'];
38+
39+
it('keeps deploy-coupled files uncacheable (a stale one breaks the shell)', () => {
40+
for (const p of ['/sw.js', '/home.css', '/landing.css', '/lang-switch.js', '/ranui-iife/*']) {
41+
expect(cc(p), p).toBe('no-cache');
42+
}
43+
});
44+
45+
it('keeps hashed/immutable assets long-lived: build assets, font catalog, x2t wasm', () => {
46+
for (const p of ['/assets/*', '/fonts/*', '/sdkjs/common/wasm/x2t/x2t.wasm.gz', '/ran-tokens.*.css']) {
47+
expect(cc(p), p).toMatch(/max-age=31536000.*immutable/);
48+
}
49+
});
50+
51+
it('does not make patched vendor trees immutable (x2t_helper.js and editor HTML must revalidate)', () => {
52+
for (const p of Object.keys(rules)) {
53+
if (/^\/(sdkjs|web-apps)\/\*$/.test(p) || p === '/sdkjs/*' || p === '/web-apps/*') {
54+
expect(cc(p)).not.toMatch(/immutable/);
55+
}
56+
}
57+
expect(Object.keys(rules).some((p) => p.startsWith('/sdkjs/common/wasm/x2t/x2t_helper'))).toBe(false);
58+
});
59+
});
60+
61+
describe('public/_redirects', () => {
62+
it('canonicalizes the localized landing directory', () => {
63+
expect(read('public/_redirects')).toMatch(/^\/zh-CN\s+\/zh-CN\/\s+308/m);
64+
});
65+
});
66+
67+
describe('index.html -> directory normalization awareness', () => {
68+
it('the app never relies on the vendor common loader for PDFs (it breaks behind the 308)', () => {
69+
const src = read('lib/onlyoffice-editor.ts');
70+
expect(src).toMatch(/isForm:\s*false/);
71+
});
72+
});

0 commit comments

Comments
 (0)