/// import { defineConfig, type Plugin } from "vite"; import react from "@vitejs/plugin-react"; // https://vite.dev/config/ import path from "node:path"; import { fileURLToPath } from "node:url"; import { storybookTest } from "@storybook/addon-vitest/vitest-plugin"; import { playwright } from "@vitest/browser-playwright"; import { honoMiddlewarePlugin } from "./server/vite-hono-plugin"; import { getViteBaseConfig, getViteDevOptimizeDeps, } from "./server/vite-base-config"; import { buildWebServerConfigFromEnv } from "./server/web-server-config"; import { createBrowserExternalizedBuiltinGate } from "./server/browser-externalized-builtin-gate"; import { vitestSharedPaths } from "../../vitest.shared.mts"; const dirname = typeof __dirname !== "undefined" ? __dirname : path.dirname(fileURLToPath(import.meta.url)); const { repoRoot, sharedDedupe, nodeModulesAliases, projectResolve, sharedAliases, } = vitestSharedPaths(dirname); // Integration tests live under clients/web/src/test/integration/ and run in // the node-env vitest project below. The folder is the manifest: anything // inside it is integration (node env, 30s timeout, real servers); anything // outside is a unit test (happy-dom). This prevents the silent // misclassification trap where a file's environment depended on whether // someone remembered to add it to an enumeration (#1314). // // Match `{ts,tsx}` to mirror the unit project's include below — otherwise a // stray `.test.tsx` placed inside this folder would slip past the integration // include AND fail to be excluded from unit, silently landing under happy-dom. const integrationGlob = "clients/web/src/test/integration/**/*.test.{ts,tsx}"; // Fail `vite build` when a Node built-in lands in the browser bundle (#1769). // Vite 8 otherwise only *warns* and ships a `module.exports = {}` stub, so a // broken bundle builds green; the runtime browser smoke (`smoke:web:browser`, // #1615) only catches the subset where that stub is *called* at module init. // The detection + error logic lives in `browser-externalized-builtin-gate.ts` // (Vite-agnostic, unit-tested); this is only the Vite wiring. // // Throwing directly in `onLog` does NOT abort a rolldown build (it's the one // hook where a thrown error is swallowed — verified against vite@8.0.0), so the // gate *records* the warning in `onLog` and re-throws in `buildEnd`, which runs // after module resolution (by when the warning has fired) and where a throw // aborts the build with a non-zero exit. // // `apply: 'build'` scopes it to `vite build` (never `vite dev` or the vitest // projects), and `applyToEnvironment` narrows it to the **browser** (`client`) // environment so a future SSR/node environment built from this config isn't // failed for a legitimate `node:*` import — the browser-only intent is // structural, not incidental. (The Node runner build is a separate tsup config.) // // `buildStart` resets the gate so a `vite build --watch` rebuild doesn't inherit // a prior build's recorded warnings (the plugin instance is reused across // rebuilds). `buildEnd` only asserts on the *success* path: rolldown also calls // `buildEnd(error)` when the build already failed for another reason, and // throwing then would mask that real error with the #1769 message. function browserExternalizedBuiltinGate(): Plugin { const gate = createBrowserExternalizedBuiltinGate(); return { name: "inspector:fail-on-browser-externalized-builtin", apply: "build", applyToEnvironment: (environment) => environment.name === "client", // `enforce: 'pre'` runs this ahead of the normal-plugin group (and Vite's // core plugins) in the `onLog` chain: a plugin whose `onLog` returns `false` // filters that log for every later plugin, so trailing the array would let a // future log-filtering plugin silently blind the gate. Ordering the gate // first makes "it sees every log" structural rather than dependent on array // position. Harmless to the emitting `rolldown:vite-resolve` plugin — the gate // defines no resolve/load/transform, and `onLog` delivery doesn't depend on // the emitter's position. enforce: "pre", buildStart() { gate.reset(); }, onLog(_level, log) { gate.recordLog(log.message); }, buildEnd(error) { if (!error) gate.assertClean(); }, }; } // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon export default defineConfig(({ command }) => { const isDevServer = command === "serve" && !process.env.VITEST; // Build the validated dev backend config ONCE (when serving) and reuse it for // both the Hono plugin and the `server` block below, so the dev server's // `port`/`host` come from the same guard-checked source (`resolveBindHostname` // + the CLIENT_PORT validation) rather than a second raw parse. This also // removes the implicit "plugins must be evaluated before server" ordering the // guard previously relied on to throw first. const devConfig = isDevServer ? buildWebServerConfigFromEnv() : undefined; return { // `honoMiddlewarePlugin` only attaches during `vite dev` / `vite preview`. // It's included conditionally on `isDevServer` (not merely `apply: 'serve'`) // because `devConfig` (from `buildWebServerConfigFromEnv()`, which calls // `resolveBindHostname()`) is built eagerly above. Left unconditional, an // ambient `HOST=0.0.0.0` would make the guard throw at config load for // `vite build` and every vitest project too, not just when serving. Gating // the whole plugin also skips that wasted config build for non-serve // commands. // // The plugin statically imports the node-only dev backend // (`core/mcp/remote/node/server.ts`), so Vite's config bundler (Rolldown) // walks that chain when it loads this file and reaches node-only deps // (`chokidar`, `atomically`, `@napi-rs/keyring`). Those resolve cleanly at // config-bundle time because they're declared in the repo-root // `package.json` and installed into the repo-root `node_modules`, which sits // on `core/`'s module-resolution chain (core/ has no node_modules of its // own). Keep them in the root manifest: drop one and Rolldown can no longer // resolve it from core/, reviving the benign `UNRESOLVED_IMPORT` warnings // that #1491 eliminated at the source (by removing the old stream filter and // build-time onwarn suppressions rather than re-hiding the symptom). // `browserExternalizedBuiltinGate` fails `vite build` if a Node built-in // reaches the browser bundle (#1769) — see its definition above. plugins: [ react(), ...(devConfig ? [honoMiddlewarePlugin(devConfig)] : []), browserExternalizedBuiltinGate(), ], // Shared optimizeDeps exclusions so node-only packages // (`@modelcontextprotocol/client/stdio`, `cross-spawn`, `which`) // consumed by the dev backend aren't scanned for browser pre-bundling. // Browser code reaches the node-side stack via the Hono plugin only. // Dev server: force a full dep pre-bundle each launch (no stale cache). optimizeDeps: isDevServer ? getViteDevOptimizeDeps() : getViteBaseConfig().optimizeDeps, resolve: { // NOTE: the unit vitest project (below) overrides this — see comment there. // // Once App.tsx started consuming the full hook + state-manager surface // (#1244), the browser dep graph reached bare-module subpaths in core/ // that Rolldown couldn't resolve against `core/`'s parent (it has no // node_modules of its own). Promote the same bare-module aliases the // vitest projects use so `vite dev` / `vite build` can resolve them // from `clients/web/node_modules`. alias: [ ...Object.entries(sharedAliases).map(([find, replacement]) => ({ find, replacement, })), ...nodeModulesAliases, ], // Source files in core/ import bare modules (react, @testing-library/react, // etc.) that only exist in clients/web/node_modules. Dedupe ensures Vite // resolves them from this package rather than walking up from core/'s // location (which has no node_modules of its own yet). dedupe: sharedDedupe, }, // Pin the Vite dev server to the same port and host the Hono plugin // configures, so `allowedOrigins` actually matches the browser origin. // Without this, `vite dev` falls back to Vite's default 5173 while the dev // backend defaults to CLIENT_PORT=6274 — origin check rejects every `/api/*` // request. When serving, both come from the already-validated `devConfig` // (guard-checked host + CLIENT_PORT); `vite build` and the vitest projects // evaluate this config but never bind, so they fall back to the raw env // (an ambient HOST=0.0.0.0 must not fail them at config load). // `strictPort: true` so a port collision fails loudly instead of silently // picking a different port (which would leave `allowedOrigins` wrong). server: { // The `|| "6274"` (empty ⇒ unset) mirrors buildWebServerConfig, so the // non-serve fallback agrees with the validated dev path on a blank // CLIENT_PORT rather than parsing it to NaN. port: devConfig?.port ?? parseInt(process.env.CLIENT_PORT?.trim() || "6274", 10), // `|| "localhost"` (empty ⇒ unset) like the port above — an ambient // HOST="" is Node's all-interfaces address, the one value this guards. host: devConfig?.hostname ?? (process.env.HOST?.trim() || "localhost"), strictPort: true, fs: { allow: [path.resolve(dirname, "../..")], }, }, test: { coverage: { provider: "v8", reporter: ["text", "html", "json-summary"], // Whitelist of gated directories. Deliberate top-level-file omissions // (every src *directory* below is gated): // • `src/App.tsx` — a ~4.5k-line composition root at ~42% branch // coverage; gating it is a dedicated testing/decomposition effort, // not a whitelist tweak. // • `src/main.tsx` / `src/index.ts` — the browser and bin bootstraps // (createRoot render / `runWeb` re-export), the analog of // clients/cli's excluded `src/index.ts`. // These omissions are intentional and documented here (and in AGENTS.md) // rather than silent. Add new gated dirs here as they appear. include: [ "src/components/**/*.{ts,tsx}", "src/hooks/**/*.{ts,tsx}", "src/theme/**/*.{ts,tsx}", "src/lib/**/*.{ts,tsx}", "src/utils/**/*.{ts,tsx}", "clients/web/server/**/*.{ts,tsx}", path.join(repoRoot, "core/mcp/**/*.{ts,tsx}"), path.join(repoRoot, "core/json/**/*.{ts,tsx}"), path.join(repoRoot, "core/client/**/*.{ts,tsx}"), path.join(repoRoot, "core/react/**/*.{ts,tsx}"), path.join(repoRoot, "core/auth/**/*.{ts,tsx}"), path.join(repoRoot, "core/storage/**/*.{ts,tsx}"), path.join(repoRoot, "core/logging/**/*.{ts,tsx}"), path.join(repoRoot, "core/node/**/*.{ts,tsx}"), ], exclude: [ "**/*.stories.{ts,tsx}", "**/*.test.{ts,tsx}", "**/*.fixtures.{ts,tsx}", "**/index.{ts,tsx}", "src/components/**/types.ts", // Dev-backend runtime glue: each file is exercised end-to-end via // `npm run dev` (Hono plugin attaches, banner prints, /api/* serves). // `vite-hono-plugin.ts` requires standing up a real Vite server with // an HTTP listener to drive `configureServer`; `server.ts` is the // production Hono entry that the v2 launcher (not yet ported, #1246) // will invoke; `start-vite-dev-server.ts` is its dev counterpart. // The non-glue parts are extracted to `web-server-config.ts` (fully // tested) and `sandbox-controller.ts` (HTTP behavior tested). "clients/web/server/vite-hono-plugin.ts", "clients/web/server/server.ts", "clients/web/server/start-vite-dev-server.ts", // Pure-type modules: `interface`/`type` declarations only, no runtime // statements. Excluding them keeps the report clean (would otherwise // surface as misleading 0/0 rows). path.join(repoRoot, "core/mcp/types.ts"), path.join(repoRoot, "core/mcp/elicitationCreateMessage.ts"), path.join(repoRoot, "core/mcp/samplingCreateMessage.ts"), path.join(repoRoot, "core/mcp/sessionStorage.ts"), path.join(repoRoot, "core/mcp/inspectorClientProtocol.ts"), path.join(repoRoot, "core/mcp/remote/types.ts"), path.join(repoRoot, "core/mcp/import/types.ts"), "clients/web/server/types.ts", // .d.ts files are declaration-only. path.join(repoRoot, "**/*.d.ts"), // inspectorClientEventTarget.ts is types + a single empty-body class // (extends TypedEventTarget). v8/istanbul records 0 statements for it // today. TODO(#1243): drop this exclusion once the class gains real // behavior as the v1.5 InspectorClient port progresses. path.join(repoRoot, "core/mcp/inspectorClientEventTarget.ts"), path.join(repoRoot, "core/mcp/__tests__/**"), // test-servers/ is test infrastructure (composable MCP servers and // fixtures), not application code — its build output also lives at // test-servers/build/, which we don't want to measure either. path.join(repoRoot, "test-servers/**"), ], thresholds: { perFile: true, // Uniform 90 per-file gate across every dimension. The branch floor was // lifted 50 → 70 (#1271) and then the whole gate raised to 90 after a // codebase-wide coverage audit added real tests for every outlier. // Genuinely-unreachable branches (Mantine portal / `useMediaQuery` / // `typeof window` SSR guards, React StrictMode effect replay, and // provably-dead defensive guards) are annotated with justified // `/* v8 ignore … */` comments at the source rather than relaxing the // gate. New code must clear 90 on all four dimensions. lines: 90, statements: 90, functions: 90, branches: 90, }, }, projects: [ { extends: true, // Vitest projects don't inherit `resolve` from the parent. The unit // project runs from repoRoot (so vitest's coverage transformer can // reach core/), but repoRoot has no node_modules of its own — the // shared regex aliases redirect bare `react`/`pino`/etc. imports // from core/ back into clients/web/node_modules. resolve: projectResolve, test: { name: "unit", environment: "happy-dom", // Don't let happy-dom actually navigate child frames. Components like // the MCP Apps sandbox render an