effect-browser 0.2.0-beta.9 → 0.3.0-beta.5
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 +705 -1184
- package/dist/Browser-CM65Uo7f.mjs +437 -0
- package/dist/Browser-CM65Uo7f.mjs.map +1 -0
- package/dist/Browser.d.mts +115 -3
- package/dist/Browser.mjs +2 -22
- package/dist/BrowserError-DXXIA6Rk.d.mts +135 -0
- package/dist/BrowserError.d.mts +2 -0
- package/dist/BrowserError.mjs +202 -0
- package/dist/BrowserError.mjs.map +1 -0
- package/dist/BrowserEvent-DzdNdvoh.d.mts +277 -0
- package/dist/BrowserEvent.d.mts +2 -0
- package/dist/BrowserEvent.mjs +295 -0
- package/dist/BrowserEvent.mjs.map +1 -0
- package/dist/Cdp.d.mts +32 -0
- package/dist/Cdp.mjs +61 -0
- package/dist/Cdp.mjs.map +1 -0
- package/dist/Change-DNvztNLu.mjs +138 -0
- package/dist/Change-DNvztNLu.mjs.map +1 -0
- package/dist/Change-DVP2IJWm.d.mts +71 -0
- package/dist/Change.d.mts +2 -0
- package/dist/Change.mjs +2 -0
- package/dist/Chromium.d.mts +29 -81
- package/dist/Chromium.mjs +57 -392
- package/dist/Chromium.mjs.map +1 -1
- package/dist/Expect.d.mts +62 -0
- package/dist/Expect.mjs +114 -0
- package/dist/Expect.mjs.map +1 -0
- package/dist/Frame-BERmf_gM.d.mts +117 -0
- package/dist/Frame.d.mts +2 -0
- package/dist/Frame.mjs +123 -0
- package/dist/Frame.mjs.map +1 -0
- package/dist/Moment.d.mts +2 -0
- package/dist/Moment.mjs +288 -0
- package/dist/Moment.mjs.map +1 -0
- package/dist/Motion.d.mts +40 -0
- package/dist/Motion.mjs +122 -0
- package/dist/Motion.mjs.map +1 -0
- package/dist/Page-D4281dIi.mjs +618 -0
- package/dist/Page-D4281dIi.mjs.map +1 -0
- package/dist/Page-DYTdVhYa.d.mts +593 -0
- package/dist/Page.d.mts +2 -0
- package/dist/Page.mjs +2 -0
- package/dist/Plan-CNNOU1Tx.d.mts +112 -0
- package/dist/Plan-Dtrb-8im.mjs +311 -0
- package/dist/Plan-Dtrb-8im.mjs.map +1 -0
- package/dist/Plan.d.mts +2 -3
- package/dist/Plan.mjs +2 -2
- package/dist/Policy.d.mts +76 -0
- package/dist/Policy.mjs +237 -0
- package/dist/Policy.mjs.map +1 -0
- package/dist/Presentation.d.mts +48 -0
- package/dist/Presentation.mjs +117 -0
- package/dist/Presentation.mjs.map +1 -0
- package/dist/Snapshot-B34hZ4Jl.d.mts +68 -0
- package/dist/Snapshot.d.mts +2 -0
- package/dist/Snapshot.mjs +79 -0
- package/dist/Snapshot.mjs.map +1 -0
- package/dist/Stage.d.mts +54 -0
- package/dist/Stage.mjs +133 -0
- package/dist/Stage.mjs.map +1 -0
- package/dist/Supervisor-BtEG93gR.mjs +420 -0
- package/dist/Supervisor-BtEG93gR.mjs.map +1 -0
- package/dist/Supervisor-Im6-wbg5.d.mts +230 -0
- package/dist/Supervisor.d.mts +2 -0
- package/dist/Supervisor.mjs +2 -0
- package/dist/context-DAYjATgC.mjs +137 -0
- package/dist/context-DAYjATgC.mjs.map +1 -0
- package/dist/index.d.mts +17 -14
- package/dist/index.mjs +18 -13
- package/dist/match.inpage-xOe9LNO0.mjs +48 -0
- package/dist/match.inpage-xOe9LNO0.mjs.map +1 -0
- package/dist/page-Banfm_zc.mjs +5097 -0
- package/dist/page-Banfm_zc.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/url-CcL0K7S0.mjs +262 -0
- package/dist/url-CcL0K7S0.mjs.map +1 -0
- package/package.json +96 -61
- package/src/Browser.ts +625 -0
- package/src/BrowserError.ts +225 -0
- package/src/BrowserEvent.ts +294 -0
- package/src/Cdp.ts +101 -0
- package/src/Change.ts +88 -0
- package/src/Chromium.ts +102 -0
- package/src/Expect.ts +180 -0
- package/src/Frame.ts +130 -0
- package/src/Moment.ts +479 -0
- package/src/Motion.ts +166 -0
- package/src/Page.ts +520 -0
- package/src/Plan.ts +350 -0
- package/src/Policy.ts +325 -0
- package/src/Presentation.ts +195 -0
- package/src/Snapshot.ts +89 -0
- package/src/Stage.ts +223 -0
- package/src/Supervisor.ts +353 -0
- package/src/index.ts +20 -0
- package/src/internal/input/actions.ts +607 -0
- package/src/internal/input/dispatch.ts +161 -0
- package/src/internal/input/edit.inpage.ts +162 -0
- package/src/internal/input/evidence.inpage.ts +164 -0
- package/src/internal/input/guard.inpage.ts +605 -0
- package/src/internal/input/guard.ts +183 -0
- package/src/internal/input/keyboard.ts +112 -0
- package/src/internal/input/keys.ts +127 -0
- package/src/internal/input/perform.ts +322 -0
- package/src/internal/input/pointer.ts +153 -0
- package/src/internal/input/replies.ts +159 -0
- package/src/internal/input/settle.ts +50 -0
- package/src/internal/input/style.ts +186 -0
- package/src/internal/input/targets.inpage.ts +293 -0
- package/src/internal/input/targets.ts +148 -0
- package/src/internal/page/bridge.ts +386 -0
- package/src/internal/page/context.ts +302 -0
- package/src/internal/page/lane.ts +301 -0
- package/src/internal/page/navigation.ts +183 -0
- package/src/internal/page/openings.ts +87 -0
- package/src/internal/page/page.ts +137 -0
- package/src/internal/page/url.ts +100 -0
- package/src/internal/page/viewport.ts +43 -0
- package/src/internal/pictures/capture.ts +778 -0
- package/src/internal/pictures/clock.ts +141 -0
- package/src/internal/pictures/pictures.ts +520 -0
- package/src/internal/pictures/transport.ts +242 -0
- package/src/internal/reading/choose.ts +100 -0
- package/src/internal/reading/context.inpage.ts +201 -0
- package/src/internal/reading/controls.inpage.ts +158 -0
- package/src/internal/reading/expect.inpage.ts +70 -0
- package/src/internal/reading/match.inpage.ts +92 -0
- package/src/internal/reading/names.inpage.ts +306 -0
- package/src/internal/reading/outline.inpage.ts +260 -0
- package/src/internal/reading/reading.ts +223 -0
- package/src/internal/reading/ready.inpage.ts +154 -0
- package/src/internal/reading/ready.ts +105 -0
- package/src/internal/reading/subjects.inpage.ts +188 -0
- package/src/internal/reading/text.inpage.ts +135 -0
- package/src/internal/reading/walk.inpage.ts +220 -0
- package/src/internal/supervisor/lifecycle.ts +265 -0
- package/src/internal/timeline/changes.inpage.ts +216 -0
- package/src/internal/timeline/changes.ts +100 -0
- package/src/internal/timeline/events.ts +93 -0
- package/src/internal/timeline/fold.inpage.ts +123 -0
- package/src/internal/timeline/history.inpage.ts +229 -0
- package/src/internal/timeline/marks.inpage.ts +207 -0
- package/src/internal/timeline/record.inpage.ts +133 -0
- package/src/internal/timeline/sight.inpage.ts +122 -0
- package/src/internal/timeline/window.ts +96 -0
- package/src/internal/timeline/words.ts +65 -0
- package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
- package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
- package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
- package/dist/Bootstrap.d.mts +0 -3
- package/dist/Bootstrap.mjs +0 -2
- package/dist/Browser-BF9_sORP.d.mts +0 -388
- package/dist/Browser.mjs.map +0 -1
- package/dist/BrowserData-CPIIIHol.d.mts +0 -585
- package/dist/BrowserData.d.mts +0 -3
- package/dist/BrowserData.mjs +0 -695
- package/dist/BrowserData.mjs.map +0 -1
- package/dist/BrowserRuntime-B6nT1G4X.d.mts +0 -148
- package/dist/BrowserRuntime-C1r2aSRK.mjs +0 -6820
- package/dist/BrowserRuntime-C1r2aSRK.mjs.map +0 -1
- package/dist/BrowserRuntime.d.mts +0 -3
- package/dist/BrowserRuntime.mjs +0 -3
- package/dist/Capture.d.mts +0 -56
- package/dist/Capture.mjs +0 -678
- package/dist/Capture.mjs.map +0 -1
- package/dist/CaptureData-CYriZHX1.mjs +0 -201
- package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
- package/dist/CaptureData.d.mts +0 -219
- package/dist/CaptureData.mjs +0 -2
- package/dist/Errors.d.mts +0 -158
- package/dist/Errors.mjs +0 -207
- package/dist/Errors.mjs.map +0 -1
- package/dist/Images-yCG9BmRU.mjs +0 -76
- package/dist/Images-yCG9BmRU.mjs.map +0 -1
- package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
- package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
- package/dist/PageControl.d.mts +0 -15
- package/dist/PageControl.mjs +0 -34
- package/dist/PageControl.mjs.map +0 -1
- package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
- package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
- package/dist/Plan-Dyxu2q8C.mjs +0 -543
- package/dist/Plan-Dyxu2q8C.mjs.map +0 -1
- package/dist/PlanData-B668RxbQ.d.mts +0 -4831
- package/dist/PlanData.d.mts +0 -3
- package/dist/PlanData.mjs +0 -268
- package/dist/PlanData.mjs.map +0 -1
- package/dist/PlanInput-BJcPPncs.mjs +0 -75
- package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
- package/dist/PublicSession-DCSMMqOj.mjs +0 -5602
- package/dist/PublicSession-DCSMMqOj.mjs.map +0 -1
- package/dist/Testing.d.mts +0 -479
- package/dist/Testing.mjs +0 -2147
- package/dist/Testing.mjs.map +0 -1
- package/dist/Timeline-fV22Xk-S.d.mts +0 -29
- package/dist/Timeline.d.mts +0 -4
- package/dist/Timeline.mjs +0 -20
- package/dist/Timeline.mjs.map +0 -1
- package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
- package/dist/TimelineData.d.mts +0 -3
- package/dist/TimelineData.mjs +0 -482
- package/dist/TimelineData.mjs.map +0 -1
package/src/Change.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What visibly changed on a page over a window, element by element: text that changed, appeared,
|
|
3
|
+
* disappeared or came and went, a field's value, and the document's title. A change says what was
|
|
4
|
+
* shown at the window's start and at its end, how often it changed between, where it is on the
|
|
5
|
+
* page, and the input it followed, where it was that input's doing.
|
|
6
|
+
*
|
|
7
|
+
* `Page.changes` reads it. A page records once something reads its changes: from then, and from the
|
|
8
|
+
* start of each later document once it is parsed, until nobody has read it for two minutes. A page
|
|
9
|
+
* nobody reads records nothing. The record keeps 256 elements, each with its last 32 changes, for a
|
|
10
|
+
* minute; an element that keeps changing gives way before news, and what gives way is counted in
|
|
11
|
+
* `dropped`, with `from` moved past it. A change is told when it was in view as the page rendered
|
|
12
|
+
* it, its style showing it, so one scrolled away later is still told, and something removed or
|
|
13
|
+
* hidden only if it was in view before. What the record cannot see: pictures, a canvas, frames,
|
|
14
|
+
* shadow roots and SVG; a value a script sets; and a class that reveals an element the record has
|
|
15
|
+
* not seen before, such as a toast already on the page, though its later changes are seen.
|
|
16
|
+
*
|
|
17
|
+
* @since 0.3.0
|
|
18
|
+
*/
|
|
19
|
+
import { Schema } from "effect";
|
|
20
|
+
|
|
21
|
+
import { Subject } from "./BrowserEvent.ts";
|
|
22
|
+
import * as Words from "./internal/timeline/words.ts";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `text` changed; `appeared` and `disappeared`, whether added or removed or shown or hidden;
|
|
26
|
+
* `brief`, appeared and gone again by the window's end; `value`, a field's; `title`, the document's.
|
|
27
|
+
*/
|
|
28
|
+
export const Kind = Schema.Literals(["text", "appeared", "disappeared", "brief", "value", "title"]);
|
|
29
|
+
|
|
30
|
+
export type Kind = typeof Kind.Type;
|
|
31
|
+
|
|
32
|
+
export class Change extends Schema.Class<Change>("effect-browser/Change")({
|
|
33
|
+
kind: Kind,
|
|
34
|
+
/**
|
|
35
|
+
* What changed, as the page names it now, or where it was for what went. Its own words are what
|
|
36
|
+
* changed, so its name is empty unless something else names it, as a field's label does.
|
|
37
|
+
*/
|
|
38
|
+
subject: Subject,
|
|
39
|
+
/** When it first changed in the window, in host monotonic milliseconds on the browser's clock. */
|
|
40
|
+
startedAt: Schema.Finite,
|
|
41
|
+
/** When it last changed in the window. */
|
|
42
|
+
at: Schema.Finite,
|
|
43
|
+
/**
|
|
44
|
+
* What it showed at the window's start; absent for what appeared and where the record let that go.
|
|
45
|
+
* What disappeared says what it showed as it went. A field's value reads `••••` unless unmasked.
|
|
46
|
+
*/
|
|
47
|
+
before: Schema.optional(Schema.String),
|
|
48
|
+
/** What it showed at the window's end; absent for what disappeared. What came and went says what it showed. */
|
|
49
|
+
after: Schema.optional(Schema.String),
|
|
50
|
+
/** How many times what it showed changed in the window. */
|
|
51
|
+
count: Schema.Int.check(Schema.isGreaterThan(0)),
|
|
52
|
+
/** For a number that changed more than once, its lowest and highest, as shown. */
|
|
53
|
+
lowest: Schema.optional(Schema.String),
|
|
54
|
+
highest: Schema.optional(Schema.String),
|
|
55
|
+
/** When it last changed before the window, as far back as the record keeps: in flux, not news. */
|
|
56
|
+
earlier: Schema.optional(Schema.Finite),
|
|
57
|
+
/**
|
|
58
|
+
* When the trusted input it followed reached the page, where it was that input's doing. It was not
|
|
59
|
+
* in flux: in the 10 s before the input it had not changed, and nothing had come into or left what
|
|
60
|
+
* it sits in, so a ticker's tick or a feed's line is not. Then it changed within 3 s inside what
|
|
61
|
+
* the input acted on or its row, form, dialog or controlled element; or, elsewhere, within 500 ms
|
|
62
|
+
* of an input on a page where nothing had changed in the 2 s before, as a menu a portal puts at
|
|
63
|
+
* the end of the page does. What holds the input's target, as a menu holds its item, does not
|
|
64
|
+
* count against it. The page's `Action` that sent it holds that time.
|
|
65
|
+
*/
|
|
66
|
+
cause: Schema.optional(Schema.Finite),
|
|
67
|
+
}) {}
|
|
68
|
+
|
|
69
|
+
export class Changes extends Schema.Class<Changes>("effect-browser/Changes")({
|
|
70
|
+
/** The page's document these changes belong to, as `Navigated` counts them. */
|
|
71
|
+
document: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
72
|
+
/** Where the record is whole: before it, changes may be missing. */
|
|
73
|
+
from: Schema.Finite,
|
|
74
|
+
/** Where the window ends: its `until`, or when the page was read. */
|
|
75
|
+
until: Schema.Finite,
|
|
76
|
+
/**
|
|
77
|
+
* Where the window ends on the page's own clock. Give these changes as the next read's `since` and
|
|
78
|
+
* the two windows meet exactly, whatever the clock mapping does meanwhile.
|
|
79
|
+
*/
|
|
80
|
+
cursor: Schema.Finite,
|
|
81
|
+
/** Changes in the window the record let go, at least; 0 when it kept them all. */
|
|
82
|
+
dropped: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
83
|
+
/** Oldest first, by when each began to change. */
|
|
84
|
+
changes: Schema.Array(Change),
|
|
85
|
+
}) {}
|
|
86
|
+
|
|
87
|
+
/** A change in words for a model, such as `"$61,240" became "$62,010" (row "Bitcoin", column "Price")`. */
|
|
88
|
+
export const describe = (change: Change): string => Words.describe(change);
|
package/src/Chromium.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A local Chromium, launched for the scope and closed with it.
|
|
3
|
+
*
|
|
4
|
+
* It uses the Chromium build that `playwright-core` installs
|
|
5
|
+
* (`npx playwright-core install chromium`), unless `executablePath` names another.
|
|
6
|
+
*
|
|
7
|
+
* Signals are the program's to handle: run it with `NodeRuntime.runMain`, which interrupts it on
|
|
8
|
+
* SIGINT and SIGTERM, so the browser closes with its scope.
|
|
9
|
+
*
|
|
10
|
+
* @since 0.3.0
|
|
11
|
+
*/
|
|
12
|
+
import { Effect, Layer } from "effect";
|
|
13
|
+
import { chromium } from "playwright-core";
|
|
14
|
+
|
|
15
|
+
import * as Browser from "./Browser.ts";
|
|
16
|
+
import { type BrowserError, Failed } from "./BrowserError.ts";
|
|
17
|
+
import { reasonOf, undispatched } from "./internal/page/context.ts";
|
|
18
|
+
|
|
19
|
+
export interface Options extends Browser.Options {
|
|
20
|
+
/** Defaults to true. */
|
|
21
|
+
readonly headless?: boolean | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* Run Chromium's sandbox. Defaults to false, as in Playwright, so an exploit in a page's renderer
|
|
24
|
+
* runs with this process's privileges. Hosts that don't allow unprivileged user namespaces, such
|
|
25
|
+
* as many containers and Ubuntu 23.10 or later by default, can't start the sandbox; opening then
|
|
26
|
+
* fails and says so.
|
|
27
|
+
*/
|
|
28
|
+
readonly sandbox?: boolean | undefined;
|
|
29
|
+
/** Defaults to 1280x720 CSS pixels at a device scale factor of 1. */
|
|
30
|
+
readonly viewport?: { readonly width: number; readonly height: number } | undefined;
|
|
31
|
+
readonly executablePath?: string | undefined;
|
|
32
|
+
readonly args?: ReadonlyArray<string> | undefined;
|
|
33
|
+
readonly userAgent?: string | undefined;
|
|
34
|
+
readonly locale?: string | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Playwright's own default, stated so that a launch timeout reports the bound it exceeded.
|
|
38
|
+
const launchTimeoutMillis = 180_000;
|
|
39
|
+
|
|
40
|
+
const failed = (operation: string, timeoutMillis?: number) => (cause: unknown) =>
|
|
41
|
+
undispatched(operation, reasonOf(cause, timeoutMillis));
|
|
42
|
+
|
|
43
|
+
// Playwright puts this in place of Chromium's startup log when the sandbox could not start, in an
|
|
44
|
+
// error that otherwise reads as a closed browser.
|
|
45
|
+
const sandboxFailed = /Chromium sandboxing failed|No usable sandbox/;
|
|
46
|
+
|
|
47
|
+
const launchFailed = (cause: unknown) =>
|
|
48
|
+
sandboxFailed.test(String(cause))
|
|
49
|
+
? undispatched(
|
|
50
|
+
"launch",
|
|
51
|
+
new Failed({
|
|
52
|
+
detail:
|
|
53
|
+
"Chromium's sandbox could not start on this host: it needs unprivileged user namespaces, and a user other than root",
|
|
54
|
+
}),
|
|
55
|
+
)
|
|
56
|
+
: failed("launch", launchTimeoutMillis)(cause);
|
|
57
|
+
|
|
58
|
+
/** Launch Chromium and open a `Browser` over a fresh context. */
|
|
59
|
+
export const open = Effect.fn("Chromium.open")(function* (options: Options = {}) {
|
|
60
|
+
const launched = yield* Effect.acquireRelease(
|
|
61
|
+
Effect.tryPromise({
|
|
62
|
+
try: () =>
|
|
63
|
+
chromium.launch({
|
|
64
|
+
headless: options.headless ?? true,
|
|
65
|
+
chromiumSandbox: options.sandbox ?? false,
|
|
66
|
+
timeout: launchTimeoutMillis,
|
|
67
|
+
// The program owns its signals. Playwright's handlers close every browser on any of
|
|
68
|
+
// these, and on SIGINT then exit the process, so no finalizer would run.
|
|
69
|
+
handleSIGINT: false,
|
|
70
|
+
handleSIGTERM: false,
|
|
71
|
+
handleSIGHUP: false,
|
|
72
|
+
...(options.executablePath === undefined
|
|
73
|
+
? {}
|
|
74
|
+
: { executablePath: options.executablePath }),
|
|
75
|
+
...(options.args === undefined ? {} : { args: [...options.args] }),
|
|
76
|
+
}),
|
|
77
|
+
catch: launchFailed,
|
|
78
|
+
}).pipe(Effect.withSpan("Chromium.launch", {}, { captureStackTrace: false })),
|
|
79
|
+
(browser) => Effect.tryPromise(() => browser.close()).pipe(Effect.ignore),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const context = yield* Effect.tryPromise({
|
|
83
|
+
try: () =>
|
|
84
|
+
launched.newContext({
|
|
85
|
+
viewport: options.viewport ?? { width: 1280, height: 720 },
|
|
86
|
+
deviceScaleFactor: 1,
|
|
87
|
+
...(options.userAgent === undefined ? {} : { userAgent: options.userAgent }),
|
|
88
|
+
...(options.locale === undefined ? {} : { locale: options.locale }),
|
|
89
|
+
}),
|
|
90
|
+
catch: failed("launch"),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return yield* Browser.make(
|
|
94
|
+
context,
|
|
95
|
+
{ id: `chromium-${launched.version()}`, provider: "chromium" },
|
|
96
|
+
options,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/** A `Browser` Layer that launches Chromium when built and closes it when released. */
|
|
101
|
+
export const layer = (options: Options = {}): Layer.Layer<Browser.Browser, BrowserError> =>
|
|
102
|
+
Layer.effect(Browser.Browser, open(options));
|
package/src/Expect.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expectations: what an action is for, checked on the page, so that an action whose outcome is
|
|
3
|
+
* uncertain is sent again only where it did not take effect. An action that failed after its input
|
|
4
|
+
* went, a `Timeout` or a `Closed` with `dispatched`, may have done what it was for; repeating it
|
|
5
|
+
* blindly may do it twice, such as placing an order twice.
|
|
6
|
+
*
|
|
7
|
+
* An attempt takes its expectation's baseline from the page once, as it begins. Each `run` then
|
|
8
|
+
* checks first: where the expectation holds already, it sends nothing and is `AlreadyDone`.
|
|
9
|
+
* Otherwise it acts, and waits for the expectation: `Done`, or `NotDone` when it did not come in
|
|
10
|
+
* time. An action that fails after its input went is checked too, `Done` if its effect came, and
|
|
11
|
+
* otherwise fails as it did, so a later `run` of the same attempt checks before it acts again,
|
|
12
|
+
* on the same page or on the one a reconnect finds by its id.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const placing = yield* Expect.attempt(page, Expect.appeared({ text: "Order placed" }))
|
|
16
|
+
* const outcome = yield* placing.run(page, (page) => page.click("e12"))
|
|
17
|
+
* // After a Timeout that left `dispatched`, the same call clicks again only if no order shows.
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* A query is `Page.find`'s, over the whole document unless it says otherwise, and each check is one
|
|
21
|
+
* call to the page, a wait included; `navigated` reads the page's address and document, at no
|
|
22
|
+
* call. What `changed` compares stays in the page: text, field values and states, focus aside.
|
|
23
|
+
*
|
|
24
|
+
* @since 0.3.0
|
|
25
|
+
*/
|
|
26
|
+
import { Cause, Duration, Effect, Exit, Option, Schedule } from "effect";
|
|
27
|
+
|
|
28
|
+
import { type BrowserError, InvalidRequest } from "./BrowserError.ts";
|
|
29
|
+
import { undispatched } from "./internal/page/context.ts";
|
|
30
|
+
import { type Internals, internalsOf } from "./internal/page/page.ts";
|
|
31
|
+
import type { FindQuery, Page } from "./Page.ts";
|
|
32
|
+
|
|
33
|
+
/** What an action is for, as the page can tell it. */
|
|
34
|
+
export type Expectation =
|
|
35
|
+
| { readonly _tag: "Appeared"; readonly query: FindQuery }
|
|
36
|
+
| { readonly _tag: "Gone"; readonly query: FindQuery }
|
|
37
|
+
| { readonly _tag: "Changed"; readonly query: FindQuery }
|
|
38
|
+
| { readonly _tag: "Navigated"; readonly url: string | RegExp | undefined };
|
|
39
|
+
|
|
40
|
+
/** Something the query finds. */
|
|
41
|
+
export const appeared = (query: FindQuery): Expectation => ({ _tag: "Appeared", query });
|
|
42
|
+
|
|
43
|
+
/** Nothing the query finds. */
|
|
44
|
+
export const gone = (query: FindQuery): Expectation => ({ _tag: "Gone", query });
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What the query finds shows something other than it did as the attempt began: other elements, or
|
|
48
|
+
* their text, values or states.
|
|
49
|
+
*/
|
|
50
|
+
export const changed = (query: FindQuery): Expectation => ({ _tag: "Changed", query });
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The page is at `url`, the whole address or a pattern; without one, at an address or in a
|
|
54
|
+
* document other than the attempt's first.
|
|
55
|
+
*/
|
|
56
|
+
export const navigated = (url?: string | RegExp): Expectation => ({ _tag: "Navigated", url });
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `Done`: the action ran, and what it was for came. `AlreadyDone`: it held before the action was
|
|
60
|
+
* sent, so it was not. `NotDone`: the action ran, and it did not come within the attempt's wait.
|
|
61
|
+
*/
|
|
62
|
+
export type Outcome = "Done" | "AlreadyDone" | "NotDone";
|
|
63
|
+
|
|
64
|
+
export interface Options {
|
|
65
|
+
/** How long an action's effect may take to show. Defaults to 5 seconds. */
|
|
66
|
+
readonly wait?: Duration.Input | undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface Attempt {
|
|
70
|
+
readonly expectation: Expectation;
|
|
71
|
+
/** Whether the expectation holds on `page` now. */
|
|
72
|
+
readonly holds: (page: Page) => Effect.Effect<boolean, BrowserError>;
|
|
73
|
+
/**
|
|
74
|
+
* `act` on `page`, unless the expectation holds there already: see the module's comment. A
|
|
75
|
+
* failure before its input went is the action's own, and nothing is checked.
|
|
76
|
+
*/
|
|
77
|
+
readonly run: (
|
|
78
|
+
page: Page,
|
|
79
|
+
act: (page: Page) => Effect.Effect<unknown, BrowserError>,
|
|
80
|
+
) => Effect.Effect<Outcome, BrowserError>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const refused = (detail: string) => undispatched("expect", new InvalidRequest({ detail }));
|
|
84
|
+
|
|
85
|
+
const internalsFor = (page: Page): Effect.Effect<Internals, BrowserError> => {
|
|
86
|
+
const internals = internalsOf(page);
|
|
87
|
+
|
|
88
|
+
return internals === undefined
|
|
89
|
+
? Effect.fail(
|
|
90
|
+
refused("only a page a Browser opened, or a presenter's view of one, can be checked"),
|
|
91
|
+
)
|
|
92
|
+
: Effect.succeed(internals);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const atUrl = (url: string, wanted: string | RegExp) =>
|
|
96
|
+
typeof wanted === "string" ? url === wanted : new RegExp(wanted.source, wanted.flags).test(url);
|
|
97
|
+
|
|
98
|
+
/** Begin an attempt at `expectation` on `page`, which takes its baseline from the page now. */
|
|
99
|
+
export const attempt = Effect.fn("Expect.attempt")(function* (
|
|
100
|
+
page: Page,
|
|
101
|
+
expectation: Expectation,
|
|
102
|
+
options: Options = {},
|
|
103
|
+
) {
|
|
104
|
+
const wait = Duration.fromInput(options.wait ?? Duration.seconds(5));
|
|
105
|
+
|
|
106
|
+
if (Option.isNone(wait) || Duration.isNegative(wait.value))
|
|
107
|
+
return yield* refused("wait must be a duration, not negative");
|
|
108
|
+
const waitMillis = Duration.toMillis(wait.value);
|
|
109
|
+
const first = yield* page.state;
|
|
110
|
+
const internals = yield* internalsFor(page);
|
|
111
|
+
|
|
112
|
+
// What `changed` compares with: the mark of what the query found as the attempt began.
|
|
113
|
+
const from =
|
|
114
|
+
expectation._tag === "Changed"
|
|
115
|
+
? (yield* internals.until(expectation.query, "some", 0)).mark
|
|
116
|
+
: undefined;
|
|
117
|
+
|
|
118
|
+
// Navigated: at the address asked, or, without one, somewhere other than the attempt's first,
|
|
119
|
+
// where a document counts only on the page that counted it, not on one a reconnect found.
|
|
120
|
+
const navigatedOn = (target: Page, url: string | RegExp | undefined) =>
|
|
121
|
+
Effect.map(target.state, (now) =>
|
|
122
|
+
url !== undefined
|
|
123
|
+
? atUrl(now.url, url)
|
|
124
|
+
: now.url !== first.url ||
|
|
125
|
+
(internalsOf(target) === internals && now.document !== first.document),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const holdsWithin = (target: Page, millis: number): Effect.Effect<boolean, BrowserError> => {
|
|
129
|
+
if (expectation._tag === "Navigated") {
|
|
130
|
+
const look = navigatedOn(target, expectation.url);
|
|
131
|
+
|
|
132
|
+
return look.pipe(
|
|
133
|
+
Effect.repeat({
|
|
134
|
+
until: (held) => held,
|
|
135
|
+
schedule: Schedule.spaced(Duration.millis(50)),
|
|
136
|
+
}),
|
|
137
|
+
Effect.timeoutOrElse({ duration: Duration.millis(millis), orElse: () => look }),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const want =
|
|
142
|
+
expectation._tag === "Appeared"
|
|
143
|
+
? "some"
|
|
144
|
+
: expectation._tag === "Gone"
|
|
145
|
+
? "none"
|
|
146
|
+
: { other: from ?? "" };
|
|
147
|
+
|
|
148
|
+
return internalsFor(target).pipe(
|
|
149
|
+
Effect.flatMap((own) => own.until(expectation.query, want, millis)),
|
|
150
|
+
Effect.map(({ met }) => met),
|
|
151
|
+
);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const run = Effect.fn("Expect.run")(
|
|
155
|
+
function* (target: Page, act: (page: Page) => Effect.Effect<unknown, BrowserError>) {
|
|
156
|
+
if (yield* holdsWithin(target, 0)) return "AlreadyDone" as const;
|
|
157
|
+
const acted = yield* Effect.exit(act(target));
|
|
158
|
+
|
|
159
|
+
if (Exit.isSuccess(acted))
|
|
160
|
+
return (yield* holdsWithin(target, waitMillis)) ? ("Done" as const) : ("NotDone" as const);
|
|
161
|
+
const error = Cause.findErrorOption(acted.cause);
|
|
162
|
+
|
|
163
|
+
// A failure before the input went did nothing; one after it may have done what it was for.
|
|
164
|
+
if (Option.isNone(error) || !error.value.dispatched)
|
|
165
|
+
return yield* Effect.failCause(acted.cause);
|
|
166
|
+
const held = yield* Effect.exit(holdsWithin(target, waitMillis));
|
|
167
|
+
|
|
168
|
+
return Exit.isSuccess(held) && held.value
|
|
169
|
+
? ("Done" as const)
|
|
170
|
+
: yield* Effect.failCause(acted.cause);
|
|
171
|
+
},
|
|
172
|
+
Effect.tap((outcome) => Effect.annotateCurrentSpan({ expectation: expectation._tag, outcome })),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
expectation,
|
|
177
|
+
holds: (target) => holdsWithin(target, 0),
|
|
178
|
+
run,
|
|
179
|
+
} satisfies Attempt;
|
|
180
|
+
});
|
package/src/Frame.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pictures of a page: screenshots, and screencast frames stamped with the browser's clock.
|
|
3
|
+
*
|
|
4
|
+
* @since 0.3.0
|
|
5
|
+
*/
|
|
6
|
+
import { Schema } from "effect";
|
|
7
|
+
|
|
8
|
+
/** An encoded picture with its pixel dimensions. Crops also need their viewport origin. */
|
|
9
|
+
export class Image extends Schema.Class<Image>("effect-browser/Image")({
|
|
10
|
+
data: Schema.Uint8Array,
|
|
11
|
+
mediaType: Schema.Literals(["image/jpeg", "image/png"]),
|
|
12
|
+
width: Schema.Finite,
|
|
13
|
+
height: Schema.Finite,
|
|
14
|
+
}) {}
|
|
15
|
+
|
|
16
|
+
const Nonnegative = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0));
|
|
17
|
+
const Count = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
|
|
18
|
+
|
|
19
|
+
/** Native CDP paint time mapped to the owning host clock, with transport uncertainty retained. */
|
|
20
|
+
export class BrowserPaint extends Schema.TaggedClass<BrowserPaint>()("BrowserPaint", {
|
|
21
|
+
timestamp: Schema.Finite,
|
|
22
|
+
hostTime: Schema.Finite,
|
|
23
|
+
uncertaintyMillis: Nonnegative,
|
|
24
|
+
}) {}
|
|
25
|
+
|
|
26
|
+
/** A screenshot's host capture interval; it has no native browser paint timestamp. */
|
|
27
|
+
export class Screenshot extends Schema.TaggedClass<Screenshot>()("Screenshot", {
|
|
28
|
+
hostTime: Schema.Finite,
|
|
29
|
+
uncertaintyMillis: Nonnegative,
|
|
30
|
+
}) {}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* An image and its timing evidence. Paint time and screenshot capture time cannot be confused in
|
|
34
|
+
* serialized data. `receivedAt` records delivery on the owning host's monotonic clock; it does
|
|
35
|
+
* not make an old paint current.
|
|
36
|
+
*/
|
|
37
|
+
export class Frame extends Schema.Class<Frame>("effect-browser/Frame")({
|
|
38
|
+
page: Schema.String,
|
|
39
|
+
/** The page's browser session: its provider's session id, as `Browser.Service.id` gives it. */
|
|
40
|
+
session: Schema.String,
|
|
41
|
+
data: Schema.Uint8Array,
|
|
42
|
+
timing: Schema.Union([BrowserPaint, Screenshot]),
|
|
43
|
+
receivedAt: Schema.Finite,
|
|
44
|
+
width: Schema.Finite,
|
|
45
|
+
height: Schema.Finite,
|
|
46
|
+
/**
|
|
47
|
+
* The page's document committed before the frame arrived, as `Navigated` counts them. Just
|
|
48
|
+
* after a commit a browser can still show the old document's paint, so a frame may show the
|
|
49
|
+
* document before its own, never one after it.
|
|
50
|
+
*/
|
|
51
|
+
document: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
52
|
+
/** The page's address when the frame arrived. */
|
|
53
|
+
url: Schema.String,
|
|
54
|
+
}) {
|
|
55
|
+
get timestamp(): number | undefined {
|
|
56
|
+
return this.timing._tag === "BrowserPaint" ? this.timing.timestamp : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get hostTime(): number {
|
|
60
|
+
return this.timing.hostTime;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get image(): Image {
|
|
64
|
+
return new Image({
|
|
65
|
+
data: this.data,
|
|
66
|
+
mediaType: "image/jpeg",
|
|
67
|
+
width: this.width,
|
|
68
|
+
height: this.height,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A page's capture counts, over its life or a window of the latest minute: what Chromium sent,
|
|
75
|
+
* what readers got, and why the rest went no further. Frames that came late are counted apart from
|
|
76
|
+
* those a reader lost.
|
|
77
|
+
*/
|
|
78
|
+
export class CaptureStats extends Schema.Class<CaptureStats>("effect-browser/CaptureStats")({
|
|
79
|
+
received: Count,
|
|
80
|
+
accepted: Count,
|
|
81
|
+
/**
|
|
82
|
+
* Frames Chromium finished encoding after a newer one, dropped rather than reordered. It encodes
|
|
83
|
+
* several at once, so a busy machine makes more of them; none was a paint readers missed.
|
|
84
|
+
*/
|
|
85
|
+
late: Count,
|
|
86
|
+
missingTimestamp: Count,
|
|
87
|
+
/**
|
|
88
|
+
* Frames dropped for their size. Most are other captures' frames, which a clipped screenshot
|
|
89
|
+
* draws into a screencast. The rest are the page's own frames of a new size, dropped while still
|
|
90
|
+
* unconfirmed: when another size replaced it, when its capture stopped, or beyond the 16 held.
|
|
91
|
+
*/
|
|
92
|
+
foreignSize: Count,
|
|
93
|
+
/**
|
|
94
|
+
* Frames dropped because they may have been painted, or arrived, while the library took a clipped
|
|
95
|
+
* or scaled picture of the page, such as a zoom: Chromium draws those into the screencast too.
|
|
96
|
+
* Where Playwright emulates the viewport, the page's own frames from that time are among them; a
|
|
97
|
+
* crop on the page's own session leaves out only frames of another size than the page's.
|
|
98
|
+
*/
|
|
99
|
+
duringPictures: Count,
|
|
100
|
+
/**
|
|
101
|
+
* Frames a reader missed by falling behind. Each reader counts its own, so with several readers
|
|
102
|
+
* this can exceed the frames accepted.
|
|
103
|
+
*/
|
|
104
|
+
lost: Count,
|
|
105
|
+
/**
|
|
106
|
+
* Acknowledgements of frames sent and not yet answered, now, whatever the window. Chromium sends
|
|
107
|
+
* a frame only while few are unacknowledged, so a backlog means fewer frames.
|
|
108
|
+
*/
|
|
109
|
+
ackBacklog: Count,
|
|
110
|
+
/** Paint-time gaps between consecutive accepted frames. */
|
|
111
|
+
gaps: Schema.Struct({
|
|
112
|
+
count: Count,
|
|
113
|
+
totalMillis: Nonnegative,
|
|
114
|
+
minMillis: Schema.NullOr(Nonnegative),
|
|
115
|
+
maxMillis: Schema.NullOr(Nonnegative),
|
|
116
|
+
lastMillis: Schema.NullOr(Nonnegative),
|
|
117
|
+
}),
|
|
118
|
+
}) {}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Settings for a page's one native capture. Readers that join a running capture share its
|
|
122
|
+
* settings: one without options accepts them, and one whose explicit options differ fails with
|
|
123
|
+
* `InvalidRequest`.
|
|
124
|
+
*/
|
|
125
|
+
export interface ScreencastOptions {
|
|
126
|
+
/** JPEG quality, 0 to 100. Defaults to 80. */
|
|
127
|
+
readonly quality?: number | undefined;
|
|
128
|
+
/** Frames are scaled down to fit this box. Defaults to the viewport in CSS pixels. */
|
|
129
|
+
readonly size?: { readonly width: number; readonly height: number } | undefined;
|
|
130
|
+
}
|