@yabbadabbadev/pepito 0.1.0

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.
@@ -0,0 +1,44 @@
1
+ import { recordBypassResponse, recordMatch, recordMockedResponse, recordRequestStart, recordUnhandled, } from './traffic-registry';
2
+ // The only file in the package that touches `worker.events` (spec risk 1:
3
+ // the life-cycle events API is mid-migration — see
4
+ // docs/knowledge/msw-browser-mode.md). If MSW changes that API, it gets
5
+ // fixed here without touching the registry or the matchers.
6
+ //
7
+ // Only five events: `request:end` and `unhandledException` add nothing to
8
+ // the registry (measured, see docs/knowledge/quiescencia-red-msw.md), so
9
+ // they aren't listened to.
10
+ /**
11
+ * Hooks up the traffic registry (traffic-registry.ts) to MSW's events.
12
+ * `setupNetwork()` calls it once per worker, before `worker.start()`.
13
+ */
14
+ export function watchNetwork(events) {
15
+ events.on('request:start', ({ request, requestId }) => {
16
+ const url = new URL(request.url);
17
+ // Clone without giving up control: as soon as the handler starts
18
+ // reading the stream, request.clone() throws (gotcha 1,
19
+ // docs/knowledge/msw-browser-mode.md).
20
+ const clone = request.clone();
21
+ recordRequestStart(requestId, {
22
+ method: request.method,
23
+ origin: url.origin,
24
+ path: url.pathname,
25
+ searchParams: Object.fromEntries(url.searchParams),
26
+ body: clone.json().catch(() => undefined),
27
+ });
28
+ });
29
+ events.on('request:match', ({ requestId }) => {
30
+ recordMatch(requestId);
31
+ });
32
+ events.on('request:unhandled', ({ requestId }) => {
33
+ recordUnhandled(requestId);
34
+ });
35
+ events.on('response:mocked', ({ response, requestId }) => {
36
+ recordMockedResponse(requestId, response.status, response
37
+ .clone()
38
+ .json()
39
+ .catch(() => undefined));
40
+ });
41
+ events.on('response:bypass', ({ response, requestId }) => {
42
+ recordBypassResponse(requestId, response.status);
43
+ });
44
+ }
@@ -0,0 +1,10 @@
1
+ import type { SetupWorker } from 'msw/browser';
2
+ /** What `mount` needs from `setupNetwork()`: the worker to register test handlers on and the original `href` to restore between tests. */
3
+ export interface NetworkContext {
4
+ worker: SetupWorker;
5
+ initialHref: string;
6
+ }
7
+ /** Publishes `setupNetwork()`'s context for `mount` to read; one call per test file. */
8
+ export declare function registerNetworkContext(nextContext: NetworkContext): void;
9
+ /** Reads the published context or throws with a fix instruction if `setupNetwork()` wasn't called before `caller`. */
10
+ export declare function requireNetworkContext(caller: string): NetworkContext;
@@ -0,0 +1,16 @@
1
+ // Module per test file in browser mode (docs/knowledge/aislamiento-tests.md):
2
+ // this variable doesn't leak between files, so a file that never calls
3
+ // setupNetwork always sees `undefined` here, with no need for an explicit
4
+ // reset.
5
+ let context;
6
+ /** Publishes `setupNetwork()`'s context for `mount` to read; one call per test file. */
7
+ export function registerNetworkContext(nextContext) {
8
+ context = nextContext;
9
+ }
10
+ /** Reads the published context or throws with a fix instruction if `setupNetwork()` wasn't called before `caller`. */
11
+ export function requireNetworkContext(caller) {
12
+ if (!context) {
13
+ throw new Error(`pepito: setupNetwork(handlers) has not been initialized. Call it in your setup file (setupFiles in vitest.config) before using ${caller}.`);
14
+ }
15
+ return context;
16
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Opaque marker that `expect.network()` hangs off. It doesn't identify a
3
+ * request but the overall traffic observed by the registry: a symbol keeps
4
+ * any real value from the application (a string, a spec object) from
5
+ * accidentally slipping in where only this marker makes sense —
6
+ * `toHaveNoUnhandledRequests` requires it with `===` before looking at the
7
+ * traffic.
8
+ */
9
+ export declare const NETWORK_TARGET: unique symbol;
10
+ /**
11
+ * Diagnostic utilities over the observed traffic, for inspecting it outside
12
+ * an assertion (for example, while debugging a failing test).
13
+ */
14
+ export declare const network: {
15
+ /**
16
+ * Waits for the network to settle and dumps the observed traffic via
17
+ * `console.log`, in the same format that appears in the network matchers'
18
+ * failure messages.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * await fetch('/api/products')
23
+ * await network.log()
24
+ * ```
25
+ */
26
+ log(): Promise<void>;
27
+ /**
28
+ * Waits for the network to settle, with the same double-observation
29
+ * mechanism the network matchers use (`snapshotAfterIdle` in
30
+ * matchers.ts) — it doesn't duplicate it, it only discards the snapshot it
31
+ * produces. Guarantees that every request seen up to the moment of the
32
+ * call has closed (with `response:mocked`, `response:bypass` or the 500
33
+ * MSW fabricates when a handler throws). It does not guarantee the
34
+ * absence of future traffic: the same practical blind window remains as
35
+ * for the rest of the mechanism — a request fired in the same tick as the
36
+ * call, before crossing the real round trip to the service worker (1–6 ms
37
+ * measured; see `.claude/docs/references/measured-foundations.md`), may
38
+ * not be in the registry yet when `network.idle()` resolves.
39
+ *
40
+ * Meant for visual regression: capturing right after mounting, with a
41
+ * slow network in the mix, produces a STABLE but wrong baseline — the
42
+ * native stabilizer of `toMatchScreenshot` doesn't catch it because
43
+ * "Loading…" is also a capture that stops changing between frames
44
+ * (measured: 0 failures across 17 local runs + 3 in CI waiting for calm
45
+ * this way — see `.claude/docs/references/measured-foundations.md`).
46
+ * Before this, the only public way to wait for calm on its own was to
47
+ * divert `expect.network().toHaveNoUnhandledRequests()` from its actual
48
+ * purpose (detecting traffic without a handler). It works just as well as a
49
+ * generic "wait for the network to settle", without asserting anything.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * const screen = await mount(<App />)
54
+ * await network.idle()
55
+ * await expect.element(screen.getByRole('main')).toMatchScreenshot('catalog')
56
+ * ```
57
+ *
58
+ * @throws If the network doesn't settle within the `QUIESCENCE_TIMEOUT_MS`
59
+ * budget, with the dump of the requests that were still in flight — the
60
+ * same error `waitForNetworkIdle` throws.
61
+ */
62
+ idle(): Promise<void>;
63
+ };
@@ -0,0 +1,76 @@
1
+ import { formatTraffic } from './failure-messages';
2
+ // Cycle with matchers.ts (which imports NETWORK_TARGET from this file,
3
+ // below): safe because both uses live inside function bodies that run
4
+ // deferred, never during module evaluation — `import`/`function` hoisting
5
+ // resolves them at call time. Changing either side to a module-level
6
+ // const-arrow would break the cycle, with one of the two sides seeing
7
+ // `undefined` on load.
8
+ import { snapshotAfterIdle } from './matchers';
9
+ /**
10
+ * Opaque marker that `expect.network()` hangs off. It doesn't identify a
11
+ * request but the overall traffic observed by the registry: a symbol keeps
12
+ * any real value from the application (a string, a spec object) from
13
+ * accidentally slipping in where only this marker makes sense —
14
+ * `toHaveNoUnhandledRequests` requires it with `===` before looking at the
15
+ * traffic.
16
+ */
17
+ export const NETWORK_TARGET = Symbol('pepito:network-target');
18
+ /**
19
+ * Diagnostic utilities over the observed traffic, for inspecting it outside
20
+ * an assertion (for example, while debugging a failing test).
21
+ */
22
+ export const network = {
23
+ /**
24
+ * Waits for the network to settle and dumps the observed traffic via
25
+ * `console.log`, in the same format that appears in the network matchers'
26
+ * failure messages.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * await fetch('/api/products')
31
+ * await network.log()
32
+ * ```
33
+ */
34
+ async log() {
35
+ const traffic = await snapshotAfterIdle();
36
+ console.log(formatTraffic(traffic));
37
+ },
38
+ /**
39
+ * Waits for the network to settle, with the same double-observation
40
+ * mechanism the network matchers use (`snapshotAfterIdle` in
41
+ * matchers.ts) — it doesn't duplicate it, it only discards the snapshot it
42
+ * produces. Guarantees that every request seen up to the moment of the
43
+ * call has closed (with `response:mocked`, `response:bypass` or the 500
44
+ * MSW fabricates when a handler throws). It does not guarantee the
45
+ * absence of future traffic: the same practical blind window remains as
46
+ * for the rest of the mechanism — a request fired in the same tick as the
47
+ * call, before crossing the real round trip to the service worker (1–6 ms
48
+ * measured; see `.claude/docs/references/measured-foundations.md`), may
49
+ * not be in the registry yet when `network.idle()` resolves.
50
+ *
51
+ * Meant for visual regression: capturing right after mounting, with a
52
+ * slow network in the mix, produces a STABLE but wrong baseline — the
53
+ * native stabilizer of `toMatchScreenshot` doesn't catch it because
54
+ * "Loading…" is also a capture that stops changing between frames
55
+ * (measured: 0 failures across 17 local runs + 3 in CI waiting for calm
56
+ * this way — see `.claude/docs/references/measured-foundations.md`).
57
+ * Before this, the only public way to wait for calm on its own was to
58
+ * divert `expect.network().toHaveNoUnhandledRequests()` from its actual
59
+ * purpose (detecting traffic without a handler). It works just as well as a
60
+ * generic "wait for the network to settle", without asserting anything.
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * const screen = await mount(<App />)
65
+ * await network.idle()
66
+ * await expect.element(screen.getByRole('main')).toMatchScreenshot('catalog')
67
+ * ```
68
+ *
69
+ * @throws If the network doesn't settle within the `QUIESCENCE_TIMEOUT_MS`
70
+ * budget, with the dump of the requests that were still in flight — the
71
+ * same error `waitForNetworkIdle` throws.
72
+ */
73
+ async idle() {
74
+ await snapshotAfterIdle();
75
+ },
76
+ };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * How the expected request is compared against the observed one.
3
+ * `searchParams` and `body` match by subset of top-level keys with deep
4
+ * equality per key — `{ body: { productName: 'Milk' } }` matches even if
5
+ * the real request also carries `id` or `createdAt` — unless `exact: true`,
6
+ * which requires strict equality of the whole object.
7
+ *
8
+ * A repeated key in the real query (`?tag=a&tag=b`) collapses to a single
9
+ * value — the last one — before comparing, because the registry builds
10
+ * `searchParams` with `Object.fromEntries`: if your application relies on a
11
+ * key appearing more than once, this comparison won't detect it.
12
+ */
13
+ export interface RequestSpecOptions {
14
+ searchParams?: Record<string, string>;
15
+ body?: unknown;
16
+ exact?: boolean;
17
+ }
18
+ /**
19
+ * Describes the request a network matcher looks for in the observed
20
+ * traffic. Built with `request()` or one of its shortcuts (`get`, `post`,
21
+ * `put`, `patch`, `del`, `query`); there's no need to build it by hand.
22
+ *
23
+ * Matching ignores `origin`: `get('/api/x')` matches both a same-origin
24
+ * request to `/api/x` and one to `https://other.host/api/x`, if some
25
+ * handler responds there. The traffic registry does keep the `origin` of
26
+ * each request — not used yet, but it leaves room for an assertion by host
27
+ * the day two hosts share the same path.
28
+ */
29
+ export interface RequestSpec extends RequestSpecOptions {
30
+ method: string;
31
+ path: string;
32
+ }
33
+ /**
34
+ * Escape hatch for any HTTP method, including ones MSW 2.15 doesn't yet
35
+ * expose as its own helper (`QUERY`). The shortcuts (`get`, `post`, …) are
36
+ * `request(fixedMethod, ...)`; for any other method, or to leave the method
37
+ * explicit in the test itself, use this directly.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * import { request } from '@yabbadabbadev/pepito'
42
+ *
43
+ * await expect(request('QUERY', '/api/products')).toHaveBeenRequested()
44
+ * ```
45
+ */
46
+ export declare function request(method: string, path: string, options?: RequestSpecOptions): RequestSpec;
47
+ /**
48
+ * Describes an expected `GET` request.
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * import { get } from '@yabbadabbadev/pepito'
53
+ *
54
+ * await expect(get('/api/products', { searchParams: { filter: 'bread' } })).toHaveBeenRequested()
55
+ * ```
56
+ */
57
+ export declare const get: (path: string, options?: RequestSpecOptions) => RequestSpec;
58
+ /** Describes an expected `POST` request; same shape as {@link get}. */
59
+ export declare const post: typeof get;
60
+ /** Describes an expected `PUT` request; same shape as {@link get}. */
61
+ export declare const put: typeof get;
62
+ /** Describes an expected `PATCH` request; same shape as {@link get}. */
63
+ export declare const patch: typeof get;
64
+ /**
65
+ * Describes an expected `DELETE` request; same shape as {@link get}.
66
+ * Named `del` because `delete` is a reserved word.
67
+ */
68
+ export declare const del: typeof get;
69
+ /**
70
+ * Describes an expected `QUERY` request — the method this package's spec
71
+ * introduces, between `GET` and `POST`. The relevant option is usually
72
+ * `searchParams`, not `body`: it's called `searchParams` and not `query` on
73
+ * purpose, because `query` is already the name of this HTTP method in the
74
+ * same API. MSW 2.15 doesn't expose `query` as a handler helper yet — it's
75
+ * declared with `http.all` and filtered by method — but pepito's traffic
76
+ * registry observes it just the same, because it reads `request.method` as
77
+ * a plain string.
78
+ */
79
+ export declare const query: typeof get;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Escape hatch for any HTTP method, including ones MSW 2.15 doesn't yet
3
+ * expose as its own helper (`QUERY`). The shortcuts (`get`, `post`, …) are
4
+ * `request(fixedMethod, ...)`; for any other method, or to leave the method
5
+ * explicit in the test itself, use this directly.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { request } from '@yabbadabbadev/pepito'
10
+ *
11
+ * await expect(request('QUERY', '/api/products')).toHaveBeenRequested()
12
+ * ```
13
+ */
14
+ export function request(method, path, options) {
15
+ return { method, path, ...options };
16
+ }
17
+ /**
18
+ * Describes an expected `GET` request.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * import { get } from '@yabbadabbadev/pepito'
23
+ *
24
+ * await expect(get('/api/products', { searchParams: { filter: 'bread' } })).toHaveBeenRequested()
25
+ * ```
26
+ */
27
+ export const get = (path, options) => request('GET', path, options);
28
+ /** Describes an expected `POST` request; same shape as {@link get}. */
29
+ export const post = (path, options) => request('POST', path, options);
30
+ /** Describes an expected `PUT` request; same shape as {@link get}. */
31
+ export const put = (path, options) => request('PUT', path, options);
32
+ /** Describes an expected `PATCH` request; same shape as {@link get}. */
33
+ export const patch = (path, options) => request('PATCH', path, options);
34
+ /**
35
+ * Describes an expected `DELETE` request; same shape as {@link get}.
36
+ * Named `del` because `delete` is a reserved word.
37
+ */
38
+ export const del = (path, options) => request('DELETE', path, options);
39
+ /**
40
+ * Describes an expected `QUERY` request — the method this package's spec
41
+ * introduces, between `GET` and `POST`. The relevant option is usually
42
+ * `searchParams`, not `body`: it's called `searchParams` and not `query` on
43
+ * purpose, because `query` is already the name of this HTTP method in the
44
+ * same API. MSW 2.15 doesn't expose `query` as a handler helper yet — it's
45
+ * declared with `http.all` and filtered by method — but pepito's traffic
46
+ * registry observes it just the same, because it reads `request.method` as
47
+ * a plain string.
48
+ */
49
+ export const query = (path, options) => request('QUERY', path, options);
@@ -0,0 +1,33 @@
1
+ import type { RequestHandler } from 'msw';
2
+ import type { SetupWorker, StartOptions } from 'msw/browser';
3
+ /**
4
+ * Starts the MSW worker and hooks up the traffic registry, leaving the
5
+ * between-test cleanup installed in `afterEach`: registry, hot handlers,
6
+ * document URL and origin storage all return to their pre-test state
7
+ * (measured — see `.claude/docs/references/measured-foundations.md`).
8
+ *
9
+ * Called once per test file, typically from a `setupFiles` entry in
10
+ * `vitest.config`, never from inside a test.
11
+ *
12
+ * Known cleanup limit: a cookie set with an explicit `path` other than `/`
13
+ * (or with `domain`) can't be enumerated from `document.cookie`, so it
14
+ * survives between tests — see `clearOriginStorage` in storage-cleanup.ts.
15
+ * A cookie without an explicit `path` does get cleared, including one set
16
+ * while `mount` was simulating being on a nested route (measured — see
17
+ * `.claude/docs/references/measured-foundations.md`).
18
+ *
19
+ * @param handlers - Initial MSW handlers, the same ones `setupWorker` would take.
20
+ * @param startOptions - Passed through to `worker.start()` as-is; no wrapper of its own.
21
+ * @returns The MSW `SetupWorker`, so individual tests can call `worker.use()`.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * import { http, HttpResponse } from 'msw'
26
+ * import { setupNetwork } from '@yabbadabbadev/pepito'
27
+ *
28
+ * setupNetwork([
29
+ * http.get('/api/products', () => HttpResponse.json([])),
30
+ * ])
31
+ * ```
32
+ */
33
+ export declare function setupNetwork(handlers: RequestHandler[], startOptions?: StartOptions): SetupWorker;
@@ -0,0 +1,57 @@
1
+ import { setupWorker } from 'msw/browser';
2
+ import { afterEach, beforeAll } from 'vitest';
3
+ import { watchNetwork } from './msw-events';
4
+ import { registerNetworkContext, requireNetworkContext, } from './network-singleton';
5
+ import { clearOriginStorage } from './storage-cleanup';
6
+ import { resetTraffic } from './traffic-registry';
7
+ /**
8
+ * Starts the MSW worker and hooks up the traffic registry, leaving the
9
+ * between-test cleanup installed in `afterEach`: registry, hot handlers,
10
+ * document URL and origin storage all return to their pre-test state
11
+ * (measured — see `.claude/docs/references/measured-foundations.md`).
12
+ *
13
+ * Called once per test file, typically from a `setupFiles` entry in
14
+ * `vitest.config`, never from inside a test.
15
+ *
16
+ * Known cleanup limit: a cookie set with an explicit `path` other than `/`
17
+ * (or with `domain`) can't be enumerated from `document.cookie`, so it
18
+ * survives between tests — see `clearOriginStorage` in storage-cleanup.ts.
19
+ * A cookie without an explicit `path` does get cleared, including one set
20
+ * while `mount` was simulating being on a nested route (measured — see
21
+ * `.claude/docs/references/measured-foundations.md`).
22
+ *
23
+ * @param handlers - Initial MSW handlers, the same ones `setupWorker` would take.
24
+ * @param startOptions - Passed through to `worker.start()` as-is; no wrapper of its own.
25
+ * @returns The MSW `SetupWorker`, so individual tests can call `worker.use()`.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * import { http, HttpResponse } from 'msw'
30
+ * import { setupNetwork } from '@yabbadabbadev/pepito'
31
+ *
32
+ * setupNetwork([
33
+ * http.get('/api/products', () => HttpResponse.json([])),
34
+ * ])
35
+ * ```
36
+ */
37
+ export function setupNetwork(handlers, startOptions) {
38
+ const worker = setupWorker(...handlers);
39
+ // The registry is hooked up BEFORE start(): start is the earliest point
40
+ // that can generate traffic.
41
+ watchNetwork(worker.events);
42
+ registerNetworkContext({ worker, initialHref: location.href });
43
+ beforeAll(async () => {
44
+ await worker.start({ quiet: true, ...startOptions });
45
+ });
46
+ afterEach(() => {
47
+ resetTraffic();
48
+ worker.resetHandlers();
49
+ // clearOriginStorage() goes BEFORE restoring the URL, not after:
50
+ // defensive ordering per RFC 6265, with no observable effect measured
51
+ // in this harness — see the TSDoc on clearOriginStorage in
52
+ // storage-cleanup.ts.
53
+ clearOriginStorage();
54
+ history.replaceState({}, '', requireNetworkContext('setupNetwork').initialHref);
55
+ });
56
+ return worker;
57
+ }
@@ -0,0 +1,18 @@
1
+ import type { RequestSpec } from './request-descriptors';
2
+ import type { ResolvedRequest } from './traffic-registry';
3
+ /**
4
+ * Compares an observed value against an expected one by subset of
5
+ * top-level keys with deep equality per key (or direct deep equality if
6
+ * `expected` isn't a plain object), unless `exact: true`, which requires
7
+ * strict equality of the whole object. Used both by `matchesSpec` over the
8
+ * request's `body` and by `toHaveRespondedWith` over the `responseBody`:
9
+ * same subset semantics, two different places it's applied.
10
+ */
11
+ export declare function matchesBody(observed: unknown, expected: unknown, exact: boolean | undefined): boolean;
12
+ /**
13
+ * Compares an observed request against a `RequestSpec`: method and path by
14
+ * strict equality; `body` and `searchParams` by subset of top-level keys
15
+ * with deep equality per key, unless `{ exact: true }`, which requires
16
+ * strict equality of the whole object.
17
+ */
18
+ export declare function matchesSpec(observed: ResolvedRequest, spec: RequestSpec): boolean;
@@ -0,0 +1,66 @@
1
+ function deepEqual(a, b) {
2
+ if (a === b)
3
+ return true;
4
+ if (typeof a !== 'object' ||
5
+ typeof b !== 'object' ||
6
+ a === null ||
7
+ b === null) {
8
+ return false;
9
+ }
10
+ if (Array.isArray(a) !== Array.isArray(b))
11
+ return false;
12
+ const keysA = Object.keys(a);
13
+ const keysB = Object.keys(b);
14
+ if (keysA.length !== keysB.length)
15
+ return false;
16
+ return keysA.every((key) => deepEqual(a[key], b[key]));
17
+ }
18
+ // Excludes null (typeof null === 'object') and arrays: both match by direct
19
+ // deep equality, not by subset of keys.
20
+ function isPlainObject(candidate) {
21
+ return (typeof candidate === 'object' &&
22
+ candidate !== null &&
23
+ !Array.isArray(candidate));
24
+ }
25
+ function matchesSubset(observed, expected, exact) {
26
+ if (exact)
27
+ return deepEqual(observed, expected);
28
+ return Object.keys(expected).every((key) => deepEqual(observed[key], expected[key]));
29
+ }
30
+ /**
31
+ * Compares an observed value against an expected one by subset of
32
+ * top-level keys with deep equality per key (or direct deep equality if
33
+ * `expected` isn't a plain object), unless `exact: true`, which requires
34
+ * strict equality of the whole object. Used both by `matchesSpec` over the
35
+ * request's `body` and by `toHaveRespondedWith` over the `responseBody`:
36
+ * same subset semantics, two different places it's applied.
37
+ */
38
+ export function matchesBody(observed, expected, exact) {
39
+ if (!isPlainObject(expected))
40
+ return deepEqual(observed, expected);
41
+ return isPlainObject(observed) && matchesSubset(observed, expected, exact);
42
+ }
43
+ /**
44
+ * Compares an observed request against a `RequestSpec`: method and path by
45
+ * strict equality; `body` and `searchParams` by subset of top-level keys
46
+ * with deep equality per key, unless `{ exact: true }`, which requires
47
+ * strict equality of the whole object.
48
+ */
49
+ export function matchesSpec(observed, spec) {
50
+ if (observed.method !== spec.method || observed.path !== spec.path) {
51
+ return false;
52
+ }
53
+ if (spec.searchParams !== undefined &&
54
+ // observed.searchParams comes from Object.fromEntries(url.searchParams):
55
+ // with a repeated key (`?tag=a&tag=b`) only the last value survives. The
56
+ // subset comparison checks that single value without being able to
57
+ // detect the loss.
58
+ !matchesSubset(observed.searchParams, spec.searchParams, spec.exact)) {
59
+ return false;
60
+ }
61
+ if (spec.body !== undefined &&
62
+ !matchesBody(observed.body, spec.body, spec.exact)) {
63
+ return false;
64
+ }
65
+ return true;
66
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Clears the origin storage that browser mode leaks between test files that
3
+ * land on the same worker: `localStorage`, `sessionStorage` and cookies
4
+ * belong to the origin, not the document, so they survive per-file
5
+ * isolation (measured — see
6
+ * `.claude/docs/references/measured-foundations.md`).
7
+ *
8
+ * Each cookie is expired twice per name: once with `path=/` (the one most
9
+ * application code sets) and once with no `path` attribute, in case one was
10
+ * set without it. `setupNetwork()` calls this function BEFORE restoring the
11
+ * URL in its `afterEach`, as a defensive ordering: per RFC 6265, the
12
+ * default `path` of a cookie without the attribute is, in theory, computed
13
+ * from the active URL both when it's set and when it's deleted, so clearing
14
+ * before restoring the URL would be the correct order IF that computation
15
+ * followed the routes `setupNetwork()` simulates with `history.pushState`.
16
+ *
17
+ * Measured that it doesn't, in this harness (Chromium via Playwright,
18
+ * `vitest@4.1.10`): the order has no observable effect today — full
19
+ * evidence in `.claude/docs/references/measured-foundations.md`. The order
20
+ * is kept anyway, at no cost, in case some future runner or browser does
21
+ * follow the simulated URL.
22
+ *
23
+ * Known and actually verified limit: a cookie set with an explicit `path`
24
+ * or with `domain` can't even be enumerated from `document.cookie` — it
25
+ * stays alive after this call.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * afterEach(() => {
30
+ * clearOriginStorage()
31
+ * })
32
+ * ```
33
+ */
34
+ export declare function clearOriginStorage(): void;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Clears the origin storage that browser mode leaks between test files that
3
+ * land on the same worker: `localStorage`, `sessionStorage` and cookies
4
+ * belong to the origin, not the document, so they survive per-file
5
+ * isolation (measured — see
6
+ * `.claude/docs/references/measured-foundations.md`).
7
+ *
8
+ * Each cookie is expired twice per name: once with `path=/` (the one most
9
+ * application code sets) and once with no `path` attribute, in case one was
10
+ * set without it. `setupNetwork()` calls this function BEFORE restoring the
11
+ * URL in its `afterEach`, as a defensive ordering: per RFC 6265, the
12
+ * default `path` of a cookie without the attribute is, in theory, computed
13
+ * from the active URL both when it's set and when it's deleted, so clearing
14
+ * before restoring the URL would be the correct order IF that computation
15
+ * followed the routes `setupNetwork()` simulates with `history.pushState`.
16
+ *
17
+ * Measured that it doesn't, in this harness (Chromium via Playwright,
18
+ * `vitest@4.1.10`): the order has no observable effect today — full
19
+ * evidence in `.claude/docs/references/measured-foundations.md`. The order
20
+ * is kept anyway, at no cost, in case some future runner or browser does
21
+ * follow the simulated URL.
22
+ *
23
+ * Known and actually verified limit: a cookie set with an explicit `path`
24
+ * or with `domain` can't even be enumerated from `document.cookie` — it
25
+ * stays alive after this call.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * afterEach(() => {
30
+ * clearOriginStorage()
31
+ * })
32
+ * ```
33
+ */
34
+ export function clearOriginStorage() {
35
+ localStorage.clear();
36
+ sessionStorage.clear();
37
+ for (const cookie of document.cookie.split(';')) {
38
+ const name = cookie.split('=')[0]?.trim();
39
+ if (!name)
40
+ continue;
41
+ document.cookie = `${name}=;expires=${new Date(0).toUTCString()};path=/`;
42
+ document.cookie = `${name}=;expires=${new Date(0).toUTCString()}`;
43
+ }
44
+ }