fix: prevent site storybook tests from hanging after completion (#23936)

The vitest process hung after all 2132 story tests passed because
leftover refetchInterval polls kept the Node.js event loop alive.
Components that set per-query refetchInterval override the
QueryClient default, causing HTTP requests through vite's proxy
to localhost:3000 (no backend) that never resolve cleanly.

Three fixes:

- preview.tsx: disable all automatic refetching defaults and cancel
  in-flight queries on story unmount via useEffect cleanup
- storybook.tsx: save/restore the original window.WebSocket in the
  withWebSocket decorator, clear pending timers in close()
- vite.config.mts: add explicit testTimeout, hookTimeout, bail, and
  retry settings to the storybook vitest project

Also fix 5 story files that imported from @testing-library/react
instead of storybook/test.
This commit is contained in:
Mathias Fredriksson
2026-04-14 15:19:55 +03:00
committed by GitHub
parent 10f0786966
commit a1ef3043bb
9 changed files with 129 additions and 63 deletions
+1
View File
@@ -856,6 +856,7 @@ pre-push:
start=$$(date +%s) start=$$(date +%s)
logdir=$$(mktemp -d "$${TMPDIR:-/tmp}/coder-pre-push.XXXXXX") logdir=$$(mktemp -d "$${TMPDIR:-/tmp}/coder-pre-push.XXXXXX")
echo "$(BOLD)pre-push$(RESET) ($$logdir)" echo "$(BOLD)pre-push$(RESET) ($$logdir)"
test -d site/node_modules/.cache/storybook || (cd site/ && pnpm exec node scripts/warmup-storybook-cache.mjs)
echo "test + build site:" echo "test + build site:"
$(MAKE) --no-print-directory -j$(PARALLEL_JOBS) MAKE_TIMED=1 MAKE_LOGDIR=$$logdir \ $(MAKE) --no-print-directory -j$(PARALLEL_JOBS) MAKE_TIMED=1 MAKE_LOGDIR=$$logdir \
test \ test \
+27
View File
@@ -0,0 +1,27 @@
// Warm vite's transform cache for storybook story files.
// Only needed on cold cache (first run after pnpm install).
import { createServer } from "vite";
import { readdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, "..");
const server = await createServer({
configFile: join(root, "vite.config.mts"),
root,
});
await server.listen();
const stories = readdirSync(join(root, "src"), { recursive: true })
.filter((f) => String(f).endsWith(".stories.tsx"))
.map((f) => `/src/${f}`);
await Promise.all(
stories.map((f) =>
server.environments.client.warmupRequest(f).catch(() => {}),
),
);
await server.close();
@@ -1,7 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { within } from "@testing-library/react";
import { action } from "storybook/actions"; import { action } from "storybook/actions";
import { userEvent } from "storybook/test"; import { userEvent, within } from "storybook/test";
import { DeleteDialog } from "./DeleteDialog"; import { DeleteDialog } from "./DeleteDialog";
const meta: Meta<typeof DeleteDialog> = { const meta: Meta<typeof DeleteDialog> = {
@@ -1,7 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { within } from "@testing-library/react";
import type { ErrorResponse } from "react-router"; import type { ErrorResponse } from "react-router";
import { expect, userEvent } from "storybook/test"; import { expect, userEvent, within } from "storybook/test";
import { GlobalErrorBoundaryInner } from "./GlobalErrorBoundary"; import { GlobalErrorBoundaryInner } from "./GlobalErrorBoundary";
/** /**
@@ -103,6 +103,11 @@ export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
original={compareWith} original={compareWith}
modified={value} modified={value}
{...commonProps} {...commonProps}
// Let the editor handle model cleanup. Without this,
// @monaco-editor/react disposes models before the
// DiffEditorWidget and throws an error.
keepCurrentOriginalModel
keepCurrentModifiedModel
onMount={handleDiffEditorMount} onMount={handleDiffEditorMount}
/> />
) : ( ) : (
@@ -1,7 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { waitFor } from "@testing-library/react";
import { useState } from "react"; import { useState } from "react";
import { expect, spyOn, userEvent, within } from "storybook/test"; import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { API } from "#/api/api"; import { API } from "#/api/api";
import { MockUsers } from "#/pages/UsersPage/storybookData/users"; import { MockUsers } from "#/pages/UsersPage/storybookData/users";
import { MockUserOwner } from "#/testHelpers/entities"; import { MockUserOwner } from "#/testHelpers/entities";
@@ -1,7 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { within } from "@testing-library/react";
import { action } from "storybook/actions"; import { action } from "storybook/actions";
import { userEvent } from "storybook/test"; import { userEvent, within } from "storybook/test";
import { chromatic } from "#/testHelpers/chromatic"; import { chromatic } from "#/testHelpers/chromatic";
import { import {
MockDefaultOrganization, MockDefaultOrganization,
@@ -1,7 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { within } from "@testing-library/react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { userEvent } from "storybook/test"; import { userEvent, within } from "storybook/test";
import { TemplateInsightsControls } from "./TemplateInsightsPage"; import { TemplateInsightsControls } from "./TemplateInsightsPage";
const meta: Meta<typeof TemplateInsightsControls> = { const meta: Meta<typeof TemplateInsightsControls> = {
+91 -53
View File
@@ -90,63 +90,68 @@ export default defineConfig({
"Set-Cookie": "Set-Cookie":
"csrf_token=JXm9hOUdZctWt0ZZGAy9xiS/gxMKYOThdxjjMnMUyn4=; Path=/; HttpOnly; SameSite=Lax", "csrf_token=JXm9hOUdZctWt0ZZGAy9xiS/gxMKYOThdxjjMnMUyn4=; Path=/; HttpOnly; SameSite=Lax",
}, },
proxy: { // The proxy targets localhost:3000 (coderd). During tests no
"//": { // coderd is running, and the proxy's retry sockets keep the
changeOrigin: true, // Node process alive after vitest finishes.
target: process.env.CODER_HOST || "http://localhost:3000", proxy: process.env.VITEST
secure: process.env.NODE_ENV === "production", ? undefined
rewrite: (path) => path.replace(/\/+/g, "/"), : {
}, "//": {
"/api": { changeOrigin: true,
ws: true, target: process.env.CODER_HOST || "http://localhost:3000",
changeOrigin: true, secure: process.env.NODE_ENV === "production",
target: process.env.CODER_HOST || "http://localhost:3000", rewrite: (path) => path.replace(/\/+/g, "/"),
secure: process.env.NODE_ENV === "production", },
configure: (proxy) => { "/api": {
if (process.env.CODER_SESSION_TOKEN) { ws: true,
proxy.on("proxyReq", (proxyReq) => { changeOrigin: true,
proxyReq.setHeader( target: process.env.CODER_HOST || "http://localhost:3000",
"Coder-Session-Token", secure: process.env.NODE_ENV === "production",
process.env.CODER_SESSION_TOKEN!, configure: (proxy) => {
);
});
}
// Vite does not catch socket errors, and stops the webserver.
// As /logs endpoint can return HTTP 4xx status, we need to embrace
// Vite with a custom error handler to prevent from quitting.
proxy.on("proxyReqWs", (proxyReq, _req, socket) => {
if (process.env.NODE_ENV === "development") {
proxyReq.setHeader(
"origin",
process.env.CODER_HOST || "http://localhost:3000",
);
if (process.env.CODER_SESSION_TOKEN) { if (process.env.CODER_SESSION_TOKEN) {
proxyReq.setHeader( proxy.on("proxyReq", (proxyReq) => {
"Coder-Session-Token", proxyReq.setHeader(
process.env.CODER_SESSION_TOKEN!, "Coder-Session-Token",
); process.env.CODER_SESSION_TOKEN!,
);
});
} }
} // Vite does not catch socket errors, and stops the webserver.
// As /logs endpoint can return HTTP 4xx status, we need to embrace
// Vite with a custom error handler to prevent from quitting.
proxy.on("proxyReqWs", (proxyReq, _req, socket) => {
if (process.env.NODE_ENV === "development") {
proxyReq.setHeader(
"origin",
process.env.CODER_HOST || "http://localhost:3000",
);
if (process.env.CODER_SESSION_TOKEN) {
proxyReq.setHeader(
"Coder-Session-Token",
process.env.CODER_SESSION_TOKEN!,
);
}
}
socket.on("error", (error) => { socket.on("error", (error) => {
console.error(error); console.error(error);
}); });
}); });
},
},
"/swagger": {
target: process.env.CODER_HOST || "http://localhost:3000",
secure: process.env.NODE_ENV === "production",
},
"/healthz": {
target: process.env.CODER_HOST || "http://localhost:3000",
secure: process.env.NODE_ENV === "production",
},
"/serviceWorker.js": {
target: process.env.CODER_HOST || "http://localhost:3000",
secure: process.env.NODE_ENV === "production",
},
}, },
},
"/swagger": {
target: process.env.CODER_HOST || "http://localhost:3000",
secure: process.env.NODE_ENV === "production",
},
"/healthz": {
target: process.env.CODER_HOST || "http://localhost:3000",
secure: process.env.NODE_ENV === "production",
},
"/serviceWorker.js": {
target: process.env.CODER_HOST || "http://localhost:3000",
secure: process.env.NODE_ENV === "production",
},
},
allowedHosts: [".coder", ".dev.coder.com"], allowedHosts: [".coder", ".dev.coder.com"],
}, },
// Pre-bundle deps that Vite tends to discover late (deep MUI // Pre-bundle deps that Vite tends to discover late (deep MUI
@@ -204,6 +209,9 @@ export default defineConfig({
"@mui/system/createTheme", "@mui/system/createTheme",
"@mui/system/useTheme", "@mui/system/useTheme",
"@mui/x-tree-view", "@mui/x-tree-view",
// Discovered at runtime without this entry, triggering
// a mid-run dep re-optimization that breaks imports.
"@tanstack/react-query-devtools",
], ],
}, },
resolve: { resolve: {
@@ -217,6 +225,9 @@ export default defineConfig({
}, },
test: { test: {
silent: "passed-only", silent: "passed-only",
// Rolldown's native threads do not terminate on close,
// so vitest always hits this timeout. Keep it short.
teardownTimeout: 1000,
projects: [ projects: [
{ {
extends: true, extends: true,
@@ -243,6 +254,27 @@ export default defineConfig({
storybookTest({ storybookTest({
configDir: path.join(__dirname, ".storybook"), configDir: path.join(__dirname, ".storybook"),
}), }),
{
name: "storybook-test-setup",
// Return 502 for API routes. The proxy is disabled
// during tests (see above), so without this vite
// serves its HTML fallback for unmatched routes.
configureServer(server) {
server.middlewares.use((req, res, next) => {
const url = req.url ?? "";
if (
url.startsWith("/api/") ||
url.startsWith("/swagger/") ||
url.startsWith("/healthz")
) {
res.statusCode = 502;
res.end();
return;
}
next();
});
},
},
], ],
test: { test: {
name: "storybook", name: "storybook",
@@ -253,6 +285,12 @@ export default defineConfig({
instances: [{ browser: "chromium" }], instances: [{ browser: "chromium" }],
}, },
setupFiles: [".storybook/vitest.setup.ts"], setupFiles: [".storybook/vitest.setup.ts"],
// Stop early on systemic failures.
bail: 5,
// Cap concurrent browser iframes. The default
// (os.availableParallelism, 96 on dev workspaces)
// overwhelms vite's transform pipeline on cold cache.
maxWorkers: 4,
}, },
}, },
], ],