mockaton 8.7.7 → 8.8.1

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
@@ -3,8 +3,6 @@
3
3
  ![NPM Version](https://img.shields.io/npm/v/mockaton)
4
4
  ![NPM Version](https://img.shields.io/npm/l/mockaton)
5
5
 
6
- ## Mock your APIs, Enhance your Development Workflow
7
-
8
6
  Mockaton is an HTTP mock server with the goal of making
9
7
  your frontend development and testing easier—and a lot more fun.
10
8
 
@@ -17,11 +15,31 @@ For example, for `/api/user/1234` the mock filename would be:
17
15
  my-mocks-dir/api/user/[user-id].GET.200.json
18
16
  ```
19
17
 
20
- ## Scrapping Mocks from you Backend
21
18
 
22
- Mockaton can fallback to your real backend on routes you don’t have mocks for. For that,
23
- type your backend address in the **Fallback Backend** field. And if you
24
- check **Save Mocks**, it will collect those responses that hit your backend.
19
+ ## Dashboard
20
+ In the dashboard you can select a mock variant for a particular route, among
21
+ other features such as delaying responses, or triggering an autogenerated
22
+ `500` (Internal Server Error). Nonetheless, there’s a programmatic API,
23
+ which is handy for setting up tests (see **Commander API** below).
24
+
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">
29
+ </picture>
30
+
31
+
32
+
33
+ ## No Need to Mock Everything
34
+ Mockaton can fallback to your real backend on routes you don’t have mocks
35
+ for. For that, type your backend address in the **Fallback Backend** field.
36
+
37
+ Similarly, if you already have mocks for a route you can check the
38
+ ☁️ **Cloud checkbox** and that route will be requested from your backend.
39
+
40
+
41
+ ## Scrapping Mocks from you Backend
42
+ If you check **Save Mocks**, Mockaton will collect the responses that hit your backend.
25
43
  Those mocks will be saved to your `config.mocksDir` following the filename convention.
26
44
 
27
45
 
@@ -31,26 +49,15 @@ Those mocks will be saved to your `config.mocksDir` following the filename conve
31
49
  Want to mock a locked-out user or an invalid login attempt?
32
50
  Add a comment to the filename in parentheses. For example:
33
51
 
34
- `api/login(locked out user).POST.423.json`
52
+ <pre>
53
+ api/login<b>(locked out user)</b>.POST.423.json
54
+ </pre>
35
55
 
36
56
  ### Different response status code
37
57
  For instance, you can have mocks with a `4xx` or `5xx` status code for triggering
38
58
  error responses. Or with a `204` (No Content) for testing empty collections.
39
59
 
40
60
 
41
- ## Dashboard
42
- In the dashboard you can select a mock variant for a particular route, among
43
- other features such as delaying responses, or triggering an autogenerated
44
- `500` (Internal Server Error). Nonetheless, there’s a programmatic API,
45
- which is handy for setting up tests (see **Commander API** below).
46
-
47
- <picture>
48
- <source media="(prefers-color-scheme: light)" srcset="./pixaton-tests/pic-for-readme.vp860x800.light.gold.png">
49
- <source media="(prefers-color-scheme: dark)" srcset="./pixaton-tests/pic-for-readme.vp860x800.dark.gold.png">
50
- <img alt="Mockaton Dashboard" src="./pixaton-tests/pic-for-readme.vp860x800.light.gold.png">
51
- </picture>
52
-
53
-
54
61
 
55
62
  ## Basic Usage
56
63
  `tsx` is only needed if you want to write mocks in TypeScript.
@@ -295,9 +302,11 @@ checkbox, the delay amount is globally configurable.
295
302
 
296
303
 
297
304
  ### `proxyFallback?: string`
298
- Lets you specify a target server for serving routes you don’t have mocks for.
299
305
  For example, `config.proxyFallback = 'http://example.com'`
300
306
 
307
+ Lets you specify a target server for serving routes you don’t have mocks for,
308
+ or that you manually picked with the ☁️ **Cloud Checkbox**.
309
+
301
310
  ### `collectProxied?: boolean`
302
311
  Defaults to `false`. With this flag you can save mocks that hit
303
312
  your proxy fallback to `config.mocksDir`. If there are UUIDv4 in the
@@ -308,7 +317,10 @@ URL, the filename will have `[id]` in their place. For example,
308
317
  my-mocks-dir/api/user/[id]/likes.GET.200.json
309
318
  ```
310
319
 
311
- Your existing mocks won’t be overwritten (because they don’t hit the fallback server).
320
+ Your existing mocks won’t be overwritten. That is, the routes you manually
321
+ selected for using your backend with the ☁️ **Cloud Checkbox**, will have
322
+ a unique filename comment.
323
+
312
324
 
313
325
  <details>
314
326
  <summary>Extension Details</summary>
@@ -495,6 +507,11 @@ first mock in alphabetical order that matches.
495
507
  await mockaton.setRouteIsDelayed('GET', '/api/foo', true)
496
508
  ```
497
509
 
510
+ ### Set Route is Proxied
511
+ ```js
512
+ await mockaton.setRouteIsProxied('GET', '/api/foo', true)
513
+ ```
514
+
498
515
  ### Select a cookie
499
516
  In `config.cookies`, each key is the label used for selecting it.
500
517
  ```js
@@ -521,5 +538,5 @@ await mockaton.reset()
521
538
 
522
539
  <div style="display: flex; align-items: center; gap: 20px">
523
540
  <img src="fixtures-mocks/api/user/avatar.GET.200.png" width="170"/>
524
- <p style="font-size: 18px">“Use Mockaton”</p>
541
+ <p style="font-size: 18px">“Mockaton”</p>
525
542
  </div>
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.7.7",
5
+ "version": "8.8.1",
6
6
  "main": "index.js",
7
7
  "types": "index.d.ts",
8
8
  "license": "MIT",
package/src/Api.js CHANGED
@@ -38,6 +38,7 @@ export const apiGetRequests = new Map([
38
38
  export const apiPatchRequests = new Map([
39
39
  [API.select, selectMock],
40
40
  [API.delay, setRouteIsDelayed],
41
+ [API.proxied, setRouteIsProxied],
41
42
  [API.reset, reinitialize],
42
43
  [API.cookies, selectCookie],
43
44
  [API.fallback, updateProxyFallback],
@@ -118,13 +119,43 @@ async function selectMock(req, response) {
118
119
  async function setRouteIsDelayed(req, response) {
119
120
  try {
120
121
  const body = await parseJSON(req)
122
+ const delayed = body[DF.delayed]
121
123
  const broker = mockBrokersCollection.getBrokerForUrl(
122
124
  body[DF.routeMethod],
123
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)
138
+ }
139
+ }
140
+
141
+ 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
+
124
149
  if (!broker)
125
- throw `Route does not exist: ${body[DF.routeUrlMask]} ${body[DF.routeUrlMask]}`
126
- broker.updateDelay(body[DF.delayed])
127
- sendOK(response)
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
+ }
128
159
  }
129
160
  catch (error) {
130
161
  sendBadRequest(response, error)
@@ -134,12 +165,14 @@ async function setRouteIsDelayed(req, response) {
134
165
  async function updateProxyFallback(req, response) {
135
166
  try {
136
167
  const fallback = await parseJSON(req)
137
- if (fallback && !URL.canParse(fallback))
168
+ if (fallback && !URL.canParse(fallback)) {
138
169
  sendUnprocessableContent(response)
139
- else {
140
- config.proxyFallback = fallback
141
- sendOK(response)
170
+ return
142
171
  }
172
+ if (!fallback) // TESTME
173
+ mockBrokersCollection.ensureAllRoutesHaveSelectedMock()
174
+ config.proxyFallback = fallback
175
+ sendOK(response)
143
176
  }
144
177
  catch (error) {
145
178
  sendBadRequest(response, error)
@@ -10,6 +10,7 @@ export const API = {
10
10
  cookies: MOUNT + '/cookies',
11
11
  fallback: MOUNT + '/fallback',
12
12
  collectProxied: MOUNT + '/collect-proxied',
13
+ proxied: MOUNT + '/proxied',
13
14
  cors: MOUNT + '/cors',
14
15
  static: MOUNT + '/static'
15
16
  }
@@ -17,7 +18,8 @@ export const API = {
17
18
  export const DF = { // Dashboard Fields (XHR)
18
19
  routeMethod: 'route_method',
19
20
  routeUrlMask: 'route_url_mask',
20
- delayed: 'delayed'
21
+ delayed: 'delayed',
22
+ proxied: 'proxied'
21
23
  }
22
24
 
23
25
  export const DEFAULT_500_COMMENT = '(Mockaton 500)'
package/src/Commander.js CHANGED
@@ -37,6 +37,14 @@ export class Commander {
37
37
  })
38
38
  }
39
39
 
40
+ setRouteIsProxied(routeMethod, routeUrlMask, proxied) {
41
+ return this.#patch(API.proxied, {
42
+ [DF.routeMethod]: routeMethod,
43
+ [DF.routeUrlMask]: routeUrlMask,
44
+ [DF.proxied]: proxied
45
+ })
46
+ }
47
+
40
48
  listCookies() {
41
49
  return this.#get(API.cookies)
42
50
  }
package/src/Dashboard.css CHANGED
@@ -1,5 +1,5 @@
1
1
  :root {
2
- --boxShadow1: 0 2px 1px -1px rgba(0, 0, 0, 0.1), 0 1px 1px 0 rgba(0, 0, 0, 0.1), 0 1px 3px 0 rgba(0, 0, 0, 0.08);
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
3
  --radius: 6px
4
4
  }
5
5
 
@@ -7,13 +7,14 @@
7
7
  :root {
8
8
  --color4xxBackground: #ffedd1;
9
9
  --colorAccent: #0075db;
10
- --colorAccentAlt: #008664;
10
+ --colorAccentAlt: #068564;
11
11
  --colorBackground: #fff;
12
12
  --colorComboBoxHeaderBackground: #fff;
13
13
  --colorComboBoxBackground: #f7f7f7;
14
14
  --colorHeaderBackground: #f3f3f3;
15
- --colorSecondaryButtonBackground: #f3f3f3;
16
- --colorDisabled: #444;
15
+ --colorSecondaryButtonBackground: transparent;
16
+ --colorSecondaryAction: #555;
17
+ --colorDisabledMockSelector: #444;
17
18
  --colorHover: #dfefff;
18
19
  --colorLabel: #444;
19
20
  --colorLightRed: #ffe4ee;
@@ -29,9 +30,10 @@
29
30
  --colorBackground: #161616;
30
31
  --colorHeaderBackground: #090909;
31
32
  --colorComboBoxBackground: #252525;
32
- --colorSecondaryButtonBackground: #444;
33
+ --colorSecondaryButtonBackground: #202020;
34
+ --colorSecondaryAction: #bbb;
33
35
  --colorComboBoxHeaderBackground: #222;
34
- --colorDisabled: #bbb;
36
+ --colorDisabledMockSelector: #b9b9b9;
35
37
  --colorHover: #023661;
36
38
  --colorLabel: #aaa;
37
39
  --colorLightRed: #ffe4ee;
@@ -101,11 +103,11 @@ select {
101
103
  display: flex;
102
104
  width: 100%;
103
105
  align-items: flex-end;
104
- padding: 15px 16px;
106
+ padding: 16px;
105
107
  border-bottom: 1px solid rgba(127, 127, 127, 0.1);
106
108
  background: var(--colorHeaderBackground);
107
109
  box-shadow: var(--boxShadow1);
108
- gap: 12px;
110
+ gap: 8px;
109
111
 
110
112
  img {
111
113
  width: 130px;
@@ -114,12 +116,14 @@ select {
114
116
  }
115
117
 
116
118
  .Field {
117
- min-width: 150px;
119
+ width: 120px;
118
120
 
119
121
  span {
120
- display: block;
122
+ display: flex;
123
+ align-items: center;
121
124
  color: var(--colorLabel);
122
125
  font-size: 11px;
126
+ gap: 4px;
123
127
  }
124
128
 
125
129
  input[type=url],
@@ -140,7 +144,14 @@ select {
140
144
 
141
145
  &.FallbackBackend {
142
146
  position: relative;
143
- width: 194px;
147
+ width: 210px;
148
+
149
+ svg {
150
+ width: 14px;
151
+ height: 14px;
152
+ fill: var(--colorLabel);
153
+ opacity: 0.6;
154
+ }
144
155
 
145
156
  input[type=url] {
146
157
  padding: 0 6px;
@@ -244,7 +255,7 @@ select {
244
255
  position: relative;
245
256
  left: -6px;
246
257
  display: inline-block;
247
- width: 280px;
258
+ width: 300px;
248
259
  padding: 8px 6px;
249
260
  border-radius: var(--radius);
250
261
  color: var(--colorAccent);
@@ -260,7 +271,7 @@ select {
260
271
  }
261
272
 
262
273
  .MockSelector {
263
- width: 300px;
274
+ width: 260px;
264
275
  height: 30px;
265
276
  border: 0;
266
277
  border-left: 3px solid transparent;
@@ -277,12 +288,13 @@ select {
277
288
  appearance: none;
278
289
  background: transparent;
279
290
  cursor: default;
280
- color: var(--colorDisabled);
291
+ color: var(--colorDisabledMockSelector);
281
292
  opacity: 1;
282
293
  }
283
294
  }
284
295
 
285
- .DelayToggler {
296
+ .DelayToggler,
297
+ .ProxyToggler {
286
298
  display: flex;
287
299
  margin-left: 8px;
288
300
  cursor: pointer;
@@ -297,25 +309,38 @@ select {
297
309
  }
298
310
  }
299
311
 
312
+ &:enabled:hover:not(:checked) ~ svg {
313
+ background: var(--colorHover);
314
+ fill: var(--colorText);
315
+ }
316
+
300
317
  &:checked ~ svg {
301
318
  background: var(--colorAccent);
302
319
  fill: white;
303
320
  }
321
+
322
+ &:disabled ~ svg {
323
+ opacity: .5;
324
+ cursor: not-allowed;
325
+ }
304
326
  }
305
327
 
306
328
  > svg {
307
329
  width: 20px;
308
330
  height: 20px;
309
331
  vertical-align: bottom;
310
- fill: var(--colorText);
332
+ fill: var(--colorSecondaryAction);
311
333
  border-radius: 50%;
312
334
  background: var(--colorSecondaryButtonBackground);
313
335
  box-shadow: var(--boxShadow1);
336
+ }
337
+ }
314
338
 
315
- &:hover {
316
- background: var(--colorHover);
317
- fill: var(--colorText);
318
- }
339
+ .ProxyToggler {
340
+ > svg {
341
+ width: 24px;
342
+ padding: 3px;
343
+ border-radius: 4px;
319
344
  }
320
345
  }
321
346
 
@@ -344,8 +369,8 @@ select {
344
369
  padding: 5px 4px;
345
370
  box-shadow: var(--boxShadow1);
346
371
  font-size: 10px;
347
- color: var(--colorText);
348
- border-radius: 2px;
372
+ color: var(--colorSecondaryAction);
373
+ border-radius: 4px;
349
374
  background: var(--colorSecondaryButtonBackground);
350
375
 
351
376
  &:hover {
package/src/Dashboard.js CHANGED
@@ -12,8 +12,8 @@ function syntaxHighlightJson(textBody) {
12
12
 
13
13
 
14
14
  const Strings = {
15
- bulk_select_by_comment: 'Bulk Select by Comment',
16
- bulk_select_by_comment_disabled_title: 'No mock files have comments, which are anything within parentheses on the filename.',
15
+ bulk_select: 'Bulk Select',
16
+ bulk_select_disabled_title: 'No mock files have comments, which are anything within parentheses on the filename.',
17
17
  click_link_to_preview: 'Click a link to preview it',
18
18
  cookie: 'Cookie',
19
19
  cookie_disabled_title: 'No cookies specified in Config.cookies',
@@ -21,10 +21,13 @@ const Strings = {
21
21
  empty_response_body: '/* Empty Response Body */',
22
22
  fallback_server: 'Fallback Backend',
23
23
  fallback_server_placeholder: 'Type Server Address',
24
+ got: 'Got',
24
25
  internal_server_error: 'Internal Server Error',
25
26
  mock: 'Mock',
26
27
  no_mocks_found: 'No mocks found',
27
- pick: 'Pick…',
28
+ pick_comment: 'Pick Comment…',
29
+ proxied: 'Proxied',
30
+ proxy_toggler: 'Proxy Toggler',
28
31
  reset: 'Reset',
29
32
  save_proxied: 'Save Mocks',
30
33
  static_get: 'Static GET'
@@ -42,6 +45,7 @@ const CSS = {
42
45
  PayloadViewer: 'PayloadViewer',
43
46
  PreviewLink: 'PreviewLink',
44
47
  ProgressBar: 'ProgressBar',
48
+ ProxyToggler: 'ProxyToggler',
45
49
  ResetButton: 'ResetButton',
46
50
  SaveProxiedCheckbox: 'SaveProxiedCheckbox',
47
51
  StaticFilesList: 'StaticFilesList',
@@ -73,7 +77,7 @@ init()
73
77
  function App([brokersByMethod, cookies, comments, collectProxied, fallbackAddress, staticFiles]) {
74
78
  return [
75
79
  r(Header, { cookies, comments, fallbackAddress, collectProxied }),
76
- r(MockList, { brokersByMethod }),
80
+ r(MockList, { brokersByMethod, canProxy: Boolean(fallbackAddress) }),
77
81
  r(StaticFilesList, { staticFiles })
78
82
  ]
79
83
  }
@@ -121,7 +125,7 @@ function CookieSelector({ cookies }) {
121
125
  function BulkSelector({ comments }) {
122
126
  // UX wise this should be a menu instead of this `select`.
123
127
  // But this way is easier to implement, with a few hacks.
124
- const firstOption = Strings.pick
128
+ const firstOption = Strings.pick_comment
125
129
  function onChange() {
126
130
  const value = this.value
127
131
  this.value = firstOption // Hack
@@ -135,19 +139,19 @@ function BulkSelector({ comments }) {
135
139
  : [firstOption].concat(comments)
136
140
  return (
137
141
  r('label', { className: CSS.Field },
138
- r('span', null, Strings.bulk_select_by_comment),
142
+ r('span', null, Strings.bulk_select),
139
143
  r('select', {
140
144
  className: CSS.BulkSelector,
141
145
  'data-qaid': 'BulkSelector',
142
146
  autocomplete: 'off',
143
147
  disabled,
144
- title: disabled ? Strings.bulk_select_by_comment_disabled_title : '',
148
+ title: disabled ? Strings.bulk_select_disabled_title : '',
145
149
  onChange
146
150
  }, list.map(value =>
147
151
  r('option', { value }, value)))))
148
152
  }
149
153
 
150
- function ProxyFallbackField({ fallbackAddress = '', collectProxied }) {
154
+ function ProxyFallbackField({ fallbackAddress, collectProxied }) {
151
155
  function onChange() {
152
156
  const saveCheckbox = this.closest(`.${CSS.FallbackBackend}`).querySelector('[type=checkbox]')
153
157
  saveCheckbox.disabled = !this.validity.valid || !this.value.trim()
@@ -155,12 +159,16 @@ function ProxyFallbackField({ fallbackAddress = '', collectProxied }) {
155
159
  if (!this.validity.valid)
156
160
  this.reportValidity()
157
161
  else
158
- mockaton.setProxyFallback(this.value.trim()).catch(onError)
162
+ mockaton.setProxyFallback(this.value.trim())
163
+ .then(init)
164
+ .catch(onError)
159
165
  }
160
166
  return (
161
167
  r('div', { className: cssClass(CSS.Field, CSS.FallbackBackend) },
162
168
  r('label', null,
163
- r('span', null, Strings.fallback_server),
169
+ r('span', null,
170
+ r(CloudIcon),
171
+ Strings.fallback_server),
164
172
  r('input', {
165
173
  type: 'url',
166
174
  autocomplete: 'none',
@@ -205,7 +213,7 @@ function ResetButton() {
205
213
 
206
214
  // MockList ===============
207
215
 
208
- function MockList({ brokersByMethod }) {
216
+ function MockList({ brokersByMethod, canProxy }) {
209
217
  const hasMocks = Object.keys(brokersByMethod).length
210
218
  if (!hasMocks)
211
219
  return (
@@ -214,12 +222,12 @@ function MockList({ brokersByMethod }) {
214
222
  return (
215
223
  r('main', { className: CSS.MockList },
216
224
  r('table', null, Object.entries(brokersByMethod).map(([method, brokers]) =>
217
- r(SectionByMethod, { method, brokers }))),
225
+ r(SectionByMethod, { method, brokers, canProxy }))),
218
226
  r(PayloadViewer)))
219
227
  }
220
228
 
221
229
 
222
- function SectionByMethod({ method, brokers }) {
230
+ function SectionByMethod({ method, brokers, canProxy }) {
223
231
  return (
224
232
  r('tbody', null,
225
233
  r('th', null, method),
@@ -231,7 +239,8 @@ function SectionByMethod({ method, brokers }) {
231
239
  r('td', null, r(PreviewLink, { method, urlMask })),
232
240
  r('td', null, r(MockSelector, { broker })),
233
241
  r('td', null, r(DelayRouteToggler, { broker })),
234
- r('td', null, r(InternalServerErrorToggler, { broker }))))))
242
+ r('td', null, r(InternalServerErrorToggler, { broker })),
243
+ r('td', null, r(ProxyToggler, { broker, disabled: !canProxy }))))))
235
244
  }
236
245
 
237
246
 
@@ -257,38 +266,35 @@ function PreviewLink({ method, urlMask }) {
257
266
 
258
267
 
259
268
  function MockSelector({ broker }) {
260
- function className(defaultIsSelected, status) {
261
- return cssClass(
262
- CSS.MockSelector,
263
- !defaultIsSelected && CSS.bold,
264
- status >= 400 && status < 500 && CSS.status4xx)
265
- }
266
-
267
269
  function onChange() {
268
- const { status, urlMask, method } = parseFilename(this.value)
270
+ const { urlMask, method } = parseFilename(this.value)
269
271
  this.style.fontWeight = this.value === this.options[0].value // default is selected
270
272
  ? 'normal'
271
273
  : 'bold'
272
274
  mockaton.select(this.value)
273
- .then(() => {
274
- linkFor(method, urlMask)?.click()
275
- checkbox500For(method, urlMask).checked = status === 500
276
- this.className = className(this.value === this.options[0].value, status)
277
- })
275
+ .then(init)
276
+ .then(() => linkFor(method, urlMask)?.click())
278
277
  .catch(onError)
279
278
  }
280
279
 
281
- const selected = broker.currentMock.file
280
+ let selected = broker.currentMock.file
282
281
  const { status, urlMask } = parseFilename(selected)
283
282
  const files = broker.mocks.filter(item =>
284
283
  status === 500 ||
285
284
  !item.includes(DEFAULT_500_COMMENT))
285
+ if (!selected) {
286
+ selected = Strings.proxied
287
+ files.push(selected)
288
+ }
286
289
 
287
290
  return (
288
291
  r('select', {
289
292
  'data-qaid': urlMask,
290
293
  autocomplete: 'off',
291
- className: className(selected === files[0], status),
294
+ className: cssClass(
295
+ CSS.MockSelector,
296
+ selected !== files[0] && CSS.bold,
297
+ status >= 400 && status < 500 && CSS.status4xx),
292
298
  disabled: files.length <= 1,
293
299
  onChange
294
300
  }, files.map(file =>
@@ -317,12 +323,6 @@ function DelayRouteToggler({ broker }) {
317
323
  onChange
318
324
  }),
319
325
  TimerIcon()))
320
-
321
- function TimerIcon() {
322
- return (
323
- r('svg', { viewBox: '0 0 24 24' },
324
- r('path', { d: 'M12 7H11v6l5 3.2.75-1.23-4.5-3z' })))
325
- }
326
326
  }
327
327
 
328
328
 
@@ -353,6 +353,30 @@ function InternalServerErrorToggler({ broker }) {
353
353
  )
354
354
  }
355
355
 
356
+ function ProxyToggler({ broker, disabled }) {
357
+ function onChange() {
358
+ const { urlMask, method } = parseFilename(this.name)
359
+ mockaton.setRouteIsProxied(method, urlMask, this.checked)
360
+ .then(init)
361
+ .then(() => linkFor(method, urlMask)?.click())
362
+ .catch(onError)
363
+ }
364
+ return (
365
+ r('label', {
366
+ className: CSS.ProxyToggler,
367
+ title: Strings.proxy_toggler
368
+ },
369
+ r('input', {
370
+ type: 'checkbox',
371
+ disabled,
372
+ name: broker.currentMock.file,
373
+ checked: !broker.currentMock.file,
374
+ onChange
375
+ }),
376
+ r(CloudIcon)))
377
+ }
378
+
379
+
356
380
 
357
381
  // Payload Preview ===============
358
382
 
@@ -373,14 +397,21 @@ function PayloadViewerProgressBar() {
373
397
  r('div', { style: { animationDuration: '1000ms' } }))) // TODO from Config.delay - 180
374
398
  }
375
399
 
376
- function PayloadViewerTitle({ file }) {
377
- const { urlMask, method, status, ext } = parseFilename(file)
400
+ function PayloadViewerTitle({ file, status, statusText }) {
401
+ const { urlMask, method, ext } = parseFilename(file)
378
402
  return (
379
403
  r('span', null,
380
404
  urlMask + '.' + method + '.',
381
- r('abbr', { title: HttpStatus[status] }, status),
405
+ r('abbr', { title: statusText }, status),
382
406
  '.' + ext))
383
407
  }
408
+ function PayloadViewerTitleWhenProxied({ mime, status, statusText }) {
409
+ return (
410
+ r('span', null,
411
+ Strings.got + ' ',
412
+ r('abbr', { title: statusText }, status),
413
+ ' ' + mime))
414
+ }
384
415
 
385
416
  async function previewMock(method, urlMask, href) {
386
417
  const timer = setTimeout(renderProgressBar, 180)
@@ -394,10 +425,22 @@ async function previewMock(method, urlMask, href) {
394
425
  }
395
426
 
396
427
  async function updatePayloadViewer(method, urlMask, response) {
397
- payloadViewerTitleRef.current.replaceChildren(
398
- PayloadViewerTitle({ file: mockSelectorFor(method, urlMask).value }))
399
-
400
428
  const mime = response.headers.get('content-type') || ''
429
+
430
+ const file = mockSelectorFor(method, urlMask).value
431
+ if (file === Strings.proxied)
432
+ payloadViewerTitleRef.current.replaceChildren(PayloadViewerTitleWhenProxied({
433
+ status: response.status,
434
+ statusText: response.statusText,
435
+ mime
436
+ }))
437
+ else
438
+ payloadViewerTitleRef.current.replaceChildren(PayloadViewerTitle({
439
+ status: response.status,
440
+ statusText: response.statusText,
441
+ file
442
+ }))
443
+
401
444
  if (mime.startsWith('image/')) { // Naively assumes GET.200
402
445
  payloadViewerRef.current.replaceChildren(
403
446
  r('img', {
@@ -424,9 +467,6 @@ function trFor(method, urlMask) {
424
467
  function linkFor(method, urlMask) {
425
468
  return trFor(method, urlMask)?.querySelector(`a.${CSS.PreviewLink}`)
426
469
  }
427
- function checkbox500For(method, urlMask) {
428
- return trFor(method, urlMask)?.querySelector(`.${CSS.InternalServerErrorToggler} > input`)
429
- }
430
470
  function mockSelectorFor(method, urlMask) {
431
471
  return trFor(method, urlMask)?.querySelector(`select.${CSS.MockSelector}`)
432
472
  }
@@ -458,6 +498,19 @@ function onError(error) {
458
498
  }
459
499
 
460
500
 
501
+ function TimerIcon() {
502
+ return (
503
+ r('svg', { viewBox: '0 0 24 24' },
504
+ r('path', { d: 'M12 7H11v6l5 3.2.75-1.23-4.5-3z' })))
505
+ }
506
+
507
+ function CloudIcon() {
508
+ return (
509
+ r('svg', { viewBox: '0 0 24 24' },
510
+ r('path', { d: 'M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.61 5.64 5.36 8.04 2.35 8.36 0 10.9 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96M19 18H6c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4h2c0-2.76-1.86-5.08-4.4-5.78C8.61 6.88 10.2 6 12 6c3.03 0 5.5 2.47 5.5 5.5v.5H19c1.65 0 3 1.35 3 3s-1.35 3-3 3' })))
511
+ }
512
+
513
+
461
514
 
462
515
  // Utils ============
463
516
 
@@ -504,91 +557,3 @@ function createSvgElement(tagName, props, ...children) {
504
557
  function useRef() {
505
558
  return { current: null }
506
559
  }
507
-
508
- const HttpStatus = {
509
- 100: 'Continue',
510
- 101: 'Switching Protocols',
511
- 102: 'Processing',
512
- 103: 'Early Hints',
513
- 200: 'OK',
514
- 201: 'Created',
515
- 202: 'Accepted',
516
- 203: 'Non-Authoritative Information',
517
- 204: 'No Content',
518
- 205: 'Reset Content',
519
- 206: 'Partial Content',
520
- 207: 'Multi-Status',
521
- 208: 'Already Reported',
522
- 218: 'This is fine (Apache Web Server)',
523
- 226: 'IM Used',
524
- 300: 'Multiple Choices',
525
- 301: 'Moved Permanently',
526
- 302: 'Found',
527
- 303: 'See Other',
528
- 304: 'Not Modified',
529
- 306: 'Switch Proxy',
530
- 307: 'Temporary Redirect',
531
- 308: 'Resume Incomplete',
532
- 400: 'Bad Request',
533
- 401: 'Unauthorized',
534
- 402: 'Payment Required',
535
- 403: 'Forbidden',
536
- 404: 'Not Found',
537
- 405: 'Method Not Allowed',
538
- 406: 'Not Acceptable',
539
- 407: 'Proxy Authentication Required',
540
- 408: 'Request Timeout',
541
- 409: 'Conflict',
542
- 410: 'Gone',
543
- 411: 'Length Required',
544
- 412: 'Precondition Failed',
545
- 413: 'Request Entity Too Large',
546
- 414: 'Request-URI Too Long',
547
- 415: 'Unsupported Media Type',
548
- 416: 'Requested Range Not Satisfiable',
549
- 417: 'Expectation Failed',
550
- 418: 'I’m a teapot',
551
- 419: 'Page Expired (Laravel Framework)',
552
- 420: 'Method Failure (Spring Framework)',
553
- 421: 'Misdirected Request',
554
- 422: 'Unprocessable Entity',
555
- 423: 'Locked',
556
- 424: 'Failed Dependency',
557
- 426: 'Upgrade Required',
558
- 428: 'Precondition Required',
559
- 429: 'Too Many Requests',
560
- 431: 'Request Header Fields Too Large',
561
- 440: 'Login Time-out',
562
- 444: 'Connection Closed Without Response',
563
- 449: 'Retry With',
564
- 450: 'Blocked by Windows Parental Controls',
565
- 451: 'Unavailable For Legal Reasons',
566
- 494: 'Request Header Too Large',
567
- 495: 'SSL Certificate Error',
568
- 496: 'SSL Certificate Required',
569
- 497: 'HTTP Request Sent to HTTPS Port',
570
- 498: 'Invalid Token (Esri)',
571
- 499: 'Client Closed Request',
572
- 500: 'Internal Server Error',
573
- 501: 'Not Implemented',
574
- 502: 'Bad Gateway',
575
- 503: 'Service Unavailable',
576
- 504: 'Gateway Timeout',
577
- 505: 'HTTP Version Not Supported',
578
- 506: 'Variant Also Negotiates',
579
- 507: 'Insufficient Storage',
580
- 508: 'Loop Detected',
581
- 509: 'Bandwidth Limit Exceeded',
582
- 510: 'Not Extended',
583
- 511: 'Network Authentication Required',
584
- 520: 'Unknown Error',
585
- 521: 'Web Server Is Down',
586
- 522: 'Connection Timed Out',
587
- 523: 'Origin Is Unreachable',
588
- 524: 'A Timeout Occurred',
589
- 525: 'SSL Handshake Failed',
590
- 526: 'Invalid SSL Certificate',
591
- 527: 'Railgun Listener to Origin Error',
592
- 530: 'Origin DNS Error',
593
- 598: 'Network Read Timeout Error'
594
- }
package/src/MockBroker.js CHANGED
@@ -60,6 +60,7 @@ export class MockBroker {
60
60
 
61
61
  get file() { return this.currentMock.file }
62
62
  get delay() { return this.currentMock.delay }
63
+ get proxied() { return !this.currentMock.file }
63
64
  get status() { return parseFilename(this.file).status }
64
65
  get temp500IsSelected() { return this.#isTemp500(this.file) }
65
66
 
@@ -85,6 +86,13 @@ export class MockBroker {
85
86
  updateFile(filename) { this.currentMock.file = filename }
86
87
  updateDelay(delayed) { this.currentMock.delay = Number(delayed) * config.delay }
87
88
 
89
+ updateProxied(proxied) {
90
+ if (proxied)
91
+ this.updateFile('')
92
+ else
93
+ this.selectDefaultFile()
94
+ }
95
+
88
96
  setByMatchingComment(comment) {
89
97
  for (const file of this.mocks)
90
98
  if (includesComment(file, comment)) {
@@ -12,7 +12,7 @@ import { sendInternalServerError, sendNotFound, sendBadRequest } from './utils/h
12
12
  export async function dispatchMock(req, response) {
13
13
  try {
14
14
  const broker = mockBrokerCollection.getBrokerForUrl(req.method, req.url)
15
- if (!broker) {
15
+ if (!broker || broker.proxied) {
16
16
  if (config.proxyFallback)
17
17
  await proxy(req, response)
18
18
  else
@@ -174,7 +174,7 @@ for (const [file, body] of staticFiles)
174
174
  const server = Mockaton({
175
175
  mocksDir: tmpDir,
176
176
  staticDir: staticTmpDir,
177
- delay: 40,
177
+ delay: 80,
178
178
  onReady: () => {},
179
179
  cookies: {
180
180
  userA: 'CookieA',
package/src/ProxyRelay.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { join } from 'node:path'
2
+ import { existsSync } from 'node:fs'
3
+ import { randomUUID } from 'node:crypto'
2
4
  import { write } from './utils/fs.js'
3
5
  import { config } from './config.js'
4
6
  import { extFor } from './utils/mime.js'
@@ -23,7 +25,9 @@ export async function proxy(req, response) {
23
25
 
24
26
  if (config.collectProxied) {
25
27
  const ext = extFor(proxyResponse.headers.get('content-type'))
26
- const filename = makeMockFilename(req.url, req.method, proxyResponse.status, ext)
28
+ let filename = makeMockFilename(req.url, req.method, proxyResponse.status, ext)
29
+ if (existsSync(join(config.mocksDir, filename))) // TESTME
30
+ filename = makeMockFilename(req.url + `(${randomUUID()})`, req.method, proxyResponse.status, ext)
27
31
  write(join(config.mocksDir, filename), body)
28
32
  }
29
33
  }
@@ -1,10 +1,10 @@
1
- import { join, resolve } from 'node:path'
1
+ import { join } from 'node:path'
2
2
  import fs, { readFileSync, realpathSync } from 'node:fs'
3
3
 
4
4
  import { config } from './config.js'
5
5
  import { mimeFor } from './utils/mime.js'
6
6
  import { isDirectory, isFile } from './utils/fs.js'
7
- import { sendNotFound, sendInternalServerError } from './utils/http-response.js'
7
+ import { sendInternalServerError } from './utils/http-response.js'
8
8
 
9
9
 
10
10
  export function isStatic(req) {
@@ -16,34 +16,27 @@ export function isStatic(req) {
16
16
 
17
17
  export async function dispatchStatic(req, response) {
18
18
  const file = resolvedAllowedPath(req.url)
19
- if (!file)
20
- sendNotFound(response)
21
- else if (req.headers.range)
19
+ if (req.headers.range)
22
20
  await sendPartialContent(response, req.headers.range, file)
23
- else
24
- sendFile(response, file)
25
- }
26
-
27
- function resolvedAllowedPath(url) {
28
- let candidate = resolve(join(config.staticDir, url))
29
- if (isDirectory(candidate))
30
- candidate = join(candidate, 'index.html')
31
- if (!isFile(candidate))
32
- return false
33
- candidate = realpathSync(candidate)
34
- if (candidate.startsWith(config.staticDir))
35
- return candidate
36
- }
37
-
38
- function sendFile(response, file) {
39
- if (!isFile(file))
40
- sendNotFound(response)
41
21
  else {
42
22
  response.setHeader('Content-Type', mimeFor(file))
43
23
  response.end(readFileSync(file, 'utf8'))
44
24
  }
45
25
  }
46
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 {}
38
+ }
39
+
47
40
  async function sendPartialContent(response, range, file) {
48
41
  const { size } = await fs.promises.lstat(file)
49
42
  let [start, end] = range.replace(/bytes=/, '').split('-').map(n => parseInt(n, 10))
@@ -104,9 +104,16 @@ export function extractAllComments() {
104
104
  }
105
105
 
106
106
  export function setMocksMatchingComment(comment) {
107
- forEachBroker(broker => broker.setByMatchingComment(comment))
107
+ forEachBroker(broker =>
108
+ broker.setByMatchingComment(comment))
108
109
  }
109
110
 
111
+ export function ensureAllRoutesHaveSelectedMock() {
112
+ forEachBroker(broker => {
113
+ if (broker.proxied)
114
+ broker.selectDefaultFile()
115
+ })
116
+ }
110
117
 
111
118
  function forEachBroker(fn) {
112
119
  for (const brokers of Object.values(collection))
package/src/utils/fs.js CHANGED
@@ -14,6 +14,11 @@ export const listFilesRecursively = dir => {
14
14
  }
15
15
 
16
16
  export const write = (path, body) => {
17
- mkdirSync(dirname(path), { recursive: true })
18
- writeFileSync(path, body)
17
+ try {
18
+ mkdirSync(dirname(path), { recursive: true })
19
+ writeFileSync(path, body)
20
+ }
21
+ catch (err) {
22
+ console.error('Write access denied', err)
23
+ }
19
24
  }