Skip to content

Commit c6ed13e

Browse files
grypezclaude
andcommitted
test(kernel-utils): add e2e test for multi-candidate lift retry on handler failure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d4ab36e commit c6ed13e

1 file changed

Lines changed: 112 additions & 0 deletions

File tree

packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,3 +469,115 @@ describe('e2e: callable metadata — cost varies with invocation args', () => {
469469
expect(swapAFn).not.toHaveBeenCalled();
470470
});
471471
});
472+
473+
// ---------------------------------------------------------------------------
474+
// E2E: lift retry — first candidate throws, sheaf recovers to fallback
475+
// ---------------------------------------------------------------------------
476+
477+
describe('e2e: lift retry on handler failure', () => {
478+
it('recovers to next candidate when first throws, lift receives non-empty errors', async () => {
479+
type RouteMeta = { priority: number };
480+
481+
const primaryFn = vi.fn((_acct: string): number => {
482+
throw new Error('primary unavailable');
483+
});
484+
const fallbackFn = vi.fn((_acct: string): number => 99);
485+
486+
const sections: PresheafSection<RouteMeta>[] = [
487+
{
488+
exo: makeSection(
489+
'Primary',
490+
M.interface('Primary', {
491+
getBalance: M.call(M.string()).returns(M.number()),
492+
}),
493+
{ getBalance: primaryFn },
494+
),
495+
metadata: constant({ priority: 0 }),
496+
},
497+
{
498+
exo: makeSection(
499+
'Fallback',
500+
M.interface('Fallback', {
501+
getBalance: M.call(M.string()).returns(M.number()),
502+
}),
503+
{ getBalance: fallbackFn },
504+
),
505+
metadata: constant({ priority: 1 }),
506+
},
507+
];
508+
509+
// Track the error-array length the lift receives after each failed attempt.
510+
const errorCountsSeenByLift: number[] = [];
511+
const priorityFirst: Lift<RouteMeta> = async function* (germs) {
512+
const ordered = [...germs].sort(
513+
(a, b) => (a.metadata?.priority ?? 0) - (b.metadata?.priority ?? 0),
514+
);
515+
for (const germ of ordered) {
516+
const errors: unknown[] = yield germ;
517+
errorCountsSeenByLift.push(errors.length);
518+
}
519+
};
520+
521+
const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({
522+
lift: priorityFirst,
523+
});
524+
525+
const result = await E(wallet).getBalance('alice');
526+
527+
// fallback succeeded and both handlers were invoked
528+
expect(result).toBe(99);
529+
expect(primaryFn).toHaveBeenCalledWith('alice');
530+
expect(fallbackFn).toHaveBeenCalledWith('alice');
531+
532+
// after the primary failed the lift received an errors array with one entry
533+
expect(errorCountsSeenByLift).toHaveLength(1);
534+
expect(errorCountsSeenByLift[0]).toBe(1);
535+
});
536+
537+
it('throws accumulated errors when all candidates fail', async () => {
538+
type RouteMeta = { priority: number };
539+
540+
const sections: PresheafSection<RouteMeta>[] = [
541+
{
542+
exo: makeSection(
543+
'A',
544+
M.interface('A', {
545+
getBalance: M.call(M.string()).returns(M.number()),
546+
}),
547+
{
548+
getBalance: (_acct: string): number => {
549+
throw new Error('A failed');
550+
},
551+
},
552+
),
553+
metadata: constant({ priority: 0 }),
554+
},
555+
{
556+
exo: makeSection(
557+
'B',
558+
M.interface('B', {
559+
getBalance: M.call(M.string()).returns(M.number()),
560+
}),
561+
{
562+
getBalance: (_acct: string): number => {
563+
throw new Error('B failed');
564+
},
565+
},
566+
),
567+
metadata: constant({ priority: 1 }),
568+
},
569+
];
570+
571+
const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({
572+
async *lift(germs) {
573+
yield* [...germs].sort(
574+
(a, b) => (a.metadata?.priority ?? 0) - (b.metadata?.priority ?? 0),
575+
);
576+
},
577+
});
578+
579+
await expect(E(wallet).getBalance('alice')).rejects.toThrow(
580+
'No viable section',
581+
);
582+
});
583+
});

0 commit comments

Comments
 (0)