@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
package/src/browser/page.ts
DELETED
|
@@ -1,1176 +0,0 @@
|
|
|
1
|
-
import Locator, { type TextOptions } from "../locator/locator.js";
|
|
2
|
-
import { cssSelector, roleSelector, textSelector } from "../locator/selector.js";
|
|
3
|
-
import EventEmitter from "node:events";
|
|
4
|
-
import { readConfig } from "../config/config.js";
|
|
5
|
-
import { peekRunSettings } from "../config/run-settings.js";
|
|
6
|
-
import logger from "../logger/index.js";
|
|
7
|
-
import {
|
|
8
|
-
NetworkTracker,
|
|
9
|
-
type NetworkEventName,
|
|
10
|
-
} from "../network/network-tracker.js";
|
|
11
|
-
import type { NetworkRequest, NetworkResponse, FailedRequest } from "../network/network-tracker.js";
|
|
12
|
-
import { ContextTree } from "./context-tree.js";
|
|
13
|
-
import { Dialog } from "./dialog.js";
|
|
14
|
-
import type { BrowserContext } from "./browser-context.js";
|
|
15
|
-
import { printPdf, type PdfOptions } from "./pdf.js";
|
|
16
|
-
import { applyEmulation, type EmulationOptions } from "./emulation.js";
|
|
17
|
-
import { Download, DownloadTracker } from "./download.js";
|
|
18
|
-
import { FileChooser, FileChooserTracker } from "./file-chooser.js";
|
|
19
|
-
import { DownloadWaitTimeoutError, FileChooserTimeoutError } from "./page-wait.js";
|
|
20
|
-
import { takeScreenshot, type ScreenshotOptions } from "./screenshot.js";
|
|
21
|
-
import { PageLogs, type ConsoleMessage, type LogRecord, type PageError } from "./page-logs.js";
|
|
22
|
-
import { Router, type RouteHandler } from "../network/router.js";
|
|
23
|
-
import { DataCollector, ResponseBodyUnavailableError } from "../network/data-collector.js";
|
|
24
|
-
import { Response } from "../network/response.js";
|
|
25
|
-
import {
|
|
26
|
-
RequestTimeoutError,
|
|
27
|
-
ResponseTimeoutError,
|
|
28
|
-
toPredicate,
|
|
29
|
-
type NetworkMatch,
|
|
30
|
-
} from "../network/network-wait.js";
|
|
31
|
-
import { UnsupportedOperationError } from "../locator/selector-errors.js";
|
|
32
|
-
import type { UrlPattern } from "../network/url-match.js";
|
|
33
|
-
import type {
|
|
34
|
-
UserPromptClosedParameters,
|
|
35
|
-
UserPromptOpenedParameters,
|
|
36
|
-
} from "../types/bidi-modules/browsing-context.js";
|
|
37
|
-
import { toFunctionDeclaration } from "../script/call-function.js";
|
|
38
|
-
import { HelperRealm } from "../script/helpers.js";
|
|
39
|
-
import { addPreload, type PreloadHandle } from "../script/preload.js";
|
|
40
|
-
import type { BiDiConnector } from "../transport/bidi-connection.js";
|
|
41
|
-
import type { BiDiCommands } from "../types/bidi.js";
|
|
42
|
-
import type { SamuraiTestConfig } from "../types/config.js";
|
|
43
|
-
import { BiDiError } from "../transport/bidi-error.js";
|
|
44
|
-
import type { Subscription } from "../transport/subscriptions.js";
|
|
45
|
-
import { WaitTimeoutError } from "../wait/wait-until.js";
|
|
46
|
-
import { LoadStateTimeoutError, NavigationError } from "./navigation-error.js";
|
|
47
|
-
import { callFunction } from "../script/call-function.js";
|
|
48
|
-
import type { BrowsingContext } from "../types/bidi-modules/browsing-context.js";
|
|
49
|
-
|
|
50
|
-
/** Time (ms) a navigation or a wait for a load state may take */
|
|
51
|
-
const DEFAULT_NAVIGATION_TIMEOUT = 30000;
|
|
52
|
-
/** Time (ms) without requests in flight that counts as idle */
|
|
53
|
-
const DEFAULT_IDLE_TIME = 500;
|
|
54
|
-
|
|
55
|
-
/** Options that otherwise come from `samurai.config.ts` */
|
|
56
|
-
export type PageSettings = Pick<SamuraiTestConfig, "network" | "navigation" | "use">;
|
|
57
|
-
|
|
58
|
-
/** The size a page gets unless config `use.viewport` says otherwise */
|
|
59
|
-
const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
|
|
60
|
-
|
|
61
|
-
export type ReadinessState = NonNullable<
|
|
62
|
-
BiDiCommands["browsingContext.navigate"]["params"]["wait"]
|
|
63
|
-
>;
|
|
64
|
-
export type LoadState = "load" | "domcontentloaded" | "networkidle";
|
|
65
|
-
|
|
66
|
-
export interface NavigateOptions {
|
|
67
|
-
/** When navigateTo resolves. @default config `navigation.waitUntil`, then "complete" */
|
|
68
|
-
wait?: ReadinessState;
|
|
69
|
-
/** Time (ms) the navigation may take. @default config `navigation.timeout`, then 30000 */
|
|
70
|
-
timeout?: number;
|
|
71
|
-
/** Scheme added to a URL without one. @default "https" */
|
|
72
|
-
protocol?: "http" | "https";
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
interface NetworkEvents {
|
|
76
|
-
request: [NetworkRequest];
|
|
77
|
-
response: [Response];
|
|
78
|
-
requestfailed: [FailedRequest];
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Everything `page.on` can listen for */
|
|
82
|
-
interface PageEvents extends NetworkEvents {
|
|
83
|
-
download: [Download];
|
|
84
|
-
filechooser: [FileChooser];
|
|
85
|
-
dialog: [Dialog];
|
|
86
|
-
console: [ConsoleMessage];
|
|
87
|
-
pageerror: [PageError];
|
|
88
|
-
}
|
|
89
|
-
export type PageEventName = keyof PageEvents;
|
|
90
|
-
|
|
91
|
-
/** Events a connection's browser rejected as unknown, so nothing asks for them again */
|
|
92
|
-
const unsupportedEventsByConnector = new WeakMap<BiDiConnector, Set<string>>();
|
|
93
|
-
|
|
94
|
-
function unsupportedEvents(connector: BiDiConnector): Set<string> {
|
|
95
|
-
let events = unsupportedEventsByConnector.get(connector);
|
|
96
|
-
if (!events) unsupportedEventsByConnector.set(connector, (events = new Set()));
|
|
97
|
-
return events;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
class NetworkTrackingDisabledError extends Error {
|
|
101
|
-
constructor() {
|
|
102
|
-
super("network tracking is disabled");
|
|
103
|
-
this.name = "NetworkTrackingDisabledError";
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async function readSection<K extends keyof PageSettings>(
|
|
108
|
-
key: K,
|
|
109
|
-
): Promise<NonNullable<PageSettings[K]>> {
|
|
110
|
-
try {
|
|
111
|
-
return ((await readConfig(key)) ?? {}) as NonNullable<PageSettings[K]>;
|
|
112
|
-
} catch (err) {
|
|
113
|
-
logger.debug("Could not read %s config, using defaults", key, { err });
|
|
114
|
-
return {} as NonNullable<PageSettings[K]>;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export default class Page {
|
|
119
|
-
private biDiConnector: BiDiConnector;
|
|
120
|
-
private id: BrowsingContext;
|
|
121
|
-
private contextTree: Promise<ContextTree> | undefined;
|
|
122
|
-
private helperRealm: HelperRealm;
|
|
123
|
-
private initScripts = new Set<PreloadHandle>();
|
|
124
|
-
private settings: PageSettings | undefined;
|
|
125
|
-
private networkTracker: Promise<NetworkTracker> | undefined;
|
|
126
|
-
private pageEvents = new EventEmitter();
|
|
127
|
-
/** The navigation started by the last navigateTo; failures of it fail a wait */
|
|
128
|
-
private lastNavigation: string | undefined;
|
|
129
|
-
/** Base for relative page.goto URLs; falls back to the active run's baseURL */
|
|
130
|
-
private baseURL: string | undefined;
|
|
131
|
-
/** How far navigations that did not wait for the load got, by navigation id (the latest few) */
|
|
132
|
-
private navigationProgress = new Map<string, { domContentLoaded: boolean; load: boolean }>();
|
|
133
|
-
private documentEvents: Promise<Subscription> | undefined;
|
|
134
|
-
private dialogHandling: Promise<Subscription> | undefined;
|
|
135
|
-
private pageLogs: Promise<PageLogs> | undefined;
|
|
136
|
-
private loadedLogs: PageLogs | undefined;
|
|
137
|
-
private errorsAllowed = false;
|
|
138
|
-
private isClosed = false;
|
|
139
|
-
private browserContext: BrowserContext | undefined;
|
|
140
|
-
private downloadTracker: Promise<DownloadTracker> | undefined;
|
|
141
|
-
private fileChooserTracker: Promise<FileChooserTracker> | undefined;
|
|
142
|
-
private collectorPromise: Promise<DataCollector> | undefined;
|
|
143
|
-
private responses = new WeakMap<NetworkResponse, Response>();
|
|
144
|
-
private routerPromise: Promise<Router> | undefined;
|
|
145
|
-
private loadedRouter: Router | undefined;
|
|
146
|
-
/** The context tree once loaded, for handlers that cannot wait for it */
|
|
147
|
-
private loadedTree: ContextTree | undefined;
|
|
148
|
-
private openDialogs = new Map<string, Dialog>();
|
|
149
|
-
|
|
150
|
-
/** `settings` override what `samurai.config.ts` says; without them the config is read when needed */
|
|
151
|
-
constructor(biDiConnector: BiDiConnector, contextId: string, settings?: PageSettings) {
|
|
152
|
-
this.biDiConnector = biDiConnector;
|
|
153
|
-
this.id = contextId;
|
|
154
|
-
this.settings = settings;
|
|
155
|
-
this.helperRealm = new HelperRealm(biDiConnector, contextId);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** The BiDi browsing context id of this page's top-level context */
|
|
159
|
-
public get contextId(): BrowsingContext {
|
|
160
|
-
return this.id;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** The context tree, created on first use and shared by everything that needs to attribute frame events to this page */
|
|
164
|
-
public tree(): Promise<ContextTree> {
|
|
165
|
-
this.contextTree ??= ContextTree.create(this.biDiConnector).then(
|
|
166
|
-
(tree) => (this.loadedTree = tree),
|
|
167
|
-
(err) => {
|
|
168
|
-
this.contextTree = undefined;
|
|
169
|
-
throw err;
|
|
170
|
-
},
|
|
171
|
-
);
|
|
172
|
-
return this.contextTree;
|
|
173
|
-
}
|
|
174
|
-
/** Locates an element on the page and returns an interactable object */
|
|
175
|
-
public locator(xpath: string) {
|
|
176
|
-
this.assertOpen();
|
|
177
|
-
return new Locator(xpath, this.biDiConnector, this.id, this.helperRealm);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** Locates elements with a CSS selector */
|
|
181
|
-
public getByCss(css: string) {
|
|
182
|
-
this.assertOpen();
|
|
183
|
-
return new Locator([cssSelector(css)], this.biDiConnector, this.id, this.helperRealm);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** Locates elements by their text: an exact match unless `match: "partial"` */
|
|
187
|
-
public getByText(text: string, options?: TextOptions) {
|
|
188
|
-
this.assertOpen();
|
|
189
|
-
return new Locator([textSelector(text, options)], this.biDiConnector, this.id, this.helperRealm);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** Locates elements by ARIA role and, optionally, accessible name */
|
|
193
|
-
public getByRole(role: string, options?: { name?: string }) {
|
|
194
|
-
this.assertOpen();
|
|
195
|
-
return new Locator([roleSelector(role, options)], this.biDiConnector, this.id, this.helperRealm);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Runs `script` before any page script in every document this page loads from now on, and once in
|
|
200
|
-
* the document that is loaded now. It runs in the page's own realm, so the page sees what it sets.
|
|
201
|
-
* A function receives `arg` (which must be JSON-serializable) as its only argument;
|
|
202
|
-
* a string is script source (statements) and takes no argument.
|
|
203
|
-
* @example
|
|
204
|
-
* await page.addInitScript(() => { window.__testMode = true; });
|
|
205
|
-
* await page.addInitScript((flags) => { window.flags = flags; }, { beta: true });
|
|
206
|
-
*/
|
|
207
|
-
public async addInitScript(
|
|
208
|
-
script: string | ((arg?: any) => void),
|
|
209
|
-
arg?: unknown,
|
|
210
|
-
): Promise<PreloadHandle> {
|
|
211
|
-
this.assertOpen();
|
|
212
|
-
const source = initScriptSource(script, arg);
|
|
213
|
-
const handle = await addPreload(this.biDiConnector, {
|
|
214
|
-
source,
|
|
215
|
-
contexts: [this.id],
|
|
216
|
-
onRunError: "log",
|
|
217
|
-
});
|
|
218
|
-
const tracked: PreloadHandle = {
|
|
219
|
-
id: handle.id,
|
|
220
|
-
dispose: async () => {
|
|
221
|
-
this.initScripts.delete(tracked);
|
|
222
|
-
await handle.dispose();
|
|
223
|
-
},
|
|
224
|
-
};
|
|
225
|
-
this.initScripts.add(tracked);
|
|
226
|
-
return tracked;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** Removes everything the page registered in the browser: init scripts, framework helpers and event subscriptions */
|
|
230
|
-
public async dispose(): Promise<void> {
|
|
231
|
-
const scripts = [...this.initScripts];
|
|
232
|
-
const tracker = this.networkTracker;
|
|
233
|
-
this.networkTracker = undefined;
|
|
234
|
-
const results = await Promise.allSettled([
|
|
235
|
-
...scripts.map((handle) => handle.dispose()),
|
|
236
|
-
this.helperRealm.dispose(),
|
|
237
|
-
// The tracker goes before the tree it reads
|
|
238
|
-
tracker?.then((resolved) => resolved.dispose()).catch(() => undefined),
|
|
239
|
-
]);
|
|
240
|
-
const tree = this.contextTree;
|
|
241
|
-
this.contextTree = undefined;
|
|
242
|
-
const documentEvents = this.documentEvents;
|
|
243
|
-
this.documentEvents = undefined;
|
|
244
|
-
const dialogHandling = this.dialogHandling;
|
|
245
|
-
this.dialogHandling = undefined;
|
|
246
|
-
const pageLogs = this.pageLogs;
|
|
247
|
-
this.pageLogs = undefined;
|
|
248
|
-
this.loadedLogs = undefined;
|
|
249
|
-
const downloads = this.downloadTracker;
|
|
250
|
-
this.downloadTracker = undefined;
|
|
251
|
-
const fileChoosers = this.fileChooserTracker;
|
|
252
|
-
this.fileChooserTracker = undefined;
|
|
253
|
-
const collector = this.collectorPromise;
|
|
254
|
-
this.collectorPromise = undefined;
|
|
255
|
-
const router = this.routerPromise;
|
|
256
|
-
this.routerPromise = undefined;
|
|
257
|
-
this.loadedRouter = undefined;
|
|
258
|
-
this.biDiConnector.offEvent("browsingContext.userPromptOpened", this.onPromptOpened);
|
|
259
|
-
this.biDiConnector.offEvent("browsingContext.userPromptClosed", this.onPromptClosed);
|
|
260
|
-
this.openDialogs.clear();
|
|
261
|
-
this.biDiConnector.offEvent("browsingContext.domContentLoaded", this.onDomContentLoaded);
|
|
262
|
-
this.biDiConnector.offEvent("browsingContext.load", this.onLoad);
|
|
263
|
-
results.push(
|
|
264
|
-
...(await Promise.allSettled([
|
|
265
|
-
tree?.then((resolved) => resolved.dispose()),
|
|
266
|
-
documentEvents?.then((subscription) => subscription.unsubscribe()),
|
|
267
|
-
dialogHandling?.then((subscription) => subscription.unsubscribe()),
|
|
268
|
-
pageLogs?.then((logs) => logs.dispose()),
|
|
269
|
-
router?.then((resolved) => resolved.dispose()),
|
|
270
|
-
collector?.then((resolved) => resolved.dispose()),
|
|
271
|
-
downloads?.then((resolved) => resolved.dispose()),
|
|
272
|
-
fileChoosers?.then((resolved) => resolved.dispose()),
|
|
273
|
-
])),
|
|
274
|
-
);
|
|
275
|
-
const failed = results.find((result) => result.status === "rejected");
|
|
276
|
-
if (failed) throw (failed as PromiseRejectedResult).reason;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/** Sets the base that relative `goto` URLs resolve against; `undefined` falls back to the run's baseURL */
|
|
280
|
-
public setBaseURL(url: string | undefined): void {
|
|
281
|
-
this.baseURL = url;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Navigates like `navigateTo`, but a URL starting with "/", "./" or "../" resolves against the base URL
|
|
286
|
-
* (`setBaseURL`, else the environment's `baseURL`).
|
|
287
|
-
* @example
|
|
288
|
-
* await page.goto("/products");
|
|
289
|
-
* await page.goto("https://example.com", { wait: "interactive" });
|
|
290
|
-
*/
|
|
291
|
-
public async goto(
|
|
292
|
-
url: string,
|
|
293
|
-
options?: NavigateOptions,
|
|
294
|
-
): Promise<{ navigation: string | null; url: string }> {
|
|
295
|
-
if (!/^\.{0,2}\//.test(url)) return this.navigateTo(url, options);
|
|
296
|
-
const base = this.baseURL ?? peekRunSettings()?.baseURL;
|
|
297
|
-
if (!base) {
|
|
298
|
-
throw new Error(`page.goto("${url}") needs a baseURL; set one in samurai.config.ts or the environment`);
|
|
299
|
-
}
|
|
300
|
-
return this.navigateTo(new URL(url, base).href, options);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* Navigates to `url` and resolves when the page reached `wait` (default "complete", the load event).
|
|
305
|
-
* A URL without a scheme gets `https://`. Throws NavigationError when the navigation fails or takes too long.
|
|
306
|
-
* The positional form `navigateTo(url, wait, protocol)` still works.
|
|
307
|
-
* @example
|
|
308
|
-
* await page.navigateTo("example.com");
|
|
309
|
-
* await page.navigateTo("localhost:3000", { wait: "interactive", protocol: "http" });
|
|
310
|
-
*/
|
|
311
|
-
public async navigateTo(
|
|
312
|
-
url: string,
|
|
313
|
-
options?: NavigateOptions | ReadinessState,
|
|
314
|
-
protocol?: "http" | "https",
|
|
315
|
-
): Promise<{ navigation: string | null; url: string }> {
|
|
316
|
-
const given: NavigateOptions = typeof options === "string" ? { wait: options } : (options ?? {});
|
|
317
|
-
const settings = await this.navigationSettings();
|
|
318
|
-
const wait = given.wait ?? settings.waitUntil ?? "complete";
|
|
319
|
-
const timeout = given.timeout ?? settings.timeout ?? DEFAULT_NAVIGATION_TIMEOUT;
|
|
320
|
-
// A scheme is letters followed by ":" not directly followed by a digit, so "localhost:3000" is a host
|
|
321
|
-
const hasScheme = /^[a-z][a-z\d+.-]*:(?!\d)/i.test(url);
|
|
322
|
-
const parsedURL = hasScheme ? url : `${given.protocol ?? protocol ?? "https"}://${url}`;
|
|
323
|
-
this.assertOpen();
|
|
324
|
-
return this.navigation("navigateTo", async () => parsedURL, wait, timeout, async (sendOptions) => {
|
|
325
|
-
const result = await this.biDiConnector.send(
|
|
326
|
-
"browsingContext.navigate",
|
|
327
|
-
{ context: this.id, url: parsedURL, wait },
|
|
328
|
-
sendOptions,
|
|
329
|
-
);
|
|
330
|
-
return { navigation: result.navigation ?? null, url: result.url ?? parsedURL };
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* Runs a navigation command and settles what waiting for it means: failures become NavigationErrors, and a
|
|
336
|
-
* navigation that did not wait for the load is watched so `waitForLoadState` can tell it from the old page.
|
|
337
|
-
*/
|
|
338
|
-
private async navigation(
|
|
339
|
-
operation: string,
|
|
340
|
-
describe: () => Promise<string>,
|
|
341
|
-
wait: ReadinessState,
|
|
342
|
-
timeout: number,
|
|
343
|
-
send: (options: { timeout: number }) => Promise<{ navigation: string | null; url: string }>,
|
|
344
|
-
): Promise<{ navigation: string | null; url: string }> {
|
|
345
|
-
// A navigation that does not wait for the load needs watching, or waitForLoadState cannot tell it from the old page
|
|
346
|
-
if (wait !== "complete") await this.watchDocumentEvents();
|
|
347
|
-
try {
|
|
348
|
-
const result = await send({ timeout });
|
|
349
|
-
this.lastNavigation = result.navigation ?? undefined;
|
|
350
|
-
if (result.navigation && wait !== "complete") {
|
|
351
|
-
// Tracked from now on: the document still showing is the old one until this navigation commits
|
|
352
|
-
const progress = this.progressOf(result.navigation);
|
|
353
|
-
if (wait === "interactive") progress.domContentLoaded = true;
|
|
354
|
-
}
|
|
355
|
-
return result;
|
|
356
|
-
} catch (err) {
|
|
357
|
-
if (err instanceof BiDiError) {
|
|
358
|
-
const reason = err.code === "timeout" ? `timeout after ${timeout}ms` : err.message;
|
|
359
|
-
throw new NavigationError(await describe(), reason, err.code, operation);
|
|
360
|
-
}
|
|
361
|
-
throw err;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Reloads the page and resolves when it reached `wait` (default like `navigateTo`). Firefox does not support
|
|
367
|
-
* `ignoreCache`; asking for it throws UnsupportedOperationError.
|
|
368
|
-
*/
|
|
369
|
-
public async reload(
|
|
370
|
-
options: { wait?: ReadinessState; timeout?: number; ignoreCache?: boolean } = {},
|
|
371
|
-
): Promise<{ navigation: string | null; url: string }> {
|
|
372
|
-
this.assertOpen();
|
|
373
|
-
const settings = await this.navigationSettings();
|
|
374
|
-
const wait = options.wait ?? settings.waitUntil ?? "complete";
|
|
375
|
-
const timeout = options.timeout ?? settings.timeout ?? DEFAULT_NAVIGATION_TIMEOUT;
|
|
376
|
-
return this.navigation("reload", () => this.url().catch(() => "the page"), wait, timeout, async (sendOptions) => {
|
|
377
|
-
try {
|
|
378
|
-
const result = await this.biDiConnector.send(
|
|
379
|
-
"browsingContext.reload",
|
|
380
|
-
{ context: this.id, wait, ...(options.ignoreCache && { ignoreCache: true }) },
|
|
381
|
-
sendOptions,
|
|
382
|
-
);
|
|
383
|
-
return { navigation: result.navigation ?? null, url: result.url };
|
|
384
|
-
} catch (err) {
|
|
385
|
-
if (options.ignoreCache && err instanceof BiDiError && err.code === "unsupported operation") {
|
|
386
|
-
throw new UnsupportedOperationError("reload({ ignoreCache }) is not supported by the browser");
|
|
387
|
-
}
|
|
388
|
-
throw err;
|
|
389
|
-
}
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/** Goes back one entry in the history. False when there is none to go back to */
|
|
394
|
-
public goBack(): Promise<boolean> {
|
|
395
|
-
return this.traverse(-1);
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/** Goes forward one entry in the history. False when there is none */
|
|
399
|
-
public goForward(): Promise<boolean> {
|
|
400
|
-
return this.traverse(1);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
private async traverse(delta: -1 | 1): Promise<boolean> {
|
|
404
|
-
this.assertOpen();
|
|
405
|
-
try {
|
|
406
|
-
await this.biDiConnector.send("browsingContext.traverseHistory", { context: this.id, delta });
|
|
407
|
-
return true;
|
|
408
|
-
} catch (err) {
|
|
409
|
-
if (err instanceof BiDiError && err.code === "no such history entry") return false;
|
|
410
|
-
throw err;
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/** The URL of the page now */
|
|
415
|
-
public async url(): Promise<string> {
|
|
416
|
-
this.assertOpen();
|
|
417
|
-
return callFunction<string>(this.biDiConnector, this.id, "() => location.href", [], { awaitPromise: false });
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/** The title of the document now */
|
|
421
|
-
public async title(): Promise<string> {
|
|
422
|
-
this.assertOpen();
|
|
423
|
-
return callFunction<string>(this.biDiConnector, this.id, "() => document.title", [], { awaitPromise: false });
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* Sets the size of the viewport (and, optionally, the device pixel ratio); `null` puts the browser's own size back.
|
|
428
|
-
* @example
|
|
429
|
-
* await page.setViewport({ width: 390, height: 844, devicePixelRatio: 3 });
|
|
430
|
-
*/
|
|
431
|
-
public async setViewport(
|
|
432
|
-
viewport: { width: number; height: number; devicePixelRatio?: number } | null,
|
|
433
|
-
): Promise<void> {
|
|
434
|
-
this.assertOpen();
|
|
435
|
-
if (viewport === null) {
|
|
436
|
-
await this.biDiConnector.send("browsingContext.setViewport", {
|
|
437
|
-
context: this.id,
|
|
438
|
-
viewport: null,
|
|
439
|
-
devicePixelRatio: null,
|
|
440
|
-
});
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
const { width, height, devicePixelRatio } = viewport;
|
|
444
|
-
for (const [name, value] of [["width", width], ["height", height]] as const) {
|
|
445
|
-
if (!Number.isInteger(value) || value <= 0) {
|
|
446
|
-
throw new RangeError(`setViewport(): ${name} must be a positive integer, got ${value}`);
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
if (devicePixelRatio !== undefined && !(devicePixelRatio > 0 && Number.isFinite(devicePixelRatio))) {
|
|
450
|
-
throw new RangeError(`setViewport(): devicePixelRatio must be a positive number, got ${devicePixelRatio}`);
|
|
451
|
-
}
|
|
452
|
-
await this.biDiConnector.send("browsingContext.setViewport", {
|
|
453
|
-
context: this.id,
|
|
454
|
-
viewport: { width, height },
|
|
455
|
-
...(devicePixelRatio !== undefined && { devicePixelRatio }),
|
|
456
|
-
});
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/** Gives the page the size from config `use.viewport` (1280x720 by default); the browser does this when it opens a page */
|
|
460
|
-
public async applyDefaultViewport(): Promise<void> {
|
|
461
|
-
const { viewport } = this.settings?.use ?? (await readSection("use"));
|
|
462
|
-
if (viewport === null) return;
|
|
463
|
-
await this.setViewport(viewport ?? DEFAULT_VIEWPORT);
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/**
|
|
467
|
-
* Takes a screenshot and returns the image (also written to `options.path`).
|
|
468
|
-
* @example
|
|
469
|
-
* await page.screenshot({ path: "out/home.png", fullPage: true });
|
|
470
|
-
*/
|
|
471
|
-
public async screenshot(options: ScreenshotOptions = {}): Promise<Buffer> {
|
|
472
|
-
this.assertOpen();
|
|
473
|
-
return takeScreenshot(this.biDiConnector, this.id, options);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/** Renders the page as a PDF and returns it (also written to `options.path`) */
|
|
477
|
-
public async pdf(options: PdfOptions = {}): Promise<Buffer> {
|
|
478
|
-
this.assertOpen();
|
|
479
|
-
return printPdf(this.biDiConnector, this.id, options);
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
/** The browser context (cookies, storage) this page belongs to */
|
|
483
|
-
public context(): BrowserContext {
|
|
484
|
-
if (!this.browserContext) throw new Error("this page has no browser context: open it with browser.newPage() or context.newPage()");
|
|
485
|
-
return this.browserContext;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/** @internal Called when the page is opened in a context */
|
|
489
|
-
public setContext(context: BrowserContext): void {
|
|
490
|
-
this.browserContext = context;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
/** @internal Marks the page closed after its tab is gone (its context was removed) and releases what it registered */
|
|
494
|
-
public async detach(): Promise<void> {
|
|
495
|
-
this.isClosed = true;
|
|
496
|
-
await this.dispose().catch((err) => logger.debug("Releasing a detached page failed", { err }));
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
/**
|
|
500
|
-
* Pretends things about this page only: locale, timezone, user agent, geolocation, offline, orientation, screen
|
|
501
|
-
* size. It wins over what the page's context says; `null` puts the real (or the context's) value back.
|
|
502
|
-
* @example
|
|
503
|
-
* await page.emulate({ locale: "nl-NL", offline: true });
|
|
504
|
-
*/
|
|
505
|
-
public async emulate(options: EmulationOptions): Promise<void> {
|
|
506
|
-
this.assertOpen();
|
|
507
|
-
await applyEmulation(this.biDiConnector, { contexts: [this.id] }, options);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* Grants a browser permission ("geolocation", "notifications", "camera", …) to the origin this page is on,
|
|
512
|
-
* or to `options.origin`. Geolocation emulation only answers once this is granted.
|
|
513
|
-
*/
|
|
514
|
-
public async grantPermission(name: string, options: { origin?: string } = {}): Promise<void> {
|
|
515
|
-
this.assertOpen();
|
|
516
|
-
const origin = options.origin ?? new URL(await this.url()).origin;
|
|
517
|
-
// about:blank and data: pages have no origin to grant to
|
|
518
|
-
if (origin === "null") throw new Error("grantPermission() needs a page on a web origin, or an explicit origin option");
|
|
519
|
-
const context = this.browserContext;
|
|
520
|
-
await this.biDiConnector.send("permissions.setPermission", {
|
|
521
|
-
descriptor: { name },
|
|
522
|
-
state: "granted",
|
|
523
|
-
origin,
|
|
524
|
-
...(context && !context.isDefault && { userContext: context.id }),
|
|
525
|
-
});
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
/** True once `close()` has run */
|
|
529
|
-
public get closed(): boolean {
|
|
530
|
-
return this.isClosed;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
private assertOpen() {
|
|
534
|
-
if (this.isClosed) throw new Error("page closed");
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/**
|
|
538
|
-
* Closes the tab and releases everything the page registered. Later calls on the page throw "page closed".
|
|
539
|
-
* With `runBeforeUnload` the page's `beforeunload` handlers run first. Closing the browser's last tab may end the browser.
|
|
540
|
-
*/
|
|
541
|
-
public async close(options: { runBeforeUnload?: boolean } = {}): Promise<void> {
|
|
542
|
-
if (this.isClosed) return;
|
|
543
|
-
try {
|
|
544
|
-
await this.biDiConnector.send("browsingContext.close", {
|
|
545
|
-
context: this.id,
|
|
546
|
-
...(options.runBeforeUnload && { promptUnload: true }),
|
|
547
|
-
});
|
|
548
|
-
} catch (err) {
|
|
549
|
-
// Already gone (closed by the page, the browser or another call): the goal is met
|
|
550
|
-
if (!(err instanceof BiDiError && err.code === "no such frame")) throw err;
|
|
551
|
-
}
|
|
552
|
-
this.isClosed = true;
|
|
553
|
-
await this.dispose().catch((err) => logger.debug("Releasing a closed page failed", { err }));
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/**
|
|
557
|
-
* Waits until the page's document reached `state` (default "load"): "domcontentloaded" and "load" follow the
|
|
558
|
-
* document, "networkidle" is "load" plus a quiet network (see `waitForNetworkIdle`). Resolves at once when it
|
|
559
|
-
* is already there. Call it once the navigation has started; a document that is still the old one counts.
|
|
560
|
-
* @example
|
|
561
|
-
* await page.locator("a[@id='next']").click();
|
|
562
|
-
* await page.waitForLoadState("domcontentloaded");
|
|
563
|
-
*/
|
|
564
|
-
public async waitForLoadState(
|
|
565
|
-
state: LoadState = "load",
|
|
566
|
-
options: { timeout?: number } = {},
|
|
567
|
-
): Promise<void> {
|
|
568
|
-
this.assertOpen();
|
|
569
|
-
const timeout =
|
|
570
|
-
options.timeout ?? (await this.navigationSettings()).timeout ?? DEFAULT_NAVIGATION_TIMEOUT;
|
|
571
|
-
try {
|
|
572
|
-
await this.waitForDocumentState(state === "domcontentloaded" ? "domcontentloaded" : "load", timeout);
|
|
573
|
-
} catch (err) {
|
|
574
|
-
if (err instanceof WaitTimeoutError) throw new LoadStateTimeoutError(state, timeout);
|
|
575
|
-
throw err;
|
|
576
|
-
}
|
|
577
|
-
if (state === "networkidle") {
|
|
578
|
-
const tracker = await this.network();
|
|
579
|
-
const idleTime = (await this.networkSettings()).idleTime ?? DEFAULT_IDLE_TIME;
|
|
580
|
-
await tracker.waitForIdle({ idleTime, timeout });
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
private progressOf(navigation: string) {
|
|
585
|
-
let progress = this.navigationProgress.get(navigation);
|
|
586
|
-
if (!progress) {
|
|
587
|
-
progress = { domContentLoaded: false, load: false };
|
|
588
|
-
this.navigationProgress.set(navigation, progress);
|
|
589
|
-
// Only the latest few navigations matter
|
|
590
|
-
if (this.navigationProgress.size > 20) {
|
|
591
|
-
this.navigationProgress.delete(this.navigationProgress.keys().next().value as string);
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
return progress;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
private onDocumentEvent = (kind: "domContentLoaded" | "load") => (info: { context: string; navigation: string | null }) => {
|
|
598
|
-
if (info.context !== this.id || info.navigation === null) return;
|
|
599
|
-
const progress = this.progressOf(info.navigation);
|
|
600
|
-
progress.domContentLoaded = true;
|
|
601
|
-
if (kind === "load") progress.load = true;
|
|
602
|
-
};
|
|
603
|
-
private onDomContentLoaded = this.onDocumentEvent("domContentLoaded");
|
|
604
|
-
private onLoad = this.onDocumentEvent("load");
|
|
605
|
-
|
|
606
|
-
/** Starts recording which navigations reached DOMContentLoaded and load; kept until dispose */
|
|
607
|
-
private watchDocumentEvents(): Promise<Subscription> {
|
|
608
|
-
this.documentEvents ??= (async () => {
|
|
609
|
-
this.biDiConnector.onEvent("browsingContext.domContentLoaded", this.onDomContentLoaded);
|
|
610
|
-
this.biDiConnector.onEvent("browsingContext.load", this.onLoad);
|
|
611
|
-
try {
|
|
612
|
-
return await this.biDiConnector.subscribe([
|
|
613
|
-
"browsingContext.domContentLoaded",
|
|
614
|
-
"browsingContext.load",
|
|
615
|
-
]);
|
|
616
|
-
} catch (err) {
|
|
617
|
-
this.biDiConnector.offEvent("browsingContext.domContentLoaded", this.onDomContentLoaded);
|
|
618
|
-
this.biDiConnector.offEvent("browsingContext.load", this.onLoad);
|
|
619
|
-
this.documentEvents = undefined;
|
|
620
|
-
throw err;
|
|
621
|
-
}
|
|
622
|
-
})();
|
|
623
|
-
return this.documentEvents;
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
private canBeAborted(): boolean {
|
|
627
|
-
return !unsupportedEvents(this.biDiConnector).has("browsingContext.navigationAborted");
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/** Subscribes to what a wait listens for; a browser that lacks the navigationAborted event (Firefox) is asked only once */
|
|
631
|
-
private async subscribeToDocumentEvents(reachedEvent: "browsingContext.load" | "browsingContext.domContentLoaded") {
|
|
632
|
-
const required = [reachedEvent, "browsingContext.navigationFailed"] as const;
|
|
633
|
-
if (!this.canBeAborted()) return this.biDiConnector.subscribe([...required]);
|
|
634
|
-
try {
|
|
635
|
-
return await this.biDiConnector.subscribe([...required, "browsingContext.navigationAborted"]);
|
|
636
|
-
} catch (err) {
|
|
637
|
-
if (!(err instanceof BiDiError) || err.code !== "invalid argument") throw err;
|
|
638
|
-
unsupportedEvents(this.biDiConnector).add("browsingContext.navigationAborted");
|
|
639
|
-
return this.biDiConnector.subscribe([...required]);
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
private async waitForDocumentState(target: "load" | "domcontentloaded", timeout: number) {
|
|
644
|
-
const connector = this.biDiConnector;
|
|
645
|
-
const reachedEvent = target === "load" ? "browsingContext.load" : "browsingContext.domContentLoaded";
|
|
646
|
-
const subscription = await this.subscribeToDocumentEvents(reachedEvent);
|
|
647
|
-
const abort = new AbortController();
|
|
648
|
-
const last = this.lastNavigation;
|
|
649
|
-
const listen = { timeout, signal: abort.signal };
|
|
650
|
-
const failure = (info: { url: string }, what: string) => {
|
|
651
|
-
throw new NavigationError(info.url, what, undefined, "waitForLoadState");
|
|
652
|
-
};
|
|
653
|
-
try {
|
|
654
|
-
// Registered before the state is read, so an event in between is not lost
|
|
655
|
-
const events = Promise.race([
|
|
656
|
-
connector.waitForEvent(reachedEvent, (p) => p.context === this.id, listen),
|
|
657
|
-
connector
|
|
658
|
-
.waitForEvent(
|
|
659
|
-
"browsingContext.navigationFailed",
|
|
660
|
-
(p) => p.context === this.id && (last === undefined || p.navigation === last),
|
|
661
|
-
listen,
|
|
662
|
-
)
|
|
663
|
-
.then((p) => failure(p, "navigation failed")),
|
|
664
|
-
...(this.canBeAborted()
|
|
665
|
-
? [
|
|
666
|
-
connector
|
|
667
|
-
.waitForEvent(
|
|
668
|
-
"browsingContext.navigationAborted",
|
|
669
|
-
(p) => p.context === this.id && last !== undefined && p.navigation === last,
|
|
670
|
-
listen,
|
|
671
|
-
)
|
|
672
|
-
.then((p) => failure(p, "navigation aborted")),
|
|
673
|
-
]
|
|
674
|
-
: []),
|
|
675
|
-
]);
|
|
676
|
-
events.catch(() => {}); // settled below, or aborted in finally
|
|
677
|
-
const known = last === undefined ? undefined : this.navigationProgress.get(last);
|
|
678
|
-
// A navigation we started and saw fall short of the state beats the document that is showing
|
|
679
|
-
let reached: boolean;
|
|
680
|
-
if (known && !(target === "load" ? known.load : known.domContentLoaded)) {
|
|
681
|
-
reached = false;
|
|
682
|
-
} else {
|
|
683
|
-
const readyState = await callFunction<string>(connector, this.id, "() => document.readyState", [], {
|
|
684
|
-
awaitPromise: false,
|
|
685
|
-
});
|
|
686
|
-
reached =
|
|
687
|
-
target === "load" ? readyState === "complete" : readyState === "interactive" || readyState === "complete";
|
|
688
|
-
}
|
|
689
|
-
if (!reached) await events;
|
|
690
|
-
} finally {
|
|
691
|
-
abort.abort();
|
|
692
|
-
await subscription.unsubscribe();
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
private networkSettings() {
|
|
697
|
-
return this.settings?.network ? Promise.resolve(this.settings.network) : readSection("network");
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
private navigationSettings() {
|
|
701
|
-
return this.settings?.navigation
|
|
702
|
-
? Promise.resolve(this.settings.navigation)
|
|
703
|
-
: readSection("navigation");
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
/** The network tracker, created on first use. Rejects with NetworkTrackingDisabledError when config turned tracking off */
|
|
707
|
-
private network(): Promise<NetworkTracker> {
|
|
708
|
-
this.networkTracker ??= (async () => {
|
|
709
|
-
const settings = await this.networkSettings();
|
|
710
|
-
if (settings.track === false) throw new NetworkTrackingDisabledError();
|
|
711
|
-
const tracker = await NetworkTracker.start(this.biDiConnector, await this.tree(), this.id);
|
|
712
|
-
tracker.on("request", (request) => this.pageEvents.emit("request", request));
|
|
713
|
-
tracker.on("response", (response) => this.pageEvents.emit("response", this.responseFor(response)));
|
|
714
|
-
tracker.on("requestfailed", (failed) => this.pageEvents.emit("requestfailed", failed));
|
|
715
|
-
if (settings.collectBodies) await this.startBodyCollection();
|
|
716
|
-
return tracker;
|
|
717
|
-
})().catch((err) => {
|
|
718
|
-
if (!(err instanceof NetworkTrackingDisabledError)) this.networkTracker = undefined;
|
|
719
|
-
throw err;
|
|
720
|
-
});
|
|
721
|
-
return this.networkTracker;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
/**
|
|
725
|
-
* Starts following this page's requests now. Tracking only sees requests made after it started, so the
|
|
726
|
-
* browser calls this when it opens a page; without it, tracking starts on first use. Does nothing when
|
|
727
|
-
* config `network.track` is false.
|
|
728
|
-
*/
|
|
729
|
-
public async startNetworkTracking(): Promise<void> {
|
|
730
|
-
try {
|
|
731
|
-
await this.network();
|
|
732
|
-
} catch (err) {
|
|
733
|
-
if (!(err instanceof NetworkTrackingDisabledError)) throw err;
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
/**
|
|
738
|
-
* Waits until no request has been in flight for `idleTime` ms (default config `network.idleTime`, then 500).
|
|
739
|
-
* Long-lived requests (server-sent events, long polling) keep the page busy until `timeout`.
|
|
740
|
-
*/
|
|
741
|
-
public async waitForNetworkIdle(options: { idleTime?: number; timeout?: number } = {}): Promise<void> {
|
|
742
|
-
const tracker = await this.network();
|
|
743
|
-
const idleTime = options.idleTime ?? (await this.networkSettings()).idleTime ?? DEFAULT_IDLE_TIME;
|
|
744
|
-
const timeout =
|
|
745
|
-
options.timeout ?? (await this.navigationSettings()).timeout ?? DEFAULT_NAVIGATION_TIMEOUT;
|
|
746
|
-
await tracker.waitForIdle({ idleTime, timeout });
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
/**
|
|
750
|
-
* Listens for page events: `request`, `response`, `requestfailed` (network), `dialog` (a JavaScript dialog or
|
|
751
|
-
* `beforeunload` prompt opened; answer it with `dialog.accept()` / `dialog.dismiss()`), `console` (a `console.*` call)
|
|
752
|
-
* and `pageerror` (an uncaught exception). Starts what the event needs.
|
|
753
|
-
* A dialog nobody answers is dismissed automatically (a `beforeunload` prompt is accepted), so it cannot hang a test.
|
|
754
|
-
* @example
|
|
755
|
-
* page.on("dialog", (dialog) => dialog.accept());
|
|
756
|
-
*/
|
|
757
|
-
public on<E extends PageEventName>(event: E, listener: (...args: PageEvents[E]) => void): this {
|
|
758
|
-
this.pageEvents.on(event, listener as (...args: unknown[]) => void);
|
|
759
|
-
this.trackInBackground(event);
|
|
760
|
-
return this;
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
public once<E extends PageEventName>(event: E, listener: (...args: PageEvents[E]) => void): this {
|
|
764
|
-
this.pageEvents.once(event, listener as (...args: unknown[]) => void);
|
|
765
|
-
this.trackInBackground(event);
|
|
766
|
-
return this;
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
public off<E extends PageEventName>(event: E, listener: (...args: PageEvents[E]) => void): this {
|
|
770
|
-
this.pageEvents.off(event, listener as (...args: unknown[]) => void);
|
|
771
|
-
return this;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
private trackInBackground(event: PageEventName) {
|
|
775
|
-
const started =
|
|
776
|
-
event === "dialog"
|
|
777
|
-
? this.startDialogHandling()
|
|
778
|
-
: event === "download"
|
|
779
|
-
? this.startDownloadTracking()
|
|
780
|
-
: event === "filechooser"
|
|
781
|
-
? this.startFileChooserTracking()
|
|
782
|
-
: event === "console" || event === "pageerror"
|
|
783
|
-
? this.startLogging()
|
|
784
|
-
: event === "response"
|
|
785
|
-
? Promise.all([this.startNetworkTracking(), this.startBodyCollection()])
|
|
786
|
-
: this.startNetworkTracking();
|
|
787
|
-
started.catch((err) => logger.error("Could not start listening for %s", event, { err }));
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
/** Starts following downloads; the browser does this when it opens a page, `page.on("download")` and `waitForDownload` when needed */
|
|
791
|
-
public async startDownloadTracking(): Promise<void> {
|
|
792
|
-
this.downloadTracker ??= (async () => {
|
|
793
|
-
const timeout = (await this.navigationSettings()).timeout ?? DEFAULT_NAVIGATION_TIMEOUT;
|
|
794
|
-
const tracker = await DownloadTracker.start(this.biDiConnector, await this.tree(), this.id, { timeout });
|
|
795
|
-
tracker.on("download", (download) => this.pageEvents.emit("download", download));
|
|
796
|
-
return tracker;
|
|
797
|
-
})().catch((err) => {
|
|
798
|
-
this.downloadTracker = undefined;
|
|
799
|
-
throw err;
|
|
800
|
-
});
|
|
801
|
-
await this.downloadTracker;
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
/** Starts following file pickers; the browser does this when it opens a page, `page.on("filechooser")` and `waitForFileChooser` when needed */
|
|
805
|
-
public async startFileChooserTracking(): Promise<void> {
|
|
806
|
-
this.fileChooserTracker ??= (async () => {
|
|
807
|
-
const tracker = await FileChooserTracker.start(this.biDiConnector, await this.tree(), this.id);
|
|
808
|
-
tracker.on("filechooser", (chooser) => this.pageEvents.emit("filechooser", chooser));
|
|
809
|
-
return tracker;
|
|
810
|
-
})().catch((err) => {
|
|
811
|
-
this.fileChooserTracker = undefined;
|
|
812
|
-
throw err;
|
|
813
|
-
});
|
|
814
|
-
await this.fileChooserTracker;
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
/**
|
|
818
|
-
* Resolves with the next download this page starts (when it starts, not when it ends: use `download.path()` for that).
|
|
819
|
-
* Call it before the download is triggered, or pass `trigger`, which runs once the wait is set up.
|
|
820
|
-
* @example
|
|
821
|
-
* const download = await page.waitForDownload({ trigger: () => link.click() });
|
|
822
|
-
* const file = await download.path();
|
|
823
|
-
*/
|
|
824
|
-
public async waitForDownload(options: { timeout?: number; trigger?: () => Promise<unknown> } = {}): Promise<Download> {
|
|
825
|
-
this.assertOpen();
|
|
826
|
-
await this.startDownloadTracking();
|
|
827
|
-
return this.waitForPageEvent("download", () => true, options, (timeout) => new DownloadWaitTimeoutError(timeout));
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
/**
|
|
831
|
-
* Resolves with the next file picker the page opens (a click on an `<input type=file>` or its label). Nothing shows
|
|
832
|
-
* on screen: answer it with `chooser.setFiles(...)`.
|
|
833
|
-
* @example
|
|
834
|
-
* const chooser = await page.waitForFileChooser({ trigger: () => page.locator("label[@for='avatar']").click() });
|
|
835
|
-
* await chooser.setFiles("./fixtures/avatar.png");
|
|
836
|
-
*/
|
|
837
|
-
public async waitForFileChooser(options: { timeout?: number; trigger?: () => Promise<unknown> } = {}): Promise<FileChooser> {
|
|
838
|
-
this.assertOpen();
|
|
839
|
-
await this.startFileChooserTracking();
|
|
840
|
-
return this.waitForPageEvent("filechooser", () => true, options, (timeout) => new FileChooserTimeoutError(timeout));
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
/**
|
|
844
|
-
* Starts keeping response bodies, which is what makes `response.body()` possible: the browser only keeps a body
|
|
845
|
-
* when this ran before the request started. `page.on("response")` and `waitForResponse` call it; config
|
|
846
|
-
* `network.collectBodies` calls it when the browser opens the page.
|
|
847
|
-
*/
|
|
848
|
-
public async startBodyCollection(): Promise<void> {
|
|
849
|
-
this.collectorPromise ??= (async () => {
|
|
850
|
-
const { maxBodySize } = await this.networkSettings();
|
|
851
|
-
return DataCollector.start(this.biDiConnector, this.id, {
|
|
852
|
-
...(maxBodySize !== undefined && { maxBodySize }),
|
|
853
|
-
});
|
|
854
|
-
})().catch((err) => {
|
|
855
|
-
this.collectorPromise = undefined;
|
|
856
|
-
throw err;
|
|
857
|
-
});
|
|
858
|
-
await this.collectorPromise;
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
/** One Response per network response, whoever asks, so a body is read from the browser once */
|
|
862
|
-
private responseFor(data: NetworkResponse): Response {
|
|
863
|
-
let response = this.responses.get(data);
|
|
864
|
-
if (!response) {
|
|
865
|
-
response = new Response(data, (loaded) => this.loadBody(loaded));
|
|
866
|
-
this.responses.set(data, response);
|
|
867
|
-
}
|
|
868
|
-
return response;
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
private async loadBody(response: Response): Promise<Buffer> {
|
|
872
|
-
if (!this.collectorPromise) {
|
|
873
|
-
throw new ResponseBodyUnavailableError(
|
|
874
|
-
response.url,
|
|
875
|
-
"no body collection was running: call page.startBodyCollection() before the request, or listen with page.on('response')",
|
|
876
|
-
);
|
|
877
|
-
}
|
|
878
|
-
const collector = await this.collectorPromise;
|
|
879
|
-
const body = await collector.read(response.id, response.url);
|
|
880
|
-
void collector.release(response.id);
|
|
881
|
-
return body;
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
/**
|
|
885
|
-
* Resolves with the next response of this page whose URL matches (a pattern, see `route`) or that the function
|
|
886
|
-
* accepts. Call it before the request is made, or pass `trigger`, which runs once the wait is set up.
|
|
887
|
-
* @example
|
|
888
|
-
* const response = await page.waitForResponse("**\/api/users", { trigger: () => button.click() });
|
|
889
|
-
* const users = await response.json();
|
|
890
|
-
*/
|
|
891
|
-
public async waitForResponse(
|
|
892
|
-
match: NetworkMatch<Response>,
|
|
893
|
-
options: { timeout?: number; trigger?: () => Promise<unknown> } = {},
|
|
894
|
-
): Promise<Response> {
|
|
895
|
-
await Promise.all([this.network(), this.startBodyCollection()]);
|
|
896
|
-
return this.waitForNetworkEvent("response", match, options);
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
/** Like `waitForResponse`, for requests */
|
|
900
|
-
public async waitForRequest(
|
|
901
|
-
match: NetworkMatch<NetworkRequest>,
|
|
902
|
-
options: { timeout?: number; trigger?: () => Promise<unknown> } = {},
|
|
903
|
-
): Promise<NetworkRequest> {
|
|
904
|
-
await this.network();
|
|
905
|
-
return this.waitForNetworkEvent("request", match, options);
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
private waitForNetworkEvent<E extends "request" | "response">(
|
|
909
|
-
event: E,
|
|
910
|
-
match: NetworkMatch<NetworkEvents[E][0]>,
|
|
911
|
-
options: { timeout?: number; trigger?: () => Promise<unknown> },
|
|
912
|
-
): Promise<NetworkEvents[E][0]> {
|
|
913
|
-
const matches = toPredicate(match as NetworkMatch<{ url: string }>) as (item: NetworkEvents[E][0]) => boolean;
|
|
914
|
-
const Timeout = event === "request" ? RequestTimeoutError : ResponseTimeoutError;
|
|
915
|
-
return this.waitForPageEvent(event, matches, options, (timeout) =>
|
|
916
|
-
new Timeout(event === "request" ? "waitForRequest" : "waitForResponse", match as never, timeout),
|
|
917
|
-
);
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
/**
|
|
921
|
-
* Resolves with the first `event` `matches` accepts. Listens before `trigger` runs, so what the trigger causes cannot
|
|
922
|
-
* be missed; a trigger that throws rejects the wait with its error. `onTimeout` builds the error for a wait that ran out.
|
|
923
|
-
*/
|
|
924
|
-
private async waitForPageEvent<E extends keyof PageEvents>(
|
|
925
|
-
event: E,
|
|
926
|
-
matches: (item: PageEvents[E][0]) => boolean,
|
|
927
|
-
options: { timeout?: number; trigger?: () => Promise<unknown> },
|
|
928
|
-
onTimeout: (timeout: number) => Error,
|
|
929
|
-
): Promise<PageEvents[E][0]> {
|
|
930
|
-
const timeout =
|
|
931
|
-
options.timeout ?? (await this.navigationSettings()).timeout ?? DEFAULT_NAVIGATION_TIMEOUT;
|
|
932
|
-
return new Promise((resolve, reject) => {
|
|
933
|
-
const done = () => {
|
|
934
|
-
clearTimeout(timer);
|
|
935
|
-
this.pageEvents.off(event, listener);
|
|
936
|
-
};
|
|
937
|
-
const listener = (item: PageEvents[E][0]) => {
|
|
938
|
-
try {
|
|
939
|
-
if (!matches(item)) return;
|
|
940
|
-
} catch (err) {
|
|
941
|
-
done();
|
|
942
|
-
reject(err);
|
|
943
|
-
return;
|
|
944
|
-
}
|
|
945
|
-
done();
|
|
946
|
-
resolve(item);
|
|
947
|
-
};
|
|
948
|
-
const timer = setTimeout(() => {
|
|
949
|
-
done();
|
|
950
|
-
reject(onTimeout(timeout));
|
|
951
|
-
}, timeout);
|
|
952
|
-
this.pageEvents.on(event, listener as (...args: unknown[]) => void);
|
|
953
|
-
const { trigger } = options;
|
|
954
|
-
if (trigger) {
|
|
955
|
-
Promise.resolve()
|
|
956
|
-
.then(trigger)
|
|
957
|
-
.catch((err) => {
|
|
958
|
-
done();
|
|
959
|
-
reject(err);
|
|
960
|
-
});
|
|
961
|
-
}
|
|
962
|
-
});
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
/** Turns the browser's HTTP cache off (`true`) or back on for this page: every request then goes to the server */
|
|
966
|
-
public async setCacheDisabled(disabled: boolean): Promise<void> {
|
|
967
|
-
try {
|
|
968
|
-
await this.biDiConnector.send("network.setCacheBehavior", {
|
|
969
|
-
cacheBehavior: disabled ? "bypass" : "default",
|
|
970
|
-
contexts: [this.id],
|
|
971
|
-
});
|
|
972
|
-
} catch (err) {
|
|
973
|
-
if (err instanceof BiDiError && (err.code === "unsupported operation" || err.code === "unknown command")) {
|
|
974
|
-
throw new UnsupportedOperationError("page.setCacheDisabled() is not supported by the browser");
|
|
975
|
-
}
|
|
976
|
-
throw err;
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
private router(): Promise<Router> {
|
|
981
|
-
this.routerPromise ??= (async () => {
|
|
982
|
-
const routeTimeout = (await this.networkSettings()).routeTimeout;
|
|
983
|
-
const router = new Router(this.biDiConnector, await this.tree(), this.id, {
|
|
984
|
-
...(routeTimeout !== undefined && { routeTimeout }),
|
|
985
|
-
});
|
|
986
|
-
this.loadedRouter = router;
|
|
987
|
-
return router;
|
|
988
|
-
})().catch((err) => {
|
|
989
|
-
this.routerPromise = undefined;
|
|
990
|
-
throw err;
|
|
991
|
-
});
|
|
992
|
-
return this.routerPromise;
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
/**
|
|
996
|
-
* Sends requests whose URL matches to `handler`, which must answer with `route.fulfill()`, `route.continue()`,
|
|
997
|
-
* `route.abort()` or `route.fallback()` (await the answer: a handler that returns first lets the request continue).
|
|
998
|
-
* A string without `*` is an exact URL, with `*` / `**` a glob (`*` stops at `/`), a RegExp is tested on the URL.
|
|
999
|
-
* Routes added later are asked first. Errors a handler throws fail the request and, in the runner, the test.
|
|
1000
|
-
* @example
|
|
1001
|
-
* await page.route("**\/api/users", (route) => route.fulfill({ json: [{ id: 1 }] }));
|
|
1002
|
-
* await page.route(/\.(png|jpg)$/, (route) => route.abort());
|
|
1003
|
-
*/
|
|
1004
|
-
public async route(url: UrlPattern, handler: RouteHandler): Promise<void> {
|
|
1005
|
-
this.assertOpen();
|
|
1006
|
-
await (await this.router()).route(url, handler);
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
/** Removes the routes for `url` (only the one with `handler`, when given) */
|
|
1010
|
-
public async unroute(url: UrlPattern, handler?: RouteHandler): Promise<void> {
|
|
1011
|
-
if (!this.routerPromise) return;
|
|
1012
|
-
await (await this.router()).unroute(url, handler);
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
public async unrouteAll(): Promise<void> {
|
|
1016
|
-
if (!this.routerPromise) return;
|
|
1017
|
-
await (await this.router()).unrouteAll();
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
/** Errors thrown by route handlers so far */
|
|
1021
|
-
public routeErrors(): readonly Error[] {
|
|
1022
|
-
return this.loadedRouter?.errors ?? [];
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
/**
|
|
1026
|
-
* Starts collecting the page's console output and uncaught exceptions. The browser does this when it opens a page so
|
|
1027
|
-
* early output is kept; `page.on("console" | "pageerror")` starts it when needed.
|
|
1028
|
-
*/
|
|
1029
|
-
public async startLogging(): Promise<void> {
|
|
1030
|
-
this.pageLogs ??= (async () => {
|
|
1031
|
-
const logs = await PageLogs.start(this.biDiConnector, await this.tree(), this.id);
|
|
1032
|
-
logs.on("console", (message) => this.pageEvents.emit("console", message));
|
|
1033
|
-
logs.on("pageerror", (error) => this.pageEvents.emit("pageerror", error));
|
|
1034
|
-
this.loadedLogs = logs;
|
|
1035
|
-
return logs;
|
|
1036
|
-
})().catch((err) => {
|
|
1037
|
-
this.pageLogs = undefined;
|
|
1038
|
-
throw err;
|
|
1039
|
-
});
|
|
1040
|
-
await this.pageLogs;
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
/** The newest console and error entries of this page (empty until logging started), oldest first */
|
|
1044
|
-
public getLogs(): readonly LogRecord[] {
|
|
1045
|
-
return this.loadedLogs?.entries ?? [];
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
/** Uncaught exceptions of the page since logging started or `clearLogs()` */
|
|
1049
|
-
public pageErrors(): readonly PageError[] {
|
|
1050
|
-
return this.loadedLogs?.errors ?? [];
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
/** How many entries fell out of the page's log buffer */
|
|
1054
|
-
public get logsDropped(): number {
|
|
1055
|
-
return this.loadedLogs?.dropped ?? 0;
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
public clearLogs(): void {
|
|
1059
|
-
this.loadedLogs?.clear();
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
/** Resolves once everything the page logged before the call has arrived: events and replies share one ordered connection */
|
|
1063
|
-
public async syncLogs(): Promise<void> {
|
|
1064
|
-
await this.biDiConnector.send("session.status", {});
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
/** Tells the runner not to fail this test on uncaught page exceptions (config `logs.failOnPageError`) */
|
|
1068
|
-
public allowPageErrors(): void {
|
|
1069
|
-
this.errorsAllowed = true;
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
public get pageErrorsAllowed(): boolean {
|
|
1073
|
-
return this.errorsAllowed;
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
/** Starts watching for dialogs; the browser does this when it opens a page, `page.on("dialog")` when needed */
|
|
1077
|
-
public async startDialogHandling(): Promise<void> {
|
|
1078
|
-
this.dialogHandling ??= (async () => {
|
|
1079
|
-
await this.tree();
|
|
1080
|
-
this.biDiConnector.onEvent("browsingContext.userPromptOpened", this.onPromptOpened);
|
|
1081
|
-
this.biDiConnector.onEvent("browsingContext.userPromptClosed", this.onPromptClosed);
|
|
1082
|
-
try {
|
|
1083
|
-
return await this.biDiConnector.subscribe([
|
|
1084
|
-
"browsingContext.userPromptOpened",
|
|
1085
|
-
"browsingContext.userPromptClosed",
|
|
1086
|
-
]);
|
|
1087
|
-
} catch (err) {
|
|
1088
|
-
this.biDiConnector.offEvent("browsingContext.userPromptOpened", this.onPromptOpened);
|
|
1089
|
-
this.biDiConnector.offEvent("browsingContext.userPromptClosed", this.onPromptClosed);
|
|
1090
|
-
this.dialogHandling = undefined;
|
|
1091
|
-
throw err;
|
|
1092
|
-
}
|
|
1093
|
-
})();
|
|
1094
|
-
await this.dialogHandling;
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
private ownsContext(context: string): boolean {
|
|
1098
|
-
return this.loadedTree?.isWithin(context, this.id) ?? context === this.id;
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
private onPromptOpened = (params: UserPromptOpenedParameters) => {
|
|
1102
|
-
if (!this.ownsContext(params.context)) return;
|
|
1103
|
-
this.dispatchDialog(new Dialog(this.biDiConnector, this.id, params), params).catch((err) =>
|
|
1104
|
-
logger.error("Handling a dialog failed", { err }),
|
|
1105
|
-
);
|
|
1106
|
-
};
|
|
1107
|
-
|
|
1108
|
-
private onPromptClosed = (params: UserPromptClosedParameters) => {
|
|
1109
|
-
if (!this.ownsContext(params.context)) return;
|
|
1110
|
-
const dialog = this.openDialogs.get(params.context);
|
|
1111
|
-
this.openDialogs.delete(params.context);
|
|
1112
|
-
dialog?.markClosed({
|
|
1113
|
-
accepted: params.accepted,
|
|
1114
|
-
...(params.userText !== undefined && { userText: params.userText }),
|
|
1115
|
-
});
|
|
1116
|
-
};
|
|
1117
|
-
|
|
1118
|
-
/** Lets the listeners answer; a dialog still open afterwards gets the default answer, so nothing hangs */
|
|
1119
|
-
private async dispatchDialog(dialog: Dialog, params: UserPromptOpenedParameters) {
|
|
1120
|
-
this.openDialogs.set(params.context, dialog);
|
|
1121
|
-
// rawListeners: the wrappers that make a `once` listener remove itself when called
|
|
1122
|
-
const listeners = this.pageEvents.rawListeners("dialog") as Array<(dialog: Dialog) => unknown>;
|
|
1123
|
-
await Promise.all(
|
|
1124
|
-
listeners.map(async (listener) => {
|
|
1125
|
-
try {
|
|
1126
|
-
await listener(dialog);
|
|
1127
|
-
} catch (err) {
|
|
1128
|
-
logger.error("A dialog listener threw", { err });
|
|
1129
|
-
}
|
|
1130
|
-
}),
|
|
1131
|
-
);
|
|
1132
|
-
if (dialog.handled) return;
|
|
1133
|
-
// Staying on a page is not what a test that navigates wants: leave it, dismiss everything else
|
|
1134
|
-
const accept = params.type === "beforeunload";
|
|
1135
|
-
logger.warn(
|
|
1136
|
-
'Dialog "%s" was %s automatically; add page.on("dialog") to handle it',
|
|
1137
|
-
params.message,
|
|
1138
|
-
accept ? "accepted" : "dismissed",
|
|
1139
|
-
);
|
|
1140
|
-
await (accept ? dialog.accept() : dialog.dismiss());
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
/** Captures an image of the given navigable, and returns it as a Base64-encoded string */
|
|
1144
|
-
public async captureScreenshot(
|
|
1145
|
-
clip?: BiDiCommands["browsingContext.captureScreenshot"]["params"]["clip"],
|
|
1146
|
-
format?: BiDiCommands["browsingContext.captureScreenshot"]["params"]["format"],
|
|
1147
|
-
origin?: BiDiCommands["browsingContext.captureScreenshot"]["params"]["origin"],
|
|
1148
|
-
) {
|
|
1149
|
-
const screenshot = await this.biDiConnector.send(
|
|
1150
|
-
"browsingContext.captureScreenshot",
|
|
1151
|
-
{
|
|
1152
|
-
context: this.id,
|
|
1153
|
-
clip,
|
|
1154
|
-
format,
|
|
1155
|
-
origin,
|
|
1156
|
-
},
|
|
1157
|
-
);
|
|
1158
|
-
// TODO: Store screenshot according to config specs
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
/** Wraps an init script as one function declaration; the argument is embedded as JSON because preload scripts take no arguments */
|
|
1163
|
-
function initScriptSource(script: string | ((arg?: any) => void), arg: unknown): string {
|
|
1164
|
-
if (typeof script === "string") {
|
|
1165
|
-
if (arg !== undefined) {
|
|
1166
|
-
throw new TypeError("addInitScript(): a string script does not take an argument; pass a function instead");
|
|
1167
|
-
}
|
|
1168
|
-
return `() => {\n${script}\n}`;
|
|
1169
|
-
}
|
|
1170
|
-
const declaration = toFunctionDeclaration(script);
|
|
1171
|
-
const argument = arg === undefined ? "undefined" : JSON.stringify(arg);
|
|
1172
|
-
if (argument === undefined) {
|
|
1173
|
-
throw new TypeError("addInitScript(): the argument must be JSON-serializable");
|
|
1174
|
-
}
|
|
1175
|
-
return `() => (${declaration})(${argument})`;
|
|
1176
|
-
}
|