///
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