@itmetsam/samurai-framework 0.21.1 → 0.21.2

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
@@ -2,7 +2,9 @@
2
2
 
3
3
  A browser test framework written in TypeScript. It drives a real browser over the [WebDriver BiDi](https://www.w3.org/TR/webdriver-bidi/) protocol, with no Playwright or Puppeteer underneath. Firefox speaks BiDi itself; Chrome is driven through its own `chromedriver`, which the framework starts for you.
4
4
 
5
- [![CI](https://github.com/degrootsam/samurai-framework/actions/workflows/ci.yml/badge.svg)](https://github.com/degrootsam/samurai-framework/actions/workflows/ci.yml)
5
+ [![npm](https://img.shields.io/npm/v/@itmetsam/samurai-framework)](https://www.npmjs.com/package/@itmetsam/samurai-framework)
6
+
7
+ > This repository is a read-only mirror of `packages/framework` in the SAMURAI monorepo. Issues are welcome; pull requests aren't merged here. See [CONTRIBUTING.md](CONTRIBUTING.md).
6
8
 
7
9
  ```ts
8
10
  import { expect, test } from "../api.js";
@@ -56,6 +58,8 @@ cd samurai-framework
56
58
  bun install
57
59
  ```
58
60
 
61
+ The clone is the mirror described in [CONTRIBUTING.md](CONTRIBUTING.md). It has no lockfile of its own (the monorepo keeps one for all its packages), so `bun install` resolves the ranges in `package.json`.
62
+
59
63
  ### 3. Write a test
60
64
 
61
65
  Tests live in the folder `srcDir` points to (`./src/tests` in `samurai.config.ts`) and end in `.spec.ts`. This repository has one as `src/tests/example.spec.ts`:
@@ -138,7 +142,8 @@ Read it in a test as `secrets.TEST_PASSWORD`. Real environment variables win ove
138
142
  | [Running tests and reports](https://github.com/degrootsam/samurai-framework/wiki/Running-tests-and-reports) | What a run does, `report.json`, page logs, timeouts |
139
143
  | [Steps and the recorder](https://github.com/degrootsam/samurai-framework/wiki/Steps-and-the-recorder) | The step codec, the recording engine and the recorder toolbar |
140
144
  | [Embedding the framework](https://github.com/degrootsam/samurai-framework/wiki/Embedding-the-framework) | Using the package from another app (such as the Electron UI): build, entry points, `runTests`, events |
141
- | [Contributing](docs/contributing.md) | Project layout, running the framework's own tests, conventions |
145
+ | [Contributing](CONTRIBUTING.md) | Issues and pull requests on this mirror |
146
+ | [Developer guide](docs/contributing.md) | Project layout, running the framework's own tests, conventions |
142
147
  | [Roadmap](https://github.com/degrootsam/samurai-framework/wiki/Roadmap) | What is built and what is still open |
143
148
 
144
149
  The [documentation index](https://github.com/degrootsam/samurai-framework/wiki/Home) lists every guide by topic.
@@ -11,6 +11,12 @@ export interface TrackerOptions {
11
11
  * Follows the network activity of one page (its frames included): which requests are in flight,
12
12
  * and `request` / `response` / `requestfailed` events. In-flight is keyed by `request id:redirect count`,
13
13
  * so a redirect chain is one live request at a time.
14
+ *
15
+ * Chrome (chromium-bidi) reports a redirect response only once the browser has started the next hop, and it can
16
+ * leave out that hop's `beforeRequestSent`: when the hop's `requestWillBeSentExtraInfo` reaches it before the
17
+ * redirect's `requestWillBeSent`, it files the extra info under the previous hop and never announces the new one.
18
+ * So on Chrome a redirect keeps the chain in flight until the next hop shows itself, and a hop that first shows up
19
+ * with its response or error is announced then.
14
20
  */
15
21
  export declare class NetworkTracker {
16
22
  private tree;
@@ -21,13 +27,16 @@ export declare class NetworkTracker {
21
27
  private subscription;
22
28
  /**
23
29
  * Every hop that is in flight or may still be the source of a redirect, by hop key. A redirect response ends a
24
- * hop's flight (`live` false) but keeps it, for the next hop's `redirectedFrom`.
30
+ * hop's flight (`live` false; on Chrome `answered`, live until the next hop shows) but keeps it, for the next
31
+ * hop's `redirectedFrom`.
25
32
  */
26
33
  private hops;
27
34
  /** Rejects a pending `waitForIdle`; each wait adds its own */
28
35
  private waiters;
29
36
  private disposed;
30
37
  private constructor();
38
+ /** Whether a redirect response means the next hop has already started (Chrome), see the class comment */
39
+ private get redirectsStartNextHop();
31
40
  static start(connector: BiDiConnector, tree: ContextTree, contextId: string, options?: TrackerOptions): Promise<NetworkTracker>;
32
41
  /** Requests currently in flight */
33
42
  get inflight(): number;
@@ -52,6 +61,13 @@ export declare class NetworkTracker {
52
61
  private owns;
53
62
  private static key;
54
63
  private onBeforeRequestSent;
64
+ /** Tracks a new hop and emits its `request`; the hop it was redirected from is done with */
65
+ private startHop;
66
+ /**
67
+ * The hop a response or error is for. A hop Chrome never announced (see the class comment) is started here,
68
+ * when the hop it was redirected from is known.
69
+ */
70
+ private hopOf;
55
71
  private onResponseCompleted;
56
72
  private onFetchError;
57
73
  /**
@@ -12,6 +12,12 @@ const URLS_IN_MESSAGE = 5;
12
12
  * Follows the network activity of one page (its frames included): which requests are in flight,
13
13
  * and `request` / `response` / `requestfailed` events. In-flight is keyed by `request id:redirect count`,
14
14
  * so a redirect chain is one live request at a time.
15
+ *
16
+ * Chrome (chromium-bidi) reports a redirect response only once the browser has started the next hop, and it can
17
+ * leave out that hop's `beforeRequestSent`: when the hop's `requestWillBeSentExtraInfo` reaches it before the
18
+ * redirect's `requestWillBeSent`, it files the extra info under the previous hop and never announces the new one.
19
+ * So on Chrome a redirect keeps the chain in flight until the next hop shows itself, and a hop that first shows up
20
+ * with its response or error is announced then.
15
21
  */
16
22
  export class NetworkTracker {
17
23
  constructor(tree, contextId, ignore, connector) {
@@ -22,7 +28,8 @@ export class NetworkTracker {
22
28
  this.events = new EventEmitter();
23
29
  /**
24
30
  * Every hop that is in flight or may still be the source of a redirect, by hop key. A redirect response ends a
25
- * hop's flight (`live` false) but keeps it, for the next hop's `redirectedFrom`.
31
+ * hop's flight (`live` false; on Chrome `answered`, live until the next hop shows) but keeps it, for the next
32
+ * hop's `redirectedFrom`.
26
33
  */
27
34
  this.hops = new Map();
28
35
  /** Rejects a pending `waitForIdle`; each wait adds its own */
@@ -31,30 +38,22 @@ export class NetworkTracker {
31
38
  this.onBeforeRequestSent = (params) => {
32
39
  if (!this.owns(params.context))
33
40
  return;
34
- const key = NetworkTracker.key(params.request, params.redirectCount);
35
- const { redirectCount } = params;
36
- const previousKey = `${params.request.request}:${redirectCount - 1}`;
37
- const redirectedFrom = redirectCount > 0 ? (this.hops.get(previousKey)?.request ?? null) : null;
38
- this.hops.delete(previousKey);
39
- const request = toRequest(params.request, params.navigation, params.isBlocked, redirectedFrom);
40
- if (this.ignore(request))
41
- return;
42
- this.hops.set(key, { request, context: params.context, live: true });
43
- this.emit("request", request);
44
- this.events.emit("change");
41
+ this.startHop(params);
45
42
  };
46
43
  this.onResponseCompleted = (params) => {
47
44
  if (!this.owns(params.context))
48
45
  return;
49
46
  const key = NetworkTracker.key(params.request, params.redirectCount);
50
- const hop = this.hops.get(key);
51
- if (!hop?.live)
47
+ const hop = this.hopOf(params);
48
+ if (!hop?.live || hop.answered)
52
49
  return;
53
50
  const { request } = hop;
54
51
  const { response } = params;
55
52
  // A redirect response keeps its request around: the next hop links back to it
56
53
  if (response.status < 300 || response.status >= 400)
57
54
  this.hops.delete(key);
55
+ else if (this.redirectsStartNextHop && hasLocation(response))
56
+ hop.answered = true;
58
57
  else
59
58
  hop.live = false;
60
59
  this.emit("response", {
@@ -71,11 +70,13 @@ export class NetworkTracker {
71
70
  if (!this.owns(params.context))
72
71
  return;
73
72
  const key = NetworkTracker.key(params.request, params.redirectCount);
74
- const hop = this.hops.get(key);
73
+ const hop = this.hopOf(params);
75
74
  if (!hop?.live)
76
75
  return;
77
76
  this.hops.delete(key);
78
- this.emit("requestfailed", Object.assign(hop.request, { errorText: params.errorText }));
77
+ // A hop that has its response already did not fail
78
+ if (!hop.answered)
79
+ this.emit("requestfailed", Object.assign(hop.request, { errorText: params.errorText }));
79
80
  this.events.emit("change");
80
81
  };
81
82
  /**
@@ -86,19 +87,24 @@ export class NetworkTracker {
86
87
  if (!this.owns(params.context))
87
88
  return;
88
89
  let changed = false;
89
- for (const [key, { request, context, live }] of [...this.hops]) {
90
+ for (const [key, { request, context, live, answered }] of [...this.hops]) {
90
91
  if (!live || request.navigation === params.navigation)
91
92
  continue;
92
93
  if (!this.tree.isWithin(context, params.context))
93
94
  continue;
94
95
  this.hops.delete(key);
95
- this.emit("requestfailed", Object.assign(request, { errorText: ABORTED_BY_NAVIGATION }));
96
+ if (!answered)
97
+ this.emit("requestfailed", Object.assign(request, { errorText: ABORTED_BY_NAVIGATION }));
96
98
  changed = true;
97
99
  }
98
100
  if (changed)
99
101
  this.events.emit("change");
100
102
  };
101
103
  }
104
+ /** Whether a redirect response means the next hop has already started (Chrome), see the class comment */
105
+ get redirectsStartNextHop() {
106
+ return this.connector.browserName === "chrome";
107
+ }
102
108
  static async start(connector, tree, contextId, options = {}) {
103
109
  const tracker = new NetworkTracker(tree, contextId, options.ignore ?? (() => false), connector);
104
110
  // Listen first, then subscribe: nothing can arrive in between
@@ -189,6 +195,41 @@ export class NetworkTracker {
189
195
  static key(data, redirectCount) {
190
196
  return `${data.request}:${redirectCount}`;
191
197
  }
198
+ /** Tracks a new hop and emits its `request`; the hop it was redirected from is done with */
199
+ startHop(params) {
200
+ const key = NetworkTracker.key(params.request, params.redirectCount);
201
+ const { redirectCount } = params;
202
+ const previousKey = `${params.request.request}:${redirectCount - 1}`;
203
+ const previous = redirectCount > 0 ? this.hops.get(previousKey) : undefined;
204
+ this.hops.delete(previousKey);
205
+ const request = toRequest(params.request, params.navigation, params.isBlocked, previous?.request ?? null);
206
+ if (this.ignore(request)) {
207
+ if (previous?.live)
208
+ this.events.emit("change");
209
+ return undefined;
210
+ }
211
+ const hop = {
212
+ request,
213
+ context: params.context,
214
+ live: true,
215
+ answered: false,
216
+ };
217
+ this.hops.set(key, hop);
218
+ this.emit("request", request);
219
+ this.events.emit("change");
220
+ return hop;
221
+ }
222
+ /**
223
+ * The hop a response or error is for. A hop Chrome never announced (see the class comment) is started here,
224
+ * when the hop it was redirected from is known.
225
+ */
226
+ hopOf(params) {
227
+ const hop = this.hops.get(NetworkTracker.key(params.request, params.redirectCount));
228
+ if (hop || params.redirectCount === 0)
229
+ return hop;
230
+ const previousKey = `${params.request.request}:${params.redirectCount - 1}`;
231
+ return this.hops.has(previousKey) ? this.startHop(params) : undefined;
232
+ }
192
233
  /** A listener that throws must not stop the tracker or the other listeners */
193
234
  emit(event, ...args) {
194
235
  for (const listener of this.events.listeners(event)) {
@@ -201,3 +242,7 @@ export class NetworkTracker {
201
242
  }
202
243
  }
203
244
  }
245
+ /** Whether a 3xx response says where to go next: only then does the browser follow it */
246
+ function hasLocation(response) {
247
+ return response.headers.some((header) => header.name.toLowerCase() === "location");
248
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itmetsam/samurai-framework",
3
- "version": "0.21.1",
3
+ "version": "0.21.2",
4
4
  "description": "Browser test framework over WebDriver BiDi: auto-waiting locators, environments and secrets, a recorder, and a command line.",
5
5
  "main": "dist/api.js",
6
6
  "exports": {