chore(web): format-gate the ungated first-party TS/JS (server/, vite.config.ts, scripts/) (#1788)

* chore(web): format-gate the ungated first-party TS/JS (#1778)

Prettier only covered core/ and each client's src/, leaving the web Node
backend (clients/web/server/**), web top-level configs (vite.config.ts,
tsup.runner.config.ts, eslint.config.js, ...), and the root build/verify
tooling (scripts/**/*.mjs) ungated — so their formatting was never checked
by validate/CI. eslint already reached these via `eslint .`, so this is a
prettier-only gap.

- clients/web format/format:check now cover src, server, and top-level
  *.{ts,js}, not just src.
- New root format:scripts / format:check:scripts for scripts/**/*.mjs,
  folded into the root format and into validate:core.
- Normalized the newly-covered files with prettier --write (pure
  formatting; no runtime change).

Closes #1778

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore: future-proof the scripts format glob (#1778 review)

Widen format:scripts / format:check:scripts from "scripts/**/*.mjs" to
"scripts/**/*.{mjs,js,cjs,ts}" so a future non-.mjs file under scripts/
is auto-gated, matching the future-proof glob style clients/web now uses.
A brace glob is a single pattern, so prettier does not error on the
unmatched branches. Addresses observation #2 from the PR #1788 review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* docs: sync AGENTS.md scripts glob after the widening (#1788 review r2)

d86d8f7 widened the scripts glob to {mjs,js,cjs,ts} and updated the
validate:core bullet, but the format bullet at AGENTS.md:217 still said
scripts/**/*.mjs. Bring it in line so the two mentions agree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-07-25 23:15:45 -04:00 • committed by GitHub
commit 166ed38e7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 406 additions and 324 deletions

View file

@ -214,12 +214,12 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i
- after making the changes, respond to each review comment with what was done (or why it was ignored)
### Mandatory pre-push gate
- ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`) and every client's scope in one shot. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it.
- ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`), the root `scripts/**/*.{mjs,js,cjs,ts}` (`format:scripts`), and every client's scope in one shot. `validate` runs `format:check` (the non-fixing variant, including `format:check:core` and `format:check:scripts`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it.
- **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression).
- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs the **`core/` gate first** (`validate:core`) and then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies).
- **`validate:core` is the shared-code format + lint gate (#1689).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/` before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`). Use `npm run format:core` to auto-fix. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`).
- **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/` or the root `scripts/` before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{mjs,js,cjs,ts}"`, the root build/verify tooling — #1778; a brace glob so a future non-`.mjs` script is auto-included) + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`). Use `npm run format:core` / `npm run format:scripts` to auto-fix (both folded into the root `format`). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`).
- **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script (`tsc --noEmit -p tsconfig.json`) folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`.
- The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`.
- The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778) `clients/web`'s `format`/`format:check` covers `src`, `server`, and its top-level configs (`*.{ts,js}` — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend and Vite/build config are prettier-gated too.
- **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers.
- **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below).
- `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below.

View file

@ -147,7 +147,7 @@ Each client self-validates from its own folder; the root scripts chain them. The
| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. |
| `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). |
Per-client scripts exist too (`validate:web`, `coverage:cli`, `smoke:tui`, …), plus root `validate:core` / `format:core` for the shared `core/` package. Run `npm run format` before committing — the root `format` fixes `core/` and every client; `validate` runs the non-fixing `format:check` and fails CI on any unformatted file.
Per-client scripts exist too (`validate:web`, `coverage:cli`, `smoke:tui`, …), plus root `validate:core` / `format:core` for the shared `core/` package and `format:scripts` for the root `scripts/` tooling. Run `npm run format` before committing — the root `format` fixes `core/`, the root `scripts/`, and every client; `validate` runs the non-fixing `format:check` and fails CI on any unformatted file.
For the full testing rules — the ≥90% per-file gate, where test files live, the unit vs. integration vs. storybook projects, and the `v8 ignore` policy — see [`AGENTS.md`](./AGENTS.md).

View file

@ -1,30 +1,35 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from "eslint-plugin-storybook";
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([globalIgnores(['dist', 'storybook-static', 'coverage']), {
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
export default defineConfig([
globalIgnores(["dist", "storybook-static", "coverage"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
}, {
// Test setup files re-export utilities and mix components with helpers — the
// react-refresh rule does not apply.
files: ['src/test/**/*.{ts,tsx}', 'src/**/*.test.{ts,tsx}'],
rules: {
'react-refresh/only-export-components': 'off',
{
// Test setup files re-export utilities and mix components with helpers — the
// react-refresh rule does not apply.
files: ["src/test/**/*.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
rules: {
"react-refresh/only-export-components": "off",
},
},
}, ...storybook.configs["flat/recommended"]])
...storybook.configs["flat/recommended"],
]);

View file

@ -26,8 +26,8 @@
"test:integration:watch": "npm run test-servers:build && vitest --project=integration",
"test-servers:build": "tsc -p ../../test-servers --noCheck",
"lint": "eslint .",
"format": "prettier --write src",
"format:check": "prettier --check src",
"format": "prettier --write src server \"*.{ts,js}\"",
"format:check": "prettier --check src server \"*.{ts,js}\"",
"preview": "vite preview",
"storybook": "storybook dev -p 6006"
},

View file

@ -1,38 +1,38 @@
import { defineConfig } from 'tsup';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from "tsup";
import path from "node:path";
import { fileURLToPath } from "node:url";
const dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(dirname, '../..');
const repoRoot = path.resolve(dirname, "../..");
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
outDir: 'build',
entry: ["src/index.ts"],
format: ["esm"],
outDir: "build",
clean: true,
// No source maps in the published bundle — they roughly double the on-disk
// size and aren't needed at runtime (debug via `npm run dev` on the source).
sourcemap: false,
target: 'node22',
platform: 'node',
target: "node22",
platform: "node",
noExternal: [/^@inspector\/core/],
external: [
'commander',
'open',
'pino',
'hono',
'@hono/node-server',
'vite',
'@vitejs/plugin-react',
'atomically',
'chokidar',
'@napi-rs/keyring',
'@modelcontextprotocol/client',
'@modelcontextprotocol/core',
"commander",
"open",
"pino",
"hono",
"@hono/node-server",
"vite",
"@vitejs/plugin-react",
"atomically",
"chokidar",
"@napi-rs/keyring",
"@modelcontextprotocol/client",
"@modelcontextprotocol/core",
],
esbuildOptions(options) {
options.alias = {
'@inspector/core': path.join(repoRoot, 'core'),
"@inspector/core": path.join(repoRoot, "core"),
};
},
});

View file

@ -1,20 +1,31 @@
/// <reference types="vitest/config" />
import { defineConfig, type Plugin } from 'vite';
import react from '@vitejs/plugin-react';
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);
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
@ -26,7 +37,7 @@ const { repoRoot, sharedDedupe, nodeModulesAliases, projectResolve, sharedAliase
// 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}';
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
@ -55,9 +66,9 @@ const integrationGlob = 'clients/web/src/test/integration/**/*.test.{ts,tsx}';
function browserExternalizedBuiltinGate(): Plugin {
const gate = createBrowserExternalizedBuiltinGate();
return {
name: 'inspector:fail-on-browser-externalized-builtin',
apply: 'build',
applyToEnvironment: (environment) => environment.name === 'client',
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
@ -66,7 +77,7 @@ function browserExternalizedBuiltinGate(): Plugin {
// 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',
enforce: "pre",
buildStart() {
gate.reset();
},
@ -83,248 +94,248 @@ function browserExternalizedBuiltinGate(): Plugin {
export default defineConfig(({ command }) => {
const isDevServer = command === "serve" && !process.env.VITEST;
return {
// `honoMiddlewarePlugin` is gated by `apply: 'serve'` so it only attaches
// during `vite dev` / `vite preview` — vitest projects share this config
// but never invoke `configureServer`, so the plugin stays inert there.
//
// 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(),
honoMiddlewarePlugin(buildWebServerConfigFromEnv()),
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.
// `honoMiddlewarePlugin` is gated by `apply: 'serve'` so it only attaches
// during `vite dev` / `vite preview` — vitest projects share this config
// but never invoke `configureServer`, so the plugin stays inert 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,
// 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(),
honoMiddlewarePlugin(buildWebServerConfigFromEnv()),
browserExternalizedBuiltinGate(),
],
// 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 from env, so `allowedOrigins` actually matches the browser
// origin. Without this, `vite dev` falls back to Vite's default 5173
// while the dev backend's `buildWebServerConfigFromEnv()` defaults to
// CLIENT_PORT=6274 — origin check rejects every `/api/*` request from
// the browser. CLIENT_PORT / HOST overrides flow through here too.
// `strictPort: true` so a port collision fails loudly instead of
// silently picking a different port (which would leave `allowedOrigins`
// pointing at the wrong host and break browser fetches).
server: {
port: parseInt(process.env.CLIENT_PORT ?? '6274', 10),
host: process.env.HOST ?? 'localhost',
strictPort: true,
fs: {
allow: [path.resolve(dirname, '../..')],
// 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,
},
},
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'json-summary'],
include: [
'src/components/**/*.{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,
// Pin the Vite dev server to the same port (and host) the Hono plugin
// configures from env, so `allowedOrigins` actually matches the browser
// origin. Without this, `vite dev` falls back to Vite's default 5173
// while the dev backend's `buildWebServerConfigFromEnv()` defaults to
// CLIENT_PORT=6274 — origin check rejects every `/api/*` request from
// the browser. CLIENT_PORT / HOST overrides flow through here too.
// `strictPort: true` so a port collision fails loudly instead of
// silently picking a different port (which would leave `allowedOrigins`
// pointing at the wrong host and break browser fetches).
server: {
port: parseInt(process.env.CLIENT_PORT ?? "6274", 10),
host: process.env.HOST ?? "localhost",
strictPort: true,
fs: {
allow: [path.resolve(dirname, "../..")],
},
},
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 <iframe src="/sandbox.html">; with
// navigation enabled happy-dom fetches that URL (and unloads it on
// teardown), which fails under the test server and floods the run with
// alarming-but-expected `DOMException [NetworkError/AbortError]` and
// `AsyncTaskManager destroyed` output. The component tests only assert
// on the iframe element/attributes, not its loaded document, so
// disabling frame navigation removes the noise without losing coverage.
environmentOptions: {
happyDOM: {
settings: { navigation: { disableChildFrameNavigation: true } },
},
},
// Root the unit project at the repo root so vitest's coverage
// transformer (which only processes files inside a project root)
// can reach core/ modules. Without this, untested core/ files fall
// back to raw-TS parsing in rolldown, which can't handle TS-only
// syntax (e.g. `import type`) — silently dropping them and bypassing
// the per-file gate.
root: repoRoot,
// No `globals: true` — every test file imports `describe`, `it`,
// `expect`, `vi` explicitly from "vitest". This keeps the pattern
// consistent and avoids relying on auto-cleanup tied to Vitest's
// global lifecycle hooks; cleanup is invoked manually in setup.ts.
include: ['clients/web/src/**/*.test.{ts,tsx}'],
// Integration tests run in the integration project below (node env).
exclude: [integrationGlob],
setupFiles: [path.join(dirname, 'src/test/setup.ts')],
},
},
{
extends: true,
// See note on the unit project: integration tests also run from
// repoRoot and import core/ modules, so they need the same alias
// setup. The shared bare-module aliases keep `pino`, `hono`, etc.
// resolving against clients/web/node_modules.
resolve: projectResolve,
test: {
name: 'integration',
environment: 'node',
// Same reason as the unit project: rooted at repoRoot so vitest
// can transform core/ modules and run tests against the source.
root: repoRoot,
include: [integrationGlob],
// Integration tests spawn real HTTP/stdio servers via test-servers/,
// bind sockets, run e2e OAuth flows, and exercise filesystem-backed
// storage. 30s matches the v1.5 core/vitest.config.ts.
testTimeout: 30000,
hookTimeout: 30000,
// Inline the MCP SDK so vi.mock("@modelcontextprotocol/client")
// hooks the same transformed copy that source files import.
// Externalized node_modules are loaded via Node's loader and bypass
// Vitest's mock system. Inlining ensures the transformer pipeline
// owns the module rather than the Node loader.
server: {
deps: {
inline: [/@modelcontextprotocol\/(client|core)/],
},
},
},
},
{
extends: true,
plugins: [
// The plugin will run tests for the stories defined in your Storybook config
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
storybookTest({
configDir: path.join(dirname, '.storybook'),
}),
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "json-summary"],
include: [
"src/components/**/*.{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}"),
],
test: {
name: 'storybook',
browser: {
enabled: true,
headless: true,
provider: playwright({}),
instances: [
{
browser: 'chromium',
},
],
},
setupFiles: ['.storybook/vitest.setup.ts'],
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 <iframe src="/sandbox.html">; with
// navigation enabled happy-dom fetches that URL (and unloads it on
// teardown), which fails under the test server and floods the run with
// alarming-but-expected `DOMException [NetworkError/AbortError]` and
// `AsyncTaskManager destroyed` output. The component tests only assert
// on the iframe element/attributes, not its loaded document, so
// disabling frame navigation removes the noise without losing coverage.
environmentOptions: {
happyDOM: {
settings: { navigation: { disableChildFrameNavigation: true } },
},
},
// Root the unit project at the repo root so vitest's coverage
// transformer (which only processes files inside a project root)
// can reach core/ modules. Without this, untested core/ files fall
// back to raw-TS parsing in rolldown, which can't handle TS-only
// syntax (e.g. `import type`) — silently dropping them and bypassing
// the per-file gate.
root: repoRoot,
// No `globals: true` — every test file imports `describe`, `it`,
// `expect`, `vi` explicitly from "vitest". This keeps the pattern
// consistent and avoids relying on auto-cleanup tied to Vitest's
// global lifecycle hooks; cleanup is invoked manually in setup.ts.
include: ["clients/web/src/**/*.test.{ts,tsx}"],
// Integration tests run in the integration project below (node env).
exclude: [integrationGlob],
setupFiles: [path.join(dirname, "src/test/setup.ts")],
},
},
{
extends: true,
// See note on the unit project: integration tests also run from
// repoRoot and import core/ modules, so they need the same alias
// setup. The shared bare-module aliases keep `pino`, `hono`, etc.
// resolving against clients/web/node_modules.
resolve: projectResolve,
test: {
name: "integration",
environment: "node",
// Same reason as the unit project: rooted at repoRoot so vitest
// can transform core/ modules and run tests against the source.
root: repoRoot,
include: [integrationGlob],
// Integration tests spawn real HTTP/stdio servers via test-servers/,
// bind sockets, run e2e OAuth flows, and exercise filesystem-backed
// storage. 30s matches the v1.5 core/vitest.config.ts.
testTimeout: 30000,
hookTimeout: 30000,
// Inline the MCP SDK so vi.mock("@modelcontextprotocol/client")
// hooks the same transformed copy that source files import.
// Externalized node_modules are loaded via Node's loader and bypass
// Vitest's mock system. Inlining ensures the transformer pipeline
// owns the module rather than the Node loader.
server: {
deps: {
inline: [/@modelcontextprotocol\/(client|core)/],
},
},
},
},
{
extends: true,
plugins: [
// The plugin will run tests for the stories defined in your Storybook config
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
storybookTest({
configDir: path.join(dirname, ".storybook"),
}),
],
test: {
name: "storybook",
browser: {
enabled: true,
headless: true,
provider: playwright({}),
instances: [
{
browser: "chromium",
},
],
},
setupFiles: [".storybook/vitest.setup.ts"],
},
},
],
},
};
});

View file

@ -1 +1 @@
/// <reference types="@vitest/browser-playwright" />
/// <reference types="@vitest/browser-playwright" />

View file

@ -41,11 +41,13 @@
"ci:storybook": "cd clients/web && npx playwright install chromium && npm run test:storybook",
"verify:build-gate": "node scripts/verify-build-gate.mjs",
"validate": "npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher",
"validate:core": "npm run format:check:core && npm run lint:core",
"validate:core": "npm run format:check:core && npm run format:check:scripts && npm run lint:core",
"lint:core": "eslint \"core/**/*.{ts,tsx}\"",
"format:core": "prettier --write \"core/**/*.{ts,tsx}\"",
"format:check:core": "prettier --check \"core/**/*.{ts,tsx}\"",
"format": "npm run format:core && cd clients/web && npm run format && cd ../cli && npm run format && cd ../tui && npm run format && cd ../launcher && npm run format",
"format:scripts": "prettier --write \"scripts/**/*.{mjs,js,cjs,ts}\"",
"format:check:scripts": "prettier --check \"scripts/**/*.{mjs,js,cjs,ts}\"",
"format": "npm run format:core && npm run format:scripts && cd clients/web && npm run format && cd ../cli && npm run format && cd ../tui && npm run format && cd ../launcher && npm run format",
"validate:cli": "cd clients/cli && npm run validate",
"validate:tui": "cd clients/tui && npm run validate",
"validate:web": "cd clients/web && npm run validate",

View file

@ -45,7 +45,12 @@ import { join, resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const repoRoot = resolve(import.meta.dirname, "..");
const testServer = join(repoRoot, "test-servers", "build", "test-server-stdio.js");
const testServer = join(
repoRoot,
"test-servers",
"build",
"test-server-stdio.js",
);
// Mirrors INSPECTOR_API_TOKEN_GLOBAL in core/mcp/remote/constants.ts; kept as a
// literal because this plain .mjs script can't import the TS source.
@ -72,7 +77,9 @@ function fail(message) {
if (workDir) {
rmSync(workDir, { recursive: true, force: true });
// `tarball` is initialized before `workDir` is ever set, so this is safe.
console.error(`pack:verify — tarball retained for inspection at ${tarball}`);
console.error(
`pack:verify — tarball retained for inspection at ${tarball}`,
);
}
process.exit(1);
}
@ -97,7 +104,9 @@ function ensureTestServer() {
step("building test-servers (missing build output)...");
const status = runInherit("npx", ["tsc", "-p", "test-servers", "--noCheck"]);
if (status !== 0 || !existsSync(testServer)) {
fail("could not build the stdio test server (test-servers/build/test-server-stdio.js)");
fail(
"could not build the stdio test server (test-servers/build/test-server-stdio.js)",
);
}
}
@ -175,10 +184,16 @@ const work = mkdtempSync(join(tmpdir(), "pack-verify-"));
workDir = work; // from now on, fail() cleans this up
try {
ensureTestServer();
step(`installing the tarball into a clean consumer at ${work} (pulls runtime deps)...`);
step(
`installing the tarball into a clean consumer at ${work} (pulls runtime deps)...`,
);
writeFileSync(
join(work, "package.json"),
JSON.stringify({ name: "pack-verify-consumer", private: true, version: "0.0.0" }),
JSON.stringify({
name: "pack-verify-consumer",
private: true,
version: "0.0.0",
}),
);
const install = runInherit(
"npm",
@ -189,8 +204,18 @@ try {
fail("`npm install <tarball>` into the throwaway consumer failed");
}
const installedPkg = join(work, "node_modules", "@modelcontextprotocol", "inspector");
const bin = join(work, "node_modules", ".bin", process.platform === "win32" ? "mcp-inspector.cmd" : "mcp-inspector");
const installedPkg = join(
work,
"node_modules",
"@modelcontextprotocol",
"inspector",
);
const bin = join(
work,
"node_modules",
".bin",
process.platform === "win32" ? "mcp-inspector.cmd" : "mcp-inspector",
);
if (!existsSync(bin)) {
fail(`installed \`mcp-inspector\` bin not found at ${bin}`);
}
@ -213,7 +238,11 @@ try {
env: { ...process.env, ...extraEnv },
shell: process.platform === "win32",
});
return { status: r.status, output: `${r.stdout ?? ""}${r.stderr ?? ""}`, stdout: r.stdout ?? "" };
return {
status: r.status,
output: `${r.stdout ?? ""}${r.stderr ?? ""}`,
stdout: r.stdout ?? "",
};
};
// ---------------------------------------------------------------------------
@ -223,17 +252,33 @@ try {
// build from its installed location.
step("verifying help dispatch (--help, --cli --help, --tui --help)...");
const helpChecks = [
{ args: ["--help"], marker: "Mode flags (--web, --cli, --tui)", label: "launcher --help" },
{ args: ["--cli", "--help"], marker: "Usage: inspector-cli", label: "--cli dispatch" },
{ args: ["--tui", "--help"], marker: "Usage: mcp-inspector-tui", label: "--tui dispatch" },
{
args: ["--help"],
marker: "Mode flags (--web, --cli, --tui)",
label: "launcher --help",
},
{
args: ["--cli", "--help"],
marker: "Usage: inspector-cli",
label: "--cli dispatch",
},
{
args: ["--tui", "--help"],
marker: "Usage: mcp-inspector-tui",
label: "--tui dispatch",
},
];
for (const { args, marker, label } of helpChecks) {
const r = runBin(args);
if (r.status !== 0) {
fail(`\`${args.join(" ")}\` exited ${r.status}\n${r.output.slice(0, 800)}`);
fail(
`\`${args.join(" ")}\` exited ${r.status}\n${r.output.slice(0, 800)}`,
);
}
if (!r.output.includes(marker)) {
fail(`\`${args.join(" ")}\` (${label}) did not print "${marker}"\n${r.output.slice(0, 800)}`);
fail(
`\`${args.join(" ")}\` (${label}) did not print "${marker}"\n${r.output.slice(0, 800)}`,
);
}
}
@ -250,7 +295,15 @@ try {
},
}),
);
const list = runBin(["--cli", "--catalog", catalogPath, "--server", "test", "--method", "tools/list"]);
const list = runBin([
"--cli",
"--catalog",
catalogPath,
"--server",
"test",
"--method",
"tools/list",
]);
if (list.status !== 0) {
fail(`\`--cli … tools/list\` exited ${list.status}\n${list.output}`);
}
@ -258,7 +311,9 @@ try {
try {
parsed = JSON.parse(list.stdout);
} catch {
fail(`\`--cli … tools/list\` did not return JSON on stdout:\n${list.stdout}`);
fail(
`\`--cli … tools/list\` did not return JSON on stdout:\n${list.stdout}`,
);
}
if (!(parsed.tools ?? []).some((t) => t.name === "echo")) {
fail(`\`--cli … tools/list\` missing expected "echo" tool`);
@ -291,7 +346,9 @@ try {
* shipped `dist`. Kills the server before returning.
*/
async function verifyWeb(bin, cwd) {
step("verifying prod `--web` serves the shipped dist from the installed package...");
step(
"verifying prod `--web` serves the shipped dist from the installed package...",
);
const host = "127.0.0.1";
const port = process.env.PACK_VERIFY_WEB_PORT ?? "6399";
const token = "pack-verify-token";
@ -326,7 +383,9 @@ async function verifyWeb(bin, cwd) {
let res = null;
for (let attempt = 0; attempt < 120 && !res; attempt++) {
if (exited) {
fail(`\`--web\` exited (code ${exitCode}) before serving — see output above`);
fail(
`\`--web\` exited (code ${exitCode}) before serving — see output above`,
);
}
try {
res = await fetch(`http://${host}:${port}/`);
@ -342,7 +401,9 @@ async function verifyWeb(bin, cwd) {
}
const body = await res.text();
if (!body.includes(TOKEN_GLOBAL)) {
fail(`\`--web\` served HTML is missing the ${TOKEN_GLOBAL} global (token not injected)`);
fail(
`\`--web\` served HTML is missing the ${TOKEN_GLOBAL} global (token not injected)`,
);
}
if (!body.includes(token)) {
fail("`--web` served HTML is missing the injected auth-token value");

View file

@ -33,7 +33,10 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
const webDir = path.join(repoRoot, "clients/web");
// Hardcoded browser entry. If it's ever renamed, the guarded read below fails
// with an actionable message rather than a raw ENOENT stack.