mokapi-storybook-addon 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +376 -2
- package/dist/forwarding.d.ts +5 -0
- package/dist/forwarding.js +21 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +1 -0
- package/dist/manager.d.ts +6 -0
- package/dist/manager.js +218 -0
- package/dist/mock-backend-sync.d.ts +14 -0
- package/dist/mock-backend-sync.js +112 -0
- package/dist/mokapi-events.d.ts +3 -0
- package/dist/mokapi-events.js +31 -0
- package/dist/msw.d.ts +17 -0
- package/dist/msw.js +81 -0
- package/dist/options.d.ts +2 -0
- package/dist/options.js +31 -0
- package/dist/preset.d.ts +6 -0
- package/dist/preset.js +49 -0
- package/dist/preview.d.ts +16 -0
- package/dist/preview.js +15 -0
- package/dist/runtime.d.ts +3 -0
- package/dist/runtime.js +9 -0
- package/dist/scenarios.d.ts +17 -0
- package/dist/scenarios.js +47 -0
- package/dist/types.d.ts +77 -0
- package/dist/types.js +1 -0
- package/package.json +97 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marcel Lehmann
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,377 @@
|
|
|
1
|
-
#
|
|
1
|
+
# mokapi-storybook-addon
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Check your Storybook mocks against your OpenAPI contract with
|
|
4
|
+
[Mokapi](https://mokapi.io).
|
|
5
|
+
|
|
6
|
+
Your stories already use [MSW](https://mswjs.io) handlers to mock API
|
|
7
|
+
responses. This addon adds a toolbar switch to Storybook: run the handlers in
|
|
8
|
+
the browser as usual, or send the same requests to Mokapi, which validates every
|
|
9
|
+
response against your OpenAPI contract. You keep one set of mocks and see in the
|
|
10
|
+
story when they drift from the API.
|
|
11
|
+
|
|
12
|
+
- **One toolbar switch:** change between **MSW (browser)** and **Mokapi (OpenAPI validation)** for all stories.
|
|
13
|
+
- **Same mocks:** stories reuse your existing MSW handlers. Together with
|
|
14
|
+
[mokapi-msw-adapter](https://github.com/marle3003/mokapi-msw-adapter), Mokapi
|
|
15
|
+
serves the same handlers.
|
|
16
|
+
- **Request panel:** see each request in Storybook, with status, response body,
|
|
17
|
+
contract errors, script logs and errors.
|
|
18
|
+
- **Your MSW setup stays yours:** the addon does not start MSW or Mokapi and does
|
|
19
|
+
not import your mock modules.
|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
23
|
+
## How it works
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
Storybook toolbar: mockBackend
|
|
27
|
+
┌───────────────┴────────────────┐
|
|
28
|
+
▼ ▼
|
|
29
|
+
msw mokapi
|
|
30
|
+
story handlers run in the requests are forwarded through the
|
|
31
|
+
browser (MSW service worker) Vite proxy /mokapi to Mokapi, with
|
|
32
|
+
?__mockScenario=<scenario>
|
|
33
|
+
│
|
|
34
|
+
▼
|
|
35
|
+
Mokapi answers with your shared
|
|
36
|
+
handlers (mokapi-msw-adapter) and
|
|
37
|
+
validates against OpenAPI
|
|
38
|
+
│
|
|
39
|
+
▼
|
|
40
|
+
Mokapi panel shows the requests
|
|
41
|
+
(dashboard API /api/events)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
In MSW mode, nothing changes: `parameters.msw` goes to your MSW loader
|
|
45
|
+
unchanged. In Mokapi mode, the addon replaces the story's handlers with
|
|
46
|
+
forwarding handlers. They send each request to Mokapi and add the story's
|
|
47
|
+
scenario as a query parameter. Mokapi's answer, including a validation error,
|
|
48
|
+
goes back to your component unchanged.
|
|
49
|
+
|
|
50
|
+
## Requirements
|
|
51
|
+
|
|
52
|
+
- [Mokapi](https://mokapi.io/docs/get-started/installation) 0.53.0 or newer
|
|
53
|
+
(0.56.0 or newer with mokapi-msw-adapter)
|
|
54
|
+
- Storybook 10.6.4 or newer with the Vite builder and CSF3 stories
|
|
55
|
+
- MSW 2.15.0 or newer
|
|
56
|
+
- React 18 or newer (used for the addon panel only)
|
|
57
|
+
- Node.js 22.12.0 or newer
|
|
58
|
+
|
|
59
|
+
## Quick start
|
|
60
|
+
|
|
61
|
+
This example assumes that your shared MSW handlers are in
|
|
62
|
+
`src/mocks/profile.ts` and that Mokapi serves them with mokapi-msw-adapter. See
|
|
63
|
+
its [Quick start](https://github.com/marle3003/mokapi-msw-adapter#quick-start)
|
|
64
|
+
for the handler module, `mokapi.yaml` and the OpenAPI contract.
|
|
65
|
+
|
|
66
|
+
### 1. Install
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
npm install --save-dev mokapi-storybook-addon msw msw-storybook-addon
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Generate `mockServiceWorker.js` with MSW's standard setup
|
|
73
|
+
(`npx msw init public`) and serve it from Storybook.
|
|
74
|
+
|
|
75
|
+
### 2. Register the addon
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
// .storybook/main.ts
|
|
79
|
+
export default {
|
|
80
|
+
framework: '@storybook/react-vite',
|
|
81
|
+
staticDirs: ['../public'],
|
|
82
|
+
addons: [
|
|
83
|
+
{
|
|
84
|
+
name: 'mokapi-storybook-addon',
|
|
85
|
+
options: {
|
|
86
|
+
target: 'http://localhost:3000', // your API in Mokapi
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`target` is required: use the server URL from your OpenAPI contract. The
|
|
94
|
+
addon adds the toolbar switch, the **Mokapi** panel and a Vite development
|
|
95
|
+
proxy from `/mokapi` to `target`. The panel reads requests from the Mokapi
|
|
96
|
+
dashboard at `http://localhost:8080`. If you run the dashboard elsewhere, set
|
|
97
|
+
`dashboardUrl`.
|
|
98
|
+
|
|
99
|
+
### 3. Wrap your MSW loader
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
// .storybook/preview.ts
|
|
103
|
+
import { setupWorker } from 'msw/browser';
|
|
104
|
+
import { mswLoader } from 'msw-storybook-addon/csf3';
|
|
105
|
+
import { createMokapiLoader } from 'mokapi-storybook-addon/msw';
|
|
106
|
+
|
|
107
|
+
const loadMsw = mswLoader(async () => {
|
|
108
|
+
const worker = setupWorker(); // Your initial handlers can go here.
|
|
109
|
+
await worker.start({ onUnhandledRequest: 'bypass', quiet: true });
|
|
110
|
+
return worker;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export default {
|
|
114
|
+
loaders: [createMokapiLoader(loadMsw)],
|
|
115
|
+
parameters: { docs: { story: { inline: false, iframeHeight: '300px' } } },
|
|
116
|
+
};
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Register only the wrapped loader. If you also register `loadMsw` directly, both
|
|
120
|
+
reset the MSW handlers.
|
|
121
|
+
|
|
122
|
+
`msw-storybook-addon` is optional: any loader that reads `parameters.msw` works.
|
|
123
|
+
Your loader keeps creating, starting and resetting the worker.
|
|
124
|
+
|
|
125
|
+
### 4. Write stories
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
// src/Profile.stories.ts
|
|
129
|
+
import { createStoryScenarios } from 'mokapi-storybook-addon/msw';
|
|
130
|
+
import { scenarios as profileScenarios } from './mocks/profile';
|
|
131
|
+
|
|
132
|
+
const scenarios = createStoryScenarios(profileScenarios, { api: 'My API' });
|
|
133
|
+
|
|
134
|
+
export const Success = {
|
|
135
|
+
parameters: scenarios.success,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const ServerError = {
|
|
139
|
+
parameters: scenarios.error,
|
|
140
|
+
};
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`api` is the OpenAPI `info.title`. The request panel needs it.
|
|
144
|
+
|
|
145
|
+
### 5. Start Mokapi and Storybook
|
|
146
|
+
|
|
147
|
+
```sh
|
|
148
|
+
npx mokapi
|
|
149
|
+
npx storybook dev
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Open a story and switch the toolbar from **MSW (browser)** to
|
|
153
|
+
**Mokapi (OpenAPI validation)**. The story reloads and its requests go to Mokapi.
|
|
154
|
+
Open the **Mokapi** panel to see them.
|
|
155
|
+
|
|
156
|
+
## Scenarios
|
|
157
|
+
|
|
158
|
+
`createStoryScenarios()` turns your named handler lists into story parameters:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
scenarios.success
|
|
162
|
+
// {
|
|
163
|
+
// msw: { handlers: [/* your handlers */] },
|
|
164
|
+
// mokapi: { scenario: 'success', api: 'My API', routes: [{ method: 'GET', path: '/api/profile' }] },
|
|
165
|
+
// }
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
- Scenario names stay typed: `scenarios.sucess` is a TypeScript error.
|
|
169
|
+
- Routes are read from the handlers' method and path. The resolvers do not run,
|
|
170
|
+
and duplicate routes are merged.
|
|
171
|
+
- An empty handler list, a non-HTTP handler (for example GraphQL) or a path
|
|
172
|
+
pattern throws an error.
|
|
173
|
+
|
|
174
|
+
In Mokapi mode, each forwarded request carries `?__mockScenario=<scenario>`.
|
|
175
|
+
Other query parameters, the method, headers, the body and the abort signal are
|
|
176
|
+
kept. The adapter uses the parameter to pick the scenario.
|
|
177
|
+
|
|
178
|
+
If you rename the parameter, configure the same name in both places:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
// .storybook/main.ts
|
|
182
|
+
options: { scenarioParameter: 'example' }
|
|
183
|
+
|
|
184
|
+
// src/mocks/profile.ts
|
|
185
|
+
register(scenarios, { api: 'My API', scenarioParameter: 'example' });
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
To add other parameters, spread the scenario:
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
export const Success = {
|
|
192
|
+
parameters: {
|
|
193
|
+
...scenarios.success,
|
|
194
|
+
docs: { description: { story: 'Successful profile request.' } },
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
You can also write `msw: { handlers }` and `mokapi: { scenario, routes }`
|
|
200
|
+
yourself. Stories without `parameters.mokapi` always use MSW.
|
|
201
|
+
|
|
202
|
+
## Keep other handlers local
|
|
203
|
+
|
|
204
|
+
By default, Mokapi mode replaces all story handlers. To keep unrelated handlers
|
|
205
|
+
in the browser, for example for authentication, put the Mokapi handlers in a
|
|
206
|
+
named MSW handler group:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
const scenarios = createStoryScenarios(profileScenarios, {
|
|
210
|
+
api: 'My API',
|
|
211
|
+
handlerGroup: 'profile',
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
export const Success = {
|
|
215
|
+
parameters: {
|
|
216
|
+
...scenarios.success,
|
|
217
|
+
msw: { handlers: { ...scenarios.success.msw.handlers, auth: authHandlers } },
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
In Mokapi mode, only the `profile` group is replaced. Other groups, including
|
|
223
|
+
groups set to `null`, stay as they are. A missing group throws an error. Only
|
|
224
|
+
the handlers in the selected group must fit
|
|
225
|
+
[the adapter's supported MSW API](https://github.com/marle3003/mokapi-msw-adapter#supported-msw-api).
|
|
226
|
+
|
|
227
|
+
## Request panel
|
|
228
|
+
|
|
229
|
+
Select a story, switch the toolbar to Mokapi and open the **Mokapi** panel. For
|
|
230
|
+
each request, it shows:
|
|
231
|
+
|
|
232
|
+
- time, HTTP status and duration
|
|
233
|
+
- request and response metadata and the response body, including contract
|
|
234
|
+
validation errors
|
|
235
|
+
- executed scripts with console logs, parameters and errors
|
|
236
|
+
|
|
237
|
+
HTTP errors and script errors are shown separately: an intended HTTP 500 is not
|
|
238
|
+
a contract violation.
|
|
239
|
+
|
|
240
|
+
If the story has several routes, select an operation. The panel loads that
|
|
241
|
+
operation's requests from Mokapi's dashboard API and keeps those whose
|
|
242
|
+
scenario parameter matches the story. It shows the operation's history, not one
|
|
243
|
+
story execution. Requests without the scenario parameter are not shown.
|
|
244
|
+
|
|
245
|
+
- The panel refreshes every two seconds while it is open in Mokapi mode.
|
|
246
|
+
**Refresh** reloads immediately.
|
|
247
|
+
- Pages hold 25 requests and are filtered after loading, so a page can be empty
|
|
248
|
+
while other pages contain matches.
|
|
249
|
+
- Errors appear in the panel and never block the story.
|
|
250
|
+
|
|
251
|
+
The panel needs `parameters.mokapi.api`, set by the `api` option of
|
|
252
|
+
`createStoryScenarios()`.
|
|
253
|
+
|
|
254
|
+
## Docs pages
|
|
255
|
+
|
|
256
|
+
Use Storybook's standard Docs page with stories that are not inline:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
// .storybook/preview.ts
|
|
260
|
+
export default {
|
|
261
|
+
parameters: {
|
|
262
|
+
docs: { story: { inline: false, iframeHeight: '300px' } },
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Each story then runs in its own iframe with its own MSW worker. Inline stories
|
|
268
|
+
share one worker, so they can't show different scenarios.
|
|
269
|
+
|
|
270
|
+
When you change the toolbar, the addon reloads every Storybook preview iframe,
|
|
271
|
+
including the story iframes on a Docs page, with the new `mockBackend`. Other
|
|
272
|
+
globals, query parameters and fragments are kept. Loaders wait for this
|
|
273
|
+
synchronization, so a new iframe never uses the wrong backend. Without the
|
|
274
|
+
Storybook manager, for example in a standalone preview, the `mockBackend` URL
|
|
275
|
+
global or `defaultBackend` applies.
|
|
276
|
+
|
|
277
|
+
## Static builds and deployment
|
|
278
|
+
|
|
279
|
+
`storybook build` has no Vite server, so the `/mokapi` proxy is missing. Choose
|
|
280
|
+
one of these:
|
|
281
|
+
|
|
282
|
+
- Provide a reverse proxy for `/mokapi` (to `target`) and `/mokapi-dashboard`
|
|
283
|
+
(to `dashboardUrl`).
|
|
284
|
+
- Set `browserUrl` at build time and enable CORS in Mokapi. The browser then
|
|
285
|
+
calls Mokapi directly.
|
|
286
|
+
|
|
287
|
+
The panel's dashboard link uses `dashboardUrl` directly, so use an address your
|
|
288
|
+
users can reach. Only expose the dashboard API to trusted users: the request
|
|
289
|
+
history can contain sensitive headers, bodies and script data. Don't put
|
|
290
|
+
credentials in any option, because the options are visible in the browser.
|
|
291
|
+
|
|
292
|
+
## API
|
|
293
|
+
|
|
294
|
+
### Addon options
|
|
295
|
+
|
|
296
|
+
Set in `.storybook/main.ts`. Restart Storybook after changing them.
|
|
297
|
+
|
|
298
|
+
| Option | Type | Default | Description |
|
|
299
|
+
|---------------------|-----------------------|-------------------------|-----------------------------------------------------------------------------|
|
|
300
|
+
| `target` | `string` | required | Mocked API in Mokapi, target of the `proxyPath` proxy. Not sent to the browser |
|
|
301
|
+
| `dashboardUrl` | `string` | `'http://localhost:8080'` | Mokapi dashboard, target of the `<proxyPath>-dashboard` proxy and of the panel link |
|
|
302
|
+
| `proxyPath` | `string` | `'/mokapi'` | Local proxy path, removed before forwarding |
|
|
303
|
+
| `defaultBackend` | `'msw' \| 'mokapi'` | `'msw'` | Initial toolbar selection |
|
|
304
|
+
| `scenarioParameter` | `string` | `'__mockScenario'` | Query parameter that selects the scenario |
|
|
305
|
+
| `browserUrl` | `string` | not set | Mokapi address used by the browser instead of `proxyPath`, see [Static builds and deployment](#static-builds-and-deployment) |
|
|
306
|
+
|
|
307
|
+
### `createStoryScenarios(scenarios, options)`
|
|
308
|
+
|
|
309
|
+
Imported from `mokapi-storybook-addon/msw`.
|
|
310
|
+
|
|
311
|
+
| Parameter | Type | Default | Description |
|
|
312
|
+
|------------------------|---------------------------------|----------|---------------------------------------------------------|
|
|
313
|
+
| `scenarios` | `Record<string, HttpHandler[]>` | – | MSW handlers grouped by scenario name |
|
|
314
|
+
| `options.api` | `string` | not set | OpenAPI `info.title`, needed by the request panel |
|
|
315
|
+
| `options.handlerGroup` | `string` | not set | MSW handler group managed by Mokapi, see [Keep other handlers local](#keep-other-handlers-local) |
|
|
316
|
+
|
|
317
|
+
Returns one story parameters object (`msw` and `mokapi`) per scenario.
|
|
318
|
+
|
|
319
|
+
### `createMokapiLoader(loader, options)`
|
|
320
|
+
|
|
321
|
+
Imported from `mokapi-storybook-addon/msw`. Wraps your MSW loader. In Mokapi
|
|
322
|
+
mode, the loader receives forwarding handlers in `parameters.msw`. The original
|
|
323
|
+
parameters are restored afterwards, also when the loader throws. The loader's
|
|
324
|
+
result and its changes to the context are kept.
|
|
325
|
+
|
|
326
|
+
`options` (`proxyPath`, `scenarioParameter`, `browserUrl`) is only needed
|
|
327
|
+
without the registered addon, for example in tests.
|
|
328
|
+
|
|
329
|
+
### `parameters.mokapi`
|
|
330
|
+
|
|
331
|
+
| Field | Type | Description |
|
|
332
|
+
|----------------|--------------------------------------------------|-------------------------------------------------|
|
|
333
|
+
| `scenario` | `string` | Value of the scenario query parameter |
|
|
334
|
+
| `routes` | `Array<string \| { method?, path }>` | Routes to forward. A string means `GET` |
|
|
335
|
+
| `api` | `string` | OpenAPI `info.title`, needed by the panel |
|
|
336
|
+
| `handlerGroup` | `string` | MSW handler group to replace |
|
|
337
|
+
|
|
338
|
+
### TypeScript
|
|
339
|
+
|
|
340
|
+
Type definitions are included. Importing `mokapi-storybook-addon` adds
|
|
341
|
+
`mokapi` to Storybook's `Parameters` type.
|
|
342
|
+
|
|
343
|
+
### HTTP methods
|
|
344
|
+
|
|
345
|
+
All MSW HTTP methods are forwarded: the `http.*` helpers, `http.all()` and
|
|
346
|
+
string or RegExp methods in `new HttpHandler(...)`, for example `QUERY`. RegExp
|
|
347
|
+
methods are stored as `{ source, flags }` in the story parameters. Mokapi checks
|
|
348
|
+
the actual method against the contract. The browser's Fetch API still applies:
|
|
349
|
+
it doesn't allow `CONNECT`, `TRACE` or `TRACK`.
|
|
350
|
+
|
|
351
|
+
## Errors
|
|
352
|
+
|
|
353
|
+
- If Mokapi can't be reached, the addon logs the error and answers with HTTP 502.
|
|
354
|
+
It never falls back to the local mocks.
|
|
355
|
+
- HTTP errors from Mokapi, including contract validation errors, go to the
|
|
356
|
+
component unchanged.
|
|
357
|
+
- Invalid options or story parameters throw an error that starts with `Mokapi:`.
|
|
358
|
+
- If the preview gets no answer from the Storybook manager within 10 seconds,
|
|
359
|
+
loaders fail with `backend synchronization timed out`. Reload Storybook.
|
|
360
|
+
|
|
361
|
+
## Limitations
|
|
362
|
+
|
|
363
|
+
- Only exact relative paths. Path patterns such as `/users/:id` or `/files/*`,
|
|
364
|
+
query strings in routes and absolute URLs are not supported.
|
|
365
|
+
- Only MSW HTTP handlers can be forwarded. To keep GraphQL or WebSocket
|
|
366
|
+
handlers in Mokapi mode, put them in another handler group, see
|
|
367
|
+
[Keep other handlers local](#keep-other-handlers-local).
|
|
368
|
+
- Storybook with Vite and CSF3 only. Webpack and CSF Next are not supported.
|
|
369
|
+
Only React projects are tested.
|
|
370
|
+
- Preview iframes must have the same origin as the Storybook manager.
|
|
371
|
+
- The panel shows the request history per operation, not exactly the requests of
|
|
372
|
+
one story execution.
|
|
373
|
+
- In Mokapi mode, mokapi-msw-adapter supports JSON responses only.
|
|
374
|
+
|
|
375
|
+
## License
|
|
376
|
+
|
|
377
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { MokapiRoute, MokapiRouteDefinition, SerializedMethod } from './types.js';
|
|
2
|
+
export declare function forwardingUrl(requestUrl: string, base: string, scenarioParameter: string, scenario: string): URL;
|
|
3
|
+
export declare function validateRoute(definition: MokapiRouteDefinition): Required<MokapiRoute>;
|
|
4
|
+
export declare function handlerMethod(method: SerializedMethod): string | RegExp;
|
|
5
|
+
export declare function methodLabel(method: SerializedMethod): string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function forwardingUrl(requestUrl, base, scenarioParameter, scenario) {
|
|
2
|
+
const source = new URL(requestUrl);
|
|
3
|
+
const target = new URL(`${base.replace(/\/$/, '')}${source.pathname}`, source.origin);
|
|
4
|
+
target.search = source.search;
|
|
5
|
+
target.searchParams.set(scenarioParameter, scenario);
|
|
6
|
+
return target;
|
|
7
|
+
}
|
|
8
|
+
export function validateRoute(definition) {
|
|
9
|
+
const route = typeof definition === 'string' ? { path: definition } : definition;
|
|
10
|
+
const method = route.method ?? 'GET';
|
|
11
|
+
if (typeof route.path !== 'string' || !/^\/[^:*?{}#]*$/.test(route.path)) {
|
|
12
|
+
throw new Error('Mokapi: routes must use exact relative paths, without query or path patterns.');
|
|
13
|
+
}
|
|
14
|
+
return { method, path: route.path };
|
|
15
|
+
}
|
|
16
|
+
export function handlerMethod(method) {
|
|
17
|
+
return typeof method === 'string' ? method : new RegExp(method.source, method.flags);
|
|
18
|
+
}
|
|
19
|
+
export function methodLabel(method) {
|
|
20
|
+
return typeof method === 'string' ? method : `/${method.source}/${method.flags}`;
|
|
21
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { MokapiStoryParameters } from './types.js';
|
|
2
|
+
export { resolveOptions } from './options.js';
|
|
3
|
+
export type { ManagerPanelOptions, MokapiAddonOptions, MokapiEvent, MokapiEventAction, MokapiEventPage, MokapiEventRequest, MokapiEventResponse, MokapiRoute, MokapiRouteDefinition, MokapiStoryParameters, MockBackend, ResolvedAddonOptions, RuntimeOptions, SerializedMethod, } from './types.js';
|
|
4
|
+
declare module 'storybook/internal/csf' {
|
|
5
|
+
interface Parameters {
|
|
6
|
+
mokapi?: MokapiStoryParameters;
|
|
7
|
+
}
|
|
8
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { resolveOptions } from './options.js';
|
package/dist/manager.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/** @jsxRuntime classic */
|
|
2
|
+
// The Storybook manager provides `react` but not `react/jsx-runtime`, so this file must not use the automatic runtime.
|
|
3
|
+
import React, { useEffect, useState } from 'react';
|
|
4
|
+
import { addons, types, useGlobals, useParameter, useStorybookState } from 'storybook/manager-api';
|
|
5
|
+
import { UPDATE_GLOBALS } from 'storybook/internal/core-events';
|
|
6
|
+
import { Button, Form, Link, ScrollArea } from 'storybook/internal/components';
|
|
7
|
+
import { styled } from 'storybook/theming';
|
|
8
|
+
import { eventsUrl, fetchEvents } from './mokapi-events.js';
|
|
9
|
+
import { handlerMethod, methodLabel } from './forwarding.js';
|
|
10
|
+
import { initialMockBackend, installManagerMockBackendSync, isMockBackend, } from './mock-backend-sync.js';
|
|
11
|
+
const REQUEST_PANEL_ID = 'mokapi/requests';
|
|
12
|
+
const REFRESH_INTERVAL_MS = 2000;
|
|
13
|
+
const PanelRoot = styled.div(({ theme }) => ({
|
|
14
|
+
height: '100%',
|
|
15
|
+
minHeight: 0,
|
|
16
|
+
overflow: 'hidden',
|
|
17
|
+
color: theme.color.defaultText,
|
|
18
|
+
background: theme.background.content,
|
|
19
|
+
fontFamily: theme.typography.fonts.base,
|
|
20
|
+
fontSize: theme.typography.size.s1,
|
|
21
|
+
}));
|
|
22
|
+
const PanelScroll = styled(ScrollArea)({ height: '100%' });
|
|
23
|
+
const Content = styled.section({ padding: 16, minWidth: 0 });
|
|
24
|
+
const Toolbar = styled.div({
|
|
25
|
+
display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap',
|
|
26
|
+
});
|
|
27
|
+
const OperationLabel = styled.label({
|
|
28
|
+
display: 'flex', gap: 8, alignItems: 'center',
|
|
29
|
+
});
|
|
30
|
+
const Disclosure = styled.details(({ theme }) => ({
|
|
31
|
+
padding: '6px 0',
|
|
32
|
+
'& > summary': {
|
|
33
|
+
cursor: 'pointer',
|
|
34
|
+
padding: '6px 0',
|
|
35
|
+
overflowWrap: 'anywhere',
|
|
36
|
+
'&:focus-visible': { outline: `2px solid ${theme.color.secondary}`, outlineOffset: 2 },
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
const RequestDisclosure = styled(Disclosure)(({ theme }) => ({
|
|
40
|
+
borderBottom: `1px solid ${theme.appBorderColor}`,
|
|
41
|
+
padding: '8px 0',
|
|
42
|
+
}));
|
|
43
|
+
const DataBlock = styled.pre(({ theme }) => ({
|
|
44
|
+
whiteSpace: 'pre-wrap',
|
|
45
|
+
overflowWrap: 'anywhere',
|
|
46
|
+
fontFamily: theme.typography.fonts.mono,
|
|
47
|
+
fontSize: theme.typography.size.s1,
|
|
48
|
+
background: theme.background.app,
|
|
49
|
+
border: `1px solid ${theme.appBorderColor}`,
|
|
50
|
+
borderRadius: theme.appBorderRadius,
|
|
51
|
+
padding: 12,
|
|
52
|
+
margin: '8px 0',
|
|
53
|
+
}));
|
|
54
|
+
const RequestUrl = styled.p(({ theme }) => ({
|
|
55
|
+
overflowWrap: 'anywhere', fontFamily: theme.typography.fonts.mono,
|
|
56
|
+
}));
|
|
57
|
+
const ErrorMessage = styled.p(({ theme }) => ({ color: theme.color.negative }));
|
|
58
|
+
function Details({ title, value }) {
|
|
59
|
+
return (React.createElement(Disclosure, null,
|
|
60
|
+
React.createElement("summary", null, title),
|
|
61
|
+
React.createElement(DataBlock, null, typeof value === 'string' ? value : JSON.stringify(value, null, 2))));
|
|
62
|
+
}
|
|
63
|
+
function ScriptExecution({ action }) {
|
|
64
|
+
const name = action.tags?.file ?? action.tags?.name ?? 'Script';
|
|
65
|
+
return (React.createElement("section", null,
|
|
66
|
+
React.createElement(RequestUrl, null, `${name} (${action.duration} ms)`),
|
|
67
|
+
action.error !== undefined && React.createElement(Details, { title: "Script error", value: action.error }),
|
|
68
|
+
React.createElement(Details, { title: "Console logs", value: action.logs ?? [] }),
|
|
69
|
+
React.createElement(Details, { title: "Script parameters", value: action.parameters ?? [] })));
|
|
70
|
+
}
|
|
71
|
+
function RequestEvent({ event }) {
|
|
72
|
+
const { request, response, actions, duration } = event.data;
|
|
73
|
+
return (React.createElement(RequestDisclosure, null,
|
|
74
|
+
React.createElement("summary", null, `${response?.statusCode ?? 'No response'} | ${request.method} ${event.traits.path} | ${duration} ms | ${event.time}`),
|
|
75
|
+
React.createElement(RequestUrl, null, request.url),
|
|
76
|
+
response?.statusCode !== undefined && response.statusCode >= 400 && (React.createElement("p", { role: "note" }, "HTTP error response. Inspect the response and scripts below; a mocked error status is not necessarily a contract violation.")),
|
|
77
|
+
response?.body !== undefined && React.createElement(Details, { title: "Response body / validation errors", value: response.body }),
|
|
78
|
+
React.createElement(Details, { title: "Request", value: request }),
|
|
79
|
+
React.createElement(Details, { title: "Response", value: response }),
|
|
80
|
+
React.createElement("h4", null, "Executed scripts"),
|
|
81
|
+
(actions ?? []).map((action, index) => React.createElement(ScriptExecution, { key: index, action: action })),
|
|
82
|
+
!actions?.length && React.createElement("p", null, "No script executions recorded.")));
|
|
83
|
+
}
|
|
84
|
+
function filterRequestMethods(result, route) {
|
|
85
|
+
const method = handlerMethod(typeof route === 'string' ? 'GET' : route.method ?? 'GET');
|
|
86
|
+
if (!(method instanceof RegExp))
|
|
87
|
+
return result;
|
|
88
|
+
const items = result.items.filter((event) => {
|
|
89
|
+
method.lastIndex = 0;
|
|
90
|
+
return method.test(event.data.request.method);
|
|
91
|
+
});
|
|
92
|
+
return { ...result, items };
|
|
93
|
+
}
|
|
94
|
+
function useRequestHistory(config, settings, route, page) {
|
|
95
|
+
const [refreshVersion, setRefreshVersion] = useState(0);
|
|
96
|
+
const [historyState, setHistoryState] = useState({ loading: true });
|
|
97
|
+
const url = eventsUrl(settings.eventsUrl, config.api, route, page);
|
|
98
|
+
// Storybook hands out a new but equal config object on every STORY_PREPARED; depend on the route's content so the list is not reloaded (and expanded entries collapsed).
|
|
99
|
+
const routeKey = JSON.stringify(route);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
let refreshTimer;
|
|
103
|
+
setHistoryState({ loading: true });
|
|
104
|
+
const loadRequests = async () => {
|
|
105
|
+
try {
|
|
106
|
+
const result = await fetchEvents(url, config.scenario, settings.scenarioParameter, controller.signal);
|
|
107
|
+
const filteredResult = filterRequestMethods(result, route);
|
|
108
|
+
if (!controller.signal.aborted) {
|
|
109
|
+
setHistoryState({ loading: false, result: filteredResult });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (!controller.signal.aborted) {
|
|
114
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
115
|
+
setHistoryState({ loading: false, error: `Cannot load Mokapi requests: ${message}` });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
if (!controller.signal.aborted) {
|
|
120
|
+
refreshTimer = setTimeout(() => { void loadRequests(); }, REFRESH_INTERVAL_MS);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
void loadRequests();
|
|
125
|
+
return () => {
|
|
126
|
+
controller.abort();
|
|
127
|
+
clearTimeout(refreshTimer);
|
|
128
|
+
};
|
|
129
|
+
}, [url, config.scenario, refreshVersion, routeKey, settings.scenarioParameter]);
|
|
130
|
+
const refresh = () => setRefreshVersion((version) => version + 1);
|
|
131
|
+
return { historyState, refresh };
|
|
132
|
+
}
|
|
133
|
+
function Pagination({ page, result, onPageChange }) {
|
|
134
|
+
const totalPages = Math.max(1, Math.ceil(result.total / result.size));
|
|
135
|
+
return (React.createElement(Toolbar, null,
|
|
136
|
+
React.createElement(Button, { variant: "outline", size: "small", disabled: page <= 1, onClick: () => onPageChange(page - 1) }, "Previous"),
|
|
137
|
+
React.createElement("span", null, `Operation page ${page} of ${totalPages}`),
|
|
138
|
+
React.createElement(Button, { variant: "outline", size: "small", disabled: page * result.size >= result.total, onClick: () => onPageChange(page + 1) }, "Next")));
|
|
139
|
+
}
|
|
140
|
+
function RequestHistory({ config, settings }) {
|
|
141
|
+
const [routeIndex, setRouteIndex] = useState(0);
|
|
142
|
+
const [page, setPage] = useState(1);
|
|
143
|
+
const route = config.routes[routeIndex];
|
|
144
|
+
const { historyState, refresh } = useRequestHistory(config, settings, route, page);
|
|
145
|
+
const { loading, error, result } = historyState;
|
|
146
|
+
const selectRoute = (event) => {
|
|
147
|
+
setRouteIndex(Number(event.target.value));
|
|
148
|
+
setPage(1);
|
|
149
|
+
};
|
|
150
|
+
return (React.createElement(Content, null,
|
|
151
|
+
React.createElement(Toolbar, null,
|
|
152
|
+
React.createElement(OperationLabel, null,
|
|
153
|
+
"Operation",
|
|
154
|
+
React.createElement(Form.Select, { value: routeIndex, onChange: selectRoute }, config.routes.map((route, index) => (React.createElement("option", { key: index, value: index }, typeof route === 'string'
|
|
155
|
+
? `GET ${route}`
|
|
156
|
+
: `${methodLabel(route.method ?? 'GET')} ${route.path}`))))),
|
|
157
|
+
React.createElement(Button, { variant: "outline", size: "small", onClick: refresh }, "Refresh"),
|
|
158
|
+
React.createElement(Link, { href: settings.dashboardUrl, target: "_blank", rel: "noreferrer" }, "Open Mokapi dashboard")),
|
|
159
|
+
React.createElement("p", null, `API: ${config.api} | Scenario: ${config.scenario}`),
|
|
160
|
+
React.createElement("p", null, "Operation history filtered by scenario, not by an individual story execution. Auto-refresh every 2 seconds."),
|
|
161
|
+
loading && React.createElement("p", { role: "status" }, "Loading Mokapi requests..."),
|
|
162
|
+
error && React.createElement(ErrorMessage, { role: "alert" }, error),
|
|
163
|
+
result && (React.createElement("div", null,
|
|
164
|
+
React.createElement(Pagination, { page: page, result: result, onPageChange: setPage }),
|
|
165
|
+
!result.items.length && (React.createElement("p", null, "No requests for this scenario on this operation page. Run the story or check another page.")),
|
|
166
|
+
result.items.map((event) => React.createElement(RequestEvent, { key: event.id, event: event }))))));
|
|
167
|
+
}
|
|
168
|
+
function PanelFrame({ children }) {
|
|
169
|
+
return React.createElement(PanelRoot, null,
|
|
170
|
+
React.createElement(PanelScroll, { vertical: true }, children));
|
|
171
|
+
}
|
|
172
|
+
function PanelMessage({ children, error = false }) {
|
|
173
|
+
return (React.createElement(PanelFrame, null,
|
|
174
|
+
React.createElement(Content, null, error ? React.createElement(ErrorMessage, { role: "alert" }, children) : children)));
|
|
175
|
+
}
|
|
176
|
+
function hasConfiguredApi(config) {
|
|
177
|
+
return !!config?.api && !!config.routes?.length;
|
|
178
|
+
}
|
|
179
|
+
function Panel() {
|
|
180
|
+
const config = useParameter('mokapi');
|
|
181
|
+
const [globals] = useGlobals();
|
|
182
|
+
const { storyId, selectedPanel, layout } = useStorybookState();
|
|
183
|
+
const settings = window.__MOKAPI_PANEL_OPTIONS__;
|
|
184
|
+
if (selectedPanel !== REQUEST_PANEL_ID || !layout.showPanel)
|
|
185
|
+
return null;
|
|
186
|
+
if (!settings) {
|
|
187
|
+
return React.createElement(PanelMessage, { error: true }, "Mokapi panel configuration is missing. Restart Storybook.");
|
|
188
|
+
}
|
|
189
|
+
if (globals.mockBackend !== 'mokapi') {
|
|
190
|
+
return React.createElement(PanelMessage, null, "Select Mokapi in the toolbar to inspect request logs.");
|
|
191
|
+
}
|
|
192
|
+
if (!hasConfiguredApi(config)) {
|
|
193
|
+
return (React.createElement(PanelMessage, null,
|
|
194
|
+
"Configure an API title and routes with createStoryScenarios(mocks, ",
|
|
195
|
+
'{ api: "My API" }',
|
|
196
|
+
") to inspect requests."));
|
|
197
|
+
}
|
|
198
|
+
return (React.createElement(PanelFrame, null,
|
|
199
|
+
React.createElement(RequestHistory, { key: `${storyId}-${JSON.stringify(config)}`, config: config, settings: settings })));
|
|
200
|
+
}
|
|
201
|
+
addons.register('mokapi', (api) => {
|
|
202
|
+
const configuredBackend = api.getGlobals().mockBackend;
|
|
203
|
+
const fallback = isMockBackend(configuredBackend) ? configuredBackend : 'msw';
|
|
204
|
+
let mockBackend = initialMockBackend(window.location.href, fallback);
|
|
205
|
+
const synchronization = installManagerMockBackendSync(window, () => mockBackend);
|
|
206
|
+
api.on(UPDATE_GLOBALS, ({ globals }) => {
|
|
207
|
+
if (!isMockBackend(globals.mockBackend))
|
|
208
|
+
return;
|
|
209
|
+
mockBackend = globals.mockBackend;
|
|
210
|
+
synchronization.update(mockBackend);
|
|
211
|
+
});
|
|
212
|
+
addons.add(REQUEST_PANEL_ID, {
|
|
213
|
+
type: types.PANEL,
|
|
214
|
+
title: 'Mokapi',
|
|
215
|
+
match: ({ viewMode }) => viewMode === 'story',
|
|
216
|
+
render: ({ active }) => active ? React.createElement(Panel, null) : null,
|
|
217
|
+
});
|
|
218
|
+
});
|