@itmetsam/samurai-framework 0.1.0 → 0.2.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 +129 -0
- package/bin/samurai.mjs +31 -0
- package/dist/api.js +3 -0
- package/dist/assert/assertion-error.d.ts +23 -0
- package/dist/assert/assertion-error.js +45 -0
- package/dist/assert/expect.d.ts +55 -0
- package/dist/assert/expect.js +188 -0
- package/dist/browser/browser-context.d.ts +91 -0
- package/dist/browser/browser-context.js +146 -0
- package/dist/browser/browser-finder.d.ts +2 -0
- package/dist/browser/browser-finder.js +95 -0
- package/dist/browser/browser.d.ts +95 -0
- package/dist/browser/browser.js +372 -0
- package/dist/browser/context-tree.d.ts +23 -0
- package/dist/browser/context-tree.js +60 -0
- package/dist/browser/dialog.d.ts +37 -0
- package/dist/browser/dialog.js +61 -0
- package/dist/browser/download.d.ts +71 -0
- package/dist/browser/download.js +137 -0
- package/dist/browser/downloads-dir.d.ts +8 -0
- package/dist/browser/downloads-dir.js +38 -0
- package/dist/browser/emulation.d.ts +61 -0
- package/dist/browser/emulation.js +144 -0
- package/dist/browser/file-chooser.d.ts +39 -0
- package/dist/browser/file-chooser.js +86 -0
- package/dist/browser/file-paths.d.ts +4 -0
- package/dist/browser/file-paths.js +18 -0
- package/dist/browser/index.d.ts +4 -0
- package/dist/browser/index.js +4 -0
- package/dist/browser/navigation-error.d.ts +13 -0
- package/dist/browser/navigation-error.js +19 -0
- package/dist/browser/page-logs.d.ts +86 -0
- package/dist/browser/page-logs.js +149 -0
- package/dist/browser/page-wait.d.ts +9 -0
- package/dist/browser/page-wait.js +17 -0
- package/dist/browser/page.d.ts +370 -0
- package/dist/browser/page.js +1038 -0
- package/dist/browser/pdf.d.ts +72 -0
- package/dist/browser/pdf.js +37 -0
- package/dist/browser/screenshot.d.ts +29 -0
- package/dist/browser/screenshot.js +42 -0
- package/dist/cli/args.d.ts +35 -0
- package/dist/cli/args.js +69 -0
- package/dist/cli/cli.cli-test.d.ts +1 -0
- package/dist/cli/cli.cli-test.js +170 -0
- package/dist/cli/console-reporter.d.ts +15 -0
- package/dist/cli/console-reporter.js +52 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +61 -0
- package/dist/cli/main.d.ts +12 -0
- package/dist/cli/main.js +156 -0
- package/dist/config/config.d.ts +24 -0
- package/dist/config/config.js +42 -0
- package/dist/config/default.d.ts +2 -0
- package/dist/config/default.js +3 -0
- package/dist/config/mask.d.ts +11 -0
- package/dist/config/mask.js +85 -0
- package/dist/config/run-settings.d.ts +28 -0
- package/dist/config/run-settings.js +61 -0
- package/dist/config/secrets.d.ts +10 -0
- package/dist/config/secrets.js +67 -0
- package/dist/config/variables.d.ts +11 -0
- package/dist/config/variables.js +26 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +15 -0
- package/dist/locator/action-timeout-error.d.ts +25 -0
- package/dist/locator/action-timeout-error.js +35 -0
- package/dist/locator/element-state.d.ts +44 -0
- package/dist/locator/element-state.js +109 -0
- package/dist/locator/label-locator.d.ts +13 -0
- package/dist/locator/label-locator.js +49 -0
- package/dist/locator/locator.d.ts +168 -0
- package/dist/locator/locator.js +591 -0
- package/dist/locator/selector-errors.d.ts +9 -0
- package/dist/locator/selector-errors.js +15 -0
- package/dist/locator/selector.d.ts +60 -0
- package/dist/locator/selector.js +113 -0
- package/dist/locator/text-locator.d.ts +13 -0
- package/dist/logger/index.d.ts +2 -0
- package/dist/logger/index.js +30 -0
- package/dist/network/data-collector.d.ts +26 -0
- package/dist/network/data-collector.js +77 -0
- package/dist/network/network-tracker.d.ts +94 -0
- package/dist/network/network-tracker.js +198 -0
- package/dist/network/network-wait.d.ts +16 -0
- package/dist/network/network-wait.js +24 -0
- package/dist/network/response.d.ts +22 -0
- package/dist/network/response.js +42 -0
- package/dist/network/router.d.ts +120 -0
- package/dist/network/router.js +343 -0
- package/dist/network/url-match.d.ts +8 -0
- package/dist/network/url-match.js +38 -0
- package/dist/recorder/apply.d.ts +3 -0
- package/dist/recorder/apply.js +10 -0
- package/dist/recorder/capture.d.ts +18 -0
- package/dist/recorder/capture.js +198 -0
- package/dist/recorder/index.d.ts +3 -0
- package/dist/recorder/index.js +3 -0
- package/dist/recorder/normalise.d.ts +68 -0
- package/dist/recorder/normalise.js +104 -0
- package/dist/recorder/ranking.d.ts +9 -0
- package/dist/recorder/ranking.js +21 -0
- package/dist/recorder/recorder.d.ts +52 -0
- package/dist/recorder/recorder.js +143 -0
- package/dist/recorder/session.d.ts +49 -0
- package/dist/recorder/session.js +113 -0
- package/dist/runner/module-type.d.ts +6 -0
- package/dist/runner/module-type.js +29 -0
- package/dist/runner/page-logs-report.d.ts +40 -0
- package/dist/runner/page-logs-report.js +44 -0
- package/dist/runner/prepare-run.d.ts +14 -0
- package/dist/runner/prepare-run.js +20 -0
- package/dist/runner/project.d.ts +17 -0
- package/dist/runner/project.js +33 -0
- package/dist/runner/registry.d.ts +7 -0
- package/dist/runner/registry.js +40 -0
- package/dist/runner/reporter.d.ts +43 -0
- package/dist/runner/reporter.js +83 -0
- package/dist/runner/run.d.ts +27 -0
- package/dist/runner/run.js +34 -0
- package/dist/runner/test-error.d.ts +3 -0
- package/dist/runner/test-error.js +18 -0
- package/dist/runner/test-runner.d.ts +44 -0
- package/dist/runner/test-runner.js +205 -0
- package/dist/script/call-function.d.ts +34 -0
- package/dist/script/call-function.js +71 -0
- package/dist/script/element-handle.d.ts +10 -0
- package/{src/script/element-handle.ts → dist/script/element-handle.js} +4 -4
- package/dist/script/helpers.d.ts +28 -0
- package/dist/script/helpers.js +50 -0
- package/dist/script/preload.d.ts +31 -0
- package/dist/script/preload.js +71 -0
- package/dist/script/serialize.d.ts +29 -0
- package/dist/script/serialize.js +207 -0
- package/dist/steps/build.d.ts +5 -0
- package/dist/steps/build.js +38 -0
- package/dist/steps/edit.d.ts +30 -0
- package/dist/steps/edit.js +80 -0
- package/dist/steps/emit.d.ts +6 -0
- package/dist/steps/emit.js +73 -0
- package/dist/steps/index.d.ts +5 -0
- package/dist/steps/index.js +4 -0
- package/dist/steps/model.d.ts +103 -0
- package/dist/steps/model.js +1 -0
- package/dist/steps/parse.d.ts +3 -0
- package/dist/steps/parse.js +272 -0
- package/dist/transport/bidi-connection.d.ts +46 -0
- package/dist/transport/bidi-connection.js +176 -0
- package/dist/transport/bidi-error.d.ts +10 -0
- package/dist/transport/bidi-error.js +10 -0
- package/dist/transport/subscriptions.d.ts +36 -0
- package/dist/transport/subscriptions.js +81 -0
- package/{src → dist}/types/test.d.ts +10 -4
- package/dist/wait/wait-until.d.ts +20 -0
- package/dist/wait/wait-until.js +63 -0
- package/package.json +44 -7
- package/.claude/settings.local.json +0 -7
- package/.env.example +0 -3
- package/.github/actions/setup/action.yml +0 -16
- package/.github/workflows/ci.yml +0 -71
- package/.trunk/configs/.markdownlint.yaml +0 -2
- package/.trunk/configs/.yamllint.yaml +0 -7
- package/.trunk/configs/svgo.config.mjs +0 -14
- package/.trunk/trunk.yaml +0 -34
- package/docs/superpowers/bidi-roadmap.md +0 -60
- package/docs/superpowers/plans/2026-09-29-auto-wait.md +0 -1827
- package/docs/superpowers/plans/2026-09-29-expect.md +0 -1350
- package/docs/superpowers/plans/2026-10-01-ci.md +0 -350
- package/docs/superpowers/plans/2026-10-01-test-api-alignment.md +0 -1821
- package/docs/superpowers/recorder-roadmap.md +0 -52
- package/docs/superpowers/specs/2026-09-29-auto-wait-design.md +0 -185
- package/docs/superpowers/specs/2026-09-29-bidi-foundation-design.md +0 -130
- package/docs/superpowers/specs/2026-09-29-contexts-and-storage-design.md +0 -88
- package/docs/superpowers/specs/2026-09-29-downloads-and-file-dialogs-design.md +0 -94
- package/docs/superpowers/specs/2026-09-29-emulation-design.md +0 -88
- package/docs/superpowers/specs/2026-09-29-expect-design.md +0 -130
- package/docs/superpowers/specs/2026-09-29-locate-nodes-design.md +0 -90
- package/docs/superpowers/specs/2026-09-29-logs-and-errors-design.md +0 -87
- package/docs/superpowers/specs/2026-09-29-navigation-waits-design.md +0 -83
- package/docs/superpowers/specs/2026-09-29-network-data-design.md +0 -78
- package/docs/superpowers/specs/2026-09-29-network-interception-design.md +0 -91
- package/docs/superpowers/specs/2026-09-29-network-tracking-design.md +0 -86
- package/docs/superpowers/specs/2026-09-29-page-api-design.md +0 -90
- package/docs/superpowers/specs/2026-09-29-preload-scripts-design.md +0 -82
- package/docs/superpowers/specs/2026-09-29-script-call-function-design.md +0 -107
- package/docs/superpowers/specs/2026-09-29-set-files-design.md +0 -62
- package/docs/superpowers/specs/2026-09-29-user-prompts-design.md +0 -75
- package/docs/superpowers/specs/2026-10-01-ci-design.md +0 -106
- package/docs/superpowers/specs/2026-10-01-test-api-alignment-design.md +0 -241
- package/public/logo-dark.svg +0 -236
- package/public/logo-light.svg +0 -262
- package/samurai.config.ts +0 -7
- package/src/assert/assertion-error.test.ts +0 -55
- package/src/assert/assertion-error.ts +0 -62
- package/src/assert/expect-locator.test.ts +0 -217
- package/src/assert/expect.test.ts +0 -73
- package/src/assert/expect.ts +0 -299
- package/src/browser/browser-context.browser-test.ts +0 -201
- package/src/browser/browser-context.test.ts +0 -407
- package/src/browser/browser-context.ts +0 -208
- package/src/browser/browser-finder.ts +0 -110
- package/src/browser/browser.test.ts +0 -88
- package/src/browser/browser.ts +0 -515
- package/src/browser/context-tree.test.ts +0 -86
- package/src/browser/context-tree.ts +0 -70
- package/src/browser/dialog.browser-test.ts +0 -144
- package/src/browser/dialog.test.ts +0 -322
- package/src/browser/dialog.ts +0 -83
- package/src/browser/download.test.ts +0 -161
- package/src/browser/download.ts +0 -172
- package/src/browser/downloads-dir.test.ts +0 -75
- package/src/browser/downloads-dir.ts +0 -39
- package/src/browser/emulation.browser-test.ts +0 -186
- package/src/browser/emulation.test.ts +0 -200
- package/src/browser/emulation.ts +0 -204
- package/src/browser/file-chooser.test.ts +0 -142
- package/src/browser/file-chooser.ts +0 -99
- package/src/browser/file-paths.ts +0 -18
- package/src/browser/goto.browser-test.ts +0 -44
- package/src/browser/navigation-error.ts +0 -26
- package/src/browser/navigation.browser-test.ts +0 -172
- package/src/browser/page-api.browser-test.ts +0 -228
- package/src/browser/page-logs.browser-test.ts +0 -119
- package/src/browser/page-logs.test.ts +0 -212
- package/src/browser/page-logs.ts +0 -208
- package/src/browser/page-wait.ts +0 -19
- package/src/browser/page.test.ts +0 -1567
- package/src/browser/page.ts +0 -1176
- package/src/browser/pdf.ts +0 -62
- package/src/browser/screenshot.ts +0 -65
- package/src/browser/transfers.browser-test.ts +0 -180
- package/src/config/config.ts +0 -33
- package/src/config/default.ts +0 -5
- package/src/config/fixtures.test.ts +0 -137
- package/src/config/mask.test.ts +0 -143
- package/src/config/mask.ts +0 -87
- package/src/config/run-settings.test.ts +0 -163
- package/src/config/run-settings.ts +0 -114
- package/src/config/secrets.ts +0 -70
- package/src/config/variables.ts +0 -31
- package/src/index.ts +0 -10
- package/src/locator/action-timeout-error.test.ts +0 -83
- package/src/locator/action-timeout-error.ts +0 -72
- package/src/locator/element-state.test.ts +0 -181
- package/src/locator/element-state.ts +0 -159
- package/src/locator/locate-nodes.browser-test.ts +0 -169
- package/src/locator/locator-actions.test.ts +0 -355
- package/src/locator/locator-chain.test.ts +0 -292
- package/src/locator/locator.browser-test.ts +0 -137
- package/src/locator/locator.test.ts +0 -179
- package/src/locator/locator.ts +0 -656
- package/src/locator/selector-errors.ts +0 -18
- package/src/locator/selector.test.ts +0 -124
- package/src/locator/selector.ts +0 -107
- package/src/locator/set-files.browser-test.ts +0 -141
- package/src/locator/set-files.test.ts +0 -191
- package/src/locator/text-locator.test.ts +0 -118
- package/src/logger/index.test.ts +0 -28
- package/src/logger/index.ts +0 -40
- package/src/network/data-collector.test.ts +0 -111
- package/src/network/data-collector.ts +0 -92
- package/src/network/network-data.browser-test.ts +0 -192
- package/src/network/network-tracker.browser-test.ts +0 -151
- package/src/network/network-tracker.test.ts +0 -307
- package/src/network/network-tracker.ts +0 -286
- package/src/network/network-wait.ts +0 -28
- package/src/network/response.test.ts +0 -93
- package/src/network/response.ts +0 -58
- package/src/network/router.browser-test.ts +0 -260
- package/src/network/router.test.ts +0 -528
- package/src/network/router.ts +0 -420
- package/src/network/url-match.test.ts +0 -69
- package/src/network/url-match.ts +0 -41
- package/src/runner/page-logs-report.test.ts +0 -144
- package/src/runner/page-logs-report.ts +0 -82
- package/src/runner/prepare-run.test.ts +0 -33
- package/src/runner/prepare-run.ts +0 -36
- package/src/runner/registry.test.ts +0 -59
- package/src/runner/registry.ts +0 -46
- package/src/runner/reporter.test.ts +0 -73
- package/src/runner/reporter.ts +0 -109
- package/src/runner/test-error.test.ts +0 -39
- package/src/runner/test-error.ts +0 -20
- package/src/runner/test-runner.ts +0 -184
- package/src/script/call-function.browser-test.ts +0 -112
- package/src/script/call-function.test.ts +0 -174
- package/src/script/call-function.ts +0 -113
- package/src/script/helpers.test.ts +0 -116
- package/src/script/helpers.ts +0 -68
- package/src/script/preload.browser-test.ts +0 -144
- package/src/script/preload.test.ts +0 -180
- package/src/script/preload.ts +0 -102
- package/src/script/serialize.test.ts +0 -182
- package/src/script/serialize.ts +0 -203
- package/src/testing/browser-fixture.ts +0 -18
- package/src/testing/fake-websocket.ts +0 -72
- package/src/testing/stub-connector.ts +0 -154
- package/src/tests/index.spec.ts +0 -30
- package/src/transport/bidi-connection.test.ts +0 -155
- package/src/transport/bidi-connection.ts +0 -256
- package/src/transport/bidi-error.ts +0 -23
- package/src/transport/foundation.browser-test.ts +0 -73
- package/src/transport/subscriptions.test.ts +0 -140
- package/src/transport/subscriptions.ts +0 -110
- package/src/types/bidi-commands.test-d.ts +0 -55
- package/src/wait/wait-until.test.ts +0 -117
- package/src/wait/wait-until.ts +0 -86
- package/tsconfig.json +0 -44
- /package/{src/api.ts → dist/api.d.ts} +0 -0
- /package/{src/locator/text-locator.ts → dist/locator/text-locator.js} +0 -0
- /package/{src → dist}/types/bidi-modules/browser.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/browsing-context.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/emulation.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/input.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/log.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/network.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/permissions.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/script.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/session.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/storage.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/web-extensions.d.ts +0 -0
- /package/{src → dist}/types/bidi-protocols/command.d.ts +0 -0
- /package/{src → dist}/types/bidi-protocols/error.d.ts +0 -0
- /package/{src → dist}/types/bidi-protocols/event.d.ts +0 -0
- /package/{src → dist}/types/bidi.d.ts +0 -0
- /package/{src → dist}/types/browser.d.ts +0 -0
- /package/{src → dist}/types/config.d.ts +0 -0
- /package/{src → dist}/types/env.d.ts +0 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { ContextTree } from "../browser/context-tree.js";
|
|
2
|
+
import type { BiDiConnector } from "../transport/bidi-connection.js";
|
|
3
|
+
import { type NetworkRequest } from "./network-tracker.js";
|
|
4
|
+
import { type UrlPattern } from "./url-match.js";
|
|
5
|
+
export type RouteHandler = (route: Route) => void | Promise<void>;
|
|
6
|
+
export interface FulfillOptions {
|
|
7
|
+
/** @default 200 */
|
|
8
|
+
status?: number;
|
|
9
|
+
headers?: Record<string, string>;
|
|
10
|
+
/** Sets `content-type` unless `headers` has one; `json` implies "application/json" */
|
|
11
|
+
contentType?: string;
|
|
12
|
+
body?: string | Buffer;
|
|
13
|
+
/** Sent as JSON text; cannot be combined with `body` */
|
|
14
|
+
json?: unknown;
|
|
15
|
+
}
|
|
16
|
+
export interface ContinueOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Sends the request to another URL. Firefox applies this to navigations only: the page's `fetch`, XHR and
|
|
19
|
+
* image requests fail when their URL is changed, so mock those with `fulfill` instead
|
|
20
|
+
*/
|
|
21
|
+
url?: string;
|
|
22
|
+
method?: string;
|
|
23
|
+
/** Replaces all request headers */
|
|
24
|
+
headers?: Record<string, string>;
|
|
25
|
+
postData?: string | Buffer;
|
|
26
|
+
}
|
|
27
|
+
interface RouteEntry {
|
|
28
|
+
pattern: UrlPattern;
|
|
29
|
+
handler: RouteHandler;
|
|
30
|
+
}
|
|
31
|
+
/** Answers `network.*` commands for one blocked request */
|
|
32
|
+
interface RouteDeps {
|
|
33
|
+
send: BiDiConnector["send"];
|
|
34
|
+
/** Passes the request to the next matching route, or lets it continue */
|
|
35
|
+
next: () => Promise<void>;
|
|
36
|
+
/** Called once the route is answered, whichever way */
|
|
37
|
+
settled: (route: Route) => void;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A request the browser holds until it is answered. Answer it exactly once with `fulfill`, `continue`,
|
|
41
|
+
* `abort` or `fallback`; a second call throws.
|
|
42
|
+
*/
|
|
43
|
+
export declare class Route {
|
|
44
|
+
private readonly info;
|
|
45
|
+
/** @internal */
|
|
46
|
+
readonly entry: RouteEntry;
|
|
47
|
+
private readonly deps;
|
|
48
|
+
private answered;
|
|
49
|
+
/** @internal */
|
|
50
|
+
timer: ReturnType<typeof setTimeout> | undefined;
|
|
51
|
+
constructor(info: NetworkRequest,
|
|
52
|
+
/** @internal */
|
|
53
|
+
entry: RouteEntry, deps: RouteDeps);
|
|
54
|
+
request(): NetworkRequest;
|
|
55
|
+
/** @internal */
|
|
56
|
+
get isAnswered(): boolean;
|
|
57
|
+
/** Answers the request without sending it: status 200 and no body unless told otherwise */
|
|
58
|
+
fulfill(options?: FulfillOptions): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* Lets the request go on, optionally changed. `headers` replaces all request headers. A new `postData` brings its
|
|
61
|
+
* own `content-length` (the request's old one would cut the new body off or leave the server waiting).
|
|
62
|
+
*/
|
|
63
|
+
continue(options?: ContinueOptions): Promise<void>;
|
|
64
|
+
/** Makes the request fail (the browser reports a network error to the page) */
|
|
65
|
+
abort(): Promise<void>;
|
|
66
|
+
/** Hands the request to the next route that matches it, or lets it continue when there is none */
|
|
67
|
+
fallback(): Promise<void>;
|
|
68
|
+
/** @internal Lets the request continue unchanged; failures are logged, not thrown */
|
|
69
|
+
release(): Promise<void>;
|
|
70
|
+
/** @internal Fails the request; failures are logged, not thrown */
|
|
71
|
+
failQuietly(): Promise<void>;
|
|
72
|
+
private claim;
|
|
73
|
+
private answer;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Lets a page mock, block and change requests. One `network.addIntercept` serves all routes; matching
|
|
77
|
+
* happens here. Every request the browser holds is answered exactly once, so nothing hangs.
|
|
78
|
+
*/
|
|
79
|
+
export declare class Router {
|
|
80
|
+
private connector;
|
|
81
|
+
private tree;
|
|
82
|
+
private contextId;
|
|
83
|
+
private routes;
|
|
84
|
+
private pending;
|
|
85
|
+
private failures;
|
|
86
|
+
private queue;
|
|
87
|
+
private interceptIds;
|
|
88
|
+
private currentKey;
|
|
89
|
+
private currentId;
|
|
90
|
+
private subscription;
|
|
91
|
+
private disposed;
|
|
92
|
+
private routeTimeout;
|
|
93
|
+
constructor(connector: BiDiConnector, tree: ContextTree, contextId: string, options?: {
|
|
94
|
+
routeTimeout?: number;
|
|
95
|
+
});
|
|
96
|
+
/** Errors thrown by route handlers, oldest first */
|
|
97
|
+
get errors(): readonly Error[];
|
|
98
|
+
/** Sends matching requests to `handler`. Later routes are asked first */
|
|
99
|
+
route(pattern: UrlPattern, handler: RouteHandler): Promise<void>;
|
|
100
|
+
/** Removes the routes for `pattern` (only the one with `handler`, when given) */
|
|
101
|
+
unroute(pattern: UrlPattern, handler?: RouteHandler): Promise<void>;
|
|
102
|
+
unrouteAll(): Promise<void>;
|
|
103
|
+
/** Lets held requests continue, removes the intercept and stops listening. Idempotent */
|
|
104
|
+
dispose(): Promise<void>;
|
|
105
|
+
private remove;
|
|
106
|
+
private enqueue;
|
|
107
|
+
/** Makes the browser-side intercept match the routes: none, one for everything, or one for exact URLs */
|
|
108
|
+
private sync;
|
|
109
|
+
/** The exact URLs to block, or undefined to block everything and match here */
|
|
110
|
+
private urlPatterns;
|
|
111
|
+
private addIntercept;
|
|
112
|
+
private removeIntercept;
|
|
113
|
+
private listen;
|
|
114
|
+
private stop;
|
|
115
|
+
private onBeforeRequestSent;
|
|
116
|
+
/** Runs the `index`-th matching route; past the last one the request continues unchanged */
|
|
117
|
+
private dispatch;
|
|
118
|
+
private record;
|
|
119
|
+
}
|
|
120
|
+
export {};
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { STATUS_CODES } from "node:http";
|
|
2
|
+
import logger from "../logger/index.js";
|
|
3
|
+
import { BiDiError } from "../transport/bidi-error.js";
|
|
4
|
+
import { toRequest } from "./network-tracker.js";
|
|
5
|
+
import { isExactString, matchUrl, samePattern } from "./url-match.js";
|
|
6
|
+
/** Time (ms) a handler may take to answer a request before the router lets it continue */
|
|
7
|
+
const DEFAULT_ROUTE_TIMEOUT = 30000;
|
|
8
|
+
/** How many handler errors the router keeps */
|
|
9
|
+
const MAX_ERRORS = 100;
|
|
10
|
+
function toHeaders(headers) {
|
|
11
|
+
return Object.entries(headers).map(([name, value]) => ({ name, value: { type: "string", value } }));
|
|
12
|
+
}
|
|
13
|
+
function toBytes(data) {
|
|
14
|
+
return typeof data === "string"
|
|
15
|
+
? { type: "string", value: data }
|
|
16
|
+
: { type: "base64", value: data.toString("base64") };
|
|
17
|
+
}
|
|
18
|
+
const hasHeader = (headers, name) => Object.keys(headers).some((key) => key.toLowerCase() === name);
|
|
19
|
+
/**
|
|
20
|
+
* A request the browser holds until it is answered. Answer it exactly once with `fulfill`, `continue`,
|
|
21
|
+
* `abort` or `fallback`; a second call throws.
|
|
22
|
+
*/
|
|
23
|
+
export class Route {
|
|
24
|
+
constructor(info,
|
|
25
|
+
/** @internal */
|
|
26
|
+
entry, deps) {
|
|
27
|
+
this.info = info;
|
|
28
|
+
this.entry = entry;
|
|
29
|
+
this.deps = deps;
|
|
30
|
+
this.answered = false;
|
|
31
|
+
}
|
|
32
|
+
request() {
|
|
33
|
+
return this.info;
|
|
34
|
+
}
|
|
35
|
+
/** @internal */
|
|
36
|
+
get isAnswered() {
|
|
37
|
+
return this.answered;
|
|
38
|
+
}
|
|
39
|
+
/** Answers the request without sending it: status 200 and no body unless told otherwise */
|
|
40
|
+
async fulfill(options = {}) {
|
|
41
|
+
if (options.json !== undefined && options.body !== undefined) {
|
|
42
|
+
throw new TypeError("route.fulfill(): use either json or body, not both");
|
|
43
|
+
}
|
|
44
|
+
const text = options.json !== undefined ? JSON.stringify(options.json) : undefined;
|
|
45
|
+
const body = options.body ?? text;
|
|
46
|
+
const headers = { ...options.headers };
|
|
47
|
+
const contentType = options.contentType ?? (text !== undefined ? "application/json" : undefined);
|
|
48
|
+
if (contentType && !hasHeader(headers, "content-type"))
|
|
49
|
+
headers["content-type"] = contentType;
|
|
50
|
+
if (!hasHeader(headers, "content-length")) {
|
|
51
|
+
headers["content-length"] = String(body === undefined ? 0 : Buffer.byteLength(body));
|
|
52
|
+
}
|
|
53
|
+
const status = options.status ?? 200;
|
|
54
|
+
await this.answer("network.provideResponse", {
|
|
55
|
+
request: this.info.id,
|
|
56
|
+
statusCode: status,
|
|
57
|
+
reasonPhrase: STATUS_CODES[status] ?? "",
|
|
58
|
+
headers: toHeaders(headers),
|
|
59
|
+
...(body !== undefined && { body: toBytes(body) }),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Lets the request go on, optionally changed. `headers` replaces all request headers. A new `postData` brings its
|
|
64
|
+
* own `content-length` (the request's old one would cut the new body off or leave the server waiting).
|
|
65
|
+
*/
|
|
66
|
+
async continue(options = {}) {
|
|
67
|
+
let headers = options.headers;
|
|
68
|
+
if (options.postData !== undefined) {
|
|
69
|
+
const withoutLength = Object.fromEntries(Object.entries(headers ?? this.info.headers).filter(([name]) => name.toLowerCase() !== "content-length"));
|
|
70
|
+
headers = { ...withoutLength, "content-length": String(Buffer.byteLength(options.postData)) };
|
|
71
|
+
}
|
|
72
|
+
await this.answer("network.continueRequest", {
|
|
73
|
+
request: this.info.id,
|
|
74
|
+
...(options.url !== undefined && { url: options.url }),
|
|
75
|
+
...(options.method !== undefined && { method: options.method }),
|
|
76
|
+
...(headers !== undefined && { headers: toHeaders(headers) }),
|
|
77
|
+
...(options.postData !== undefined && { body: toBytes(options.postData) }),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** Makes the request fail (the browser reports a network error to the page) */
|
|
81
|
+
async abort() {
|
|
82
|
+
await this.answer("network.failRequest", { request: this.info.id });
|
|
83
|
+
}
|
|
84
|
+
/** Hands the request to the next route that matches it, or lets it continue when there is none */
|
|
85
|
+
async fallback() {
|
|
86
|
+
this.claim();
|
|
87
|
+
await this.deps.next();
|
|
88
|
+
}
|
|
89
|
+
/** @internal Lets the request continue unchanged; failures are logged, not thrown */
|
|
90
|
+
async release() {
|
|
91
|
+
if (this.answered)
|
|
92
|
+
return;
|
|
93
|
+
try {
|
|
94
|
+
await this.continue();
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
logger.debug("Could not let %s continue", this.info.url, { err });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** @internal Fails the request; failures are logged, not thrown */
|
|
101
|
+
async failQuietly() {
|
|
102
|
+
if (this.answered)
|
|
103
|
+
return;
|
|
104
|
+
try {
|
|
105
|
+
await this.abort();
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
logger.debug("Could not fail %s", this.info.url, { err });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
claim() {
|
|
112
|
+
if (this.answered)
|
|
113
|
+
throw new Error("route already handled");
|
|
114
|
+
this.answered = true;
|
|
115
|
+
clearTimeout(this.timer);
|
|
116
|
+
this.deps.settled(this);
|
|
117
|
+
}
|
|
118
|
+
async answer(method, params) {
|
|
119
|
+
this.claim();
|
|
120
|
+
await this.deps.send(method, params);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Whether a browser accepts `urlPatterns` in `network.addIntercept`; asked once per connection */
|
|
124
|
+
const patternsUnsupported = new WeakSet();
|
|
125
|
+
/**
|
|
126
|
+
* Lets a page mock, block and change requests. One `network.addIntercept` serves all routes; matching
|
|
127
|
+
* happens here. Every request the browser holds is answered exactly once, so nothing hangs.
|
|
128
|
+
*/
|
|
129
|
+
export class Router {
|
|
130
|
+
constructor(connector, tree, contextId, options = {}) {
|
|
131
|
+
this.connector = connector;
|
|
132
|
+
this.tree = tree;
|
|
133
|
+
this.contextId = contextId;
|
|
134
|
+
this.routes = [];
|
|
135
|
+
this.pending = new Set();
|
|
136
|
+
this.failures = [];
|
|
137
|
+
this.queue = Promise.resolve();
|
|
138
|
+
this.interceptIds = new Set();
|
|
139
|
+
this.disposed = false;
|
|
140
|
+
this.onBeforeRequestSent = (params) => {
|
|
141
|
+
if (!params.isBlocked || params.context === null)
|
|
142
|
+
return;
|
|
143
|
+
if (!this.tree.isWithin(params.context, this.contextId))
|
|
144
|
+
return;
|
|
145
|
+
// Held by an intercept of ours, not someone else's
|
|
146
|
+
if (!(params.intercepts ?? []).some((id) => this.interceptIds.has(id)))
|
|
147
|
+
return;
|
|
148
|
+
const info = toRequest(params.request, params.navigation, params.isBlocked, null);
|
|
149
|
+
const matching = this.routes.filter((entry) => matchUrl(entry.pattern, info.url)).reverse();
|
|
150
|
+
this.dispatch(info, matching, 0).catch((err) => logger.error("Handling the request to %s failed", info.url, { err }));
|
|
151
|
+
};
|
|
152
|
+
this.routeTimeout = options.routeTimeout ?? DEFAULT_ROUTE_TIMEOUT;
|
|
153
|
+
}
|
|
154
|
+
/** Errors thrown by route handlers, oldest first */
|
|
155
|
+
get errors() {
|
|
156
|
+
return [...this.failures];
|
|
157
|
+
}
|
|
158
|
+
/** Sends matching requests to `handler`. Later routes are asked first */
|
|
159
|
+
async route(pattern, handler) {
|
|
160
|
+
if (this.disposed)
|
|
161
|
+
throw new Error("router disposed");
|
|
162
|
+
const entry = { pattern, handler };
|
|
163
|
+
this.routes.push(entry);
|
|
164
|
+
try {
|
|
165
|
+
await this.enqueue(() => this.sync());
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
this.routes = this.routes.filter((route) => route !== entry);
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Removes the routes for `pattern` (only the one with `handler`, when given) */
|
|
173
|
+
async unroute(pattern, handler) {
|
|
174
|
+
const removed = this.routes.filter((route) => samePattern(route.pattern, pattern) && (handler === undefined || route.handler === handler));
|
|
175
|
+
await this.remove(removed);
|
|
176
|
+
}
|
|
177
|
+
async unrouteAll() {
|
|
178
|
+
await this.remove([...this.routes]);
|
|
179
|
+
}
|
|
180
|
+
/** Lets held requests continue, removes the intercept and stops listening. Idempotent */
|
|
181
|
+
async dispose() {
|
|
182
|
+
if (this.disposed)
|
|
183
|
+
return;
|
|
184
|
+
this.disposed = true;
|
|
185
|
+
await this.remove([...this.routes]);
|
|
186
|
+
}
|
|
187
|
+
async remove(removed) {
|
|
188
|
+
if (removed.length === 0)
|
|
189
|
+
return;
|
|
190
|
+
this.routes = this.routes.filter((route) => !removed.includes(route));
|
|
191
|
+
// Requests a removed route still holds would wait for nobody
|
|
192
|
+
await Promise.all([...this.pending].filter((route) => removed.includes(route.entry)).map((route) => route.release()));
|
|
193
|
+
await this.enqueue(() => this.sync());
|
|
194
|
+
}
|
|
195
|
+
enqueue(work) {
|
|
196
|
+
const run = this.queue.then(work);
|
|
197
|
+
this.queue = run.catch(() => undefined);
|
|
198
|
+
return run;
|
|
199
|
+
}
|
|
200
|
+
/** Makes the browser-side intercept match the routes: none, one for everything, or one for exact URLs */
|
|
201
|
+
async sync() {
|
|
202
|
+
if (this.routes.length === 0)
|
|
203
|
+
return this.stop();
|
|
204
|
+
await this.listen();
|
|
205
|
+
const patterns = this.urlPatterns();
|
|
206
|
+
const key = patterns ? JSON.stringify(patterns) : "all";
|
|
207
|
+
if (key === this.currentKey)
|
|
208
|
+
return;
|
|
209
|
+
const id = await this.addIntercept(patterns);
|
|
210
|
+
const previous = this.currentId;
|
|
211
|
+
this.currentId = id;
|
|
212
|
+
this.currentKey = patterns && !patternsUnsupported.has(this.connector) ? key : "all";
|
|
213
|
+
this.interceptIds.add(id);
|
|
214
|
+
// The new intercept is in place before the old one goes: nothing slips through in between
|
|
215
|
+
if (previous !== undefined)
|
|
216
|
+
await this.removeIntercept(previous);
|
|
217
|
+
}
|
|
218
|
+
/** The exact URLs to block, or undefined to block everything and match here */
|
|
219
|
+
urlPatterns() {
|
|
220
|
+
if (patternsUnsupported.has(this.connector))
|
|
221
|
+
return undefined;
|
|
222
|
+
const patterns = [];
|
|
223
|
+
for (const { pattern } of this.routes) {
|
|
224
|
+
// The browser reads a URL string in normal form; anything else could mean something else to it
|
|
225
|
+
if (!isExactString(pattern) || !isNormalUrl(pattern))
|
|
226
|
+
return undefined;
|
|
227
|
+
if (!patterns.some((existing) => existing.type === "string" && existing.pattern === pattern)) {
|
|
228
|
+
patterns.push({ type: "string", pattern });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return patterns;
|
|
232
|
+
}
|
|
233
|
+
async addIntercept(patterns) {
|
|
234
|
+
const params = {
|
|
235
|
+
phases: ["beforeRequestSent"],
|
|
236
|
+
contexts: [this.contextId],
|
|
237
|
+
};
|
|
238
|
+
try {
|
|
239
|
+
const { intercept } = await this.connector.send("network.addIntercept", {
|
|
240
|
+
...params,
|
|
241
|
+
...(patterns && { urlPatterns: patterns }),
|
|
242
|
+
});
|
|
243
|
+
return intercept;
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
if (patterns && err instanceof BiDiError && err.code === "invalid argument") {
|
|
247
|
+
patternsUnsupported.add(this.connector);
|
|
248
|
+
return this.addIntercept(undefined);
|
|
249
|
+
}
|
|
250
|
+
throw err;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async removeIntercept(id) {
|
|
254
|
+
try {
|
|
255
|
+
await this.connector.send("network.removeIntercept", { intercept: id });
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
if (!(err instanceof BiDiError && err.code === "no such intercept"))
|
|
259
|
+
throw err;
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
this.interceptIds.delete(id);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async listen() {
|
|
266
|
+
if (this.subscription)
|
|
267
|
+
return;
|
|
268
|
+
this.connector.onEvent("network.beforeRequestSent", this.onBeforeRequestSent);
|
|
269
|
+
try {
|
|
270
|
+
this.subscription = await this.connector.subscribe(["network.beforeRequestSent"]);
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
this.connector.offEvent("network.beforeRequestSent", this.onBeforeRequestSent);
|
|
274
|
+
throw err;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async stop() {
|
|
278
|
+
const id = this.currentId;
|
|
279
|
+
this.currentId = undefined;
|
|
280
|
+
this.currentKey = undefined;
|
|
281
|
+
if (id !== undefined)
|
|
282
|
+
await this.removeIntercept(id);
|
|
283
|
+
if (this.subscription) {
|
|
284
|
+
this.connector.offEvent("network.beforeRequestSent", this.onBeforeRequestSent);
|
|
285
|
+
const subscription = this.subscription;
|
|
286
|
+
this.subscription = undefined;
|
|
287
|
+
await subscription.unsubscribe();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/** Runs the `index`-th matching route; past the last one the request continues unchanged */
|
|
291
|
+
async dispatch(info, matching, index) {
|
|
292
|
+
const entry = matching[index];
|
|
293
|
+
const deps = {
|
|
294
|
+
send: (method, params) => this.connector.send(method, params),
|
|
295
|
+
next: () => this.dispatch(info, matching, index + 1),
|
|
296
|
+
settled: (settled) => {
|
|
297
|
+
this.pending.delete(settled);
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
if (!entry) {
|
|
301
|
+
// Nobody (left) to ask: an unmatched request, or every route fell back
|
|
302
|
+
await new Route(info, { pattern: "", handler: () => { } }, deps).release();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const route = new Route(info, entry, deps);
|
|
306
|
+
this.pending.add(route);
|
|
307
|
+
route.timer = setTimeout(() => {
|
|
308
|
+
if (route.isAnswered)
|
|
309
|
+
return;
|
|
310
|
+
logger.error("A route for %s did not answer within %dms; letting the request continue", info.url, this.routeTimeout);
|
|
311
|
+
void route.release();
|
|
312
|
+
}, this.routeTimeout);
|
|
313
|
+
route.timer.unref?.();
|
|
314
|
+
try {
|
|
315
|
+
await entry.handler(route);
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
this.record(info, err);
|
|
319
|
+
await route.failQuietly();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (!route.isAnswered) {
|
|
323
|
+
logger.warn("The route for %s did not answer the request; letting it continue", info.url);
|
|
324
|
+
await route.release();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
record(info, err) {
|
|
328
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
329
|
+
const error = Object.assign(new Error(`route handler for ${info.url} threw: ${message}`), { cause: err });
|
|
330
|
+
this.failures.push(error);
|
|
331
|
+
if (this.failures.length > MAX_ERRORS)
|
|
332
|
+
this.failures.shift();
|
|
333
|
+
logger.error("A route handler threw for %s", info.url, { err });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function isNormalUrl(value) {
|
|
337
|
+
try {
|
|
338
|
+
return new URL(value).href === value;
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** A URL to match: an exact string, a glob (`*` = anything but `/`, `**` = anything) or a RegExp */
|
|
2
|
+
export type UrlPattern = string | RegExp;
|
|
3
|
+
/** True for a string without `*`: it must equal the URL */
|
|
4
|
+
export declare function isExactString(pattern: UrlPattern): pattern is string;
|
|
5
|
+
/** Whether `url` matches `pattern` */
|
|
6
|
+
export declare function matchUrl(pattern: UrlPattern, url: string): boolean;
|
|
7
|
+
/** Same pattern, so `unroute` finds the route a `route` call made */
|
|
8
|
+
export declare function samePattern(a: UrlPattern, b: UrlPattern): boolean;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** True for a string without `*`: it must equal the URL */
|
|
2
|
+
export function isExactString(pattern) {
|
|
3
|
+
return typeof pattern === "string" && !pattern.includes("*");
|
|
4
|
+
}
|
|
5
|
+
function globToRegExp(glob) {
|
|
6
|
+
let source = "";
|
|
7
|
+
for (let i = 0; i < glob.length; i++) {
|
|
8
|
+
const char = glob[i];
|
|
9
|
+
if (char === "*") {
|
|
10
|
+
if (glob[i + 1] === "*") {
|
|
11
|
+
source += ".*";
|
|
12
|
+
i++;
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
source += "[^/]*";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
source += char.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return new RegExp(`^${source}$`);
|
|
23
|
+
}
|
|
24
|
+
/** Whether `url` matches `pattern` */
|
|
25
|
+
export function matchUrl(pattern, url) {
|
|
26
|
+
if (typeof pattern === "string") {
|
|
27
|
+
return isExactString(pattern) ? pattern === url : globToRegExp(pattern).test(url);
|
|
28
|
+
}
|
|
29
|
+
// A global or sticky RegExp remembers where it stopped; every match must start fresh
|
|
30
|
+
pattern.lastIndex = 0;
|
|
31
|
+
return pattern.test(url);
|
|
32
|
+
}
|
|
33
|
+
/** Same pattern, so `unroute` finds the route a `route` call made */
|
|
34
|
+
export function samePattern(a, b) {
|
|
35
|
+
if (typeof a === "string" || typeof b === "string")
|
|
36
|
+
return a === b;
|
|
37
|
+
return a.source === b.source && a.flags === b.flags;
|
|
38
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { applyEdit } from "../steps/edit.js";
|
|
2
|
+
/** Writes a recorder event into the spec source, as an edit of test number `test` */
|
|
3
|
+
export function applyRecorderEvent(source, test, event) {
|
|
4
|
+
return applyEdit(source, {
|
|
5
|
+
op: event.op,
|
|
6
|
+
test,
|
|
7
|
+
index: event.index,
|
|
8
|
+
step: event.step,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Function declaration installed as a preload script, called as `(send)` where `send` is the recorder's
|
|
3
|
+
* channel. Reports what the tester does in the top-level document:
|
|
4
|
+
*
|
|
5
|
+
* - `{ type: "click", target, candidates, textEntry }`: a click, retargeted to the nearest interactive
|
|
6
|
+
* element (a label click reports its control; the click the browser forwards to the control is dropped).
|
|
7
|
+
* - `{ type: "input", target, candidates, value, secret, secretName }`: text typed into a field. Password
|
|
8
|
+
* values never leave the page: `secret` is set and `value` is left out.
|
|
9
|
+
* - `{ type: "assert", target, candidates, text, value }`: an Alt+click, which does not reach the page.
|
|
10
|
+
* While Alt is held, the element under the pointer is outlined.
|
|
11
|
+
*
|
|
12
|
+
* `candidates` are locator calls for the element, most stable first (test id, role and name, label,
|
|
13
|
+
* text, css, and an absolute xpath that always exists). The recorder checks each one against the page.
|
|
14
|
+
* Runs in the page's own realm; a string, so no transpiler helper can end up in it.
|
|
15
|
+
*/
|
|
16
|
+
export declare const CAPTURE_SOURCE: string;
|
|
17
|
+
/** Run in every loaded document to stop its capture script reporting and swallowing Alt+clicks */
|
|
18
|
+
export declare const CAPTURE_OFF = "() => { window[Symbol.for(\"samurai.recorder.off\")] = true; }";
|