Skip to content

fix(web-shell): make the 30 s overview-poll e2e deterministic - #10934

Merged
yiliang114 merged 1 commit into
QwenLM:mainfrom
yiliang114:fix/issue-10903-overview-spec
Sep 3, 2026
Merged

fix(web-shell): make the 30 s overview-poll e2e deterministic#10934
yiliang114 merged 1 commit into
QwenLM:mainfrom
yiliang114:fix/issue-10903-overview-spec

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

Makes the web-shell e2e case "polls an expanded workspace once per 30 s tick and not faster" deterministic and tags it @smoke. The spec now pauses the fake clock across startup, waits for the real-time startup request bursts to settle, then flushes the fake clock up to and including the first poll tick so the baseline sits on a known interval phase, and only then asserts the cadence (no new requests just short of the next tick, exactly one round past it). No product code changes; the rest of the spec file is untouched.

Why it's needed

The nightly web-shell Browser Regression job has been red in every run since #10407 introduced this case (e.g. runs 33590493339 and 33702961416), always failing with Expected 11 / Received 16. Two intertwined races, both confirmed by local instrumentation:

  1. The settled baseline snapshot was taken as soon as the five distinct facets first appeared, while the startup bursts — the StrictMode double mount (two facet rounds), the composer skill loader's stray GET .../skills (counted because it shares the workspace route), and one more facet round when the connection settles — kept landing in real time afterwards, so the count kept climbing after the snapshot.
  2. Even after waiting those bursts out, page.clock.install() keeps advancing fake time in lockstep with real time while the spec waits ~2 s for startup to settle. The overview interval is anchored at mount, so its first tick deadline lies ~28 s into the fake 29 s "quiet" window and always fires inside it.

So the case never actually measured "polling faster than 30 s"; it measured the phase offset between mount and the first runFor, which is never zero in a real browser. The 30 s timer itself behaves correctly. This PR is the deterministic-equivalent path of the issue's acceptance criteria.

Reviewer Test Plan

How to verify

From packages/web-shell (full config, not the @smoke subset):

npm run test:e2e -- --grep "30 s tick"

Before: fails reliably with the exact CI signature (Expected 11 / Received 16 at the "just short of a tick" assertion). After: 5 consecutive local runs pass, and the whole spec file stays green:

run 1: 1 passed (18.1s)
run 2: 1 passed (19.9s)
run 3: 1 passed (18.3s)
run 4: 1 passed (18.5s)
run 5: 1 passed (17.8s)
full spec file: 4 passed (37.4s)

Why it is deterministic: with the clock paused, every startup timer is pinned to one fake instant; the flush then consumes exactly one interval tick (the following deadline is a full 30 s away), so the 29 s quiet window and the 2 s tick window keep a fixed phase relationship to the interval regardless of real startup duration.

Evidence (Before & After)

N/A (e2e spec-only change; the failing/passing output is inline above)

Tested on

OS Status
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux

Environment (optional)

Local vite dev server + Playwright chromium (headless) via the package's test:e2e script; tsc -p tsconfig.json --noEmit, eslint and prettier --check all clean for the package.

Risk & Scope

  • Main risk or tradeoff: the case now exercises a paused-then-flushed clock rather than a purely advancing one; that matches what the case name promises (cadence after startup settles) and costs ~4 extra real seconds per run.
  • Not validated / out of scope: the optional product-side follow-up from the issue (stabilising client identity / skipping an immediate re-fetch right after a completed round) is deliberately not done here; the nightly leg remains the long-term watch for this job.
  • Breaking changes / migration notes: none.

@smoke decision (asked by the issue): added. This job only runs on schedule/workflow_dispatch, and the case merged without the pre-merge leg ever executing it — which is exactly how it stayed red unnoticed. The pre-merge web-shell browser smoke job already boots the same vite + mock-daemon harness for other @smoke cases, so the marginal cost is this case's ~18 s runtime.

Linked Issues

Fixes #10903

中文说明

这个 PR 做了什么

让 web-shell e2e 用例 "polls an expanded workspace once per 30 s tick and not faster" 变为确定性通过,并给它加上 @smoke 标记。现在 spec 会在启动阶段暂停假时钟,等真实时间里的启动请求突发全部落定,再把假时钟快进“冲刷”到(含)第一次轮询 tick 为止,使基线落在已知的区间相位上,然后才断言节奏(下一个 tick 到来前没有新请求,过 tick 后恰好一轮)。不改任何产品代码,spec 文件其余部分不动。

为什么需要

#10407 引入该用例后,nightly 的 web-shell Browser Regression 任务每次运行都红(例如 run 3359049333933702961416),失败签名始终是 Expected 11 / Received 16。这是两个交织的竞争,均已通过本地埋点确认:

  1. settled 基线快照在五个 facet 首次全部出现时就被取走,而启动突发——StrictMode 双挂载(两轮 facet 请求)、composer 技能加载器的单独 GET .../skills(因为同路由被计入)、连接收敛后的又一轮 facet——之后仍在真实时间里陆续到达,计数在快照之后持续上涨。
  2. 即使等这些突发落定,page.clock.install() 在 spec 等待启动的约 2 秒真实时间里仍让假时钟与真实时间同步前进。overview 轮询定时器锚定在挂载时刻,其首个 tick 截止点恰好落在假时钟 29 秒“静默”窗口内(约第 28 秒),因此必然在窗口内触发。

所以该用例实际测的从来不是“轮询快于 30 秒”,而是挂载时刻与首次 runFor 之间的相位差——在真实浏览器里这个差值永远不为零。30 秒定时器本身行为正确。本 PR 走 issue 验收标准里“确定性等价物”这条路径。

审阅者测试计划

如何验证

packages/web-shell 下(全量 config,不是 @smoke 子集):

npm run test:e2e -- --grep "30 s tick"

修复前:稳定复现 CI 签名(“差一点到一个 tick”断言处 Expected 11 / Received 16)。修复后:本地连续 5 次通过,整个 spec 文件保持全绿:

第 1 次: 1 passed (18.1s)
第 2 次: 1 passed (19.9s)
第 3 次: 1 passed (18.3s)
第 4 次: 1 passed (18.5s)
第 5 次: 1 passed (17.8s)
整个 spec 文件: 4 passed (37.4s)

为什么是确定性的:时钟暂停期间,所有启动定时器都钉在同一个假时刻;随后的冲刷恰好消耗掉一次区间 tick(下一个截止点在整整 30 秒之后),因此 29 秒静默窗口和 2 秒过 tick 窗口与区间保持固定相位关系,与真实启动耗时无关。

前后证据

N/A(仅 e2e spec 改动;失败/通过输出已在上文贴出)

测试环境

系统 状态
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux

环境(可选)

本地 vite dev server + Playwright chromium(headless),走包内 test:e2e 脚本;tsc -p tsconfig.json --noEmiteslintprettier --check 对该包均干净。

风险与范围

  • 主要风险或取舍:用例现在走“暂停后冲刷”的时钟,而不是纯前进时钟;这与用例名承诺的语义(启动稳定后的节奏)一致,每次运行多花约 4 秒真实时间。
  • 未验证 / 不在范围内:issue 中可选的产品侧跟进(稳定 client 身份 / 跳过刚完成一轮后的立即重取)本次刻意不做;该任务的 nightly leg 仍是长期观察手段。
  • 破坏性变更 / 迁移说明:无。

@smoke 决定(issue 点名要回答):加。该任务只在 schedule/workflow_dispatch 运行,用例合入时合入前 leg 从未执行过它——这正是它一直红却无人发现的原因。合入前的 web-shell browser smoke 任务本来就会为其它 @smoke 用例启动同一套 vite + mock-daemon 基建,边际成本只是该用例约 18 秒的运行时间。

关联 Issue

Fixes #10903

The "polls an expanded workspace once per 30 s tick and not faster" case
has been red in every web-shell Browser Regression run since it landed in
QwenLM#10407. The `settled` baseline raced the real-time startup bursts
(StrictMode double mount, composer skill loader, connection-settle
re-fetch), and the fake clock kept advancing in real time while the spec
waited for startup to settle, so the mount-anchored 30 s interval tick
landed inside the "no new requests" runFor window (Expected 11 /
Received 16 in CI).

Pause the fake clock across startup, let the real-time bursts land and
stabilise, then flush up to and including the first poll tick so the
baseline sits on a known interval phase before asserting the cadence.
Tag the case @smoke so the pre-merge web-shell leg covers it.

Fixes QwenLM#10903

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-issue-patrol/jmtljezz3fw
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — every section filled in, real before/after output, and a deliberate answer on the @smoke question rather than leaving it to chance.

Problem: observed, not theoretical. #10903 carries job-level history (green 08-28/29/30, red from 08-31 onward), two named failing runs with job ids, and one stable assertion signature (Expected length: 11 / Received length: 16). The case has never passed anywhere in CI, and the pre-merge leg never executed it because it isn't tagged @smoke — so it merged with zero execution evidence and stayed red unnoticed.

Direction: aligned. A standing red nightly is precisely the condition under which a real red gets waved through, and the 30 s polling contract #10407 introduced currently has no working automated proof in either direction. The issue's acceptance criteria explicitly permit the deterministic-equivalent path this PR takes, and it changes no product code.

Size: not applicable. One changed file, packages/web-shell/client/e2e/web-shell.workspace-overview.spec.ts — a spec file outside every core path, so 0 production lines and 53 test lines (+50/−3), no generated or schema files. No cross-package reach.

Approach: the scope feels right and is genuinely minimal — one local helper plus a re-sequenced test body, the rest of the spec untouched, no drive-by edits or formatting churn. I checked the phase math against the product code rather than taking the description's word for it: the facet poll is a mount-anchored window.setInterval(…, 30_000) gated on document.visibilityState === 'visible', so freezing the clock across startup pins every instance to one fake instant, the flush consumes exactly one tick, and the next deadline sits at 60 s — the quiet window ends at 59.5 s and the final advance reaches 61.5 s. That keeps ~1 s of margin even if something re-anchors the interval at the very end of the flush. I also checked the two things that could add an uncounted-for round mid-window: the reload token is only bumped by workspace mutations and user actions (never by a timer), and the focus listener has nothing to fire on in a headless run. So the 29 s window is genuinely quiet.

The one thing I'd ask you to think about, not a blocker: the 2 s quiet window is a wall-clock heuristic. It's generous against a local mock daemon where startup bursts are milliseconds apart, but it is still a real-time bound, so a pathologically slow runner could land a burst after it — which narrows the old race rather than removing it in every environment. Given the 60 s test timeout, the two CI retries, and the fact that a flake now surfaces pre-merge instead of only at 3 AM, that tradeoff looks acceptable to me; just naming it so it isn't a surprise later.

The @smoke call is the right one. The pre-merge smoke job already boots the same vite + mock-daemon harness for ~40 other tagged cases and runs on a 20-minute budget, so ~18 s is noise. More importantly it means this PR's own CI executes the case it changed — the missing property is the actual bug in #10903.

Risk: no elevated risk signals. The revert-history path screen doesn't match a spec file.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 每一节都填了,有真实的 before/after 输出,并且对 @smoke 标记问题给了明确的决定,而不是留给偶然。

问题: 是已观测到的 bug,不是理论性加固。#10903 提供了 job 级别的历史(08-28/29/30 绿,08-31 起每次红)、两个带 job id 的失败 run,以及一个稳定的失败签名(Expected length: 11 / Received length: 16)。这个用例在 CI 里从未通过过;而合入前的 leg 因为它没有 @smoke 标记,从来没有执行过它——所以它在零执行证据的情况下合入,并且一直红着没人发现。

方向: 对齐。nightly 长期红正是"真正的红被放行"的那种环境;#10407 引入的 30 秒轮询约定目前两个方向都没有可用的自动化证明。issue 的验收标准明确允许本 PR 走的"确定性等价物"这条路,而且它不改任何产品代码。

规模: 不适用。只改了一个文件 packages/web-shell/client/e2e/web-shell.workspace-overview.spec.ts——spec 文件,不在任何核心路径内,因此生产行数 0、测试行数 53(+50/−3),无生成/schema 文件,也没有跨包影响。

方案: 范围合理,而且是真正最小化的——一个本地 helper 加上重排过的用例主体,spec 其余部分不动,没有顺手重构或格式化噪音。我没有直接采信描述,而是对着产品代码核了相位关系:facet 轮询是挂载时锚定的 window.setInterval(…, 30_000),并以 document.visibilityState === 'visible' 为条件,所以启动期间冻结时钟会把每个实例都钉在同一个假时刻,冲刷恰好消耗一次 tick,下一个截止点在 60 秒——静默窗口在 59.5 秒结束,最后一次推进到 61.5 秒。即使在冲刷末尾有东西重新锚定了 interval,也还留有约 1 秒余量。我也核了两件可能在窗口中多出"一轮未计入请求"的事:reload token 只由 workspace 变更和用户操作触发(绝不由定时器触发),而 focus 监听器在无头运行里没有触发源。所以那 29 秒窗口确实是安静的。

有一个想请你考虑的点,不是阻塞项:2 秒静默窗口是一个真实时间的启发式判断。对着本地 mock daemon(启动突发之间只差毫秒)它很宽裕,但它终究是一个真实时间上界,所以极端慢的 runner 仍可能在窗口之后才落下一次突发——这是把旧的竞争收窄了,而不是在所有环境下彻底消除。考虑到 60 秒用例超时、CI 的两次重试,以及一旦抖动现在会在合入前暴露而不是只在凌晨三点,我认为这个取舍可以接受;只是先说清楚,免得以后意外。

@smoke 的决定是对的。合入前的 smoke job 本来就会为约 40 个其它带标记的用例启动同一套 vite + mock-daemon 基建,且有 20 分钟预算,所以约 18 秒可以忽略。更重要的是,这意味着本 PR 自己的 CI 会执行它所修改的用例——缺失的这个属性正是 #10903 的真正 bug。

风险: 无升级风险信号。回滚历史的路径筛查不会命中 spec 文件。

进入代码审查 🔍

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

I wrote down what I'd do before reading the diff, from the issue's two races alone: pin the phase with Playwright's documented install-then-pause idiom so every startup timer shares one fake instant, flush past exactly one interval to land on a known phase, take the baseline there, and wait out the startup bursts in real time with a Node-side timer rather than page.waitForTimeout (which is coupled to the fake clock). That's what this PR does, so there's no simpler path I found that it missed. The one variant I considered — waiting for a specific known-last startup signal and asserting a fixed count — would be stricter but brittle against any future startup change, so the quiet-window shape is the better call.

The subtlest part of the change is right, and it's worth saying why: waitForStableOverviewCount sleeps on Node's setTimeout and samples with Node's Date.now(), both in the test process. Had it used page.waitForTimeout, the fake clock would have advanced during the settle wait and the phase pinning would have been undone — which is the exact mechanism behind the original failure. Same reasoning makes the ordering of pauseAt before gotoSession load-bearing rather than cosmetic.

I checked the timing claims against the product code instead of taking the description's word:

  • The facet poll is window.setInterval(…, WORKSPACE_OVERVIEW_POLL_MS = 30_000) created in the mount effect and gated on document.visibilityState === 'visible' (components/sidebar/useWorkspaceOverview.ts). Mount-anchored, so the flush of 30 500 ms consumes exactly one tick, the next deadline is 60 000, the quiet window ends at 59 500, and the final advance reaches 61 500 → exactly one more round, matching the unchanged settled + 5 assertion.
  • The effect re-runs (and re-anchors the interval, firing an immediate extra round) when reloadToken changes. I traced every bump of workspaceSessionsReloadToken — workspace removal reconciliation and user actions only, never a timer — so nothing re-anchors inside the quiet window. Even in the worst case, a re-anchor at the very end of the flush puts the next tick at ≤ 60 500, still inside the final window, so the assertion keeps ~1 s of margin.
  • The flush fires 30 s worth of every shorter app timer at once (live-state 2 s, session list 3 s, status 10 s, background tasks 3 s, git 60 s). None of those routes match the overviewRequests filter /(mcp|skills|extensions|channels|memory|hooks)$/, including the 60 s git poll that lands inside the final advance — so none of them can inflate the counted baseline or the +5 delta.

Conventions are clean: no any, the JSDoc explains only the non-obvious why and matches the file's existing helper style, the inline @smoke tag matches ~40 existing cases, and the helper stays colocated because it has one consumer — extracting it would be premature under AGENTS.md.

No blockers. Two observations, neither a request to change this PR:

  • The sibling assertion in this same file (the hover test, settledwaitForTimeout(1_500)toHaveLength) has the same snapshot-then-wait shape and would be exposed to the same class of race. It passes today and this PR correctly leaves it alone. If it ever starts flaking, the new helper is the fix — and with a second consumer it would then earn a move into client/e2e/utils/.
  • retries: 2 under CI in playwright.config.ts means a green job can hide a case that only passed on a retry. That's not a defect here, but it's why "CI is green" and "the case is deterministic" aren't the same sentence — the uploaded web-shell-e2e-smoke Playwright report shows the retry count per case, which is where to look.

Testing

What this section carries: the PR's own CI check results, read through the API for the reviewed commit. Nothing was built or executed here — this is an unattended CI run, so the review is static and no PR-derived code was run. No tmux capture (that path is local-invocation only).

Not verified: the leg that actually executes the changed case has not run yet. web-shell E2E Smoke is gated on needs.classify_pr and needs.test with ci_profile == 'full', and Test (ubuntu-latest, Node 22.x) is still in progress, so the smoke job has not been created. This is the one check that proves the newly-tagged @smoke case passes in CI, and it is the reason the case's whole history went unobserved. Everything below is the state of the commit as fetched, not a settled result — the table region gets rewritten once CI lands.

Nothing red on this commit as of the fetch. Four real jobs still in flight (Test ubuntu, Lint & Static, Integration Tests (no-AK, No Sandbox), Capture web-shell visuals); macOS/Windows Test and Integration Tests (CLI, No Sandbox) are skipped by the PR's classify profile, not failing. review-pr and triage are bot orchestration for this run, not product CI.

Final CI results for c1a6fe0 (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
Dependency CVE audit ✅ 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
Secret scan (TruffleHog) ✅ 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,失败项排在最前。

The author's local numbers (5 consecutive passes, full spec file green, ~18 s per run) are quoted in the description; they are the author's claim, not evidence I re-ran, and I'm not presenting them as verification.

Sandboxed verification would settle the part CI cannot: @qwen-code /verify — that the case passes repeatedly rather than once, and that it fails on base. A single green smoke job with retries: 2 available cannot distinguish "deterministic" from "passed on the third attempt", and the determinism claim is the entire point of this PR. An A/B run of the case against the base build, repeated enough times to see a flake, is exactly that gap. @qwen-code /tmux is the wrong lane here — this is a browser e2e spec with no TUI surface. The author has write access, so neither lane needs sponsoring. The durable oracle remains the one #10903 names: two consecutive green nightly web-shell Browser Regression runs, which only the schedule can supply.

中文说明

代码审查

在读 diff 之前,我先只根据 issue 里那两个竞争写下了自己的做法:用 Playwright 官方文档里的 install-then-pause 惯用法把相位钉住,让所有启动定时器共享同一个假时刻;冲刷过恰好一个 interval 落到已知相位;在那里取基线;并且用 Node 侧的定时器(而不是 page.waitForTimeout,它与假时钟耦合)在真实时间里等启动突发落定。这正是本 PR 做的,所以我没有找到它漏掉的更简路径。我考虑过的唯一变体是——等待某个特定的"最后一次启动信号"然后断言固定请求数——那会更严格,但对未来任何启动流程改动都很脆弱,所以"静默窗口"这个形状是更好的选择。

这个改动里最微妙的一点是对的,值得说明为什么:waitForStableOverviewCount 用的是 Node 的 setTimeout 睡眠、Node 的 Date.now() 采样,两者都在测试进程里。如果它用的是 page.waitForTimeout,假时钟就会在等待稳定期间前进,相位钉定也就被破坏了——而这正是原始失败的机制。同样的道理使 pauseAt 放在 gotoSession 之前是关键性的,而不只是装饰。

我没有采信描述,而是对着产品代码核了这些时序论断:

  • facet 轮询是挂载 effect 里创建的 window.setInterval(…, WORKSPACE_OVERVIEW_POLL_MS = 30_000),并以 document.visibilityState === 'visible' 为条件(components/sidebar/useWorkspaceOverview.ts)。它锚定在挂载时刻,所以 30 500 ms 的冲刷恰好消耗一次 tick,下一个截止点是 60 000,静默窗口在 59 500 结束,最后一次推进到 61 500 → 恰好再多一轮,与未改动的 settled + 5 断言一致。
  • reloadToken 变化时该 effect 会重跑(重新锚定 interval,并立即多打一轮)。我追了 workspaceSessionsReloadToken 的每一次自增——只有 workspace 移除协调和用户操作,绝不由定时器触发——所以静默窗口内不会有东西重新锚定。即使是最坏情况,在冲刷末尾重新锚定也会把下一个 tick 放在 ≤ 60 500,仍在最后窗口内,所以断言保有约 1 秒余量。
  • 冲刷会一次性触发所有更短的 app 定时器 30 秒的量(live-state 2 s、会话列表 3 s、状态 10 s、后台任务 3 s、git 60 s)。这些路由没有一个匹配 overviewRequests 的过滤器 /(mcp|skills|extensions|channels|memory|hooks)$/,包括落在最后推进窗口里的那次 60 s git 轮询——所以它们都无法抬高被计数的基线或 +5 的差值。

约定方面是干净的:没有 any;JSDoc 只解释了不明显的那个 why,与该文件既有 helper 风格一致;行内 @smoke 标记与约 40 个现有用例一致;helper 保持就近放置因为它只有一个消费者——按 AGENTS.md,现在抽取它是过早抽象。

没有阻塞项。两点观察,都不是要求本 PR 修改:

  • 同一个文件里的兄弟断言(hover 那个用例,settledwaitForTimeout(1_500)toHaveLength)是同样的"先快照再等待"形状,会暴露在同一类竞争下。它今天是通过的,本 PR 正确地没有动它。如果它哪天开始抖动,这个新 helper 就是解法——而一旦有了第二个消费者,它届时才值得移到 client/e2e/utils/
  • playwright.config.tsCI 下的 retries: 2 意味着一个绿的 job 可能掩盖了只在重试时才通过的用例。这不是本 PR 的缺陷,但这也是为什么"CI 绿了"和"用例是确定性的"不是同一句话——上传的 web-shell-e2e-smoke Playwright 报告里有每个用例的重试次数,那才是该看的地方。

测试

本节承载的是:通过 API 读到的、针对被审 commit 的 PR 自身 CI 检查结果。这里没有构建或执行任何东西——这是无人值守的 CI 运行,审查是静态的,没有运行任何 PR 派生代码。没有 tmux 抓屏(那条路径只适用于本地调用)。

未验证:真正执行被改用例的那条 leg 还没跑。 web-shell E2E Smoke 依赖 needs.classify_prneeds.test 且要求 ci_profile == 'full',而 Test (ubuntu-latest, Node 22.x) 仍在进行中,所以 smoke job 还没被创建。这正是能证明新打 @smoke 标记的用例在 CI 里通过的那一个检查,也正是这个用例的全部历史一直无人观测的原因。下面所有内容都是抓取时刻的状态,不是已定的结果——CI 落定后表格区域会被重写。

截至抓取,该 commit 上没有红的。四个真实 job 仍在进行(ubuntu TestLint & StaticIntegration Tests (no-AK, No Sandbox)Capture web-shell visuals);macOS/Windows 的 TestIntegration Tests (CLI, No Sandbox) 是被该 PR 的 classify profile 跳过的,不是失败。review-prtriage 是本次运行的机器人编排,不是产品 CI。

(上方表格区域带机器可读标记,CI 落定后由 finalize 任务原地重写。)

作者本地的数字(连续 5 次通过、整个 spec 文件全绿、每次约 18 秒)引自描述;那是作者的说法,不是我重跑出来的证据,我不把它当作验证呈现。

沙箱验证可以补上 CI 补不了的那部分:@qwen-code /verify——用来证明该用例是反复通过,而不是只通过一次,并且在 base 上会失败。一个带 retries: 2 的单次绿 smoke job 无法区分"确定性"和"第三次才通过",而确定性正是本 PR 的全部意义。针对 base 构建做 A/B、重复足够多次以暴露抖动的运行,恰好填这个缺口。@qwen-code /tmux 在这里是错误的通道——这是浏览器 e2e spec,没有 TUI 界面。作者有写权限,所以两条通道都不需要担保。长期的判定标准仍然是 #10903 点名的那个:nightly web-shell Browser Regression 连续两次绿,而这只有 schedule 能提供。

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

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — solid and genuinely minimal; the two things I'd name are a pre-existing sibling assertion this PR correctly leaves alone, and the fact that the one CI leg able to substantiate "deterministic" hasn't run yet.

Stepping back: this is the right fix for the right problem, and it answers the question the issue actually asked. #10903 isn't complaining that a test is red — it's complaining that a browser e2e case merged with zero execution evidence and then stayed red for days where only a schedule could see it. The PR fixes the case and closes that hole by tagging it @smoke, so the pre-merge leg executes it from here on. That second half is the part with lasting value, and it costs ~18 s on a job with a 20-minute budget.

The implementation doesn't feel like it's trying too hard. Twenty-odd lines of helper, a re-sequenced test body, nothing else touched. I went looking for the shortcut it missed and didn't find one: the startup bursts are React-effect and network driven, not timer driven, so no amount of fake-clock stepping can wait them out — a real-time settle is unavoidable, and doing it on Node's timer rather than page.waitForTimeout is the non-obvious part that makes the whole approach hold together.

The thing that most raised my confidence is that this test fails loudly rather than silently. If the phase pinning were wrong — say the clock resumed real time after the flush — the second advance would straddle the 60 s deadline and the quiet-window assertion would fail. There's no path where a broken pin produces a green run that measures nothing, which is the failure mode I'd normally worry about in a clock-mocking test. Combined with the interval being mount-anchored at a documented 30 s, the reload token never being bumped by a timer, and none of the shorter polls hitting a counted route, the arithmetic checks out from the product side and not just from the description.

What I'd want before merging, and can't have in this run: the smoke job's actual result on this commit, and ideally a look at its Playwright report to confirm the case passed on the first attempt rather than on one of the two configured retries. A green run with retries: 2 available is weaker evidence than it looks, and "deterministic" is a claim about repetition. That's why my Stage 2 comment names /verify as the lane that would settle it, and why the issue's own acceptance bar — two consecutive green nightly runs — is still worth holding this against after merge. Not a reason to hold the PR up: it is a strict improvement over a case that has never passed anywhere.

Six months from now I'd thank whoever wrote this, because the comments explain the fake-clock reasoning that would otherwise read as arbitrary runFor numbers.

Verdict: approve — deferred until CI lands green. Two pull_request workflow runs are still in flight on this commit (Qwen Code CI, Web-shell Visuals), and web-shell E2E Smoke — the leg that executes the changed case — is queued behind Test, which hasn't finished. Approving now would attest to a result that doesn't exist yet. This comment carries the deferral marker for commit c1a6fe0a0505ba5931413fc676b7dcf5b2f92ef3; the finalize workflow posts the commit-pinned approval once every check on that SHA completes green, and withholds it if anything lands red or the head moves.

中文说明

信心度:4/5 —— 扎实且真正最小化;我要点名的两件事,一是本 PR 正确地没有去动的、同文件里一个早已存在的兄弟断言,二是唯一能证明"确定性"的那条 CI leg 还没跑。

退一步看:这是对的问题的对的修法,而且回答了 issue 真正在问的问题。#10903 抱怨的不是"某个测试红了"——它抱怨的是一个浏览器 e2e 用例在零执行证据的情况下合入,然后连续红了好几天,而只有 schedule 能看见它。本 PR 既修了用例,又通过打上 @smoke 标记堵上了那个洞,从此合入前的 leg 会执行它。后半部分才是有长期价值的地方,而代价只是在一个有 20 分钟预算的 job 上多花约 18 秒。

实现没有"用力过猛"的感觉。二十来行 helper、一段重排过的用例主体,其它什么都没动。我去找它漏掉的捷径,没找到:启动突发是 React effect 和网络驱动的,不是定时器驱动的,所以再多的假时钟步进也等不完它们——真实时间里的稳定等待无法避免;而用 Node 的定时器而不是 page.waitForTimeout 来做这件事,正是让整个方案站得住的那个不明显的点。

最让我提高信心的是:这个测试是响亮地失败,而不是静默地失败。如果相位钉定错了——比如冲刷之后时钟恢复走真实时间——那么第二次推进就会跨过 60 秒截止点,静默窗口的断言会失败。不存在"钉定坏了却跑绿、但什么也没测"的路径,而那正是我在一个 mock 时钟的测试里通常会担心的失效模式。再加上 interval 是挂载时锚定的、有文档记录的 30 秒,reload token 绝不由定时器自增,更短的那些轮询没有一个打在被计数的路由上——这套算术从产品侧就能核对,而不只是从描述里读到。

在合入前我会想要、但这一次拿不到的东西:这个 commit 上 smoke job 的真实结果,最好还能看它的 Playwright 报告,确认用例是第一次尝试就通过、而不是靠配置的两次重试之一通过的。在 retries: 2 可用时,一次绿的运行比它看起来是更弱的证据,而"确定性"是一个关于重复的论断。这就是为什么我在 Stage 2 评论里点名 /verify 是能补上这一点的通道,也是为什么 issue 自己的验收标准——nightly 连续两次绿——在合入后仍然值得拿来衡量这个改动。这不是拖住 PR 的理由:相比一个从未在任何地方通过过的用例,它是严格的改进。

六个月后我会感谢写这段代码的人,因为注释解释了假时钟的推理,否则那些 runFor 数字读起来会像是随意写的。

结论:approve —— 延后到 CI 变绿。 该 commit 上还有两个 pull_request workflow run 在进行中(Qwen Code CIWeb-shell Visuals),而 web-shell E2E Smoke——执行被改用例的那条 leg——排在尚未结束的 Test 之后。现在批准等于为一个还不存在的结果背书。本评论携带针对 commit c1a6fe0a0505ba5931413fc676b7dcf5b2f92ef3 的延后批准标记;finalize 任务会在该 SHA 上所有检查都绿之后发出与该 commit 绑定的批准,若有检查变红或 head 移动则不发。

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

Reviewed at c1a6fe0a0505ba5931413fc676b7dcf5b2f92ef3 · 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 c1a6fe0. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

⚠️ One or more scenarios failed to render on this head, so this preview may be missing views — see the workflow run. The composites below are the scenarios that did render.

terminal-turn-error-copy-narrow-dark before/after

terminal-turn-error-copy-narrow-light before/after

workflow-page-running-dark before/after

workflow-page-running-light before/after

workflow-page-saved-dark before/after

workflow-page-saved-detail-dark before/after

workflow-page-saved-detail-light before/after

workflow-page-saved-light before/after

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

Qwen Code · web-shell visuals

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

Reviewed at head c1a6fe0a.

  • One Playwright spec, no product code. The phase math checks out independent of the description: after runFor(30_500) the mount-anchored 30 s interval sits 0.5 s past a tick with the next deadline 30 s away, so the quiet window at +29 s (59.5 s) stays pre-deadline and the assertion window at +2 s (61.5 s) straddles exactly one round — the case now measures "not faster than 30 s" instead of the old mount-to-runFor phase offset. A wrong pin fails loudly (the second advance would straddle two deadlines), which is the right shape for a clock-mocked cadence test.
  • The subtleties are each handled: the stability helper polls on Node timers, not page.waitForTimeout, so it cannot deadlock against the paused fake clock; its 15 s budget throws rather than returning a moving baseline; both bursts that made the old snapshot stale (StrictMode re-mount, skill loader, connection-settle round) land inside the real-time quiet window before the flush; and re-taking settled after the flush anchors on the post-tick count.
  • Tagging the case @smoke is the durable half of the fix: the pre-merge leg will execute it from now on, which is what the red-for-days nightly was actually complaining about.
  • No reviews or threads to reconcile; the author's before/after evidence reproduces the exact CI signature and five consecutive local greens, which is the claim under test. Local re-execution of the Playwright case was not possible on this machine (no browser binaries installed here) — disclosed rather than implied.
  • CI facts: 15 checks pass, zero fail, three lanes still in flight on this head (the smoke leg that executes this case queues behind Test); per the channel convention the call is on the review itself.

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

Scope: 1 file changed, test-only: packages/web-shell/client/e2e/web-shell.workspace-overview.spec.ts. Not reviewed: no production code touched; no CI configuration consulted. Working tree unavailable — execution rung not run (test-only spec file; no production behavior changed, so this does not block approval).

No blocking findings.
Approval blockers: none.


Findings

None.


What was checked

Class 1 — contract asymmetry: Not applicable — no persisted or transmitted format touched.

Class 2 — API compatibility: Not applicable — no exported symbols changed.

Class 3 — error handling: waitForStableOverviewCount propagates a throw at its 15s deadline and has no silent-swallow path. Correct.

Class 5 — test validity (primary concern for a test-only PR):

Can waitForStableOverviewCount pass while covering nothing? The function uses Node.js setTimeout and Date.now() in the test process, not page.waitForTimeout, which is coupled to the fake browser clock. This distinction is load-bearing: using page.waitForTimeout would advance the fake clock during the settle wait and undo the phase pinning that pauseAt established. The ordering clock.install() then clock.pauseAt(Date.now()) then gotoSession(...) is correct and necessary.

Phase math: The facet poll is a window.setInterval(..., 30_000) created at mount, gated on document.visibilityState === "visible". Freezing the clock across startup pins every instance to one fake instant; runFor(30_500) consumes exactly one tick; next deadline is 60 s; quiet window ends at 59.5 s; final advance reaches 61.5 s — exactly settled + 5 (one round of 5 facets). The assertion is tight in both directions: a 20 s polling interval fires during the 29 s short window (assertion fails); a 60 s interval does not fire during the remaining 31.5 s window (assertion fails). The test detects both too-fast and too-slow polling.

reloadToken re-anchor risk: workspaceSessionsReloadToken is bumped only by workspace removal and user actions, never by a timer. No re-anchor can fire inside the quiet window from a timer.

Other app timers during the flush: Shorter timers (live-state 2 s, session list 3 s, status 10 s, background tasks 3 s, git 60 s) all fire during runFor(30_500), but none match the overviewRequests filter /(mcp|skills|extensions|channels|memory|hooks)$/. They cannot inflate settled or the +5 delta.

@smoke tag: Adds the test to the pre-merge smoke job, closing the gap that allowed the original non-deterministic case to merge with zero execution evidence.

Cross-check against existing reviews: The qwen-code-ci-bot independently traced the same phase math, identified the same Node-vs-fake-clock distinction as the key mechanism, and noted the same pre-existing sibling assertion in the hover test. No finding in the bot review that I cannot confirm; no finding I hold that the bot refuted. The sibling assertion in the hover test was correctly left untouched by this PR.


Unreviewed dimensions

  • Execution rung not run: no working tree available. The changed file is a browser e2e spec; the inability to execute it does not create a coverage gap that blocks approval of a test-only fix.
  • retries: 2 in playwright.config.ts means a single green smoke job does not distinguish "deterministic" from "passed on retry". Pre-existing CI configuration, outside this diff, disclosed here only.

Reviewed with AI assistance.

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

@yiliang114
yiliang114 added this pull request to the merge queue Sep 3, 2026
Merged via the queue into QwenLM:main with commit ed2a914 Sep 3, 2026
85 of 87 checks passed
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.

E2E: web-shell Browser Regression is red in every run that executes it — the 30 s overview-tick case has never passed

4 participants