diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fffcedb..55e2b346 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,17 +11,10 @@ concurrency: cancel-in-progress: true jobs: - smoke: - name: Smoke (${{ matrix.os }}) - runs-on: ${{ matrix.os }} + test: + name: Test (Linux) + runs-on: ubuntu-latest timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - os: - - ubuntu-latest - - macos-latest - - windows-latest env: DEVSPACE_ALLOWED_ROOTS: ${{ github.workspace }} @@ -42,7 +35,6 @@ jobs: run: npm ci - name: Install Pi sandbox dependencies - if: matrix.os == 'ubuntu-latest' run: | sudo apt-get update sudo apt-get install -y ripgrep bubblewrap socat @@ -55,7 +47,7 @@ jobs: - name: Test env: - DEVSPACE_REQUIRE_PI_SANDBOX: ${{ matrix.os == 'ubuntu-latest' && '1' || '0' }} + DEVSPACE_REQUIRE_PI_SANDBOX: 1 run: npm test - name: Build @@ -63,3 +55,68 @@ jobs: - name: Doctor run: node dist/cli.js doctor + + platform: + name: Platform (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: + - macos-latest + - windows-latest + + env: + DEVSPACE_ALLOWED_ROOTS: ${{ github.workspace }} + DEVSPACE_OAUTH_OWNER_TOKEN: ci-owner-token-that-is-long-enough + DEVSPACE_PUBLIC_BASE_URL: http://127.0.0.1:7676 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Test platform boundaries + run: >- + npx tsx --test --test-concurrency=1 + src/cli-workspace.test.ts + src/process-platform.test.ts + src/process-sessions.test.ts + src/roots.test.ts + src/workspaces.test.ts + + - name: Build + run: npm run build + + - name: Doctor + run: node dist/cli.js doctor + + package: + name: Package (Node 26) + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 26 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Test installed package + run: npm run test:package diff --git a/docs/testing-research.md b/docs/testing-research.md new file mode 100644 index 00000000..b1d64b4c --- /dev/null +++ b/docs/testing-research.md @@ -0,0 +1,219 @@ +# MCP testing research for DevSpace + +Research date: 2026-08-24 + +DevSpace currently installs `@modelcontextprotocol/sdk` 1.29.0 and exposes a stateful Streamable HTTP endpoint. This note therefore uses the 2025-11-25 MCP specification and the matching TypeScript SDK tag as the main reference point. The newer 2026-07-28 revision is discussed only as an upgrade risk. + +## Conclusion + +The largest gap in the DevSpace suite is not a missing unit test. It is a missing boundary. + +[`src/server.test.ts`](../src/server.test.ts) connects `createMcpServer()` to an SDK `InMemoryTransport`. That is a good seam for MCP tool behavior. It proves that an SDK client can discover and call registered DevSpace tools without coupling tests to private registration helpers. + +It does not execute [`createServer()`](../src/server.ts), Express, Streamable HTTP, OAuth middleware, HTTP headers, session routing, SSE, or the shutdown path used by a real MCP host. No current test calls `createServer()`. The suite consequently has substantial coverage of code below the MCP endpoint and almost no evidence about the endpoint itself. + +My recommendation is to retain focused domain tests and the in-memory MCP client tests, then add a small number of production-shaped tests in this order: + +1. An authenticated Streamable HTTP lifecycle test on an ephemeral port. +2. Adversarial HTTP tests for sessions, versions, media types, Origin and Host validation, and OAuth failures. +3. An official MCP conformance run against the real DevSpace transport with a test-only authenticated proxy. +4. A packed-install smoke test that installs the generated tarball in a temporary directory and invokes its public binaries. +5. A CI split that runs platform-neutral evidence once and only platform-sensitive tests on macOS and Windows. + +Do not add an MCP stdio suite to DevSpace. The product does not expose an MCP stdio transport. The stdio examples below are useful because they demonstrate how to test a packaged child-process boundary, which applies to DevSpace's CLI package, not because DevSpace should gain another transport. + +## The MCP contracts worth testing + +The protocol defines a small number of boundaries that matter much more than helper coverage. + +| Concern | Protocol invariant | What DevSpace should observe | +| --- | --- | --- | +| Lifecycle | Initialization must happen first, negotiates a protocol version and capabilities, and is followed by `notifications/initialized`. Peers may only use negotiated capabilities. | A real SDK client can initialize the HTTP endpoint; advertised capabilities and instructions are correct; invalid order and unsupported versions fail at the endpoint. [MCP lifecycle specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle) | +| Streamable HTTP | POST and GET share one endpoint. POST clients advertise JSON and SSE support. Stateful servers issue a secure session ID, require it on later calls, return 404 for expired sessions, accept DELETE termination, and validate `MCP-Protocol-Version`. Servers must reject an invalid Origin with 403. | Status codes, headers, session creation and reuse, unknown sessions, DELETE cleanup, media-type failures, protocol-version failures, and Origin rejection through `/mcp`. [MCP transport specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) | +| Authorization | HTTP authorization applies to every request in a logical session. Invalid or expired tokens return 401, insufficient scope returns 403, the server validates the token audience, and protected-resource discovery is part of the HTTP contract. | OAuth metadata, `WWW-Authenticate`, token issuance, authorization on initialization and subsequent requests, scope failure, audience mismatch, expiration, refresh rotation, and revocation through HTTP. [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) | +| Tool contracts | `tools/list` advertises input and optional output JSON Schemas. If an output schema exists, the server must return conforming `structuredContent`. Malformed protocol input and tool execution failures use different error mechanisms. | The schema seen by an SDK client matches the intended model contract; representative calls validate at the SDK boundary; invalid arguments produce the intended model-recoverable result. [MCP tools specification](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) | +| Cancellation and timeouts | Cancellation is a raced notification. A receiver should stop work and free associated resources, but must tolerate an unknown or already-completed request. Sent requests should have bounded timeouts even when progress resets an idle timer. | Cancel a live effect through an MCP client and inspect the user-visible result plus resource ownership. Test completion-before-cancel and cancel-before-completion deterministically. [MCP cancellation specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation), [MCP lifecycle timeout rules](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts) | + +These are interface tests. They remain useful if session storage, Express routing, tool registration, or process management is refactored. + +## What the official TypeScript SDK tests + +The TypeScript SDK 1.29.0 uses several distinct seams instead of asking one kind of test to prove everything. + +### HTTP transport tests use HTTP + +The SDK's Streamable HTTP server tests create a Node HTTP server on a random port and use `fetch` to initialize, call tools, open SSE streams, send DELETE, and exercise invalid headers and session IDs. The scenarios include missing and invalid session IDs, Accept and Content-Type errors, protocol-version validation on POST, GET, and DELETE, resumability, stateless mode, callbacks, and DNS rebinding protection. [TypeScript SDK 1.29.0 Streamable HTTP server tests](https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.29.0/test/server/streamableHttp.test.ts) + +The SDK also has a higher-level integration test that connects its real `Client` and `StreamableHTTPClientTransport` to a real Node HTTP server, then compares stateful and stateless behavior and multiple clients. This is close to the missing DevSpace seam. [TypeScript SDK 1.29.0 session-management integration test](https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.29.0/test/integration-tests/stateManagementStreamableHttp.test.ts) + +DevSpace should not copy all of the SDK's transport cases. The SDK owns generic transport correctness. DevSpace needs the cases where its composition can violate the contract: authentication before session routing, session registry ownership, headers added or rejected by Express, the selected protocol versions, and cleanup of DevSpace resources. + +### Temporal behavior uses deterministic control + +The SDK protocol tests use fake timers for request timeouts, progress-based resets, and maximum total timeouts. Its cancellation tests drive messages through the protocol abstraction and distinguish request cancellation from task cancellation. [TypeScript SDK 1.29.0 protocol tests](https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.29.0/test/shared/protocol.test.ts) + +That is the right pattern for DevSpace process and subagent lifecycle tests. Inject time, deferred completion, and explicit queues. Do not add sleeps to make races probable. + +### Conformance is a separate gate + +The SDK runs the official conformance CLI in a separate CI workflow against a running server and client, with an expected-failures baseline. [TypeScript SDK 1.29.0 conformance workflow](https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.29.0/.github/workflows/conformance.yml), [TypeScript SDK 1.29.0 conformance runner](https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.29.0/test/conformance/scripts/run-server-conformance.sh) + +The official conformance framework connects to a running server, records protocol interactions, checks scenario behavior, and validates wire messages against the schema for the negotiated protocol version. Its baseline fails both on a new regression and when an expected failure starts passing but remains listed. [MCP conformance framework](https://github.com/modelcontextprotocol/conformance) + +Conformance is useful but not sufficient here. The current framework explicitly notes that its server suite does not exercise an SDK server as an OAuth protected resource. DevSpace still needs its own HTTP authorization tests. [Conformance authorization coverage note](https://github.com/modelcontextprotocol/conformance/blob/main/src/seps/sep-2207.yaml) + +Since DevSpace requires OAuth on `/mcp`, the clean conformance setup is a test-only loopback proxy that obtains or receives a valid test token, attaches it to each conformance request, and forwards to an otherwise unchanged DevSpace app. The proxy must stay in test code. A production `disableAuth` option would weaken the exact composition being tested. + +### Packaged boundaries are tested as packages + +The first-party filesystem server builds its distribution, starts `dist/index.js` with the SDK's `StdioClientTransport`, and calls tools through an actual MCP subprocess. The same test checks the advertised `outputSchema` through `tools/list` and validates results through `callTool`. [Filesystem server structured-content integration test](https://github.com/modelcontextprotocol/servers/blob/main/src/filesystem/__tests__/structured-content.test.ts) + +The MCP Inspector goes further for its CLI product. Its documented smoke path runs the built launcher against a bundled test server, and its package verification inspects the publish artifact. [MCP Inspector launcher and packaging checks](https://github.com/modelcontextprotocol/inspector/blob/main/clients/launcher/README.md) + +DevSpace is distributed as an npm CLI with native and static assets. A source-checkout build is not enough evidence. A temporary install of `npm pack` output should prove that `devspace`, `devspace-agentd`, migrations, UI assets, skills, docs, and the postinstall/native dependency path are present and resolvable. + +### Official does not mean automatically good + +The canonical `server-everything` suite also contains implementation-coupled tests that mock `McpServer` and assert exact registration counts and direct registrar calls. [Everything server registration tests](https://github.com/modelcontextprotocol/servers/blob/main/src/everything/__tests__/registrations.test.ts) + +Those are poor examples for DevSpace. A new composition strategy could register the same public tools and break the tests without changing client-visible behavior. Use the official repositories to find protocol scenarios and executable boundaries, not as a blanket quality standard. + +## DevSpace comparison + +| Seam | Current evidence | Assessment | Required change | +| --- | --- | --- | --- | +| Domain modules | Filesystem, roots, workspaces, process sessions, persistence, runtime pools, and provider adapters have direct tests. | Mixed. Security and temporal invariants are valuable. Exact mappings and incidental call patterns need pruning. | Keep tests that name an invariant and observe the module's public result. Delete or merge implementation-shaped cases after replacement evidence exists. | +| MCP tool interface | [`src/server.test.ts`](../src/server.test.ts) uses the SDK client through `InMemoryTransport`. | Good component seam, but it mostly exercises `open_workspace`. | Keep it. Expand only for tool contracts that cross registration, schema, metadata, and handler output. Do not duplicate every handler's domain cases here. | +| Production HTTP endpoint | [`src/server.ts`](../src/server.ts) composes Express, OAuth, session registry, transport, tools, stores, and shutdown. No test invokes this composition. | Critical gap. | Add an ephemeral HTTP fixture around `createServer().app` and a real SDK `StreamableHTTPClientTransport`. | +| MCP sessions | [`src/mcp-sessions.test.ts`](../src/mcp-sessions.test.ts) tests idle close and shutdown of a generic registry. | Valuable invariant test, but it cannot prove header routing or HTTP status codes. | Keep it and add a smaller HTTP lifecycle set. Do not repeat registry internals at HTTP level. | +| OAuth | [`src/oauth-store.test.ts`](../src/oauth-store.test.ts) tests persistence, hashing, rotation, expiry, restart, and provider methods. | Strong storage evidence. It bypasses HTTP discovery and bearer middleware. | Keep it. Add HTTP tests for metadata, challenge, resource audience, scopes, per-request authorization, and revocation. | +| Cancellation | Process termination is tested inside [`src/process-sessions.test.ts`](../src/process-sessions.test.ts), but MCP request cancellation is not driven through the server. Tool handlers in `server.ts` do not currently consume the SDK request abort signal. | Missing ownership contract. | Decide the invariant first. For example, cancelling a still-blocked `bash` request either terminates the owned process or deliberately returns a retained process session. Then write one adversarial MCP test for each legal ordering. | +| Protocol versions and capabilities | The in-memory SDK handshake succeeds, but HTTP version headers and capability gating are not asserted. | Missing upgrade guard. | Pin the supported protocol version in a production-shaped test and assert advertised capabilities through the client. | +| Package | CI builds and runs `dist/cli.js doctor`, but does not install the package it would publish. | Missing consumer path. | Pack, install in a clean temporary project, invoke the public bin, and verify required packaged files. | +| CI | [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) runs typecheck, the whole test chain, build, and doctor on Linux, macOS, and Windows under Node 22. | Broad repetition without evidence labels. It omits package install, HTTP conformance, and the upper supported Node version. | Split fast platform-neutral checks from focused OS and package jobs. | + +## Proposed test architecture + +Use Node's built-in test runner across the repository. DevSpace already uses `node:test` in several newer files, it supports named tests and cleanup hooks, and it avoids adding a framework only to obtain basic structure. A single runner is more important than which runner wins. + +The suite should expose five scripts with distinct purposes: + +```text +test:unit pure domain rules and narrow module interfaces +test:component real files, Git, SQLite, processes, provider protocol fixtures +test:mcp SDK client against in-memory and real Streamable HTTP transports +test:package npm tarball installed and invoked in a clean temporary project +test:conformance pinned official MCP conformance CLI against the HTTP test endpoint +``` + +`npm test` should run the first three locally. Package and conformance tests can remain explicit CI jobs if their runtime is materially higher. Every test must have a behavioral name and register cleanup at fixture creation time. + +This is not a test pyramid by file count. It is a set of agreed interfaces: + +1. Domain modules own pure rules and temporal transitions. +2. Adapter tests own decoding and normalization of external provider messages. +3. MCP component tests own the model-facing schema and tool result. +4. HTTP tests own transport, session, authorization, and server lifecycle. +5. Package tests own the npm consumer experience. + +An assertion belongs at the lowest interface that can prove the invariant without reaching into implementation details. The same fact should not be repeated at every level. + +## Cleanup sequence + +### 1. Write the invariant inventory + +Before editing tests, list the permanent product invariants by owner: + +- allowed-root containment and symlink handling +- workspace identity, checkout reuse, and worktree isolation +- process and subagent ownership, cancellation, shutdown, and bounded retention +- persisted state and restart compatibility +- provider protocol decoding and error preservation +- MCP tool schemas and model-visible results +- HTTP session, OAuth, and host-boundary behavior +- package contents and entry points + +Attach each existing test to one invariant and one public interface. A test with no invariant is a deletion candidate. An invariant with no interface test is a gap. + +### 2. Normalize the runner without changing assertions + +Move the anonymous top-level assertion scripts into named `node:test` cases. Add reusable fixtures for temporary directories, repositories, databases, child processes, environment variables, and MCP clients. Register cleanup immediately with `t.after()`. + +This mechanical stage should not rewrite behavior or add coverage. It makes later pruning reviewable and gives agents a test name instead of a file line when something fails. + +### 3. Add missing boundary tests before deleting substitutes + +Build one authenticated HTTP tracer test first: + +1. Start `createServer().app` on port `0`. +2. Complete the real local OAuth flow or issue a token through a public test fixture. +3. Connect `StreamableHTTPClientTransport`. +4. Assert negotiated version and advertised capabilities. +5. Call `open_workspace`, then a read-only workspace tool. +6. Close the MCP client and DevSpace server. +7. Assert no session, process, database, or listener remains owned by the fixture. + +Then add table-driven negative HTTP cases for the spec requirements DevSpace composes itself. Keep the table small: missing token, wrong audience, missing session, unknown session, unsupported protocol version, invalid Origin, and DELETE termination. The SDK already exhaustively tests generic media parsing and SSE mechanics. + +Add one cancellation history through the MCP interface only after the ownership rule is agreed. Use deferred values or an explicitly controlled process. Do not use sleeps. + +### 4. Add conformance and package evidence + +Pin the conformance package version. Run the server suite at the exact protocol version DevSpace claims to support. Keep any expected-failures baseline short and require a reason beside every entry. + +For the package job: + +1. Build once. +2. Create an npm tarball. +3. Inspect its file list for the declared runtime assets. +4. Install it into a clean temporary project without workspace links. +5. Run `devspace --version` and `devspace doctor` through the installed bin. +6. Exercise one bounded command that loads migrations and UI assets from the installed package. + +The package test should never import source files. + +### 5. Prune by evidence, not percentage + +Delete or merge a test when one of these is true: + +- It observes a private helper or internal collaborator and a public-interface test proves the same invariant. +- Its expected value is reconstructed using the same algorithm as production. +- TypeScript already makes the asserted state impossible and there is no runtime decoding seam. +- It checks an exact call count, ordering, string, or complete object with no documented contract requiring that exact value. +- It preserves the absence of a deleted feature without a security, compatibility, or migration reason. +- No plausible production defect makes it fail. + +Retain a test when it protects authority, atomicity, ownership, ordering, idempotency, bounded resources, restart behavior, version compatibility, or a model-visible contract. + +Do not set a coverage target or deletion quota. Both are easy for coding agents to game. For disputed tests, make one plausible mutation to the implementation. If the test stays green, it does not prove the claimed behavior. If an unrelated refactor breaks it while behavior remains correct, it is coupled to implementation. + +### 6. Reshape CI + +The official SDK 1.29.0 separates build from tests, runs its normal suite on its minimum and current Node versions, and gives conformance a separate workflow. [TypeScript SDK 1.29.0 CI](https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.29.0/.github/workflows/main.yml) + +For DevSpace, use: + +- Linux, Node 22: typecheck, unit, component, MCP, build. +- Linux, highest supported Node: package install and doctor. +- macOS and Windows, Node 22: only path, process, native module, Git/worktree, and packaged-bin cases that can vary by OS. +- Linux, pinned Node: MCP conformance. + +This keeps cross-platform evidence where the product actually differs while making the important HTTP and package failures visible as separate jobs. + +## Protocol-version horizon + +The 2026-07-28 revision removes protocol-level sessions and `MCP-Session-Id`. DevSpace 1.29.0 is built around the 2025 stateful session model. [MCP 2026-07-28 changelog](https://modelcontextprotocol.io/specification/2026-07-28/changelog) + +Do not mix support for the newer revision into the cleanup. First pin and test the contract DevSpace ships today. A later SDK v2 migration should begin with an explicit compatibility decision and new tracer test because the HTTP lifecycle, request association, and cancellation model change. Keeping protocol-version evidence in its own MCP test layer will make that migration legible instead of turning hundreds of lower-level tests red at once. + +## Practical standard for future tests + +Before adding a test, write down: + +1. The invariant. +2. The public interface where callers observe it. +3. The independent source of the expected result. +4. The plausible defect that would make the test fail. +5. The resource cleanup and time bound. + +If those answers do not fit in a few lines, the test probably needs a clearer seam or the behavior is not ready to test. This is the admission rule that prevents the suite from growing back into assertion inventory. diff --git a/package.json b/package.json index 388f99e2..cf031845 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx --test --test-concurrency=1 \"src/**/*.test.ts\"", + "test:package": "npm run build && node scripts/test-package.mjs", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs new file mode 100644 index 00000000..4fdf7fb6 --- /dev/null +++ b/scripts/test-package.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const npmExecutable = process.platform === "win32" ? "npm.cmd" : "npm"; +const requiredPackageFiles = [ + "dist/cli.js", + "dist/server.js", + "dist/local-agent-daemon-main.js", + "dist/db/migrations.js", + "dist/ui/workspace-app.html", + "scripts/fix-node-pty-permissions.mjs", + "skills/subagents/SKILL.md", +]; + +const temporaryRoot = await mkdtemp(join(tmpdir(), "devspace-package-test-")); + +try { + const packageJson = JSON.parse(await readFile(join(repositoryRoot, "package.json"), "utf8")); + const packed = await runNpm(["pack", "--json", "--pack-destination", temporaryRoot], repositoryRoot); + const [packageResult] = JSON.parse(packed.stdout); + const packedPaths = new Set(packageResult.files.map(({ path }) => path)); + + for (const requiredPath of requiredPackageFiles) { + assert.ok(packedPaths.has(requiredPath), `${requiredPath} is missing from the npm package`); + } + + const consumerRoot = join(temporaryRoot, "consumer"); + await mkdir(consumerRoot); + await writeFile( + join(consumerRoot, "package.json"), + JSON.stringify({ name: "devspace-package-consumer", private: true }, null, 2), + ); + + const tarballPath = join(temporaryRoot, packageResult.filename); + await runNpm(["install", "--no-audit", "--no-fund", tarballPath], consumerRoot); + + const executable = join( + consumerRoot, + "node_modules", + ".bin", + process.platform === "win32" ? "devspace.cmd" : "devspace", + ); + const version = await run(executable, ["--version"], consumerRoot); + assert.equal(version.stdout.trim(), packageJson.version); + + await run(executable, ["doctor"], consumerRoot, { + ...process.env, + DEVSPACE_ALLOWED_ROOTS: consumerRoot, + DEVSPACE_CONFIG_DIR: join(temporaryRoot, "config"), + DEVSPACE_OAUTH_OWNER_TOKEN: "package-test-owner-token-that-is-long-enough", + DEVSPACE_PUBLIC_BASE_URL: "http://127.0.0.1:7676", + }); + + console.log(`Installed and exercised ${packageResult.filename} as a consumer.`); +} finally { + await rm(temporaryRoot, { recursive: true, force: true }); +} + +function runNpm(args, cwd) { + return run(npmExecutable, args, cwd); +} + +function run(file, args, cwd, env = process.env) { + return execFileAsync(file, args, { + cwd, + env, + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + }); +} diff --git a/src/config.test.ts b/src/config.test.ts index 7b3eeeb6..a5b5d409 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,188 +1,196 @@ import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import test, { type TestContext } from "node:test"; import { loadConfig } from "./config.js"; -const emptyConfigDir = mkdtempSync(join(tmpdir(), "devspace-empty-config-test-")); -const baseEnv = { - DEVSPACE_CONFIG_DIR: emptyConfigDir, - DEVSPACE_ALLOWED_ROOTS: process.cwd(), - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", -}; - -assert.equal(loadConfig(baseEnv).widgets, "full"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "changes" }).widgets, "changes"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "full" }).widgets, "full"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).widgets, "off"); -assert.equal(loadConfig(baseEnv).toolMode, "minimal"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "minimal" }).toolMode, "minimal"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).toolMode, "full"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).toolMode, "codex"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).toolMode, "full"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).toolMode, "minimal"); -assert.equal(loadConfig(baseEnv).skillsEnabled, true); -assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills")); -assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents")); -assert.deepEqual(loadConfig(baseEnv).subagents, { enabled: false, providers: [] }); -assert.equal(loadConfig(baseEnv).artifactsEnabled, false); -assert.equal(loadConfig(baseEnv).artifactMaxFileBytes, 100 * 1024 * 1024); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_ARTIFACTS: "1" }).artifactsEnabled, true); -assert.equal( - loadConfig({ ...baseEnv, DEVSPACE_ARTIFACT_MAX_FILE_BYTES: "123" }).artifactMaxFileBytes, - 123, -); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); -assert.deepEqual(loadConfig({ ...baseEnv, DEVSPACE_SUBAGENTS: "1" }).subagents, { - enabled: true, - providers: [], +test("configuration defaults keep optional capabilities disabled", async (t) => { + const { configDir, env } = await configEnvironment(t); + const config = loadConfig(env); + + assert.equal(config.widgets, "full"); + assert.equal(config.toolMode, "minimal"); + assert.equal(config.skillsEnabled, true); + assert.equal(config.devspaceSkillsDir, join(configDir, "skills")); + assert.equal(config.devspaceAgentsDir, join(configDir, "agents")); + assert.deepEqual(config.subagents, { enabled: false, providers: [] }); + assert.equal(config.artifactsEnabled, false); + assert.equal(config.artifactMaxFileBytes, 100 * 1024 * 1024); +}); + +test("environment options enable supported tool and feature modes", async (t) => { + const { env } = await configEnvironment(t); + + assert.equal(loadConfig({ ...env, DEVSPACE_WIDGETS: "changes" }).widgets, "changes"); + assert.equal(loadConfig({ ...env, DEVSPACE_WIDGETS: "off" }).widgets, "off"); + assert.equal(loadConfig({ ...env, DEVSPACE_TOOL_MODE: "full" }).toolMode, "full"); + assert.equal(loadConfig({ ...env, DEVSPACE_TOOL_MODE: "codex" }).toolMode, "codex"); + assert.equal(loadConfig({ ...env, DEVSPACE_MINIMAL_TOOLS: "0" }).toolMode, "full"); + assert.equal(loadConfig({ ...env, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); + assert.equal(loadConfig({ ...env, DEVSPACE_ARTIFACTS: "1" }).artifactsEnabled, true); + assert.equal( + loadConfig({ ...env, DEVSPACE_ARTIFACT_MAX_FILE_BYTES: "123" }).artifactMaxFileBytes, + 123, + ); + assert.deepEqual(loadConfig({ ...env, DEVSPACE_SUBAGENTS: "1" }).subagents, { + enabled: true, + providers: [], + }); +}); + +test("invalid configuration fails at the environment boundary", async (t) => { + const { configDir, env } = await configEnvironment(t); + + assert.throws( + () => loadConfig({ ...env, DEVSPACE_WIDGETS: "invalid" }), + /Invalid DEVSPACE_WIDGETS: invalid/, + ); + assert.throws( + () => loadConfig({ ...env, DEVSPACE_TOOL_MODE: "invalid" }), + /Invalid DEVSPACE_TOOL_MODE: invalid/, + ); + assert.throws( + () => loadConfig({ ...env, DEVSPACE_LOG_LEVEL: "trace" }), + /Invalid DEVSPACE_LOG_LEVEL: trace/, + ); + assert.throws( + () => loadConfig({ ...env, DEVSPACE_LOG_FORMAT: "color" }), + /Invalid DEVSPACE_LOG_FORMAT: color/, + ); + assert.throws( + () => loadConfig({ DEVSPACE_CONFIG_DIR: configDir, DEVSPACE_ALLOWED_ROOTS: process.cwd() }), + /DEVSPACE_OAUTH_OWNER_TOKEN is required/, + ); + assert.throws( + () => loadConfig({ ...env, DEVSPACE_OAUTH_OWNER_TOKEN: "too-short" }), + /DEVSPACE_OAUTH_OWNER_TOKEN must be at least 16 characters long/, + ); + assert.throws( + () => loadConfig({ ...env, DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS: "0" }), + /Invalid DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS: 0/, + ); + assert.throws( + () => loadConfig({ ...env, DEVSPACE_ARTIFACT_MAX_FILE_BYTES: "0" }), + /Invalid DEVSPACE_ARTIFACT_MAX_FILE_BYTES: 0/, + ); +}); + +test("logging configuration preserves explicit operational choices", async (t) => { + const { env } = await configEnvironment(t); + + assert.deepEqual(loadConfig(env).logging, { + level: "info", + format: "json", + requests: true, + assets: false, + toolCalls: true, + shellCommands: false, + trustProxy: false, + }); + + const configured = loadConfig({ + ...env, + DEVSPACE_LOG_LEVEL: "debug", + DEVSPACE_LOG_FORMAT: "pretty", + DEVSPACE_LOG_REQUESTS: "0", + DEVSPACE_LOG_ASSETS: "1", + DEVSPACE_LOG_TOOL_CALLS: "0", + DEVSPACE_LOG_SHELL_COMMANDS: "1", + DEVSPACE_TRUST_PROXY: "1", + }); + assert.deepEqual(configured.logging, { + level: "debug", + format: "pretty", + requests: false, + assets: true, + toolCalls: false, + shellCommands: true, + trustProxy: true, + }); }); -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "invalid" }), - /Invalid DEVSPACE_WIDGETS: invalid/, -); -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "minimal" }), - /Invalid DEVSPACE_WIDGETS: minimal/, -); -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "write-only" }), - /Invalid DEVSPACE_WIDGETS: write-only/, -); -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "invalid" }), - /Invalid DEVSPACE_TOOL_MODE: invalid/, -); - -assert.deepEqual(loadConfig(baseEnv).logging, { - level: "info", - format: "json", - requests: true, - assets: false, - toolCalls: true, - shellCommands: false, - trustProxy: false, + +test("OAuth and public URL configuration define the server authority boundary", async (t) => { + const { env } = await configEnvironment(t); + const defaults = loadConfig(env); + + assert.equal(defaults.oauth.ownerToken, "test-owner-token-that-is-long-enough"); + assert.deepEqual(defaults.oauth.scopes, ["devspace"]); + assert.deepEqual(defaults.oauth.allowedRedirectHosts, ["chatgpt.com", "localhost", "127.0.0.1"]); + assert.equal(defaults.oauth.accessTokenTtlSeconds, 3600); + assert.equal(defaults.oauth.refreshTokenTtlSeconds, 2592000); + assert.equal(defaults.publicBaseUrl, "http://127.0.0.1:7676"); + assert.deepEqual(defaults.allowedHosts, ["localhost", "127.0.0.1", "::1"]); + + const configured = loadConfig({ + ...env, + DEVSPACE_OAUTH_SCOPES: "devspace,admin", + DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS: "chatgpt.com,example.com", + DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS: "120", + DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS: "240", + DEVSPACE_PUBLIC_BASE_URL: "https://abc.trycloudflare.com/", + }); + assert.deepEqual(configured.oauth.scopes, ["devspace", "admin"]); + assert.deepEqual(configured.oauth.allowedRedirectHosts, ["chatgpt.com", "example.com"]); + assert.equal(configured.oauth.accessTokenTtlSeconds, 120); + assert.equal(configured.oauth.refreshTokenTtlSeconds, 240); + assert.equal(configured.publicBaseUrl, "https://abc.trycloudflare.com"); + assert.deepEqual(configured.allowedHosts, [ + "localhost", + "127.0.0.1", + "::1", + "abc.trycloudflare.com", + ]); + assert.deepEqual(loadConfig({ ...env, DEVSPACE_ALLOWED_HOSTS: "*" }).allowedHosts, ["*"]); }); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "silent" }).logging.level, "silent"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "error" }).logging.level, "error"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "warn" }).logging.level, "warn"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "info" }).logging.level, "info"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "debug" }).logging.level, "debug"); - -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_FORMAT: "json" }).logging.format, "json"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_FORMAT: "pretty" }).logging.format, "pretty"); - -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_REQUESTS: "0" }).logging.requests, false); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_ASSETS: "1" }).logging.assets, true); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_TOOL_CALLS: "0" }).logging.toolCalls, false); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_SHELL_COMMANDS: "1" }).logging.shellCommands, true); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TRUST_PROXY: "1" }).logging.trustProxy, true); - -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "trace" }), - /Invalid DEVSPACE_LOG_LEVEL: trace/, -); - -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_LOG_FORMAT: "color" }), - /Invalid DEVSPACE_LOG_FORMAT: color/, -); - -assert.equal(loadConfig(baseEnv).oauth.ownerToken, "test-owner-token-that-is-long-enough"); -assert.deepEqual(loadConfig(baseEnv).oauth.scopes, ["devspace"]); -assert.deepEqual(loadConfig(baseEnv).oauth.allowedRedirectHosts, [ - "chatgpt.com", - "localhost", - "127.0.0.1", -]); -assert.equal(loadConfig(baseEnv).oauth.accessTokenTtlSeconds, 3600); -assert.equal(loadConfig(baseEnv).oauth.refreshTokenTtlSeconds, 2592000); - -assert.deepEqual( - loadConfig({ ...baseEnv, DEVSPACE_OAUTH_SCOPES: "devspace,admin" }).oauth.scopes, - ["devspace", "admin"], -); -assert.deepEqual( - loadConfig({ ...baseEnv, DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS: "chatgpt.com,example.com" }).oauth - .allowedRedirectHosts, - ["chatgpt.com", "example.com"], -); -assert.equal( - loadConfig({ ...baseEnv, DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS: "120" }).oauth - .accessTokenTtlSeconds, - 120, -); -assert.equal( - loadConfig({ ...baseEnv, DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS: "240" }).oauth - .refreshTokenTtlSeconds, - 240, -); - -assert.throws( - () => loadConfig({ DEVSPACE_CONFIG_DIR: emptyConfigDir, DEVSPACE_ALLOWED_ROOTS: process.cwd() }), - /DEVSPACE_OAUTH_OWNER_TOKEN is required/, -); -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_OAUTH_OWNER_TOKEN: "too-short" }), - /DEVSPACE_OAUTH_OWNER_TOKEN must be at least 16 characters long/, -); -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS: "0" }), - /Invalid DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS: 0/, -); -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_ARTIFACT_MAX_FILE_BYTES: "0" }), - /Invalid DEVSPACE_ARTIFACT_MAX_FILE_BYTES: 0/, -); - -assert.equal(loadConfig(baseEnv).publicBaseUrl, "http://127.0.0.1:7676"); -assert.deepEqual(loadConfig(baseEnv).allowedHosts, ["localhost", "127.0.0.1", "::1"]); - -assert.equal( - loadConfig({ ...baseEnv, DEVSPACE_PUBLIC_BASE_URL: "https://abc.trycloudflare.com/" }).publicBaseUrl, - "https://abc.trycloudflare.com", -); -assert.deepEqual( - loadConfig({ ...baseEnv, DEVSPACE_PUBLIC_BASE_URL: "https://abc.trycloudflare.com/" }).allowedHosts, - ["localhost", "127.0.0.1", "::1", "abc.trycloudflare.com"], -); -assert.deepEqual( - loadConfig({ ...baseEnv, DEVSPACE_ALLOWED_HOSTS: "*" }).allowedHosts, - ["*"], -); - -const configDir = mkdtempSync(join(tmpdir(), "devspace-config-test-")); -writeFileSync( - join(configDir, "config.json"), - JSON.stringify({ - port: 8787, - allowedRoots: [process.cwd()], - publicBaseUrl: "https://devspace.example.com", - subagents: true, - artifactsEnabled: true, - artifactMaxFileBytes: 321, - }), -); -writeFileSync( - join(configDir, "auth.json"), - JSON.stringify({ - ownerToken: "persisted-owner-token-long-enough", - }), -); - -const fileConfig = loadConfig({ DEVSPACE_CONFIG_DIR: configDir }); -assert.equal(fileConfig.port, 8787); -assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough"); -assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com"); -assert.equal(fileConfig.subagents.enabled, true); -assert.equal(fileConfig.subagents.providers.length, 7); -assert.equal(fileConfig.artifactsEnabled, true); -assert.equal(fileConfig.artifactMaxFileBytes, 321); -assert.deepEqual(fileConfig.allowedHosts, [ - "localhost", - "127.0.0.1", - "::1", - "devspace.example.com", -]); +test("persisted configuration is restored through the public loader", async (t) => { + const configDir = await temporaryDirectory(t, "devspace-config-test-"); + await writeFile( + join(configDir, "config.json"), + JSON.stringify({ + port: 8787, + allowedRoots: [process.cwd()], + publicBaseUrl: "https://devspace.example.com", + subagents: true, + artifactsEnabled: true, + artifactMaxFileBytes: 321, + }), + ); + await writeFile( + join(configDir, "auth.json"), + JSON.stringify({ ownerToken: "persisted-owner-token-long-enough" }), + ); + + const config = loadConfig({ DEVSPACE_CONFIG_DIR: configDir }); + assert.equal(config.port, 8787); + assert.equal(config.oauth.ownerToken, "persisted-owner-token-long-enough"); + assert.equal(config.publicBaseUrl, "https://devspace.example.com"); + assert.equal(config.subagents.enabled, true); + assert.equal(config.artifactsEnabled, true); + assert.equal(config.artifactMaxFileBytes, 321); + assert.deepEqual(config.allowedHosts, [ + "localhost", + "127.0.0.1", + "::1", + "devspace.example.com", + ]); +}); + +async function configEnvironment(t: TestContext) { + const configDir = await temporaryDirectory(t, "devspace-empty-config-test-"); + return { + configDir, + env: { + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_ALLOWED_ROOTS: process.cwd(), + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }, + }; +} + +async function temporaryDirectory(t: TestContext, prefix: string): Promise { + const directory = await mkdtemp(join(tmpdir(), prefix)); + t.after(() => rm(directory, { recursive: true, force: true })); + return directory; +} diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index 49d88aad..912899ff 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -1,48 +1,29 @@ import assert from "node:assert/strict"; +import test from "node:test"; import { checkLocalAgentProviderAvailability, formatLocalAgentProviderAvailabilitySummary, - getLocalAgentProviderAvailabilitySnapshot, } from "./local-agent-availability.js"; -{ - const availability = checkLocalAgentProviderAvailability("codex"); - assert.equal(availability.name, "codex"); - assert.equal(typeof availability.available, "boolean"); - if (availability.available) { - assert.equal(availability.note, "available"); - } -} - -{ +test("a configured provider command reports a useful missing-executable failure", () => { const availability = checkLocalAgentProviderAvailability("codex", { ...process.env, CODEX_COMMAND: "/definitely/missing/devspace-codex", }); - assert.equal(availability.available, false); - assert.match(availability.reason ?? "", /executable not found/); -} - -{ - assert.equal(checkLocalAgentProviderAvailability("pi").available, true); -} -{ - const snapshot = getLocalAgentProviderAvailabilitySnapshot({ - ...process.env, - CODEX_COMMAND: "/definitely/missing/devspace-codex", + assert.deepEqual(availability, { + name: "codex", + available: false, + reason: "/definitely/missing/devspace-codex executable not found", }); - assert.deepEqual( - snapshot.map((provider) => provider.name), - ["codex", "claude", "opencode", "pi", "cursor", "copilot", "grok"], - ); - assert.equal(snapshot.find((provider) => provider.name === "pi")?.available, true); -} +}); -assert.equal( - formatLocalAgentProviderAvailabilitySummary([ - { name: "codex", available: true, note: "available" }, - { name: "pi", available: false, reason: "pi executable not found" }, - ]), - "available: codex (available); unavailable: pi (pi executable not found)", -); +test("the availability summary separates usable and unusable providers", () => { + assert.equal( + formatLocalAgentProviderAvailabilitySummary([ + { name: "codex", available: true, note: "available" }, + { name: "pi", available: false, reason: "pi executable not found" }, + ]), + "available: codex (available); unavailable: pi (pi executable not found)", + ); +}); diff --git a/src/server-http.test.ts b/src/server-http.test.ts new file mode 100644 index 00000000..04a7cbec --- /dev/null +++ b/src/server-http.test.ts @@ -0,0 +1,271 @@ +import assert from "node:assert/strict"; +import { createHash, randomBytes } from "node:crypto"; +import { createServer as createNodeServer, type Server as HttpServer } from "node:http"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { type TestContext } from "node:test"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { loadConfig } from "./config.js"; +import { createServer } from "./server.js"; + +test("the HTTP endpoint advertises OAuth and rejects unauthenticated MCP requests", async (t) => { + const fixture = await httpFixture(t); + + const metadataResponse = await fetch( + new URL("/.well-known/oauth-protected-resource/mcp", fixture.baseUrl), + ); + assert.equal(metadataResponse.status, 200); + assert.deepEqual(await metadataResponse.json(), { + resource: fixture.mcpUrl.href, + authorization_servers: [fixture.baseUrl.href], + scopes_supported: ["devspace"], + resource_name: "DevSpace", + }); + + const response = await fetch(fixture.mcpUrl, { + method: "POST", + headers: { + Accept: "application/json, text/event-stream", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "devspace-http-test", version: "1.0.0" }, + }, + }), + }); + assert.equal(response.status, 401); + assert.match( + response.headers.get("www-authenticate") ?? "", + /resource_metadata=.*\/\.well-known\/oauth-protected-resource\/mcp/, + ); +}); + +test("the HTTP boundary rejects an untrusted browser origin", async (t) => { + const fixture = await httpFixture(t); + const request = (origin: string) => fetch(fixture.mcpUrl, { + method: "POST", + headers: { + Accept: "application/json, text/event-stream", + "Content-Type": "application/json", + Origin: origin, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "devspace-http-test", version: "1.0.0" }, + }, + }), + }); + + assert.equal((await request("https://attacker.example")).status, 403); + assert.equal((await request(fixture.baseUrl.origin)).status, 401); +}); + +test("an authenticated MCP client owns one HTTP session through termination", async (t) => { + const fixture = await httpFixture(t); + const accessToken = await authorize(fixture); + const transport = new StreamableHTTPClientTransport(fixture.mcpUrl, { + requestInit: { headers: { Authorization: `Bearer ${accessToken}` } }, + }); + const client = new Client({ name: "devspace-http-test", version: "1.0.0" }); + let closed = false; + const closeClient = async () => { + if (closed) return; + closed = true; + await client.close(); + }; + t.after(closeClient); + + await client.connect(transport); + const sessionId = transport.sessionId; + assert.ok(sessionId); + + const tools = await client.listTools(); + assert.ok(tools.tools.some((tool) => tool.name === "open_workspace")); + + const opened = await client.callTool({ + name: "open_workspace", + arguments: { path: fixture.root }, + }); + assert.ok(opened.structuredContent); + const openedWorkspace = jsonObject(opened.structuredContent); + assert.equal(typeof openedWorkspace.workspaceId, "string"); + assert.equal(openedWorkspace.root, fixture.root); + + const unauthenticatedSessionResponse = await fetch(fixture.mcpUrl, { + method: "POST", + headers: { + Accept: "application/json, text/event-stream", + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-11-25", + "MCP-Session-Id": sessionId, + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "ping" }), + }); + assert.equal(unauthenticatedSessionResponse.status, 401); + + await transport.terminateSession(); + const staleSessionResponse = await fetch(fixture.mcpUrl, { + method: "POST", + headers: { + Accept: "application/json, text/event-stream", + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-11-25", + "MCP-Session-Id": sessionId, + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "ping" }), + }); + assert.equal(staleSessionResponse.status, 404); + assert.match(await staleSessionResponse.text(), /Unknown MCP session/); +}); + +interface HttpFixture { + baseUrl: URL; + mcpUrl: URL; + root: string; + running: ReturnType; + httpServer: HttpServer; +} + +async function httpFixture(t: TestContext): Promise { + const root = await mkdtemp(join(tmpdir(), "devspace-http-test-")); + const port = await availablePort(); + const baseUrl = new URL(`http://127.0.0.1:${port}`); + const mcpUrl = new URL("/mcp", baseUrl); + const config = loadConfig({ + DEVSPACE_CONFIG_DIR: join(root, ".config"), + DEVSPACE_STATE_DIR: join(root, ".state"), + DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), + DEVSPACE_AGENT_DIR: join(root, ".agent"), + DEVSPACE_ALLOWED_ROOTS: root, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + DEVSPACE_PUBLIC_BASE_URL: baseUrl.href, + DEVSPACE_LOG_LEVEL: "silent", + DEVSPACE_WIDGETS: "off", + PORT: String(port), + }); + const running = createServer(config, { incomingArtifactAdapters: [] }); + const httpServer = await listen(running, port); + + t.after(async () => { + await closeHttpServer(httpServer); + await running.close(); + await rm(root, { recursive: true, force: true }); + }); + + return { baseUrl, mcpUrl, root, running, httpServer }; +} + +async function authorize(fixture: HttpFixture): Promise { + const redirectUri = new URL("/oauth-callback", fixture.baseUrl).href; + const registrationResponse = await fetch(new URL("/register", fixture.baseUrl), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "DevSpace HTTP test", + redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }), + }); + assert.equal(registrationResponse.status, 201); + const registration = jsonObject(await registrationResponse.json()); + const clientId = stringProperty(registration, "client_id"); + + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + const state = randomBytes(12).toString("base64url"); + const authorizationResponse = await fetch(new URL("/authorize", fixture.baseUrl), { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + redirect: "manual", + body: new URLSearchParams({ + response_type: "code", + client_id: clientId, + redirect_uri: redirectUri, + code_challenge: challenge, + code_challenge_method: "S256", + scope: "devspace", + state, + resource: fixture.mcpUrl.href, + owner_token: "test-owner-token-that-is-long-enough", + }), + }); + assert.equal(authorizationResponse.status, 302); + const authorizationLocation = authorizationResponse.headers.get("location"); + assert.ok(authorizationLocation); + const authorizationResult = new URL(authorizationLocation); + assert.equal(authorizationResult.searchParams.get("state"), state); + const code = authorizationResult.searchParams.get("code"); + assert.ok(code); + + const tokenResponse = await fetch(new URL("/token", fixture.baseUrl), { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: clientId, + code, + code_verifier: verifier, + redirect_uri: redirectUri, + resource: fixture.mcpUrl.href, + }), + }); + assert.equal(tokenResponse.status, 200); + const tokens = jsonObject(await tokenResponse.json()); + assert.equal(tokens.token_type, "bearer"); + return stringProperty(tokens, "access_token"); +} + +function jsonObject(value: unknown): Record { + assert.ok(value && typeof value === "object" && !Array.isArray(value)); + return value as Record; +} + +function stringProperty(object: Record, property: string): string { + const value = object[property]; + assert.equal(typeof value, "string"); + return value as string; +} + +async function availablePort(): Promise { + const server = createNodeServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const port = address.port; + await closeHttpServer(server); + return port; +} + +async function listen(running: ReturnType, port: number): Promise { + const server = running.app.listen(port, "127.0.0.1"); + await new Promise((resolve, reject) => { + server.once("listening", resolve); + server.once("error", reject); + }); + return server; +} + +async function closeHttpServer(server: HttpServer): Promise { + await new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); +} diff --git a/src/server.ts b/src/server.ts index 16c2010d..07a1eb46 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1800,6 +1800,22 @@ export function createServer( const requestId = res.locals.requestId as string | undefined; const sessionId = req.header("mcp-session-id"); const initializeRequest = req.method === "POST" && isInitializeRequest(req.body); + const origin = req.header("origin"); + + // Streamable HTTP clients normally omit Origin. Browser requests must come + // from the configured public origin so another site cannot drive the local + // MCP endpoint through DNS rebinding. + if (origin && !isConfiguredOrigin(origin, config.publicBaseUrl)) { + logEvent(config.logging, "warn", "auth_denied", { + requestId, + method: req.method, + path: requestPath(req), + reason: "invalid_origin", + ...requestLogFields(req, config), + }); + sendJsonRpcError(res, 403, -32000, "Invalid Origin"); + return; + } await new Promise((resolve, reject) => { bearerAuth(req, res, (error?: unknown) => { @@ -1906,6 +1922,14 @@ export function createServer( }; } +function isConfiguredOrigin(origin: string, publicBaseUrl: string): boolean { + try { + return new URL(origin).origin === new URL(publicBaseUrl).origin; + } catch { + return false; + } +} + async function isMainModule(): Promise { if (!process.argv[1]) return false; diff --git a/src/ui/patch-display.test.ts b/src/ui/patch-display.test.ts index 612809ff..63b8f7bf 100644 --- a/src/ui/patch-display.test.ts +++ b/src/ui/patch-display.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import test from "node:test"; import { getFileChangePathDisplay, getPatchDisplayParts, @@ -6,200 +7,98 @@ import { getRenderedFileChangePathDisplay, } from "./patch-display.js"; -assert.deepEqual(getPatchDisplayParts({}), { - title: "Applied patch", - tone: "edit", +test("a homogeneous patch reports its operation and unique file count", () => { + assert.deepEqual( + getPatchDisplayParts({ + files: [ + { path: "created.ts", operation: "add" }, + { path: "nested.ts", operation: "add" }, + ], + }), + { title: "Added 2 files", iconKind: "added", tone: "write" }, + ); + + assert.deepEqual( + getPatchDisplayParts({ + files: [ + { path: "same.ts", operation: "add" }, + { path: "same.ts", operation: "update" }, + ], + }), + { title: "Changed 1 file", tone: "edit" }, + ); }); -assert.deepEqual( - getPatchDisplayParts({ files: [{ path: "created.ts", operation: "add" }] }), - { - title: "Added 1 file", - iconKind: "added", - tone: "write", - }, -); - -assert.deepEqual( - getPatchDisplayParts({ - files: [ - { path: "a.ts", operation: "add" }, - { path: "b.ts", operation: "add" }, - ], - }), - { - title: "Added 2 files", - iconKind: "added", - tone: "write", - }, -); - -assert.deepEqual( - getFileChangePathDisplay({ - path: "src/new-name.ts", - previousPath: "src/old-name.ts", - }), - { - current: "new-name.ts", - previous: "old-name.ts", - title: "src/old-name.ts → src/new-name.ts", - }, -); - -assert.deepEqual( - getFileChangePathDisplay({ - path: "packages/new/file.ts", - previousPath: "src/old/file.ts", - }), - { - current: "packages/new/file.ts", - previous: "src/old/file.ts", - title: "src/old/file.ts → packages/new/file.ts", - }, -); - -assert.deepEqual( - getRenderedFileChangePathDisplay( - [{ path: "src/new-name.ts", previousPath: "src/old-name.ts", operation: "move" }], - { path: "src/new-name.ts" }, - 0, - ), - { - current: "new-name.ts", - previous: "old-name.ts", - title: "src/old-name.ts → src/new-name.ts", - }, -); - -assert.deepEqual( - getRenderedFileChangePathDisplay( - [ - { path: "shared.ts", previousPath: "first.ts", operation: "move" }, - { path: "shared.ts", previousPath: "second.ts", operation: "move" }, - ], - { path: "shared.ts" }, - 1, - ), - { - current: "shared.ts", - previous: "second.ts", - title: "second.ts → shared.ts", - }, -); - -assert.equal( - getRenderedFileChangeKind( - [ - { path: "same.tmp", operation: "add" }, - { path: "same.tmp", operation: "delete" }, - ], - { path: "same.tmp", type: "new" }, - 0, - ), - "added", -); - -assert.equal( - getRenderedFileChangeKind( - [ - { path: "same.tmp", operation: "add" }, - { path: "same.tmp", operation: "delete" }, - ], - { path: "same.tmp", type: "deleted" }, - 1, - ), - "deleted", -); - -assert.equal( - getRenderedFileChangeKind( - [{ path: "report.md", operation: "add" }], - { path: "report.md", type: "change" }, - 0, - ), - "edited", -); - -assert.equal( - getRenderedFileChangeKind( - [{ path: "renamed.md", previousPath: "old.md", operation: "move" }], - { path: "renamed.md", type: "change" }, - 0, - ), - "renamed", -); - -assert.deepEqual( - getPatchDisplayParts({ files: [{ path: "created.ts", type: "new" }] }), - { - title: "Added 1 file", - iconKind: "added", - tone: "write", - }, -); - -assert.deepEqual( - getPatchDisplayParts({ files: [{ path: "renamed.ts", type: "rename-changed" }] }), - { - title: "Renamed and edited 1 file", - iconKind: "renamed-edited", - tone: "edit", - }, -); - -assert.deepEqual( - getPatchDisplayParts({ files: [{ path: "removed.ts", type: "deleted" }] }), - { - title: "Deleted 1 file", - iconKind: "deleted", - tone: "delete", - }, -); - -assert.deepEqual( - getPatchDisplayParts({ files: [{ path: "unknown.ts" }] }), - { - title: "Changed 1 file", - tone: "edit", - }, -); - -assert.deepEqual( - getPatchDisplayParts({ - files: [ - { path: "created.ts", operation: "add" }, - { path: "edited.ts", operation: "update" }, - ], - }), - { - title: "Changed 2 files", - tone: "edit", - }, -); +test("renames keep enough path context to distinguish their source", () => { + assert.deepEqual( + getFileChangePathDisplay({ + path: "src/new-name.ts", + previousPath: "src/old-name.ts", + }), + { + current: "new-name.ts", + previous: "old-name.ts", + title: "src/old-name.ts → src/new-name.ts", + }, + ); + + assert.deepEqual( + getFileChangePathDisplay({ + path: "packages/new/file.ts", + previousPath: "src/old/file.ts", + }), + { + current: "packages/new/file.ts", + previous: "src/old/file.ts", + title: "src/old/file.ts → packages/new/file.ts", + }, + ); +}); -assert.deepEqual( - getPatchDisplayParts({ - files: [ - { path: "same.ts", operation: "add" }, - { path: "same.ts", operation: "update" }, - ], - }), - { - title: "Changed 1 file", - tone: "edit", - }, -); +test("repeated destination paths use the matching patch entry", () => { + const files = [ + { path: "shared.ts", previousPath: "first.ts", operation: "move" as const }, + { path: "shared.ts", previousPath: "second.ts", operation: "move" as const }, + ]; + + assert.deepEqual( + getRenderedFileChangePathDisplay(files, { path: "shared.ts" }, 1), + { + current: "shared.ts", + previous: "second.ts", + title: "second.ts → shared.ts", + }, + ); +}); -assert.deepEqual( - getPatchDisplayParts({ - files: [ - { path: "edited.ts", operation: "update" }, - { path: "moved.ts", previousPath: "old.ts", operation: "move" }, - { path: "removed.ts", operation: "delete" }, - ], - }), - { - title: "Changed 3 files", - tone: "edit", - }, -); +test("parsed diff metadata wins except when apply_patch records a move", () => { + assert.equal( + getRenderedFileChangeKind( + [ + { path: "same.tmp", operation: "add" }, + { path: "same.tmp", operation: "delete" }, + ], + { path: "same.tmp", type: "deleted" }, + 1, + ), + "deleted", + ); + + assert.equal( + getRenderedFileChangeKind( + [{ path: "renamed.md", previousPath: "old.md", operation: "move" }], + { path: "renamed.md", type: "change" }, + 0, + ), + "renamed", + ); + + assert.equal( + getRenderedFileChangeKind( + [{ path: "report.md", operation: "add" }], + { path: "report.md", type: "change" }, + 0, + ), + "edited", + ); +}); diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts index 71d3504d..e4b79d8d 100644 --- a/src/ui/tool-display.test.ts +++ b/src/ui/tool-display.test.ts @@ -1,179 +1,96 @@ import assert from "node:assert/strict"; -import type { ToolResultCard } from "./card-types.js"; +import test from "node:test"; import { toolIcons } from "./icons.js"; import { getToolDisplay, getToolHeaderSummary } from "./tool-display.js"; -const displayCases: Array<[ToolResultCard, { title: string; tone: string }]> = [ - [{ tool: "open_workspace", root: "/tmp/project" }, { title: "Opened workspace", tone: "workspace" }], - [{ tool: "open_workspace", root: "/tmp/project", workspaceReused: true }, { title: "Reused workspace", tone: "workspace" }], - [{ tool: "open_workspace", root: "/tmp/project", mode: "worktree" }, { title: "Opened workspace", tone: "workspace" }], - [{ tool: "open_workspace", root: "/tmp/project", mode: "worktree", workspaceReused: true }, { title: "Reused workspace", tone: "workspace" }], - [{ tool: "read", path: "src/read.ts" }, { title: "Read file", tone: "read" }], - [{ tool: "write", path: "src/write.ts" }, { title: "Wrote file", tone: "write" }], - [{ tool: "edit", path: "src/edit.ts" }, { title: "Edited file", tone: "edit" }], - [{ - tool: "apply_patch", - files: [{ path: "src/new.ts", operation: "add" }], - }, { title: "Added 1 file", tone: "write" }], - [{ - tool: "grep", - summary: { pattern: "needle", scope: "src" }, - }, { title: "Searched files", tone: "search" }], - [{ tool: "ls", path: "src" }, { title: "Listed directory", tone: "directory" }], - [{ tool: "bash", summary: { command: "npm test", exitCode: 0 } }, { title: "Ran command", tone: "shell" }], -]; - -for (const [card, expected] of displayCases) { - assert.deepEqual(pickDisplay(getToolDisplay(card)), expected); -} - -assert.equal(getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).label, "/tmp/project"); -assert.equal( - getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).icon, - toolIcons.folderOpen, -); -assert.equal( - getToolDisplay({ tool: "open_workspace", root: "/tmp/project", mode: "worktree" }).icon, - toolIcons.gitBranch, -); -assert.equal( - getToolDisplay({ tool: "grep", summary: { pattern: "needle", scope: "src" } }).label, - "needle in src", -); - -assert.equal( - getToolDisplay({ - tool: "apply_patch", - files: [{ - path: "src/new-name.ts", - previousPath: "src/old-name.ts", - operation: "move", - }], - }).label, - "src/old-name.ts → src/new-name.ts", -); - -assert.deepEqual( - pickDisplay(getToolDisplay({ - tool: "show_changes", - files: [ - { path: "src/a.ts", type: "change" }, - { path: "src/b.ts", type: "change" }, - ], - })), - { title: "Edited 2 files", tone: "review" }, -); - -assert.deepEqual( - pickDisplay(getToolDisplay({ - tool: "show_changes", - files: [ - { path: "src/a.ts", type: "new" }, - { path: "src/b.ts", type: "change" }, - ], - })), - { title: "Changed 2 files", tone: "review" }, -); - -assert.deepEqual( - pickDisplay(getToolDisplay({ - tool: "show_changes", - files: [{ path: "src/old.ts", type: "deleted" }], - })), - { title: "Deleted 1 file", tone: "review" }, -); - -assert.equal( - getToolDisplay({ tool: "show_changes", payload: { patch: "diff --git a/a b/a" } }).title, - "Changes ready", -); - -assert.equal(getToolDisplay({ tool: "show_changes" }).title, "No changes"); - -assert.equal( - getToolDisplay({ tool: "exec_command", summary: { running: true, command: "npm test" } }).title, - "Command running", -); -assert.equal( - getToolDisplay({ tool: "exec_command", summary: { running: false, exitCode: 1 } }).title, - "Command failed", -); -assert.equal( - getToolDisplay({ tool: "write_stdin", summary: { running: false, exitCode: 0 } }).title, - "Process finished", -); -assert.equal( - getToolDisplay({ tool: "exec_command", summary: { running: true } }).state, - "running", -); -assert.equal( - getToolDisplay({ tool: "exec_command", summary: { running: false, exitCode: 0 } }).state, - "success", -); -assert.equal( - getToolDisplay({ tool: "exec_command", summary: { running: false, exitCode: 1 } }).state, - "error", -); - -assert.deepEqual( - pickDisplay(getToolDisplay({ tool: "glob", summary: { lines: 1, pattern: "**/*.ts" } })), - { title: "Found files", tone: "search" }, -); - -assert.deepEqual( - getToolHeaderSummary({ tool: "glob", summary: { lines: 1 } }), - { kind: "empty" }, -); - -assert.equal( - getToolDisplay({ - tool: "apply_patch", - files: [{ path: "src/removed.ts", operation: "delete" }], - }).icon, - toolIcons.deleteFile, -); - -assert.deepEqual( - getToolHeaderSummary({ tool: "show_changes", summary: { additions: 14, removals: 1 } }), - { kind: "diff", additions: 14, removals: 1 }, -); - -assert.deepEqual( - getToolHeaderSummary({ +test("workspace display distinguishes checkout reuse from a new worktree", () => { + const reused = getToolDisplay({ tool: "open_workspace", - summary: { mode: "worktree", agentsFiles: 1, skills: 4 }, - }), - { kind: "text", text: "1 instruction · 4 skills" }, -); - -assert.deepEqual( - getToolHeaderSummary({ tool: "exec_command", summary: { lines: 3, wallTimeMs: 1_500 } }), - { kind: "text", text: "3 lines · 1.5s" }, -); - -assert.deepEqual( - getToolHeaderSummary({ tool: "grep", summary: { lines: 2 } }), - { kind: "text", text: "2 lines" }, -); - -assert.deepEqual( - getToolHeaderSummary({ tool: "read", summary: { lines: 1 } }), - { kind: "text", text: "1 line" }, -); - -assert.deepEqual( - getToolHeaderSummary({ tool: "ls", summary: { lines: 0 } }), - { kind: "text", text: "0 lines" }, -); - -assert.deepEqual( - getToolHeaderSummary({ tool: "open_workspace" }), - { kind: "empty" }, -); + root: "/tmp/project", + workspaceReused: true, + }); + assert.deepEqual( + { title: reused.title, label: reused.label, icon: reused.icon }, + { + title: "Reused workspace", + label: "/tmp/project", + icon: toolIcons.folderOpen, + }, + ); + + const worktree = getToolDisplay({ + tool: "open_workspace", + root: "/tmp/worktree", + mode: "worktree", + }); + assert.deepEqual( + { title: worktree.title, label: worktree.label, icon: worktree.icon }, + { + title: "Opened workspace", + label: "/tmp/worktree", + icon: toolIcons.gitBranch, + }, + ); +}); + +test("process display follows running, successful, and failed lifecycles", () => { + assert.deepEqual( + displayState({ tool: "exec_command", summary: { running: true, command: "npm test" } }), + { title: "Command running", label: "npm test", state: "running" }, + ); + assert.deepEqual( + displayState({ tool: "exec_command", summary: { running: false, exitCode: 0 } }), + { title: "Ran command", label: undefined, state: "success" }, + ); + assert.deepEqual( + displayState({ tool: "write_stdin", summary: { running: false, exitCode: 1 } }), + { title: "Process failed", label: undefined, state: "error" }, + ); +}); + +test("review display reports no changes and mixed file changes", () => { + assert.equal(getToolDisplay({ tool: "show_changes" }).title, "No changes"); + assert.deepEqual( + pickDisplay(getToolDisplay({ + tool: "show_changes", + files: [ + { path: "src/a.ts", type: "new" }, + { path: "src/b.ts", type: "change" }, + ], + })), + { title: "Changed 2 files", tone: "review" }, + ); +}); + +test("header summaries expose caller-visible counts and duration", () => { + assert.deepEqual( + getToolHeaderSummary({ + tool: "open_workspace", + summary: { agentsFiles: 1, skills: 4 }, + }), + { kind: "text", text: "1 instruction · 4 skills" }, + ); + assert.deepEqual( + getToolHeaderSummary({ + tool: "exec_command", + summary: { lines: 3, wallTimeMs: 1_500 }, + }), + { kind: "text", text: "3 lines · 1.5s" }, + ); + assert.deepEqual( + getToolHeaderSummary({ + tool: "show_changes", + summary: { additions: 14, removals: 1 }, + }), + { kind: "diff", additions: 14, removals: 1 }, + ); +}); + +function displayState(card: Parameters[0]) { + const display = getToolDisplay(card); + return { title: display.title, label: display.label, state: display.state }; +} function pickDisplay(display: ReturnType) { - return { - title: display.title, - tone: display.tone, - }; + return { title: display.title, tone: display.tone }; }