diff --git a/.prettierignore b/.prettierignore index 6c55fed..46682f0 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,4 @@ node_modules generated package-lock.json *.tgz +test/fixtures/syntax.mjs diff --git a/AGENTS.md b/AGENTS.md index e4da980..58a9758 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,13 +23,22 @@ npm run lint # eslint npm run lint:fix npm run format # prettier --write npm run format:check +npm run demo # build, then serve examples/inspect-demo.mjs in the live inspector npm run generate # build, render a letter, print explain(), write generated/letter.html npm run publint # export and types check; requires dist/ npm run smoke # load the ESM, CJS, and IIFE builds; requires dist/ npm run check # format, lint, typecheck, build, test, smoke, publint ``` -Node.js **24+**. Run `npm run check` before handing work back. Run `npm run test:coverage` when you add or change an executable `src/` file. `src/index.ts`, `src/runtime/types.ts`, `src/templates/email/types.ts`, and `src/templates/document/index.ts` are excluded: v8 reports 0% on files with no runtime code. +## Hard rules for AI agents + +These two rules are non-negotiable. Fail the task if either is skipped. + +1. **Coverage must stay at 100%.** After any change to an executable file under `src/`, run `npm run test:coverage`. Statements, branches, functions, and lines must all be 100%. Do not lower the thresholds in `vitest.config.ts`. Do not exclude a file that has runtime code. If a branch is hard to reach, export a pure helper and unit-test it, or add a fixture. Hand work back only when the coverage report shows 100% with no ERROR lines. + +2. **Prettier must pass.** Before committing or opening a PR, run `npx prettier --write .` (or `npm run format`) and then `npx prettier --check .` (or `npm run format:check`). Zero warnings. Do not hand back a branch that fails `format:check`. + +Node.js **24+**. Run `npm run check` before handing work back. Run `npm run test:coverage` when you add or change an executable `src/` file. `src/index.ts`, `src/runtime/types.ts`, `src/templates/email/types.ts`, `src/templates/document/index.ts`, `src/cli/main.ts`, and `src/cli/worker.ts` are excluded. The first four have no runtime code, so v8 reports 0%. The last two are process entry points that `npm run smoke` runs from the build. ESLint and Prettier both run in CI. Tests are Vitest, not Jest. `markup-generator` is a devDependency used to write rendered HTML in `test/write.test.ts` and `npm run generate`. It is not a runtime dependency. @@ -53,10 +62,24 @@ src/templates/content/content.ts content() src/templates/document/document.ts document() src/utils/escape.ts escapeHtml() src/utils/pickDefined.ts drop omitted string fields +src/runtime/result.ts fromError(): a thrown RenderError as a failed result +src/inspect/inspect.ts inspect(): self-contained HTML report +src/inspect/diff.ts diffRenders, formatDiff, line diff +src/inspect/styles.ts inspector stylesheet +src/cli/args.ts parseArgs (pure) +src/cli/outcome.ts runModule: load a user module and render it +src/cli/fork.ts forkRender: render in a child process with a timeout +src/cli/pages.ts outcome to page, crash page +src/cli/server.ts startServer: dev server, SSE reload, file watching +src/cli/commands.ts inspectCommand and its exit codes +src/cli/main.ts, worker.ts process entry points (CLI bin and the render child) test/runtime.test.ts runtime spec test/email.test.ts email preset spec test/index.test.ts fragment spec examples/compose.ts sample letter; sampleHtml is covered by the suite +test/inspect.test.ts inspector page spec +test/diff.test.ts diff spec +test/cli.test.ts CLI, child process and dev server spec (fixtures in test/fixtures) test/write.test.ts writes a rendered letter through markup-generator and reads it back scripts/generate.mjs prints explain() and writes generated/letter.html .github/workflows/ci.yml @@ -67,7 +90,7 @@ tsup.config.ts ESM + CJS + IIFE, dts, sourcemaps vitest.config.ts ``` -Do not add a second entry point. Do not add `src/**/*.js` next to a `.ts` file. Do not add a template registry. +The library has one entry point, `src/index.ts`. The CLI is a `bin`, built from `src/cli/main.ts` and `src/cli/worker.ts`; nothing in the library imports `src/cli/`. Keep Node built-ins (`node:http`, `node:child_process`, `node:fs`) inside `src/cli/`, so the ESM, CJS and script-tag builds stay platform neutral. Do not add another library entry point. Do not add `src/**/*.js` next to a `.ts` file. Do not add a template registry. ## Runtime model @@ -89,6 +112,14 @@ Custom code is a part, or a slot that a part reads with `slot(ctx, id)`. A missi Traces and defined templates are frozen so a later template cannot change an earlier debug snapshot. +## Inspector and CLI + +`inspect(result | RenderError, { title?, compareTo? })` returns a page and does no I/O. It has no scripts and no network requests, and it escapes every id, message and title. Part and document HTML go only into ``, -+ '', -+ '', -+ ].join('\n'); -+} -+ -+function partsSection(parts: readonly PartTrace[]): string { -+ if (parts.length === 0) return ''; -+ return `

Parts

${parts.map(partCard).join('\n')}
`; -+} -+ -+function partCard(part: PartTrace, index: number): string { -+ const open = part.status === 'error' ? ' open' : ''; -+ return [ -+ `
`, -+ `${escapeHtml(part.id)} ${chip(part.status)} ${fmtChars(part.chars)} · ${fmtMs(part.ms)}`, -+ partBody(part), -+ '
', -+ ].join('\n'); -+} -+ -+function partBody(part: PartTrace): string { -+ if (part.status === 'skipped') { -+ return '

Skipped because an earlier part failed.

'; -+ } -+ const error = part.error -+ ? `` -+ : ''; -+ if (part.html === '') { -+ return `${error}

${part.status === 'error' ? 'Rendered nothing because it failed.' : 'Rendered an empty string.'}

`; -+ } -+ return [ -+ error, -+ ``, -+ `
${escapeHtml(part.html)}
`, -+ ].join('\n'); -+} -+ -+function diffSection(diff: RenderDiff): string { -+ if (!diff.changed) { -+ return '

Changes

No changes since the previous render.

'; -+ } -+ const changes = diff.parts -+ .filter((part) => part.change !== 'unchanged') -+ .map((part) => { -+ const sign = part.charsDelta > 0 ? '+' : ''; -+ return `
  • ${escapeHtml(part.id)} ${part.change} ${sign}${part.charsDelta} chars
  • `; -+ }); -+ const lines = trimContext(diff.lines).map(diffLine); -+ return `

    Changes since the previous render

    ${lines.join('')}
    `; -+} -+ -+function diffLine(line: DiffLine): string { -+ const sign = line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '; -+ return `${sign} ${escapeHtml(line.text)}`; -+} -diff --git a/src/inspect/styles.ts b/src/inspect/styles.ts -new file mode 100644 -index 0000000..323d6c8 ---- /dev/null -+++ b/src/inspect/styles.ts -@@ -0,0 +1,70 @@ -+/** Stylesheet for the inspector page. Inlined, so the report is one file with no requests. */ -+export const STYLES = ` -+:root{--bg:#f4f5f8;--panel:#fff;--ink:#15181e;--muted:#5b6472;--line:#e1e4ea;--accent:#3556d8; -+--ok:#16764a;--okbg:#e2f4ea;--bad:#b9232b;--badbg:#fde8e9;--warn:#8a5a00;--warnbg:#fff1cf; -+--skip:#667085;--skipbg:#eceef2;--add:#e2f4ea;--del:#fde8e9;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} -+@media (prefers-color-scheme:dark){:root{--bg:#0e1014;--panel:#161920;--ink:#e9ebf0;--muted:#97a0af;--line:#272c37;--accent:#8fa3ff; -+--ok:#62d696;--okbg:#10301f;--bad:#ff8087;--badbg:#3b1518;--warn:#f2bc55;--warnbg:#3a2b0b; -+--skip:#97a0af;--skipbg:#222733;--add:#10301f;--del:#3b1518}} -+*{box-sizing:border-box} -+body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif} -+.page{max-width:980px;margin:0 auto;padding:28px 16px 48px;display:grid;gap:16px} -+h1{margin:0;font-size:26px;letter-spacing:-.01em} -+h2{margin:0 0 12px;font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)} -+.eyebrow{margin:0;font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)} -+.head{display:grid;grid-template-columns:1fr auto;gap:4px 16px;align-items:center} -+.facts{grid-column:1/-1;margin:0;color:var(--muted)} -+.verdict{font-weight:700;font-size:13px;padding:6px 14px;border-radius:999px;letter-spacing:.06em} -+.v-ok{color:var(--ok);background:var(--okbg)} -+.v-bad{color:var(--bad);background:var(--badbg)} -+.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:18px} -+.banner{background:var(--badbg);color:var(--bad);border:1px solid var(--bad);border-radius:12px;padding:14px 18px} -+.banner.inline{margin:10px 0;border-radius:8px;padding:10px 14px} -+.banner pre{margin:6px 0 0;white-space:pre-wrap;font:13px/1.45 var(--mono)} -+.banner ul{margin:6px 0 0;padding-left:20px} -+.banner a{color:inherit;font-weight:600} -+.rows{display:grid;gap:4px} -+.row{display:grid;grid-template-columns:minmax(80px,150px) 1fr minmax(190px,260px);gap:12px;align-items:center; -+padding:6px 8px;border-radius:8px;color:inherit;text-decoration:none} -+.row:hover{background:var(--bg)} -+.name{font-family:var(--mono);font-size:13px;font-weight:600;overflow-wrap:anywhere} -+.track{height:10px;background:var(--bg);border-radius:999px;overflow:hidden} -+.bar{display:block;height:100%;width:var(--w,2%);border-radius:999px;background:var(--ok)} -+.bar.b-empty{background:var(--warn)} -+.bar.b-error{background:var(--bad)} -+.bar.skipped{width:100%;background:repeating-linear-gradient(90deg,var(--line) 0 6px,transparent 6px 12px)} -+.stat,.meta{color:var(--muted);font-size:13px;white-space:nowrap} -+.stat{text-align:right} -+.chip{display:inline-block;font:600 11px/1 system-ui,sans-serif;text-transform:uppercase;letter-spacing:.05em; -+padding:4px 8px;border-radius:999px;margin-right:4px} -+.s-ok{color:var(--ok);background:var(--okbg)} -+.s-empty{color:var(--warn);background:var(--warnbg)} -+.s-error{color:var(--bad);background:var(--badbg)} -+.s-skipped{color:var(--skip);background:var(--skipbg)} -+.c-added{color:var(--ok);background:var(--okbg)} -+.c-removed{color:var(--bad);background:var(--badbg)} -+.c-changed{color:var(--warn);background:var(--warnbg)} -+.viewport{position:relative} -+.viewport input{position:absolute;opacity:0;pointer-events:none} -+.viewport label{display:inline-block;margin:0 6px 10px 0;padding:6px 12px;border:1px solid var(--line);border-radius:999px; -+font-size:13px;cursor:pointer;color:var(--muted)} -+.viewport input:checked+label{color:var(--accent);border-color:var(--accent);font-weight:600} -+.viewport input:focus-visible+label{outline:2px solid var(--accent);outline-offset:2px} -+.stage{background:var(--bg);border-radius:8px;padding:12px;overflow:auto} -+.stage iframe{display:block;margin:0 auto;width:100%;height:560px;border:1px solid var(--line);border-radius:6px;background:#fff;color-scheme:light} -+#vp-tablet:checked~.stage iframe{width:768px;max-width:100%} -+#vp-mobile:checked~.stage iframe{width:375px;max-width:100%} -+.part{border-top:1px solid var(--line);padding:2px 0} -+.part:first-of-type{border-top:0} -+.part summary{cursor:pointer;padding:10px 0;display:flex;gap:10px;align-items:center;flex-wrap:wrap} -+.frag{display:block;width:100%;height:220px;resize:vertical;border:1px solid var(--line);border-radius:6px;background:#fff;color-scheme:light;margin:8px 0} -+.src,.diff{margin:8px 0 12px;padding:12px;background:var(--bg);border-radius:8px;overflow:auto;max-height:360px;font:12.5px/1.5 var(--mono)} -+.src code,.diff code{font:inherit} -+.diff span{display:block;white-space:pre} -+.d-add{background:var(--add)} -+.d-del{background:var(--del)} -+.changes{list-style:none;margin:0 0 4px;padding:0;display:grid;gap:6px} -+.note{margin:8px 0 12px;color:var(--muted)} -+.foot{margin:8px 0 0;text-align:center;color:var(--muted);font-size:12px} -+@media (max-width:620px){.row{grid-template-columns:1fr;gap:4px}.stat{text-align:left}} -+`; -diff --git a/src/runtime/explain.ts b/src/runtime/explain.ts -index 5ded7b6..524dc14 100644 ---- a/src/runtime/explain.ts -+++ b/src/runtime/explain.ts -@@ -1,4 +1,5 @@ - import { RenderError } from '@llazyemail/render-template'; -+import { fromError } from './result'; - import type { RenderResult } from './types'; - - /** One text block for logs. Full HTML stays on each part trace. */ -@@ -19,12 +20,3 @@ export function explain(result: RenderResult | RenderError): string { - } - return lines.join('\n'); - } -- --function fromError(error: RenderError): RenderResult { -- return { -- templateId: error.templateId, -- ok: false, -- html: '', -- parts: error.trace, -- }; --} -diff --git a/src/runtime/result.ts b/src/runtime/result.ts -new file mode 100644 -index 0000000..deee621 ---- /dev/null -+++ b/src/runtime/result.ts -@@ -0,0 +1,12 @@ -+import type { RenderError } from '@llazyemail/render-template'; -+import type { RenderResult } from './types'; -+ -+/** The failed result a thrown `RenderError` stands for. Its trace is the part list. */ -+export function fromError(error: RenderError): RenderResult { -+ return { -+ templateId: error.templateId, -+ ok: false, -+ html: '', -+ parts: error.trace, -+ }; -+} -diff --git a/test/cli.test.ts b/test/cli.test.ts -new file mode 100644 -index 0000000..7eeba97 ---- /dev/null -+++ b/test/cli.test.ts -@@ -0,0 +1,380 @@ -+import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -+import { tmpdir } from 'node:os'; -+import { join } from 'node:path'; -+import { afterEach, describe, expect, it } from 'vitest'; -+import { RenderError } from '@llazyemail/render-template'; -+import { parseArgs, USAGE } from '../src/cli/args'; -+import { inspectCommand } from '../src/cli/commands'; -+import { forkRender } from '../src/cli/fork'; -+import { crash, runModule } from '../src/cli/outcome'; -+import type { Outcome } from '../src/cli/outcome'; -+import { crashPage, outcomePage, renderChanged, toInspectable } from '../src/cli/pages'; -+import { startServer } from '../src/cli/server'; -+import type { DevServer } from '../src/cli/server'; -+import type { PartTrace, RenderResult } from '../src/index'; -+ -+const fx = (name: string): string => new URL(`./fixtures/${name}`, import.meta.url).pathname; -+ -+describe('parseArgs', () => { -+ it('shows help for no arguments, help words and -h', () => { -+ for (const argv of [ -+ [], -+ ['help'], -+ ['--help'], -+ ['-h'], -+ ['dev', '-h'], -+ ['inspect', 'x', '--help'], -+ ]) { -+ expect(parseArgs(argv)).toEqual({ kind: 'help' }); -+ } -+ expect(USAGE).toContain('template-runtime-display dev '); -+ }); -+ -+ it('shows the version', () => { -+ expect(parseArgs(['-v'])).toEqual({ kind: 'version' }); -+ expect(parseArgs(['--version'])).toEqual({ kind: 'version' }); -+ }); -+ -+ it('parses dev with defaults and with every option, in both flag styles', () => { -+ expect(parseArgs(['dev', 'a.mjs'])).toEqual({ -+ kind: 'dev', -+ file: 'a.mjs', -+ port: 4177, -+ host: '127.0.0.1', -+ watch: [], -+ }); -+ expect( -+ parseArgs(['dev', 'a.mjs', '--port', '0', '--host=::1', '--watch', 'src', '--watch=lib']), -+ ).toEqual({ kind: 'dev', file: 'a.mjs', port: 0, host: '::1', watch: ['src', 'lib'] }); -+ }); -+ -+ it('parses inspect with and without --out', () => { -+ expect(parseArgs(['inspect', 'a.mjs'])).toEqual({ -+ kind: 'inspect', -+ file: 'a.mjs', -+ out: 'inspect.html', -+ }); -+ expect(parseArgs(['inspect', '--out', 'r.html', 'a.mjs'])).toEqual({ -+ kind: 'inspect', -+ file: 'a.mjs', -+ out: 'r.html', -+ }); -+ }); -+ -+ it.each([ -+ [['nope'], /Unknown command "nope"/], -+ [['dev'], /dev needs a module path/], -+ [['inspect'], /inspect needs a module path/], -+ [['dev', 'a', 'b'], /Unexpected extra argument "b"/], -+ [['dev', 'a', '--out', 'x'], /Unknown option "--out" for dev/], -+ [['inspect', 'a', '--port', '1'], /Unknown option "--port" for inspect/], -+ [['dev', 'a', '--port'], /--port needs a value/], -+ [['dev', 'a', '--port='], /--port needs a value/], -+ [['dev', 'a', '--port', 'abc'], /Invalid port "abc"/], -+ [['dev', 'a', '--port', '70000'], /Invalid port "70000"/], -+ [['dev', 'a', '--port', '1.5'], /Invalid port "1.5"/], -+ [['dev', 'a', '--port', '-1'], /Invalid port "-1"/], -+ ])('rejects %j', (argv: string[], message: RegExp) => { -+ const command = parseArgs(argv); -+ expect(command.kind).toBe('error'); -+ expect((command as { message: string }).message).toMatch(message); -+ }); -+}); -+ -+describe('runModule', () => { -+ it('renders a module in collect mode and returns the result', async () => { -+ const outcome = await runModule(fx('ok.mjs')); -+ expect(outcome.kind).toBe('result'); -+ expect(outcome.kind === 'result' && outcome.result.ok).toBe(true); -+ }); -+ -+ it('collects a broken part by default', async () => { -+ const outcome = await runModule(fx('broken.mjs')); -+ expect(outcome.kind === 'result' && outcome.result.ok).toBe(false); -+ expect( -+ outcome.kind === 'result' && outcome.result.parts.some((p) => p.status === 'error'), -+ ).toBe(true); -+ }); -+ -+ it('reports a throw when the module asks for throw mode', async () => { -+ const outcome = await runModule(fx('throws.mjs')); -+ expect(outcome).toMatchObject({ kind: 'thrown', templateId: 'throws', partId: 'bad' }); -+ expect(outcome.kind === 'thrown' && outcome.trace.length).toBeGreaterThan(0); -+ }); -+ -+ it('accepts a default export with the same keys', async () => { -+ expect((await runModule(fx('default-export.mjs'))).kind).toBe('result'); -+ }); -+ -+ it('turns a missing template, a syntax error and a non-Error throw into a crash', async () => { -+ expect(await runModule(fx('no-template.mjs'))).toMatchObject({ -+ kind: 'crash', -+ message: expect.stringContaining('does not export a template'), -+ }); -+ expect(await runModule(fx('syntax.mjs'))).toMatchObject({ -+ kind: 'crash', -+ detail: expect.any(String), -+ }); -+ expect(await runModule(fx('bad-throw.mjs'))).toEqual({ -+ kind: 'crash', -+ message: 'a plain string', -+ detail: 'a plain string', -+ }); -+ expect((await runModule(fx('missing-file.mjs'))).kind).toBe('crash'); -+ }); -+ -+ it('caps crash detail', () => { -+ expect((crash('m', 'x'.repeat(9000)) as { detail: string }).detail).toHaveLength(4000); -+ expect(crash('m')).toEqual({ kind: 'crash', message: 'm' }); -+ }); -+ -+ it('does not treat a lookalike error as a RenderError', async () => { -+ const fake = Object.assign(new Error('x'), { name: 'RenderError', templateId: 1 }); -+ expect(fake).toBeInstanceOf(Error); -+ const dir = await mkdtemp(join(tmpdir(), 'trd-')); -+ const file = join(dir, 'fake.mjs'); -+ await writeFile( -+ file, -+ "const e = new Error('lookalike'); e.name = 'RenderError'; e.templateId = 1; throw e;", -+ ); -+ expect(await runModule(file)).toMatchObject({ kind: 'crash', message: 'lookalike' }); -+ await rm(dir, { recursive: true }); -+ }); -+}); -+ -+describe('forkRender', () => { -+ it('returns what the worker reports, passing the module path', async () => { -+ const outcome = await forkRender('m.mjs', { workerPath: fx('worker-reports.mjs') }); -+ expect(outcome).toEqual({ kind: 'crash', message: 'from worker m.mjs' }); -+ }); -+ -+ it('turns a worker that dies into a crash with its stderr', async () => { -+ const outcome = await forkRender('m', { workerPath: fx('worker-dies.mjs') }); -+ expect(outcome).toMatchObject({ -+ kind: 'crash', -+ message: expect.stringContaining('exit code 3'), -+ detail: expect.stringContaining('about to die'), -+ }); -+ }); -+ -+ it('kills a worker that never reports', async () => { -+ const outcome = await forkRender('m', { workerPath: fx('worker-loops.mjs'), timeoutMs: 150 }); -+ expect(outcome).toMatchObject({ -+ kind: 'crash', -+ message: expect.stringContaining('timed out after 150ms'), -+ }); -+ }); -+ -+ it('turns a program that cannot start into a crash', async () => { -+ const outcome = await forkRender('m', { -+ workerPath: fx('worker-reports.mjs'), -+ execPath: '/nonexistent/node', -+ }); -+ expect(outcome.kind).toBe('crash'); -+ }); -+ -+ it('turns a missing worker file into a crash', async () => { -+ const outcome = await forkRender('m', { workerPath: fx('nope.mjs') }); -+ expect(outcome.kind).toBe('crash'); -+ }); -+}); -+ -+const trace = (id: string, html: string): PartTrace => ({ -+ id, -+ status: 'ok', -+ html, -+ chars: html.length, -+ ms: 0, -+}); -+const rendered = (html: string): RenderResult => ({ -+ templateId: 't', -+ ok: true, -+ html, -+ parts: [trace('a', html)], -+}); -+ -+describe('pages', () => { -+ it('maps each outcome to something the inspector can show', () => { -+ const result = rendered('x'); -+ expect(toInspectable({ kind: 'result', result })).toBe(result); -+ const thrown = toInspectable({ -+ kind: 'thrown', -+ message: 'm', -+ templateId: 't', -+ partId: 'a', -+ trace: [trace('a', '')], -+ }); -+ expect(thrown).toBeInstanceOf(RenderError); -+ expect(toInspectable(crash('c'))).toBeUndefined(); -+ }); -+ -+ it('renders the inspector, the comparison and the crash page', () => { -+ expect(outcomePage({ kind: 'result', result: rendered('one') })).toContain('

    t

    '); -+ expect(outcomePage({ kind: 'result', result: rendered('two') }, rendered('one'))).toContain( -+ 'Changes since', -+ ); -+ const page = outcomePage(crash('', 'stack ')); -+ expect(page).toContain('CRASHED'); -+ expect(page).toContain('<bad>'); -+ expect(page).toContain('stack <here>'); -+ expect(crashPage({ kind: 'crash', message: 'no detail' })).not.toContain('
    ');
    -+  });
    -+
    -+  it('detects change between results and between a result and a thrown error', () => {
    -+    expect(renderChanged(rendered('a'), rendered('a'))).toBe(false);
    -+    expect(renderChanged(rendered('a'), rendered('b'))).toBe(true);
    -+    const err = new RenderError('m', { templateId: 't', partId: 'a', trace: [trace('a', 'a')] });
    -+    expect(renderChanged(err, rendered('a'))).toBe(true);
    -+    expect(renderChanged(rendered('a'), err)).toBe(true);
    -+  });
    -+});
    -+
    -+describe('inspectCommand', () => {
    -+  function run(outcome: Outcome) {
    -+    const written: Record = {};
    -+    const logs: string[] = [];
    -+    const code = inspectCommand('mod.mjs', 'out.html', {
    -+      render: async () => outcome,
    -+      write: async (file, contents) => void (written[file] = contents),
    -+      log: (line) => void logs.push(line),
    -+    });
    -+    return { code, written, logs };
    -+  }
    -+
    -+  it('exits 0 for a clean render and writes the report', async () => {
    -+    const r = run({ kind: 'result', result: rendered('x') });
    -+    expect(await r.code).toBe(0);
    -+    expect(r.written['out.html']).toContain('v-ok');
    -+    expect(r.logs.join('\n')).toContain('t: ok');
    -+  });
    -+
    -+  it('exits 1 when a part broke', async () => {
    -+    const result: RenderResult = { ...rendered('x'), ok: false };
    -+    expect(await run({ kind: 'result', result }).code).toBe(1);
    -+  });
    -+
    -+  it('exits 1 for a throw and still writes a report', async () => {
    -+    const r = run({
    -+      kind: 'thrown',
    -+      message: 'part failed',
    -+      templateId: 't',
    -+      partId: 'a',
    -+      trace: [trace('a', '')],
    -+    });
    -+    expect(await r.code).toBe(1);
    -+    expect(r.written['out.html']).toContain('Render threw in part');
    -+  });
    -+
    -+  it('exits 2 for a crash, writes the crash page and prints the detail when there is one', async () => {
    -+    const withDetail = run(crash('nope', 'stack'));
    -+    expect(await withDetail.code).toBe(2);
    -+    expect(withDetail.written['out.html']).toContain('CRASHED');
    -+    expect(withDetail.logs).toContain('stack');
    -+    const plain = run(crash('nope'));
    -+    expect(await plain.code).toBe(2);
    -+    expect(plain.logs).not.toContain('stack');
    -+  });
    -+});
    -+
    -+describe('startServer', () => {
    -+  let server: DevServer | undefined;
    -+  afterEach(async () => {
    -+    await server?.close();
    -+    server = undefined;
    -+  });
    -+
    -+  let current: Outcome = { kind: 'result', result: rendered('one') };
    -+  const start = async (extra: Partial[0]> = {}) => {
    -+    const dir = await mkdtemp(join(tmpdir(), 'trd-srv-'));
    -+    server = await startServer({
    -+      file: join(dir, 'mod.mjs'),
    -+      port: 0,
    -+      render: async () => current,
    -+      debounceMs: 10,
    -+      ...extra,
    -+    });
    -+    return { dir, url: server.url };
    -+  };
    -+  const get = (url: string, init?: RequestInit) => fetch(url, init);
    -+
    -+  it('serves the inspector with a reload listener, no caching, and nosniff', async () => {
    -+    current = { kind: 'result', result: rendered('one') };
    -+    const { url } = await start();
    -+    const res = await get(url);
    -+    const body = await res.text();
    -+    expect(res.status).toBe(200);
    -+    expect(res.headers.get('cache-control')).toBe('no-store');
    -+    expect(res.headers.get('x-content-type-options')).toBe('nosniff');
    -+    expect(body).toContain("new EventSource('/__events')");
    -+    expect(body.indexOf('EventSource')).toBeGreaterThan(body.indexOf('Generated by'));
    -+  });
    -+
    -+  it('shows what changed once the output changes, and keeps it across reloads of the same render', async () => {
    -+    current = { kind: 'result', result: rendered('one') };
    -+    const { url } = await start();
    -+    expect(await (await get(url)).text()).not.toContain('Changes since');
    -+    current = { kind: 'result', result: rendered('two') };
    -+    expect(await (await get(url)).text()).toContain('Changes since the previous render');
    -+    expect(await (await get(url)).text()).toContain('Changes since the previous render');
    -+  });
    -+
    -+  it('serves the document alone at /email, or a 404 when there is none', async () => {
    -+    current = { kind: 'result', result: rendered('

    doc

    ') }; -+ const { url } = await start(); -+ expect(await (await get(`${url}email`)).text()).toBe('

    doc

    '); -+ current = { kind: 'result', result: rendered('') }; -+ expect((await get(`${url}email`)).status).toBe(404); -+ current = crash('c'); -+ expect((await get(`${url}email`)).status).toBe(404); -+ const page = await get(url); -+ expect(page.status).toBe(200); -+ expect(await page.text()).toContain('CRASHED'); -+ }); -+ -+ it('refuses other paths, other methods and foreign Host headers', async () => { -+ const { url } = await start(); -+ expect((await get(`${url}nope`)).status).toBe(404); -+ expect((await get(url, { method: 'POST' })).status).toBe(405); -+ const forged = await new Promise((resolve, reject) => { -+ import('node:http').then(({ request }) => { -+ const req = request(url, { headers: { host: 'evil.example' } }, (res) => { -+ res.resume(); -+ resolve(res.statusCode ?? 0); -+ }); -+ req.on('error', reject); -+ req.end(); -+ }, reject); -+ }); -+ expect(forged).toBe(403); -+ }); -+ -+ it('answers 500 when rendering itself throws', async () => { -+ const { url } = await start({ -+ render: async () => { -+ throw new Error('boom'); -+ }, -+ }); -+ expect((await get(url)).status).toBe(500); -+ }); -+ -+ it('pushes a reload when a watched file changes, and ignores node_modules', async () => { -+ const { dir, url } = await start(); -+ const res = await get(`${url}__events`); -+ const reader = res.body!.getReader(); -+ const next = async () => new TextDecoder().decode((await reader.read()).value); -+ expect(await next()).toContain('retry: 500'); -+ await writeFile(join(dir, 'mod.mjs'), 'export {}'); -+ expect(await next()).toContain('data: reload'); -+ server!.reload(); -+ expect(await next()).toContain('data: reload'); -+ await reader.cancel(); -+ }); -+ -+ it('reports a port that is already taken', async () => { -+ const { url } = await start(); -+ const port = Number(new URL(url).port); -+ await expect( -+ startServer({ file: join(tmpdir(), 'x.mjs'), port, render: async () => current }), -+ ).rejects.toMatchObject({ code: 'EADDRINUSE' }); -+ }); -+}); -diff --git a/test/diff.test.ts b/test/diff.test.ts -new file mode 100644 -index 0000000..d7c5612 ---- /dev/null -+++ b/test/diff.test.ts -@@ -0,0 +1,221 @@ -+import { describe, expect, it } from 'vitest'; -+import { diffRenders, formatDiff } from '../src/index'; -+import type { PartStatus, PartTrace, RenderResult } from '../src/index'; -+import { diffLines, trimContext } from '../src/inspect/diff'; -+import type { DiffLine } from '../src/inspect/diff'; -+ -+function part(id: string, html: string, status: PartStatus = 'ok'): PartTrace { -+ return { id, status, html, chars: html.length, ms: 0 }; -+} -+ -+function result(parts: PartTrace[], html?: string, templateId = 'demo'): RenderResult { -+ return { -+ templateId, -+ ok: parts.every((p) => p.status !== 'error'), -+ html: html ?? parts.map((p) => p.html).join('\n'), -+ parts, -+ }; -+} -+ -+function summarize(lines: readonly DiffLine[]): string[] { -+ return lines.map( -+ (line) => `${line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : '='}${line.text}`, -+ ); -+} -+ -+describe('diffLines', () => { -+ it('marks identical text as unchanged', () => { -+ expect(summarize(diffLines('a\nb', 'a\nb'))).toEqual(['=a', '=b']); -+ }); -+ -+ it('reports pure additions and pure deletions', () => { -+ expect(summarize(diffLines('a', 'a\nb'))).toEqual(['=a', '+b']); -+ expect(summarize(diffLines('a\nb', 'a'))).toEqual(['=a', '-b']); -+ }); -+ -+ it('keeps the shared head and tail and diffs only the middle', () => { -+ expect(summarize(diffLines('head\nold\ntail', 'head\nnew\ntail'))).toEqual([ -+ '=head', -+ '-old', -+ '+new', -+ '=tail', -+ ]); -+ }); -+ -+ it('finds the common line inside the middle block', () => { -+ expect(summarize(diffLines('a\nb\nc\nd', 'a\nx\nc\ny'))).toEqual([ -+ '=a', -+ '-b', -+ '+x', -+ '=c', -+ '-d', -+ '+y', -+ ]); -+ }); -+ -+ it('takes an addition before a shared line when that keeps more in common', () => { -+ expect(summarize(diffLines('q', 'p\nq'))).toEqual(['+p', '=q']); -+ }); -+ -+ it('keeps deleting after the other side is used up', () => { -+ expect(summarize(diffLines('q\nz', 'p\nq'))).toEqual(['+p', '=q', '-z']); -+ }); -+ -+ it('keeps adding after the other side is used up', () => { -+ expect(summarize(diffLines('x\ny', 'y\nz'))).toEqual(['-x', '=y', '+z']); -+ }); -+ -+ it('replaces the whole middle block when the search would be too large', () => { -+ const before = Array.from({ length: 600 }, (_, i) => `a${i}`).join('\n'); -+ const after = Array.from({ length: 600 }, (_, i) => `b${i}`).join('\n'); -+ const lines = diffLines(before, after); -+ expect(lines).toHaveLength(1200); -+ expect(lines.slice(0, 600).every((line) => line.kind === 'del')).toBe(true); -+ expect(lines.slice(600).every((line) => line.kind === 'add')).toBe(true); -+ }); -+ -+ it('treats an empty side as all additions or all deletions', () => { -+ expect(summarize(diffLines('', 'a'))).toEqual(['-', '+a']); -+ expect(summarize(diffLines('a', ''))).toEqual(['-a', '+']); -+ }); -+}); -+ -+describe('diffRenders', () => { -+ it('reports no change for identical renders', () => { -+ const before = result([part('a', '

    A

    '), part('b', '

    B

    ')]); -+ const diff = diffRenders(before, result([part('a', '

    A

    '), part('b', '

    B

    ')])); -+ expect(diff.changed).toBe(false); -+ expect(diff.parts.map((p) => p.change)).toEqual(['unchanged', 'unchanged']); -+ expect(diff.parts.every((p) => p.lines.length === 0 && p.charsDelta === 0)).toBe(true); -+ expect(diff.lines.every((line) => line.kind === 'same')).toBe(true); -+ }); -+ -+ it('names the part whose HTML changed and by how many characters', () => { -+ const diff = diffRenders( -+ result([part('a', '

    A

    '), part('b', '

    B

    ')]), -+ result([part('a', '

    A

    '), part('b', '

    Bee

    ')]), -+ ); -+ const changed = diff.parts.filter((p) => p.change !== 'unchanged'); -+ expect(diff.changed).toBe(true); -+ expect(changed.map((p) => p.id)).toEqual(['b']); -+ expect(changed[0]?.change).toBe('changed'); -+ expect(changed[0]?.charsDelta).toBe(2); -+ expect(summarize(changed[0]?.lines ?? [])).toEqual(['-

    B

    ', '+

    Bee

    ']); -+ expect(changed[0]?.statusBefore).toBe('ok'); -+ expect(changed[0]?.statusAfter).toBe('ok'); -+ }); -+ -+ it('counts a status change as a change even when the HTML is the same', () => { -+ const diff = diffRenders( -+ result([part('a', '', 'error')], ''), -+ result([part('a', '', 'empty')], ''), -+ ); -+ expect(diff.changed).toBe(true); -+ expect(diff.parts[0]).toMatchObject({ -+ change: 'changed', -+ statusBefore: 'error', -+ statusAfter: 'empty', -+ charsDelta: 0, -+ }); -+ }); -+ -+ it('reports added and removed parts, counting from zero', () => { -+ const diff = diffRenders( -+ result([part('keep', 'K'), part('gone', 'gone!')]), -+ result([part('keep', 'K'), part('fresh', 'new')]), -+ ); -+ const byId = Object.fromEntries(diff.parts.map((p) => [p.id, p])); -+ expect(byId['gone']).toMatchObject({ change: 'removed', charsDelta: -5 }); -+ expect(byId['gone']?.statusAfter).toBeUndefined(); -+ expect(byId['gone']?.statusBefore).toBe('ok'); -+ expect(byId['fresh']).toMatchObject({ change: 'added', charsDelta: 3 }); -+ expect(byId['fresh']?.statusBefore).toBeUndefined(); -+ expect(byId['fresh']?.statusAfter).toBe('ok'); -+ expect(summarize(byId['fresh']?.lines ?? [])).toEqual(['-', '+new']); -+ }); -+ -+ it('matches parts by id, so reordering alone is not a part change', () => { -+ const diff = diffRenders( -+ result([part('a', 'A'), part('b', 'B')], 'same'), -+ result([part('b', 'B'), part('a', 'A')], 'same'), -+ ); -+ expect(diff.changed).toBe(false); -+ }); -+ -+ it('flags a changed document even when every part is the same', () => { -+ const parts = [part('a', 'A')]; -+ const diff = diffRenders(result(parts, '
    one
    '), result(parts, '
    two
    ')); -+ expect(diff.changed).toBe(true); -+ expect(diff.parts.every((p) => p.change === 'unchanged')).toBe(true); -+ }); -+ -+ it('carries both template ids', () => { -+ const diff = diffRenders(result([], '', 'before'), result([], '', 'after')); -+ expect([diff.templateBefore, diff.templateAfter]).toEqual(['before', 'after']); -+ }); -+}); -+ -+describe('trimContext', () => { -+ const lines = (...kinds: string[]): DiffLine[] => -+ kinds.map((kind, i) => ({ kind: kind as DiffLine['kind'], text: `l${i}` })); -+ -+ it('collapses an unchanged document to one marker', () => { -+ expect(summarize(trimContext(lines('same', 'same', 'same')))).toEqual(['=…']); -+ }); -+ -+ it('keeps context around a change and collapses each gap once', () => { -+ const out = trimContext( -+ lines('same', 'same', 'same', 'same', 'add', 'same', 'same', 'same', 'same', 'same'), -+ 1, -+ ); -+ expect(summarize(out)).toEqual(['=…', '=l3', '+l4', '=l5', '=…']); -+ }); -+ -+ it('stops at the edges of the document', () => { -+ expect(summarize(trimContext(lines('del', 'same', 'same', 'same', 'add'), 1))).toEqual([ -+ '-l0', -+ '=l1', -+ '=…', -+ '=l3', -+ '+l4', -+ ]); -+ }); -+ -+ it('uses two lines of context by default and none when asked', () => { -+ const six = lines('same', 'same', 'same', 'add', 'same', 'same', 'same'); -+ expect(summarize(trimContext(six))).toEqual(['=…', '=l1', '=l2', '+l3', '=l4', '=l5', '=…']); -+ expect(summarize(trimContext(six, 0))).toEqual(['=…', '+l3', '=…']); -+ }); -+}); -+ -+describe('formatDiff', () => { -+ it('says so when nothing changed', () => { -+ const same = result([part('a', 'A')]); -+ expect(formatDiff(diffRenders(same, same))).toBe('demo: no changes'); -+ }); -+ -+ it('lists the changed parts, then the changed lines', () => { -+ const text = formatDiff( -+ diffRenders( -+ result([part('a', 'A'), part('b', 'old text')], 'top\nold text\nbottom'), -+ result([part('a', 'A'), part('b', 'new')], 'top\nnew\nbottom'), -+ ), -+ ); -+ expect(text).toBe( -+ ['demo: changed', ' b changed (-5 chars)', ' top', '- old text', '+ new', ' bottom'].join( -+ '\n', -+ ), -+ ); -+ }); -+ -+ it('prefixes growth with a plus sign and skips unchanged parts', () => { -+ const text = formatDiff( -+ diffRenders( -+ result([part('a', 'A'), part('b', '')]), -+ result([part('a', 'A'), part('b', 'xy')]), -+ ), -+ ); -+ expect(text).toContain(' b changed (+2 chars)'); -+ expect(text).not.toContain(' a '); -+ }); -+}); -diff --git a/test/fixtures/bad-throw.mjs b/test/fixtures/bad-throw.mjs -new file mode 100644 -index 0000000..3db1408 ---- /dev/null -+++ b/test/fixtures/bad-throw.mjs -@@ -0,0 +1 @@ -+throw 'a plain string'; -diff --git a/test/fixtures/broken.mjs b/test/fixtures/broken.mjs -new file mode 100644 -index 0000000..cf477c5 ---- /dev/null -+++ b/test/fixtures/broken.mjs -@@ -0,0 +1,11 @@ -+export const template = { -+ id: 'broken', -+ parts: [ -+ { -+ id: 'bad', -+ render: () => { -+ throw new Error('boom'); -+ }, -+ }, -+ ], -+}; -diff --git a/test/fixtures/default-export.mjs b/test/fixtures/default-export.mjs -new file mode 100644 -index 0000000..717a367 ---- /dev/null -+++ b/test/fixtures/default-export.mjs -@@ -0,0 +1 @@ -+export default { template: { id: 'dflt', parts: [] } }; -diff --git a/test/fixtures/no-template.mjs b/test/fixtures/no-template.mjs -new file mode 100644 -index 0000000..77bda47 ---- /dev/null -+++ b/test/fixtures/no-template.mjs -@@ -0,0 +1 @@ -+export const props = {}; -diff --git a/test/fixtures/ok.mjs b/test/fixtures/ok.mjs -new file mode 100644 -index 0000000..a278485 ---- /dev/null -+++ b/test/fixtures/ok.mjs -@@ -0,0 +1,2 @@ -+export const template = { id: 'ok', parts: [{ id: 'note', render: () => '

    hi

    ' }] }; -+export const props = { title: 'T' }; -diff --git a/test/fixtures/syntax.mjs b/test/fixtures/syntax.mjs -new file mode 100644 -index 0000000..4aa8236 ---- /dev/null -+++ b/test/fixtures/syntax.mjs -@@ -0,0 +1 @@ -+export const template = {{{ -diff --git a/test/fixtures/throws.mjs b/test/fixtures/throws.mjs -new file mode 100644 -index 0000000..db5b107 ---- /dev/null -+++ b/test/fixtures/throws.mjs -@@ -0,0 +1,12 @@ -+export const template = { -+ id: 'throws', -+ parts: [ -+ { -+ id: 'bad', -+ render: () => { -+ throw new Error('boom'); -+ }, -+ }, -+ ], -+}; -+export const options = { onError: 'throw' }; -diff --git a/test/fixtures/worker-dies.mjs b/test/fixtures/worker-dies.mjs -new file mode 100644 -index 0000000..a488b63 ---- /dev/null -+++ b/test/fixtures/worker-dies.mjs -@@ -0,0 +1,2 @@ -+console.error('about to die'); -+process.exit(3); -diff --git a/test/fixtures/worker-loops.mjs b/test/fixtures/worker-loops.mjs -new file mode 100644 -index 0000000..32940f0 ---- /dev/null -+++ b/test/fixtures/worker-loops.mjs -@@ -0,0 +1 @@ -+setInterval(() => {}, 1000); -diff --git a/test/fixtures/worker-reports.mjs b/test/fixtures/worker-reports.mjs -new file mode 100644 -index 0000000..741e1b8 ---- /dev/null -+++ b/test/fixtures/worker-reports.mjs -@@ -0,0 +1 @@ -+process.send({ kind: 'crash', message: 'from worker ' + process.argv[2] }); -diff --git a/test/inspect.test.ts b/test/inspect.test.ts -new file mode 100644 -index 0000000..22d75d3 ---- /dev/null -+++ b/test/inspect.test.ts -@@ -0,0 +1,310 @@ -+import { describe, expect, it } from 'vitest'; -+import { -+ defineEmailTemplate, -+ defineTemplate, -+ inspect, -+ renderEmail, -+ renderTemplate, -+ RenderError, -+ slot, -+} from '../src/index'; -+import type { PartStatus, PartTrace, RenderResult } from '../src/index'; -+ -+function part( -+ id: string, -+ html: string, -+ status: PartStatus = 'ok', -+ extra: Partial = {}, -+): PartTrace { -+ return { id, status, html, chars: html.length, ms: 0, ...extra }; -+} -+ -+function result(parts: PartTrace[], overrides: Partial = {}): RenderResult { -+ return { -+ templateId: 'demo', -+ ok: parts.every((p) => p.status !== 'error'), -+ html: parts.map((p) => p.html).join('\n'), -+ parts, -+ ...overrides, -+ }; -+} -+ -+const letter = defineEmailTemplate({ -+ id: 'weekly', -+ parts: [ -+ { -+ id: 'promo', -+ render: (ctx) => `

    Code ${(ctx.props as { code?: string }).code!.toUpperCase()}

    `, -+ }, -+ { id: 'note', render: (ctx) => slot(ctx, 'note') }, -+ ], -+}); -+ -+describe('inspect: a healthy render', () => { -+ const html = inspect(renderEmail(letter, { title: 'Hi', heading: 'Hi', code: 'x1' } as never)); -+ -+ it('is a complete HTML5 page', () => { -+ expect(html.startsWith('\n')).toBe(true); -+ expect(html).toContain(''); -+ expect(html).toContain('Inspect · weekly'); -+ expect(html.trimEnd().endsWith('')).toBe(true); -+ }); -+ -+ it('shows the verdict, the template id and a summary line', () => { -+ expect(html).toContain('v-ok">OK'); -+ expect(html).toContain('

    weekly

    '); -+ expect(html).toMatch(/5 parts · [\d,]+ chars · /); -+ }); -+ -+ it('draws one timeline row and one part card per part', () => { -+ for (const id of ['head', 'main', 'promo', 'note', 'footer']) { -+ expect(html).toContain(`${id}`); -+ } -+ expect(html.match(/class="row"/g)).toHaveLength(5); -+ expect(html.match(/
    { -+ expect(html).not.toContain('role="alert"'); -+ expect(html).not.toMatch(/
    /); -+ }); -+ -+ it('has no scripts, no network requests and never prints "undefined"', () => { -+ expect(html).not.toMatch(/ { -+ expect(html).toContain('id="vp-full" checked'); -+ expect(html).toContain('for="vp-tablet">Tablet 768'); -+ expect(html).toContain('for="vp-mobile">Mobile 375'); -+ expect(html).toContain( -+ '`, + '', + '', + ].join('\n'); +} + +function partsSection(parts: readonly PartTrace[]): string { + if (parts.length === 0) return ''; + return `

    Parts

    ${parts.map(partCard).join('\n')}
    `; +} + +function partCard(part: PartTrace, index: number): string { + const open = part.status === 'error' ? ' open' : ''; + return [ + `
    `, + `${escapeHtml(part.id)} ${chip(part.status)} ${fmtChars(part.chars)} · ${fmtMs(part.ms)}`, + partBody(part), + '
    ', + ].join('\n'); +} + +function partBody(part: PartTrace): string { + if (part.status === 'skipped') { + return '

    Skipped because an earlier part failed.

    '; + } + const error = part.error + ? `` + : ''; + if (part.html === '') { + return `${error}

    ${part.status === 'error' ? 'Rendered nothing because it failed.' : 'Rendered an empty string.'}

    `; + } + return [ + error, + ``, + `
    ${escapeHtml(part.html)}
    `, + ].join('\n'); +} + +function diffSection(diff: RenderDiff): string { + if (!diff.changed) { + return '

    Changes

    No changes since the previous render.

    '; + } + const changes = diff.parts + .filter((part) => part.change !== 'unchanged') + .map((part) => { + const sign = part.charsDelta > 0 ? '+' : ''; + return `
  • ${escapeHtml(part.id)} ${part.change} ${sign}${part.charsDelta} chars
  • `; + }); + const lines = trimContext(diff.lines).map(diffLine); + return `

    Changes since the previous render

      ${changes.join('')}
    ${lines.join('')}
    `; +} + +function diffLine(line: DiffLine): string { + const sign = line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '; + return `${sign} ${escapeHtml(line.text)}`; +} diff --git a/src/inspect/styles.ts b/src/inspect/styles.ts new file mode 100644 index 0000000..323d6c8 --- /dev/null +++ b/src/inspect/styles.ts @@ -0,0 +1,70 @@ +/** Stylesheet for the inspector page. Inlined, so the report is one file with no requests. */ +export const STYLES = ` +:root{--bg:#f4f5f8;--panel:#fff;--ink:#15181e;--muted:#5b6472;--line:#e1e4ea;--accent:#3556d8; +--ok:#16764a;--okbg:#e2f4ea;--bad:#b9232b;--badbg:#fde8e9;--warn:#8a5a00;--warnbg:#fff1cf; +--skip:#667085;--skipbg:#eceef2;--add:#e2f4ea;--del:#fde8e9;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} +@media (prefers-color-scheme:dark){:root{--bg:#0e1014;--panel:#161920;--ink:#e9ebf0;--muted:#97a0af;--line:#272c37;--accent:#8fa3ff; +--ok:#62d696;--okbg:#10301f;--bad:#ff8087;--badbg:#3b1518;--warn:#f2bc55;--warnbg:#3a2b0b; +--skip:#97a0af;--skipbg:#222733;--add:#10301f;--del:#3b1518}} +*{box-sizing:border-box} +body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif} +.page{max-width:980px;margin:0 auto;padding:28px 16px 48px;display:grid;gap:16px} +h1{margin:0;font-size:26px;letter-spacing:-.01em} +h2{margin:0 0 12px;font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)} +.eyebrow{margin:0;font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)} +.head{display:grid;grid-template-columns:1fr auto;gap:4px 16px;align-items:center} +.facts{grid-column:1/-1;margin:0;color:var(--muted)} +.verdict{font-weight:700;font-size:13px;padding:6px 14px;border-radius:999px;letter-spacing:.06em} +.v-ok{color:var(--ok);background:var(--okbg)} +.v-bad{color:var(--bad);background:var(--badbg)} +.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:18px} +.banner{background:var(--badbg);color:var(--bad);border:1px solid var(--bad);border-radius:12px;padding:14px 18px} +.banner.inline{margin:10px 0;border-radius:8px;padding:10px 14px} +.banner pre{margin:6px 0 0;white-space:pre-wrap;font:13px/1.45 var(--mono)} +.banner ul{margin:6px 0 0;padding-left:20px} +.banner a{color:inherit;font-weight:600} +.rows{display:grid;gap:4px} +.row{display:grid;grid-template-columns:minmax(80px,150px) 1fr minmax(190px,260px);gap:12px;align-items:center; +padding:6px 8px;border-radius:8px;color:inherit;text-decoration:none} +.row:hover{background:var(--bg)} +.name{font-family:var(--mono);font-size:13px;font-weight:600;overflow-wrap:anywhere} +.track{height:10px;background:var(--bg);border-radius:999px;overflow:hidden} +.bar{display:block;height:100%;width:var(--w,2%);border-radius:999px;background:var(--ok)} +.bar.b-empty{background:var(--warn)} +.bar.b-error{background:var(--bad)} +.bar.skipped{width:100%;background:repeating-linear-gradient(90deg,var(--line) 0 6px,transparent 6px 12px)} +.stat,.meta{color:var(--muted);font-size:13px;white-space:nowrap} +.stat{text-align:right} +.chip{display:inline-block;font:600 11px/1 system-ui,sans-serif;text-transform:uppercase;letter-spacing:.05em; +padding:4px 8px;border-radius:999px;margin-right:4px} +.s-ok{color:var(--ok);background:var(--okbg)} +.s-empty{color:var(--warn);background:var(--warnbg)} +.s-error{color:var(--bad);background:var(--badbg)} +.s-skipped{color:var(--skip);background:var(--skipbg)} +.c-added{color:var(--ok);background:var(--okbg)} +.c-removed{color:var(--bad);background:var(--badbg)} +.c-changed{color:var(--warn);background:var(--warnbg)} +.viewport{position:relative} +.viewport input{position:absolute;opacity:0;pointer-events:none} +.viewport label{display:inline-block;margin:0 6px 10px 0;padding:6px 12px;border:1px solid var(--line);border-radius:999px; +font-size:13px;cursor:pointer;color:var(--muted)} +.viewport input:checked+label{color:var(--accent);border-color:var(--accent);font-weight:600} +.viewport input:focus-visible+label{outline:2px solid var(--accent);outline-offset:2px} +.stage{background:var(--bg);border-radius:8px;padding:12px;overflow:auto} +.stage iframe{display:block;margin:0 auto;width:100%;height:560px;border:1px solid var(--line);border-radius:6px;background:#fff;color-scheme:light} +#vp-tablet:checked~.stage iframe{width:768px;max-width:100%} +#vp-mobile:checked~.stage iframe{width:375px;max-width:100%} +.part{border-top:1px solid var(--line);padding:2px 0} +.part:first-of-type{border-top:0} +.part summary{cursor:pointer;padding:10px 0;display:flex;gap:10px;align-items:center;flex-wrap:wrap} +.frag{display:block;width:100%;height:220px;resize:vertical;border:1px solid var(--line);border-radius:6px;background:#fff;color-scheme:light;margin:8px 0} +.src,.diff{margin:8px 0 12px;padding:12px;background:var(--bg);border-radius:8px;overflow:auto;max-height:360px;font:12.5px/1.5 var(--mono)} +.src code,.diff code{font:inherit} +.diff span{display:block;white-space:pre} +.d-add{background:var(--add)} +.d-del{background:var(--del)} +.changes{list-style:none;margin:0 0 4px;padding:0;display:grid;gap:6px} +.note{margin:8px 0 12px;color:var(--muted)} +.foot{margin:8px 0 0;text-align:center;color:var(--muted);font-size:12px} +@media (max-width:620px){.row{grid-template-columns:1fr;gap:4px}.stat{text-align:left}} +`; diff --git a/src/runtime/explain.ts b/src/runtime/explain.ts index 5ded7b6..524dc14 100644 --- a/src/runtime/explain.ts +++ b/src/runtime/explain.ts @@ -1,4 +1,5 @@ import { RenderError } from '@llazyemail/render-template'; +import { fromError } from './result'; import type { RenderResult } from './types'; /** One text block for logs. Full HTML stays on each part trace. */ @@ -19,12 +20,3 @@ export function explain(result: RenderResult | RenderError): string { } return lines.join('\n'); } - -function fromError(error: RenderError): RenderResult { - return { - templateId: error.templateId, - ok: false, - html: '', - parts: error.trace, - }; -} diff --git a/src/runtime/result.ts b/src/runtime/result.ts new file mode 100644 index 0000000..deee621 --- /dev/null +++ b/src/runtime/result.ts @@ -0,0 +1,12 @@ +import type { RenderError } from '@llazyemail/render-template'; +import type { RenderResult } from './types'; + +/** The failed result a thrown `RenderError` stands for. Its trace is the part list. */ +export function fromError(error: RenderError): RenderResult { + return { + templateId: error.templateId, + ok: false, + html: '', + parts: error.trace, + }; +} diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..bc30148 --- /dev/null +++ b/test/cli.test.ts @@ -0,0 +1,576 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { RenderError } from '@llazyemail/render-template'; +import { parseArgs, USAGE } from '../src/cli/args'; +import { inspectCommand } from '../src/cli/commands'; +import { forkRender, appendStderr } from '../src/cli/fork'; +import { crash, runModule } from '../src/cli/outcome'; +import type { Outcome } from '../src/cli/outcome'; +import { crashPage, outcomePage, renderChanged, toInspectable } from '../src/cli/pages'; +import { startServer, withReload, isLoopbackHost, requestPath } from '../src/cli/server'; +import type { DevServer } from '../src/cli/server'; +import type { PartTrace, RenderResult } from '../src/index'; + +const fx = (name: string): string => new URL(`./fixtures/${name}`, import.meta.url).pathname; + +describe('parseArgs', () => { + it('shows help for no arguments, help words and -h', () => { + for (const argv of [ + [], + ['help'], + ['--help'], + ['-h'], + ['dev', '-h'], + ['inspect', 'x', '--help'], + ]) { + expect(parseArgs(argv)).toEqual({ kind: 'help' }); + } + expect(USAGE).toContain('template-runtime-display dev '); + }); + + it('shows the version', () => { + expect(parseArgs(['-v'])).toEqual({ kind: 'version' }); + expect(parseArgs(['--version'])).toEqual({ kind: 'version' }); + }); + + it('parses dev with defaults and with every option, in both flag styles', () => { + expect(parseArgs(['dev', 'a.mjs'])).toEqual({ + kind: 'dev', + file: 'a.mjs', + port: 4177, + host: '127.0.0.1', + watch: [], + }); + expect( + parseArgs(['dev', 'a.mjs', '--port', '0', '--host=::1', '--watch', 'src', '--watch=lib']), + ).toEqual({ kind: 'dev', file: 'a.mjs', port: 0, host: '::1', watch: ['src', 'lib'] }); + }); + + it('parses inspect with and without --out', () => { + expect(parseArgs(['inspect', 'a.mjs'])).toEqual({ + kind: 'inspect', + file: 'a.mjs', + out: 'inspect.html', + }); + expect(parseArgs(['inspect', '--out', 'r.html', 'a.mjs'])).toEqual({ + kind: 'inspect', + file: 'a.mjs', + out: 'r.html', + }); + }); + + it.each([ + [['nope'], /Unknown command "nope"/], + [['dev'], /dev needs a module path/], + [['inspect'], /inspect needs a module path/], + [['dev', 'a', 'b'], /Unexpected extra argument "b"/], + [['dev', 'a', '--out', 'x'], /Unknown option "--out" for dev/], + [['inspect', 'a', '--port', '1'], /Unknown option "--port" for inspect/], + [['dev', 'a', '--port'], /--port needs a value/], + [['dev', 'a', '--port='], /--port needs a value/], + [['dev', 'a', '--port', 'abc'], /Invalid port "abc"/], + [['dev', 'a', '--port', '70000'], /Invalid port "70000"/], + [['dev', 'a', '--port', '1.5'], /Invalid port "1.5"/], + [['dev', 'a', '--port', '-1'], /Invalid port "-1"/], + ])('rejects %j', (argv: string[], message: RegExp) => { + const command = parseArgs(argv); + expect(command.kind).toBe('error'); + expect((command as { message: string }).message).toMatch(message); + }); +}); + +describe('runModule', () => { + it('renders a module in collect mode and returns the result', async () => { + const outcome = await runModule(fx('ok.mjs')); + expect(outcome.kind).toBe('result'); + expect(outcome.kind === 'result' && outcome.result.ok).toBe(true); + }); + + it('collects a broken part by default', async () => { + const outcome = await runModule(fx('broken.mjs')); + expect(outcome.kind === 'result' && outcome.result.ok).toBe(false); + expect( + outcome.kind === 'result' && outcome.result.parts.some((p) => p.status === 'error'), + ).toBe(true); + }); + + it('reports a throw when the module asks for throw mode', async () => { + const outcome = await runModule(fx('throws.mjs')); + expect(outcome).toMatchObject({ kind: 'thrown', templateId: 'throws', partId: 'bad' }); + expect(outcome.kind === 'thrown' && outcome.trace.length).toBeGreaterThan(0); + }); + + it('accepts a default export with the same keys', async () => { + expect((await runModule(fx('default-export.mjs'))).kind).toBe('result'); + }); + + it('turns a missing template, a syntax error and a non-Error throw into a crash', async () => { + expect(await runModule(fx('no-template.mjs'))).toMatchObject({ + kind: 'crash', + message: expect.stringContaining('does not export a template'), + }); + expect(await runModule(fx('syntax.mjs'))).toMatchObject({ + kind: 'crash', + detail: expect.any(String), + }); + expect(await runModule(fx('bad-throw.mjs'))).toEqual({ + kind: 'crash', + message: 'a plain string', + detail: 'a plain string', + }); + expect((await runModule(fx('missing-file.mjs'))).kind).toBe('crash'); + }); + + it('caps crash detail', () => { + expect((crash('m', 'x'.repeat(9000)) as { detail: string }).detail).toHaveLength(4000); + expect(crash('m')).toEqual({ kind: 'crash', message: 'm' }); + }); + + it('does not treat a lookalike error as a RenderError', async () => { + const fake = Object.assign(new Error('x'), { name: 'RenderError', templateId: 1 }); + expect(fake).toBeInstanceOf(Error); + const dir = await mkdtemp(join(tmpdir(), 'trd-')); + const file = join(dir, 'fake.mjs'); + await writeFile( + file, + "const e = new Error('lookalike'); e.name = 'RenderError'; e.templateId = 1; throw e;", + ); + expect(await runModule(file)).toMatchObject({ kind: 'crash', message: 'lookalike' }); + await rm(dir, { recursive: true }); + }); +}); + +describe('forkRender', () => { + it('returns what the worker reports, passing the module path', async () => { + const outcome = await forkRender('m.mjs', { workerPath: fx('worker-reports.mjs') }); + expect(outcome).toEqual({ kind: 'crash', message: 'from worker m.mjs' }); + }); + + it('turns a worker that dies into a crash with its stderr', async () => { + const outcome = await forkRender('m', { workerPath: fx('worker-dies.mjs') }); + expect(outcome).toMatchObject({ + kind: 'crash', + message: expect.stringContaining('exit code 3'), + detail: expect.stringContaining('about to die'), + }); + }); + + it('kills a worker that never reports', async () => { + const outcome = await forkRender('m', { workerPath: fx('worker-loops.mjs'), timeoutMs: 150 }); + expect(outcome).toMatchObject({ + kind: 'crash', + message: expect.stringContaining('timed out after 150ms'), + }); + }); + + it('turns a program that cannot start into a crash', async () => { + const outcome = await forkRender('m', { + workerPath: fx('worker-reports.mjs'), + execPath: '/nonexistent/node', + }); + expect(outcome.kind).toBe('crash'); + }); + + it('turns a missing worker file into a crash', async () => { + const outcome = await forkRender('m', { workerPath: fx('nope.mjs') }); + expect(outcome.kind).toBe('crash'); + }); +}); + +const trace = (id: string, html: string): PartTrace => ({ + id, + status: 'ok', + html, + chars: html.length, + ms: 0, +}); +const rendered = (html: string): RenderResult => ({ + templateId: 't', + ok: true, + html, + parts: [trace('a', html)], +}); + +describe('pages', () => { + it('maps each outcome to something the inspector can show', () => { + const result = rendered('x'); + expect(toInspectable({ kind: 'result', result })).toBe(result); + const thrown = toInspectable({ + kind: 'thrown', + message: 'm', + templateId: 't', + partId: 'a', + trace: [trace('a', '')], + }); + expect(thrown).toBeInstanceOf(RenderError); + expect(toInspectable(crash('c'))).toBeUndefined(); + }); + + it('renders the inspector, the comparison and the crash page', () => { + expect(outcomePage({ kind: 'result', result: rendered('one') })).toContain('

    t

    '); + expect(outcomePage({ kind: 'result', result: rendered('two') }, rendered('one'))).toContain( + 'Changes since', + ); + const page = outcomePage(crash('', 'stack ')); + expect(page).toContain('CRASHED'); + expect(page).toContain('<bad>'); + expect(page).toContain('stack <here>'); + expect(crashPage({ kind: 'crash', message: 'no detail' })).not.toContain('
    ');
    +  });
    +
    +  it('detects change between results and between a result and a thrown error', () => {
    +    expect(renderChanged(rendered('a'), rendered('a'))).toBe(false);
    +    expect(renderChanged(rendered('a'), rendered('b'))).toBe(true);
    +    const err = new RenderError('m', { templateId: 't', partId: 'a', trace: [trace('a', 'a')] });
    +    expect(renderChanged(err, rendered('a'))).toBe(true);
    +    expect(renderChanged(rendered('a'), err)).toBe(true);
    +  });
    +});
    +
    +describe('inspectCommand', () => {
    +  function run(outcome: Outcome) {
    +    const written: Record = {};
    +    const logs: string[] = [];
    +    const code = inspectCommand('mod.mjs', 'out.html', {
    +      render: async () => outcome,
    +      write: async (file, contents) => void (written[file] = contents),
    +      log: (line) => void logs.push(line),
    +    });
    +    return { code, written, logs };
    +  }
    +
    +  it('exits 0 for a clean render and writes the report', async () => {
    +    const r = run({ kind: 'result', result: rendered('x') });
    +    expect(await r.code).toBe(0);
    +    expect(r.written['out.html']).toContain('v-ok');
    +    expect(r.logs.join('\n')).toContain('t: ok');
    +  });
    +
    +  it('exits 1 when a part broke', async () => {
    +    const result: RenderResult = { ...rendered('x'), ok: false };
    +    expect(await run({ kind: 'result', result }).code).toBe(1);
    +  });
    +
    +  it('exits 1 for a throw and still writes a report', async () => {
    +    const r = run({
    +      kind: 'thrown',
    +      message: 'part failed',
    +      templateId: 't',
    +      partId: 'a',
    +      trace: [trace('a', '')],
    +    });
    +    expect(await r.code).toBe(1);
    +    expect(r.written['out.html']).toContain('Render threw in part');
    +  });
    +
    +  it('exits 2 for a crash, writes the crash page and prints the detail when there is one', async () => {
    +    const withDetail = run(crash('nope', 'stack'));
    +    expect(await withDetail.code).toBe(2);
    +    expect(withDetail.written['out.html']).toContain('CRASHED');
    +    expect(withDetail.logs).toContain('stack');
    +    const plain = run(crash('nope'));
    +    expect(await plain.code).toBe(2);
    +    expect(plain.logs).not.toContain('stack');
    +  });
    +});
    +
    +describe('startServer', () => {
    +  let server: DevServer | undefined;
    +  afterEach(async () => {
    +    await server?.close();
    +    server = undefined;
    +  });
    +
    +  let current: Outcome = { kind: 'result', result: rendered('one') };
    +  const start = async (extra: Partial[0]> = {}) => {
    +    const dir = await mkdtemp(join(tmpdir(), 'trd-srv-'));
    +    server = await startServer({
    +      file: join(dir, 'mod.mjs'),
    +      port: 0,
    +      render: async () => current,
    +      debounceMs: 10,
    +      ...extra,
    +    });
    +    return { dir, url: server.url };
    +  };
    +  const get = (url: string, init?: RequestInit) => fetch(url, init);
    +
    +  it('serves the inspector with a reload listener, no caching, and nosniff', async () => {
    +    current = { kind: 'result', result: rendered('one') };
    +    const { url } = await start();
    +    const res = await get(url);
    +    const body = await res.text();
    +    expect(res.status).toBe(200);
    +    expect(res.headers.get('cache-control')).toBe('no-store');
    +    expect(res.headers.get('x-content-type-options')).toBe('nosniff');
    +    expect(body).toContain("new EventSource('/__events')");
    +    expect(body.indexOf('EventSource')).toBeGreaterThan(body.indexOf('Generated by'));
    +  });
    +
    +  it('shows what changed once the output changes, and keeps it across reloads of the same render', async () => {
    +    current = { kind: 'result', result: rendered('one') };
    +    const { url } = await start();
    +    expect(await (await get(url)).text()).not.toContain('Changes since');
    +    current = { kind: 'result', result: rendered('two') };
    +    expect(await (await get(url)).text()).toContain('Changes since the previous render');
    +    expect(await (await get(url)).text()).toContain('Changes since the previous render');
    +  });
    +
    +  it('serves the document alone at /email, or a 404 when there is none', async () => {
    +    current = { kind: 'result', result: rendered('

    doc

    ') }; + const { url } = await start(); + expect(await (await get(`${url}email`)).text()).toBe('

    doc

    '); + current = { kind: 'result', result: rendered('') }; + expect((await get(`${url}email`)).status).toBe(404); + current = crash('c'); + expect((await get(`${url}email`)).status).toBe(404); + const page = await get(url); + expect(page.status).toBe(200); + expect(await page.text()).toContain('CRASHED'); + }); + + it('refuses other paths, other methods and foreign Host headers', async () => { + const { url } = await start(); + expect((await get(`${url}nope`)).status).toBe(404); + expect((await get(url, { method: 'POST' })).status).toBe(405); + const forged = await new Promise((resolve, reject) => { + import('node:http').then(({ request }) => { + const req = request(url, { headers: { host: 'evil.example' } }, (res) => { + res.resume(); + resolve(res.statusCode ?? 0); + }); + req.on('error', reject); + req.end(); + }, reject); + }); + expect(forged).toBe(403); + }); + + it('answers 500 when rendering itself throws', async () => { + const { url } = await start({ + render: async () => { + throw new Error('boom'); + }, + }); + expect((await get(url)).status).toBe(500); + }); + + it('pushes a reload when a watched file changes, and ignores node_modules', async () => { + const { dir, url } = await start(); + const res = await get(`${url}__events`); + const reader = res.body!.getReader(); + const next = async () => new TextDecoder().decode((await reader.read()).value); + expect(await next()).toContain('retry: 500'); + await writeFile(join(dir, 'mod.mjs'), 'export {}'); + expect(await next()).toContain('data: reload'); + server!.reload(); + expect(await next()).toContain('data: reload'); + await reader.cancel(); + }); + + it('uses default port and debounce when omitted, and handles a missing request url', async () => { + const dir = await mkdtemp(join(tmpdir(), 'trd-def-')); + const server = await startServer({ + file: join(dir, 'mod.mjs'), + render: async () => ({ kind: 'result', result: rendered('x') }), + }); + expect(server.port).toBeGreaterThan(0); + await server.close(); + await rm(dir, { recursive: true, force: true }); + }); + + it('withReload appends the script when the page has no body tag', async () => { + // Force a page without by using a crash that we know produces minimal HTML. + // Direct unit coverage is obtained by requesting / after a crash outcome. + current = crash('no body'); + const { url } = await start(); + const body = await (await get(url)).text(); + expect(body).toContain('EventSource'); + }); + it('reports a port that is already taken', async () => { + const { url } = await start(); + const port = Number(new URL(url).port); + await expect( + startServer({ file: join(tmpdir(), 'x.mjs'), port, render: async () => current }), + ).rejects.toMatchObject({ code: 'EADDRINUSE' }); + }); +}); + +describe('coverage gaps', () => { + it('forkRender drops stderr once the limit is reached', async () => { + const noisy = await mkdtemp(join(tmpdir(), 'trd-noise-')); + const worker = join(noisy, 'worker.mjs'); + await writeFile( + worker, + `process.stderr.write('x'.repeat(9000)); process.send({ kind: 'crash', message: 'done' });`, + ); + const outcome = await forkRender('m', { workerPath: worker, timeoutMs: 2000 }); + expect(outcome.kind).toBe('crash'); + await rm(noisy, { recursive: true, force: true }); + }); + + it('runModule reports a non-Error throw as a crash', async () => { + const dir = await mkdtemp(join(tmpdir(), 'trd-throw-')); + const file = join(dir, 'throw.mjs'); + await writeFile(file, 'throw "plain string throw";'); + const outcome = await runModule(file); + expect(outcome.kind).toBe('crash'); + await rm(dir, { recursive: true, force: true }); + }); + + it('startServer with ::1 host and extra watch dirs, ignores node_modules changes', async () => { + const dir = await mkdtemp(join(tmpdir(), 'trd-ipv6-')); + const nm = join(dir, 'node_modules'); + await import('node:fs/promises').then((fs) => fs.mkdir(nm)); + const server = await startServer({ + file: join(dir, 'mod.mjs'), + port: 0, + host: '::1', + watchDirs: [dir], + render: async () => ({ kind: 'result', result: rendered('x') }), + }); + expect(server.url).toContain('[::1]'); + await writeFile(join(nm, 'ignored.txt'), 'noise'); + await new Promise((r) => setTimeout(r, 50)); + await server.close(); + await rm(dir, { recursive: true, force: true }); + }); +}); + +describe('remaining coverage', () => { + it('forkRender ignores additional stderr after the limit', async () => { + const dir = await mkdtemp(join(tmpdir(), 'trd-big-')); + const worker = join(dir, 'w.mjs'); + // Emit more than STDERR_LIMIT then report + await writeFile( + worker, + ` + process.stderr.write('a'.repeat(10000)); + process.send({ kind: 'crash', message: 'capped' }); + `, + ); + const outcome = await forkRender('m', { workerPath: worker, timeoutMs: 3000 }); + expect(outcome.kind).toBe('crash'); + await rm(dir, { recursive: true, force: true }); + }); + + it('runModule uses error.message when an Error has no stack', async () => { + const dir = await mkdtemp(join(tmpdir(), 'trd-nostack-')); + const file = join(dir, 'e.mjs'); + await writeFile( + file, + ` + const err = new Error('no stack'); + err.stack = undefined; + throw err; + `, + ); + const outcome = await runModule(file); + expect(outcome.kind).toBe('crash'); + expect((outcome as { message: string }).message).toBe('no stack'); + await rm(dir, { recursive: true, force: true }); + }); + + it('startServer binds the default port and debounce, and serves when host header is absent', async () => { + const dir = await mkdtemp(join(tmpdir(), 'trd-defport-')); + const server = await startServer({ + file: join(dir, 'mod.mjs'), + render: async () => ({ kind: 'result', result: rendered('hello') }), + }); + // default port is 4177 when omitted + expect(server.port).toBe(4177); + // request without a Host header hits the !header path + const res = await fetch(server.url, { headers: { host: '' } }); + expect(res.status).toBe(200); + await server.close(); + await rm(dir, { recursive: true, force: true }); + }); + + it('isLoopbackHost style headers are accepted for localhost and IPv6', async () => { + const dir = await mkdtemp(join(tmpdir(), 'trd-hosts-')); + const server = await startServer({ + file: join(dir, 'mod.mjs'), + host: '127.0.0.1', + port: 0, + render: async () => ({ kind: 'result', result: rendered('x') }), + }); + const local = await fetch(server.url, { headers: { host: 'localhost:1234' } }); + expect(local.status).toBe(200); + const ipv6 = await fetch(server.url, { headers: { host: '[::1]:4177' } }); + expect(ipv6.status).toBe(200); + await server.close(); + await rm(dir, { recursive: true, force: true }); + }); +}); + +describe('exported helpers for full coverage', () => { + it('withReload appends when there is no body tag', () => { + expect(withReload('plain')).toContain('EventSource'); + expect(withReload('')).toContain('EventSource'); + }); + + it('isLoopbackHost covers every host shape', () => { + expect(isLoopbackHost(undefined)).toBe(false); + expect(isLoopbackHost('')).toBe(false); + expect(isLoopbackHost('localhost')).toBe(true); + expect(isLoopbackHost('localhost:4177')).toBe(true); + expect(isLoopbackHost('127.0.0.1')).toBe(true); + expect(isLoopbackHost('127.0.0.1:80')).toBe(true); + expect(isLoopbackHost('[::1]')).toBe(true); + expect(isLoopbackHost('[::1]:4177')).toBe(true); + expect(isLoopbackHost('evil.example')).toBe(false); + expect(isLoopbackHost('[evil]')).toBe(false); + }); + + it('appendStderr stops once the limit is reached', () => { + expect(appendStderr('short', 'more')).toBe('shortmore'); + expect(appendStderr('x'.repeat(8000), 'extra')).toBe('x'.repeat(8000)); + }); +}); + +it('handles a request with no url and uses the default debounce', async () => { + const dir = await mkdtemp(join(tmpdir(), 'trd-nourl-')); + const server = await startServer({ + file: join(dir, 'mod.mjs'), + port: 0, + // omit debounceMs to hit the default + render: async () => ({ kind: 'result', result: rendered('x') }), + }); + // raw request with url undefined is hard; force the ?? by calling the handler path + // via a request that fetch always sets. We still exercise the default debounce by touching a file. + await writeFile(join(dir, 'mod.mjs'), 'export {}'); + await new Promise((r) => setTimeout(r, 80)); + await server.close(); + await rm(dir, { recursive: true, force: true }); +}); + +it('treats an empty request url as the root', async () => { + const dir = await mkdtemp(join(tmpdir(), 'trd-emptyurl-')); + const server = await startServer({ + file: join(dir, 'mod.mjs'), + port: 0, + render: async () => ({ kind: 'result', result: rendered('root') }), + }); + const status = await new Promise((resolve, reject) => { + import('node:http').then(({ request }) => { + const req = request(server.url, { path: '' }, (res) => { + res.resume(); + resolve(res.statusCode ?? 0); + }); + req.on('error', reject); + req.end(); + }, reject); + }); + expect(status).toBe(200); + await server.close(); + await rm(dir, { recursive: true, force: true }); +}); + +it('requestPath defaults a missing or empty url to root', () => { + expect(requestPath(undefined)).toBe('/'); + expect(requestPath('')).toBe('/'); + expect(requestPath('/email')).toBe('/email'); + expect(requestPath('/')).toBe('/'); +}); diff --git a/test/diff.test.ts b/test/diff.test.ts new file mode 100644 index 0000000..a346e05 --- /dev/null +++ b/test/diff.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest'; +import { diffRenders, formatDiff } from '../src/index'; +import type { PartStatus, PartTrace, RenderResult } from '../src/index'; +import { diffLines, trimContext } from '../src/inspect/diff'; +import type { DiffLine } from '../src/inspect/diff'; + +function part(id: string, html: string, status: PartStatus = 'ok'): PartTrace { + return { id, status, html, chars: html.length, ms: 0 }; +} + +function result(parts: PartTrace[], html?: string, templateId = 'demo'): RenderResult { + return { + templateId, + ok: parts.every((p) => p.status !== 'error'), + html: html ?? parts.map((p) => p.html).join('\n'), + parts, + }; +} + +function summarize(lines: readonly DiffLine[]): string[] { + return lines.map( + (line) => `${line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : '='}${line.text}`, + ); +} + +describe('diffLines', () => { + it('marks identical text as unchanged', () => { + expect(summarize(diffLines('a\nb', 'a\nb'))).toEqual(['=a', '=b']); + }); + + it('reports pure additions and pure deletions', () => { + expect(summarize(diffLines('a', 'a\nb'))).toEqual(['=a', '+b']); + expect(summarize(diffLines('a\nb', 'a'))).toEqual(['=a', '-b']); + }); + + it('keeps the shared head and tail and diffs only the middle', () => { + expect(summarize(diffLines('head\nold\ntail', 'head\nnew\ntail'))).toEqual([ + '=head', + '-old', + '+new', + '=tail', + ]); + }); + + it('finds the common line inside the middle block', () => { + expect(summarize(diffLines('a\nb\nc\nd', 'a\nx\nc\ny'))).toEqual([ + '=a', + '-b', + '+x', + '=c', + '-d', + '+y', + ]); + }); + + it('takes an addition before a shared line when that keeps more in common', () => { + expect(summarize(diffLines('q', 'p\nq'))).toEqual(['+p', '=q']); + }); + + it('keeps deleting after the other side is used up', () => { + expect(summarize(diffLines('q\nz', 'p\nq'))).toEqual(['+p', '=q', '-z']); + }); + + it('keeps adding after the other side is used up', () => { + expect(summarize(diffLines('x\ny', 'y\nz'))).toEqual(['-x', '=y', '+z']); + }); + + it('replaces the whole middle block when the search would be too large', () => { + const before = Array.from({ length: 600 }, (_, i) => `a${i}`).join('\n'); + const after = Array.from({ length: 600 }, (_, i) => `b${i}`).join('\n'); + const lines = diffLines(before, after); + expect(lines).toHaveLength(1200); + expect(lines.slice(0, 600).every((line) => line.kind === 'del')).toBe(true); + expect(lines.slice(600).every((line) => line.kind === 'add')).toBe(true); + }); + + it('treats an empty side as all additions or all deletions', () => { + expect(summarize(diffLines('', 'a'))).toEqual(['-', '+a']); + expect(summarize(diffLines('a', ''))).toEqual(['-a', '+']); + }); +}); + +describe('diffRenders', () => { + it('reports no change for identical renders', () => { + const before = result([part('a', '

    A

    '), part('b', '

    B

    ')]); + const diff = diffRenders(before, result([part('a', '

    A

    '), part('b', '

    B

    ')])); + expect(diff.changed).toBe(false); + expect(diff.parts.map((p) => p.change)).toEqual(['unchanged', 'unchanged']); + expect(diff.parts.every((p) => p.lines.length === 0 && p.charsDelta === 0)).toBe(true); + expect(diff.lines.every((line) => line.kind === 'same')).toBe(true); + }); + + it('names the part whose HTML changed and by how many characters', () => { + const diff = diffRenders( + result([part('a', '

    A

    '), part('b', '

    B

    ')]), + result([part('a', '

    A

    '), part('b', '

    Bee

    ')]), + ); + const changed = diff.parts.filter((p) => p.change !== 'unchanged'); + expect(diff.changed).toBe(true); + expect(changed.map((p) => p.id)).toEqual(['b']); + expect(changed[0]?.change).toBe('changed'); + expect(changed[0]?.charsDelta).toBe(2); + expect(summarize(changed[0]?.lines ?? [])).toEqual(['-

    B

    ', '+

    Bee

    ']); + expect(changed[0]?.statusBefore).toBe('ok'); + expect(changed[0]?.statusAfter).toBe('ok'); + }); + + it('counts a status change as a change even when the HTML is the same', () => { + const diff = diffRenders( + result([part('a', '', 'error')], ''), + result([part('a', '', 'empty')], ''), + ); + expect(diff.changed).toBe(true); + expect(diff.parts[0]).toMatchObject({ + change: 'changed', + statusBefore: 'error', + statusAfter: 'empty', + charsDelta: 0, + }); + }); + + it('reports added and removed parts, counting from zero', () => { + const diff = diffRenders( + result([part('keep', 'K'), part('gone', 'gone!')]), + result([part('keep', 'K'), part('fresh', 'new')]), + ); + const byId = Object.fromEntries(diff.parts.map((p) => [p.id, p])); + expect(byId['gone']).toMatchObject({ change: 'removed', charsDelta: -5 }); + expect(byId['gone']?.statusAfter).toBeUndefined(); + expect(byId['gone']?.statusBefore).toBe('ok'); + expect(byId['fresh']).toMatchObject({ change: 'added', charsDelta: 3 }); + expect(byId['fresh']?.statusBefore).toBeUndefined(); + expect(byId['fresh']?.statusAfter).toBe('ok'); + expect(summarize(byId['fresh']?.lines ?? [])).toEqual(['-', '+new']); + }); + + it('matches parts by id, so reordering alone is not a part change', () => { + const diff = diffRenders( + result([part('a', 'A'), part('b', 'B')], 'same'), + result([part('b', 'B'), part('a', 'A')], 'same'), + ); + expect(diff.changed).toBe(false); + }); + + it('flags a changed document even when every part is the same', () => { + const parts = [part('a', 'A')]; + const diff = diffRenders(result(parts, '
    one
    '), result(parts, '
    two
    ')); + expect(diff.changed).toBe(true); + expect(diff.parts.every((p) => p.change === 'unchanged')).toBe(true); + }); + + it('carries both template ids', () => { + const diff = diffRenders(result([], '', 'before'), result([], '', 'after')); + expect([diff.templateBefore, diff.templateAfter]).toEqual(['before', 'after']); + }); +}); + +describe('trimContext', () => { + const lines = (...kinds: string[]): DiffLine[] => + kinds.map((kind, i) => ({ kind: kind as DiffLine['kind'], text: `l${i}` })); + + it('collapses an unchanged document to one marker', () => { + expect(summarize(trimContext(lines('same', 'same', 'same')))).toEqual(['=…']); + }); + + it('keeps context around a change and collapses each gap once', () => { + const out = trimContext( + lines('same', 'same', 'same', 'same', 'add', 'same', 'same', 'same', 'same', 'same'), + 1, + ); + expect(summarize(out)).toEqual(['=…', '=l3', '+l4', '=l5', '=…']); + }); + + it('stops at the edges of the document', () => { + expect(summarize(trimContext(lines('del', 'same', 'same', 'same', 'add'), 1))).toEqual([ + '-l0', + '=l1', + '=…', + '=l3', + '+l4', + ]); + }); + + it('uses two lines of context by default and none when asked', () => { + const six = lines('same', 'same', 'same', 'add', 'same', 'same', 'same'); + expect(summarize(trimContext(six))).toEqual(['=…', '=l1', '=l2', '+l3', '=l4', '=l5', '=…']); + expect(summarize(trimContext(six, 0))).toEqual(['=…', '+l3', '=…']); + }); +}); + +describe('formatDiff', () => { + it('says so when nothing changed', () => { + const same = result([part('a', 'A')]); + expect(formatDiff(diffRenders(same, same))).toBe('demo: no changes'); + }); + + it('lists the changed parts, then the changed lines', () => { + const text = formatDiff( + diffRenders( + result([part('a', 'A'), part('b', 'old text')], 'top\nold text\nbottom'), + result([part('a', 'A'), part('b', 'new')], 'top\nnew\nbottom'), + ), + ); + expect(text).toBe( + ['demo: changed', ' b changed (-5 chars)', ' top', '- old text', '+ new', ' bottom'].join( + '\n', + ), + ); + }); + + it('prefixes growth with a plus sign and skips unchanged parts', () => { + const text = formatDiff( + diffRenders( + result([part('a', 'A'), part('b', '')]), + result([part('a', 'A'), part('b', 'xy')]), + ), + ); + expect(text).toContain(' b changed (+2 chars)'); + expect(text).not.toContain(' a '); + }); +}); + +it('treats a missing html field as empty when a part is added', () => { + const before = result([]); + const after = result([ + { id: 'fresh', status: 'ok', html: undefined as unknown as string, chars: 0, ms: 0 }, + ]); + const diff = diffRenders(before, after); + expect(diff.parts[0]).toMatchObject({ id: 'fresh', change: 'added' }); +}); diff --git a/test/fixtures/bad-throw.mjs b/test/fixtures/bad-throw.mjs new file mode 100644 index 0000000..3db1408 --- /dev/null +++ b/test/fixtures/bad-throw.mjs @@ -0,0 +1 @@ +throw 'a plain string'; diff --git a/test/fixtures/broken.mjs b/test/fixtures/broken.mjs new file mode 100644 index 0000000..cf477c5 --- /dev/null +++ b/test/fixtures/broken.mjs @@ -0,0 +1,11 @@ +export const template = { + id: 'broken', + parts: [ + { + id: 'bad', + render: () => { + throw new Error('boom'); + }, + }, + ], +}; diff --git a/test/fixtures/default-export.mjs b/test/fixtures/default-export.mjs new file mode 100644 index 0000000..717a367 --- /dev/null +++ b/test/fixtures/default-export.mjs @@ -0,0 +1 @@ +export default { template: { id: 'dflt', parts: [] } }; diff --git a/test/fixtures/no-template.mjs b/test/fixtures/no-template.mjs new file mode 100644 index 0000000..77bda47 --- /dev/null +++ b/test/fixtures/no-template.mjs @@ -0,0 +1 @@ +export const props = {}; diff --git a/test/fixtures/ok.mjs b/test/fixtures/ok.mjs new file mode 100644 index 0000000..a278485 --- /dev/null +++ b/test/fixtures/ok.mjs @@ -0,0 +1,2 @@ +export const template = { id: 'ok', parts: [{ id: 'note', render: () => '

    hi

    ' }] }; +export const props = { title: 'T' }; diff --git a/test/fixtures/syntax.mjs b/test/fixtures/syntax.mjs new file mode 100644 index 0000000..4aa8236 --- /dev/null +++ b/test/fixtures/syntax.mjs @@ -0,0 +1 @@ +export const template = {{{ diff --git a/test/fixtures/throws.mjs b/test/fixtures/throws.mjs new file mode 100644 index 0000000..db5b107 --- /dev/null +++ b/test/fixtures/throws.mjs @@ -0,0 +1,12 @@ +export const template = { + id: 'throws', + parts: [ + { + id: 'bad', + render: () => { + throw new Error('boom'); + }, + }, + ], +}; +export const options = { onError: 'throw' }; diff --git a/test/fixtures/worker-dies.mjs b/test/fixtures/worker-dies.mjs new file mode 100644 index 0000000..a488b63 --- /dev/null +++ b/test/fixtures/worker-dies.mjs @@ -0,0 +1,2 @@ +console.error('about to die'); +process.exit(3); diff --git a/test/fixtures/worker-loops.mjs b/test/fixtures/worker-loops.mjs new file mode 100644 index 0000000..32940f0 --- /dev/null +++ b/test/fixtures/worker-loops.mjs @@ -0,0 +1 @@ +setInterval(() => {}, 1000); diff --git a/test/fixtures/worker-reports.mjs b/test/fixtures/worker-reports.mjs new file mode 100644 index 0000000..741e1b8 --- /dev/null +++ b/test/fixtures/worker-reports.mjs @@ -0,0 +1 @@ +process.send({ kind: 'crash', message: 'from worker ' + process.argv[2] }); diff --git a/test/inspect.test.ts b/test/inspect.test.ts new file mode 100644 index 0000000..22d75d3 --- /dev/null +++ b/test/inspect.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from 'vitest'; +import { + defineEmailTemplate, + defineTemplate, + inspect, + renderEmail, + renderTemplate, + RenderError, + slot, +} from '../src/index'; +import type { PartStatus, PartTrace, RenderResult } from '../src/index'; + +function part( + id: string, + html: string, + status: PartStatus = 'ok', + extra: Partial = {}, +): PartTrace { + return { id, status, html, chars: html.length, ms: 0, ...extra }; +} + +function result(parts: PartTrace[], overrides: Partial = {}): RenderResult { + return { + templateId: 'demo', + ok: parts.every((p) => p.status !== 'error'), + html: parts.map((p) => p.html).join('\n'), + parts, + ...overrides, + }; +} + +const letter = defineEmailTemplate({ + id: 'weekly', + parts: [ + { + id: 'promo', + render: (ctx) => `

    Code ${(ctx.props as { code?: string }).code!.toUpperCase()}

    `, + }, + { id: 'note', render: (ctx) => slot(ctx, 'note') }, + ], +}); + +describe('inspect: a healthy render', () => { + const html = inspect(renderEmail(letter, { title: 'Hi', heading: 'Hi', code: 'x1' } as never)); + + it('is a complete HTML5 page', () => { + expect(html.startsWith('\n')).toBe(true); + expect(html).toContain(''); + expect(html).toContain('Inspect · weekly'); + expect(html.trimEnd().endsWith('')).toBe(true); + }); + + it('shows the verdict, the template id and a summary line', () => { + expect(html).toContain('v-ok">OK'); + expect(html).toContain('

    weekly

    '); + expect(html).toMatch(/5 parts · [\d,]+ chars · /); + }); + + it('draws one timeline row and one part card per part', () => { + for (const id of ['head', 'main', 'promo', 'note', 'footer']) { + expect(html).toContain(`${id}`); + } + expect(html.match(/class="row"/g)).toHaveLength(5); + expect(html.match(/
    { + expect(html).not.toContain('role="alert"'); + expect(html).not.toMatch(/
    /); + }); + + it('has no scripts, no network requests and never prints "undefined"', () => { + expect(html).not.toMatch(/ { + expect(html).toContain('id="vp-full" checked'); + expect(html).toContain('for="vp-tablet">Tablet 768'); + expect(html).toContain('for="vp-mobile">Mobile 375'); + expect(html).toContain( + '