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 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
- function freePort() {
270
- return new Promise((resolve, reject) => {
271
- const srv = createServer();
272
- srv.unref();
273
- srv.on("error", reject);
274
- srv.listen(0, "127.0.0.1", () => {
275
- const { port } = srv.address();
276
- srv.close(() => resolve(port));
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 && this.#session.cookies.size > 0 && !headers.has("cookie")) {
140
- headers.set("cookie", [...this.#session.cookies].map(([k, v]) => `${k}=${v}`).join("; "));
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.#session.cookies.clear();
222
+ this.clearCookies();
218
223
  this.#session.history = [];
219
224
  }
220
- #storeCookie(header) {
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
- const expired = attrs.some((a) => {
227
- const [k, v = ""] = a.split("=").map((s) => s.trim());
228
- if (k.toLowerCase() === "max-age")
229
- return Number(v) <= 0;
230
- if (k.toLowerCase() === "expires")
231
- return Date.parse(v) <= Date.now();
232
- return false;
233
- });
234
- if (expired)
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
- else
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.0",
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",