Skip to content

Commit c096a7d

Browse files
committed
test(test-utils): cover race with an in-memory harness
`race` had no tests of its own. Every claim about it was inferred from contract specs that also exercised a real ledger, so an ordering bug in the scenario would have surfaced as a confusing contract-level failure, if at all. A recording harness pins the parts that are backend-independent: both calls built against one snapshot, the first landed before the second is attempted, the verdict and its reason passed through, and a throw from any phase tagged with that phase rather than scored as an outcome. What is left for a backend to get right is replay, which needs real state.
1 parent fc54aea commit c096a7d

1 file changed

Lines changed: 205 additions & 0 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/**
2+
* What `race` guarantees about the scenario it runs, independent of a backend.
3+
*
4+
* The claims are ordering and plumbing: both calls are built against ONE
5+
* snapshot, the first lands before the second is attempted, and the attempt's
6+
* verdict reaches the caller intact. A recording harness pins all of that
7+
* without a ledger, so a real backend only has to be correct about replay.
8+
*/
9+
10+
import { describe, expect, it } from 'vitest';
11+
import { race } from '../race.js';
12+
import type { Attempt, Call, ConcurrencyHarness, Pending } from '../types.js';
13+
14+
// ---------------------------------------------------------------------------
15+
// A harness that records instead of executing
16+
// ---------------------------------------------------------------------------
17+
18+
/** The snapshot handle. Identity is the whole point, so an empty object serves. */
19+
type Snapshot = { readonly tag: 'snapshot' };
20+
21+
interface Recorder {
22+
readonly harness: ConcurrencyHarness<Snapshot>;
23+
/** Method names in call order. */
24+
readonly steps: string[];
25+
/** The snapshot each `build` was handed. */
26+
readonly builtOn: Snapshot[];
27+
readonly landed: Pending[];
28+
readonly attempted: Pending[];
29+
}
30+
31+
const recorder = (
32+
attempt: Attempt,
33+
overrides: Partial<ConcurrencyHarness<Snapshot>> = {},
34+
): Recorder => {
35+
const snapshot: Snapshot = { tag: 'snapshot' };
36+
const steps: string[] = [];
37+
const builtOn: Snapshot[] = [];
38+
const landed: Pending[] = [];
39+
const attempted: Pending[] = [];
40+
41+
const harness: ConcurrencyHarness<Snapshot> = {
42+
async snapshot() {
43+
steps.push('snapshot');
44+
return snapshot;
45+
},
46+
async build<R>(call: Call, at: Snapshot): Promise<Pending<R>> {
47+
steps.push(`build:${call.actor}.${call.circuitId}`);
48+
builtOn.push(at);
49+
return { call, result: undefined as R };
50+
},
51+
async apply<R>(): Promise<R> {
52+
throw new Error('apply: not used by race');
53+
},
54+
async land(pending: Pending) {
55+
steps.push('land');
56+
landed.push(pending);
57+
},
58+
async attempt(pending: Pending): Promise<Attempt> {
59+
steps.push('attempt');
60+
attempted.push(pending);
61+
return attempt;
62+
},
63+
...overrides,
64+
};
65+
66+
return { harness, steps, builtOn, landed, attempted };
67+
};
68+
69+
const alice: Call = { actor: 'alice', circuitId: 'transfer', args: [] };
70+
const bob: Call = { actor: 'bob', circuitId: 'burn', args: [] };
71+
72+
// ---------------------------------------------------------------------------
73+
// The scenario
74+
// ---------------------------------------------------------------------------
75+
76+
describe('race: the scenario', () => {
77+
it('should build both calls against one snapshot', async () => {
78+
// The whole method rests on this: two builds against DIFFERENT states are
79+
// sequential calls, and no conflict could arise.
80+
const rec = recorder({ outcome: 'landed' });
81+
82+
await race(rec.harness, alice, bob);
83+
84+
expect(rec.builtOn).toHaveLength(2);
85+
expect(rec.builtOn[0]).toBe(rec.builtOn[1]);
86+
});
87+
88+
it('should land the first call before attempting the second', async () => {
89+
const rec = recorder({ outcome: 'landed' });
90+
91+
await race(rec.harness, alice, bob);
92+
93+
expect(rec.steps).toStrictEqual([
94+
'snapshot',
95+
'build:alice.transfer',
96+
'build:bob.burn',
97+
'land',
98+
'attempt',
99+
]);
100+
});
101+
102+
it('should land the first call and attempt the second, not the reverse', async () => {
103+
const rec = recorder({ outcome: 'landed' });
104+
105+
await race(rec.harness, alice, bob);
106+
107+
expect(rec.landed[0]?.call).toBe(alice);
108+
expect(rec.attempted[0]?.call).toBe(bob);
109+
});
110+
});
111+
112+
// ---------------------------------------------------------------------------
113+
// The verdict
114+
// ---------------------------------------------------------------------------
115+
116+
describe('race: the verdict', () => {
117+
it('should report both landed when the second is accepted', async () => {
118+
const rec = recorder({ outcome: 'landed' });
119+
120+
expect(await race(rec.harness, alice, bob)).toStrictEqual({
121+
outcome: 'both-landed',
122+
});
123+
});
124+
125+
it('should pass the rejection reason through', async () => {
126+
// The reason is what lets a spec check the conflict was the one it set up.
127+
const rec = recorder({ outcome: 'rejected', reason: 'popeq mismatch' });
128+
129+
expect(await race(rec.harness, alice, bob)).toStrictEqual({
130+
outcome: 'second-rejected',
131+
reason: 'popeq mismatch',
132+
});
133+
});
134+
});
135+
136+
// ---------------------------------------------------------------------------
137+
// Failures that are not outcomes
138+
// ---------------------------------------------------------------------------
139+
140+
describe('race: phase failures', () => {
141+
it('should name the phase and keep the cause when a build throws', async () => {
142+
const boom = new Error('unknown actor');
143+
const rec = recorder(
144+
{ outcome: 'landed' },
145+
{
146+
build: async (call: Call) => {
147+
if (call.actor === 'bob') {
148+
throw boom;
149+
}
150+
return { call, result: undefined };
151+
},
152+
},
153+
);
154+
155+
try {
156+
await race(rec.harness, alice, bob);
157+
expect.unreachable('expected a throw');
158+
} catch (error) {
159+
expect((error as Error).message).toBe(
160+
"race: build of second call 'bob.burn' failed",
161+
);
162+
expect((error as Error).cause).toBe(boom);
163+
}
164+
});
165+
166+
it('should name the phase when landing the first call throws', async () => {
167+
const boom = new Error('replay refused');
168+
const rec = recorder(
169+
{ outcome: 'landed' },
170+
{
171+
land: async () => {
172+
throw boom;
173+
},
174+
},
175+
);
176+
177+
try {
178+
await race(rec.harness, alice, bob);
179+
expect.unreachable('expected a throw');
180+
} catch (error) {
181+
expect((error as Error).message).toBe(
182+
'race: landing the first call failed',
183+
);
184+
expect((error as Error).cause).toBe(boom);
185+
}
186+
});
187+
188+
// A rejection is a returned value; a throw out of `attempt` is a defect and
189+
// must not be scored as the outcome under test.
190+
it('should not score a throw from attempt as a rejection', async () => {
191+
const boom = new Error('harness broke');
192+
const rec = recorder(
193+
{ outcome: 'landed' },
194+
{
195+
attempt: async () => {
196+
throw boom;
197+
},
198+
},
199+
);
200+
201+
await expect(race(rec.harness, alice, bob)).rejects.toThrow(
202+
'race: attempting the second call failed',
203+
);
204+
});
205+
});

0 commit comments

Comments
 (0)