diff --git a/package-lock.json b/package-lock.json index 57a239e..b27aeeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "globals": "^16.0.0", "husky": "^9.1.7", "lint-staged": "^15.5.1", + "nock": "^14.0.15", "pino-pretty": "^13.0.0", "prettier": "^3.5.3", "prettier-plugin-organize-imports": "^4.1.0", @@ -999,6 +1000,49 @@ } } }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.8", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.8.tgz", + "integrity": "sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -3625,6 +3669,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3806,6 +3857,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4236,6 +4294,21 @@ "node": ">= 0.6" } }, + "node_modules/nock": { + "version": "14.0.15", + "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.15.tgz", + "integrity": "sha512-S0a47C9pLvcYx/Ugf0H30BVBEcUgMMBDk9VJIDlJ8XGrfH2QDUD4Tgdp45qDIiHttokBG+IbsOtsvIjGR/j3bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mswjs/interceptors": "^0.41.0", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">=18.20.0 <20 || >=20.12.1" + } + }, "node_modules/node-cleanup": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/node-cleanup/-/node-cleanup-2.1.2.tgz", @@ -4377,6 +4450,13 @@ "node": ">= 0.8.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4732,6 +4812,16 @@ ], "license": "MIT" }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -5288,6 +5378,13 @@ "duplexer": "~0.1.1" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", diff --git a/package.json b/package.json index f9c914a..5ba22b8 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,12 @@ "practice" ], "scripts": { - "test": "vitest run | pino-pretty", + "test": "vitest run --exclude 'tests/e2e/**' | pino-pretty", "test:unit": "vitest run tests/mcp tests/utils tests/services tests/tools", "test:integration": "vitest run tests/integration", - "test:e2e": "vitest run tests/e2e", - "test:all": "vitest run", - "test:coverage": "vitest run --coverage", + "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:all": "npm run test && npm run test:e2e", + "test:coverage": "vitest run --exclude 'tests/e2e/**' --coverage", "test:watch": "vitest watch", "test:types": "tsc -p tsconfig.test.json", "build": "tsc && chmod u+x build/index.js", @@ -83,6 +83,7 @@ "globals": "^16.0.0", "husky": "^9.1.7", "lint-staged": "^15.5.1", + "nock": "^14.0.15", "pino-pretty": "^13.0.0", "prettier": "^3.5.3", "prettier-plugin-organize-imports": "^4.1.0", diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 169ac6c..1931bcc 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -1,12 +1,70 @@ # End-to-End Tests -This directory will host real end-to-end tests that exercise the MCP server as -a black box: the suite spawns the built binary (`build/index.js`), attaches the -MCP SDK's `StdioClientTransport`, and mocks LeetCode HTTP via `nock`. +Real end-to-end tests that exercise the MCP server as a black box: each +spec spawns the built binary (`build/index.js`) as a child process, +attaches the MCP SDK's `StdioClientTransport`, and drives the server over +stdio just like a real MCP client would. -The full e2e harness is defined in §6 of the assessment report and will be -implemented in a dedicated PR (Phase 2 of the redesign plan). This Phase 0 PR -only sets up the directory and a placeholder spec so that `npm run test:e2e` -exits 0 instead of 1 with "No test files found". +## Running -Once the harness lands, the placeholder will be removed. +```bash +npm run test:e2e +``` + +This is also wired into `npm run test:all` (which runs unit + integration + +- e2e) so CI exercises the full stack. The default `npm test` script + **excludes** this directory because spawning a node child per spec is + significantly slower than the in-memory integration suites; keep it that + way unless you specifically want the e2e run. + +## How HTTP is mocked + +The server child process never reaches the real `leetcode.com`. Instead: + +1. `harness/preload.mjs` is registered via `NODE_OPTIONS=--import …` + when the child is spawned, so it runs before any user code. +2. The preload script activates [`nock`](https://github.com/nock/nock) + with `disableNetConnect()` and reads a JSON fixture from + `process.env.E2E_FIXTURE_PATH`. +3. The fixture (defined by `harness/types.ts`) describes which GraphQL + operations and REST endpoints to intercept and what to reply with. + +Specs author the fixture in TypeScript and pass it to `spawnServer({ fixture })`; +the harness writes it to a temp file and points the child at it. + +## Isolation + +Each `spawnServer()` call gets a fresh `mkdtemp` `HOME`, so +`~/.leetcode-mcp/credentials.json` is per-test and never touches the +developer's real home. Specs that need to pre-seed credentials can pass +`{ home }` to reuse a directory they prepared themselves. + +## Authoring a spec + +```ts +import { spawnServer } from "./harness/spawn-server.js"; + +const spawned = await spawnServer({ + fixture: { + graphql: [ + { + operationContains: "userStatus", + response: { + data: { userStatus: { isSignedIn: true, username: "alice" } } + } + } + ] + } +}); + +const result = await spawned.client.callTool({ + name: "check_auth_status", + arguments: {} +}); + +await spawned.cleanup(); +``` + +`spawnServer` ensures `build/index.js` is fresh (via `tests/e2e/harness/global-setup.ts`) +before any spec runs; you don't need to `npm run build` manually. diff --git a/tests/e2e/auth-restore.test.ts b/tests/e2e/auth-restore.test.ts new file mode 100644 index 0000000..bedfd29 --- /dev/null +++ b/tests/e2e/auth-restore.test.ts @@ -0,0 +1,103 @@ +/** + * E2E regression for the "silent-logout-on-restart" bug fixed in Phase 1. + * + * Before the fix, a server restart would re-read the credentials file from + * `~/.leetcode-mcp/credentials.json` and tell the user they were + * authenticated, but never actually push the cookies into the in-memory + * `Credential` the LeetCode client reads from. The very next authenticated + * tool call then failed with "Authentication required". + * + * This spec spawns a real server with a pre-seeded credentials file and a + * mocked `userStatus` GraphQL response, then calls `check_auth_status` over + * stdio. If the fix regresses, the tool will report `authenticated: false` + * and this spec fails. + */ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { spawnServer, type SpawnedServer } from "./harness/spawn-server.js"; + +interface ToolTextResult { + content: Array<{ type: string; text: string }>; +} + +describe("e2e: auth restore on startup", () => { + let spawned: SpawnedServer | undefined; + let seededHome: string | undefined; + + beforeEach(() => { + spawned = undefined; + seededHome = undefined; + }); + + afterEach(async () => { + if (spawned) { + await spawned.cleanup(); + } + if (seededHome) { + await rm(seededHome, { recursive: true, force: true }); + } + }); + + async function makeSeededHome(): Promise { + const home = await mkdtemp(join(tmpdir(), "leetcode-mcp-e2e-auth-")); + const dir = join(home, ".leetcode-mcp"); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, "credentials.json"), + JSON.stringify({ + csrftoken: "test-csrf", + LEETCODE_SESSION: "test-session", + createdAt: new Date().toISOString() + }), + "utf-8" + ); + return home; + } + + it("check_auth_status reports authenticated after a fresh restart", async () => { + seededHome = await makeSeededHome(); + + spawned = await spawnServer({ + home: seededHome, + fixture: { + graphql: [ + { + operationContains: "userStatus", + response: { + data: { + userStatus: { + isSignedIn: true, + username: "alice" + } + } + } + } + ] + } + }); + + const result = (await spawned.client.callTool({ + name: "check_auth_status", + arguments: {} + })) as ToolTextResult; + + expect(result.content[0]?.type).toBe("text"); + const payload = JSON.parse(result.content[0].text); + expect(payload.authenticated).toBe(true); + expect(payload.username).toBe("alice"); + }); + + it("check_auth_status reports unauthenticated when no credentials file exists", async () => { + spawned = await spawnServer(); + + const result = (await spawned.client.callTool({ + name: "check_auth_status", + arguments: {} + })) as ToolTextResult; + + const payload = JSON.parse(result.content[0].text); + expect(payload.authenticated).toBe(false); + }); +}); diff --git a/tests/e2e/harness/global-setup.ts b/tests/e2e/harness/global-setup.ts new file mode 100644 index 0000000..4af42c4 --- /dev/null +++ b/tests/e2e/harness/global-setup.ts @@ -0,0 +1,62 @@ +/** + * Vitest globalSetup hook: ensures `build/index.js` exists before any e2e + * spec spawns the server, and that it's at least as fresh as everything + * under `src/`. + * + * Without this, an unsuspecting `npm run test:e2e` after editing source + * would silently exercise a stale binary and report green, hiding real + * regressions. We'd rather pay the ~1s `tsc` cost up front than ship a + * blind-spot. + */ +import { execFile } from "node:child_process"; +import { readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export default async function setup(): Promise { + if (!(await needsRebuild())) { + return; + } + await execFileAsync("npm", ["run", "build"], { + // Inherit cwd so it builds the project under test, not whichever + // sub-package vitest happens to launch from. + cwd: process.cwd(), + // Fail loudly if tsc errors, rather than silently letting the e2e + // suite fall through to "command not found" on `node build/index.js`. + env: { ...process.env, npm_config_loglevel: "error" } + }); +} + +async function needsRebuild(): Promise { + let binMtime: number; + try { + binMtime = (await stat("build/index.js")).mtimeMs; + } catch { + return true; + } + // Walk every `.ts` file under `src/` — comparing only against + // `src/index.ts` would let edits to any other module slip through. + const srcMtime = await maxMtimeUnder("src"); + return binMtime < srcMtime; +} + +/** Recursively returns the largest mtime among `.ts` files under `dir`. */ +async function maxMtimeUnder(dir: string): Promise { + let max = 0; + const entries = await readdir(dir, { withFileTypes: true }); + await Promise.all( + entries.map(async (entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + const sub = await maxMtimeUnder(path); + if (sub > max) max = sub; + } else if (entry.isFile() && entry.name.endsWith(".ts")) { + const m = (await stat(path)).mtimeMs; + if (m > max) max = m; + } + }) + ); + return max; +} diff --git a/tests/e2e/harness/preload.mjs b/tests/e2e/harness/preload.mjs new file mode 100644 index 0000000..b387461 --- /dev/null +++ b/tests/e2e/harness/preload.mjs @@ -0,0 +1,77 @@ +/** + * Preload script that runs inside the spawned MCP server child process before + * any user code. + * + * Registered via `NODE_OPTIONS="--import .../preload.mjs"`. Its job is to: + * + * 1. Activate `nock`, blocking the child from making any real network + * requests to leetcode.com (the e2e suite must never depend on the + * live LeetCode service being reachable or behaving consistently). + * 2. Read fixture data from a JSON file whose path is provided via the + * `E2E_FIXTURE_PATH` env var, and install nock interceptors that + * replay the canned GraphQL / REST responses back to the server. + * + * The fixture format is the {@link E2EFixture} type from `./types.ts`. Tests + * write a JSON file describing the LeetCode responses they want, point the + * child at it via env, and then drive the server through StdioClientTransport. + * + * If `E2E_FIXTURE_PATH` is not set, this preload is a no-op apart from + * disabling network — useful for lifecycle tests that don't touch LeetCode. + */ +import nock from "nock"; +import { readFileSync } from "node:fs"; + +nock.disableNetConnect(); + +const fixturePath = process.env.E2E_FIXTURE_PATH; +if (fixturePath) { + /** @type {import("./types.ts").E2EFixture} */ + let fixture; + try { + fixture = JSON.parse(readFileSync(fixturePath, "utf-8")); + } catch (error) { + // If the fixture file is malformed, fail loudly rather than silently + // letting the server hit `nock.disableNetConnect` and produce a + // confusing "Nock: Disallowed net connect" error mid-test. + process.stderr.write( + `[e2e preload] Failed to read fixture at ${fixturePath}: ${error}\n` + ); + process.exit(1); + } + + for (const entry of fixture.graphql ?? []) { + // `.persist()` is a Scope method (must be called before the + // interceptor is constructed); `.times()` is an Interceptor method. + // Default to persist so a single fixture entry can serve multiple + // calls to the same operation without callers tracking counts. + const scope = nock("https://leetcode.com"); + if (entry.times === undefined) { + scope.persist(); + } + const interceptor = scope.post( + "/graphql", + (body) => + typeof body?.query === "string" && + body.query.includes(entry.operationContains) + ); + if (entry.times !== undefined) { + interceptor.times(entry.times); + } + interceptor.reply(entry.status ?? 200, entry.response); + } + + for (const entry of fixture.rest ?? []) { + const scope = nock("https://leetcode.com"); + if (entry.times === undefined) { + scope.persist(); + } + const interceptor = + entry.method === "GET" + ? scope.get(entry.path) + : scope.post(entry.path); + if (entry.times !== undefined) { + interceptor.times(entry.times); + } + interceptor.reply(entry.status ?? 200, entry.response); + } +} diff --git a/tests/e2e/harness/spawn-server.ts b/tests/e2e/harness/spawn-server.ts new file mode 100644 index 0000000..388df73 --- /dev/null +++ b/tests/e2e/harness/spawn-server.ts @@ -0,0 +1,134 @@ +/** + * Test harness for spawning the LeetCode MCP server as a real child process + * over stdio and connecting an MCP client to it. + * + * Each spawn: + * - Runs the freshly built `build/index.js` binary (via `node`). + * - Gets its own isolated `HOME` (a fresh `mkdtemp`) so the credentials + * store at `~/.leetcode-mcp/credentials.json` is per-test, never leaks + * between specs, and never touches the developer's real home. + * - Uses `NODE_OPTIONS="--import preload.mjs"` to activate `nock` inside + * the child before any application code runs, so all LeetCode HTTP is + * served from a JSON fixture instead of the real internet. + * + * The harness returns an MCP `Client` already wired to the child plus the + * directory acting as `HOME` so tests can pre-seed credentials, and a + * `cleanup()` to close the client and remove the temp directory. + */ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import type { E2EFixture } from "./types.js"; + +const HARNESS_DIR = fileURLToPath(new URL(".", import.meta.url)); +const REPO_ROOT = resolve(HARNESS_DIR, "..", "..", ".."); +const SERVER_BIN = join(REPO_ROOT, "build", "index.js"); +const PRELOAD = join(HARNESS_DIR, "preload.mjs"); + +export interface SpawnOptions { + /** + * LeetCode HTTP responses to serve back to the child via nock. If + * omitted, nock is still activated (so the child can't reach the real + * leetcode.com), but no interceptors are installed — useful for + * lifecycle / negative-path specs that don't drive an authenticated + * tool. + */ + fixture?: E2EFixture; + /** + * Reuse an existing directory as the child's `HOME` instead of letting + * the harness mkdtemp a fresh one. The caller is responsible for + * cleanup of any home it provides; the harness only removes homes it + * created itself. + * + * Useful for specs that need to pre-seed `~/.leetcode-mcp/...` before + * the server boots (e.g., the auth-restore regression). + */ + home?: string; + /** + * Extra environment variables to pass to the child. Merged on top of + * the harness-controlled ones (`HOME`, `NODE_OPTIONS`, + * `E2E_FIXTURE_PATH`). + */ + env?: Record; +} + +export interface SpawnedServer { + /** Connected MCP `Client` ready to call tools / list / etc. */ + client: Client; + /** Temp directory acting as the child's `HOME`. */ + home: string; + /** Tear down the client transport and remove the temp directory. */ + cleanup: () => Promise; +} + +/** + * Spawns `build/index.js` as a child process with isolated `HOME` and + * preloaded nock, and returns an MCP client connected over stdio. + */ +export async function spawnServer( + options: SpawnOptions = {} +): Promise { + const homeWasProvided = options.home !== undefined; + const home = + options.home ?? (await mkdtemp(join(tmpdir(), "leetcode-mcp-e2e-"))); + + // Fixtures live in their own temp directory regardless of who owns + // `HOME`, so a caller-provided `HOME` never gets a stray + // `fixture.json` byproduct. The harness owns the fixture dir and + // always cleans it up. + let fixtureDir: string | undefined; + let fixturePath: string | undefined; + if (options.fixture) { + fixtureDir = await mkdtemp(join(tmpdir(), "leetcode-mcp-e2e-fix-")); + fixturePath = join(fixtureDir, "fixture.json"); + await writeFile(fixturePath, JSON.stringify(options.fixture), "utf-8"); + } + + const env: Record = { + // Pass through the bare minimum from the parent so node can find + // node_modules and the test runner's cwd matches the repo root. + PATH: process.env.PATH ?? "", + HOME: home, + // `pathToFileURL` percent-encodes path segments so the preload + // import works even when the harness path contains spaces or + // other URL-reserved characters (common on macOS user dirs). + NODE_OPTIONS: `--import ${pathToFileURL(PRELOAD).href}`, + ...(fixturePath ? { E2E_FIXTURE_PATH: fixturePath } : {}), + ...(options.env ?? {}) + }; + + const transport = new StdioClientTransport({ + command: process.execPath, + args: [SERVER_BIN], + env, + cwd: REPO_ROOT, + // Forward stderr so the test runner surfaces server logs / nock + // errors when things go wrong. + stderr: "inherit" + }); + + const client = new Client({ + name: "leetcode-mcp-e2e", + version: "0.0.0" + }); + await client.connect(transport); + + const cleanup = async () => { + try { + await client.close(); + } catch { + // Already closed — ignore. + } + if (!homeWasProvided) { + await rm(home, { recursive: true, force: true }); + } + if (fixtureDir) { + await rm(fixtureDir, { recursive: true, force: true }); + } + }; + + return { client, home, cleanup }; +} diff --git a/tests/e2e/harness/types.ts b/tests/e2e/harness/types.ts new file mode 100644 index 0000000..40ad091 --- /dev/null +++ b/tests/e2e/harness/types.ts @@ -0,0 +1,42 @@ +/** + * Shared types for the e2e harness fixtures. + * + * The fixture file is the contract between the parent test process (which + * authors the fixture) and the spawned MCP server child process (which reads + * the fixture via the `preload.mjs` script and replays it through nock). + * + * Keep this module dependency-free so it can be imported by both vitest + * specs and the lightweight preload script without dragging in the rest of + * the codebase. + */ + +export interface MockGraphqlResponse { + /** Match request body where `body.query` includes this substring. */ + operationContains: string; + /** Response payload (will be JSON-stringified into the response body). */ + response: unknown; + /** HTTP status to return. Defaults to 200. */ + status?: number; + /** How many times this interceptor should fire. Defaults to Infinity. */ + times?: number; +} + +export interface MockRestEndpoint { + /** HTTP method, e.g. "POST" or "GET". */ + method: "GET" | "POST"; + /** URL path on `https://leetcode.com`, e.g. "/problems/two-sum/submit/". */ + path: string; + /** Response payload. */ + response: unknown; + /** HTTP status to return. Defaults to 200. */ + status?: number; + /** How many times this interceptor should fire. Defaults to Infinity. */ + times?: number; +} + +export interface E2EFixture { + /** GraphQL operations on https://leetcode.com/graphql. */ + graphql?: MockGraphqlResponse[]; + /** REST endpoints on https://leetcode.com (submit / check / etc). */ + rest?: MockRestEndpoint[]; +} diff --git a/tests/e2e/lifecycle.test.ts b/tests/e2e/lifecycle.test.ts new file mode 100644 index 0000000..2cdfcfb --- /dev/null +++ b/tests/e2e/lifecycle.test.ts @@ -0,0 +1,77 @@ +/** + * Lifecycle e2e: spawns the real `build/index.js` over stdio, performs the + * MCP handshake via the SDK client, and asserts the server reports the + * tools / resources / prompts we expect. + * + * This locks in the wire-level surface area: any drift in tool names or + * server identity is caught before clients do. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { spawnServer, type SpawnedServer } from "./harness/spawn-server.js"; + +describe("e2e: server lifecycle", () => { + let spawned: SpawnedServer; + + beforeAll(async () => { + spawned = await spawnServer(); + }); + + afterAll(async () => { + await spawned.cleanup(); + }); + + it("advertises a non-empty server name and version after handshake", () => { + const info = spawned.client.getServerVersion(); + expect(info?.name).toBeTruthy(); + expect(info?.version).toBeTruthy(); + }); + + it("registers all expected tools", async () => { + const { tools } = await spawned.client.listTools(); + const names = tools.map((t) => t.name).sort(); + + // The exact set must stay stable — adding a tool is intentional and + // should bump this assertion. Keep alphabetised so diffs are easy to + // read. + const expected = [ + "check_auth_status", + "get_all_submissions", + "get_daily_challenge", + "get_problem", + "get_problem_progress", + "get_problem_solution", + "get_problem_submission_report", + "get_recent_ac_submissions", + "get_recent_submissions", + "get_started", + "get_user_contest_ranking", + "get_user_profile", + "get_user_status", + "list_problem_solutions", + "save_leetcode_credentials", + "search_problems", + "start_leetcode_auth", + "submit_solution" + ]; + + // Use toEqual with a sorted expected so any addition / rename + // surfaces clearly without a brittle "every name in any order" + // assertion. + expect(names).toEqual(expected.sort()); + }); + + it("registers MCP prompts", async () => { + const { prompts } = await spawned.client.listPrompts(); + expect(prompts.length).toBeGreaterThan(0); + const names = prompts.map((p) => p.name); + expect(names).toContain("leetcode_authentication_guide"); + }); + + it("exposes resource templates for problems and solutions", async () => { + const { resourceTemplates } = + await spawned.client.listResourceTemplates(); + expect(resourceTemplates.length).toBeGreaterThan(0); + const uriTemplates = resourceTemplates.map((r) => r.uriTemplate); + expect(uriTemplates.some((u) => u.startsWith("problem://"))).toBe(true); + }); +}); diff --git a/tests/e2e/placeholder.test.ts b/tests/e2e/placeholder.test.ts deleted file mode 100644 index b5eb08c..0000000 --- a/tests/e2e/placeholder.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Placeholder e2e spec. - * - * The real end-to-end harness — spawning `build/index.js`, attaching - * `StdioClientTransport`, and mocking LeetCode over `nock` — lands in a - * dedicated PR (Phase 2 of the redesign plan). This file exists so that - * `npm run test:e2e` (which targets `tests/e2e/`) exits 0 instead of 1 - * with "No test files found", giving CI an honest signal until then. - * - * Once the real harness lands, this file is removed. - */ -import { describe, expect, it } from "vitest"; - -describe("e2e harness placeholder", () => { - it("reserves the tests/e2e directory until the real harness lands", () => { - expect(true).toBe(true); - }); -}); diff --git a/tests/e2e/problem-flow.test.ts b/tests/e2e/problem-flow.test.ts new file mode 100644 index 0000000..928123f --- /dev/null +++ b/tests/e2e/problem-flow.test.ts @@ -0,0 +1,83 @@ +/** + * Happy-path e2e: spawn the server, call `get_problem` with a mocked + * GraphQL response, and assert the wire-level envelope flows through + * unchanged. + * + * Locks in the contract that callers see structured JSON (not free-form + * text) when a problem is fetched, and that the slug round-trips through + * the GraphQL boundary unmodified. + */ +import { afterEach, describe, expect, it } from "vitest"; +import { spawnServer, type SpawnedServer } from "./harness/spawn-server.js"; + +interface ToolTextResult { + content: Array<{ type: string; text: string }>; +} + +const TWO_SUM_PROBLEM = { + questionId: "1", + questionFrontendId: "1", + title: "Two Sum", + titleSlug: "two-sum", + difficulty: "Easy", + isPaidOnly: false, + content: + "

Given an array of integers nums and an integer target...

", + topicTags: [{ name: "Array", slug: "array" }], + codeSnippets: [ + { + lang: "Python3", + langSlug: "python3", + code: "class Solution:\n def twoSum(self, nums, target):\n pass\n" + } + ], + similarQuestions: "[]", + exampleTestcases: "[2,7,11,15]\n9", + hints: ["Try a hash map for O(n) lookup"], + stats: '{"totalAccepted":"10M","totalSubmission":"20M","acRate":"50.0%"}' +}; + +describe("e2e: problem-flow happy path", () => { + let spawned: SpawnedServer | undefined; + + afterEach(async () => { + if (spawned) { + await spawned.cleanup(); + spawned = undefined; + } + }); + + it("get_problem returns a structured envelope for a known slug", async () => { + spawned = await spawnServer({ + fixture: { + graphql: [ + { + // `leetcode-query` issues an anonymous GraphQL + // `question(titleSlug: ...)` query for problem + // fetches. Match on the field-level selector + // rather than an operation name (it doesn't have + // one) to stay robust to formatting changes. + operationContains: "question(titleSlug:", + response: { + data: { question: TWO_SUM_PROBLEM } + } + } + ] + } + }); + + const result = (await spawned.client.callTool({ + name: "get_problem", + arguments: { titleSlug: "two-sum" } + })) as ToolTextResult; + + expect(result.content[0]?.type).toBe("text"); + const payload = JSON.parse(result.content[0].text); + // The tool wraps the simplified projection in `{ titleSlug, problem }`; + // assert the wire-level envelope, not the internal projection. + expect(payload.titleSlug).toBe("two-sum"); + expect(payload.problem.title).toBe("Two Sum"); + // topicTags are projected down to a string[] of slugs. + expect(payload.problem.topicTags).toEqual(["array"]); + }); +}); diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..aa8b82f --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vitest/config"; + +/** + * Dedicated config for the e2e suite. + * + * - Only includes `tests/e2e/**` so the slow spawn-the-binary specs don't + * run alongside the fast unit / integration suites. + * - Wires in `global-setup.ts` so `build/index.js` is guaranteed to exist + * and be at least as fresh as `src/` before any spec spawns the server. + * - 30s test timeout because spawning a Node child process plus an MCP + * handshake comfortably exceeds the 5s integration default. + */ +export default defineConfig({ + test: { + environment: "node", + include: ["tests/e2e/**/*.test.ts"], + globals: true, + globalSetup: ["tests/e2e/harness/global-setup.ts"], + testTimeout: 30_000, + hookTimeout: 30_000 + } +});