import { describe, expect, it } from 'vitest'; import type { PanelEvent } from '@open-design/contracts/critique'; import { parseV1, extractArtifactBlock, indexOfOutsideCdata, } from '../../../src/critique/parsers/v1.js'; import { MalformedBlockError, MissingArtifactError, OversizeBlockError, } from '../../../src/critique/errors.js'; // --------------------------------------------------------------------------- // Targeted unit coverage for the v1 streaming parser (parseV1 + the two // pure helpers it exposes). The existing tests/parser.test.ts exercises the // public facade parseCritiqueStream end-to-end; this file pins behaviors at // the parser-internal level so a refactor that preserves the public happy // path but breaks a single regex or boundary rule still goes red here. // --------------------------------------------------------------------------- async function* chunkify(s: string, size: number): AsyncGenerator { for (let i = 0; i < s.length; i += size) yield s.slice(i, i + size); } async function* oneChunk(s: string): AsyncGenerator { yield s; } async function collect(iter: AsyncIterable): Promise { const out: PanelEvent[] = []; for await (const e of iter) out.push(e); return out; } // Minimal opts builder so each test only passes the knobs it cares about. function opts(over: Partial[1]> = {}) { return { runId: 'run-test', adapter: 'test-adapter', parserMaxBlockBytes: 262_144, ...over, }; } // Wrap a body inside a complete, well-formed CRITIQUE_RUN/ROUND/SHIP envelope // so individual block-level assertions can run without re-deriving the full // scaffolding for every test. function wrapRound1(roundBody: string, scale = 10, threshold = '8.0'): string { return ` v1 v1

]]>
${roundBody} ok
final

]]>
done
`; } describe('parseV1 -- DIM_RE dimension extraction', () => { it('emits one panelist_dim per match with name, score, and trimmed note', async () => { // Three DIMs inside one critic panelist; each must surface independently // with its own name/score/note. The regex is /g so multiple matches in // the same body must all be yielded, not just the first. const body = ` low contrast on hero spacing reads ok clean `; const events = await collect(parseV1(oneChunk(wrapRound1(body)), opts())); const dims = events.filter((e) => e.type === 'panelist_dim' && e.role === 'critic'); expect(dims).toHaveLength(3); const names = dims.map((d) => (d.type === 'panelist_dim' ? d.dimName : '')); expect(names).toEqual(['contrast', 'hierarchy', 'typography']); // Note must be .trim()'d so the second DIM's surrounding whitespace is gone. const second = dims[1]; expect(second?.type).toBe('panelist_dim'); if (second?.type === 'panelist_dim') { expect(second.dimNote).toBe('spacing reads ok'); expect(second.dimScore).toBe(7); } }); it('emits panelist_dim with the round captured from the enclosing ', async () => { // The dim event must carry the round number from the open ROUND envelope, // not a default or a re-derived value. Round 1 here. const body = ` ok `; const events = await collect(parseV1(oneChunk(wrapRound1(body)), opts())); const dim = events.find((e) => e.type === 'panelist_dim' && e.role === 'brand'); expect(dim?.type).toBe('panelist_dim'); if (dim?.type === 'panelist_dim') expect(dim.round).toBe(1); }); }); describe('parseV1 -- MUST_FIX_RE flag', () => { it('emits a panelist_must_fix event for each block with trimmed text', async () => { // Two must-fix entries plus a DIM in the same body. The must-fix events // are independent of the dim events and both must surface. const body = ` ok hero buttons fail WCAG AA nav lacks landmark `; const events = await collect(parseV1(oneChunk(wrapRound1(body)), opts())); const fixes = events.filter( (e) => e.type === 'panelist_must_fix' && e.role === 'a11y', ); expect(fixes).toHaveLength(2); if (fixes[0]?.type === 'panelist_must_fix') { expect(fixes[0].text).toBe('hero buttons fail WCAG AA'); expect(fixes[0].round).toBe(1); } if (fixes[1]?.type === 'panelist_must_fix') { expect(fixes[1].text).toBe('nav lacks landmark'); } }); it('emits zero panelist_must_fix events when the body has none', async () => { const body = ` ok`; const events = await collect(parseV1(oneChunk(wrapRound1(body)), opts())); expect(events.filter((e) => e.type === 'panelist_must_fix')).toHaveLength(0); }); }); describe('parseV1 -- score clamping against scoreScale', () => { it('clamps a negative panelist score to 0 and emits a score_clamped warning', async () => { // Negative scores are out of [0, scale]; the parser clamps to 0 and emits // a parser_warning so downstream consumers know composite math used the // clamped value. const body = ` bad `; const events = await collect(parseV1(oneChunk(wrapRound1(body)), opts())); const close = events.find( (e) => e.type === 'panelist_close' && e.role === 'critic', ); expect(close?.type).toBe('panelist_close'); if (close?.type === 'panelist_close') expect(close.score).toBe(0); const warnings = events.filter( (e) => e.type === 'parser_warning' && e.kind === 'score_clamped', ); // At least one warning each for the panelist score and the dim score. expect(warnings.length).toBeGreaterThanOrEqual(2); }); it('clamps a panelist score that exceeds the declared scale and surfaces a warning', async () => { // scoreScale comes from ; anything above 10 must // clamp to 10 regardless of how large the raw value is. const body = ` ok`; const events = await collect(parseV1(oneChunk(wrapRound1(body)), opts())); const close = events.find( (e) => e.type === 'panelist_close' && e.role === 'critic', ); expect(close?.type).toBe('panelist_close'); if (close?.type === 'panelist_close') expect(close.score).toBe(10); expect( events.find( (e) => e.type === 'parser_warning' && e.kind === 'score_clamped', ), ).toBeDefined(); }); it('treats a non-numeric panelist score as 0 (NaN is out of range)', async () => { // Number("not-a-number") is NaN. isOutOfRange returns true for !isFinite, // and clampScore returns 0 for !isFinite, so the final score is 0. const body = ` ok`; const events = await collect(parseV1(oneChunk(wrapRound1(body)), opts())); const close = events.find( (e) => e.type === 'panelist_close' && e.role === 'critic', ); expect(close?.type).toBe('panelist_close'); if (close?.type === 'panelist_close') expect(close.score).toBe(0); }); }); describe('parseV1 -- byte cap (parserMaxBlockBytes) enforcement', () => { it('throws OversizeBlockError when a complete PANELIST block arrives over the cap', async () => { // Per-block check inside drain catches a complete oversized block before // its body is sliced and emitted; without it the events would leak past // the cap and the post-drain buf check would only catch unclosed runaways. const cap = 2048; const giant = 'x'.repeat(cap + 1024); const body = ` ${giant}ok`; await expect( collect(parseV1(oneChunk(wrapRound1(body)), opts({ parserMaxBlockBytes: cap }))), ).rejects.toBeInstanceOf(OversizeBlockError); }); it('throws OversizeBlockError when an unclosed buffer exceeds the cap', async () => { // No closing , so the per-block guard never fires; the post- // drain bufBytes check is the only line of defense and must trip. const cap = 1024; const giant = 'q'.repeat(cap + 512); const stream = ` ${giant}`; await expect( collect(parseV1(oneChunk(stream), opts({ parserMaxBlockBytes: cap }))), ).rejects.toBeInstanceOf(OversizeBlockError); }); it('measures bytes in UTF-8 so multibyte content cannot bypass the cap by JS string length', async () => { // Each CJK char is 3 UTF-8 bytes. 1500 chars = 4500 bytes > 4096 cap, // but JS string length is 1500, well under the cap. The byte-aware check // must reject; a length-based check would let this through. const cap = 4096; const giant = '汉'.repeat(1500); const body = ` ${giant}ok`; await expect( collect(parseV1(oneChunk(wrapRound1(body)), opts({ parserMaxBlockBytes: cap }))), ).rejects.toBeInstanceOf(OversizeBlockError); }); }); describe('parseV1 -- SHIP guard emission and envelope rules', () => { it('emits exactly one ship event with status, round, summary, and artifactRef', async () => { const stream = wrapRound1( ` ok ok ok ok`, ); const events = await collect( parseV1(oneChunk(stream), opts({ projectId: 'pid', artifactId: 'aid' })), ); const ships = events.filter((e) => e.type === 'ship'); expect(ships).toHaveLength(1); const ship = ships[0]; if (ship?.type === 'ship') { expect(ship.status).toBe('shipped'); expect(ship.round).toBe(1); expect(ship.summary).toBe('done'); // artifactRef must come straight from the parser options, not from the // attrs or anywhere else. expect(ship.artifactRef.projectId).toBe('pid'); expect(ship.artifactRef.artifactId).toBe('aid'); } }); it('invokes onArtifact with the round, mime, and decoded body before the ship event yields', async () => { // The side-channel callback must fire so the orchestrator can persist // bytes to disk before any consumer reacts to the ship event. const stream = wrapRound1( ` ok ok ok ok`, ); const captured: Array<{ round: number; mime: string; body: string }> = []; const events = await collect( parseV1(oneChunk(stream), opts({ onArtifact: (p) => captured.push(p) })), ); expect(captured).toHaveLength(1); expect(captured[0]?.round).toBe(1); expect(captured[0]?.mime).toBe('text/html'); expect(captured[0]?.body).toBe('

final

'); // And the ship event must still be yielded after the callback fired. expect(events.find((e) => e.type === 'ship')).toBeDefined(); }); it('throws MalformedBlockError when SHIP arrives before any ROUND_END closes', async () => { // SHIP must not arrive before at least one round has completed, otherwise // the round-1 designer-artifact invariant could be bypassed. const stream = ` v1 v1

]]>
final

]]>
premature
`; await expect( collect(parseV1(oneChunk(stream), opts())), ).rejects.toBeInstanceOf(MalformedBlockError); }); it('emits a duplicate_ship parser_warning on the second SHIP and keeps the first', async () => { // Two complete SHIP blocks. shipSeen flips on the first; the second is // surfaced as a warning at its position and skipped (no second ship event). const stream = wrapRound1( ` ok ok ok ok`, ).replace( '
', ` second

]]>
second
`, ); const events = await collect(parseV1(oneChunk(stream), opts())); expect(events.filter((e) => e.type === 'ship')).toHaveLength(1); expect( events.find( (e) => e.type === 'parser_warning' && e.kind === 'duplicate_ship', ), ).toBeDefined(); }); }); describe('parseV1 -- multi-round streaming accumulation', () => { it('emits identical event sequences (modulo parser_warning) for 1, 7, and all-at-once chunkings', async () => { // Streaming parsers must accumulate state across chunk boundaries. Vary // the chunk size aggressively and compare. parser_warning carries a // position that depends on chunk timing, so strip it before comparing. const stream = wrapRound1( ` ok ok ok ok`, ); const strip = (xs: PanelEvent[]) => xs.filter((e) => e.type !== 'parser_warning'); const one = strip(await collect(parseV1(chunkify(stream, 1), opts()))); const seven = strip(await collect(parseV1(chunkify(stream, 7), opts()))); const whole = strip(await collect(parseV1(oneChunk(stream), opts()))); expect(one).toEqual(seven); expect(seven).toEqual(whole); }); it('handles chunk boundaries that cut inside attribute values and CDATA bodies', async () => { // Worst-case chunk boundaries: 3 bytes per chunk slices through tag names, // attribute strings, and CDATA markers. The parser must wait for more bytes // (the `<` fallthrough in drain) and never emit a partial event. const stream = wrapRound1( ` ok ok ok ok`, ); const events = await collect(parseV1(chunkify(stream, 3), opts())); expect(events.filter((e) => e.type === 'panelist_open')).toHaveLength(5); expect(events.filter((e) => e.type === 'panelist_close')).toHaveLength(5); expect(events.filter((e) => e.type === 'ship')).toHaveLength(1); }); it('counts roundsClosed across multiple envelopes before SHIP', async () => { // Two rounds close before SHIP. SHIP must succeed (roundsClosed > 0) and // emit a single ship event tied to round 2. const stream = ` v1 v1

]]>
low iterate
v2 notes only strong ok final

]]>
two rounds in
`; const events = await collect(parseV1(chunkify(stream, 16), opts())); expect(events.filter((e) => e.type === 'round_end')).toHaveLength(2); const ship = events.find((e) => e.type === 'ship'); if (ship?.type === 'ship') expect(ship.round).toBe(2); }); }); describe('parseV1 -- unknown / garbage input', () => { it('emits parser_warning with kind=unknown_role for an unrecognized role and does not throw', async () => { // KNOWN_ROLES is closed at {designer, critic, brand, a11y, copy}. An // unknown role surfaces as a parser_warning and the block is skipped // without panelist_open / panelist_close events. const body = ` ok ok ok ok ok`; const events = await collect(parseV1(oneChunk(wrapRound1(body)), opts())); expect( events.find( (e) => e.type === 'parser_warning' && e.kind === 'unknown_role', ), ).toBeDefined(); // The phantom role contributes no open/close pair. const openRoles = events .filter((e) => e.type === 'panelist_open') .map((e) => (e.type === 'panelist_open' ? e.role : '')); expect(openRoles).not.toContain('phantom'); }); it('returns no events for an empty stream and never throws (no run started)', async () => { const events = await collect(parseV1(oneChunk(''), opts())); expect(events).toEqual([]); }); it('emits run_started but throws MalformedBlockError when the run never closes', async () => { // A stream that opens CRITIQUE_RUN but never reaches or // is a producer-side bug; the parser must surface it at end-of- // stream rather than swallow it silently. const stream = ` `; await expect( collect(parseV1(oneChunk(stream), opts())), ).rejects.toBeInstanceOf(MalformedBlockError); }); it('throws MalformedBlockError on a stray non-whitespace character inside CRITIQUE_RUN', async () => { // Once inside the envelope, the only legal top-level characters are // whitespace, `<` (start of a tag, possibly partial), or one of the // recognized tag openers. A bare letter triggers the malformed guard. const stream = ` garbage`; await expect( collect(parseV1(oneChunk(stream), opts())), ).rejects.toBeInstanceOf(MalformedBlockError); }); it('throws MissingArtifactError when round 1 closes without a designer ARTIFACT', async () => { // Round 1 designer must emit exactly one . NOTES-only is an // illegal stream for round 1 even if every other panelist scored fine. const stream = ` no artifact this round ok ok `; await expect( collect(parseV1(oneChunk(stream), opts())), ).rejects.toBeInstanceOf(MissingArtifactError); }); }); describe('parseV1 helpers -- extractArtifactBlock', () => { it('returns null when there is no opener', () => { expect(extractArtifactBlock('none')).toBeNull(); }); it('extracts an inline (non-CDATA) body and reports blockEnd just past ', () => { const inner = `

hi

x`; const result = extractArtifactBlock(inner); expect(result).not.toBeNull(); if (result) { expect(result.body).toBe('

hi

'); expect(result.attrText.trim()).toBe('mime="text/html"'); // The slice that starts at blockEnd should begin with the SUMMARY tag. expect(inner.slice(result.blockEnd).startsWith('')).toBe(true); } }); it('strips the CDATA wrapper and preserves a literal inside the body', () => { const inner = `const s = "";]]>`; const result = extractArtifactBlock(inner); expect(result).not.toBeNull(); if (result) { expect(result.body).toBe(``); } }); it('returns null when CDATA opens but never terminates', () => { const inner = `unterminated`; expect(extractArtifactBlock(inner)).toBeNull(); }); }); describe('parseV1 helpers -- indexOfOutsideCdata', () => { it('finds the needle when no CDATA span is in the way', () => { expect(indexOfOutsideCdata('hello world', '')).toBe(6); }); it('skips a needle that appears inside and finds the next one outside', () => { const src = ` me ]]> real `; const idx = indexOfOutsideCdata(src, ''); expect(idx).toBe(src.lastIndexOf('')); }); it('returns -1 when the only needle is inside an unterminated CDATA span', () => { expect(indexOfOutsideCdata('', '')).toBe(-1); }); it('honors the startOffset argument and does not search before it', () => { expect(indexOfOutsideCdata(' later ', '', 3)).toBe(13); }); });