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 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
- # Temporary Holding Version
1
+ # mokapi-storybook-addon
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ ![Storybook with the Mokapi toolbar switch and the Mokapi panel: the story's mock is missing the required email property, so Mokapi answers with HTTP 500 and the panel shows the validation error](docs/screenshot.png)
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
+ }
@@ -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';
@@ -0,0 +1,6 @@
1
+ import type { ManagerPanelOptions } from './types.js';
2
+ declare global {
3
+ interface Window {
4
+ __MOKAPI_PANEL_OPTIONS__?: ManagerPanelOptions;
5
+ }
6
+ }
@@ -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
+ });