mockaton 8.8.1 → 8.9.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
@@ -23,9 +23,9 @@ other features such as delaying responses, or triggering an autogenerated
23
23
  which is handy for setting up tests (see **Commander API** below).
24
24
 
25
25
  <picture>
26
- <source media="(prefers-color-scheme: light)" srcset="./pixaton-tests/pic-for-readme.vp860x800.light.gold.png">
27
- <source media="(prefers-color-scheme: dark)" srcset="./pixaton-tests/pic-for-readme.vp860x800.dark.gold.png">
28
- <img alt="Mockaton Dashboard" src="./pixaton-tests/pic-for-readme.vp860x800.light.gold.png">
26
+ <source media="(prefers-color-scheme: light)" srcset="./pixaton-tests/pic-for-readme.vp830x800.light.gold.png">
27
+ <source media="(prefers-color-scheme: dark)" srcset="./pixaton-tests/pic-for-readme.vp830x800.dark.gold.png">
28
+ <img alt="Mockaton Dashboard" src="./pixaton-tests/pic-for-readme.vp830x800.light.gold.png">
29
29
  </picture>
30
30
 
31
31
 
package/TODO.md CHANGED
@@ -1,4 +1,6 @@
1
1
  # TODO
2
2
 
3
3
  - Refactor tests
4
- - Dashboard refresh (long polling?)
4
+ - Preserve focus when refreshing dashboard `init()`
5
+ - More real-time updates. Currently, it's only for add/remove mock but not for
6
+ static files and changes from another client (Browser, or Commander).
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "mockaton",
3
3
  "description": "A deterministic server-side for developing and testing frontend clients",
4
4
  "type": "module",
5
- "version": "8.8.1",
5
+ "version": "8.9.0",
6
6
  "main": "index.js",
7
7
  "types": "index.d.ts",
8
8
  "license": "MIT",
package/src/Api.js CHANGED
@@ -6,11 +6,12 @@
6
6
  import { join } from 'node:path'
7
7
  import { cookie } from './cookie.js'
8
8
  import { config } from './config.js'
9
- import { DF, API } from './ApiConstants.js'
10
9
  import { parseJSON } from './utils/http-request.js'
11
10
  import { listFilesRecursively } from './utils/fs.js'
12
11
  import * as mockBrokersCollection from './mockBrokersCollection.js'
13
- import { sendOK, sendBadRequest, sendJSON, sendUnprocessableContent, sendDashboardFile, sendForbidden } from './utils/http-response.js'
12
+ import { DF, API, LONG_POLL_SERVER_TIMEOUT } from './ApiConstants.js'
13
+ import { countAR_Events, subscribeAR_EventListener, unsubscribeAR_EventListener } from './Watcher.js'
14
+ import { sendOK, sendJSON, sendUnprocessableContent, sendDashboardFile, sendForbidden } from './utils/http-response.js'
14
15
 
15
16
 
16
17
  const dashboardAssets = [
@@ -26,25 +27,26 @@ export const apiGetRequests = new Map([
26
27
  [API.dashboard, serveDashboard],
27
28
  ...dashboardAssets.map(f =>
28
29
  [API.dashboard + f, serveDashboardAsset]),
30
+ [API.cors, getIsCorsAllowed],
31
+ [API.static, listStaticFiles],
29
32
  [API.mocks, listMockBrokers],
30
33
  [API.cookies, listCookies],
31
- [API.comments, listComments],
32
34
  [API.fallback, getProxyFallback],
33
- [API.cors, getIsCorsAllowed],
34
- [API.collectProxied, getCollectProxied],
35
- [API.static, listStaticFiles]
35
+ [API.arEvents, longPollAR_Events],
36
+ [API.comments, listComments],
37
+ [API.collectProxied, getCollectProxied]
36
38
  ])
37
39
 
38
40
  export const apiPatchRequests = new Map([
39
- [API.select, selectMock],
41
+ [API.cors, setCorsAllowed],
40
42
  [API.delay, setRouteIsDelayed],
41
- [API.proxied, setRouteIsProxied],
42
43
  [API.reset, reinitialize],
44
+ [API.select, selectMock],
45
+ [API.proxied, setRouteIsProxied],
43
46
  [API.cookies, selectCookie],
44
47
  [API.fallback, updateProxyFallback],
45
- [API.collectProxied, setCollectProxied],
46
48
  [API.bulkSelect, bulkUpdateBrokersByCommentTag],
47
- [API.cors, setCorsAllowed]
49
+ [API.collectProxied, setCollectProxied]
48
50
  ])
49
51
 
50
52
  /* === GET === */
@@ -52,6 +54,7 @@ export const apiPatchRequests = new Map([
52
54
  function serveDashboard(_, response) {
53
55
  sendDashboardFile(response, join(import.meta.dirname, 'Dashboard.html'))
54
56
  }
57
+
55
58
  function serveDashboardAsset(req, response) {
56
59
  const f = req.url.replace(API.dashboard, '')
57
60
  if (dashboardAssets.includes(f))
@@ -68,18 +71,27 @@ function getIsCorsAllowed(_, response) { sendJSON(response, config.corsAllowed)
68
71
  function getCollectProxied(_, response) { sendJSON(response, config.collectProxied) }
69
72
 
70
73
  function listStaticFiles(req, response) {
71
- try {
72
- const files = config.staticDir
73
- ? listFilesRecursively(config.staticDir).filter(f => !config.ignore.test(f))
74
- : []
75
- sendJSON(response, files)
76
- }
77
- catch (error) {
78
- sendBadRequest(response, error)
79
- }
74
+ const files = config.staticDir
75
+ ? listFilesRecursively(config.staticDir).filter(f => !config.ignore.test(f))
76
+ : []
77
+ sendJSON(response, files)
78
+ }
79
+
80
+ function longPollAR_Events(req, response) {
81
+ function onAddOrRemoveMock() {
82
+ unsubscribeAR_EventListener(onAddOrRemoveMock)
83
+ sendJSON(response, countAR_Events())
84
+ }
85
+ response.setTimeout(LONG_POLL_SERVER_TIMEOUT, onAddOrRemoveMock)
86
+ req.on('error', () => {
87
+ unsubscribeAR_EventListener(onAddOrRemoveMock)
88
+ response.destroy()
89
+ })
90
+ subscribeAR_EventListener(onAddOrRemoveMock)
80
91
  }
81
92
 
82
93
 
94
+
83
95
  /* === PATCH === */
84
96
 
85
97
  function reinitialize(_, response) {
@@ -88,124 +100,84 @@ function reinitialize(_, response) {
88
100
  }
89
101
 
90
102
  async function selectCookie(req, response) {
91
- try {
92
- const error = cookie.setCurrent(await parseJSON(req))
93
- if (error)
94
- sendUnprocessableContent(response, error)
95
- else
96
- sendOK(response)
97
- }
98
- catch (error) {
99
- sendBadRequest(response, error)
100
- }
103
+ const error = cookie.setCurrent(await parseJSON(req))
104
+ if (error)
105
+ sendUnprocessableContent(response, error)
106
+ else
107
+ sendOK(response)
101
108
  }
102
109
 
103
110
  async function selectMock(req, response) {
104
- try {
105
- const file = await parseJSON(req)
106
- const broker = mockBrokersCollection.getBrokerByFilename(file)
107
- if (!broker || !broker.hasMock(file))
108
- sendUnprocessableContent(response, `Missing Mock: ${file}`)
109
- else {
110
- broker.updateFile(file)
111
- sendOK(response)
112
- }
113
- }
114
- catch (error) {
115
- sendBadRequest(response, error)
111
+ const file = await parseJSON(req)
112
+ const broker = mockBrokersCollection.getBrokerByFilename(file)
113
+ if (!broker || !broker.hasMock(file))
114
+ sendUnprocessableContent(response, `Missing Mock: ${file}`)
115
+ else {
116
+ broker.updateFile(file)
117
+ sendOK(response)
116
118
  }
117
119
  }
118
120
 
119
121
  async function setRouteIsDelayed(req, response) {
120
- try {
121
- const body = await parseJSON(req)
122
- const delayed = body[DF.delayed]
123
- const broker = mockBrokersCollection.getBrokerForUrl(
124
- body[DF.routeMethod],
125
- body[DF.routeUrlMask])
126
-
127
- if (!broker) // TESTME
128
- sendUnprocessableContent(response, `Route does not exist: ${body[DF.routeUrlMask]} ${body[DF.routeUrlMask]}`)
129
- else if (typeof delayed !== 'boolean')
130
- sendUnprocessableContent(response, `Expected a boolean for "delayed"`) // TESTME
131
- else {
132
- broker.updateDelay(body[DF.delayed])
133
- sendOK(response)
134
- }
135
- }
136
- catch (error) {
137
- sendBadRequest(response, error)
122
+ const body = await parseJSON(req)
123
+ const delayed = body[DF.delayed]
124
+ const broker = mockBrokersCollection.getBrokerForUrl(
125
+ body[DF.routeMethod],
126
+ body[DF.routeUrlMask])
127
+
128
+ if (!broker) // TESTME
129
+ sendUnprocessableContent(response, `Route does not exist: ${body[DF.routeUrlMask]} ${body[DF.routeUrlMask]}`)
130
+ else if (typeof delayed !== 'boolean')
131
+ sendUnprocessableContent(response, `Expected a boolean for "delayed"`) // TESTME
132
+ else {
133
+ broker.updateDelay(body[DF.delayed])
134
+ sendOK(response)
138
135
  }
139
136
  }
140
137
 
141
138
  async function setRouteIsProxied(req, response) { // TESTME
142
- try {
143
- const body = await parseJSON(req)
144
- const proxied = body[DF.proxied]
145
- const broker = mockBrokersCollection.getBrokerForUrl(
146
- body[DF.routeMethod],
147
- body[DF.routeUrlMask])
148
-
149
- if (!broker)
150
- sendUnprocessableContent(response, `Route does not exist: ${body[DF.routeUrlMask]} ${body[DF.routeUrlMask]}`)
151
- else if (typeof proxied !== 'boolean')
152
- sendUnprocessableContent(response, `Expected a boolean for "proxied"`)
153
- else if (proxied && !config.proxyFallback)
154
- sendUnprocessableContent(response, `There’s no proxy fallback`)
155
- else {
156
- broker.updateProxied(proxied)
157
- sendOK(response)
158
- }
159
- }
160
- catch (error) {
161
- sendBadRequest(response, error)
139
+ const body = await parseJSON(req)
140
+ const proxied = body[DF.proxied]
141
+ const broker = mockBrokersCollection.getBrokerForUrl(
142
+ body[DF.routeMethod],
143
+ body[DF.routeUrlMask])
144
+
145
+ if (!broker)
146
+ sendUnprocessableContent(response, `Route does not exist: ${body[DF.routeUrlMask]} ${body[DF.routeUrlMask]}`)
147
+ else if (typeof proxied !== 'boolean')
148
+ sendUnprocessableContent(response, `Expected a boolean for "proxied"`)
149
+ else if (proxied && !config.proxyFallback)
150
+ sendUnprocessableContent(response, `There’s no proxy fallback`)
151
+ else {
152
+ broker.updateProxied(proxied)
153
+ sendOK(response)
162
154
  }
163
155
  }
164
156
 
165
157
  async function updateProxyFallback(req, response) {
166
- try {
167
- const fallback = await parseJSON(req)
168
- if (fallback && !URL.canParse(fallback)) {
169
- sendUnprocessableContent(response)
170
- return
171
- }
172
- if (!fallback) // TESTME
173
- mockBrokersCollection.ensureAllRoutesHaveSelectedMock()
174
- config.proxyFallback = fallback
175
- sendOK(response)
176
- }
177
- catch (error) {
178
- sendBadRequest(response, error)
179
- }
158
+ const fallback = await parseJSON(req)
159
+ if (fallback && !URL.canParse(fallback)) {
160
+ sendUnprocessableContent(response)
161
+ return
162
+ }
163
+ if (!fallback) // TESTME
164
+ mockBrokersCollection.ensureAllRoutesHaveSelectedMock()
165
+ config.proxyFallback = fallback
166
+ sendOK(response)
180
167
  }
181
168
 
182
169
  async function setCollectProxied(req, response) {
183
- try {
184
- config.collectProxied = await parseJSON(req)
185
- sendOK(response)
186
- }
187
- catch (error) {
188
- sendBadRequest(response, error)
189
- }
170
+ config.collectProxied = await parseJSON(req)
171
+ sendOK(response)
190
172
  }
191
173
 
192
174
  async function bulkUpdateBrokersByCommentTag(req, response) {
193
- try {
194
- mockBrokersCollection.setMocksMatchingComment(await parseJSON(req))
195
- sendOK(response)
196
- }
197
- catch (error) {
198
- sendBadRequest(response, error)
199
- }
175
+ mockBrokersCollection.setMocksMatchingComment(await parseJSON(req))
176
+ sendOK(response)
200
177
  }
201
178
 
202
179
  async function setCorsAllowed(req, response) {
203
- try {
204
- config.corsAllowed = await parseJSON(req)
205
- sendOK(response)
206
- }
207
- catch (error) {
208
- sendBadRequest(response, error)
209
- }
180
+ config.corsAllowed = await parseJSON(req)
181
+ sendOK(response)
210
182
  }
211
183
 
@@ -12,7 +12,8 @@ export const API = {
12
12
  collectProxied: MOUNT + '/collect-proxied',
13
13
  proxied: MOUNT + '/proxied',
14
14
  cors: MOUNT + '/cors',
15
- static: MOUNT + '/static'
15
+ static: MOUNT + '/static',
16
+ arEvents: MOUNT + '/ar_events'
16
17
  }
17
18
 
18
19
  export const DF = { // Dashboard Fields (XHR)
@@ -25,3 +26,5 @@ export const DF = { // Dashboard Fields (XHR)
25
26
  export const DEFAULT_500_COMMENT = '(Mockaton 500)'
26
27
  export const DEFAULT_MOCK_COMMENT = '(default)'
27
28
  export const EXT_FOR_UNKNOWN_MIME = 'unknown'
29
+
30
+ export const LONG_POLL_SERVER_TIMEOUT = 8_000
package/src/Commander.js CHANGED
@@ -1,4 +1,4 @@
1
- import { API, DF } from './ApiConstants.js'
1
+ import { API, DF, LONG_POLL_SERVER_TIMEOUT } from './ApiConstants.js'
2
2
 
3
3
 
4
4
  // Client for controlling Mockaton via its HTTP API
@@ -84,4 +84,10 @@ export class Commander {
84
84
  reset() {
85
85
  return this.#patch(API.reset)
86
86
  }
87
+
88
+ getAR_EventsCount() {
89
+ return fetch(API.arEvents, {
90
+ signal: AbortSignal.timeout(LONG_POLL_SERVER_TIMEOUT + 1000)
91
+ })
92
+ }
87
93
  }
package/src/Dashboard.css CHANGED
@@ -1,18 +1,19 @@
1
1
  :root {
2
2
  --boxShadow1: 0 2px 1px -1px rgba(0, 0, 0, 0.15), 0 1px 1px 0 rgba(0, 0, 0, 0.15), 0 1px 3px 0 rgba(0, 0, 0, 0.1);
3
- --radius: 6px
3
+ --radius: 4px;
4
+ --radiusSmall: 2px;
4
5
  }
5
6
 
6
7
  @media (prefers-color-scheme: light) {
7
8
  :root {
8
9
  --color4xxBackground: #ffedd1;
9
10
  --colorAccent: #0075db;
10
- --colorAccentAlt: #068564;
11
+ --colorAccentAlt: #068185;
11
12
  --colorBackground: #fff;
12
13
  --colorComboBoxHeaderBackground: #fff;
13
14
  --colorComboBoxBackground: #f7f7f7;
14
15
  --colorHeaderBackground: #f3f3f3;
15
- --colorSecondaryButtonBackground: transparent;
16
+ --colorSecondaryButtonBackground: #f3f3f3;
16
17
  --colorSecondaryAction: #555;
17
18
  --colorDisabledMockSelector: #444;
18
19
  --colorHover: #dfefff;
@@ -77,18 +78,22 @@ a, button, input[type=checkbox] {
77
78
 
78
79
  select {
79
80
  font-size: 100%;
80
- background: var(--colorComboBoxBackground);
81
81
  color: var(--colorText);
82
82
  cursor: pointer;
83
83
  outline: 0;
84
84
  border-radius: var(--radius);
85
+ appearance: none;
86
+ background: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23888888'><path d='M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z'/></svg>") no-repeat;
87
+ background-color: var(--colorComboBoxBackground);
88
+ background-size: 16px;
89
+ background-position: 100% center;
85
90
 
86
91
  &:enabled {
87
92
  box-shadow: var(--boxShadow1);
88
93
  }
89
94
  &:enabled:hover {
90
95
  cursor: pointer;
91
- background: var(--colorHover);
96
+ background-color: var(--colorHover);
92
97
  }
93
98
  &:disabled {
94
99
  cursor: not-allowed;
@@ -112,11 +117,11 @@ select {
112
117
  img {
113
118
  width: 130px;
114
119
  align-self: center;
115
- margin-right: 18px;
120
+ margin-right: 20px;
116
121
  }
117
122
 
118
123
  .Field {
119
- width: 120px;
124
+ width: 132px;
120
125
 
121
126
  span {
122
127
  display: flex;
@@ -130,11 +135,11 @@ select {
130
135
  select {
131
136
  width: 100%;
132
137
  height: 28px;
133
- padding: 4px 2px;
138
+ padding: 4px 8px;
134
139
  border-right: 3px solid transparent;
135
140
  margin-top: 4px;
136
141
  font-size: 11px;
137
- background: var(--colorComboBoxHeaderBackground);
142
+ background-color: var(--colorComboBoxHeaderBackground);
138
143
  border-radius: var(--radius);
139
144
  }
140
145
 
@@ -171,25 +176,26 @@ select {
171
176
  gap: 4px;
172
177
 
173
178
  input:disabled + span {
174
- opacity: 0.7;
179
+ opacity: 0.8;
175
180
  }
176
181
  }
177
182
  }
178
183
  }
179
184
 
180
185
  .BulkSelector {
181
- appearance: none;
182
- text-align: center;
186
+ background-image: none;
187
+ text-align-last: center;
183
188
  }
184
189
 
185
190
  .ResetButton {
186
- padding: 4px 12px;
191
+ padding: 6px 12px;
187
192
  border: 1px solid var(--colorRed);
188
- margin-bottom: 4px;
193
+ margin-left: 4px;
189
194
  background: transparent;
190
195
  color: var(--colorRed);
191
196
  border-radius: 50px;
192
197
 
198
+
193
199
  &:hover {
194
200
  background: var(--colorRed);
195
201
  color: white;
@@ -255,7 +261,7 @@ select {
255
261
  position: relative;
256
262
  left: -6px;
257
263
  display: inline-block;
258
- width: 300px;
264
+ width: 278px;
259
265
  padding: 8px 6px;
260
266
  border-radius: var(--radius);
261
267
  color: var(--colorAccent);
@@ -274,12 +280,17 @@ select {
274
280
  width: 260px;
275
281
  height: 30px;
276
282
  border: 0;
277
- border-left: 3px solid transparent;
283
+ padding-right: 5px;
278
284
  text-align: right;
279
285
  direction: rtl;
280
286
  text-overflow: ellipsis;
281
287
  font-size: 12px;
288
+ background-position: 2px center;
282
289
 
290
+ &.nonDefault {
291
+ font-weight: bold;
292
+ font-size: 0.92rem;
293
+ }
283
294
  &.status4xx {
284
295
  background: var(--color4xxBackground);
285
296
  }
@@ -315,32 +326,36 @@ select {
315
326
  }
316
327
 
317
328
  &:checked ~ svg {
329
+ border-color: transparent;
318
330
  background: var(--colorAccent);
319
331
  fill: white;
332
+ box-shadow: var(--boxShadow1);
320
333
  }
321
334
 
322
335
  &:disabled ~ svg {
323
336
  opacity: .5;
324
337
  cursor: not-allowed;
338
+ box-shadow: none;
325
339
  }
326
340
  }
327
341
 
328
342
  > svg {
329
- width: 20px;
330
- height: 20px;
343
+ width: 18px;
344
+ height: 18px;
331
345
  vertical-align: bottom;
332
346
  fill: var(--colorSecondaryAction);
333
347
  border-radius: 50%;
334
348
  background: var(--colorSecondaryButtonBackground);
335
- box-shadow: var(--boxShadow1);
336
349
  }
337
350
  }
338
351
 
339
352
  .ProxyToggler {
353
+ margin-left: 4px;
340
354
  > svg {
341
- width: 24px;
342
- padding: 3px;
343
- border-radius: 4px;
355
+ width: 22px;
356
+ padding: 1px;
357
+ border-color: transparent;
358
+ border-radius: var(--radiusSmall);
344
359
  }
345
360
  }
346
361
 
@@ -362,18 +377,19 @@ select {
362
377
  &:checked ~ span {
363
378
  color: white;
364
379
  background: var(--colorRed);
380
+ box-shadow: var(--boxShadow1);
365
381
  }
366
382
  }
367
383
 
368
384
  > span {
369
385
  padding: 5px 4px;
370
- box-shadow: var(--boxShadow1);
371
386
  font-size: 10px;
372
387
  color: var(--colorSecondaryAction);
373
- border-radius: 4px;
388
+ border-radius: var(--radiusSmall);
374
389
  background: var(--colorSecondaryButtonBackground);
375
390
 
376
391
  &:hover {
392
+ box-shadow: var(--boxShadow1);
377
393
  background: var(--colorLightRed);
378
394
  color: var(--colorRed);
379
395
  }
@@ -406,10 +422,6 @@ select {
406
422
  }
407
423
  }
408
424
 
409
- .bold {
410
- font-weight: bold;
411
- }
412
-
413
425
  .StaticFilesList {
414
426
  margin-top: 20px;
415
427
 
package/src/Dashboard.js CHANGED
@@ -1,6 +1,6 @@
1
+ import { DEFAULT_500_COMMENT } from './ApiConstants.js'
1
2
  import { parseFilename } from './Filename.js'
2
3
  import { Commander } from './Commander.js'
3
- import { DEFAULT_500_COMMENT } from './ApiConstants.js'
4
4
 
5
5
 
6
6
  function syntaxHighlightJson(textBody) {
@@ -10,13 +10,12 @@ function syntaxHighlightJson(textBody) {
10
10
  : false
11
11
  }
12
12
 
13
-
14
13
  const Strings = {
15
14
  bulk_select: 'Bulk Select',
16
15
  bulk_select_disabled_title: 'No mock files have comments, which are anything within parentheses on the filename.',
17
16
  click_link_to_preview: 'Click a link to preview it',
18
17
  cookie: 'Cookie',
19
- cookie_disabled_title: 'No cookies specified in Config.cookies',
18
+ cookie_disabled_title: 'No cookies specified in config.cookies',
20
19
  delay: 'Delay',
21
20
  empty_response_body: '/* Empty Response Body */',
22
21
  fallback_server: 'Fallback Backend',
@@ -50,16 +49,22 @@ const CSS = {
50
49
  SaveProxiedCheckbox: 'SaveProxiedCheckbox',
51
50
  StaticFilesList: 'StaticFilesList',
52
51
 
53
- bold: 'bold',
54
52
  empty: 'empty',
55
53
  chosen: 'chosen',
56
- status4xx: 'status4xx'
54
+ status4xx: 'status4xx',
55
+ nonDefault: 'nonDefault'
57
56
  }
58
57
 
59
58
  const r = createElement
60
-
61
59
  const mockaton = new Commander(window.location.origin)
62
60
 
61
+ init()
62
+ pollAR_Events() // Add or Remove Mocks from File System
63
+ document.addEventListener('visibilitychange', () => {
64
+ if (!document.hidden)
65
+ pollAR_Events()
66
+ })
67
+
63
68
  function init() {
64
69
  return Promise.all([
65
70
  mockaton.listMocks(),
@@ -69,20 +74,18 @@ function init() {
69
74
  mockaton.getProxyFallback(),
70
75
  mockaton.listStaticFiles()
71
76
  ].map(api => api.then(response => response.ok && response.json())))
72
- .then(data => document.body.replaceChildren(...App(data)))
77
+ .then(data => document.body.replaceChildren(App(data)))
73
78
  .catch(onError)
74
79
  }
75
- init()
76
80
 
77
81
  function App([brokersByMethod, cookies, comments, collectProxied, fallbackAddress, staticFiles]) {
78
- return [
79
- r(Header, { cookies, comments, fallbackAddress, collectProxied }),
80
- r(MockList, { brokersByMethod, canProxy: Boolean(fallbackAddress) }),
81
- r(StaticFilesList, { staticFiles })
82
- ]
82
+ return (
83
+ r('div', null,
84
+ r(Header, { cookies, comments, fallbackAddress, collectProxied }),
85
+ r(MockList, { brokersByMethod, canProxy: Boolean(fallbackAddress) }),
86
+ r(StaticFilesList, { staticFiles })))
83
87
  }
84
88
 
85
-
86
89
  // Header ===============
87
90
 
88
91
  function Header({ cookies, comments, fallbackAddress, collectProxied }) {
@@ -106,8 +109,7 @@ function Logo() {
106
109
 
107
110
  function CookieSelector({ cookies }) {
108
111
  function onChange() {
109
- mockaton.selectCookie(this.value)
110
- .catch(onError)
112
+ mockaton.selectCookie(this.value).catch(onError)
111
113
  }
112
114
  const disabled = cookies.length <= 1
113
115
  return (
@@ -166,9 +168,7 @@ function ProxyFallbackField({ fallbackAddress, collectProxied }) {
166
168
  return (
167
169
  r('div', { className: cssClass(CSS.Field, CSS.FallbackBackend) },
168
170
  r('label', null,
169
- r('span', null,
170
- r(CloudIcon),
171
- Strings.fallback_server),
171
+ r('span', null, r(CloudIcon), Strings.fallback_server),
172
172
  r('input', {
173
173
  type: 'url',
174
174
  autocomplete: 'none',
@@ -268,9 +268,6 @@ function PreviewLink({ method, urlMask }) {
268
268
  function MockSelector({ broker }) {
269
269
  function onChange() {
270
270
  const { urlMask, method } = parseFilename(this.value)
271
- this.style.fontWeight = this.value === this.options[0].value // default is selected
272
- ? 'normal'
273
- : 'bold'
274
271
  mockaton.select(this.value)
275
272
  .then(init)
276
273
  .then(() => linkFor(method, urlMask)?.click())
@@ -289,14 +286,14 @@ function MockSelector({ broker }) {
289
286
 
290
287
  return (
291
288
  r('select', {
292
- 'data-qaid': urlMask,
289
+ onChange,
293
290
  autocomplete: 'off',
291
+ 'data-qaid': urlMask,
292
+ disabled: files.length <= 1,
294
293
  className: cssClass(
295
294
  CSS.MockSelector,
296
- selected !== files[0] && CSS.bold,
297
- status >= 400 && status < 500 && CSS.status4xx),
298
- disabled: files.length <= 1,
299
- onChange
295
+ selected !== files[0] && CSS.nonDefault,
296
+ status >= 400 && status < 500 && CSS.status4xx)
300
297
  }, files.map(file =>
301
298
  r('option', {
302
299
  value: file,
@@ -304,12 +301,10 @@ function MockSelector({ broker }) {
304
301
  }, file))))
305
302
  }
306
303
 
307
-
308
304
  function DelayRouteToggler({ broker }) {
309
305
  function onChange() {
310
- const { method, urlMask } = parseFilename(this.name)
311
- mockaton.setRouteIsDelayed(method, urlMask, this.checked)
312
- .catch(onError)
306
+ const { method, urlMask } = parseFilename(broker.mocks[0])
307
+ mockaton.setRouteIsDelayed(method, urlMask, this.checked).catch(onError)
313
308
  }
314
309
  return (
315
310
  r('label', {
@@ -318,7 +313,6 @@ function DelayRouteToggler({ broker }) {
318
313
  },
319
314
  r('input', {
320
315
  type: 'checkbox',
321
- name: broker.currentMock.file,
322
316
  checked: Boolean(broker.currentMock.delay),
323
317
  onChange
324
318
  }),
@@ -348,14 +342,13 @@ function InternalServerErrorToggler({ broker }) {
348
342
  checked: parseFilename(broker.currentMock.file).status === 500,
349
343
  onChange
350
344
  }),
351
- r('span', null, '500')
352
- )
353
- )
345
+ r('span', null, '500')))
354
346
  }
355
347
 
348
+
356
349
  function ProxyToggler({ broker, disabled }) {
357
350
  function onChange() {
358
- const { urlMask, method } = parseFilename(this.name)
351
+ const { urlMask, method } = parseFilename(broker.mocks[0])
359
352
  mockaton.setRouteIsProxied(method, urlMask, this.checked)
360
353
  .then(init)
361
354
  .then(() => linkFor(method, urlMask)?.click())
@@ -369,7 +362,6 @@ function ProxyToggler({ broker, disabled }) {
369
362
  r('input', {
370
363
  type: 'checkbox',
371
364
  disabled,
372
- name: broker.currentMock.file,
373
365
  checked: !broker.currentMock.file,
374
366
  onChange
375
367
  }),
@@ -490,6 +482,7 @@ function StaticFilesList({ staticFiles }) {
490
482
  }
491
483
 
492
484
 
485
+ // Misc ===============
493
486
 
494
487
  function onError(error) {
495
488
  if (error?.message === 'Failed to fetch')
@@ -497,7 +490,6 @@ function onError(error) {
497
490
  console.error(error)
498
491
  }
499
492
 
500
-
501
493
  function TimerIcon() {
502
494
  return (
503
495
  r('svg', { viewBox: '0 0 24 24' },
@@ -511,11 +503,39 @@ function CloudIcon() {
511
503
  }
512
504
 
513
505
 
506
+ // AR Events (Add or Remove mock) ============
507
+
508
+ pollAR_Events.isPolling = false
509
+ pollAR_Events.oldAR_EventsCount = 0
510
+ async function pollAR_Events() {
511
+ if (pollAR_Events.isPolling || document.hidden)
512
+ return
513
+ try {
514
+ pollAR_Events.isPolling = true
515
+ const response = await mockaton.getAR_EventsCount()
516
+ if (response.ok) {
517
+ const nAR_Events = await response.json()
518
+ if (pollAR_Events.oldAR_EventsCount !== nAR_Events) { // because it could be < or >
519
+ pollAR_Events.oldAR_EventsCount = nAR_Events
520
+ await init()
521
+ }
522
+ pollAR_Events.isPolling = false
523
+ pollAR_Events()
524
+ }
525
+ else
526
+ throw response.status
527
+ }
528
+ catch (_) {
529
+ pollAR_Events.isPolling = false
530
+ setTimeout(pollAR_Events, 5000)
531
+ }
532
+ }
533
+
514
534
 
515
535
  // Utils ============
516
536
 
517
537
  function cssClass(...args) {
518
- return args.filter(a => a).join(' ')
538
+ return args.filter(Boolean).join(' ')
519
539
  }
520
540
 
521
541
 
@@ -542,7 +562,7 @@ function createElement(elem, props = null, ...children) {
542
562
  node[key] = value
543
563
  else
544
564
  node.setAttribute(key, value)
545
- node.append(...children.flat().filter(a => a))
565
+ node.append(...children.flat().filter(Boolean))
546
566
  return node
547
567
  }
548
568
 
@@ -550,7 +570,7 @@ function createSvgElement(tagName, props, ...children) {
550
570
  const elem = document.createElementNS('http://www.w3.org/2000/svg', tagName)
551
571
  for (const [key, value] of Object.entries(props))
552
572
  elem.setAttribute(key, value)
553
- elem.append(...children.flat().filter(a => a))
573
+ elem.append(...children.flat().filter(Boolean))
554
574
  return elem
555
575
  }
556
576
 
package/src/MockBroker.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { config } from './config.js'
2
- import { DEFAULT_500_COMMENT, DEFAULT_MOCK_COMMENT } from './ApiConstants.js'
3
2
  import { includesComment, extractComments, parseFilename } from './Filename.js'
3
+ import { DEFAULT_500_COMMENT, DEFAULT_MOCK_COMMENT } from './ApiConstants.js'
4
4
 
5
5
 
6
6
  // MockBroker is a state for a particular route. It knows the available mock files
@@ -70,10 +70,9 @@ export class MockBroker {
70
70
  this.mocks.sort()
71
71
  const defaults = this.mocks.filter(file => includesComment(file, DEFAULT_MOCK_COMMENT))
72
72
  const temp500 = this.mocks.filter(file => includesComment(file, DEFAULT_500_COMMENT))
73
- this.mocks = this.mocks.filter(file => !defaults.includes(file) && !temp500.includes(file))
74
73
  this.mocks = [
75
74
  ...defaults,
76
- ...this.mocks,
75
+ ...this.mocks.filter(file => !defaults.includes(file) && !temp500.includes(file)),
77
76
  ...temp500
78
77
  ]
79
78
  }
@@ -4,9 +4,9 @@ import { proxy } from './ProxyRelay.js'
4
4
  import { cookie } from './cookie.js'
5
5
  import { config } from './config.js'
6
6
  import { applyPlugins } from './MockDispatcherPlugins.js'
7
- import * as mockBrokerCollection from './mockBrokersCollection.js'
8
7
  import { BodyReaderError } from './utils/http-request.js'
9
- import { sendInternalServerError, sendNotFound, sendBadRequest } from './utils/http-response.js'
8
+ import * as mockBrokerCollection from './mockBrokersCollection.js'
9
+ import { sendInternalServerError, sendNotFound, sendUnprocessableContent } from './utils/http-response.js'
10
10
 
11
11
 
12
12
  export async function dispatchMock(req, response) {
@@ -38,7 +38,7 @@ export async function dispatchMock(req, response) {
38
38
  }
39
39
  catch (error) {
40
40
  if (error instanceof BodyReaderError)
41
- sendBadRequest(response, error)
41
+ sendUnprocessableContent(response, error.name)
42
42
  else if (error.code === 'ENOENT') // mock-file has been deleted
43
43
  sendNotFound(response)
44
44
  else if (error.code === 'ERR_UNKNOWN_FILE_EXTENSION') {
package/src/Mockaton.js CHANGED
@@ -1,15 +1,15 @@
1
- import { join } from 'node:path'
2
1
  import { createServer } from 'node:http'
3
- import { watch, existsSync } from 'node:fs'
4
2
 
5
3
  import { API } from './ApiConstants.js'
6
- import { dispatchMock } from './MockDispatcher.js'
7
4
  import { config, setup } from './config.js'
8
- import { sendNoContent } from './utils/http-response.js'
5
+ import { dispatchMock } from './MockDispatcher.js'
6
+ import { watchMocksDir } from './Watcher.js'
7
+ import { BodyReaderError } from './utils/http-request.js'
9
8
  import * as mockBrokerCollection from './mockBrokersCollection.js'
10
9
  import { dispatchStatic, isStatic } from './StaticDispatcher.js'
11
10
  import { setCorsHeaders, isPreflight } from './utils/http-cors.js'
12
11
  import { apiPatchRequests, apiGetRequests } from './Api.js'
12
+ import { sendNoContent, sendInternalServerError, sendUnprocessableContent } from './utils/http-response.js'
13
13
 
14
14
 
15
15
  process.on('unhandledRejection', error => { throw error })
@@ -17,16 +17,7 @@ process.on('unhandledRejection', error => { throw error })
17
17
  export function Mockaton(options) {
18
18
  setup(options)
19
19
  mockBrokerCollection.init()
20
-
21
- watch(config.mocksDir, { recursive: true, persistent: false },
22
- function handleAddedOrDeletedMocks(_, file) {
23
- if (!file)
24
- return
25
- if (existsSync(join(config.mocksDir, file)))
26
- mockBrokerCollection.registerMock(file, 'isFromWatcher')
27
- else
28
- mockBrokerCollection.unregisterMock(file)
29
- })
20
+ watchMocksDir()
30
21
 
31
22
  return createServer(onRequest).listen(config.port, config.host, function (error) {
32
23
  const { address, port } = this.address()
@@ -41,34 +32,42 @@ export function Mockaton(options) {
41
32
  }
42
33
 
43
34
  async function onRequest(req, response) {
44
- req.on('error', console.error)
45
35
  response.on('error', console.error)
46
- response.setHeader('Server', 'Mockaton')
47
36
 
48
- if (config.corsAllowed)
49
- setCorsHeaders(req, response, {
50
- origins: config.corsOrigins,
51
- headers: config.corsHeaders,
52
- methods: config.corsMethods,
53
- maxAge: config.corsMaxAge,
54
- credentials: config.corsCredentials,
55
- exposedHeaders: config.corsExposedHeaders
56
- })
37
+ try {
38
+ response.setHeader('Server', 'Mockaton')
57
39
 
58
- const { url, method } = req
40
+ if (config.corsAllowed)
41
+ setCorsHeaders(req, response, {
42
+ origins: config.corsOrigins,
43
+ headers: config.corsHeaders,
44
+ methods: config.corsMethods,
45
+ maxAge: config.corsMaxAge,
46
+ credentials: config.corsCredentials,
47
+ exposedHeaders: config.corsExposedHeaders
48
+ })
59
49
 
60
- if (isPreflight(req))
61
- sendNoContent(response)
50
+ const { url, method } = req
62
51
 
63
- else if (method === 'PATCH' && apiPatchRequests.has(url))
64
- await apiPatchRequests.get(url)(req, response)
52
+ if (isPreflight(req))
53
+ sendNoContent(response)
65
54
 
66
- else if (method === 'GET' && apiGetRequests.has(url))
67
- apiGetRequests.get(url)(req, response)
55
+ else if (method === 'PATCH' && apiPatchRequests.has(url))
56
+ await apiPatchRequests.get(url)(req, response)
68
57
 
69
- else if (method === 'GET' && isStatic(req))
70
- await dispatchStatic(req, response)
58
+ else if (method === 'GET' && apiGetRequests.has(url))
59
+ apiGetRequests.get(url)(req, response)
71
60
 
72
- else
73
- await dispatchMock(req, response)
61
+ else if (method === 'GET' && isStatic(req))
62
+ await dispatchStatic(req, response)
63
+
64
+ else
65
+ await dispatchMock(req, response)
66
+ }
67
+ catch (error) {
68
+ if (error instanceof BodyReaderError)
69
+ sendUnprocessableContent(response, error.name)
70
+ else
71
+ sendInternalServerError(response, error)
72
+ }
74
73
  }
@@ -501,7 +501,7 @@ export default function (req, response) {
501
501
  async function testStaticFileServing() {
502
502
  await describe('Static File Serving', () => {
503
503
  it('404 path traversal', async () => {
504
- const res = await request('/../../../etc/passwd')
504
+ const res = await request('/../../../../../../../../../../../%2E%2E/etc/passwd')
505
505
  equal(res.status, 404)
506
506
  })
507
507
 
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path'
2
- import fs, { readFileSync, realpathSync } from 'node:fs'
2
+ import fs, { readFileSync } from 'node:fs'
3
3
 
4
4
  import { config } from './config.js'
5
5
  import { mimeFor } from './utils/mime.js'
@@ -10,12 +10,12 @@ import { sendInternalServerError } from './utils/http-response.js'
10
10
  export function isStatic(req) {
11
11
  if (!config.staticDir)
12
12
  return false
13
- const f = resolvedAllowedPath(req.url)
13
+ const f = resolvePath(req.url)
14
14
  return f && !config.ignore.test(f)
15
15
  }
16
16
 
17
17
  export async function dispatchStatic(req, response) {
18
- const file = resolvedAllowedPath(req.url)
18
+ const file = resolvePath(req.url)
19
19
  if (req.headers.range)
20
20
  await sendPartialContent(response, req.headers.range, file)
21
21
  else {
@@ -24,17 +24,12 @@ export async function dispatchStatic(req, response) {
24
24
  }
25
25
  }
26
26
 
27
- function resolvedAllowedPath(url) {
28
- try {
29
- let candidate = realpathSync(join(config.staticDir, url))
30
- if (!candidate.startsWith(config.staticDir))
31
- return false
32
- if (isDirectory(candidate))
33
- candidate = join(candidate, 'index.html')
34
- if (isFile(candidate))
35
- return candidate
36
- }
37
- catch {}
27
+ function resolvePath(url) { // url is absolute e.g. /home/../.. => /
28
+ let candidate = join(config.staticDir, url)
29
+ if (isDirectory(candidate))
30
+ candidate = join(candidate, 'index.html')
31
+ if (isFile(candidate))
32
+ return candidate
38
33
  }
39
34
 
40
35
  async function sendPartialContent(response, range, file) {
package/src/Watcher.js ADDED
@@ -0,0 +1,48 @@
1
+ import { join } from 'node:path'
2
+ import { watch } from 'node:fs'
3
+ import { EventEmitter } from 'node:events'
4
+
5
+ import { config } from './config.js'
6
+ import { isFile } from './utils/fs.js'
7
+ import * as mockBrokerCollection from './mockBrokersCollection.js'
8
+
9
+
10
+ let nAR_Events = 0 // AR = Add or Remove Mock
11
+
12
+ export function countAR_Events() {
13
+ return nAR_Events
14
+ }
15
+
16
+
17
+ const emitter = new EventEmitter()
18
+
19
+ export function subscribeAR_EventListener(callback) {
20
+ emitter.on('AR', callback)
21
+ }
22
+ export function unsubscribeAR_EventListener(callback) {
23
+ emitter.removeListener('AR', callback)
24
+ }
25
+
26
+ function emitAddOrRemoveMock() {
27
+ nAR_Events++
28
+ emitter.emit('AR')
29
+ }
30
+
31
+ export function watchMocksDir() {
32
+ const dir = config.mocksDir
33
+ watch(dir, { recursive: true, persistent: false }, (_, file) => {
34
+ if (!file)
35
+ return
36
+ if (isFile(join(dir, file))) {
37
+ if (mockBrokerCollection.registerMock(file, 'isFromWatcher'))
38
+ emitAddOrRemoveMock()
39
+ }
40
+ else {
41
+ mockBrokerCollection.unregisterMock(file)
42
+ emitAddOrRemoveMock()
43
+ }
44
+ })
45
+ }
46
+
47
+ // TODO staticDir, config changes
48
+ // TODO think about throttling e.g. bulk deletes/remove files
@@ -35,24 +35,28 @@ export function init() {
35
35
  })
36
36
  }
37
37
 
38
+ /** @returns {boolean} registered */
38
39
  export function registerMock(file, isFromWatcher) {
39
40
  if (getBrokerByFilename(file)?.hasMock(file)
40
41
  || config.ignore.test(file)
41
42
  || !filenameIsValid(file))
42
- return
43
+ return false
43
44
 
44
45
  const { method, urlMask } = parseFilename(file)
45
46
  collection[method] ??= {}
47
+
46
48
  if (!collection[method][urlMask])
47
49
  collection[method][urlMask] = new MockBroker(file)
48
50
  else
49
51
  collection[method][urlMask].register(file)
50
52
 
51
- if (isFromWatcher) {
52
- if (!this.file)
53
- collection[method][urlMask].selectDefaultFile()
53
+ if (isFromWatcher && !this.file)
54
+ collection[method][urlMask].selectDefaultFile()
55
+
56
+ if (isFromWatcher)
54
57
  collection[method][urlMask].ensureItHas500()
55
- }
58
+
59
+ return true
56
60
  }
57
61
 
58
62
  export function unregisterMock(file) {
@@ -2,7 +2,7 @@
2
2
  <svg version="1.1" viewBox="0 0 570 100" xmlns="http://www.w3.org/2000/svg">
3
3
  <style>
4
4
  :root { --color: #000000; }
5
- @media (prefers-color-scheme: light) { :root { --color: #555 } }
5
+ @media (prefers-color-scheme: light) { :root { --color: #444 } }
6
6
  @media (prefers-color-scheme: dark) { :root { --color: #eee } }
7
7
  path { fill: var(--color) }
8
8
  </style>
@@ -3,7 +3,7 @@ export const StandardMethods = [
3
3
  'HEAD', 'OPTIONS', 'TRACE', 'CONNECT'
4
4
  ]
5
5
 
6
- export class BodyReaderError extends Error {}
6
+ export class BodyReaderError extends Error {name = 'BodyReaderError'}
7
7
 
8
8
  export const parseJSON = req => readBody(req, JSON.parse)
9
9
 
@@ -26,12 +26,6 @@ export function sendDashboardFile(response, file) {
26
26
  response.end(readFileSync(file, 'utf8'))
27
27
  }
28
28
 
29
- export function sendBadRequest(response, error) {
30
- console.error(error)
31
- response.statusCode = 400
32
- response.end()
33
- }
34
-
35
29
  export function sendNotFound(response) {
36
30
  response.statusCode = 404
37
31
  response.end()