mockaton 8.3.2 → 8.3.4
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 +8 -5
- package/index.d.ts +1 -2
- package/package.json +1 -1
- package/src/Api.js +27 -13
- package/src/ApiConstants.js +1 -1
- package/src/Commander.js +4 -0
- package/src/Dashboard.css +31 -4
- package/src/Dashboard.js +0 -1
- package/src/MockBroker.js +5 -5
- package/src/MockDispatcher.js +5 -5
- package/src/MockDispatcherPlugins.js +2 -2
- package/src/Mockaton.js +10 -10
- package/src/Mockaton.test.js +85 -20
- package/src/ProxyRelay.js +4 -4
- package/src/StaticDispatcher.js +4 -6
- package/src/{Config.js → config.js} +16 -22
- package/src/cookie.js +1 -1
- package/src/mockBrokersCollection.js +4 -4
- package/src/utils/fs.js +6 -6
- package/src/utils/http-cors.js +22 -16
- package/src/utils/mime.js +3 -3
- package/src/utils/validate.js +0 -8
package/README.md
CHANGED
|
@@ -13,10 +13,13 @@ URL paths. For example, the following file will be served on `/api/user/1234`
|
|
|
13
13
|
my-mocks-dir/api/user/[user-id].GET.200.json
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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:
|
|
19
|
+
|
|
20
|
+
`config.proxyFallback = 'http://mybackend'`
|
|
21
|
+
|
|
22
|
+
`config.collectProxied = true` lets you save those responses as mocks following the filename convention.
|
|
20
23
|
|
|
21
24
|
## Multiple Mock Variants
|
|
22
25
|
Each route can have many mocks, which could either be:
|
|
@@ -420,7 +423,7 @@ function capitalizePlugin(filePath) {
|
|
|
420
423
|
|
|
421
424
|
|
|
422
425
|
### `corsAllowed?: boolean`
|
|
423
|
-
Defaults to `
|
|
426
|
+
Defaults to `true`. When `true`, these are the default options:
|
|
424
427
|
```js
|
|
425
428
|
config.corsOrigins = ['*']
|
|
426
429
|
config.corsMethods = ['GET', 'PUT', 'DELETE', 'POST', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT']
|
package/index.d.ts
CHANGED
package/package.json
CHANGED
package/src/Api.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { join } from 'node:path'
|
|
7
7
|
import { cookie } from './cookie.js'
|
|
8
|
-
import {
|
|
8
|
+
import { config } from './config.js'
|
|
9
9
|
import { DF, API } from './ApiConstants.js'
|
|
10
10
|
import { parseJSON } from './utils/http-request.js'
|
|
11
11
|
import { listFilesRecursively } from './utils/fs.js'
|
|
@@ -35,11 +35,12 @@ export const apiPatchRequests = new Map([
|
|
|
35
35
|
[API.reset, reinitialize],
|
|
36
36
|
[API.cookies, selectCookie],
|
|
37
37
|
[API.fallback, updateProxyFallback],
|
|
38
|
+
[API.collectProxied, setCollectProxied],
|
|
38
39
|
[API.bulkSelect, bulkUpdateBrokersByCommentTag],
|
|
39
40
|
[API.cors, setCorsAllowed]
|
|
40
41
|
])
|
|
41
42
|
|
|
42
|
-
/* GET */
|
|
43
|
+
/* === GET === */
|
|
43
44
|
|
|
44
45
|
function serveDashboard(_, response) { sendFile(response, join(import.meta.dirname, 'Dashboard.html')) }
|
|
45
46
|
function serveDashboardAsset(req, response) { sendFile(response, join(import.meta.dirname, req.url)) }
|
|
@@ -47,13 +48,13 @@ function serveDashboardAsset(req, response) { sendFile(response, join(import.met
|
|
|
47
48
|
function listCookies(_, response) { sendJSON(response, cookie.list()) }
|
|
48
49
|
function listComments(_, response) { sendJSON(response, mockBrokersCollection.extractAllComments()) }
|
|
49
50
|
function listMockBrokers(_, response) { sendJSON(response, mockBrokersCollection.getAll()) }
|
|
50
|
-
function getProxyFallback(_, response) { sendJSON(response,
|
|
51
|
-
function getIsCorsAllowed(_, response) { sendJSON(response,
|
|
51
|
+
function getProxyFallback(_, response) { sendJSON(response, config.proxyFallback) }
|
|
52
|
+
function getIsCorsAllowed(_, response) { sendJSON(response, config.corsAllowed) }
|
|
52
53
|
|
|
53
|
-
|
|
54
|
+
function listStaticFiles(req, response) {
|
|
54
55
|
try {
|
|
55
|
-
const files =
|
|
56
|
-
? listFilesRecursively(
|
|
56
|
+
const files = config.staticDir
|
|
57
|
+
? listFilesRecursively(config.staticDir).filter(f => !config.ignore.test(f))
|
|
57
58
|
: []
|
|
58
59
|
sendJSON(response, files)
|
|
59
60
|
}
|
|
@@ -63,7 +64,7 @@ async function listStaticFiles(req, response) { // TESTME
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
|
|
66
|
-
/* PATCH */
|
|
67
|
+
/* === PATCH === */
|
|
67
68
|
|
|
68
69
|
function reinitialize(_, response) {
|
|
69
70
|
mockBrokersCollection.init()
|
|
@@ -72,8 +73,11 @@ function reinitialize(_, response) {
|
|
|
72
73
|
|
|
73
74
|
async function selectCookie(req, response) {
|
|
74
75
|
try {
|
|
75
|
-
cookie.setCurrent(await parseJSON(req))
|
|
76
|
-
|
|
76
|
+
const error = cookie.setCurrent(await parseJSON(req))
|
|
77
|
+
if (error)
|
|
78
|
+
sendUnprocessableContent(response, error)
|
|
79
|
+
else
|
|
80
|
+
sendOK(response)
|
|
77
81
|
}
|
|
78
82
|
catch (error) {
|
|
79
83
|
sendBadRequest(response, error)
|
|
@@ -113,10 +117,10 @@ async function setRouteIsDelayed(req, response) {
|
|
|
113
117
|
async function updateProxyFallback(req, response) {
|
|
114
118
|
try {
|
|
115
119
|
const fallback = await parseJSON(req)
|
|
116
|
-
if (fallback && !URL.canParse(fallback))
|
|
120
|
+
if (fallback && !URL.canParse(fallback))
|
|
117
121
|
sendUnprocessableContent(response)
|
|
118
122
|
else {
|
|
119
|
-
|
|
123
|
+
config.proxyFallback = fallback
|
|
120
124
|
sendOK(response)
|
|
121
125
|
}
|
|
122
126
|
}
|
|
@@ -125,6 +129,16 @@ async function updateProxyFallback(req, response) {
|
|
|
125
129
|
}
|
|
126
130
|
}
|
|
127
131
|
|
|
132
|
+
async function setCollectProxied(req, response) {
|
|
133
|
+
try {
|
|
134
|
+
config.collectProxied = await parseJSON(req)
|
|
135
|
+
sendOK(response)
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
sendBadRequest(response, error)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
128
142
|
async function bulkUpdateBrokersByCommentTag(req, response) {
|
|
129
143
|
try {
|
|
130
144
|
mockBrokersCollection.setMocksMatchingComment(await parseJSON(req))
|
|
@@ -137,7 +151,7 @@ async function bulkUpdateBrokersByCommentTag(req, response) {
|
|
|
137
151
|
|
|
138
152
|
async function setCorsAllowed(req, response) {
|
|
139
153
|
try {
|
|
140
|
-
|
|
154
|
+
config.corsAllowed = await parseJSON(req)
|
|
141
155
|
sendOK(response)
|
|
142
156
|
}
|
|
143
157
|
catch (error) {
|
package/src/ApiConstants.js
CHANGED
|
@@ -9,6 +9,7 @@ export const API = {
|
|
|
9
9
|
reset: MOUNT + '/reset',
|
|
10
10
|
cookies: MOUNT + '/cookies',
|
|
11
11
|
fallback: MOUNT + '/fallback',
|
|
12
|
+
collectProxied: MOUNT + '/collect-proxied',
|
|
12
13
|
cors: MOUNT + '/cors',
|
|
13
14
|
static: MOUNT + '/static'
|
|
14
15
|
}
|
|
@@ -21,5 +22,4 @@ export const DF = { // Dashboard Fields (XHR)
|
|
|
21
22
|
|
|
22
23
|
export const DEFAULT_500_COMMENT = '(Mockaton 500)'
|
|
23
24
|
export const DEFAULT_MOCK_COMMENT = '(default)'
|
|
24
|
-
|
|
25
25
|
export const EXT_FOR_UNKNOWN_MIME = 'unknown'
|
package/src/Commander.js
CHANGED
package/src/Dashboard.css
CHANGED
|
@@ -54,6 +54,13 @@ body {
|
|
|
54
54
|
margin: 0;
|
|
55
55
|
font-family: system-ui, sans-serif;
|
|
56
56
|
font-size: 100%;
|
|
57
|
+
outline: 0
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
select, a, input, button, summary {
|
|
61
|
+
&:focus-visible {
|
|
62
|
+
outline: 2px solid var(--colorAccent);
|
|
63
|
+
}
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
select {
|
|
@@ -116,7 +123,6 @@ menu {
|
|
|
116
123
|
}
|
|
117
124
|
|
|
118
125
|
input[type=url] {
|
|
119
|
-
outline: 0;
|
|
120
126
|
padding: 0 6px;
|
|
121
127
|
box-shadow: var(--boxShadow1);
|
|
122
128
|
color: var(--colorText);
|
|
@@ -244,7 +250,14 @@ main {
|
|
|
244
250
|
cursor: pointer;
|
|
245
251
|
|
|
246
252
|
> input {
|
|
247
|
-
|
|
253
|
+
appearance: none;
|
|
254
|
+
|
|
255
|
+
&:focus-visible {
|
|
256
|
+
outline: 0;
|
|
257
|
+
& ~ svg {
|
|
258
|
+
outline: 2px solid var(--colorAccent)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
248
261
|
|
|
249
262
|
&:checked ~ svg {
|
|
250
263
|
background: var(--colorAccent);
|
|
@@ -274,7 +287,14 @@ main {
|
|
|
274
287
|
cursor: pointer;
|
|
275
288
|
|
|
276
289
|
> input {
|
|
277
|
-
|
|
290
|
+
appearance: none;
|
|
291
|
+
|
|
292
|
+
&:focus-visible {
|
|
293
|
+
outline: 0;
|
|
294
|
+
& ~ span {
|
|
295
|
+
outline: 2px solid var(--colorAccent)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
278
298
|
|
|
279
299
|
&:checked ~ span {
|
|
280
300
|
color: white;
|
|
@@ -331,18 +351,25 @@ main {
|
|
|
331
351
|
margin-top: 40px;
|
|
332
352
|
|
|
333
353
|
summary {
|
|
354
|
+
width: max-content;
|
|
334
355
|
margin-bottom: 8px;
|
|
335
356
|
cursor: pointer;
|
|
336
357
|
font-weight: bold;
|
|
337
358
|
}
|
|
338
359
|
|
|
360
|
+
ul {
|
|
361
|
+
position: relative;
|
|
362
|
+
left: -6px;
|
|
363
|
+
}
|
|
364
|
+
|
|
339
365
|
li {
|
|
340
366
|
list-style: none;
|
|
341
367
|
}
|
|
342
368
|
|
|
343
369
|
a {
|
|
344
370
|
display: inline-block;
|
|
345
|
-
|
|
371
|
+
border-radius: 6px;
|
|
372
|
+
padding: 6px;
|
|
346
373
|
color: var(--colorAccentAlt);
|
|
347
374
|
text-decoration: none;
|
|
348
375
|
|
package/src/Dashboard.js
CHANGED
package/src/MockBroker.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { config } from './config.js'
|
|
2
2
|
import { DEFAULT_500_COMMENT, DEFAULT_MOCK_COMMENT } from './ApiConstants.js'
|
|
3
3
|
import { includesComment, extractComments, parseFilename } from './Filename.js'
|
|
4
4
|
|
|
@@ -38,7 +38,7 @@ export class MockBroker {
|
|
|
38
38
|
|
|
39
39
|
selectDefaultFile() {
|
|
40
40
|
const userSpecifiedDefault = this.#findMockWithDefaultComment()
|
|
41
|
-
if (userSpecifiedDefault) // Sort for dashboard list
|
|
41
|
+
if (userSpecifiedDefault) // Sort for dashboard list
|
|
42
42
|
this.mocks = [
|
|
43
43
|
userSpecifiedDefault,
|
|
44
44
|
...this.mocks.filter(m => m !== userSpecifiedDefault)
|
|
@@ -53,7 +53,7 @@ export class MockBroker {
|
|
|
53
53
|
|
|
54
54
|
mockExists(file) { return this.mocks.includes(file) }
|
|
55
55
|
updateFile(filename) { this.currentMock.file = filename }
|
|
56
|
-
updateDelay(delayed) { this.currentMock.delay = Number(delayed) *
|
|
56
|
+
updateDelay(delayed) { this.currentMock.delay = Number(delayed) * config.delay }
|
|
57
57
|
|
|
58
58
|
setByMatchingComment(comment) {
|
|
59
59
|
for (const file of this.mocks)
|
|
@@ -79,8 +79,8 @@ export class MockBroker {
|
|
|
79
79
|
}
|
|
80
80
|
#registerTemp500() {
|
|
81
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.
|
|
82
|
+
const file = urlMask.replace(/^\//, '') // Removes leading slash
|
|
83
|
+
this.register(`${file}${DEFAULT_500_COMMENT}.${method}.500.empty`)
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
package/src/MockDispatcher.js
CHANGED
|
@@ -2,7 +2,7 @@ import { join } from 'node:path'
|
|
|
2
2
|
|
|
3
3
|
import { proxy } from './ProxyRelay.js'
|
|
4
4
|
import { cookie } from './cookie.js'
|
|
5
|
-
import {
|
|
5
|
+
import { config } from './config.js'
|
|
6
6
|
import { applyPlugins } from './MockDispatcherPlugins.js'
|
|
7
7
|
import * as mockBrokerCollection from './mockBrokersCollection.js'
|
|
8
8
|
import { BodyReaderError } from './utils/http-request.js'
|
|
@@ -13,7 +13,7 @@ export async function dispatchMock(req, response) {
|
|
|
13
13
|
try {
|
|
14
14
|
const broker = mockBrokerCollection.getBrokerForUrl(req.method, req.url)
|
|
15
15
|
if (!broker) {
|
|
16
|
-
if (
|
|
16
|
+
if (config.proxyFallback)
|
|
17
17
|
await proxy(req, response)
|
|
18
18
|
else
|
|
19
19
|
sendNotFound(response)
|
|
@@ -26,12 +26,12 @@ export async function dispatchMock(req, response) {
|
|
|
26
26
|
if (cookie.getCurrent())
|
|
27
27
|
response.setHeader('Set-Cookie', cookie.getCurrent())
|
|
28
28
|
|
|
29
|
-
for (let i = 0; i <
|
|
30
|
-
response.setHeader(
|
|
29
|
+
for (let i = 0; i < config.extraHeaders.length; i += 2)
|
|
30
|
+
response.setHeader(config.extraHeaders[i], config.extraHeaders[i + 1])
|
|
31
31
|
|
|
32
32
|
const { mime, body } = broker.isTemp500
|
|
33
33
|
? { mime: '', body: '' }
|
|
34
|
-
: await applyPlugins(join(
|
|
34
|
+
: await applyPlugins(join(config.mocksDir, broker.file), req, response)
|
|
35
35
|
|
|
36
36
|
response.setHeader('Content-Type', mime)
|
|
37
37
|
setTimeout(() => response.end(body), broker.delay)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { readFileSync as read } from 'node:fs'
|
|
2
2
|
import { mimeFor } from './utils/mime.js'
|
|
3
|
-
import {
|
|
3
|
+
import { config } from './config.js'
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
export async function applyPlugins(filePath, req, response) {
|
|
7
|
-
for (const [regex, plugin] of
|
|
7
|
+
for (const [regex, plugin] of config.plugins)
|
|
8
8
|
if (regex.test(filePath))
|
|
9
9
|
return await plugin(filePath, req, response)
|
|
10
10
|
return {
|
package/src/Mockaton.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createServer } from 'node:http'
|
|
|
2
2
|
|
|
3
3
|
import { API } from './ApiConstants.js'
|
|
4
4
|
import { dispatchMock } from './MockDispatcher.js'
|
|
5
|
-
import {
|
|
5
|
+
import { config, setup } from './config.js'
|
|
6
6
|
import { sendNoContent } from './utils/http-response.js'
|
|
7
7
|
import * as mockBrokerCollection from './mockBrokersCollection.js'
|
|
8
8
|
import { dispatchStatic, isStatic } from './StaticDispatcher.js'
|
|
@@ -13,7 +13,7 @@ import { apiPatchRequests, apiGetRequests } from './Api.js'
|
|
|
13
13
|
export function Mockaton(options) {
|
|
14
14
|
setup(options)
|
|
15
15
|
mockBrokerCollection.init()
|
|
16
|
-
return createServer(onRequest).listen(
|
|
16
|
+
return createServer(onRequest).listen(config.port, config.host, function (error) {
|
|
17
17
|
const { address, port } = this.address()
|
|
18
18
|
const url = `http://${address}:${port}`
|
|
19
19
|
console.log('Listening', url)
|
|
@@ -21,21 +21,21 @@ export function Mockaton(options) {
|
|
|
21
21
|
if (error)
|
|
22
22
|
console.error(error)
|
|
23
23
|
else
|
|
24
|
-
|
|
24
|
+
config.onReady(url + API.dashboard)
|
|
25
25
|
})
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
async function onRequest(req, response) {
|
|
29
29
|
response.setHeader('Server', 'Mockaton')
|
|
30
30
|
|
|
31
|
-
if (
|
|
31
|
+
if (config.corsAllowed)
|
|
32
32
|
setCorsHeaders(req, response, {
|
|
33
|
-
origins:
|
|
34
|
-
headers:
|
|
35
|
-
methods:
|
|
36
|
-
maxAge:
|
|
37
|
-
credentials:
|
|
38
|
-
exposedHeaders:
|
|
33
|
+
origins: config.corsOrigins,
|
|
34
|
+
headers: config.corsHeaders,
|
|
35
|
+
methods: config.corsMethods,
|
|
36
|
+
maxAge: config.corsMaxAge,
|
|
37
|
+
credentials: config.corsCredentials,
|
|
38
|
+
exposedHeaders: config.corsExposedHeaders
|
|
39
39
|
})
|
|
40
40
|
|
|
41
41
|
const { url, method } = req
|
package/src/Mockaton.test.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import { tmpdir } from 'node:os'
|
|
2
|
-
import { dirname } from 'node:path'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
3
|
import { promisify } from 'node:util'
|
|
4
4
|
import { describe, it } from 'node:test'
|
|
5
5
|
import { createServer } from 'node:http'
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
6
7
|
import { equal, deepEqual, match } from 'node:assert/strict'
|
|
7
8
|
import { writeFileSync, mkdtempSync, mkdirSync } from 'node:fs'
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
|
|
11
|
+
import { config } from './config.js'
|
|
10
12
|
import { mimeFor } from './utils/mime.js'
|
|
11
13
|
import { Mockaton } from './Mockaton.js'
|
|
12
14
|
import { readBody } from './utils/http-request.js'
|
|
13
15
|
import { Commander } from './Commander.js'
|
|
14
|
-
import { parseFilename } from './Filename.js'
|
|
15
16
|
import { CorsHeader } from './utils/http-cors.js'
|
|
17
|
+
import { parseFilename } from './Filename.js'
|
|
18
|
+
import { listFilesRecursively, read } from './utils/fs.js'
|
|
16
19
|
import { API, DEFAULT_500_COMMENT, DEFAULT_MOCK_COMMENT } from './ApiConstants.js'
|
|
17
20
|
|
|
18
21
|
|
|
@@ -27,7 +30,7 @@ const fixtureCustomMime = [
|
|
|
27
30
|
]
|
|
28
31
|
const fixtureNonDefaultInName = [
|
|
29
32
|
'/api/the-route',
|
|
30
|
-
'api/the-route
|
|
33
|
+
'api/the-route.GET.200.json',
|
|
31
34
|
'default my route body content'
|
|
32
35
|
]
|
|
33
36
|
const fixtureDefaultInName = [
|
|
@@ -138,11 +141,14 @@ write('api/ignored.GET.200.json~', '')
|
|
|
138
141
|
// JavaScript to JSON
|
|
139
142
|
write('/api/object.GET.200.js', 'export default { JSON_FROM_JS: true }')
|
|
140
143
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
const staticFiles = [
|
|
145
|
+
['index.html', '<h1>Static</h1>'],
|
|
146
|
+
['assets/app.js', 'const app = 1'],
|
|
147
|
+
['another-entry/index.html', '<h1>Another</h1>']
|
|
148
|
+
]
|
|
149
|
+
writeStatic('ignored.js~', 'ignored_file_body')
|
|
150
|
+
for (const [file, body] of staticFiles)
|
|
151
|
+
writeStatic(file, body)
|
|
146
152
|
|
|
147
153
|
const server = Mockaton({
|
|
148
154
|
mocksDir: tmpDir,
|
|
@@ -157,7 +163,8 @@ const server = Mockaton({
|
|
|
157
163
|
extraMimes: {
|
|
158
164
|
my_custom_extension: 'my_custom_mime'
|
|
159
165
|
},
|
|
160
|
-
corsOrigins: ['http://example.com']
|
|
166
|
+
corsOrigins: ['http://example.com'],
|
|
167
|
+
corsExposedHeaders: ['Content-Encoding']
|
|
161
168
|
})
|
|
162
169
|
server.on('listening', runTests)
|
|
163
170
|
|
|
@@ -187,7 +194,7 @@ async function runTests() {
|
|
|
187
194
|
|
|
188
195
|
await testAutogenerates500(
|
|
189
196
|
'/api/alternative',
|
|
190
|
-
`api/alternative${DEFAULT_500_COMMENT}.GET.500.
|
|
197
|
+
`api/alternative${DEFAULT_500_COMMENT}.GET.500.empty`)
|
|
191
198
|
|
|
192
199
|
await testPreservesExiting500(
|
|
193
200
|
'/api',
|
|
@@ -226,11 +233,14 @@ async function runTests() {
|
|
|
226
233
|
await testMockDispatching(...fixtureCustomMime, 'my_custom_mime')
|
|
227
234
|
await testJsFunctionMocks()
|
|
228
235
|
|
|
229
|
-
await
|
|
236
|
+
await testItUpdatesCookie()
|
|
230
237
|
await testStaticFileServing()
|
|
238
|
+
await testStaticFileList()
|
|
231
239
|
await testInvalidFilenamesAreIgnored()
|
|
232
240
|
await testEnableFallbackSoRoutesWithoutMocksGetRelayed()
|
|
241
|
+
await testValidatesProxyFallbackURL()
|
|
233
242
|
await testCorsAllowed()
|
|
243
|
+
testWindowsPaths()
|
|
234
244
|
|
|
235
245
|
server.close()
|
|
236
246
|
}
|
|
@@ -256,6 +266,10 @@ async function test404() {
|
|
|
256
266
|
const res = await request('/api/ignored')
|
|
257
267
|
equal(res.status, 404)
|
|
258
268
|
})
|
|
269
|
+
await it('Ignores static files ending in ~ by default, e.g. JetBrains temp files', async () => {
|
|
270
|
+
const res = await request('/ignored.js~')
|
|
271
|
+
equal(res.status, 404)
|
|
272
|
+
})
|
|
259
273
|
}
|
|
260
274
|
|
|
261
275
|
async function testMockDispatching(url, file, expectedBody, forcedMime = undefined) {
|
|
@@ -276,6 +290,13 @@ async function testMockDispatching(url, file, expectedBody, forcedMime = undefin
|
|
|
276
290
|
|
|
277
291
|
async function testDefaultMock() {
|
|
278
292
|
await testMockDispatching(...fixtureDefaultInName)
|
|
293
|
+
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]]
|
|
297
|
+
equal(mocks[0], fixtureDefaultInName[1])
|
|
298
|
+
equal(mocks[1], fixtureNonDefaultInName[1])
|
|
299
|
+
})
|
|
279
300
|
}
|
|
280
301
|
|
|
281
302
|
async function testItUpdatesTheCurrentSelectedMock(url, file, expectedStatus, expectedBody) {
|
|
@@ -296,7 +317,7 @@ async function testItUpdatesRouteDelay(url, file, expectedBody) {
|
|
|
296
317
|
const body = await res.text()
|
|
297
318
|
await describe('url: ' + url, () => {
|
|
298
319
|
it('body is: ' + expectedBody, () => equal(body, JSON.stringify(expectedBody)))
|
|
299
|
-
it('delay', () => equal((new Date()).getTime() - now.getTime() >
|
|
320
|
+
it('delay', () => equal((new Date()).getTime() - now.getTime() > config.delay, true))
|
|
300
321
|
})
|
|
301
322
|
}
|
|
302
323
|
|
|
@@ -343,7 +364,7 @@ async function testItBulkSelectsByComment(comment, tests) {
|
|
|
343
364
|
}
|
|
344
365
|
|
|
345
366
|
|
|
346
|
-
async function
|
|
367
|
+
async function testItUpdatesCookie() {
|
|
347
368
|
await describe('Cookie', () => {
|
|
348
369
|
it('Defaults to the first key:value', async () => {
|
|
349
370
|
const res = await commander.listCookies()
|
|
@@ -353,7 +374,7 @@ async function testItUpdatesUserRole() {
|
|
|
353
374
|
])
|
|
354
375
|
})
|
|
355
376
|
|
|
356
|
-
it('
|
|
377
|
+
it('Updates selected cookie', async () => {
|
|
357
378
|
await commander.selectCookie('userB')
|
|
358
379
|
const res = await commander.listCookies()
|
|
359
380
|
deepEqual(await res.json(), [
|
|
@@ -361,6 +382,11 @@ async function testItUpdatesUserRole() {
|
|
|
361
382
|
['userB', true]
|
|
362
383
|
])
|
|
363
384
|
})
|
|
385
|
+
|
|
386
|
+
it('422 when trying to select non-existing cookie', async () => {
|
|
387
|
+
const res = await commander.selectCookie('non-existing-cookie-key')
|
|
388
|
+
equal(res.status, 422)
|
|
389
|
+
})
|
|
364
390
|
})
|
|
365
391
|
}
|
|
366
392
|
|
|
@@ -405,6 +431,13 @@ async function testStaticFileServing() {
|
|
|
405
431
|
})
|
|
406
432
|
}
|
|
407
433
|
|
|
434
|
+
async function testStaticFileList() {
|
|
435
|
+
await it('Static File List', async () => {
|
|
436
|
+
const res = await commander.listStaticFiles()
|
|
437
|
+
deepEqual((await res.json()).sort(), staticFiles.map(([file]) => file).sort())
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
|
|
408
441
|
async function testInvalidFilenamesAreIgnored() {
|
|
409
442
|
await it('Invalid filenames get skipped, so they don’t crash the server', async (t) => {
|
|
410
443
|
const consoleErrorSpy = t.mock.method(console, 'error')
|
|
@@ -425,6 +458,7 @@ async function testEnableFallbackSoRoutesWithoutMocksGetRelayed() {
|
|
|
425
458
|
const fallbackServer = createServer(async (req, response) => {
|
|
426
459
|
response.writeHead(423, {
|
|
427
460
|
'custom_header': 'my_custom_header',
|
|
461
|
+
'content-type': mimeFor('txt'),
|
|
428
462
|
'set-cookie': [
|
|
429
463
|
'cookieA=A',
|
|
430
464
|
'cookieB=B'
|
|
@@ -435,22 +469,36 @@ async function testEnableFallbackSoRoutesWithoutMocksGetRelayed() {
|
|
|
435
469
|
await promisify(fallbackServer.listen).bind(fallbackServer, 0, '127.0.0.1')()
|
|
436
470
|
|
|
437
471
|
await commander.setProxyFallback(`http://localhost:${fallbackServer.address().port}`)
|
|
438
|
-
await
|
|
439
|
-
|
|
472
|
+
await commander.setCollectProxied(true)
|
|
473
|
+
await it('Relays to fallback server and saves the mock', async () => {
|
|
474
|
+
const reqBodyPayload = 'text_req_body'
|
|
475
|
+
|
|
476
|
+
const res = await request(`/api/non-existing-mock/${randomUUID()}`, {
|
|
440
477
|
method: 'POST',
|
|
441
|
-
body:
|
|
478
|
+
body: reqBodyPayload
|
|
442
479
|
})
|
|
443
480
|
equal(res.status, 423)
|
|
444
481
|
equal(res.headers.get('custom_header'), 'my_custom_header')
|
|
445
482
|
equal(res.headers.get('set-cookie'), ['cookieA=A', 'cookieB=B'].join(', '))
|
|
446
|
-
equal(await res.text(),
|
|
483
|
+
equal(await res.text(), reqBodyPayload)
|
|
484
|
+
|
|
485
|
+
const savedBody = read(join(tmpDir, 'api/non-existing-mock/[id].POST.423.txt'))
|
|
486
|
+
equal(savedBody, reqBodyPayload)
|
|
487
|
+
|
|
447
488
|
fallbackServer.close()
|
|
448
489
|
})
|
|
449
490
|
})
|
|
450
491
|
}
|
|
451
492
|
|
|
493
|
+
async function testValidatesProxyFallbackURL() {
|
|
494
|
+
await it('422 when value is not a valid URL', async () => {
|
|
495
|
+
const res = await commander.setProxyFallback('bad url')
|
|
496
|
+
equal(res.status, 422)
|
|
497
|
+
})
|
|
498
|
+
}
|
|
499
|
+
|
|
452
500
|
async function testCorsAllowed() {
|
|
453
|
-
await it('cors', async () => {
|
|
501
|
+
await it('cors preflight', async () => {
|
|
454
502
|
await commander.setCorsAllowed(true)
|
|
455
503
|
const res = await request('/does-not-matter', {
|
|
456
504
|
method: 'OPTIONS',
|
|
@@ -463,6 +511,23 @@ async function testCorsAllowed() {
|
|
|
463
511
|
equal(res.headers.get(CorsHeader.AccessControlAllowOrigin), 'http://example.com')
|
|
464
512
|
equal(res.headers.get(CorsHeader.AccessControlAllowMethods), 'GET')
|
|
465
513
|
})
|
|
514
|
+
await it('cors actual response', async () => {
|
|
515
|
+
const res = await request(fixtureDefaultInName[0], {
|
|
516
|
+
headers: {
|
|
517
|
+
[CorsHeader.Origin]: 'http://example.com'
|
|
518
|
+
}
|
|
519
|
+
})
|
|
520
|
+
equal(res.status, 200)
|
|
521
|
+
equal(res.headers.get(CorsHeader.AccessControlAllowOrigin), 'http://example.com')
|
|
522
|
+
equal(res.headers.get(CorsHeader.AccessControlExposeHeaders), 'Content-Encoding')
|
|
523
|
+
})
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function testWindowsPaths() {
|
|
527
|
+
it('normalizes backslashes with forward ones', () => {
|
|
528
|
+
const files = listFilesRecursively(config.mocksDir)
|
|
529
|
+
equal(files[0], 'api/.GET.200.json')
|
|
530
|
+
})
|
|
466
531
|
}
|
|
467
532
|
|
|
468
533
|
|
package/src/ProxyRelay.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { write } from './utils/fs.js'
|
|
3
|
-
import {
|
|
3
|
+
import { config } from './config.js'
|
|
4
4
|
import { extFor } from './utils/mime.js'
|
|
5
5
|
import { readBody } from './utils/http-request.js'
|
|
6
6
|
import { makeMockFilename } from './Filename.js'
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
export async function proxy(req, response) {
|
|
10
|
-
const proxyResponse = await fetch(
|
|
10
|
+
const proxyResponse = await fetch(config.proxyFallback + req.url, {
|
|
11
11
|
method: req.method,
|
|
12
12
|
headers: req.headers,
|
|
13
13
|
body: req.method === 'GET' || req.method === 'HEAD'
|
|
@@ -21,9 +21,9 @@ export async function proxy(req, response) {
|
|
|
21
21
|
const body = await proxyResponse.text()
|
|
22
22
|
response.end(body)
|
|
23
23
|
|
|
24
|
-
if (
|
|
24
|
+
if (config.collectProxied) {
|
|
25
25
|
const ext = extFor(proxyResponse.headers.get('content-type'))
|
|
26
26
|
const filename = makeMockFilename(req.url, req.method, proxyResponse.status, ext)
|
|
27
|
-
write(join(
|
|
27
|
+
write(join(config.mocksDir, filename), body)
|
|
28
28
|
}
|
|
29
29
|
}
|
package/src/StaticDispatcher.js
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
|
-
import {
|
|
2
|
+
import { config } from './config.js'
|
|
3
3
|
import { isDirectory, isFile } from './utils/fs.js'
|
|
4
4
|
import { sendFile, sendPartialContent, sendNotFound } from './utils/http-response.js'
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
export function isStatic(req) {
|
|
8
|
-
if (!
|
|
8
|
+
if (!config.staticDir)
|
|
9
9
|
return false
|
|
10
|
-
|
|
11
10
|
const f = resolvePath(req.url)
|
|
12
|
-
return !
|
|
13
|
-
&& Boolean(f)
|
|
11
|
+
return !config.ignore.test(f) && Boolean(f)
|
|
14
12
|
}
|
|
15
13
|
|
|
16
14
|
export async function dispatchStatic(req, response) {
|
|
@@ -24,7 +22,7 @@ export async function dispatchStatic(req, response) {
|
|
|
24
22
|
}
|
|
25
23
|
|
|
26
24
|
function resolvePath(url) {
|
|
27
|
-
let candidate = join(
|
|
25
|
+
let candidate = join(config.staticDir, url)
|
|
28
26
|
if (isDirectory(candidate))
|
|
29
27
|
candidate += '/index.html'
|
|
30
28
|
if (isFile(candidate))
|
|
@@ -2,14 +2,13 @@ import { isDirectory } from './utils/fs.js'
|
|
|
2
2
|
import { openInBrowser } from './utils/openInBrowser.js'
|
|
3
3
|
import { jsToJsonPlugin } from './MockDispatcherPlugins.js'
|
|
4
4
|
import { StandardMethods } from './utils/http-request.js'
|
|
5
|
-
import {
|
|
5
|
+
import { validateCorsAllowedMethods, validateCorsAllowedOrigins } from './utils/http-cors.js'
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
export const
|
|
8
|
+
export const config = Object.seal({
|
|
9
9
|
mocksDir: '',
|
|
10
|
-
ignore: /(\.DS_Store|~)$/,
|
|
11
|
-
|
|
12
10
|
staticDir: '',
|
|
11
|
+
ignore: /(\.DS_Store|~)$/,
|
|
13
12
|
|
|
14
13
|
host: '127.0.0.1',
|
|
15
14
|
port: 0, // auto-assigned
|
|
@@ -25,7 +24,7 @@ export const Config = Object.seal({
|
|
|
25
24
|
[/\.(js|ts)$/, jsToJsonPlugin]
|
|
26
25
|
],
|
|
27
26
|
|
|
28
|
-
corsAllowed:
|
|
27
|
+
corsAllowed: true,
|
|
29
28
|
corsOrigins: ['*'],
|
|
30
29
|
corsMethods: StandardMethods,
|
|
31
30
|
corsHeaders: ['content-type'],
|
|
@@ -38,12 +37,11 @@ export const Config = Object.seal({
|
|
|
38
37
|
|
|
39
38
|
|
|
40
39
|
export function setup(options) {
|
|
41
|
-
Object.assign(
|
|
42
|
-
validate(
|
|
40
|
+
Object.assign(config, options)
|
|
41
|
+
validate(config, {
|
|
43
42
|
mocksDir: isDirectory,
|
|
44
|
-
ignore: is(RegExp),
|
|
45
|
-
|
|
46
43
|
staticDir: optional(isDirectory),
|
|
44
|
+
ignore: is(RegExp),
|
|
47
45
|
|
|
48
46
|
host: is(String),
|
|
49
47
|
port: port => Number.isInteger(port) && port >= 0 && port < 2 ** 16,
|
|
@@ -70,20 +68,16 @@ export function setup(options) {
|
|
|
70
68
|
}
|
|
71
69
|
|
|
72
70
|
|
|
73
|
-
function
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (arr.length === 1 && arr[0] === '*')
|
|
78
|
-
return true
|
|
79
|
-
|
|
80
|
-
return arr.every(o => URL.canParse(o))
|
|
71
|
+
function validate(obj, shape) {
|
|
72
|
+
for (const [field, value] of Object.entries(obj))
|
|
73
|
+
if (!shape[field](value))
|
|
74
|
+
throw new TypeError(`config.${field}=${JSON.stringify(value)} is invalid`)
|
|
81
75
|
}
|
|
82
76
|
|
|
77
|
+
function is(ctor) {
|
|
78
|
+
return val => val.constructor === ctor
|
|
79
|
+
}
|
|
83
80
|
|
|
84
|
-
function
|
|
85
|
-
|
|
86
|
-
return false
|
|
87
|
-
|
|
88
|
-
return arr.every(m => StandardMethods.includes(m))
|
|
81
|
+
function optional(tester) {
|
|
82
|
+
return val => !val || tester(val)
|
|
89
83
|
}
|
package/src/cookie.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { config } from './config.js'
|
|
2
2
|
import { cookie } from './cookie.js'
|
|
3
3
|
import { MockBroker } from './MockBroker.js'
|
|
4
4
|
import { listFilesRecursively } from './utils/fs.js'
|
|
@@ -19,11 +19,11 @@ let collection = {}
|
|
|
19
19
|
|
|
20
20
|
export function init() {
|
|
21
21
|
collection = {}
|
|
22
|
-
cookie.init(
|
|
22
|
+
cookie.init(config.cookies)
|
|
23
23
|
|
|
24
|
-
const files = listFilesRecursively(
|
|
24
|
+
const files = listFilesRecursively(config.mocksDir)
|
|
25
25
|
.sort()
|
|
26
|
-
.filter(f => !
|
|
26
|
+
.filter(f => !config.ignore.test(f) && filenameIsValid(f))
|
|
27
27
|
|
|
28
28
|
for (const file of files) {
|
|
29
29
|
const { method, urlMask } = parseFilename(file)
|
package/src/utils/fs.js
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { join, dirname, sep, posix } from 'node:path'
|
|
2
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()
|
|
6
6
|
export const isDirectory = path => lstatSync(path, { throwIfNoEntry: false })?.isDirectory()
|
|
7
7
|
|
|
8
|
-
export const read = path => readFileSync(path)
|
|
8
|
+
export const read = path => readFileSync(path, 'utf8')
|
|
9
9
|
|
|
10
10
|
/** @returns {Array<string>} paths relative to `dir` */
|
|
11
11
|
export const listFilesRecursively = dir => {
|
|
12
12
|
const files = readdirSync(dir, { recursive: true }).filter(f => isFile(join(dir, f)))
|
|
13
13
|
return process.platform === 'win32'
|
|
14
|
-
? files.map(f => f.replaceAll(
|
|
14
|
+
? files.map(f => f.replaceAll(sep, posix.sep))
|
|
15
15
|
: files
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export const write = (
|
|
19
|
-
mkdirSync(
|
|
20
|
-
writeFileSync(
|
|
18
|
+
export const write = (path, body) => {
|
|
19
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
20
|
+
writeFileSync(path, body)
|
|
21
21
|
}
|
package/src/utils/http-cors.js
CHANGED
|
@@ -1,19 +1,35 @@
|
|
|
1
1
|
import { StandardMethods } from './http-request.js'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
/* https://www.w3.org/TR/2020/SPSD-cors-20200602/#resource-processing-model */
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
export function validateCorsAllowedOrigins(arr) {
|
|
8
|
+
if (!Array.isArray(arr))
|
|
9
|
+
return false
|
|
10
|
+
if (arr.length === 1 && arr[0] === '*')
|
|
11
|
+
return true
|
|
12
|
+
return arr.every(o => URL.canParse(o))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validateCorsAllowedMethods(arr) {
|
|
16
|
+
return Array.isArray(arr)
|
|
17
|
+
&& arr.every(m => StandardMethods.includes(m))
|
|
18
|
+
}
|
|
19
|
+
|
|
4
20
|
|
|
5
21
|
export const CorsHeader = {
|
|
6
|
-
//
|
|
22
|
+
// Request
|
|
7
23
|
Origin: 'origin',
|
|
8
24
|
AccessControlRequestMethod: 'access-control-request-method',
|
|
9
25
|
AccessControlRequestHeaders: 'access-control-request-headers', // Comma separated
|
|
10
26
|
|
|
11
|
-
//
|
|
27
|
+
// Response
|
|
12
28
|
AccessControlMaxAge: 'Access-Control-Max-Age',
|
|
13
29
|
AccessControlAllowOrigin: 'Access-Control-Allow-Origin', // '*' | Space delimited | null
|
|
14
30
|
AccessControlAllowMethods: 'Access-Control-Allow-Methods', // '*' | Comma delimited
|
|
15
31
|
AccessControlAllowHeaders: 'Access-Control-Allow-Headers', // '*' | Comma delimited
|
|
16
|
-
AccessControlExposeHeaders: 'Access-Control-Expose-Headers', // '*' | Comma delimited
|
|
32
|
+
AccessControlExposeHeaders: 'Access-Control-Expose-Headers', // '*' | Comma delimited (headers client-side JS can read)
|
|
17
33
|
AccessControlAllowCredentials: 'Access-Control-Allow-Credentials' // 'true'
|
|
18
34
|
}
|
|
19
35
|
const CH = CorsHeader
|
|
@@ -45,26 +61,16 @@ export function setCorsHeaders(req, response, {
|
|
|
45
61
|
|
|
46
62
|
if (req.headers[CH.AccessControlRequestMethod])
|
|
47
63
|
setPreflightSpecificHeaders(req, response, methods, headers, maxAge)
|
|
48
|
-
else
|
|
49
|
-
|
|
64
|
+
else if (exposedHeaders.length)
|
|
65
|
+
response.setHeader(CH.AccessControlExposeHeaders, exposedHeaders.join(','))
|
|
50
66
|
}
|
|
51
67
|
|
|
52
|
-
|
|
53
68
|
function setPreflightSpecificHeaders(req, response, methods, headers, maxAge) {
|
|
54
69
|
const methodAskingFor = req.headers[CH.AccessControlRequestMethod]
|
|
55
70
|
if (!methods.includes(methodAskingFor))
|
|
56
71
|
return
|
|
57
|
-
|
|
58
72
|
response.setHeader(CH.AccessControlMaxAge, maxAge)
|
|
59
73
|
response.setHeader(CH.AccessControlAllowMethods, methodAskingFor)
|
|
60
74
|
if (headers.length)
|
|
61
75
|
response.setHeader(CH.AccessControlAllowHeaders, headers.join(','))
|
|
62
76
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
// TESTME
|
|
66
|
-
function setActualRequestHeaders(response, exposedHeaders) {
|
|
67
|
-
// Exposed means the client-side JavaScript can read them
|
|
68
|
-
if (exposedHeaders.length)
|
|
69
|
-
response.setHeader(CH.AccessControlExposeHeaders, exposedHeaders.join(','))
|
|
70
|
-
}
|
package/src/utils/mime.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { config } from '../config.js'
|
|
2
2
|
import { EXT_FOR_UNKNOWN_MIME } from '../ApiConstants.js'
|
|
3
3
|
|
|
4
4
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
|
|
@@ -89,7 +89,7 @@ const mimes = {
|
|
|
89
89
|
|
|
90
90
|
export function mimeFor(filename) {
|
|
91
91
|
const ext = filename.replace(/.*\./, '').toLowerCase()
|
|
92
|
-
return
|
|
92
|
+
return config.extraMimes[ext] || mimes[ext] || ''
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
export function extFor(mime) {
|
|
@@ -99,7 +99,7 @@ export function extFor(mime) {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
function findExt(targetMime) {
|
|
102
|
-
for (const [ext, mime] of Object.entries(
|
|
102
|
+
for (const [ext, mime] of Object.entries(config.extraMimes))
|
|
103
103
|
if (targetMime === mime)
|
|
104
104
|
return ext
|
|
105
105
|
for (const [ext, mime] of Object.entries(mimes))
|
package/src/utils/validate.js
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
export function validate(obj, shape) {
|
|
2
|
-
for (const [field, value] of Object.entries(obj))
|
|
3
|
-
if (!shape[field](value))
|
|
4
|
-
throw new TypeError(`Config.${field}=${JSON.stringify(value)} is invalid`)
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export const is = ctor => val => val.constructor === ctor
|
|
8
|
-
export const optional = tester => val => !val || tester(val)
|