mockaton 13.2.1 → 13.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "mockaton",
3
3
  "description": "HTTP Mock Server",
4
4
  "type": "module",
5
- "version": "13.2.1",
5
+ "version": "13.3.1",
6
6
  "exports": {
7
7
  ".": {
8
8
  "import": "./index.js",
@@ -42,7 +42,7 @@ export class Commander {
42
42
  /** @returns {JsonPromise<ClientMockBroker>} */
43
43
  toggleStatus = (method, urlMask, status) => this.#patch(API.toggleStatus, [method, urlMask, status])
44
44
 
45
-
45
+
46
46
  /** @returns {JsonPromise<ClientMockBroker>} */
47
47
  setRouteIsProxied = (method, urlMask, proxied) => this.#patch(API.proxied, [method, urlMask, proxied])
48
48
 
@@ -132,7 +132,7 @@ async function updatePayloadViewer(proxied, file, response) {
132
132
  else if (['text/xml', 'application/xml'].includes(mime))
133
133
  codeRef.elem.replaceChildren(SyntaxXML(await bodyAsText()))
134
134
 
135
- else if (mime.startsWith('text/'))
135
+ else if (mime.startsWith('text/') || mime === 'application/yaml')
136
136
  codeRef.elem.textContent = await bodyAsText()
137
137
 
138
138
  else
@@ -33,6 +33,16 @@ export const store = {
33
33
  store.render()
34
34
  },
35
35
 
36
+ collapsedFolders: new Set(JSON.parse(globalThis.localStorage?.getItem('collapsedFolders') || '[]')),
37
+ setFolderCollapsed(folder, collapsed) {
38
+ if (collapsed)
39
+ store.collapsedFolders.add(folder)
40
+ else
41
+ store.collapsedFolders.delete(folder)
42
+
43
+ globalThis.localStorage?.setItem('collapsedFolders', JSON.stringify([...store.collapsedFolders]))
44
+ },
45
+
36
46
  chosenLink: { method: '', urlMask: '' },
37
47
  setChosenLink(method, urlMask) {
38
48
  store.chosenLink = { method, urlMask }
@@ -98,7 +108,6 @@ export const store = {
98
108
  },
99
109
 
100
110
  setProxyFallback(value) {
101
- store.skipNextRender = true
102
111
  store._request(() => api.setProxyFallback(value), () => {
103
112
  store.proxyFallback = value
104
113
  })
@@ -112,27 +121,40 @@ export const store = {
112
121
  },
113
122
 
114
123
 
124
+ _dittoCache: new Map(),
125
+
115
126
  brokerFor(method, urlMask) {
116
127
  return store.brokersByMethod[method]?.[urlMask]
117
128
  },
118
129
 
130
+ brokerAsRow(method, urlMask) {
131
+ const b = store.brokerFor(method, urlMask)
132
+ const r = new BrokerRowModel(b, store.canProxy)
133
+ r.setUrlMaskDittoed(store._dittoCache.get(r.key))
134
+ return r
135
+ },
136
+
119
137
  _setBroker(broker) {
120
138
  const { method, urlMask } = parseFilename(broker.file)
121
139
  store.brokersByMethod[method] ??= {}
122
140
  store.brokersByMethod[method][urlMask] = broker
123
141
  },
124
142
 
125
- _dittoCache: new Map(),
126
-
127
- _brokersAsArray(byMethod = '*') {
128
- const arr = []
129
- for (const [method, brokers] of Object.entries(store.brokersByMethod))
130
- if (byMethod === '*' || byMethod === method)
131
- arr.push(...Object.values(brokers))
132
- return arr
143
+ folderGroupsByMethod(method) {
144
+ const groups = []
145
+ let g = null
146
+ for (const row of store._brokersAsRowsByMethod(method)) {
147
+ const folder = row.urlMask.substring(0, row.urlMask.lastIndexOf('/') + 1)
148
+ if (!g || g.folder !== folder) {
149
+ g = { folder, children: [] }
150
+ groups.push(g)
151
+ }
152
+ g.children.push(row)
153
+ }
154
+ return groups
133
155
  },
134
156
 
135
- brokersAsRowsByMethod(method) {
157
+ _brokersAsRowsByMethod(method) {
136
158
  const rows = store._brokersAsArray(method)
137
159
  .map(b => new BrokerRowModel(b, store.canProxy))
138
160
  .sort((a, b) => a.urlMask.localeCompare(b.urlMask))
@@ -145,12 +167,14 @@ export const store = {
145
167
  return rows
146
168
  },
147
169
 
148
- brokerAsRow(method, urlMask) {
149
- const b = store.brokerFor(method, urlMask)
150
- const r = new BrokerRowModel(b, store.canProxy)
151
- r.setUrlMaskDittoed(store._dittoCache.get(r.key))
152
- return r
170
+ _brokersAsArray(byMethod = '*') {
171
+ const arr = []
172
+ for (const [method, brokers] of Object.entries(store.brokersByMethod))
173
+ if (byMethod === '*' || byMethod === method)
174
+ arr.push(...Object.values(brokers))
175
+ return arr
153
176
  },
177
+
154
178
 
155
179
  previewLink(method, urlMask) {
156
180
  store.setChosenLink(method, urlMask)
@@ -423,7 +423,7 @@ main {
423
423
  padding-bottom: 4px;
424
424
  padding-left: 1px;
425
425
  border-top: 24px solid transparent;
426
- margin-left: 71px;
426
+ margin-left: 94px;
427
427
  font-weight: bold;
428
428
  text-align: left;
429
429
 
@@ -432,13 +432,13 @@ main {
432
432
  }
433
433
 
434
434
  &.canProxy {
435
- margin-left: 100px;
435
+ margin-left: 124px;
436
436
  }
437
437
  }
438
438
 
439
439
  .TableRow {
440
440
  display: flex;
441
- align-items: center;
441
+ margin-left: 24px;
442
442
 
443
443
  &.animIn {
444
444
  opacity: 0;
@@ -446,7 +446,86 @@ main {
446
446
  animation: _kfRowIn 180ms ease-in-out forwards;
447
447
  }
448
448
  }
449
+
450
+ .FolderGroup {
451
+ position: relative;
452
+
453
+ > summary {
454
+ left: 0;
455
+ display: flex;
456
+ overflow: hidden;
457
+ align-items: center;
458
+ padding: 5px 0;
459
+ font-size: 12px;
460
+ list-style: none;
461
+ cursor: pointer;
462
+ user-select: none;
463
+ color: var(--colorLabel);
464
+ white-space: nowrap;
465
+ text-overflow: ellipsis;
466
+ border-radius: var(--radius);
467
+
468
+
469
+ &:active {
470
+ cursor: grabbing;
471
+ }
472
+
473
+ &::-webkit-details-marker {
474
+ display: none;
475
+ }
476
+
477
+ &:hover {
478
+ color: var(--colorText);
479
+ }
480
+
481
+ .FolderName {
482
+ margin-left: 125px;
483
+ &.canProxy {
484
+ margin-left: 155px;
485
+ }
486
+
487
+ &.groupedByMethod {
488
+ margin-left: 79px;
489
+ &.canProxy {
490
+ margin-left: 109px;
491
+ }
492
+ }
493
+ }
494
+
495
+ .FolderChevron {
496
+ display: flex;
497
+ width: 16px;
498
+ height: 16px;
499
+ flex-shrink: 0;
500
+ align-items: center;
501
+ justify-content: center;
502
+ opacity: .7;
503
+ transition: transform cubic-bezier(.2, .7, .8, 1.4) 400ms;
504
+ transform: rotate(-90deg);
505
+
506
+ svg {
507
+ width: 100%;
508
+ height: 100%;
509
+ }
510
+ }
511
+ }
512
+
513
+ &[open] > summary {
514
+ position: absolute;
515
+ top: 0;
516
+ left: 0;
517
+ width: 16px;
518
+
519
+ .FolderChevron {
520
+ transform: rotate(0deg);
521
+ }
522
+ .FolderName {
523
+ display: none;
524
+ }
525
+ }
526
+ }
449
527
  }
528
+
450
529
  @keyframes _kfRowIn {
451
530
  to {
452
531
  opacity: 1;
@@ -458,6 +537,7 @@ main {
458
537
  overflow: hidden;
459
538
  min-width: 38px;
460
539
  padding: 4px 0;
540
+ margin-top: 3px;
461
541
  margin-right: 8px;
462
542
  color: var(--colorLabel);
463
543
  font-size: 11px;
@@ -523,6 +603,7 @@ main {
523
603
 
524
604
  .Toggler {
525
605
  display: flex;
606
+ margin-top: 3px;
526
607
 
527
608
  input {
528
609
  /* For click drag target */
package/src/client/app.js CHANGED
@@ -3,8 +3,8 @@ import { createElement as r, t, classNames, restoreFocus, Fragment, defineClassN
3
3
  import { store } from './app-store.js'
4
4
  import { API } from './ApiConstants.js'
5
5
  import { Header } from './app-header.js'
6
- import { TimerIcon, CloudIcon } from './graphics.js'
7
6
  import { PayloadViewer, previewMock } from './app-payload-viewer.js'
7
+ import { TimerIcon, CloudIcon, ChevronDownIcon } from './graphics.js'
8
8
 
9
9
  import CSS from './app.css' with { type: 'css' }
10
10
  document.adoptedStyleSheets.push(CSS)
@@ -100,9 +100,35 @@ function MockList() {
100
100
  r('div', {
101
101
  className: classNames(CSS.TableHeading, store.canProxy && CSS.canProxy)
102
102
  }, method),
103
- store.brokersAsRowsByMethod(method).map(Row)))
103
+ FolderGroups(store.folderGroupsByMethod(method))))
104
104
 
105
- return store.brokersAsRowsByMethod('*').map(Row)
105
+ return FolderGroups(store.folderGroupsByMethod('*'))
106
+ }
107
+
108
+ function FolderGroups(groups) {
109
+ return groups.map(({ folder, children }) => {
110
+ if (children.length === 1)
111
+ return Row(children[0], 0)
112
+
113
+ return r('details', {
114
+ className: CSS.FolderGroup,
115
+ open: !store.collapsedFolders.has(folder),
116
+ onToggle() {
117
+ // TODO alt+click exclusive open
118
+ store.setFolderCollapsed(folder, !this.open)
119
+ }
120
+ },
121
+ r('summary', null,
122
+ r('span', { className: CSS.FolderChevron }, ChevronDownIcon()),
123
+ r('span', {
124
+ className: classNames(
125
+ CSS.FolderName,
126
+ store.groupByMethod && CSS.groupedByMethod,
127
+ store.canProxy && CSS.canProxy)
128
+ },
129
+ folder + '…')),
130
+ children.map(Row))
131
+ })
106
132
  }
107
133
 
108
134
  /**
@@ -27,3 +27,9 @@ export function HelpIcon() {
27
27
  s('svg', { viewBox: '0 0 24 24' },
28
28
  s('path', { d: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m1 17h-2v-2h2zm2.07-7.75-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25' })))
29
29
  }
30
+
31
+ export function ChevronDownIcon() {
32
+ return (
33
+ s('svg', { viewBox: '0 0 24 24', stroke: 'currentColor', fill: 'none', 'stroke-width': '2' },
34
+ s('path', { d: 'M6 9l6 6 6-6' })))
35
+ }
package/src/server/Api.js CHANGED
@@ -113,7 +113,6 @@ async function setGlobalDelay(req, response) {
113
113
  response.unprocessable(`Expected non-negative integer for "delay"`)
114
114
  else {
115
115
  config.delay = delay
116
- uiSyncVersion.increment()
117
116
  response.ok()
118
117
  uiSyncVersion.increment()
119
118
  }
@@ -219,7 +218,7 @@ async function setRouteIsDelayed(req, response) {
219
218
  else {
220
219
  broker.setDelayed(delayed)
221
220
  response.json(broker)
222
- uiSyncVersion.increment()
221
+ uiSyncVersion.increment()
223
222
  }
224
223
  }
225
224
 
@@ -36,10 +36,10 @@ export class MockBroker {
36
36
 
37
37
  unregister(file) {
38
38
  this.mocks = this.mocks.filter(f => f !== file)
39
- const isEmpty = !this.mocks.length
40
- if (!isEmpty && this.file === file)
39
+ const brokerIsEmpty = !this.mocks.length
40
+ if (!brokerIsEmpty && this.file === file)
41
41
  this.selectDefaultFile()
42
- return isEmpty
42
+ return brokerIsEmpty
43
43
  }
44
44
 
45
45
  hasMock = file => this.mocks.includes(file)
@@ -53,11 +53,8 @@ export async function dispatchMock(req, response) {
53
53
  response.setHeader('Content-Type', mime)
54
54
  response.setHeader('Content-Length', length(body))
55
55
 
56
- setTimeout(() =>
57
- response.end(isHead
58
- ? null
59
- : body
60
- ), Number(broker.delayed && calcDelay()))
56
+ setTimeout(() => response.end(isHead ? null : body),
57
+ Number(broker.delayed && calcDelay()))
61
58
 
62
59
  logger.accessMock(req.url, broker.file)
63
60
  }
@@ -5,9 +5,8 @@ import pkgJSON from '../../package.json' with { type: 'json' }
5
5
 
6
6
  import { logger } from './utils/logger.js'
7
7
  import { ServerResponse } from './utils/HttpServerResponse.js'
8
- import { IncomingMessage } from './utils/HttpIncomingMessage.js'
8
+ import { IncomingMessage, BodyReaderError, hasControlChars } from './utils/HttpIncomingMessage.js'
9
9
  import { setCorsHeaders, isPreflight } from './utils/http-cors.js'
10
- import { BodyReaderError, hasControlChars } from './utils/HttpIncomingMessage.js'
11
10
 
12
11
  import { API } from '../client/ApiConstants.js'
13
12
 
@@ -7,7 +7,7 @@ import { mkdtempSync } from 'node:fs'
7
7
  import { randomUUID } from 'node:crypto'
8
8
  import { equal, deepEqual, match } from 'node:assert/strict'
9
9
  import { describe, test, before, beforeEach, after } from 'node:test'
10
- import { unlink, mkdir, readFile, rename, readdir, writeFile } from 'node:fs/promises'
10
+ import { unlink, mkdir, readFile, rename, readdir, writeFile, rm } from 'node:fs/promises'
11
11
 
12
12
  import { mimeFor } from './utils/mime.js'
13
13
  import { parseFilename } from '../client/Filename.js'
@@ -26,8 +26,15 @@ const proc = spawn(join(import.meta.dirname, 'cli.js'), [
26
26
  '--no-open'
27
27
  ])
28
28
 
29
- proc.stdout.on('data', data => stdout.push(data.toString()))
30
- proc.stderr.on('data', data => stderr.push(data.toString()))
29
+ const DEBUG = false
30
+ proc.stdout.on('data', data => {
31
+ stdout.push(data.toString())
32
+ DEBUG && process.stdout.write(stdout.at(-1))
33
+ })
34
+ proc.stderr.on('data', data => {
35
+ stderr.push(data.toString())
36
+ DEBUG && process.stderr.write(stdout.at(-1))
37
+ })
31
38
 
32
39
  const serverAddr = await new Promise((resolve, reject) => {
33
40
  proc.stdout.once('data', () => {
@@ -41,12 +48,14 @@ after(() => proc.kill('SIGUSR2'))
41
48
 
42
49
 
43
50
  const rmFromMocksDir = f => unlink(join(mocksDir, f))
44
- const listFromMocksDir = d => readdir(join(mocksDir, d))
45
51
  const readFromMocksDir = f => readFile(join(mocksDir, f), 'utf8')
46
52
  const writeInMocksDir = (f, data) => writeFile(join(mocksDir, f), data)
47
- const makeDirInMocks = dir => mkdir(join(mocksDir, dir), { recursive: true })
48
53
  const renameInMocksDir = (src, target) => rename(join(mocksDir, src), join(mocksDir, target))
49
54
 
55
+ const listFromMocksDir = d => readdir(join(mocksDir, d))
56
+ const rmDirFromMocks = d => rm(join(mocksDir, d), { recursive: true })
57
+ const makeDirInMocks = dir => mkdir(join(mocksDir, dir), { recursive: true })
58
+
50
59
 
51
60
  const api = new Commander(serverAddr)
52
61
 
@@ -61,29 +70,10 @@ function request(path, options = {}) {
61
70
  }
62
71
 
63
72
 
64
- class BaseFixture {
65
- dir = ''
66
- urlMask = ''
67
- method = ''
68
-
73
+ class Fixture {
69
74
  constructor(file, body = '') {
70
75
  this.file = file
71
76
  this.body = body || `Body for ${file}`
72
- }
73
- write() { return api.writeMock(this.file, this.body) }
74
- delete() { return api.deleteMock(this.file) }
75
-
76
- request(options = {}) {
77
- options.method ??= this.method
78
- return request(this.urlMask, options)
79
- }
80
- }
81
-
82
-
83
- class Fixture extends BaseFixture {
84
- constructor(file, body = '') {
85
- super(file, body)
86
- this.dir = mocksDir
87
77
  const t = parseFilename(file)
88
78
  this.urlMask = t.urlMask
89
79
  this.method = t.method
@@ -93,14 +83,12 @@ class Fixture extends BaseFixture {
93
83
  async fetchBroker() {
94
84
  return (await fetchState()).brokersByMethod?.[this.method]?.[this.urlMask]
95
85
  }
96
- }
86
+ write() { return api.writeMock(this.file, this.body) }
87
+ delete() { return api.deleteMock(this.file) }
97
88
 
98
- class FixtureStatic extends BaseFixture {
99
- constructor(file, body = '') {
100
- super(file, body)
101
- this.dir = mocksDir
102
- this.urlMask = '/' + file
103
- this.method = 'GET'
89
+ request(options = {}) {
90
+ options.method ??= this.method
91
+ return request(this.urlMask, options)
104
92
  }
105
93
  }
106
94
 
@@ -614,8 +602,8 @@ describe('Dynamic Function Mocks', () => {
614
602
 
615
603
 
616
604
  describe('Static Files', () => {
617
- const fxsIndex = new FixtureStatic('index.html', '<h1>Index</h1>')
618
- const fxsAsset = new FixtureStatic('asset-script.js', 'const a = 1')
605
+ const fxsIndex = new Fixture('index.html', '<h1>Index</h1>')
606
+ const fxsAsset = new Fixture('asset-script.js', 'const a = 1')
619
607
  before(async () => {
620
608
  await api.reset()
621
609
  await fxsIndex.write()
@@ -724,7 +712,7 @@ describe('Auto Status', () => {
724
712
  })
725
713
 
726
714
  test('toggling ON 404 for static routes', async () => {
727
- const fx = new FixtureStatic('static-404.txt')
715
+ const fx = new Fixture('static-404.txt')
728
716
  await fx.write()
729
717
  equal((await fx.request()).status, 200)
730
718
 
@@ -1040,10 +1028,6 @@ describe('Watch mocks API toggler', () => {
1040
1028
  describe('Registering Mocks', () => {
1041
1029
  // simulates user interacting with the file-system directly
1042
1030
  class FixtureExternal extends Fixture {
1043
- constructor(props) {
1044
- super(props)
1045
- }
1046
-
1047
1031
  async writeExternally() {
1048
1032
  const nextVerPromise = resolveOnNextSyncVersion()
1049
1033
  await sleep(0) // next macro task
@@ -1063,7 +1047,6 @@ describe('Registering Mocks', () => {
1063
1047
  return new Promise(resolve => setTimeout(resolve, ms))
1064
1048
  }
1065
1049
 
1066
-
1067
1050
  const fxA = new FixtureExternal('register(default).GET.200.json')
1068
1051
  const fxB = new FixtureExternal('register(alt).GET.200.json')
1069
1052
 
@@ -1138,14 +1121,24 @@ describe('Registering Mocks', () => {
1138
1121
  })
1139
1122
 
1140
1123
  test('responds when dir is renamed', async () => {
1141
- const p0 = resolveOnNextSyncVersion(version + 2)
1124
+ const prom = resolveOnNextSyncVersion(version + 2)
1142
1125
  await renameInMocksDir('reg0', 'reg1')
1143
- equal(await p0, version + 3)
1126
+ equal(await prom, version + 3)
1144
1127
 
1145
1128
  const s = await fetchState()
1146
1129
  equal(s.brokersByMethod.GET['/reg1/runtime0'].file, 'reg1/runtime0.GET.200.txt')
1147
1130
  })
1148
1131
  })
1132
+
1133
+ test('deleting a folder unregisters mocks in it', async () => {
1134
+ const fx = new Fixture('api/bulk-delete/bar.GET.200.json')
1135
+ await fx.write()
1136
+ await sleep(0)
1137
+ const nextVerPromise = resolveOnNextSyncVersion()
1138
+ await rmDirFromMocks('api/bulk-delete')
1139
+ await nextVerPromise
1140
+ equal(await fx.fetchBroker(), undefined)
1141
+ })
1149
1142
  })
1150
1143
 
1151
1144
 
@@ -47,7 +47,6 @@ export function registerMock(file, isFromWatcher = false) {
47
47
  collection[method] ??= {}
48
48
 
49
49
  let broker = collection[method][urlMask]
50
-
51
50
  if (!broker)
52
51
  broker = collection[method][urlMask] = new MockBroker(file)
53
52
  else
@@ -61,15 +60,26 @@ export function registerMock(file, isFromWatcher = false) {
61
60
 
62
61
  export function unregisterMock(file) {
63
62
  const broker = brokerByFilename(file)
64
- const hasNoMoreMocks = broker?.unregister(file)
65
- if (hasNoMoreMocks) {
66
- const { method, urlMask } = parseFilename(file)
67
- delete collection[method][urlMask]
68
- if (!Object.keys(collection[method]).length)
69
- delete collection[method]
63
+ if (broker) {
64
+ const brokerIsEmpty = broker.unregister(file)
65
+ if (brokerIsEmpty) {
66
+ const { method, urlMask } = parseFilename(file)
67
+ delete collection[method][urlMask]
68
+ if (!Object.keys(collection[method]).length)
69
+ delete collection[method]
70
+ }
70
71
  }
72
+ else for (const f of filesInDir(file)) // maybe it was a dir
73
+ unregisterMock(f)
71
74
  }
72
75
 
76
+ function filesInDir(dir) {
77
+ const files = []
78
+ forEachBroker(b => {
79
+ files.push(...(b.mocks.filter(m => m.startsWith(dir + '/'))))
80
+ })
81
+ return files
82
+ }
73
83
 
74
84
  /** @returns {MockBroker | undefined} */
75
85
  export function brokerByFilename(file) {
@@ -19,14 +19,14 @@ export class ServerResponse extends http.ServerResponse {
19
19
 
20
20
  html(html, csp) {
21
21
  logger.access(this)
22
- this.setHeader('Content-Type', mimeFor('html'))
22
+ this.setHeader('Content-Type', mimeFor('.html'))
23
23
  this.setHeader('Content-Security-Policy', csp)
24
24
  this.end(html)
25
25
  }
26
26
 
27
27
  json(payload) {
28
28
  logger.access(this)
29
- this.setHeader('Content-Type', mimeFor('json'))
29
+ this.setHeader('Content-Type', mimeFor('.json'))
30
30
  this.end(JSON.stringify(payload))
31
31
  }
32
32
 
@@ -40,7 +40,6 @@ export async function resolveIn(baseDir, file) {
40
40
  : null
41
41
  }
42
42
  catch (e) {
43
- console.error('DDDDDD', e)
44
43
  return null
45
44
  }
46
45
  }
@@ -545,7 +545,7 @@
545
545
  "Filename": {
546
546
  "type": "string",
547
547
  "description": "Mock filename. The convention is UrlMask.Method.StatusCode.Extension",
548
- "example": "api/user/avatar.GET.200.svg"
548
+ "example": "api/user/friends.GET.200.json"
549
549
  },
550
550
  "ClientMockBroker": {
551
551
  "type": "object",