From fd5155925e6e6c424f4b6a21458e7778ce5148bb Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 11:51:47 -0700 Subject: [PATCH] Lock down the three flaky visual snapshots - RemoteControlSection: freeze `Date.now` for the file and let the stub mint the setup code under it; the QR encodes its expiry, so a real clock drew a new code every run. - Kill confirmation: `cfg.killConfirm.char` pins the letter, set under visual snapshots. - Wall TerminalContext: hold the capture until the helper's autorun has finished rather than whichever of waiting/running it had reached. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/layout.md | 2 +- lib/.storybook/preview.ts | 2 ++ lib/src/cfg.ts | 6 ++++++ lib/src/components/KillConfirm.test.ts | 16 ++++++++++++++++ lib/src/components/KillConfirm.tsx | 3 ++- lib/src/stories/RemoteControlSection.stories.tsx | 14 ++++++++++++-- lib/src/stories/Wall.stories.tsx | 5 +++++ 7 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 lib/src/components/KillConfirm.test.ts diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 7b720bf5e..d67164047 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -312,7 +312,7 @@ The source cwd is read from `getInheritableCwd(sourceId)`. **Never inherit a rem ### Kill confirmation -`x`/`k` (or the kill button, which first leaves passthrough) shows a pane-centered semi-transparent overlay (`KillConfirmOverlay` → `KillConfirmModal`) with a random lowercase letter; typing it confirms the kill. **`x` and `k` are excluded from that alphabet** so a double-tap can't accept itself. `Escape`, the `Esc to cancel` button, and clicking another panel cancel; any other key runs a 400ms `shake-x` animation and then auto-dismisses. +`x`/`k` (or the kill button, which first leaves passthrough) shows a pane-centered semi-transparent overlay (`KillConfirmOverlay` → `KillConfirmModal`) with a random lowercase letter (`cfg.killConfirm.char` pins it for visual snapshots); typing it confirms the kill. **`x` and `k` are excluded from that alphabet** so a double-tap can't accept itself. `Escape`, the `Esc to cancel` button, and clicking another panel cancel; any other key runs a 400ms `shake-x` animation and then auto-dismisses. **Confirmation must be staged in a ref synchronously, not only in React state** — a second confirm keydown arriving before React flushes would otherwise pass the guard and kill twice (`lath.isDying` is the second line of defense). diff --git a/lib/.storybook/preview.ts b/lib/.storybook/preview.ts index 3592dfcd6..bf6a54b6e 100644 --- a/lib/.storybook/preview.ts +++ b/lib/.storybook/preview.ts @@ -115,6 +115,8 @@ if (visualSnapshot) { // seconds after it appears, which a play function cannot outrun: whether it is // still on screen at capture time depends on how loaded the runner is. cfg.overlays.warningAutoDismissMs = 0; + // One kill-confirm letter rather than a random one per prompt. + cfg.killConfirm.char = 'q'; // Zero every CSS transition. Unlike the keyframe animations above, each of // which has a static substitute, transitions are started by state that lands // AFTER first paint — the primed-state decorator applies two rAFs in, which diff --git a/lib/src/cfg.ts b/lib/src/cfg.ts index aabb98471..9c78711da 100644 --- a/lib/src/cfg.ts +++ b/lib/src/cfg.ts @@ -59,6 +59,12 @@ export const cfg = { * Snapping straight to the final geometry removes that whole race. */ animate: true, }, + killConfirm: { + /** The letter a kill confirmation asks for; null draws one at random per + * prompt. Pinned under visual snapshots, where a random letter would + * change the capture on every run. */ + char: null as string | null, + }, overlays: { /** ms before the illegal-rename warning dismisses itself. 0 disables the * timer entirely — what Chromatic uses, because a popover that removes diff --git a/lib/src/components/KillConfirm.test.ts b/lib/src/components/KillConfirm.test.ts new file mode 100644 index 000000000..1c47b8504 --- /dev/null +++ b/lib/src/components/KillConfirm.test.ts @@ -0,0 +1,16 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cfg } from '../cfg'; +import { randomKillChar } from './KillConfirm'; + +describe('randomKillChar', () => { + afterEach(() => { cfg.killConfirm.char = null; }); + + it('answers the pinned letter when one is set', () => { + cfg.killConfirm.char = 'q'; + expect(Array.from({ length: 20 }, randomKillChar)).toEqual(Array(20).fill('q')); + }); + + it('draws a lowercase letter other than x or k when unpinned', () => { + for (let i = 0; i < 200; i++) expect(randomKillChar()).toMatch(/^[a-jl-wyz]$/); + }); +}); diff --git a/lib/src/components/KillConfirm.tsx b/lib/src/components/KillConfirm.tsx index 6ed69b5e3..a3b10becb 100644 --- a/lib/src/components/KillConfirm.tsx +++ b/lib/src/components/KillConfirm.tsx @@ -1,6 +1,7 @@ import { useRef } from 'react'; import { resolvePaneElement } from './wall/resolve-pane-element'; import { ModalFrame, Shortcut, type ModalLayer } from './design'; +import { cfg } from '../cfg'; export type KillExit = 'shake' | 'confirm'; @@ -16,7 +17,7 @@ export const KILL_CONFIRM_MS = 220; // Excludes both kill shortcuts ('x' and 'k') so a double-tap can't accept itself. const KILL_CONFIRM_CHARS = 'abcdefghijlmnopqrstuvwyz'; export function randomKillChar(): string { - return KILL_CONFIRM_CHARS[Math.floor(Math.random() * KILL_CONFIRM_CHARS.length)]; + return cfg.killConfirm.char ?? KILL_CONFIRM_CHARS[Math.floor(Math.random() * KILL_CONFIRM_CHARS.length)]; } export function KillConfirmModal({ diff --git a/lib/src/stories/RemoteControlSection.stories.tsx b/lib/src/stories/RemoteControlSection.stories.tsx index a5a160a34..e77bf686e 100644 --- a/lib/src/stories/RemoteControlSection.stories.tsx +++ b/lib/src/stories/RemoteControlSection.stories.tsx @@ -6,7 +6,6 @@ import { enrolledStatus, OFFER_STATUS, UNENROLLED_STATUS, - setupQrResult, } from '../host/remote/test-burrow-link'; import { TEST_SETUP_PASSWORD } from '../remote/test-setup-password'; @@ -32,9 +31,18 @@ function RemoteControlStory() { ); } +/** The clock every story here reads. A setup code encodes its expiry, so a real + * clock would draw a different QR on every run. */ +const STORY_NOW = Date.UTC(2026, 0, 1); + const meta: Meta = { title: 'Modals/RemoteControlSection', component: RemoteControlStory, + beforeEach: () => { + const realNow = Date.now; + Date.now = () => STORY_NOW; + return () => { Date.now = realNow; }; + }, // Embedded in a docs page, each of these needs its own frame. The section // reads a module-singleton store (`burrow-status-store.ts`: `state` is module // scope, and the link is captured only when `listeners.size === 1`), so N @@ -190,7 +198,9 @@ export const ConfirmingDisconnect: Story = { */ export const SetupPhoneQr: Story = { parameters: { - primedBurrow: { status: enrolledStatus(), setupQr: setupQrResult() }, + // No `setupQr`: the stub's default mints at request time, under the frozen + // clock, where one built here would read the real clock at import. + primedBurrow: { status: enrolledStatus() }, docs: { story: { height: '520px' } }, }, // The one setup-panel story that settles on the QR's accessible name rather diff --git a/lib/src/stories/Wall.stories.tsx b/lib/src/stories/Wall.stories.tsx index 5bb324bb2..9b3fb5a5c 100644 --- a/lib/src/stories/Wall.stories.tsx +++ b/lib/src/stories/Wall.stories.tsx @@ -220,5 +220,10 @@ export const TerminalContext: Story = { const header = await requireElement('[data-pane-header-for="context-live"]', 'terminal header'); header.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); await waitForCondition(() => !!document.querySelector('[data-helper-terminal]')); + // Hold until autorun has finished and the helper has painted, so the capture + // is never the "Waiting for shell…" or "Running …" frame on the way there. + await waitForCondition(() => + document.querySelector('[aria-label="Helper terminal status"]')?.textContent?.includes('autoran') ?? false); + await settleTerminals(); }, };