mockaton 13.9.3 → 13.9.5

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
@@ -2,7 +2,6 @@
2
2
  ![NPM Version](https://img.shields.io/npm/v/mockaton)
3
3
  [![Test](https://github.com/ericfortis/mockaton/actions/workflows/test.yml/badge.svg)](https://github.com/ericfortis/mockaton/actions/workflows/test.yml)
4
4
  [![codecov](https://codecov.io/github/ericfortis/mockaton/graph/badge.svg?token=90NYLMMG1J)](https://codecov.io/github/ericfortis/mockaton)
5
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
5
 
7
6
  ## [Docs ↗](https://mockaton.com) | [Changelog ↗](https://mockaton.com/changelog) | [Skills](skills/mockaton/SKILL.md)
8
7
 
@@ -38,9 +37,10 @@ Dashboard: [localhost:2020/mockaton](http://localhost:2020/mockaton)
38
37
  npx mockaton --port 2020 my-mocks-dir/
39
38
  ```
40
39
 
41
- Mockaton will serve the files on the given directory. It's a file-system
42
- based router, so filenames can have dynamic parameters and comments.
43
- Also, each route can have different mock file variants.
40
+ Mockaton will serve the files on the given directory. It's a file-system based router, so filenames can have dynamic
41
+ parameters.
42
+ Also, filenames can have comments, which are anything within parentheses, this way each route can have different mock
43
+ file variants.
44
44
 
45
45
 
46
46
  | Route | Filename | Description |
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.9.3",
5
+ "version": "13.9.5",
6
6
  "exports": {
7
7
  ".": {
8
8
  "import": "./index.js",
@@ -17,15 +17,10 @@ export class Commander {
17
17
  reset = () => this.#patch(API.reset)
18
18
 
19
19
  setGlobalDelay = delay => this.#patch(API.globalDelay, delay)
20
-
21
20
  setGlobalDelayJitter = jitterPct => this.#patch(API.globalDelayJitter, jitterPct)
22
-
23
21
  setCorsAllowed = value => this.#patch(API.cors, value)
24
-
25
22
  setWatchMocks = enabled => this.#patch(API.watchMocks, enabled)
26
-
27
23
  setProxyFallback = proxyAddr => this.#patch(API.fallback, proxyAddr)
28
-
29
24
  setCollectProxied = shouldCollect => this.#patch(API.collectProxied, shouldCollect)
30
25
 
31
26
  /** @returns {JsonPromise<State.cookies>} */
@@ -50,7 +45,6 @@ export class Commander {
50
45
 
51
46
 
52
47
  writeMock = (file, content) => this.#patch(API.writeMock, [file, content])
53
-
54
48
  deleteMock = file => this.#patch(API.deleteMock, file)
55
49
 
56
50
 
@@ -6,7 +6,6 @@ export const API = {
6
6
  dashboard: MOUNT,
7
7
 
8
8
  reset: MOUNT + '/reset',
9
-
10
9
  select: MOUNT + '/select',
11
10
  bulkSelect: MOUNT + '/bulk-select-by-comment',
12
11
 
@@ -27,7 +27,6 @@
27
27
 
28
28
  html,
29
29
  body {
30
- overflow: hidden;
31
30
  height: 100%;
32
31
  font-size: 12px;
33
32
  }
@@ -45,7 +44,7 @@ body {
45
44
  border: 0;
46
45
  margin: 0;
47
46
  letter-spacing: -0.374px;
48
- line-height: 1.2;
47
+ line-height: 14px;
49
48
  font-family: inherit;
50
49
  font-size: 100%;
51
50
  scrollbar-width: thin;
@@ -185,13 +184,13 @@ header {
185
184
  }
186
185
 
187
186
  .HelpLink {
188
- opacity: 0.8;
189
187
  width: 22px;
190
188
  height: 22px;
191
189
  flex-shrink: 0;
192
190
  align-self: end;
193
191
  margin-bottom: 3px;
194
192
  margin-left: auto;
193
+ opacity: 0.8;
195
194
  border-radius: 50%;
196
195
  fill: var(--colorBgHeader);
197
196
  background: var(--colorLabel);
@@ -342,6 +341,7 @@ main {
342
341
  }
343
342
 
344
343
  .leftSide {
344
+ overflow: hidden;
345
345
  width: 50%;
346
346
  border-top: 1px solid var(--colorBorder);
347
347
  border-right: 1px solid var(--colorBorder);
@@ -349,6 +349,7 @@ main {
349
349
 
350
350
  .rightSide {
351
351
  position: relative;
352
+ overflow: hidden;
352
353
  min-width: 100px;
353
354
  min-height: 0;
354
355
  flex: 1;
@@ -399,8 +400,7 @@ main {
399
400
  background: var(--colorBgHeader);
400
401
  }
401
402
 
402
- .GroupByMethod,
403
- .ViewSourceCheckbox {
403
+ .GroupByMethod {
404
404
  display: flex;
405
405
  align-items: center;
406
406
  gap: 6px;
@@ -426,7 +426,7 @@ main {
426
426
  height: 100%;
427
427
  padding: 16px;
428
428
  padding-bottom: 64px;
429
- padding-left: 12px;
429
+ padding-left: 15px;
430
430
  user-select: none;
431
431
  overflow-y: auto;
432
432
 
@@ -482,7 +482,8 @@ main {
482
482
  cursor: grabbing;
483
483
  }
484
484
 
485
- &::-webkit-details-marker {
485
+ &::-webkit-details-marker,
486
+ &::marker {
486
487
  display: none;
487
488
  }
488
489
 
@@ -11,7 +11,7 @@ test('groupByFolder', () => {
11
11
  PartialBrokerRowModel('GET', '/api/user/avatar'),
12
12
  PartialBrokerRowModel('GET', '/api/video/[id]'),
13
13
  PartialBrokerRowModel('GET', '/index.html'),
14
- PartialBrokerRowModel('GET', '/media/file-a.txt'),
14
+ PartialBrokerRowModel('GET', '/media/file-a'),
15
15
  PartialBrokerRowModel('GET', '/media/file-b.txt'),
16
16
  PartialBrokerRowModel('GET', '/media/sub/file-aa.txt'),
17
17
  PartialBrokerRowModel('GET', '/media/sub/file-bb.txt'),
@@ -28,7 +28,7 @@ test('groupByFolder', () => {
28
28
  PartialBrokerRowModel('PATCH', '/api/user')),
29
29
  PartialBrokerRowModel('GET', '/api/video/[id]'),
30
30
  PartialBrokerRowModel('GET', '/index.html'),
31
- PartialBrokerRowModel('GET', '/media/file-a.txt',
31
+ PartialBrokerRowModel('GET', '/media/file-a',
32
32
  PartialBrokerRowModel('GET', '/media/file-b.txt'),
33
33
  PartialBrokerRowModel('GET', '/media/sub/file-aa.txt',
34
34
  PartialBrokerRowModel('GET', '/media/sub/file-bb.txt')))
@@ -6,6 +6,7 @@ export function classNames(...args) {
6
6
  export function extractClassNames({ cssRules }) {
7
7
  // Class names must begin with _ or a letter, then it can have numbers and hyphens
8
8
  // TODO think about tag.className selectors
9
+ // TODO think about collisions with props on CSSStyleSheet (e.g. title, type, disabled, href, etc.)
9
10
  const reClassName = /(?:^|[\s,{>])&?\s*\.([a-zA-Z_][\w-]*)/g
10
11
  const cNames = {}
11
12
  let match
package/src/server/Api.js CHANGED
@@ -4,8 +4,7 @@
4
4
  */
5
5
 
6
6
  import { join } from 'node:path'
7
- import { readdirSync } from 'node:fs'
8
- import { write, rm, isFile, resolveIn } from './utils/fs.js'
7
+ import { write, rm, isFile, resolveIn, listFilesRecursively } from './utils/fs.js'
9
8
 
10
9
  import openapi from '../../www/src/assets/openapi.json' with { type: 'json' }
11
10
  import pkgJSON from '../../package.json' with { type: 'json' }
@@ -21,24 +20,25 @@ import { config, ConfigValidator } from './config.js'
21
20
  import * as mockBrokersCollection from './mockBrokersCollection.js'
22
21
 
23
22
 
24
- export const CLIENT_DIR = join(import.meta.dirname, '../client')
25
- const DASHBOARD_ASSETS = readdirSync(CLIENT_DIR, { recursive: true })
26
-
23
+ export const CLIENT_ASSETS = join(import.meta.dirname, '../client')
27
24
 
28
25
  export const apiGetReqs = new Map([
29
26
  [API.dashboard, serveDashboard],
30
- ...DASHBOARD_ASSETS.map(f => [API.dashboard + '/' + f, serveStatic(f)]),
27
+
28
+ ...listFilesRecursively(CLIENT_ASSETS).map(f => [
29
+ API.dashboard + '/' + f,
30
+ serveDashboardAsset(f)
31
+ ]),
31
32
 
32
33
  [API.state, getState],
33
34
  [API.syncVersion, sseClientSyncVersion],
34
35
 
35
36
  [API.watchHotReload, onDevWatch],
36
- [API.throws, () => { throw new Error('Test500') }],
37
- [API.openAPI, (_, response) => response.json(openapi)]
37
+ [API.openAPI, (_, response) => response.json(openapi)],
38
+ [API.throws, () => { throw new Error('Test500') }]
38
39
  ])
39
40
 
40
41
 
41
-
42
42
  export const apiPatchReqs = new Map([
43
43
  [API.cors, setCorsAllowed],
44
44
  [API.reset, reset],
@@ -68,9 +68,9 @@ function serveDashboard(_, response) {
68
68
  response.html(IndexHtml(config.hotReload, pkgJSON.version), CSP)
69
69
  }
70
70
 
71
- function serveStatic(f) {
71
+ function serveDashboardAsset(f) {
72
72
  return (_, response) => {
73
- response.file(join(CLIENT_DIR, f))
73
+ response.file(join(CLIENT_ASSETS, f))
74
74
  }
75
75
  }
76
76
 
@@ -186,7 +186,6 @@ async function setCollectProxied(req, response) {
186
186
 
187
187
  async function bulkUpdateBrokersByCommentTag(req, response) {
188
188
  const comment = await req.json()
189
-
190
189
  mockBrokersCollection.setMocksMatchingComment(comment)
191
190
  response.ok()
192
191
  uiSyncVersion.increment()
@@ -195,7 +194,6 @@ async function bulkUpdateBrokersByCommentTag(req, response) {
195
194
 
196
195
  async function selectMock(req, response) {
197
196
  const file = await req.json()
198
-
199
197
  const broker = mockBrokersCollection.brokerByFilename(file)
200
198
  if (!broker || !broker.hasMock(file))
201
199
  response.unprocessable(`Missing Mock: ${file}`)
@@ -209,7 +207,6 @@ async function selectMock(req, response) {
209
207
 
210
208
  async function toggleRouteStatus(req, response) {
211
209
  const [method, urlMask, status] = await req.json()
212
-
213
210
  const broker = mockBrokersCollection.brokerByRoute(method, urlMask)
214
211
  if (!broker)
215
212
  response.unprocessable(`Route does not exist: ${method} ${urlMask}`)
@@ -223,7 +220,6 @@ async function toggleRouteStatus(req, response) {
223
220
 
224
221
  async function setRouteIsDelayed(req, response) {
225
222
  const [method, urlMask, delayed] = await req.json()
226
-
227
223
  const broker = mockBrokersCollection.brokerByRoute(method, urlMask)
228
224
  if (!broker)
229
225
  response.unprocessable(`Route does not exist: ${method} ${urlMask}`)
@@ -239,7 +235,6 @@ async function setRouteIsDelayed(req, response) {
239
235
 
240
236
  async function setRouteIsProxied(req, response) {
241
237
  const [method, urlMask, proxied] = await req.json()
242
-
243
238
  const broker = mockBrokersCollection.brokerByRoute(method, urlMask)
244
239
  if (!broker)
245
240
  response.unprocessable(`Route does not exist: ${method} ${urlMask}`)
@@ -303,7 +298,6 @@ async function deleteMock(req, response) {
303
298
 
304
299
  async function setWatchMocks(req, response) {
305
300
  const enabled = await req.json()
306
-
307
301
  if (typeof enabled !== 'boolean')
308
302
  response.unprocessable(`Expected boolean for "watchMocks"`)
309
303
  else {
@@ -39,7 +39,7 @@ export async function dispatchMock(req, response) {
39
39
 
40
40
  if (isStatic && req.headers.range && !broker.autoStatus) {
41
41
  setTimeout(async () => {
42
- await response.partialContent(req.headers.range, join(config.mocksDir, broker.file))
42
+ await response.partialContent(join(config.mocksDir, broker.file))
43
43
  }, Number(broker.delayed && calcDelay()))
44
44
  return
45
45
  }
@@ -11,7 +11,7 @@ import { IncomingMessage, BodyReaderError, hasControlChars } from './utils/HttpI
11
11
  import { API } from '../client/ApiConstants.js'
12
12
  import { cookie } from './cookie.js'
13
13
  import { config, setup } from './config.js'
14
- import { apiPatchReqs, apiGetReqs, CLIENT_DIR } from './Api.js'
14
+ import { apiPatchReqs, apiGetReqs, CLIENT_ASSETS } from './Api.js'
15
15
 
16
16
  import { dispatchMock } from './MockDispatcher.js'
17
17
  import * as mockBrokerCollection from './mockBrokersCollection.js'
@@ -27,6 +27,7 @@ export function Mockaton(options) {
27
27
  mockBrokerCollection.init()
28
28
 
29
29
  register('./resolverResolveExtensionless.js', import.meta.url)
30
+
30
31
  if (config.bypassImportCache)
31
32
  register('./resolverBypassImportCache.js', import.meta.url)
32
33
 
@@ -34,7 +35,7 @@ export function Mockaton(options) {
34
35
  watchMocksDir()
35
36
 
36
37
  if (config.hotReload)
37
- watchDevSPA(CLIENT_DIR)
38
+ watchDevSPA(CLIENT_ASSETS)
38
39
 
39
40
  const server = createServer({ IncomingMessage, ServerResponse }, onRequest)
40
41
  server.on('error', reject)
@@ -1047,7 +1047,7 @@ describe('Write and Delete Mock', () => {
1047
1047
  })
1048
1048
 
1049
1049
 
1050
- describe('import resolver', () => {
1050
+ describe('import resolvers', () => {
1051
1051
  test('resolves extensionless ts', async () => {
1052
1052
  await api.writeMock('_scores.ts', 'export default [1,2,3]')
1053
1053
  await api.writeMock('user-scores.GET.200.ts',
@@ -2,7 +2,6 @@ import { resolve as _resolve } from 'node:path'
2
2
 
3
3
  const mockatonSrcRoot = `file://${_resolve(import.meta.dirname, '..')}`
4
4
 
5
-
6
5
  // We register this hook at runtime so it doesn’t interfere with non-dynamic imports.
7
6
  // Cache bust by appending timestamp query param
8
7
  export async function resolve(specifier, context, nextResolve) {
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  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
7
  try {
7
8
  return await nextResolve(specifier, context)
@@ -1,5 +1,6 @@
1
- import http from 'node:http'
2
1
  import fs from 'node:fs'
2
+ import http from 'node:http'
3
+ import { pipeline } from 'node:stream/promises'
3
4
 
4
5
  import { mimeFor } from './mime.js'
5
6
 
@@ -27,7 +28,7 @@ export class ServerResponse extends http.ServerResponse {
27
28
 
28
29
  async file(file) {
29
30
  this.setHeader('Content-Type', mimeFor(file))
30
- this.end(await fs.promises.readFile(file, 'utf8'))
31
+ await pipeline(fs.createReadStream(file), this)
31
32
  }
32
33
 
33
34
  noContent() {
@@ -73,13 +74,18 @@ export class ServerResponse extends http.ServerResponse {
73
74
  }
74
75
 
75
76
 
76
- async partialContent(range, file) {
77
+ async partialContent(file) {
77
78
  const { size } = await fs.promises.lstat(file)
78
- let [start, end] = range.replace(/bytes=/, '').split('-').map(n => parseInt(n, 10))
79
- if (isNaN(end)) end = size - 1
80
- if (isNaN(start)) start = size - end
79
+ let [start, end] = this.req.headers.range.replace(/bytes=/, '').split('-').map(n => parseInt(n, 10))
80
+
81
+ if (isNaN(start)) {
82
+ start = size - end
83
+ end = size - 1
84
+ }
85
+ else if (isNaN(end))
86
+ end = size - 1
81
87
 
82
- if (start < 0 || start > end || start >= size || end >= size) {
88
+ if (start < 0 || end >= size || start > end) {
83
89
  this.statusCode = 416 // Range Not Satisfiable
84
90
  this.setHeader('Content-Range', `bytes */${size}`)
85
91
  this.end()
@@ -89,14 +95,11 @@ export class ServerResponse extends http.ServerResponse {
89
95
  this.statusCode = 206 // Partial Content
90
96
  this.setHeader('Accept-Ranges', 'bytes')
91
97
  this.setHeader('Content-Range', `bytes ${start}-${end}/${size}`)
98
+ this.setHeader('Content-Length', (end - start) + 1)
92
99
  this.setHeader('Content-Type', mimeFor(file))
93
100
 
94
- return new Promise((resolve, reject) => {
95
- const reader = fs.createReadStream(file, { start, end })
96
- this.on('error', reject)
97
- reader.on('error', reject)
98
- reader.on('end', resolve)
99
- reader.pipe(this)
100
- })
101
+ const stream = fs.createReadStream(file, { start, end })
102
+ this.on('close', () => stream.destroy())
103
+ stream.pipe(this)
101
104
  }
102
105
  }
@@ -0,0 +1,84 @@
1
+ import { describe, test, before, after } from 'node:test'
2
+ import { mkdtempSync, writeFileSync } from 'node:fs'
3
+ import http, { createServer } from 'node:http'
4
+ import { join, dirname } from 'node:path'
5
+ import { strictEqual } from 'node:assert'
6
+ import { tmpdir } from 'node:os'
7
+ import { rm } from 'node:fs/promises'
8
+
9
+ import { ServerResponse } from './HttpServerResponse.js'
10
+
11
+ describe('ServerResponse.partialContent (real HTTP)', () => {
12
+ const FILE = '0123456789'
13
+ const FILE_SIZE = FILE.length
14
+
15
+ let tmpFile, server, baseUrl
16
+
17
+ before(async () => {
18
+ const tmpDir = mkdtempSync(join(tmpdir(), 'response-'))
19
+ tmpFile = join(tmpDir, 'test.txt')
20
+ writeFileSync(tmpFile, FILE)
21
+ server = createServer({ ServerResponse }, async (_, response) => {
22
+ await response.partialContent(tmpFile)
23
+ })
24
+ await new Promise(resolve => server.listen(0, () => {
25
+ const { port } = server.address()
26
+ baseUrl = `http://127.0.0.1:${port}`
27
+ resolve()
28
+ }))
29
+ })
30
+
31
+ after(async () => {
32
+ server?.close()
33
+ await rm(dirname(tmpFile), { recursive: true, force: true })
34
+ })
35
+
36
+ function request(range) {
37
+ return new Promise((resolve, reject) => {
38
+ const req = http.get(baseUrl, { headers: { range } }, response => {
39
+ let data = ''
40
+ response.setEncoding('utf8')
41
+ response.on('data', chunk => data += chunk)
42
+ response.on('end', () => resolve({
43
+ statusCode: response.statusCode,
44
+ headers: response.headers,
45
+ data
46
+ }))
47
+ })
48
+ req.on('error', reject)
49
+ })
50
+ }
51
+
52
+ test('416 - out of bounds', async () => {
53
+ for (const range of ['bytes=10-12', 'bytes=5-2', 'bytes=12-', 'bytes=-15']) {
54
+ const { statusCode, headers } = await request(range)
55
+ strictEqual(statusCode, 416)
56
+ strictEqual(headers['content-range'], `bytes */${FILE_SIZE}`)
57
+ }
58
+ })
59
+
60
+ test('206 - normal range', async () => {
61
+ const { statusCode, headers, data } = await request('bytes=0-4')
62
+ strictEqual(statusCode, 206)
63
+ strictEqual(headers['content-range'], `bytes 0-4/${FILE_SIZE}`)
64
+ strictEqual(headers['content-length'], '5')
65
+ strictEqual(headers['content-type'], 'text/plain')
66
+ strictEqual(data, '01234')
67
+ })
68
+
69
+ test('206 - suffix range', async () => {
70
+ const { statusCode, headers, data } = await request('bytes=-3')
71
+ strictEqual(statusCode, 206)
72
+ strictEqual(headers['content-range'], `bytes 7-9/${FILE_SIZE}`)
73
+ strictEqual(headers['content-length'], '3')
74
+ strictEqual(data, '789')
75
+ })
76
+
77
+ test('206 - open ended range', async () => {
78
+ const { statusCode, headers, data } = await request('bytes=5-')
79
+ strictEqual(statusCode, 206)
80
+ strictEqual(headers['content-range'], `bytes 5-9/${FILE_SIZE}`)
81
+ strictEqual(headers['content-length'], '5')
82
+ strictEqual(data, '56789')
83
+ })
84
+ })
@@ -34,8 +34,8 @@ export async function rm(path) {
34
34
  export async function resolveIn(baseDir, file) {
35
35
  try {
36
36
  const parent = await realpath(baseDir)
37
- const child = resolve(parent, file)
38
- return child.startsWith(parent + sep)
37
+ const child = resolve(join(parent, file))
38
+ return child.startsWith(join(parent, sep))
39
39
  ? child
40
40
  : null
41
41
  }
@@ -0,0 +1,31 @@
1
+ import { join } from 'node:path'
2
+ import { equal } from 'node:assert/strict'
3
+ import { tmpdir } from 'node:os'
4
+ import { after, describe, test } from 'node:test'
5
+ import { mkdtempSync, rmSync, realpathSync } from 'node:fs'
6
+
7
+ import { resolveIn } from './fs.js'
8
+
9
+ const isNull = v => equal(v, null)
10
+
11
+ describe('resolveIn', () => {
12
+ const baseDir = mkdtempSync(join(tmpdir(), '_resolveIn'))
13
+ const baseParentDir = join(baseDir, '..')
14
+ after(() => rmSync(baseDir, { recursive: true, force: true }))
15
+
16
+ test('null when baseDir does not exist', async () =>
17
+ isNull(await resolveIn(join(baseParentDir, 'missing'), 'file.json')))
18
+
19
+ test('null when relative path escapes baseDir', async () =>
20
+ isNull(await resolveIn(baseDir, '../outside.json')))
21
+
22
+
23
+ const realBaseDir = realpathSync(baseDir)
24
+ const onReal = f => join(realBaseDir, f)
25
+
26
+ test('resolves a relative file within baseDir', async () =>
27
+ equal(await resolveIn(baseDir, 'file.json'), onReal('file.json')))
28
+
29
+ test('resolves file starting with /', async () =>
30
+ equal(await resolveIn(baseDir, '/file.json'), onReal('file.json')))
31
+ })