From 426a8376b2c4ab5ac15e64a20045de2b7a9eb1c0 Mon Sep 17 00:00:00 2001 From: Austin Date: Thu, 17 Sep 2026 10:51:28 -0500 Subject: [PATCH 1/3] test(ui): consolidate Storybook DOM wait helpers --- .changeset/full-flies-worry.md | 2 + ...ersion-picker.shadow-isolation.stories.tsx | 30 +++--------- ...compatibility.shadow-isolation.stories.tsx | 9 +--- ...oduction-seam.shadow-isolation.stories.tsx | 47 ++++++++++++------- ...ign-in-dialog.shadow-isolation.stories.tsx | 4 +- ...ction-popover.shadow-isolation.stories.tsx | 16 +------ packages/ui/src/test/storybook-dom.ts | 26 ++++++++++ 7 files changed, 70 insertions(+), 64 deletions(-) create mode 100644 .changeset/full-flies-worry.md create mode 100644 packages/ui/src/test/storybook-dom.ts diff --git a/.changeset/full-flies-worry.md b/.changeset/full-flies-worry.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/full-flies-worry.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx b/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx index d44153a7..6c8cb0fb 100644 --- a/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx +++ b/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx @@ -5,8 +5,8 @@ import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { expect, userEvent, waitFor, within } from 'storybook/test'; import { ShadowRootHost } from '../lib/shadow-root-host'; -import { requireShadowRoot } from '../test/dom-stubs'; import { globalHandlers } from '../test/mocks/handlers'; +import { waitForElement, waitForShadowRoot } from '../test/storybook-dom'; import { BibleVersionPicker } from './bible-version-picker'; type PortalStrategy = 'local-inline' | 'local-top-layer'; @@ -110,28 +110,12 @@ const meta = { export default meta; type Story = StoryObj; -async function getComponentRoot(container: ParentNode): Promise { - return waitFor(() => requireShadowRoot(container)); -} - -async function waitForElement( - container: ParentNode, - selector: string, - errorMessage: string, -): Promise { - return waitFor(() => { - const element = container.querySelector(selector); - if (!element) throw new Error(errorMessage); - return element; - }); -} - async function getTrigger(root: ShadowRoot): Promise { return waitForElement(root, '[data-slot="popover-trigger"]', 'picker trigger not rendered'); } async function openPicker(container: ParentNode) { - const root = await getComponentRoot(container); + const root = await waitForShadowRoot(container); const trigger = await getTrigger(root); void expect(root.querySelector('[data-yv-shadow-local-overlay]')).toBeNull(); await userEvent.click(trigger); @@ -264,7 +248,7 @@ export const InlineControlIsClippedByItsAncestor: Story = { '[data-testid="clipping-container"]', 'clipping container not rendered', ); - const root = await getComponentRoot(clippingContainer); + const root = await waitForShadowRoot(clippingContainer); void expect(root.querySelector('[data-yv-shadow-inline-overlay]')).toBeNull(); await userEvent.click(await getTrigger(root)); const inlineContainer = await waitForElement( @@ -519,8 +503,8 @@ export const MultiplePickersCreateIndependentLazyContainers: Story = { const first = canvasElement.querySelector('[data-testid="first-picker"]'); const second = canvasElement.querySelector('[data-testid="second-picker"]'); if (!first || !second) throw new Error('picker harness not rendered'); - const firstRoot = await getComponentRoot(first); - const secondRoot = await getComponentRoot(second); + const firstRoot = await waitForShadowRoot(first); + const secondRoot = await waitForShadowRoot(second); void expect(firstRoot.querySelector('[data-yv-shadow-local-overlay]')).toBeNull(); void expect(secondRoot.querySelector('[data-yv-shadow-local-overlay]')).toBeNull(); @@ -551,7 +535,7 @@ export const MultiplePickersCreateIndependentLazyContainers: Story = { export const MultiplePopoversShareOneIslandContainer: Story = { render: () => , play: async ({ canvasElement }) => { - const root = await getComponentRoot(canvasElement); + const root = await waitForShadowRoot(canvasElement); const triggers = Array.from( root.querySelectorAll('[data-slot="popover-trigger"]'), ); @@ -697,7 +681,7 @@ export const SameOriginIframeTopLayerRemainsInteractive: Story = { play: async ({ canvasElement }) => { const iframe = canvasElement.querySelector('[data-testid="iframe"]'); if (!iframe?.contentDocument) throw new Error('same-origin iframe document not available'); - const componentRoot = await getComponentRoot(iframe.contentDocument); + const componentRoot = await waitForShadowRoot(iframe.contentDocument); const trigger = await getTrigger(componentRoot); await userEvent.click(trigger); const topLayer = await waitForElement( diff --git a/packages/ui/src/components/consumer-compatibility.shadow-isolation.stories.tsx b/packages/ui/src/components/consumer-compatibility.shadow-isolation.stories.tsx index 2c6a6b95..9d5dd458 100644 --- a/packages/ui/src/components/consumer-compatibility.shadow-isolation.stories.tsx +++ b/packages/ui/src/components/consumer-compatibility.shadow-isolation.stories.tsx @@ -4,6 +4,7 @@ import { http, HttpResponse } from 'msw'; import { useCallback, useLayoutEffect, useRef, useState } from 'react'; import { expect, userEvent, waitFor } from 'storybook/test'; import { ShadowRootHost } from '../lib/shadow-root-host'; +import { waitForElement } from '../test/storybook-dom'; import { Textarea } from './ui/textarea'; import { YouVersionAuthButton } from './YouVersionAuthButton'; @@ -35,14 +36,6 @@ function requireElement( return element; } -async function waitForElement( - container: ParentNode, - selector: string, - message: string, -): Promise { - return waitFor(() => requireElement(container, selector, message)); -} - async function requireShadowHost(container: ParentNode): Promise { return waitFor(() => { const host = requireElement( diff --git a/packages/ui/src/components/shadow-overlay-production-seam.shadow-isolation.stories.tsx b/packages/ui/src/components/shadow-overlay-production-seam.shadow-isolation.stories.tsx index 446a955b..71f7662e 100644 --- a/packages/ui/src/components/shadow-overlay-production-seam.shadow-isolation.stories.tsx +++ b/packages/ui/src/components/shadow-overlay-production-seam.shadow-isolation.stories.tsx @@ -3,7 +3,7 @@ import { http, HttpResponse } from 'msw'; import { useCallback, useEffect, useRef, useState } from 'react'; import { expect, userEvent, waitFor } from 'storybook/test'; import { ShadowRootHost } from '../lib/shadow-root-host'; -import { requireShadowRoot } from '../test/dom-stubs'; +import { waitForElement, waitForShadowRoot } from '../test/storybook-dom'; import { HighlightPermissionDialog } from './highlight-permission-dialog'; import { Dialog, DialogContent, DialogTitle } from './ui/dialog'; import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; @@ -209,20 +209,7 @@ const meta = { export default meta; type Story = StoryObj; -async function waitForElement( - container: ParentNode, - selector: string, - message: string, -): Promise { - return waitFor( - () => { - const element = container.querySelector(selector); - if (!element) throw new Error(message); - return element; - }, - { timeout: 5_000 }, - ); -} +const productionWaitOptions = { timeout: 5_000 } as const; function getPermissionDialog(topLayer: HTMLElement): HTMLElement | null { return topLayer.querySelector( @@ -314,8 +301,9 @@ async function getPrimaryHarness(canvasElement: HTMLElement): Promise requireShadowRoot(primaryIsland)); + const root = await waitForShadowRoot(primaryIsland, productionWaitOptions); const contentWrapper = root.querySelector('[data-yv-shadow-content-wrapper]'); if (!contentWrapper) throw new Error('shadow content wrapper not rendered'); void expect(root.querySelector('[data-yv-shadow-local-overlay]')).toBeNull(); @@ -331,11 +319,13 @@ async function openVerseActionPopover( root, '[data-yv-shadow-local-overlay]', 'shadow-local top layer not created', + productionWaitOptions, ); const versePopover = await waitForElement( topLayer, '[data-slot="verse-action-popover"]', 'verse action popover not rendered', + productionWaitOptions, ); await waitFor(() => void expect(topLayer.matches(':popover-open')).toBe(true)); return { topLayer, versePopover }; @@ -394,21 +384,25 @@ export const PopoverOpensDialogEvidence: Story = { root, '[data-testid="prior-control"]', 'prior focus control not rendered', + productionWaitOptions, ); const verse = await waitForElement( root, '[data-testid="verse-1"]', 'verse not rendered', + productionWaitOptions, ); const closeAllOverlays = await waitForElement( canvasElement, '[data-testid="close-all-overlays"]', 'close-all control not rendered', + productionWaitOptions, ); const closeAllRequests = await waitForElement( canvasElement, '[data-testid="close-all-requests"]', 'close-all request count not rendered', + productionWaitOptions, ); const exitAnimationStyles: HTMLStyleElement[] = []; let topLayer!: HTMLElement; @@ -538,11 +532,13 @@ export const DialogContainsPopoverEvidence: Story = { root, '[data-testid="open-notes-dialog"]', 'notes opener not rendered', + productionWaitOptions, ); const noteActionCount = await waitForElement( canvasElement, '[data-testid="note-action-count"]', 'note action count not rendered', + productionWaitOptions, ); let notesDialog!: HTMLElement; @@ -555,22 +551,26 @@ export const DialogContainsPopoverEvidence: Story = { root, '[data-yv-shadow-local-overlay]', 'shadow-local top layer not created', + productionWaitOptions, ); notesDialog = await waitForElement( topLayer, '[data-testid="notes-dialog"]', 'notes dialog not rendered', + productionWaitOptions, ); notesTrigger = await waitForElement( notesDialog, '[data-testid="notes-popover-trigger"]', 'notes popover trigger not rendered', + productionWaitOptions, ); await userEvent.click(notesTrigger); notesPopover = await waitForElement( topLayer, '[data-testid="notes-popover"]', 'notes popover not rendered', + productionWaitOptions, ); const restorePosition = placeOverlayCenterUnderneath(notesDialog, notesPopover); try { @@ -582,6 +582,7 @@ export const DialogContainsPopoverEvidence: Story = { notesPopover, '[data-testid="notes-popover-action"]', 'notes popover action not rendered', + productionWaitOptions, ); await userEvent.click(notesAction); await waitFor(() => void expect(noteActionCount.getAttribute('data-count')).toBe('1')); @@ -618,16 +619,19 @@ export const TwoIndependentOverlaysEvidence: Story = { root, '[data-testid="verse-1"]', 'verse not rendered', + productionWaitOptions, ); const independentTrigger = await waitForElement( root, '[data-testid="independent-popover-trigger"]', 'independent popover trigger not rendered', + productionWaitOptions, ); const closeAllOverlays = await waitForElement( canvasElement, '[data-testid="close-all-overlays"]', 'close-all control not rendered', + productionWaitOptions, ); await step('Open a second same-root peer and tear down cleanly', async () => { @@ -637,6 +641,7 @@ export const TwoIndependentOverlaysEvidence: Story = { topLayer, '[data-testid="independent-popover"]', 'independent popover not rendered', + productionWaitOptions, ); await waitFor(() => { const focused = root.activeElement; @@ -655,23 +660,27 @@ export const TwoIndependentOverlaysEvidence: Story = { canvasElement, '[data-testid="secondary-island"]', 'secondary island not rendered', + productionWaitOptions, ); - const secondaryRoot = await waitFor(() => requireShadowRoot(secondaryIsland)); + const secondaryRoot = await waitForShadowRoot(secondaryIsland, productionWaitOptions); const secondaryTrigger = await waitForElement( secondaryRoot, '[data-testid="secondary-popover-trigger"]', 'secondary popover trigger not rendered', + productionWaitOptions, ); await userEvent.click(secondaryTrigger); const secondaryTopLayer = await waitForElement( secondaryRoot, '[data-yv-shadow-local-overlay]', 'secondary island top layer not created', + productionWaitOptions, ); const secondaryPopover = await waitForElement( secondaryTopLayer, '[data-testid="secondary-popover"]', 'secondary popover not rendered', + productionWaitOptions, ); const opened = await openVerseActionPopover(root, verse); @@ -681,6 +690,7 @@ export const TwoIndependentOverlaysEvidence: Story = { canvasElement, '[data-testid="unmount-primary"]', 'primary island unmount control not rendered', + productionWaitOptions, ); unmountPrimary.click(); await waitFor(() => void expect(primaryIsland.isConnected).toBe(false)); @@ -694,6 +704,7 @@ export const TwoIndependentOverlaysEvidence: Story = { secondaryTopLayer, '[data-testid="secondary-popover"]', 'secondary popover did not reopen after primary teardown', + productionWaitOptions, ); void expect(reopenedSecondaryPopover).toHaveAttribute('data-state', 'open'); void expect(secondaryTopLayer.matches(':popover-open')).toBe(true); @@ -711,6 +722,7 @@ export const RapidCloseReopenDuringExitEvidence: Story = { root, '[data-testid="run-rapid-close-reopen"]', 'rapid close/reopen control not rendered', + productionWaitOptions, ); const exitAnimationStyle = installUnequalExitDurations(root, { dialog: 400, @@ -727,6 +739,7 @@ export const RapidCloseReopenDuringExitEvidence: Story = { root, '[data-yv-shadow-local-overlay]', 'shadow-local top layer not created', + productionWaitOptions, ); firstDialog = await waitFor(() => { const dialog = getPermissionDialog(topLayer); diff --git a/packages/ui/src/components/sign-in-dialog.shadow-isolation.stories.tsx b/packages/ui/src/components/sign-in-dialog.shadow-isolation.stories.tsx index 2a3353ba..fca35c99 100644 --- a/packages/ui/src/components/sign-in-dialog.shadow-isolation.stories.tsx +++ b/packages/ui/src/components/sign-in-dialog.shadow-isolation.stories.tsx @@ -3,7 +3,7 @@ import { http, HttpResponse } from 'msw'; import { useState } from 'react'; import { expect, userEvent, waitFor } from 'storybook/test'; import { ShadowRootHost } from '../lib/shadow-root-host'; -import { requireShadowRoot } from '../test/dom-stubs'; +import { waitForShadowRoot } from '../test/storybook-dom'; import { SignInDialog } from './sign-in-dialog'; function SignInDialogHarness(): React.ReactNode { @@ -92,7 +92,7 @@ export const IsolatesFocusInertnessBackdropAndRestoration: Story = { return { outsideControl: outside, clippingContainer: clipping }; }); - const root = await waitFor(() => requireShadowRoot(canvasElement)); + const root = await waitForShadowRoot(canvasElement); const exitAnimationStyle = canvasElement.ownerDocument.createElement('style'); exitAnimationStyle.textContent = ` [role='dialog'][data-state='closed'] { animation-duration: 400ms !important; } diff --git a/packages/ui/src/components/verse-action-popover.shadow-isolation.stories.tsx b/packages/ui/src/components/verse-action-popover.shadow-isolation.stories.tsx index fedc9146..2e163857 100644 --- a/packages/ui/src/components/verse-action-popover.shadow-isolation.stories.tsx +++ b/packages/ui/src/components/verse-action-popover.shadow-isolation.stories.tsx @@ -4,7 +4,7 @@ import { useRef, useState } from 'react'; import { expect, userEvent, waitFor, within } from 'storybook/test'; import i18n from '../i18n'; import { ShadowRootHost } from '../lib/shadow-root-host'; -import { requireShadowRoot } from '../test/dom-stubs'; +import { waitForElement, waitForShadowRoot } from '../test/storybook-dom'; import { VerseActionPopover } from './verse-action-popover'; function IsolatedVerseActionPopover(): React.ReactNode { @@ -142,18 +142,6 @@ const meta = { export default meta; type Story = StoryObj; -async function waitForElement( - container: ParentNode, - selector: string, - message: string, -): Promise { - return waitFor(() => { - const element = container.querySelector(selector); - if (!element) throw new Error(message); - return element; - }); -} - async function waitForClosed(topLayer: HTMLElement): Promise { await waitFor(() => { void expect(topLayer.querySelector('[role="dialog"]')).toBeNull(); @@ -167,7 +155,7 @@ function getCopyButton(dialog: HTMLElement): HTMLButtonElement { export const PortalPlacementDockingReanchoringAndFocusRestoration: Story = { play: async ({ canvasElement }) => { - const shadowRoot = await waitFor(() => requireShadowRoot(canvasElement)); + const shadowRoot = await waitForShadowRoot(canvasElement); const priorControl = await waitForElement( shadowRoot, '[data-testid="prior-control"]', diff --git a/packages/ui/src/test/storybook-dom.ts b/packages/ui/src/test/storybook-dom.ts new file mode 100644 index 00000000..de24e9c0 --- /dev/null +++ b/packages/ui/src/test/storybook-dom.ts @@ -0,0 +1,26 @@ +import { waitFor } from 'storybook/test'; +import { requireShadowRoot } from './dom-stubs'; + +interface StorybookWaitOptions { + timeout?: number; +} + +export async function waitForElement( + container: ParentNode, + selector: string, + message: string, + options?: StorybookWaitOptions, +): Promise { + return waitFor(() => { + const element = container.querySelector(selector); + if (!element) throw new Error(message); + return element; + }, options); +} + +export async function waitForShadowRoot( + container: ParentNode, + options?: StorybookWaitOptions, +): Promise { + return waitFor(() => requireShadowRoot(container), options); +} From 91c751226d90ba0e00fe66e01641b39bf25f36b9 Mon Sep 17 00:00:00 2001 From: Austin Date: Thu, 17 Sep 2026 10:54:54 -0500 Subject: [PATCH 2/3] test(ui): preserve shadow root wait timing --- ...ow-overlay-production-seam.shadow-isolation.stories.tsx | 4 ++-- packages/ui/src/test/storybook-dom.ts | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/shadow-overlay-production-seam.shadow-isolation.stories.tsx b/packages/ui/src/components/shadow-overlay-production-seam.shadow-isolation.stories.tsx index 71f7662e..d6b9ca3b 100644 --- a/packages/ui/src/components/shadow-overlay-production-seam.shadow-isolation.stories.tsx +++ b/packages/ui/src/components/shadow-overlay-production-seam.shadow-isolation.stories.tsx @@ -303,7 +303,7 @@ async function getPrimaryHarness(canvasElement: HTMLElement): Promise('[data-yv-shadow-content-wrapper]'); if (!contentWrapper) throw new Error('shadow content wrapper not rendered'); void expect(root.querySelector('[data-yv-shadow-local-overlay]')).toBeNull(); @@ -662,7 +662,7 @@ export const TwoIndependentOverlaysEvidence: Story = { 'secondary island not rendered', productionWaitOptions, ); - const secondaryRoot = await waitForShadowRoot(secondaryIsland, productionWaitOptions); + const secondaryRoot = await waitForShadowRoot(secondaryIsland); const secondaryTrigger = await waitForElement( secondaryRoot, '[data-testid="secondary-popover-trigger"]', diff --git a/packages/ui/src/test/storybook-dom.ts b/packages/ui/src/test/storybook-dom.ts index de24e9c0..23c32bc2 100644 --- a/packages/ui/src/test/storybook-dom.ts +++ b/packages/ui/src/test/storybook-dom.ts @@ -18,9 +18,6 @@ export async function waitForElement( }, options); } -export async function waitForShadowRoot( - container: ParentNode, - options?: StorybookWaitOptions, -): Promise { - return waitFor(() => requireShadowRoot(container), options); +export async function waitForShadowRoot(container: ParentNode): Promise { + return waitFor(() => requireShadowRoot(container)); } From ce97bde9acee3373615766df0f5abd3e2b4c9fc0 Mon Sep 17 00:00:00 2001 From: Austin Date: Thu, 17 Sep 2026 11:12:09 -0500 Subject: [PATCH 3/3] test(ui): mock font stylesheet in Storybook --- packages/ui/src/test/mocks/handlers.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ui/src/test/mocks/handlers.ts b/packages/ui/src/test/mocks/handlers.ts index a73810c5..d7f790df 100644 --- a/packages/ui/src/test/mocks/handlers.ts +++ b/packages/ui/src/test/mocks/handlers.ts @@ -13,6 +13,10 @@ function pathParam(value: string | readonly string[] | undefined): string | unde } export const globalHandlers = [ + // Font stylesheet loaded by YouVersionProvider + http.get('*/v1/fonts/1/stylesheet', () => + HttpResponse.text('', { headers: { 'Content-Type': 'text/css' } }), + ), // Organization (publisher) lookup for the version picker http.get('*/v1/organizations/:id', ({ params }) => { const id = pathParam(params.id);