slicetest 0.6.0 → 0.6.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 +1 -1
- package/dist/app.js +37 -9
- package/dist/http.d.ts +2 -0
- package/dist/http.js +56 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -179,7 +179,7 @@ const admin = http.with({ headers: { authorization: `Bearer ${token}` } }); // s
|
|
|
179
179
|
http.cookies.get("session"); // cookies persist within a scenario
|
|
180
180
|
```
|
|
181
181
|
|
|
182
|
-
Requests may only go to the app under test; absolute URLs to other hosts are rejected. Defaults for every request can be set with `http: { headers }` in the plugin config. With `follow`, cookies set by each redirect are kept (a login answering `302` with `Set-Cookie`), `303` and `301`/`302` turn into a `GET` as in browsers, and a redirect to another host is returned instead of followed.
|
|
182
|
+
Requests may only go to the app under test; absolute URLs to other hosts are rejected. Defaults for every request can be set with `http: { headers }` in the plugin config. With `follow`, cookies set by each redirect are kept (a login answering `302` with `Set-Cookie`), `303` and `301`/`302` turn into a `GET` as in browsers, and a redirect to another host is returned instead of followed. Cookies follow their `Path` as in browsers.
|
|
183
183
|
|
|
184
184
|
#### Forms: `http.submit()`
|
|
185
185
|
|
package/dist/app.js
CHANGED
|
@@ -66,6 +66,19 @@ export class App {
|
|
|
66
66
|
* `service.<name>` for a service (its port is then `{{service.<name>.port}}`).
|
|
67
67
|
*/
|
|
68
68
|
static async start(opts, root, vars, key = "app", fixedPort) {
|
|
69
|
+
// A free port can be taken by someone else before the app binds it (another worker, another
|
|
70
|
+
// program). When that is why the app died, try again on a fresh port.
|
|
71
|
+
for (let attempt = 1;; attempt++) {
|
|
72
|
+
try {
|
|
73
|
+
return await App.#start(opts, root, vars, key, fixedPort);
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
if (fixedPort !== undefined || attempt >= PORT_ATTEMPTS || !(e instanceof PortInUse))
|
|
77
|
+
throw e;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
static async #start(opts, root, vars, key, fixedPort) {
|
|
69
82
|
// A restarted service keeps its port, so URLs already handed to other processes stay valid.
|
|
70
83
|
const port = fixedPort ?? (await freePort());
|
|
71
84
|
vars = { ...vars, [`${key}.port`]: String(port) };
|
|
@@ -87,6 +100,8 @@ export class App {
|
|
|
87
100
|
}
|
|
88
101
|
catch (e) {
|
|
89
102
|
await app.stop();
|
|
103
|
+
if (app.#exit && ADDRESS_IN_USE.test(app.logs()))
|
|
104
|
+
throw new PortInUse(e.message);
|
|
90
105
|
throw e;
|
|
91
106
|
}
|
|
92
107
|
return app;
|
|
@@ -266,14 +281,27 @@ function escapeRegExp(s) {
|
|
|
266
281
|
function mapValues(obj, fn) {
|
|
267
282
|
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, fn(v)]));
|
|
268
283
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
284
|
+
const PORT_ATTEMPTS = 3;
|
|
285
|
+
/** How Node, Python, Go, Ruby, the JVM and others report a port that is taken. */
|
|
286
|
+
const ADDRESS_IN_USE = /EADDRINUSE|address already in use|Address in use|BindException/i;
|
|
287
|
+
class PortInUse extends Error {
|
|
288
|
+
}
|
|
289
|
+
/** Ports this process has handed out; the OS may offer one again before the app has bound it. */
|
|
290
|
+
const handedOut = new Set();
|
|
291
|
+
async function freePort() {
|
|
292
|
+
for (;;) {
|
|
293
|
+
const port = await new Promise((resolve, reject) => {
|
|
294
|
+
const srv = createServer();
|
|
295
|
+
srv.unref();
|
|
296
|
+
srv.on("error", reject);
|
|
297
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
298
|
+
const { port } = srv.address();
|
|
299
|
+
srv.close(() => resolve(port));
|
|
300
|
+
});
|
|
277
301
|
});
|
|
278
|
-
|
|
302
|
+
if (!handedOut.has(port)) {
|
|
303
|
+
handedOut.add(port);
|
|
304
|
+
return port;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
279
307
|
}
|
package/dist/http.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ declare class Session {
|
|
|
22
22
|
/** Stub base URLs of intercepted hosts, which redirects may lead to (an OAuth provider's login page). */
|
|
23
23
|
external: Map<string, string>;
|
|
24
24
|
cookies: Map<string, string>;
|
|
25
|
+
/** The Path each cookie was set for; a cookie without one (added by hand) goes everywhere. */
|
|
26
|
+
cookiePaths: Map<string, string>;
|
|
25
27
|
history: HttpResponse[];
|
|
26
28
|
listeners: ((res: HttpResponse) => void)[];
|
|
27
29
|
}
|
package/dist/http.js
CHANGED
|
@@ -6,6 +6,8 @@ class Session {
|
|
|
6
6
|
/** Stub base URLs of intercepted hosts, which redirects may lead to (an OAuth provider's login page). */
|
|
7
7
|
external = new Map();
|
|
8
8
|
cookies = new Map();
|
|
9
|
+
/** The Path each cookie was set for; a cookie without one (added by hand) goes everywhere. */
|
|
10
|
+
cookiePaths = new Map();
|
|
9
11
|
history = [];
|
|
10
12
|
listeners = [];
|
|
11
13
|
}
|
|
@@ -136,8 +138,10 @@ export class HttpClient {
|
|
|
136
138
|
const stub = url.origin === new URL(this.baseUrl).origin ? undefined : lookupHost(this.#session.external, url.hostname);
|
|
137
139
|
// The app's cookies stay with the app; the stub is reached at its own address.
|
|
138
140
|
const target = stub ? new URL(url.pathname + url.search, stub) : url;
|
|
139
|
-
if (!stub &&
|
|
140
|
-
|
|
141
|
+
if (!stub && !headers.has("cookie")) {
|
|
142
|
+
const cookie = this.#cookieHeader(url.pathname);
|
|
143
|
+
if (cookie)
|
|
144
|
+
headers.set("cookie", cookie);
|
|
141
145
|
}
|
|
142
146
|
let payload;
|
|
143
147
|
if (body === undefined ||
|
|
@@ -165,7 +169,7 @@ export class HttpClient {
|
|
|
165
169
|
}
|
|
166
170
|
if (!stub)
|
|
167
171
|
for (const cookie of res.headers.getSetCookie())
|
|
168
|
-
this.#storeCookie(cookie);
|
|
172
|
+
this.#storeCookie(cookie, url.pathname);
|
|
169
173
|
const text = await res.text();
|
|
170
174
|
let json;
|
|
171
175
|
try {
|
|
@@ -207,6 +211,7 @@ export class HttpClient {
|
|
|
207
211
|
}
|
|
208
212
|
clearCookies() {
|
|
209
213
|
this.#session.cookies.clear();
|
|
214
|
+
this.#session.cookiePaths.clear();
|
|
210
215
|
}
|
|
211
216
|
/** Requests made during the current scenario, oldest first (last 20). */
|
|
212
217
|
get history() {
|
|
@@ -214,29 +219,65 @@ export class HttpClient {
|
|
|
214
219
|
}
|
|
215
220
|
/** Start a new scenario: forget cookies and history. */
|
|
216
221
|
reset() {
|
|
217
|
-
this
|
|
222
|
+
this.clearCookies();
|
|
218
223
|
this.#session.history = [];
|
|
219
224
|
}
|
|
220
|
-
|
|
225
|
+
/** Cookies whose Path covers `requestPath`, longest Path first as browsers send them (RFC 6265 5.4). */
|
|
226
|
+
#cookieHeader(requestPath) {
|
|
227
|
+
const { cookies, cookiePaths } = this.#session;
|
|
228
|
+
return [...cookies]
|
|
229
|
+
.map(([name, value]) => ({ name, value, path: cookiePaths.get(name) ?? "/" }))
|
|
230
|
+
.filter((c) => pathMatches(requestPath, c.path))
|
|
231
|
+
.sort((a, b) => b.path.length - a.path.length)
|
|
232
|
+
.map((c) => `${c.name}=${c.value}`)
|
|
233
|
+
.join("; ");
|
|
234
|
+
}
|
|
235
|
+
#storeCookie(header, requestPath) {
|
|
221
236
|
const [pair, ...attrs] = header.split(";");
|
|
222
237
|
const eq = pair.indexOf("=");
|
|
223
238
|
if (eq <= 0)
|
|
224
239
|
return;
|
|
225
240
|
const name = pair.slice(0, eq).trim();
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
241
|
+
let maxAge;
|
|
242
|
+
let expires;
|
|
243
|
+
let cookiePath;
|
|
244
|
+
for (const a of attrs) {
|
|
245
|
+
const i = a.indexOf("=");
|
|
246
|
+
const k = (i < 0 ? a : a.slice(0, i)).trim().toLowerCase();
|
|
247
|
+
const v = i < 0 ? "" : a.slice(i + 1).trim();
|
|
248
|
+
if (k === "max-age")
|
|
249
|
+
maxAge = Number(v);
|
|
250
|
+
else if (k === "expires")
|
|
251
|
+
expires = Date.parse(v);
|
|
252
|
+
else if (k === "path" && v.startsWith("/"))
|
|
253
|
+
cookiePath = v;
|
|
254
|
+
}
|
|
255
|
+
// Max-Age wins over Expires whichever comes first.
|
|
256
|
+
const expired = maxAge !== undefined && !Number.isNaN(maxAge) ? maxAge <= 0 : expires !== undefined && expires <= Date.now();
|
|
257
|
+
// Keyed by name alone: a second cookie of the same name under another Path replaces the first.
|
|
258
|
+
if (expired) {
|
|
235
259
|
this.#session.cookies.delete(name);
|
|
236
|
-
|
|
260
|
+
this.#session.cookiePaths.delete(name);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
237
263
|
this.#session.cookies.set(name, pair.slice(eq + 1).trim());
|
|
264
|
+
this.#session.cookiePaths.set(name, cookiePath ?? defaultCookiePath(requestPath));
|
|
265
|
+
}
|
|
238
266
|
}
|
|
239
267
|
}
|
|
268
|
+
/** RFC 6265 5.1.4: the request path's directory, e.g. "/auth" for "/auth/login". */
|
|
269
|
+
function defaultCookiePath(requestPath) {
|
|
270
|
+
const slash = requestPath.lastIndexOf("/");
|
|
271
|
+
return slash <= 0 ? "/" : requestPath.slice(0, slash);
|
|
272
|
+
}
|
|
273
|
+
/** RFC 6265 5.1.4: "/api" covers "/api" and "/api/x" but not "/apix". */
|
|
274
|
+
function pathMatches(requestPath, cookiePath) {
|
|
275
|
+
if (requestPath === cookiePath)
|
|
276
|
+
return true;
|
|
277
|
+
if (!requestPath.startsWith(cookiePath))
|
|
278
|
+
return false;
|
|
279
|
+
return cookiePath.endsWith("/") || requestPath[cookiePath.length] === "/";
|
|
280
|
+
}
|
|
240
281
|
function merge(a, b) {
|
|
241
282
|
return {
|
|
242
283
|
...a,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slicetest",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Framework-agnostic slice tests: drive a real app process over HTTP with a real Postgres, MySQL or SQLite and stubbed outbound APIs, checked against OpenAPI contracts.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"testing",
|