mockaton 12.2.2 → 12.2.3

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
@@ -8,8 +8,7 @@ for testing difficult to reproduce backend states.
8
8
  [![codecov](https://codecov.io/github/ericfortis/mockaton/graph/badge.svg?token=90NYLMMG1J)](https://codecov.io/github/ericfortis/mockaton)
9
9
 
10
10
 
11
- ## [Documentation ↗](https://mockaton.com)
12
- ## [Changelog ↗](https://mockaton.com/changelog)
11
+ ## [Documentation ↗](https://mockaton.com) | [Changelog ↗](https://mockaton.com/changelog)
13
12
 
14
13
  ## Overview
15
14
  With Mockaton, you don’t need to write code for wiring up your
@@ -22,7 +21,6 @@ For example, for [/api/company/123](#), the filename could be:
22
21
  <code>my-mocks-dir/<b>api/company</b>/[id].GET.200.json</code>
23
22
  </pre>
24
23
 
25
- <br/>
26
24
 
27
25
  ## Dashboard
28
26
 
@@ -32,16 +30,13 @@ For example, for [/api/company/123](#), the filename could be:
32
30
  <img alt="Mockaton Dashboard" src="https://raw.githubusercontent.com/ericfortis/mockaton/refs/heads/main/pixaton-tests/tests/macos/pic-for-readme.vp762x762.dark.gold.png">
33
31
  </picture>
34
32
 
35
- On the dashboard you can: select a mock variant for a particular route; 🕓 delay responses;
36
- and cycle an autogenerated `500` error (e.g., for testing retries). Nonetheless,
37
- there’s a [Control API ↗](https://mockaton.com/api), which is handy for setting up tests.
38
-
39
33
  <br/>
40
34
 
41
35
 
42
36
  ## Quick Start (Docker)
43
37
  This will spin up Mockaton with the sample directories
44
- included in this repo mounted on the container ([mockaton-mocks/](./mockaton-mocks) and [mockaton-static-mocks/](./mockaton-static-mocks))
38
+ included in this repo mounted on the container ([mockaton-mocks/](./mockaton-mocks)
39
+ and [mockaton-static-mocks/](./mockaton-static-mocks))
45
40
 
46
41
  ```sh
47
42
  git clone https://github.com/ericfortis/mockaton.git --depth 1
@@ -50,13 +45,7 @@ make docker
50
45
  ```
51
46
  Dashboard: http://localhost:2020/mockaton
52
47
 
53
-
54
48
  Test it:
55
49
  ```shell
56
50
  curl localhost:2020/api/user
57
51
  ```
58
-
59
-
60
-
61
- ## License
62
- MIT
package/index.d.ts CHANGED
@@ -44,7 +44,7 @@ export interface Config {
44
44
  plugins?: [filenameTester: RegExp, plugin: Plugin][]
45
45
 
46
46
  onReady?: (address: string) => void
47
-
47
+
48
48
  hotReload?: boolean // For UI dev purposes only
49
49
  }
50
50
 
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.2.2",
5
+ "version": "12.2.3",
6
6
  "exports": {
7
7
  ".": {
8
8
  "import": "./index.js",
@@ -23,7 +23,7 @@ export class Commander {
23
23
 
24
24
  /** @returns {Promise<Response>} */
25
25
  setGlobalDelay = delay => this.#patch(API.globalDelay, delay)
26
-
26
+
27
27
  /** @returns {Promise<Response>} */
28
28
  setGlobalDelayJitter = jitterPct => this.#patch(API.globalDelayJitter, jitterPct)
29
29
 
@@ -1,9 +1,10 @@
1
- /** @KeepSync src/server/ApiConstants.js */
1
+ /** # @SharedWithServer */
2
2
 
3
3
  const MOUNT = '/mockaton'
4
+
4
5
  export const API = {
5
6
  dashboard: MOUNT,
6
-
7
+
7
8
  bulkSelect: MOUNT + '/bulk-select-by-comment',
8
9
  collectProxied: MOUNT + '/collect-proxied',
9
10
  cookies: MOUNT + '/cookies',
@@ -1,4 +1,13 @@
1
- // @KeepSync src/server/Filename.js
1
+ /** # @SharedWithServer */
2
+
3
+ const METHODS = [ // @KeepSync node:http.METHODS
4
+ 'ACL', 'BIND', 'CHECKOUT', 'CONNECT', 'COPY', 'DELETE',
5
+ 'GET', 'HEAD', 'LINK', 'LOCK', 'M-SEARCH', 'MERGE',
6
+ 'MKACTIVITY', 'MKCALENDAR', 'MKCOL', 'MOVE', 'NOTIFY', 'OPTIONS',
7
+ 'PATCH', 'POST', 'PROPFIND', 'PROPPATCH', 'PURGE', 'PUT',
8
+ 'QUERY', 'REBIND', 'REPORT', 'SEARCH', 'SOURCE', 'SUBSCRIBE',
9
+ 'TRACE', 'UNBIND', 'UNLINK', 'UNLOCK', 'UNSUBSCRIBE'
10
+ ]
2
11
 
3
12
  const reComments = /\(.*?\)/g // Anything within parentheses
4
13
 
@@ -6,6 +15,25 @@ export function extractComments(file) {
6
15
  return Array.from(file.matchAll(reComments), ([c]) => c)
7
16
  }
8
17
 
18
+ export function includesComment(file, search) {
19
+ return extractComments(file).some(c => c.includes(search))
20
+ }
21
+
22
+
23
+ export function validateFilename(file) {
24
+ const tokens = file.replace(reComments, '').split('.')
25
+ if (tokens.length < 4)
26
+ return 'Invalid Filename Convention'
27
+
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
+ }
35
+
36
+
9
37
  export function parseFilename(file) {
10
38
  const tokens = file.replace(reComments, '').split('.')
11
39
  return {
@@ -22,3 +50,19 @@ function removeTrailingSlash(url = '') {
22
50
  .replace('/?', '?')
23
51
  .replace('/#', '#')
24
52
  }
53
+
54
+ function responseStatusIsValid(status) {
55
+ return Number.isInteger(status)
56
+ && status >= 100
57
+ && status <= 599
58
+ }
59
+
60
+ export function makeMockFilename(url, method, status, ext) {
61
+ const urlMask = replaceIds(removeTrailingSlash(url))
62
+ return [urlMask, method, status, ext].join('.')
63
+ }
64
+
65
+ 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
66
+ function replaceIds(filename) {
67
+ return filename.replaceAll(reUuidV4, '[id]')
68
+ }
@@ -82,7 +82,7 @@ export const store = {
82
82
  }
83
83
  catch (error) { store.onError(error) }
84
84
  },
85
-
85
+
86
86
  async setGlobalDelayJitter(value) {
87
87
  try {
88
88
  const response = await api.setGlobalDelayJitter(value)
@@ -160,7 +160,7 @@ export const store = {
160
160
  r.setUrlMaskDittoed(store._dittoCache.get(r.key))
161
161
  return r
162
162
  },
163
-
163
+
164
164
  staticBrokersAsRows() {
165
165
  const rows = Object.values(store.staticBrokers)
166
166
  .map(b => new StaticBrokerRowModel(b))
package/src/client/app.js CHANGED
@@ -11,18 +11,13 @@ import { HEADER_502 } from './ApiConstants.js'
11
11
  import CSS from './styles.css' with { type: 'css' }
12
12
  adoptCSS(CSS)
13
13
 
14
- const FocusGroup = {
15
- ProxyToggler: 0,
16
- DelayToggler: 1,
17
- StatusToggler: 2,
18
- PreviewLink: 3
19
- }
20
14
 
21
15
  const t = translation => translation[0]
22
16
 
23
17
  store.onError = onError
24
18
  store.render = render
25
19
  store.renderRow = renderRow
20
+
26
21
  store.fetchState()
27
22
  initRealTimeUpdates()
28
23
  initKeyboardNavigation()
@@ -64,7 +59,8 @@ function Header() {
64
59
  r('header', null,
65
60
  r('a', {
66
61
  className: CSS.Logo,
67
- href: 'https://mockaton.com'
62
+ href: 'https://mockaton.com',
63
+ alt: t`Documentation`
68
64
  },
69
65
  Logo()),
70
66
  r('div', null,
@@ -95,7 +91,6 @@ function GlobalDelayField() {
95
91
  r('label', className(CSS.Field, CSS.GlobalDelayField),
96
92
  r('span', null, t`Delay (ms)`),
97
93
  r('input', {
98
- name: 'delay',
99
94
  type: 'number',
100
95
  min: 0,
101
96
  step: 100,
@@ -125,7 +120,6 @@ function GlobalDelayJitterField() {
125
120
  r('label', className(CSS.Field, CSS.GlobalDelayJitterField),
126
121
  r('span', null, t`Max Jitter %`),
127
122
  r('input', {
128
- name: 'delay-jitter',
129
123
  type: 'number',
130
124
  min: 0,
131
125
  max: 300,
@@ -148,7 +142,9 @@ function CookieSelector() {
148
142
  r('select', {
149
143
  autocomplete: 'off',
150
144
  disabled,
151
- title: disabled ? t`No cookies specified in config.cookies` : '',
145
+ title: disabled
146
+ ? t`No cookies specified in config.cookies`
147
+ : undefined,
152
148
  onChange() { store.selectCookie(this.value) }
153
149
  }, list.map(([value, selected]) =>
154
150
  r('option', { value, selected }, value)))))
@@ -169,9 +165,8 @@ function ProxyFallbackField() {
169
165
  r('label', null,
170
166
  r('span', null, t`Fallback`),
171
167
  r('input', {
172
- name: 'fallback',
173
168
  type: 'url',
174
- autocomplete: 'none',
169
+ name: 'fallback',
175
170
  placeholder: t`Type backend address`,
176
171
  value: store.proxyFallback,
177
172
  onChange
@@ -183,7 +178,6 @@ function SaveProxiedCheckbox(ref) {
183
178
  return (
184
179
  r('label', className(CSS.SaveProxiedCheckbox),
185
180
  r('input', {
186
- name: 'save-proxied',
187
181
  ref,
188
182
  type: 'checkbox',
189
183
  disabled: !store.canProxy,
@@ -204,13 +198,13 @@ function ResetButton() {
204
198
 
205
199
 
206
200
  function HelpLink() {
207
- return r('a', {
201
+ return (
202
+ r('a', {
208
203
  target: '_blank',
209
204
  href: 'https://mockaton.com',
210
205
  title: t`Documentation`,
211
206
  className: CSS.HelpLink
212
- },
213
- HelpIcon())
207
+ }, HelpIcon()))
214
208
  }
215
209
 
216
210
 
@@ -232,7 +226,9 @@ function BulkSelector() {
232
226
  r('select', {
233
227
  autocomplete: 'off',
234
228
  disabled,
235
- title: disabled ? t`No mock files have comments which are anything within parentheses on the filename.` : '',
229
+ title: disabled
230
+ ? t`No mock files have comments which are anything within parentheses on the filename.`
231
+ : undefined,
236
232
  onChange
237
233
  },
238
234
  r('option', { value: firstOption }, firstOption),
@@ -246,7 +242,6 @@ function GroupByMethod() {
246
242
  return (
247
243
  r('label', className(CSS.GroupByMethod),
248
244
  r('input', {
249
- name: 'group-by-method',
250
245
  type: 'checkbox',
251
246
  checked: store.groupByMethod,
252
247
  onChange: store.toggleGroupByMethod
@@ -284,11 +279,20 @@ function Row(row, i) {
284
279
  },
285
280
  store.canProxy && ProxyToggler(method, urlMask, row.proxied),
286
281
 
287
- DelayRouteToggler(method, urlMask, row.delayed),
282
+ DelayToggler({
283
+ checked: row.delayed,
284
+ commit(checked) { store.setDelayed(method, urlMask, checked) },
285
+ }),
288
286
 
289
- InternalServerErrorToggler(method, urlMask,
290
- !row.proxied && row.status === 500, // checked
291
- row.opts.length === 1 && row.status === 500), // disabled
287
+ StatusCodeToggler({
288
+ title: t`Internal Server Error`,
289
+ label: t`500`,
290
+ disabled: row.opts.length === 1 && row.status === 500,
291
+ checked: !row.proxied && row.status === 500,
292
+ onChange() {
293
+ store.toggle500(method, urlMask)
294
+ }
295
+ }),
292
296
 
293
297
  !store.groupByMethod && r('span', className(CSS.Method), method),
294
298
 
@@ -327,7 +331,6 @@ function PreviewLink(method, urlMask, urlMaskDittoed, autofocus) {
327
331
  ...className(CSS.PreviewLink, isChosen && CSS.chosen),
328
332
  href: urlMask,
329
333
  autofocus,
330
- 'data-focus-group': FocusGroup.PreviewLink,
331
334
  onClick
332
335
  }, ditto
333
336
  ? [r('span', className(CSS.dittoDir), ditto), tail]
@@ -339,8 +342,15 @@ function PreviewLink(method, urlMask, urlMaskDittoed, autofocus) {
339
342
  function MockSelector(row) {
340
343
  return (
341
344
  r('select', {
342
- onChange() { store.selectFile(this.value) },
343
- autocomplete: 'off',
345
+ onChange() {
346
+ store.selectFile(this.value)
347
+ },
348
+ onKeyDown(event) {
349
+ if (event.key === 'ArrowRight' || event.key === 'ArrowLeft')
350
+ event.preventDefault()
351
+ // Because in Firefox they change the select.option, and
352
+ // we use those keys for spreadsheet-like navigation.
353
+ },
344
354
  'aria-label': t`Mock Selector`,
345
355
  disabled: row.opts.length < 2,
346
356
  ...className(
@@ -352,30 +362,6 @@ function MockSelector(row) {
352
362
  }
353
363
 
354
364
 
355
- function DelayRouteToggler(method, urlMask, checked) {
356
- return ClickDragToggler({
357
- checked,
358
- commit(checked) { store.setDelayed(method, urlMask, checked) },
359
- focusGroup: FocusGroup.DelayToggler
360
- })
361
- }
362
-
363
- function InternalServerErrorToggler(method, urlMask, checked, disabled) {
364
- return (
365
- r('label', {
366
- className: CSS.InternalServerErrorToggler,
367
- title: t`Internal Server Error`
368
- },
369
- r('input', {
370
- type: 'checkbox',
371
- disabled,
372
- checked,
373
- onChange() { store.toggle500(method, urlMask) },
374
- 'data-focus-group': FocusGroup.StatusToggler
375
- }),
376
- r('span', className(CSS.checkboxBody), t`500`)))
377
- }
378
-
379
365
  function ProxyToggler(method, urlMask, checked) {
380
366
  return (
381
367
  r('label', {
@@ -386,7 +372,6 @@ function ProxyToggler(method, urlMask, checked) {
386
372
  type: 'checkbox',
387
373
  checked,
388
374
  onChange() { store.setProxied(method, urlMask, this.checked) },
389
- 'data-focus-group': FocusGroup.ProxyToggler
390
375
  }),
391
376
  CloudIcon()))
392
377
  }
@@ -404,7 +389,9 @@ function StaticFilesList() {
404
389
  className(CSS.TableHeading,
405
390
  store.canProxy && CSS.canProxy,
406
391
  !store.groupByMethod && CSS.nonGroupedByMethod),
407
- store.groupByMethod ? t`Static GET` : t`Static`),
392
+ store.groupByMethod
393
+ ? t`Static GET`
394
+ : t`Static`),
408
395
  rows.map(StaticRow))
409
396
  }
410
397
 
@@ -418,9 +405,25 @@ function StaticRow(row) {
418
405
  ...className(CSS.TableRow,
419
406
  render.count > 1 && row.isNew && CSS.animIn)
420
407
  },
421
- DelayStaticRouteToggler(row.urlMask, row.delayed),
422
408
 
423
- NotFoundToggler(row.urlMask, row.status === 404),
409
+ DelayToggler({
410
+ optClassName: store.canProxy && CSS.canProxy,
411
+ checked: row.delayed,
412
+ commit(checked) {
413
+ store.setDelayedStatic(row.urlMask, checked)
414
+ }
415
+ }),
416
+
417
+ StatusCodeToggler({
418
+ title: t`Not Found`,
419
+ label: t`404`,
420
+ checked: row.status === 404,
421
+ onChange() {
422
+ store.setStaticRouteStatus(row.urlMask, this.checked
423
+ ? 404
424
+ : 200)
425
+ }
426
+ }),
424
427
 
425
428
  !groupByMethod && r('span', className(CSS.Method), 'GET'),
426
429
 
@@ -428,42 +431,27 @@ function StaticRow(row) {
428
431
  href: row.urlMask,
429
432
  target: '_blank',
430
433
  className: CSS.PreviewLink,
431
- 'data-focus-group': FocusGroup.PreviewLink
432
434
  }, ditto
433
435
  ? [r('span', className(CSS.dittoDir), ditto), tail]
434
436
  : tail)))
435
437
  }
436
438
 
437
- function DelayStaticRouteToggler(route, checked) {
438
- return ClickDragToggler({
439
- optClassName: store.canProxy && CSS.canProxy,
440
- checked,
441
- focusGroup: FocusGroup.DelayToggler,
442
- commit(checked) {
443
- store.setDelayedStatic(route, checked)
444
- }
445
- })
446
- }
447
-
448
- function NotFoundToggler(route, checked) {
439
+ function StatusCodeToggler({ title, label, onChange, checked }) {
449
440
  return (
450
441
  r('label', {
451
- className: CSS.NotFoundToggler,
452
- title: t`Not Found`
442
+ title,
443
+ className: CSS.StatusCodeToggler
453
444
  },
454
445
  r('input', {
455
446
  type: 'checkbox',
456
447
  checked,
457
- 'data-focus-group': FocusGroup.StatusToggler,
458
- onChange() {
459
- store.setStaticRouteStatus(route, this.checked ? 404 : 200)
460
- }
448
+ onChange
461
449
  }),
462
- r('span', className(CSS.checkboxBody), t`404`)))
450
+ r('span', className(CSS.checkboxBody), label)))
463
451
  }
464
452
 
465
453
 
466
- function ClickDragToggler({ checked, commit, focusGroup, optClassName }) {
454
+ function DelayToggler({ checked, commit, optClassName }) {
467
455
  function onPointerEnter(event) {
468
456
  if (event.buttons === 1)
469
457
  onPointerDown.call(this)
@@ -487,7 +475,6 @@ function ClickDragToggler({ checked, commit, focusGroup, optClassName }) {
487
475
  },
488
476
  r('input', {
489
477
  type: 'checkbox',
490
- 'data-focus-group': focusGroup,
491
478
  checked,
492
479
  onPointerEnter,
493
480
  onPointerDown,
@@ -557,9 +544,10 @@ function PayloadViewer() {
557
544
  }
558
545
 
559
546
  function RightToolbar() {
560
- return r('div', className(CSS.SubToolbar),
561
- r('h2', { ref: payloadViewerTitleRef },
562
- !store.hasChosenLink && t`Preview`))
547
+ return (
548
+ r('div', className(CSS.SubToolbar),
549
+ r('h2', { ref: payloadViewerTitleRef },
550
+ !store.hasChosenLink && t`Preview`)))
563
551
  }
564
552
 
565
553
 
@@ -589,7 +577,11 @@ const SPINNER_DELAY = 80
589
577
  function PayloadViewerProgressBar() {
590
578
  return (
591
579
  r('div', className(CSS.ProgressBar),
592
- r('div', { style: { animationDuration: store.delay - SPINNER_DELAY + 'ms' } })))
580
+ r('div', {
581
+ style: {
582
+ animationDuration: store.delay - SPINNER_DELAY + 'ms'
583
+ }
584
+ })))
593
585
  }
594
586
 
595
587
  async function previewMock() {
@@ -765,45 +757,48 @@ function initRealTimeUpdates() {
765
757
 
766
758
 
767
759
  function initKeyboardNavigation() {
768
- addEventListener('keydown', onKeyDown)
760
+ const columnSelectors = [
761
+ `.${CSS.TableRow} .${CSS.ProxyToggler} input`,
762
+ `.${CSS.TableRow} .${CSS.DelayToggler} input`,
763
+ `.${CSS.TableRow} .${CSS.StatusCodeToggler} input`,
764
+ `.${CSS.TableRow} .${CSS.PreviewLink}`,
765
+ // No .MockSelector because down/up arrows have native behavior on them
766
+ ]
769
767
 
770
- function onKeyDown(event) {
771
- const pivot = document.activeElement
772
- switch (event.key) {
768
+ const rowSelectors = [
769
+ ...columnSelectors,
770
+ `.${CSS.TableRow} .${CSS.MockSelector}:enabled`,
771
+ ]
772
+
773
+ addEventListener('keydown', function ({ key }) {
774
+ switch (key) {
773
775
  case 'ArrowDown':
774
776
  case 'ArrowUp': {
775
- let fg = pivot.getAttribute('data-focus-group')
776
- if (fg !== null) {
777
- const offset = event.key === 'ArrowDown' ? +1 : -1
778
- circularAdjacent(offset, allInFocusGroup(+fg), pivot).focus()
777
+ const pivot = document.activeElement
778
+ const sel = columnSelectors.find(s => pivot?.matches(s))
779
+ if (sel) {
780
+ const offset = key === 'ArrowDown' ? +1 : -1
781
+ const siblings = leftSideRef.elem.querySelectorAll(sel)
782
+ circularAdjacent(offset, siblings, pivot).focus()
779
783
  }
780
784
  break
781
785
  }
782
786
  case 'ArrowRight':
783
787
  case 'ArrowLeft': {
784
- if (pivot.hasAttribute('data-focus-group') || pivot.classList.contains(CSS.MockSelector)) {
785
- const offset = event.key === 'ArrowRight' ? +1 : -1
786
- rowFocusable(pivot, offset).focus()
788
+ const pivot = document.activeElement
789
+ const sel = rowSelectors.find(s => pivot?.matches(s))
790
+ if (sel) {
791
+ const offset = key === 'ArrowRight' ? +1 : -1
792
+ const siblings = pivot.closest(`.${CSS.TableRow}`).querySelectorAll(rowSelectors.join(','))
793
+ circularAdjacent(offset, siblings, pivot).focus()
787
794
  }
788
795
  break
789
796
  }
790
797
  }
791
- }
792
-
793
- function rowFocusable(el, step) {
794
- const row = el.closest(`.${CSS.TableRow}`)
795
- if (row) {
796
- const focusables = Array.from(row.querySelectorAll('a, input, select:not(:disabled)'))
797
- return circularAdjacent(step, focusables, el)
798
- }
799
- }
800
-
801
- function allInFocusGroup(focusGroup) {
802
- return Array.from(leftSideRef.elem.querySelectorAll(
803
- `.${CSS.TableRow} [data-focus-group="${focusGroup}"]:is(input, a)`))
804
- }
798
+ })
805
799
 
806
- function circularAdjacent(step = 1, arr, pivot) {
800
+ function circularAdjacent(step, siblings, pivot) {
801
+ const arr = Array.from(siblings)
807
802
  return arr[(arr.indexOf(pivot) + step + arr.length) % arr.length]
808
803
  }
809
804
  }
@@ -6,12 +6,14 @@ export function className(...args) {
6
6
 
7
7
  export function createElement(tag, props, ...children) {
8
8
  const elem = document.createElement(tag)
9
- for (const [k, v] of Object.entries(props || {}))
10
- if (k === 'ref') v.elem = elem
11
- else if (k === 'style') Object.assign(elem.style, v)
12
- else if (k.startsWith('on')) elem.addEventListener(k.slice(2).toLowerCase(), ...[v].flat())
13
- else if (k in elem) elem[k] = v
14
- else elem.setAttribute(k, v)
9
+ if (props)
10
+ for (const [k, v] of Object.entries(props))
11
+ if (v === undefined) continue
12
+ else if (k === 'ref') v.elem = elem
13
+ else if (k === 'style') Object.assign(elem.style, v)
14
+ else if (k.startsWith('on')) elem.addEventListener(k.slice(2).toLowerCase(), ...[v].flat())
15
+ else if (k in elem) elem[k] = v
16
+ else elem.setAttribute(k, v)
15
17
  elem.append(...children.flat().filter(Boolean))
16
18
  return elem
17
19
  }
@@ -66,13 +68,6 @@ function selectorFor(elem) {
66
68
  }
67
69
 
68
70
 
69
-
70
- // Minimal implementation of CSS Modules in the browser
71
- // TODO think about avoiding clashes when using multiple files. e.g.:
72
- // - should the user pass a prefix?, or
73
- // - should the ensure there's a unique top-level classname on each file
74
- // TODO ignore rules in comments?
75
-
76
71
  export function adoptCSS(sheet) {
77
72
  document.adoptedStyleSheets.push(sheet)
78
73
  Object.assign(sheet, extractClassNames(sheet))
@@ -1,6 +1,5 @@
1
1
  import { API } from './ApiConstants.js'
2
2
 
3
-
4
3
  export const CSP = [
5
4
  `default-src 'self'`,
6
5
  `img-src data: blob: 'self'`
@@ -9,24 +8,24 @@ export const CSP = [
9
8
 
10
9
  // language=html
11
10
  export const IndexHtml = (hotReloadEnabled, version) => `
12
- <!DOCTYPE html>
13
- <html lang="en-US">
14
- <head>
15
- <meta charset="UTF-8">
16
- <base href="${API.dashboard}/">
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>
17
19
 
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>
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>
32
31
  `
@@ -607,8 +607,7 @@ main {
607
607
  }
608
608
  }
609
609
 
610
- .InternalServerErrorToggler,
611
- .NotFoundToggler {
610
+ .StatusCodeToggler {
612
611
  display: flex;
613
612
  margin-right: 10px;
614
613
  margin-left: 8px;
package/src/server/Api.js CHANGED
@@ -13,9 +13,10 @@ import {
13
13
  import { longPollClientSyncVersion } from './Watcher.js'
14
14
 
15
15
  import pkgJSON from '../../package.json' with { type: 'json' }
16
+
17
+ import { API } from '../client/ApiConstants.js'
16
18
  import { IndexHtml, CSP } from '../client/indexHtml.js'
17
19
 
18
- import { API } from './ApiConstants.js'
19
20
  import { cookie } from './cookie.js'
20
21
  import { config, ConfigValidator } from './config.js'
21
22
 
@@ -29,7 +30,7 @@ export const apiGetReqs = new Map([
29
30
 
30
31
  [API.state, getState],
31
32
  [API.syncVersion, longPollClientSyncVersion],
32
-
33
+
33
34
  [API.watchHotReload, longPollDevClientHotReload],
34
35
  [API.throws, () => { throw new Error('Test500') }]
35
36
  ])
@@ -41,17 +42,17 @@ export const apiPatchReqs = new Map([
41
42
  [API.cookies, selectCookie],
42
43
  [API.globalDelay, setGlobalDelay],
43
44
  [API.globalDelayJitter, setGlobalDelayJitter],
44
-
45
+
45
46
  [API.fallback, setProxyFallback],
46
47
  [API.collectProxied, setCollectProxied],
47
-
48
+
48
49
  [API.bulkSelect, bulkUpdateBrokersByCommentTag],
49
50
 
50
51
  [API.delay, setRouteIsDelayed],
51
52
  [API.select, selectMock],
52
53
  [API.proxied, setRouteIsProxied],
53
54
  [API.toggle500, toggleRoute500],
54
-
55
+
55
56
  [API.delayStatic, setStaticRouteIsDelayed],
56
57
  [API.staticStatus, setStaticRouteStatusCode]
57
58
  ])
@@ -220,7 +221,7 @@ async function setRouteIsProxied(req, response) {
220
221
 
221
222
  const broker = mockBrokersCollection.brokerByRoute(method, urlMask)
222
223
  if (!broker)
223
- response.unprocessable( `Route does not exist: ${method} ${urlMask}`)
224
+ response.unprocessable(`Route does not exist: ${method} ${urlMask}`)
224
225
  else if (typeof proxied !== 'boolean')
225
226
  response.unprocessable(`Expected boolean for "proxied"`)
226
227
  else if (proxied && !config.proxyFallback)
@@ -1,5 +1,5 @@
1
- import { DEFAULT_MOCK_COMMENT } from './ApiConstants.js'
2
- import { includesComment, extractComments, parseFilename } from './Filename.js'
1
+ import { DEFAULT_MOCK_COMMENT } from '../client/ApiConstants.js'
2
+ import { parseFilename, includesComment, extractComments } from '../client/Filename.js'
3
3
 
4
4
 
5
5
  /**
@@ -8,7 +8,8 @@ import { IncomingMessage } from './utils/HttpIncomingMessage.js'
8
8
  import { setCorsHeaders, isPreflight } from './utils/http-cors.js'
9
9
  import { BodyReaderError, hasControlChars } from './utils/HttpIncomingMessage.js'
10
10
 
11
- import { API } from './ApiConstants.js'
11
+ import { API } from '../client/ApiConstants.js'
12
+
12
13
  import { config, setup } from './config.js'
13
14
  import { apiPatchReqs, apiGetReqs } from './Api.js'
14
15
 
@@ -9,17 +9,18 @@ import { describe, test, before, beforeEach, after } from 'node:test'
9
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
+
12
14
  import { logger } from './utils/logger.js'
13
15
  import { mimeFor } from './utils/mime.js'
14
16
  import { readBody } from './utils/HttpIncomingMessage.js'
15
17
  import { CorsHeader } from './utils/http-cors.js'
16
18
 
17
- import { API } from './ApiConstants.js'
18
19
  import { Mockaton } from './Mockaton.js'
19
- import { parseFilename } from './Filename.js'
20
20
  import { watchMocksDir, watchStaticDir } from './Watcher.js'
21
21
 
22
22
  import { Commander } from '../client/ApiCommander.js'
23
+ import { parseFilename } from '../client/Filename.js'
23
24
 
24
25
 
25
26
  const mocksDir = mkdtempSync(join(tmpdir(), 'mocks'))
@@ -6,7 +6,8 @@ import { write, isFile } from './utils/fs.js'
6
6
  import { readBody, BodyReaderError } from './utils/HttpIncomingMessage.js'
7
7
 
8
8
  import { config } from './config.js'
9
- import { makeMockFilename } from './Filename.js'
9
+
10
+ import { makeMockFilename } from '../client/Filename.js'
10
11
 
11
12
 
12
13
  export async function proxy(req, response, delay) {
@@ -24,7 +24,7 @@ export async function dispatchStatic(req, response) {
24
24
  response.mockNotFound()
25
25
  return
26
26
  }
27
-
27
+
28
28
  logger.accessMock(req.url, 'static200')
29
29
  if (req.headers.range)
30
30
  await response.partialContent(req.headers.range, file)
@@ -5,7 +5,7 @@ import { EventEmitter } from 'node:events'
5
5
  import {
6
6
  HEADER_SYNC_VERSION,
7
7
  LONG_POLL_SERVER_TIMEOUT
8
- } from './ApiConstants.js'
8
+ } from '../client/ApiConstants.js'
9
9
 
10
10
  import { config } from './config.js'
11
11
  import { isFile, isDirectory } from './utils/fs.js'
@@ -1,8 +1,9 @@
1
1
  import { join } from 'node:path'
2
2
  import { EventEmitter } from 'node:events'
3
3
  import { watch, readdirSync } from 'node:fs'
4
+
4
5
  import { config } from './config.js'
5
- import { LONG_POLL_SERVER_TIMEOUT } from './ApiConstants.js'
6
+ import { LONG_POLL_SERVER_TIMEOUT } from '../client/ApiConstants.js'
6
7
 
7
8
 
8
9
  export const CLIENT_DIR = join(import.meta.dirname, '../client')
@@ -31,7 +32,7 @@ export function longPollDevClientHotReload(req, response) {
31
32
  response.notFound()
32
33
  return
33
34
  }
34
-
35
+
35
36
  function onDevChange(file) {
36
37
  devClientWatcher.unsubscribe(onDevChange)
37
38
  response.json(file)
@@ -6,7 +6,7 @@ import { listFilesRecursively } from './utils/fs.js'
6
6
  import { cookie } from './cookie.js'
7
7
  import { MockBroker } from './MockBroker.js'
8
8
  import { config, isFileAllowed } from './config.js'
9
- import { parseFilename, validateFilename } from './Filename.js'
9
+ import { parseFilename, validateFilename } from '../client/Filename.js'
10
10
 
11
11
 
12
12
  /**
@@ -69,7 +69,7 @@ export function hasControlChars(url) {
69
69
 
70
70
  export function decode(url) {
71
71
  const candidate = decodeURIComponent(url)
72
- return candidate === decodeURIComponent(candidate)
73
- ? candidate
72
+ return candidate === decodeURIComponent(candidate)
73
+ ? candidate
74
74
  : '' // reject multiple encodings
75
75
  }
@@ -3,7 +3,7 @@ import fs, { readFileSync } from 'node:fs'
3
3
 
4
4
  import { logger } from './logger.js'
5
5
  import { mimeFor } from './mime.js'
6
- import { HEADER_502 } from '../ApiConstants.js'
6
+ import { HEADER_502 } from '../../client/ApiConstants.js'
7
7
 
8
8
 
9
9
  export class ServerResponse extends http.ServerResponse {
@@ -1,5 +1,5 @@
1
1
  import { config } from '../config.js'
2
- import { UNKNOWN_MIME_EXT } from '../ApiConstants.js'
2
+ import { UNKNOWN_MIME_EXT } from '../../client/ApiConstants.js'
3
3
 
4
4
 
5
5
  // Generated with:
@@ -1,32 +0,0 @@
1
- // @KeepSync src/client/ApiConstants.js
2
-
3
- const MOUNT = '/mockaton'
4
- export const API = {
5
- dashboard: MOUNT,
6
-
7
- bulkSelect: MOUNT + '/bulk-select-by-comment',
8
- collectProxied: MOUNT + '/collect-proxied',
9
- cookies: MOUNT + '/cookies',
10
- cors: MOUNT + '/cors',
11
- delay: MOUNT + '/delay',
12
- delayStatic: MOUNT + '/delay-static',
13
- fallback: MOUNT + '/fallback',
14
- globalDelay: MOUNT + '/global-delay',
15
- globalDelayJitter: MOUNT + '/global-delay-jitter',
16
- proxied: MOUNT + '/proxied',
17
- reset: MOUNT + '/reset',
18
- select: MOUNT + '/select',
19
- state: MOUNT + '/state',
20
- staticStatus: MOUNT + '/static-status',
21
- syncVersion: MOUNT + '/sync-version',
22
- throws: MOUNT + '/throws',
23
- toggle500: MOUNT + '/toggle500',
24
- watchHotReload: MOUNT + '/watch-hot-reload',
25
- }
26
-
27
- export const HEADER_502 = 'Mockaton502'
28
- export const HEADER_SYNC_VERSION = 'sync_version'
29
-
30
- export const DEFAULT_MOCK_COMMENT = '(default)'
31
- export const UNKNOWN_MIME_EXT = 'unknown'
32
- export const LONG_POLL_SERVER_TIMEOUT = 8_000
@@ -1,65 +0,0 @@
1
- /** @KeepSync src/client/Filename.js */
2
-
3
- import { METHODS } from 'node:http'
4
-
5
-
6
- const reComments = /\(.*?\)/g // Anything within parentheses
7
-
8
- export function extractComments(file) {
9
- return Array.from(file.matchAll(reComments), ([c]) => c)
10
- }
11
-
12
- export function includesComment(file, search) {
13
- return extractComments(file).some(c => c.includes(search))
14
- }
15
-
16
-
17
- export function validateFilename(file) {
18
- const tokens = file.replace(reComments, '').split('.')
19
- if (tokens.length < 4)
20
- return 'Invalid Filename Convention'
21
-
22
- const { status, method } = parseFilename(file)
23
- if (!responseStatusIsValid(status))
24
- return `Invalid HTTP Response Status: "${status}"`
25
-
26
- if (!METHODS.includes(method))
27
- return `Unrecognized HTTP Method: "${method}"`
28
- }
29
-
30
-
31
- export function parseFilename(file) {
32
- const tokens = file.replace(reComments, '').split('.')
33
- return {
34
- ext: tokens.pop(),
35
- status: Number(tokens.pop()),
36
- method: tokens.pop(),
37
- urlMask: '/' + removeTrailingSlash(tokens.join('.'))
38
- }
39
- }
40
-
41
- function removeTrailingSlash(url = '') {
42
- return url
43
- .replace(/\/$/, '')
44
- .replace('/?', '?')
45
- .replace('/#', '#')
46
- }
47
-
48
- function responseStatusIsValid(status) {
49
- return Number.isInteger(status)
50
- && status >= 100
51
- && status <= 599
52
- }
53
-
54
-
55
- export function makeMockFilename(url, method, status, ext) {
56
- const urlMask = replaceIds(removeTrailingSlash(url))
57
- return [urlMask, method, status, ext].join('.')
58
- }
59
-
60
- 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
61
- function replaceIds(filename) {
62
- return filename.replaceAll(reUuidV4, '[id]')
63
- }
64
-
65
-