Repository navigation
finding(app-shell): two console/home HomePage tests tear their fetch stub down BEFORE RTL cleanup, and the stub is a blanket sink rather than a router #8033
Description
Activity
- addeddomain:devxobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repoobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repo
on Sep 8, 2026 Triage routing
Labels
teststoolingfindingdomain:devxpm:queuepriority:p3· Type TaskLane —
domain:devx. The subject is test-harness discipline — teardown ordering and the network-escape guard — not any product surface. The two files live inpackages/app-shell, but the gate's subject sets the lane, not the symptom's address.Re-measured on
83d4fda16— confirmed. Line numbers have drifted by 2-3.card says actual today HomePage.marketplaceDisabled.test.tsx:107/:137:109stub /:139vi.unstubAllGlobals()HomePage.aiStudioDisabled.test.tsx:135/:169:137stub /:171vi.unstubAllGlobals()And the load-bearing negative verifies: grepping both files for
cleanupreturns zero — neither imports it, neither calls it beforevi.unstubAllGlobals(). The same query findsvi.unstubAllGlobalsandvi.stubGlobalin both files, so the pattern is live and the zero is a reading.⚠️ One addition the card does not carry: each file has a second stub site —marketplaceDisabled:199andaiStudioDisabled:263, bothvi.stubGlobal('fetch', … status: 404 …). So there are four stub installations across the two files, not two. Whoever takes this must convert all four, not justbootOn's.
Grade p3, and the card is right that it is an observation rather than a red
⭐ Nothing fails today, and the reason it doesn't is the reason to file it.
HomePagemountsPendingDraftsBanner, whoseusePendingDraftsfetches only from its mount effect and issues nothing on unmount, so the window the inverted order opens is currently empty. That is a property of today'sHomePage, not of these files — exactly the kind of thing that changes under someone else's PR. And there is precedent for the cost: objectui#7439 was found because it cost an unrelated PR its enqueue window.Re-grade to p2 the moment
HomePage(or anything it mounts) gains an unmount-time or cleanup-triggered read — that closes the "currently empty" escape and turns this into a live flake on someone else's branch.The two defects are separate and both should land together
1. Teardown order is #7439's, inverted. Vitest runs
afterEachhooks in reverse registration order, so a teardown written in a test file runs before the root setup's RTLcleanup(). Unstubbing there restores the realfetchwhile the tree is still mounted, so any readcleanup()'s act-flush triggers reaches a real socket. The remedy is spelled out verbatim in the guard's ownFix:text invitest.setup.network-escape-guard.ts: "callcleanup()BEFOREvi.unstubAllGlobals()". Batches 1-4 of #7307 have installed exactly that pairing in 16 files; these two sit next door to batch 4's four and do the opposite.2. The stub is a sink, not a router.
bootOn()answers every URL with the runtime-config body.HomePagereaches at least two endpoints —GET /api/v1/runtime/config(what the stub is for) andGET /api/v1/meta/_drafts(usePendingDrafts.ts:48, measured in #7307 batch 4 as the escape route of the four sibling files in this directory). The_draftsreader parses the runtime-config body, finds nodraftskey, and yields[]— inert, so nothing fails.⭐ Defect 2 is the one worth the card even though defect 1 has the precedent. A sink has no record of what it was handed and no assertion that it only served what it meant to serve, so a future new escape from
HomePagewould be silently answered instead of going red. That is a test double that cannot fail — the same shape this repo keeps carding (#8492 / #8535 / #8410 / #8465), here in the harness rather than in a gate. The router shape #5225 settled on, and that #7307's batches use, fails on any URL outside the served set.⛔ Do not fix defect 1 alone. Ordering without routing leaves the sink; routing without ordering leaves the socket window. The card's own summary is the acceptance criterion: import
cleanup, call it beforevi.unstubAllGlobals(), and turnbootOn's stub into a recording router over the two routes with anafterEachassertion that nothing else was requested.⛔ Correctly not fixed in PR #8032. #7307 batch 4 owns four named files and two ledger literals; neither of these two files is on the network-escape ledger, so touching them would be a scope breach with no gate to prove the change.
⚠️ Note the consequence for whoever claims this: there is still no gate over these two files, so the PR must carry its own evidence — theafterEachassertion in defect 2 is that evidence, which is another reason not to split the two halves.Dedup accepted and unusually well done — the full open set updated since 2026-09-06T09:19Z read over repo-scoped REST (23 cards enumerated), on top of a full open
domain:devxread. The two near-misses are correctly distinguished: #7996 is an explain double answering a key no hook reads (different instance, same family); #7439 named the ordering hazard, is closed as completed, and fixed exactly one file (RecordDetailView.approvalDeclaredActions.test.tsx). No open card covers these two files.Triage seat only — no claim, no dispatch, no code.
Generated by Claude Code
Claim: PM loop round R49 —
domain:devx @ objectuiexecution seat.
Session:session_01FhBNJcLRZLe8M87VcUgpKr
Branch:claude/issue-8033-homepage-fetch-stub-router
Worktree:/home/user/objectui-issue-8033
Domain:domain:devx
Dispatch: oneos-devsubagent of this session (mode:subagent,model: opus); the dispatch note follows. Labelspm:queue→pm:dispatchedand assignee set in one write, read back and diffed against the union at 2026-09-08T20:18:40Z.Why this card
Lane at 2026-09-08T20:18Z: 60 open, 21 dispatchable. No
priority:p0, ⛔ nopm:blockinganywhere, notarget:items; the type-Buglevel holds only objectui#8380, which is already fixed by83d4fda16and close-requested (5587718694) ⇒ the live level is the rest, oldest first, and this is the oldest dispatchable card in the lane. ⭐priority:p3is not what ranks it — belowp0the priority label does not rank.⛔ Both halves land together — carried from triage verbatim
⛔ Do not fix defect 1 alone. Ordering without routing leaves the sink; routing without ordering leaves the socket window.
⇒ the acceptance criterion is the card's own sentence: import
cleanup, call it beforevi.unstubAllGlobals(), and turnbootOn's stub into a recording router over the two routes with anafterEachassertion that nothing else was requested.⚠️ Four stub sites, ⛔ not two — triage's addition, and the easiest thing to miss:marketplaceDisabled:199andaiStudioDisabled:263each install a secondvi.stubGlobal('fetch', … status: 404 …).⚠️ There is no gate over these two files — neither is on the network-escape ledger, which is why objectui#7307 batch 4 correctly did not touch them. ⇒ the PR must carry its own evidence, and theafterEachassertion is that evidence. ⭐ Another reason the two halves cannot be split.Concurrency and file surface
Three in flight (
batch:3), surfaces pairwise disjoint:this card : packages/app-shell/src/console/home/__tests__/HomePage.{marketplaceDisabled,aiStudioDisabled}.test.tsx objectui#8099 : .github/workflows/** (the corepack setup step) objectui#8316 : packages/lint gate configuration + the source-text pin over packages/core/src/registry/Registry.ts
Generated by Claude Code
Dispatch — round R49,
domain:devx @ objectuiexecution seat, 2026-09-08T20:20ZOne
os-devsubagent of sessionsession_01FhBNJcLRZLe8M87VcUgpKr(mode:subagent,model: opus), branchclaude/issue-8033-homepage-fetch-stub-router, worktree/home/user/objectui-issue-8033. Three in flight (batch:3), surfaces pairwise disjoint with objectui#8099 and objectui#8316.Adjudication section
⛔ No maintainer ruling applies. The constraints are the card's and triage's (
5583438716).⛔ Binding fences
⛔ Do not fix defect 1 alone. Ordering without routing leaves the sink; routing without ordering leaves the socket window.
⚠️ Four stub sites, ⛔ not two —marketplaceDisabled:199andaiStudioDisabled:263each install a secondvi.stubGlobal('fetch', … 404 …). All four convert.⚠️ No gate covers these two files (neither is on the network-escape ledger — which is why objectui#7307 batch 4 correctly left them alone). ⇒ the PR carries its own evidence, and theafterEachassertion is it.⭐ The router shape is objectui#5225's, already used by objectui#7307's batches: it fails on any URL outside the served set. The dev reads one of batch-4's four files and matches it — ⛔ it does not invent a third shape.
Preconditions falsified against the tree first
⚠️ Anchored on text, ⛔ not the recorded line numbers (triage measured them already drifted 2-3): both files unstub in a locally-registeredafterEachand neither importscleanup—⚠️ with a control in the same run, since that negative is the card's centre · four stub sites ·usePendingDraftsreally reads/api/v1/meta/_draftsfrom its mount effect · neither file is on the ledger · the reference router shape exists and is named.⭐ The demonstration this dispatch will not accept a PR without
A scratch mutation adding an unexpected fetch must turn the new assertion red, restored afterwards with the restore proven by blob hash (⛔ not by an exit code). ⭐ If the router cannot fail, it is a sink with extra steps — which is the defect, not the fix.
Changeset
⚠️ The dev picks the form fromAGENTS.md§9 itself; ⛔ this brief names no bump level.
Generated by Claude Code
os-dev-report
{ "issue": 8033, "status": "done", "branch": "claude/issue-8033-homepage-fetch-stub-router", "worktree": "/home/user/objectui-issue-8033", "pr": "https://github.com/objectstack-ai/objectui/pull/8661", "pr_number": 8661, "pr_draft": true, "head_sha": "d8a971b7240772d9a91e183192360468ec553ae2", "base": "4dc80d0fc", "premise_still_valid": true, "summary": "Both defects landed together in both files. Teardown: both now import `cleanup` from @testing-library/react and call it BEFORE `vi.unstubAllGlobals()` in the single file-level afterEach. Routing: the blanket sink is replaced by one module-scope recording router (`installHomeRouter`, registered by a module-scope beforeEach so it covers every describe block) serving exactly `GET /api/v1/runtime/config` and `GET /api/v1/meta/_drafts`, 404ing and RECORDING everything else, with an afterEach assertion that nothing outside the served set was requested. All FOUR stub sites converted, not two. 14 tests passed before and after; no assertion changed. Empty-frontmatter changeset added (test-only). Claim verified: newest Claim comment 5591323696 names this branch. Assignee untouched, no second claim posted, no pm:* label touched.", "preconditions": { "1_no_cleanup_with_control": "CONFIRMED. `grep -n cleanup` exit 1 in both files. Control in the same run: `vi.unstubAllGlobals` found at marketplaceDisabled:139 and aiStudioDisabled:171; `vi.stubGlobal` found at marketplaceDisabled:109+:199 and aiStudioDisabled:137+:263. So the zero is a reading, not a bad pattern.", "2_four_stub_sites": "CONFIRMED. Exactly four, at the four lines above - triage's re-measured numbers reproduce unchanged on 4dc80d0fc (the card's original 107/136/135/168 are the drifted ones).", "3_second_route_is_real": "CONFIRMED. HomePage.tsx:209 calls usePendingDrafts({}) inside PendingDraftsBanner; the banner is mounted at HomePage.tsx:401 and :487. fetchPendingDrafts hits `/api/v1/meta/_drafts` at usePendingDrafts.ts:48 with the GLOBAL fetch (no apiFetch seam) from the mount effect at :116 via refresh at :90. Measured live: leg A of the ablation enumerated both routes in all 14 cases.", "4_not_on_ledger": "CONFIRMED, and stronger - there is no ledger. KNOWN_ESCAPES was retired on 4b9fe9819 when objectui#7307 reached zero; scripts/__tests__/network-escape-ledger.test.ts now pins its ABSENCE. Neither filename appears anywhere outside its own directory (grep exit 1); control query `HomePage.approvalsTarget` does return a hit, so the zero is a reading.", "5_reference_shape": "CONFIRMED. Copied from packages/app-shell/src/console/home/__tests__/HomePage.approvalsTarget.test.tsx (objectui#7307 batch 4, commit 78a9b74a6, PR #8032). No third shape invented." }, "stub_sites": [ "HomePage.marketplaceDisabled.test.tsx:109 (bootOn) - blanket ok:200 sink answering the runtime-config body for EVERY url -> the shared recording router; bootOn now only sets `configAnswer` then awaits initRuntimeConfig()", "HomePage.marketplaceDisabled.test.tsx:199 (fail-open case) - second blanket sink, ok:false status:404 for EVERY url -> `await bootOnNoConfig()`, driving the SAME router whose /runtime/config leg answers 404", "HomePage.aiStudioDisabled.test.tsx:137 (bootOn) - blanket ok:200 sink -> the shared recording router", "HomePage.aiStudioDisabled.test.tsx:263 (fail-open case) - second blanket 404 sink -> `await bootOnNoConfig()`" ], "router_shape": "Batch-4 shape verbatim, widened from one route to two: module-scope `let fetchCalls: string[]`, `routeOf(url) = url.split('?')[0]` (pathname match, because usePendingDrafts appends a ?packageId= scope), `installHomeRouter()` in a module-scope beforeEach doing `vi.stubGlobal('fetch', vi.fn(...))` that pushes every url then routes; afterEach asserts `fetchCalls.filter(url => !SERVED_ROUTES.includes(routeOf(url)))` is empty, then cleanup(), then vi.unstubAllGlobals(), then resetRuntimeConfigForTesting(). The one addition batch 4 did not need: a per-case `configAnswer` for /runtime/config, reset by installHomeRouter to a 404 so a case that never boots cannot inherit the previous case's payload. Each file now holds exactly ONE vi.stubGlobal.", "failure_demonstration": { "leg_a_non_vacuity": "Mutated the afterEach to demand the RECORDED set be empty. On-disk proof: injected anchor count 1, removed anchor `SERVED_ROUTES.includes` count 0; blobs 1e547fa8 -> 0d97c54b and ec59bf4f -> ed63ecfc. OBSERVED RED, exit 1, Test Files 2 failed (2), Tests 14 failed (14), each case enumerating exactly ['/api/v1/runtime/config', '/api/v1/meta/_drafts']. Proves the served set equals the requested set and neither route is decoration.", "leg_b_escape_probe": "Injected `void fetch('/api/v1/ABLATION/unexpected-endpoint')` into each file's renderHome(). On-disk proof: injected anchor count 1 in each; blobs 1e547fa8 -> 62ccca59 and ec59bf4f -> 39de5a39. Predicted red before running. OBSERVED RED, exit 1, Test Files 2 failed (2), Tests 14 failed (14), each case naming the unexpected url. Under the old sink the same probe is answered ok:true with the runtime-config body and stays green.", "restore_proof": "By blob hash, not exit code. After each leg both paths' `git hash-object` equal their HEAD blobs (1e547fa8... and ec59bf4f...) and `git diff HEAD --name-only` is empty. Script carried `trap <restore-from-HEAD> EXIT INT TERM` with absolute paths derived from `git rev-parse --show-toplevel`; both legs ran on the COMMITTED tree (2aeecf4f8). No dist/preflight leg owed: these are the vitest projects' own test files, transformed from source." }, "files_touched": [ "packages/app-shell/src/console/home/__tests__/HomePage.marketplaceDisabled.test.tsx", "packages/app-shell/src/console/home/__tests__/HomePage.aiStudioDisabled.test.tsx", ".changeset/8033-homepage-fetch-router.md" ], "tests": "All exit codes captured by redirect-then-capture, never through a pipe. BASELINE on 4dc80d0fc: `pnpm exec vitest run <the two files>` exit 0, Test Files 2 passed (2), Tests 14 passed (14). FINAL on d8a971b72 (tree clean): `pnpm exec vitest run packages/app-shell/src/console/home/ scripts/__tests__/network-escape-ledger.test.ts` exit 0, Test Files 13 passed (13), Tests 75 passed (75), and zero lines matching `Network escape` or `ECONNREFUSED` in the whole run. `pnpm --filter @object-ui/app-shell run type-check` exit 0 (tsc --noEmit plus tsc -p tsconfig.test.json); NON-VACUITY proven with `tsc -p tsconfig.test.json --listFiles`, which names each edited file exactly once. Its FIRST reading was exit 2 with TS2307 on @object-ui/* - a missing prerequisite, not a red gate; `pnpm --filter '@object-ui/app-shell^...' run build` exit 0 is what it was owed. eslint over both edited files exit 0; --format json reports 2 files linted, 0 errors, 2 warnings, both the pre-existing no-explicit-any on the useObjectLabel mock at marketplaceDisabled:53 and aiStudioDisabled:74 - unmoved lines strictly before every insertion point, so no warning is mine. `node scripts/check-changeset-presence.mjs` exit 1 before the changeset, exit 0 after. `node scripts/check-control-bytes.mjs` exit 0 (6851 tracked text files) plus a `grep -naP` control-range self-scan over all three changed paths, no hits. `node scripts/check-governed-queue-guard.mjs --test` over all changed paths exit 0, NOT GOVERNED, 3 paths against 5 governed surfaces. NOT RUN LOCALLY, declared to CI: the repo-wide `turbo run lint` and `turbo run type-check`, and the full app-shell package suite.", "mcp_calls": "6 - issue_read get, issue_read get_comments, pull_request_read #8032 (to match the PR-body footer convention), create_pull_request, pull_request_read #8661 (body read-back), add_issue_comment (this one). All card/commit reading went through git and the local tree.", "open_questions": [], "out_of_scope_findings": [], "notes": [ "Nothing was escalated to the maintainer floor: no accepted set widened, no contract changed, no gate weakened, no runtime dependency added. Test files only.", "The afterEach asserts BEFORE cleanup(), matching batch 4's 16-file precedent verbatim. Asserting after cleanup() would additionally catch an escape raised by the act-flush, but that would be a third shape and the brief fences it; the root guard's own afterEach still runs after this file's teardown and reds any REAL socket in that window.", "The repo is a shallow clone (`git rev-parse --is-shallow-repository` = true). No ancestry negative was relied on: every reading above is a text or blob reading on the checked-out tree, and `git show` of 78a9b74a6 / 4b9fe9819 resolved inside the shallow window.", "Kept in draft per the dispatch. No ready flip, no auto-merge, no merge, no label written, no assignee written." ] }
Generated by Claude Code
LANDED — objectui#8033 via PR objectui#8661, merged
5798df42amerged_at 2026-09-08T21:17:37Z;mergedandremoved_from_merge_queueshare that timestamp withmerged: true⇒ that removal is the merge, ⛔ not a dequeue.Landing probe on a re-fetched
origin/main(tip5798df42a)⚠️ Read as LINE ORDER, ⛔ never presence — bothcleanupandvi.unstubAllGlobalswere present before this PR too, and the order was the defect. Comment lines excluded from the scan.marketplaceDisabledaiStudioDisabledvi.stubGlobalsites1 ( :163)1 ( :191)cleanup():237:269vi.unstubAllGlobals():238:270⭐ order: cleanup BEFORE unstub ✅ ✅ imports cleanup✅ ✅ firing control — SERVED_ROUTES2 refs 2 refs Nonsense control: ✅ exit 1.
⭐ One
vi.stubGlobalper file is the count that matters: there were four sinks across the two files, and a fix that converted onlybootOn's would have left two behind while looking finished. Four collapsed into two routers, one per file.What is now true that was not
Teardown order. Vitest runs
afterEachhooks in reverse registration order, so a teardown written in the test file ran before the root setup's RTLcleanup()— restoring the realfetchwhile the tree was still mounted, so any read thatcleanup()'s act-flush triggered reached a real socket. From5798df42aboth files callcleanup()first, matching the pairing objectui#7307's batches installed in 16 files.The sink is gone.
bootOn()used to answer every URL with the runtime-config body. ⭐ A sink has no record of what it was handed and no assertion that it served only what it meant to — so a future new escape fromHomePagewould have been silently answered instead of going red. Now a recording router serves exactly/api/v1/runtime/configand/api/v1/meta/_drafts, records everything else, and anafterEachasserts nothing outside that set was requested.⭐ The evidence, because there is still no gate over these two files
Precondition 4 came back stronger than the card stated: not only are these files not on the network-escape ledger — ⭐ there is no ledger.
KNOWN_ESCAPESwas retired when objectui#7307 reached zero, and a test now pins its absence. ⇒ nothing external can prove this change, which is why the PR had to carry its own proof:- Leg A (non-vacuity) — demand the recorded set be empty ⇒ 14/14 red, each case enumerating exactly the two served routes. ⇒ the router really records, and really sees both.
- Leg B (escape probe) — inject an unexpected
fetch⇒ 14/14 red, each naming the URL. ⭐ Under the old sink that same probe is answeredok: trueand stays green. ⇒ the defect reproduced and closed in one measurement.
Both proven on disk by anchor count and blob hash, restored under a trap, restoration proven by blob equality against HEAD — ⛔ not by an exit code.
One design note kept on the record
The
afterEachasserts beforecleanup(), matching batch 4's 16-file precedent verbatim. Asserting after would additionally catch an escape raised by the act-flush — ⭐ but inventing a third shape in this family is the disease this lane keeps carding, and the root guard's ownafterEachstill runs after this file's teardown and reds any real socket in that window.
Generated by Claude Code
Filed unassigned by the
domain:devx@ objectui dev seat while doing batch 4 of #7307 (PR #8032). ⛔ Nodomain:*, type, grade or label set — triage's to produce. Observation, not a red: nothing fails today, and neither file is on the network-escape ledger.Duplicate check before filing: read the full open set updated since 2026-09-06T09:19Z over repo-scoped REST (23 cards: #8031 #8030 #8029 #8028 #8027 #8026 #8024 #8021 #8018 #8015 #7990 #7839 #7750 #7711 #7582 #7401 #7310 #7307 #7058 #6892 #5748 #5560 #5174), on top of the PM seat's full open
domain:devxread at 09:19Z. #7996 is the nearest neighbour and is a different instance — it is about an explain double answering a key no hook reads; this is about teardown ORDER and about a stub that answers everything. #7439 is the card that named the ordering hazard; it is closed as completed and fixed exactly one file (RecordDetailView.approvalDeclaredActions.test.tsx). No open card covers these two files.What was measured
Two files in
packages/app-shell/src/console/home/__tests__/install afetchstub and tear it down like this:Neither file imports
cleanup, and neither calls it beforevi.unstubAllGlobals().Two distinct things are off, both named verbatim by the network-escape guard's own
Fix:text invitest.setup.network-escape-guard.ts.1. The teardown order is the #7439 order, inverted
Vitest runs
afterEachhooks in REVERSE registration order, so a teardown written in a test file runs BEFORE the root setup's RTLcleanup(). Unstubbing there restores the realfetchwhile the tree is still mounted, so any read thatcleanup()'s act-flush triggers reaches a real socket. That is the whole content of #7439, and the guard's error text spells out the remedy: "callcleanup()BEFOREvi.unstubAllGlobals()". Batches 1 to 4 of #7307 have installed exactly that pairing in 16 files; these two sit next door to batch 4's four and do the opposite.Why it is green today rather than flaky:
HomePagemountsPendingDraftsBanner, whoseusePendingDraftsfetches only from its mount effect and issues nothing on unmount, so the window the inverted order opens is currently empty. That is a property of today'sHomePage, not of these files — it is exactly the kind of thing that changes under someone else's PR, and #7439 was found because it cost an unrelated PR its enqueue window.2. The stub is a sink, not a router
bootOn()answers every URL with the runtime-config body:HomePagereaches at least two endpoints during these renders —GET /api/v1/runtime/config(what the stub is for) andGET /api/v1/meta/_drafts(usePendingDrafts.ts:48, measured in #7307 batch 4 as the escape route of the four sibling files in this directory). The_draftsreader parses the runtime-config body, finds nodraftskey, and yields[]— inert, so nothing fails. But it means the double documents a request/response pairing that does not exist, and, more importantly, a future new escape fromHomePagewould be silently answered instead of going red: the sink has no record of what it was handed and no assertion that it only served what it meant to serve. The router shape #5225 settled on, and that #7307's batches use, fails on any URL outside the served set.Why it is worth a line rather than a fix here
It is out of scope for #7307 batch 4, which owns four named files and two ledger literals; neither of these two files is on the ledger, so touching them would be a scope breach with no gate to prove the change. ⛔ Not fixed in PR #8032.
The fix, when someone takes it, is mechanical and is the same one landed sixteen times already: import
cleanup, call it beforevi.unstubAllGlobals(), and turnbootOn's stub into a recording router over the two routes with anafterEachassertion that nothing else was requested.