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
140 changes: 140 additions & 0 deletions tests/_helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Shared test helpers extracted from cli.test.ts / server.test.ts / e2e.test.ts
// / snapshots.test.ts to remove copy-paste duplication. Not a test file itself
// — the runner pattern `tsx --test tests/*.test.ts` (see package.json) excludes
// this file by name.

import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import {
createServer,
type IncomingMessage,
type ServerResponse,
} from "node:http";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

export async function startMock(
handler: (req: IncomingMessage, res: ServerResponse) => void,
): Promise<{ url: string; close: () => Promise<void> }> {
const httpServer = createServer(handler);
await new Promise<void>((resolve) =>
httpServer.listen(0, "127.0.0.1", () => resolve()),
);
const address = httpServer.address();
if (!address || typeof address !== "object") {
throw new Error("mock server address unavailable");
}
return {
url: `http://127.0.0.1:${address.port}`,
// closeAllConnections() drops keep-alive sockets so close() actually
// resolves; without it the server lingers past the test boundary.
close: () =>
new Promise<void>((resolve, reject) => {
httpServer.closeAllConnections();
httpServer.close((err) => (err ? reject(err) : resolve()));
}),
};
}

export async function spawnClient(opts?: {
name?: string;
env?: Record<string, string>;
}): Promise<Client> {
const transport = new StdioClientTransport({
command: "tsx",
args: ["src/index.ts"],
env: { ...process.env, ...opts?.env } as Record<string, string>,
});
const client = new Client({
name: opts?.name ?? "markfetch-test",
version: "0.0.0",
});
await client.connect(transport);
return client;
}

export function textOf(result: { content: unknown }): string {
const content = result.content as Array<{ type: string; text?: string }>;
return content[0]?.text ?? "";
}

// Matches any of the seven [code] error prefixes the tool emits. Used by
// schema-rejection assertions to prove the handler did NOT run — a [code]
// prefix would mean the call escaped Zod and reached core.
export const ERROR_CODE_PREFIX_RE =
/^\[(network_error|http_error|timeout|unsupported_content_type|extraction_failed|too_large|save_failed)\]/;

// Asserts that a tool call is rejected at the Zod schema boundary, not by the
// handler. The SDK either throws (some versions) or returns isError:true with
// schema-error text — both are valid rejections. What's NOT valid is a
// [code]-prefixed reply, which would prove the handler ran.
export async function assertSchemaRejection(
client: Client,
args: Record<string, unknown>,
failureMessage: string,
): Promise<void> {
let caught = false;
let result: { isError?: boolean; content?: unknown } | undefined;
try {
result = (await client.callTool({
name: "fetch_markdown",
arguments: args,
})) as { isError?: boolean; content?: unknown };
} catch {
caught = true;
}
if (!caught) {
assert.equal(
result?.isError,
true,
"schema rejection must surface as isError",
);
const text = textOf(result as { content: unknown });
assert.ok(
!ERROR_CODE_PREFIX_RE.test(text),
`${failureMessage}: ${text}`,
);
}
}

// One-shot subprocess spawn that returns exit code + stderr. Used by
// startup-failure tests that expect a misconfigured env var to fail fast.
export async function spawnAndCaptureExit(
args: string[],
env: Record<string, string>,
): Promise<{ exitCode: number; stderr: string }> {
const child = spawn("./node_modules/.bin/tsx", args, {
env: { ...process.env, ...env } as Record<string, string>,
stdio: ["pipe", "pipe", "pipe"],
});
let stderr = "";
child.stderr.on("data", (d: Buffer) => {
stderr += d.toString();
});
const exitCode = await new Promise<number>((resolve) =>
child.on("exit", (code) => resolve(code ?? -1)),
);
Comment on lines +114 to +116
return { exitCode, stderr };
}

// Deterministic Readability-friendly fixture with three <h2> sections so
// server-side tests that assert on multiple sub-headings have material;
// CLI tests assert on a subset and still pass.
export const HAPPY_FIXTURE = `<!DOCTYPE html>
<html lang="en">
<head><title>Test Article</title></head>
<body>
<header><nav>nav links</nav></header>
<main>
<article>
<h1>Test Article Title</h1>
<p>First substantive paragraph with enough content to pass Readability's heuristics. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. The article contains real prose for the extractor to score positively.</p>
<h2>Section heading</h2>
<p>Second paragraph with continuing content. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. More words to give Readability adequate signal.</p>
<h2>Another section</h2>
<p>Third paragraph: Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
</article>
</main>
<footer>copyright</footer>
</body>
</html>`;
44 changes: 1 addition & 43 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,10 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import {
createServer,
type IncomingMessage,
type ServerResponse,
} from "node:http";
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve as resolvePath } from "node:path";
import { startMock, HAPPY_FIXTURE } from "./_helpers.js";

const execFileAsync = promisify(execFile);

Expand All @@ -24,44 +20,6 @@ const execFileAsync = promisify(execFile);
const TSX_CLI = resolvePath("./node_modules/.bin/tsx");
const ENTRY = resolvePath("src/index.ts");

const HAPPY_FIXTURE = `<!DOCTYPE html>
<html lang="en">
<head><title>Test Article</title></head>
<body>
<header><nav>nav links</nav></header>
<main>
<article>
<h1>Test Article Title</h1>
<p>First substantive paragraph with enough content to pass Readability's heuristics. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. The article contains real prose for the extractor to score positively.</p>
<h2>Section heading</h2>
<p>Second paragraph with continuing content. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. More words to give Readability adequate signal.</p>
</article>
</main>
<footer>copyright</footer>
</body>
</html>`;

async function startMock(
handler: (req: IncomingMessage, res: ServerResponse) => void,
): Promise<{ url: string; close: () => Promise<void> }> {
const httpServer = createServer(handler);
await new Promise<void>((resolve) =>
httpServer.listen(0, "127.0.0.1", () => resolve()),
);
const address = httpServer.address();
if (!address || typeof address !== "object") {
throw new Error("mock server address unavailable");
}
return {
url: `http://127.0.0.1:${address.port}`,
close: () =>
new Promise<void>((resolve, reject) => {
httpServer.closeAllConnections();
httpServer.close((err) => (err ? reject(err) : resolve()));
}),
};
}

type RunResult = { code: number; stdout: string; stderr: string };

// Runs `tsx src/index.ts <args>` and resolves with the full process result.
Expand Down
32 changes: 1 addition & 31 deletions tests/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,12 @@ import { test, before } from "node:test";
import assert from "node:assert/strict";
import { execFile, execSync } from "node:child_process";
import { promisify } from "node:util";
import {
createServer,
type IncomingMessage,
type ServerResponse,
} from "node:http";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve as resolvePath } from "node:path";
import { startMock, textOf } from "./_helpers.js";

const execFileAsync = promisify(execFile);

Expand All @@ -40,32 +36,6 @@ async function spawnBuilt(env: Record<string, string> = {}) {
return client;
}

function textOf(result: { content: unknown }): string {
const content = result.content as Array<{ type: string; text?: string }>;
return content[0]?.text ?? "";
}

async function startMock(
handler: (req: IncomingMessage, res: ServerResponse) => void,
): Promise<{ url: string; close: () => Promise<void> }> {
const httpServer = createServer(handler);
await new Promise<void>((resolve) =>
httpServer.listen(0, "127.0.0.1", () => resolve()),
);
const address = httpServer.address();
if (!address || typeof address !== "object") {
throw new Error("mock server address unavailable");
}
return {
url: `http://127.0.0.1:${address.port}`,
close: () =>
new Promise<void>((resolve, reject) => {
httpServer.closeAllConnections();
httpServer.close((err) => (err ? reject(err) : resolve()));
}),
};
}

const HAPPY_FIXTURE = `<!DOCTYPE html>
<html lang="en">
<head><title>E2E Fixture</title></head>
Expand Down
Loading