Skip to content

Commit bfc464a

Browse files
authored
test(capture-kit): pin the ax1 digest wire format the daemon persists (#2941)
`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.
1 parent 21d959e commit bfc464a

1 file changed

Lines changed: 30 additions & 3 deletions

File tree

‎packages/capture-kit/src/snapshot/__tests__/ax-digest.test.ts‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,37 @@ test('digest for an empty node array is stable and reports zero nodes', () => {
6161
expect(result.digest).toBe(computeAxDigest([]).digest);
6262
});
6363

64-
test('digest is prefixed for forward-compatible versioning', () => {
65-
const result = computeAxDigest([{ type: 'button', label: 'Continue' }]);
64+
test('digest separates the tuple fields so text shifted across them cannot collide', () => {
65+
// `hashNode` joins (type, label, identifier) through NUL separators. Drop them and
66+
// ('ab', '', '') hashes the same bytes as ('a', 'b', '') — two different trees that
67+
// would report identical evidence.
68+
const labelShifted = computeAxDigest([{ type: 'ab', label: '' }]);
69+
const labelSplit = computeAxDigest([{ type: 'a', label: 'b' }]);
70+
const identifierSplit = computeAxDigest([{ type: 'a', label: '', identifier: 'b' }]);
71+
72+
expect(labelShifted.digest).not.toBe(labelSplit.digest);
73+
expect(labelShifted.digest).not.toBe(identifierSplit.digest);
74+
});
75+
76+
test('digest is a versioned fixed-width hex string', () => {
77+
const { digest } = computeAxDigest([{ type: 'button', label: 'Continue' }]);
78+
79+
expect(digest).toMatch(/^ax1:[0-9a-f]{16}$/);
80+
});
81+
82+
test('golden: the ax1 wire format is pinned to these bytes', () => {
83+
// A digest is persisted as post-action evidence and compared across daemon restarts
84+
// (#1047), so the XOR width, separators, and count folding are all wire contract.
85+
const nodes = [
86+
{ type: 'button', label: 'Continue', identifier: 'continue-btn' },
87+
{ type: 'text', label: 'Welcome' },
88+
{ type: 'image', label: 'Logo' },
89+
];
6690

67-
expect(result.digest.startsWith('ax1:')).toBe(true);
91+
expect(computeAxDigest(nodes)).toEqual({
92+
digest: 'ax1:0a88b2cbf989ae22',
93+
nodeCount: 3,
94+
});
6895
});
6996

7097
test('digest ignores volatile fields such as rects that are not part of the tuple', () => {

0 commit comments

Comments
 (0)