From dc0394588ce3360d53afd8dc50103214f9a747b9 Mon Sep 17 00:00:00 2001 From: webtheory <11144268+sinups@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:42:41 +0300 Subject: [PATCH] ci: make the visual comparison mean something again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job was red on every pull request, including one that changed only the version and the changelog, and always on the same stories with the same pixel counts — ChatLauncher at 4.94%, then ShellOutput, MicButton, TaskList and the rest. A gate that fires on everything stops being read, and it held the last release for four days. Three causes, three fixes: - A style tag stops at a shadow boundary, so the animation of a widget mounted into a shadow root kept running and the two sides of the comparison caught it in different frames. The freeze is now adopted into every shadow root. - A widget that mounts itself in an effect reports when it is there (`data-shadow-ready`), and the screenshot waits for that instead of racing it. - Loaders, voice levels and pulse rings move from JavaScript, which no stylesheet can stop. The page now asks for reduced motion, the switch the kit already honours everywhere. The job also stops running where pixels cannot move: a release pull request, a change to the docs data or to another workflow no longer pays 25 minutes for a comparison of two identical Storybooks. --- .github/workflows/visual.yml | 9 +++++++ .storybook/test-runner.ts | 26 +++++++++++++++++++++ package/src/launcher/ChatLauncher.story.tsx | 2 +- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.github/workflows/visual.yml b/.github/workflows/visual.yml index 5968641..adaad2e 100644 --- a/.github/workflows/visual.yml +++ b/.github/workflows/visual.yml @@ -3,6 +3,15 @@ name: Visual regression on: pull_request: types: [opened, synchronize, reopened, labeled, unlabeled] + # Comparing two Storybook builds costs ~25 minutes, so it runs only where pixels can move. + # A release pull request touches the version and the changelog, and used to hold the release + # for as long as this job took, once per merge into main. + paths: + - 'package/src/**' + - '.storybook/**' + - 'package/package.json' + - 'yarn.lock' + - '.github/workflows/visual.yml' concurrency: group: ${{ github.workflow }}-${{ github.event.number }} diff --git a/.storybook/test-runner.ts b/.storybook/test-runner.ts index 0ba8332..3b85359 100644 --- a/.storybook/test-runner.ts +++ b/.storybook/test-runner.ts @@ -21,6 +21,24 @@ async function settle(page: Page) { await waitForPageReady(page); await page.evaluate(() => document.fonts.ready); await page.evaluate(() => window.scrollTo(0, 0)); + // A style tag stops at the shadow boundary, so an animation inside a widget kept running and the + // two sides of the comparison caught it in different frames. + await page.evaluate((css) => { + const freeze = (root: Document | ShadowRoot) => { + const sheet = new CSSStyleSheet(); + sheet.replaceSync(css); + root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet]; + }; + const walk = (root: Document | ShadowRoot) => { + freeze(root); + root.querySelectorAll('*').forEach((element) => { + if (element.shadowRoot) { + walk(element.shadowRoot); + } + }); + }; + walk(document); + }, FREEZE_CSS); await page.waitForTimeout(300); } @@ -45,6 +63,9 @@ const config: TestRunnerConfig = { async preVisit(page) { await page.setViewportSize({ width: 1280, height: 800 }); await page.clock.setFixedTime(FIXED_NOW); + // Loaders, voice levels and pulse rings move from JavaScript, which no stylesheet can stop; the + // kit holds them still under reduced motion, so the screenshots ask for exactly that. + await page.emulateMedia({ reducedMotion: 'reduce' }); }, async postVisit(page, context) { @@ -69,6 +90,11 @@ const config: TestRunnerConfig = { } await page.addStyleTag({ content: FREEZE_CSS }); + // A widget that mounts itself into a shadow root reports when it is there; without the wait the + // baseline and the comparison could be taken on either side of that moment. + if (await page.locator('[data-shadow-ready]').count()) { + await page.locator('[data-shadow-ready="true"]').first().waitFor({ state: 'attached' }); + } for (const theme of THEMES) { await setTheme(page, theme); await settle(page); diff --git a/package/src/launcher/ChatLauncher.story.tsx b/package/src/launcher/ChatLauncher.story.tsx index ffd0a6a..c5e7688 100644 --- a/package/src/launcher/ChatLauncher.story.tsx +++ b/package/src/launcher/ChatLauncher.story.tsx @@ -163,7 +163,7 @@ function ShadowLauncher() { return ( - +