Skip to content

fix(web-shell): pin es2021 floor across both build configs and harden terminal tests - #11758

Merged
wenshao merged 1 commit into
mainfrom
codex/web-terminal-followup-es2021
Sep 13, 2026
Merged

wenshao merged 1 commit into
mainfrom
codex/web-terminal-followup-es2021

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

Follow-up to #11748 (merged at bc7a186) closing the five review findings that landed after its last head:

  • Gives the packages/web-shell lib build the same ES2021 floor as the app build. vite.lib.config.ts bundles and minifies the same xterm at Vite 5's default target, which miscompiles its logical assignments, so an npm host rendering TerminalPanel gets a terminal that throws on the first DECRQM query. The target is now a shared WEB_SHELL_BUILD_TARGET constant both configs import, so the two builds cannot drift apart.
  • Fixes the regression guard in scripts/tests/web-terminal-build.test.ts: it resolves the config through web-shell's own Vite 5 instead of the root-hoisted Vite 7 (whose default target never lowers logical assignments and would mask a dropped target), pins build.target === 'es2021' explicitly, and now covers both config files.
  • Strengthens the protocol-mismatch test: pins the localized restart notice copy (the mock dictionary's ?? key fallback rendered the raw key) and asserts no second release-only socket is opened, making the releaseRequested flag observable.
  • Replaces the hardcoded node-pty pin count in scripts/tests/package-assets.test.js with a non-empty guard; the count itself stays owned by conpty-host.test.ts.

Why it's needed

#11748 fixed the app-served terminal but left the npm-published lib bundle (dist/index.js, the package's main/module/exports["."]) broken by the same esbuild lowering bug, and left the new regression tests unable to catch the regressions they were written for.

Reviewer Test Plan

How to verify

  • npx vitest run --config ./scripts/tests/vitest.config.ts scripts/tests/web-terminal-build.test.ts scripts/tests/package-assets.test.js
  • cd packages/web-shell && npx vitest run client/components/terminal/TerminalPanel.test.tsx
  • Mutations: dropping target from vite.lib.config.ts turns the lib case of the build test red; removing releaseRequested = true turns the mismatch case red (toHaveLength(1) catches the second socket); misspelling the i18n key turns the notice assertion red via the ?? key fallback.

Evidence (Before & After)

N/A — test/build-hardening change, no user-visible UI delta.

Tested on

OS Status
🍏 macOS ⚠️ not tested
🪟 Windows ⚠️ not tested
🐧 Linux ✅ focused tests above (TerminalPanel 22/22, scripts 39 passed + 1 skipped)

Risk & Scope

  • The lib target bump also raises the syntax floor of dist/transcript.js, which is inlined into every /export html document — ES2021 matches the app bundle's existing floor.
  • No production behavior change beyond the lib bundle's minified output; the app build is untouched.

Linked Issues

Refs #11748, #11643.

… terminal tests

- Resolve the build-config probe through web-shell's own Vite 5 instead of
  the root-hoisted Vite 7, parameterize it over vite.config.ts and
  vite.lib.config.ts, and pin build.target itself (R1-1, R1-2).
- Share WEB_SHELL_BUILD_TARGET between the app and lib configs so the
  published lib bundle gets the same es2021 floor (R1-2).
- Pin the localized protocol-mismatch notice and the releaseRequested flag
  in the mismatch test (R1-3, R1-4).
- Drop the duplicated node-pty pin count owned by conpty-host.test.ts (R1-5).

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

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

Copy link
Copy Markdown
Collaborator

Qwen Triage finished — CI landed green on 02d8dc1 and the deferred approval was posted. finalize run

Qwen Triage 已完成 —— 02d8dc1 的 CI 全绿,延迟审批已提交。查看 finalize 运行

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the follow-up — I traced the whole chain rather than take the premise on faith, and it holds.

Template ✓ all required sections present. The only gap is the template's optional <details>中文说明</details> block, which isn't a heading requirement — non-blocking, add it if convenient.

Problem: observed, not theoretical. vite.lib.config.ts sets no build.target, so the lib build falls back to web-shell's own Vite default — and the two Vites here genuinely differ: package-lock.json resolves root node_modules/vite to 7.3.6 but packages/web-shell/node_modules/vite to 5.4.21. Vite 5's default is 'modules'/es2020, which makes esbuild lower xterm's logical assignments. I confirmed the rest of the chain too: xterm is absent from the lib rollupOptions.external list, and it is reachable from the published entry (client/index.tsxAppTerminalPanel@xterm/xterm, see App.tsx:197). So dist/index.js — the package's main/module/exports["."] — really does ship a minified, miscompiled terminal. #11748 (merged at bc7a186) fixed only the app config.

The masked-regression claim is also correct, and it's the more interesting half. The old probe imported bare vite, i.e. root-hoisted 7.3.6, whose default target never lowers logical assignments — so deleting target from either config would have kept that test green while the Vite 5 build shipped broken output. config.build.target || 'esnext' was dead for the same reason, since resolveConfig always fills a default. Resolving through web-shell's own Vite plus pinning toBe('es2021') closes both holes.

Direction: aligned. This is a defect in an already-published public entry point, and the follow-up scope matches what #11748 left behind. The one judgment call a human may want to confirm: the shared constant also raises dist/transcript.js's syntax floor from es2020 to es2021, and that file is inlined into every /export html document. You disclose it under Risk & Scope, it matches the app bundle's existing floor, and transcript.ts doesn't bundle xterm so it was never affected by the miscompile — so this is a floor alignment, not a fix the transcript entry needs on its own. Given the delta is one syntax level and the affected browsers are already broken by the lowering bug, I'm comfortable, but it is the only part of this PR that changes what an external consumer receives.

Size: not applicable. No core paths — the two configs sit at the package root rather than under src/config/, and only one packages/* is touched. 14 production lines (vite.config.ts 8, vite.lib.config.ts 6) against 80 test lines. Title is fix, so no Stage 0 tier applies.

Approach: scope feels right, and it reuses an existing convention instead of inventing one — vite.config.ts already exports shared constants (BRAND_ROUTE_PROXY, QUALIFIED_ACP_WS_PROXY, QUALIFIED_VOICE_STREAM_PROXY) that client/vite-config.test.ts imports, so WEB_SHELL_BUILD_TARGET is the same shape and no new module is needed. I checked the new cross-import for side effects: vite.config.ts has none at module scope (daemonProxy is a plain object literal and plugins: [react(), tailwindcss()] lives inside the factory), and it doesn't import the lib config back, so there's no cycle. One thing worth a thought, not a blocker: pulling the whole app config into the lib config's module graph means any top-level side effect someone later adds to vite.config.ts will now also run during lib builds. A one-line build-target.ts both configs import would avoid that coupling — but it's safe as written today and the current form keeps the two floors physically incapable of drifting, which is the point.

The package-assets.test.js change is an improvement rather than a weakening. I verified conpty-host.test.ts really does own the count (packages/core/src/services/conpty-host.test.ts:67, toHaveLength(6), with a comment explaining it as a deliberate human re-check tripwire on native semantics). The dropped assertion was firing during fixture construction, before preparePackage — the actual subject — ever ran, so adding a node-pty platform would have failed an unrelated asset test. toBeGreaterThan(0) keeps the only property that mattered there: toEqual(pins) can't pass vacuously on an empty object.

The two new TerminalPanel assertions are load-bearing, not decoration. FakeWebSocket.instances is reset in beforeEach (line 130), so toHaveLength(1) is correctly scoped, and it does catch the regression it claims: release() early-returns on releaseRequested (TerminalPanel.tsx:388), so without the flag set by the mismatch branch the connect(true) path would open a second release-only socket. The pinned notice copy also matches production verbatim — i18n.tsx:1472 reads exactly 'Terminal protocol changed; restart the daemon and reload this page.' — so the mock isn't asserting against invented text.

Risk: no elevated risk signals — none of the five files match the high-risk path patterns. Review depth: normal.

Moving on to code review. 🔍

中文说明

感谢这个后续 PR——我没有直接采信前提,而是把整条链路都验证了一遍,结论是成立的。

模板 ✓ 必需章节齐全。唯一缺少的是模板里可选的 <details>中文说明</details> 块,它不属于标题要求——不阻塞,方便的话可以补上。

问题: 是已观测到的缺陷,不是理论性加固。vite.lib.config.ts 没有设置 build.target,因此 lib 构建会回落到 web-shell 自己的 Vite 默认值——而这里两个 Vite 版本确实不同:package-lock.json 中根 node_modules/vite 解析为 7.3.6,packages/web-shell/node_modules/vite 为 5.4.21。Vite 5 的默认值是 'modules'/es2020,会让 esbuild 降级 xterm 的逻辑赋值运算符。链路其余部分我也确认了:xterm 不在 lib 的 rollupOptions.external 列表里,并且能从发布入口到达(client/index.tsxAppTerminalPanel@xterm/xterm,见 App.tsx:197)。所以 dist/index.js——即该包的 main/module/exports["."]——确实发布了经过压缩且被错误编译的终端。#11748(合并于 bc7a186)只修了 app 配置。

回归测试被掩盖这一点也是对的,而且是更有意思的一半。旧的探测代码 import 的是裸 vite,也就是根目录提升的 7.3.6,它的默认 target 从不降级逻辑赋值运算符——所以从任一配置里删掉 target,那个测试依然会是绿的,而 Vite 5 构建却产出了坏掉的产物。config.build.target || 'esnext' 出于同样原因是死代码,因为 resolveConfig 总会填充默认值。改为通过 web-shell 自己的 Vite 解析、并显式断言 toBe('es2021'),把这两个漏洞都堵上了。

方向: 一致。这是已发布公共入口上的缺陷,后续范围与 #11748 遗留的部分相符。唯一需要人来确认的判断点:共享常量同时把 dist/transcript.js 的语法下限从 es2020 抬到 es2021,而该文件会被内联进每个 /export html 文档。你在 Risk & Scope 中已披露,它与 app 产物现有下限一致,且 transcript.ts 并不打包 xterm,本来就不受该错误编译影响——所以这是一次下限对齐,而不是 transcript 入口自身需要的修复。考虑到只差一个语法级别、且受影响的浏览器本来就已经被降级 bug 打坏,我可以接受,但这确实是本 PR 中唯一改变外部消费者所获内容的部分。

规模: 不适用。没有触及核心路径——两个配置文件位于包根目录而非 src/config/ 下,且只涉及一个 packages/*。生产代码 14 行(vite.config.ts 8 行、vite.lib.config.ts 6 行),测试 80 行。标题是 fix,因此 Stage 0 两档规则都不适用。

方案: 范围合理,而且复用了既有约定而非新造一个——vite.config.ts 本来就导出共享常量(BRAND_ROUTE_PROXYQUALIFIED_ACP_WS_PROXYQUALIFIED_VOICE_STREAM_PROXY)供 client/vite-config.test.ts import,所以 WEB_SHELL_BUILD_TARGET 是同一形态,不需要新增模块。我也检查了新的跨文件 import 是否有副作用:vite.config.ts 在模块作用域没有副作用(daemonProxy 是普通对象字面量,plugins: [react(), tailwindcss()] 位于工厂函数内部),且它不会反向 import lib 配置,因此不存在循环引用。有一点值得考虑但不阻塞:把整个 app 配置拉进 lib 配置的模块图,意味着以后任何人往 vite.config.ts 顶层加的副作用都会在 lib 构建时一并执行。一个两个配置都 import 的单行 build-target.ts 可以避免这种耦合——但按现状是安全的,而且当前形态让两处下限物理上无法漂移,这正是目的所在。

package-assets.test.js 的改动是改进而非削弱。我确认了 conpty-host.test.ts 确实拥有这个计数(packages/core/src/services/conpty-host.test.ts:67toHaveLength(6),并有注释说明它是针对原生语义的人工复核触发器)。被删掉的断言原本在 fixture 构造阶段就会触发,早于真正的被测对象 preparePackage 运行,所以新增一个 node-pty 平台会让一个无关的资源测试失败。toBeGreaterThan(0) 保留了那里唯一重要的性质:toEqual(pins) 不会在空对象上空过。

TerminalPanel 新增的两个断言是有效的,不是装饰。FakeWebSocket.instancesbeforeEach(第 130 行)中被重置,所以 toHaveLength(1) 的作用域是正确的,而且确实能捕获它声称的回归:release()releaseRequested 上提前返回(TerminalPanel.tsx:388),因此若协议不匹配分支没有设置该标志,connect(true) 路径就会开出第二个仅用于 release 的 socket。被固定下来的提示文案也与生产代码逐字一致——i18n.tsx:1472 正是 'Terminal protocol changed; restart the daemon and reload this page.'——所以 mock 断言的不是凭空编造的文本。

风险: 无升级风险信号——五个文件都不匹配高风险路径模式。审查深度:常规。

进入代码审查 🔍

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

Reviewed at 02d8dc11d822846d9f916fbad259da1fa56dae25 · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

Reading only the title and the "Why it's needed" section, here is what I would have done: give the lib config the same explicit build.target as the app config and hoist the literal into one shared export so the two cannot drift; make the regression test resolve the config through the same Vite the build actually uses, because a test that resolves a different major is measuring the wrong default; assert the target literally rather than relying only on the behavioural probe, since resolveConfig always substitutes a default and would mask a deletion; and drop the duplicated pin count from the asset test, letting it live wherever the native-semantics tripwire belongs.

That is what this PR does, point for point. I did not find a simpler path it missed, and I found no correctness bug, security hole, regression, or AGENTS.md violation. The four substantive points I checked independently rather than on trust:

  • The masking was real, not hypothetical. package-lock.json resolves root node_modules/vite to 7.3.6 and packages/web-shell/node_modules/vite to 5.4.21, so the old bare import { resolveConfig } from 'vite' in a scripts/tests/ file really did bind to the wrong major. Its default target never lowers logical assignments, so deleting target from either config left the probe green while the Vite 5 build shipped broken output. config.build.target || 'esnext' was dead code for the same reason. Both holes are now closed — the explicit toBe('es2021') is what makes the test robust even if the resolution ever changes again.
  • The lib bundle does contain xterm. This was the load-bearing premise and it checks out end to end: xterm is absent from the lib rollupOptions.external list, and it is reachable from the published entry via client/index.tsxAppTerminalPanel (App.tsx:197) → @xterm/xterm. dist/index.js is the package's main/module/exports["."], so npm hosts were receiving the miscompiled terminal.
  • The new cross-config import is safe. vite.lib.config.ts importing ./vite.config pulls the app config into the lib config's module graph, so I checked it for side effects: there are none at module scope — daemonProxy is a plain object literal, the three proxy constants are regex strings, plugins: [react(), tailwindcss()] lives inside the factory, and vite.config.ts does not import the lib config back, so there is no cycle. Exporting a shared constant from this file is also the established pattern here (BRAND_ROUTE_PROXY, QUALIFIED_ACP_WS_PROXY, QUALIFIED_VOICE_STREAM_PROXY, consumed by client/vite-config.test.ts), so this reuses a convention instead of adding a new module.
  • The new assertions are load-bearing, and the weakened one is not a loss. FakeWebSocket.instances is reset in beforeEach (line 130), so toHaveLength(1) is correctly scoped, and it genuinely pins the releaseRequested guard: release() early-returns on that flag (TerminalPanel.tsx:388), so without it the connect(true) branch would open a second release-only socket. The notice copy in the mock matches production verbatim — i18n.tsx:1472 is exactly 'Terminal protocol changed; restart the daemon and reload this page.' — so it is not asserting against invented text. On the asset test, conpty-host.test.ts does own the count as claimed (packages/core/src/services/conpty-host.test.ts:67, toHaveLength(6), documented as a deliberate human re-check tripwire); the assertion removed here fired during fixture construction, before preparePackage — the actual subject — ever ran, so a new node-pty platform would have failed an unrelated test. toBeGreaterThan(0) keeps the only property that mattered: toEqual(pins) cannot pass vacuously on an empty object.

Two non-blocking observations, neither of which I'd hold the PR for:

  1. build.target is mode-independent, so the shared constant also raises the transcript mode's floor even though transcript.ts never bundles xterm and was therefore never affected by the miscompile. You disclose this under Risk & Scope and it aligns with the app floor, so I read it as a deliberate floor alignment rather than an unnoticed side effect — flagging it only so a maintainer can confirm the es2020 → es2021 move on /export html documents is intended, since that is the one part of this diff that changes what an external consumer receives.
  2. The comment in the build test describes "three builds" while describe.each resolves two config files. That is correct as written — build.target does not vary by mode, so one resolution of vite.lib.config.ts covers both lib invocations — but a reader could momentarily wonder whether transcript mode went untested. Optional to tighten.

I skipped the sequence diagram and the changed-files table here: this is a five-file, 94-line config-and-test change with no new runtime flow, and the findings above already name every file. A table would just be noise.

Test evidence

This is an unattended CI run, so per the gate rules I did not build, run, or execute anything from this PR — no npm, no vitest, no vite build. The evidence below is the PR's own CI, read through the API for the reviewed commit 02d8dc11d822846d9f916fbad259da1fa56dae25.

Final CI results for 02d8dc1 (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Capture web-shell visuals (ubuntu-latest, Node 22.x) ✅ success
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

Reading the table:

  • The decisive job has not finished. Test (ubuntu-latest, Node 22.x) is the one that runs both changed test suites — ci.yml has it invoke npm run test:ci:workspaces (which covers packages/web-shell's TerminalPanel.test.tsx) followed by npm run test:scripts, and scripts/tests/vitest.config.ts includes scripts/tests/**/*.test.{js,ts}, so both web-terminal-build.test.ts and package-assets.test.js are in scope. It was still in_progress when I fetched. I did not poll or sleep-wait on it — the unit suite runs far longer than any in-agent wait — so the new describe.each probe and the toBe('es2021') pin are not verified: the job that runs them had not reported yet. The finalize workflow rewrites the table above once CI settles.
  • The two skips are by design, not caused by this PR. test_macos in ci.yml is gated to github.event_name == 'merge_group' || 'schedule' || 'workflow_dispatch', so it never runs on a pull_request event; the Windows job is shaped the same way. Nothing here suggests a platform gap the PR introduced. The changed tests are platform-independent anyway — an esbuild transform plus jsdom, and scripts/tests/vitest.config.ts only excludes bash-driven suites on win32, not these.
  • What is already green is real but adjacent. Lint & Static, Integration Tests (no-AK, No Sandbox), and both Desktop Shell jobs passed, which tells us the diff is lint-clean and type-clean and did not disturb the desktop shell. None of them executes the changed test files, so they are not evidence for the fix itself.
  • No check is red, so there is no failing-job log excerpt to quote. triage and review-pr also appear on this commit but are bot orchestration (pull_request_target), not PR CI, and carry no bearing on the verdict.
  • The PR body reports "TerminalPanel 22/22, scripts 39 passed + 1 skipped" on Linux, with macOS and Windows marked not tested. That is the author's own claim, not evidence — I did not re-run it and am not presenting it as a result.

Sandboxed verification would settle the one claim this diff cannot prove statically: @qwen-code /verify — the probe transforms xterm.mjs through esbuild at the resolved target and asserts DECRQM, which substantiates the mechanism, but nothing here builds dist/index.js through Vite 5.4.21 and runs a query against the shipped artifact. An A/B of the lib bundle at base versus this head would confirm the published entry actually stops throwing, rather than inferring it from the config value. The author has write access, so this can be triggered directly; /tmux is the wrong lane here since the surface is a browser bundle, not the CLI TUI.

Not verified: the real-scenario tmux capture. This is an unattended CI run, where the gate forbids executing PR-derived code, so the live-behaviour signal comes from the /verify lane named above rather than from anything I drove myself.

中文说明

代码审查

只看标题和 "Why it's needed" 部分,我会这么做:给 lib 配置补上与 app 配置相同的显式 build.target,并把这个字面量提到一个共享导出里,让两处无法漂移;让回归测试通过构建实际使用的同一个 Vite 来解析配置,因为解析到另一个大版本的测试衡量的是错误的默认值;显式断言 target 本身,而不只依赖行为探测,因为 resolveConfig 总会填入默认值、从而掩盖删除;以及从资源测试里去掉重复的 pin 计数,让它留在原生语义触发器该在的地方。

这个 PR 正是这么做的,逐点吻合。我没有找到它遗漏的更简路径,也没有发现正确性缺陷、安全漏洞、回归或违反 AGENTS.md 之处。有四个关键点我是独立核实的,而非采信:

  • 测试被掩盖是真事,不是假设。 package-lock.json 中根 node_modules/vite 解析为 7.3.6,packages/web-shell/node_modules/vite 为 5.4.21,所以 scripts/tests/ 里旧的裸 import { resolveConfig } from 'vite' 确实绑定到了错误的大版本。它的默认 target 从不降级逻辑赋值运算符,因此从任一配置删掉 target,探测依然是绿的,而 Vite 5 构建却产出了坏产物。config.build.target || 'esnext' 出于同样原因是死代码。两个漏洞现在都堵上了——显式的 toBe('es2021') 正是让测试即使将来解析方式再变也依然可靠的关键。
  • lib 产物确实包含 xterm。 这是承重前提,端到端成立:xterm 不在 lib 的 rollupOptions.external 列表里,且能从发布入口到达——client/index.tsxAppTerminalPanelApp.tsx:197)→ @xterm/xtermdist/index.js 就是该包的 main/module/exports["."],所以 npm 宿主拿到的是被错误编译的终端。
  • 新的跨配置 import 是安全的。 vite.lib.config.ts import ./vite.config 会把 app 配置拉进 lib 配置的模块图,所以我检查了副作用:模块作用域没有——daemonProxy 是普通对象字面量,三个代理常量是正则字符串,plugins: [react(), tailwindcss()] 位于工厂函数内部,且 vite.config.ts 不会反向 import lib 配置,因此不存在循环。从该文件导出共享常量也是这里既有的约定(BRAND_ROUTE_PROXYQUALIFIED_ACP_WS_PROXYQUALIFIED_VOICE_STREAM_PROXY,由 client/vite-config.test.ts 消费),所以这是复用约定而非新增模块。
  • 新断言是承重的,被削弱的那个也不是损失。 FakeWebSocket.instancesbeforeEach(第 130 行)中重置,所以 toHaveLength(1) 作用域正确,并且确实固定住了 releaseRequested 守卫:release() 在该标志上提前返回(TerminalPanel.tsx:388),若没有它,connect(true) 分支就会开出第二个仅用于 release 的 socket。mock 里的提示文案与生产代码逐字一致——i18n.tsx:1472 正是 'Terminal protocol changed; restart the daemon and reload this page.'——所以断言的不是凭空编造的文本。资源测试方面,conpty-host.test.ts 确实如所述拥有该计数(packages/core/src/services/conpty-host.test.ts:67toHaveLength(6),注释说明它是刻意的人工复核触发器);这里删掉的断言原本在 fixture 构造阶段就触发,早于真正的被测对象 preparePackage 运行,所以新增一个 node-pty 平台会让一个无关测试失败。toBeGreaterThan(0) 保留了唯一重要的性质:toEqual(pins) 不会在空对象上空过。

两点不阻塞的观察,我都不会为此卡住这个 PR:

  1. build.target 与 mode 无关,所以共享常量也抬高了 transcript mode 的下限,尽管 transcript.ts 从不打包 xterm、本来就不受该错误编译影响。你在 Risk & Scope 中已披露,且它与 app 下限一致,所以我把这读作刻意的下限对齐而非未察觉的副作用——提出来只是便于维护者确认 /export html 文档上 es2020 → es2021 这一变动是有意的,因为这是本 diff 中唯一改变外部消费者所获内容的部分。
  2. 构建测试的注释写了 "three builds",而 describe.each 解析的是两个配置文件。按现状这是正确的——build.target 不随 mode 变化,所以解析一次 vite.lib.config.ts 就覆盖了两次 lib 调用——但读者可能会一时怀疑 transcript mode 没被测到。是否收紧可选。

这里我省略了时序图和变更文件表:这是一个五文件、94 行的配置与测试改动,没有新的运行时流程,且上面的发现已逐一点名每个文件。加个表只会是噪音。

测试证据

这是无人值守的 CI 运行,所以按 gate 规则我没有构建、运行或执行本 PR 的任何东西——没有 npm、没有 vitest、没有 vite build。下面的证据来自 PR 自身的 CI,通过 API 读取被审查的 commit 02d8dc11d822846d9f916fbad259da1fa56dae25

(CI 表格见上方英文部分,由 finalize 任务在 CI 结束后原地更新。)

表格解读:

  • 决定性的那个 job 还没跑完。 Test (ubuntu-latest, Node 22.x) 正是运行两个被改测试套件的那个——ci.yml 让它先执行 npm run test:ci:workspaces(覆盖 packages/web-shellTerminalPanel.test.tsx),再执行 npm run test:scripts,而 scripts/tests/vitest.config.ts 的 include 是 scripts/tests/**/*.test.{js,ts},所以 web-terminal-build.test.tspackage-assets.test.js 都在范围内。我抓取时它仍是 in_progress。我没有轮询或 sleep 等待——单元测试套件的耗时远超任何 agent 内等待预算——因此新的 describe.each 探测和 toBe('es2021') 断言未经验证:运行它们的 job 当时还没给出结果。 CI 结束后 finalize 任务会更新上面的表格。
  • 两个 skip 是设计使然,不是本 PR 造成的。 ci.ymltest_macos 的条件被限定为 github.event_name == 'merge_group' || 'schedule' || 'workflow_dispatch',所以在 pull_request 事件上永不运行;Windows job 形态相同。这里没有任何迹象表明 PR 引入了平台缺口。何况被改的测试本身与平台无关——esbuild transform 加 jsdom,而 scripts/tests/vitest.config.tswin32 上只排除 bash 驱动的套件,不包括这两个。
  • 已经变绿的部分是真实的,但只是旁证。 Lint & StaticIntegration Tests (no-AK, No Sandbox) 和两个 Desktop Shell job 通过了,说明 diff 在 lint 与类型上是干净的、也没有扰动 desktop shell。但它们都不执行被改的测试文件,所以不能作为修复本身的证据。
  • 没有 check 变红,因此没有失败 job 的日志摘录可引用。该 commit 上还有 triagereview-pr,但它们是 bot 编排(pull_request_target),不是 PR CI,对结论没有影响。
  • PR 正文报告 Linux 上 "TerminalPanel 22/22, scripts 39 passed + 1 skipped",macOS 与 Windows 标记为未测试。这是作者自己的声明,不是证据——我没有重跑,也不把它当作结果呈现。

沙箱验证可以了结本 diff 无法静态证明的那一条主张:@qwen-code /verify——探测代码是用 esbuild 以解析出的 target 转换 xterm.mjs 并断言 DECRQM,这证实的是机制,但这里没有任何东西用 Vite 5.4.21 构建出 dist/index.js 并对实际产物发起查询。对 lib 产物做 base 与本 head 的 A/B,才能确认发布入口真的不再抛错,而不是从配置值推断出来。作者有写权限,可以直接触发;这里 /tmux 不是合适的通道,因为涉及的是浏览器产物而非 CLI TUI。

未验证:真实场景 tmux 抓取。这是无人值守 CI 运行,gate 禁止执行 PR 派生代码,所以实时行为信号来自上面点名的 /verify 通道,而不是我自己驱动的任何东西。

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

Reviewed at 02d8dc11d822846d9f916fbad259da1fa56dae25 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head 02d8dc1. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

No screenshot changes against the PR base.

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — every claim in this PR survived independent checking and I found no blocker; the two reservations are small and named below, and the job that actually runs the changed tests had not reported yet.

Going back to the proposal I wrote before comparing it to the diff: this PR matches it point for point, and I did not find a simpler path it missed. That is not the usual shape of a follow-up PR — most carry some extra refactor or a half-finished idea alongside the stated fix. This one is 14 production lines and 80 test lines, all four changes map onto a defect I could confirm independently, and there is no formatting churn or drive-by edit anywhere in it.

What convinced me is that the premise was checkable rather than merely plausible. I could have taken "the lib bundle is broken by the same lowering bug" on faith; instead the chain resolves cleanly from the lockfile (root Vite 7.3.6 versus web-shell's 5.4.21 — a real divergence, not a hypothetical one) through the lib config's external list (xterm absent) to import reachability (index.tsxAppTerminalPanel@xterm/xterm) to the package manifest (dist/index.js is main/module/exports["."]). Each link holds. The same goes for the more interesting half: the old regression test genuinely could not fail when the thing it guarded was deleted, because it resolved the wrong Vite major and resolveConfig always fills a default. A test that cannot catch its own regression is worse than no test, since it spends review attention buying nothing.

I pushed back on myself about whether the fix is worth its surface, since the population that hits the broken lib terminal is external npm hosts rather than the daemon-served app #11748 already fixed. It is, and not because of that population: the value that does not depend on consumer count is the repaired guard, which is what keeps #11643 from silently returning the next time someone touches either config.

On maintenance six months out, I would thank the author rather than curse them. The comments earn their length — the Vite-version divergence, the reason the target must be pinned explicitly, and the fact that the pin count is owned by conpty-host.test.ts are all traps that a future reader would otherwise walk straight back into. Without that last note in particular, someone would "restore" the magic 6 in the asset test as a matter of tidiness.

The two things keeping this at 4 rather than 5, both non-blocking:

  • The shared constant also raises dist/transcript.js from es2020 to es2021, and that file is inlined into every /export html document. transcript.ts never bundles xterm, so this entry did not need the fix on its own — it is a floor alignment, disclosed under Risk & Scope and consistent with the app bundle. It is still the one line in the diff that changes what an external consumer receives, so it deserves a human's explicit yes rather than mine by inference.
  • The decisive CI job had not finished when I reviewed. Test (ubuntu-latest, Node 22.x) is what runs both changed suites, and it was in_progress. Everything already green — lint, static, integration, desktop shell — is real but adjacent: none of it executes the new probe. So the fix is verified by reading and by the lockfile, not yet by a completed run.

Verdict is approve, but I am not posting an approval in this run: CI is still in flight on the reviewed commit, and approving now would attest to a result that does not exist yet. Approval is deferred until CI lands green on 02d8dc11d822846d9f916fbad259da1fa56dae25; the finalize workflow posts the commit-pinned approval at that point, and withholds it if anything lands red or the head moves. The guardrail check is clean — this is a same-repository branch, not a fork, and the title is fix, not refactor — and no core path is touched, so nothing caps the score or forces escalation.

中文说明

信心:4/5 —— 本 PR 的每一项主张都经受了独立核查,我没有发现阻塞问题;两点保留意见都很小且已在下面点名,另外真正运行被改测试的那个 job 当时还没给出结果。

回到我在对比 diff 之前写下的方案:这个 PR 与之逐点吻合,我没有找到它遗漏的更简路径。这并不是后续 PR 常见的形态——多数都会在既定修复之外夹带一些额外重构或半成品想法。而这个 PR 是 14 行生产代码加 80 行测试,四处改动都能对应到一个我可以独立确认的缺陷,全程没有格式化噪音或顺手改动。

说服我的是:这个前提是可核查的,而不只是听起来合理。我本可以采信"lib 产物被同一个降级 bug 打坏了";但整条链路从 lockfile(根 Vite 7.3.6 对 web-shell 的 5.4.21——是真实的版本分叉,不是假设)经 lib 配置的 external 列表(xterm 不在其中)、到 import 可达性(index.tsxAppTerminalPanel@xterm/xterm)、再到包清单(dist/index.jsmain/module/exports["."])都能干净地解出来。每一环都成立。更有意思的那一半同样如此:旧的回归测试在其守护对象被删除时确实不会失败,因为它解析到了错误的大版本,而 resolveConfig 总会填入默认值。一个无法捕获自身回归的测试比没有测试更糟,因为它消耗了审查注意力却什么也没换来。

我也对自己反驳过:既然踩到坏掉的 lib 终端的是外部 npm 宿主、而非 #11748 已修好的 daemon 服务页面,这个修复是否值得它带来的改动面。答案是值得,而且理由不在那部分人群:不依赖消费者数量的价值在于被修好的守卫本身——下次有人改动任一配置时,正是它防止 #11643 悄悄复发。

从六个月后的维护角度看,我会感谢作者而不是埋怨。这些注释对得起它们的篇幅——Vite 版本分叉、为什么必须显式固定 target、以及 pin 计数conpty-host.test.ts 所有,都是未来读者会径直踩回去的坑。尤其最后那条注释若不存在,就会有人出于整洁把资源测试里那个魔数 6 "恢复"回来。

让它停在 4 分而非 5 分的两点,均不阻塞:

  • 共享常量也把 dist/transcript.js 从 es2020 抬到了 es2021,而该文件会被内联进每个 /export html 文档。transcript.ts 从不打包 xterm,所以这个入口本身并不需要该修复——这是一次下限对齐,已在 Risk & Scope 中披露,且与 app 产物一致。但它仍是 diff 中唯一改变外部消费者所获内容的一行,所以值得一个人明确点头,而不该由我推断代替。
  • 决定性的 CI job 在我审查时还没跑完。Test (ubuntu-latest, Node 22.x) 才是运行两个被改套件的那个,当时状态是 in_progress。已经变绿的部分——lint、静态检查、集成测试、desktop shell——是真实的,但只是旁证:没有一个执行了新的探测代码。所以这个修复目前是靠阅读和 lockfile 验证的,还不是靠一次已完成的运行。

结论是 approve,但本次运行我不会提交批准:CI 在被审查的 commit 上仍在进行,现在批准等于为一个尚不存在的结果背书。批准将延后至 CI 在 02d8dc11d822846d9f916fbad259da1fa56dae25 上全绿;届时 finalize 任务会提交与该 commit 绑定的批准,若有 check 变红或 head 发生移动则会撤回。护栏检查是干净的——这是同仓库分支而非 fork,标题是 fix 而非 refactor——且未触及核心路径,因此没有任何规则压低分数或强制升级处理。

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

Reviewed at 02d8dc11d822846d9f916fbad259da1fa56dae25 · re-run with @qwen-code /triage

@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.

LGTM, looks ready to ship — CI landed green after the review. ✅

@qqqys

qqqys commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Independent verification report — measured A/B on the real build pipeline

结论 / TL;DR: 未发现 Critical。用真实的 xterm.mjs + 真实的 Vite 5 配置解析结果做了双臂 A/B:base(lib 配置未固定 target)在第一个 DECRQM 查询处抛 ReferenceError: i is not defined,PR 的 es2021 floor 下同一探针正常回复 \x1b[?2004;2$y 并继续输出 after-query。这个改动修的是已经随 npm 包发布的 lib bundle,不只是测试。

No Critical found. The fix is load-bearing and measured on real artifacts, not inferred from the diff.


Gate state, read immediately before posting (head 02d8dc11d822)

leg value
qwen-code-ci-bot review APPROVED @ head, 2026-09-13T07:04:05Z
product lanes at head (named, not a rollup) Test (ubuntu-latest, Node 22.x) ✅ · Lint & Static (ubuntu-latest, Node 22.x) ✅ · web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ · Integration Tests (no-AK, No Sandbox) ✅ · Capture web-shell visuals (ubuntu-latest, Node 22.x)
skipped by the route classifier Test (macos/windows), Integration Tests (CLI, No Sandbox), build-cli
check-runs at head 47 total = 15 success / 31 skipped / 1 in_progress (review-pr, a bot lane — not counted as a product lane)
qqqys rows before this comment reviews 0 · issue comments 0 · inline comments 0

Why this report has no tmux arm (declared, not omitted)

Both production files in this PR are browser-bundle build configurations (packages/web-shell/vite.config.ts, vite.lib.config.ts). Their only observable effect is the syntax floor of an emitted bundle that runs in a browser / webview, so no tmux TUI arm can reach the changed code — a tmux run would exercise the CLI's own app build and come back flat in both arms, which is the dangerous polarity (a flat A/B that reads as "no difference"). The substitute below drives the real compiler at the real resolved targets and the real emitted artifact, which is strictly more discriminating for this delta.

Arm A/B — real xterm.mjs, real resolved targets, both esbuild copies

Arm A's target is not a guess: it is what web-shell's own Vite 5.4.21 resolves for the base vite.lib.config.ts (which carries no target), read through resolveConfig: ["es2020","edge88","firefox78","chrome87","safari14"]. Arm B is the PR's WEB_SHELL_BUILD_TARGET = 'es2021'. Probe = the PR's own: _inputHandler.parse('\x1b[?2004$pafter-query') in a JSDOM context.

arm esbuild target ??=/||=/&&= surviving DECRQM probe
A (base) root 0.25.6 Vite 5 default (above) 0 / 0 / 0 (all lowered) PARSE_THREW: "i is not defined", replies [], line 0 empty
A (base) web-shell 0.21.5 same 0 / 0 / 0 ❌ identical throw, byte-identical 347,688-byte output
B (PR) root 0.25.6 es2021 2 / 17 / 2 preserved ✅ replies ["\u001b[?2004;2$y"], line 0 after-query
B (PR) web-shell 0.21.5 es2021 2 / 17 / 2 ✅ identical, byte-identical 344,620-byte output
C (masking control) root 0.25.6 root Vite 7 default ["chrome107","edge107","firefox104","safari16"] 2 / 17 / 2 passes without the pin

Throw site in arm A, from the stack: Za.requestModeArray.<anonymous>Ya.parse. The lowered code at that position, as emitted into a real bundle (arm A's own minified transform carries the same construct with a different mangled name, (void 0||(i={}))):

…_[_.PERMANENTLY_RESET = 4] = "PERMANENTLY_RESET"))(void 0 || (n = {}));
    let s = this._coreService.decPrivateModes, 

i.e. ??= was lowered into an IIFE argument whose assignment target is not in scope there, so the first mode query throws before any reply is emitted. Source xterm.mjs carries 2×??=, 17×||=, 2×&&=.

Arm C is why the test's webShellVite change matters. Resolving the config through the root-hoisted Vite 7 gives a default target that never lowers logical assignments, so the probe passes with the pin removed — a false-green. Resolving through web-shell's own Vite 5 (what this PR's test now does) is the only version of the probe that can fail. Independently confirmed, not taken from the comment.

The shipped artifact carries the same broken shape

The lib bundle built in this checkout from an unpinned vite.lib.config.ts (packages/web-shell/dist/index.js, 7,271,983 B) contains the identical construct at the identical site — (void 0 || (n = {})) immediately before let s = this._coreService.decPrivateModes — with 0 surviving logical-assignment operators anywhere in the file, and requestMode registered as the handler for CSI ? Pd $ p (DECRQM). So arm A is not a synthetic transform artefact: it is what npm hosts receive today. In-repo consumer: packages/vscode-ide-companion/src/webview/EmbeddedApp.tsx imports @qwen-code/web-shell.

What that artifact does and does not cover: the checkout it was built from (b5567bb7a9) also predates the app-build pin, so its assets/ carry the same broken shape — that half is not evidence about this PR, whose base already pins the app build (vite.config.ts:92). Only the lib half is cited, and the lib config is unpinned at both that checkout and this PR's base, which is the state the PR changes.

Coverage checks behind "no Critical"

  • Both production files read in full. vite.config.ts's change is behaviour-preserving (a literal replaced by a shared const of the same value); vite.lib.config.ts gains the floor. No circular import (vite.config.ts does not import the lib config) and no duplicate declaration of WEB_SHELL_BUILD_TARGET (1 declaration + 1 import + 1 use per file).
  • The strengthened test really runs in the green lane. scripts is a project in the root vitest.config.ts, and Test (ubuntu-latest, Node 22.x) runs npm run test:scriptsvitest run --config ./scripts/tests/vitest.config.ts. A test-efficacy argument would be fiction otherwise.
  • "Three builds" claim checks out. build = app + lib default + lib --mode transcript; build.target sits outside the mode branch, so describe.each over the two config files does cover all three vite invocations.
  • terminal.notice.protocolMismatch is a real key, not a mock invention. Present in both catalogues (client/i18n.tsx:1472 EN = 'Terminal protocol changed; restart the daemon and reload this page.', :5169 ZH) and consumed at client/components/terminal/TerminalPanel.tsx:310. The new stringContaining('Terminal protocol changed') assertion therefore pins production text verbatim rather than the mock's own ?? key fallback.
  • The relaxed pin count is a de-duplication, not a lost tripwire. scripts/tests/package-assets.test.js drops toHaveLength(6) for toBeGreaterThan(0), and packages/core/src/services/conpty-host.test.ts:64 does assert expect(Object.keys(corePins)).toHaveLength(6) — so the human re-check the comment names exists at head.
  • Scope note, not a defect: dist/transcript.js contains no xterm at all (0 markers), so the transcript build cannot be hit by this bug; the pin there is floor consistency, which is what its comment claims.

One non-blocking observation (no action requested)

The lib floor rises from es2020/edge88/firefox78/chrome87/safari14 to es2021, which narrows browser support for npm hosts. Measured against the alternative — a bundle that throws on the first mode query — this is a strict improvement, and the app build already carried the same floor at this PR's base (vite.config.ts:92), so the two builds now agree. Recording it only so the support delta is a decision rather than a side effect.


Scope of this comment, as of the repository state read immediately before posting: it is a verification report and carries no approval; the merge gate on this PR is unchanged by it. Arm coverage is exactly what is declared above — the compiler/artifact A/B on the terminal DECRQM path and the enumerated file/test checks. It does not cover the rest of the lib bundle's runtime behaviour, the VS Code webview mount, or a browser-driven render.

@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. Suggestions are inline.

Not explored to full depth (tool budget reached): "agent 6b": verifying the new comment's claim that "both esbuild copies miscompile identically only at the Vite 5 default" — I did not compile xterm.mjs at Vite 5's defau…; "agent reverse-audit (round 1)": I did not run the real vite build --config vite.lib.config.ts and grep the emitted dist/transcript.js / dist/index.js for un-lowered ??= — it is a multi….

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

Test Plan (not a blocker): 39 passed — this review observed 7669, 31059, 587 passed.

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

Comment on lines +70 to +71
'terminal.notice.protocolMismatch':
'Terminal protocol changed; restart the daemon and reload this page.',

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.

[Suggestion] R1-1: This pins the restart notice against a copy the test supplies to itself. vi.mock('../../i18n') at line 62 replaces the whole module, so the new assertion can catch a wrong key at the call site but says nothing about whether the real catalogs still carry the string — while the comment above it calls the notice "a named deliverable".

Delete or rename terminal.notice.protocolMismatch in client/i18n.tsx (EN at :1472, ZH at :5169), or lose it in a merge, and this suite stays green. Nothing else catches it either: npm run check-i18n is --workspace=packages/cli, and client/i18n.test.ts pins only two settings.* keys. The shipped terminal then writes the literal text terminal.notice.protocolMismatch into the pane at exactly the moment the user is being told to restart the daemon and reload.

Witness:

grep -rn 'terminal\.notice\.protocolMismatch'  -> 4 hits, none a catalog assertion
  client/i18n.tsx:1472            (EN catalog)
  client/i18n.tsx:5169            (ZH catalog)
  TerminalPanel.test.tsx:70       (the vi.mock dictionary)
  TerminalPanel.tsx:310           (the call site)

client/i18n.test.ts -> 1 test; FOLLOWUP_SETTING_KEYS pins only
  settings.label.ui.enableFollowupSuggestions
  settings.description.ui.enableFollowupSuggestions

driven through the real module:
  en:    protocolMismatch -> "Terminal protocol changed; restart the daemon and reload this page."
  en:    absent key       -> "terminal.notice.thisKeyDoesNotExist"
  zh-CN: absent key       -> "terminal.notice.thisKeyDoesNotExist"

TerminalPanel.test.tsx -> 22 passed, with no assertion touching i18n.tsx

Keep this assertion — the mock's ?? key fallback does make a wrong call-site key observable, which is real coverage. Add the missing half where the repo already pins this class, in client/i18n.test.ts alongside FOLLOWUP_SETTING_KEYS:

const TERMINAL_NOTICE_KEYS = ['terminal.notice.protocolMismatch'] as const;

it.each(TERMINAL_NOTICE_KEYS)('pins the terminal notice %s', (key) => {
  expect(getTranslator('en')(key)).not.toBe(key);
  // Compare against the EN copy, not against `key` — see the constraint below.
  expect(getTranslator('zh-CN')(key)).not.toBe(getTranslator('en')(key));
});

One constraint on that fix: const message = messages[key] ?? EN[key] ?? key; (client/i18n.tsx:7421) falls back to EN, so a zh-CN assertion written as !== key passes with the ZH entry deleted. EN and ZH are module-private, so getTranslator is the only route — which is what the existing test already uses.

The new client/i18n.test.ts case must go red when the key is removed from EN, and red again when it is removed from ZH; today no test in the repo fails for either mutation.

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

Comment on lines +29 to +30
const resolveViteConfig = (webShellVite.resolveConfig ??
webShellVite.default?.resolveConfig)!;

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.

[Suggestion] R1-2: This branch cannot be taken. createRequire gives Node's CJS loader, which returns packages/web-shell/node_modules/vite/index.cjs's module.exports — a shape with resolveConfig directly and no .default — so the right operand of the ?? never evaluates, the & { default?: … } widening exists only to type it, and the trailing ! silences a nullability the expression can never observe. It does not become reachable under a future Vite 6/7 bump either: require() of an ESM-only Vite yields the namespace, where the named export sits alongside .default.

The cost is small but real. A genuinely missing resolveConfig surfaces as an opaque resolveViteConfig is not a function at the describe.each call site instead of a diagnosable undefined at the require, and the comment above records an interop shape as fact that the resolved Vite does not have. AGENTS.md's Simplicity First rules out error handling for impossible scenarios, and the house review rules name unreachable code as in scope rather than as a formatter nit.

To be explicit about how this got here: the predecessor review on the earlier PR told the author to expect resolveConfig on .default, so this is compliance with review guidance that measurement shows was unnecessary — not an invention, and not a mistake worth any extra scrutiny.

Witness:

vite resolved from web-shell : packages/web-shell/node_modules/vite/index.cjs
  version                    = 5.4.21
  typeof resolveConfig       = function
  typeof .default            = undefined
  .default?.resolveConfig    = undefined
  fallback branch taken?     no (direct .resolveConfig exists)

root-hoisted vite 7.3.6, same probe: typeof resolveConfig = function
Suggested change
const resolveViteConfig = (webShellVite.resolveConfig ??
webShellVite.default?.resolveConfig)!;
const resolveViteConfig = webShellVite.resolveConfig;

with the cast on line 28 dropping the widening to plain as typeof import('vite'), and the last sentence of the comment ("Vite 5 exposes resolveConfig on .default under some interops, so normalize that.") deleted.

Please keep the createRequire itself — reverting to a bare import { resolveConfig } from 'vite' would resolve root-hoisted Vite 7.3.6 (package-lock.json:25783) instead of web-shell's 5.4.21 (package-lock.json:31764), and only the package-anchored require reproduces the Vite 5 default build.target that lowers xterm's logical assignments. Measured, root esbuild at Vite 7's default [chrome107, edge107, firefox104, safari16] returns OK rather than throwing, so the hoisted copy would leave the string comparison as the only tripwire.

No test can pin this one — both Web Shell production terminal (%s) cases take the left operand before and after, so npx vitest run --config ./scripts/tests/vitest.config.ts scripts/tests/web-terminal-build.test.ts staying at 2 passed is the whole check.

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

Comment on lines +93 to +95
describe.each(['vite.config.ts', 'vite.lib.config.ts'])(
'Web Shell production terminal (%s)',
(configFile) => {

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.

[Suggestion] R1-3: The matrix covers two config files, but the comment directly above it counts three builds — and resolveViteConfig(inline, 'build') passes no mode, so Vite 5.4.21 falls back to defaultMode = 'development'. That is a mode no real invocation produces: the package's build script runs production and transcript. So the --mode transcript lib build is never observed, even though target sits six lines above an already-existing mode === 'transcript' branch inside the same build: object.

Change vite.lib.config.ts:177 to target: mode === 'transcript' ? 'es2020' : WEB_SHELL_BUILD_TARGET — the exact shape the neighbouring lib.entry ternary at :179-186 already uses — and npm run test:scripts stays green on both cases, because both resolve mode 'development' and see 'es2021'. The real transcript build then ships a bundle lowered by the buggy esbuild pass, and nothing downstream repairs it: dist/transcript.js is inlined verbatim into every /export html document, and that build's own floor is target: ['chrome120'], which keeps ??= rather than re-lowering it.

One correction to the framing, so the gap is not dismissed along with it: dist/transcript.js carries no xterm at all, so this is not the DECRQM freeze. The harm is narrower and still real — the transcript build's options go unobserved by a guard whose comment says it covers three builds. The same correction applies to vite.lib.config.ts's own added clause "Also covers the transcript entry inlined into /export html documents", which names a hazard that bundle does not carry.

Witness:

mode measurement, unmodified PR config:
  TEST-CALL lib, no mode          -> mode="development" target="es2021" libEntry=["index","daemon-react-sdk"]
  REAL lib default                -> mode="production"  target="es2021" libEntry=["index","daemon-react-sdk"]
  REAL lib --mode transcript      -> mode="transcript"  target="es2021" libEntry=["transcript"]

mutation: vite.lib.config.ts:177 -> target: mode === 'transcript' ? 'es2020' : WEB_SHELL_BUILD_TARGET
  PR's 2-case matrix              -> Tests 2 passed (2)          <- mutant SURVIVES
  mutant + suggested 3-case fix   -> x expected 'es2020' to be 'es2021'
                                     Tests 1 failed | 2 passed (3) <- fix is load-bearing
  mutant reverted, fix applied    -> Tests 3 passed (3)          <- green on arrival

grep of the built artifacts: dist/transcript.js -> xterm 0 hits, _inputHandler 0, allowProposedApi 0
                             dist/index.js      -> xterm 178,    _inputHandler 21, allowProposedApi 4

Add the third invocation and supply the mode in the inline config (the fix spans both the header and the call, so no one-click block):

describe.each([
  ['vite.config.ts', undefined],
  ['vite.lib.config.ts', undefined],
  ['vite.lib.config.ts', 'transcript'],
])('Web Shell production terminal (%s%s)', (configFile, mode) => {
  it('answers DECRQM and keeps processing output after minification', async () => {
    const config = await resolveViteConfig(
      {
        root: webShellRoot,
        configFile: resolve(webShellRoot, configFile),
        ...(mode ? { mode } : {}),
      },
      'build',
    );

The mode has to go in the inline config, not positionally: measured against Vite 5.4.21, resolveConfig({ root, configFile, mode: 'transcript' }, 'build') returns mode="transcript", libEntry=["transcript"], while passing 'transcript' as the 4th positional argument returns mode="production", libEntry=["index","daemon-react-sdk"] — that parameter is defaultNodeEnv, not an override mode, and never reaches the config factory.

The new third case must go red when vite.lib.config.ts is mutated to target: mode === 'transcript' ? 'es2020' : WEB_SHELL_BUILD_TARGET and stay green on the unmodified config; today the suite is green under that mutation.

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

Comment on lines +104 to +107
// Pin the floor itself: the probe cannot discriminate it (both esbuild
// copies miscompile identically only at the Vite 5 default), and
// resolveConfig always fills a default so a dropped `target` would be
// masked without this assertion.

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.

[Suggestion] R1-4: This diff adds two comments that give contradictory accounts of the same guard. Lines 20-25 say the config is now resolved through web-shell's Vite 5 precisely so a dropped target no longer "keep[s] this probe green". Lines 106-107 say resolveConfig always fills a default, so a dropped target "would be masked without this assertion". The second was true of the pre-diff root-Vite-7 resolution it replaced, and is false of the code this diff introduces.

The concrete cost lands on the next maintainer of this file. These lines are the stated justification for the assertion, and they mark the probe half as inert — so an intentional floor bump, which trips toBe('es2021') on both describe.each cases, invites deleting the probe as dead weight. That would remove the only check in the repo that the pinned floor still produces a working xterm bundle rather than a correctly-spelled config value.

Witness:

vite5 default build.target = ["es2020","edge88","firefox78","chrome87","safari14"]   (Vite 5.4.21, configFile:false)

root esbuild 0.25.6      @ vite5 default list -> THREW ReferenceError: i is not defined
web-shell esbuild 0.21.5 @ vite5 default list -> THREW ReferenceError: i is not defined
root esbuild 0.25.6      @ es2021 (pinned)    -> OK replies=["\u001b[?2004;2$y"] output="after-query"

So a dropped target reddens the case without the assertion. To be fair to the first clause: "the probe cannot discriminate it" is defensible if "it" means the exact floor value — the probe passes at es2021, es2022 and esnext alike, so only the assertion pins the string. That is worth saying explicitly rather than leaving the parenthetical to imply the probe is inert.

Suggested change
// Pin the floor itself: the probe cannot discriminate it (both esbuild
// copies miscompile identically only at the Vite 5 default), and
// resolveConfig always fills a default so a dropped `target` would be
// masked without this assertion.
// Pin the exact floor as well as the outcome: the probe passes at any
// target >= es2021, so only this assertion names the value. A dropped
// `target` resolves to Vite 5's es2020-era default, which the probe
// below also rejects — but as an opaque `i is not defined` rather than
// as the regression it is.

The reworded comment must not contradict the sibling comment this same diff adds at lines 20-25 ("the bare vite import here lands on the root-hoisted Vite 7, whose default build.target (chrome107/…) never lowers logical assignments, so a dropped target: 'es2021' would keep this probe green"), which the measurement above confirms is the true account.

Comment-only, so nothing can go red for it — the toBe('es2021') assertion and the probe it annotates are unchanged, and the file runs 2/2 green either way.

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

Comment on lines +108 to +109
expect(config.build.target).toBe('es2021');
const terminal = await builtTerminal(config.build.target);

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.

[Suggestion] R1-5: Dropping the || 'esnext' operand turns this call into a strict-mode type error. config.build.target is string | false | string[] under the Vite types this file resolves, and builtTerminal (line 41) accepts only string | string[]. The old || was what narrowed false out of the union, so this diff introduces the error rather than inheriting it.

No lane catches it, which is why it is a Suggestion and not a blocker: root typecheck is npm run typecheck --workspaces --if-present && npm run typecheck:integration and scripts/ is in neither, packages/web-shell/tsconfig.json includes only client/**, eslint has no parserOptions.project here, and vitest transpiles with esbuild. But the root tsc program does cover the file, so every gate stays green while the file is red in any editor's TS server and in the first job that ever typechecks the scripts tree.

Witness:

ARM pre  (bc7a186cda:scripts/tests/web-terminal-build.test.ts, line 87)
  const terminal = await builtTerminal(config.build.target || 'esnext');
  tsc exit=0

ARM post (worktree at 02d8dc11d8, line 109)
  const terminal = await builtTerminal(config.build.target);
  error TS2345: Argument of type 'string | false | string[]' is not assignable to
   parameter of type 'string | string[]'. Type 'boolean' is not assignable to type 'string | string[]'.

flags: --noEmit --strict --skipLibCheck --module nodenext --moduleResolution nodenext --target es2022
flip:  fix applied -> exit=0 ; fix reverted -> exit=2
also reproduces under a program extending the repo tsconfig.json with
  include: ["scripts/tests/web-terminal-build.test.ts"]
root program coverage: tsc -p tsconfig.json --showConfig -> 9838 files, 22 under scripts/
Suggested change
expect(config.build.target).toBe('es2021');
const terminal = await builtTerminal(config.build.target);
expect(config.build.target).toBe('es2021');
const terminal = await builtTerminal(config.build.target || 'esnext');

Two things this fix must not disturb. async function builtTerminal(target: string | string[]) at line 41 forwards straight to esbuild's transform({ target }), which does not accept false either — so narrow the argument, do not widen the parameter. And the assertion on line 108 has to stay before the fallback: a fallback on its own would re-mask a dropped target, since resolveConfig always fills a default.

Type-only, so no test can pin it — tsc --noEmit --strict over the file exiting clean is the witness, and npx vitest run --config ./scripts/tests/vitest.config.ts scripts/tests/web-terminal-build.test.ts must stay at 2 passed.

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

@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.

Approve — cashes in the verification report above

结论 / TL;DR: 上面的独立验证报告(comment 5652040579,同一 head 02d8dc11d822)结论是「未发现 Critical、改动 load-bearing、双臂 A/B 在真实构建产物上量得」。本次 approve 兑现该结论,未新增审查范围。

Gate legs re-read immediately before this write — every value measured, none inferred:

leg value
head 02d8dc11d822846d9f916fbad259da1fa56dae25 — unchanged since the report
state open · merged=false · draft=false
qwen-code-ci-bot APPROVED row 5189938914 @ head, 07:04:05Z, undismissed
lanes at head (enumerated, not a rollup) 122/122 check-runs scanned = 17 success / 105 skipped / 0 failure / 0 in_progress; all five product lanes the report named are green
Criticals from any author 0
CHANGES_REQUESTED at any commit 0

⚠️ One disclosure about the effective review set

Between the report and this approval, qwen-code-ci-bot posted a second and different review of the same head — a /review round at 08:42:20Z (row 5190254886, state COMMENTED). GitHub keeps one effective review per author, so that row superseded the bot's own APPROVED: immediately before this write, reviewDecision read REVIEW_REQUIRED and mergeStateStatus read BLOCKED even though the approval row itself is still at head and undismissed.

The superseding round filed 5 findings, all Suggestion severity, 0 Critical (R1-1R1-5, inline), and did not request changes. So no reviewer retracted anything — the BLOCKED state is a same-author bookkeeping artefact, and this approval is what puts an effective APPROVED back into the set. Recording it so the state change is not later read as a withdrawn approval.

On R1-5 — verified independently, and it is not a build break

R1-5 reports that dropping the || 'esnext' operand turns builtTerminal(config.build.target) into a strict-mode TS2345, and argues no lane catches it. That claim decides whether this is a Suggestion or a merge-blocking compile error, so it was checked against the tree rather than accepted:

  • root typecheck = npm run typecheck --workspaces --if-present && npm run typecheck:integration
  • workspaces = packages/*, packages/channels/*, integrations/*scripts/ is not among them, and there is no scripts/package.json, so --workspaces never reaches it
  • typecheck:integration = tsc -p integration-tests/tsconfig.json, which covers only integration-tests/

⇒ the file is outside every typecheck gate, which is consistent with Lint & Static and Test both being green at this head. So R1-5 is a latent type error — red in an editor's TS server and in any future job that typechecks the scripts tree, but not a break of this PR's CI. Non-blocking, and worth fixing in passing since the operand this diff removed was what narrowed false out of string | false | string[].

The other four (R1-1 a mock-supplied i18n copy, R1-2 an unreachable ?? branch, R1-3 matrix/comment coverage of the transcript mode, R1-4 two contradictory comments about the same guard) are test-strength and comment-accuracy notes. None contradicts the report's measurements, and none is merge-blocking.


Scope of this approval, as of the repository state read immediately before posting: it covers the delta at 02d8dc11d822 — the two build-config files and the three test files — and the arms declared in the report above. It does not cover the rest of the lib bundle's runtime behaviour, the VS Code webview mount, or a browser-driven render. A head move voids it: re-derive rather than assume it carries.

@wenshao
wenshao added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit 9d69c91 Sep 13, 2026
141 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.23.4.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants