mockaton 12.6.0 → 12.7.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/index.d.ts CHANGED
@@ -54,6 +54,7 @@ export function Mockaton(options: Partial<Config>): Promise<Server | undefined>
54
54
  export function defineConfig(options: Partial<Config>): Partial<Config>
55
55
 
56
56
  export const jsToJsonPlugin: Plugin
57
+ export const echoFilePlugin: Plugin
57
58
 
58
59
 
59
60
  // Utils
package/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  export { Commander } from './src/client/ApiCommander.js'
2
+ export { API } from './src/client/ApiConstants.js'
2
3
 
3
4
  export { Mockaton } from './src/server/Mockaton.js'
4
5
  export { jwtCookie } from './src/server/utils/jwt.js'
5
- export { jsToJsonPlugin } from './src/server/MockDispatcher.js'
6
+ export { jsToJsonPlugin, echoFilePlugin } from './src/server/MockDispatcherPlugins.js'
6
7
  export { parseJSON, BodyReaderError } from './src/server/utils/HttpIncomingMessage.js'
7
8
  export { parseSplats, parseQueryParams } from './src/server/utils/UrlParsers.js'
8
9
 
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.6.0",
5
+ "version": "12.7.0",
6
6
  "exports": {
7
7
  ".": {
8
8
  "import": "./index.js",
@@ -30,6 +30,9 @@ export class Commander {
30
30
  /** @returns {Promise<Response>} */
31
31
  setCorsAllowed = value => this.#patch(API.cors, value)
32
32
 
33
+ /** @returns {Promise<Response>} */
34
+ setWatchMocks = enabled => this.#patch(API.watchMocks, enabled)
35
+
33
36
  /** @returns {Promise<Response>} */
34
37
  setProxyFallback = proxyAddr => this.#patch(API.fallback, proxyAddr)
35
38
 
@@ -23,11 +23,14 @@ export const API = {
23
23
  throws: MOUNT + '/throws',
24
24
  toggle500: MOUNT + '/toggle500',
25
25
  watchHotReload: MOUNT + '/watch-hot-reload',
26
+ watchMocks: MOUNT + '/watch-mocks',
26
27
  }
27
28
 
28
29
  export const HEADER_502 = 'Mockaton502'
29
30
  export const HEADER_SYNC_VERSION = 'sync_version'
30
31
 
31
32
  export const DEFAULT_MOCK_COMMENT = '(default)'
32
- export const UNKNOWN_MIME_EXT = 'unknown'
33
33
  export const LONG_POLL_SERVER_TIMEOUT = 8_000
34
+
35
+ export const EXT_UNKNOWN_MIME = 'unknown'
36
+ export const EXT_EMPTY = 'empty'
@@ -0,0 +1,32 @@
1
+ import { API } from './ApiConstants.js'
2
+
3
+ export const CSP = [
4
+ `default-src 'self'`,
5
+ `img-src data: blob: 'self'`
6
+ ].join(';')
7
+
8
+
9
+ // language=html
10
+ export const IndexHtml = (hotReloadEnabled, version) =>
11
+ `
12
+ <!DOCTYPE html>
13
+ <html lang="en-US">
14
+ <head>
15
+ <meta charset="UTF-8">
16
+ <base href="${API.dashboard}/">
17
+
18
+ <script type="module" src="app.js"></script>
19
+ <link rel="preload" href="${API.state}" as="fetch" crossorigin>
20
+
21
+ <link rel="icon" href="data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m235 33.7v202c0 9.19-5.81 14-17.4 14-11.6 0-17.4-4.83-17.4-14v-151c-0.115-4.49-6.72-5.88-8.46-0.87l-48.3 155c-2.22 7.01-7.72 10.1-16 9.9-3.63-0.191-7.01-1.14-9.66-2.89-2.89-1.72-4.83-4.34-5.57-7.72-11.1-37-22.6-74.3-34.1-111-4.34-14-8.95-31.4-14-48.3-1.82-4.83-8.16-5.32-8.46 1.16v156c0 9.19-5.81 14-17.4 14-11.6 0-17.4-4.83-17.4-14v-207c0-5.74 2.62-13.2 9.39-16.3 7.5-3.14 15-4.05 21.8-3.8 3.14 0 6.03 0.686 8.95 1.46 3.14 0.797 6.03 1.98 8.7 3.63 2.65 1.38 5.32 3.14 7.5 5.57 2.22 2.22 3.87 4.83 5.07 7.72l45.8 157c4.63-15.9 32.4-117 33.3-121 4.12-13.8 7.72-26.5 10.9-38.7 1.16-2.65 2.89-5.32 5.07-7.5 2.15-2.15 4.58-4.12 7.5-5.32 2.65-1.57 5.57-2.89 8.46-3.63 3.14-0.797 9.44-0.988 12.1-0.988 11.6 1.07 29.4 9.14 29.4 27z' fill='%23808080'/%3E%3C/svg%3E">
22
+ <meta name="viewport" content="width=device-width, initial-scale=1">
23
+ <meta name="description" content="HTTP Mock Server">
24
+ <title>Mockaton v${version}</title>
25
+ </head>
26
+ <body>
27
+ ${hotReloadEnabled
28
+ ? '<script type="module" src="watcherDev.js"></script>'
29
+ : ''}
30
+ </body>
31
+ </html>
32
+ `.trim()
@@ -1,5 +1,6 @@
1
1
  import { Commander } from './ApiCommander.js'
2
2
  import { parseFilename, extractComments } from './Filename.js'
3
+ import { EXT_UNKNOWN_MIME, EXT_EMPTY } from './ApiConstants.js'
3
4
 
4
5
 
5
6
  export const t = translation => translation[0]
@@ -25,84 +26,85 @@ export const store = {
25
26
  return Boolean(store.proxyFallback)
26
27
  },
27
28
 
29
+ groupByMethod: initPreference('groupByMethod'),
30
+ toggleGroupByMethod() {
31
+ store.groupByMethod = !store.groupByMethod
32
+ togglePreference('groupByMethod', store.groupByMethod)
33
+ store.render()
34
+ },
35
+
36
+ chosenLink: { method: '', urlMask: '' },
37
+ setChosenLink(method, urlMask) {
38
+ store.chosenLink = { method, urlMask }
39
+ },
40
+ get hasChosenLink() {
41
+ return store.chosenLink.method && store.chosenLink.urlMask
42
+ },
43
+
44
+
28
45
  getSyncVersion: api.getSyncVersion,
29
46
 
30
- _action(action, onSuccess) {
47
+ _request(action, onSuccess) {
31
48
  Promise.try(async () => {
32
49
  const response = await action()
33
- if (!response.ok) throw response
34
- return response
50
+ if (response.ok) return response
51
+ throw response
35
52
  })
36
53
  .then(onSuccess)
37
54
  .catch(store.onError)
38
55
  },
39
56
 
40
- async fetchState() {
41
- store._action(api.getState, async response => {
57
+ fetchState() {
58
+ store._request(api.getState, async response => {
42
59
  Object.assign(store, await response.json())
43
-
60
+
44
61
  if (store.showProxyField === null) // isFirstCall
45
62
  store.showProxyField = Boolean(store.proxyFallback)
46
-
63
+
47
64
  store.render()
48
65
  })
49
66
  },
50
67
 
51
- groupByMethod: initPreference('groupByMethod'),
52
- toggleGroupByMethod() {
53
- store.groupByMethod = !store.groupByMethod
54
- togglePreference('groupByMethod', store.groupByMethod)
55
- store.render()
56
- },
57
-
58
-
59
- chosenLink: { method: '', urlMask: '' },
60
- setChosenLink(method, urlMask) {
61
- store.chosenLink = { method, urlMask }
62
- },
63
- get hasChosenLink() {
64
- return store.chosenLink.method && store.chosenLink.urlMask
65
- },
66
-
67
- async reset() {
68
- store._action(api.reset, () => {
68
+ reset() {
69
+ store._request(api.reset, () => {
69
70
  store.setChosenLink('', '')
70
71
  store.fetchState()
71
72
  })
72
73
  },
73
74
 
74
- async bulkSelectByComment(value) {
75
- store._action(() => api.bulkSelectByComment(value),
76
- store.fetchState)
75
+ bulkSelectByComment(value) {
76
+ store._request(() => api.bulkSelectByComment(value), () => {
77
+ store.fetchState()
78
+ })
77
79
  },
78
80
 
79
- async setGlobalDelay(value) {
80
- store._action(() => api.setGlobalDelay(value), () => {
81
+ setGlobalDelay(value) {
82
+ store._request(() => api.setGlobalDelay(value), () => {
81
83
  store.delay = value
82
84
  })
83
85
  },
84
86
 
85
- async setGlobalDelayJitter(value) {
86
- store._action(() => api.setGlobalDelayJitter(value), () => {
87
+ setGlobalDelayJitter(value) {
88
+ store._request(() => api.setGlobalDelayJitter(value), () => {
87
89
  store.delayJitter = value
88
90
  })
89
91
  },
90
92
 
91
- async selectCookie(name) {
92
- store._action(() => api.selectCookie(name), async response => {
93
+ selectCookie(name) {
94
+ store._request(() => api.selectCookie(name), async response => {
93
95
  store.cookies = await response.json()
94
96
  })
95
97
  },
96
98
 
97
- async setProxyFallback(value) {
98
- store._action(() => api.setProxyFallback(value), () => {
99
+ setProxyFallback(value) {
100
+ store._request(() => api.setProxyFallback(value), () => {
99
101
  store.proxyFallback = value
100
102
  store.render()
101
103
  })
102
104
  },
103
105
 
104
- async setCollectProxied(checked) {
105
- store._action(() => api.setCollectProxied(checked), () => {
106
+ setCollectProxied(checked) {
107
+ store._request(() => api.setCollectProxied(checked), () => {
106
108
  store.collectProxied = checked
107
109
  })
108
110
  },
@@ -166,8 +168,8 @@ export const store = {
166
168
  store.renderRow(method, urlMask)
167
169
  },
168
170
 
169
- async selectFile(file) {
170
- store._action(() => api.select(file), async response => {
171
+ selectFile(file) {
172
+ store._request(() => api.select(file), async response => {
171
173
  const { method, urlMask } = parseFilename(file)
172
174
  store.setBroker(await response.json())
173
175
  store.setChosenLink(method, urlMask)
@@ -175,36 +177,36 @@ export const store = {
175
177
  })
176
178
  },
177
179
 
178
- async toggle500(method, urlMask) {
179
- store._action(() => api.toggle500(method, urlMask), async response => {
180
+ toggle500(method, urlMask) {
181
+ store._request(() => api.toggle500(method, urlMask), async response => {
180
182
  store.setBroker(await response.json())
181
183
  store.setChosenLink(method, urlMask)
182
184
  store.renderRow(method, urlMask)
183
185
  })
184
186
  },
185
187
 
186
- async setProxied(method, urlMask, checked) {
187
- store._action(() => api.setRouteIsProxied(method, urlMask, checked), async response => {
188
+ setProxied(method, urlMask, checked) {
189
+ store._request(() => api.setRouteIsProxied(method, urlMask, checked), async response => {
188
190
  store.setBroker(await response.json())
189
191
  store.setChosenLink(method, urlMask)
190
192
  store.renderRow(method, urlMask)
191
193
  })
192
194
  },
193
195
 
194
- async setDelayed(method, urlMask, checked) {
195
- store._action(() => api.setRouteIsDelayed(method, urlMask, checked), async response => {
196
+ setDelayed(method, urlMask, checked) {
197
+ store._request(() => api.setRouteIsDelayed(method, urlMask, checked), async response => {
196
198
  store.setBroker(await response.json())
197
199
  })
198
200
  },
199
201
 
200
- async setDelayedStatic(route, checked) {
201
- store._action(() => api.setStaticRouteIsDelayed(route, checked), () => {
202
+ setDelayedStatic(route, checked) {
203
+ store._request(() => api.setStaticRouteIsDelayed(route, checked), () => {
202
204
  store.staticBrokers[route].delayed = checked
203
205
  })
204
206
  },
205
207
 
206
- async setStaticRouteStatus(route, status) {
207
- store._action(() => api.setStaticRouteStatus(route, status), () => {
208
+ setStaticRouteStatus(route, status) {
209
+ store._request(() => api.setStaticRouteStatus(route, status), () => {
208
210
  store.staticBrokers[route].status = status
209
211
  })
210
212
  }
@@ -250,26 +252,24 @@ function togglePreference(param, nextVal) {
250
252
  * @param {string[]} paths - sorted
251
253
  */
252
254
  export function dittoSplitPaths(paths) {
253
- const result = [['', paths[0]]]
254
- const pathsInParts = paths.map(p => p.split('/').filter(Boolean))
255
-
256
- for (let i = 1; i < paths.length; i++) {
257
- const prev = pathsInParts[i - 1]
258
- const curr = pathsInParts[i]
255
+ const pParts = paths.map(p => p.split('/').filter(Boolean))
256
+ return paths.map((p, i) => {
257
+ if (i === 0)
258
+ return ['', p]
259
259
 
260
+ const prev = pParts[i - 1]
261
+ const curr = pParts[i]
260
262
  const min = Math.min(curr.length, prev.length)
261
263
  let j = 0
262
264
  while (j < min && curr[j] === prev[j])
263
265
  j++
264
266
 
265
267
  if (!j) // no common dirs
266
- result.push(['', paths[i]])
267
- else {
268
- const ditto = '/' + curr.slice(0, j).join('/') + '/'
269
- result.push([ditto, paths[i].slice(ditto.length)])
270
- }
271
- }
272
- return result
268
+ return ['', p]
269
+
270
+ const ditto = '/' + curr.slice(0, j).join('/') + '/'
271
+ return [ditto, p.slice(ditto.length)]
272
+ })
273
273
  }
274
274
 
275
275
 
@@ -343,7 +343,7 @@ export class BrokerRowModel {
343
343
  const { status, ext } = parseFilename(file)
344
344
  return [
345
345
  status,
346
- ext === 'empty' || ext === 'unknown' ? '' : ext,
346
+ ext === EXT_EMPTY || ext === EXT_UNKNOWN_MIME ? '' : ext,
347
347
  extractComments(file).join(' ')
348
348
  ].filter(Boolean).join(' ')
349
349
  }
@@ -1,11 +1,12 @@
1
1
  :root {
2
2
  color-scheme: light dark;
3
3
  --colorBackground: light-dark(#fff, #181818);
4
- --colorComboBoxHeaderBackground: light-dark(#fff, #222);
4
+ --colorInputBackground: light-dark(#fff, #222);
5
5
  --colorSecondaryButtonBackground: light-dark(#fcfcfc, #2c2c2c);
6
6
  --colorHeaderBackground: light-dark(#f2f2f3, #141414);
7
7
  --colorComboBoxBackground: light-dark(#eee, #2a2a2a);
8
- --colorBorder: light-dark(#e0e0e0, #333);
8
+ --colorBorder: light-dark(#e0e0e0, #323232);
9
+ --colorBorderActive: light-dark(#c8c8c8, #3c3c3c);
9
10
  --colorSecondaryAction: light-dark(#666, #aaa);
10
11
  --colorLabel: light-dark(#555, #aaa);
11
12
  --colorDisabledMockSelector: light-dark(#444, #a9b9b9);
@@ -17,7 +18,7 @@
17
18
  --colorPurple: light-dark(#9b71e8, #ae81ff);
18
19
  --colorGreen: light-dark(#388e3c, #a6e22e);
19
20
  --color4xxBackground: light-dark(#ffedd1, #68554a);
20
-
21
+
21
22
  accent-color: var(--colorAccent);
22
23
  --radius: 16px;
23
24
  }
@@ -115,10 +116,6 @@ header {
115
116
  opacity: 1;
116
117
  }
117
118
 
118
- @media (max-width: 740px) {
119
- display: none;
120
- }
121
-
122
119
  svg {
123
120
  width: 120px;
124
121
  pointer-events: none;
@@ -126,6 +123,20 @@ header {
126
123
  }
127
124
  }
128
125
 
126
+ @media (max-width: 570px) {
127
+ .Logo {
128
+ display: none;
129
+ }
130
+ }
131
+
132
+ &:has(.FallbackBackend) {
133
+ @media (max-width: 740px) {
134
+ .Logo {
135
+ display: none;
136
+ }
137
+ }
138
+ }
139
+
129
140
  > div {
130
141
  display: flex;
131
142
  width: 100%;
@@ -133,9 +144,11 @@ header {
133
144
  align-items: flex-end;
134
145
  gap: 16px 12px;
135
146
 
136
- @media (max-width: 600px) {
137
- .HelpLink {
138
- margin-left: unset;
147
+ &:has(.FallbackBackend) {
148
+ @media (max-width: 600px) {
149
+ .HelpLink {
150
+ margin-left: unset;
151
+ }
139
152
  }
140
153
  }
141
154
  }
@@ -209,7 +222,7 @@ header {
209
222
  margin-top: 2px;
210
223
  color: var(--colorText);
211
224
  font-size: 11px;
212
- background-color: var(--colorComboBoxHeaderBackground);
225
+ background-color: var(--colorInputBackground);
213
226
  border-radius: var(--radius);
214
227
  }
215
228
 
@@ -301,14 +314,14 @@ main {
301
314
  }
302
315
 
303
316
  .leftSide {
304
- width: 100% !important;
317
+ width: 100% !important; /* because it's resizable in js */
305
318
  height: 50%;
306
319
  border-right: 0;
307
320
  }
308
321
  }
309
322
 
310
323
  .leftSide {
311
- width: 50%; /* resizable in js */
324
+ width: 50%;
312
325
  border-top: 1px solid var(--colorBorder);
313
326
  border-right: 1px solid var(--colorBorder);
314
327
  }
@@ -323,14 +336,31 @@ main {
323
336
  .Resizer {
324
337
  position: absolute;
325
338
  top: 0;
326
- left: 0;
339
+ left: -1px;
327
340
  width: 8px;
328
341
  height: 100%;
329
- border-left: 3px solid transparent;
342
+ border-left: 1px solid transparent;
330
343
  cursor: col-resize;
331
344
 
345
+ &:hover,
332
346
  &:active {
333
- border-color: var(--colorBorder);
347
+ border-color: var(--colorBorderActive);
348
+ }
349
+ &::before,
350
+ &::after {
351
+ position: absolute;
352
+ left: -2px;
353
+ content: '';
354
+ }
355
+ &::before {
356
+ top: calc(50% + 6px);
357
+ height: 22px;
358
+ border-left: 3px solid var(--colorBackground);
359
+ }
360
+ &::after {
361
+ top: calc(50% + 10px);
362
+ height: 14px;
363
+ border-left: 3px dotted var(--colorBorder);
334
364
  }
335
365
  }
336
366
  }
@@ -364,7 +394,7 @@ main {
364
394
  text-align-last: center;
365
395
  color: var(--colorText);
366
396
  font-size: 11px;
367
- background-color: var(--colorComboBoxHeaderBackground);
397
+ background-color: var(--colorInputBackground);
368
398
  border-radius: var(--radius);
369
399
  }
370
400
  }
@@ -622,7 +652,7 @@ main {
622
652
  left: -16px;
623
653
  width: calc(100% + 32px);
624
654
  height: 2px;
625
- background: var(--colorComboBoxHeaderBackground);
655
+ background: var(--colorInputBackground);
626
656
 
627
657
  > div {
628
658
  position: absolute;
@@ -651,6 +681,7 @@ main {
651
681
  bottom: 12px;
652
682
  left: 12px;
653
683
  padding: 12px 16px;
684
+ padding-right: 42px;
654
685
  cursor: pointer;
655
686
  background: var(--colorRed);
656
687
  color: white;
@@ -659,18 +690,16 @@ main {
659
690
  transform: translateY(20px);
660
691
  animation: _kfToastIn 240ms forwards;
661
692
 
662
- &:hover::after {
693
+ &::after {
663
694
  position: absolute;
664
695
  top: 0;
665
- left: 0;
666
- width: 100%;
667
- height: 100%;
696
+ right: 16px;
697
+ width: 12px;
698
+ height: 12px;
668
699
  text-align: center;
669
700
  content: '×';
670
701
  font-size: 28px;
671
702
  line-height: 34px;
672
- border-radius: var(--radius);
673
- background: rgba(0, 0, 0, 0.5);
674
703
  }
675
704
  }
676
705
  @keyframes _kfToastIn {
package/src/client/app.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import {
2
2
  createElement as r,
3
3
  createSvgElement as s,
4
- t, classNames, restoreFocus, Fragment, adoptCSS
4
+ t, classNames, restoreFocus, Fragment, defineClassNames
5
5
  } from './dom-utils.js'
6
6
  import { store } from './app-store.js'
7
7
  import { PayloadViewer, previewMock } from './payload-viewer.js'
8
8
 
9
9
  import CSS from './app.css' with { type: 'css' }
10
- adoptCSS(CSS)
10
+ document.adoptedStyleSheets.push(CSS)
11
+ defineClassNames(CSS)
11
12
 
12
13
 
13
14
  store.onError = onError
@@ -480,13 +481,38 @@ function DelayToggler({ checked, commit, optClassName }) {
480
481
  function ClickDragToggler({ checked, commit, className, title, body }) {
481
482
  function onPointerEnter(event) {
482
483
  if (event.buttons === 1)
483
- onPointerDown.call(this)
484
+ onPointerDown.call(this, event)
484
485
  }
485
- function onPointerDown() {
486
+
487
+ function onPointerDown(event) {
488
+ if (event.altKey) {
489
+ onExclusiveClick.call(this)
490
+ return
491
+ }
486
492
  this.checked = !this.checked
487
493
  this.focus()
488
494
  commit(this.checked)
489
495
  }
496
+
497
+ function onExclusiveClick() {
498
+ const selector = selectorForColumnOf(this)
499
+ if (!selector)
500
+ return
501
+
502
+ // Uncheck all other in the column.
503
+ for (const elem of leftSideRef.elem.querySelectorAll(selector))
504
+ if (elem !== this && elem.checked && !elem.disabled) {
505
+ elem.checked = false
506
+ elem.dispatchEvent(new Event('change'))
507
+ }
508
+
509
+ if (!this.checked) {
510
+ this.checked = true
511
+ this.dispatchEvent(new Event('change'))
512
+ }
513
+ this.focus()
514
+ }
515
+
490
516
  function onClick(event) {
491
517
  if (event.pointerType === 'mouse')
492
518
  event.preventDefault()
@@ -666,18 +692,24 @@ function initRealTimeUpdates() {
666
692
  }
667
693
  }
668
694
 
695
+ function selectorForColumnOf(elem) {
696
+ return columnSelectors().find(s => elem?.matches(s))
697
+ }
669
698
 
670
- function initKeyboardNavigation() {
671
- const columnSelectors = [
699
+ function columnSelectors() {
700
+ return [
672
701
  `.${CSS.TableRow} .${CSS.ProxyToggler} input`,
673
702
  `.${CSS.TableRow} .${CSS.DelayToggler} input`,
674
703
  `.${CSS.TableRow} .${CSS.StatusCodeToggler} input`,
675
704
  `.${CSS.TableRow} .${CSS.PreviewLink}`,
676
705
  // No .MockSelector because down/up arrows have native behavior on them
677
706
  ]
707
+ }
678
708
 
709
+
710
+ function initKeyboardNavigation() {
679
711
  const rowSelectors = [
680
- ...columnSelectors,
712
+ ...columnSelectors(),
681
713
  `.${CSS.TableRow} .${CSS.MockSelector}:enabled`,
682
714
  ]
683
715
 
@@ -686,7 +718,7 @@ function initKeyboardNavigation() {
686
718
  case 'ArrowDown':
687
719
  case 'ArrowUp': {
688
720
  const pivot = document.activeElement
689
- const sel = columnSelectors.find(s => pivot?.matches(s))
721
+ const sel = selectorForColumnOf(pivot)
690
722
  if (sel) {
691
723
  const offset = key === 'ArrowDown' ? +1 : -1
692
724
  const siblings = leftSideRef.elem.querySelectorAll(sel)
@@ -72,8 +72,7 @@ function selectorFor(elem) {
72
72
  }
73
73
 
74
74
 
75
- export function adoptCSS(sheet) {
76
- document.adoptedStyleSheets.push(sheet)
75
+ export function defineClassNames(sheet) {
77
76
  Object.assign(sheet, extractClassNames(sheet))
78
77
  }
79
78
 
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  createElement as r,
3
- t, classNames, adoptCSS
3
+ t, classNames, defineClassNames
4
4
  } from './dom-utils.js'
5
5
  import { parseFilename } from './Filename.js'
6
6
  import { HEADER_502 } from './ApiConstants.js'
7
7
  import { store } from './app-store.js'
8
8
 
9
9
  import CSS from './app.css' with { type: 'css' }
10
- adoptCSS(CSS)
10
+ defineClassNames(CSS)
11
11
 
12
12
 
13
13
  const payloadViewerTitleRef = {}
@@ -84,7 +84,9 @@ export async function previewMock() {
84
84
  if (proxied || file)
85
85
  await updatePayloadViewer(proxied, file, response)
86
86
  }
87
- catch {
87
+ catch (error) {
88
+ clearTimeout(spinnerTimer)
89
+ store.onError(error)
88
90
  payloadViewerCodeRef.elem.replaceChildren()
89
91
  }
90
92
  }
package/src/server/Api.js CHANGED
@@ -10,12 +10,12 @@ import {
10
10
  DASHBOARD_ASSETS,
11
11
  CLIENT_DIR
12
12
  } from './WatcherDevClient.js'
13
- import { longPollClientSyncVersion } from './Watcher.js'
13
+ import { longPollClientSyncVersion, startWatchers, stopWatchers } from './Watcher.js'
14
14
 
15
15
  import pkgJSON from '../../package.json' with { type: 'json' }
16
16
 
17
17
  import { API } from '../client/ApiConstants.js'
18
- import { IndexHtml, CSP } from '../client/indexHtml.js'
18
+ import { IndexHtml, CSP } from '../client/IndexHtml.js'
19
19
 
20
20
  import { cookie } from './cookie.js'
21
21
  import { config, ConfigValidator } from './config.js'
@@ -54,7 +54,9 @@ export const apiPatchReqs = new Map([
54
54
  [API.toggle500, toggleRoute500],
55
55
 
56
56
  [API.delayStatic, setStaticRouteIsDelayed],
57
- [API.staticStatus, setStaticRouteStatusCode]
57
+ [API.staticStatus, setStaticRouteStatusCode],
58
+
59
+ [API.watchMocks, setWatchMocks]
58
60
  ])
59
61
 
60
62
 
@@ -109,6 +111,21 @@ async function setCorsAllowed(req, response) {
109
111
  }
110
112
 
111
113
 
114
+ async function setWatchMocks(req, response) {
115
+ const enabled = await req.json()
116
+
117
+ if (typeof enabled !== 'boolean')
118
+ response.unprocessable(`Expected boolean for "watchMocks"`)
119
+ else {
120
+ if (enabled)
121
+ startWatchers()
122
+ else
123
+ stopWatchers()
124
+ response.ok()
125
+ }
126
+ }
127
+
128
+
112
129
  async function setGlobalDelay(req, response) {
113
130
  const delay = await req.json()
114
131
 
@@ -1,12 +1,10 @@
1
1
  import { join } from 'node:path'
2
- import { readFileSync } from 'node:fs'
3
- import { pathToFileURL } from 'node:url'
4
2
 
5
3
  import { logger } from './utils/logger.js'
6
- import { mimeFor } from './utils/mime.js'
7
4
 
8
5
  import { proxy } from './ProxyRelay.js'
9
6
  import { cookie } from './cookie.js'
7
+ import { echoFilePlugin } from './MockDispatcherPlugins.js'
10
8
  import { brokerByRoute } from './mockBrokersCollection.js'
11
9
  import { config, calcDelay } from './config.js'
12
10
 
@@ -61,21 +59,7 @@ async function applyPlugins(filePath, req, response) {
61
59
  for (const [regex, plugin] of config.plugins)
62
60
  if (regex.test(filePath))
63
61
  return await plugin(filePath, req, response)
64
- return {
65
- mime: mimeFor(filePath),
66
- body: readFileSync(filePath)
67
- }
68
- }
69
-
70
- export async function jsToJsonPlugin(filePath, req, response) {
71
- const jsExport = (await import(pathToFileURL(filePath))).default
72
- const body = typeof jsExport === 'function'
73
- ? await jsExport(req, response)
74
- : JSON.stringify(jsExport, null, 2)
75
- return {
76
- mime: response.getHeader('Content-Type') || mimeFor('.json'), // jsFunc are allowed to set it
77
- body
78
- }
62
+ return echoFilePlugin(filePath)
79
63
  }
80
64
 
81
65
  function length(body) {
@@ -0,0 +1,22 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { pathToFileURL } from 'node:url'
3
+
4
+ import { mimeFor } from './utils/mime.js'
5
+
6
+ export function echoFilePlugin(filePath) {
7
+ return {
8
+ mime: mimeFor(filePath),
9
+ body: readFileSync(filePath)
10
+ }
11
+ }
12
+
13
+ export async function jsToJsonPlugin(filePath, req, response) {
14
+ const jsExport = (await import(pathToFileURL(filePath))).default
15
+ const body = typeof jsExport === 'function'
16
+ ? await jsExport(req, response)
17
+ : JSON.stringify(jsExport, null, 2)
18
+ return {
19
+ mime: response.getHeader('Content-Type') || mimeFor('.json'), // js functions are allowed to set it
20
+ body
21
+ }
22
+ }
@@ -0,0 +1,17 @@
1
+ import { jwtCookie } from '../../index.js'
2
+
3
+ export default {
4
+ cookies: {
5
+ userA: 'CookieA',
6
+ userB: jwtCookie('CookieB', { email: 'john@example.test' }),
7
+ },
8
+ extraHeaders: ['custom_header_name', 'custom_header_val'],
9
+ extraMimes: {
10
+ ['custom_extension']: 'custom_mime'
11
+ },
12
+ logLevel: 'verbose',
13
+ corsOrigins: ['https://example.test'],
14
+ corsExposedHeaders: ['Content-Encoding'],
15
+ watcherEnabled: false, // But we enable it at run-time
16
+ watcherDebounceMs: 0
17
+ }
@@ -1,64 +1,57 @@
1
1
  import { join } from 'node:path'
2
+ import { spawn } from 'node:child_process'
2
3
  import { tmpdir } from 'node:os'
3
4
  import { promisify } from 'node:util'
4
5
  import { createServer } from 'node:http'
6
+ import { mkdtempSync } from 'node:fs'
5
7
  import { randomUUID } from 'node:crypto'
6
8
  import { equal, deepEqual, match } from 'node:assert/strict'
7
9
  import { describe, test, before, beforeEach, after } from 'node:test'
8
-
9
- import { mkdtempSync } from 'node:fs'
10
10
  import { writeFile, unlink, mkdir, readFile, rename } from 'node:fs/promises'
11
11
 
12
- import { API } from '../client/ApiConstants.js'
13
-
14
- import { logger } from './utils/logger.js'
15
12
  import { mimeFor } from './utils/mime.js'
16
- import { readBody } from './utils/HttpIncomingMessage.js'
17
- import { CorsHeader } from './utils/http-cors.js'
18
-
19
- import { Mockaton } from './Mockaton.js'
20
- import { watchMocksDir, watchStaticDir } from './Watcher.js'
21
-
22
- import { Commander } from '../client/ApiCommander.js'
23
13
  import { parseFilename } from '../client/Filename.js'
14
+ import { API, Commander } from '../../index.js'
15
+
16
+ import CONFIG from './Mockaton.test.config.js'
24
17
 
25
18
 
26
19
  const mocksDir = mkdtempSync(join(tmpdir(), 'mocks'))
27
20
  const staticDir = mkdtempSync(join(tmpdir(), 'static'))
28
21
 
22
+ const stdout = []
23
+ const stderr = []
24
+ const proc = spawn(join(import.meta.dirname, 'cli.js'), [
25
+ '--config', join(import.meta.dirname, 'Mockaton.test.config.js'),
26
+ '--mocks-dir', mocksDir,
27
+ '--static-dir', staticDir,
28
+ '--no-open'
29
+ ])
30
+
31
+ proc.stdout.on('data', data => { stdout.push(data.toString()) })
32
+ proc.stderr.on('data', data => { stderr.push(data.toString()) })
33
+
34
+ const serverAddr = await new Promise((resolve, reject) => {
35
+ proc.stdout.once('data', () => {
36
+ const addr = stdout[0].match(/Listening::(http:[^\n]+)/)[1]
37
+ resolve(addr)
38
+ })
39
+ proc.on('error', reject)
40
+ })
41
+
42
+ after(() => proc.kill('SIGUSR2'))
43
+
44
+
45
+ const readFromMocksDir = f => readFile(join(mocksDir, f), 'utf8')
46
+
29
47
  const makeDirInMocks = dir => mkdir(join(mocksDir, dir), { recursive: true })
30
48
  const makeDirInStaticMocks = dir => mkdir(join(staticDir, dir), { recursive: true })
31
49
 
32
50
  const renameInMocksDir = (src, target) => rename(join(mocksDir, src), join(mocksDir, target))
33
51
  const renameInStaticMocksDir = (src, target) => rename(join(staticDir, src), join(staticDir, target))
34
52
 
35
- const readFromMocksDir = f => readFile(join(mocksDir, f), 'utf8')
36
-
37
53
 
38
- const COOKIES = { userA: 'CookieA', userB: 'CookieB' }
39
- const CUSTOM_EXT = 'custom_extension'
40
- const CUSTOM_MIME = 'custom_mime'
41
- const CUSTOM_HEADER_NAME = 'custom_header_name'
42
- const CUSTOM_HEADER_VAL = 'custom_header_val'
43
- const ALLOWED_ORIGIN = 'https://example.test'
44
-
45
- const server = await Mockaton({
46
- mocksDir,
47
- staticDir,
48
- onReady() {},
49
- cookies: COOKIES,
50
- extraHeaders: [CUSTOM_HEADER_NAME, CUSTOM_HEADER_VAL],
51
- extraMimes: { [CUSTOM_EXT]: CUSTOM_MIME },
52
- logLevel: 'verbose',
53
- corsOrigins: [ALLOWED_ORIGIN],
54
- corsExposedHeaders: ['Content-Encoding'],
55
- watcherEnabled: false, // But we enable it at run-time
56
- watcherDebounceMs: 0
57
- })
58
- after(() => server?.close())
59
-
60
- const api = new Commander(
61
- `http://${server.address().address}:${server.address().port}`)
54
+ const api = new Commander(serverAddr)
62
55
 
63
56
  /** @returns {Promise<State>} */
64
57
  async function fetchState() {
@@ -172,14 +165,7 @@ describe('Rejects malicious URLs', () => {
172
165
 
173
166
 
174
167
  describe('Warnings', () => {
175
- function spyLogger(t, method) {
176
- const spy = t.mock.method(logger, method)
177
- spy.mock.mockImplementation(() => null)
178
- return spy.mock
179
- }
180
-
181
- test('rejects invalid filenames', async t => {
182
- const spy = spyLogger(t, 'warn')
168
+ test('rejects invalid filenames', async () => {
183
169
  const fx0 = new Fixture('bar.GET._INVALID_STATUS_.json')
184
170
  const fx1 = new Fixture('foo._INVALID_METHOD_.202.json')
185
171
  const fx2 = new Fixture('missing-method-and-status.json')
@@ -188,30 +174,29 @@ describe('Warnings', () => {
188
174
  await fx2.write()
189
175
  await api.reset()
190
176
 
191
- equal(spy.calls[0].arguments[0], 'Invalid HTTP Response Status: "NaN"')
192
- equal(spy.calls[1].arguments[0], 'Unrecognized HTTP Method: "_INVALID_METHOD_"')
193
- equal(spy.calls[2].arguments[0], 'Invalid Filename Convention')
177
+ const log = stderr.join('')
178
+ match(log, /Invalid HTTP Response Status: "NaN"/)
179
+ match(log, /Unrecognized HTTP Method: "_INVALID_METHOD_"/)
180
+ match(log, /Invalid Filename Convention/)
194
181
 
195
182
  await fx0.unlink()
196
183
  await fx1.unlink()
197
184
  await fx2.unlink()
198
185
  })
199
186
 
200
- test('body parser rejects invalid JSON in API requests', async t => {
201
- const spy = spyLogger(t, 'access')
187
+ test('body parser rejects invalid JSON in API requests', async () => {
202
188
  const r = await request(API.cookies, {
203
189
  method: 'PATCH',
204
190
  body: '[invalid_json]'
205
191
  })
206
192
  equal(r.status, 422)
207
- equal(spy.calls[0].arguments[1], 'BodyReaderError: Could not parse')
193
+ match(stdout.at(-1), /BodyReaderError: Could not parse/)
208
194
  })
209
195
 
210
- test('returns 500 when a handler throws', async t => {
211
- const spy = spyLogger(t, 'error')
196
+ test('returns 500 when a handler throws', async () => {
212
197
  const r = await request(API.throws)
213
198
  equal(r.status, 500)
214
- equal(spy.calls[0].arguments[2], 'Test500')
199
+ match(stderr.at(-1), /Test500/)
215
200
  })
216
201
  })
217
202
 
@@ -239,13 +224,13 @@ describe('CORS', () => {
239
224
  const r = await request('/does-not-matter', {
240
225
  method: 'OPTIONS',
241
226
  headers: {
242
- [CorsHeader.Origin]: ALLOWED_ORIGIN,
243
- [CorsHeader.AcRequestMethod]: 'GET'
227
+ 'origin': CONFIG.corsOrigins[0],
228
+ 'access-control-request-method': 'GET'
244
229
  }
245
230
  })
246
231
  equal(r.status, 204)
247
- equal(r.headers.get(CorsHeader.AcAllowOrigin), ALLOWED_ORIGIN)
248
- equal(r.headers.get(CorsHeader.AcAllowMethods), 'GET')
232
+ equal(r.headers.get('access-control-allow-origin'), CONFIG.corsOrigins[0])
233
+ equal(r.headers.get('access-control-allow-methods'), 'GET')
249
234
  })
250
235
 
251
236
  test('responds', async () => {
@@ -253,12 +238,12 @@ describe('CORS', () => {
253
238
  await fx.sync()
254
239
  const r = await fx.request({
255
240
  headers: {
256
- [CorsHeader.Origin]: ALLOWED_ORIGIN
241
+ 'origin': CONFIG.corsOrigins[0]
257
242
  }
258
243
  })
259
244
  equal(r.status, 200)
260
- equal(r.headers.get(CorsHeader.AcAllowOrigin), ALLOWED_ORIGIN)
261
- equal(r.headers.get(CorsHeader.AcExposeHeaders), 'Content-Encoding')
245
+ equal(r.headers.get('access-control-allow-origin'), CONFIG.corsOrigins[0])
246
+ equal(r.headers.get('access-control-expose-headers'), 'Content-Encoding')
262
247
  await fx.unlink()
263
248
  })
264
249
  })
@@ -274,6 +259,11 @@ describe('Dashboard', () => {
274
259
  const r = await request(API.dashboard + '?foo=bar')
275
260
  match(await r.text(), new RegExp('<!DOCTYPE html>'))
276
261
  })
262
+
263
+ test('serves assets', async () => {
264
+ const r = await request(API.dashboard + '/app.css')
265
+ match(await r.text(), new RegExp(':root {'))
266
+ })
277
267
  })
278
268
 
279
269
 
@@ -293,7 +283,7 @@ describe('Cookie', () => {
293
283
  const fx = new Fixture('update-cookie.GET.200.json')
294
284
  await fx.sync()
295
285
  const resA = await fx.request()
296
- equal(resA.headers.get('set-cookie'), COOKIES.userA)
286
+ equal(resA.headers.get('set-cookie'), CONFIG.cookies.userA)
297
287
 
298
288
  const response = await api.selectCookie('userB')
299
289
  deepEqual(await response.json(), [
@@ -302,7 +292,7 @@ describe('Cookie', () => {
302
292
  ])
303
293
 
304
294
  const resB = await fx.request()
305
- equal(resB.headers.get('set-cookie'), COOKIES.userB)
295
+ equal(resB.headers.get('set-cookie'), CONFIG.cookies.userB)
306
296
  await fx.unlink()
307
297
  })
308
298
  })
@@ -378,6 +368,7 @@ describe('Proxy Fallback', () => {
378
368
  describe('Fallback', () => {
379
369
  let fallbackServer
380
370
  const CUSTOM_COOKIES = ['cookieX=x', 'cookieY=y']
371
+ const BODY_PAYLOAD = 'text_req_body'
381
372
  before(async () => {
382
373
  fallbackServer = createServer(async (req, response) => {
383
374
  response.writeHead(423, {
@@ -385,7 +376,7 @@ describe('Proxy Fallback', () => {
385
376
  'content-type': mimeFor('.txt'),
386
377
  'set-cookie': CUSTOM_COOKIES
387
378
  })
388
- response.end(await readBody(req)) // echoes the req body payload
379
+ response.end(BODY_PAYLOAD)
389
380
  })
390
381
  await promisify(fallbackServer.listen).bind(fallbackServer, 0, '127.0.0.1')()
391
382
  await api.setProxyFallback(`http://localhost:${fallbackServer.address().port}`)
@@ -395,19 +386,14 @@ describe('Proxy Fallback', () => {
395
386
  after(() => fallbackServer.close())
396
387
 
397
388
  test('Relays to fallback server and saves the mock', async () => {
398
- const reqBodyPayload = 'text_req_body'
399
-
400
- const r = await request(`/non-existing-mock/${randomUUID()}`, {
401
- method: 'POST',
402
- body: reqBodyPayload
403
- })
389
+ const r = await request(`/non-existing-mock/${randomUUID()}`, { method: 'POST' })
404
390
  equal(r.status, 423)
405
391
  equal(r.headers.get('custom_header'), 'my_custom_header')
406
392
  equal(r.headers.get('set-cookie'), CUSTOM_COOKIES.join(', '))
407
- equal(await r.text(), reqBodyPayload)
393
+ equal(await r.text(), BODY_PAYLOAD)
408
394
 
409
395
  const savedBody = await readFromMocksDir('non-existing-mock/[id].POST.423.txt')
410
- equal(savedBody, reqBodyPayload)
396
+ equal(savedBody, BODY_PAYLOAD)
411
397
  })
412
398
  })
413
399
 
@@ -623,7 +609,7 @@ describe('Dynamic Function Mocks', () => {
623
609
  equal(r.status, 200)
624
610
  equal(r.headers.get('content-length'), '1')
625
611
  equal(r.headers.get('content-type'), mimeFor('.json'))
626
- equal(r.headers.get('set-cookie'), COOKIES.userA)
612
+ equal(r.headers.get('set-cookie'), CONFIG.cookies.userA)
627
613
  equal(await r.text(), 'A')
628
614
  await fx.unlink()
629
615
  })
@@ -835,10 +821,12 @@ describe('MIME', () => {
835
821
  })
836
822
 
837
823
  test('derives content-type from custom mime', async () => {
838
- const fx = new Fixture(`tmp.GET.200.${CUSTOM_EXT}`)
824
+ const ext = Object.keys(CONFIG.extraMimes)[0]
825
+ const mime = Object.values(CONFIG.extraMimes)[0]
826
+ const fx = new Fixture(`tmp.GET.200.${ext}`)
839
827
  await fx.sync()
840
828
  const r = await fx.request()
841
- equal(r.headers.get('content-type'), CUSTOM_MIME)
829
+ equal(r.headers.get('content-type'), mime)
842
830
  await fx.unlink()
843
831
  })
844
832
  })
@@ -852,9 +840,8 @@ describe('Headers', () => {
852
840
  })
853
841
 
854
842
  test('custom headers are included', async () => {
855
- const r = await api.getState()
856
- const val = r.headers.get(CUSTOM_HEADER_NAME)
857
- equal(val, CUSTOM_HEADER_VAL)
843
+ const { headers } = await api.getState()
844
+ equal(headers.get(CONFIG.extraHeaders[0]), CONFIG.extraHeaders[1])
858
845
  })
859
846
  })
860
847
 
@@ -1054,13 +1041,35 @@ test('head for get. returns the headers without body only for GETs requested as
1054
1041
  })
1055
1042
 
1056
1043
 
1057
- describe('Registering Mocks', () => {
1058
- before(watchMocksDir)
1044
+ describe('Watch mocks API toggler', () => {
1045
+ test('422 for non boolean', async () => {
1046
+ const r = await api.setWatchMocks('not-a-boolean')
1047
+ equal(r.status, 422)
1048
+ equal(await r.text(), 'Expected boolean for "watchMocks"')
1049
+ })
1059
1050
 
1051
+ test('200', async () => {
1052
+ equal((await api.setWatchMocks(true)).status, 200)
1053
+ equal((await api.setWatchMocks(false)).status, 200)
1054
+ })
1055
+ })
1056
+
1057
+
1058
+ describe('Registering Mocks', () => {
1060
1059
  const fxA = new Fixture('register(default).GET.200.json')
1061
1060
  const fxB = new Fixture('register(alt).GET.200.json')
1062
1061
 
1062
+ test('when watcher is off, newly added mocks do not get registered', async () => {
1063
+ await api.setWatchMocks(false)
1064
+ const fx = new Fixture('non-auto-registered-file.GET.200.json')
1065
+ await fx.write()
1066
+ await sleep()
1067
+ equal(await fx.fetchBroker(), undefined)
1068
+ await fx.unlink()
1069
+ })
1070
+
1063
1071
  test('register', async () => {
1072
+ await api.setWatchMocks(true)
1064
1073
  await fxA.register()
1065
1074
  await fxB.register()
1066
1075
  const b = await fxA.fetchBroker()
@@ -1141,11 +1150,19 @@ describe('Registering Mocks', () => {
1141
1150
 
1142
1151
 
1143
1152
  describe('Registering Static Mocks', () => {
1144
- before(watchStaticDir)
1145
-
1146
- const fx = new FixtureStatic('static-register.txt')
1153
+ test('when watcher is off, newly added mocks do not get registered', async () => {
1154
+ await api.setWatchMocks(false)
1155
+ const fx = new FixtureStatic('non-auto-registered-file.txt')
1156
+ await fx.write()
1157
+ await sleep()
1158
+ const { staticBrokers } = await fetchState()
1159
+ equal(staticBrokers['/' + fx.file], undefined)
1160
+ await fx.unlink()
1161
+ })
1147
1162
 
1163
+ const fx = new FixtureStatic('static-register.txt', 'static-body')
1148
1164
  test('registers static', async () => {
1165
+ await api.setWatchMocks(true)
1149
1166
  await fx.register()
1150
1167
  const { staticBrokers } = await fetchState()
1151
1168
  deepEqual(staticBrokers, {
@@ -1155,6 +1172,9 @@ describe('Registering Static Mocks', () => {
1155
1172
  delayed: false
1156
1173
  }
1157
1174
  })
1175
+ const response = await fx.request()
1176
+ equal(response.status, 200)
1177
+ equal(await response.text(), fx.body)
1158
1178
  })
1159
1179
 
1160
1180
  test('unregisters static', async () => {
@@ -1203,3 +1223,8 @@ describe('Registering Static Mocks', () => {
1203
1223
  })
1204
1224
  })
1205
1225
  })
1226
+
1227
+
1228
+ async function sleep(ms = 100) {
1229
+ await new Promise(resolve => setTimeout(resolve, ms))
1230
+ }
@@ -14,6 +14,10 @@ import * as staticCollection from './staticCollection.js'
14
14
  import * as mockBrokerCollection from './mockBrokersCollection.js'
15
15
 
16
16
 
17
+ let mocksWatcher = null
18
+ let staticWatcher = null
19
+
20
+
17
21
  /**
18
22
  * ARR Event = Add, Remove, or Rename Mock
19
23
  *
@@ -47,7 +51,7 @@ const uiSyncVersion = new class extends EventEmitter {
47
51
 
48
52
  export function watchMocksDir() {
49
53
  const dir = config.mocksDir
50
- watch(dir, { recursive: true, persistent: false }, (_, file) => {
54
+ mocksWatcher = mocksWatcher || watch(dir, { recursive: true, persistent: false }, (_, file) => {
51
55
  if (!file)
52
56
  return
53
57
 
@@ -73,7 +77,7 @@ export function watchStaticDir() {
73
77
  if (!dir)
74
78
  return
75
79
 
76
- watch(dir, { recursive: true, persistent: false }, (_, file) => {
80
+ staticWatcher = staticWatcher || watch(dir, { recursive: true, persistent: false }, (_, file) => {
77
81
  if (!file)
78
82
  return
79
83
 
@@ -113,3 +117,16 @@ export function longPollClientSyncVersion(req, response) {
113
117
  })
114
118
  uiSyncVersion.subscribe(onARR)
115
119
  }
120
+
121
+
122
+ export function startWatchers() {
123
+ watchMocksDir()
124
+ watchStaticDir()
125
+ }
126
+
127
+ export function stopWatchers() {
128
+ mocksWatcher?.close()
129
+ staticWatcher?.close()
130
+ mocksWatcher = null
131
+ staticWatcher = null
132
+ }
@@ -17,7 +17,7 @@ const devClientWatcher = new class extends EventEmitter {
17
17
  }
18
18
 
19
19
 
20
- // Although `client/indexHtml.js` is watched, it returns a stale version.
20
+ // Although `client/IndexHtml.js` is watched, it returns a stale version.
21
21
  // i.e., it would need to be a dynamic import + cache busting.
22
22
  export function watchDevSPA() {
23
23
  watch(CLIENT_DIR, (_, file) => {
package/src/server/cli.js CHANGED
@@ -36,6 +36,9 @@ catch (error) {
36
36
  process.exit(1)
37
37
  }
38
38
 
39
+ // For clean exit on tests, so we can collect code-coverage
40
+ process.on('SIGUSR2', () => process.exit(0))
41
+
39
42
 
40
43
  if (args.version)
41
44
  console.log(pkgJSON.version)
@@ -0,0 +1,39 @@
1
+ import { join } from 'node:path'
2
+ import { equal } from 'node:assert/strict'
3
+ import { spawnSync } from 'node:child_process'
4
+ import { describe, test } from 'node:test'
5
+
6
+ import pkgJSON from '../../package.json' with { type: 'json' }
7
+
8
+ const cli = (...args) => spawnSync(join(import.meta.dirname, 'cli.js'), args, {
9
+ encoding: 'utf8'
10
+ })
11
+
12
+ describe('CLI', () => {
13
+ test('--invalid-flag', () => {
14
+ const { stderr, status } = cli('--invalid-flag')
15
+ equal(stderr.trim(), `Unknown option '--invalid-flag'`)
16
+ equal(status, 1)
17
+ })
18
+
19
+ test('invalid config file', () => {
20
+ const { stderr, status } = cli('--config', 'non-existing-file.js')
21
+ equal(stderr.trim(), `Invalid config file: non-existing-file.js`)
22
+ equal(status, 1)
23
+ })
24
+
25
+ test('-v outputs version from package.json', () => {
26
+ const { stdout, status } = cli('-v')
27
+ equal(stdout.trim(), pkgJSON.version)
28
+ equal(status, 0)
29
+ })
30
+
31
+ test('-h outputs usage message', () => {
32
+ const { stdout, status } = cli('-h')
33
+ equal(stdout.split('\n')[0], 'Usage: mockaton [options]')
34
+ equal(status, 0)
35
+ })
36
+
37
+ // Mockaton.test.js tests the remaining cli branch
38
+ })
39
+
@@ -7,7 +7,8 @@ import { openInBrowser } from './utils/openInBrowser.js'
7
7
  import { optional, is, validate } from './utils/validate.js'
8
8
  import { validateCorsAllowedMethods, validateCorsAllowedOrigins } from './utils/http-cors.js'
9
9
 
10
- import { jsToJsonPlugin } from './MockDispatcher.js'
10
+
11
+ import { jsToJsonPlugin } from './MockDispatcherPlugins.js'
11
12
 
12
13
 
13
14
  /** @type {{
@@ -1,5 +1,6 @@
1
+ import { MIMEType } from 'node:util'
1
2
  import { config } from '../config.js'
2
- import { UNKNOWN_MIME_EXT } from '../../client/ApiConstants.js'
3
+ import { EXT_UNKNOWN_MIME, EXT_EMPTY } from '../../client/ApiConstants.js'
3
4
 
4
5
 
5
6
  // Generated with:
@@ -143,15 +144,11 @@ function extname(filename) {
143
144
  export function extFor(mime) {
144
145
  return mime
145
146
  ? findExt(mime)
146
- : 'empty'
147
+ : EXT_EMPTY
147
148
  }
148
149
  function findExt(rawMime) {
149
- const m = parseMime(rawMime)
150
+ const m = new MIMEType(rawMime).essence
150
151
  const extraMimeToExt = mapMimeToExt(config.extraMimes)
151
- return extraMimeToExt[m] || mimeToExt[m] || UNKNOWN_MIME_EXT
152
+ return extraMimeToExt[m] || mimeToExt[m] || EXT_UNKNOWN_MIME
152
153
  }
153
154
 
154
- export function parseMime(mime) {
155
- return mime.split(';')[0].toLowerCase()
156
- // RFC 9110 §8.3.1
157
- }
@@ -1,15 +1,8 @@
1
1
  import { test } from 'node:test'
2
2
  import { equal } from 'node:assert/strict'
3
- import { parseMime, extFor, mimeFor } from './mime.js'
3
+ import { extFor, mimeFor } from './mime.js'
4
4
 
5
5
 
6
- test('parseMime', () => [
7
- 'text/html',
8
- 'TEXT/html',
9
- 'text/html; charset=utf-8'
10
- ].map(input =>
11
- equal(parseMime(input), 'text/html')))
12
-
13
6
  test('extFor', () => [
14
7
  'text/html',
15
8
  'Text/html',
@@ -1,31 +0,0 @@
1
- import { API } from './ApiConstants.js'
2
-
3
- export const CSP = [
4
- `default-src 'self'`,
5
- `img-src data: blob: 'self'`
6
- ].join(';')
7
-
8
-
9
- // language=html
10
- export const IndexHtml = (hotReloadEnabled, version) => `
11
- <!DOCTYPE html>
12
- <html lang="en-US">
13
- <head>
14
- <meta charset="UTF-8">
15
- <base href="${API.dashboard}/">
16
-
17
- <script type="module" src="app.js"></script>
18
- <link rel="preload" href="${API.state}" as="fetch" crossorigin>
19
-
20
- <link rel="icon" href="data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m235 33.7v202c0 9.19-5.81 14-17.4 14-11.6 0-17.4-4.83-17.4-14v-151c-0.115-4.49-6.72-5.88-8.46-0.87l-48.3 155c-2.22 7.01-7.72 10.1-16 9.9-3.63-0.191-7.01-1.14-9.66-2.89-2.89-1.72-4.83-4.34-5.57-7.72-11.1-37-22.6-74.3-34.1-111-4.34-14-8.95-31.4-14-48.3-1.82-4.83-8.16-5.32-8.46 1.16v156c0 9.19-5.81 14-17.4 14-11.6 0-17.4-4.83-17.4-14v-207c0-5.74 2.62-13.2 9.39-16.3 7.5-3.14 15-4.05 21.8-3.8 3.14 0 6.03 0.686 8.95 1.46 3.14 0.797 6.03 1.98 8.7 3.63 2.65 1.38 5.32 3.14 7.5 5.57 2.22 2.22 3.87 4.83 5.07 7.72l45.8 157c4.63-15.9 32.4-117 33.3-121 4.12-13.8 7.72-26.5 10.9-38.7 1.16-2.65 2.89-5.32 5.07-7.5 2.15-2.15 4.58-4.12 7.5-5.32 2.65-1.57 5.57-2.89 8.46-3.63 3.14-0.797 9.44-0.988 12.1-0.988 11.6 1.07 29.4 9.14 29.4 27z' fill='%23808080'/%3E%3C/svg%3E">
21
- <meta name="viewport" content="width=device-width, initial-scale=1">
22
- <meta name="description" content="HTTP Mock Server">
23
- <title>Mockaton v${version}</title>
24
- </head>
25
- <body>
26
- ${hotReloadEnabled
27
- ? '<script type="module" src="watcherDev.js"></script>'
28
- : ''}
29
- </body>
30
- </html>
31
- `