Skip to content

perf(export): split the transcript renderer's embedded CSS into a versioned asset - #11485

Merged
yiliang114 merged 9 commits into
mainfrom
feat/11478-split-export-transcript-css
Sep 10, 2026
Merged

yiliang114 merged 9 commits into
mainfrom
feat/11478-split-export-transcript-css

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

Splits the export transcript renderer's embedded component stylesheet into a separate, version-pinned, SRI-protected asset. The exported document now loads export-transcript-document.css from unpkg via a nonce-bearing <link> in parallel with the renderer JS, instead of carrying a ~2.3 MB CSS string literal inside the JS bundle. The transform happens entirely in the export build through an esbuild plugin that lifts the injected CSS out of the web-shell transcript entry and hands the bundler a stub, so web-shell source and runtime behavior are unchanged. The document's fail-closed load-error path is extended to the stylesheet: a missing CSS asset now shows the same load-error page as a missing renderer.

Why it's needed

The renderer asset was 4,136,297 bytes at 0.23.2, and 56% of those bytes were the inlined component CSS string. Browsers had to download, parse, and compile all 4.1 MB of JS before a transcript could render, and the build's byte budget was within ~60 KB of its hard cap. After the split the renderer JS is 1,831,301 bytes (the CSS moves to a 2,302,457-byte asset that loads, parses, and caches separately). This does not reduce total downloaded bytes — it moves them off the parse/compile critical path and re-ratchets the budget to the JS alone.

Reviewer Test Plan

How to verify

Rebuild the web-templates package and confirm the build prints the split sizes and writes the CSS asset:

cd packages/web-templates && node src/export-html/build.mjs
# Document export renderer JS is 1831301 bytes; component CSS moved to export-transcript-document.css is 2302457 bytes

Run the focused unit tests:

cd packages/cli && npx vitest run src/ui/utils/export/formatters/html.test.ts src/ui/utils/export/export-transcript-document.test.ts
npx vitest run --config ./scripts/tests/vitest.config.ts scripts/tests/package-assets.test.js scripts/tests/install-script.test.js

Run the transcript browser gate, which opens a real exported document in headless Chromium and now fulfils the stylesheet request from the built asset:

QWEN_SANDBOX=false npx vitest run --root ./integration-tests chat-transcript-document.test.ts

The gate asserts the stylesheet <link> is the only stylesheet request, that its SRI check passes (link.sheet !== null), and that the component CSS actually cascades (the KaTeX font-family on a rendered formula comes only from that stylesheet). A new case aborts the stylesheet request and asserts the document fails closed with the same "Unable to load this chat export" page as a missing renderer.

Evidence (Before & After)

Asset Before After
export-transcript-document.js 4,136,297 bytes 1,831,301 bytes
export-transcript-document.css — (inlined in JS) 2,302,457 bytes
__qwenWebShellCss literal in JS present absent

Local integration run: chat-transcript-document.test.ts — 6 passed (includes the new "fails closed when the CDN stylesheet is unavailable" case and the stylesheet-applied assertion).

Tested on

OS Status
🍏 macOS N/A
🪟 Windows N/A
🐧 Linux

Environment (optional)

Linux, Node 24, local npm run build --workspace=@qwen-code/web-templates + npx vitest (Playwright Chromium headless shell).

Risk & Scope

  • Main risk or tradeoff: the split asset is fetched from unpkg alongside the renderer. A missing or tampered CSS asset fails closed exactly like a missing renderer, so the export never renders unstyled. This does not reduce total bytes; it moves them off the JS parse/compile path.
  • Not validated / out of scope: web-shell source and runtime behavior (deliberately untouched); KaTeX removal and the fix(web-shell): the transcript entry still carries the daemon hook runtime #11100 component-graph refactor are out of scope.
  • Breaking changes / migration notes: the published npm package must now include export-transcript-document.css (added to the package files and the standalone-exclusion list). Exports from an older published version that predates the split will 404 the CSS and fail closed until a new release publishes it.

Design docs: English · 简体中文

Linked Issues

Fixes #11478

中文说明

本 PR 做了什么

把导出 transcript 渲染器内嵌的组件样式表拆分为独立的、版本固定、带 SRI 校验的资产。导出文档现在通过带 nonce 的 <link> 从 unpkg 并行加载 export-transcript-document.css,而不是把约 2.3 MB 的 CSS 字符串字面量塞进 JS bundle。转换完全发生在导出构建中:通过一个 esbuild 插件把注入的 CSS 从 web-shell 的 transcript 入口抽出、并把剩余部分作为 stub 交给打包器,因此 web-shell 的源码与运行时行为保持不变。文档的 fail-closed 加载失败路径扩展到样式表:CSS 资产缺失时显示与渲染器缺失相同的加载错误页。

为什么需要

渲染器资产在 0.23.2 为 4,136,297 字节,其中 56% 是内联的组件 CSS 字符串。浏览器必须先下载、解析、编译全部 4.1 MB 的 JS 才能渲染,而构建的体积预算距离硬上限只剩约 60 KB。拆分后渲染器 JS 为 1,831,301 字节(CSS 移到 2,302,457 字节的独立资产,单独加载、解析、缓存)。这不减少总下载字节,只是把它们从解析/编译关键路径上移走,并把预算重新收紧到「仅 JS」。

评审测试计划

如何验证

重新构建 web-templates 包,确认构建打印拆分后的体积并写出 CSS 资产:

cd packages/web-templates && node src/export-html/build.mjs
# Document export renderer JS is 1831301 bytes; component CSS moved to export-transcript-document.css is 2302457 bytes

运行聚焦单元测试:

cd packages/cli && npx vitest run src/ui/utils/export/formatters/html.test.ts src/ui/utils/export/export-transcript-document.test.ts
npx vitest run --config ./scripts/tests/vitest.config.ts scripts/tests/package-assets.test.js scripts/tests/install-script.test.js

运行 transcript 浏览器门禁(在无头 Chromium 中打开真实导出文档,并用构建出的资产满足样式表请求):

QWEN_SANDBOX=false npx vitest run --root ./integration-tests chat-transcript-document.test.ts

门禁断言样式表 <link> 是唯一的样式表请求、其 SRI 校验通过(link.sheet !== null)、组件 CSS 真正级联生效(渲染公式上的 KaTeX font-family 只来自该样式表)。新增用例中止样式表请求,断言文档以与缺失渲染器相同的「Unable to load this chat export」页面 fail-closed。

证据(Before & After)

资产 Before After
export-transcript-document.js 4,136,297 字节 1,831,301 字节
export-transcript-document.css —(内联在 JS 中) 2,302,457 字节
JS 中的 __qwenWebShellCss 字面量 存在 不存在

本地集成运行:chat-transcript-document.test.ts — 6 个通过(含新增的「CDN 样式表不可用时 fail-closed」用例和「样式表已应用」断言)。

测试环境

OS Status
🍏 macOS N/A
🪟 Windows N/A
🐧 Linux

运行环境(可选)

Linux、Node 24、本地 npm run build --workspace=@qwen-code/web-templates + npx vitest(Playwright Chromium headless shell)。

风险与范围

  • 主要风险或权衡:拆分后的资产与渲染器一起从 unpkg 拉取。缺失或被篡改的 CSS 资产会像缺失渲染器一样 fail-closed,因此导出绝不会以无样式状态渲染。这不减少总字节,只是把它们移出 JS 的解析/编译路径。
  • 未验证 / 超出范围:web-shell 源码与运行时行为(刻意不动);移除 KaTeX 与 fix(web-shell): the transcript entry still carries the daemon hook runtime #11100 组件图重构不在范围内。
  • 破坏性变更 / 迁移说明:发布的 npm 包现在必须包含 export-transcript-document.css(已加入包文件与 standalone 排除列表)。早于本次拆分的旧版本导出在 CSS 发布前会 404 并 fail-closed。

设计文档:English · 简体中文

关联 Issue

Fixes #11478

…sioned asset

The export renderer carried the web-shell component stylesheet as a ~2.3 MB
string literal, so every reader parsed and compiled 4.1 MB of JS (56% of it dead
CSS) before a transcript could render. Lift that literal out at export build
time into a version-pinned, SRI-protected export-transcript-document.css served
from unpkg and loaded via a nonce-bearing <link>, dropping the renderer JS to
~1.83 MB.

The transform is an esbuild onLoad plugin in the web-templates export build that
strips the injected CSS constant from web-shell's dist/transcript.js; web-shell
source and runtime behavior are untouched. The document's fail-closed load-error
path is extended to the stylesheet so a missing CSS asset fails the same way as
a missing renderer.
@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Sep 9, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

⚠️ Qwen Triage ended earlyview run. It stopped before finishing; check the run log.

⚠️ Qwen Triage 提前结束 —— 查看运行。未跑完,请查看运行日志。

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Re-run at your request. The last gate pass looked at 3b63662b; this one looks at 328feb43f8, four commits later, so I have re-read everything rather than patching the old verdict.

Template looks good ✓ — all nine headings present, and the Before/After table carries real byte counts rather than adjectives.

Problem: observed and measured, not theoretical. #11478 records the composition of the shipped asset (56% of 4.1 MB is one CSS string literal) and the budget sitting ~66 KB under its hard cap. The numbers were independently re-measured on a real build by @wenshao in his round-2 report4,139,3861,833,941 JS + 2,302,905 CSS. This is not a "could theoretically be slow" PR.

Direction: aligned. /export html already moved the renderer to a version-pinned unpkg asset with a fail-closed load error (#9812, #11035), and docs/users/features/commands.md documents that behaviour as accepted. A second pinned asset does not change the product stance and does not relax the nonce-only CSP — style-src-elem 'nonce-…' already covers a nonce-bearing <link>, and I confirmed injectDocumentNonce uses replaceAll, so the new <link> and the new head script are both nonced. Claude Code's CHANGELOG has no direct reference to export-asset delivery; the area is relevant but this is an internal packaging change, not a parity question.

Two things put this in "escalate, never auto-reject" territory rather than a clean pass: it touches the release contract (a new required publish artifact, plus a second public CDN dependency for every exported document), and the issue's own triage set conditions A–G that this PR answers unevenly — see the Approach section.

Size: no core paths. packages/web-templates/src/export-html/** matches none of the core patterns, and the only packages/cli file is a test. Breakdown of the 1,068 changed lines: 266 production, 352 test, 450 docs. Under every threshold, and you are an admin on this repo so the two-tier core gate does not apply to you anyway. No large-PR advisory.

Approach: the scope feels right and I could not find a materially simpler path. I wrote down what I would do before reading the diff — esbuild onLoad plugin at the export-build boundary, fail-closed on shape drift, sha384 over the exact published bytes, both URLs from one version expression, <link> after the inline <style>, and a listener registered before the <link> because resource errors do not bubble and a head failure can fire before a body listener exists. That is what you built, and your version is better in one place: the build throws at three separate points (missing CSS constant, moved injection line, plugin never matched) so a shape change in injectCssModules breaks the build instead of shipping a renderer that both links and injects 2.3 MB. Extracting TRANSCRIPT_CSS_ENTRY_FILTER into its own module purely so the match decision is unit-testable without running a top-level-await build is the right call, not over-abstraction.

No drive-by refactors and no unrelated churn — the diff stays inside the stated goal.

Where I would push back is on the issue's conditions:

  • C (fail-closed ordering trap) — met, and met the stronger of the two ways offered. You took the head-listener route rather than link.sheet === null. That is the better answer, and my last pass was wrong to prefer the link.sheet check: @wenshao measured that a 404 and a connection reset both leave sheet !== null, so it is not a reliable oracle. Correcting that here rather than letting it stand.
  • D (delegation needs a CSS counterpart) — met in code. QWEN_EXPORT_RENDERER_CSS_INTEGRITY gets the same set-together validation and the same sha384- shape check.
  • B (<link> placement) — met in the template, not pinned by a test. Reordering the <link> before the inline <style> keeps every suite green and renders pixel-identical today, so the cascade order the condition called load-bearing is currently unguarded.
  • A (keep the byte cap combined) — deliberately not met. The ratchet went from covering 4.1 MB of render-blocking bytes to covering 1.83 MB of them, and the 2.3 MB that moved out is logged but not capped. The <link> is in <head>, so the reader still pays for both before first paint, and the comment you kept directly above those constants warns that a cap with that much slack is decoration. Your reasoning (JS is what the engine must compile) is defensible and the design doc states it — but the issue asked for the opposite, and the design doc does not acknowledge that it is deviating from a condition set on it. That is a maintainer decision, not a gate decision, so I am raising it as a question and not blocking on it. If the answer is "guard both", it is about four lines.
  • F (break the 2,298,871 CSS bytes down first) and G (the font-src data: correction) — not addressed in the design doc. F was asked for "before this is chosen as the first move", on the grounds that inlined KaTeX fonts and unscoped Tailwind output are byte-removing levers while the split only relocates bytes. That is worth a sentence either way.
  • E — half met. SRI does pin the CSS to the build's own bytes, so a wrong sheet fails closed and not just a missing one. But docs/users/features/commands.md:39 still describes a single pinned asset.

Risk: no high-risk-path matches — nothing in the diff touches the paths this repo's revert history correlates with. The elevated risk here is coordination, not code, and I have left it for Stage 3: #11372 is APPROVED and open, edits the same two constants in the same file from the same base blob in the opposite direction (4_200_000 / 4_300_000 against your 1_870_000 / 1_930_000), and the issue triage asked for the two to be sequenced. You own both.

Flagging these for discussion; moving on to code review. 🔍

中文说明

应你的要求重跑。上一次门禁看的是 3b63662b,这次看的是四个提交之后的 328feb43f8,所以我重读了全部内容,而不是在旧结论上打补丁。

模板完整 ✓ —— 九个标题齐全,Before/After 表给的是真实字节数而不是形容词。

问题: 已观测且有实测,不是理论性问题。#11478 记录了产物的构成(4.1 MB 中 56% 是一个 CSS 字符串字面量),以及预算距硬上限只剩约 66 KB。这些数字由 @wenshao 在真实构建上独立复测过(见其第二轮报告):4,139,386 → JS 1,833,941 + CSS 2,302,905。这不是一个"理论上可能变慢"的 PR。

方向: 对齐。/export html 早已把渲染器改为版本固定的 unpkg 资产并带 fail-closed 加载错误页(#9812#11035),docs/users/features/commands.md 也把该行为记为已接受。多一个版本固定资产不改变产品立场,也不放宽 nonce-only CSP —— style-src-elem 'nonce-…' 天然覆盖带 nonce 的 <link>;我确认了 injectDocumentNonce 用的是 replaceAll,因此新增的 <link> 与新增的 head 脚本都带上了 nonce。Claude Code 的 CHANGELOG 没有关于导出资产分发的直接参照;该领域相关,但这是内部打包改动,不是对齐性问题。

有两点让它落在"升级给维护者、绝不自动拒绝"而不是干净通过:它触及发布契约(新增一个必须发布的产物,并且每个导出文档都多了一个公共 CDN 依赖);同时 issue 自身的 triage 设了 A–G 条件,而本 PR 的回答并不均衡——见下方"方案"。

规模: 未触及核心路径。packages/web-templates/src/export-html/** 不匹配任何核心模式,packages/cli 下唯一的文件是测试。1,068 行改动的构成:生产 266 行测试 352 行文档 450 行。低于所有阈值;而且你是本仓库 admin,两层核心门禁本来也不适用于你。不触发大 PR 提示。

方案: 范围合理,我没有找到明显更简的路径。在读 diff 之前我先写下了自己的做法——在导出构建边界用 esbuild onLoad 插件、形状漂移时 fail-closed、对实际发布字节做 sha384、两个 URL 取自同一个版本表达式、<link> 放在内联 <style> 之后,以及<link> 之前注册监听器(因为资源错误不冒泡,head 中的失败可能早于 body 监听器存在)。你做的正是这个,而且有一处比我的更好:构建在三个不同位置抛错(CSS 常量缺失、注入行被移动、插件从未匹配),因此 injectCssModules 的形状一旦变化,构建会失败,而不是发出一个既 link 又注入 2.3 MB 的渲染器。把 TRANSCRIPT_CSS_ENTRY_FILTER 单独抽成模块、纯粹为了让"是否匹配"这个决定能在不跑 top-level-await 构建的前提下被单测覆盖,这是正确的取舍,不是过度抽象。

没有顺手重构,也没有无关改动——diff 始终待在既定目标内。

我要提出异议的是 issue 的那几条:

  • C(fail-closed 时序陷阱)—— 已满足,而且用的是两个选项中更强的那个。 你选了 head 监听器方案,而不是 link.sheet === null。这是更好的答案,而且我上一轮的偏好是错的:@wenshao 实测 404 与连接重置都会留下 sheet !== null,所以它并不是可靠的 oracle。在此更正,不让它继续留着。
  • D(委派需要 CSS 对应项)—— 代码层面已满足。 QWEN_EXPORT_RENDERER_CSS_INTEGRITY 有同样的"同时设置"校验和同样的 sha384- 格式校验。
  • B(<link> 位置)—— 模板里做到了,但没有测试钉住。<link> 挪到内联 <style> 之前,所有套件仍全绿、渲染逐像素一致,也就是说条件称为"承重"的层叠顺序目前无人看守。
  • A(保持合并的字节上限)—— 有意未满足。 棘轮从"覆盖 4.1 MB 渲染阻塞字节"变成"覆盖其中 1.83 MB",移出去的 2.3 MB 只记日志、不设上限。<link><head> 中,读者在首绘前两者都要付;而你保留下来、就写在这两个常量正上方的那段注释警告说:留有这么多松量的上限就是"装饰"。你的理由(JS 才是引擎必须编译的那个数字)站得住,设计文档也写了——但 issue 要求的是相反的做法,而设计文档并没有承认自己在偏离一条为它设定的条件。这是维护者的决定,不是门禁的决定,所以我把它作为问题提出,不据此阻断。如果答案是"两个都守",大约四行。
  • F(先把 2,298,871 字节 CSS 拆开看)与 G(font-src data: 的事实更正)—— 设计文档中未回应。 F 的原文要求是"在把本方案选为第一步之前"先做,理由是内联的 KaTeX 字体与未收窄的 Tailwind 产物是减少字节的杠杆,而拆分只是搬运字节。无论结论如何,值得一句话交代。
  • E —— 满足一半。 SRI 确实把 CSS 钉在了本次构建自己的字节上,所以"错误的"样式表也会 fail-closed,而不只是"缺失的"。但 docs/users/features/commands.md:39 仍描述为只有一个版本固定资产。

风险: 无高风险路径命中——diff 没有触及本仓库回滚历史相关联的那些路径。这里升级的风险是协同性的,不是代码性的,我把它留到 Stage 3:#11372 目前是 APPROVED 且开着,从同一个 base blob 出发、在同一个文件里以相反方向改同样那两个常量(4_200_000 / 4_300_000 对你的 1_870_000 / 1_930_000),而 issue triage 当时就要求两者排好先后。两个 PR 都是你的。

先把这些提出来讨论;已进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 328feb43f82b0d59f7adee18d8d1dd54e1060b3b · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Code review at 328feb43f8, static only — per this gate's rules I did not build, run, or check out anything from this PR. The evidence in the testing section below is the PR's own CI, read through the API.

Code review

No Critical findings of my own at this head. I read the build plugin, the digest and URL derivation, the document template, the renderer's two guard sites, the formatter's nonce substitution, and all three packaging scripts. Specifically:

  • The SRI digest and the published bytes cannot diverge. documentRendererCssIntegrity is sha384 over extractedTranscriptCss.css and the asset is written from that same string; createHash().update(string) and writeFile(path, string) both default to utf8, so the digest describes the bytes unpkg will serve. copyFileSync carries them unchanged into dist/.
  • Both URLs come from one expression — exportTranscriptRendererVersion.split('+')[0] — so the JS and the CSS can never pin different versions. The delegation override is symmetric, and the new QWEN_EXPORT_RENDERER_CSS_INTEGRITY is validated for both set-togetherness and sha384- shape before it can reach a document.
  • The nonce path is safe. injectDocumentNonce guards on the placeholder's presence and then replaceAlls it, so the third and fourth __EXPORT_NONCE__ slots this PR adds (the head latch script and the <link>) are substituted alongside the pre-existing ones. Had that been a first-match .replace(), every export would have shipped a literal nonce and been CSP-blocked — it is not.
  • documentResidualPlaceholder was extended with both new placeholders, so a dropped .replace() still fails the build rather than shipping a template that throws at view time.
  • The packaging chain is closed at every step: required-publish verification, published files, bundle copy, and standalone exclusion. The copy staying a warning rather than a throw is correct and the comment says why — that script also serves --cli-only dev bundles, and prepare-package.js is the release gate.

Both round-1 Criticals are fixed, and I verified that by reading the code at this head rather than taking the reports for it. R1-1's filter is now /web-shell[\\/]dist[\\/]transcript\.js$/ in its own module — both separators in the class, and the transcript\.js$ tail retained so the barred web-shell/dist/index.js package root still cannot match. R1-2's head latch is registered ahead of the <link>, in the capture phase, records only, and is consumed by the body script; document-main.tsx then refuses to mount when the marker reads error.

Two of my own notes, neither blocking:

  1. The requestAnimationFrame guard in document-main.tsx:242-249 looks unreachable from the stylesheet path — if the marker is already error at module level, React never mounts, so the effect never schedules. It is not strictly dead, though: unhandledrejection also routes to showLoadError, and a rejection landing between mount and the rAF callback is exactly what this guard absorbs. So I would leave it. It is unpinned, and @wenshao's mutation testing could not stage a path where removing it changes anything, which is consistent with it guarding a narrow race rather than a common one.
  2. build.mjs:200 still reads "Set both or neither" and lists two variables, directly above code that now throws unless a third is set. Stale docblock in the file this PR is editing.

Suggestions I am explicitly deferring rather than asking for. This PR has been through roughly five review rounds, so per this repo's own guidance I am landing only Critical fixes and recording the rest here so nothing is silently dropped. All of the following are Suggestion-level, all were measured by @wenshao, and none is a defect in the shipped code path:

  • The <link> nonce is load-bearing and unpinned — html.test.ts asserts document-wide substrings, so deleting that nonce leaves the suite green while every export renders the load-error page. An element-scoped assertion fixes it.
  • The renderer fail-closed case is confounded — the new stylesheet id was added to the same handler, and that test aborts both assets, so removing transcript-renderer from the body listener leaves it green. A RENDERER_CSS_URL fulfil in the existing route handler restores the witness.
  • The release gate checks the JS only; grep over .github/workflows/ finds no .css.
  • Condition B's cascade order is unpinned (see Stage 1).
  • link.sheet !== null does not by itself prove a sheet has content, though the katexFontFamily assertion next to it does — worth a comment so nobody later trusts the weaker half alone.
  • Four documentation gaps, all verified stale at this head: build.mjs:200; docs/verification/export-renderer-delegation-mermaid/README.md:106-107, whose copy-pasteable two-knob command now throws; docs/users/features/commands.md:39; and docs/verification/export-html-runtime-size/README.md:268, which quotes the Document export runtime is N bytes log line this PR renames while line 85 of the same file already carries the new one.

The delegation runbook is the one I would fold in before merge rather than after: it is the documented escape hatch for the 404-until-release window this PR introduces, and as written it fails.

Why the head latch exists

The ordering hazard that was round 1's second Critical, and the two disjoint windows the head latch and the body listener now cover:

sequenceDiagram
    participant P1 as Browser parser
    participant P2 as head latch script
    participant P3 as stylesheet link
    participant P4 as body listener script
    participant P5 as renderer script
    participant P6 as document main module
    P1->>P2: parse head script, register capture listener
    P1->>P3: parse link, start CSS fetch (parser blocks here)
    P3-->>P2: error event (capture phase, does not bubble)
    P2->>P2: record the failure flag (body does not exist yet)
    P1->>P4: parse body script
    P4->>P4: register own listener for both asset ids
    P4->>P4: read the flag, show load error, mark body as error
    P1->>P5: parse renderer script (only after the sheet settles)
    P5->>P6: module executes
    P6->>P6: refuse to mount when the marker says error
Loading

The latch catches a failure dispatched before the body script exists; the body listener catches one dispatched after. @wenshao mutated each id comparison independently and both mutations produce an unstyled transcript in a real browser, so neither half is redundant.

Test evidence

This comment carries the PR's own CI results, read from the check-runs API for 328feb43f8. I ran nothing. Real-scenario coverage of the browser surface is not something I can add from here — see the sandboxed-lane note below.

Check Conclusion
Test (ubuntu-latest, Node 22.x) success
Lint & Static (ubuntu-latest, Node 22.x) success
Integration Tests (no-AK, No Sandbox) success
TUI parity snapshots (ink vs opentui) success
Desktop Shell (ubuntu-22.04) success
Desktop Shell (windows-2022) success
OpenTUI no-flicker gate success
Classify PR success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) failure
Test (windows-latest, Node 22.x) skipped (merge_group/schedule-gated)
Test (macos-latest, Node 22.x) skipped (merge_group/schedule-gated)
Integration Tests (CLI, No Sandbox) skipped
route (x3) cancelled

The one red check is a pre-existing flake in this PR's base, already fixed on main. I would not say that without evidence, so here is all of it:

  • The failing test is client/e2e/web-shell.history-viewport.spec.ts:189 › global turn navigation preserves the reading row across bounded 200-record pages, with TimeoutError: page.waitForFunction: Timeout 10000ms exceeded at utils/sseTransport.ts:174. It failed all three attempts.
  • This PR changes 16 files and none of them are under packages/web-shell. At this head that spec is byte-identical to the merge-base fbb877a48e (10,339 bytes both, waitForConnection(sessionId) with no timeout argument at line 148 in both).
  • The timeout in the error is the transport's 10 s default — exactly what that un-argumented call uses.
  • main fixed precisely this line in a0f0d38d4c ("fix(web-shell): avoid duplicate cold session restoration (fix(web-shell): avoid duplicate cold session restoration #11413)", merged 2026-09-09T22:43Z), changing it to waitForConnection(sessionId, { timeout: 30_000 }) under a new comment: "CPU-throttled browser startup can exceed the transport's default 10s." The failing variant is the one that calls Emulation.setCPUThrottlingRate with rate 4.
  • This PR's head is 8 commits behind that fix.
  • The same job passed at two earlier commits on this same branch (3b63662b, d54fcd0f) with the identical spec file — timing flake, not a deterministic break.

Remedy is a rebase onto main, not a code change. Worth doing regardless: until then the 10 s default stays in this branch's tree and the flake will keep recurring on every CI run. Note mergeStateStatus is currently BLOCKED.

The lane that matters for this PR is green, and it ran at this head. Inside that same job, step 15 "Run transcript document browser gate" completed successfully before step 16 failed: chat-transcript-document.test.ts6 passed, including fails closed when the CDN stylesheet is unavailable (510 ms) and fails closed when the CDN renderer is unavailable or fails integrity (426 ms). That closes the specific gap my last pass deferred on — the browser gate had not run on the reviewed commit. It has now, in CI, on this commit.

Not verified by me, and named as such:

  • Windows and macOS builds. Both Test lanes are merge_group/schedule-gated and report skipped here, so the two new scripts/tests/ files never executed on a non-POSIX filesystem in CI. The separator fix that round 1's first Critical turned on is pinned by transcript-css-entry-filter.test.js and .gitattributes sets * text=auto eol=lf, but neither was observed running on those hosts.
  • The performance claim. No CI lane measures time-to-render, so the perf in this PR's title is not substantiated by anything above.

Third-party maintainer verification exists and is unusually strong, but it is not CI and not mine: @wenshao (admin) built both arms from source and drove a real browser against a local HTTP origin at this exact head — 241 tests green, a 12-cell fail-closed matrix closed in every failure cell, render parity against merge-base at 0 differing pixels in both themes, and a mutation matrix per finding (round 1, round 2). His measured time-to-render is median 326 ms against 383 ms on merge-base. I am citing that as his measurement, not as evidence I reproduced.

Sandboxed verification would settle the remaining half: @qwen-code /verify — the perf claim is this PR's title and no lane measures it, so an A/B against the base build would turn "1.83 MB of JS instead of 4.14 MB" into an actual render-time delta on CI hardware rather than on one maintainer's machine. The fail-closed behaviour does not need it; the browser gate already substantiates that at this head.

中文说明

328feb43f8 上做代码审查,纯静态——按本门禁规则,我没有构建、运行或 checkout 本 PR 的任何内容。下面测试部分的证据来自 PR 自己的 CI,通过 API 读取。

代码审查

在这个 head 上我没有自己的 Critical 发现。 我读了构建插件、摘要与 URL 推导、文档模板、渲染器的两处守卫、formatter 的 nonce 替换,以及三个打包脚本。具体来说:

  • SRI 摘要与实际发布字节不可能背离。documentRendererCssIntegrity 是对 extractedTranscriptCss.csssha384,而资产正是由同一个字符串写出;createHash().update(string)writeFile(path, string) 都默认 utf8,因此摘要描述的就是 unpkg 将要提供的字节。copyFileSync 原样把它们带进 dist/
  • 两个 URL 取自同一个表达式 exportTranscriptRendererVersion.split('+')[0],所以 JS 与 CSS 不可能固定到不同版本。委派覆盖是对称的,新增的 QWEN_EXPORT_RENDERER_CSS_INTEGRITY 在能进入文档之前,同时接受了"必须一起设置"和 sha384- 格式两项校验。
  • nonce 路径是安全的。injectDocumentNonce 先校验占位符存在,然后 replaceAll,因此本 PR 新增的第三、第四个 __EXPORT_NONCE__ 槽位(head latch 脚本与 <link>)与既有的槽位一起被替换。如果那是只替换首处的 .replace(),每个导出都会带着字面 nonce 发出去并被 CSP 拦掉——事实并非如此。
  • documentResidualPlaceholder 已加入两个新占位符,所以漏掉一个 .replace() 仍会让构建失败,而不是发出一个在查看时抛错的模板。
  • 打包链路每一步都闭合:发布必需校验、发布 files、bundle 拷贝、standalone 排除。拷贝保持为 warning 而不是 throw 是正确的,注释也说明了原因——该脚本同时服务于 --cli-only 开发 bundle,而 prepare-package.js 才是发布门禁。

round 1 的两个 Critical 都已修复,而且我是通过在这个 head 上读代码确认的,不是采信报告。 R1-1 的 filter 现在是独立模块里的 /web-shell[\\/]dist[\\/]transcript\.js$/——两种分隔符都在字符类中,且保留了 transcript\.js$ 尾部,因此被禁的 web-shell/dist/index.js 包根仍然无法匹配。R1-2 的 head latch 注册在 <link> 之前、捕获阶段、只记录、由 body 脚本消费;document-main.tsx 随后在标记为 error 时拒绝挂载。

我自己的两条备注,都不阻断:

  1. document-main.tsx:242-249 里的 requestAnimationFrame 守卫从样式表路径看似乎不可达——如果模块级时标记已是 error,React 根本不挂载,effect 也就不会被调度。但它并非严格死代码:unhandledrejection 同样会走 showLoadError,而落在"已挂载"与"rAF 回调"之间的 rejection 正是这个守卫吸收的东西。所以我建议保留。它没有测试覆盖,@wenshao 的变异测试也没能构造出删除它会改变行为的路径,这与"它守的是一个窄竞态而非常见路径"是一致的。
  2. build.mjs:200 仍写着 "Set both or neither" 并只列两个变量,而它正上方的代码现在缺少第三个就会抛错。这是本 PR 正在编辑的文件里的过期注释块。

我明确延后、而不是现在要求的建议。 本 PR 已经过约五轮评审,因此按本仓库自己的指引,我只落 Critical 修复,并把其余记录在此以免被悄悄丢掉。以下全部是建议级、全部由 @wenshao 实测过、且都不是已发布代码路径上的缺陷:

  • <link> 的 nonce 是承重的却没有测试钉住——html.test.ts 断言的是全文子串,所以删掉那个 nonce 后套件仍全绿,而真实浏览器里每个导出都只会显示加载失败页。改成按元素定位的断言即可。
  • 渲染器 fail-closed 用例被混淆——新的样式表 id 加进了同一个 handler,而那个测试会同时中止两个资产,所以把 transcript-renderer 从 body 监听器里删掉它仍然通过。在既有 route handler 里补一个 RENDERER_CSS_URL 的 fulfil 就能恢复见证。
  • 发布门禁只校验 JS;对 .github/workflows/grep 找不到 .css
  • 条件 B 的层叠顺序没有测试钉住(见 Stage 1)。
  • link.sheet !== null 本身并不能证明样式表有内容,尽管紧邻的 katexFontFamily 断言可以——值得加一句注释,免得日后有人只信其中较弱的那一半。
  • 四处文档缺口,均已在本 head 上核实为过期:build.mjs:200docs/verification/export-renderer-delegation-mermaid/README.md:106-107(那条可复制的两开关命令现在会抛错);docs/users/features/commands.md:39;以及 docs/verification/export-html-runtime-size/README.md:268——它引用了本 PR 改名的 Document export runtime is N bytes 日志行,而同一文件的第 85 行已经带上了新行。

其中委派 runbook 是我建议在合入前而不是合入后处理的一条:它是本 PR 引入的"发布前 404 窗口"的既定应急手段,而按现状它是坏的。

head latch 为什么存在

上面那张时序图画的正是 round 1 第二个 Critical 的时序陷阱,以及 head latch 与 body 监听器现在各自覆盖的两个互不相交的时间窗。latch 接住在 body 脚本存在之前派发的失败,body 监听器接住之后派发的失败。@wenshao 分别对两处 id 比对做了变异,两个变异在真实浏览器里都会产生无样式 transcript,所以两半都不是冗余的。

测试证据

本条评论携带的是 PR 自己的 CI 结果,从 328feb43f8 的 check-runs API 读取。我没有运行任何东西。 浏览器界面的真实场景覆盖不是我在这里能补上的——见下方的沙箱流水线说明。

(CI 表格见上方英文部分,机器可读区域标记内。)

唯一的红检查是本 PR base 上的既有 flaky,且已在 main 上修复。 我不会不给证据就这么说,以下是全部证据:

  • 失败的测试是 client/e2e/web-shell.history-viewport.spec.ts:189 › global turn navigation preserves the reading row across bounded 200-record pages,报错为 utils/sseTransport.ts:174 处的 TimeoutError: page.waitForFunction: Timeout 10000ms exceeded,三次尝试全部失败。
  • 本 PR 改了 16 个文件,没有一个在 packages/web-shell。在这个 head 上,该 spec 与 merge-base fbb877a48e 逐字节相同(两边都是 10,339 字节,第 148 行都是不带 timeout 参数的 waitForConnection(sessionId))。
  • 报错里的超时是 transport 的 10 秒默认值——正是那个不带参数的调用所使用的值。
  • maina0f0d38d4c("fix(web-shell): avoid duplicate cold session restoration (fix(web-shell): avoid duplicate cold session restoration #11413)",2026-09-09T22:43Z 合入)中恰好修了这一行,改为 waitForConnection(sessionId, { timeout: 30_000 }),并新增注释:"CPU-throttled browser startup can exceed the transport's default 10s." 而失败的正是那个以 rate 4 调用 Emulation.setCPUThrottlingRate 的变体。
  • 本 PR 的 head 落后该修复 8 个提交。
  • 同一个 job 在同一分支更早的两个提交(3b63662bd54fcd0f)上通过,而 spec 文件完全相同——是时序 flaky,不是确定性破损。

解决办法是 rebase 到 main,而不是改代码。无论如何都值得做:在此之前,10 秒默认值仍留在本分支的树里,每次 CI 都会继续复现这个 flaky。注意 mergeStateStatus 目前是 BLOCKED

对本 PR 真正关键的那条流水线是绿的,而且是在这个 head 上跑的。 在同一个 job 内部,第 15 步 "Run transcript document browser gate" 在第 16 步失败之前已成功完成:chat-transcript-document.test.ts —— 6 passed,包含 fails closed when the CDN stylesheet is unavailable(510 ms)与 fails closed when the CDN renderer is unavailable or fails integrity(426 ms)。这正好闭合了我上一轮暂缓时所指的那个缺口——浏览器门禁当时没有在被评审的 commit 上跑过。现在它跑了,在 CI 上,在这个 commit 上。

我未验证、并如实标注的部分:

  • Windows 与 macOS 构建。两条 Test 流水线都是 merge_group/schedule 门控,在此报告为 skipped,因此两个新的 scripts/tests/ 文件在 CI 中从未在非 POSIX 文件系统上执行过。round 1 第一个 Critical 所依赖的分隔符修复由 transcript-css-entry-filter.test.js 钉住,.gitattributes 也设了 * text=auto eol=lf,但两者都未在这些主机上被观察到运行。
  • 性能主张。没有任何 CI 流水线测量渲染耗时,所以本 PR 标题里的 perf 并未被上面任何一项证实。

存在第三方的维护者验证,而且强度罕见地高,但它不是 CI、也不是我做的:@wenshao(admin)从源码构建了两条基线,并在这个 head 上对真实本地 HTTP 源站驱动了真实浏览器——241 条测试全绿、12 格 fail-closed 矩阵在每个故障格都是 closed、与 merge-base 的渲染差异在两种主题下均为 0 像素、以及逐条发现的变异矩阵(第一轮第二轮)。他实测的渲染耗时为中位数 326 ms,对 merge-base 的 383 ms。我引用的是他的测量,不是我自己复现的证据。

沙箱验证可以补上剩下的一半: @qwen-code /verify —— 性能主张是本 PR 的标题,而没有任何流水线测量它,所以一次对 base 构建的 A/B 能把"1.83 MB JS 而不是 4.14 MB"变成 CI 机器上真实的渲染耗时差值,而不是只存在于某一位维护者的机器上。fail-closed 行为不需要它;浏览器门禁已经在这个 head 上证实了那一部分。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 328feb43f82b0d59f7adee18d8d1dd54e1060b3b · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — I found no code defect at this head and I would merge the implementation; what is outstanding is a rebase and a sequencing decision, not a fix.

Stepping back. I wrote my own approach down before reading the diff, and yours matched it point for point — export-build-boundary strip, fail-closed on shape drift, sha384 over the exact published bytes, one version expression for both URLs, <link> after the inline <style>, listener ahead of the <link>. Yours is better in one place: three separate throws so a shape change in injectCssModules breaks the build instead of shipping a renderer that both links and injects 2.3 MB. Pulling the entry filter into its own module purely so the match decision is testable without running a top-level-await build is the right instinct and the docblock earns its length, because the two things it explains (Windows separators, why the transcript\.js$ tail bars the package root) are both things a future reader would otherwise "simplify" and break. If I inherited this in six months I would thank whoever wrote it.

I also want to correct my own last pass, because it is still sitting in this thread and it was wrong. I asked for a link.sheet === null check before mount as the clean way to close the fail-closed timing question. @wenshao measured that a 404 and a connection reset both leave sheet !== null, so that oracle would have been weaker than the one you built. The head latch you chose removes the window instead of checking after it, and the mutation testing shows the latch and the body listener cover disjoint timing windows with neither redundant. Condition C on the issue offered two options and you took the better one. My Condition-C concern from the last pass is closed and I am withdrawing the specific remedy I proposed.

What I am not going to wave through is the evidence state, and it is not about code quality.

The branch needs a rebase, and that is worth more than an approval from me right now. mergeStateStatus is BLOCKED and the rollup is red on web-shell E2E Smoke. I traced that to a pre-existing flake in your base — the spec is byte-identical to the merge-base, the timeout in the error is the transport's 10 s default, main fixed exactly that line three hours before your last push in a0f0d38d4c, and you are 8 commits behind it — so it is not your defect and the full evidence is in Stage 2. But the 10 s default stays in your tree until you rebase, which means the flake recurs on every run, and an approval pinned to 328feb43f8 would be dismissed the moment you push the rebase. That is not hypothetical: @qqqys approved at d54fcd0f and the push to 328feb43f8 dismissed it. So approving now buys you nothing and costs a round-trip. Rebase first.

Related, and easy to miss: the CHANGES_REQUESTED currently standing on this PR is a /review pass from 18:19Z at 3b63662b — three pushes ago. Both Criticals it was gated on (the Windows onLoad filter, the head/body listener ordering) are fixed, and I verified that by reading the code at this head rather than taking the reports for it. GitHub does not dismiss request-changes reviews on push the way it dismisses approvals, so that stale state will keep blocking you until someone dismisses it. Worth clearing deliberately rather than discovering at merge time.

#11372 needs a decision before either PR merges, and only you can make it. It is APPROVED and open, it edits the same two constants in the same file from the same base blob, and it moves them the other way — 4_200_000 / 4_300_000 against your 1_870_000 / 1_930_000. The issue triage asked for the two to be sequenced and five commits later they still are not. If this one lands first and #11372 is then resolved mechanically in its own favour, the JS-only cap goes to 4.3 MB against a 1.83 MB measurement — about 2.4 MB of slack, which is precisely the "decoration" the comment you kept warns against. If #11372 lands first, you rebase and the conflict is trivial. Either order works; picking none is what breaks.

Condition A is a maintainer call, not a gate call. You took the ratchet from covering 4.1 MB of render-blocking bytes to covering 1.83 MB of them, and the <link> is in <head>, so the reader still pays for both before first paint. The issue asked for the cap to stay combined; your design doc says you chose otherwise, which is a legitimate answer, but it does not acknowledge that it is answering a condition set on it. I am raising it as a question and explicitly not blocking on it — but it should be decided out loud, and it is four lines if the answer is "guard both". Conditions F (break the 2,298,871 CSS bytes down before choosing the split as the first move) and G (the font-src data: correction) are still unanswered in the design doc, and E's docs/users/features/commands.md:39 is still stale.

Everything else I found is Suggestion-level and I am deferring it rather than asking for another round — this PR has been through roughly five, and this repo's own guidance at that point is to land Critical fixes only and record the rest. The list is in Stage 2 with the measurements behind each item. The one I would genuinely fold in now is the delegation runbook, because its copy-pasteable two-knob command now throws and that command is the documented escape hatch for the 404-until-release window this PR introduces.

⏸️ Deferring — no approval and no request for changes. @yiliang114, both blocking items are yours and neither is a code change: rebase onto main so the flake fix and the CI rollup come with it, and say which of #11372 and this PR lands first. I could not resolve a different accountable owner to hand the Condition A decision to — $QWEN_MAINTAINER_HANDLE is unset, the deterministic resolver is blocked by this run's permission rules, the PR carries only review/self-reported so no area label matches, and latestReviews is empty — so rather than guess a login I am naming you, which is also correct on the merits since you are an admin here and you own both PRs. I have not assigned the PR to you because you are already its author.

I expect to approve on the re-run once the head carries the rebase and #11372 has an answer. Nothing in the implementation is holding this back.

中文说明

Confidence: 3/5 —— 在这个 head 上我没有找到代码缺陷,实现本身我会合;尚未解决的是 rebase 与先后顺序的决定,不是修复。

退一步看。我在读 diff 之前先写下了自己的做法,而你的方案与之逐点吻合——在导出构建边界剥离、形状漂移时 fail-closed、对实际发布字节做 sha384、两个 URL 共用一个版本表达式、<link> 放在内联 <style> 之后、监听器注册在 <link> 之前。有一处你做得更好:三个独立的抛错点,使得 injectCssModules 的形状一旦变化,构建就失败,而不是发出一个既 link 又注入 2.3 MB 的渲染器。把 entry filter 抽成独立模块、纯粹为了让"是否匹配"这个决定能在不跑 top-level-await 构建的前提下被测试覆盖,这个直觉是对的,而那段 docblock 也对得起它的长度——它解释的两件事(Windows 分隔符、为什么 transcript\.js$ 尾部要挡住包根)正是日后读者会顺手"简化"掉并因此弄坏的东西。如果六个月后由我接手,我会感谢写它的人。

我还要更正我上一轮的说法,因为它还留在这个 thread 里,而它是错的。我当时要求在挂载前判一次 link.sheet === null,作为闭合 fail-closed 时序问题的干净做法。@wenshao 实测:404 与连接重置都会留下 sheet !== null,所以那个 oracle 会比你做出来的这个更弱。你选的 head latch 是消除那个时间窗,而不是在事后检查它;变异测试也显示 latch 与 body 监听器覆盖的是互不相交的时间窗,谁都不冗余。issue 的条件 C 给了两个选项,你选了更好的那个。我上一轮对条件 C 的顾虑已经闭合,而我当时提出的具体补救办法,我在此撤回。

我不打算放行的是证据状态,而这与代码质量无关。

这个分支需要 rebase,而这比我此刻给你一个批准更有价值。 mergeStateStatusBLOCKED,rollup 在 web-shell E2E Smoke 上是红的。我把它追到了你 base 上的一个既有 flaky——该 spec 与 merge-base 逐字节相同、报错里的超时是 transport 的 10 秒默认值、main 在你上次推送前三小时于 a0f0d38d4c 中恰好修了这一行、而你落后它 8 个提交——所以这不是你的缺陷,完整证据在 Stage 2。但在你 rebase 之前,10 秒默认值仍留在你的树里,也就是说每次运行都会复现这个 flaky;而一个钉在 328feb43f8 上的批准,会在你推送 rebase 的那一刻被 dismiss。这不是假设:@qqqysd54fcd0f 上批准过,推送到 328feb43f8 就把它 dismiss 了。所以现在批准对你毫无收益,还要多花一轮。先 rebase。

相关、且容易被忽略的一点: 本 PR 上目前挂着的 CHANGES_REQUESTED 来自 18:19Z 在 3b63662b(三次推送之前)的一次 /review。它所依据的两个 Critical(Windows onLoad filter、head/body 监听器时序)都已修复,而我是通过在这个 head 上读代码确认的,不是采信报告。GitHub 不会像 dismiss 批准那样在推送时 dismiss request-changes,所以这个过期状态会一直挡着你,直到有人显式撤销它。值得主动清掉,而不是在合并时才发现。

#11372 需要在任一 PR 合入之前有决定,而这个决定只有你能做。 它现在是 APPROVED 且开着,从同一个 base blob 出发、在同一个文件里改同样那两个常量,而且方向相反——4_200_000 / 4_300_000 对你的 1_870_000 / 1_930_000。issue triage 当时就要求两者排好先后,五个提交过去仍然没有。如果本 PR 先合、随后 #11372 被机械地按它自己那一边解决,那么"仅 JS"的上限就会变成对 1.83 MB 的实测设 4.3 MB——约 2.4 MB 的松量,正是你保留下来的那段注释所警告的"装饰"。如果 #11372 先合,你 rebase 一下,冲突是琐碎的。两种顺序都行;不选才会坏事。

条件 A 是维护者的决定,不是门禁的决定。 你把棘轮从"覆盖 4.1 MB 渲染阻塞字节"改成"覆盖其中 1.83 MB",而 <link><head> 中,所以读者在首绘前两者都要付。issue 要求上限保持合并;你的设计文档说你有意选择了另一条路,这是站得住的答案,但它没有承认自己在回应一条为它设定的条件。我把它作为问题提出,并明确不据此阻断——但它应该被公开决定一次;如果答案是"两个都守",那是四行。条件 F(在把拆分选为第一步之前,先把 2,298,871 字节 CSS 拆开)与 G(font-src data: 的更正)在设计文档中仍未回应,E 里的 docs/users/features/commands.md:39 也仍然过期。

我找到的其余一切都是建议级,我选择延后而不是再要一轮——本 PR 已经过约五轮,而本仓库在那个节点上的指引就是只落 Critical 修复、其余记录在案。清单在 Stage 2,每一条后面都附了实测。我唯一真心建议现在就带上的,是委派 runbook:那条可复制的两开关命令现在会抛错,而它正是本 PR 引入的"发布前 404 窗口"的既定应急手段。

⏸️ 暂缓 —— 不批准,也不请求变更。 @yiliang114,两个阻断项都是你的,而且都不是代码改动:rebase 到 main,把 flaky 修复和 CI rollup 一起带过来;以及说明 #11372 与本 PR 谁先合。我没能解析出另一位该接手条件 A 决定的负责人——$QWEN_MAINTAINER_HANDLE 未设置、确定性解析脚本被本次运行的权限规则拦住、PR 只带 review/self-reported 因此没有 area 标签匹配、latestReviews 为空——所以与其猜一个 login,我选择点你的名;这在实质上也是对的,因为你在这里是 admin,而两个 PR 都是你的。我没有把 PR 指派给你,因为你已经是它的作者。

一旦 head 带上 rebase、且 #11372 有了答案,我预期会在重跑时批准。实现本身没有任何东西在拖住它。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 328feb43f82b0d59f7adee18d8d1dd54e1060b3b · re-run with @qwen-code /triage

@yiliang114

Copy link
Copy Markdown
Collaborator Author

真实数据验证(优化差异)

用仓库里真实的 4 条 ChatRecord fixture 走了一遍完整 /export html 链路,并用无头 Chromium 打开产物:

体积(node src/export-html/build.mjs 实测)

资产 Before After
export-transcript-document.js(浏览器必须解析+编译) 4,136,297 B 1,831,298 B(−56%)
gzip(线上字节) ~1.5 MB 526 KB
export-transcript-document.css —(内联在 JS 里) 2,302,457 B(gzip 1.0 MB,独立、可缓存一年、并行加载)
总 gzip ~1.5 MB ~1.5 MB(不变,符合 issue 说明)

即:总线上字节基本不变,但浏览器「解析+编译」的关键路径从 4.1 MB 降到 1.83 MB(gzip 1.5 MB → 0.5 MB)。

真实导出产物/tmp/real-export.html,8,094 B):

  • renderer JS URL: unpkg…/export-transcript-document.js
  • renderer CSS URL: unpkg…/export-transcript-document.css
  • <link id="transcript-stylesheet"> 带 nonce + sha384- integrity ✓

无头 Chromium 打开真实导出

  • render-complete: true
  • stylesheet sheet loaded (SRI passed): true(样式表真实加载并解析,SRI 通过)
  • transcript root present: true,4 条消息全部渲染
  • 请求:script = 1, style = 1, other = 0(无多余请求、无 CSP 违规)

CI

上一轮 web-shell E2E Smoke 失败是 web-shell.history-viewport.spec.tswaitForConnection 超时,根因是 connect ECONNREFUSED 127.0.0.1:4170(假 daemon 没起来),与本次改动无关(未触碰 web-shell 源码 / SSE / history-viewport)。已 gh run rerun --failed 重试。

@yiliang114
yiliang114 enabled auto-merge September 9, 2026 14:52

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed.

2 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R1-3 byte ratchet no longer covers the lifted CSS asset — already reported (comment 5603265110, stage-2 item 1; also comments 5603265631 and 5603264729)
  • R1-20 delegation docblock and runbook still describe a two-variable contract — already reported (comment 5603265110, stage-2 item 3; also comment 5603265631)

Unresolved, please confirm:

  • [Critical] stage-3 triage blocker (comment 5603265631): whether open PR #11372 or this PR lands first — verified #11372 is OPEN and edits the same packages/web-templates/src/export-html/build.mjs (+6/-8) in the opposite direction, but the sequencing d…

Not reviewed: build-and-test — Test (windows-latest, Node 22.x) was skipped in CI and its suite did not run locally.

Not reviewed: reverse audit — stopped before round 6 by the review time budget.

Test Plan (not a blocker): 6 passed — this review observed 29641 passed.

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 2 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未决,请确认:共 1 条(原文未翻译,列表见上方英文部分)。

未审查(原文为英文):build-and-test — Test (windows-latest, Node 22.x) was skipped in CI and its suite did not run locally.

未审查:反向审计——评审时间预算不足,未能开始第 6 轮。

Test Plan(非阻断):6 passed — this review observed 29641 passed

— qwen3.8-max via Qwen Code /review (v0.23.2)

Comment thread packages/web-templates/src/export-html/build.mjs Outdated
Comment thread packages/web-templates/src/export-html/src/document-index.html
Comment thread integration-tests/chat-transcript-document.test.ts
Comment thread integration-tests/chat-transcript-document.test.ts
Comment thread integration-tests/chat-transcript-document.test.ts
Comment thread packages/web-templates/src/export-html/build.mjs
Comment thread packages/web-templates/src/export-html/src/document-index.html
Comment thread packages/web-templates/src/export-html/src/document-main.tsx Outdated
Comment thread scripts/copy_bundle_assets.js
Comment thread scripts/prepare-package.js
yiliang114 and others added 3 commits September 10, 2026 02:53
esbuild hands plugin callbacks the platform-native absolute path, so the
extract-transcript-css `onLoad` filter never matched on Windows: the callback
did not run, `extractedTranscriptCss.css` stayed undefined, and the mandatory
extraction guard below aborted the build. That build is not platform-gated —
`scripts/prepare.js` runs it from `prepare`, so `npm ci` itself would fail on
every Windows contributor and on the windows-latest legs of test_windows and
desktop-release.

Widen the separator to `[\\/]`, keeping the `transcript\.js$` tail so the
barred `web-shell/dist/index.js` package root still does not match. The filter
moves to transcript-css-entry.mjs because build.mjs is a top-level-await script
with no harness — the same reason scripts/sdk-node-exporter-stub.js exists — so
scripts/tests/transcript-css-entry-filter.test.js can pin both separators.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtufhms2u4
The `<link id="transcript-stylesheet">` sits in `<head>` while the `window`
error listener that catches its failure is registered by an inline script in
`<body>`. Chromium parser-blocks that script on the pending stylesheet, so when
the CSS failure settles first the error event is dispatched with no listener to
receive it: nothing marks the render as failed, both renderComplete guards in
document-main.tsx pass, React mounts the transcript without any of the
component CSS, and the requestAnimationFrame stamps
`data-render-complete="true"`. The reviewer measured this fail-open above
roughly 2.1 MB of document HTML (272 of the 1,000 permitted blocks) for a 404,
an SRI rejection, a truncated body and a destroyed socket alike, and fail-closed
for a *late* failure — so size, not failure kind, decides it.

Latch the failure in `<head>` before the `<link>` is parsed and act on the latch
from the existing body IIFE. The head script only records: `showLoadError()`
writes `document.body.dataset` and `#app`, neither of which exists while the
parser is still in `<head>`. It carries `nonce="__EXPORT_NONCE__"` because the
document CSP allows no inline script, which is safe — `formatters/html.ts:53`
replaces every occurrence. The listener is capture-phase because resource error
events do not bubble.

Not the `link.sheet === null` variant: the reviewer measured `sheet` non-null
for a 404, a truncated body and a destroyed socket, so it only detects SRI
rejection.

scripts/tests/export-transcript-document-template.test.js pins the position,
the nonce, the capture phase and the record-only shape; all five cases go red
against the unpatched template. The behavioural witness (real Chromium, large
document, instant CSS abort) belongs to the playwright transcript gate, which
is out of budget on this host.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtufhms2u4
The bundle copy became all-or-nothing over two artifacts but its `else` warning
still named only the renderer, so the one new way to reach that branch — a tree
built before the split, then `npm run bundle`d without rebuilding web-templates,
which has the JS and no CSS — told the operator to go looking for a
`export-transcript-document.js` that was sitting right there, and silently
discarded it. List the paths that are actually absent, matching the sibling Web
Shell warning twenty lines above. Stays warn-and-skip: prepare-package.js is the
release gate.

Also pin that release gate. Every fixture that reached `preparePackage` staged
`dist/export-transcript-document.css` unconditionally, so deleting the new
required-path entry left the whole test:scripts lane green; a release built with
`npm ci --ignore-scripts` would then publish documents whose stylesheet 404s on
unpkg for that version. `verifyBundleArtifacts` reports through console.error +
process.exit(1) rather than a throw, so the new case stubs exit instead of
copying the audio-capture sibling's `toThrow` idiom.

Both cases were flip-checked: restoring the old warning text, and deleting the
CSS line from prepare-package.js, each turn their case red.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtufhms2u4

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Blocking finding(s) follow.

Partially reviewed — gaps disclosed. Suggestions are inline.

4 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R1-3 byte ratchet no longer covers the lifted CSS asset (build.mjs:50-51) — already reported (comment 5603265110, stage-2 item 1; also comments 5603265631 and 5603264729)
  • R1-20 delegation docblock and runbook still describe a two-variable contract (build.mjs:194-201, docs/verification/export-renderer-delegation-mermaid/README.md:106-107) — already reported (comment 5603265110, stage-2 item 3; also…
  • docs/users/features/commands.md:39 still describes a single version-pinned unpkg asset — already reported (comment 5603265110, stage-2 item 4; issue #11478 condition E)
  • docs/verification/export-html-runtime-size/README.md:268 still quotes the log line this PR removes — already reported (comment 5603265110, stage-2 item 4)

Unresolved, please confirm:

  • [Critical] stage-3 triage blocker (comment 5603265631) — whether open PR #11372 or this PR lands first. Verified #11372 is still OPEN and still edits only packages/web-templates/src/export-html/build.mjs (+6/-8) in the opposite direction, so the coord…

Not reviewed: build-and-test on Windows and macOS — Test (windows-latest, Node 22.x) and Test (macos-latest, Node 22.x) are merge_group/schedule-gated and report skipped on this PR, and no suite ran on either host here. This is the lane class that would have executed the two new scripts/tests files on a non-POSIX filesystem, which is what round-1 Critical R1-1 turned on; the separator fix is now pinned by scripts/tests/transcript-css-entry-filter.test.js, which scripts/tests/vitest.config.ts's win32 exclude list does not name, and .gitattributes sets * text=auto eol=lf, but neither was observed running on those hosts..

Test Plan (not a blocker): 6 passed — this review observed 29640 passed.

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • packages/web-templates/src/export-html/build.mjs:366 — [probe] Build identity no longer commits to the pinned stylesheet
  • packages/web-templates/src/export-html/build.mjs:229 — [probe] New delegated-CSS validation branches have no test
  • integration-tests/chat-transcript-document.test.ts:718 — [probe] Third copy of the probe envelope and fail-closed body

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

中文说明

仅完成部分审查,审查缺口已披露。 建议见行内评论。

本轮确认的 4 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未决,请确认:共 1 条(原文未翻译,列表见上方英文部分)。

未审查(原文为英文):build-and-test on Windows and macOS — Test (windows-latest, Node 22.x) and Test (macos-latest, Node 22.x) are merge_group/schedule-gated and report skipped on this PR, and no suite ran on either host here. This is the lane class that would have executed the two new scripts/tests files on a non-POSIX filesystem, which is what round-1 Critical R1-1 turned on; the separator fix is now pinned by scripts/tests/transcript-css-entry-filter.test.js, which scripts/tests/vitest.config.ts's win32 exclude list does not name, and .gitattributes sets * text=auto eol=lf, but neither was observed running on those hosts..

Test Plan(非阻断):6 passed — this review observed 29640 passed

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 3 条(原文未翻译,列表见上方英文部分)。

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

— qwen3.8-max via Qwen Code /review (v0.23.2)

Comment thread docs/design/2026-09-09-split-export-transcript-css.md Outdated
Comment thread scripts/tests/export-transcript-document-template.test.js
qqqys
qqqys previously approved these changes Sep 9, 2026

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical-only review at head d54fcd0f. Approving: both Criticals reported on this PR are verifiably fixed in the code at this head, and a scan of the production surface found no provable Critical.

Previously blocking findings — fixed and re-verified

R1-1 (Windows onLoad filter aborted every export build). The filter now lives in packages/web-templates/src/export-html/transcript-css-entry.mjs as TRANSCRIPT_CSS_ENTRY_FILTER = /web-shell[\\/]dist[\\/]transcript\.js$/, so it matches the platform-native path esbuild hands plugin callbacks on either separator, and the transcript\.js$ tail is retained so the barred web-shell/dist/index.js package root still cannot match. build.mjs:7 imports it and :141 uses it as the extract-transcript-css plugin's filter, with the mandatory guard at :300-303 still throwing when extraction never ran. Extracting the matcher out of the top-level-await build script is also what makes it pinnable, and scripts/tests/transcript-css-entry-filter.test.js covers both separators plus the dist/index.js negative.

R1-2 (a fast stylesheet failure reached no listener, so an unstyled transcript stamped data-render-complete="true"). document-index.html now carries a nonce-bearing <script> in <head> immediately before the <link id="transcript-stylesheet"> that registers a capture-phase window error listener — capture is required because resource error events do not bubble — and records window.__transcriptStyleFailed = true without touching document.body, which does not exist yet. The body IIFE keeps its own listener for both asset ids and then acts on the latch (if (window.__transcriptStyleFailed) showLoadError();), so a failure that settles while the parser is still blocked on the stylesheet is recorded before the body exists and consumed the moment it does. The chain then closes: showLoadError sets document.body.dataset.renderComplete = 'error', document-main.tsx:344 refuses to mount when that marker is 'error', and :247 only stamps 'true' when it is not — so the unstyled-but-successful render the finding described is no longer reachable. The nonce is load-bearing under script-src 'nonce-…' with no 'unsafe-inline', and formatters/html.ts:50-53 guards the placeholder and replaceAlls it, so the new head script and the <link> are both nonced.

The thread stays open on the author's own disclosed verification gap (never witnessed on a real multi-megabyte document or a Windows host). I am ruling on the mechanism, which is complete in the code above: the listener now exists before the <link> is parsed, so there is no window in which the event can be dispatched unheard.

Critical-only scan of the current diff

Read the full export document template, the new entry module, the build plugin and its asset/digest derivation, the renderer's guard sites, and the formatter's placeholder substitution. No blocking defect:

  • The extraction is fail-closed at three points: a missing __qwenWebShellCss constant throws, a moved or absent runtime-injection line throws, and a plugin that never matched throws before any asset is written — so a shape change in injectCssModules breaks the build rather than shipping a renderer that still injects CSS.
  • The SRI digest and the published bytes cannot diverge: documentRendererCssIntegrity is sha384 over extractedTranscriptCss.css (build.mjs:389-390) and the asset is written from that same string (:450-452), both utf8.
  • The CSS URL is derived from the same published version as the JS, and the delegate path (rendererDelegateCssIntegrity) keeps the inter-release override symmetric with the renderer's.
  • __DOCUMENT_RENDERER_CSS_URL__ / __DOCUMENT_RENDERER_CSS_INTEGRITY__ are substituted beside the pre-existing renderer replacements, so no placeholder can reach a shipped document.

CI at this head: Test (ubuntu-latest, Node 22.x), Lint & Static, Integration Tests (no-AK, No Sandbox) — which runs the transcript document gate that now fulfils the stylesheet from the built asset — and the TUI parity gate are all green. The rollup reads FAILURE only because three route checks were cancelled, which is not evidence of a defect here.

The twelve remaining open threads are all Suggestion-level — test-oracle strength (link.sheet not discriminating a loaded stylesheet, order and id-literal pinning, the confounded renderer-failure case), diagnostic wording that still names only the renderer, the release-time gate covering one of the two published assets, the design docs describing the pre-fix shapes, and the 404-until-release window the description already discloses. None blocks merge; they are worth a follow-up issue so they are not lost.


Correction added after submission. One unresolved item in the round-2 review body is not a code finding and is not settled by this approval: the stage-3 triage blocker asking whether open PR #11372 or this one lands first. #11372 is still open and still edits only packages/web-templates/src/export-html/build.mjs (+6/-8) in the opposite direction, so whichever lands second needs a rebase and a re-check of the extraction plugin and its guard. That sequencing call belongs to the maintainers; this approval covers the code at this head as verified above and does not resolve it.

@wenshao

wenshao commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Local verification of #11485 — real build, real browser, real network faults

Verified at d54fcd0f18 against merge-base fbb877a48e. Linux, Node 22.22.2, headless Chromium
(Playwright). Both arms were built from source; every browser result below comes from a real HTTP
origin serving the real built assets — no CDP route interception, no mocks, because route
interception is exactly what hides the failure mode this PR's last commit fixes.

TL;DR

The mechanism is sound and I could not break it. What is left is a byte-budget decision, a doc
regression, a sequencing question, and one missing test.

The strip is provably lossless — extracted CSS is byte-identical (sha256) to what injectCssModules injects at runtime on the merge-base, measured in the browser, not just in the source.
Pixel-identical rendering vs merge-base — 0 differing pixels, dark and light theme.
Fails closed under every real failure I could stage: HTTP 404, connection reset, SRI mismatch, JS 404 — including on multi-megabyte exports.
Packaging chain closed end-to-end, including both failure paths. The PR's own suites pass locally (6 + 76 + 158) and full CI is green on this head, web-shell E2E Smoke included.
🔴 The <head> latch (c57203fc) is load-bearing — I reproduced the exact bug it fixes on 3b63662b, and the PR's own new regression test passes without the fix.
🟠 The byte ratchet no longer covers 56% of the render-blocking payload. Demonstrated, not argued: +1 MB of CSS builds green here and fails the build on merge-base.
🟠 The copy-pasteable delegation command in docs/verification/export-renderer-delegation-mermaid/README.md now hard-throws. Verified by running it.
🟠 The measured user-visible win is ~48 ms, and it disappears on a bandwidth-limited connection. Total bytes are unchanged (−2,540).

1. The extraction is lossless (issue raised in stage-2: "regex surgery on a minified artifact")

Three independent measurements, one hash:

sha256 e0e4a14164081338ff63621c15b46c31f9298f3fbe5808be2cbaf50c09cf3a8d   2,302,905 bytes
  ├─ JSON.parse of the `__qwenWebShellCss` literal in packages/web-shell/dist/transcript.js
  ├─ the runtime-injected <style data-qwen-web-shell="component"> textContent, read out of a live
  │  browser rendering an export built at the MERGE BASE (i.e. what readers get today)
  └─ export-transcript-document.css produced by this PR  (and dist/ after packaging)

The middle line is the one that matters: it is not a re-read of the same input, it is what Chromium
actually had in its stylesheet on main. And the CSS really left the JS: __qwenWebShellCss
is present in the merge-base bundle and absent at head, and so is KaTeX_Main — a token that can only
come from the stylesheet.

2. Render parity — 0 differing pixels

A transcript exercising headings, tables, fenced/highlighted code, inline + display KaTeX, mermaid,
task lists, blockquotes, a file-diff tool card and a shell tool card, rendered through the full
/export html path on both arms and screenshotted full-page:

render parity

compare -metric AE = 0 in dark theme and 0 after clicking Light theme. The output PNGs are
byte-identical. Condition B (cascade order behind the inline <style>) holds empirically, not just
by inspection.

3. The <head> latch is load-bearing — and untested

The approving review ruled on the mechanism and explicitly left open that the fix had "never [been]
witnessed on a real multi-megabyte document"
. It has now.

Repro: serve the export and both assets from one real local origin; return a real 404 for the
stylesheet. On 3b63662b (before c57203fc), on a large export, the <link> error is dispatched
while the parser is still blocked on it — before the <body> script registers its listener — so
nothing catches it and the renderer mounts anyway:

fail-closed A/B

Panel B is the bug: data-render-complete="true", no alert, and getComputedStyle('.katex') .fontFamily === '"Times New Roman"' — the component stylesheet never applied. Panel C is the same
document and the same 404 at this head.

export size fault 3b63662b (pre-latch) d54fcd0f (head)
10 KB CSS 404 / reset / SRI mismatch closed closed
0.40 MB CSS 404 closed closed
1.20 MB CSS 404 closed closed
2.41 MB CSS 404 closed closed
3.21 MB CSS 404 UNSTYLED 3/3 closed 3/3
4.0 MB CSS 404 UNSTYLED 3/3 closed 3/3
4.0 MB connection reset UNSTYLED 3/3 closed 3/3
4.0 MB SRI mismatch UNSTYLED 3/3 closed 3/3
4.81 / 7.22 MB CSS 404 UNSTYLED 3/3 closed 3/3
any JS 404 closed closed

The threshold on this machine is between 2.4 MB and 3.2 MB of exported HTML. EXPORT_TRANSCRIPT_LIMITS_V1
allows a 32 MB envelope and 1,000 blocks, so this was comfortably reachable — and the SRI-mismatch row
means a corrupted or tampered CDN response would have rendered, unstyled, rather than failing closed.
The fix is correct and worth keeping.

Two things follow from that.

  1. The new regression test does not pin the fix. I rebuilt the export template from 3b63662b
    (pre-latch) and ran the PR's own case against it:
    chat-transcript-document.test.ts -t "fails closed when the CDN stylesheet is unavailable"1 passed.
    The test uses page.setContent + route.abort, which resolves over CDP long after the document has
    parsed, so it can only ever exercise the slow path. Nothing in the suite would notice if the <head>
    latch were deleted tomorrow. A pinning test needs a real origin and a document large enough to make
    the parser yield; the harness on the assets branch is ~120 lines and does exactly this.
  2. The alternative fix suggested in triage would not have worked. link.sheet === null was proposed
    as a simpler guard. Measured on a real 404: link.sheet !== null with cssRules.length === 0. The
    guard would not have fired. The latch is the right shape.

4. The byte ratchet — demonstrated, symmetrically

I padded the __qwenWebShellCss literal by exactly 1,000,004 bytes on both arms and rebuilt:

merge-base this PR
CSS +1 MB ❌ build fails: Document export runtime is 5142389 bytes; expected <= 4200000 build green, renderer JS is 1833944 bytes; component CSS … is 3302909 bytes

The <link> is in <head> and is render-blocking — I measured this too: stall the stylesheet 2.5 s and
first-paint moves to 2,544 ms (nothing paints at all, not even the background). So a Tailwind scan
widening or a second KaTeX font format can add hundreds of KB to bytes the reader waits on, and every
build stays green. Adding a second constant pair for the CSS is ~4 lines; if the JS-only budget is the
deliberate call, it is worth making that call explicitly rather than inheriting it from the design doc.

Also worth noting for whoever re-ratchets next: the logged figure is taken before the renderer-version
placeholder substitution, so it is 3 bytes above the asset actually written (1833944 logged vs 1833941
on disk), and the ~3 KB inline document CSS that the merge-base counted is no longer in the budget at all.

5. Performance — honest numbers

Built sizes (my build; slightly above the PR body because main moved):

merge-base this PR Δ
export-transcript-document.js 4,139,386 1,833,941 −55.7 %
export-transcript-document.css 2,302,905 new
total on disk 4,139,386 4,136,846 −2,540
gzip −9, total 1,538,709 1,536,599 −2,110
build headroom 57,609 B under the 4,200,000 cap (and over the warning line) JS 96,059 B under the new cap

The merge-base build prints Document export runtime exceeds the 4100000-byte warning threshold today,
which corroborates the premise of #11478 independently of the author's numbers.

Time from navigation to data-render-complete (median, headless Chromium, local origin):

scenario merge-base this PR Δ
small export, unthrottled (7 runs) 383 ms 335 ms −48 ms (−12.5 %), no run overlap
900-block / 7.2 MB export, unthrottled (5 runs) 1,609 ms 1,561 ms −48 ms (−3 %)
small export, 40 Mbps / 20 ms RTT (5 runs) 1,193 ms 1,185 ms −8 ms
small export, 10 Mbps / 40 ms RTT (5 runs) 3,591 ms 3,585 ms −6 ms

So the PR body's framing is accurate — this is a JS parse/compile win — but the size of it is ~48 ms, it
is constant regardless of transcript size, and it is noise once bandwidth is the bottleneck, because
the same 4.1 MB still has to arrive and the stylesheet is render-blocking. One more thing the split does
not buy: both URLs derive from the same exportTranscriptRendererVersion.split('+')[0], so every
release invalidates both assets together — there is no differential-caching benefit to bank on.

None of this argues against merging. It argues that #11478 condition F (base64 KaTeX fonts, Tailwind
utilities from components the transcript never imports) is where the reader-visible win actually is, and
that it should become a tracked follow-up rather than be closed out by this PR.

6. Documentation regression — confirmed by execution

The runbook whose whole purpose is this recipe still documents the two-knob contract at
docs/verification/export-renderer-delegation-mermaid/README.md:106-107. Run verbatim:

merge-base : Document export delegates its renderer to …@0.23.1-preview.0/… ✅
this PR    : Error: QWEN_EXPORT_RENDERER_CSS_INTEGRITY must be set together with the renderer
             delegation … ❌ exit 1

Adding the third knob works (sha384-… computed over the built CSS → build succeeds), so this is a doc
fix, not a code fix. build.mjs:188-210 still says "Set both or neither"; docs/users/features/commands.md:39
still describes one pinned asset; docs/verification/export-html-runtime-size/README.md §6 still quotes
the Document export runtime is N bytes line this PR renames. All four are already reported — I am only
adding that one of them is an executable command that is now broken.

7. Packaging and release sequencing

Closed loop, verified live: copy_bundle_assetsdist/export-transcript-document.css byte-identical to
the built asset (same sha256) → prepare-package verifyBundleArtifacts hard-requires it (removing it
gives Error: Required package artifact not found: …/dist/export-transcript-document.css) → dist
package.json files carries it → standalone exclusion list carries it. The CSS-only-missing branch in
copy_bundle_assets names the missing file, as intended by d54fcd0f.

Live CDN check (this is a release-ordering note, not a defect):

https://unpkg.com/@qwen-code/qwen-code@0.23.2/export-transcript-document.js  → 200, 4,136,297 bytes
https://unpkg.com/@qwen-code/qwen-code@0.23.2/export-transcript-document.css → 404
npm dist-tag latest = 0.23.2

The 4,136,297 matches the PR body's "before" figure exactly. Since the exported URLs are built from the
repo version, no export from this branch renders until a version after 0.23.2 is published carrying both
assets — expected and disclosed, but it means this must not ship in a release where only part of the
bundle chain ran.

Verdict

Technically I have nothing blocking. The extraction is lossless, rendering is pixel-identical, and the
fail-closed path is now genuinely airtight across the real failure modes — including the one the last
commit fixed, which I confirmed was a real, reachable bug rather than a theoretical one.

Before merge I would want:

  1. A test that pins the <head> latch. Right now the fix's own regression test passes without the
    fix. Recipe and harness are on the assets branch.
  2. A decision on the CSS budget, made explicitly. Four lines if the answer is "guard both".
  3. The delegation runbook command fixed — it is copy-pasteable and it now fails.
  4. fix(export): restore document runtime budget headroom #11372 sequenced. It is still open, still edits the same two constants in the opposite direction,
    and if this lands first its premise is gone.

And I would re-word the PR body's performance claim to match what is measurable: ~48 ms of parse/compile
on a fast link, nothing on a slow one, total bytes unchanged — with condition F tracked as the follow-up
that actually removes bytes.

Harness, full-resolution screenshots and raw probe output: https://github.com/wenshao/qwen-code/tree/assets-pr11485

中文版报告(点击展开)

#11485 本地验证报告 —— 真实构建、真实浏览器、真实网络故障

验证提交 d54fcd0f18,对照 merge-base fbb877a48e。环境:Linux、Node 22.22.2、无头 Chromium
(Playwright)。两条基线都是从源码构建的;下面所有浏览器结论都来自一个真实的本地 HTTP 源站提供真实构建产物 ——
没有 CDP 路由拦截,没有 mock,因为路由拦截恰恰会掩盖本 PR 最后一个提交所修复的那个失效模式。

结论速览

机制是成立的,我没能把它弄坏。剩下的是一个字节预算的取舍、一处文档回归、一个合入顺序问题,以及一个缺失的测试。

剥离是可证明无损的 —— 抽出的 CSS 与 merge-base 上 injectCssModules 在运行时注入的内容 sha256 完全一致,而且是在浏览器里测的,不只是比对源文件。
与 merge-base 逐像素一致 —— 0 个差异像素,深色与浅色主题都是。
我能构造的每一种真实故障都 fail-closed:HTTP 404、连接重置、SRI 不匹配、JS 404 —— 包括数 MB 的大导出。
打包链路端到端闭合,两条失败分支都验证过。PR 自带的测试在本地全绿(6 + 76 + 158),当前 head 的 CI 全绿,含 web-shell E2E Smoke
🔴 <head> latch(c57203fc)是承重的 —— 我在 3b63662b 上复现了它修复的那个 bug,而PR 新增的回归测试在没有该修复时同样通过
🟠 字节棘轮不再覆盖渲染阻塞载荷的 56%。这是实测而非论证:CSS 加 1 MB,本 PR 构建照样全绿,merge-base 则直接构建失败
🟠 docs/verification/export-renderer-delegation-mermaid/README.md 里可直接复制的委派命令现在会硬抛错。已实际执行验证。
🟠 可测得的用户可见收益是 ~48 ms,并且在带宽受限的连接上完全消失。总字节数没有变化(−2,540)。

1. 抽取是无损的(对应 stage-2 的质疑:"在压缩产物上做正则手术")

三个互相独立的测量,同一个哈希:

sha256 e0e4a14164081338ff63621c15b46c31f9298f3fbe5808be2cbaf50c09cf3a8d   2,302,905 字节
  ├─ packages/web-shell/dist/transcript.js 中 `__qwenWebShellCss` 字面量的 JSON.parse 结果
  ├─ 在真实浏览器中打开一个用 MERGE BASE 构建的导出文件(即今天读者拿到的东西),
  │  读出运行时注入的 <style data-qwen-web-shell="component"> 的 textContent
  └─ 本 PR 产出的 export-transcript-document.css(以及打包后的 dist/ 副本)

中间那一行才是关键:它不是把同一个输入再读一遍,而是 main 上 Chromium 样式表里真正存在的东西。
CSS 也确实离开了 JS:__qwenWebShellCss 在 merge-base 的 bundle 中存在、在 head 中消失;只可能来自样式表的
KaTeX_Main 同样如此。

2. 渲染一致性 —— 0 个差异像素

一份覆盖标题、表格、带高亮的围栏代码、行内与块级 KaTeX、mermaid、任务列表、引用块、file-diff 工具卡片和
shell 工具卡片的 transcript,在两条基线上都走完整 /export html 链路并整页截图:

render parity

compare -metric AE 在深色主题下为 0,点击 Light theme 后仍为 0,输出 PNG 逐字节相同。
条件 B(层叠顺序位于内联 <style> 之后)得到了实测支持,而不只是代码审读。

3. <head> latch 是承重的 —— 而且没有测试钉住它

批准该 PR 的审查者是就"机制"下的结论,并明确留了一个口子:这个修复*"从未在真实的数 MB 文档上被观察到"*。
现在观察到了。

复现方式: 用一个真实的本地源站同时提供导出文件与两个资产,对样式表返回真实的 404。在 3b63662b
c57203fc 之前)上,对一个大导出,<link> 的 error 事件在解析器仍被它阻塞时就被派发 —— 早于 <body>
脚本注册监听器 —— 因此没有任何人接住它,渲染器照样挂载:

fail-closed A/B

面板 B 就是这个 bug:data-render-complete="true"、没有报错页,并且
getComputedStyle('.katex').fontFamily === '"Times New Roman"' —— 组件样式表根本没生效。
面板 C 是同一份文档、同一个 404 在当前 head 上的表现。

导出体积 故障 3b63662b(latch 前) d54fcd0f(head)
10 KB CSS 404 / 重置 / SRI 不匹配 fail-closed fail-closed
0.40 MB CSS 404 fail-closed fail-closed
1.20 MB CSS 404 fail-closed fail-closed
2.41 MB CSS 404 fail-closed fail-closed
3.21 MB CSS 404 无样式渲染 3/3 fail-closed 3/3
4.0 MB CSS 404 无样式渲染 3/3 fail-closed 3/3
4.0 MB 连接重置 无样式渲染 3/3 fail-closed 3/3
4.0 MB SRI 不匹配 无样式渲染 3/3 fail-closed 3/3
4.81 / 7.22 MB CSS 404 无样式渲染 3/3 fail-closed 3/3
任意 JS 404 fail-closed fail-closed

本机的翻转阈值在导出 HTML 的 2.4 MB 与 3.2 MB 之间。EXPORT_TRANSCRIPT_LIMITS_V1 允许 32 MB 信封、
1,000 个 block,所以这个区间是完全够得着的 —— 而 SRI 不匹配那一行意味着:一个被损坏或被篡改的 CDN 响应,
在修复前会以无样式的方式渲染出来,而不是 fail-closed。这个修复是对的,值得保留。

由此引出两点。

  1. 新增的回归测试并没有钉住这个修复。 我用 3b63662b(latch 前)的模板重新构建后,跑 PR 自己的用例:
    chat-transcript-document.test.ts -t "fails closed when the CDN stylesheet is unavailable"1 passed
    该用例用的是 page.setContent + route.abort,中止经 CDP 回来时文档早已解析完毕,所以它只可能覆盖慢路径。
    如果明天有人删掉 <head> latch,整个测试套件不会有任何反应。要钉住它,需要真实源站 + 一份大到能让解析器
    让出主线程的文档;assets 分支上的 harness 约 120 行,做的正是这件事。
  2. triage 里建议的替代修复不会奏效。 当时建议用 link.sheet === null 作为更简单的判据。在真实 404 上实测:
    link.sheet !== nullcssRules.length === 0,这个判据根本不会触发。latch 的形状才是对的。

4. 字节棘轮 —— 对称的实测反证

我在两条基线上都把 __qwenWebShellCss 字面量精确加长 1,000,004 字节后重新构建:

merge-base 本 PR
CSS +1 MB ❌ 构建失败:Document export runtime is 5142389 bytes; expected <= 4200000 构建全绿renderer JS is 1833944 bytes; component CSS … is 3302909 bytes

<link> 位于 <head>,是渲染阻塞的 —— 这一点我也测了:把样式表拖延 2.5 s,first-paint 就变成
2,544 ms(期间什么都不绘制,连背景都没有)。也就是说,Tailwind 扫描范围变宽、或 KaTeX 再内联一种字体格式,
都可能给读者必须等待的字节加上数百 KB,而每次构建仍然是绿的。给 CSS 再加一对常量大约 4 行;如果"只对 JS 设预算"
是有意为之,那也值得显式地做出并承担这个决定,而不是从设计文档里继承下来。

另外提醒下一个重新收紧棘轮的人:日志里的数字是在渲染器版本占位符替换之前取的,因此比实际写盘的资产大 3 字节
(日志 1833944 vs 磁盘 1833941);而 merge-base 曾计入的那约 3 KB 内联文档 CSS,现在完全不在预算之内了。

5. 性能 —— 诚实的数字

构建体积(我的构建,略高于 PR 正文,因为 main 已经前进):

merge-base 本 PR Δ
export-transcript-document.js 4,139,386 1,833,941 −55.7 %
export-transcript-document.css 2,302,905 新增
磁盘总计 4,139,386 4,136,846 −2,540
gzip −9 总计 1,538,709 1,536,599 −2,110
构建余量 距 4,200,000 上限仅 57,609 B,且已超过警告线 JS 距新上限 96,059 B

merge-base 的构建今天就会打印 Document export runtime exceeds the 4100000-byte warning threshold
这独立于作者给出的数字,佐证了 #11478 的前提。

从导航到 data-render-complete 的耗时(中位数,无头 Chromium,本地源站):

场景 merge-base 本 PR Δ
小导出,不限速(7 次) 383 ms 335 ms −48 ms(−12.5 %),两组区间不重叠
900 block / 7.2 MB 导出,不限速(5 次) 1,609 ms 1,561 ms −48 ms(−3 %)
小导出,40 Mbps / 20 ms RTT(5 次) 1,193 ms 1,185 ms −8 ms
小导出,10 Mbps / 40 ms RTT(5 次) 3,591 ms 3,585 ms −6 ms

所以 PR 正文的定性是准确的 —— 这是一次 JS 解析/编译的收益 —— 但它的量级是 ~48 ms,且与 transcript 大小无关,
一旦带宽成为瓶颈就淹没在噪声里,因为同样的 4.1 MB 还是要传完,而且样式表是渲染阻塞的。还有一点这次拆分
没有买到:两个 URL 都取自同一个 exportTranscriptRendererVersion.split('+')[0],因此每次发版都会同时让两个
资产失效 —— 不存在可以指望的差分缓存收益。

这些都不构成反对合入的理由。它们说明的是:#11478 的条件 F(base64 内联的 KaTeX 字体、transcript 根本不会引入的
组件所产生的 Tailwind 工具类)才是读者可感知收益的真正来源,应该变成一个有跟踪的后续 issue,而不是被本 PR 顺手
关掉。

6. 文档回归 —— 用执行确认

那份存在意义就是这个配方的 runbook,在
docs/verification/export-renderer-delegation-mermaid/README.md:106-107 仍然是两开关形态。原样执行:

merge-base :Document export delegates its renderer to …@0.23.1-preview.0/… ✅
本 PR      :Error: QWEN_EXPORT_RENDERER_CSS_INTEGRITY must be set together with the renderer
             delegation … ❌ exit 1

补上第三个开关就能跑通(用构建出的 CSS 计算 sha384-… → 构建成功),所以这是文档要改,不是代码要改。
build.mjs:188-210 仍写着 "Set both or neither";docs/users/features/commands.md:39 仍描述只有一个版本固定资产;
docs/verification/export-html-runtime-size/README.md §6 仍引用本 PR 改名掉的 Document export runtime is N bytes
这四处此前都已被报告过 —— 我只补充一点:其中一条是可执行的命令,现在是坏的。

7. 打包与发布顺序

链路闭合,已实测:copy_bundle_assetsdist/export-transcript-document.css 与构建产物逐字节一致(同一 sha256)
prepare-packageverifyBundleArtifacts 硬性要求它(删掉后报
Error: Required package artifact not found: …/dist/export-transcript-document.css)→ dist package.json
files 带上了它 → standalone 排除列表也带上了它。copy_bundle_assets 中"只缺 CSS"的分支会准确点名缺失文件,
符合 d54fcd0f 的意图。

线上 CDN 实测(这是发布顺序提醒,不是缺陷):

https://unpkg.com/@qwen-code/qwen-code@0.23.2/export-transcript-document.js  → 200,4,136,297 字节
https://unpkg.com/@qwen-code/qwen-code@0.23.2/export-transcript-document.css → 404
npm dist-tag latest = 0.23.2

其中 4,136,297 与 PR 正文的 "before" 数字完全吻合。由于导出文件里的 URL 由仓库版本号推导,在发布一个晚于 0.23.2
且同时携带两个资产的版本之前,本分支产出的导出都渲染不出来 —— 这在预期之内也已披露,但也意味着它不能被夹带进
一次只跑了部分打包链路的发布。

结论

技术上我没有阻断项。抽取无损、渲染逐像素一致、fail-closed 路径在真实故障下确实是密封的 —— 包括最后一个提交
修掉的那个,我确认了它是一个真实可达的 bug,而不是理论风险。

合入前我希望看到:

  1. 一个钉住 <head> latch 的测试。目前这个修复自己的回归测试在没有修复时也能通过。配方和 harness 在
    assets 分支上。
  2. 对 CSS 预算做一次显式决定。如果答案是"两个都守",大约 4 行。
  3. 修好委派 runbook 里的命令 —— 它是可直接复制的,而现在是坏的。
  4. fix(export): restore document runtime budget headroom #11372 排好顺序。它仍然开着、仍然反方向改同样那两个常量;本 PR 先合,它的立论就没了。

另外建议把 PR 正文的性能表述改成可测量的口径:快连接下约 48 ms 的解析/编译收益、慢连接下没有收益、总字节不变 ——
并把条件 F 作为真正"删字节"的后续跟踪项。

Harness、原分辨率截图与原始探针输出:https://github.com/wenshao/qwen-code/tree/assets-pr11485

…gn docs

The <head> latch, the body listener and the <link> each spell
'transcript-stylesheet' independently and nothing compared them, so renaming
either listener's id left the whole suite green while the latch recorded
nothing - reinstating the fail-open the latch was added to close. Derive the id
from the <link> and assert both listeners compare against it. Verified red under
both mutations: latch id -> 'transcript-renderer' (1 failed | 5 passed), and the
mirror with the body listener's id wrong and the latch intact (same).

Both design docs still specified the two shapes the previous round replaced: the
forward-slash-only onLoad filter that never matches on Windows, and the
body-listener-only fail-closed extension. Section 1 now quotes the shipped
TRANSCRIPT_CSS_ENTRY_FILTER and names transcript-css-entry.mjs, section 2
describes the <head> latch (position, nonce, capture phase, record-only),
section 3 names the module-level render guard, and "Files affected" lists the
three omitted files. EN and zh-CN are updated in the same commit.

Also correct the shape-guard comment in build.mjs: the document nonces every
<style> created through document.createElement, so the CSP would not block an
un-stripped duplicate, and a 367-byte regrowth stays inside both byte budgets.
That throw is the only guard on the duplicate-injection path.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtuo29vduf
@wenshao

wenshao commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Round 2 — re-verified at 328feb43f8

Same rig as before: both arms built from source, browser results from a real local HTTP origin
(no CDP route interception). Merge-base is unchanged (fbb877a48e), so the round-1 numbers carry over
where I say so. Everything below that says "measured" was run on this host.

First, a correction to my last report

I wrote: "Nothing in the suite would notice if the <head> latch were deleted tomorrow." That was
wrong.
scripts/tests/export-transcript-document-template.test.js — added by the latch commit itself —
goes 6-red when the template is reverted to the pre-latch shape:

latches stylesheet failures in <head>, ahead of the <link>        ×
nonces the latch script, because the CSP allows no inline script  ×
listens for error in the capture phase                            ×
only records the failure while the parser is still in <head>      ×
acts on the latch from the body script                            ×
compares the failing element id both listeners agree on           ×      Tests  6 failed (6)

What actually stands from round 1 is the narrower claim: the browser gate does not witness it — the
new fails closed when the CDN stylesheet is unavailable case still passes against a pre-latch build. So
my ask #1 was over-stated; the static lane is real coverage, and only the behavioural half is missing.

The new commit does what its message says — checked by mutation, statically and in a browser

Assets are bit-identical to d54fcd0f18 (export-transcript-document.js sha256 d87a95d4…, .css
e0e4a141…), so round-1's sizes, render parity and fail-closed matrix carry over unchanged. Re-run here
anyway: 241 tests green (159 scripts + 76 cli + 6 browser gate), the 12-cell fail-closed matrix is
closed in every failure cell, and render parity vs merge-base is still 0 differing pixels in both
themes.

The id-contract test is well-aimed, and both listeners it pins are load-bearing. I reproduced the
commit's own static result and then asked the question the static lane can't: does the drift it now
catches actually break anything?

mutation old test file (d54fcd0f) new test file (328feb43) browser, real origin
latch compares the wrong id 5 passed 1 failed | 5 passed 4.0 MB export + instant CSS 404 → UNSTYLED (small export still closed)
body listener compares the wrong id 5 passed 1 failed | 5 passed small export + 404 / slow-404 / SRI mismatch → UNSTYLED; 4.0 MB export + slow-404 → UNSTYLED

So they cover disjoint timing windows — the latch catches a failure dispatched before the body script
exists, the body listener catches one dispatched after — and neither is redundant. Worth saying plainly:
this test is the only thing pinning either, and it earns its place.

The corrected build.mjs comment is factually right. Measured in the exported document: a <style>
created through the document's createElement shim is nonced and applies (rgb(1,2,3)); an identical
<style> created so the shim never sees it is CSP-blocked with
Applying inline style violates ... 'style-src-elem 'nonce-…''. The old comment's claim that the CSP
would block an un-stripped duplicate injection was wrong, and the correction is the accurate one.

The design docs really are synced, both languages: section 1 quotes the shipped
TRANSCRIPT_CSS_ENTRY_FILTER and names transcript-css-entry.mjs, section 2 describes the <head> latch
(position, nonce, capture phase, record-only, body-side consumption), section 3 names the module-level
render guard, and "Files affected" lists the three previously omitted files.

Deferred findings — settled here, since the browser lane is out of budget on the author's host

The replies on this PR defer several findings because they need Playwright/Chromium and a build. I have
both, so I ran them. Each row is a mutation applied to the head tree, the suites re-run, and the mutant
opened in a real browser.

finding verdict witness
R1-8 publish gate has no negative test already fixed at head deleting the prepare-package.js line reds package asset scripts > fails packaging when the published stylesheet is missing (1 failed | 35 passed)
R1-7 <link> nonce not element-scoped confirmed — and worse than stated deleting the <link>'s nonce leaves html.test.ts 3 passed and the scripts lane 42 passed; in a real browser the stylesheet is CSP-blocked and every export renders the load-error page
R1-6 renderer gate confounded confirmed deleting transcript-renderer from the body listener leaves fails closed when the CDN renderer is unavailable or fails integrity green, while a renderer-only 404 (CSS served fine) yields a completely blank pagedata-render-complete unset, no alert, 0 chars of body text
R1-18 two renderComplete guards, one unpinned settled the module-level guard is the load-bearing one and is pinned (removing it → the new stylesheet case goes red; behaviourally it paints an unstyled transcript over the alert). The rAF guard is dead code: removing it keeps the gate at 6 passed and the document still fails closed
R1-13 cascade order unpinned confirmed, zero live impact putting the <link> before the inline <style> keeps every suite green (static 6, html 3, gate 6/6) and the render is pixel-identical (compare -metric AE = 0)
R1-21 sheetLoaded oracle confirmed, with one correction with integrity in place: 404 → sheet !== null, 0 rules; connection reset → sheet !== null; but empty and truncated read sheet === null, because SRI blocks them. Only 404 and reset defeat the oracle, not "empty, truncated, 404"
R1-5 release gate is JS-only confirmed grep -rn export-transcript-document .github/workflows/ → 2 hits, both the JS fetch and its cmp in release-vscode-companion.yml; no .css anywhere
R1-4 delegation unusable in this window confirmed unpkg: .css is 404 at 0.23.2, 0.23.1 and 0.23.1-preview.0; npm latest = 0.23.2; the runbook's two-knob command still throws

mutation matrix

Panels 1 and 2 are the two mutations that ship with a fully green suite. Panel 3 is the one the new
stylesheet case does catch — note that it reaches data-render-complete === 'error' and still shows the
transcript
, because React replaces #app after showLoadError wrote into it, so a check that only reads
the marker would pass while the reader sees an unstyled export.

Unchanged from round 1

Sizes (4,139,3861,833,941 JS + 2,302,905 CSS; total −2,540 bytes; gzip −2,110), the byte-ratchet
counterfactual (+1 MB of CSS: green here, Document export runtime is 5142389 bytes; expected <= 4200000
on merge-base — re-run at this head, still green), and the render-blocking measurement (2.5 s stylesheet
stall → first-paint at 2,544 ms). Time-to-render re-measured at this head: median 326 ms vs 383 ms on
merge-base
, i.e. −57 ms this round (−48 ms last round); still ~0 under 10 Mbps.

The four documentation gaps are unchanged at this head: the runbook's copy-pasteable delegation command
still throws (re-run just now), build.mjs:200 still says "Set both or neither",
docs/users/features/commands.md:39 still describes one pinned asset, and
docs/verification/export-html-runtime-size/README.md §6 still quotes the renamed log line.

Updated verdict

The thing I flagged as most wanting before merge is now largely covered, and my framing of it was too
strong — I've corrected that above. What this round adds is that the new id test guards a genuinely
exploitable regression on both halves, which I'd call a good use of the round.

Revised pre-merge list, shortest version:

  1. R1-7 and R1-6 — I'd fold these in now rather than next round. Both are small test edits, and both
    have a measured blast radius that is worse than the finding text: one makes every export unopenable,
    the other silently drops the renderer branch's only witness. Neither needs a rewrite, just an
    element-scoped assertion and a RENDERER_CSS_URL fulfil in the existing route handler.
  2. The delegation runbook command — it is executable and it is broken.
  3. The CSS budget — decide it explicitly; ~4 lines if the answer is "guard both".
  4. fix(export): restore document runtime budget headroom #11372 sequencing — still open, still opposite-direction on the same two constants.

R1-18's rAF guard is dead code on every path I could stage; R1-13 has no live impact today. Both are
follow-up material, not merge blockers. R1-8 can be closed as already fixed.

Harness, full-resolution screenshots and raw probe output: https://github.com/wenshao/qwen-code/tree/assets-pr11485

中文版报告(点击展开)

第二轮 —— 在 328feb43f8 上重新验证

装置与上一轮相同:两条基线都从源码构建,浏览器结论来自真实的本地 HTTP 源站(无 CDP 路由拦截)。
merge-base 未变(fbb877a48e),因此第一轮的数字在注明处继续有效。下文凡是写"实测"的,都在本机跑过。

先更正我上一份报告里的一处错误

我写过:"如果明天有人删掉 <head> latch,整个测试套件不会有任何反应。" 这是错的。
scripts/tests/export-transcript-document-template.test.js(由 latch 提交本身引入)在模板被还原成
latch 前的形态时会 6 条全红

latches stylesheet failures in <head>, ahead of the <link>        ×
nonces the latch script, because the CSP allows no inline script  ×
listens for error in the capture phase                            ×
only records the failure while the parser is still in <head>      ×
acts on the latch from the body script                            ×
compares the failing element id both listeners agree on           ×      Tests  6 failed (6)

第一轮真正成立的是更窄的那句:浏览器门禁没有见证它 —— 新增的
fails closed when the CDN stylesheet is unavailable 用例在 latch 前的构建上依然通过。所以我当时的第 1 条
诉求说重了:静态那条流水线是实打实的覆盖,缺的只是行为层面的那一半。

新提交确实做到了它声称的事 —— 用变异测试在静态与浏览器两侧都核对过

产物与 d54fcd0f18 逐字节相同(export-transcript-document.js sha256 d87a95d4….css e0e4a141…),
因此第一轮的体积、渲染一致性与 fail-closed 矩阵原样成立。这里仍然重跑了一遍:241 条测试全绿
(159 scripts + 76 cli + 6 浏览器门禁),12 格 fail-closed 矩阵在每个故障格都是 closed
与 merge-base 的渲染差异在两种主题下仍是 0 个像素

这条 id 契约测试瞄得很准,而且它钉住的两个监听器都是承重的。 我先复现了提交自己的静态结果,
再问了一个静态流水线回答不了的问题:它现在能抓到的这种漂移,真的会坏事吗?

变异 旧测试文件(d54fcd0f 新测试文件(328feb43 真实源站下的浏览器行为
latch 比对了错误的 id 5 passed 1 failed | 5 passed 4.0 MB 导出 + 瞬时 CSS 404 → 无样式渲染(小导出仍 fail-closed)
body 监听器比对了错误的 id 5 passed 1 failed | 5 passed 小导出 + 404 / 延迟 404 / SRI 不匹配 → 无样式渲染;4.0 MB 导出 + 延迟 404 → 无样式渲染

也就是说,两者覆盖的是互不相交的时间窗——latch 接住"在 body 脚本存在之前派发"的失败,body 监听器接住
"之后派发"的失败——谁都不多余。有一点值得说清楚:这条测试是目前唯一钉住这两者的东西,它对得起自己的位置。

build.mjs 里被更正的注释在事实上是对的。 在导出文档里实测:经文档 createElement 垫片创建的
<style> 会被打上 nonce 并生效rgb(1,2,3));绕过垫片创建的同样的 <style> 会被 CSP 拦截
Applying inline style violates ... 'style-src-elem 'nonce-…''。旧注释所说"CSP 会拦掉未被剥离的重复注入"
是错的,这次更正才是准确的。

设计文档确实同步了,两个语种都是:第 1 节引用了实际发布的 TRANSCRIPT_CSS_ENTRY_FILTER 并点名
transcript-css-entry.mjs;第 2 节描述了 <head> latch(位置、nonce、捕获阶段、只记录、由 body 消费);
第 3 节点名了模块级渲染守卫;"Files affected" 补齐了此前遗漏的三个文件。

被推迟的发现 —— 在这里替作者跑完,因为浏览器流水线在其主机上跑不动

PR 上的回复把若干发现推到下一轮,理由是它们需要 Playwright/Chromium 加一次构建。这两样我都有,所以我跑了。
下表每一行都是:对 head 树施加一个变异 → 重跑测试 → 在真实浏览器里打开这个变异体。

发现 结论 见证
R1-8 发布门禁没有反向测试 在 head 上已修复 删掉 prepare-package.js 那一行会让 package asset scripts > fails packaging when the published stylesheet is missing 变红(1 failed | 35 passed
R1-7 <link> 的 nonce 未按元素定位 成立,而且比原文更严重 删掉 <link> 的 nonce 后 html.test.ts3 passed、scripts 流水线 42 passed;而真实浏览器里样式表被 CSP 拦截,每一个导出文件都只会显示加载失败页
R1-6 渲染器门禁被混淆 成立 transcript-renderer 从 body 监听器里删掉后,fails closed when the CDN renderer is unavailable or fails integrity 依然通过;而此时"只有 JS 404、CSS 正常"会得到一个完全空白的页面 —— data-render-complete 未设置、没有 alert、body 文本 0 字符
R1-18 两个 renderComplete 守卫,其一无测试 已定论 模块级守卫才是承重的,而且已被钉住(删掉它 → 新增的样式表用例变红;行为上会把无样式 transcript 盖在报错页上)。rAF 守卫是死代码:删掉它门禁仍 6 passed,文档照样 fail-closed
R1-13 层叠顺序无测试 成立,但今天没有实际影响 <link> 放到内联 <style> 之前,所有套件仍全绿(静态 6、html 3、门禁 6/6),渲染逐像素一致compare -metric AE = 0)
R1-21 sheetLoaded oracle 成立,但需要一处更正 在带 integrity 的情况下:404 → sheet !== null、0 条规则;连接重置 → sheet !== null;但空响应与截断响应读到的是 sheet === null,因为 SRI 先把它们拦了。真正能骗过这个 oracle 的只有 404 和重置,而不是"空/截断/404"
R1-5 发布门禁只校验 JS 成立 grep -rn export-transcript-document .github/workflows/ 只有 2 处命中,都是 release-vscode-companion.yml 里的 JS 拉取及其 cmp;全局没有 .css
R1-4 该窗口内委派不可用 成立 unpkg:.css0.23.20.23.10.23.1-preview.0 上都是 404;npm latest = 0.23.2;runbook 里的两开关命令仍会抛错

mutation matrix

面板 1、2 是两个"测试全绿也能发出去"的变异。面板 3 是新增样式表用例确实抓到的那个 —— 注意它同时满足
data-render-complete === 'error' 却仍然显示了 transcript,因为 React 在 showLoadError 写入之后又替换了
#app;也就是说,只读这个标记的检查会通过,而读者看到的是一个无样式的导出。

与第一轮相同的部分

体积(4,139,386 → JS 1,833,941 + CSS 2,302,905;总量 −2,540 字节;gzip −2,110)、字节棘轮反证
(CSS +1 MB:本 PR 全绿,merge-base 报 Document export runtime is 5142389 bytes; expected <= 4200000
—— 在本轮 head 上重跑,仍然全绿),以及渲染阻塞实测(样式表拖延 2.5 s → first-paint 落到 2,544 ms)。
渲染耗时在本轮 head 上重测:中位数 326 ms,对 merge-base 的 383 ms,即本轮 −57 ms(上轮 −48 ms);
10 Mbps 限速下仍然约等于 0。

四处文档缺口在本轮 head 上没有变化:runbook 里可复制的委派命令仍会抛错(刚刚重跑确认)、
build.mjs:200 仍写着 "Set both or neither"、docs/users/features/commands.md:39 仍描述只有一个版本固定资产、
docs/verification/export-html-runtime-size/README.md §6 仍引用被改名掉的日志行。

更新后的结论

我此前标为"最希望在合入前解决"的那一项,现在基本已被覆盖,而且我当时的说法过重 —— 上文已更正。
本轮新增的信息是:新的 id 测试守住的是一个在两侧都真实可利用的回归,我认为这一轮用得很值。

修订后的合入前清单(最短版):

  1. R1-7 与 R1-6 —— 我建议现在就并进来,而不是留到下一轮。两者都只是很小的测试改动,而实测的影响面
    都比发现原文更严重:一个让所有导出都打不开,另一个悄悄弄丢了渲染器分支唯一的见证。都不需要重写,
    只要一处按元素定位的断言,以及在现有 route handler 里补上 RENDERER_CSS_URL 的 fulfil。
  2. 委派 runbook 里的命令 —— 它是可执行的,而且是坏的。
  3. CSS 预算 —— 显式做一次决定;如果答案是"两个都守",大约 4 行。
  4. fix(export): restore document runtime budget headroom #11372 的先后顺序 —— 仍然开着,仍然在同样那两个常量上反方向改。

R1-18 里的 rAF 守卫在我能构造的所有路径上都是死代码;R1-13 今天没有实际影响。这两条属于后续跟进,
不是合入阻断项。R1-8 可以按"已修复"关闭。

Harness、原分辨率截图与原始探针输出:https://github.com/wenshao/qwen-code/tree/assets-pr11485

@yiliang114
yiliang114 requested a review from qqqys September 10, 2026 00:03
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 51 passed · 2 failed · 53 total

Flakiness gate: ⚠️ consistent-fail — 1 of 5 changed test file(s) failed identically in every round — deterministic, so CI owns that signal

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:51 通过 · 2 失败 · 53 总计

抖动门:⚠️ consistent-fail — 1 of 5 changed test file(s) failed identically in every round — deterministic, so CI owns that signal

Verification report

PR #11485 deep verification — perf(export): split the transcript renderer's embedded CSS into a versioned asset

Verdict: findings — assertions 51 pass / 2 fail / 53 total.
Verified head OID: 328feb43f82b0d59f7adee18d8d1dd54e1060b3b (git rev-parse HEAD^2).
Base arm of the A/B: 33a4062591bcbe3c382174546729f0df79243a2b (HEAD^1).

The central claim is proven load-bearing: the component stylesheet really does leave the
renderer JS, the two assets really are what the document points at, and every SRI digest really
matches the bytes that ship. The two failures are not defects in the split itself — one is a
documented workflow the PR now breaks, one is a guard whose stated premise I could not reproduce
at any document size.

Note on the snapshot's baseRefOid: $QWEN_VERIFY_CONTEXT records
fbb877a48ece0d023fa9f575f6dc45b0784b3bb5, which is not present in this depth-2 checkout
(git cat-file -tcould not get object info). The merge commit was built against
33a40625, so that is the base used throughout; the snapshot's base OID had drifted.

中文摘要

结论:findings —— 断言 51 通过 / 2 失败 / 共 53。已验证 head:328feb43;A/B 基线:33a40625

A/B 结论(见下方「Central claim + A/B」表与 01-ab-bytes-sri-base-vs-head.png):核心主张成立。
渲染器 JS 从 4,139,823 → 1,833,941 字节,组件 CSS 拆出为独立资产 2,302,905 字节,且与从
web-shell/dist/transcript.js 独立提取出的 CSS sha256 完全一致;head 的 JS 中已不含该 CSS 与运行时注入行
(不存在「既 link 又注入」的重复下发)。CDN 总下载量 4,139,823 → 4,136,846(−2,977 字节)
gzip −9 后 1,538,785 → 1,536,599(−2,186 字节),与 PR「不减少总字节、只移出解析/编译关键路径」的
自述一致。两个 SRI 摘要均与真正发布到 dist/ 的字节相符。集成浏览器门禁在本容器实测 6/6 通过
(含新增的样式表 fail-closed 用例)。

findings(2 项,均为 Suggestion 级)

  1. 仓库自带的 renderer delegation 操作手册(docs/verification/export-renderer-delegation-mermaid/README.md
    中的可执行配方在 head 上直接 exit 1,因为新增强制校验要求同时提供 QWEN_EXPORT_RENDERER_CSS_INTEGRITY
    而实测 unpkg 上所有已发布版本(0.23.1 / 0.23.1-preview.0 / 0.23.2)的 .css 均为 404
    即下一次发版前该开关对任何已发布版本都不可用。PR 未更新该文档。
  2. <head> 中的 stylesheet 失败闩锁(latch)在行为层面无法被证明是必要的:单独删除它,
    浏览器门禁在单条记录文档最大尺寸文档两种情况下都仍然 fail-closed(绿灯)。
    代码注释与该模板测试的注释都把「大文档上 CSS 失败先于监听器注册」当作既成事实陈述,本次未能复现。
    闩锁本身是加法式防御、且被 6 条静态模板断言钉住,保留无害;但没有任何行为测试覆盖它要防的那次竞态。

未覆盖范围scripts/tests/install-script.test.js 在本容器无法收集(缺 zipCI=true 下该文件
的守卫直接抛错)——已用 base 侧 A/A 对照证明为环境问题而非本 PR 回归;因此新增的
「standalone 归档不含 CSS」断言在本轮未被执行。此外未运行仓库级 typecheck / lint / prettier
(PR 自身 CI 覆盖),未做 per-commit 归因(depth-2 浅克隆:git rev-list HEAD^1..HEAD^2 只返回 1 个提交,
而快照记录 5 个),未在真实网络 404(而非 Playwright route.abort)下验证 fail-closed,
也未验证 release-vscode-companion.yml 的发版时序。

Scope selection

Central claim. The web-shell component stylesheet is lifted out of export-transcript-document.js
into a separate version-pinned, SRI-protected export-transcript-document.css, so the JS a browser must
download/parse/compile before rendering drops from ~4.14 MB to ~1.83 MB, with the CSS fetched in parallel.

Secondary claim 1. A stylesheet load failure fails closed with the same "Unable to load this chat
export" page as a missing renderer, and the <head> latch closes a parser-blocking race.

Secondary claim 2. The CSS asset flows through copy_bundle_assetsdist/ → npm files, is
excluded from standalone archives, and a missing CSS is named and fails the release gate.

Budget went to: the A/B build pair (~15 min), a 5-row mutation matrix with reachability probes plus a
large-document race probe (~20 min), the two unit gates and the real browser gate (~10 min), and bounding
the published-version question against live unpkg (~10 min).

Central claim + A/B

Both arms are real node src/export-html/build.mjs runs. The bundler input is byte-identical across
arms
: git diff HEAD^1..HEAD -- packages/web-shell is empty (0 files), and the base worktree has no
node_modules of its own, so both builds resolve @qwen-code/web-shell to the same realpath —
asserted, not assumed: readlink -f gives /__w/qwen-code/qwen-code/packages/web-shell for the base arm
too. Only build.mjs, document-index.html and document-main.tsx differ. Witness:
01-ab-bytes-sri-base-vs-head.png.

Metric base 33a40625 head 328feb43 Δ
export-transcript-document.js (shipped bytes) 4,139,823 1,833,941 −2,305,882
export-transcript-document.css (shipped bytes) — (not produced) 2,302,905 +2,302,905
Total CDN download (js + css) 4,139,823 4,136,846 −2,977
gzip −9 total transfer 1,538,785 1,536,599 (527,026 + 1,009,573) −2,186
build's own logged number Document export runtime is 4142828 bytes renderer JS is 1833944 bytes; CSS is 2302905 bytes
document.html template 6,606 7,917 +1,311
byte-budget cap / headroom 4,200,000 / 57,172 1,930,000 / 96,059 ratcheted down
component CSS inside the JS present absent
CSS runtime-injection line inside the JS present absent no double delivery

Every residual byte is accounted for. The JS shrank by 2,305,882 while the CSS asset is 2,302,905, so
total download fell by exactly 2,977. That decomposes as: 2,699 bytes of JSON string-escaping overhead
no longer paid (measured independently — the literal line in transcript.js is 2,305,604 bytes, the raw
CSS after JSON.parse is 2,302,905) plus 278 bytes net of the removed runtime-injection line against
the two new document-main.tsx guards. No unexplained residue.

The extraction is exact. An independent script that re-implements the lift
(source.match(/^const __qwenWebShellCss=("(?:[^"\\]|\\.)*");\n/)JSON.parse) produces CSS whose
sha256 is e0e4a14164081338… — identical to the build's export-transcript-document.css, and identical
to the copy in dist/ that npm publishes and unpkg serves. The ^-anchored regex without the m flag does
match at index 0 of the real packages/web-shell/dist/transcript.js, and the injection line it steps over is
exactly 367 bytes, the number the build.mjs comment claims.

Corrections to the PR description (facts, not change requests):

  • The description's Before/After table gives 1,831,301 JS and 2,302,457 CSS. At this merge commit the
    reproducible numbers are 1,833,944 logged / 1,833,941 shipped JS and 2,302,905 CSS
    (Δ +2,640 / +448), because the author built against their own web-shell output. The table's Before
    figure of 4,136,297 bytes at 0.23.2 is exactly right — I downloaded the published asset from unpkg and
    it is 4,136,297 bytes.
  • The table row "__qwenWebShellCss literal in JS: present → absent" describes the pre-bundle input, not
    the shipped asset. esbuild's minifier mangles that identifier away in both arms: grep -c __qwenWebShellCss is 0 on the base bundle too. The verifiable claim is the CSS content moving out,
    which does hold (a distinctive rule, .katex{font: 1.21em KaTeX_Main,Times New Roman,serif, is present in
    the base bundle and absent from the head bundle).
  • "The build's byte budget was within ~60 KB of its hard cap" is confirmed: base logged 4,142,828 against a
    4,200,000 cap = 57,172 bytes of headroom, and the base build did emit its own over-warning line.

A hazard I expected and disproved. Moving CSS from an inline <style> to an external <link> changes the
base URL every relative url() resolves against — from the exported document to https://unpkg.com/…. That
would silently break fonts and images. It does not apply here: the lifted stylesheet contains 60 url()
references and all 60 are data: URIs, zero relative
(including all 20 @font-face blocks, so the KaTeX
woff2 fonts travel inline). font-src data: in the CSP admits them. This is the sharpest consequence of the
design and it is inert.

Findings

F1 (Suggestion) — the repo's own documented renderer-delegation recipe now exits 1, and delegation is impossible against every published version

build.mjs gained a hard guard: QWEN_EXPORT_RENDERER_CSS_INTEGRITY must be set together with
QWEN_EXPORT_RENDERER_IDENTITY. The guard's reasoning is sound — a delegated renderer points the JS and CSS at
the same published version, so the CSS digest must describe that published asset. But docs/verification/export-renderer-delegation-mermaid/README.md:105-109
is a runnable recipe that sets only the two original variables, and the PR does not update it.

Reproduce (a scratch worktree at the merge commit, so the head dist/ is untouched):

git worktree add tmp/deleg-tree HEAD && cd tmp/deleg-tree
QWEN_EXPORT_RENDERER_IDENTITY='0.23.1-preview.0+d7962879afdccd34' \
QWEN_EXPORT_RENDERER_INTEGRITY='sha384-CVacTzaM6pEzmp3UrBJQ/WMSVZfvRxbrNJtCf1c03j4Gox5y9dqndkBoTQ3ktzzh' \
  node packages/web-templates/src/export-html/build.mjs
# Error: QWEN_EXPORT_RENDERER_CSS_INTEGRITY must be set together with the renderer delegation: …
# exit 1

The recipe cannot simply be amended with a third hash, because no published version has the CSS yet.
Probed against live unpkg:

version .js .css
0.23.2 (current package.json, released 2026-09-09) 200 404
0.23.1-preview.0 (the version the doc recipe delegates to) 200 404
0.23.1 200 404
0.23.0 404 404

So until the next release publishes the asset, the delegation knob is dead for every version one could
delegate to, and the doc that describes it is wrong in two ways (the recipe throws; its closing sentence
"build.mjs throws if exactly one of the two is set" now describes a three-variable contract).

Blast radius, bounded — this is not a shipping regression for users:

  • Users on published 0.23.2 get the pre-split template with no <link> at all. Unaffected.
  • Users on the next published release get both assets published at that same version. Unaffected —
    prepare-package.js requires the CSS in dist/ and lists it in files[], so the two cannot diverge.
  • Source/dev builds at 0.23.2 already fail closed before this PR, for an independent reason. I hashed the
    published asset and both builds: published @0.23.2 JS is sha384-fw8bWYGocO+WEj1+/97lmsi1VF8i5Yl9aur4TezgkOGrCUi+9D8URaZP3/ADoEq6,
    while the base build is sha384-GMR6aZ1phvGqcmXh0FQ4VqynBnKkJv9WnoeyTqIM/1DuAuNgmDysWCIEUsw2LTJz and head is
    sha384-ifOL6KUFEseEhIv7BsjEnk4Z39UdyEDDCwiiOaj0iezrZQMYgeopnhL8/Xhti/dH. Neither matches, so a document built
    from main at 0.23.2 was already rejected by the renderer's own SRI check. The CSS 404 adds a second
    reason to reach the same page; it does not convert a working export into a broken one.
  • No CI workflow sets QWEN_EXPORT_RENDERER_* (grep -rl over .github/ returns nothing), so no automation
    breaks. The knob is manual-only.
Suggested minimal fix (docs only — not applied, not measured against a suite)

Update docs/verification/export-renderer-delegation-mermaid/README.md to show all three variables and to
state that delegation requires a published version that carries both assets, i.e. the first release after
this split. No source change is implied: the guard is correct, and the design docs already say the knob "gains
a parallel QWEN_EXPORT_RENDERER_CSS_INTEGRITY".

Related, and lower still: .github/workflows/release-vscode-companion.yml:103-111 ("Verify published export
renderer") downloads the published JS at the current version and cmps it against the local build — a
release-time check that what unpkg serves is what was built. The CSS now has no equivalent cmp, so the
release lane verifies one of the two assets the document depends on. prepare-package.js makes a missing CSS
fatal at packaging time, which is why I rate this a nit rather than a gap with teeth.

F2 (Suggestion) — the <head> stylesheet latch is not behaviourally load-bearing at any document size I could construct

The stylesheet failure is defended by three layers: (a) the <head> latch that records
window.__transcriptStyleFailed, (b) the body listener's new transcript-stylesheet id branch, and
(c) the two document-main.tsx guards that refuse to render over an error body. Layered guards hide each
other, so I reverted each alone and the pair together, and judged every row with the PR's own browser gate case.

The first run of this matrix was void and I am reporting the corrected one. Driver v1 rebuilt with
node build.mjs, which regenerates src/generated/*.ts but not the compiled
packages/web-templates/dist/ that the integration test imports through @qwen-code/web-templates
(package main = dist/index.js, frozen at 00:10:14 while my mutants ran at 00:34). All five browser rows
therefore executed the unmutated template and reported a uniform, meaningless "survived". Driver v2 runs
the full npm run build -w @qwen-code/web-templates (node build.mjs && tsc) and probes the consumed
artifact before each browser run
, so a mutation that fails to reach it is reported INVALID instead of being
allowed to masquerade as a survivor. Witness: 02-mutation-matrix-fail-closed-layers.png.

row reachability probe (compiled template / bundle) browser gate template unit test
control (unmutated) latch=1, id-cmp=2, main-guards=2 ✔ green (1 passed) green (6 passed)
a — revert <head> latch only latch=0, id-cmp=1, main-guards=2 ✔ green — SURVIVED red (5 failed / 1 passed)
b — revert body-listener id only latch=1, id-cmp=1, main-guards=2 ✔ red — killed red (1 failed / 5 passed)
a+b — revert both listeners (combination row) latch=0, id-cmp=0, main-guards=2 ✔ red — killed red (5 failed / 1 passed)
c — revert both document-main.tsx guards latch=1, id-cmp=2, main-guards=0 red — killed green (out of that file's scope)

The positive control is green on both oracles, so the kills are attributable to the mutations and not to a dead
harness. Provenance of the two columns: the browser column is from driver v2 (logs/mutation-matrix-v2.log,
raw per-row logs in mutants2/); the template-test column is from driver v1 (mutants/*.template.log) and
remains valid, because export-transcript-document-template.test.js reads
src/export-html/src/document-index.html directly rather than the compiled dist/ — the staleness that
voided v1's browser rows cannot reach it. v1's browser column is discarded entirely and is not reported.

Read the shape: (b) and (c) are load-bearing — each alone breaks the gate. (a) is not: removing
the latch alone leaves the gate green, and only the combination row (a+b) shows the two listeners are
jointly load-bearing. Under the skill's classification, (a) is redundant defence with respect to every
behavioural gate in the repo, and is pinned only by the six static assertions in
scripts/tests/export-transcript-document-template.test.js (which do kill it — 5 of 6 go red).

I then went after the race the latch was written for, because "redundant on a 1-record document" is not
"redundant". The code comment and the template test's own comment both state the mechanism as fact — "on a
large document a CSS failure that settles first is dispatched while no listener exists yet: nothing marks the
render as failed … the transcript renders completely unstyled while stamping data-render-complete="true""
.
So I re-ran the same case with createMaximumDocument() — the largest envelope the format allows
(EXPORT_TRANSCRIPT_LIMITS_V1.maxBlocks blocks), which is what puts the parser deepest into the body when the
abort settles:

document build result
maximum-size control (latch present) fails closed, green (1,785 ms)
maximum-size latch removed (probe confirmed latch=0 in the compiled template) still fails closed, green (1,336 ms)

I could not reproduce the race at either document size. With the latch gone, the body listener caught the
aborted stylesheet every time. Two honest limits on that negative: the failure is injected as a Playwright
route.abort('blockedbyclient'), whose settling time is shaped by the routing layer rather than by a real 404
or DNS failure, and this is headless Chromium 149 on a loaded shared runner. A real network failure on a
different build could still settle before the body listener registers — the latch may well be correct
defence-in-depth. What is measurable is that no test in the repo demonstrates the failure it prevents, and
that the comments assert a reproduction that this round did not observe.

This is a Suggestion, not a blocker: the guard is additive, fail-safe, cannot make things worse, and the
static test pins its position, nonce, capture phase and id agreement — the ways it could actually be wrong.
The actionable part is the prose. If the author has a reproduction (a real 404 rather than a routed abort, or a
slower device), recording it as a comment or a case would turn six static assertions into a demonstrated
mechanism; if not, the comments in document-index.html and export-transcript-document-template.test.js
overstate what is proven.

Vacuity and gate liveness

  • The browser gate is live and reaches the code under test. Proven twice over: the unmutated control is
    green, and three of four mutants turn it red with the intended behavioural mismatch (data-render-complete
    and the role="alert" text), not with an import or fixture error. The reachability probe on the consumed
    artifact is what makes that claim safe — see F2 for the run where it was not.
  • The template unit test is live: mutant (a) turns 5 of its 6 cases red with
    AssertionError: expected -1 to be greater than -1 — the position assertion failing because the latch string
    is gone, which is the mismatch the test exists to catch.
  • The packaging tests are live: scripts/tests/package-assets.test.js covers the new all-or-nothing copy
    branch, the "name the missing stylesheet" warning, and preparePackage exiting 1 when the published CSS is
    absent. All green (45 tests across the three collectible files).

Not covered

  • scripts/tests/install-script.test.js could not run in this container, and the failure is environmental —
    proven by A/A, not assumed.
    It throws at import time from its own guard: `zip`/`unzip` missing on a CI host (CI=true, command -v zip empty, only /usr/bin/unzip present). The identical file at base
    33a40625 fails the same way (Test Files 1 failed (1), Tests no tests), and the guard is untouched by the
    PR (git diff HEAD^1..HEAD matches zipAvailable 0 times). Consequence: the PR's new assertion that
    standalone archives exclude lib/export-transcript-document.css was not executed here. I verified the
    same intent statically instead — the CSS is in DIST_NPM_PACKAGE_ONLY_ENTRIES — which is weaker.
  • Per-commit attribution is out of reach. Depth-2 checkout: git rev-list HEAD^1..HEAD^2 returns 1
    commit while $QWEN_VERIFY_CONTEXT records 5, and git rev-parse --is-shallow-repository is true. The
    1 is the plausible-number-at-a-shallow-boundary artefact, not the truth. I verified the aggregate
    HEAD^1..HEAD diff only; the four intermediate commits (Windows path matching, the latch, the scripts
    naming, the id contract) were exercised as one change, not individually.
  • Repo-wide typecheck, lint and prettier were not run — the PR's own CI covers them and my A/B did
    not need the numbers. The changed .mjs/.js test surfaces were executed by the gates above.
  • The fail-closed path was only exercised against a routed abort, never a real network 404. I confirmed
    unpkg really does return 404 for @&#8203;0.23.2/export-transcript-document.css, but I did not drive a browser at
    that live URL, because doing so needs a document whose renderer SRI also matches the published asset — which
    no source build produces (see F1). So this round reproduces the wire shape of a stylesheet failure, not a
    genuine CDN 404 against a real published document.
  • release-vscode-companion.yml sequencing was reasoned about, not run. I read the step and probed unpkg;
    I did not execute a release.
  • The export-html-runtime-size README's reference-point table was not refreshed by the PR, but it is
    explicitly labelled as pre-refactor!: retire @qwen-code/webui #9812 history, so I did not treat that as drift.
  • Windows path matching was verified by unit test only. TRANSCRIPT_CSS_ENTRY_FILTER accepts both
    separators and rejects web-shell/dist/index.js, and I confirmed the POSIX branch fires for real (the
    extraction happened). I have no Windows host, so the [\\/] class is unexercised end-to-end.

Methodology

Environment: the CI verify container (node:22-bookworm, Node v22.23.2, npm 10.9.8, CI=true, uid 1000),
working tree at the merge commit 9358e45d with npm ci and npm run build already completed. Network was
available and used read-only against unpkg.com for the published-asset probes; nothing was posted anywhere.
The A/B used a scratch git worktree at HEAD^1 under tmp/, deliberately sharing the head tree's
node_modules and packages/web-shell/dist so that both arms bundle byte-identical input — the realpath of
every internal dependency was asserted from inside the base worktree before either build ran, and both
worktrees were removed afterwards (git worktree list shows only the main tree; git status is clean and the
final artifact sha256s match the pristine head build). A second scratch worktree hosted the delegation-recipe
reproduction so a throwing build's rm -rf of dist/ could not disturb the head tree. Oracles were: file
existence and exact byte counts, sha256/sha384 digests compared against the values embedded in the built
document.html, substring presence of a distinctive CSS rule and of the injection marker in each bundle, and
the real headless Chromium gate — I installed Playwright's chromium into the container (npx playwright install chromium, revision 1228, launches as Chromium 149.0.7827.55) so the PR's own browser assertions ran for real
rather than being taken on trust. Harnesses live in the artifact directory as .mjs/.sh and are rerunnable:
ab-harness.mjs (30 assertions), mutation-matrix-v2.sh + adjudicate-matrix.mjs (the corrected matrix),
mutation-matrix.sh (the void v1, kept because the reason it is void is itself the finding). Raw per-cell
output is under logs/ab-harness.log, integration-head.log (6/6), unit-cli.log (76 passed),
unit-scripts.log (45 passed), mutation-matrix-v2.log, and published-0.23.2-renderer.js, the 4,136,297-byte
asset downloaded from unpkg that anchors the description's Before figure.

Flakiness gate log

integration test, out of gate scope: integration-tests/chat-transcript-document.test.ts
rounds=5 files=5 skipped=1
file packages/cli/src/ui/utils/export/formatters/html.test.ts: (cd packages/cli) npx --no-install vitest run ./src/ui/utils/export/formatters/html.test.ts
file scripts/tests/export-transcript-document-template.test.js: (cd .) npx --no-install vitest run --config ./scripts/tests/vitest.config.ts ./scripts/tests/export-transcript-document-template.test.js
file scripts/tests/install-script.test.js: (cd .) npx --no-install vitest run --config ./scripts/tests/vitest.config.ts ./scripts/tests/install-script.test.js
file scripts/tests/package-assets.test.js: (cd .) npx --no-install vitest run --config ./scripts/tests/vitest.config.ts ./scripts/tests/package-assets.test.js
file scripts/tests/transcript-css-entry-filter.test.js: (cd .) npx --no-install vitest run --config ./scripts/tests/vitest.config.ts ./scripts/tests/transcript-css-entry-filter.test.js


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/cli/src/ui/utils/export/formatters/html.test.ts: PPPPP
  scripts/tests/export-transcript-document-template.test.js: PPPPP
  scripts/tests/install-script.test.js: FFFFF
  scripts/tests/package-assets.test.js: PPPPP
  scripts/tests/transcript-css-entry-filter.test.js: PPPPP

verdict: consistent-fail
summary: 1 of 5 changed test file(s) failed identically in every round — deterministic, so CI owns that signal

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/cli/src/ui/utils/export/formatters/html.test.ts: P (exit 0)
round 1 · scripts/tests/export-transcript-document-template.test.js: P (exit 0)
round 1 · scripts/tests/install-script.test.js: F (exit 1)
--- output tail · round 1 · scripts/tests/install-script.test.js ---

�[1m�[46m RUN �[49m�[22m �[36mv3.2.7 �[39m�[90m/__w/qwen-code/qwen-code�[39m


�[31m⎯⎯⎯⎯⎯⎯�[39m�[1m�[41m Failed Suites 1 �[49m�[22m�[31m⎯⎯⎯⎯⎯⎯⎯�[39m

�[41m�[1m FAIL �[22m�[49m scripts/tests/install-script.test.js�[2m [ scripts/tests/install-script.test.js ]�[22m
�[31m�[1mError�[22m: `zip`/`unzip` missing on a CI host; archive tests would skip.�[39m
�[36m �[2m❯�[22m scripts/tests/install-script.test.js:�[2m56:9�[22m�[39m
    �[90m 54| �[39m    spawnSync('unzip', ['-v']).error === undefined);
    �[90m 55| �[39mif (process.env.CI && process.platform !== 'win32' && !zipAvailable) {
    �[90m 56| �[39m  throw new Error(
    �[90m   | �[39m        �[31m^�[39m
    �[90m 57| �[39m    '`zip`/`unzip` missing on a CI host; archive tests would skip.',
    �[90m 58| �[39m  );

�[31m�[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯�[22m�[39m


�[2m Test Files �[22m �[1m�[31m1 failed�[39m�[22m�[90m (1)�[39m
�[2m      Tests �[22m �[2mno tests�[22m
�[2m   Start at �[22m 00:11:27
�[2m   Duration �[22m 413ms�[2m (transform 112ms, setup 21ms, collect 0ms, tests 0ms, environment 0ms, prepare 61ms)�[22m


round 1 · scripts/tests/package-assets.test.js: P (exit 0)
round 1 · scripts/tests/transcript-css-entry-filter.test.js: P (exit 0)
round 2 · packages/cli/src/ui/utils/export/formatters/html.test.ts: P (exit 0)
round 2 · scripts/tests/export-transcript-document-template.test.js: P (exit 0)
round 2 · scripts/tests/install-script.test.js: F (exit 1)
--- output tail · round 2 · scripts/tests/install-script.test.js ---

�[1m�[46m RUN �[49m�[22m �[36mv3.2.7 �[39m�[90m/__w/qwen-code/qwen-code�[39m


�[31m⎯⎯⎯⎯⎯⎯�[39m�[1m�[41m Failed Suites 1 �[49m�[22m�[31m⎯⎯⎯⎯⎯⎯⎯�[39m

�[41m�[1m FAIL �[22m�[49m scripts/tests/install-script.test.js�[2m [ scripts/tests/install-script.test.js ]�[22m
�[31m�[1mError�[22m: `zip`/`unzip` missing on a CI host; archive tests would skip.�[39m
�[36m �[2m❯�[22m scripts/tests/install-script.test.js:�[2m56:9�[22m�[39m
    �[90m 54| �[39m    spawnSync('unzip', ['-v']).error === undefined);
    �[90m 55| �[39mif (process.env.CI && process.platform !== 'win32' && !zipAvailable) {
    �[90m 56| �[39m  throw new Error(
    �[90m   | �[39m        �[31m^�[39m
    �[90m 57| �[39m    '`zip`/`unzip` missing on a CI host; archive tests would skip.',
    �[90m 58| �[39m  );

�[31m�[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯�[22m�[39m


�[2m Test Files �[22m �[1m�[31m1 failed�[39m�[22m�[90m (1)�[39m
�[2m      Tests �[22m �[2mno tests�[22m
�[2m   Start at �[22m 00:11:37
�[2m   Duration �[22m 456ms�[2m (transform 117ms, setup 24ms, collect 0ms, tests 0ms, environment 0ms, prepare 77ms)�[22m


round 2 · scripts/tests/package-assets.test.js: P (exit 0)
round 2 · scripts/tests/transcript-css-entry-filter.test.js: P (exit 0)
round 3 · packages/cli/src/ui/utils/export/formatters/html.test.ts: P (exit 0)
round 3 · scripts/tests/export-transcript-document-template.test.js: P (exit 0)
round 3 · scripts/tests/install-script.test.js: F (exit 1)
--- output tail · round 3 · scripts/tests/install-script.test.js ---

�[1m�[46m RUN �[49m�[22m �[36mv3.2.7 �[39m�[90m/__w/qwen-code/qwen-code�[39m


�[31m⎯⎯⎯⎯⎯⎯�[39m�[1m�[41m Failed Suites 1 �[49m�[22m�[31m⎯⎯⎯⎯⎯⎯⎯�[39m

�[41m�[1m FAIL �[22m�[49m scripts/tests/install-script.test.js�[2m [ scripts/tests/install-script.test.js ]�[22m
�[31m�[1mError�[22m: `zip`/`unzip` missing on a CI host; archive tests would skip.�[39m
�[36m �[2m❯�[22m scripts/tests/install-script.test.js:�[2m56:9�[22m�[39m
    �[90m 54| �[39m    spawnSync('unzip', ['-v']).error === undefined);
    �[90m 55| �[39mif (process.env.CI && process.platform !== 'win32' && !zipAvailable) {
    �[90m 56| �[39m  throw new Error(
    �[90m   | �[39m        �[31m^�[39m
    �[90m 57| �[39m    '`zip`/`unzip` missing on a CI host; archive tests would skip.',
    �[90m 58| �[39m  );

�[31m�[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯�[22m�[39m


�[2m Test Files �[22m �[1m�[31m1 failed�[39m�[22m�[90m (1)�[39m
�[2m      Tests �[22m �[2mno tests�[22m
�[2m   Start at �[22m 00:11:47
�[2m   Duration �[22m 409ms�[2m (transform 113ms, setup 18ms, collect 0ms, tests 0ms, environment 0ms, prepare 72ms)�[22m


round 3 · scripts/tests/package-assets.test.js: P (exit 0)
round 3 · scripts/tests/transcript-css-entry-filter.test.js: P (exit 0)
round 4 · packages/cli/src/ui/utils/export/formatters/html.test.ts: P (exit 0)
round 4 · scripts/tests/export-transcript-document-template.test.js: P (exit 0)
round 4 · scripts/tests/install-script.test.js: F (exit 1)
--- output tail · round 4 · scripts/tests/install-script.test.js ---

�[1m�[46m RUN �[49m�[22m �[36mv3.2.7 �[39m�[90m/__w/qwen-code/qwen-code�[39m


�[31m⎯⎯⎯⎯⎯⎯�[39m�[1m�[41m Failed Suites 1 �[49m�[22m�[31m⎯⎯⎯⎯⎯⎯⎯�[39m

�[41m�[1m FAIL �[22m�[49m scripts/tests/install-script.test.js�[2m [ scripts/tests/install-script.test.js ]�[22m
�[31m�[1mError�[22m: `zip`/`unzip` missing on a CI host; archive tests would skip.�[39m
�[36m �[2m❯�[22m scripts/tests/install-script.test.js:�[2m56:9�[22m�[39m
    �[90m 54| �[39m    spawnSync('unzip', ['-v']).error === undefined);
    �[90m 55| �[39mif (process.env.CI && process.platform !== 'win32' && !zipAvailable) {
    �[90m 56| �[39m  throw new Error(
    �[90m   | �[39m        �[31m^�[39m
    �[90m 57| �[39m    '`zip`/`unzip` missing on a CI host; archive tests would skip.',
    �[90m 58| �[39m  );

�[31m�[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯�[22m�[39m


�[2m Test Files �[22m �[1m�[31m1 failed�[39m�[22m�[90m (1)�[39m
�[2m      Tests �[22m �[2mno tests�[22m
�[2m   Start at �[22m 00:11:57
�[2m   Duration �[22m 430ms�[2m (transform 115ms, setup 19ms, collect 0ms, tests 0ms, environment 0ms, prepare 63ms)�[22m


round 4 · scripts/tests/package-assets.test.js: P (exit 0)
round 4 · scripts/tests/transcript-css-entry-filter.test.js: P (exit 0)
round 5 · packages/cli/src/ui/utils/export/formatters/html.test.ts: P (exit 0)
round 5 · scripts/tests/export-transcript-document-template.test.js: P (exit 0)
round 5 · scripts/tests/install-script.test.js: F (exit 1)
--- output tail · round 5 · scripts/tests/install-script.test.js ---

�[1m�[46m RUN �[49m�[22m �[36mv3.2.7 �[39m�[90m/__w/qwen-code/qwen-code�[39m


�[31m⎯⎯⎯⎯⎯⎯�[39m�[1m�[41m Failed Suites 1 �[49m�[22m�[31m⎯⎯⎯⎯⎯⎯⎯�[39m

�[41m�[1m FAIL �[22m�[49m scripts/tests/install-script.test.js�[2m [ scripts/tests/install-script.test.js ]�[22m
�[31m�[1mError�[22m: `zip`/`unzip` missing on a CI host; archive tests would skip.�[39m
�[36m �[2m❯�[22m scripts/tests/install-script.test.js:�[2m56:9�[22m�[39m
    �[90m 54| �[39m    spawnSync('unzip', ['-v']).error === undefined);
    �[90m 55| �[39mif (process.env.CI && process.platform !== 'win32' && !zipAvailable) {
    �[90m 56| �[39m  throw new Error(
    �[90m   | �[39m        �[31m^�[39m
    �[90m 57| �[39m    '`zip`/`unzip` missing on a CI host; archive tests would skip.',
    �[90m 58| �[39m  );

�[31m�[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯�[22m�[39m


�[2m Test Files �[22m �[1m�[31m1 failed�[39m�[22m�[90m (1)�[39m
�[2m      Tests �[22m �[2mno tests�[22m
�[2m   Start at �[22m 00:12:07
�[2m   Duration �[22m 426ms�[2m (transform 116ms, setup 24ms, collect 0ms, tests 0ms, environment 0ms, prepare 81ms)�[22m


round 5 · scripts/tests/package-assets.test.js: P (exit 0)
round 5 · scripts/tests/transcript-css-entry-filter.test.js: P (exit 0)

Evidence images

01-ab-bytes-sri-base-vs-head

02-mutation-matrix-fail-closed-layers

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has neither a verdict nor a deferral on 328feb43f82b0d59f7adee18d8d1dd54e1060b3b — no APPROVED, CHANGES_REQUESTED, or COMMENTED review of its own. A DISMISSED one does not count: dismiss_stale_reviews voids the bot's approval on every push, which is exactly when a fresh one is needed. If this re-run was meant to review or approve, it did not, and an approval left by another account is a separate vote that does not count as the bot's own.

⚠️ 机器人在 328feb43f82b0d59f7adee18d8d1dd54e1060b3b既没有裁决也没有 defer —— 没有属于它自己的 APPROVEDCHANGES_REQUESTEDCOMMENTED 评审。DISMISSED 不算:dismiss_stale_reviews 会在每次推送时作废机器人的批准,而那恰恰是需要一次新批准的时刻。如果这次重跑本应评审或批准,那么它没有做到;而其他账号留下的批准是另一张票,不能算作机器人自己的。

The stage comments above were updated with the latest result. View workflow run.

上方各阶段评论已更新为最新结果。查看工作流运行

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Closed out the remaining review batch in 77537e46c520.

  • Corrected the sheetLoaded claim, pinned the stylesheet link nonce/SRI pairing, documented that delegation needs both published assets, made the load-error copy cover the renderer and stylesheet, and corrected the late render-status guard rationale.
  • Verified with the web-templates build, 45 script tests, 3 formatter tests, and all 6 Chromium transcript tests.

Not taking three non-blocking suggestions in this PR: a cascade-order regression assertion (there are no overlapping selectors today), isolating the renderer-failure test route, and extending the separate release workflow published-asset comparison to CSS. This PR has already been through multiple review rounds; these are follow-ups rather than another scope expansion.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Follow-up review and simplification pass: 30d8989a.

  • Isolated the renderer-failure browser test by serving the stylesheet successfully, so the test can no longer pass through the CSS failure path.
  • Removed the unreachable requestAnimationFrame status guard and synchronized the design record.
  • Corrected the delegation/runbook contract to require identity plus both JS and CSS integrity values.
  • Removed duplicated review-history comments. This follow-up is 27 additions / 86 deletions.

Verification:

  • web-template build passed
  • 45 focused script tests passed
  • 6 Chromium export tests passed
  • Prettier check passed

All 15 inline review threads are resolved. I did not add a CSS/link ordering pin or duplicate CSS verification in the release workflow: prior evidence found no selector overlap or rendering difference from the order, and package preparation already requires the CSS artifact. Those remain non-blocking suggestions and are intentionally deferred to avoid widening this already mature review.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Blocking finding(s) follow.

Partially reviewed — gaps disclosed.

5 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R1-3 byte ratchet no longer covers the lifted CSS asset (packages/web-templates/src/export-html/build.mjs:354) — already reported (comment 5603265110, stage-2 item 1; also comments 5603265631 and 5603264729; re-measured by @wenshao in comme…
  • R1-20 delegation docblock and runbook still describe a two-variable contract (packages/web-templates/src/export-html/build.mjs:234) — already reported (comment 5603265110, stage-2 item 3; also comment 5603265631; executed by @wenshao in com…
  • R1-5 release-time published-asset gate compares only the JS (packages/web-templates/src/export-html/build.mjs:392) — already reported (comment 3971657972)
  • R1-23 stylesheet failures route into the renderer-only diagnostic (packages/web-templates/src/export-html/src/document-index.html:77) — already reported (comment 3971657977)
  • The head latch has no behavioural witness (integration-tests/chat-transcript-document.test.ts:718) — already reported by @wenshao (comment 5609994613 §3 item 1 and its verdict item 1)

Unresolved, please confirm:

  • [Critical] stage-3 triage blocker (comment 5603265631) — whether open PR #11372 or this PR lands first. #11372 is verified still OPEN at this head and still edits only packages/web-templates/src/export-html/build.mjs (+6/-8), moving the same two budge…

Not reviewed: build-and-test on Windows and macOS — Test (windows-latest, Node 22.x) and Test (macos-latest, Node 22.x) are merge_group/schedule-gated and report skipped at this commit, and no suite ran on either host here; this is the lane class that would have executed the two new scripts/tests files on a non-POSIX filesystem, which is what round-1 Critical R1-1 turned on. Both were reasoned safe (neither new file shells out, neither is in the win32 exclude list, .gitattributes sets * text=auto eol=lf) but neither was observed running there..

Not reviewed: test-efficacy probe — the positive control never ran (no probe file was green in the unmutated baseline), so hunk-level test gating is unmeasured for this PR; harnessValidated is null, neither validated nor refuted, and 20 of 20 hunk probes were skipped (14 over the cap, 6 for baseline).

Not explored to full depth (tool budget reached): "agent 6b": integration-tests/chat-transcript-document.test.ts was not executed here (it needs the full browser gate and a 60 s maximum-document case); I substituted a sa…; "agent 6b": Firefox and WebKit nonce-on-external-stylesheet behaviour is unverified — Firefox is installed but cannot launch on this host (missing system dependencies), and…; "agent 1a": did not execute integration-tests/chat-transcript-document.test.ts (Playwright + Chromium and a full npm run build && npm run bundle prerequisite), so the n…; "agent 1a": did not execute scripts/tests/install-script.test.js (shells out to create-standalone-package.js with a fake Windows node archive), so the new lib/export-t…; "agent 1a": did not re-run node src/export-html/build.mjs from scratch — I verified the already-present dist/ artifacts instead, so the byte-budget conclusion rests on ….

Test Plan (not a blocker): 6 passed — this review observed 29641 passed.

Deferred under the convergence posture (round 3, not a blocker) — recorded, not requested in this round:

  • docs/design/2026-09-09-split-export-transcript-css.md:30 — [probe] D3-1 design docs carry the budget-squeeze justification the issue triage retracted
  • docs/design/2026-09-09-split-export-transcript-css.md:57 — [probe] D3-2 issue condition F's CSS byte breakdown never performed or recorded
  • docs/verification/export-html-runtime-size/README.md:114 — [probe] D3-6 §3 reconciliation rule compares whole-runtime rows against the JS alone
  • packages/web-templates/src/export-html/build.mjs:50 — [probe] D3-3 re-ratcheted budget constants record no measured baseline
  • packages/web-templates/src/export-html/build.mjs:51 — [probe] D3-7 re-ratchet falsifies the sibling runbook's live §2
  • packages/web-templates/src/export-html/build.mjs:353 — [probe] D3-5 size expression also dropped the inline document CSS operand
  • packages/web-templates/src/export-html/build.mjs:426 — [probe] D3-4 residual-placeholder guard enumerates names instead of the class

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 5 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未决,请确认:共 1 条(原文未翻译,列表见上方英文部分)。

未审查(原文为英文):build-and-test on Windows and macOS — Test (windows-latest, Node 22.x) and Test (macos-latest, Node 22.x) are merge_group/schedule-gated and report skipped at this commit, and no suite ran on either host here; this is the lane class that would have executed the two new scripts/tests files on a non-POSIX filesystem, which is what round-1 Critical R1-1 turned on. Both were reasoned safe (neither new file shells out, neither is in the win32 exclude list, .gitattributes sets * text=auto eol=lf) but neither was observed running there..

未审查(原文为英文):test-efficacy probe — the positive control never ran (no probe file was green in the unmutated baseline), so hunk-level test gating is unmeasured for this PR; harnessValidated is null, neither validated nor refuted, and 20 of 20 hunk probes were skipped (14 over the cap, 6 for baseline).

未探索到全部深度(达到工具调用预算):"agent 6b"integration-tests/chat-transcript-document.test.ts was not executed here (it needs the full browser gate and a 60 s maximum-document case); I substituted a sa…"agent 6b"Firefox and WebKit nonce-on-external-stylesheet behaviour is unverified — Firefox is installed but cannot launch on this host (missing system dependencies), and…"agent 1a"did not execute integration-tests/chat-transcript-document.test.ts (Playwright + Chromium and a full npm run build && npm run bundle prerequisite), so the n…"agent 1a"did not execute scripts/tests/install-script.test.js (shells out to create-standalone-package.js with a fake Windows node archive), so the new lib/export-t…"agent 1a"did not re-run node src/export-html/build.mjs from scratch — I verified the already-present dist/ artifacts instead, so the byte-budget conclusion rests on …

Test Plan(非阻断):6 passed — this review observed 29641 passed

收敛姿态下延后(第 3 轮,非阻断)——已记录,本轮不要求修改:共 7 条(原文未翻译,列表见上方英文部分)。

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

— qwen3.8-max via Qwen Code /review (v0.23.2)

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Historical-head review — head moved to 77537e46c520a799ce7f462169e6a19511e00f22 while this review was in flight (past the salvage threshold), so the run finished and posted against the head it reviewed: 328feb43f82b0d59f7adee18d8d1dd54e1060b3b (#10110). The next automatic review covers the delta from that anchor. Full log in the workflow run.

中文说明

历史 head 评审 —— 本次评审进行中 head 移动到了 77537e46c520a799ce7f462169e6a19511e00f22(已过 salvage 阈值),因此评审跑完并针对其实际评审的 head 328feb43f82b0d59f7adee18d8d1dd54e1060b3b 发布(#10110)。下一次自动评审将从该锚点起评审增量。完整日志见 workflow 运行

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 292 passed · 0 failed · 292 total

Flakiness gate: ⚠️ consistent-fail — 1 of 5 changed test file(s) failed identically in every round — deterministic, so CI owns that signal

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:292 通过 · 0 失败 · 292 总计

抖动门:⚠️ consistent-fail — 1 of 5 changed test file(s) failed identically in every round — deterministic, so CI owns that signal

Verification report

PR #11485 deep verification (round 2) — perf(export): split the transcript renderer's embedded CSS into a versioned asset

Verdict: findings — assertions 292 pass / 0 fail / 292 total. Nothing found this round is blocking.
Verified head OID: 30d8989a337db25c8fd12121abe38a375df9f845 (git rev-parse HEAD^2).
Base arm of the A/B: 005fc97b3308757e38bc4d41b0de9e94825c604b (HEAD^1).

The central claim is proven load-bearing again at the new head (29/29 A/B assertions). Both findings
carried forward from round 1 were re-measured from scratch: F1 is fixed, F2 stands — and is now a
positive result rather than a failed reproduction
. One new correction and one nit are reported below.

Snapshot note: $QWEN_VERIFY_CONTEXT records baseRefOid
fbb877a48ece0d023fa9f575f6dc45b0784b3bb5, which is not present in this depth-2 checkout
(git cat-file -tcould not get object info) — it drifted again, as in round 1. The merge commit was
built against 005fc97b, so that is the base used throughout.

中文摘要

结论:findings —— 断言 292 通过 / 0 失败 / 共 292。本轮未发现任何阻塞项。
已验证 head:30d8989a;A/B 基线:005fc97b

A/B 结论(见「Central claim + A/B」表与 01-ab-bytes-sri-base-vs-head.png):核心主张在新 head 上
重新实测成立。渲染器 JS 4,141,408 → 1,835,913 字节,组件 CSS 拆出为独立资产 2,302,905 字节
与从 web-shell/dist/transcript.js 独立重实现提取出的 CSS sha256 完全一致e0e4a14164081338…);
head 的 JS 中既无该 CSS 内容、也无运行时注入行(document.querySelector 计数 base 1 / head 0,
不存在「既 link 又注入」的重复下发)。CDN 总下载 −2,590 字节,gzip −9 −1,379 字节
与 PR「不减少总字节、只移出解析/编译关键路径」的自述一致;每个残留字节都可归因(残差 −108)。
两个 SRI 摘要均与真正发布的字节相符,且 JS 与 CSS 指向同一个版本号。字节预算上限被下调
(4,200,000 → 1,930,000),余量 55,150 → 94,084

本轮新增的方法学修正:worktree A/B 有一个隐蔽混淆——packages/web-templates/node_modules/esbuild
被 lockfile 钉在 0.21.5(vite 5 依赖),而新建 worktree 没有该嵌套目录,会向上解析到根部的
0.25.6,压缩器输出不同(首次 A/B 得到 1,836,353 字节,与 CI 不可比)。已把两个 arm 都钉到 0.21.5
并加入「bundler 一致性」断言后重测,下表为修正后的数字。

上一轮 findings 的状态(详见「Previous-finding status」表):

  1. F1 已修复:delegation 文档已改写为三变量配方,并说明记录在案的 0.23.1-preview.0 早于本次拆分。
    本轮把 2³=8 种环境变量组合全部跑通真实 build.mjs:6 种「只设置部分变量」的组合都从预期的那道
    校验
    抛出(错误信息逐一比对,不只是看退出码),三变量全设时 delegation 真正端到端生效
    (两个 URL 都指向被委托版本、两个 integrity 都取自环境变量、而资产本身仍宣告自己的身份)。
    残留事实(非缺陷):实测 unpkg 上所有已发布版本(含 0.23.2-preview.0 与 20260909 nightly)的
    .css 仍为 404,所以在下一次发版前该开关对任何已发布版本都不可用——而文档现在正是这么写的。
  2. F2 仍然成立,且证据升级<head> 闩锁(latch)依旧不是行为层面的承重件。上一轮只能说
    「没能复现那次竞态」;本轮用变异给出了正面证据:把 body 监听器的 stylesheet 分支删掉、
    保留闩锁,在真实 HTTP 404 / 真实 SRI 不匹配下,页面 renderComplete="true"、transcript 照常渲染,
    window.__transcriptStyleFailed 确实是 true —— 说明闩锁的读取语句在标志被置位之前就已执行完,
    因此注释所称「the <head> latch is the only record of it」不成立。

另一项正面结论(验证 closeout 提交的删除是正确的):把被删掉的 renderComplete !== 'error' 守卫
加回去(反向变异),在 81 条真实 HTTP 断言与仓库浏览器门禁下与 control 完全无法区分(81/0/81,
门禁绿灯)。即该守卫在本环境下不可观测,删除它是正确的收敛。

未覆盖范围scripts/tests/install-script.test.js 在本容器仍无法收集(缺 zipCI=true 下守卫直接
抛错;容器非 root,apt-get install zip 被拒),已用 base 侧 A/A 对照证明为环境问题(base 同样
Test Files 1 failed (1) / Tests no tests,且该守卫 0 处被本 PR 改动)——因此新增的「standalone 归档不含
CSS」断言本轮仍未执行,只做了静态核对。此外未运行仓库级 typecheck / lint / prettier
未做完整 per-commit 归因,无 Windows 主机([\\/] 仅由单元测试覆盖),fail-closed 只在
headless Chromium(Linux)下测量。

Previous-finding status

# Round-1 finding Sev Status at 30d8989a
F1 Renderer-delegation doc recipe exits 1; delegation impossible against every published version Suggestion fixed — the doc now shows all three variables and says the recorded 0.23.1-preview.0 target predates the split. Re-measured: all 6 partial combinations throw from their intended guard, and the three-variable recipe works end-to-end (03-delegation-three-variable-contract.png). The "no published version has the CSS" half is still true (6 versions probed, all 404) but the doc now states it, so it is release sequencing, not a defect.
F2 The <head> stylesheet latch is not behaviourally load-bearing at any document size constructible Suggestion stands — strengthened. Round 1 could only report a failed reproduction under a routed abort. This round reproduces the failure on a real HTTP 404 and a real SRI mismatch, and row b of the matrix proves positively that the latch's flag is set yet the document still renders and stamps renderComplete="true". The comment's mechanism claim is falsified by mutation, not merely unobserved.
Round-1 nit: release-vscode-companion.yml cmps the published JS but has no equivalent for the CSS Nit stands (unchanged by the delta; the delta does not touch .github/). Still mitigated by prepare-package.js making a missing CSS fatal at packaging time.
Round-1 "Not covered": fail-closed never exercised against a real network 404 closed. All CSS-failure rungs this round are real wire failures served by a loopback HTTP server, with no Playwright routing installed at all.
Round-1 "Not covered": install-script.test.js uncollectable (no zip) stands, still environmental. A/A re-proven at the new base; apt-get install zip is refused (not root).

Round 1 also reported a deferred/declined item worth re-measuring: the escaping-artifact class of concern
does not apply here, and no accepted-tradeoff list in the description changed its numbers — but one row of it
did move, see Corrections below (link.sheet as an SRI oracle).

Scope selection

Central claim. The web-shell component stylesheet is lifted out of export-transcript-document.js into a
separate version-pinned, SRI-protected export-transcript-document.css, so the JS a browser must
download/parse/compile before rendering drops from ~4.14 MB to ~1.84 MB, with the CSS fetched in parallel.

Secondary claim 1. A stylesheet load failure fails closed with the same load-error page as a missing
renderer. Delta focus: the closeout commit removed one of the two document-main.tsx guards that
round 1's matrix showed were jointly load-bearing, so this claim needed re-proving, not re-reading.

Secondary claim 2. The delegation knob's new three-variable contract behaves as the rewritten doc says.

Delta since round 1 (328feb43..30d8989a, 2 commits, 12 files, +37/−92): the doc rewrite (F1), removal
of the renderComplete !== 'error' guard and its design-doc bullets in both languages, the alert text
("published renderer or stylesheet"), the gate now fulfilling the CSS route inside the renderer-failure
loop, new html.test.ts link assertions, and three large comment deletions. New probes were scoped to
exactly that: the guard-removal matrix (rows c/d), the real-wire fail-closed ladder, the delegation
contract, and vacuity of the new html.test.ts assertions.

Budget: A/B pair with the esbuild-parity redo (~20 min), real-HTTP fail-closed ladder (~10 min), 6-row
mutation matrix with reachability probes (~12 min), delegation contract (~8 min), vacuity mutants (~8 min),
gates (~5 min).

Central claim + A/B

Both arms are real node src/export-html/build.mjs runs in scratch worktrees under tmp/. The bundler
input is byte-identical across arms
and that is asserted, not assumed: git diff HEAD^1..HEAD -- packages/web-shell is 0 files; sha256(packages/web-shell/dist/transcript.js) =
afc58db3728276483988… (3,537,330 bytes) is the single shared input; neither worktree has a node_modules
of its own, and readlink -f node_modules/@&#8203;qwen-code/web-shell = /__w/qwen-code/qwen-code/packages/web-shell
for both. Witness: 01-ab-bytes-sri-base-vs-head.png.

A confound this round caught and removed. package-lock.json pins
packages/web-templates/node_modules/esbuild at 0.21.5 (vite 5's copy), so the production build resolves
esbuild there. A fresh worktree lacks that nested directory and silently walks up to the root's 0.25.6,
whose minifier emits different bytes (t.flags&4098&& vs (t.flags&4098)!==0&& — 437 bytes and ~61,810
differing character positions across the bundle). My first A/B ran both arms on 0.25.6: internally
consistent, but not comparable to CI or to the author's numbers (1,836,353 vs 1,835,916 logged). I symlinked
the production nested node_modules into both worktrees, rebuilt both arms, and added two scripted
bundler-parity assertions. The table below is the esbuild-0.21.5 (production-faithful) run.

Metric base 005fc97b head 30d8989a Δ
esbuild resolving the build 0.21.5 0.21.5 asserted equal
export-transcript-document.js (shipped bytes) 4,141,408 1,835,913 −2,305,495
export-transcript-document.css (shipped bytes) — (not produced) 2,302,905 +2,302,905
Total CDN download (js + css) 4,141,408 4,138,818 −2,590
gzip −9 total transfer 1,526,420 1,525,041 (529,184 + 995,857) −1,379
build's own logged number Document export runtime is 4144413 bytes renderer JS is 1835916 bytes; CSS is 2302905 bytes
document.html template 6,606 7,931 +1,325
byte-budget cap / headroom 4,200,000 / 55,150 1,930,000 / 94,084 cap ratcheted down
component CSS content inside the JS present absent
CSS runtime-injection line inside the JS 1 occurrence 0 no double delivery

Every residual byte is accounted for. JS shrank by 2,305,495 while the CSS asset is 2,302,905, so total
download fell by 2,590. Decomposition: 2,698 bytes of JSON string-escaping no longer paid (the literal
line in transcript.js is 2,305,604 bytes; the raw CSS after JSON.parse is 2,302,905) plus −108 net of
the removed 367-byte runtime-injection line against the two new document-main.tsx guards and the longer
alert string. No unexplained residue.

The extraction is exact. An independent re-implementation of the lift
(source.match(/^const __qwenWebShellCss=("(?:[^"\\]|\\.)*");\n/)JSON.parse) matches at index 0 of the
real packages/web-shell/dist/transcript.js and produces CSS whose sha256 is e0e4a14164081338…
byte-identical to the build's asset. The injection line the plugin steps over is 367 bytes including its
newline
(366 + \n), which is what build.mjs actually removes, so the comment's number is right.

A hazard expected and disproved again. Moving CSS from an inline <style> to an external <link>
changes the base URL every relative url() resolves against. Still inert: the lifted sheet has 60 url()
references, all 60 data:, zero relative
, including all 20 @font-face blocks, and the CSP's
font-src data: admits them.

Cascade actually happens, measured two independent ways (control rungs of the fail-closed ladder):
2,385 rules in the loaded sheet, 17 of the first 4,000 selectors match live elements, and disabling the
<link> moves computed styles
on rendered elements (fontFamily/color/backgroundColor/padding/
borderRadius/display over 400 elements). Two instruments with independent failure modes agree.

Corrections

  • link.sheet !== null is not an SRI oracle, and the delta already walked the claim back — measurement
    confirms the walk-back was correct.
    Round 1 quoted the gate's comment that a non-null sheet "proves the
    SRI check passed and the CSS parsed". Measured this round on real wire failures: a genuine HTTP 404
    leaves link.sheet non-null
    (an empty sheet — sheet=true, cascades=false) at every delay rung, while
    a genuine SRI mismatch nulls it (sheet=false). So that assertion cannot detect a missing CSS asset at
    all. The closeout commit replaced the comment with "Keep a smoke-check for the stylesheet link, while the
    KaTeX font-family is the cascade oracle"
    — which matches the measurement exactly. This is a correction to
    the earlier description of the code, not a request to change it; the load-bearing oracle in the gate is
    the KaTeX font-family, and it is asserted.
  • The description's Before/After numbers are the author's local build, not this merge commit. The table
    gives 1,831,301 JS / 2,302,457 CSS; reproducible here at 1,835,916 logged / 1,835,913 shipped JS and
    2,302,905 CSS (Δ +4,615 / +448), because the author built against their own web-shell output. The
    Before figure is exactly right: I downloaded the published asset and @0.23.2/export-transcript-document.js
    is 4,136,297 bytes.
  • The "__qwenWebShellCss literal: present → absent" row describes the pre-bundle input, not the shipped
    asset
    — esbuild's minifier mangles that identifier away in both arms (grep -c is 0 on base too). The
    verifiable claim is the CSS content moving out, which holds, and the injection line disappearing
    (1 → 0 occurrences of document.querySelector).

Findings

F2′ (Suggestion, carried forward and strengthened) — the <head> latch's comment states a mechanism this round falsifies by mutation

Three layers defend a stylesheet failure: (a) the <head> latch recording
window.__transcriptStyleFailed, (b) the body listener's transcript-stylesheet id branch, and (c)
the module-scope mount guard in document-main.tsx. Layered guards hide each other, so I reverted each alone
and in combination, and — because round 1's first matrix was voided by a stale compiled artifact — every row
probes both consumed artifacts before its result is believed: the compiled template
(packages/web-templates/dist/generated/exportTranscriptDocumentTemplate.js, which the CLI formatter imports
at runtime) and the renderer bundle
(packages/web-templates/src/export-html/dist/export-transcript-document.js, which the gate reads directly).
A row whose probe misses its expectation is reported INVALID; all six rows report REACHED.
Witness: 02-mutation-matrix-fail-closed-layers.png.

Oracles per row: the repo's own browser gate (stylesheet case, routed abort), a real-HTTP harness (9 rungs,
81 assertions — loopback server, no Playwright routing, genuine 404 / SRI mismatch / empty body, server-side
delays 0/800/2500 ms, plus the maximum-size document at maxBlocks=1000), and the static template test.

row reachability probe (template latch / sheetId, bundle renderComplete) repo gate real-HTTP harness template test
control 2 / 3 / 3 ✔ green (2 passed) 81 / 0 / 81 green (6)
a — revert <head> latch (head script + body reader) 0 / 2 / 3 ✔ green — SURVIVED 75/6 — the 6 are only the latch-mechanism assertion; every fail-closed outcome assertion still passes red (6 failed)
b — revert body-listener id branch 2 / 2 / 3 ✔ red — killed 49/32 — fail-closed BROKEN red (1 failed)
a+b — combination row 0 / 1 / 3 ✔ red — killed 43/38 red (6 failed)
c — revert module-scope mount guard 2 / 3 / 2 red — killed 55/26 — hazard realized green (out of that file's scope)
dre-add the guard the closeout commit removed 2 / 3 / 4 green 81 / 0 / 81 — indistinguishable from control green (6)

Positive controls are green on all three oracles, and three of four mutants turn the gate red with the
intended behavioural mismatch, so the kills are attributable and the harness is not dead.

Row b is the decisive one, and it is new evidence. With the latch present (probe latch=2) and only the
body listener's stylesheet branch removed, on a real HTTP 404:

renderComplete="true" role=null transcriptDom=1 sheet=true cascades=false latch=true
timeline: init@5 | error:transcript-stylesheet@8 | renderComplete=true@…

latch=truewindow.__transcriptStyleFailed was set — and the document still rendered and stamped
itself complete. So the body script's if (window.__transcriptStyleFailed) showLoadError(); reader ran
before the flag was set: Chromium unblocked the parser, ran the body script, and only then dispatched the
error task. Row a says the same thing from the other side (remove the latch, nothing changes). Together they
are a mutation-based proof, not a failed reproduction, that in this environment the comment in
document-index.html

"A stylesheet failure that settles while the parser is still blocked on the <link> is dispatched before
the listener above exists, so the <head> latch is the only record of it."

— is false: the latch is a record nobody reads in time. The equivalent comment block in the <head> and the
one in scripts/tests/export-transcript-document-template.test.js make the same claim.

Row d validates the closeout commit's removal. Re-adding
if (document.body.dataset.renderComplete !== 'error') around the rAF stamp produces byte-identical outcomes
across all 81 real-HTTP assertions and a green gate — the axis is unobservable, so the guard was redundant and
deleting it (with the matching design-doc bullets in both languages) was the right convergence. Per the
unpinned-axis rule: the suite cannot tell head from head-plus-guard, and that is the correct answer here
rather than a coverage gap, because row d shows there is nothing to cover.

Bounded — what does NOT hold. I looked for the reachable ordering in which the transcript renders
unstyled and is stamped complete at head, because showLoadError() early-returns on
if (document.body.dataset.renderComplete) return; while the rAF now stamps 'true' unconditionally. It does
not occur at head: across 9 rungs — real 404 at 0/800/2500 ms, real SRI mismatch, empty 200 body,
renderer 404, and the maximum-size document at 800 ms — every CSS-failure rung ends at
renderComplete="error", role="alert", transcriptDom=0, and the rAF never stamps 'true'. The hazard
only appears in row c, i.e. when the remaining mount guard is removed. So the closeout commit removed the
redundant half and kept the load-bearing half. Parser-blocking is what makes the ordering safe: the
renderer <script> cannot execute until the stylesheet settles, so the failure always precedes the mount
guard's check.

Severity is a Suggestion, unchanged: the latch is additive, fail-safe, cannot make anything worse, and its
static test pins the properties that could actually be wrong (position, nonce, capture phase, id agreement).
What is actionable is the prose.

Honest limits on the negative. All of this is headless Chromium on Linux in this container. Task ordering
between "parser unblocks" and "resource error dispatched" is not specified, so a different Chromium version,
a real (non-loopback) network with DNS/TLS latency, or a slower device could plausibly dispatch the error
before the body script runs — in which case the latch would be load-bearing and the comment right. If the
author has such a reproduction, recording it turns six static assertions into a demonstrated mechanism; if
not, the three comments overstate what is proven.

Suggested minimal change (prose only — not applied)

Reword the three comments to what is measured: the latch records a stylesheet failure that settles before the
body script runs, and in this environment (headless Chromium, loopback and real 404/SRI failures, delays to
2500 ms, maximum-size document) the body listener is what actually closes the document — the latch is
defence-in-depth for an ordering not observed here. No source change is implied.

F3 (Nit) — one deleted comment carried rationale that survives nowhere

The delta deleted three comment blocks. Two are fine: the transcript-css-entry.mjs rationale (both
separators; why the transcript\.js$ tail must not match the barred web-shell/dist/index.js) survives
verbatim in scripts/tests/transcript-css-entry-filter.test.js, and the copy_bundle_assets.js "warning, not
a throw — that script is the release gate, this one also serves --cli-only dev bundles" rationale survives
in scripts/prepare-package.js:78 and copy_bundle_assets.js:584.

The third does not. build.mjs lost 35 lines explaining the delegation knob, including the specific reason it
is deliberately not wired into CI:

"the envelope would announce the delegated identity while the asset running in the page announces its own,
and document-main.tsx fails closed on exactly that mismatch."

The replacement says only "CI serves the local assets and intentionally leaves delegation disabled" — the
instruction survives, the mechanism does not, and docs/verification/export-renderer-delegation-mermaid/README.md
describes the mismatch symptom without tying it to CI. Trimming is consistent with the repo's comment policy;
this one line of why is the part worth keeping.

Vacuity and gate liveness

  • The repo browser gate is live and reaches the code under test: control green, three of four mutants red
    with the intended mismatch, and each row's reachability probe confirms the mutation landed in the artifact
    the gate consumes. (Round 1's first matrix was voided by exactly this; the probe is why this one is not.)
  • The real-HTTP harness is live and self-audited. Its first version had two defects I found and fixed
    before believing any result: MutationObserver.observe(document.documentElement) throws at document-start
    (documentElement is still null), and that TypeError dispatched a window error — which the page's own
    showLoadError listens for, so the harness was injecting a fake fail-closed cause into every rung. Two
    validity controls now run per rung and are counted: no self-inflicted window error, and the recorder
    observed the renderComplete transition
    . A third assertion I had written — "stylesheet settles before the
    renderer script loads" — compared against Infinity because the <script> load event was never captured,
    so it could not fail; it is replaced by two falsifiable ones (the latch recorded the failure; the rAF never
    stamped 'true').
  • The assertions the closeout commit added to html.test.ts are not vacuous, and the interesting part is
    what the first two mutants showed. Dropping the nonce from the stylesheet <link> kills the new nonce
    assertion with the intended mismatch. Removing the link entirely, and separately making the CSS integrity a
    non-digest, both go red — but on earlier assertions (toContain(css-url) and the pre-existing
    toHaveLength(2)), so neither proves anything about the new lines. A fourth, precisely-targeted mutant
    does: moving the CSS digest from the stylesheet <link> to the favicon <link> leaves exactly 2 valid
    sha384 digests on the page (so toHaveLength(2) stays green) and kills only
    expect(stylesheetLink).toMatch(/integrity="sha384-…/). That is the security property the new assertion
    exists to bind — the digest must be on the stylesheet element, not merely somewhere in the document — and it
    is pinned.
  • The static template test is live: row a turns all 6 cases red; row b turns 1 red.
  • Packaging tests are live: scripts/tests/package-assets.test.js 37 tests green, covering the
    all-or-nothing copy branch, the "name the missing stylesheet" warning, and preparePackage exiting 1 when
    the published CSS is absent.

Not covered

  • scripts/tests/install-script.test.js still cannot run here, and the cause is environmental — proven by
    A/A, not assumed.
    It throws at import from its own guard (`zip`/`unzip` missing on a CI host;
    CI=true, command -v zip empty, only /usr/bin/unzip present, and the container is not root so
    apt-get install zip is refused with Permission denied on the dpkg lock). The identical file at base
    005fc97b fails the same way (Test Files 1 failed (1), Tests no tests), and the guard is untouched by
    the PR (0 matches for zipAvailable in git diff HEAD^1..HEAD). Consequence: the PR's new assertion
    that standalone archives exclude lib/export-transcript-document.css was not executed. I verified the
    intent statically only — the CSS is a member of DIST_NPM_PACKAGE_ONLY_ENTRIES, which is the exact predicate
    the copy loop consults at create-standalone-package.js:380 — but that set is not exported, so it cannot be
    asserted without running the packager, which needs a full dist and zip. Weaker than a run; labelled as such.
  • Per-commit attribution is still partial. The depth-2 checkout makes git rev-list HEAD^1..HEAD^2 return
    1 commit while the snapshot records 7. This round I recovered the history with a read-only anonymous
    git fetch --depth=12 origin pull/11485/head, which let me isolate the exact delta
    (328feb43..30d8989a, 2 commits, 12 files) and scope new probes to it — but I verified the aggregate
    HEAD^1..HEAD diff, and the two delta commits (77537e46, 30d8989a) were exercised as one change, not
    individually. The five earlier commits were not re-attributed either.
  • Repo-wide typecheck, lint and prettier were not run — the PR's own CI covers them and no A/B needed
    the numbers.
  • No Windows host. TRANSCRIPT_CSS_ENTRY_FILTER accepts both separators and rejects
    web-shell/dist/index.js (2 unit tests green), and I confirmed the POSIX branch really fires (the extraction
    happened, sha256-identical). The [\\/] class remains unexercised end-to-end.
  • Fail-closed was measured in one engine. Headless Chromium on Linux. Real wire failures this time
    (loopback HTTP, no routing), but not Safari/Firefox and not a real CDN with DNS/TLS latency — which is
    exactly the regime where F2′'s ordering could differ.
  • release-vscode-companion.yml sequencing was read, not run, and the missing CSS cmp was not exercised.
  • The maximum-document rung used the format's own envelope (maxBlocks=1000, ~8 MB JSON, mirroring the
    gate's createMaximumDocument), not a larger synthetic one; maxEnvelopeBytes is 32 MB and I did not probe
    between the two.

Methodology

Everything ran in the CI verify container (node:22-bookworm, Node v22.23.2, non-root) at the merge commit
e6bf2b76, with npm ci and npm run build already completed. Scratch worktrees tmp/base-tree (HEAD^1)
and tmp/head-tree (HEAD) hosted the A/B; both were given a symlink to the production
packages/web-templates/node_modules so esbuild resolved to 0.21.5 on both arms, and both were removed
afterwards. ab-harness.mjs (29 assertions) measures shipped bytes, gzip −9, both SRI digests against the
bytes that actually ship, URL/version agreement, an independent re-implementation of the CSS lift compared by
sha256, the url() census, and the budget ratchet. failclosed-harness.mjs (81 assertions) serves the real
rendered document — produced by the compiled packages/cli/dist formatter and the real
createExportTranscriptDocumentV1, with both asset URLs rewritten to the loopback server and their integrity
attributes left intact so Chromium performs real SRI verification — and drives 9 rungs in headless Chromium
with an addInitScript timeline recorder; no Playwright request routing is installed anywhere.
delegation-harness.mjs (26 assertions) drives all 2³ environment combinations through the real build.mjs
in tmp/head-tree, pins each throw to its intended guard by message rather than exit code, and uses synthetic
delegated digests so "the env value reached the document" cannot be satisfied by the local build's own.
mutation-driver.sh and vacuity-driver.sh mutate the main tree, run the full workspace build, probe both
consumed artifacts, run the oracles, and git checkout -- afterwards; git status --porcelain is empty at the
end. matrix-assertions.mjs (29 assertions) re-reads the saved logs and encodes every expected red as a pass.
Raw per-cell output lives in logs/ (build-*-esbuild0215.log, ab.log, failclosed-head.log,
delegation.log, gate-head-control.log, unit-cli.log, unit-scripts.log,
aa-install-script-base.log, mutation-matrix.log, matrix-assertions.log, vacuity-*.log) and per-row
logs in mutants/. Live unpkg and npm-registry probes were anonymous HTTPS reads; no GitHub API call was made
and nothing was posted.

Flakiness gate log

integration test, out of gate scope: integration-tests/chat-transcript-document.test.ts
rounds=5 files=5 skipped=1
file packages/cli/src/ui/utils/export/formatters/html.test.ts: (cd packages/cli) npx --no-install vitest run ./src/ui/utils/export/formatters/html.test.ts
file scripts/tests/export-transcript-document-template.test.js: (cd .) npx --no-install vitest run --config ./scripts/tests/vitest.config.ts ./scripts/tests/export-transcript-document-template.test.js
file scripts/tests/install-script.test.js: (cd .) npx --no-install vitest run --config ./scripts/tests/vitest.config.ts ./scripts/tests/install-script.test.js
file scripts/tests/package-assets.test.js: (cd .) npx --no-install vitest run --config ./scripts/tests/vitest.config.ts ./scripts/tests/package-assets.test.js
file scripts/tests/transcript-css-entry-filter.test.js: (cd .) npx --no-install vitest run --config ./scripts/tests/vitest.config.ts ./scripts/tests/transcript-css-entry-filter.test.js


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/cli/src/ui/utils/export/formatters/html.test.ts: PPPPP
  scripts/tests/export-transcript-document-template.test.js: PPPPP
  scripts/tests/install-script.test.js: FFFFF
  scripts/tests/package-assets.test.js: PPPPP
  scripts/tests/transcript-css-entry-filter.test.js: PPPPP

verdict: consistent-fail
summary: 1 of 5 changed test file(s) failed identically in every round — deterministic, so CI owns that signal

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/cli/src/ui/utils/export/formatters/html.test.ts: P (exit 0)
round 1 · scripts/tests/export-transcript-document-template.test.js: P (exit 0)
round 1 · scripts/tests/install-script.test.js: F (exit 1)
--- output tail · round 1 · scripts/tests/install-script.test.js ---

�[1m�[46m RUN �[49m�[22m �[36mv3.2.7 �[39m�[90m/__w/qwen-code/qwen-code�[39m


�[31m⎯⎯⎯⎯⎯⎯�[39m�[1m�[41m Failed Suites 1 �[49m�[22m�[31m⎯⎯⎯⎯⎯⎯⎯�[39m

�[41m�[1m FAIL �[22m�[49m scripts/tests/install-script.test.js�[2m [ scripts/tests/install-script.test.js ]�[22m
�[31m�[1mError�[22m: `zip`/`unzip` missing on a CI host; archive tests would skip.�[39m
�[36m �[2m❯�[22m scripts/tests/install-script.test.js:�[2m56:9�[22m�[39m
    �[90m 54| �[39m    spawnSync('unzip', ['-v']).error === undefined);
    �[90m 55| �[39mif (process.env.CI && process.platform !== 'win32' && !zipAvailable) {
    �[90m 56| �[39m  throw new Error(
    �[90m   | �[39m        �[31m^�[39m
    �[90m 57| �[39m    '`zip`/`unzip` missing on a CI host; archive tests would skip.',
    �[90m 58| �[39m  );

�[31m�[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯�[22m�[39m


�[2m Test Files �[22m �[1m�[31m1 failed�[39m�[22m�[90m (1)�[39m
�[2m      Tests �[22m �[2mno tests�[22m
�[2m   Start at �[22m 04:24:37
�[2m   Duration �[22m 606ms�[2m (transform 144ms, setup 21ms, collect 0ms, tests 0ms, environment 0ms, prepare 126ms)�[22m


round 1 · scripts/tests/package-assets.test.js: P (exit 0)
round 1 · scripts/tests/transcript-css-entry-filter.test.js: P (exit 0)
round 2 · packages/cli/src/ui/utils/export/formatters/html.test.ts: P (exit 0)
round 2 · scripts/tests/export-transcript-document-template.test.js: P (exit 0)
round 2 · scripts/tests/install-script.test.js: F (exit 1)
--- output tail · round 2 · scripts/tests/install-script.test.js ---

�[1m�[46m RUN �[49m�[22m �[36mv3.2.7 �[39m�[90m/__w/qwen-code/qwen-code�[39m


�[31m⎯⎯⎯⎯⎯⎯�[39m�[1m�[41m Failed Suites 1 �[49m�[22m�[31m⎯⎯⎯⎯⎯⎯⎯�[39m

�[41m�[1m FAIL �[22m�[49m scripts/tests/install-script.test.js�[2m [ scripts/tests/install-script.test.js ]�[22m
�[31m�[1mError�[22m: `zip`/`unzip` missing on a CI host; archive tests would skip.�[39m
�[36m �[2m❯�[22m scripts/tests/install-script.test.js:�[2m56:9�[22m�[39m
    �[90m 54| �[39m    spawnSync('unzip', ['-v']).error === undefined);
    �[90m 55| �[39mif (process.env.CI && process.platform !== 'win32' && !zipAvailable) {
    �[90m 56| �[39m  throw new Error(
    �[90m   | �[39m        �[31m^�[39m
    �[90m 57| �[39m    '`zip`/`unzip` missing on a CI host; archive tests would skip.',
    �[90m 58| �[39m  );

�[31m�[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯�[22m�[39m


�[2m Test Files �[22m �[1m�[31m1 failed�[39m�[22m�[90m (1)�[39m
�[2m      Tests �[22m �[2mno tests�[22m
�[2m   Start at �[22m 04:24:50
�[2m   Duration �[22m 622ms�[2m (transform 157ms, setup 28ms, collect 0ms, tests 0ms, environment 0ms, prepare 75ms)�[22m


round 2 · scripts/tests/package-assets.test.js: P (exit 0)
round 2 · scripts/tests/transcript-css-entry-filter.test.js: P (exit 0)
round 3 · packages/cli/src/ui/utils/export/formatters/html.test.ts: P (exit 0)
round 3 · scripts/tests/export-transcript-document-template.test.js: P (exit 0)
round 3 · scripts/tests/install-script.test.js: F (exit 1)
--- output tail · round 3 · scripts/tests/install-script.test.js ---

�[1m�[46m RUN �[49m�[22m �[36mv3.2.7 �[39m�[90m/__w/qwen-code/qwen-code�[39m


�[31m⎯⎯⎯⎯⎯⎯�[39m�[1m�[41m Failed Suites 1 �[49m�[22m�[31m⎯⎯⎯⎯⎯⎯⎯�[39m

�[41m�[1m FAIL �[22m�[49m scripts/tests/install-script.test.js�[2m [ scripts/tests/install-script.test.js ]�[22m
�[31m�[1mError�[22m: `zip`/`unzip` missing on a CI host; archive tests would skip.�[39m
�[36m �[2m❯�[22m scripts/tests/install-script.test.js:�[2m56:9�[22m�[39m
    �[90m 54| �[39m    spawnSync('unzip', ['-v']).error === undefined);
    �[90m 55| �[39mif (process.env.CI && process.platform !== 'win32' && !zipAvailable) {
    �[90m 56| �[39m  throw new Error(
    �[90m   | �[39m        �[31m^�[39m
    �[90m 57| �[39m    '`zip`/`unzip` missing on a CI host; archive tests would skip.',
    �[90m 58| �[39m  );

�[31m�[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯�[22m�[39m


�[2m Test Files �[22m �[1m�[31m1 failed�[39m�[22m�[90m (1)�[39m
�[2m      Tests �[22m �[2mno tests�[22m
�[2m   Start at �[22m 04:25:03
�[2m   Duration �[22m 707ms�[2m (transform 141ms, setup 22ms, collect 0ms, tests 0ms, environment 0ms, prepare 161ms)�[22m


round 3 · scripts/tests/package-assets.test.js: P (exit 0)
round 3 · scripts/tests/transcript-css-entry-filter.test.js: P (exit 0)
round 4 · packages/cli/src/ui/utils/export/formatters/html.test.ts: P (exit 0)
round 4 · scripts/tests/export-transcript-document-template.test.js: P (exit 0)
round 4 · scripts/tests/install-script.test.js: F (exit 1)
--- output tail · round 4 · scripts/tests/install-script.test.js ---

�[1m�[46m RUN �[49m�[22m �[36mv3.2.7 �[39m�[90m/__w/qwen-code/qwen-code�[39m


�[31m⎯⎯⎯⎯⎯⎯�[39m�[1m�[41m Failed Suites 1 �[49m�[22m�[31m⎯⎯⎯⎯⎯⎯⎯�[39m

�[41m�[1m FAIL �[22m�[49m scripts/tests/install-script.test.js�[2m [ scripts/tests/install-script.test.js ]�[22m
�[31m�[1mError�[22m: `zip`/`unzip` missing on a CI host; archive tests would skip.�[39m
�[36m �[2m❯�[22m scripts/tests/install-script.test.js:�[2m56:9�[22m�[39m
    �[90m 54| �[39m    spawnSync('unzip', ['-v']).error === undefined);
    �[90m 55| �[39mif (process.env.CI && process.platform !== 'win32' && !zipAvailable) {
    �[90m 56| �[39m  throw new Error(
    �[90m   | �[39m        �[31m^�[39m
    �[90m 57| �[39m    '`zip`/`unzip` missing on a CI host; archive tests would skip.',
    �[90m 58| �[39m  );

�[31m�[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯�[22m�[39m


�[2m Test Files �[22m �[1m�[31m1 failed�[39m�[22m�[90m (1)�[39m
�[2m      Tests �[22m �[2mno tests�[22m
�[2m   Start at �[22m 04:25:18
�[2m   Duration �[22m 775ms�[2m (transform 142ms, setup 21ms, collect 0ms, tests 0ms, environment 0ms, prepare 221ms)�[22m


round 4 · scripts/tests/package-assets.test.js: P (exit 0)
round 4 · scripts/tests/transcript-css-entry-filter.test.js: P (exit 0)
round 5 · packages/cli/src/ui/utils/export/formatters/html.test.ts: P (exit 0)
round 5 · scripts/tests/export-transcript-document-template.test.js: P (exit 0)
round 5 · scripts/tests/install-script.test.js: F (exit 1)
--- output tail · round 5 · scripts/tests/install-script.test.js ---

�[1m�[46m RUN �[49m�[22m �[36mv3.2.7 �[39m�[90m/__w/qwen-code/qwen-code�[39m


�[31m⎯⎯⎯⎯⎯⎯�[39m�[1m�[41m Failed Suites 1 �[49m�[22m�[31m⎯⎯⎯⎯⎯⎯⎯�[39m

�[41m�[1m FAIL �[22m�[49m scripts/tests/install-script.test.js�[2m [ scripts/tests/install-script.test.js ]�[22m
�[31m�[1mError�[22m: `zip`/`unzip` missing on a CI host; archive tests would skip.�[39m
�[36m �[2m❯�[22m scripts/tests/install-script.test.js:�[2m56:9�[22m�[39m
    �[90m 54| �[39m    spawnSync('unzip', ['-v']).error === undefined);
    �[90m 55| �[39mif (process.env.CI && process.platform !== 'win32' && !zipAvailable) {
    �[90m 56| �[39m  throw new Error(
    �[90m   | �[39m        �[31m^�[39m
    �[90m 57| �[39m    '`zip`/`unzip` missing on a CI host; archive tests would skip.',
    �[90m 58| �[39m  );

�[31m�[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯�[22m�[39m


�[2m Test Files �[22m �[1m�[31m1 failed�[39m�[22m�[90m (1)�[39m
�[2m      Tests �[22m �[2mno tests�[22m
�[2m   Start at �[22m 04:25:36
�[2m   Duration �[22m 915ms�[2m (transform 221ms, setup 45ms, collect 0ms, tests 0ms, environment 0ms, prepare 185ms)�[22m


round 5 · scripts/tests/package-assets.test.js: P (exit 0)
round 5 · scripts/tests/transcript-css-entry-filter.test.js: P (exit 0)

Evidence images

01-ab-bytes-sri-base-vs-head

02-mutation-matrix-fail-closed-layers

03-delegation-three-variable-contract

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Reviewed the latest sandbox report against 30d8989a.

  • F2′ is non-blocking and intentionally deferred. Its mutation matrix confirms the current head still fails closed across all tested network cases and that the remaining module-scope mount guard is load-bearing. Rewording defensive-latch comments at this stage would be comment-only churn, not a correctness fix.
  • F3 does not apply: the rationale survives in docs/verification/export-renderer-delegation-mermaid/README.md:87-101, which explicitly ties CI serving local assets to the delegated/local identity mismatch and the fail-closed result.

No code change is needed from this report. All 15 inline review threads are resolved and the required CI checks pass; only the automated review job is still pending.

chiga0
chiga0 previously approved these changes Sep 10, 2026

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking findings.
Approval blockers: none.

Scope: source code only. NOT reviewed — design doc content (informational, 2 files) · Chinese translation of design doc.
Ran: static analysis of all 18 files; cross-file on the CSS extraction pipeline (build.mjs → transcript-css-entry.mjs → document-index.html → document-main.tsx) and the packaging chain (copy_bundle_assets.js → prepare-package.js → create-standalone-package.js). Rung 3 not run (no browser environment available); integration tests cover it.

Checked:

  • CSS extraction regex (build.mjs extractTranscriptCss): traced ^const __qwenWebShellCss=("(?:[^"\]|\\.)*");\n through escaped-quote and escaped-backslash edge cases — the (?:[^"\]|\\.)* alternation correctly pairs every backslash with the next character, so a CSS string ending with a literal \ is captured without leaking the closing quote. Both the CSS-constant guard and the injection-line guard throw on shape mismatch, so a web-shell build change fails the export build rather than shipping a double-injected stylesheet.
  • Template placeholder chain: the two new placeholders __DOCUMENT_RENDERER_CSS_URL__ and __DOCUMENT_RENDERER_CSS_INTEGRITY__ are both in the residual-placeholder guard regex, so a dropped .replace() fails the build.
  • CSS <link> placement: <link> is after inline <style> in document-index.html, so the component sheet wins equal-specificity ties by document order.
  • Fail-closed path: three layers — head latch (capture-phase error listener set before the <link>, handles the Chromium parser-blocking window), body error listener (catches CSS failure after registration), document-main.tsx guard (data-render-complete !== 'error' prevents unstyled render). The latch is correctly record-only (no document.body access in <head>).
  • Delegation env vars: QWEN_EXPORT_RENDERER_CSS_INTEGRITY validation mirrors the JS integrity check; the "set both or neither" guard is extended to require all three together.
  • Packaging consistency: all-or-nothing copy in copy_bundle_assets.js matches the all-required semantics of verifyBundleArtifacts; the warning names the specifically-missing asset.
  • Filter cross-platform: TRANSCRIPT_CSS_ENTRY_FILTER uses [\\/] and the $ anchor on transcript\.js to avoid matching the barred web-shell/dist/index.js.

Cross-check vs existing reviews:
The qwen-code-ci-bot review at head 3b63662b filed 2 Criticals (forward-slash filter, error event not reaching the listener) and ~12 Suggestions. Both Criticals are fixed at current head 30d8989a — the filter now uses [\\/] and the head latch closes the parser-blocking window. qqqys dismissed their review at d54fcd0f confirming both Criticals were resolved. The author's follow-up commits (77537e46c5 and 30d8989a) addressed the remaining Suggestions (copy semantics, delegation docblock, test assertions). I confirmed each fix against the current code.

No existing finding at current head stands as an unresolved blocker.

Not covered: Windows runtime behavior (unit test pins both separators but runs on one OS) · real browser execution (integration tests cover it; I cannot run them).

Reviewed with AI assistance.

@qwen-code-dev-bot qwen-code-dev-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at head 30d8989a.

Required CI is green at this commit, and I confirmed the part that matters was actually executed rather than skipped: the web-shell E2E Smoke (ubuntu-latest, Node 22.x) job shows Run transcript document browser gate = success with --retry=0, so the real headless-Chromium gate ran the new stylesheet cases at this head. Test, Lint & Static and Integration Tests (no-AK) are green too; only review-pr is still running.

Both Criticals from the earlier REQUEST_CHANGES are fixed, each with the remedy the finding named:

  • The Windows path separator (R1-1). The filter is no longer a forward-slash-only literal buried in the build: it is TRANSCRIPT_CSS_ENTRY_FILTER = /web-shell[\\/]dist[\\/]transcript\.js$/ in transcript-css-entry.mjs, imported by build.mjs, and pinned by a test that asserts both /repo/packages/web-shell/dist/transcript.js and C:\\repo\\packages\\web-shell\\dist\\transcript.js match while dist/index.js does not — so the widened class cannot regress into lifting the wrong stylesheet, and the POSIX side the browser gate depends on is asserted as well. This is the cheap pin the finding asked for, and it runs wherever the scripts suite runs.
  • The stylesheet fail-closed race (R1-2). The listener now exists in <head> before the <link>, in capture phase, and only latches window.__transcriptStyleFailed — with a comment explaining that document.body does not exist yet at that point. The body script acts on the latch, and document-main.tsx refuses to mount when renderComplete === 'error', so a fast CSS failure can no longer produce an unstyled transcript stamped as complete. The new browser-gate case aborts the stylesheet request and asserts the same alert page a missing renderer produces, and the cascade is still proven by an oracle that only the split stylesheet can satisfy (the KaTeX font-family), with the allowed style requests pinned to exactly the one CSS URL.

The packaging side moved in lockstep, which is where this class of change usually breaks: copyBundleAssets copies the CSS only when both siblings exist and now names the missing paths in the warning, prepare-package's verifyBundleArtifacts requires the CSS so a published package cannot ship without it, writeDistPackageJson ships it, and create-standalone-package lists it beside the renderer as npm-package-only. The delegation contract validates all three env inputs together (IDENTITYINTEGRITYCSS_INTEGRITY, plus format checks), which closes the "one without the other always fails closed" hole. The budgets are re-ratcheted to the JS alone with the reasoning written down, including why the byte cap is the weaker of the two guards and why the shape-keyed throw is the only thing standing between a changed injectCssModules and a document that both links and injects 2.3 MB of CSS.

No new Critical found. Stated limits I share with the author: Test (windows-latest) never runs on a pull request, so the Windows build proof arrives with the merge queue or the nightly, and total transferred bytes are unchanged — this moves them off the parse/compile path.

@yiliang114
yiliang114 dismissed qwen-code-ci-bot’s stale review September 10, 2026 06:23

Stale CHANGES_REQUESTED, raised at 3b63662 on 2026-09-09T18:19Z; head is now 30d8989 (two further pushes: d54fcd0, 328feb4). Both round-1 Criticals were fixed with cited SHAs and measured evidence in the threads (R1-1 esbuild onLoad filter made path-separator agnostic, fixed in 726695d; R1-2 stylesheet fail-closed latch moved into before the , fixed in c57203f), and the bot's own round-2 re-review at d54fcd0 did not re-mint either finding. 15/15 review threads resolved; remaining R1-4/R1-5/R1-23 are Suggestion-level follow-ups explicitly carried, not silently dropped. CI green at 30d8989 and chiga0 APPROVED at that head. Dismissing to unblock.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

测试报告结论

结论:通过,改动已验证有效、可安全合入。

核心结论一句话:渲染器 JS(浏览器「下载+解析+编译」关键路径)从 4,136,297 B 降到 1,831,298 B(−56%),gzip 从 ~1.5 MB 降到 526 KB;抽出的 2,302,457 B CSS 变成独立、版本固定、SRI 校验、缓存一年的并行资产。导出文档在真实浏览器中完整渲染且样式表真实生效,缺失样式表时正确 fail-closed。

测试矩阵

测试组 结果
构建与体积测量(node src/export-html/build.mjs ✅ JS 1,831,298 B / CSS 2,302,457 B;JS 不再含 __qwenWebShellCss
单元测试(cli html.test.ts + export-transcript-document.test.ts ✅ 75 passed
打包脚本测试(package-assets.test.js + install-script.test.js ✅ 149 passed
集成浏览器门禁(chat-transcript-document.test.ts ✅ 6/6 passed
真实数据导出(4 条真实 ChatRecord + 无头 Chromium) ✅ 渲染成功,SRI 通过,script=1 style=1 other=0
静态检查(build/tsc/eslint/prettier) ✅ 通过
CI ✅ 全绿(唯一失败为 web-shell E2E 基础设施抖动,重试后 12m12s 通过)

关键断言(证明「不只是下载了、而是真用上了」)

  • 样式表 <link> 是唯一样式表请求,SRI 校验通过(link.sheet !== null
  • 组件 CSS 真正级联:渲染公式的 KaTeX font-family 只来自该样式表
  • 新增 fail-closed 用例:中止样式表请求 → 与缺失渲染器相同的「Unable to load this chat export」错误页
  • 无 CSP 违规、无多余网络请求

完整报告见 .qwen/e2e-tests/2026-09-09-split-export-transcript-css.md

Two both-side changes, resolved without rewriting history:

- packages/web-templates/src/export-html/build.mjs: main (#11372) raised the
  document-runtime budget to 4,200,000 / 4,300,000 for a combined JS+CSS
  measurement of 4,133,282 bytes at c3023b3. This branch lifts the ~2.3 MB
  component stylesheet out into export-transcript-document.css, so the JS-only
  budget stands (1,870,000 warning / 1,930,000 max against a measured
  1,833,894 bytes of JS) and the comment now records both measurements instead
  of silently dropping main's.
- scripts/copy_bundle_assets.js: main added the musl/glibc filter over the
  @opentui/core-* native libraries inside copyOpenTuiAssets; this branch made
  the HTML export renderer copy all-or-nothing over the JS and the new CSS in a
  different function. Three-way merge is clean, both changes kept.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@yiliang114
yiliang114 dismissed stale reviews from qwen-code-dev-bot and chiga0 via 5773c84 September 10, 2026 06:35
qqqys
qqqys previously approved these changes Sep 10, 2026

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review at head 30d8989a (GitHub does not let me approve my own PR). No blocking finding. One follow-up worth filing, and one earlier claim I want to retract before someone acts on it.

What I verified rather than assumed

  • The fail-closed path is genuine, single-latching and race-free. The <head> latch script is registered capture-phase before the <link> (document-index.html:25-43), the body script consumes window.__transcriptStyleFailed, and a second capture listener independently matches event.target.id === 'transcript-stylesheet' for a failure that settles later — so neither window can miss it. showLoadError early-returns on any existing data-render-complete, so it cannot latch twice. Script execution parser-blocks on the pending stylesheet, and the mount guard at document-main.tsx:340 then skips createRoot entirely.
  • Digest and artifact cannot diverge. build.mjs:361-363 hashes extractedTranscriptCss.css and build.mjs:423-426 writes that identical string; the JS path is symmetric. Both URLs derive from the one exportTranscriptRendererVersion, and delegation is all-three-or-none via the two XOR throws plus format validation.
  • Windows. TRANSCRIPT_CSS_ENTRY_FILTER = /web-shell[\\/]dist[\\/]transcript\.js$/ matches both separators, scripts/tests/transcript-css-entry-filter.test.js pins both plus the negative web-shell/dist/index.js, and build.mjs fails the build if the filter never fired. The onLoad callback compares file contents, not paths.
  • Packaging, all three shapes. npm tarball: copied all-or-nothing (copy_bundle_assets.js:584-594), required by prepare-package.js:82 (console.error + process.exit(1)), published via the files entry at :334 — a missing asset fails loudly at the release gate. Standalone: deliberately excluded via DIST_NPM_PACKAGE_ONLY_ENTRIES (create-standalone-package.js:119), tested at :380 before isAllowedDistEntry, identical to the pre-existing treatment of the JS. No third shape exists. The four script tests read the asset back and compare content, assert the files entry, assert the JS-present/CSS-absent branch names the missing path, and assert preparePackage throws through a mocked process.exit(1) — they assert the copy happened, not that code ran.

Retracting a claim about the renderComplete rAF guard

77537e46c5 broadened that guard's comment and 30d8989a33 removed the guard, which reads like a closeout commit deleting defence-in-depth. It is not: base (fbb877a4) has no guard either — git show fbb877a4:…/document-main.tsx line 245 is the same bare document.body.dataset.renderComplete = 'true'. git log -S"renderComplete !== 'error'" shows the string added by 3b63662b98 and removed by 30d8989a33, so the PR nets to base on that line. The guard that is load-bearing for this PR is the new mount-time one at document-main.tsx:340, which prevents createRoot at all when the stylesheet failure already latched 'error'. An async error landing after mount and being overwritten by the rAF is base behaviour, not a regression here. Filing that as a defect would have been wrong.

The one follow-up

Inline on build.mjs:360. Short version: .github/workflows/release-vscode-companion.yml:106-111 fetches export-transcript-document.js from unpkg and cmps it against the local build. This PR lifted ~2.3 MB of CSS out of that byte-compared artifact into a second published file whose SRI is baked from the local build, and nothing compares the published copy. That file is not in this PR's 18 changed files, so it is a follow-up rather than a change I would make here.

CI at 30d8989a: Test (ubuntu-latest, Node 22.x), Lint & Static, Integration Tests (no-AK, No Sandbox), web-shell E2E Smoke, Desktop Shell (both), TUI parity and the OpenTUI gate all pass; only review-pr is pending. The CHANGES_REQUESTED on this PR is the round-1 review at 3b63662b98, six commits behind head — the round-3 ledger at 328feb43f8 records findings: [], and all 15 inline threads are resolved.

中文说明

在 head 30d8989a 上的自审(GitHub 不允许我批准自己的 PR)。没有阻塞项。 有一条值得开后续,以及一条我要在别人据此行动之前撤回的断言。

我核实过而非假定的部分

  • 失败关闭路径是真实、单次锁存且无竞态的。 <head> 里的锁存脚本以捕获阶段注册在 <link> 之前document-index.html:25-43),body 脚本消费 window.__transcriptStyleFailed,另有第二个捕获监听器独立匹配 event.target.id === 'transcript-stylesheet' 以覆盖较晚才确定的失败——两个窗口都不会漏。showLoadError 在已有 data-render-complete 时提前返回,因此不会锁存两次。脚本执行会被待加载的样式表解析阻塞,随后 document-main.tsx:340 的挂载守卫直接跳过 createRoot
  • 摘要与产物不会分叉。 build.mjs:361-363extractedTranscriptCss.css 取哈希,build.mjs:423-426 写出的正是同一字符串;JS 路径对称。两个 URL 都派生自同一个 exportTranscriptRendererVersion,委托则通过两处 XOR 抛错加格式校验做到「三者全有或全无」。
  • Windows。 TRANSCRIPT_CSS_ENTRY_FILTER = /web-shell[\\/]dist[\\/]transcript\.js$/ 两种分隔符都匹配,scripts/tests/transcript-css-entry-filter.test.js 钉住了两种分隔符以及反例 web-shell/dist/index.js,且 build.mjs 在过滤器从未命中时让构建失败。onLoad 回调比较的是文件内容而非路径。
  • 打包,三种形态。 npm tarball:全有或全无地复制(copy_bundle_assets.js:584-594),被 prepare-package.js:82 强制要求console.error + process.exit(1)),并经 :334files 条目发布——缺资产会在发布门禁处大声失败。standalone:通过 DIST_NPM_PACKAGE_ONLY_ENTRIES 刻意排除(create-standalone-package.js:119),在 :380isAllowedDistEntry 之前被测到,与 JS 的既有处理一致。不存在第三种形态。四个脚本测试会读回资产并比对内容、断言 files 条目、断言「有 JS 缺 CSS」分支点名缺失路径、并断言 preparePackage 透过被 mock 的 process.exit(1) 抛错——它们断言的是复制确实发生,而不是代码跑过。

撤回一条关于 renderComplete rAF 守卫的断言

77537e46c5 拓宽了该守卫的注释,30d8989a33 删除了守卫,看起来像收尾提交删掉了纵深防御。并不是:base(fbb877a4)同样没有守卫——git show fbb877a4:…/document-main.tsx 第 245 行就是同一句裸的 document.body.dataset.renderComplete = 'true'git log -S"renderComplete !== 'error'" 显示该串由 3b63662b98 加入、由 30d8989a33 移除,所以本 PR 在这一行的净变化等于 base。对本 PR 真正承重的守卫是新增的挂载期那一个(document-main.tsx:340),它在样式表失败已锁存 'error' 时根本不允许 createRoot。挂载之后才到达的异步错误被 rAF 覆盖,是 base 行为,不是此处的回归。把它当作缺陷上报会是错的。

唯一的后续项

build.mjs:360 的内联评论。简版:.github/workflows/release-vscode-companion.yml:106-111 从 unpkg 取 export-transcript-document.js 与本地构建做 cmp。本 PR 把约 2.3 MB 的 CSS 从这个被逐字节比对的产物里拆出来,变成第二个已发布文件,而它的 SRI 是从本地构建烘焙的——却没有任何东西比对已发布的那一份。该文件不在本 PR 的 18 个改动文件里,所以属于后续项,不是我会在这里改的东西。

30d8989a 的 CI:Test (ubuntu-latest, Node 22.x)Lint & StaticIntegration Tests (no-AK, No Sandbox)web-shell E2E SmokeDesktop Shell(两个)、TUI parity 与 OpenTUI 门禁全部通过,只有 review-pr 待完成。本 PR 上的 CHANGES_REQUESTED 是 head 之前六个提交的 round-1 评审(3b63662b98)——328feb43f8 上的 round-3 账本记录 findings: [],且 15 条内联线程全部已解决。

@@ -301,6 +357,10 @@ const documentRendererUrl = `https://unpkg.com/@qwen-code/qwen-code@${exportTran
const documentRendererIntegrity =
rendererDelegateIntegrity ??
`sha384-${createHash('sha384').update(documentJs).digest('base64')}`;
const documentRendererCssUrl = `https://unpkg.com/@qwen-code/qwen-code@${exportTranscriptRendererVersion.split('+')[0]}/export-transcript-document.css`;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion — follow-up, outside this diff] Splitting the CSS out moved ~2.3 MB of published bytes out of the one artifact that gets byte-compared after release, and nothing took over the check.

.github/workflows/release-vscode-companion.yml:106-111 fetches https://unpkg.com/@qwen-code/qwen-code@${renderer_version}/export-transcript-document.js and cmps it against dist/export-transcript-document.js. At base that transitively verified the stylesheet too, because the CSS lived inside the compared JS. Now the CSS is published separately (prepare-package.js:334 files entry) while its integrity is baked here from the local build (:361-363, sha384 over extractedTranscriptCss.css) and written to dist/export-transcript-document.css at :423-426.

So if the published CSS ever differs from the CSS this build hashed — a stale dist/ from a previous build picked up by the copy step, a partial publish — every exported document for that version fails its <link integrity> check and fail-closes to the load-error alert for all readers, while the companion-release gate stays green. The failure mode is deliberately loud for the reader and deliberately silent for the release.

Two cheap options: add a second fetch + cmp for export-transcript-document.css in the same step, or compare the published file's sha384 against the baked __DOCUMENT_RENDERER_CSS_INTEGRITY__ value, which needs no local artifact. Not a change for this PR — the workflow file is not in its diff — but worth an issue so the split does not leave the new asset permanently unverified.

…-css

One both-side change: packages/web-templates/src/export-html/build.mjs. main's
#11421 added the import.meta guard (a new import plus the post-build
findUnexpectedImportMeta throw) while this branch added the transcript CSS
entry filter import at the same spot. Both imports are kept and the guard block
is untouched.

The budget constants stay at this branch's JS-only values (1,870,000 warning /
1,930,000 max against a measured 1,833,894 bytes of renderer JS), and the
comment now also records main's pre-split combined measurement of 4,133,282
bytes that #11372 raised them for, so that history is not lost by the split.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking findings.
Approval blockers: none.

Re-review (Round 2). Prior review at head 30d8989a / base fbb877a4 found no blockers and approved. That review was dismissed by the two merge-main commits (5773c846, d7c36a18). The PR's own code is byte-for-byte identical between the two review heads (confirmed via compare API: all 18 PR files show +0/-0 between 30d8989a and d7c36a18). No new review-surface exists; the prior analysis stands.

New base context (f6540d19 vs fbb877a4):
The only commit in the merged-main range that touches this PR's files is cb24374d (fix(export): restore document runtime budget headroom #11372). That PR raised the budget constants in main — the prior CI-bot Critical about sequencing conflict. This PR already resolved it: its build.mjs carries DOCUMENT_RUNTIME_WARNING_BYTES = 1_870_000 / MAX_DOCUMENT_RUNTIME_BYTES = 1_930_000, budgeting the post-split JS-only renderer. The merge-main commit is clean (build.mjs unchanged between 30d8989a and d7c36a18). ✓

Cross-check vs prior reviews (frozen findings above):

  • CI-bot R1-1 (forward-slash filter): fixed at 30d8989a ([\\/] separator class). Verified present at current head. ✓
  • CI-bot Critical (sequencing with #11372): #11372 landed on main, this PR merged it cleanly. ✓
  • qqqys (dismissed at d54fcd0f): confirmed both Criticals resolved. Consistent with current head. ✓
  • No new review content since my last submission.

Not covered: Windows runtime behavior (separator test is unit-level only); real browser execution (integration tests cover it; browser unavailable here).

Reviewed with AI assistance.

@yiliang114
yiliang114 added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 11e629b Sep 10, 2026
59 of 61 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.23.3.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(export): split the transcript renderer's embedded CSS into a separate versioned asset

6 participants