Files
TREK/server/tests/unit/scripts/error-envelope-ratchets.test.ts
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

217 lines
9.0 KiB
TypeScript

/**
* scripts/mcp-inline-zod.mjs (lint:mcp-zod) and scripts/service-http-ratchet.mjs
* (lint:service-http) on their shared rules in scripts/lib/count-ratchet.mjs.
* Both run as the real scripts against throwaway server roots (--dir) and once
* on this repository, so the committed baselines are checked by the unit suite.
*/
import { countInlineZod } from '../../../scripts/mcp-inline-zod.mjs';
import { countFile, countResultBranches, countServiceIdioms } from '../../../scripts/service-http-ratchet.mjs';
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
const SCRIPTS = path.join(__dirname, '../../../scripts');
const SERVER_ROOT = path.join(__dirname, '../../..');
const NEST = 'src/nest';
const roots: string[] = [];
function serverRoot(files: Record<string, string>, baselineName: string, baseline: unknown = { counts: {} }): string {
const dir = mkdtempSync(path.join(tmpdir(), 'trek-envelope-'));
roots.push(dir);
mkdirSync(path.join(dir, NEST), { recursive: true });
mkdirSync(path.join(dir, 'scripts'), { recursive: true });
for (const [file, text] of Object.entries(files)) {
mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
writeFileSync(path.join(dir, file), text);
}
if (baseline !== null) {
writeFileSync(
path.join(dir, 'scripts', baselineName),
typeof baseline === 'string' ? baseline : JSON.stringify(baseline),
);
}
return dir;
}
function run(script: string, dir: string, ...args: string[]): { status: number | null; out: string } {
try {
const stdout = execFileSync(process.execPath, [path.join(SCRIPTS, script), `--dir=${dir}`, ...args], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
return { status: 0, out: stdout };
} catch (err) {
const { status, stdout, stderr } = err as { status: number | null; stdout: string; stderr: string };
return { status, out: `${stdout}${stderr}` };
}
}
afterEach(() => {
for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true });
});
// Spelled in pieces so the files read as what they are, fixtures.
const Z = ['z', '.'].join('');
const zodField = (n: number) => ` name: ${Z}string(),\n`.repeat(n);
const STATUS = ['status', ': 404'].join('');
const HTTP = ['new Http', 'Exception('].join('');
describe('the counters', () => {
it('ENV-RATCHET-001: counts every z. call, and nothing that only ends in z', () => {
expect(countInlineZod(`${Z}object({ a: ${Z}string(), b: idSchema, c: fizz.x })`)).toBe(2);
});
it('ENV-RATCHET-001b: counts a z. call Prettier broke across lines', () => {
expect(
countInlineZod(`const s = z
.object({ a: idSchema })
.strict();`),
).toBe(1);
});
it('ENV-RATCHET-002: counts the result envelope and HTTP in a service, not a 2xx status', () => {
expect(
countServiceIdioms(`return { error: 'x', ${STATUS} };\nthrow ${HTTP}{}, 400);\nreturn { status: 200 };`),
).toBe(2);
expect(countServiceIdioms(`return { error: 'x', status: 503 };`)).toBe(1);
});
it('ENV-RATCHET-003: counts the branches on a returned error, in each spelling', () => {
const text = [
'if (result.error) {',
"if ('error' in outcome) throw x;",
'if (res.error) return errorResult(res.error);',
'if (this.state.last.error) {',
'if (!parsed.success) {',
'if (error) {',
].join('\n');
expect(countResultBranches(text)).toBe(4);
});
it('ENV-RATCHET-004: counts a comparison, a negation, an optional chain and a ternary on a returned error', () => {
const text = [
"if (result.error === 'not_found') {",
'if (!r.error) return ok(r);',
'if (res?.error && res.status) {',
'return outcome.error ? fail() : pass();',
"const notFound = r.error !== 'gone';",
'logger.error(r);',
'const e = res.error;',
'if (r.errors) {',
].join('\n');
expect(countResultBranches(text)).toBe(5);
});
it('ENV-RATCHET-005: counts the envelope in any domain file, not in the HTTP layer or the DomainError itself', () => {
const envelope = `return { error: 'x', ${STATUS} };`;
for (const file of ['a.helpers.ts', 'a.impl.ts', 'providers/x.provider.ts', 'a.service.ts']) {
expect(countFile(envelope, `src/nest/a/${file}`)).toBe(1);
}
for (const file of ['a.controller.ts', 'a.guard.ts', 'a.pipe.ts', 'a.filter.ts', 'a.interceptor.ts', 'a.dto.ts']) {
expect(countFile(envelope, `src/nest/a/${file}`)).toBe(0);
}
expect(countFile(envelope, 'src/nest/common/domain-error.ts')).toBe(0);
expect(countFile('if (r.error) {', 'src/nest/a/a.rpc.ts')).toBe(1);
expect(countFile('if (r.error) {', 'src/nest/a/a.helpers.ts')).toBe(0);
});
});
describe('mcp-inline-zod.mjs', () => {
const BASE = 'mcp-inline-zod-baseline.json';
it('ENV-RATCHET-010: passes a tree whose tools derive every field', () => {
const { status, out } = run(
'mcp-inline-zod.mjs',
serverRoot({ [`${NEST}/a/a.mcp.ts`]: 'tripId: idSchema,\n' }, BASE),
);
expect(status).toBe(0);
expect(out).toContain('mcp-zod: 0 inline z. call(s) in 0 file(s)');
});
it('ENV-RATCHET-011: fails a new tool file spelling a field inline, and leaves other files alone', () => {
const dir = serverRoot({ [`${NEST}/a/a.mcp.ts`]: zodField(1), [`${NEST}/a/a.service.ts`]: zodField(5) }, BASE);
const { status, out } = run('mcp-inline-zod.mjs', dir);
expect(status).toBe(1);
expect(out).toContain(
`FAIL ${NEST}/a/a.mcp.ts: 1 inline z. call(s), a file without a baseline entry may hold none.`,
);
expect(out).not.toContain('a.service.ts');
});
it('ENV-RATCHET-012: holds a file to its entry, and --update only lowers', () => {
const file = `${NEST}/a/a.mcp.ts`;
expect(
run('mcp-inline-zod.mjs', serverRoot({ [file]: zodField(3) }, BASE, { counts: { [file]: 2 } })).out,
).toContain('its baseline is 2.');
const dir = serverRoot({ [file]: zodField(1) }, BASE, { counts: { [file]: 3, [`${NEST}/gone.mcp.ts`]: 1 } });
const stale = run('mcp-inline-zod.mjs', dir);
expect(stale.status).toBe(1);
expect(stale.out).toContain(`FAIL ${file} is held at 3`);
expect(stale.out).toContain('npm run lint:mcp-zod -- --update');
run('mcp-inline-zod.mjs', dir, '--update');
expect(JSON.parse(readFileSync(path.join(dir, 'scripts', BASE), 'utf8'))).toEqual({ counts: { [file]: 1 } });
expect(run('mcp-inline-zod.mjs', dir).status).toBe(0);
});
it('ENV-RATCHET-013: a missing or malformed baseline, or no src/nest, stops the run', () => {
expect(run('mcp-inline-zod.mjs', serverRoot({}, BASE, null)).out).toContain('cannot be read');
expect(run('mcp-inline-zod.mjs', serverRoot({}, BASE, '{"counts":{"x":0}}')).out).toContain(
'expected a whole number above 0',
);
expect(run('mcp-inline-zod.mjs', serverRoot({}, BASE, '[]')).out).toContain(
'must be an object with a "counts" object',
);
const empty = mkdtempSync(path.join(tmpdir(), 'trek-envelope-'));
roots.push(empty);
expect(run('mcp-inline-zod.mjs', empty).out).toContain('does not exist');
});
it('ENV-RATCHET-014: this repository holds its committed baseline', () => {
const { status, out } = run('mcp-inline-zod.mjs', SERVER_ROOT);
expect(out).toContain('mcp-zod:');
expect(status).toBe(0);
});
});
describe('service-http-ratchet.mjs', () => {
const BASE = 'service-http-baseline.json';
it('ENV-RATCHET-020: counts domain files, controllers, tools and RPC handlers, not the HTTP layer', () => {
const dir = serverRoot(
{
[`${NEST}/a/a.service.ts`]: `return { error: 'x', ${STATUS} };\n`,
[`${NEST}/a/a.controller.ts`]: 'if (result.error) {\n',
[`${NEST}/a/a.mcp.ts`]: "if ('error' in r) return x;\n",
[`${NEST}/a/a.rpc.ts`]: "if (r.error === 'nope') throw x;\n",
[`${NEST}/a/a.helpers.ts`]: `return { error: 'x', ${STATUS} };\n`,
[`${NEST}/a/a.guard.ts`]: `throw ${HTTP}'no', 403);\n`,
},
BASE,
);
const { status, out } = run('service-http-ratchet.mjs', dir);
expect(status).toBe(1);
expect(out).not.toContain('a.guard.ts');
for (const file of ['a.service.ts', 'a.controller.ts', 'a.mcp.ts', 'a.rpc.ts', 'a.helpers.ts']) {
expect(out).toContain(
`FAIL ${NEST}/a/${file}: 1 error-envelope idiom(s), a file without a baseline entry may hold none.`,
);
}
expect(out).toContain('Throw a DomainError');
});
it('ENV-RATCHET-021: a service that throws DomainError passes', () => {
const dir = serverRoot({ [`${NEST}/a/a.service.ts`]: "throw new DomainError(404, 'Trip not found');\n" }, BASE);
expect(run('service-http-ratchet.mjs', dir).status).toBe(0);
});
it('ENV-RATCHET-022: this repository holds its committed baseline', () => {
const { status, out } = run('service-http-ratchet.mjs', SERVER_ROOT);
expect(out).toContain('service-http:');
expect(status).toBe(0);
});
});