Skip to content

fix(test): flush effects before writing input in MainConfig walkthrough tests - #137

Merged
dawsontoth merged 2 commits into
mainfrom
fix/ink-walkthrough-effect-flush
Jul 27, 2026
Merged

fix(test): flush effects before writing input in MainConfig walkthrough tests#137
dawsontoth merged 2 commits into
mainfrom
fix/ink-walkthrough-effect-flush

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the MainConfig walkthrough tests in ink/main.test.tsx, which were failing on roughly two of every three CI runs and blocking every PR in this repo.

The cause

It looked like timing flake — the failing test case and assertion moved between runs — but there's a specific mechanism.

Ink paints a frame during render, while useInput subscribes to stdin in a passive effect. Between those two points, the step being left is still the one listening. waitForFrame polled lastFrame(), so it returned as soon as the new step painted — before that step's useInput had subscribed — and the following stdin.write went to the outgoing step's handlers and was dropped.

I confirmed this by instrumenting ink's useInput. After advancing to ApiKeyStep, the handlers actually invoked for the API-key write were still ProviderStep's onExit and its Select's focusNextOption; ApiKeyStep's and BlinkingTextInput's handlers subscribed only afterwards:

emitInput "sk-test-key"  → INVOKE Select.focusNextOption + ProviderStep.onExit   (stale)
UNSUB (ProviderStep, Select)      ← effects flush only now
SUB   (BlinkingTextInput, ApiKeyStep)

That also explains why 1c67a48 (fixed sleeps → polling) didn't settle it: the sleeps had been incidentally covering the effect flush that polling no longer waits for.

The change

  • waitForFrame awaits act() after its frame assertion, so effects commit before the next keystroke.
  • Arrow-key navigation had the same gap — down and enter were written back to back, so a down that hadn't landed would silently select the wrong provider. selectProvider now confirms one highlight at a time.
  • The ExitUI test gets the same settle(), since useListener also subscribes in an effect. It passes today but was latently racy.
  • waitForMaskedInput collapsed into maskFor() + waitForFrame; its old signature took a text argument and used only its .length, which was misleading.

Verification

Clean worktree, Node 24.17:

result
previous test (control, same worktree) 4/4 runs failed
this version 10/10 runs passed
full suite 53 files / 345 tests green
dprint check, oxlint, npm run build clean

Where to look

  • selectProvider makes no ordering assumption. It walks the highlight down until the requested provider is selected, so it survives changes to ProviderStep's sort; providers supplies only the loop bound. (First revision hardcoded the order — @gemini-code-assist flagged the duplication, addressed in 43d86eb.)
  • act() from react on an ink tree. It flushes ink's reconciler because ink builds on react-reconciler, and no act-environment warnings appear. Worth a sanity check if you've seen ink and act interact badly before.
  • Product behaviour is untouched. The same gap exists at runtime in principle — a keystroke arriving between a step's render and its effect commit would be dropped — but a human types far slower than the flush, so I treated this as test-only rather than reworking the wizard. Flagging it in case you'd rather harden ConfigurationWizard instead.

Both commits were verified the same way (10/10 runs, full suite green, format/lint/build clean).

Note the step-10 cross-model review was not run: the cross-model-review skill isn't installed in this environment, and spawning a Claude reviewer would defeat its purpose. This is a test-only change, but the act() choice and the ordering coupling above are the spots that would most benefit from a second opinion.

🤖 Generated with Claude Code (Claude Opus 5)

…gh tests

The four MainConfig walkthrough tests failed on roughly two of every three CI
runs, blocking every PR in the repo. The failing case and assertion moved
between runs, which read as timing flake but has a specific cause.

Ink paints a frame during render, while `useInput` subscribes to stdin in a
passive effect. Between those two points the step being left is still the one
listening. `waitForFrame` polled `lastFrame()`, so it returned as soon as the
new step painted -- before that step's `useInput` was subscribed -- and the next
`stdin.write` was delivered to the outgoing step's handlers and dropped.

Traced by instrumenting ink's `useInput`: after advancing to ApiKeyStep, the
handlers invoked for the API key write were still ProviderStep's `onExit` and its
Select's `focusNextOption`, with ApiKeyStep's and BlinkingTextInput's handlers
subscribing only afterwards.

This is why 1c67a48 (fixed sleeps -> polling) did not settle it: the sleeps had
been incidentally covering the effect flush that the polling no longer waits for.

`waitForFrame` now awaits `act()` after the frame assertion, so effects commit
before the next keystroke. Arrow-key navigation went through the same gap --
`down` and `enter` were written back to back, so a `down` that had not landed
would select the wrong provider -- and is now confirmed one highlight at a time
in `selectProvider`. The ExitUI test gets the same treatment, since
`useListener` also subscribes in an effect.

Verified in a clean worktree on Node 24.17: the previous test fails 4/4 runs,
this version passes 10/10, and the full suite is 53 files / 345 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gemini-code-assist[bot]

This comment was marked as resolved.

Addresses the review note that the hardcoded provider order duplicated
ProviderStep's sort.

Rather than importing `providers` and re-implementing that comparator in the test
(which trades one duplication for another), `selectProvider` now walks the
highlight down until the requested label is selected. `providers` is used only to
bound the loop, so the test no longer encodes an ordering at all and keeps working
if ProviderStep's sort changes.

The explicit unknown-provider guard is gone with it: the bound plus the following
`waitForFrame` assertion already turn a label that never highlights into a clear
failure instead of a hang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dawsontoth
dawsontoth marked this pull request as ready for review July 27, 2026 15:28
@dawsontoth
dawsontoth merged commit 706ec5f into main Jul 27, 2026
6 checks passed
@dawsontoth
dawsontoth deleted the fix/ink-walkthrough-effect-flush branch July 27, 2026 15:28
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.16.29 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant