mockaton 12.7.1 → 13.0.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/README.md CHANGED
@@ -10,20 +10,6 @@ for testing difficult to reproduce backend states.
10
10
 
11
11
  ## [Documentation ↗](https://mockaton.com) | [Changelog ↗](https://mockaton.com/changelog)
12
12
 
13
- ## Overview
14
- With Mockaton, you don’t need to write code for wiring up your
15
- mocks. Instead, a given directory is scanned for filenames
16
- following a convention similar to the URLs.
17
-
18
- For example, for [/api/company/123](#), the filename could be:
19
-
20
- <pre>
21
- <code>my-mocks-dir/<b>api/company</b>/[id].GET.200.json</code>
22
- </pre>
23
-
24
-
25
- ## Dashboard
26
-
27
13
  <picture>
28
14
  <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/ericfortis/mockaton/refs/heads/main/pixaton-tests/tests/macos/pic-for-readme.vp762x762.light.gold.png">
29
15
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ericfortis/mockaton/refs/heads/main/pixaton-tests/tests/macos/pic-for-readme.vp762x762.dark.gold.png">
@@ -34,9 +20,8 @@ For example, for [/api/company/123](#), the filename could be:
34
20
 
35
21
 
36
22
  ## Quick Start (Docker)
37
- This will spin up Mockaton with the sample directories
38
- included in this repo mounted on the container. Mentioned dirs are: [mockaton-mocks/](./mockaton-mocks)
39
- and [mockaton-static-mocks/](./mockaton-static-mocks).
23
+ This will spin up Mockaton with the sample directory
24
+ included in this repo mounted on the container. Mentioned dir is: [mockaton-mocks/](./mockaton-mocks).
40
25
 
41
26
  ```sh
42
27
  git clone https://github.com/ericfortis/mockaton.git --depth 1
@@ -49,3 +34,54 @@ Test it:
49
34
  ```shell
50
35
  curl localhost:2020/api/user
51
36
  ```
37
+
38
+
39
+ ## Overview
40
+ With Mockaton, you don’t need to write code for wiring up your
41
+ mocks. Instead, a given directory is scanned for filenames
42
+ following a convention similar to the URLs.
43
+
44
+ For example, for [/api/company/123](#), the file could be:
45
+
46
+ <code>my_mocks_dir/<b>api/company/[id]</b>.GET.200.json</code>
47
+ ```json
48
+ {
49
+ "name": "Acme, Inc."
50
+ }
51
+ ```
52
+
53
+ Or, you can write it in TypeScript (it will be sent as JSON).
54
+
55
+ <code>my_mocks_dir/<b>api/company/[id]</b>.GET.200.ts</code>
56
+ ```ts
57
+ export default {
58
+ name: 'Acme, Inc.'
59
+ }
60
+ ```
61
+
62
+ Similarly, you can handle logic with [Functional Mocks](https://mockaton.com/functional-mocks):
63
+
64
+ <code>my_mocks_dir/<b>api/company/[companyId]/user/[userId]</b>.GET.200.ts</code>
65
+ ```ts
66
+ import { IncomingMessage, OutgoingMessage } from 'node:http'
67
+ import { parseSplats } from 'mockaton'
68
+
69
+ export default async function (req: IncomingMessage, response: OutgoingMessage) {
70
+ const { companyId, userId } = parseSplats(req.url, import.meta.filename)
71
+ const foo = await getFoo()
72
+ return JSON.stringify({
73
+ foo,
74
+ companyId,
75
+ userId,
76
+ name: 'Acme, Inc.'
77
+ })
78
+ }
79
+ ```
80
+
81
+ ## Browser Extension
82
+ [Browser Extension](https://mockaton.com/scraping) for scraping responses from your backend.
83
+
84
+
85
+ ## API
86
+ Programmatic [Control API](https://mockaton.com/api).
87
+
package/index.d.ts CHANGED
@@ -11,7 +11,6 @@ export type Plugin = (
11
11
 
12
12
  export interface Config {
13
13
  mocksDir?: string
14
- staticDir?: string
15
14
  ignore?: RegExp
16
15
  watcherEnabled?: boolean
17
16
  watcherDebounceMs?: number
@@ -74,7 +73,8 @@ export type ClientMockBroker = {
74
73
  mocks: string[]
75
74
  file: string
76
75
  status: number
77
- auto500: boolean
76
+ isStatic: boolean
77
+ autoStatus: number
78
78
  delayed: boolean
79
79
  proxied: boolean
80
80
  }
@@ -84,19 +84,8 @@ export type ClientBrokersByMethod = {
84
84
  }
85
85
  }
86
86
 
87
- export type ClientStaticBroker = {
88
- route: string
89
- delayed: boolean
90
- status: number
91
- }
92
- export type ClientStaticBrokers = {
93
- [route: string]: ClientStaticBroker
94
- }
95
-
96
-
97
87
  export interface State {
98
88
  brokersByMethod: ClientBrokersByMethod
99
- staticBrokers: ClientStaticBrokers
100
89
 
101
90
  cookies: [label: string, selected: boolean][]
102
91
  comments: string[]
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "mockaton",
3
3
  "description": "HTTP Mock Server",
4
4
  "type": "module",
5
- "version": "12.7.1",
5
+ "version": "13.0.0",
6
6
  "exports": {
7
7
  ".": {
8
8
  "import": "./index.js",
@@ -17,18 +17,13 @@
17
17
  "license": "MIT",
18
18
  "homepage": "https://mockaton.com",
19
19
  "repository": "https://github.com/ericfortis/mockaton",
20
- "keywords": [
21
- "mock-server",
22
- "rest-api",
23
- "mock",
24
- "api",
25
- "testing"
26
- ],
20
+ "keywords": ["mock-server"],
27
21
  "bin": {
28
22
  "mockaton": "src/server/cli.js"
29
23
  },
30
24
  "scripts": {
31
- "start": "make start"
25
+ "start": "make start",
26
+ "test": "make test"
32
27
  },
33
28
  "engines": {
34
29
  "node": ">=22.18 <23 || >=23.6"
@@ -40,7 +40,9 @@ export class Commander {
40
40
 
41
41
 
42
42
  /** @returns {JsonPromise<ClientMockBroker>} */
43
- toggle500 = (method, urlMask) => this.#patch(API.toggle500, [method, urlMask])
43
+ toggleStatus = (status, method, urlMask) => this.#patch(API.toggleStatus, [status, method, urlMask])
44
+
45
+ // TODO change Status or Toggle404?
44
46
 
45
47
  /** @returns {JsonPromise<ClientMockBroker>} */
46
48
  setRouteIsProxied = (method, urlMask, proxied) => this.#patch(API.proxied, [method, urlMask, proxied])
@@ -49,19 +51,13 @@ export class Commander {
49
51
  setRouteIsDelayed = (method, urlMask, delayed) => this.#patch(API.delay, [method, urlMask, delayed])
50
52
 
51
53
 
52
- setStaticRouteStatus = (urlMask, status) => this.#patch(API.staticStatus, [urlMask, status])
53
-
54
- setStaticRouteIsDelayed = (urlMask, delayed) => this.#patch(API.delayStatic, [urlMask, delayed])
55
-
56
-
57
-
58
54
  /** @returns {JsonPromise<State>} */
59
55
  getState = () => fetch(this.addr + API.state)
60
-
61
-
62
- /**
56
+
57
+
58
+ /**
63
59
  * SSE - Streams an incremental version when a mock is added, deleted, or renamed
64
- * @returns {Promise<Response>}
60
+ * @returns {Promise<Response>}
65
61
  */
66
62
  getSyncVersion = () => fetch(this.addr + API.syncVersion)
67
63
  }
@@ -4,13 +4,11 @@ const MOUNT = '/mockaton'
4
4
 
5
5
  export const API = {
6
6
  dashboard: MOUNT,
7
-
8
7
  bulkSelect: MOUNT + '/bulk-select-by-comment',
9
8
  collectProxied: MOUNT + '/collect-proxied',
10
9
  cookies: MOUNT + '/cookies',
11
10
  cors: MOUNT + '/cors',
12
11
  delay: MOUNT + '/delay',
13
- delayStatic: MOUNT + '/delay-static',
14
12
  fallback: MOUNT + '/fallback',
15
13
  globalDelay: MOUNT + '/global-delay',
16
14
  globalDelayJitter: MOUNT + '/global-delay-jitter',
@@ -18,10 +16,9 @@ export const API = {
18
16
  reset: MOUNT + '/reset',
19
17
  select: MOUNT + '/select',
20
18
  state: MOUNT + '/state',
21
- staticStatus: MOUNT + '/static-status',
22
19
  syncVersion: MOUNT + '/sync-version',
23
20
  throws: MOUNT + '/throws',
24
- toggle500: MOUNT + '/toggle500',
21
+ toggleStatus: MOUNT + '/toggle-status',
25
22
  watchHotReload: MOUNT + '/watch-hot-reload',
26
23
  watchMocks: MOUNT + '/watch-mocks',
27
24
  }
@@ -19,29 +19,29 @@ export function includesComment(file, search) {
19
19
  return extractComments(file).some(c => c.includes(search))
20
20
  }
21
21
 
22
-
23
- export function validateFilename(file) {
22
+ export function parseFilename(file) {
24
23
  const tokens = file.replace(reComments, '').split('.')
25
- if (tokens.length < 4)
26
- return 'Invalid Filename Convention'
27
24
 
28
- const { status, method } = parseFilename(file)
29
- if (!responseStatusIsValid(status))
30
- return `Invalid HTTP Response Status: "${status}"`
31
-
32
- if (!METHODS.includes(method))
33
- return `Unrecognized HTTP Method: "${method}"`
34
- }
25
+ const followsConvention = tokens.length > 3
26
+ && responseStatusIsValid(Number(tokens.at(-2)))
27
+ && METHODS.includes(tokens.at(-3))
28
+ const isStatic = !followsConvention
35
29
 
36
-
37
- export function parseFilename(file) {
38
- const tokens = file.replace(reComments, '').split('.')
39
- return {
40
- ext: tokens.pop(),
41
- status: Number(tokens.pop()),
42
- method: tokens.pop(),
43
- urlMask: '/' + removeTrailingSlash(tokens.join('.'))
44
- }
30
+ return isStatic
31
+ ? {
32
+ isStatic,
33
+ ext: tokens.pop() || '',
34
+ status: 200,
35
+ method: 'GET',
36
+ urlMask: '/' + file
37
+ }
38
+ : {
39
+ isStatic,
40
+ ext: tokens.pop(),
41
+ status: Number(tokens.pop()),
42
+ method: tokens.pop(),
43
+ urlMask: '/' + removeTrailingSlash(tokens.join('.'))
44
+ }
45
45
  }
46
46
 
47
47
  export function removeTrailingSlash(url = '') {
@@ -61,9 +61,9 @@ function responseStatusIsValid(status) {
61
61
  && status <= 599
62
62
  }
63
63
 
64
- export function makeMockFilename(url, method, status, ext) {
64
+ export function makeMockFilename(url, method, status, ext, comment = '') {
65
65
  const urlMask = replaceIds(removeTrailingSlash(url))
66
- return [urlMask, method, status, ext].join('.')
66
+ return [urlMask + comment, method, status, ext].join('.')
67
67
  }
68
68
 
69
69
  const reUuidV4 = /([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/gi
@@ -1,6 +1,6 @@
1
1
  import { createElement as r, t, defineClassNames } from './dom-utils.js'
2
+ import { Logo, HelpIcon } from './graphics.js'
2
3
  import { store } from './app-store.js'
3
- import { Logo, HelpIcon } from './app-icons.js'
4
4
 
5
5
  import CSS from './app.css' with { type: 'css' }
6
6
  defineClassNames(CSS)
@@ -47,8 +47,8 @@ function GlobalDelayField() {
47
47
  step: 100,
48
48
  autocomplete: 'none',
49
49
  value: store.delay,
50
- onChange,
51
- onWheel: [onWheel, { passive: true }]
50
+ onWheel: [onWheel, { passive: true }],
51
+ onChange
52
52
  })))
53
53
  }
54
54
 
@@ -73,13 +73,13 @@ function GlobalDelayJitterField() {
73
73
  r('span', null, t`Max Jitter %`),
74
74
  r('input', {
75
75
  type: 'number',
76
+ autocomplete: 'none',
76
77
  min: 0,
77
78
  max: 300,
78
79
  step: 10,
79
- autocomplete: 'none',
80
80
  value: (store.delayJitter * 100).toFixed(0),
81
- onChange,
82
- onWheel: [onWheel, { passive: true }]
81
+ onWheel: [onWheel, { passive: true }],
82
+ onChange
83
83
  })))
84
84
  }
85
85
 
@@ -7,25 +7,29 @@ import CSS from './app.css' with { type: 'css' }
7
7
  defineClassNames(CSS)
8
8
 
9
9
 
10
- const payloadViewerTitleRef = {}
11
- const payloadViewerCodeRef = {}
10
+ const titleRef = {}
11
+ const codeRef = {}
12
12
 
13
13
  export function PayloadViewer() {
14
14
  return (
15
15
  r('div', { className: CSS.PayloadViewer },
16
16
 
17
17
  r('div', { className: CSS.SubToolbar },
18
- r('h2', { ref: payloadViewerTitleRef },
18
+ r('h2', { ref: titleRef },
19
19
  !store.hasChosenLink && t`Preview`)),
20
20
 
21
21
  r('pre', null,
22
- r('code', { ref: payloadViewerCodeRef },
22
+ r('code', { ref: codeRef },
23
23
  !store.hasChosenLink && t`Click a link to preview it`))))
24
24
  }
25
25
 
26
26
 
27
27
  function PayloadViewerTitle(file, statusText) {
28
- const { method, status, ext } = parseFilename(file)
28
+ const { method, status, ext, isStatic } = parseFilename(file)
29
+
30
+ if (isStatic)
31
+ return r('span', null, file)
32
+
29
33
  const fileNameWithComments = file.split('.').slice(0, -3).join('.')
30
34
  return (
31
35
  r('span', null,
@@ -66,8 +70,8 @@ export async function previewMock() {
66
70
  previewMock.controller = new AbortController
67
71
 
68
72
  const spinnerTimer = setTimeout(() => {
69
- payloadViewerTitleRef.elem.replaceChildren(t`Fetching…`)
70
- payloadViewerCodeRef.elem.replaceChildren(PayloadViewerProgressBar())
73
+ titleRef.elem.replaceChildren(t`Fetching…`)
74
+ codeRef.elem.replaceChildren(PayloadViewerProgressBar())
71
75
  }, SPINNER_DELAY)
72
76
 
73
77
  try {
@@ -83,7 +87,7 @@ export async function previewMock() {
83
87
  catch (error) {
84
88
  clearTimeout(spinnerTimer)
85
89
  store.onError(error)
86
- payloadViewerCodeRef.elem.replaceChildren()
90
+ codeRef.elem.replaceChildren()
87
91
  }
88
92
  }
89
93
 
@@ -91,30 +95,53 @@ export async function previewMock() {
91
95
  async function updatePayloadViewer(proxied, file, response) {
92
96
  const mime = response.headers.get('content-type') || ''
93
97
 
94
- payloadViewerTitleRef.elem.replaceChildren(proxied
98
+ titleRef.elem.replaceChildren(proxied
95
99
  ? PayloadViewerTitleWhenProxied(response)
96
100
  : PayloadViewerTitle(file, response.statusText))
97
101
 
98
- if (mime.startsWith('image/')) // Naively assumes GET 200
99
- payloadViewerCodeRef.elem.replaceChildren(r('img', {
102
+ // All branches naively assume GET 200
103
+ if (mime.startsWith('image/'))
104
+ codeRef.elem.replaceChildren(r('img', {
100
105
  src: URL.createObjectURL(await response.blob())
101
106
  }))
102
- else {
103
- const body = await response.text() || t`/* Empty Response Body */`
104
- if (mime === 'application/json')
105
- payloadViewerCodeRef.elem.replaceChildren(SyntaxJSON(body))
106
- else if (isXML(mime))
107
- payloadViewerCodeRef.elem.replaceChildren(SyntaxXML(body))
108
- else
109
- payloadViewerCodeRef.elem.textContent = body
110
- }
111
- }
112
107
 
113
- function isXML(mime) {
114
- return ['text/html', 'text/xml', 'application/xml'].some(m => mime.includes(m))
115
- || /application\/.*\+xml/.test(mime)
116
- }
108
+ else if (mime.startsWith('video/'))
109
+ codeRef.elem.replaceChildren(r('video', {
110
+ src: store.chosenLink.urlMask,
111
+ controls: true
112
+ }))
113
+
114
+ else if (mime.startsWith('audio/'))
115
+ codeRef.elem.replaceChildren(r('audio', {
116
+ src: store.chosenLink.urlMask,
117
+ controls: true
118
+ }))
117
119
 
120
+ else if (['text/html', 'application/pdf'].includes(mime))
121
+ codeRef.elem.replaceChildren(r('iframe', {
122
+ src: store.chosenLink.urlMask // using a blob is would need to allow inline styles etc in CSP
123
+ }))
124
+
125
+ else if (mime === 'application/json')
126
+ codeRef.elem.replaceChildren(SyntaxJSON(await bodyAsText()))
127
+
128
+ else if (['text/xml', 'application/xml'].includes(mime))
129
+ codeRef.elem.replaceChildren(SyntaxXML(await bodyAsText()))
130
+
131
+ else if (mime.startsWith('text/'))
132
+ codeRef.elem.textContent = await bodyAsText()
133
+
134
+ else
135
+ codeRef.elem.replaceChildren(r('a', {
136
+ href: URL.createObjectURL(await response.blob()),
137
+ download: store.chosenLink.urlMask
138
+ }, t`Download`))
139
+
140
+
141
+ function bodyAsText() {
142
+ return response.text() || t`/* Empty Response Body */`
143
+ }
144
+ }
118
145
 
119
146
 
120
147
  function SyntaxJSON(json) {
@@ -12,7 +12,6 @@ export const store = {
12
12
  renderRow(method, urlMask) {},
13
13
 
14
14
  brokersByMethod: /** @type ClientBrokersByMethod */ {},
15
- staticBrokers: /** @type ClientStaticBrokers */ {},
16
15
 
17
16
  cookies: [],
18
17
  comments: [],
@@ -148,19 +147,6 @@ export const store = {
148
147
  return r
149
148
  },
150
149
 
151
- staticBrokersAsRows() {
152
- const rows = Object.values(store.staticBrokers)
153
- .map(b => new StaticBrokerRowModel(b))
154
- .sort((a, b) => a.urlMask.localeCompare(b.urlMask))
155
- const urlMasksDittoed = dittoSplitPaths(rows.map(r => r.urlMask))
156
- rows.forEach((r, i) => {
157
- r.setUrlMaskDittoed(urlMasksDittoed[i])
158
- r.setIsNew(!store._dittoCache.has(r.key))
159
- store._dittoCache.set(r.key, urlMasksDittoed[i])
160
- })
161
- return rows
162
- },
163
-
164
150
  previewLink(method, urlMask) {
165
151
  store.setChosenLink(method, urlMask)
166
152
  store.renderRow(method, urlMask)
@@ -175,8 +161,8 @@ export const store = {
175
161
  })
176
162
  },
177
163
 
178
- toggle500(method, urlMask) {
179
- store._request(() => api.toggle500(method, urlMask), async response => {
164
+ toggleStatus(status, method, urlMask) {
165
+ store._request(() => api.toggleStatus(status, method, urlMask), async response => {
180
166
  store.setBroker(await response.json())
181
167
  store.setChosenLink(method, urlMask)
182
168
  store.renderRow(method, urlMask)
@@ -195,18 +181,6 @@ export const store = {
195
181
  store._request(() => api.setRouteIsDelayed(method, urlMask, checked), async response => {
196
182
  store.setBroker(await response.json())
197
183
  })
198
- },
199
-
200
- setDelayedStatic(route, checked) {
201
- store._request(() => api.setStaticRouteIsDelayed(route, checked), () => {
202
- store.staticBrokers[route].delayed = checked
203
- })
204
- },
205
-
206
- setStaticRouteStatus(route, status) {
207
- store._request(() => api.setStaticRouteStatus(route, status), () => {
208
- store.staticBrokers[route].status = status
209
- })
210
184
  }
211
185
  }
212
186
 
@@ -303,28 +277,25 @@ export class BrokerRowModel {
303
277
  }
304
278
 
305
279
  get status() { return this.#broker.status }
306
- get auto500() { return this.#broker.auto500 }
280
+ get autoStatus() { return this.#broker.autoStatus }
281
+ get isStatic() { return this.#broker.isStatic }
307
282
  get delayed() { return this.#broker.delayed }
308
- get proxied() { return this.#canProxy && this.#broker.proxied }
283
+ get proxied() { return this.#broker.proxied && this.#canProxy }
309
284
  get selectedFile() { return this.#broker.file }
310
- get selectedIdx() {
311
- return this.opts.findIndex(([, , selected]) => selected)
312
- }
313
- get selectedFileIs4xx() {
314
- return this.status >= 400 && this.status < 500
315
- }
285
+ get selectedIdx() { return this.opts.findIndex(([, , selected]) => selected) }
286
+ get selectedFileIs4xx() { return this.status >= 400 && this.status < 500 }
316
287
 
317
288
  #makeOptions() {
318
289
  const opts = this.#broker.mocks.map(f => [
319
290
  f,
320
291
  this.#optionLabelFor(f),
321
- !this.auto500 && !this.proxied && f === this.selectedFile
292
+ !this.autoStatus && !this.proxied && f === this.selectedFile
322
293
  ])
323
294
 
324
- if (this.auto500)
295
+ if (this.autoStatus)
325
296
  opts.push([
326
- '__AUTO_500__',
327
- t`Auto500`,
297
+ '__AUTO_STATUS__',
298
+ this.autoStatus === 404 ? t`Auto404` : t`Auto500`,
328
299
  true
329
300
  ])
330
301
  else if (this.proxied)
@@ -346,27 +317,3 @@ export class BrokerRowModel {
346
317
  ].filter(Boolean).join(' ')
347
318
  }
348
319
  }
349
-
350
-
351
- class StaticBrokerRowModel {
352
- isNew = false
353
- key = ''
354
- method = 'GET'
355
- urlMaskDittoed = ['', '']
356
- #broker = /** @type ClientStaticBroker */ {}
357
-
358
- /** @param {ClientStaticBroker} broker */
359
- constructor(broker) {
360
- this.#broker = broker
361
- this.key = 'sbrm' + '::' + this.method + '::' + broker.route
362
- }
363
- setUrlMaskDittoed(urlMaskDittoed) {
364
- this.urlMaskDittoed = urlMaskDittoed
365
- }
366
- setIsNew(isNew) {
367
- this.isNew = isNew
368
- }
369
- get urlMask() { return this.#broker.route }
370
- get delayed() { return this.#broker.delayed }
371
- get status() { return this.#broker.status }
372
- }
@@ -31,7 +31,7 @@ test('dittoSplitPaths', () => {
31
31
  test('BrokerRowModel', () => {
32
32
  test('has Auto500 when is autogenerated 500', () => {
33
33
  const broker = {
34
- auto500: true,
34
+ autoStatus: 500,
35
35
  file: 'api/user.GET.200.json',
36
36
  mocks: ['api/user.GET.200.json']
37
37
  }
@@ -43,6 +43,20 @@ test('BrokerRowModel', () => {
43
43
  ])
44
44
  })
45
45
 
46
+ test('has Auto 404 when is autogenerated 404', () => {
47
+ const broker = {
48
+ autoStatus: 404,
49
+ file: 'index.html',
50
+ mocks: ['index.html']
51
+ }
52
+ const row = new BrokerRowModel(broker, false)
53
+ const opts = row.opts.map(([, n, selected]) => [n, selected])
54
+ deepEqual(opts, [
55
+ ['200 html', false],
56
+ [t`Auto404`, true],
57
+ ])
58
+ })
59
+
46
60
  test('filename has extension except when empty or unknown', () => {
47
61
  const broker = {
48
62
  file: `api/user0.GET.200.empty`,