mockaton 8.3.4 → 8.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,8 +3,10 @@
3
3
  ![NPM Version](https://img.shields.io/npm/v/mockaton)
4
4
  ![NPM Version](https://img.shields.io/npm/l/mockaton)
5
5
 
6
+ ## Mock your APIs, Enhance your Development Workflow
6
7
 
7
- _Mockaton_ is a mock server for improving the frontend development and testing experience.
8
+ _Mockaton_ is an HTTP mock server built for improving the frontend
9
+ development and testing experience.
8
10
 
9
11
  With Mockaton you don’t need to write code for wiring your mocks. Instead, it
10
12
  scans a given directory for filenames following a convention similar to the
@@ -13,13 +15,17 @@ URL paths. For example, the following file will be served on `/api/user/1234`
13
15
  my-mocks-dir/api/user/[user-id].GET.200.json
14
16
  ```
15
17
 
16
- ## Scraping Mocks
17
- You don’t need to mock all your APIs. Mockaton can request
18
- from your backend the routes you don’t have mocks for. That’s done with:
18
+ By the way, you don’t need to mock all your APIs. You can request from
19
+ your backend the routes you don’t have mocks for. That’s done with:
19
20
 
20
21
  `config.proxyFallback = 'http://mybackend'`
21
22
 
22
- `config.collectProxied = true` lets you save those responses as mocks following the filename convention.
23
+ ## Scraping Mocks
24
+ You can save mocks following the filename convention
25
+ for the routes that reached your `proxyFallback` with:
26
+
27
+ `config.collectProxied = true`
28
+
23
29
 
24
30
  ## Multiple Mock Variants
25
31
  Each route can have many mocks, which could either be:
@@ -28,9 +34,9 @@ Each route can have many mocks, which could either be:
28
34
  - e.g. `api/login(locked out user).POST.423.json`
29
35
 
30
36
 
31
- ## Dashboard UI
37
+ ## Dashboard
32
38
 
33
- In the dashboard, you can select a mock variant for a particular
39
+ In the dashboard you can select a mock variant for a particular
34
40
  route, among other options. In addition, there’s a programmatic API,
35
41
  which is handy for setting up tests (see **Commander API** below).
36
42
 
@@ -64,7 +70,7 @@ node --import=tsx my-mockaton.js
64
70
  ```
65
71
 
66
72
 
67
- ## Running the Demo Example
73
+ ## Running the Built-in Demo
68
74
  This demo uses the [sample-mocks/](./sample-mocks) of this repository.
69
75
 
70
76
  ```sh
@@ -80,8 +86,7 @@ Experiment with the Dashboard:
80
86
  - Toggle the 🕓 _Delay Responses_ button, (e.g. for testing spinners)
81
87
  - Toggle the _500_ button, which sends and _Internal Server Error_ on that endpoint
82
88
 
83
- Finally, edit a mock file in your IDE. You don’t need to restart Mockaton for that.
84
- The _Reset_ button is for registering newly added, removed, or renamed mocks.
89
+ Finally, edit a mock file in your IDE. You don’t need to restart Mockaton.
85
90
 
86
91
 
87
92
  ## Use Cases
@@ -183,9 +188,7 @@ export default function listColors() {
183
188
  ```
184
189
  </details>
185
190
 
186
- ---
187
-
188
- If you are wondering, what if I need to serve a static `.js`?
191
+ **What if I need to serve a static .js?**
189
192
  Put it in your `config.staticDir` without the mock filename convention.
190
193
 
191
194
  ---
@@ -194,8 +197,8 @@ Put it in your `config.staticDir` without the mock filename convention.
194
197
 
195
198
  ### Extension
196
199
 
197
- The last 3 dots are reserved for the HTTP Method,
198
- Response Status Code, and the File Extension.
200
+ The last three dots are reserved for the HTTP Method,
201
+ Response Status Code, and File Extension.
199
202
 
200
203
  ```
201
204
  api/user.GET.200.json
@@ -235,7 +238,7 @@ api/video<b>?limit=[limit]</b>.GET.200.json
235
238
  </pre>
236
239
 
237
240
  Speaking of which, on Windows filenames containing "?" are [not
238
- permitted](https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file), but since that’s part of the query string, it’s ignored anyway.
241
+ permitted](https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file), but since that’s part of the query string it’s ignored anyway.
239
242
 
240
243
 
241
244
  ### Index-like routes
@@ -272,8 +275,10 @@ Defaults to `/(\.DS_Store|~)$/`
272
275
 
273
276
 
274
277
  ### `delay?: number`
275
- Routes can individually be delayed with the 🕓 checkbox. On the other hand,
276
- the amount is globally configurable. It defaults to `config.delay=1200` milliseconds.
278
+ Defaults to `config.delay=1200` milliseconds.
279
+
280
+ Although routes can individually be delayed with the 🕓
281
+ checkbox, delay the amount is globally configurable.
277
282
 
278
283
 
279
284
  ### `proxyFallback?: string`
@@ -290,11 +295,7 @@ URL the filename will have `[id]` in their place. For example,
290
295
  my-mocks-dir/api/user/[id]/likes.GET.200.json
291
296
  ```
292
297
 
293
- Note that newly saved mocks won’t be served until you
294
- **register them** by reinitializing Mockaton or clicking "Reset".
295
-
296
- Registered mocks won’t be overwritten (they don’t hit the fallback server).
297
- On the other hand, newly saved mocks get overwritten while they are unregistered.
298
+ Your existing mocks won’t be overwritten (they don’t hit the fallback server).
298
299
 
299
300
  <details>
300
301
  <summary>Extension Details</summary>
@@ -343,7 +344,7 @@ response in a `Set-Cookie` header. If you need to send more
343
344
  cookies, inject them globally in `config.extraHeaders`.
344
345
 
345
346
  By the way, the `jwtCookie` helper has a hardcoded header and signature.
346
- In other words, it’s useful only if you care about the payload.
347
+ In other words, it’s useful only if you care about its payload.
347
348
 
348
349
 
349
350
  ### `extraHeaders?: string[]`
@@ -490,9 +491,8 @@ await mockaton.setProxyFallback('http://example.com')
490
491
  Pass an empty string to disable it.
491
492
 
492
493
  ### Reset
493
- Re-initialize the collection. So if you added or removed mocks they will
494
- be considered. The selected mocks, cookies, and delays go back to default,
495
- but `config.proxyFallback` and `config.corsAllowed` are not affected.
494
+ Re-initialize the collection. The selected mocks, cookies, and delays go back to
495
+ default, but `config.proxyFallback` and `config.corsAllowed` are not affected.
496
496
  ```js
497
497
  await mockaton.reset()
498
498
  ```
package/TODO.md CHANGED
@@ -1,7 +1,5 @@
1
1
  # TODO
2
2
 
3
3
  - Refactor tests
4
- - Rename 'Reset' to 'Reload'
5
4
  - Add Collect Proxied checkbox to the dashboard
6
- - Subscribe button for newly added mocks
7
- - or perhaps registering them automatically without full reset?
5
+ - Dashboard refresh (poll?)
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "mockaton",
3
3
  "description": "A deterministic server-side for developing and testing frontend clients",
4
4
  "type": "module",
5
- "version": "8.3.4",
5
+ "version": "8.4.0",
6
6
  "main": "index.js",
7
7
  "types": "index.d.ts",
8
8
  "license": "MIT",
package/src/Dashboard.js CHANGED
@@ -43,7 +43,7 @@ const refPayloadViewerFileTitle = useRef()
43
43
 
44
44
  const mockaton = new Commander(window.location.origin)
45
45
 
46
-
46
+ window.onfocus = init
47
47
  function init() {
48
48
  Promise.all([
49
49
  mockaton.listMocks(),
@@ -204,7 +204,8 @@ function SectionByMethod({ method, brokers }) {
204
204
  r('tbody', null,
205
205
  r('th', null, method),
206
206
  Object.entries(brokers)
207
- .filter(([, broker]) => broker.mocks.length > 1) // Excludes Markdown only routes (>1 because of the autogen500)
207
+ .filter(([, broker]) => broker.mocks.length > 1) // >1 because of autogen500
208
+ .sort((a, b) => a[0].localeCompare(b[0]))
208
209
  .map(([urlMask, broker]) =>
209
210
  r('tr', null,
210
211
  r('td', null, r(PreviewLink, { method, urlMask })),
package/src/MockBroker.js CHANGED
@@ -18,7 +18,37 @@ export class MockBroker {
18
18
  this.register(file)
19
19
  }
20
20
 
21
- register(file) { this.mocks.push(file) }
21
+ register(file) {
22
+ if (this.mockExists(file))
23
+ return
24
+ const { status } = parseFilename(file)
25
+ if (status === 500) {
26
+ this.#deleteTemp500()
27
+ if (this.temp500IsSelected)
28
+ this.updateFile(file)
29
+ }
30
+ this.mocks.push(file)
31
+ this.sortMocks()
32
+ }
33
+
34
+ #deleteTemp500() {
35
+ this.mocks = this.mocks.filter(file => !this.#isTemp500(file))
36
+ }
37
+
38
+ #registerTemp500() {
39
+ const { urlMask, method } = parseFilename(this.mocks[0])
40
+ const file = urlMask.replace(/^\//, '') // Removes leading slash
41
+ this.mocks.push(`${file}${DEFAULT_500_COMMENT}.${method}.500.empty`)
42
+ }
43
+
44
+ unregister(file) {
45
+ this.mocks = this.mocks.filter(f => f !== file)
46
+ const isEmpty = !this.mocks.length
47
+ || this.mocks.length === 1 && this.#isTemp500(this.mocks[0])
48
+ if (!isEmpty && this.file === file)
49
+ this.selectDefaultFile()
50
+ return isEmpty
51
+ }
22
52
 
23
53
  // Appending a '/' so URLs ending with variables don't match
24
54
  // URLs that have a path after that variable. For example,
@@ -34,21 +64,24 @@ export class MockBroker {
34
64
  get file() { return this.currentMock.file }
35
65
  get delay() { return this.currentMock.delay }
36
66
  get status() { return parseFilename(this.file).status }
37
- get isTemp500() { return includesComment(this.file, DEFAULT_500_COMMENT) }
67
+ get temp500IsSelected() { return this.#isTemp500(this.file) }
38
68
 
39
- selectDefaultFile() {
40
- const userSpecifiedDefault = this.#findMockWithDefaultComment()
41
- if (userSpecifiedDefault) // Sort for dashboard list
42
- this.mocks = [
43
- userSpecifiedDefault,
44
- ...this.mocks.filter(m => m !== userSpecifiedDefault)
45
- ]
46
- this.updateFile(userSpecifiedDefault || this.mocks[0])
69
+ #isTemp500(file) { return includesComment(file, DEFAULT_500_COMMENT) }
70
+
71
+ sortMocks() {
72
+ this.mocks.sort()
73
+ const defaults = this.mocks.filter(file => includesComment(file, DEFAULT_MOCK_COMMENT))
74
+ const temp500 = this.mocks.filter(file => includesComment(file, DEFAULT_500_COMMENT))
75
+ this.mocks = this.mocks.filter(file => !defaults.includes(file) && !temp500.includes(file))
76
+ this.mocks = [
77
+ ...defaults,
78
+ ...this.mocks,
79
+ ...temp500
80
+ ]
47
81
  }
48
- #findMockWithDefaultComment() {
49
- for (const f of this.mocks)
50
- if (includesComment(f, DEFAULT_MOCK_COMMENT))
51
- return f
82
+
83
+ selectDefaultFile() {
84
+ this.updateFile(this.mocks[0])
52
85
  }
53
86
 
54
87
  mockExists(file) { return this.mocks.includes(file) }
@@ -77,11 +110,6 @@ export class MockBroker {
77
110
  #has500() {
78
111
  return this.mocks.some(mock => parseFilename(mock).status === 500)
79
112
  }
80
- #registerTemp500() {
81
- const { urlMask, method } = parseFilename(this.mocks[0])
82
- const file = urlMask.replace(/^\//, '') // Removes leading slash
83
- this.register(`${file}${DEFAULT_500_COMMENT}.${method}.500.empty`)
84
- }
85
113
  }
86
114
 
87
115
  // Stars out (for regex) all the paths that are in square brackets
@@ -29,7 +29,7 @@ export async function dispatchMock(req, response) {
29
29
  for (let i = 0; i < config.extraHeaders.length; i += 2)
30
30
  response.setHeader(config.extraHeaders[i], config.extraHeaders[i + 1])
31
31
 
32
- const { mime, body } = broker.isTemp500
32
+ const { mime, body } = broker.temp500IsSelected
33
33
  ? { mime: '', body: '' }
34
34
  : await applyPlugins(join(config.mocksDir, broker.file), req, response)
35
35
 
package/src/Mockaton.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { join } from 'node:path'
1
2
  import { createServer } from 'node:http'
3
+ import { watch, existsSync } from 'node:fs'
2
4
 
3
5
  import { API } from './ApiConstants.js'
4
6
  import { dispatchMock } from './MockDispatcher.js'
@@ -13,6 +15,16 @@ import { apiPatchRequests, apiGetRequests } from './Api.js'
13
15
  export function Mockaton(options) {
14
16
  setup(options)
15
17
  mockBrokerCollection.init()
18
+
19
+ watch(config.mocksDir, { recursive: true, persistent: false }, (_, filename) => {
20
+ if (existsSync(join(config.mocksDir, filename))) {
21
+ const broker = mockBrokerCollection.registerMock(filename)
22
+ broker.ensureItHas500()
23
+ }
24
+ else
25
+ mockBrokerCollection.unregisterMock(filename)
26
+ })
27
+
16
28
  return createServer(onRequest).listen(config.port, config.host, function (error) {
17
29
  const { address, port } = this.address()
18
30
  const url = `http://${address}:${port}`
@@ -1,12 +1,11 @@
1
1
  import { tmpdir } from 'node:os'
2
- import { dirname, join } from 'node:path'
3
2
  import { promisify } from 'node:util'
4
3
  import { describe, it } from 'node:test'
5
4
  import { createServer } from 'node:http'
5
+ import { dirname, join } from 'node:path'
6
6
  import { randomUUID } from 'node:crypto'
7
7
  import { equal, deepEqual, match } from 'node:assert/strict'
8
- import { writeFileSync, mkdtempSync, mkdirSync } from 'node:fs'
9
-
8
+ import { writeFileSync, mkdtempSync, mkdirSync, unlinkSync } from 'node:fs'
10
9
 
11
10
  import { config } from './config.js'
12
11
  import { mimeFor } from './utils/mime.js'
@@ -21,7 +20,6 @@ import { API, DEFAULT_500_COMMENT, DEFAULT_MOCK_COMMENT } from './ApiConstants.j
21
20
 
22
21
  const tmpDir = mkdtempSync(tmpdir()) + '/'
23
22
  const staticTmpDir = mkdtempSync(tmpdir()) + '/'
24
- console.log(tmpDir)
25
23
 
26
24
  const fixtureCustomMime = [
27
25
  '/api/custom-mime',
@@ -44,6 +42,29 @@ const fixtureDelayed = [
44
42
  'Route_To_Be_Delayed'
45
43
  ]
46
44
 
45
+ /* Only fixtures with PUT */
46
+ const fixtureForRegisteringPutA = [
47
+ '/api/register',
48
+ 'api/register(a).PUT.200.json',
49
+ 'fixture_for_registering_a'
50
+ ]
51
+ const fixtureForRegisteringPutB = [
52
+ '/api/register',
53
+ 'api/register(b).PUT.200.json',
54
+ 'fixture_for_registering_b'
55
+ ]
56
+ const fixtureForRegisteringPutA500 = [
57
+ '/api/register',
58
+ 'api/register.PUT.500.json',
59
+ 'fixture_for_registering_500'
60
+ ]
61
+ const fixtureForUnregisteringPutC = [
62
+ '/api/unregister',
63
+ 'api/unregister.PUT.200.json',
64
+ 'fixture_for_unregistering'
65
+ ]
66
+
67
+
47
68
  const fixtures = [
48
69
  [
49
70
  '/api',
@@ -242,6 +263,8 @@ async function runTests() {
242
263
  await testCorsAllowed()
243
264
  testWindowsPaths()
244
265
 
266
+ await testRegistering()
267
+
245
268
  server.close()
246
269
  }
247
270
 
@@ -291,14 +314,82 @@ async function testMockDispatching(url, file, expectedBody, forcedMime = undefin
291
314
  async function testDefaultMock() {
292
315
  await testMockDispatching(...fixtureDefaultInName)
293
316
  await it('sorts mocks list with the user specified default first for dashboard display', async () => {
294
- const res = await commander.listMocks()
295
- const body = await res.json()
296
- const { mocks } = body.GET[fixtureDefaultInName[0]]
317
+ const body = await (await commander.listMocks()).json()
318
+ const { mocks } = body['GET'][fixtureDefaultInName[0]]
297
319
  equal(mocks[0], fixtureDefaultInName[1])
298
320
  equal(mocks[1], fixtureNonDefaultInName[1])
299
321
  })
300
322
  }
301
323
 
324
+ async function testRegistering() {
325
+ await describe('Registering', async () => {
326
+ const temp500 = `api/register${DEFAULT_500_COMMENT}.PUT.500.empty`
327
+
328
+ await it('registering new route creates temp 500 as well and re-registering is a noop', async () => {
329
+ write(fixtureForRegisteringPutA[1], '')
330
+ await sleep()
331
+ write(fixtureForRegisteringPutB[1], '')
332
+ await sleep()
333
+ write(fixtureForRegisteringPutA[1], '')
334
+ await sleep()
335
+ const collection = await (await commander.listMocks()).json()
336
+ deepEqual(collection['PUT'][fixtureForRegisteringPutA[0]].mocks, [
337
+ fixtureForRegisteringPutA[1],
338
+ fixtureForRegisteringPutB[1],
339
+ temp500
340
+ ])
341
+ })
342
+ await it('registering a 500 removes the temp 500 (and selects the new 500)', async () => {
343
+ await commander.select(temp500)
344
+ write(fixtureForRegisteringPutA500[1], '')
345
+ await sleep()
346
+ const collection = await (await commander.listMocks()).json()
347
+ const { mocks, currentMock } = collection['PUT'][fixtureForRegisteringPutA[0]]
348
+ deepEqual(mocks, [
349
+ fixtureForRegisteringPutA[1],
350
+ fixtureForRegisteringPutB[1],
351
+ fixtureForRegisteringPutA500[1]
352
+ ])
353
+ deepEqual(currentMock, {
354
+ file: fixtureForRegisteringPutA500[1],
355
+ delay: 0
356
+ })
357
+ })
358
+ await it('unregisters selected', async () => {
359
+ await commander.select(fixtureForRegisteringPutA[1])
360
+ remove(fixtureForRegisteringPutA[1])
361
+ await sleep()
362
+ const collection = await (await commander.listMocks()).json()
363
+ const { mocks, currentMock } = collection['PUT'][fixtureForRegisteringPutA[0]]
364
+ deepEqual(mocks, [
365
+ fixtureForRegisteringPutB[1],
366
+ fixtureForRegisteringPutA500[1]
367
+ ])
368
+ deepEqual(currentMock, {
369
+ file: fixtureForRegisteringPutB[1],
370
+ delay: 0
371
+ })
372
+ })
373
+ await it('unregistering the last mock removes broker', async () => {
374
+ write(fixtureForUnregisteringPutC[1], '') // Register another PUT so it doesn't delete PUT from collection
375
+ await sleep()
376
+ remove(fixtureForUnregisteringPutC[1])
377
+ await sleep()
378
+ const collection = await (await commander.listMocks()).json()
379
+ equal(collection['PUT'][fixtureForUnregisteringPutC[0]], undefined)
380
+ })
381
+
382
+ await it('unregistering the last PUT mock removes PUT from collection', async () => {
383
+ remove(fixtureForRegisteringPutB[1])
384
+ remove(fixtureForRegisteringPutA500[1])
385
+ await sleep()
386
+ const collection = await (await commander.listMocks()).json()
387
+ equal(collection['PUT'], undefined)
388
+ })
389
+ })
390
+ }
391
+
392
+
302
393
  async function testItUpdatesTheCurrentSelectedMock(url, file, expectedStatus, expectedBody) {
303
394
  await commander.select(file)
304
395
  const res = await request(url)
@@ -537,6 +628,10 @@ function write(filename, data) {
537
628
  _write(tmpDir + filename, data)
538
629
  }
539
630
 
631
+ function remove(filename) {
632
+ unlinkSync(tmpDir + filename)
633
+ }
634
+
540
635
  function writeStatic(filename, data) {
541
636
  _write(staticTmpDir + filename, data)
542
637
  }
@@ -546,3 +641,6 @@ function _write(absPath, data) {
546
641
  writeFileSync(absPath, data, 'utf8')
547
642
  }
548
643
 
644
+ async function sleep(ms = 50) {
645
+ return new Promise(resolve => setTimeout(resolve, ms))
646
+ }
@@ -21,39 +21,58 @@ export function init() {
21
21
  collection = {}
22
22
  cookie.init(config.cookies)
23
23
 
24
- const files = listFilesRecursively(config.mocksDir)
24
+ listFilesRecursively(config.mocksDir)
25
25
  .sort()
26
26
  .filter(f => !config.ignore.test(f) && filenameIsValid(f))
27
-
28
- for (const file of files) {
29
- const { method, urlMask } = parseFilename(file)
30
- collection[method] ??= {}
31
- if (!collection[method][urlMask])
32
- collection[method][urlMask] = new MockBroker(file)
33
- else
34
- collection[method][urlMask].register(file)
35
- }
27
+ .forEach(registerMock)
36
28
 
37
29
  forEachBroker(broker => {
38
- broker.selectDefaultFile()
39
30
  broker.ensureItHas500()
31
+ broker.selectDefaultFile()
40
32
  })
41
33
  }
42
34
 
35
+ /** @returns {MockBroker} */
36
+ export function registerMock(file) {
37
+ const { method, urlMask } = parseFilename(file)
38
+ collection[method] ??= {}
39
+ if (!collection[method][urlMask])
40
+ collection[method][urlMask] = new MockBroker(file)
41
+ else
42
+ collection[method][urlMask].register(file)
43
+ return collection[method][urlMask]
44
+ }
45
+
46
+ export function unregisterMock(file) {
47
+ const broker = getBrokerByFilename(file)
48
+ if (!broker)
49
+ return
50
+ const isEmpty = broker.unregister(file)
51
+ if (isEmpty) {
52
+ const { method, urlMask } = parseFilename(file)
53
+ delete collection[method][urlMask]
54
+ if (!Object.keys(collection[method]).length)
55
+ delete collection[method]
56
+ }
57
+ }
43
58
 
44
59
  export const getAll = () => collection
45
60
 
46
- export const getBrokerByFilename = file => {
61
+
62
+ /** @returns {MockBroker | undefined} */
63
+ export function getBrokerByFilename(file) {
47
64
  const { method, urlMask } = parseFilename(file)
48
65
  if (collection[method])
49
66
  return collection[method][urlMask]
50
67
  }
51
68
 
52
- // Searching the routes in reverse order so dynamic params (e.g.
53
- // /user/[id]) don’t take precedence over exact paths (e.g.
54
- // /user/name). Thats because "[]" chars are lower than alphanumeric ones.
55
- // BTW, `urlMasks` always start with "/", so there’s no need to
56
- // worry about the primacy of array-like keys when iterating.
69
+ /**
70
+ * Searching the routes in reverse order so dynamic params (e.g.
71
+ * /user/[id]) dont take precedence over exact paths (e.g.
72
+ * /user/name). That’s because "[]" chars are lower than alphanumeric ones.
73
+ * BTW, `urlMasks` always start with "/", so there’s no need to
74
+ * worry about the primacy of array-like keys when iterating.
75
+ @returns {MockBroker | undefined} */
57
76
  export function getBrokerForUrl(method, url) {
58
77
  if (!collection[method])
59
78
  return