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) - after making the changes, respond to each review comment with what was done (or why it was ignored)
### Mandatory pre-push gate ### 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). - **`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). - 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`. - **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. - **`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` 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. - `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 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). | | `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). 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,15 +1,17 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format // For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from "eslint-plugin-storybook"; import storybook from "eslint-plugin-storybook";
import js from '@eslint/js' import js from "@eslint/js";
import globals from 'globals' import globals from "globals";
import reactHooks from 'eslint-plugin-react-hooks' import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from 'eslint-plugin-react-refresh' import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from 'typescript-eslint' import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from 'eslint/config' import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([globalIgnores(['dist', 'storybook-static', 'coverage']), { export default defineConfig([
files: ['**/*.{ts,tsx}'], globalIgnores(["dist", "storybook-static", "coverage"]),
{
files: ["**/*.{ts,tsx}"],
extends: [ extends: [
js.configs.recommended, js.configs.recommended,
tseslint.configs.recommended, tseslint.configs.recommended,
@ -20,11 +22,14 @@ export default defineConfig([globalIgnores(['dist', 'storybook-static', 'coverag
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
}, { },
{
// Test setup files re-export utilities and mix components with helpers — the // Test setup files re-export utilities and mix components with helpers — the
// react-refresh rule does not apply. // react-refresh rule does not apply.
files: ['src/test/**/*.{ts,tsx}', 'src/**/*.test.{ts,tsx}'], files: ["src/test/**/*.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
rules: { rules: {
'react-refresh/only-export-components': 'off', "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:integration:watch": "npm run test-servers:build && vitest --project=integration",
"test-servers:build": "tsc -p ../../test-servers --noCheck", "test-servers:build": "tsc -p ../../test-servers --noCheck",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --write src", "format": "prettier --write src server \"*.{ts,js}\"",
"format:check": "prettier --check src", "format:check": "prettier --check src server \"*.{ts,js}\"",
"preview": "vite preview", "preview": "vite preview",
"storybook": "storybook dev -p 6006" "storybook": "storybook dev -p 6006"
}, },

View file

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

View file

@ -1,20 +1,31 @@
/// <reference types="vitest/config" /> /// <reference types="vitest/config" />
import { defineConfig, type Plugin } from 'vite'; import { defineConfig, type Plugin } from "vite";
import react from '@vitejs/plugin-react'; import react from "@vitejs/plugin-react";
// https://vite.dev/config/ // https://vite.dev/config/
import path from 'node:path'; import path from "node:path";
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from "node:url";
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
import { playwright } from '@vitest/browser-playwright'; import { playwright } from "@vitest/browser-playwright";
import { honoMiddlewarePlugin } from './server/vite-hono-plugin'; import { honoMiddlewarePlugin } from "./server/vite-hono-plugin";
import { getViteBaseConfig, getViteDevOptimizeDeps } from './server/vite-base-config'; import {
import { buildWebServerConfigFromEnv } from './server/web-server-config'; getViteBaseConfig,
import { createBrowserExternalizedBuiltinGate } from './server/browser-externalized-builtin-gate'; getViteDevOptimizeDeps,
import { vitestSharedPaths } from '../../vitest.shared.mts'; } from "./server/vite-base-config";
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); import { buildWebServerConfigFromEnv } from "./server/web-server-config";
const { repoRoot, sharedDedupe, nodeModulesAliases, projectResolve, sharedAliases } = import { createBrowserExternalizedBuiltinGate } from "./server/browser-externalized-builtin-gate";
vitestSharedPaths(dirname); 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 // Integration tests live under clients/web/src/test/integration/ and run in
// the node-env vitest project below. The folder is the manifest: anything // 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 // 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 // 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. // 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). // 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 // 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 { function browserExternalizedBuiltinGate(): Plugin {
const gate = createBrowserExternalizedBuiltinGate(); const gate = createBrowserExternalizedBuiltinGate();
return { return {
name: 'inspector:fail-on-browser-externalized-builtin', name: "inspector:fail-on-browser-externalized-builtin",
apply: 'build', apply: "build",
applyToEnvironment: (environment) => environment.name === 'client', applyToEnvironment: (environment) => environment.name === "client",
// `enforce: 'pre'` runs this ahead of the normal-plugin group (and Vite's // `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` // 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 // 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 // position. Harmless to the emitting `rolldown:vite-resolve` plugin — the gate
// defines no resolve/load/transform, and `onLog` delivery doesn't depend on // defines no resolve/load/transform, and `onLog` delivery doesn't depend on
// the emitter's position. // the emitter's position.
enforce: 'pre', enforce: "pre",
buildStart() { buildStart() {
gate.reset(); gate.reset();
}, },
@ -145,37 +156,37 @@ export default defineConfig(({ command }) => {
// silently picking a different port (which would leave `allowedOrigins` // silently picking a different port (which would leave `allowedOrigins`
// pointing at the wrong host and break browser fetches). // pointing at the wrong host and break browser fetches).
server: { server: {
port: parseInt(process.env.CLIENT_PORT ?? '6274', 10), port: parseInt(process.env.CLIENT_PORT ?? "6274", 10),
host: process.env.HOST ?? 'localhost', host: process.env.HOST ?? "localhost",
strictPort: true, strictPort: true,
fs: { fs: {
allow: [path.resolve(dirname, '../..')], allow: [path.resolve(dirname, "../..")],
}, },
}, },
test: { test: {
coverage: { coverage: {
provider: 'v8', provider: "v8",
reporter: ['text', 'html', 'json-summary'], reporter: ["text", "html", "json-summary"],
include: [ include: [
'src/components/**/*.{ts,tsx}', "src/components/**/*.{ts,tsx}",
'src/lib/**/*.{ts,tsx}', "src/lib/**/*.{ts,tsx}",
'src/utils/**/*.{ts,tsx}', "src/utils/**/*.{ts,tsx}",
'clients/web/server/**/*.{ts,tsx}', "clients/web/server/**/*.{ts,tsx}",
path.join(repoRoot, 'core/mcp/**/*.{ts,tsx}'), path.join(repoRoot, "core/mcp/**/*.{ts,tsx}"),
path.join(repoRoot, 'core/json/**/*.{ts,tsx}'), path.join(repoRoot, "core/json/**/*.{ts,tsx}"),
path.join(repoRoot, 'core/client/**/*.{ts,tsx}'), path.join(repoRoot, "core/client/**/*.{ts,tsx}"),
path.join(repoRoot, 'core/react/**/*.{ts,tsx}'), path.join(repoRoot, "core/react/**/*.{ts,tsx}"),
path.join(repoRoot, 'core/auth/**/*.{ts,tsx}'), path.join(repoRoot, "core/auth/**/*.{ts,tsx}"),
path.join(repoRoot, 'core/storage/**/*.{ts,tsx}'), path.join(repoRoot, "core/storage/**/*.{ts,tsx}"),
path.join(repoRoot, 'core/logging/**/*.{ts,tsx}'), path.join(repoRoot, "core/logging/**/*.{ts,tsx}"),
path.join(repoRoot, 'core/node/**/*.{ts,tsx}'), path.join(repoRoot, "core/node/**/*.{ts,tsx}"),
], ],
exclude: [ exclude: [
'**/*.stories.{ts,tsx}', "**/*.stories.{ts,tsx}",
'**/*.test.{ts,tsx}', "**/*.test.{ts,tsx}",
'**/*.fixtures.{ts,tsx}', "**/*.fixtures.{ts,tsx}",
'**/index.{ts,tsx}', "**/index.{ts,tsx}",
'src/components/**/types.ts', "src/components/**/types.ts",
// Dev-backend runtime glue: each file is exercised end-to-end via // Dev-backend runtime glue: each file is exercised end-to-end via
// `npm run dev` (Hono plugin attaches, banner prints, /api/* serves). // `npm run dev` (Hono plugin attaches, banner prints, /api/* serves).
// `vite-hono-plugin.ts` requires standing up a real Vite server with // `vite-hono-plugin.ts` requires standing up a real Vite server with
@ -184,32 +195,32 @@ export default defineConfig(({ command }) => {
// will invoke; `start-vite-dev-server.ts` is its dev counterpart. // will invoke; `start-vite-dev-server.ts` is its dev counterpart.
// The non-glue parts are extracted to `web-server-config.ts` (fully // The non-glue parts are extracted to `web-server-config.ts` (fully
// tested) and `sandbox-controller.ts` (HTTP behavior tested). // tested) and `sandbox-controller.ts` (HTTP behavior tested).
'clients/web/server/vite-hono-plugin.ts', "clients/web/server/vite-hono-plugin.ts",
'clients/web/server/server.ts', "clients/web/server/server.ts",
'clients/web/server/start-vite-dev-server.ts', "clients/web/server/start-vite-dev-server.ts",
// Pure-type modules: `interface`/`type` declarations only, no runtime // Pure-type modules: `interface`/`type` declarations only, no runtime
// statements. Excluding them keeps the report clean (would otherwise // statements. Excluding them keeps the report clean (would otherwise
// surface as misleading 0/0 rows). // surface as misleading 0/0 rows).
path.join(repoRoot, 'core/mcp/types.ts'), path.join(repoRoot, "core/mcp/types.ts"),
path.join(repoRoot, 'core/mcp/elicitationCreateMessage.ts'), path.join(repoRoot, "core/mcp/elicitationCreateMessage.ts"),
path.join(repoRoot, 'core/mcp/samplingCreateMessage.ts'), path.join(repoRoot, "core/mcp/samplingCreateMessage.ts"),
path.join(repoRoot, 'core/mcp/sessionStorage.ts'), path.join(repoRoot, "core/mcp/sessionStorage.ts"),
path.join(repoRoot, 'core/mcp/inspectorClientProtocol.ts'), path.join(repoRoot, "core/mcp/inspectorClientProtocol.ts"),
path.join(repoRoot, 'core/mcp/remote/types.ts'), path.join(repoRoot, "core/mcp/remote/types.ts"),
path.join(repoRoot, 'core/mcp/import/types.ts'), path.join(repoRoot, "core/mcp/import/types.ts"),
'clients/web/server/types.ts', "clients/web/server/types.ts",
// .d.ts files are declaration-only. // .d.ts files are declaration-only.
path.join(repoRoot, '**/*.d.ts'), path.join(repoRoot, "**/*.d.ts"),
// inspectorClientEventTarget.ts is types + a single empty-body class // inspectorClientEventTarget.ts is types + a single empty-body class
// (extends TypedEventTarget). v8/istanbul records 0 statements for it // (extends TypedEventTarget). v8/istanbul records 0 statements for it
// today. TODO(#1243): drop this exclusion once the class gains real // today. TODO(#1243): drop this exclusion once the class gains real
// behavior as the v1.5 InspectorClient port progresses. // behavior as the v1.5 InspectorClient port progresses.
path.join(repoRoot, 'core/mcp/inspectorClientEventTarget.ts'), path.join(repoRoot, "core/mcp/inspectorClientEventTarget.ts"),
path.join(repoRoot, 'core/mcp/__tests__/**'), path.join(repoRoot, "core/mcp/__tests__/**"),
// test-servers/ is test infrastructure (composable MCP servers and // test-servers/ is test infrastructure (composable MCP servers and
// fixtures), not application code — its build output also lives at // fixtures), not application code — its build output also lives at
// test-servers/build/, which we don't want to measure either. // test-servers/build/, which we don't want to measure either.
path.join(repoRoot, 'test-servers/**'), path.join(repoRoot, "test-servers/**"),
], ],
thresholds: { thresholds: {
perFile: true, perFile: true,
@ -237,8 +248,8 @@ export default defineConfig(({ command }) => {
// from core/ back into clients/web/node_modules. // from core/ back into clients/web/node_modules.
resolve: projectResolve, resolve: projectResolve,
test: { test: {
name: 'unit', name: "unit",
environment: 'happy-dom', environment: "happy-dom",
// Don't let happy-dom actually navigate child frames. Components like // Don't let happy-dom actually navigate child frames. Components like
// the MCP Apps sandbox render an <iframe src="/sandbox.html">; with // the MCP Apps sandbox render an <iframe src="/sandbox.html">; with
// navigation enabled happy-dom fetches that URL (and unloads it on // navigation enabled happy-dom fetches that URL (and unloads it on
@ -263,10 +274,10 @@ export default defineConfig(({ command }) => {
// `expect`, `vi` explicitly from "vitest". This keeps the pattern // `expect`, `vi` explicitly from "vitest". This keeps the pattern
// consistent and avoids relying on auto-cleanup tied to Vitest's // consistent and avoids relying on auto-cleanup tied to Vitest's
// global lifecycle hooks; cleanup is invoked manually in setup.ts. // global lifecycle hooks; cleanup is invoked manually in setup.ts.
include: ['clients/web/src/**/*.test.{ts,tsx}'], include: ["clients/web/src/**/*.test.{ts,tsx}"],
// Integration tests run in the integration project below (node env). // Integration tests run in the integration project below (node env).
exclude: [integrationGlob], exclude: [integrationGlob],
setupFiles: [path.join(dirname, 'src/test/setup.ts')], setupFiles: [path.join(dirname, "src/test/setup.ts")],
}, },
}, },
{ {
@ -277,8 +288,8 @@ export default defineConfig(({ command }) => {
// resolving against clients/web/node_modules. // resolving against clients/web/node_modules.
resolve: projectResolve, resolve: projectResolve,
test: { test: {
name: 'integration', name: "integration",
environment: 'node', environment: "node",
// Same reason as the unit project: rooted at repoRoot so vitest // Same reason as the unit project: rooted at repoRoot so vitest
// can transform core/ modules and run tests against the source. // can transform core/ modules and run tests against the source.
root: repoRoot, root: repoRoot,
@ -306,25 +317,25 @@ export default defineConfig(({ command }) => {
// The plugin will run tests for the stories defined in your Storybook config // 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 // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
storybookTest({ storybookTest({
configDir: path.join(dirname, '.storybook'), configDir: path.join(dirname, ".storybook"),
}), }),
], ],
test: { test: {
name: 'storybook', name: "storybook",
browser: { browser: {
enabled: true, enabled: true,
headless: true, headless: true,
provider: playwright({}), provider: playwright({}),
instances: [ instances: [
{ {
browser: 'chromium', browser: "chromium",
}, },
], ],
}, },
setupFiles: ['.storybook/vitest.setup.ts'], setupFiles: [".storybook/vitest.setup.ts"],
}, },
}, },
], ],
}, },
}; };
}); });

View file

@ -41,11 +41,13 @@
"ci:storybook": "cd clients/web && npx playwright install chromium && npm run test:storybook", "ci:storybook": "cd clients/web && npx playwright install chromium && npm run test:storybook",
"verify:build-gate": "node scripts/verify-build-gate.mjs", "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": "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}\"", "lint:core": "eslint \"core/**/*.{ts,tsx}\"",
"format:core": "prettier --write \"core/**/*.{ts,tsx}\"", "format:core": "prettier --write \"core/**/*.{ts,tsx}\"",
"format:check:core": "prettier --check \"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:cli": "cd clients/cli && npm run validate",
"validate:tui": "cd clients/tui && npm run validate", "validate:tui": "cd clients/tui && npm run validate",
"validate:web": "cd clients/web && 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"; import { setTimeout as delay } from "node:timers/promises";
const repoRoot = resolve(import.meta.dirname, ".."); 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 // 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. // literal because this plain .mjs script can't import the TS source.
@ -72,7 +77,9 @@ function fail(message) {
if (workDir) { if (workDir) {
rmSync(workDir, { recursive: true, force: true }); rmSync(workDir, { recursive: true, force: true });
// `tarball` is initialized before `workDir` is ever set, so this is safe. // `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); process.exit(1);
} }
@ -97,7 +104,9 @@ function ensureTestServer() {
step("building test-servers (missing build output)..."); step("building test-servers (missing build output)...");
const status = runInherit("npx", ["tsc", "-p", "test-servers", "--noCheck"]); const status = runInherit("npx", ["tsc", "-p", "test-servers", "--noCheck"]);
if (status !== 0 || !existsSync(testServer)) { 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 workDir = work; // from now on, fail() cleans this up
try { try {
ensureTestServer(); 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( writeFileSync(
join(work, "package.json"), 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( const install = runInherit(
"npm", "npm",
@ -189,8 +204,18 @@ try {
fail("`npm install <tarball>` into the throwaway consumer failed"); fail("`npm install <tarball>` into the throwaway consumer failed");
} }
const installedPkg = join(work, "node_modules", "@modelcontextprotocol", "inspector"); const installedPkg = join(
const bin = join(work, "node_modules", ".bin", process.platform === "win32" ? "mcp-inspector.cmd" : "mcp-inspector"); work,
"node_modules",
"@modelcontextprotocol",
"inspector",
);
const bin = join(
work,
"node_modules",
".bin",
process.platform === "win32" ? "mcp-inspector.cmd" : "mcp-inspector",
);
if (!existsSync(bin)) { if (!existsSync(bin)) {
fail(`installed \`mcp-inspector\` bin not found at ${bin}`); fail(`installed \`mcp-inspector\` bin not found at ${bin}`);
} }
@ -213,7 +238,11 @@ try {
env: { ...process.env, ...extraEnv }, env: { ...process.env, ...extraEnv },
shell: process.platform === "win32", 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. // build from its installed location.
step("verifying help dispatch (--help, --cli --help, --tui --help)..."); step("verifying help dispatch (--help, --cli --help, --tui --help)...");
const helpChecks = [ const helpChecks = [
{ args: ["--help"], marker: "Mode flags (--web, --cli, --tui)", label: "launcher --help" }, {
{ args: ["--cli", "--help"], marker: "Usage: inspector-cli", label: "--cli dispatch" }, args: ["--help"],
{ args: ["--tui", "--help"], marker: "Usage: mcp-inspector-tui", label: "--tui dispatch" }, 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) { for (const { args, marker, label } of helpChecks) {
const r = runBin(args); const r = runBin(args);
if (r.status !== 0) { 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)) { 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) { if (list.status !== 0) {
fail(`\`--cli … tools/list\` exited ${list.status}\n${list.output}`); fail(`\`--cli … tools/list\` exited ${list.status}\n${list.output}`);
} }
@ -258,7 +311,9 @@ try {
try { try {
parsed = JSON.parse(list.stdout); parsed = JSON.parse(list.stdout);
} catch { } 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")) { if (!(parsed.tools ?? []).some((t) => t.name === "echo")) {
fail(`\`--cli … tools/list\` missing expected "echo" tool`); fail(`\`--cli … tools/list\` missing expected "echo" tool`);
@ -291,7 +346,9 @@ try {
* shipped `dist`. Kills the server before returning. * shipped `dist`. Kills the server before returning.
*/ */
async function verifyWeb(bin, cwd) { 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 host = "127.0.0.1";
const port = process.env.PACK_VERIFY_WEB_PORT ?? "6399"; const port = process.env.PACK_VERIFY_WEB_PORT ?? "6399";
const token = "pack-verify-token"; const token = "pack-verify-token";
@ -326,7 +383,9 @@ async function verifyWeb(bin, cwd) {
let res = null; let res = null;
for (let attempt = 0; attempt < 120 && !res; attempt++) { for (let attempt = 0; attempt < 120 && !res; attempt++) {
if (exited) { if (exited) {
fail(`\`--web\` exited (code ${exitCode}) before serving — see output above`); fail(
`\`--web\` exited (code ${exitCode}) before serving — see output above`,
);
} }
try { try {
res = await fetch(`http://${host}:${port}/`); res = await fetch(`http://${host}:${port}/`);
@ -342,7 +401,9 @@ async function verifyWeb(bin, cwd) {
} }
const body = await res.text(); const body = await res.text();
if (!body.includes(TOKEN_GLOBAL)) { 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)) { if (!body.includes(token)) {
fail("`--web` served HTML is missing the injected auth-token value"); 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 path from "node:path";
import { fileURLToPath } from "node:url"; 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"); const webDir = path.join(repoRoot, "clients/web");
// Hardcoded browser entry. If it's ever renamed, the guarded read below fails // Hardcoded browser entry. If it's ever renamed, the guarded read below fails
// with an actionable message rather than a raw ENOENT stack. // with an actionable message rather than a raw ENOENT stack.