mockaton 0.0.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/Api.js +108 -0
- package/ApiConstants.js +22 -0
- package/Config.js +41 -0
- package/Dashboard.css +206 -0
- package/Dashboard.html +12 -0
- package/Dashboard.js +355 -0
- package/LICENSE +21 -0
- package/MockBroker.js +107 -0
- package/MockDispatcher.js +72 -0
- package/Mockaton.js +39 -0
- package/README-dashboard-dropdown.png +0 -0
- package/README-dashboard.png +0 -0
- package/README-mocks-with-comments.png +0 -0
- package/README.md +211 -0
- package/Route.js +90 -0
- package/StaticDispatcher.js +29 -0
- package/Tests.js +367 -0
- package/_usage_example.js +14 -0
- package/cookie.js +29 -0
- package/index.d.ts +17 -0
- package/index.js +2 -0
- package/mockBrokersCollection.js +84 -0
- package/package.json +12 -0
- package/sample-mocks/api/user/.GET.200.json +1 -0
- package/sample-mocks/api/user/.GET.501.txt +7 -0
- package/sample-mocks/api/user/edit-name.PATCH.200.json +1 -0
- package/sample-mocks/api/user/edit-name.PATCH.200.md +12 -0
- package/sample-mocks/api/user/edit-name.PATCH.501.txt +0 -0
- package/sample-mocks/api/user/friends.GET.200.json +1 -0
- package/sample-mocks/api/user/friends.GET.204.json +4 -0
- package/sample-mocks/api/user/friends.GET.501.txt +0 -0
- package/sample-mocks/api/user/logout.POST.200.json +1 -0
- package/sample-mocks/api/user/logout.POST.501.txt +0 -0
- package/sample-mocks/api/user/videos(assorted).GET.200.json +13 -0
- package/sample-mocks/api/user/videos(entirely unverified).GET.200.json +13 -0
- package/sample-mocks/api/user/videos(entirely verified)(another comment).GET.200.json +13 -0
- package/sample-mocks/api/user/videos.GET.501.txt +0 -0
- package/sample-mocks/api/video/[id].GET.200.json +4 -0
- package/sample-mocks/api/video/[id].GET.501.txt +0 -0
- package/sample-mocks/api/video/list(concat newly uploaded).GET.200.mjs +8 -0
- package/sample-mocks/api/video/list.GET.200.json +11 -0
- package/sample-mocks/api/video/list.GET.501.txt +0 -0
- package/sample-mocks/api/video/stat/[stat-id]/[video-id].GET.200.json +1 -0
- package/sample-mocks/api/video/stat/[stat-id]/[video-id].GET.501.txt +0 -0
- package/sample-mocks/api/video/stat/[stat-id]/all-videos?limit=[limit].GET.200.json +4 -0
- package/sample-mocks/api/video/stat/[stat-id]/all-videos?limit=[limit].GET.501.txt +0 -0
- package/sample-mocks/api/video/upload(insert newly uploaded).POST.201.mjs +10 -0
- package/sample-mocks/api/video/upload.POST.201.json +3 -0
- package/sample-mocks/api/video/upload.POST.501.txt +0 -0
- package/sample-static/another-entry/index.html +12 -0
- package/sample-static/assets/app.js +1 -0
- package/sample-static/assets/video.mp4 +0 -0
- package/sample-static/index.html +13 -0
- package/utils/http-request.js +36 -0
- package/utils/http-response.js +60 -0
- package/utils/jwt.js +21 -0
- package/utils/mime.js +47 -0
- package/utils/validate.js +17 -0
package/README.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Mockaton (Mock Server)
|
|
2
|
+
|
|
3
|
+
Mockaton scans `Config.mocksDir` for files
|
|
4
|
+
following a specific file name convention, which is similar to the URLs.
|
|
5
|
+
|
|
6
|
+
For example, the following file will be served for `/api/user/1234`
|
|
7
|
+
```
|
|
8
|
+
api/
|
|
9
|
+
api/user/
|
|
10
|
+
api/user/[user-id].GET.200.json
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### Mock Variants
|
|
14
|
+
Each route can have different mocks and those variants could either be:
|
|
15
|
+
- a different response status code, (e.g. 200, 401),
|
|
16
|
+
- or a comment on the filename, which is anything within parentheses.
|
|
17
|
+
|
|
18
|
+
The variants can be manually selected via the dashboard
|
|
19
|
+
UI, or programmatically for instance for setting up tests.
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
## Getting Started
|
|
23
|
+
The best way to learn mockaton is by checking out this repo and
|
|
24
|
+
exploring its [sample-mocks/](./sample-mocks) directory. Then run
|
|
25
|
+
[`./_usage_example.js`](./_usage_example.js) and you’ll see this dashboard:
|
|
26
|
+
|
|
27
|
+

|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
### Mock Variants of Status Code
|
|
31
|
+
The **sample-mocks/** directory has three mock alternatives for serving
|
|
32
|
+
`/api/user/friends`.
|
|
33
|
+
- with an HTTP status of _200 - OK_,
|
|
34
|
+
- another one with _204 - No Content_ of an empty list of friends, and
|
|
35
|
+
- a _501 - Internal Server Error_
|
|
36
|
+
- By the way, 501 mocks get autogenerated for routes that have no 501’s.
|
|
37
|
+
|
|
38
|
+

|
|
39
|
+
|
|
40
|
+
### Mock Variants with Comments
|
|
41
|
+
Comments are anything within parentheses, including them.
|
|
42
|
+

|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Delay
|
|
47
|
+
The clock icon next to the mock selector dropdown is a checkbox for delaying a
|
|
48
|
+
particular response. They are handy for testing spinners when developing UIs. By the
|
|
49
|
+
way, the milliseconds for the delay is globally configurable via `Config.delay`.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Basic Usage (see [_usage_example.js](./_usage_example.js))
|
|
54
|
+
```
|
|
55
|
+
npm install @ericfortis/mockaton
|
|
56
|
+
```
|
|
57
|
+
Create a `my-mockaton.js` file
|
|
58
|
+
```js
|
|
59
|
+
import { resolve } from 'node:path'
|
|
60
|
+
import { Mockaton } from '@ericfortis/mockaton'
|
|
61
|
+
|
|
62
|
+
Mockaton({ // Config options
|
|
63
|
+
port: 2345,
|
|
64
|
+
mocksDir: resolve('my-mocks-dir')
|
|
65
|
+
})
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
node my-mockaton.js
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## File Name Convention
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
### Extension
|
|
79
|
+
`.Method.HttpResponseStatusCode.FileExt`
|
|
80
|
+
|
|
81
|
+
The **file extension** can anything, but `.md` and `.mjs` are reserved
|
|
82
|
+
for documentation, and mock processors (more on that later).
|
|
83
|
+
|
|
84
|
+
By the way, the `Config.allowedExt` regex defaults to: `/\.(json|txt|md|mjs)$/`
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
### Dynamic Parameters
|
|
88
|
+
Anything within square brackets. For example, `api/user/[id]/[age].GET.200.json`
|
|
89
|
+
|
|
90
|
+
### Comments
|
|
91
|
+
Comments are anything within parentheses, including them, and they are
|
|
92
|
+
ignored for URL purposes. In other words, comments have no effect on the
|
|
93
|
+
URL mask. For example, these two are for `/api/foo`
|
|
94
|
+
```
|
|
95
|
+
api/foo(my comment).GET.200.json(foo)
|
|
96
|
+
api/foo.GET.200.json
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Query String Params
|
|
100
|
+
```
|
|
101
|
+
api/video?limit=[limit].GET.200.json
|
|
102
|
+
```
|
|
103
|
+
The query string behaves like comments in the sense it’s
|
|
104
|
+
only used for documenting the URL API contract.
|
|
105
|
+
|
|
106
|
+
In other words, the query string is ignored when routing to it. BTW, in Windows,
|
|
107
|
+
filenames containing "?" are not permitted, but they are ignored anyway.
|
|
108
|
+
|
|
109
|
+
https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
### Default (index-like) file
|
|
113
|
+
For the default route of a directory, omit the name (just use
|
|
114
|
+
the extension). For example, the following files will be routed
|
|
115
|
+
to `api/foo` because comments and the query string are ignored.
|
|
116
|
+
```text
|
|
117
|
+
api/foo/.GET.200.json
|
|
118
|
+
api/foo/?bar=[bar].GET.200.json
|
|
119
|
+
api/foo/(my comment).GET.200.json
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Config Options
|
|
125
|
+
```ts
|
|
126
|
+
interface Config {
|
|
127
|
+
mocksDir: string
|
|
128
|
+
staticDir?: string
|
|
129
|
+
host?: string,
|
|
130
|
+
port?: number
|
|
131
|
+
|
|
132
|
+
cookies?(): object
|
|
133
|
+
|
|
134
|
+
skipOpen?: boolean
|
|
135
|
+
allowedExt?: RegExp
|
|
136
|
+
delayMilliseconds?: number
|
|
137
|
+
database?: object
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Cookies
|
|
143
|
+
```
|
|
144
|
+
Config.cookies = {
|
|
145
|
+
'My Admin User': 'my-cookie=1;Path=/;SameSite=strict',
|
|
146
|
+
'My Normal User': 'my-cookie=0;Path=/;SameSite=strict'
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
## Mock Precedence
|
|
152
|
+
The first file in **alphabetical order** wins when a particular route has many files.
|
|
153
|
+
|
|
154
|
+
### Why do we have many mocks per Route+Method?
|
|
155
|
+
Each route has mocks for many status codes, and also different
|
|
156
|
+
mocks (by having comments) for testing particular scenarios.
|
|
157
|
+
For example, different 422 validation error messages.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Reset the Dashboard UI after insert or delete
|
|
162
|
+
When deleting the currently selected option, without refreshing the dashboard, the
|
|
163
|
+
served mock will be an alternative mock if it exists. That is, the dashboard won't show
|
|
164
|
+
a 404 after deleting the current mock if there’s another mock for that particular route.
|
|
165
|
+
|
|
166
|
+
Similarly, inserting a file that goes first in alphabetical order will
|
|
167
|
+
send a different mock from the one stated in the dashboard dropdown.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Documenting Contracts (.md)
|
|
172
|
+
This is handy for documenting request payload parameters. The dashboard will
|
|
173
|
+
print the markdown document (as plain text) above the actual payload content.
|
|
174
|
+
|
|
175
|
+
Create a markdown file following the same filename convention.
|
|
176
|
+
The status code can be any number. For example,
|
|
177
|
+
```text
|
|
178
|
+
api/foo/[user-id].POST.201.md
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Non-Deterministic Mocks (.mjs handlers)
|
|
184
|
+
Using the same filename convention, files ending
|
|
185
|
+
with `.mjs` will process the mock before serving it.
|
|
186
|
+
|
|
187
|
+
For example, this handler will uppercase the mock body.
|
|
188
|
+
```js
|
|
189
|
+
export default capitalizeAllText(mockAsText, requestBody) {
|
|
190
|
+
return mockAsText.toUpperCase();
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
In demo mode, transforms tagged with the string `demo` within a filename
|
|
195
|
+
comment get activated. Mock sets tags e.g. `demo-a` have no effect. In
|
|
196
|
+
other words, only one transform per route is supported in demo mode.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Bulk Selecting Mocks by Matching comments
|
|
201
|
+
Many mocks can be changed at once. We do that by searching the
|
|
202
|
+
comments on the filename. For example, `api/foo(demo-a).GET.200.json`
|
|
203
|
+
|
|
204
|
+
Non-matching mocks are ignored. For instance, if for a
|
|
205
|
+
particular API there is only `demo-a` and `demo-b`, changing to
|
|
206
|
+
`demo-c` will preserve the last one that was successfully set.
|
|
207
|
+
|
|
208
|
+
Similarly, if there’s no demo mock at all for
|
|
209
|
+
a route, the first dev mock (a-z) will be served.
|
|
210
|
+
|
|
211
|
+
|
package/Route.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const httpMethods = [
|
|
2
|
+
'CONNECT',
|
|
3
|
+
'DELETE',
|
|
4
|
+
'GET',
|
|
5
|
+
'HEAD',
|
|
6
|
+
'OPTIONS',
|
|
7
|
+
'PATCH',
|
|
8
|
+
'POST',
|
|
9
|
+
'PUT',
|
|
10
|
+
'TRACE'
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
export class Route {
|
|
14
|
+
#urlRegex
|
|
15
|
+
|
|
16
|
+
constructor(file) {
|
|
17
|
+
const { urlMask, method } = Route.parseFilename(file)
|
|
18
|
+
this.method = method
|
|
19
|
+
this.#urlRegex = new RegExp(`^${disregardVariables(removeQueryStringAndFragment(urlMask))}/*$`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
urlMaskMatches(url) {
|
|
23
|
+
// Appending a '/' so URLs ending with variables don't match
|
|
24
|
+
// URLs that have a path after that variable. For example,
|
|
25
|
+
// without it, the following regex would match both of these URLs:
|
|
26
|
+
// api/foo/[route_id] => api/foo/.* (wrong match because it’s too greedy)
|
|
27
|
+
// api/foo/[route_id]/suffix => api/foo/.*/suffix
|
|
28
|
+
// By the same token, the regex handles many trailing
|
|
29
|
+
// slashes. For instance, for routing api/foo/[id]?qs…
|
|
30
|
+
return this.#urlRegex.test(removeQueryStringAndFragment(url) + '/')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Anything within parentheses in the filename is a comment, including the parentheses.
|
|
34
|
+
static reComments = /\(.*?\)/g
|
|
35
|
+
|
|
36
|
+
static extractComments(filename) {
|
|
37
|
+
return Array.from(filename.matchAll(Route.reComments), ([comment]) => comment)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static hasInParentheses(filename, search) {
|
|
41
|
+
return Route.extractComments(filename)
|
|
42
|
+
.some(comment => comment.includes(search))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static parseFilename(file) {
|
|
46
|
+
const tokens = file.replace(Route.reComments, '').split('.')
|
|
47
|
+
|
|
48
|
+
let error = ''
|
|
49
|
+
if (tokens.length < 4)
|
|
50
|
+
error = 'Invalid Filename Convention'
|
|
51
|
+
|
|
52
|
+
const method = tokens.at(-3)
|
|
53
|
+
if (!httpMethods.includes(method))
|
|
54
|
+
error = `Unrecognized HTTP Method: "${method}"`
|
|
55
|
+
|
|
56
|
+
const status = Number(tokens.at(-2))
|
|
57
|
+
if (!responseStatusIsValid(status))
|
|
58
|
+
error = `Invalid HTTP Response Status: "${status}"`
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
error,
|
|
62
|
+
urlMask: '/' + removeTrailingSlash(tokens.at(-4)),
|
|
63
|
+
method,
|
|
64
|
+
status
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
// Stars out (for regex) all the paths that are in angle brackets
|
|
71
|
+
function disregardVariables(str) {
|
|
72
|
+
return str.replace(/\[.*?]/g, '[^/]*')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function removeQueryStringAndFragment(urlMask) {
|
|
76
|
+
return urlMask.replace(/[?#].*/, '')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function removeTrailingSlash(url = '') {
|
|
80
|
+
return decodeURIComponent(url
|
|
81
|
+
.replace(/\/$/, '')
|
|
82
|
+
.replace('/?', '?')
|
|
83
|
+
.replace('/#', '#'))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function responseStatusIsValid(status) {
|
|
87
|
+
return Number.isInteger(status)
|
|
88
|
+
&& status >= 100
|
|
89
|
+
&& status <= 599
|
|
90
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { existsSync, lstatSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
import { Config } from './Config.js'
|
|
5
|
+
import { sendFile, sendPartialContent } from './utils/http-response.js'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
export function isStatic(req) {
|
|
9
|
+
return Config.staticDir &&
|
|
10
|
+
existsSync(resolvePath(req))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function dispatchStatic(req, response) {
|
|
14
|
+
const file = resolvePath(req)
|
|
15
|
+
if (req.headers.range)
|
|
16
|
+
await sendPartialContent(response, req.headers.range, file)
|
|
17
|
+
else
|
|
18
|
+
sendFile(response, file)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolvePath(req) {
|
|
22
|
+
const candidate = join(Config.staticDir, req.url)
|
|
23
|
+
if (existsSync(candidate))
|
|
24
|
+
return lstatSync(candidate).isDirectory()
|
|
25
|
+
? candidate + '/index.html'
|
|
26
|
+
: candidate
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
package/Tests.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { dirname } from 'node:path'
|
|
5
|
+
import { describe, it } from 'node:test'
|
|
6
|
+
import { equal, deepEqual, match } from 'node:assert/strict'
|
|
7
|
+
import { writeFileSync, mkdtempSync, mkdirSync } from 'node:fs'
|
|
8
|
+
|
|
9
|
+
import { Route } from './Route.js'
|
|
10
|
+
import { mimeFor } from './utils/mime.js'
|
|
11
|
+
import { DP, DF } from './ApiConstants.js'
|
|
12
|
+
import { Mockaton } from './Mockaton.js'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
const tmpDir = mkdtempSync(tmpdir()) + '/'
|
|
16
|
+
const staticTmpDir = mkdtempSync(tmpdir()) + '/'
|
|
17
|
+
const fixtures = [
|
|
18
|
+
[
|
|
19
|
+
'/api',
|
|
20
|
+
'api/.GET.200.json',
|
|
21
|
+
'index-like route is just the extension convention'
|
|
22
|
+
],
|
|
23
|
+
|
|
24
|
+
// Exact route paths
|
|
25
|
+
[
|
|
26
|
+
'/api/the-route',
|
|
27
|
+
'api/the-route(comment-1).GET.200.json',
|
|
28
|
+
'my route body content'
|
|
29
|
+
], [
|
|
30
|
+
'/api/the-mime',
|
|
31
|
+
'api/the-mime.GET.200.txt',
|
|
32
|
+
'determines the content type'
|
|
33
|
+
], [
|
|
34
|
+
'/api/the-method-and-status',
|
|
35
|
+
'api/the-method-and-status.POST.201.json',
|
|
36
|
+
'obeys the HTTP method and response status'
|
|
37
|
+
], [
|
|
38
|
+
'/api/the-comment',
|
|
39
|
+
'api/the-comment(this is the actual comment).GET.200(another comment).txt',
|
|
40
|
+
''
|
|
41
|
+
], [
|
|
42
|
+
'/api/alternative',
|
|
43
|
+
'api/alternative(comment-1).GET.200.json',
|
|
44
|
+
'With_Comment_1'
|
|
45
|
+
],
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
// Dynamic Params
|
|
49
|
+
[
|
|
50
|
+
'/api/user/1234',
|
|
51
|
+
'api/user/[id]/.GET.200.json',
|
|
52
|
+
'variable at end'
|
|
53
|
+
], [
|
|
54
|
+
'/api/user/1234/suffix',
|
|
55
|
+
'api/user/[id]/suffix.GET.200.json',
|
|
56
|
+
'sandwich a variable that another route has at the end'
|
|
57
|
+
], [
|
|
58
|
+
'/api/user/exact-route',
|
|
59
|
+
'api/user/exact-route.GET.200.json',
|
|
60
|
+
'ensure dynamic params do not take precedence over exact routes'
|
|
61
|
+
],
|
|
62
|
+
|
|
63
|
+
// Query String
|
|
64
|
+
[
|
|
65
|
+
'/api/my-query-string?foo=[foo]&bar=[bar]',
|
|
66
|
+
'api/my-query-string?foo=[foo]&bar=[bar].GET.200.json',
|
|
67
|
+
'two query string params'
|
|
68
|
+
], [
|
|
69
|
+
'/api/company-a',
|
|
70
|
+
'api/company-a/[id]?limit=[limit].GET.200.json',
|
|
71
|
+
'without pretty-param nor query-params'
|
|
72
|
+
], [
|
|
73
|
+
'/api/company-b/',
|
|
74
|
+
'api/company-b/[id]?limit=[limit].GET.200.json',
|
|
75
|
+
'without pretty-param nor query-params with trailing slash'
|
|
76
|
+
], [
|
|
77
|
+
'/api/company-c/1234',
|
|
78
|
+
'api/company-c/[id]?limit=[limit].GET.200.json',
|
|
79
|
+
'with pretty-param and without query-params'
|
|
80
|
+
], [
|
|
81
|
+
'/api/company-d/1234/?',
|
|
82
|
+
'api/company-d/[id]?limit=[limit].GET.200.json',
|
|
83
|
+
'with pretty-param and without query-params, but with trailing slash and "?"'
|
|
84
|
+
], [
|
|
85
|
+
'/api/company-e/1234/?limit=4',
|
|
86
|
+
'api/company-e/[id]?limit=[limit].GET.200.json',
|
|
87
|
+
'with pretty-param and query-params'
|
|
88
|
+
]
|
|
89
|
+
]
|
|
90
|
+
for (const [, file, body] of fixtures)
|
|
91
|
+
write(file, file.endsWith('.json') ? JSON.stringify(body) : body)
|
|
92
|
+
|
|
93
|
+
write('api/.GET.501.txt', 'keeps non-autogenerated 501')
|
|
94
|
+
write('api/alternative(comment-2).GET.200.json', JSON.stringify({ comment: 2 }))
|
|
95
|
+
write('api/my-route(comment-2).GET.200.json', JSON.stringify({ comment: 2 }))
|
|
96
|
+
|
|
97
|
+
// These files ensure the server doesn’t crash. We don’t test their console.error
|
|
98
|
+
write('api/bad-filename.200.json', 'missing method')
|
|
99
|
+
write('api/bad-filename.GET.200', 'missing extension')
|
|
100
|
+
write('api/bad-filename.GET.json', 'missing response status')
|
|
101
|
+
|
|
102
|
+
writeStatic('index.html', '<h1>Static</h1>')
|
|
103
|
+
writeStatic('assets/app.js', 'const app = 1')
|
|
104
|
+
writeStatic('another-entry/index.html', '<h1>Another</h1>')
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
const server = Mockaton({
|
|
108
|
+
mocksDir: tmpDir,
|
|
109
|
+
staticDir: staticTmpDir,
|
|
110
|
+
skipOpen: true,
|
|
111
|
+
cookies: {
|
|
112
|
+
userA: 'CookieA',
|
|
113
|
+
userB: 'CookieB'
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
server.on('listening', runTests)
|
|
117
|
+
|
|
118
|
+
async function runTests() {
|
|
119
|
+
await testItRendersDashboard()
|
|
120
|
+
|
|
121
|
+
for (const [url, file, body] of fixtures)
|
|
122
|
+
await testMockDispatching(url, file, body)
|
|
123
|
+
|
|
124
|
+
await testItUpdatesDelayAndFile(
|
|
125
|
+
'/api/alternative',
|
|
126
|
+
'api/alternative(comment-1).GET.200.json')
|
|
127
|
+
|
|
128
|
+
await testAutogenerates501(
|
|
129
|
+
'/api/company-e/123?limit=9',
|
|
130
|
+
'api/company-e/[id]?limit=[limit].GET.501.txt')
|
|
131
|
+
|
|
132
|
+
await testPreservesExiting501(
|
|
133
|
+
'/api',
|
|
134
|
+
'api/.GET.501.txt',
|
|
135
|
+
'keeps non-autogenerated 501')
|
|
136
|
+
|
|
137
|
+
await reset()
|
|
138
|
+
await testItUpdatesTheCurrentSelectedMock(
|
|
139
|
+
'/api/alternative',
|
|
140
|
+
'api/alternative(comment-2).GET.200.json',
|
|
141
|
+
200,
|
|
142
|
+
JSON.stringify({ comment: 2 }))
|
|
143
|
+
|
|
144
|
+
await reset()
|
|
145
|
+
await testExtractsAllComments([
|
|
146
|
+
'(comment-1)',
|
|
147
|
+
'(comment-2)',
|
|
148
|
+
'(this is the actual comment)',
|
|
149
|
+
'(another comment)'
|
|
150
|
+
])
|
|
151
|
+
await testItBulkSelectsByComment('(comment-2)',
|
|
152
|
+
[
|
|
153
|
+
['/api/alternative', 'api/alternative(comment-2).GET.200.json', { comment: 2 }],
|
|
154
|
+
['/api/my-route', 'api/my-route(comment-2).GET.200.json', { comment: 2 }]
|
|
155
|
+
]
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
await reset()
|
|
159
|
+
for (const [url, file, body] of fixtures)
|
|
160
|
+
await testMockDispatching(url, file, body)
|
|
161
|
+
|
|
162
|
+
await testItUpdatesUserRole()
|
|
163
|
+
await testTransforms()
|
|
164
|
+
|
|
165
|
+
await testStaticFileServing()
|
|
166
|
+
|
|
167
|
+
server.close()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function reset() {
|
|
171
|
+
await request(DP.reset, { method: 'PATCH' })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function testItRendersDashboard() {
|
|
175
|
+
const res = await request(DP.dashboard)
|
|
176
|
+
const body = await res.text()
|
|
177
|
+
await describe('Dashboard', () =>
|
|
178
|
+
it('Renders HTML', () => match(body, new RegExp('<!DOCTYPE html>'))))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function testMockDispatching(url, file, expectedBody, reqBody = void 0) {
|
|
182
|
+
const { urlMask, method, status } = Route.parseFilename(file)
|
|
183
|
+
const mime = mimeFor(file)
|
|
184
|
+
const now = new Date()
|
|
185
|
+
const res = await request(url, { method, body: reqBody })
|
|
186
|
+
const body = mime === 'application/json'
|
|
187
|
+
? await res.json()
|
|
188
|
+
: await res.text()
|
|
189
|
+
await describe('URL Mask: ' + urlMask, () => {
|
|
190
|
+
it('file: ' + file, () => deepEqual(body, expectedBody))
|
|
191
|
+
it('mime: ' + mime, () => equal(res.headers.get('content-type'), mime))
|
|
192
|
+
it('status: ' + status, () => equal(res.status, status))
|
|
193
|
+
it('cookie: ' + mime, () => equal(res.headers.get('set-cookie'), 'CookieA'))
|
|
194
|
+
it('delay is under 1 sec', () => equal((new Date()).getTime() - now.getTime() < 1000, true))
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function testItUpdatesTheCurrentSelectedMock(url, file, expectedStatus, expectedBody) {
|
|
199
|
+
await request(DP.edit, {
|
|
200
|
+
method: 'PATCH',
|
|
201
|
+
body: JSON.stringify({ [DF.file]: file })
|
|
202
|
+
})
|
|
203
|
+
const res = await request(url)
|
|
204
|
+
const body = await res.text()
|
|
205
|
+
await describe('url: ' + url, () => {
|
|
206
|
+
it('body is: ' + expectedBody, () => equal(body, expectedBody))
|
|
207
|
+
it('status is: ' + expectedStatus, () => equal(res.status, expectedStatus))
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function testItUpdatesDelayAndFile(url, file) {
|
|
212
|
+
await request(DP.edit, {
|
|
213
|
+
method: 'PATCH',
|
|
214
|
+
body: JSON.stringify({
|
|
215
|
+
[DF.file]: file,
|
|
216
|
+
[DF.delayed]: true
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
const now = new Date()
|
|
220
|
+
await request(url)
|
|
221
|
+
await describe('url: ' + url, () =>
|
|
222
|
+
it('delay is over 1 sec', () => equal((new Date()).getTime() - now.getTime() > 1000, true)))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async function testAutogenerates501(url, file) {
|
|
227
|
+
await request(DP.edit, {
|
|
228
|
+
method: 'PATCH',
|
|
229
|
+
body: JSON.stringify({ [DF.file]: file })
|
|
230
|
+
})
|
|
231
|
+
const res = await request(url)
|
|
232
|
+
const body = await res.text()
|
|
233
|
+
await describe('autogenerated 501', () => {
|
|
234
|
+
it('body is empty', () => equal(body, ''))
|
|
235
|
+
it('status is: 501', () => equal(res.status, 501))
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function testPreservesExiting501(url, file, expectedBody) {
|
|
240
|
+
await request(DP.edit, {
|
|
241
|
+
method: 'PATCH',
|
|
242
|
+
body: JSON.stringify({ [DF.file]: file })
|
|
243
|
+
})
|
|
244
|
+
const res = await request(url)
|
|
245
|
+
const body = await res.text()
|
|
246
|
+
await describe('preserves existing 501', () => {
|
|
247
|
+
it('body is empty', () => equal(body, expectedBody))
|
|
248
|
+
it('status is: 501', () => equal(res.status, 501))
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function testExtractsAllComments(expected) {
|
|
253
|
+
const res = await request(DP.comments)
|
|
254
|
+
const body = await res.json()
|
|
255
|
+
await it('Extracts all comments without duplicates', () =>
|
|
256
|
+
deepEqual(body, expected))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function testItBulkSelectsByComment(comment, tests) {
|
|
260
|
+
await request(DP.bulkSelect, {
|
|
261
|
+
method: 'PATCH',
|
|
262
|
+
body: JSON.stringify({
|
|
263
|
+
[DF.comment]: comment
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
for (const [url, file, body] of tests)
|
|
267
|
+
await testMockDispatching(url, file, body)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async function testItUpdatesUserRole() {
|
|
272
|
+
await describe('Cookie', () => {
|
|
273
|
+
it('Defaults to the first key:value', async () => {
|
|
274
|
+
const res = await request(DP.cookies)
|
|
275
|
+
deepEqual(await res.json(), [
|
|
276
|
+
['userA', true],
|
|
277
|
+
['userB', false]
|
|
278
|
+
])
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('Update the selected cookie', async () => {
|
|
282
|
+
await request(DP.cookies, {
|
|
283
|
+
method: 'PATCH',
|
|
284
|
+
body: JSON.stringify({ [DF.currentCookieKey]: 'userB' })
|
|
285
|
+
})
|
|
286
|
+
const res = await request(DP.cookies)
|
|
287
|
+
deepEqual(await res.json(), [
|
|
288
|
+
['userA', false],
|
|
289
|
+
['userB', true]
|
|
290
|
+
])
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function testTransforms() {
|
|
296
|
+
await describe('Applies transform', async () => {
|
|
297
|
+
write('api/transform.POST.200.json', JSON.stringify(['initial']))
|
|
298
|
+
write('api/transform.POST.200.mjs', `
|
|
299
|
+
export default function (mock, reqBody, config) {
|
|
300
|
+
const body = JSON.parse(mock);
|
|
301
|
+
body.push(reqBody[0]);
|
|
302
|
+
body.push(config.mocksDir);
|
|
303
|
+
return JSON.stringify(body);
|
|
304
|
+
}`)
|
|
305
|
+
await reset() // for registering the files
|
|
306
|
+
await request(DP.transform, {
|
|
307
|
+
method: 'PATCH',
|
|
308
|
+
body: JSON.stringify({
|
|
309
|
+
[DF.method]: 'POST',
|
|
310
|
+
[DF.urlMask]: '/api/transform',
|
|
311
|
+
[DF.file]: 'api/transform.POST.200.mjs'
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
await testMockDispatching('/api/transform',
|
|
315
|
+
'api/transform.POST.200.json',
|
|
316
|
+
['initial', 'another', tmpDir],
|
|
317
|
+
JSON.stringify(['another']))
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
async function testStaticFileServing() {
|
|
323
|
+
await describe('Static File Serving', () => {
|
|
324
|
+
it('Defaults to index.html', async () => {
|
|
325
|
+
const res = await request('/')
|
|
326
|
+
const body = await res.text()
|
|
327
|
+
equal(body, '<h1>Static</h1>')
|
|
328
|
+
equal(res.status, 200)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('Defaults to in subdirs index.html', async () => {
|
|
332
|
+
const res = await request('/another-entry')
|
|
333
|
+
const body = await res.text()
|
|
334
|
+
equal(body, '<h1>Another</h1>')
|
|
335
|
+
equal(res.status, 200)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('Serves exacts paths', async () => {
|
|
339
|
+
const res = await request('/assets/app.js')
|
|
340
|
+
const body = await res.text()
|
|
341
|
+
equal(body, 'const app = 1')
|
|
342
|
+
equal(res.status, 200)
|
|
343
|
+
})
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
// Utils
|
|
349
|
+
|
|
350
|
+
function write(filename, data) {
|
|
351
|
+
_write(tmpDir + filename, data)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function writeStatic(filename, data) {
|
|
355
|
+
_write(staticTmpDir + filename, data)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function _write(absPath, data) {
|
|
359
|
+
mkdirSync(dirname(absPath), { recursive: true })
|
|
360
|
+
writeFileSync(absPath, data, 'utf8')
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function request(path, options = {}) {
|
|
364
|
+
const { address, port } = server.address()
|
|
365
|
+
return fetch(`http://${address}:${port}${path}`, options)
|
|
366
|
+
}
|
|
367
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { Mockaton } from './index.js' // from 'mockaton'
|
|
5
|
+
|
|
6
|
+
Mockaton({
|
|
7
|
+
port: 2345,
|
|
8
|
+
mocksDir: resolve('sample-mocks'),
|
|
9
|
+
staticDir: resolve('sample-static'),
|
|
10
|
+
cookies: {
|
|
11
|
+
'Admin User': 'my-cookie=1;Path=/;SameSite=strict',
|
|
12
|
+
'Normal User': 'my-cookie=0;Path=/;SameSite=strict'
|
|
13
|
+
}
|
|
14
|
+
})
|