mockaton 13.12.0 → 13.15.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 +21 -15
- package/index.d.ts +29 -29
- package/package.json +1 -1
- package/skills/mockaton/SKILL.md +5 -4
- package/src/client/ApiCommander.js +1 -1
- package/src/client/Filename.js +7 -8
- package/src/client/Header.css +1 -1
- package/src/server/Api.js +9 -4
- package/src/server/Mockaton.js +5 -5
- package/src/server/Mockaton.test.js +19 -34
- package/src/server/stores/config.js +0 -1
- package/src/server/utils/resolveBypassImportCache.js +21 -0
- package/src/server/{ResolverResolveExtensionless.js → utils/resolveExtensionless.js} +3 -3
- package/src/server/ResolverBypassImportCache.js +0 -19
package/README.md
CHANGED
|
@@ -6,22 +6,24 @@
|
|
|
6
6
|
|
|
7
7
|
<img src="logo.svg" alt="Mockaton Logo" width="180" style="margin-top: 30px"/>
|
|
8
8
|
|
|
9
|
-
No API state should be too difficult to test
|
|
9
|
+
*No API state should be too difficult to test*
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
### [Docs](https://mockaton.com) | [Changelog](https://mockaton.com/changelog) | [Skills](skills/mockaton/SKILL.md) | [Use Cases](https://mockaton.com/use-cases)
|
|
12
12
|
|
|
13
|
+
## Overview
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
Mockaton is a local HTTP mock server. With it, you can test API states
|
|
16
|
+
that are difficult to reproduce from an actual backend.
|
|
17
|
+
|
|
18
|
+
For example, to quickly trigger an error on an endpoint, click the 500 button.
|
|
19
|
+
Then, unclick it to test your retry logic.
|
|
20
|
+
|
|
21
|
+
Similarly, pick a mock variant from the dropdown, say to respond with a 423 (locked account).
|
|
22
|
+
|
|
23
|
+
Or click the clock button 🕓 to delay a response so you can test spinners.
|
|
20
24
|
|
|
21
25
|
|
|
22
26
|
## Dashboard
|
|
23
|
-
Besides the dashboard UI, there’s [an API](https://mockaton.com/api)
|
|
24
|
-
you can use to set up tests.
|
|
25
27
|
|
|
26
28
|
<picture>
|
|
27
29
|
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/ericfortis/mockaton/refs/heads/main/pixaton-tests/tests/macos/pic-for-readme.vp762x762.light.gold.png">
|
|
@@ -29,6 +31,9 @@ you can use to set up tests.
|
|
|
29
31
|
<img alt="Mockaton Dashboard" src="https://raw.githubusercontent.com/ericfortis/mockaton/refs/heads/main/pixaton-tests/tests/macos/pic-for-readme.vp762x762.dark.gold.png">
|
|
30
32
|
</picture>
|
|
31
33
|
|
|
34
|
+
Besides the dashboard, there’s a [programmatic API](https://mockaton.com/api),
|
|
35
|
+
which is handy for setting up tests.
|
|
36
|
+
|
|
32
37
|
|
|
33
38
|
## Demo (Docker)
|
|
34
39
|
This will spin up Mockaton with the [sample directory](./mockaton-mocks)
|
|
@@ -78,26 +83,27 @@ code variants.
|
|
|
78
83
|
| -----| -----| ---|
|
|
79
84
|
| /api/company/123 | api/company/[id].GET.200.ts | `[id]` is a dynamic parameter. `.ts`, and `.js` are sent as JSON by default. |
|
|
80
85
|
| /media/avatar.png | media/avatar.png | Statics assets don't need the above extension. |
|
|
81
|
-
| /api/login | api/login(invalid attempt).POST.401.ts | Anything within parenthesis is a comment. They are ignored when routing. |
|
|
82
|
-
| /api/login | api/login(default).GET.200.ts | `(default)` is a special comment
|
|
86
|
+
| /api/login | api/login(invalid attempt).POST.401.ts | Anything within parenthesis is a comment. They are ignored when routing. You can add many comments, `foo(c0)(c1).png` |
|
|
87
|
+
| /api/login | api/login(default).GET.200.ts | `(default)` is a special comment. Otherwise, the first mock variant in alphabetical order wins. |
|
|
83
88
|
| /api/login | api/login(locked out user).POST.423.json | `.json` is allowed too. |
|
|
84
89
|
|
|
85
90
|
<!-- SKILLS_IGNORE_BEGIN -->
|
|
86
91
|
## How to scrape your backend APIs?
|
|
87
|
-
|
|
92
|
+
There’s a sister [Browser Extension](https://mockaton.com/scraping) that lets
|
|
88
93
|
you download in bulk all your API responses following Mockaton's filename convention.
|
|
89
94
|
<!-- SKILLS_IGNORE_END -->
|
|
90
95
|
|
|
91
96
|
|
|
92
97
|
## How to create mocks?
|
|
93
|
-
Write it to your mocks directory.
|
|
98
|
+
Write it to your mocks directory. TypeScript files are sent as JSON by default.
|
|
94
99
|
```sh
|
|
95
100
|
mkdir -p my-mocks-dir/api
|
|
96
101
|
echo "export default { name: 'John' }" > my-mocks-dir/api/user.GET.200.ts
|
|
97
102
|
```
|
|
103
|
+
Alternatively, there’s a [write-mock API](https://mockaton.com/api).
|
|
98
104
|
|
|
99
105
|
### Example A: JSON
|
|
100
|
-
For JSON responses, use TypeScript (or JS)
|
|
106
|
+
For JSON responses, you can use TypeScript (or JS) and `export default` an Object, Array, or
|
|
101
107
|
String.
|
|
102
108
|
|
|
103
109
|
- **Route:** /api/company/123
|
package/index.d.ts
CHANGED
|
@@ -46,7 +46,6 @@ export declare interface Config {
|
|
|
46
46
|
onReady?: (address: string) => void
|
|
47
47
|
|
|
48
48
|
hotReload?: boolean // For UI dev purposes only
|
|
49
|
-
bypassImportCache?: boolean
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
|
|
@@ -66,39 +65,40 @@ export declare function parseJSON(request: IncomingMessage): Promise<any>
|
|
|
66
65
|
export declare function parseSegments(reqUrl: string, filename: string): Record<string, string>
|
|
67
66
|
export declare function parseQueryParams(reqUrl: string): URLSearchParams
|
|
68
67
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
[urlMask: string]: ClientMockBroker
|
|
68
|
+
declare global {
|
|
69
|
+
type JsonPromise<T> = Promise<Response & { json(): Promise<T> }>
|
|
70
|
+
|
|
71
|
+
type ClientMockBroker = {
|
|
72
|
+
mocks: string[]
|
|
73
|
+
file: string
|
|
74
|
+
status: number
|
|
75
|
+
isStatic: boolean
|
|
76
|
+
autoStatus: number
|
|
77
|
+
delayed: boolean
|
|
78
|
+
proxied: boolean
|
|
79
|
+
}
|
|
80
|
+
type ClientBrokersByMethod = {
|
|
81
|
+
[method: string]: {
|
|
82
|
+
[urlMask: string]: ClientMockBroker
|
|
83
|
+
}
|
|
86
84
|
}
|
|
87
|
-
}
|
|
88
85
|
|
|
89
|
-
|
|
90
|
-
|
|
86
|
+
interface State {
|
|
87
|
+
brokersByMethod: ClientBrokersByMethod
|
|
91
88
|
|
|
92
|
-
|
|
93
|
-
|
|
89
|
+
cookies: [label: string, selected: boolean][]
|
|
90
|
+
comments: string[]
|
|
94
91
|
|
|
95
|
-
|
|
96
|
-
|
|
92
|
+
delay: number
|
|
93
|
+
delayJitter: number
|
|
97
94
|
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
collectProxied: boolean
|
|
96
|
+
proxyFallback: string
|
|
100
97
|
|
|
101
|
-
|
|
98
|
+
readOnly: boolean
|
|
102
99
|
|
|
103
|
-
|
|
100
|
+
corsAllowed?: boolean
|
|
101
|
+
}
|
|
104
102
|
}
|
|
103
|
+
|
|
104
|
+
export type { JsonPromise, ClientMockBroker, ClientBrokersByMethod, State }
|
package/package.json
CHANGED
package/skills/mockaton/SKILL.md
CHANGED
|
@@ -29,22 +29,23 @@ code variants.
|
|
|
29
29
|
| -----| -----| ---|
|
|
30
30
|
| /api/company/123 | api/company/[id].GET.200.ts | `[id]` is a dynamic parameter. `.ts`, and `.js` are sent as JSON by default. |
|
|
31
31
|
| /media/avatar.png | media/avatar.png | Statics assets don't need the above extension. |
|
|
32
|
-
| /api/login | api/login(invalid attempt).POST.401.ts | Anything within parenthesis is a comment. They are ignored when routing. |
|
|
33
|
-
| /api/login | api/login(default).GET.200.ts | `(default)` is a special comment
|
|
32
|
+
| /api/login | api/login(invalid attempt).POST.401.ts | Anything within parenthesis is a comment. They are ignored when routing. You can add many comments, `foo(c0)(c1).png` |
|
|
33
|
+
| /api/login | api/login(default).GET.200.ts | `(default)` is a special comment. Otherwise, the first mock variant in alphabetical order wins. |
|
|
34
34
|
| /api/login | api/login(locked out user).POST.423.json | `.json` is allowed too. |
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
## How to create mocks?
|
|
40
|
-
Write it to your mocks directory.
|
|
40
|
+
Write it to your mocks directory. TypeScript files are sent as JSON by default.
|
|
41
41
|
```sh
|
|
42
42
|
mkdir -p my-mocks-dir/api
|
|
43
43
|
echo "export default { name: 'John' }" > my-mocks-dir/api/user.GET.200.ts
|
|
44
44
|
```
|
|
45
|
+
Alternatively, there’s a [write-mock API](https://mockaton.com/api).
|
|
45
46
|
|
|
46
47
|
### Example A: JSON
|
|
47
|
-
For JSON responses, use TypeScript (or JS)
|
|
48
|
+
For JSON responses, you can use TypeScript (or JS) and `export default` an Object, Array, or
|
|
48
49
|
String.
|
|
49
50
|
|
|
50
51
|
- **Route:** /api/company/123
|
|
@@ -36,7 +36,7 @@ export class Commander {
|
|
|
36
36
|
setRouteIsProxied = (method, urlMask, proxied) => this.#patch(API.proxied, [method, urlMask, proxied])
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
/** @returns {JsonPromise<State
|
|
39
|
+
/** @returns {JsonPromise<State['cookies']>} */
|
|
40
40
|
selectCookie = label => this.#patch(API.cookies, label)
|
|
41
41
|
|
|
42
42
|
setGlobalDelay = delay => this.#patch(API.globalDelay, delay)
|
package/src/client/Filename.js
CHANGED
|
@@ -11,13 +11,12 @@ const METHODS = [ // @KeepSync node:http.METHODS
|
|
|
11
11
|
|
|
12
12
|
const reComments = /\([^()]*\)/g // Anything within parentheses
|
|
13
13
|
|
|
14
|
-
export
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
export const extractComments = file =>
|
|
15
|
+
Array.from(file.matchAll(reComments), ([c]) => c)
|
|
16
|
+
|
|
17
|
+
export const includesComment = (file, search) =>
|
|
18
|
+
extractComments(file).some(c => c.includes(search))
|
|
17
19
|
|
|
18
|
-
export function includesComment(file, search) {
|
|
19
|
-
return extractComments(file).some(c => c.includes(search))
|
|
20
|
-
}
|
|
21
20
|
|
|
22
21
|
export function parseFilename(file) {
|
|
23
22
|
const tokens = file.replace(reComments, '').split('.')
|
|
@@ -62,6 +61,6 @@ export function makeMockFilename(url, method, status, ext, comment = '') {
|
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
function replaceIds(filename) {
|
|
65
|
-
|
|
64
|
+
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
|
|
65
|
+
return filename.replaceAll(reUuidV4, '[id]')
|
|
66
66
|
}
|
|
67
|
-
replaceIds.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
|
package/src/client/Header.css
CHANGED
package/src/server/Api.js
CHANGED
|
@@ -300,12 +300,17 @@ async function deleteMock(req, response) {
|
|
|
300
300
|
const file = await req.json()
|
|
301
301
|
const path = await resolveIn(config.mocksDir, file)
|
|
302
302
|
|
|
303
|
-
if (!path)
|
|
303
|
+
if (!path) {
|
|
304
304
|
response.forbidden('Filename path resolves outside config.mocksDir')
|
|
305
|
-
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (!isFile(path)) {
|
|
306
309
|
response.unprocessable(`Missing Mock: ${file}`)
|
|
307
|
-
|
|
308
|
-
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
await rm(path)
|
|
309
314
|
|
|
310
315
|
if (!config.watcherEnabled) {
|
|
311
316
|
brokers.unregisterMock(file)
|
package/src/server/Mockaton.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { registerHooks } from 'node:module'
|
|
2
2
|
import { createServer } from 'node:http'
|
|
3
3
|
|
|
4
4
|
import pkgJSON from '../../package.json' with { type: 'json' }
|
|
@@ -8,6 +8,8 @@ import { ServerResponse } from './utils/HttpServerResponse.js'
|
|
|
8
8
|
import { setCorsHeaders, isPreflight } from './utils/http-cors.js'
|
|
9
9
|
import { IncomingMessage } from './utils/HttpIncomingMessage.js'
|
|
10
10
|
import { watchDevSPA } from './utils/WatcherDevClient.js'
|
|
11
|
+
import { resolveExtensionless } from './utils/resolveExtensionless.js'
|
|
12
|
+
import { bypassImportCache } from './utils/resolveBypassImportCache.js'
|
|
11
13
|
|
|
12
14
|
import { API } from '../client/ApiConstants.js'
|
|
13
15
|
import { dispatchMock } from './MockDispatcher.js'
|
|
@@ -25,10 +27,8 @@ export function Mockaton(options) {
|
|
|
25
27
|
cookie.init(config.cookies)
|
|
26
28
|
brokers.init()
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (config.bypassImportCache)
|
|
31
|
-
register('./ResolverBypassImportCache.js', import.meta.url)
|
|
30
|
+
registerHooks({ resolve: resolveExtensionless })
|
|
31
|
+
registerHooks({ resolve: bypassImportCache(config.mocksDir) })
|
|
32
32
|
|
|
33
33
|
if (config.watcherEnabled)
|
|
34
34
|
watchMocksDir()
|
|
@@ -36,7 +36,7 @@ const proc = spawn(join(import.meta.dirname, 'cli.js'), [
|
|
|
36
36
|
mocksDir.value,
|
|
37
37
|
'--config', join(import.meta.dirname, 'Mockaton.test.config.js'),
|
|
38
38
|
'--no-open'
|
|
39
|
-
])
|
|
39
|
+
], { env: { ...process.env, FORCE_COLOR: 0, NO_COLOR: 1 } })
|
|
40
40
|
|
|
41
41
|
const DEBUG = false
|
|
42
42
|
proc.stdout.on('data', data => {
|
|
@@ -1178,6 +1178,7 @@ describe('Registering Mocks', () => {
|
|
|
1178
1178
|
before(async () => {
|
|
1179
1179
|
await mocksDir.mkdir('reg0')
|
|
1180
1180
|
await mocksDir.write(fx0.file, fx0.body)
|
|
1181
|
+
await sleep(config.watcherDebounceMs + 50)
|
|
1181
1182
|
version = await resolveOnNextSyncVersion(-1)
|
|
1182
1183
|
})
|
|
1183
1184
|
|
|
@@ -1217,41 +1218,25 @@ describe('Registering Mocks', () => {
|
|
|
1217
1218
|
})
|
|
1218
1219
|
|
|
1219
1220
|
|
|
1220
|
-
/**
|
|
1221
|
-
|
|
1222
|
-
async function resolveOnNextSyncVersion(currSyncVer = undefined) {
|
|
1221
|
+
/** Resolves when a new mock is added, deleted, or renamed. */
|
|
1222
|
+
function resolveOnNextSyncVersion(currSyncVer = undefined) {
|
|
1223
1223
|
let skipFirst = currSyncVer === undefined
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
.
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
if (done) break
|
|
1234
|
-
buffer += value
|
|
1224
|
+
return new Promise((resolve, reject) => {
|
|
1225
|
+
const es = new EventSource(api.addr + API.syncVersion)
|
|
1226
|
+
es.onmessage = event => {
|
|
1227
|
+
const v = Number(event.data)
|
|
1228
|
+
if (skipFirst || v === currSyncVer)
|
|
1229
|
+
skipFirst = false
|
|
1230
|
+
else {
|
|
1231
|
+
es.close()
|
|
1232
|
+
resolve(v)
|
|
1235
1233
|
}
|
|
1236
|
-
|
|
1237
|
-
|
|
1234
|
+
}
|
|
1235
|
+
es.onerror = () => {
|
|
1236
|
+
if (es.readyState === EventSource.CLOSED) {
|
|
1237
|
+
es.close()
|
|
1238
|
+
reject(new Error('sync-version stream closed'))
|
|
1238
1239
|
}
|
|
1239
|
-
const parts = buffer.split('\n\n')
|
|
1240
|
-
buffer = parts.pop() || ''
|
|
1241
|
-
|
|
1242
|
-
for (const event of parts)
|
|
1243
|
-
for (const line of event.split(/\r?\n/))
|
|
1244
|
-
if (line.startsWith('data:')) {
|
|
1245
|
-
const v = Number(line.slice(5).trim())
|
|
1246
|
-
if (skipFirst || v === currSyncVer)
|
|
1247
|
-
skipFirst = false
|
|
1248
|
-
else
|
|
1249
|
-
return v
|
|
1250
|
-
}
|
|
1251
1240
|
}
|
|
1252
|
-
}
|
|
1253
|
-
finally {
|
|
1254
|
-
reader.cancel().catch(() => {})
|
|
1255
|
-
}
|
|
1241
|
+
})
|
|
1256
1242
|
}
|
|
1257
|
-
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs'
|
|
2
|
+
import { pathToFileURL } from 'node:url'
|
|
3
|
+
|
|
4
|
+
export function bypassImportCache(srcPath) {
|
|
5
|
+
const resolvedSrcPath = pathToFileURL(realpathSync(srcPath)).href
|
|
6
|
+
|
|
7
|
+
// We register this `resolve` hook at runtime so it doesn't interfere with non-dynamic imports.
|
|
8
|
+
return function resolve(specifier, context, nextResolve) {
|
|
9
|
+
const result = nextResolve(specifier, context)
|
|
10
|
+
if (result.url.startsWith(resolvedSrcPath)) {
|
|
11
|
+
const url = new URL(result.url)
|
|
12
|
+
url.searchParams.set('t', performance.now())
|
|
13
|
+
return {
|
|
14
|
+
...result,
|
|
15
|
+
url: url.href,
|
|
16
|
+
shortCircuit: true
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return result
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -3,9 +3,9 @@ import { join, dirname } from 'node:path'
|
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
export
|
|
6
|
+
export function resolveExtensionless(specifier, context, nextResolve) {
|
|
7
7
|
try {
|
|
8
|
-
return
|
|
8
|
+
return nextResolve(specifier, context)
|
|
9
9
|
}
|
|
10
10
|
catch (error) {
|
|
11
11
|
// Attempt to resolve imports as .ts and .js
|
|
@@ -13,7 +13,7 @@ export async function resolve(specifier, context, nextResolve) {
|
|
|
13
13
|
const absPath = join(dirname(fileURLToPath(context.parentURL)), specifier)
|
|
14
14
|
for (const candidate of ['.ts', '.js'].map(ext => absPath + ext))
|
|
15
15
|
if (existsSync(candidate))
|
|
16
|
-
return
|
|
16
|
+
return resolveExtensionless(pathToFileURL(candidate).href, context, nextResolve)
|
|
17
17
|
}
|
|
18
18
|
throw error
|
|
19
19
|
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { resolve as _resolve } from 'node:path'
|
|
2
|
-
|
|
3
|
-
const mockatonSrcRoot = `file://${_resolve(import.meta.dirname, '..')}`
|
|
4
|
-
|
|
5
|
-
// We register this hook at runtime so it doesn’t interfere with non-dynamic imports.
|
|
6
|
-
// Cache bust by appending timestamp query param
|
|
7
|
-
export async function resolve(specifier, context, nextResolve) {
|
|
8
|
-
const result = await nextResolve(specifier, context)
|
|
9
|
-
if (result.url?.startsWith('file://') && !result.url.startsWith(mockatonSrcRoot)) {
|
|
10
|
-
const url = new URL(result.url)
|
|
11
|
-
url.searchParams.set('t', performance.now())
|
|
12
|
-
return {
|
|
13
|
-
...result,
|
|
14
|
-
url: url.href,
|
|
15
|
-
shortCircuit: true
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
return result
|
|
19
|
-
}
|