docs: rebuild the v2 documentation around a task-based page tree (#2397)
This commit is contained in:
parent
708d5455d9
commit
b153505428
1 changed files with 11884 additions and 4971 deletions
120
scripts/run-guide-examples.ts
Normal file
120
scripts/run-guide-examples.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
#!/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();
|
||||
Loading…
Reference in a new issue