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);
});
});