@yabbadabbadev/pepito 0.1.0 → 0.2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0](https://github.com/yabbadabbadev/pepito/compare/v0.1.1...v0.2.0) (2026-09-02)
4
+
5
+
6
+ ### Features
7
+
8
+ * framework-agnostic core + subpath adapters for /react, /vue, /svelte ([#9](https://github.com/yabbadabbadev/pepito/issues/9)) ([2a1ab74](https://github.com/yabbadabbadev/pepito/commit/2a1ab74274d8c8ac03980f368cde8260c7330f3a))
9
+
10
+ ## [0.1.1](https://github.com/yabbadabbadev/pepito/compare/v0.1.0...v0.1.1) (2026-08-20)
11
+
12
+ Nothing changed for consumers of this package: no new API, no fix, no
13
+ behaviour change. `0.1.1` exists to exercise the publishing path end to end,
14
+ and it is the first release published from a public repository, so it is the
15
+ first to carry a provenance attestation.
16
+
17
+ If you are on `0.1.0`, there is no reason to upgrade beyond wanting the
18
+ attestation.
19
+
3
20
  ## 0.1.0 — 2026-08-14
4
21
 
5
22
  First release: `setupNetwork`, `mount`, request descriptors (`get`, `post`,
package/README.md CHANGED
@@ -6,28 +6,22 @@ doesn't abstract MSW — handlers are still declared with the usual
6
6
  `http.get(...)` — it only provides the startup, the traffic registry and the
7
7
  matchers to query it.
8
8
 
9
- Published on npm as `@yabbadabbadev/pepito`; `pepito` remains the project's
10
- code-name and this directory's name. See `CONTRIBUTING.md` to run the tests
11
- and publish a version, and `ROADMAP.md` for what's out of scope for this
12
- version.
13
-
14
9
  ## 1. Install and start
15
10
 
16
- `react`, `react-dom`, `vitest`, `msw` and `vitest-browser-react` are
17
- **peerDependencies**: install them if your project doesn't already have
18
- them.
11
+ `vitest` and `msw` are **peerDependencies**: install them if your project
12
+ doesn't already have them.
13
+
14
+ ### Core (any framework)
15
+
16
+ The core — `setupNetwork`, matchers, request descriptors, `network.log()` —
17
+ works without any framework adapter.
19
18
 
20
19
  ```bash
21
- npm i -D @yabbadabbadev/pepito msw vitest-browser-react
20
+ npm i -D @yabbadabbadev/pepito msw
22
21
  npx msw init public --save
23
22
  ```
24
23
 
25
- `npx msw init public --save` generates the service worker MSW needs in
26
- browser mode (`public/mockServiceWorker.js`); it's a one-off, not a
27
- day-to-day step — it's only repeated when upgrading MSW's version.
28
-
29
- Call `setupNetwork` once, in a `setupFiles` file of `vitest.config` — never
30
- inside a test:
24
+ Call `setupNetwork` once, in a `setupFiles` file of `vitest.config`:
31
25
 
32
26
  ```ts
33
27
  // vitest.setup.ts
@@ -47,6 +41,70 @@ export default defineConfig({
47
41
  })
48
42
  ```
49
43
 
44
+ ### React
45
+
46
+ ```bash
47
+ npm i -D @yabbadabbadev/pepito msw vitest-browser-react
48
+ npx msw init public --save
49
+ ```
50
+
51
+ ```tsx
52
+ import { mount } from '@yabbadabbadev/pepito/react'
53
+ import { App } from '../src/App'
54
+
55
+ const screen = await mount(<App />, { path: '/products' })
56
+ ```
57
+
58
+ ### Vue
59
+
60
+ ```bash
61
+ npm i -D @yabbadabbadev/pepito msw vitest-browser-vue
62
+ npx msw init public --save
63
+ ```
64
+
65
+ ```ts
66
+ import { mount } from '@yabbadabbadev/pepito/vue'
67
+ import App from '../src/App.vue'
68
+
69
+ const screen = await mount(App, { path: '/products' })
70
+ ```
71
+
72
+ ### Svelte
73
+
74
+ ```bash
75
+ npm i -D @yabbadabbadev/pepito msw vitest-browser-svelte
76
+ npx msw init public --save
77
+ ```
78
+
79
+ ```ts
80
+ import { mount } from '@yabbadabbadev/pepito/svelte'
81
+ import App from '../src/App.svelte'
82
+
83
+ const screen = await mount(App, { path: '/products' })
84
+ ```
85
+
86
+ ### Custom framework adapter
87
+
88
+ If you use a framework without an official subpath (Lit, Preact, Angular,
89
+ Solid, …), build your own adapter with `mountCore`:
90
+
91
+ ```ts
92
+ import { render } from 'vitest-browser-lit'
93
+ import { mountCore } from '@yabbadabbadev/pepito'
94
+
95
+ export function mount(
96
+ component: unknown,
97
+ options?: Parameters<typeof mountCore>[2],
98
+ ) {
99
+ return mountCore(component, (c) => render(c as any), options)
100
+ }
101
+ ```
102
+
103
+ `mountCore` applies `pushState` for routing and registers test-specific
104
+ MSW handlers before calling your `render` function. Its return type is
105
+ generic: it infers the full typed result from whatever your `render`
106
+ returns.
107
+
50
108
  `setupNetwork`'s second argument passes straight through to
51
109
  `worker.start()`, with no wrapper of its own — for example, to make a
52
110
  request with no handler fail the test instead of just warning on the
@@ -60,23 +118,26 @@ import { handlers } from './handlers'
60
118
  setupNetwork(handlers, { onUnhandledRequest: 'error' })
61
119
  ```
62
120
 
63
- Importing anything from `pepito` — here, `setupNetwork` — already brings the
64
- network matchers along as `expect` types: there's no separate type
65
- registration. If your test `tsconfig` doesn't include the setup file, `tsc`
66
- won't see the augmentation and `expect(...).toHaveBeenRequested()` will
67
- raise `TS2339` even though the test passes at runtime.
121
+ Importing anything from `pepito` — even just `setupNetwork` — already
122
+ brings the network matchers along as `expect` types: there's no separate
123
+ type registration. If your test `tsconfig` doesn't include the setup
124
+ file, `tsc` won't see the augmentation and
125
+ `expect(...).toHaveBeenRequested()` will raise `TS2339` even though the
126
+ test passes at runtime.
68
127
 
69
128
  ## 2. Mount the application
70
129
 
71
- `mount` mounts with `vitest-browser-react` and returns its `screen`
72
- unwrapped. It requires `setupNetwork` to have run first (section 1), **even
73
- for a test with no network**: the coupling is deliberate `mount` also
74
- installs URL and storage cleanup between tests, not just the network — and
75
- if it's missing, it fails immediately with a fix instruction.
130
+ `mount` (from `@yabbadabbadev/pepito/react`) mounts with
131
+ `vitest-browser-react` and returns its `screen` unwrapped. It requires
132
+ `setupNetwork` to have run first (section 1), **even for a test with no
133
+ network**: the coupling is deliberate `mount` also installs URL and
134
+ storage cleanup between tests, not just the network and if it's missing,
135
+ it fails immediately with a fix instruction.
76
136
 
77
137
  ```tsx
78
138
  import { http, HttpResponse } from 'msw'
79
- import { mount, get } from '@yabbadabbadev/pepito'
139
+ import { get } from '@yabbadabbadev/pepito'
140
+ import { mount } from '@yabbadabbadev/pepito/react'
80
141
  import { App } from '../src/App'
81
142
  import { ProductListMother } from '../test/mothers/product-list-mother'
82
143
 
@@ -107,7 +168,7 @@ route, and `setupNetwork()` undoes them afterwards in its `afterEach`.
107
168
  Neither option is required — `mount(<App />)` on its own just mounts:
108
169
 
109
170
  ```tsx
110
- import { mount } from '@yabbadabbadev/pepito'
171
+ import { mount } from '@yabbadabbadev/pepito/react'
111
172
  import { App } from '../src/App'
112
173
 
113
174
  test('mounts with no path or network of its own', async () => {
@@ -129,6 +190,16 @@ await expect.element(screen.getByText('hash: #detail')).toBeVisible()
129
190
 
130
191
  ## 3. Assert a request
131
192
 
193
+ The five matchers, at a glance:
194
+
195
+ | Matcher | What it asserts |
196
+ | --------------------------- | ----------------------------------------------------------------------- |
197
+ | `toHaveBeenRequested` | The application made the request |
198
+ | `toHaveBeenRequestedTimes` | Exactly `count` matching requests were made |
199
+ | `toHaveBeenIntercepted` | One of your handlers produced the response |
200
+ | `toHaveRespondedWith` | The intercepted response has the expected status/body |
201
+ | `toHaveNoUnhandledRequests` | No observed request was left without a handler (via `expect.network()`) |
202
+
132
203
  ```ts
133
204
  import { get, post } from '@yabbadabbadev/pepito'
134
205
 
@@ -199,14 +270,8 @@ happened.
199
270
 
200
271
  ## 4. Intercepted or escaped
201
272
 
202
- `toHaveBeenRequested` and `toHaveBeenIntercepted` assert different things:
203
-
204
- | Matcher | What it asserts |
205
- | ----------------------- | ------------------------------------------ |
206
- | `toHaveBeenRequested` | The application made the request |
207
- | `toHaveBeenIntercepted` | One of your handlers produced the response |
208
-
209
- A handler with `passthrough()` satisfies the first and not the second: the
273
+ `toHaveBeenRequested` and `toHaveBeenIntercepted` assert different things
274
+ (see the table in section 3). A handler with `passthrough()` satisfies the first and not the second: the
210
275
  response came from the real network, not from your mock.
211
276
 
212
277
  ```ts
@@ -315,198 +380,19 @@ the product and are tested like any other output (see `CONTRIBUTING.md`).
315
380
 
316
381
  ## 7. Recipes
317
382
 
318
- **Different responses across successive calls.** No `pepito` API is needed
319
- for this: it's plain MSW, with chained handlers. The first one that matches
320
- wins, and if it carries `{ once: true }` it deactivates after that first
321
- time, so the request falls through to the next handler in the list:
322
-
323
- ```tsx
324
- import { http, HttpResponse } from 'msw'
325
- import { get, mount } from '@yabbadabbadev/pepito'
326
- import { App } from '../src/App'
327
- import { ProductListMother } from '../test/mothers/product-list-mother'
328
-
329
- test('the second visit already sees the cached catalog', async () => {
330
- await mount(<App />, {
331
- network: [
332
- http.get(
333
- '/api/products',
334
- () => HttpResponse.json(ProductListMother.empty()),
335
- { once: true },
336
- ),
337
- http.get('/api/products', () =>
338
- HttpResponse.json(ProductListMother.catalog()),
339
- ),
340
- ],
341
- })
342
-
343
- await fetch('/api/products') // 1st: the once handler responds and is spent
344
- await fetch('/api/products') // 2nd: falls through to the handler below
345
- await fetch('/api/products') // 3rd: the one below isn't once, keeps responding
383
+ Task-oriented answers for what `pepito` deliberately doesn't wrap in its own
384
+ API: chaining handlers for successive responses, seeding storage before
385
+ mounting, the Mothers pattern for fixtures, cross-origin handlers, and
386
+ waiting for the network to settle before a screenshot. See
387
+ [`docs/recipes.md`](docs/recipes.md).
346
388
 
347
- await expect(get('/api/products')).toHaveBeenRequestedTimes(3)
348
- await expect(get('/api/products')).toHaveRespondedWith({
349
- status: 200,
350
- body: ProductListMother.empty(),
351
- })
352
- await expect(get('/api/products')).toHaveRespondedWith({
353
- status: 200,
354
- body: ProductListMother.catalog(),
355
- })
356
- })
357
- ```
358
-
359
- Verified in this repo: first call → empty catalog, second and third → full
360
- catalog (X→Y→Y). `setupNetwork()` undoes the test handlers in its
361
- `afterEach` with `worker.resetHandlers()`, so every following test sees a
362
- fresh `once` again, with no exhaustion carrying over between tests.
363
-
364
- Each `toHaveRespondedWith` above finds its own entry in the registry — one
365
- matches the empty response, the other the catalog — regardless of the order
366
- the `expect`s are written in. What `pepito` doesn't have yet is a matcher
367
- that asserts the ORDER between two identical requests (for example, "the
368
- empty one before the catalog"): it's in `ROADMAP.md`, because the registry
369
- is already chronological and only needs exposing.
370
-
371
- For sequences odder than `once` can express well (three different
372
- responses, or a condition that isn't "the first time"), a counter closure
373
- declared inside the test itself is the alternative — with no changes to
374
- `pepito`:
375
-
376
- ```ts
377
- test('three different responses for the same route', async () => {
378
- let callCount = 0
379
-
380
- await mount(<App />, {
381
- network: [
382
- http.get('/api/products', () => {
383
- callCount += 1
384
- if (callCount === 1) return HttpResponse.json([], { status: 202 })
385
- if (callCount === 2) return HttpResponse.json([], { status: 500 })
386
- return HttpResponse.json(ProductListMother.catalog())
387
- }),
388
- ],
389
- })
390
-
391
- await fetch('/api/products')
392
- await fetch('/api/products')
393
- await fetch('/api/products')
394
-
395
- await expect(get('/api/products')).toHaveRespondedWith(202)
396
- await expect(get('/api/products')).toHaveRespondedWith(500)
397
- })
398
- ```
399
-
400
- **Seeding `localStorage`/cookies before mounting.** The browser's storage is
401
- real; no `mount` option is needed for this, a plain `setItem` before `await
402
- mount(...)` is enough:
403
-
404
- ```ts
405
- test('the catalog respects the saved filter', async () => {
406
- localStorage.setItem('favoriteFilter', 'bread')
407
- document.cookie = 'session=abc'
408
-
409
- const screen = await mount(<App />)
410
-
411
- await expect.element(screen.getByText('filter: bread')).toBeVisible()
412
- })
413
- ```
414
-
415
- **Fixtures with Mothers.** Mocked response payloads follow the house's
416
- Mother/Builder pattern — each Mother models the shape of one endpoint's
417
- response, with named factories for its variants; no loose literals and no
418
- `structuredClone` with mutation, not even in examples copied from here:
419
-
420
- ```ts
421
- // test/mothers/product-list-mother.ts
422
- interface ProductResponse {
423
- id: number
424
- product_name: string
425
- }
426
-
427
- const milk = { id: 1, product_name: 'Whole milk' } satisfies ProductResponse
428
- const bread = { id: 2, product_name: 'Country bread' } satisfies ProductResponse
429
-
430
- export const ProductListMother = {
431
- catalog: (): ProductResponse[] => [milk, bread],
432
- empty: (): ProductResponse[] => [],
433
- }
434
- ```
435
-
436
- ```ts
437
- http.get('/api/products', () => HttpResponse.json(ProductListMother.catalog()))
438
- ```
439
-
440
- An incidental payload that isn't an entity (`{ ok: true }`) doesn't need a
441
- Mother. `pepito` doesn't export fixture infrastructure — Mothers belong to
442
- each project's own domain.
443
-
444
- **The API's host goes in the handler, not in the configuration.** There's no
445
- `defaultHost`: the service worker intercepts cross-origin the same as
446
- same-origin, so the host is a detail of the handler's URL.
447
-
448
- ```ts
449
- http.get('https://api.example.com/products', () =>
450
- HttpResponse.json(ProductListMother.catalog()),
451
- )
452
- // or with a wildcard:
453
- http.get('*/products', () => HttpResponse.json(ProductListMother.catalog()))
454
- ```
455
-
456
- `mount({ path })`, by contrast, does require same-origin — a different
457
- origin there throws with an instruction, because `path` moves the test
458
- document's URL, not the request you want to mock.
459
-
460
- **Waiting for the network to settle before capturing.**
461
- `toMatchScreenshot`'s native stabilizer waits for the frame to stop
462
- changing, but a static `<p>Loading…</p>` is also a stable capture: without
463
- waiting for the network, the baseline is the loading screen, not the real
464
- content. Measured in the evaluation repo's
465
- `docs/knowledge/regresion-visual-browser-mode.md`: 0 failures across 17
466
- local runs + 3 in CI waiting for calm this way, with
467
- baselines byte-identical to the ones anchored by `expect.element` to a
468
- specific text. `network.idle()` waits with the same mechanism the network
469
- matchers use, without asserting anything:
470
-
471
- ```tsx
472
- import { mount, network } from '@yabbadabbadev/pepito'
473
- import { App } from '../src/App'
474
-
475
- test('the catalog does not change visually', async () => {
476
- const screen = await mount(<App />)
389
+ ## About the name
477
390
 
478
- await network.idle()
479
-
480
- await expect.element(screen.getByRole('main')).toMatchScreenshot('catalog')
481
- })
482
- ```
391
+ Published on npm as `@yabbadabbadev/pepito`; `pepito` remains the project's
392
+ code-name and this directory's name. See `CONTRIBUTING.md` to run the tests
393
+ and publish a version, and `ROADMAP.md` for what's out of scope for this
394
+ version.
483
395
 
484
- ## 8. Cheat sheet
396
+ ## License
485
397
 
486
- ```ts
487
- request('QUERY', '/api/products') // any method, including ones with no shortcut
488
- get('/api/products', { searchParams: { filter: 'bread' } }) // subset of searchParams
489
- post('/api/products', { body: { product_name: 'Whole milk' } }) // subset of body
490
- put('/api/products/1', { body: { stock: 3 }, exact: true }) // strict equality
491
- patch('/api/products/1', { body: { stock: 3 } })
492
- del('/api/products/1')
493
- query('/api/products', { searchParams: { filter: 'bread' } })
494
-
495
- await expect(get('/api/products')).toHaveBeenRequested() // the app made the request
496
- await expect(get('/api/products')).not.toHaveBeenRequested() // waits for calm first
497
- await expect(get('/api/products')).toHaveBeenRequestedTimes(2) // exact count, network settled
498
- await expect(get('/api/products')).toHaveBeenIntercepted() // one of your handlers responded
499
- await expect(get('/api/products')).toHaveRespondedWith(500) // shorthand for { status: 500 }
500
- await expect(get('/api/products')).toHaveRespondedWith({
501
- status: 200,
502
- body: { total: 2 }, // subset
503
- })
504
- await expect(get('/api/products')).toHaveRespondedWith({
505
- status: 200,
506
- body: { total: 2 },
507
- exact: true, // strict equality
508
- })
509
- await expect.network().toHaveNoUnhandledRequests() // guardrail: nothing without a handler
510
- await network.log() // dumps the observed traffic, without asserting
511
- await network.idle() // waits for calm, without asserting — before capturing in visual regression
512
- ```
398
+ MIT, see [`LICENSE`](LICENSE).
package/dist/index.d.ts CHANGED
@@ -3,5 +3,5 @@ import './matcher-types';
3
3
  export * from './request-descriptors';
4
4
  export { setupNetwork } from './setup-network';
5
5
  export { network } from './network';
6
- export { mount } from './mount';
7
- export type { MountOptions } from './mount';
6
+ export { mountCore } from './mount-core';
7
+ export type { MountOptions, MountResult } from './mount-core';
package/dist/index.js CHANGED
@@ -3,4 +3,4 @@ import './matcher-types';
3
3
  export * from './request-descriptors';
4
4
  export { setupNetwork } from './setup-network';
5
5
  export { network } from './network';
6
- export { mount } from './mount';
6
+ export { mountCore } from './mount-core';
@@ -0,0 +1,38 @@
1
+ import type { RequestHandler } from 'msw';
2
+ /** Options for {@link mountCore}. Both are optional. */
3
+ export interface MountOptions {
4
+ /**
5
+ * Same-origin URI starting with `/`, query and hash included (for example
6
+ * `/products?filter=bread#detail`). Applied with `history.pushState`
7
+ * before render so the application's router reads it on mount.
8
+ */
9
+ path?: string;
10
+ /**
11
+ * MSW handlers of this test's own. Installed with `worker.use()` before
12
+ * render, so they take priority over the suite's for the same route, and
13
+ * `setupNetwork()` undoes them in its `afterEach`.
14
+ */
15
+ network?: RequestHandler[];
16
+ }
17
+ /** The minimal contract every `vitest-browser-*` render result satisfies. */
18
+ export interface MountResult {
19
+ container: Element;
20
+ }
21
+ /**
22
+ * Framework-agnostic mount: applies `pushState` (if `path`), registers
23
+ * test-specific MSW handlers (if `network`), then calls `render(component)`.
24
+ *
25
+ * The `render` function is provided by the caller — each framework subpath
26
+ * (`/react`, `/vue`, `/svelte`) wraps this with its own `vitest-browser-*`
27
+ * render. See the "Custom framework adapter" section in the README for
28
+ * building your own.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * import { render } from 'vitest-browser-lit'
33
+ * import { mountCore } from '@yabbadabbadev/pepito'
34
+ *
35
+ * const screen = await mountCore(MyComponent, (c) => render(c as any))
36
+ * ```
37
+ */
38
+ export declare function mountCore<T extends MountResult>(component: unknown, render: (component: unknown) => T | Promise<T>, options?: MountOptions): Promise<T>;
@@ -0,0 +1,44 @@
1
+ import { requireNetworkContext } from './network-singleton';
2
+ function isSameOriginPath(path) {
3
+ if (!path.startsWith('/'))
4
+ return false;
5
+ try {
6
+ return new URL(path, location.origin).origin === location.origin;
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ /**
13
+ * Framework-agnostic mount: applies `pushState` (if `path`), registers
14
+ * test-specific MSW handlers (if `network`), then calls `render(component)`.
15
+ *
16
+ * The `render` function is provided by the caller — each framework subpath
17
+ * (`/react`, `/vue`, `/svelte`) wraps this with its own `vitest-browser-*`
18
+ * render. See the "Custom framework adapter" section in the README for
19
+ * building your own.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * import { render } from 'vitest-browser-lit'
24
+ * import { mountCore } from '@yabbadabbadev/pepito'
25
+ *
26
+ * const screen = await mountCore(MyComponent, (c) => render(c as any))
27
+ * ```
28
+ */
29
+ export async function mountCore(component, render, options = {}) {
30
+ const { worker } = requireNetworkContext('mountCore');
31
+ const { path, network: testHandlers } = options;
32
+ if (path !== undefined && !isSameOriginPath(path)) {
33
+ throw new Error(`pepito: path must be a same-origin URI that starts with '/'; ` +
34
+ `received: ${path}. A different origin is mocked in the MSW ` +
35
+ `handlers, not in the mount.`);
36
+ }
37
+ if (path !== undefined) {
38
+ history.pushState({}, '', path);
39
+ }
40
+ if (testHandlers !== undefined && testHandlers.length > 0) {
41
+ worker.use(...testHandlers);
42
+ }
43
+ return await Promise.resolve(render(component));
44
+ }
package/dist/network.d.ts CHANGED
@@ -34,7 +34,7 @@ export declare const network: {
34
34
  * absence of future traffic: the same practical blind window remains as
35
35
  * for the rest of the mechanism — a request fired in the same tick as the
36
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
37
+ * measured; see `.agents/docs/references/measured-foundations.md`), may
38
38
  * not be in the registry yet when `network.idle()` resolves.
39
39
  *
40
40
  * Meant for visual regression: capturing right after mounting, with a
@@ -42,7 +42,7 @@ export declare const network: {
42
42
  * native stabilizer of `toMatchScreenshot` doesn't catch it because
43
43
  * "Loading…" is also a capture that stops changing between frames
44
44
  * (measured: 0 failures across 17 local runs + 3 in CI waiting for calm
45
- * this way — see `.claude/docs/references/measured-foundations.md`).
45
+ * this way — see `.agents/docs/references/measured-foundations.md`).
46
46
  * Before this, the only public way to wait for calm on its own was to
47
47
  * divert `expect.network().toHaveNoUnhandledRequests()` from its actual
48
48
  * purpose (detecting traffic without a handler). It works just as well as a
package/dist/network.js CHANGED
@@ -45,7 +45,7 @@ export const network = {
45
45
  * absence of future traffic: the same practical blind window remains as
46
46
  * for the rest of the mechanism — a request fired in the same tick as the
47
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
48
+ * measured; see `.agents/docs/references/measured-foundations.md`), may
49
49
  * not be in the registry yet when `network.idle()` resolves.
50
50
  *
51
51
  * Meant for visual regression: capturing right after mounting, with a
@@ -53,7 +53,7 @@ export const network = {
53
53
  * native stabilizer of `toMatchScreenshot` doesn't catch it because
54
54
  * "Loading…" is also a capture that stops changing between frames
55
55
  * (measured: 0 failures across 17 local runs + 3 in CI waiting for calm
56
- * this way — see `.claude/docs/references/measured-foundations.md`).
56
+ * this way — see `.agents/docs/references/measured-foundations.md`).
57
57
  * Before this, the only public way to wait for calm on its own was to
58
58
  * divert `expect.network().toHaveNoUnhandledRequests()` from its actual
59
59
  * purpose (detecting traffic without a handler). It works just as well as a
@@ -0,0 +1,28 @@
1
+ import type { ReactElement } from 'react';
2
+ import { type RenderResult } from 'vitest-browser-react';
3
+ import type { MountOptions } from './mount-core';
4
+ /**
5
+ * Mounts a React element with `vitest-browser-react`, optionally on a real
6
+ * document route and with test-specific MSW handlers.
7
+ *
8
+ * `path`, if given, has to be a same-origin URI starting with `/`: it's
9
+ * applied with `history.pushState` BEFORE render because the application's
10
+ * `BrowserRouter` reads the URL on mount and only listens to `popstate`
11
+ * afterwards. The URI can carry query and hash: they flow through the router
12
+ * the same as the path. `setupNetwork()` restores the original URL in its
13
+ * `afterEach`, so every test starts from the same route regardless of what
14
+ * the previous one mounted.
15
+ *
16
+ * `network`, if given, is registered with `worker.use()` before render: its
17
+ * handlers take priority over the suite's for this request, with the same
18
+ * resolution rules as MSW.
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * import { mount } from '@yabbadabbadev/pepito/react'
23
+ *
24
+ * const screen = await mount(<App />, { path: '/products?filter=bread' })
25
+ * await expect.element(screen.getByText('filter: bread')).toBeVisible()
26
+ * ```
27
+ */
28
+ export declare function mount(ui: ReactElement, options?: MountOptions): Promise<RenderResult>;
package/dist/react.js ADDED
@@ -0,0 +1,29 @@
1
+ import { render } from 'vitest-browser-react';
2
+ import { mountCore } from './mount-core';
3
+ /**
4
+ * Mounts a React element with `vitest-browser-react`, optionally on a real
5
+ * document route and with test-specific MSW handlers.
6
+ *
7
+ * `path`, if given, has to be a same-origin URI starting with `/`: it's
8
+ * applied with `history.pushState` BEFORE render because the application's
9
+ * `BrowserRouter` reads the URL on mount and only listens to `popstate`
10
+ * afterwards. The URI can carry query and hash: they flow through the router
11
+ * the same as the path. `setupNetwork()` restores the original URL in its
12
+ * `afterEach`, so every test starts from the same route regardless of what
13
+ * the previous one mounted.
14
+ *
15
+ * `network`, if given, is registered with `worker.use()` before render: its
16
+ * handlers take priority over the suite's for this request, with the same
17
+ * resolution rules as MSW.
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * import { mount } from '@yabbadabbadev/pepito/react'
22
+ *
23
+ * const screen = await mount(<App />, { path: '/products?filter=bread' })
24
+ * await expect.element(screen.getByText('filter: bread')).toBeVisible()
25
+ * ```
26
+ */
27
+ export async function mount(ui, options) {
28
+ return mountCore(ui, (c) => render(c), options);
29
+ }
@@ -4,7 +4,7 @@ import type { SetupWorker, StartOptions } from 'msw/browser';
4
4
  * Starts the MSW worker and hooks up the traffic registry, leaving the
5
5
  * between-test cleanup installed in `afterEach`: registry, hot handlers,
6
6
  * document URL and origin storage all return to their pre-test state
7
- * (measured — see `.claude/docs/references/measured-foundations.md`).
7
+ * (measured — see `.agents/docs/references/measured-foundations.md`).
8
8
  *
9
9
  * Called once per test file, typically from a `setupFiles` entry in
10
10
  * `vitest.config`, never from inside a test.
@@ -14,7 +14,7 @@ import type { SetupWorker, StartOptions } from 'msw/browser';
14
14
  * survives between tests — see `clearOriginStorage` in storage-cleanup.ts.
15
15
  * A cookie without an explicit `path` does get cleared, including one set
16
16
  * while `mount` was simulating being on a nested route (measured — see
17
- * `.claude/docs/references/measured-foundations.md`).
17
+ * `.agents/docs/references/measured-foundations.md`).
18
18
  *
19
19
  * @param handlers - Initial MSW handlers, the same ones `setupWorker` would take.
20
20
  * @param startOptions - Passed through to `worker.start()` as-is; no wrapper of its own.
@@ -8,7 +8,7 @@ import { resetTraffic } from './traffic-registry';
8
8
  * Starts the MSW worker and hooks up the traffic registry, leaving the
9
9
  * between-test cleanup installed in `afterEach`: registry, hot handlers,
10
10
  * document URL and origin storage all return to their pre-test state
11
- * (measured — see `.claude/docs/references/measured-foundations.md`).
11
+ * (measured — see `.agents/docs/references/measured-foundations.md`).
12
12
  *
13
13
  * Called once per test file, typically from a `setupFiles` entry in
14
14
  * `vitest.config`, never from inside a test.
@@ -18,7 +18,7 @@ import { resetTraffic } from './traffic-registry';
18
18
  * survives between tests — see `clearOriginStorage` in storage-cleanup.ts.
19
19
  * A cookie without an explicit `path` does get cleared, including one set
20
20
  * while `mount` was simulating being on a nested route (measured — see
21
- * `.claude/docs/references/measured-foundations.md`).
21
+ * `.agents/docs/references/measured-foundations.md`).
22
22
  *
23
23
  * @param handlers - Initial MSW handlers, the same ones `setupWorker` would take.
24
24
  * @param startOptions - Passed through to `worker.start()` as-is; no wrapper of its own.
@@ -3,7 +3,7 @@
3
3
  * land on the same worker: `localStorage`, `sessionStorage` and cookies
4
4
  * belong to the origin, not the document, so they survive per-file
5
5
  * isolation (measured — see
6
- * `.claude/docs/references/measured-foundations.md`).
6
+ * `.agents/docs/references/measured-foundations.md`).
7
7
  *
8
8
  * Each cookie is expired twice per name: once with `path=/` (the one most
9
9
  * application code sets) and once with no `path` attribute, in case one was
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * Measured that it doesn't, in this harness (Chromium via Playwright,
18
18
  * `vitest@4.1.10`): the order has no observable effect today — full
19
- * evidence in `.claude/docs/references/measured-foundations.md`. The order
19
+ * evidence in `.agents/docs/references/measured-foundations.md`. The order
20
20
  * is kept anyway, at no cost, in case some future runner or browser does
21
21
  * follow the simulated URL.
22
22
  *
@@ -3,7 +3,7 @@
3
3
  * land on the same worker: `localStorage`, `sessionStorage` and cookies
4
4
  * belong to the origin, not the document, so they survive per-file
5
5
  * isolation (measured — see
6
- * `.claude/docs/references/measured-foundations.md`).
6
+ * `.agents/docs/references/measured-foundations.md`).
7
7
  *
8
8
  * Each cookie is expired twice per name: once with `path=/` (the one most
9
9
  * application code sets) and once with no `path` attribute, in case one was
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * Measured that it doesn't, in this harness (Chromium via Playwright,
18
18
  * `vitest@4.1.10`): the order has no observable effect today — full
19
- * evidence in `.claude/docs/references/measured-foundations.md`. The order
19
+ * evidence in `.agents/docs/references/measured-foundations.md`. The order
20
20
  * is kept anyway, at no cost, in case some future runner or browser does
21
21
  * follow the simulated URL.
22
22
  *
@@ -0,0 +1,23 @@
1
+ import { render, type RenderResult } from 'vitest-browser-svelte';
2
+ import type { MountOptions } from './mount-core';
3
+ /**
4
+ * Mounts a Svelte component with `vitest-browser-svelte`, optionally on a
5
+ * real document route and with test-specific MSW handlers.
6
+ *
7
+ * `path`, if given, has to be a same-origin URI starting with `/`: it's
8
+ * applied with `history.pushState` BEFORE render so the application's
9
+ * router reads it on mount. `setupNetwork()` restores the original URL
10
+ * in its `afterEach`.
11
+ *
12
+ * `network`, if given, is registered with `worker.use()` before render.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { mount } from '@yabbadabbadev/pepito/svelte'
17
+ * import App from './App.svelte'
18
+ *
19
+ * const screen = await mount(App, { path: '/products' })
20
+ * await expect.element(screen.getByText('Products')).toBeVisible()
21
+ * ```
22
+ */
23
+ export declare function mount(component: Parameters<typeof render>[0], options?: MountOptions): Promise<RenderResult<any>>;
package/dist/svelte.js ADDED
@@ -0,0 +1,25 @@
1
+ import { render } from 'vitest-browser-svelte';
2
+ import { mountCore } from './mount-core';
3
+ /**
4
+ * Mounts a Svelte component with `vitest-browser-svelte`, optionally on a
5
+ * real document route and with test-specific MSW handlers.
6
+ *
7
+ * `path`, if given, has to be a same-origin URI starting with `/`: it's
8
+ * applied with `history.pushState` BEFORE render so the application's
9
+ * router reads it on mount. `setupNetwork()` restores the original URL
10
+ * in its `afterEach`.
11
+ *
12
+ * `network`, if given, is registered with `worker.use()` before render.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { mount } from '@yabbadabbadev/pepito/svelte'
17
+ * import App from './App.svelte'
18
+ *
19
+ * const screen = await mount(App, { path: '/products' })
20
+ * await expect.element(screen.getByText('Products')).toBeVisible()
21
+ * ```
22
+ */
23
+ export async function mount(component, options) {
24
+ return mountCore(component, (c) => render(c), options);
25
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Traffic entry as it lives in the registry while the test runs: `body` and
3
3
  * `responseBody` are promises because they can only be read once (measured
4
- * — see `.claude/docs/references/measured-foundations.md`) and are stored
4
+ * — see `.agents/docs/references/measured-foundations.md`) and are stored
5
5
  * without awaiting, so as not to block the `request:start` listener that
6
6
  * opens them.
7
7
  */
@@ -26,7 +26,7 @@ export interface ResolvedRequest extends Omit<ObservedRequest, 'body' | 'respons
26
26
  }
27
27
  /**
28
28
  * Opens a traffic entry with what can only be read at `request:start`
29
- * (measured — see `.claude/docs/references/measured-foundations.md`) and
29
+ * (measured — see `.agents/docs/references/measured-foundations.md`) and
30
30
  * marks it in flight.
31
31
  * `watchNetwork` (msw-events.ts) is the only caller.
32
32
  */
@@ -73,7 +73,7 @@ export declare const QUIESCENCE_TIMEOUT_MS = 4000;
73
73
  * in the same tick as the call hasn't yet crossed the real round trip to
74
74
  * the service worker that registers its `request:start`, so this function
75
75
  * reads the counter at zero by construction, not by actual absence (see
76
- * `.claude/docs/references/measured-foundations.md`). Whoever needs to
76
+ * `.agents/docs/references/measured-foundations.md`). Whoever needs to
77
77
  * assert an absence or count with precision must go through
78
78
  * `snapshotAfterIdle` in `pepito/src/matchers.ts`, which closes that window
79
79
  * with a two-observation stability condition; calling this function
@@ -6,7 +6,7 @@ const traffic = new Map();
6
6
  const pending = new Set();
7
7
  /**
8
8
  * Opens a traffic entry with what can only be read at `request:start`
9
- * (measured — see `.claude/docs/references/measured-foundations.md`) and
9
+ * (measured — see `.agents/docs/references/measured-foundations.md`) and
10
10
  * marks it in flight.
11
11
  * `watchNetwork` (msw-events.ts) is the only caller.
12
12
  */
@@ -99,7 +99,7 @@ export const QUIESCENCE_TIMEOUT_MS = 4000;
99
99
  * in the same tick as the call hasn't yet crossed the real round trip to
100
100
  * the service worker that registers its `request:start`, so this function
101
101
  * reads the counter at zero by construction, not by actual absence (see
102
- * `.claude/docs/references/measured-foundations.md`). Whoever needs to
102
+ * `.agents/docs/references/measured-foundations.md`). Whoever needs to
103
103
  * assert an absence or count with precision must go through
104
104
  * `snapshotAfterIdle` in `pepito/src/matchers.ts`, which closes that window
105
105
  * with a two-observation stability condition; calling this function
package/dist/vue.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { render, type RenderResult } from 'vitest-browser-vue';
2
+ import type { MountOptions } from './mount-core';
3
+ /**
4
+ * Mounts a Vue component with `vitest-browser-vue`, optionally on a real
5
+ * document route and with test-specific MSW handlers.
6
+ *
7
+ * `path`, if given, has to be a same-origin URI starting with `/`: it's
8
+ * applied with `history.pushState` BEFORE render so the application's
9
+ * router reads it on mount. `setupNetwork()` restores the original URL
10
+ * in its `afterEach`.
11
+ *
12
+ * `network`, if given, is registered with `worker.use()` before render.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { mount } from '@yabbadabbadev/pepito/vue'
17
+ * import App from './App.vue'
18
+ *
19
+ * const screen = await mount(App, { path: '/products' })
20
+ * await expect.element(screen.getByText('Products')).toBeVisible()
21
+ * ```
22
+ */
23
+ export declare function mount(component: Parameters<typeof render>[0], options?: MountOptions): Promise<RenderResult<Record<string, unknown>>>;
package/dist/vue.js ADDED
@@ -0,0 +1,25 @@
1
+ import { render } from 'vitest-browser-vue';
2
+ import { mountCore } from './mount-core';
3
+ /**
4
+ * Mounts a Vue component with `vitest-browser-vue`, optionally on a real
5
+ * document route and with test-specific MSW handlers.
6
+ *
7
+ * `path`, if given, has to be a same-origin URI starting with `/`: it's
8
+ * applied with `history.pushState` BEFORE render so the application's
9
+ * router reads it on mount. `setupNetwork()` restores the original URL
10
+ * in its `afterEach`.
11
+ *
12
+ * `network`, if given, is registered with `worker.use()` before render.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { mount } from '@yabbadabbadev/pepito/vue'
17
+ * import App from './App.vue'
18
+ *
19
+ * const screen = await mount(App, { path: '/products' })
20
+ * await expect.element(screen.getByText('Products')).toBeVisible()
21
+ * ```
22
+ */
23
+ export async function mount(component, options) {
24
+ return mountCore(component, (c) => render(c), options);
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yabbadabbadev/pepito",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Network test utilities for Vitest browser mode: application mounting and matchers over the traffic observed by MSW",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,6 +26,18 @@
26
26
  ".": {
27
27
  "types": "./dist/index.d.ts",
28
28
  "default": "./dist/index.js"
29
+ },
30
+ "./react": {
31
+ "types": "./dist/react.d.ts",
32
+ "default": "./dist/react.js"
33
+ },
34
+ "./vue": {
35
+ "types": "./dist/vue.d.ts",
36
+ "default": "./dist/vue.js"
37
+ },
38
+ "./svelte": {
39
+ "types": "./dist/svelte.d.ts",
40
+ "default": "./dist/svelte.js"
29
41
  }
30
42
  },
31
43
  "files": [
@@ -51,13 +63,11 @@
51
63
  },
52
64
  "peerDependencies": {
53
65
  "msw": "^2.15.0",
54
- "react": "^19.0.0",
55
- "react-dom": "^19.0.0",
56
- "vitest": "^4.0.0",
57
- "vitest-browser-react": "^2.0.0"
66
+ "vitest": "^4.0.0"
58
67
  },
59
68
  "devDependencies": {
60
69
  "@eslint/js": "^9.39.5",
70
+ "@sveltejs/vite-plugin-svelte": "^7.3.0",
61
71
  "@types/react": "^19.2.18",
62
72
  "@types/react-dom": "^19.2.4",
63
73
  "@typescript-eslint/eslint-plugin": "^8.67.0",
@@ -80,9 +90,13 @@
80
90
  "react": "^19.2.8",
81
91
  "react-dom": "^19.2.8",
82
92
  "react-router": "^8.3.0",
93
+ "svelte": "^5.57.0",
83
94
  "typescript": "^6.0.3",
84
95
  "vitest": "^4.1.10",
85
- "vitest-browser-react": "^2.2.0"
96
+ "vitest-browser-react": "^2.2.0",
97
+ "vitest-browser-svelte": "^3.0.0",
98
+ "vitest-browser-vue": "^2.1.0",
99
+ "vue": "^3.5.42"
86
100
  },
87
101
  "msw": {
88
102
  "workerDirectory": [
package/dist/mount.d.ts DELETED
@@ -1,45 +0,0 @@
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 DELETED
@@ -1,61 +0,0 @@
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
- }