fix: fail CI on pixel snapshot failures and fix the racing stories (#28902)

This commit is contained in:
Michael Suchacz
2026-09-02 14:41:21 +02:00
committed by GitHub
parent 6db09ed9a6
commit 4491656a84
7 changed files with 87 additions and 7 deletions
+12 -5
View File
@@ -1084,6 +1084,11 @@ jobs:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-24.04-16' || 'ubuntu-latest' }}
# Forks and Dependabot receive no secrets, so they only build Storybook;
# trusted runs snapshot it against Pixel instead.
env:
PIXEL_ENABLED: ${{ github.event_name != 'pull_request' || (!github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]') }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
name: Checkout
@@ -1096,17 +1101,19 @@ jobs:
run_install: true
cache: true
- run: pnpm storybook:build
working-directory: site/
name: Build Storybook
- run: pnpm exec playwright install chromium
working-directory: site/
name: Install Chromium
- run: pnpm pixel-storybook
- run: pnpm storybook:build
working-directory: site/
name: Build
if: env.PIXEL_ENABLED != 'true'
- run: pnpm pixel-storybook:ci
working-directory: site/
name: Snapshot
if: env.PIXEL_ENABLED == 'true'
env:
PIXEL_KEY: ${{ secrets.PIXEL_KEY }}
PIXEL_AUTO_REVIEW: ${{ github.repository_owner == 'coder' && github.ref_name == 'main' }}
+21
View File
@@ -16,6 +16,27 @@ DecoratorHelpers.initializeThemeState(Object.keys(themes), "dark");
MotionGlobalConfig.skipAnimations = isPixel();
// Two Radix modal-layer behaviors race play functions under pixel, so both
// are neutralized there only; vitest, Storybook dev, and the app keep their
// animations and layer behavior.
// 1. Exit-animating layers stay mounted (with body pointer-events locked and
// background aria-hidden) until their CSS animation ends, so animations
// are disabled outright and open/close becomes synchronous. Near-zero
// durations are not enough: the cleanup then lands one frame after a
// play's next query.
// 2. Opening a modal locks body pointer-events in an effect but re-renders
// the dialog content with inline pointer-events auto one commit later; a
// play's first interaction can land inside that window, so dialog
// surfaces are pre-granted pointer-events auto.
if (isPixel()) {
const style = document.createElement("style");
style.textContent = `
*, *::before, *::after { animation: none !important; transition: none !important; }
[role="dialog"], [role="alertdialog"] { pointer-events: auto !important; }
`;
document.head.appendChild(style);
}
export const parameters: Parameters = {
options: {
storySort: {
+1
View File
@@ -27,6 +27,7 @@
"gen:provisioner": "protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=./e2e/ --ts_proto_opt=outputJsonMethods=false,outputEncodeMethods=encode-no-creation,outputClientImpl=false,nestJs=false,outputPartialMethods=false,fileSuffix=Generated,suffix=hey -I ../provisionersdk/proto ../provisionersdk/proto/provisioner.proto",
"storybook": "STORYBOOK=true storybook dev -p 6006",
"storybook:build": "storybook build",
"pixel-storybook:ci": "node scripts/pixelStorybookCi.mjs",
"test": "vitest run --project=unit",
"test:storybook": "vitest --project=storybook",
"test:ci": "vitest run --project=unit",
+25
View File
@@ -0,0 +1,25 @@
// pixel-storybook 0.3.0 reports failed builds to the Pixel platform but its
// CLI always exits 0, which keeps the CI job green. The runner decides the
// build verdict internally (failed snapshots, report errors, upload
// failures) and only reveals it in its final PATCH to the platform, so
// observe that request and mirror the verdict into the exit code. The
// package export map hides the internal modules, so they are imported by
// file path. Drop this wrapper once the CLI can exit nonzero on failure.
import { configure } from "../node_modules/@coder/pixel-storybook/build/config.js";
import { run } from "../node_modules/@coder/pixel-storybook/build/runner.js";
let buildStatus;
const realFetch = globalThis.fetch;
globalThis.fetch = (url, init) => {
if (init?.method === "PATCH" && String(url).endsWith("/api/build")) {
buildStatus = JSON.parse(String(init.body)).status;
}
return realFetch(url, init);
};
await configure();
await run();
if (buildStatus !== "complete") {
console.error(`pixel: build finished with status ${buildStatus ?? "unknown"}`);
process.exitCode = 1;
}
@@ -157,7 +157,10 @@ export const AddSetAsDefault: Story = {
export const LeaveWithUnsavedChanges: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText(/model identifier/i), "gpt-5");
// Dirty the form through a plain input. The model identifier
// autocomplete only commits to Formik on blur, which races the
// leaving click and would skip the unsaved-changes blocker.
await userEvent.type(canvas.getByLabelText(/context limit/i), "200000");
await userEvent.click(
canvas.getByRole("link", { name: /back to models/i }),
);
@@ -625,6 +625,13 @@ export const RemembersReasoningEffortByModel: Story = {
await body.findByRole("option", { name: /Claude Sonnet 4/i }),
);
// Selecting a model with efforts keeps the popover open. Close it
// and wait for the unmount so the next trigger click is a real
// reopen rather than a toggle-close racing the exit animation.
await userEvent.keyboard("{Escape}");
await waitFor(() =>
expect(body.queryByRole("dialog")).not.toBeInTheDocument(),
);
await userEvent.click(
canvas.getByRole("combobox", { name: "Claude Sonnet 4" }),
);
@@ -634,6 +641,10 @@ export const RemembersReasoningEffortByModel: Story = {
);
await userEvent.click(await body.findByRole("option", { name: /GPT-4o/i }));
await userEvent.keyboard("{Escape}");
await waitFor(() =>
expect(body.queryByRole("dialog")).not.toBeInTheDocument(),
);
await userEvent.click(canvas.getByRole("combobox", { name: "GPT-4o" }));
const restoredSlider = await body.findByRole("slider");
expect(restoredSlider).toHaveAttribute("aria-valuenow", "4");
@@ -732,6 +743,13 @@ export const ManualReselectKeepsRootOverrideEffort: Story = {
// Re-selecting the override's own model keeps the override effort.
await userEvent.click(canvas.getByRole("combobox", { name: "GPT-4o" }));
await userEvent.click(await body.findByRole("option", { name: /GPT-4o/i }));
// Selecting a model with efforts keeps the popover open. Close it
// and wait for the unmount so the next trigger click is a real
// reopen rather than a toggle-close racing the exit animation.
await userEvent.keyboard("{Escape}");
await waitFor(() =>
expect(body.queryByRole("dialog")).not.toBeInTheDocument(),
);
await userEvent.click(canvas.getByRole("combobox", { name: "GPT-4o" }));
expect(await body.findByRole("slider")).toHaveAttribute(
"aria-valuenow",
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, spyOn, userEvent, within } from "storybook/test";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { API } from "#/api/api";
import { rolesQueryKey } from "#/api/queries/roles";
import { authMethodsQueryKey } from "#/api/queries/users";
@@ -117,6 +117,11 @@ async function fillForm(
await user.click(
await body.findByRole("option", { name: new RegExp(loginType, "i") }),
);
// Wait for the select to finish closing; while it is open or exit-animating
// Radix locks body pointer-events, which would fail the next interaction.
await waitFor(() =>
expect(body.queryByRole("listbox")).not.toBeInTheDocument(),
);
if (isPasswordLogin) {
await user.type(