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 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
- ## [Docs](https://mockaton.com) | [Changelog](https://mockaton.com/changelog) | [Skills](skills/mockaton/SKILL.md)
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
- Simulate API states that are normally ignored. For example,
15
- click the 500 button to trigger an error on an endpoint. Then
16
- unclick it to test your retry logic. Similarly, click the clock
17
- button 🕓 to delay a response so you can test spinners &mdash;
18
- you‘ve seen them in production at the top left, or restarting
19
- their animation midway.
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; otherwise, the first mock variant in alphabetical order wins. |
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
- Mockaton has a [Browser Extension](https://mockaton.com/scraping) that lets
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. `.ts` files are served as JSON by default.
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), and `export default` an Object, Array, or
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
- export declare type JsonPromise<T> = Promise<Response & { json(): Promise<T> }>
70
-
71
-
72
- // API
73
-
74
- export declare type ClientMockBroker = {
75
- mocks: string[]
76
- file: string
77
- status: number
78
- isStatic: boolean
79
- autoStatus: number
80
- delayed: boolean
81
- proxied: boolean
82
- }
83
- export declare type ClientBrokersByMethod = {
84
- [method: string]: {
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
- export declare interface State {
90
- brokersByMethod: ClientBrokersByMethod
86
+ interface State {
87
+ brokersByMethod: ClientBrokersByMethod
91
88
 
92
- cookies: [label: string, selected: boolean][]
93
- comments: string[]
89
+ cookies: [label: string, selected: boolean][]
90
+ comments: string[]
94
91
 
95
- delay: number
96
- delayJitter: number
92
+ delay: number
93
+ delayJitter: number
97
94
 
98
- collectProxied: boolean
99
- proxyFallback: string
95
+ collectProxied: boolean
96
+ proxyFallback: string
100
97
 
101
- readOnly: boolean
98
+ readOnly: boolean
102
99
 
103
- corsAllowed?: boolean
100
+ corsAllowed?: boolean
101
+ }
104
102
  }
103
+
104
+ export type { JsonPromise, ClientMockBroker, ClientBrokersByMethod, State }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "mockaton",
3
3
  "description": "HTTP Mock Server",
4
4
  "type": "module",
5
- "version": "13.12.0",
5
+ "version": "13.15.0",
6
6
  "exports": {
7
7
  ".": {
8
8
  "import": "./index.js",
@@ -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; otherwise, the first mock variant in alphabetical order wins. |
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. `.ts` files are served as JSON by default.
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), and `export default` an Object, Array, or
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.cookies>} */
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)
@@ -11,13 +11,12 @@ const METHODS = [ // @KeepSync node:http.METHODS
11
11
 
12
12
  const reComments = /\([^()]*\)/g // Anything within parentheses
13
13
 
14
- export function extractComments(file) {
15
- return Array.from(file.matchAll(reComments), ([c]) => c)
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
- return filename.replaceAll(replaceIds.reUuidV4, '[id]')
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
@@ -142,7 +142,7 @@
142
142
  &.GlobalDelay,
143
143
  &.GlobalDelayJitter {
144
144
  input {
145
- &&::selection {
145
+ &::selection {
146
146
  background-color: transparent;
147
147
  }
148
148
 
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
- else if (!isFile(path))
305
+ return
306
+ }
307
+
308
+ if (!isFile(path)) {
306
309
  response.unprocessable(`Missing Mock: ${file}`)
307
- else
308
- await rm(path)
310
+ return
311
+ }
312
+
313
+ await rm(path)
309
314
 
310
315
  if (!config.watcherEnabled) {
311
316
  brokers.unregisterMock(file)
@@ -1,4 +1,4 @@
1
- import { register } from 'node:module'
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
- register('./ResolverResolveExtensionless.js', import.meta.url)
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
- /** In Node, there's no EventSource, so we work around it like this.
1221
- * This is for listening to real-time updates. It responds when a new mock is added, deleted, or renamed. */
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
- const reader = (await api.getSyncVersion())
1225
- .body.pipeThrough(new TextDecoderStream())
1226
- .getReader()
1227
- let buffer = ''
1228
-
1229
- try {
1230
- while (true) {
1231
- try {
1232
- const { done, value } = await reader.read()
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
- catch {
1237
- break
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
-
@@ -48,7 +48,6 @@ const schema = {
48
48
  corsMaxAge: [0, is(Number)],
49
49
 
50
50
  hotReload: [false, is(Boolean)],
51
- bypassImportCache: [true, is(Boolean)],
52
51
 
53
52
  // Non-serializable
54
53
  plugins: [[[/\.(js|ts)$/, jsToJsonPlugin]], is(Array)],
@@ -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 async function resolve(specifier, context, nextResolve) {
6
+ export function resolveExtensionless(specifier, context, nextResolve) {
7
7
  try {
8
- return await nextResolve(specifier, context)
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 resolve(pathToFileURL(candidate).href, context, nextResolve)
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
- }