-
Notifications
You must be signed in to change notification settings - Fork 336
test(react-grab): coverage suite — unit + e2e sweep #510
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
aidenybai
wants to merge
10
commits into
main
Choose a base branch
from
test/coverage-suite
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
26d29e4
test(react-grab): cover freeze pause/resume for non-useState hooks
aidenybai 2679cd1
test(react-grab): address review — extract harness, fix comments, set…
aidenybai 447c908
test(react-grab): unit-test findTailwindClass across all chip scales
aidenybai 4f32220
test(react-grab): unit-test pure utils + error classes
aidenybai 3b8cb1c
test(react-grab): unit-test Next runtime detection + base path
aidenybai e5c8618
test(react-grab): cover data-typed branches in CSS/color utils
aidenybai d1e4bf8
test(react-grab): e2e sweep for the global keyboard handler
aidenybai e8477dc
test(react-grab): cover enrichServerFrameLocations owner-stack merge
aidenybai a05b77e
test(react-grab): cover freeze-updates replay order and recovery arms
aidenybai ae051c9
fix
aidenybai File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| import { | ||
| createContext, | ||
| useContext, | ||
| useReducer, | ||
| useState, | ||
| useSyncExternalStore, | ||
| useTransition, | ||
| } from "react"; | ||
|
|
||
| // e2e fixture that drives react-grab's freeze dispatcher patching for hooks | ||
| // beyond plain useState: useReducer, useTransition, useSyncExternalStore, and | ||
| // context dependencies. freeze-hooks.spec.ts freezes the page, bumps each | ||
| // counter, and asserts the displayed value holds while frozen and resumes | ||
| // afterwards. (The transition/context counters still update via useState | ||
| // internally; the point is to exercise those dispatcher/queue paths.) | ||
| interface CounterAction { | ||
| by: number; | ||
| } | ||
|
|
||
| const counterReducer = (count: number, action: CounterAction): number => count + action.by; | ||
|
|
||
| const ReducerCounter = () => { | ||
| const [count, dispatch] = useReducer(counterReducer, 0); | ||
| return ( | ||
| <div className="flex items-center gap-2" data-testid="reducer-counter"> | ||
| <span data-testid="reducer-count">{count}</span> | ||
| <button | ||
| type="button" | ||
| className="border px-2 py-1 rounded" | ||
| onClick={() => dispatch({ by: 1 })} | ||
| data-testid="reducer-increment" | ||
| > | ||
| Reducer +1 | ||
| </button> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| const TransitionCounter = () => { | ||
| const [count, setCount] = useState(0); | ||
| const [, startTransition] = useTransition(); | ||
| return ( | ||
| <div className="flex items-center gap-2" data-testid="transition-counter"> | ||
| <span data-testid="transition-count">{count}</span> | ||
| <button | ||
| type="button" | ||
| className="border px-2 py-1 rounded" | ||
| onClick={() => startTransition(() => setCount((previous) => previous + 1))} | ||
| data-testid="transition-increment" | ||
| > | ||
| Transition +1 | ||
| </button> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| let externalStoreValue = 0; | ||
| const externalStoreListeners = new Set<() => void>(); | ||
| const externalStore = { | ||
| subscribe: (listener: () => void) => { | ||
| externalStoreListeners.add(listener); | ||
| return () => { | ||
| externalStoreListeners.delete(listener); | ||
| }; | ||
| }, | ||
| getSnapshot: () => externalStoreValue, | ||
| increment: () => { | ||
| externalStoreValue += 1; | ||
| for (const listener of externalStoreListeners) listener(); | ||
| }, | ||
| }; | ||
|
|
||
| const ExternalStoreCounter = () => { | ||
| const value = useSyncExternalStore(externalStore.subscribe, externalStore.getSnapshot); | ||
| return ( | ||
| <div className="flex items-center gap-2" data-testid="store-counter"> | ||
| <span data-testid="store-count">{value}</span> | ||
| <button | ||
| type="button" | ||
| className="border px-2 py-1 rounded" | ||
| onClick={() => externalStore.increment()} | ||
| data-testid="store-increment" | ||
| > | ||
| Store +1 | ||
| </button> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| const CountContext = createContext(0); | ||
|
|
||
| const ContextConsumer = () => { | ||
| const value = useContext(CountContext); | ||
| return <span data-testid="context-count">{value}</span>; | ||
| }; | ||
|
|
||
| const ContextCounter = () => { | ||
| const [value, setValue] = useState(0); | ||
| return ( | ||
| <CountContext.Provider value={value}> | ||
| <div className="flex items-center gap-2" data-testid="context-counter"> | ||
| <ContextConsumer /> | ||
| <button | ||
| type="button" | ||
| className="border px-2 py-1 rounded" | ||
| onClick={() => setValue((previous) => previous + 1)} | ||
| data-testid="context-increment" | ||
| > | ||
| Context +1 | ||
| </button> | ||
| </div> | ||
| </CountContext.Provider> | ||
| ); | ||
| }; | ||
|
|
||
| export const FreezeHookHarness = () => { | ||
| return ( | ||
| <section className="border rounded-lg p-4" data-testid="freeze-hooks-section"> | ||
| <h2 className="text-lg font-bold mb-4">Freeze Hook Harness</h2> | ||
| <div className="space-y-3"> | ||
| <ReducerCounter /> | ||
| <TransitionCounter /> | ||
| <ExternalStoreCounter /> | ||
| <ContextCounter /> | ||
| </div> | ||
| </section> | ||
| ); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import { test, expect, type ReactGrabPageObject } from "./fixtures.js"; | ||
|
|
||
| // Covers react-grab's freeze dispatcher patching for hooks beyond useState. The | ||
| // FreezeHookHarness fixture exposes a useReducer, useTransition, | ||
| // useSyncExternalStore, and context-backed counter; each holds its displayed | ||
| // value while the page is frozen (prompt mode) and resumes afterwards. This | ||
| // drives the freeze-updates.ts pause/resume paths the useState-only | ||
| // freeze-updates.spec never reaches, notably context-dependency pause/resume. | ||
|
|
||
| const FREEZE_TARGET = "[data-testid='dynamic-element-1']"; | ||
|
|
||
| // Async React work scheduled by a click needs a beat to (not) commit before we | ||
| // read, so the freeze-hold assertion can actually catch a leaked update and the | ||
| // post-unfreeze baseline is stable. Mirrors freeze-updates.spec's settle waits. | ||
| const FREEZE_SETTLE_MS = 150; | ||
|
|
||
| const readCount = async (reactGrab: ReactGrabPageObject, testId: string): Promise<number> => { | ||
| const text = await reactGrab.page.locator(`[data-testid='${testId}']`).textContent(); | ||
| if (text === null) throw new Error(`Counter "${testId}" not found`); | ||
| return Number(text.trim()); | ||
| }; | ||
|
|
||
| // While react-grab is active its overlay intercepts pointer events, so a real | ||
| // click would be swallowed. Dispatch a synthetic DOM click straight to the | ||
| // element instead, matching the approach in freeze-updates.spec. | ||
| const clickByTestId = async (reactGrab: ReactGrabPageObject, testId: string): Promise<void> => { | ||
| await reactGrab.page.evaluate((id) => { | ||
| const button = document.querySelector<HTMLElement>(`[data-testid='${id}']`); | ||
| button?.click(); | ||
| }, testId); | ||
| }; | ||
|
|
||
| // Verifies a hook-driven counter is frozen while the page is frozen, then bumps | ||
| // normally once unfrozen. `countTestId` shows the value; `incrementTestId` bumps | ||
| // it. | ||
| const assertFreezeHoldsThenResumes = async ( | ||
| reactGrab: ReactGrabPageObject, | ||
| countTestId: string, | ||
| incrementTestId: string, | ||
| ): Promise<void> => { | ||
| const before = await readCount(reactGrab, countTestId); | ||
|
|
||
| await reactGrab.enterPromptMode(FREEZE_TARGET); | ||
| await clickByTestId(reactGrab, incrementTestId); | ||
| await clickByTestId(reactGrab, incrementTestId); | ||
| await reactGrab.page.waitForTimeout(FREEZE_SETTLE_MS); | ||
|
|
||
| // Frozen: the displayed value must not move while the page is frozen. | ||
| expect(await readCount(reactGrab, countTestId)).toBe(before); | ||
|
|
||
| await reactGrab.pressEscape(); | ||
| await reactGrab.deactivate(); | ||
| await reactGrab.page.waitForTimeout(FREEZE_SETTLE_MS); | ||
|
|
||
| // Unfrozen: a fresh bump increments from whatever value settled after | ||
| // unfreeze, proving the hook queue was restored cleanly. | ||
| const afterUnfreeze = await readCount(reactGrab, countTestId); | ||
| await clickByTestId(reactGrab, incrementTestId); | ||
| await expect.poll(() => readCount(reactGrab, countTestId)).toBe(afterUnfreeze + 1); | ||
| }; | ||
|
|
||
| test.describe("Freeze Hook Buffering", () => { | ||
| test.beforeEach(async ({ reactGrab }) => { | ||
| await reactGrab.registerCommentAction(); | ||
| }); | ||
|
|
||
| test("useReducer counter freezes then resumes", async ({ reactGrab }) => { | ||
| await assertFreezeHoldsThenResumes(reactGrab, "reducer-count", "reducer-increment"); | ||
| }); | ||
|
|
||
| test("useTransition counter freezes then resumes", async ({ reactGrab }) => { | ||
| await assertFreezeHoldsThenResumes(reactGrab, "transition-count", "transition-increment"); | ||
| }); | ||
|
|
||
| test("useSyncExternalStore counter freezes then resumes", async ({ reactGrab }) => { | ||
| await assertFreezeHoldsThenResumes(reactGrab, "store-count", "store-increment"); | ||
| }); | ||
|
|
||
| test("context-consumer value freezes then resumes", async ({ reactGrab }) => { | ||
| await assertFreezeHoldsThenResumes(reactGrab, "context-count", "context-increment"); | ||
| }); | ||
|
|
||
| test("all hook counters stay frozen together during one freeze cycle", async ({ reactGrab }) => { | ||
| const reducerBefore = await readCount(reactGrab, "reducer-count"); | ||
| const transitionBefore = await readCount(reactGrab, "transition-count"); | ||
| const storeBefore = await readCount(reactGrab, "store-count"); | ||
| const contextBefore = await readCount(reactGrab, "context-count"); | ||
|
|
||
| await reactGrab.enterPromptMode(FREEZE_TARGET); | ||
| await clickByTestId(reactGrab, "reducer-increment"); | ||
| await clickByTestId(reactGrab, "transition-increment"); | ||
| await clickByTestId(reactGrab, "store-increment"); | ||
| await clickByTestId(reactGrab, "context-increment"); | ||
| await reactGrab.page.waitForTimeout(FREEZE_SETTLE_MS); | ||
|
|
||
| expect(await readCount(reactGrab, "reducer-count")).toBe(reducerBefore); | ||
| expect(await readCount(reactGrab, "transition-count")).toBe(transitionBefore); | ||
| expect(await readCount(reactGrab, "store-count")).toBe(storeBefore); | ||
| expect(await readCount(reactGrab, "context-count")).toBe(contextBefore); | ||
|
|
||
| await reactGrab.pressEscape(); | ||
| await reactGrab.deactivate(); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { test, expect } from "./fixtures.js"; | ||
|
|
||
| // Mirrors WINDOW_REFOCUS_GRACE_PERIOD_MS in src/constants.ts. After the window | ||
| // regains focus, activation keys are ignored for this long so the modifiers used | ||
| // to alt-tab back don't accidentally activate the overlay. | ||
| const WINDOW_REFOCUS_GRACE_PERIOD_MS = 200; | ||
|
|
||
| test.describe("Global keyboard handler", () => { | ||
| test.describe("Context-menu key", () => { | ||
| test("opens the context menu via the ContextMenu key on the hovered selection", async ({ | ||
| reactGrab, | ||
| }) => { | ||
| await reactGrab.activate(); | ||
| await reactGrab.hoverUntilSelected("li"); | ||
|
|
||
| await reactGrab.pressKey("ContextMenu"); | ||
|
|
||
| await expect.poll(() => reactGrab.isContextMenuVisible(), { timeout: 5000 }).toBe(true); | ||
| }); | ||
|
|
||
| test("opens the context menu via Shift+F10", async ({ reactGrab }) => { | ||
| await reactGrab.activate(); | ||
| await reactGrab.hoverUntilSelected("li"); | ||
|
|
||
| await reactGrab.pressKeyCombo(["Shift"], "F10"); | ||
|
|
||
| await expect.poll(() => reactGrab.isContextMenuVisible(), { timeout: 5000 }).toBe(true); | ||
| }); | ||
|
|
||
| test("ignores the ContextMenu key while inactive", async ({ reactGrab }) => { | ||
| expect(await reactGrab.isOverlayVisible()).toBe(false); | ||
|
|
||
| await reactGrab.pressKey("ContextMenu"); | ||
| await reactGrab.page.waitForTimeout(200); | ||
|
|
||
| expect(await reactGrab.isContextMenuVisible()).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| test.describe("Window-refocus grace period", () => { | ||
| test("suppresses keyboard activation immediately after the window regains focus", async ({ | ||
| reactGrab, | ||
| }) => { | ||
| expect(await reactGrab.isOverlayVisible()).toBe(false); | ||
|
|
||
| // Hold the modifier first (harmless on its own), then fire the focus event | ||
| // so only the single activation keydown needs to land inside the grace | ||
| // window — keeping the assertion robust against slow CI round-trips. | ||
| await reactGrab.page.keyboard.down(reactGrab.modifierKey); | ||
| await reactGrab.page.evaluate(() => window.dispatchEvent(new Event("focus"))); | ||
| await reactGrab.page.keyboard.down("c"); | ||
| await reactGrab.page.waitForTimeout(500); | ||
|
|
||
| expect(await reactGrab.isOverlayVisible()).toBe(false); | ||
|
|
||
| await reactGrab.page.keyboard.up("c"); | ||
| await reactGrab.page.keyboard.up(reactGrab.modifierKey); | ||
| }); | ||
|
|
||
| test("allows keyboard activation once the grace period elapses", async ({ reactGrab }) => { | ||
| await reactGrab.page.evaluate(() => window.dispatchEvent(new Event("focus"))); | ||
| await reactGrab.page.waitForTimeout(WINDOW_REFOCUS_GRACE_PERIOD_MS + 100); | ||
|
|
||
| await reactGrab.activateViaKeyboard(); | ||
|
|
||
| expect(await reactGrab.isOverlayVisible()).toBe(true); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| const triggerDownload = (url: string, fileName: string): void => { | ||
| const link = document.createElement("a"); | ||
| link.href = url; | ||
| link.download = fileName; | ||
| document.body.appendChild(link); | ||
| link.click(); | ||
| link.remove(); | ||
| }; | ||
|
|
||
| export const downloadBlob = (blob: Blob, fileName: string): void => { | ||
| const url = URL.createObjectURL(blob); | ||
| triggerDownload(url, fileName); | ||
| setTimeout(() => URL.revokeObjectURL(url), 0); | ||
| }; | ||
|
|
||
| export const downloadDataUrl = (dataUrl: string, fileName: string): void => { | ||
| triggerDownload(dataUrl, fileName); | ||
| }; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Combined test verifies freeze but never verifies resume — if a multi-hook interaction bug (e.g. context-dependency interfere with useReducer/useTransition replay) prevents clean unfreeze, this test won't catch it.
Prompt for AI agents