open-design/apps/daemon/tests/critique-artifact-writer.test.ts
Nagendhra Madishetti 32fa0c23bb
feat(daemon): Critique Theater Phase 6.2 (artifact extraction + endpoint) (#1085)
The orchestrator was leaving artifactPath = null on every shipped run because
the SHIP <ARTIFACT> body never made it past the parser. Reviewers caught this
on PR #1006: a rerun-style endpoint built on top of that null could not return
a usable prior-art reference, and tests that synthesized artifactPath via
insertCritiqueRun were hiding the gap rather than covering the feature.

This PR closes that gap. The parser now hands the orchestrator a
ShipArtifactPayload (round, mime, body) through a side-channel callback, and
the orchestrator writes the bytes to <artifactsDir>/<projectId>/<runId>/
artifact.<ext> via a new artifact-writer module. The row's artifactPath is
the absolute on-disk path. The web layer never sees that path: it fetches
the bytes through GET /api/projects/:projectId/critique/:runId/artifact,
which the new artifact-handler module serves with a mime-derived
Content-Type, X-Content-Type-Options: nosniff, a CSP header for HTML and
SVG, and the same cross-project leak guard pattern the interrupt handler
uses.

The body and mime intentionally never travel on the SSE wire. The SHIP
PanelEvent (which doubles as the SSE payload shape) keeps its lightweight
artifactRef, and the orchestrator strips body/mime before bus.emit, so a
multi-megabyte artifact does not broadcast to every subscriber. The new
orchestrator test asserts this explicitly.

Defense in depth in the writer + handler:

  - mime allowlist with text/html, text/css, text/markdown, text/plain,
    application/json, image/svg+xml; everything else falls through to
    application/octet-stream + .bin so unknown payloads can't be
    misinterpreted as a known type;
  - UTF-8 byte-length cap, configurable via cfg.parserMaxBlockBytes, so
    multi-byte payloads can't sneak past a JS .length check;
  - atomic write through a sibling tmp file + rename so a daemon crash
    mid-write can't leave a half-written artifact under the canonical
    name;
  - path-traversal guard on the GET endpoint that resolves the row's
    artifactPath against the artifacts root and refuses anything that
    escapes it, refuses non-regular files (symlinks, dirs), and refuses
    files larger than the response cap.

Folded in two non-blocking notes lefarcen left on PR #1016 (the contracts
move) since persistence.ts was already in scope here:

  - P2: introduced CritiquePersistedStatus = CritiqueRunStatus | 'running'
    in the contracts package. CritiqueRunRow.status and CritiqueRunInsert.
    status now use it, and the inline `as CritiqueRunStatus | 'running'`
    widen in interrupt-handler.ts is gone. Public DTOs continue to use the
    terminal-only CritiqueRunStatus so a future endpoint can't leak a
    'running' row through the wire.
  - P3: added AssertExhaustiveValues + a compile-time assertion that
    CRITIQUE_RUN_STATUSES covers every CritiqueRunStatus variant.
    Adding a value to ShipStatus or CritiqueRunStatus without updating
    the array now fails the build with a tuple naming the missing
    variants instead of silently dropping out of UI filters.

Coverage: 174 critique tests across 14 files pass locally, including the
new critique-artifact-writer (13 cases) and critique-artifact-endpoint
(11 cases) suites, the inverted critique-lifecycle artifact-persistence
test, and the orchestrator happy-path that asserts the SSE ship payload
does NOT carry body or mime.

Validated: pnpm guard, pnpm --filter @open-design/contracts build,
pnpm --filter @open-design/daemon build (full tsc), pnpm --filter
@open-design/web typecheck, pnpm --filter @open-design/daemon exec
vitest run tests/critique (all green).

This is step (b) of the four-step plan that PR #1006's closing comment
laid out. Step (a) was the contracts move in PR #1016. Steps (c)
(persist original_message_id / agent_id / model_id) and (d) (real
rerun endpoint on top of (a)+(b)+(c)) follow.

Co-authored-by: Nagendhra <nagendhra405@gmail.com>
2026-05-10 23:59:04 +08:00

150 lines
5.3 KiB
TypeScript

/**
* Tests for the SHIP <ARTIFACT> writer module.
*
* The writer is the daemon's only path for turning a parser-side payload
* into bytes on disk; the artifact endpoint serves whatever this module
* produced, so the mime → extension contract and the size cap matter for
* production correctness, not just developer ergonomics.
*
* @see specs/current/critique-theater.md § rerun endpoint (Task 6.2)
*/
import { mkdtempSync, readFileSync, statSync } from 'node:fs';
import { rm, readdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
ARTIFACT_WRITER_INTERNALS,
ArtifactEmptyError,
ArtifactTooLargeError,
extensionForMime,
mimeForExtension,
writeShipArtifact,
} from '../src/critique/artifact-writer.js';
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'od-artifact-writer-'));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
describe('writeShipArtifact', () => {
it('writes the body verbatim and resolves the canonical mime + extension', async () => {
const result = await writeShipArtifact(
dir,
'<html><body>final</body></html>',
'text/html',
);
expect(result.mime).toBe('text/html');
expect(result.extension).toBe('html');
expect(result.filename).toBe('artifact.html');
expect(result.absPath).toBe(join(dir, 'artifact.html'));
expect(result.sizeBytes).toBe(31);
expect(readFileSync(result.absPath, 'utf8')).toBe('<html><body>final</body></html>');
});
it('strips charset parameters and case before mime lookup', async () => {
const result = await writeShipArtifact(
dir,
'/* css */',
'Text/CSS; charset=utf-8',
);
expect(result.mime).toBe('text/css');
expect(result.extension).toBe('css');
expect(readFileSync(result.absPath, 'utf8')).toBe('/* css */');
});
it('falls back to application/octet-stream + .bin for unknown mime types', async () => {
const result = await writeShipArtifact(dir, 'binary-ish', 'application/x-zip');
expect(result.mime).toBe('application/octet-stream');
expect(result.extension).toBe('bin');
expect(result.filename).toBe('artifact.bin');
expect(readFileSync(result.absPath, 'utf8')).toBe('binary-ish');
});
it('falls back to the binary extension for empty / whitespace-only mime input', async () => {
const result = await writeShipArtifact(dir, 'something', '');
expect(result.mime).toBe('application/octet-stream');
expect(result.extension).toBe('bin');
});
it('refuses zero-byte bodies with ArtifactEmptyError', async () => {
await expect(writeShipArtifact(dir, '', 'text/html'))
.rejects.toBeInstanceOf(ArtifactEmptyError);
});
it('throws ArtifactTooLargeError when the body exceeds the configured cap', async () => {
const body = 'a'.repeat(2048);
await expect(
writeShipArtifact(dir, body, 'text/html', { maxBytes: 1024 }),
).rejects.toBeInstanceOf(ArtifactTooLargeError);
});
it('rejects via UTF-8 byte length, not JS string length, for multi-byte payloads', async () => {
// Each Unicode emoji is 4 bytes in UTF-8 but 2 chars in JS string length.
// Cap of 5 bytes must reject a string whose JS .length is 2 but byte
// length is 8.
const body = '🎨🎨';
expect(body.length).toBe(4);
expect(Buffer.byteLength(body, 'utf8')).toBe(8);
await expect(
writeShipArtifact(dir, body, 'text/html', { maxBytes: 5 }),
).rejects.toBeInstanceOf(ArtifactTooLargeError);
});
it('does not leave a tmp sibling when the write succeeds', async () => {
await writeShipArtifact(dir, '<html></html>', 'text/html');
const entries = await readdir(dir);
expect(entries).toEqual(['artifact.html']);
});
it('overwrites a previous artifact in the same directory atomically', async () => {
const first = await writeShipArtifact(dir, '<html>v1</html>', 'text/html');
const second = await writeShipArtifact(dir, '<html>v2</html>', 'text/html');
expect(second.absPath).toBe(first.absPath);
expect(readFileSync(second.absPath, 'utf8')).toBe('<html>v2</html>');
const entries = await readdir(dir);
expect(entries).toEqual(['artifact.html']);
});
it('writes deterministic file size that matches the byte-length reported on the result', async () => {
const body = 'a'.repeat(123);
const result = await writeShipArtifact(dir, body, 'text/plain');
expect(result.sizeBytes).toBe(123);
expect(statSync(result.absPath).size).toBe(123);
});
});
describe('extensionForMime / mimeForExtension', () => {
it('round-trips every known mime', () => {
for (const [mime, ext] of Object.entries(
ARTIFACT_WRITER_INTERNALS.ARTIFACT_MIME_EXTENSIONS,
)) {
expect(extensionForMime(mime)).toBe(ext);
expect(mimeForExtension(ext)).toBe(mime);
}
});
it('returns the binary fallback for unknown extensions and mimes', () => {
expect(extensionForMime('application/x-foo')).toBe(
ARTIFACT_WRITER_INTERNALS.FALLBACK_EXTENSION,
);
expect(mimeForExtension('xyz')).toBe(ARTIFACT_WRITER_INTERNALS.FALLBACK_MIME);
});
it('strips a leading dot from extension lookups', () => {
expect(mimeForExtension('.html')).toBe('text/html');
expect(mimeForExtension('html')).toBe('text/html');
});
});