mockaton 8.2.22 → 8.3.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 +46 -18
- package/index.d.ts +1 -0
- package/package.json +1 -1
- package/src/Api.js +16 -22
- package/src/ApiConstants.js +2 -0
- package/src/Commander.js +10 -11
- package/src/Config.js +2 -0
- package/src/Dashboard.js +2 -2
- package/src/Filename.js +9 -1
- package/src/MockDispatcherPlugins.js +0 -4
- package/src/ProxyRelay.js +13 -1
- package/src/utils/fs.js +12 -4
- package/src/utils/mime.js +20 -0
package/README.md
CHANGED
|
@@ -6,20 +6,17 @@
|
|
|
6
6
|
|
|
7
7
|
_Mockaton_ is a mock server for improving the frontend development and testing experience.
|
|
8
8
|
|
|
9
|
-
With Mockaton
|
|
9
|
+
With Mockaton you don’t need to write code for wiring your mocks. Instead, it
|
|
10
10
|
scans a given directory for filenames following a convention similar to the
|
|
11
11
|
URL paths. For example, the following file will be served on `/api/user/1234`
|
|
12
12
|
```
|
|
13
13
|
my-mocks-dir/api/user/[user-id].GET.200.json
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
By the way,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
Nonetheless, you don’t need to mock all your APIs. Mockaton
|
|
21
|
-
can request from your backend the routes you don’t have mocks for.
|
|
22
|
-
That’s done with `config.proxyFallback = 'http://mybackend'`
|
|
16
|
+
By the way, you don’t need to mock all your APIs. Mockaton can request
|
|
17
|
+
from your backend the routes you don’t have mocks for. That’s done
|
|
18
|
+
with `config.proxyFallback = 'http://mybackend'`. For convenience, you
|
|
19
|
+
can save mocks for those responses with `config.collectProxied = true`
|
|
23
20
|
|
|
24
21
|
## Multiple Mock Variants
|
|
25
22
|
Each route can have many mocks, which could either be:
|
|
@@ -37,7 +34,7 @@ which is handy for setting up tests (see **Commander API** below).
|
|
|
37
34
|
<picture>
|
|
38
35
|
<source media="(prefers-color-scheme: light)" srcset="./README-dashboard-light.png">
|
|
39
36
|
<source media="(prefers-color-scheme: dark)" srcset="./README-dashboard-dark.png">
|
|
40
|
-
<img alt="Mockaton Dashboard Demo" src="./README-dashboard-light.png"
|
|
37
|
+
<img alt="Mockaton Dashboard Demo" src="./README-dashboard-light.png">
|
|
41
38
|
</picture>
|
|
42
39
|
|
|
43
40
|
|
|
@@ -93,7 +90,7 @@ The _Reset_ button is for registering newly added, removed, or renamed mocks.
|
|
|
93
90
|
- Polled resources (for triggering their different states)
|
|
94
91
|
- alerts
|
|
95
92
|
- notifications
|
|
96
|
-
- slow to build
|
|
93
|
+
- slow to build resources
|
|
97
94
|
|
|
98
95
|
### Time Travel
|
|
99
96
|
If you commit the mocks to your repo, it’s straightforward to bisect bugs and
|
|
@@ -103,8 +100,8 @@ backends to old API contracts or databases.
|
|
|
103
100
|
### Deterministic Standalone Demo Server
|
|
104
101
|
Perhaps you need to demo your app, but the ideal flow is too complex to
|
|
105
102
|
simulate from the actual backend. In this case, compile your frontend app and
|
|
106
|
-
put its built assets in `config.staticDir`. Then,
|
|
107
|
-
|
|
103
|
+
put its built assets in `config.staticDir`. Then, on the dashboard
|
|
104
|
+
"Bulk Select" mocks to simulate the complete states you want to demo.
|
|
108
105
|
For bulk-selecting, you just need to add a comment to the mock
|
|
109
106
|
filename, such as `(demo-part1)`, `(demo-part2)`.
|
|
110
107
|
|
|
@@ -113,7 +110,7 @@ filename, such as `(demo-part1)`, `(demo-part2)`.
|
|
|
113
110
|
- Avoids spinning up and maintaining hefty backends when developing UIs.
|
|
114
111
|
- For a deterministic, comprehensive, and consistent backend state. For example, having
|
|
115
112
|
a collection with all the possible state variants helps for spotting inadvertent bugs.
|
|
116
|
-
- Sometimes
|
|
113
|
+
- Sometimes frontend progress is blocked waiting for some backend API. Similarly,
|
|
117
114
|
it’s often delayed due to missing data or inconvenient contracts. Therefore,
|
|
118
115
|
many meetings can be saved by prototyping frontend features with mocks, and
|
|
119
116
|
then showing those contracts to the backend team.
|
|
@@ -271,14 +268,40 @@ Defaults to `0`, which means auto-assigned
|
|
|
271
268
|
Defaults to `/(\.DS_Store|~)$/`
|
|
272
269
|
|
|
273
270
|
|
|
274
|
-
### `delay?: number`
|
|
275
|
-
|
|
271
|
+
### `delay?: number`
|
|
272
|
+
Routes can individually be delayed with the 🕓 checkbox. On the other hand,
|
|
273
|
+
the amount is globally configurable. It defaults to `config.delay=1200` milliseconds.
|
|
276
274
|
|
|
277
275
|
|
|
278
276
|
### `proxyFallback?: string`
|
|
279
277
|
Lets you specify a target server for serving routes you don’t have mocks for.
|
|
280
278
|
For example, `config.proxyFallback = 'http://example.com'`
|
|
281
279
|
|
|
280
|
+
### `collectProxied?: boolean`
|
|
281
|
+
Defaults to `false`. With this flag you can save mocks that hit
|
|
282
|
+
your proxy fallback to `config.mocksDir`. If there are UUIDv4 in the
|
|
283
|
+
URL the filename will have `[id]` in their place. For example,
|
|
284
|
+
|
|
285
|
+
```
|
|
286
|
+
/api/user/d14e09c8-d970-4b07-be42-b2f4ee22f0a6/likes =>
|
|
287
|
+
my-mocks-dir/api/user/[id]/likes.GET.200.json
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Note that newly saved mocks won’t be served until you
|
|
291
|
+
**register them** by reinitializing Mockaton or clicking "Reset".
|
|
292
|
+
|
|
293
|
+
Registered mocks won’t be overwritten (they don’t hit the fallback server).
|
|
294
|
+
On the other hand, newly saved mocks get overwritten while they are unregistered.
|
|
295
|
+
|
|
296
|
+
<details>
|
|
297
|
+
<summary>Extension Details</summary>
|
|
298
|
+
<p>
|
|
299
|
+
If you see an <code>.unknown</code> extension, that’s because the
|
|
300
|
+
<code>Content-Type</code> sent by your backend is either missing or not present
|
|
301
|
+
in the predefined list. For the latter, add it to <code>config.extraMimes</code>
|
|
302
|
+
</p>
|
|
303
|
+
</details>
|
|
304
|
+
|
|
282
305
|
|
|
283
306
|
### `staticDir?: string`
|
|
284
307
|
- Use Case 1: If you have a bunch of static assets you don’t want to add `.GET.200.ext`
|
|
@@ -349,9 +372,10 @@ type Plugin = (
|
|
|
349
372
|
body: string | Uint8Array
|
|
350
373
|
}>
|
|
351
374
|
```
|
|
352
|
-
Plugins are for processing mocks before sending them.
|
|
375
|
+
Plugins are for processing mocks before sending them. If no regex matches the filename,
|
|
376
|
+
it fallbacks to reading the file from disk and computing the MIME from the extension.
|
|
353
377
|
|
|
354
|
-
Note: don’t call `response.end()` on
|
|
378
|
+
Note: don’t call `response.end()` on any plugin.
|
|
355
379
|
|
|
356
380
|
<details>
|
|
357
381
|
<summary><b> See Plugin Examples </b></summary>
|
|
@@ -365,7 +389,11 @@ import { readFileSync } from 'node:js'
|
|
|
365
389
|
import { jsToJsonPlugin } from 'mockaton'
|
|
366
390
|
|
|
367
391
|
config.plugins = [
|
|
368
|
-
|
|
392
|
+
|
|
393
|
+
// Although `jsToJsonPlugin` is set by default, you need to add it to your list if you need it.
|
|
394
|
+
// In other words, your plugins array overwrites the default list. This way you can remove it.
|
|
395
|
+
[/\.(js|ts)$/, jsToJsonPlugin],
|
|
396
|
+
|
|
369
397
|
[/\.yml$/, yamlToJsonPlugin],
|
|
370
398
|
[/foo\.GET\.200\.txt$/, capitalizePlugin], // e.g. GET /api/foo would be capitalized
|
|
371
399
|
]
|
package/index.d.ts
CHANGED
package/package.json
CHANGED
package/src/Api.js
CHANGED
|
@@ -39,6 +39,8 @@ export const apiPatchRequests = new Map([
|
|
|
39
39
|
[API.cors, setCorsAllowed]
|
|
40
40
|
])
|
|
41
41
|
|
|
42
|
+
/* GET */
|
|
43
|
+
|
|
42
44
|
function serveDashboard(_, response) { sendFile(response, join(import.meta.dirname, 'Dashboard.html')) }
|
|
43
45
|
function serveDashboardAsset(req, response) { sendFile(response, join(import.meta.dirname, req.url)) }
|
|
44
46
|
|
|
@@ -48,13 +50,26 @@ function listMockBrokers(_, response) { sendJSON(response, mockBrokersCollection
|
|
|
48
50
|
function getProxyFallback(_, response) { sendJSON(response, Config.proxyFallback) }
|
|
49
51
|
function getIsCorsAllowed(_, response) { sendJSON(response, Config.corsAllowed) }
|
|
50
52
|
|
|
53
|
+
async function listStaticFiles(req, response) { // TESTME
|
|
54
|
+
try {
|
|
55
|
+
const files = Config.staticDir
|
|
56
|
+
? listFilesRecursively(Config.staticDir).filter(f => !Config.ignore.test(f))
|
|
57
|
+
: []
|
|
58
|
+
sendJSON(response, files)
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
sendBadRequest(response, error)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
/* PATCH */
|
|
51
67
|
|
|
52
68
|
function reinitialize(_, response) {
|
|
53
69
|
mockBrokersCollection.init()
|
|
54
70
|
sendOK(response)
|
|
55
71
|
}
|
|
56
72
|
|
|
57
|
-
|
|
58
73
|
async function selectCookie(req, response) {
|
|
59
74
|
try {
|
|
60
75
|
cookie.setCurrent(await parseJSON(req))
|
|
@@ -65,14 +80,12 @@ async function selectCookie(req, response) {
|
|
|
65
80
|
}
|
|
66
81
|
}
|
|
67
82
|
|
|
68
|
-
|
|
69
83
|
async function selectMock(req, response) {
|
|
70
84
|
try {
|
|
71
85
|
const file = await parseJSON(req)
|
|
72
86
|
const broker = mockBrokersCollection.getBrokerByFilename(file)
|
|
73
87
|
if (!broker || !broker.mockExists(file))
|
|
74
88
|
throw `Missing Mock: ${file}`
|
|
75
|
-
|
|
76
89
|
broker.updateFile(file)
|
|
77
90
|
sendOK(response)
|
|
78
91
|
}
|
|
@@ -81,17 +94,14 @@ async function selectMock(req, response) {
|
|
|
81
94
|
}
|
|
82
95
|
}
|
|
83
96
|
|
|
84
|
-
|
|
85
97
|
async function setRouteIsDelayed(req, response) {
|
|
86
98
|
try {
|
|
87
99
|
const body = await parseJSON(req)
|
|
88
100
|
const broker = mockBrokersCollection.getBrokerForUrl(
|
|
89
101
|
body[DF.routeMethod],
|
|
90
102
|
body[DF.routeUrlMask])
|
|
91
|
-
|
|
92
103
|
if (!broker)
|
|
93
104
|
throw `Route does not exist: ${body[DF.routeUrlMask]} ${body[DF.routeUrlMask]}`
|
|
94
|
-
|
|
95
105
|
broker.updateDelay(body[DF.delayed])
|
|
96
106
|
sendOK(response)
|
|
97
107
|
}
|
|
@@ -100,7 +110,6 @@ async function setRouteIsDelayed(req, response) {
|
|
|
100
110
|
}
|
|
101
111
|
}
|
|
102
112
|
|
|
103
|
-
|
|
104
113
|
async function updateProxyFallback(req, response) {
|
|
105
114
|
try {
|
|
106
115
|
const fallback = await parseJSON(req)
|
|
@@ -116,7 +125,6 @@ async function updateProxyFallback(req, response) {
|
|
|
116
125
|
}
|
|
117
126
|
}
|
|
118
127
|
|
|
119
|
-
|
|
120
128
|
async function bulkUpdateBrokersByCommentTag(req, response) {
|
|
121
129
|
try {
|
|
122
130
|
mockBrokersCollection.setMocksMatchingComment(await parseJSON(req))
|
|
@@ -127,7 +135,6 @@ async function bulkUpdateBrokersByCommentTag(req, response) {
|
|
|
127
135
|
}
|
|
128
136
|
}
|
|
129
137
|
|
|
130
|
-
|
|
131
138
|
async function setCorsAllowed(req, response) {
|
|
132
139
|
try {
|
|
133
140
|
Config.corsAllowed = await parseJSON(req)
|
|
@@ -138,16 +145,3 @@ async function setCorsAllowed(req, response) {
|
|
|
138
145
|
}
|
|
139
146
|
}
|
|
140
147
|
|
|
141
|
-
|
|
142
|
-
// TESTME
|
|
143
|
-
async function listStaticFiles(req, response) {
|
|
144
|
-
try {
|
|
145
|
-
const files = Config.staticDir
|
|
146
|
-
? listFilesRecursively(Config.staticDir).filter(f => !Config.ignore.test(f))
|
|
147
|
-
: []
|
|
148
|
-
sendJSON(response, files)
|
|
149
|
-
}
|
|
150
|
-
catch (error) {
|
|
151
|
-
sendBadRequest(response, error)
|
|
152
|
-
}
|
|
153
|
-
}
|
package/src/ApiConstants.js
CHANGED
package/src/Commander.js
CHANGED
|
@@ -7,6 +7,16 @@ export class Commander {
|
|
|
7
7
|
this.#addr = addr
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
#get(api) {
|
|
11
|
+
return fetch(this.#addr + api)
|
|
12
|
+
}
|
|
13
|
+
#patch(api, body) {
|
|
14
|
+
return fetch(this.#addr + api, {
|
|
15
|
+
method: 'PATCH',
|
|
16
|
+
body: JSON.stringify(body)
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
listMocks() {
|
|
11
21
|
return this.#get(API.mocks)
|
|
12
22
|
}
|
|
@@ -58,15 +68,4 @@ export class Commander {
|
|
|
58
68
|
listStaticFiles() {
|
|
59
69
|
return this.#get(API.static)
|
|
60
70
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
#get(api) {
|
|
64
|
-
return fetch(this.#addr + api)
|
|
65
|
-
}
|
|
66
|
-
#patch(api, body) {
|
|
67
|
-
return fetch(this.#addr + api, {
|
|
68
|
-
method: 'PATCH',
|
|
69
|
-
body: JSON.stringify(body)
|
|
70
|
-
})
|
|
71
|
-
}
|
|
72
71
|
}
|
package/src/Config.js
CHANGED
|
@@ -14,6 +14,7 @@ export const Config = Object.seal({
|
|
|
14
14
|
host: '127.0.0.1',
|
|
15
15
|
port: 0, // auto-assigned
|
|
16
16
|
proxyFallback: '', // e.g. http://localhost:9999
|
|
17
|
+
collectProxied: false,
|
|
17
18
|
|
|
18
19
|
delay: 1200, // milliseconds
|
|
19
20
|
cookies: {}, // defaults to the first kv
|
|
@@ -47,6 +48,7 @@ export function setup(options) {
|
|
|
47
48
|
host: is(String),
|
|
48
49
|
port: port => Number.isInteger(port) && port >= 0 && port < 2 ** 16,
|
|
49
50
|
proxyFallback: optional(URL.canParse),
|
|
51
|
+
collectProxied: is(Boolean),
|
|
50
52
|
|
|
51
53
|
delay: ms => Number.isInteger(ms) && ms > 0,
|
|
52
54
|
cookies: is(Object),
|
package/src/Dashboard.js
CHANGED
|
@@ -423,7 +423,7 @@ function createElement(elem, props = null, ...children) {
|
|
|
423
423
|
node[key] = value
|
|
424
424
|
else
|
|
425
425
|
node.setAttribute(key, value)
|
|
426
|
-
node.append(...children.flat())
|
|
426
|
+
node.append(...children.flat().filter(a => a))
|
|
427
427
|
return node
|
|
428
428
|
}
|
|
429
429
|
|
|
@@ -431,7 +431,7 @@ function createSvgElement(tagName, props, ...children) {
|
|
|
431
431
|
const elem = document.createElementNS('http://www.w3.org/2000/svg', tagName)
|
|
432
432
|
for (const [key, value] of Object.entries(props))
|
|
433
433
|
elem.setAttribute(key, value)
|
|
434
|
-
elem.append(...children.flat())
|
|
434
|
+
elem.append(...children.flat().filter(a => a))
|
|
435
435
|
return elem
|
|
436
436
|
}
|
|
437
437
|
|
package/src/Filename.js
CHANGED
|
@@ -44,7 +44,6 @@ export function parseFilename(file) {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
47
|
function removeTrailingSlash(url = '') {
|
|
49
48
|
return url
|
|
50
49
|
.replace(/\/$/, '')
|
|
@@ -59,5 +58,14 @@ function responseStatusIsValid(status) {
|
|
|
59
58
|
}
|
|
60
59
|
|
|
61
60
|
|
|
61
|
+
export function makeMockFilename(url, method, status, ext) {
|
|
62
|
+
const urlMask = replaceIds(removeTrailingSlash(url))
|
|
63
|
+
return [urlMask, method, status, ext].join('.')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
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
|
|
67
|
+
function replaceIds(filename) {
|
|
68
|
+
return filename.replaceAll(reUuidV4, '[id]')
|
|
69
|
+
}
|
|
62
70
|
|
|
63
71
|
|
|
@@ -7,10 +7,6 @@ export async function applyPlugins(filePath, req, response) {
|
|
|
7
7
|
for (const [regex, plugin] of Config.plugins) // TESTME capitalizePlugin
|
|
8
8
|
if (regex.test(filePath))
|
|
9
9
|
return await plugin(filePath, req, response)
|
|
10
|
-
return defaultPlugin(filePath)
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function defaultPlugin(filePath) {
|
|
14
10
|
return {
|
|
15
11
|
mime: mimeFor(filePath),
|
|
16
12
|
body: read(filePath)
|
package/src/ProxyRelay.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { write } from './utils/fs.js'
|
|
1
3
|
import { Config } from './Config.js'
|
|
4
|
+
import { extFor } from './utils/mime.js'
|
|
5
|
+
import { makeMockFilename } from './Filename.js'
|
|
2
6
|
|
|
3
7
|
|
|
4
8
|
export async function proxy(req, response) {
|
|
@@ -6,6 +10,14 @@ export async function proxy(req, response) {
|
|
|
6
10
|
method: req.method,
|
|
7
11
|
headers: req.headers
|
|
8
12
|
})
|
|
13
|
+
// TODO investigate how to include many repeated headers such as set-cookie
|
|
9
14
|
response.writeHead(proxyResponse.status, Object.fromEntries(proxyResponse.headers))
|
|
10
|
-
|
|
15
|
+
const body = await proxyResponse.text()
|
|
16
|
+
response.end(body)
|
|
17
|
+
|
|
18
|
+
if (Config.collectProxied) { // TESTME
|
|
19
|
+
const ext = extFor(proxyResponse.headers.get('content-type'))
|
|
20
|
+
const filename = makeMockFilename(req.url, req.method, proxyResponse.status, ext)
|
|
21
|
+
write(join(Config.mocksDir, filename), body)
|
|
22
|
+
}
|
|
11
23
|
}
|
package/src/utils/fs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path, { join } from 'node:path'
|
|
2
|
-
import { lstatSync, readFileSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { lstatSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
export const isFile = path => lstatSync(path, { throwIfNoEntry: false })?.isFile()
|
|
@@ -8,6 +8,14 @@ export const isDirectory = path => lstatSync(path, { throwIfNoEntry: false })?.i
|
|
|
8
8
|
export const read = path => readFileSync(path)
|
|
9
9
|
|
|
10
10
|
/** @returns {Array<string>} paths relative to `dir` */
|
|
11
|
-
export const listFilesRecursively = dir =>
|
|
12
|
-
.
|
|
13
|
-
.
|
|
11
|
+
export const listFilesRecursively = dir => {
|
|
12
|
+
const files = readdirSync(dir, { recursive: true }).filter(f => isFile(join(dir, f)))
|
|
13
|
+
return process.platform === 'win32'
|
|
14
|
+
? files.map(f => f.replaceAll(path.sep, path.posix.sep)) // TESTME
|
|
15
|
+
: files
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const write = (fPath, body) => {
|
|
19
|
+
mkdirSync(path.dirname(fPath), { recursive: true })
|
|
20
|
+
writeFileSync(fPath, body)
|
|
21
|
+
}
|
package/src/utils/mime.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Config } from '../Config.js'
|
|
2
|
+
import { EXT_FOR_UNKNOWN_MIME } from '../ApiConstants.js'
|
|
2
3
|
|
|
3
4
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
|
|
4
5
|
// m = {};
|
|
@@ -93,3 +94,22 @@ export function mimeFor(filename) {
|
|
|
93
94
|
console.info(`Missing MIME for ${filename}`)
|
|
94
95
|
return mime
|
|
95
96
|
}
|
|
97
|
+
|
|
98
|
+
export function extFor(mime) {
|
|
99
|
+
const ext = findExt(mime)
|
|
100
|
+
if (!ext) {
|
|
101
|
+
console.info(`Missing extension for ${mime}`)
|
|
102
|
+
return EXT_FOR_UNKNOWN_MIME
|
|
103
|
+
}
|
|
104
|
+
return ext
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function findExt(targetMime) {
|
|
108
|
+
for (const [ext, mime] of Object.entries(Config.extraMimes))
|
|
109
|
+
if (targetMime === mime)
|
|
110
|
+
return ext
|
|
111
|
+
for (const [ext, mime] of Object.entries(mimes))
|
|
112
|
+
if (targetMime === mime)
|
|
113
|
+
return ext
|
|
114
|
+
return ''
|
|
115
|
+
}
|