mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
fix: fail CI on pixel snapshot failures and fix the racing stories (#28902)
This commit is contained in:
@@ -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' }}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user