From c982439f8ce4a626677d8486682d4fde9c6a771b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 21:00:01 +0200 Subject: [PATCH] test(capture-kit): pin the ax1 digest wire format the daemon persists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `computeAxDigest` returns a ~50-byte string that is persisted as post-action evidence and compared across daemon restarts (#1047), yet nothing in the suite pinned the bytes. Every test but two compared one invocation against another, so a change to the construction moved both sides and the file stayed green. Proven by mutant: deleting the NUL separators from `hashNode` — which makes the tuple ('ab', '', '') hash the same bytes as ('a', 'b', ''), i.e. two different trees reporting identical evidence — leaves `ax-digest.test.ts` at 9 passed. It is the only cross-field collision guard in the function and no test could see it go. Three additions, each with a named mutant: - field separation: ('ab','','') vs ('a','b','') vs ('a','','b') must differ. Separator removal now fails 2 tests. - fixed width: `ax1:` + exactly 16 hex, replacing the `startsWith('ax1:')` probe, which a widened `slice()` or a dropped prefix would survive. - a golden for a fixed 3-node set, so XOR width, separator placement, and the count folding are all contract. Narrowing `PER_NODE_HASH_BYTES` to 8 fails only this one. Test-only: no production line changes. --- .../src/snapshot/__tests__/ax-digest.test.ts | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/capture-kit/src/snapshot/__tests__/ax-digest.test.ts b/packages/capture-kit/src/snapshot/__tests__/ax-digest.test.ts index bf739418dc..646bc13c84 100644 --- a/packages/capture-kit/src/snapshot/__tests__/ax-digest.test.ts +++ b/packages/capture-kit/src/snapshot/__tests__/ax-digest.test.ts @@ -61,10 +61,37 @@ test('digest for an empty node array is stable and reports zero nodes', () => { expect(result.digest).toBe(computeAxDigest([]).digest); }); -test('digest is prefixed for forward-compatible versioning', () => { - const result = computeAxDigest([{ type: 'button', label: 'Continue' }]); +test('digest separates the tuple fields so text shifted across them cannot collide', () => { + // `hashNode` joins (type, label, identifier) through NUL separators. Drop them and + // ('ab', '', '') hashes the same bytes as ('a', 'b', '') — two different trees that + // would report identical evidence. + const labelShifted = computeAxDigest([{ type: 'ab', label: '' }]); + const labelSplit = computeAxDigest([{ type: 'a', label: 'b' }]); + const identifierSplit = computeAxDigest([{ type: 'a', label: '', identifier: 'b' }]); + + expect(labelShifted.digest).not.toBe(labelSplit.digest); + expect(labelShifted.digest).not.toBe(identifierSplit.digest); +}); + +test('digest is a versioned fixed-width hex string', () => { + const { digest } = computeAxDigest([{ type: 'button', label: 'Continue' }]); + + expect(digest).toMatch(/^ax1:[0-9a-f]{16}$/); +}); + +test('golden: the ax1 wire format is pinned to these bytes', () => { + // A digest is persisted as post-action evidence and compared across daemon restarts + // (#1047), so the XOR width, separators, and count folding are all wire contract. + const nodes = [ + { type: 'button', label: 'Continue', identifier: 'continue-btn' }, + { type: 'text', label: 'Welcome' }, + { type: 'image', label: 'Logo' }, + ]; - expect(result.digest.startsWith('ax1:')).toBe(true); + expect(computeAxDigest(nodes)).toEqual({ + digest: 'ax1:0a88b2cbf989ae22', + nodeCount: 3, + }); }); test('digest ignores volatile fields such as rects that are not part of the tuple', () => {