mockaton 13.3.4 → 13.4.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 +28 -16
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/client/ApiCommander.js +1 -1
- package/src/client/ApiConstants.js +2 -2
- package/src/client/IndexHtml.js +18 -18
- package/src/client/app-header.js +3 -2
- package/src/client/app-payload-viewer.js +4 -7
- package/src/client/app-store.js +20 -78
- package/src/client/app-store.test.js +1 -25
- package/src/client/app.css +9 -5
- package/src/client/app.js +7 -7
- package/src/client/dir/dittoSplitPaths.js +25 -0
- package/src/client/dir/dittoSplitPaths.test.js +28 -0
- package/src/client/{dirStructure.js → dir/groupByFolder.js} +17 -13
- package/src/client/dir/groupByFolder.test.js +82 -0
- package/src/client/graphics.js +2 -2
- package/src/client/utils/LocalStorage.js +69 -0
- package/src/client/utils/css.js +16 -0
- package/src/client/utils/css.test.js +74 -0
- package/src/client/{dom-utils.js → utils/dom.js} +2 -21
- package/src/client/utils/watcherDev.js +46 -0
- package/src/server/Api.js +16 -3
- package/src/server/MockDispatcher.js +11 -8
- package/src/server/Mockaton.js +18 -8
- package/src/server/Mockaton.test.js +14 -9
- package/src/server/ProxyRelay.js +9 -3
- package/src/server/{utils/UrlParsers.js → UrlParsers.js} +10 -4
- package/src/server/{utils/UrlParsers.test.js → UrlParsers.test.js} +14 -14
- package/src/server/config.js +2 -0
- package/src/server/utils/HttpServerResponse.js +18 -39
- package/src/server/{WatcherDevClient.js → utils/WatcherDevClient.js} +3 -17
- package/src/server/utils/fs.js +1 -1
- package/src/server/utils/logger.js +4 -4
- package/src/server/utils/mime.js +11 -11
- package/src/server/utils/mime.test.js +15 -11
- package/www/src/assets/openapi.json +147 -147
- package/src/client/dirStructure.test.js +0 -81
- package/src/client/dom-utils.test.js +0 -76
- package/src/client/watcherDev.js +0 -39
package/README.md
CHANGED
|
@@ -10,6 +10,26 @@ for testing difficult to reproduce backend states.
|
|
|
10
10
|
|
|
11
11
|
## [Documentation ↗](https://mockaton.com) | [Changelog ↗](https://mockaton.com/changelog)
|
|
12
12
|
|
|
13
|
+
## TL;DR
|
|
14
|
+
```shell
|
|
15
|
+
npx mockaton my-mocks-dir
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
It’s like `servedir`, but supports dynamic segments in filenames. For example:
|
|
19
|
+
|
|
20
|
+
**Route**: [/api/company/123](#) <br/>
|
|
21
|
+
**File**: my-mocks-dir/api/company/[id].GET.200.json
|
|
22
|
+
|
|
23
|
+
Statics assets don’t need that extension:
|
|
24
|
+
|
|
25
|
+
**Route**: [/media/avatar.png](#) <br/>
|
|
26
|
+
**File**: my-mocks-dir/media/avatar.png
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
## Dashboard
|
|
30
|
+
Besides the dashboard, there’s a programmatic [Control API](https://mockaton.com/api).
|
|
31
|
+
Also, there’s a [Browser Extension](https://mockaton.com/scraping) for scraping responses from your backend.
|
|
32
|
+
|
|
13
33
|
<picture>
|
|
14
34
|
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/ericfortis/mockaton/refs/heads/main/pixaton-tests/tests/macos/pic-for-readme.vp762x762.light.gold.png">
|
|
15
35
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ericfortis/mockaton/refs/heads/main/pixaton-tests/tests/macos/pic-for-readme.vp762x762.dark.gold.png">
|
|
@@ -36,12 +56,8 @@ curl localhost:2020/api/user
|
|
|
36
56
|
```
|
|
37
57
|
|
|
38
58
|
|
|
39
|
-
##
|
|
40
|
-
|
|
41
|
-
mocks. Instead, a given directory is scanned for filenames
|
|
42
|
-
following a convention similar to the URLs.
|
|
43
|
-
|
|
44
|
-
For example, for [/api/company/123](#), the file could be:
|
|
59
|
+
## Examples
|
|
60
|
+
[/api/company/123](#)
|
|
45
61
|
|
|
46
62
|
<code>my_mocks_dir/<b>api/company/[id]</b>.GET.200.json</code>
|
|
47
63
|
```json
|
|
@@ -50,6 +66,8 @@ For example, for [/api/company/123](#), the file could be:
|
|
|
50
66
|
}
|
|
51
67
|
```
|
|
52
68
|
|
|
69
|
+
<br/>
|
|
70
|
+
|
|
53
71
|
Or, you can write it in TypeScript (it will be sent as JSON).
|
|
54
72
|
|
|
55
73
|
<code>my_mocks_dir/<b>api/company/[id]</b>.GET.200.ts</code>
|
|
@@ -59,15 +77,17 @@ export default {
|
|
|
59
77
|
}
|
|
60
78
|
```
|
|
61
79
|
|
|
80
|
+
<br/>
|
|
81
|
+
|
|
62
82
|
Similarly, you can handle logic with [Functional Mocks](https://mockaton.com/functional-mocks):
|
|
63
83
|
|
|
64
84
|
<code>my_mocks_dir/<b>api/company/[companyId]/user/[userId]</b>.GET.200.ts</code>
|
|
65
85
|
```ts
|
|
66
86
|
import { IncomingMessage, OutgoingMessage } from 'node:http'
|
|
67
|
-
import {
|
|
87
|
+
import { parseSegments } from 'mockaton'
|
|
68
88
|
|
|
69
89
|
export default async function (req: IncomingMessage, response: OutgoingMessage) {
|
|
70
|
-
const { companyId, userId } =
|
|
90
|
+
const { companyId, userId } = parseSegments(req.url, import.meta.filename)
|
|
71
91
|
const foo = await getFoo()
|
|
72
92
|
return JSON.stringify({
|
|
73
93
|
foo,
|
|
@@ -77,11 +97,3 @@ export default async function (req: IncomingMessage, response: OutgoingMessage)
|
|
|
77
97
|
})
|
|
78
98
|
}
|
|
79
99
|
```
|
|
80
|
-
|
|
81
|
-
## Browser Extension
|
|
82
|
-
[Browser Extension](https://mockaton.com/scraping) for scraping responses from your backend.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
## API
|
|
86
|
-
Programmatic [Control API](https://mockaton.com/api).
|
|
87
|
-
|
package/index.d.ts
CHANGED
|
@@ -62,7 +62,7 @@ export const echoFilePlugin: Plugin
|
|
|
62
62
|
export function jwtCookie(cookieName: string, payload: any, path?: string): string
|
|
63
63
|
|
|
64
64
|
export function parseJSON(request: IncomingMessage): Promise<any>
|
|
65
|
-
export function
|
|
65
|
+
export function parseSegments(reqUrl: string, filename: string): Record<string, string>
|
|
66
66
|
export function parseQueryParams(reqUrl: string): URLSearchParams
|
|
67
67
|
|
|
68
68
|
export type JsonPromise<T> = Promise<Response & { json(): Promise<T> }>
|
package/index.js
CHANGED
|
@@ -5,6 +5,6 @@ export { Mockaton } from './src/server/Mockaton.js'
|
|
|
5
5
|
export { jwtCookie } from './src/server/utils/jwt.js'
|
|
6
6
|
export { jsToJsonPlugin, echoFilePlugin } from './src/server/MockDispatcherPlugins.js'
|
|
7
7
|
export { parseJSON, BodyReaderError } from './src/server/utils/HttpIncomingMessage.js'
|
|
8
|
-
export {
|
|
8
|
+
export { parseSegments, parseQueryParams, parseSplats } from './src/server/UrlParsers.js'
|
|
9
9
|
|
|
10
10
|
export const defineConfig = opts => opts
|
package/package.json
CHANGED
|
@@ -25,9 +25,9 @@ export const API = {
|
|
|
25
25
|
deleteMock: MOUNT + '/delete-mock',
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export const HEADER_502 = 'Mockaton502'
|
|
29
|
-
|
|
30
28
|
export const DEFAULT_MOCK_COMMENT = '(default)'
|
|
31
29
|
|
|
32
30
|
export const EXT_UNKNOWN_MIME = 'unknown'
|
|
33
31
|
export const EXT_EMPTY = 'empty'
|
|
32
|
+
|
|
33
|
+
export const FILENAME_HEADER = 'Mockaton-File'
|
package/src/client/IndexHtml.js
CHANGED
|
@@ -7,24 +7,24 @@ export const CSP = [
|
|
|
7
7
|
|
|
8
8
|
// language=html
|
|
9
9
|
export const IndexHtml = (hotReloadEnabled, version) => `
|
|
10
|
-
<!DOCTYPE html>
|
|
11
|
-
<html lang="en-US">
|
|
12
|
-
<head>
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
<!DOCTYPE html>
|
|
11
|
+
<html lang="en-US">
|
|
12
|
+
<head>
|
|
13
|
+
<meta charset="UTF-8">
|
|
14
|
+
<base href="${API.dashboard}/">
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
<script type="module" src="app.js"></script>
|
|
17
|
+
<link rel="preload" href="${API.state}" as="fetch" crossorigin>
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
</head>
|
|
24
|
-
<body>
|
|
25
|
-
${hotReloadEnabled
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
</body>
|
|
29
|
-
</html>
|
|
19
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m235 33.7v202c0 9.19-5.81 14-17.4 14-11.6 0-17.4-4.83-17.4-14v-151c-0.115-4.49-6.72-5.88-8.46-0.87l-48.3 155c-2.22 7.01-7.72 10.1-16 9.9-3.63-0.191-7.01-1.14-9.66-2.89-2.89-1.72-4.83-4.34-5.57-7.72-11.1-37-22.6-74.3-34.1-111-4.34-14-8.95-31.4-14-48.3-1.82-4.83-8.16-5.32-8.46 1.16v156c0 9.19-5.81 14-17.4 14-11.6 0-17.4-4.83-17.4-14v-207c0-5.74 2.62-13.2 9.39-16.3 7.5-3.14 15-4.05 21.8-3.8 3.14 0 6.03 0.686 8.95 1.46 3.14 0.797 6.03 1.98 8.7 3.63 2.65 1.38 5.32 3.14 7.5 5.57 2.22 2.22 3.87 4.83 5.07 7.72l45.8 157c4.63-15.9 32.4-117 33.3-121 4.12-13.8 7.72-26.5 10.9-38.7 1.16-2.65 2.89-5.32 5.07-7.5 2.15-2.15 4.58-4.12 7.5-5.32 2.65-1.57 5.57-2.89 8.46-3.63 3.14-0.797 9.44-0.988 12.1-0.988 11.6 1.07 29.4 9.14 29.4 27z' fill='%23808080'/%3E%3C/svg%3E">
|
|
20
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
21
|
+
<meta name="description" content="HTTP Mock Server">
|
|
22
|
+
<title>Mockaton v${version}</title>
|
|
23
|
+
</head>
|
|
24
|
+
<body>
|
|
25
|
+
${hotReloadEnabled
|
|
26
|
+
? `<script type="module" src="utils/watcherDev.js?url=${API.watchHotReload}"></script>`
|
|
27
|
+
: ''}
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
30
30
|
`
|
package/src/client/app-header.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { createElement as r, t
|
|
1
|
+
import { createElement as r, t } from './utils/dom.js'
|
|
2
2
|
import { Logo, HelpIcon } from './graphics.js'
|
|
3
3
|
import { store } from './app-store.js'
|
|
4
4
|
|
|
5
5
|
import CSS from './app.css' with { type: 'css' }
|
|
6
|
-
|
|
6
|
+
import { extractClassNames } from './utils/css.js'
|
|
7
|
+
Object.assign(CSS, extractClassNames(CSS))
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
export function Header() {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { createElement as r, t
|
|
2
|
-
import { HEADER_502 } from './ApiConstants.js'
|
|
1
|
+
import { createElement as r, t } from './utils/dom.js'
|
|
3
2
|
import { parseFilename } from './Filename.js'
|
|
4
3
|
import { store } from './app-store.js'
|
|
5
4
|
|
|
6
5
|
import CSS from './app.css' with { type: 'css' }
|
|
7
|
-
|
|
6
|
+
import { extractClassNames } from './utils/css.js'
|
|
7
|
+
Object.assign(CSS, extractClassNames(CSS))
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
const titleRef = {}
|
|
@@ -41,12 +41,9 @@ function PayloadViewerTitle(file, statusText) {
|
|
|
41
41
|
|
|
42
42
|
function PayloadViewerTitleWhenProxied(response) {
|
|
43
43
|
const mime = response.headers.get('content-type') || ''
|
|
44
|
-
const badGateway = response.headers.get(HEADER_502)
|
|
45
44
|
return (
|
|
46
45
|
r('span', null,
|
|
47
|
-
|
|
48
|
-
? r('span', null, t`⛔ Fallback Backend Error` + ' ')
|
|
49
|
-
: r('span', null, t`Got` + ' '),
|
|
46
|
+
r('span', null, t`Got` + ' '),
|
|
50
47
|
r('abbr', { title: response.statusText }, response.status),
|
|
51
48
|
' ' + mime))
|
|
52
49
|
}
|
package/src/client/app-store.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { Commander } from './ApiCommander.js'
|
|
2
|
+
import { groupByFolder } from './dir/groupByFolder.js'
|
|
3
|
+
import { dittoSplitPaths } from './dir/dittoSplitPaths.js'
|
|
2
4
|
import { parseFilename, extractComments } from './Filename.js'
|
|
5
|
+
import { LocalStorageSet, QueryParamBool } from './utils/LocalStorage.js'
|
|
3
6
|
import { EXT_UNKNOWN_MIME, EXT_EMPTY } from './ApiConstants.js'
|
|
4
7
|
|
|
5
8
|
|
|
@@ -22,25 +25,21 @@ export const store = {
|
|
|
22
25
|
collectProxied: false,
|
|
23
26
|
proxyFallback: '',
|
|
24
27
|
showProxyField: null,
|
|
25
|
-
get canProxy() {
|
|
26
|
-
return Boolean(store.proxyFallback)
|
|
27
|
-
},
|
|
28
|
+
get canProxy() { return Boolean(store.proxyFallback) },
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
_groupByMethod: new QueryParamBool('groupByMethod'),
|
|
31
|
+
get groupByMethod() { return store._groupByMethod.value },
|
|
30
32
|
toggleGroupByMethod() {
|
|
31
|
-
store.
|
|
32
|
-
togglePreference('groupByMethod', store.groupByMethod)
|
|
33
|
+
store._groupByMethod.toggle()
|
|
33
34
|
store.render()
|
|
34
35
|
},
|
|
35
36
|
|
|
36
|
-
collapsedFolders: new
|
|
37
|
+
collapsedFolders: new LocalStorageSet('collapsedFolders'),
|
|
37
38
|
setFolderCollapsed(folder, collapsed) {
|
|
38
39
|
if (collapsed)
|
|
39
40
|
store.collapsedFolders.add(folder)
|
|
40
41
|
else
|
|
41
42
|
store.collapsedFolders.delete(folder)
|
|
42
|
-
|
|
43
|
-
globalThis.localStorage?.setItem('collapsedFolders', JSON.stringify([...store.collapsedFolders]))
|
|
44
43
|
},
|
|
45
44
|
|
|
46
45
|
chosenLink: { method: '', urlMask: '' },
|
|
@@ -123,10 +122,6 @@ export const store = {
|
|
|
123
122
|
|
|
124
123
|
_dittoCache: new Map(),
|
|
125
124
|
|
|
126
|
-
brokerFor(method, urlMask) {
|
|
127
|
-
return store.brokersByMethod[method]?.[urlMask]
|
|
128
|
-
},
|
|
129
|
-
|
|
130
125
|
brokerAsRow(method, urlMask) {
|
|
131
126
|
const b = store.brokerFor(method, urlMask)
|
|
132
127
|
const r = new BrokerRowModel(b, store.canProxy)
|
|
@@ -134,13 +129,15 @@ export const store = {
|
|
|
134
129
|
return r
|
|
135
130
|
},
|
|
136
131
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
132
|
+
brokerFor(method, urlMask) {
|
|
133
|
+
return store.brokersByMethod[method]?.[urlMask]
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
folderGroupsByMethod(method) {
|
|
137
|
+
return groupByFolder(store._brokersAsRowsByMethod(method))
|
|
141
138
|
},
|
|
142
139
|
|
|
143
|
-
|
|
140
|
+
_brokersAsRowsByMethod(method) {
|
|
144
141
|
const rows = store._brokersAsArray(method)
|
|
145
142
|
.map(b => new BrokerRowModel(b, store.canProxy))
|
|
146
143
|
.sort((a, b) => a.urlMask.localeCompare(b.urlMask))
|
|
@@ -200,70 +197,15 @@ export const store = {
|
|
|
200
197
|
store._request(() => api.setRouteIsDelayed(method, urlMask, checked), async response => {
|
|
201
198
|
store._setBroker(await response.json())
|
|
202
199
|
})
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
|
|
200
|
+
},
|
|
207
201
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const group = globalThis.localStorage?.getItem(param) !== '0'
|
|
213
|
-
if (!group) {
|
|
214
|
-
const url = new URL(globalThis.location?.href)
|
|
215
|
-
url.searchParams.set(param, '0')
|
|
216
|
-
history.replaceState(null, '', url)
|
|
217
|
-
}
|
|
218
|
-
return group
|
|
202
|
+
_setBroker(broker) {
|
|
203
|
+
const { method, urlMask } = parseFilename(broker.file)
|
|
204
|
+
store.brokersByMethod[method] ??= {}
|
|
205
|
+
store.brokersByMethod[method][urlMask] = broker
|
|
219
206
|
}
|
|
220
|
-
return qs.get(param) !== '0'
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// When false, the URL and localStorage will have param='0'
|
|
224
|
-
function togglePreference(param, nextVal) {
|
|
225
|
-
if (nextVal)
|
|
226
|
-
globalThis.localStorage?.removeItem(param)
|
|
227
|
-
else
|
|
228
|
-
globalThis.localStorage?.setItem(param, nextVal)
|
|
229
|
-
|
|
230
|
-
const url = new URL(location.href)
|
|
231
|
-
if (nextVal)
|
|
232
|
-
url.searchParams.delete(param)
|
|
233
|
-
else
|
|
234
|
-
url.searchParams.set(param, '0')
|
|
235
|
-
history.replaceState(null, '', url)
|
|
236
207
|
}
|
|
237
208
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Think of this as a way of printing a directory tree in which
|
|
242
|
-
* the repeated folder paths are kept but styled differently.
|
|
243
|
-
* @param {string[]} paths - sorted
|
|
244
|
-
*/
|
|
245
|
-
export function dittoSplitPaths(paths) {
|
|
246
|
-
const pParts = paths.map(p => p.split('/').filter(Boolean))
|
|
247
|
-
return paths.map((p, i) => {
|
|
248
|
-
if (i === 0)
|
|
249
|
-
return ['', p]
|
|
250
|
-
|
|
251
|
-
const prev = pParts[i - 1]
|
|
252
|
-
const curr = pParts[i]
|
|
253
|
-
const min = Math.min(curr.length, prev.length)
|
|
254
|
-
let j = 0
|
|
255
|
-
while (j < min && curr[j] === prev[j])
|
|
256
|
-
j++
|
|
257
|
-
|
|
258
|
-
if (!j) // no common dirs
|
|
259
|
-
return ['', p]
|
|
260
|
-
|
|
261
|
-
const ditto = '/' + curr.slice(0, j).join('/') + '/'
|
|
262
|
-
return [ditto, p.slice(ditto.length)]
|
|
263
|
-
})
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
|
|
267
209
|
export class BrokerRowModel {
|
|
268
210
|
opts = /** @type {[key:string, label:string, selected:boolean][]} */ []
|
|
269
211
|
isNew = false
|
|
@@ -1,31 +1,7 @@
|
|
|
1
1
|
import { test } from 'node:test'
|
|
2
2
|
import { deepEqual } from 'node:assert/strict'
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
test('dittoSplitPaths', () => {
|
|
8
|
-
const input = [
|
|
9
|
-
'/api/user',
|
|
10
|
-
'/api/user/avatar',
|
|
11
|
-
'/api/user/friends',
|
|
12
|
-
'/api/vid',
|
|
13
|
-
'/api/video/id',
|
|
14
|
-
'/api/video/stats',
|
|
15
|
-
'/v2/foo',
|
|
16
|
-
'/v2/foo/bar'
|
|
17
|
-
]
|
|
18
|
-
deepEqual(dittoSplitPaths(input), [
|
|
19
|
-
['', '/api/user'],
|
|
20
|
-
['/api/user/', 'avatar'],
|
|
21
|
-
['/api/user/', 'friends'],
|
|
22
|
-
['/api/', 'vid'],
|
|
23
|
-
['/api/', 'video/id'],
|
|
24
|
-
['/api/video/', 'stats'],
|
|
25
|
-
['', '/v2/foo'],
|
|
26
|
-
['/v2/foo/', 'bar']
|
|
27
|
-
])
|
|
28
|
-
})
|
|
4
|
+
import { BrokerRowModel, t } from './app-store.js'
|
|
29
5
|
|
|
30
6
|
|
|
31
7
|
test('BrokerRowModel', () => {
|
package/src/client/app.css
CHANGED
|
@@ -185,15 +185,19 @@ header {
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
.HelpLink {
|
|
188
|
-
width:
|
|
189
|
-
height:
|
|
188
|
+
width: 22px;
|
|
189
|
+
height: 22px;
|
|
190
190
|
flex-shrink: 0;
|
|
191
191
|
align-self: end;
|
|
192
|
-
margin-bottom:
|
|
192
|
+
margin-bottom: 3px;
|
|
193
193
|
margin-left: auto;
|
|
194
194
|
border-radius: 50%;
|
|
195
|
-
fill:
|
|
196
|
-
background: var(--
|
|
195
|
+
fill: var(--colorBgHeader);
|
|
196
|
+
background: var(--colorLabel);
|
|
197
|
+
|
|
198
|
+
&:hover {
|
|
199
|
+
background: var(--colorAccent);
|
|
200
|
+
}
|
|
197
201
|
|
|
198
202
|
svg {
|
|
199
203
|
transform: scale(.7);
|
package/src/client/app.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { createElement as r, t,
|
|
1
|
+
import { createElement as r, t, restoreFocus, Fragment } from './utils/dom.js'
|
|
2
2
|
|
|
3
3
|
import { store } from './app-store.js'
|
|
4
4
|
import { API } from './ApiConstants.js'
|
|
5
5
|
import { Header } from './app-header.js'
|
|
6
|
-
import { dirStructure } from './dirStructure.js'
|
|
7
6
|
import { PayloadViewer, previewMock } from './app-payload-viewer.js'
|
|
8
7
|
import { TimerIcon, CloudIcon, ChevronDownIcon } from './graphics.js'
|
|
9
8
|
|
|
10
9
|
import CSS from './app.css' with { type: 'css' }
|
|
10
|
+
import { extractClassNames, classNames } from './utils/css.js'
|
|
11
11
|
document.adoptedStyleSheets.push(CSS)
|
|
12
|
-
|
|
12
|
+
Object.assign(CSS, extractClassNames(CSS))
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
store.onError = onError
|
|
@@ -100,14 +100,14 @@ function MockList() {
|
|
|
100
100
|
r('div', {
|
|
101
101
|
className: classNames(CSS.TableHeading, store.canProxy && CSS.canProxy)
|
|
102
102
|
}, method),
|
|
103
|
-
FolderGroups(store.
|
|
103
|
+
FolderGroups(store.folderGroupsByMethod(method))))
|
|
104
104
|
|
|
105
|
-
return FolderGroups(store.
|
|
105
|
+
return FolderGroups(store.folderGroupsByMethod('*'))
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
function FolderGroups(
|
|
108
|
+
function FolderGroups(brokersTree) {
|
|
109
109
|
const res = []
|
|
110
|
-
for (const b of
|
|
110
|
+
for (const b of brokersTree) {
|
|
111
111
|
if (!b.children.length)
|
|
112
112
|
res.push(Row(b))
|
|
113
113
|
else
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Think of this as a way of printing a directory tree in which
|
|
3
|
+
* the repeated folder paths are kept but styled differently.
|
|
4
|
+
* @param {string[]} paths - sorted
|
|
5
|
+
*/
|
|
6
|
+
export function dittoSplitPaths(paths) {
|
|
7
|
+
const pParts = paths.map(p => p.split('/').filter(Boolean))
|
|
8
|
+
return paths.map((p, i) => {
|
|
9
|
+
if (i === 0)
|
|
10
|
+
return ['', p]
|
|
11
|
+
|
|
12
|
+
const prev = pParts[i - 1]
|
|
13
|
+
const curr = pParts[i]
|
|
14
|
+
const min = Math.min(curr.length, prev.length)
|
|
15
|
+
let j = 0
|
|
16
|
+
while (j < min && curr[j] === prev[j])
|
|
17
|
+
j++
|
|
18
|
+
|
|
19
|
+
if (!j) // no common dirs
|
|
20
|
+
return ['', p]
|
|
21
|
+
|
|
22
|
+
const ditto = '/' + curr.slice(0, j).join('/') + '/'
|
|
23
|
+
return [ditto, p.slice(ditto.length)]
|
|
24
|
+
})
|
|
25
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import { deepEqual } from 'node:assert/strict'
|
|
3
|
+
import { dittoSplitPaths } from './dittoSplitPaths.js'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
test('dittoSplitPaths', () => {
|
|
7
|
+
const input = [
|
|
8
|
+
'/api/user',
|
|
9
|
+
'/api/user/avatar',
|
|
10
|
+
'/api/user/friends',
|
|
11
|
+
'/api/vid',
|
|
12
|
+
'/api/video/id',
|
|
13
|
+
'/api/video/stats',
|
|
14
|
+
'/v2/foo',
|
|
15
|
+
'/v2/foo/bar'
|
|
16
|
+
]
|
|
17
|
+
deepEqual(dittoSplitPaths(input), [
|
|
18
|
+
['', '/api/user'],
|
|
19
|
+
['/api/user/', 'avatar'],
|
|
20
|
+
['/api/user/', 'friends'],
|
|
21
|
+
['/api/', 'vid'],
|
|
22
|
+
['/api/', 'video/id'],
|
|
23
|
+
['/api/video/', 'stats'],
|
|
24
|
+
['', '/v2/foo'],
|
|
25
|
+
['/v2/foo/', 'bar']
|
|
26
|
+
])
|
|
27
|
+
})
|
|
28
|
+
|
|
@@ -1,13 +1,8 @@
|
|
|
1
|
-
function TrieNode() {
|
|
2
|
-
this.brokers = []
|
|
3
|
-
this.tnChildren = new Map()
|
|
4
|
-
}
|
|
5
|
-
|
|
6
1
|
/**
|
|
7
2
|
* @param {Partial<BrokerRowModel>[]} brokers
|
|
8
3
|
* @returns {Partial<BrokerRowModel>[]}
|
|
9
4
|
*/
|
|
10
|
-
export function
|
|
5
|
+
export function groupByFolder(brokers) {
|
|
11
6
|
return dfs(trie(brokers))
|
|
12
7
|
}
|
|
13
8
|
|
|
@@ -16,8 +11,8 @@ function trie(brokers) {
|
|
|
16
11
|
for (const b of brokers) {
|
|
17
12
|
let node = root
|
|
18
13
|
for (const seg of b.urlMask.split('/')) { // TODO it should ignore query string
|
|
19
|
-
const segNode = node.
|
|
20
|
-
node.
|
|
14
|
+
const segNode = node.getChild(seg) || new TrieNode()
|
|
15
|
+
node.addChild(seg, segNode)
|
|
21
16
|
node = segNode
|
|
22
17
|
}
|
|
23
18
|
node.brokers.push(b)
|
|
@@ -25,10 +20,18 @@ function trie(brokers) {
|
|
|
25
20
|
return root
|
|
26
21
|
}
|
|
27
22
|
|
|
23
|
+
class TrieNode {
|
|
24
|
+
#children = new Map()
|
|
25
|
+
brokers = []
|
|
26
|
+
addChild(k, v) { this.#children.set(k, v) }
|
|
27
|
+
getChild(k) { return this.#children.get(k) }
|
|
28
|
+
getChildren() { return this.#children.values() }
|
|
29
|
+
}
|
|
30
|
+
|
|
28
31
|
/** @param {TrieNode} node */
|
|
29
32
|
function dfs(node) {
|
|
30
33
|
const childBrokers = []
|
|
31
|
-
for (const tnc of node.
|
|
34
|
+
for (const tnc of node.getChildren())
|
|
32
35
|
childBrokers.push(...dfs(tnc))
|
|
33
36
|
|
|
34
37
|
const brokers = node.brokers.length
|
|
@@ -38,10 +41,11 @@ function dfs(node) {
|
|
|
38
41
|
if (!brokers.length)
|
|
39
42
|
return []
|
|
40
43
|
|
|
41
|
-
const [
|
|
42
|
-
if (node.brokers.length || !
|
|
43
|
-
|
|
44
|
-
return [
|
|
44
|
+
const [b0, ...rest] = brokers
|
|
45
|
+
if (node.brokers.length || !b0.children.length) {
|
|
46
|
+
b0.children.push(...rest)
|
|
47
|
+
return [b0]
|
|
45
48
|
}
|
|
46
49
|
return brokers
|
|
47
50
|
}
|
|
51
|
+
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import { deepEqual } from 'node:assert/strict'
|
|
3
|
+
import { groupByFolder } from './groupByFolder.js'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
test('groupByFolder', () => {
|
|
7
|
+
const input = [
|
|
8
|
+
{ children: [], method: 'GET', urlMask: '/api/user' },
|
|
9
|
+
{ children: [], method: 'GET', urlMask: '/api/user/avatar' },
|
|
10
|
+
{ children: [], method: 'GET', urlMask: '/api/video/[id]' },
|
|
11
|
+
{ children: [], method: 'GET', urlMask: '/index.html' },
|
|
12
|
+
{ children: [], method: 'GET', urlMask: '/media/file-a.txt' },
|
|
13
|
+
{ children: [], method: 'GET', urlMask: '/media/file-b.txt' },
|
|
14
|
+
{ children: [], method: 'GET', urlMask: '/media/sub/file-aa.txt' },
|
|
15
|
+
{ children: [], method: 'GET', urlMask: '/media/sub/file-bb.txt' },
|
|
16
|
+
{ children: [], method: 'POST', urlMask: '/api/user' },
|
|
17
|
+
{ children: [], method: 'POST', urlMask: '/api/user/avatar/foo' },
|
|
18
|
+
{ children: [], method: 'PATCH', urlMask: '/api/user' }
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
const expected = [
|
|
23
|
+
{
|
|
24
|
+
urlMask: '/api/user',
|
|
25
|
+
method: 'GET',
|
|
26
|
+
children: [
|
|
27
|
+
{
|
|
28
|
+
urlMask: '/api/user/avatar',
|
|
29
|
+
method: 'GET',
|
|
30
|
+
children: [
|
|
31
|
+
{
|
|
32
|
+
urlMask: '/api/user/avatar/foo',
|
|
33
|
+
method: 'POST',
|
|
34
|
+
children: []
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}, {
|
|
38
|
+
urlMask: '/api/user',
|
|
39
|
+
method: 'POST',
|
|
40
|
+
children: []
|
|
41
|
+
}, {
|
|
42
|
+
urlMask: '/api/user',
|
|
43
|
+
method: 'PATCH',
|
|
44
|
+
children: []
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
urlMask: '/api/video/[id]',
|
|
50
|
+
method: 'GET',
|
|
51
|
+
children: []
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
urlMask: '/index.html',
|
|
55
|
+
method: 'GET',
|
|
56
|
+
children: []
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
urlMask: '/media/file-a.txt',
|
|
60
|
+
method: 'GET',
|
|
61
|
+
children: [
|
|
62
|
+
{
|
|
63
|
+
urlMask: '/media/file-b.txt',
|
|
64
|
+
method: 'GET',
|
|
65
|
+
children: []
|
|
66
|
+
}, {
|
|
67
|
+
urlMask: '/media/sub/file-aa.txt',
|
|
68
|
+
method: 'GET',
|
|
69
|
+
children: [
|
|
70
|
+
{
|
|
71
|
+
urlMask: '/media/sub/file-bb.txt',
|
|
72
|
+
method: 'GET',
|
|
73
|
+
children: []
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
deepEqual(groupByFolder(input), expected)
|
|
82
|
+
})
|