@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,145 @@
1
+ import { matchesSpec } from './spec-matching';
2
+ const TRAFFIC_FLAGS = ['matched', 'mocked', 'bypassed', 'unhandled'];
3
+ function formatQuery(searchParams) {
4
+ const query = new URLSearchParams(searchParams).toString();
5
+ return query ? `?${query}` : '';
6
+ }
7
+ function formatFlags(entry) {
8
+ const activeFlags = TRAFFIC_FLAGS.filter((flag) => entry[flag]);
9
+ return activeFlags.length > 0 ? activeFlags.join('/') : 'no verdict';
10
+ }
11
+ function formatTrafficLine(entry) {
12
+ const status = entry.status ?? '(no response)';
13
+ return ` ${entry.method} ${entry.path}${formatQuery(entry.searchParams)} → ${status} [${formatFlags(entry)}]`;
14
+ }
15
+ /** Dumps the observed traffic, one line per entry, to embed in a failure message. */
16
+ export function formatTraffic(traffic) {
17
+ if (traffic.length === 0)
18
+ return ' (no traffic observed)';
19
+ return traffic.map(formatTrafficLine).join('\n');
20
+ }
21
+ // Only what identifies the expected request, not the comparison options
22
+ // (`exact`): that's a mode of matchesSpec, not something one "expected to
23
+ // receive".
24
+ function describeSpec(spec) {
25
+ return {
26
+ method: spec.method,
27
+ path: spec.path,
28
+ searchParams: spec.searchParams,
29
+ body: spec.body,
30
+ };
31
+ }
32
+ /**
33
+ * Composes the network matchers' failure message: the matcher's hint, what
34
+ * was expected, a diff against the closest candidate (same method and path,
35
+ * even if it doesn't fully match) if one exists, and the full dump of the
36
+ * observed traffic.
37
+ */
38
+ export function requestFailureMessage(messageContext) {
39
+ const { utils, matcherName, spec, traffic, isNot } = messageContext;
40
+ const sections = [
41
+ utils.matcherHint(matcherName, undefined, undefined, { isNot }),
42
+ // Under `.not`, what failed is that there WAS a matching request: the
43
+ // hint already says so with the "not.", but the "Expected" line by
44
+ // itself would still read as the positive case if it didn't change
45
+ // along with it.
46
+ isNot
47
+ ? `Not expected: ${utils.printExpected(describeSpec(spec))}`
48
+ : `Expected: ${utils.printExpected(describeSpec(spec))}`,
49
+ ];
50
+ const candidate = traffic.find((entry) => entry.method === spec.method && entry.path === spec.path);
51
+ if (candidate) {
52
+ const diff = utils.diff({ searchParams: spec.searchParams, body: spec.body }, { searchParams: candidate.searchParams, body: candidate.body });
53
+ if (diff)
54
+ sections.push(diff);
55
+ }
56
+ sections.push(`Observed traffic:\n${formatTraffic(traffic)}`);
57
+ return sections.join('\n\n');
58
+ }
59
+ /**
60
+ * Composes the `toHaveBeenRequestedTimes` failure message: there's no
61
+ * "closest candidate" to show like in `requestFailureMessage`, but an
62
+ * expected count against the one actually observed, plus the full dump.
63
+ */
64
+ export function requestCountFailureMessage(messageContext) {
65
+ const { utils, spec, expectedCount, foundCount, traffic, isNot } = messageContext;
66
+ // Under `.not`, `foundCount` is necessarily equal to `expectedCount` (that's
67
+ // what made the negated assertion fail): a bare "found 2" would read as a
68
+ // meaningless echo if it didn't also say that count is exactly the one
69
+ // that wasn't expected.
70
+ const leadLine = isNot
71
+ ? `Did not expect exactly ${expectedCount} request(s) to ${utils.printExpected(describeSpec(spec))}, yet found ${foundCount}`
72
+ : `Expected ${expectedCount} request(s) to ${utils.printExpected(describeSpec(spec))}, found ${foundCount}`;
73
+ const sections = [
74
+ utils.matcherHint('toHaveBeenRequestedTimes', undefined, undefined, {
75
+ isNot,
76
+ }),
77
+ leadLine,
78
+ `Observed traffic:\n${formatTraffic(traffic)}`,
79
+ ];
80
+ return sections.join('\n\n');
81
+ }
82
+ /**
83
+ * Composes the `toHaveNoUnhandledRequests` failure message: which requests
84
+ * arrived without a handler (method and path, the minimum needed to locate
85
+ * them in the code) followed by the full traffic dump, in case the missing
86
+ * handler becomes obvious when seen alongside the rest.
87
+ */
88
+ export function noUnhandledRequestsFailureMessage(messageContext) {
89
+ const { utils, unhandledEntries, traffic, isNot } = messageContext;
90
+ // Under `.not`, what failed is that `unhandledEntries` is EMPTY — listing
91
+ // "Unhandled requests:" followed by nothing is the bug this `isNot`
92
+ // fixes: the negated assertion demanded traffic without a handler and
93
+ // there was none.
94
+ const leadLine = isNot
95
+ ? 'Expected to find some request without a handler, but the traffic came in clean'
96
+ : `Unhandled requests:\n${unhandledEntries
97
+ .map((entry) => ` ${entry.method} ${entry.path}`)
98
+ .join('\n')}`;
99
+ const sections = [
100
+ utils.matcherHint('toHaveNoUnhandledRequests', undefined, undefined, {
101
+ isNot,
102
+ }),
103
+ leadLine,
104
+ `Observed traffic:\n${formatTraffic(traffic)}`,
105
+ ];
106
+ return sections.join('\n\n');
107
+ }
108
+ function describeExpectedResponse(expected) {
109
+ return expected.body === undefined
110
+ ? { status: expected.status }
111
+ : { status: expected.status, body: expected.body };
112
+ }
113
+ /**
114
+ * Composes the `toHaveRespondedWith` failure message: the matcher's hint,
115
+ * the expected response, a diff against the closest intercepted-and-matched
116
+ * candidate (even if it doesn't match on status or body) if one exists, and
117
+ * the full dump of the observed traffic. The candidate requires `matched &&
118
+ * mocked` because a passthrough entry can match the request's spec without
119
+ * ever having been intercepted: showing it as "close" would be misleading.
120
+ */
121
+ export function respondedWithFailureMessage(messageContext) {
122
+ const { utils, spec, expected, traffic, isNot } = messageContext;
123
+ const leadLine = isNot
124
+ ? `Not expected: ${utils.printExpected(describeSpec(spec))} responded with ${utils.printExpected(describeExpectedResponse(expected))}`
125
+ : `Expected: ${utils.printExpected(describeSpec(spec))} responded with ${utils.printExpected(describeExpectedResponse(expected))}`;
126
+ const sections = [
127
+ utils.matcherHint('toHaveRespondedWith', undefined, undefined, { isNot }),
128
+ leadLine,
129
+ ];
130
+ const candidate = traffic.find((entry) => entry.matched && entry.mocked && matchesSpec(entry, spec));
131
+ if (candidate) {
132
+ // Same key (`body`) on both sides, and absent on both if `expected`
133
+ // doesn't carry it: with different names (`body` vs `responseBody`) the
134
+ // diff finds no common ground and shows two whole unrelated blocks
135
+ // instead of pointing at the field that actually changed inside the
136
+ // body.
137
+ const diff = utils.diff(describeExpectedResponse(expected), expected.body === undefined
138
+ ? { status: candidate.status }
139
+ : { status: candidate.status, body: candidate.responseBody });
140
+ if (diff)
141
+ sections.push(diff);
142
+ }
143
+ sections.push(`Observed traffic:\n${formatTraffic(traffic)}`);
144
+ return sections.join('\n\n');
145
+ }
@@ -0,0 +1,7 @@
1
+ import './matchers';
2
+ import './matcher-types';
3
+ export * from './request-descriptors';
4
+ export { setupNetwork } from './setup-network';
5
+ export { network } from './network';
6
+ export { mount } from './mount';
7
+ export type { MountOptions } from './mount';
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ import './matchers';
2
+ import './matcher-types';
3
+ export * from './request-descriptors';
4
+ export { setupNetwork } from './setup-network';
5
+ export { network } from './network';
6
+ export { mount } from './mount';
@@ -0,0 +1,113 @@
1
+ /**
2
+ * What a handler is expected to have responded, for `toHaveRespondedWith`.
3
+ * `status` is required; `body` is compared by subset of top-level keys
4
+ * unless `exact: true`, which requires strict equality — same semantics as
5
+ * `RequestSpecOptions.body` in request-descriptors.ts, but over the
6
+ * response instead of the request.
7
+ */
8
+ export interface ExpectedResponse {
9
+ status: number;
10
+ body?: unknown;
11
+ exact?: boolean;
12
+ }
13
+ interface NetworkMatchers<ReturnType = unknown> {
14
+ /**
15
+ * Checks that the application made a request matching `spec`, by exact
16
+ * `method` and `path` and `searchParams`/`body` by subset. Retries until
17
+ * it finds one or the timeout runs out: a request is an effect that
18
+ * follows an interaction, same as `expect.element`.
19
+ *
20
+ * `.not.toHaveBeenRequested()` does not retry: it waits for the network to
21
+ * settle first, so as not to mistake a request that hasn't arrived yet for
22
+ * one that was never made.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * import { get } from '@yabbadabbadev/pepito'
27
+ *
28
+ * await fetch('/api/products')
29
+ * await expect(get('/api/products')).toHaveBeenRequested()
30
+ * await expect(get('/api/other')).not.toHaveBeenRequested()
31
+ * ```
32
+ */
33
+ toHaveBeenRequested(): Promise<ReturnType>;
34
+ /**
35
+ * Checks that exactly `count` requests matching `spec` were made. Always
36
+ * waits for the network to settle before counting, with or without
37
+ * `.not`: a count taken mid-traffic is as false as an absence taken
38
+ * mid-traffic.
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * import { get } from '@yabbadabbadev/pepito'
43
+ *
44
+ * await Promise.all([fetch('/api/products'), fetch('/api/products')])
45
+ * await expect(get('/api/products')).toHaveBeenRequestedTimes(2)
46
+ * ```
47
+ */
48
+ toHaveBeenRequestedTimes(count: number): Promise<ReturnType>;
49
+ /**
50
+ * Checks that one of your own handlers produced the response, not
51
+ * just that the request matched its route: a handler with `passthrough()`
52
+ * satisfies `toHaveBeenRequested` but not this matcher, because the
53
+ * response came from the real network.
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * import { post } from '@yabbadabbadev/pepito'
58
+ *
59
+ * await fetch('/api/products', { method: 'POST' })
60
+ * await expect(post('/api/products')).toHaveBeenIntercepted()
61
+ * ```
62
+ */
63
+ toHaveBeenIntercepted(): Promise<ReturnType>;
64
+ /**
65
+ * Checks that the request was intercepted (like `toHaveBeenIntercepted`)
66
+ * and that the response has the expected `status` and, if given, a `body`
67
+ * that matches by subset. `toHaveRespondedWith(500)` is the shorthand for
68
+ * `toHaveRespondedWith({ status: 500 })`.
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * import { get } from '@yabbadabbadev/pepito'
73
+ *
74
+ * await fetch('/api/products')
75
+ * await expect(get('/api/products')).toHaveRespondedWith({
76
+ * status: 200,
77
+ * body: { total: 2 },
78
+ * })
79
+ * ```
80
+ */
81
+ toHaveRespondedWith(expected: number | ExpectedResponse): Promise<ReturnType>;
82
+ /**
83
+ * Suite guardrail: checks that no request was left without a handler. It
84
+ * does not describe a specific request, so it hangs off `expect.network()`
85
+ * instead of a `get`/`post`/… descriptor; using it on anything else fails
86
+ * with an instruction, not a data verdict.
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * await expect.network().toHaveNoUnhandledRequests()
91
+ * ```
92
+ */
93
+ toHaveNoUnhandledRequests(): Promise<ReturnType>;
94
+ }
95
+ declare module '@vitest/expect' {
96
+ interface Assertion<T = any> extends NetworkMatchers<T> {
97
+ }
98
+ interface AsymmetricMatchersContaining extends NetworkMatchers {
99
+ }
100
+ interface ExpectStatic {
101
+ /**
102
+ * Entry point for assertions over the network as a whole, not over a
103
+ * specific request. Today only `toHaveNoUnhandledRequests` consumes it.
104
+ *
105
+ * @example
106
+ * ```ts
107
+ * await expect.network().toHaveNoUnhandledRequests()
108
+ * ```
109
+ */
110
+ network(): Assertion<unknown>;
111
+ }
112
+ }
113
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
1
+ import type { ResolvedRequest } from './traffic-registry';
2
+ /**
3
+ * Waits for the network to settle (two consecutive calm observations,
4
+ * separated by a poll interval) and returns a single snapshot of the
5
+ * traffic accumulated up to that point.
6
+ *
7
+ * Use it to assert an ABSENCE or an EXACT COUNT, never for the positive,
8
+ * retrying case (that's `pollTraffic`): it's the basis for the negated
9
+ * branch of `resolveTraffic` and for `toHaveBeenRequestedTimes`, and
10
+ * `network.log()` (network.ts) also uses it, outside this file.
11
+ *
12
+ * Total budget `QUIESCENCE_TIMEOUT_MS`: if the network doesn't settle in
13
+ * time, it throws with a dump of what's pending instead of returning a mute
14
+ * boolean. Full design rationale in the comment block right below the
15
+ * function.
16
+ */
17
+ export declare function snapshotAfterIdle(): Promise<ResolvedRequest[]>;
@@ -0,0 +1,233 @@
1
+ import { expect } from 'vitest';
2
+ import { noUnhandledRequestsFailureMessage, requestCountFailureMessage, requestFailureMessage, respondedWithFailureMessage, } from './failure-messages';
3
+ import { NETWORK_TARGET } from './network';
4
+ import { matchesBody, matchesSpec } from './spec-matching';
5
+ import { inFlightCount, QUIESCENCE_TIMEOUT_MS, RETRY_INTERVAL_MS, snapshotTraffic, waitForNetworkIdle, } from './traffic-registry';
6
+ // 1s of margin on top of polling every RETRY_INTERVAL_MS: enough for a
7
+ // request fired right after the assert to have time to complete its real
8
+ // trip through the service worker (browser mode, not a same-thread mock).
9
+ const RETRY_TIMEOUT_MS = 1000;
10
+ function sleep(ms) {
11
+ return new Promise((resolve) => setTimeout(resolve, ms));
12
+ }
13
+ // The shorthand `toHaveRespondedWith(500)` and the long form
14
+ // `toHaveRespondedWith({ status: 500 })` coexist in the public signature so
15
+ // the common case doesn't force wrapping an object; normalizing here, once,
16
+ // avoids spreading the `typeof` check across the rest of the matcher.
17
+ function normalizeExpectedResponse(expected) {
18
+ return typeof expected === 'number' ? { status: expected } : expected;
19
+ }
20
+ /**
21
+ * Polls `snapshotTraffic()` until an entry matches `spec` and satisfies
22
+ * `isSatisfyingEntry`, or until `RETRY_TIMEOUT_MS` runs out. Returns the
23
+ * last traffic seen even if there was never a positive verdict: the
24
+ * matchers need it to compose the failure message.
25
+ */
26
+ async function pollTraffic(spec, isSatisfyingEntry) {
27
+ const deadline = Date.now() + RETRY_TIMEOUT_MS;
28
+ for (;;) {
29
+ const traffic = await snapshotTraffic();
30
+ const pass = traffic.some((entry) => matchesSpec(entry, spec) && isSatisfyingEntry(entry));
31
+ if (pass || Date.now() >= deadline)
32
+ return { traffic, pass };
33
+ await sleep(RETRY_INTERVAL_MS);
34
+ }
35
+ }
36
+ /**
37
+ * Waits for the network to settle (two consecutive calm observations,
38
+ * separated by a poll interval) and returns a single snapshot of the
39
+ * traffic accumulated up to that point.
40
+ *
41
+ * Use it to assert an ABSENCE or an EXACT COUNT, never for the positive,
42
+ * retrying case (that's `pollTraffic`): it's the basis for the negated
43
+ * branch of `resolveTraffic` and for `toHaveBeenRequestedTimes`, and
44
+ * `network.log()` (network.ts) also uses it, outside this file.
45
+ *
46
+ * Total budget `QUIESCENCE_TIMEOUT_MS`: if the network doesn't settle in
47
+ * time, it throws with a dump of what's pending instead of returning a mute
48
+ * boolean. Full design rationale in the comment block right below the
49
+ * function.
50
+ */
51
+ export async function snapshotAfterIdle() {
52
+ const deadline = performance.now() + QUIESCENCE_TIMEOUT_MS;
53
+ await sleep(RETRY_INTERVAL_MS);
54
+ for (;;) {
55
+ // Second argument: if this sub-call runs out its remaining budget and
56
+ // throws, the message has to talk about the TOTAL budget, not the
57
+ // remainder it happened to get (I2 — otherwise the last lap would say
58
+ // "did not settle within 1ms" after having waited almost the entire
59
+ // QUIESCENCE_TIMEOUT_MS).
60
+ await waitForNetworkIdle(Math.max(1, deadline - performance.now()), QUIESCENCE_TIMEOUT_MS);
61
+ await sleep(RETRY_INTERVAL_MS);
62
+ if (inFlightCount() === 0)
63
+ return snapshotTraffic();
64
+ }
65
+ }
66
+ // Full design and rationale, for whoever reviews this later:
67
+ //
68
+ // Exported (instead of staying private to this file) because network.log()
69
+ // needs the same calm-wait without copying it or calling waitForNetworkIdle
70
+ // raw, which would reopen the blind window this function closes. Any future
71
+ // matcher with the same need (negated forms of toHaveRespondedWith) can call
72
+ // it as-is without needing to export anything else, as long as it lives in
73
+ // this file alongside the rest of the matchers.
74
+ //
75
+ // A single fixed margin before `waitForNetworkIdle` isn't enough: it's a
76
+ // timing assumption measured in this harness (real page-service
77
+ // worker-page round trip of 1 to 6 ms, see
78
+ // docs/knowledge/quiescencia-red-msw.md), not a guarantee. On a loaded CI
79
+ // that round trip can stretch past the margin while the margin's own
80
+ // `setTimeout` keeps its own clock — the calm check would read the registry
81
+ // as empty right when the request is still on its way, precisely in the
82
+ // tests that exist to prove that doesn't happen. That's why a STABILITY
83
+ // CONDITION is required, not a margin: two consecutive observations
84
+ // separated by a poll interval, both calm (`inFlightCount() === 0`). If the
85
+ // second one sees new traffic (arrived during the wait for the first), the
86
+ // calm wait repeats and the check runs again.
87
+ //
88
+ // This does NOT close the blind window: no bounded wait can, that's the
89
+ // preregistered position of docs/knowledge/quiescencia-red-msw.md. It only
90
+ // narrows the practical failure bound from one interval to two — reasoned
91
+ // from how `waitForNetworkIdle` works, not measured: this harness is too
92
+ // fast to reproduce the contended CI that motivates the change. The
93
+ // DETERMINISM tests in matchers-quiescence.test.ts measure something real
94
+ // but different: that the full mechanism (margin + double observation) is
95
+ // needed against a naive check or no wait at all, not that the second
96
+ // observation alone is detectable in this harness (verified by mutation,
97
+ // see docs/knowledge/quiescencia-red-msw.md).
98
+ //
99
+ // The loop needs ONE clock budget for the whole operation, not a fresh one
100
+ // per lap: `waitForNetworkIdle()` with no argument starts its own
101
+ // `QUIESCENCE_TIMEOUT_MS` every time it's called, so a relay of overlapping
102
+ // requests (each one settles the counter just before another starts during
103
+ // the trailing margin) would make the loop never converge or throw — it
104
+ // would die by Vitest's generic timeout, without the dump of what's
105
+ // pending. `deadline` is computed once on entry and each sub-call gets
106
+ // whatever budget is left, never a fresh full one again. If the budget runs
107
+ // out while the counter happens to be at zero, the current sub-call returns
108
+ // right away (nothing to wait for), the check that follows fails if
109
+ // something started meanwhile, and the next sub-call gets ~1 ms: if
110
+ // something is in flight, its own loop detects it once that remainder
111
+ // passes and throws with the dump — bounded to, at most, one extra
112
+ // `RETRY_INTERVAL_MS` on top of the global budget, never unbounded.
113
+ /**
114
+ * Single decision point between retrying and waiting for calm. `.not`
115
+ * needs quiescence: retrying until something shows up, applied to an
116
+ * absence, would give a false positive with any request still in flight
117
+ * (the DETERMINISM test in matchers-quiescence.test.ts catches exactly
118
+ * that). The positive case keeps the retrying poll because there it's the
119
+ * request arriving that matters, not the whole network going quiet.
120
+ */
121
+ async function resolveTraffic(spec, isSatisfyingEntry, isNot) {
122
+ if (!isNot)
123
+ return pollTraffic(spec, isSatisfyingEntry);
124
+ const traffic = await snapshotAfterIdle();
125
+ return {
126
+ traffic,
127
+ pass: traffic.some((entry) => matchesSpec(entry, spec) && isSatisfyingEntry(entry)),
128
+ };
129
+ }
130
+ expect.extend({
131
+ async toHaveBeenRequested(spec) {
132
+ const { traffic, pass } = await resolveTraffic(spec, () => true, this.isNot);
133
+ return {
134
+ pass,
135
+ message: () => requestFailureMessage({
136
+ utils: this.utils,
137
+ matcherName: 'toHaveBeenRequested',
138
+ spec,
139
+ traffic,
140
+ isNot: this.isNot,
141
+ }),
142
+ };
143
+ },
144
+ async toHaveBeenIntercepted(spec) {
145
+ // Neither passthrough (matched without mocked) nor the 500 MSW
146
+ // fabricates in error mode (mocked without matched) count as
147
+ // intercepted: the handler itself has to have responded. See
148
+ // docs/knowledge/msw-browser-mode.md.
149
+ const { traffic, pass } = await resolveTraffic(spec, (entry) => entry.matched && entry.mocked, this.isNot);
150
+ return {
151
+ pass,
152
+ message: () => requestFailureMessage({
153
+ utils: this.utils,
154
+ matcherName: 'toHaveBeenIntercepted',
155
+ spec,
156
+ traffic,
157
+ isNot: this.isNot,
158
+ }),
159
+ };
160
+ },
161
+ async toHaveRespondedWith(spec, expected) {
162
+ const expectedResponse = normalizeExpectedResponse(expected);
163
+ // The above (intercepted: neither passthrough nor an MSW-fabricated
164
+ // error, see toHaveBeenIntercepted above), plus this response: exact
165
+ // status plus, if given, a subset of the response body using the same
166
+ // rules matchesSpec applies to the request.
167
+ const isSatisfyingEntry = (entry) => entry.matched &&
168
+ entry.mocked &&
169
+ entry.status === expectedResponse.status &&
170
+ (expectedResponse.body === undefined ||
171
+ matchesBody(entry.responseBody, expectedResponse.body, expectedResponse.exact));
172
+ const { traffic, pass } = await resolveTraffic(spec, isSatisfyingEntry, this.isNot);
173
+ return {
174
+ pass,
175
+ message: () => respondedWithFailureMessage({
176
+ utils: this.utils,
177
+ spec,
178
+ expected: expectedResponse,
179
+ traffic,
180
+ isNot: this.isNot,
181
+ }),
182
+ };
183
+ },
184
+ async toHaveBeenRequestedTimes(spec, count) {
185
+ // Always waits for calm, negated with `.not` or not: an exact count
186
+ // taken mid-traffic is as false as an absence taken mid-traffic (the
187
+ // other DETERMINISM test in the same test file).
188
+ const traffic = await snapshotAfterIdle();
189
+ const matchingEntries = traffic.filter((entry) => matchesSpec(entry, spec));
190
+ const foundCount = matchingEntries.length;
191
+ return {
192
+ pass: foundCount === count,
193
+ message: () => requestCountFailureMessage({
194
+ utils: this.utils,
195
+ spec,
196
+ expectedCount: count,
197
+ foundCount,
198
+ traffic,
199
+ isNot: this.isNot,
200
+ }),
201
+ };
202
+ },
203
+ async toHaveNoUnhandledRequests(received) {
204
+ // This matcher doesn't describe a request: it hangs off
205
+ // `expect.network()`, the only place that produces the
206
+ // `NETWORK_TARGET` marker. Used on anything else (a `get(...)`
207
+ // descriptor, a string) is a usage error, not an assertion failing on
208
+ // data.
209
+ if (received !== NETWORK_TARGET) {
210
+ return {
211
+ pass: false,
212
+ message: () => 'toHaveNoUnhandledRequests is used through expect.network(), not on a request descriptor: expect.network().toHaveNoUnhandledRequests()',
213
+ };
214
+ }
215
+ const traffic = await snapshotAfterIdle();
216
+ const unhandledEntries = traffic.filter((entry) => entry.unhandled);
217
+ return {
218
+ pass: unhandledEntries.length === 0,
219
+ message: () => noUnhandledRequestsFailureMessage({
220
+ utils: this.utils,
221
+ unhandledEntries,
222
+ traffic,
223
+ isNot: this.isNot,
224
+ }),
225
+ };
226
+ },
227
+ });
228
+ // Vitest doesn't allow declaring new properties on `ExpectStatic` via
229
+ // `expect.extend`: `expect.extend` only installs matchers inside an
230
+ // `Assertion`. `expect.network()` needs to be a top-level function on
231
+ // `expect` itself, so it's assigned directly; the type shape comes from the
232
+ // `ExpectStatic` augmentation in matcher-types.ts.
233
+ expect.network = () => expect(NETWORK_TARGET);
@@ -0,0 +1,45 @@
1
+ import type { ReactElement } from 'react';
2
+ import type { RequestHandler } from 'msw';
3
+ import { type RenderResult } from 'vitest-browser-react';
4
+ /** Options for {@link mount}. Both are optional: `mount(<App />)` alone just mounts. */
5
+ export interface MountOptions {
6
+ /**
7
+ * Same-origin URI starting with `/`, query and hash included (for example
8
+ * `/products?filter=bread#detail`). Applied with `history.pushState`
9
+ * before render so the application's router reads it on mount.
10
+ */
11
+ path?: string;
12
+ /**
13
+ * MSW handlers of this test's own. Installed with `worker.use()` before
14
+ * render, so they take priority over the suite's for the same route, and
15
+ * `setupNetwork()` undoes them in its `afterEach`.
16
+ */
17
+ network?: RequestHandler[];
18
+ }
19
+ /**
20
+ * Mounts `ui` with `vitest-browser-react`, optionally on a real document
21
+ * route and with test-specific MSW handlers.
22
+ *
23
+ * `path`, if given, has to be a same-origin URI starting with `/`: it's
24
+ * applied with `history.pushState` BEFORE render because the application's
25
+ * `BrowserRouter` reads the URL on mount and only listens to `popstate`
26
+ * afterwards (measured — see
27
+ * `.claude/docs/references/measured-foundations.md`). The URI can carry
28
+ * query and hash: they flow through the router the same as the path.
29
+ * `setupNetwork()` restores the original URL in its `afterEach`, so every
30
+ * test starts from the same route regardless of what the previous one
31
+ * mounted.
32
+ *
33
+ * `network`, if given, is registered with `worker.use()` before render: its
34
+ * handlers take priority over the suite's for this request, with the same
35
+ * resolution rules as MSW.
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * import { mount } from '@yabbadabbadev/pepito'
40
+ *
41
+ * const screen = await mount(<App />, { path: '/products?filter=bread' })
42
+ * await expect.element(screen.getByText('filter: bread')).toBeVisible()
43
+ * ```
44
+ */
45
+ export declare function mount(ui: ReactElement, options?: MountOptions): Promise<RenderResult>;
package/dist/mount.js ADDED
@@ -0,0 +1,61 @@
1
+ import { render } from 'vitest-browser-react';
2
+ import { requireNetworkContext } from './network-singleton';
3
+ // The '/' prefix alone isn't enough: '//evil.com' is protocol-relative and
4
+ // WHATWG also treats '/\' as introducing an authority, so both would pass
5
+ // it and pushState would be the one throwing the raw SecurityError further
6
+ // down.
7
+ function isSameOriginPath(path) {
8
+ if (!path.startsWith('/'))
9
+ return false;
10
+ try {
11
+ return new URL(path, location.origin).origin === location.origin;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ /**
18
+ * Mounts `ui` with `vitest-browser-react`, optionally on a real document
19
+ * route and with test-specific MSW handlers.
20
+ *
21
+ * `path`, if given, has to be a same-origin URI starting with `/`: it's
22
+ * applied with `history.pushState` BEFORE render because the application's
23
+ * `BrowserRouter` reads the URL on mount and only listens to `popstate`
24
+ * afterwards (measured — see
25
+ * `.claude/docs/references/measured-foundations.md`). The URI can carry
26
+ * query and hash: they flow through the router the same as the path.
27
+ * `setupNetwork()` restores the original URL in its `afterEach`, so every
28
+ * test starts from the same route regardless of what the previous one
29
+ * mounted.
30
+ *
31
+ * `network`, if given, is registered with `worker.use()` before render: its
32
+ * handlers take priority over the suite's for this request, with the same
33
+ * resolution rules as MSW.
34
+ *
35
+ * @example
36
+ * ```tsx
37
+ * import { mount } from '@yabbadabbadev/pepito'
38
+ *
39
+ * const screen = await mount(<App />, { path: '/products?filter=bread' })
40
+ * await expect.element(screen.getByText('filter: bread')).toBeVisible()
41
+ * ```
42
+ */
43
+ export async function mount(ui, options = {}) {
44
+ const { worker } = requireNetworkContext('mount');
45
+ const { path, network: testHandlers } = options;
46
+ if (path !== undefined && !isSameOriginPath(path)) {
47
+ throw new Error(`pepito: path must be a same-origin URI that starts with '/'; ` +
48
+ `received: ${path}. A different origin is mocked in the MSW ` +
49
+ `handlers, not in the mount.`);
50
+ }
51
+ // Before render: the app's router reads the URL on mount and only
52
+ // listens to popstate afterwards. See
53
+ // docs/knowledge/url-navegacion-browser-mode.md.
54
+ if (path !== undefined) {
55
+ history.pushState({}, '', path);
56
+ }
57
+ if (testHandlers !== undefined && testHandlers.length > 0) {
58
+ worker.use(...testHandlers);
59
+ }
60
+ return render(ui);
61
+ }
@@ -0,0 +1,6 @@
1
+ import type { SetupWorker } from 'msw/browser';
2
+ /**
3
+ * Hooks up the traffic registry (traffic-registry.ts) to MSW's events.
4
+ * `setupNetwork()` calls it once per worker, before `worker.start()`.
5
+ */
6
+ export declare function watchNetwork(events: SetupWorker['events']): void;