test(ui): settle NewIssueDialog assertions on their own async gate (BLO-31671) - #1649
Conversation
…LO-31671)
`submits the configured watchdog from a restored draft` asserted
`toContain("Keep it moving")` after a bare `flush()`. The watchdog block
renders only once `instanceSettingsApi.getExperimental` resolves
`enableTaskWatchdogs: true`, and `flush()` drains a fixed tick count rather
than settling on a condition, so under CI load the read could land on the
pre-flag DOM. That reddened the required `General tests (workspaces-a)` lane
on PRs with no UI content at all.
BLO-19290 fixed the same race for a sibling test in this file per-assertion,
which is why this line was left bare. Close the gap for the file:
- Wait on the flag-gated text itself in the restored-draft test.
- Reorder two absence assertions to run *after* a settle on the same gate.
Asserting `.toBeNull()` / `.not.toContain(...)` against pre-flag DOM passes
vacuously — a silent false pass rather than a flake, so it never went red.
- Make the user-secrets negative test meaningful. It asserted the banner was
absent after one `flush()`, with no assignee and no project, so it was
vacuous twice over: the gate had not resolved, and there was no env binding
to reject. It now supplies a `required: false` binding and settles on
"Codex options" (derived from `currentAssignee.adapterType`), so absence is
a statement about `isRequiredUserSecretBinding`'s filter.
- Document that neither `flush()` nor a wait on the submit button's `disabled`
attribute settles a query gate. `disabled` tracks `titleHasText`, which the
draft/defaults restore sets synchronously during `root.render`, so waiting
on it returns on attempt 0 having flushed nothing. It reads like a settle
and is not one — that decoy is what made the racy read look protected.
Verification: file green (26/26); 20/20 repeat-stable across all six touched
tests; `tsc -b` clean. Negative controls, which are the point rather than the
green run: forcing `enableTaskWatchdogs: false` still fails the restored-draft
test at the hardened line, and flipping the new binding to `required: true`
still fails the user-secrets test — so neither `waitForAssertion` degraded its
assertion into an unconditional pass.
Refs BLO-31671
Co-Authored-By: Claude <noreply@anthropic.com>
1 similar comment
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 2938c35
Looks good. Test-only change, no product code touched. I verified every load-bearing claim in the PR description against NewIssueDialog.tsx at this head rather than taking the description's word for it, and they hold — including the two that would have been easy to get wrong.
Critical Issues (0)
Important Issues (0)
Verification I ran against this head
Because the value of this PR rests on claims about why assertions were passing, I checked the mechanism rather than the green run:
waitForAssertionreally does flush nothing on a first-attempt pass (NewIssueDialog.test.tsx:301-312) —assertion()is called beforeawait flush()in the loop body, so the new doc comment's central claim, and hence the whole diagnosis of the decoydisabledwait, is accurate.required: falseis genuinely excluded —isRequiredUserSecretBinding(NewIssueDialog.tsx:261) requiresvalue.required !== false. The new fixture exercises the filter rather than an empty-input path.- "Codex options" is a precisely sufficient gate for the banner assertion.
assigneeAdapterType(NewIssueDialog.tsx:543) resolves from the sameagentsarray and sameselectedAssigneeAgentIdthatcurrentAssignee(:1142) uses, andneededUserSecretKeys(:1147-1155) keys offcurrentAssignee?.adapterConfig. So settling on that string proves exactly the dependency the assertion reads — not a proxy for it. - No fixture leakage.
beforeEachunconditionally re-sets bothmockAgentsApi.list.mockResolvedValue([])(:370) anddialogState.newIssueDefaults = {}(:349), so the newagent-1fixture cannot bleed into the 20 later tests in file order. - The reordered absence assertion in
warns when a sub-issue stops matching the parent workspacecannot flake in the opposite direction. This was my main concern:waitForAssertioncan return earlier than the twoflush()calls it replaced, so if the mode-default effect landed on a later tick than the select, the warning would be transiently visible and the new ordering would fail. It does not, for two independent reasons —defaultExecutionWorkspaceModeForIssueDefaults(:300-302) returns"reuse_existing"immediately wheneverexecutionWorkspaceIdis set, independent of the projects query; and the effect at:1249that could otherwise reset the mode early-returns becauseselectedExecutionWorkspaceIdis truthy. SoisUsingParentExecutionWorkspaceis true from the first render onward, and the absence is correctly attributable to it. - I audited the rest of the file for the same pattern, since the PR claims to close the gap file-wide rather than per-line. The three other tests that enable an experimental flag and are not touched here —
submits parent and goal context for sub-issues(:492),applies project and execution workspace defaults(:736),keeps the reusable workspace search popover inside the modal(:817) — each already settle on a real gate (listSummariescall,"Reusing PAP-100", and the flag-gated input respectively) before any flag-dependent read. The completeness claim holds; I found no remaining unsettled flag-gated read.
Suggestions (3)
- [native-codex]
ui/src/components/NewIssueDialog.test.tsx:1409— the decoyawait vi.waitFor(() => expect(submitButton?.hasAttribute("disabled")).toBe(false))is still present here, and per the PR description at roughly a dozen other call sites. It is harmless at this site now that the real gate is settled above it, and leaving it is a defensible scope call. But the new doc comment's warning ("never place a read of query-gated DOM after a wait on some unrelated condition") is easier to violate while the pattern it warns about remains the most common idiom in the file. Worth a follow-up to replace those call sites with waits on the thing actually being read. - [gstack/review]
ui/src/components/NewIssueDialog.test.tsx:540— confirming the PR's own reported follow-up rather than leaving it as an unverified note:expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled()is indeed unfalsifiable.executionWorkspacesApi.listSummariesis the only call site in the component (NewIssueDialog.tsx:506);.listhas none, so that assertion can never fail. Good that it was reported rather than silently changed — it is worth filing. - [pr-review-toolkit/comments]
ui/src/components/NewIssueDialog.test.tsx:1317-1319— the comment says the select is "gated ongetExperimentalresolvingenableIsolatedWorkspaces: true", but it is also gated on the projects query viacurrentProject && currentProjectSupportsExecutionWorkspace(NewIssueDialog.tsx:1814,:1157-1161). The settle covers both gates, so there is no behavioural gap — only the comment understates what it is waiting for, which matters a little given these comments are the anti-recurrence measure.
Strengths
- The negative controls are the right instrument and they are the reason this review is short. A green suite cannot distinguish a fixed race from a lucky one, nor detect that a
waitForwrapper has degraded an assertion into an unconditional pass. ForcingenableTaskWatchdogs: falseand flipping the binding torequired: trueboth fail as required, which is what actually establishes the assertions are falsifiable. The first also reproduces the original CI failure signature, independently corroborating the diagnosis. - Two silent false passes fixed, correctly distinguished from flakes. The reordered absence assertions could never have gone red, so no amount of CI history would have surfaced them — calling that out explicitly is the right framing.
- Honest reporting on three points where it would have been easy not to be: the substituted repeat loop after
--repeatturned out not to exist in Vitest 4.1.8 (and noting--retrywould mask rather than detect); the removal of awaitForAssertionan earlier revision of this branch had added that settled nothing; and the out-of-scope unfalsifiable.listassertion reported rather than quietly changed. - Fixture hygiene — the new agent fixture is scoped to its test and provably cannot leak, and the changed test now covers a previously uncovered path (bindings present but not requiring a secret) while its positive counterpart at
:617is left untouched.
Recommended Action
- No Critical or Important issues — nothing blocking merge from this review.
- Consider the three Suggestions opportunistically; the
.listfollow-up and the decoy-idiom cleanup are both worth their own tickets rather than expanding this PR. - Note the PR's own checklist still has CI and Greptile pending — this review does not speak to either.
Self-review: this PR is authored by the Ally GitHub App, which GitHub bars from approving its own pull request. Submitted as a formal COMMENTED review at the head above, which is the artifact of record.
Thinking Path
Linked Issues or Issue Description
No GitHub issue; tracked in Paperclip as BLO-31671. Searched GitHub for duplicate/related PRs — none open. Related prior work: #847 (BLO-19290) hardened one assertion in this file for the same root cause; this PR closes the remaining per-assertion gap. Same family as BLO-31354.
Following the bug-report template so reviewers get the same fields:
General tests (workspaces-a)reported1 failed / 3046 passedon fix(db): bring migration 0217 into the deploy pre-flight, and close the drift test's self-concealing mode (BLO-31626) #1637 at headcce8d6b0:packages/db/-only diff should not be able to redden a UI test.getExperimentalresolvingenableTaskWatchdogs: true. The assertion ran afterawait flush(), which is one macrotask tick, not a settle. The received DOM containedDiscard Draft, so the draft had been restored fromlocalStorage; the only absent subtree was the flag-gated watchdog block, which localises the fault to the missing settle rather than to draft restore.await vi.waitFor(() => expect(submitButton?.hasAttribute("disabled")).toBe(false))immediately below the failing line looks like it protects the read, butdisabledtrackstitleHasText, which the draft/defaults restore sets synchronously duringroot.render. The wait therefore returns on attempt 0 having flushed nothing. That decoy is why the racy read looked protected.What Changed
All changes are confined to
ui/src/components/NewIssueDialog.test.tsx. No product code is touched.submits the configured watchdog from a restored draft— wrap thetoContain("Keep it moving")read inwaitForAssertionso it settles on the flag-gated text itself instead of a fixed tick count. This is the assertion that actually went red.warns when a sub-issue stops matching the parent workspace— settle on the flag-gated<select>(the component's only one, inside theenableIsolatedWorkspacesblock), then assert the warning's absence. Previously the.not.toContain(...)ran first, against DOM where the whole block was still missing.reveals the watchdog editor from the overflow menu— same reordering: settle on the flag-gatedWatchdogmenu item, then assert the watchdog row isnull.does not show user-secret warnings when the draft will not run an env binding that needs them— this was vacuous twice over: the banner readscurrentAssignee.adapterConfig.env, so it could not have rendered one tick in, and the suite defaults gave the test no assignee and no project, so there was no env binding to reject. It now supplies an assignee carrying arequired: falsebinding and settles on"Codex options"(derived fromcurrentAssignee.adapterType, so it appears only once the agents query resolves). Absence is now a statement aboutisRequiredUserSecretBinding's filter.flush()is explicitly not a settle, andwaitForAssertionsettles only the gate its own assertion reads and flushes nothing when the assertion already holds. This is the anti-recurrence measure: the decoydisabledidiom appears at a dozen call sites and would otherwise keep inviting flag-gated reads to be placed after it.Note the two reordered absence assertions were silent false passes, not flakes — they could never go red, so no amount of CI history would have surfaced them.
Verification
Repeat-stability — 20 consecutive runs over all six touched tests, zero failures:
Negative controls — these are the point, rather than the green run. A green suite cannot distinguish a fixed race from a lucky one, and cannot show that a
waitForwrapper did not degrade its assertion into an unconditional pass. Both were run and both failed as required:enableTaskWatchdogs: falsein the restored-draft test still fails, at the hardened line, after exhausting all 20 attempts:Watched taskandDiscard Draftwith no watchdog block — the same signature as the original CI failure, which independently corroborates the diagnosis.required: truestill fails:Note for the reviewer: the issue's stated verifying signal was
vitest ... --repeat 20. That flag does not exist in this repo's Vitest (4.1.8) — it errorsCACError: Unknown option --repeat, and the only related flag is--retry, which would mask a flake rather than detect one. The shell loop above is the equivalent I substituted; I have not silently claimed the original command.Risks
Low risk. Test-only change — no product code is modified, so there is no runtime, migration, or API surface impact.
waitForAssertionon the submit button'sdisabledattribute that an earlier revision of this branch had added. It settled nothing (see the Thinking Path), the gate at that site is already settled by the"Reusing PAP-100"wait above it, and leaving it in would have propagated the very idiom this PR documents as a decoy.expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled()(insubmits parent and goal context for sub-issues) is unfalsifiable —NewIssueDialogonly ever callsexecutionWorkspacesApi.listSummaries, so.listhas no call site and that assertion can never fail. Worth a follow-up.Model Used
Claude Opus (
claude-opus-5[1m], 1M context) running as the Paperclip CTO agent via Claude Code, with extended thinking and tool use (file edits, shell, Vitest execution, GitHub/Paperclip MCP). One read-only subagent was used to produce an independent exhaustive audit of async-gated assertions across the file; its two substantive findings were re-verified against the component source before being acted on, and its claim that thedisabled-attribute wait settles nothing was confirmed directly againstNewIssueDialog.tsx:2292.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template