@r0hitsharma/http-client-msw 0.12.0-rohit-fork-ci.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/node.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Node entry: `@r0hitsharma/http-client-msw/node`.
3
+ *
4
+ * Kept behind its own subpath so an app bundle never resolves `msw/node`, whose
5
+ * interceptors patch node's http modules.
6
+ */
7
+
8
+ import { setupServer, type SetupServer } from 'msw/node';
9
+
10
+ import type { MockSetup } from './setup.js';
11
+
12
+ export type MockServerOptions = NonNullable<
13
+ Parameters<SetupServer['listen']>[0]
14
+ >;
15
+
16
+ export type MockServer = {
17
+ /** The underlying msw server, for `use()` and lifecycle events. */
18
+ server: SetupServer;
19
+ /** Starts interception. `onUnhandledRequest` defaults to `'error'`. */
20
+ listen: (overrides?: MockServerOptions) => void;
21
+ close: () => void;
22
+ /** Runs the setup's state resets, then drops runtime handler overrides. */
23
+ reset: () => void;
24
+ };
25
+
26
+ /**
27
+ * Serves a {@link MockSetup} from msw's node interceptors — the same handler
28
+ * array a browser worker serves.
29
+ *
30
+ * ```ts
31
+ * // src/test-setup.ts
32
+ * import { setupMockServer } from '@r0hitsharma/http-client-msw/node';
33
+ * import { afterAll, afterEach, beforeAll } from 'vitest';
34
+ *
35
+ * import { mocks } from './mocks';
36
+ *
37
+ * const mockServer = setupMockServer(mocks);
38
+ *
39
+ * beforeAll(() => mockServer.listen());
40
+ * afterEach(() => mockServer.reset());
41
+ * afterAll(() => mockServer.close());
42
+ * ```
43
+ *
44
+ * `onUnhandledRequest` defaults to `'error'` rather than msw's `'warn'`: in a
45
+ * test suite an unmocked request is a hole in the fixtures, and a warning that
46
+ * scrolls past is how a test ends up asserting against real network output.
47
+ */
48
+ export function setupMockServer(mocks: MockSetup): MockServer {
49
+ const server = setupServer(...mocks.handlers);
50
+
51
+ return {
52
+ server,
53
+ listen: (overrides) =>
54
+ server.listen({ onUnhandledRequest: 'error', ...overrides }),
55
+ close: () => server.close(),
56
+ reset: () => {
57
+ mocks.resetState();
58
+ server.resetHandlers();
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createSeededRng } from './seeded-rng.js';
4
+
5
+ const take = (count: number, seed: number): number[] => {
6
+ const rng = createSeededRng(seed);
7
+
8
+ return Array.from({ length: count }, () => rng.next());
9
+ };
10
+
11
+ describe('createSeededRng', () => {
12
+ it('produces values in [0, 1)', () => {
13
+ for (const value of take(200, 7)) {
14
+ expect(value).toBeGreaterThanOrEqual(0);
15
+ expect(value).toBeLessThan(1);
16
+ }
17
+ });
18
+
19
+ it('is deterministic for a seed', () => {
20
+ expect(take(20, 42)).toEqual(take(20, 42));
21
+ });
22
+
23
+ it('differs between seeds', () => {
24
+ expect(take(20, 42)).not.toEqual(take(20, 43));
25
+ });
26
+
27
+ it('does not immediately repeat itself', () => {
28
+ const values = take(200, 1);
29
+
30
+ expect(new Set(values).size).toBe(values.length);
31
+ });
32
+
33
+ it('rewinds to the seed on reset', () => {
34
+ const rng = createSeededRng(99);
35
+ const first = [rng.next(), rng.next(), rng.next()];
36
+
37
+ rng.reset();
38
+
39
+ expect([rng.next(), rng.next(), rng.next()]).toEqual(first);
40
+ });
41
+
42
+ it('draws inclusive integers', () => {
43
+ const rng = createSeededRng(5);
44
+ const drawn = new Set(Array.from({ length: 300 }, () => rng.int(1, 3)));
45
+
46
+ expect([...drawn].sort()).toEqual([1, 2, 3]);
47
+ });
48
+
49
+ it('picks a member of the list', () => {
50
+ const rng = createSeededRng(5);
51
+ const items = ['a', 'b', 'c'] as const;
52
+
53
+ for (let i = 0; i < 50; i++) {
54
+ expect(items).toContain(rng.pick(items));
55
+ }
56
+ });
57
+
58
+ it('refuses to pick from an empty list', () => {
59
+ expect(() => createSeededRng(1).pick([])).toThrow(
60
+ /cannot pick from an empty list/,
61
+ );
62
+ });
63
+
64
+ it('shuffles a copy, deterministically, keeping every element', () => {
65
+ const items = [1, 2, 3, 4, 5, 6, 7, 8];
66
+ const shuffled = createSeededRng(3).shuffle(items);
67
+
68
+ expect(items).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
69
+ expect([...shuffled].sort((a, b) => a - b)).toEqual(items);
70
+ expect(shuffled).not.toEqual(items);
71
+ expect(shuffled).toEqual(createSeededRng(3).shuffle(items));
72
+ });
73
+
74
+ it('picks an `undefined` element rather than reporting an empty list', () => {
75
+ // `T` may include `undefined`. Guarding on the read instead of on the
76
+ // length turned a legal pick into a spurious "empty list" throw.
77
+ expect(createSeededRng(5).pick([undefined])).toBeUndefined();
78
+ });
79
+
80
+ it('shuffles by position, not by value', () => {
81
+ // The swap must not be skipped for `undefined` elements: the draw is
82
+ // consumed either way, so skipping would make the ordering depend on the
83
+ // values and break the reproducibility this generator exists to provide.
84
+ const withHoles = [1, undefined, 3, undefined, 5, 6, 7, 8];
85
+ const positions = [1, 2, 3, 4, 5, 6, 7, 8];
86
+
87
+ const shuffledHoles = createSeededRng(3).shuffle(withHoles);
88
+ const shuffledPositions = createSeededRng(3).shuffle(positions);
89
+
90
+ // Same seed, same length → the same permutation of indices, whatever the
91
+ // elements are.
92
+ expect(shuffledHoles).toEqual(
93
+ shuffledPositions.map((p) => withHoles[p - 1]),
94
+ );
95
+ });
96
+
97
+ it('gives the same sequence to a reset instance and a fresh one', () => {
98
+ const reused = createSeededRng(11);
99
+ void reused.next();
100
+ reused.reset();
101
+
102
+ expect(reused.shuffle([1, 2, 3, 4])).toEqual(
103
+ createSeededRng(11).shuffle([1, 2, 3, 4]),
104
+ );
105
+ });
106
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Deterministic randomness for generated fixtures. `Math.random` in a mock makes
3
+ * a failing test unreproducible and a visual snapshot unstable; a seeded
4
+ * generator gives varied-looking data that is identical on every run and on every
5
+ * machine.
6
+ */
7
+
8
+ export type SeededRng = {
9
+ /** The next value in `[0, 1)`. */
10
+ next: () => number;
11
+ /**
12
+ * An integer in `[min, max]`, both inclusive. Bounds are trusted: `max` below
13
+ * `min` returns a value outside either, unchecked.
14
+ */
15
+ int: (min: number, max: number) => number;
16
+ /** One element. Throws on an empty list. */
17
+ pick: <T>(items: readonly T[]) => T;
18
+ /** A shuffled copy; the input is left alone. */
19
+ shuffle: <T>(items: readonly T[]) => T[];
20
+ /** Rewinds to the seed. Pass this to `setupMocks({ onReset })`. */
21
+ reset: () => void;
22
+ };
23
+
24
+ /**
25
+ * A mulberry32 generator — 32 bits of state, a handful of integer ops, no
26
+ * dependency. Its statistical quality is irrelevant here; reproducibility and
27
+ * being cheap enough to call inside a request handler are the requirements.
28
+ */
29
+ export function createSeededRng(seed: number): SeededRng {
30
+ let state = seed >>> 0;
31
+
32
+ const next = (): number => {
33
+ state = (state + 0x6d2b79f5) >>> 0;
34
+ let t = state;
35
+ t = Math.imul(t ^ (t >>> 15), t | 1);
36
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
37
+
38
+ return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
39
+ };
40
+
41
+ const int = (min: number, max: number): number =>
42
+ min + Math.floor(next() * (max - min + 1));
43
+
44
+ return {
45
+ next,
46
+ int,
47
+ pick: <T>(items: readonly T[]): T => {
48
+ // Guard on length, not on the read being `undefined`: `T` may itself
49
+ // include `undefined`, and `pick([undefined, 1])` must return the element
50
+ // rather than report an empty list.
51
+ if (items.length === 0) {
52
+ throw new Error('http-client-msw: cannot pick from an empty list');
53
+ }
54
+
55
+ return items[int(0, items.length - 1)] as T;
56
+ },
57
+ shuffle: <T>(items: readonly T[]): T[] => {
58
+ const shuffled = [...items];
59
+
60
+ for (let i = shuffled.length - 1; i > 0; i--) {
61
+ const j = int(0, i);
62
+ // Both indices are in range by the loop bound, so the swap is
63
+ // unconditional — skipping it on an `undefined` *element* would let the
64
+ // result depend on the values while still consuming the draw above,
65
+ // which is exactly the reproducibility this generator exists to give.
66
+ const a = shuffled[i] as T;
67
+ shuffled[i] = shuffled[j] as T;
68
+ shuffled[j] = a;
69
+ }
70
+
71
+ return shuffled;
72
+ },
73
+ reset: () => {
74
+ state = seed >>> 0;
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,137 @@
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { createMockApi } from './mock-api.js';
4
+ import { createMockStore } from './mock-store.js';
5
+ import { setupMockServer } from './node.js';
6
+ import { createSeededRng } from './seeded-rng.js';
7
+ import { setupMocks } from './setup.js';
8
+ import { type Thing, type TestPaths, seedThings } from './test-fixtures.js';
9
+
10
+ const mock = createMockApi<TestPaths>({ baseUrl: '/api' });
11
+
12
+ describe('setupMocks', () => {
13
+ it('runs reset callbacks in declaration order', () => {
14
+ const calls: string[] = [];
15
+ const mocks = setupMocks([], {
16
+ onReset: [() => calls.push('first'), () => calls.push('second')],
17
+ });
18
+
19
+ mocks.resetState();
20
+ mocks.resetState();
21
+
22
+ expect(calls).toEqual(['first', 'second', 'first', 'second']);
23
+ });
24
+
25
+ it('is inert without reset callbacks', () => {
26
+ const handler = mock.get('/things', ({ response }) =>
27
+ response(200).json([]),
28
+ );
29
+
30
+ expect(() => setupMocks([handler]).resetState()).not.toThrow();
31
+ });
32
+
33
+ it('re-seeds identically when an rng feeding a store is rewound first', () => {
34
+ const rng = createSeededRng(1337);
35
+ const sized = createMockStore(() =>
36
+ seedThings().map((thing) => ({ ...thing, size: rng.int(1, 100) })),
37
+ );
38
+ const sizes = (): number[] => sized.list().map((thing) => thing.size);
39
+
40
+ const ordered = setupMocks([], { onReset: [rng.reset, sized.reset] });
41
+ const initial = sizes();
42
+
43
+ ordered.resetState();
44
+ expect(sizes()).toEqual(initial);
45
+ ordered.resetState();
46
+ expect(sizes()).toEqual(initial);
47
+ });
48
+
49
+ it('drifts when the store is rewound before the rng it draws from', () => {
50
+ // The failure mode the option's docs warn about, pinned so the ordering
51
+ // advice cannot quietly stop being true.
52
+ const rng = createSeededRng(1337);
53
+ const sized = createMockStore(() =>
54
+ seedThings().map((thing) => ({ ...thing, size: rng.int(1, 100) })),
55
+ );
56
+ const sizes = (): number[] => sized.list().map((thing) => thing.size);
57
+
58
+ const misordered = setupMocks([], { onReset: [sized.reset, rng.reset] });
59
+ const initial = sizes();
60
+
61
+ misordered.resetState();
62
+
63
+ expect(sizes()).not.toEqual(initial);
64
+ });
65
+ });
66
+
67
+ describe('setupMockServer', () => {
68
+ /** Mutable state a handler writes to, standing in for a mock store. */
69
+ let things: Thing[] = seedThings();
70
+ const mocks = setupMocks(
71
+ [
72
+ mock.get('/things', ({ response }) => response(200).json(things)),
73
+ mock.post('/things', async ({ request, response }) => {
74
+ const created = {
75
+ id: `t${things.length + 1}`,
76
+ ...(await request.json()),
77
+ };
78
+ things = [...things, created];
79
+
80
+ return response(201).json(created);
81
+ }),
82
+ ],
83
+ { onReset: [() => (things = seedThings())] },
84
+ );
85
+
86
+ const mockServer = setupMockServer(mocks);
87
+
88
+ beforeAll(() => mockServer.listen());
89
+ afterAll(() => mockServer.close());
90
+
91
+ const list = async (): Promise<Thing[]> =>
92
+ (await (await fetch('http://localhost/api/things')).json()) as Thing[];
93
+
94
+ it('serves the setup handlers', async () => {
95
+ expect(await list()).toEqual(seedThings());
96
+ });
97
+
98
+ it('reset() clears state written by a previous test', async () => {
99
+ await fetch('http://localhost/api/things', {
100
+ method: 'POST',
101
+ headers: { 'Content-Type': 'application/json' },
102
+ body: JSON.stringify({ name: 'Third', size: 3 }),
103
+ });
104
+ expect(await list()).toHaveLength(3);
105
+
106
+ mockServer.reset();
107
+
108
+ expect(await list()).toEqual(seedThings());
109
+ });
110
+
111
+ it('reset() also drops runtime handler overrides', async () => {
112
+ mockServer.server.use(
113
+ mock.get('/things', ({ response }) =>
114
+ response(500).json({ message: 'x' }),
115
+ ),
116
+ );
117
+ expect((await fetch('http://localhost/api/things')).status).toBe(500);
118
+
119
+ mockServer.reset();
120
+
121
+ expect((await fetch('http://localhost/api/things')).status).toBe(200);
122
+ });
123
+
124
+ it('errors on an unhandled request by default', async () => {
125
+ await expect(fetch('http://localhost/api/unmocked')).rejects.toThrow();
126
+ });
127
+
128
+ it('takes a listen override for the unhandled-request strategy', () => {
129
+ const listen = vi.fn();
130
+ const overridden = setupMockServer(mocks);
131
+ overridden.server.listen = listen;
132
+
133
+ overridden.listen({ onUnhandledRequest: 'bypass' });
134
+
135
+ expect(listen).toHaveBeenCalledWith({ onUnhandledRequest: 'bypass' });
136
+ });
137
+ });
package/src/setup.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type { RequestHandler, WebSocketHandler } from 'msw';
2
+
3
+ /**
4
+ * Anything msw accepts as a handler. Typed handlers from `createMockApi` are
5
+ * `RequestHandler`s; the union leaves room for a websocket mock in the same
6
+ * array without a second setup path.
7
+ */
8
+ export type MockSetupHandler = RequestHandler | WebSocketHandler;
9
+
10
+ /**
11
+ * A callback that returns mock state to its seeded starting point. Stores built
12
+ * with `createMockStore` expose exactly this as `store.reset`.
13
+ */
14
+ export type MockResetCallback = () => void;
15
+
16
+ export type MockSetupOptions = {
17
+ /**
18
+ * Callbacks that restore mock state, run **in declaration order** by `reset()`
19
+ * on whichever entry serves these handlers. This is how a stateful mock stops
20
+ * leaking writes from one test into the next: the handlers close over a store,
21
+ * and the store's `reset` is declared here once rather than at every call site.
22
+ *
23
+ * Order matters when one reset feeds another. A store whose seed function draws
24
+ * from a seeded rng must be listed *after* that rng, or each reset re-seeds
25
+ * from wherever the previous one left the sequence.
26
+ */
27
+ onReset?: readonly MockResetCallback[];
28
+ };
29
+
30
+ /**
31
+ * An environment-neutral description of a mock deployment: the handler array
32
+ * plus the state resets that belong with it. It holds no msw setup object of its
33
+ * own, which is what lets the same value be handed to the browser entry
34
+ * (`setupWorker`) or the node entry (`setupServer`) without either environment's
35
+ * msw import reaching the other's bundle.
36
+ */
37
+ export type MockSetup = {
38
+ handlers: readonly MockSetupHandler[];
39
+ /** Runs every `onReset` callback in declaration order. */
40
+ resetState: () => void;
41
+ };
42
+
43
+ /**
44
+ * Bundles handlers with their state resets.
45
+ *
46
+ * ```ts
47
+ * const rng = createSeededRng(42);
48
+ * const things = createMockStore(() => [
49
+ * { id: 't1', name: 'First', size: rng.int(1, 100) },
50
+ * ]);
51
+ *
52
+ * export const mocks = setupMocks(
53
+ * [mock.get('/things', ({ response }) => response(200).json(things.list()))],
54
+ * // The rng first: the store's seed draws from it.
55
+ * { onReset: [rng.reset, things.reset] },
56
+ * );
57
+ * ```
58
+ */
59
+ export function setupMocks(
60
+ handlers: readonly MockSetupHandler[],
61
+ options: MockSetupOptions = {},
62
+ ): MockSetup {
63
+ const onReset = options.onReset ?? [];
64
+
65
+ return {
66
+ handlers,
67
+ resetState: () => {
68
+ for (const reset of onReset) reset();
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Test-only fixtures. Excluded from `tsconfig.build.json` alongside the specs,
3
+ * so nothing here ships, but still covered by `type:check` — which matters,
4
+ * because `TestPaths` mirrors `openapi-typescript` output closely enough (every
5
+ * HTTP method present, absent ones as `?: never`, no-content responses as
6
+ * `content?: never`) to prove the handler factory's inference against a real
7
+ * generated `paths` type.
8
+ */
9
+
10
+ export type Thing = { id: string; name: string; size: number };
11
+ export type ApiFault = { message: string };
12
+ export type ValidationFault = { message: string; field: string };
13
+
14
+ type NoBody = { requestBody?: never };
15
+ type NoParams = {
16
+ query?: never;
17
+ header?: never;
18
+ path?: never;
19
+ cookie?: never;
20
+ };
21
+
22
+ export type TestPaths = {
23
+ '/things': {
24
+ parameters: NoParams;
25
+ get: NoBody & {
26
+ parameters: {
27
+ query?: { limit?: number; tag?: string[] };
28
+ header?: never;
29
+ path?: never;
30
+ cookie?: never;
31
+ };
32
+ responses: {
33
+ 200: {
34
+ headers: Record<string, unknown>;
35
+ content: { 'application/json': Thing[] };
36
+ };
37
+ 500: {
38
+ headers: Record<string, unknown>;
39
+ content: { 'application/json': ApiFault };
40
+ };
41
+ };
42
+ };
43
+ post: {
44
+ parameters: NoParams;
45
+ requestBody: {
46
+ content: { 'application/json': { name: string; size: number } };
47
+ };
48
+ responses: {
49
+ 201: {
50
+ headers: Record<string, unknown>;
51
+ content: { 'application/json': Thing };
52
+ };
53
+ 422: {
54
+ headers: Record<string, unknown>;
55
+ content: { 'application/json': ValidationFault };
56
+ };
57
+ };
58
+ };
59
+ put?: never;
60
+ delete?: never;
61
+ options?: never;
62
+ head?: never;
63
+ patch?: never;
64
+ trace?: never;
65
+ };
66
+ '/things/{id}': {
67
+ parameters: NoParams;
68
+ get: NoBody & {
69
+ parameters: {
70
+ query?: { expand?: string };
71
+ header?: never;
72
+ path: { id: string };
73
+ cookie?: never;
74
+ };
75
+ responses: {
76
+ 200: {
77
+ headers: Record<string, unknown>;
78
+ content: { 'application/json': Thing };
79
+ };
80
+ 404: {
81
+ headers: Record<string, unknown>;
82
+ content: { 'application/json': ApiFault };
83
+ };
84
+ };
85
+ };
86
+ delete: NoBody & {
87
+ parameters: {
88
+ query?: never;
89
+ header?: never;
90
+ path: { id: string };
91
+ cookie?: never;
92
+ };
93
+ responses: {
94
+ 204: { headers: Record<string, unknown>; content?: never };
95
+ 404: {
96
+ headers: Record<string, unknown>;
97
+ content: { 'application/json': ApiFault };
98
+ };
99
+ };
100
+ };
101
+ put?: never;
102
+ post?: never;
103
+ options?: never;
104
+ head?: never;
105
+ patch?: never;
106
+ trace?: never;
107
+ };
108
+ };
109
+
110
+ export const seedThings = (): Thing[] => [
111
+ { id: 't1', name: 'First', size: 1 },
112
+ { id: 't2', name: 'Second', size: 2 },
113
+ ];
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import {
4
+ buildWorkerStartOptions,
5
+ createIdempotentStart,
6
+ } from './worker-options.js';
7
+
8
+ describe('buildWorkerStartOptions', () => {
9
+ it('bypasses unhandled requests and stays quiet by default', () => {
10
+ expect(buildWorkerStartOptions()).toEqual({
11
+ onUnhandledRequest: 'bypass',
12
+ quiet: true,
13
+ serviceWorker: { url: '/mockServiceWorker.js' },
14
+ });
15
+ });
16
+
17
+ it('resolves the worker script under the app base path', () => {
18
+ expect(buildWorkerStartOptions({ baseUrl: '/app/' }).serviceWorker).toEqual(
19
+ {
20
+ url: '/app/mockServiceWorker.js',
21
+ },
22
+ );
23
+ });
24
+
25
+ it('honours explicit overrides', () => {
26
+ expect(
27
+ buildWorkerStartOptions({ onUnhandledRequest: 'warn', quiet: false }),
28
+ ).toMatchObject({ onUnhandledRequest: 'warn', quiet: false });
29
+ });
30
+
31
+ it('lets the start escape hatch win, including the script url', () => {
32
+ expect(
33
+ buildWorkerStartOptions({
34
+ baseUrl: '/app/',
35
+ start: {
36
+ onUnhandledRequest: 'error',
37
+ serviceWorker: { url: '/custom/worker.js' },
38
+ },
39
+ }),
40
+ ).toEqual({
41
+ onUnhandledRequest: 'error',
42
+ quiet: true,
43
+ serviceWorker: { url: '/custom/worker.js' },
44
+ });
45
+ });
46
+
47
+ it('keeps the defaults when a forwarded start passes undefined', () => {
48
+ expect(
49
+ buildWorkerStartOptions({
50
+ start: { onUnhandledRequest: undefined, quiet: undefined },
51
+ }),
52
+ ).toEqual({
53
+ onUnhandledRequest: 'bypass',
54
+ quiet: true,
55
+ serviceWorker: { url: '/mockServiceWorker.js' },
56
+ });
57
+ });
58
+
59
+ it('keeps the resolved script url when start sets other worker options', () => {
60
+ expect(
61
+ buildWorkerStartOptions({
62
+ baseUrl: '/app',
63
+ start: { serviceWorker: { options: { scope: '/app' } } },
64
+ }).serviceWorker,
65
+ ).toEqual({ url: '/app/mockServiceWorker.js', options: { scope: '/app' } });
66
+ });
67
+
68
+ it('keeps it when a forwarded serviceWorker passes url: undefined', () => {
69
+ expect(
70
+ buildWorkerStartOptions({
71
+ baseUrl: '/app',
72
+ start: {
73
+ serviceWorker: { url: undefined, options: { scope: '/app' } },
74
+ },
75
+ }).serviceWorker,
76
+ ).toEqual({ url: '/app/mockServiceWorker.js', options: { scope: '/app' } });
77
+ });
78
+ });
79
+
80
+ describe('createIdempotentStart', () => {
81
+ it('registers once across repeated calls', async () => {
82
+ const run = vi.fn(() => Promise.resolve());
83
+ const { start } = createIdempotentStart(run);
84
+
85
+ await Promise.all([start(), start()]);
86
+ await start();
87
+
88
+ expect(run).toHaveBeenCalledTimes(1);
89
+ });
90
+
91
+ it('returns the same promise to concurrent callers', () => {
92
+ const { start } = createIdempotentStart(() => Promise.resolve());
93
+
94
+ expect(start()).toBe(start());
95
+ });
96
+
97
+ it('allows a retry after a failed start', async () => {
98
+ const run = vi
99
+ .fn<() => Promise<void>>()
100
+ .mockRejectedValueOnce(new Error('no worker script'))
101
+ .mockResolvedValueOnce(undefined);
102
+ const { start } = createIdempotentStart(run);
103
+
104
+ await expect(start()).rejects.toThrow('no worker script');
105
+ await expect(start()).resolves.toBeUndefined();
106
+
107
+ expect(run).toHaveBeenCalledTimes(2);
108
+ });
109
+
110
+ it('runs again after invalidate(), which is how stop() re-registers', async () => {
111
+ const run = vi.fn(() => Promise.resolve());
112
+ const { start, invalidate } = createIdempotentStart(run);
113
+
114
+ await start();
115
+ invalidate();
116
+ await start();
117
+
118
+ expect(run).toHaveBeenCalledTimes(2);
119
+ });
120
+
121
+ it('rejects rather than throwing when the start function throws synchronously', async () => {
122
+ const { start } = createIdempotentStart(() => {
123
+ throw new Error('registration is unavailable');
124
+ });
125
+
126
+ await expect(start()).rejects.toThrow('registration is unavailable');
127
+ });
128
+ });