Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
74 changes: 66 additions & 8 deletions tests/e2e/README.md
Original file line number Diff line number Diff line change
@@ -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.
103 changes: 103 additions & 0 deletions tests/e2e/auth-restore.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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);
});
});
Loading
Loading