Repository navigation
Commit 208d3e2
test(ui): two flaky Test UI cases wait on the effect they read (#29532)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} -->
_Requested by **Steedos** · [project
thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb11FQ2fAk48Dv5duBionc2x)_
Fixes #29468
Clause-②: no
Metadata-protocol: none
## 维护者速读
- 改了什么:两个今天在 main 上各红过一次的 UI 测试,断言改为等待它真正读取的那个 effect 产出的结果,不再在 DOM
出现后立即同步读取。只改两个测试文件,产品代码不动。
- 为什么改:两处读到的值(Studio 的 beforeunload 守卫、Console 的 /me/apps 请求)都由 React 的
passive effect 写出,而 effect 可能晚于测试等待的那次提交执行;负载高的分片上测试先读到旧值就红了。
- 风险与回滚:仅测试改动,不发布任何包;回滚即 revert 本 PR。消融验证证明两条断言仍能钉住原卡的行为。
- 先看:`StudioDesignSurface.refusedLeafUnsaved-29113.test.tsx` 新增的
`untilReloadAsks` 及其三处调用;`App.permissionsWave-29114.test.tsx` 第一条用例中新增的
`waitFor(launcherCalls)` 加 `settle()`。
- 两分钟验证:在 `ui/` 下运行 `pnpm exec vitest run
packages/app-shell/src/views/studio-design/StudioDesignSurface.refusedLeafUnsaved-29113.test.tsx
apps/console/src/__tests__/App.permissionsWave-29114.test.tsx`,预期 2
个文件、8 条用例全绿。
- 需要你做的:无,席位落地。
## What changes
Two `ui/` test cases that each went red once on `main` today now wait on
the outcome they read instead of reading it in the same tick as the DOM
marker they awaited. Test-only: no product source changes, no changeset
(neither touched file ships: `@object-ui/app-shell` is private and
`@object-ui/console` publishes `dist` and its plugin entry only), so
this PR carries `skip-changeset`.
- `StudioDesignSurface.refusedLeafUnsaved-29113.test.tsx`: a new helper
`untilReloadAsks(expected)` wraps the `beforeunload` read in `waitFor`.
It replaces the three bare reads that follow an autosave answer or a
leaf load (the CI red was the first, line 279).
- `App.permissionsWave-29114.test.tsx`: in the first case, after the
home page shows, the test waits for the launcher's `/me/apps` read, then
runs the file's own `settle()` before the "exactly once, no duplicate
URL" assertions, so a second request would also have time to show.
## Why
The race is the same in both files: **a value written by a React passive
effect, read in the tick after the commit the test waited for.**
- Studio: the refusal banner shows in one commit. The pillar reports
`refusedUnsent` from an effect (`onDirtyChange`), and the surface
installs its `beforeunload` listener from an effect of the render that
report causes. `findByTestId('studio-refusal')` resolves on the commit,
so `reloadAsks()` can run before either effect has.
- Console: `LauncherAppsProvider` asks `/me/apps` from its effect, which
can run after the commit that shows `home-page`.
Testing Library's `waitFor` resolves on the DOM mutation and drains with
a 1 ms `setTimeout`. When React's scheduler yields after the commit
(render work over its 5 ms frame), the passive effects run from a
`setImmediate` continuation. On an idle box that continuation wins; on a
loaded box the event loop takes over 1 ms to get back, the drain timer
fires first, and the test reads the value before the effect writes it.
This is a test race, not a product race: the guard and the request both
happen, just one effect pass after the marker.
## Tests
Reproduced first, at base `8ca9dd7b`, with each test file copied N times
into one vitest run (`--maxWorkers=4`, `--sequence.shuffle`) beside node
CPU hogs on a 4-core box:
| File · case | Base: red / runs | Fixed: red / runs | Red line (same as
CI) |
|:--|:--|:--|:--|
| refusedLeafUnsaved-29113 · "a refused report save makes a reload and
the pillar link ask…" | 1 / 80 | 0 / 32 | 279
`expect(reloadAsks()).toBe(true)` |
| permissionsWave-29114 · "the session names the organization…" | 1 / 36
| 0 / 36 | 230 `expect(launcherCalls()).toHaveLength(1)` |
To name the race rather than only count it, a probe preload (`--import`,
scratch only, not committed) made React's scheduler continuation
(`performWorkUntilDeadline`) run from a timer 2 ms or 3 ms later instead
of `setImmediate`, which is what a loaded event loop does to it:
| File | Base: red / runs | Fixed: red / runs |
|:--|:--|:--|
| refusedLeafUnsaved-29113 | 14 / 20 (all at line 279) | 0 / 20 |
| permissionsWave-29114 | 15 / 20 (all at line 230) | 0 / 20 |
Ablations, each through `scripts/ablation-replace.mjs` (anchor 1 → 0,
restored to the HEAD blob, `git diff HEAD` empty):
- `StudioDesignSurface.tsx`: `onDirtyChangeRef.current?.(navDirty ||
refusedUnsent)` → `(navDirty)`. The fixed file goes red: the target case
at `untilReloadAsks(true)`, plus the list-view case. 2 failed, 2 passed.
- `apps/console/src/App.tsx`: `ConsolePermissionsRequest` removed. The
fixed file goes red in both early-request cases at
`waitFor(permissionsCalls … 1)`. 2 failed, 2 passed.
Whole files on the merged head `3d6225a8`: 2 files, 8 tests, all passed.
`pnpm --filter @object-ui/app-shell lint` and `pnpm --filter
@object-ui/console lint` exit 0, with no warning on either touched file.
`turbo run type-check` for `@object-ui/app-shell` and
`@object-ui/console`: 72 tasks, exit 0.
## Acceptance notes
- The shard-sized run used the `--maxWorkers` load beside CPU hogs, not
the real `planShards` file set. The scheduler probe shows the mechanism
directly.
- The second case of the 29113 file (the rail) did not go red under the
ablation. It reads the pillar's own `leafUnsent` during render, not the
surface report, so it is synchronous and not part of this race. It is
unchanged here.
## Verification
<details><summary>Derived gates (dispatch-gates --commands, diff
392359f, 2 files, 23 changed lines)</summary>
All exit 0 at `392359f6`: `check-closing-keyword-parity --self-test`,
`check-comment-mask-corpus`, `check-sdui-manifest` and its
`--self-test`, spec `check:duration-unit-keys`,
`check:cross-package-test-inputs`, `check:dispatcher-error-vocabulary`,
`check:driver-memory-census`, `check:engine-double-contract`,
`check:gitlink-declared`, `check:nul-bytes`, `check:published-files`,
`check:refd-timer-probe`, `check:skill-identifier-liveness`,
`check:test-source-alias`, `check:tier-file-adoption`,
`check:type-check-coverage`, `check:undeclared-dep-imports`,
`check:watch-hint-literal`, `check:where-matcher`,
`check:query-options-erasure`, `check:type-check-debt`, and `pnpm --dir
ui exec vitest run scripts/` (169 files passed, 2 skipped; 4921 tests).
Rerun on the merged head `3d6225a8`, exit 0:
`check:type-check-coverage`, `check:query-options-erasure`,
`check:type-check-debt`, `check-sdui-manifest`, `check:nul-bytes`.
`check-closing-keyword-parity` was run on this body before the PR
opened.
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(not touched)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset. (publishes
nothing: `skip-changeset`)
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed. (not touched)
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QsR2jRAnis8Swqnj1Aw3B8)_
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0e75081 commit 208d3e2
2 files changed
Lines changed: 20 additions & 3 deletions
File tree
- ui
- apps/console/src/__tests__
- packages/app-shell/src/views/studio-design
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
225 | 225 | | |
226 | 226 | | |
227 | 227 | | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
228 | 233 | | |
229 | 234 | | |
230 | 235 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
266 | 266 | | |
267 | 267 | | |
268 | 268 | | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
269 | 281 | | |
270 | 282 | | |
271 | 283 | | |
| |||
276 | 288 | | |
277 | 289 | | |
278 | 290 | | |
279 | | - | |
| 291 | + | |
280 | 292 | | |
281 | 293 | | |
282 | 294 | | |
| |||
285 | 297 | | |
286 | 298 | | |
287 | 299 | | |
288 | | - | |
| 300 | + | |
289 | 301 | | |
290 | 302 | | |
291 | 303 | | |
| |||
311 | 323 | | |
312 | 324 | | |
313 | 325 | | |
314 | | - | |
| 326 | + | |
315 | 327 | | |
316 | 328 | | |
317 | 329 | | |
| |||
0 commit comments