From a1ef3043bb6b39cb363fbfa8b69c5498000757ed Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 14 Apr 2026 15:19:55 +0300 Subject: [PATCH] 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. --- Makefile | 1 + site/scripts/warmup-storybook-cache.mjs | 27 ++++ .../DeleteDialog/DeleteDialog.stories.tsx | 3 +- .../GlobalErrorBoundary.stories.tsx | 3 +- .../SyntaxHighlighter/SyntaxHighlighter.tsx | 5 + .../TasksSidebar/UserCombobox.stories.tsx | 3 +- .../OrganizationSettingsPageView.stories.tsx | 3 +- .../TemplateInsightsControls.stories.tsx | 3 +- site/vite.config.mts | 144 +++++++++++------- 9 files changed, 129 insertions(+), 63 deletions(-) create mode 100644 site/scripts/warmup-storybook-cache.mjs diff --git a/Makefile b/Makefile index eff59f7715..dec026a700 100644 --- a/Makefile +++ b/Makefile @@ -856,6 +856,7 @@ pre-push: start=$$(date +%s) logdir=$$(mktemp -d "$${TMPDIR:-/tmp}/coder-pre-push.XXXXXX") 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:" $(MAKE) --no-print-directory -j$(PARALLEL_JOBS) MAKE_TIMED=1 MAKE_LOGDIR=$$logdir \ test \ diff --git a/site/scripts/warmup-storybook-cache.mjs b/site/scripts/warmup-storybook-cache.mjs new file mode 100644 index 0000000000..618140ff86 --- /dev/null +++ b/site/scripts/warmup-storybook-cache.mjs @@ -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(); diff --git a/site/src/components/Dialogs/DeleteDialog/DeleteDialog.stories.tsx b/site/src/components/Dialogs/DeleteDialog/DeleteDialog.stories.tsx index a86eee62b9..dc3bbe9f2d 100644 --- a/site/src/components/Dialogs/DeleteDialog/DeleteDialog.stories.tsx +++ b/site/src/components/Dialogs/DeleteDialog/DeleteDialog.stories.tsx @@ -1,7 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { within } from "@testing-library/react"; import { action } from "storybook/actions"; -import { userEvent } from "storybook/test"; +import { userEvent, within } from "storybook/test"; import { DeleteDialog } from "./DeleteDialog"; const meta: Meta = { diff --git a/site/src/components/ErrorBoundary/GlobalErrorBoundary.stories.tsx b/site/src/components/ErrorBoundary/GlobalErrorBoundary.stories.tsx index c02b27c2da..fecd2ebb5b 100644 --- a/site/src/components/ErrorBoundary/GlobalErrorBoundary.stories.tsx +++ b/site/src/components/ErrorBoundary/GlobalErrorBoundary.stories.tsx @@ -1,7 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { within } from "@testing-library/react"; import type { ErrorResponse } from "react-router"; -import { expect, userEvent } from "storybook/test"; +import { expect, userEvent, within } from "storybook/test"; import { GlobalErrorBoundaryInner } from "./GlobalErrorBoundary"; /** diff --git a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx index 451f754b95..d66d797395 100644 --- a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx +++ b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx @@ -103,6 +103,11 @@ export const SyntaxHighlighter: FC = ({ original={compareWith} modified={value} {...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} /> ) : ( diff --git a/site/src/modules/tasks/TasksSidebar/UserCombobox.stories.tsx b/site/src/modules/tasks/TasksSidebar/UserCombobox.stories.tsx index 125f541154..b273b2074f 100644 --- a/site/src/modules/tasks/TasksSidebar/UserCombobox.stories.tsx +++ b/site/src/modules/tasks/TasksSidebar/UserCombobox.stories.tsx @@ -1,7 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { waitFor } from "@testing-library/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 { MockUsers } from "#/pages/UsersPage/storybookData/users"; import { MockUserOwner } from "#/testHelpers/entities"; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx index 68c7578d36..828ed18940 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx @@ -1,7 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { within } from "@testing-library/react"; import { action } from "storybook/actions"; -import { userEvent } from "storybook/test"; +import { userEvent, within } from "storybook/test"; import { chromatic } from "#/testHelpers/chromatic"; import { MockDefaultOrganization, diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsControls.stories.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsControls.stories.tsx index a5e12fa0dd..53d467a6d4 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsControls.stories.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsControls.stories.tsx @@ -1,7 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { within } from "@testing-library/react"; import type { ComponentProps } from "react"; -import { userEvent } from "storybook/test"; +import { userEvent, within } from "storybook/test"; import { TemplateInsightsControls } from "./TemplateInsightsPage"; const meta: Meta = { diff --git a/site/vite.config.mts b/site/vite.config.mts index c85ef73371..8575e42466 100644 --- a/site/vite.config.mts +++ b/site/vite.config.mts @@ -90,63 +90,68 @@ export default defineConfig({ "Set-Cookie": "csrf_token=JXm9hOUdZctWt0ZZGAy9xiS/gxMKYOThdxjjMnMUyn4=; Path=/; HttpOnly; SameSite=Lax", }, - proxy: { - "//": { - changeOrigin: true, - target: process.env.CODER_HOST || "http://localhost:3000", - secure: process.env.NODE_ENV === "production", - rewrite: (path) => path.replace(/\/+/g, "/"), - }, - "/api": { - ws: true, - changeOrigin: true, - target: process.env.CODER_HOST || "http://localhost:3000", - secure: process.env.NODE_ENV === "production", - configure: (proxy) => { - if (process.env.CODER_SESSION_TOKEN) { - proxy.on("proxyReq", (proxyReq) => { - proxyReq.setHeader( - "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", - ); + // The proxy targets localhost:3000 (coderd). During tests no + // coderd is running, and the proxy's retry sockets keep the + // Node process alive after vitest finishes. + proxy: process.env.VITEST + ? undefined + : { + "//": { + changeOrigin: true, + target: process.env.CODER_HOST || "http://localhost:3000", + secure: process.env.NODE_ENV === "production", + rewrite: (path) => path.replace(/\/+/g, "/"), + }, + "/api": { + ws: true, + changeOrigin: true, + target: process.env.CODER_HOST || "http://localhost:3000", + secure: process.env.NODE_ENV === "production", + configure: (proxy) => { if (process.env.CODER_SESSION_TOKEN) { - proxyReq.setHeader( - "Coder-Session-Token", - process.env.CODER_SESSION_TOKEN!, - ); + proxy.on("proxyReq", (proxyReq) => { + proxyReq.setHeader( + "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) => { - console.error(error); - }); - }); + socket.on("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"], }, // Pre-bundle deps that Vite tends to discover late (deep MUI @@ -204,6 +209,9 @@ export default defineConfig({ "@mui/system/createTheme", "@mui/system/useTheme", "@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: { @@ -217,6 +225,9 @@ export default defineConfig({ }, test: { silent: "passed-only", + // Rolldown's native threads do not terminate on close, + // so vitest always hits this timeout. Keep it short. + teardownTimeout: 1000, projects: [ { extends: true, @@ -243,6 +254,27 @@ export default defineConfig({ storybookTest({ 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: { name: "storybook", @@ -253,6 +285,12 @@ export default defineConfig({ instances: [{ browser: "chromium" }], }, 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, }, }, ],