120 lines
4.7 KiB
TypeScript
120 lines
4.7 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* Run every guide companion under `examples/guides/**` as a real program.
|
|
*
|
|
* Each docs page's code fences are synced from a companion
|
|
* `examples/guides/<...>/<page>.examples.ts` (see `scripts/sync-snippets.ts`).
|
|
* Companions that quote output are self-verifying top-level-await scripts: they
|
|
* drive an in-memory client/server pair (or a web-standard `handler.fetch`),
|
|
* assert what the page claims, and exit non-zero on any mismatch. This harness
|
|
* runs each one with `tsx` and reports PASS/FAIL from the child's exit code.
|
|
*
|
|
* A companion with nothing meaningful to execute opts out by making its FIRST
|
|
* line exactly:
|
|
*
|
|
* // docs: typecheck-only
|
|
*
|
|
* Those files are still type-checked by the `@modelcontextprotocol/examples`
|
|
* package; they are listed here as SKIP and never spawned.
|
|
*
|
|
* Files run SEQUENTIALLY (they are cheap, and this avoids any port/stdin
|
|
* contention). A run that exceeds the per-file timeout is a FAIL ("hung —
|
|
* possible unclosed handle"): companions must terminate on their own, so they
|
|
* never bind a port and never block on stdin (stdin is closed for the child).
|
|
*/
|
|
import { spawn } from 'node:child_process';
|
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
import { join, relative, resolve } from 'node:path';
|
|
|
|
const ROOT = resolve(import.meta.dirname, '..');
|
|
const GUIDES = join(ROOT, 'examples', 'guides');
|
|
const TSX = join(ROOT, 'node_modules', '.bin', 'tsx');
|
|
|
|
/** Marker that opts a companion out of being executed (first line, exact). */
|
|
const TYPECHECK_ONLY = '// docs: typecheck-only';
|
|
|
|
/** Per-file timeout: a companion that has not exited by now is hung. */
|
|
const TIMEOUT_MS = 90_000;
|
|
|
|
interface FileResult {
|
|
file: string;
|
|
ok: boolean;
|
|
durationMs: number;
|
|
detail: string;
|
|
}
|
|
|
|
function isTypecheckOnly(file: string): boolean {
|
|
const firstLine = readFileSync(file, 'utf8').split('\n', 1)[0] ?? '';
|
|
return firstLine.trimEnd() === TYPECHECK_ONLY;
|
|
}
|
|
|
|
function runOne(file: string): Promise<FileResult> {
|
|
const started = Date.now();
|
|
return new Promise(resolvePromise => {
|
|
// stdin is 'ignore' so a companion that (incorrectly) reads stdin sees
|
|
// EOF immediately instead of hanging until the timeout.
|
|
// detached: the child leads its own process group, so a timeout kills the whole
|
|
// tree (tsx re-spawns node; killing only the wrapper would orphan the real run).
|
|
const child = spawn(TSX, [file], { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
let output = '';
|
|
child.stdout.on('data', d => (output += String(d)));
|
|
child.stderr.on('data', d => (output += String(d)));
|
|
const finish = (ok: boolean, detail: string): void => resolvePromise({ file, ok, durationMs: Date.now() - started, detail });
|
|
const timer = setTimeout(() => {
|
|
if (child.pid !== undefined) {
|
|
try {
|
|
process.kill(-child.pid, 'SIGKILL');
|
|
} catch {
|
|
child.kill('SIGKILL');
|
|
}
|
|
} else {
|
|
child.kill('SIGKILL');
|
|
}
|
|
finish(false, `timed out after ${TIMEOUT_MS / 1000}s (hung — possible unclosed handle)\n${output}`);
|
|
}, TIMEOUT_MS);
|
|
child.on('close', code => {
|
|
clearTimeout(timer);
|
|
if (code === 0) finish(true, '');
|
|
else finish(false, `exit ${code}\n${output}`);
|
|
});
|
|
child.on('error', err => {
|
|
clearTimeout(timer);
|
|
finish(false, `spawn error: ${err.message}\n${output}`);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const files = readdirSync(GUIDES, { recursive: true, encoding: 'utf8' })
|
|
.filter(name => name.endsWith('.examples.ts'))
|
|
.map(name => join(GUIDES, name))
|
|
.sort();
|
|
if (files.length === 0) {
|
|
console.error(`No *.examples.ts files found under ${GUIDES}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const results: FileResult[] = [];
|
|
let skipped = 0;
|
|
for (const file of files) {
|
|
const name = relative(ROOT, file);
|
|
if (isTypecheckOnly(file)) {
|
|
skipped++;
|
|
console.log(`SKIP ${name} (typecheck-only)`);
|
|
continue;
|
|
}
|
|
const result = await runOne(file);
|
|
results.push(result);
|
|
console.log(`${result.ok ? 'PASS' : 'FAIL'} ${name} (${(result.durationMs / 1000).toFixed(1)}s)`);
|
|
if (!result.ok) console.log(result.detail);
|
|
}
|
|
|
|
const failed = results.filter(r => !r.ok);
|
|
console.log('\n=== guide examples summary ===');
|
|
console.log(`files: ${results.length} run / ${skipped} typecheck-only / ${failed.length} failed`);
|
|
for (const r of failed) console.log(` FAIL ${relative(ROOT, r.file)}`);
|
|
|
|
process.exit(failed.length === 0 ? 0 : 1);
|
|
}
|
|
|
|
void main();
|