effect-browser 0.2.0-beta.9 → 0.3.0-beta.11
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 +861 -1161
- package/dist/Browser-B4qncxe7.mjs +628 -0
- package/dist/Browser-B4qncxe7.mjs.map +1 -0
- package/dist/Browser.d.mts +129 -3
- package/dist/Browser.mjs +2 -22
- package/dist/BrowserError-Coy7EW1N.d.mts +140 -0
- package/dist/BrowserError.d.mts +2 -0
- package/dist/BrowserError.mjs +211 -0
- package/dist/BrowserError.mjs.map +1 -0
- package/dist/BrowserEvent-BRu2juEz.d.mts +279 -0
- package/dist/BrowserEvent.d.mts +2 -0
- package/dist/BrowserEvent.mjs +297 -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-iMlcuzVD.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-Di2PxyF7.d.mts +119 -0
- package/dist/Frame.d.mts +2 -0
- package/dist/Frame.mjs +125 -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-B5cjaUp5.mjs +618 -0
- package/dist/Page-B5cjaUp5.mjs.map +1 -0
- package/dist/Page-B7X6DQUn.d.mts +595 -0
- package/dist/Page.d.mts +2 -0
- package/dist/Page.mjs +2 -0
- package/dist/Plan-Akcac63g.d.mts +209 -0
- package/dist/Plan-D1wrJVs8.mjs +508 -0
- package/dist/Plan-D1wrJVs8.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 +64 -0
- package/dist/Stage.mjs +237 -0
- package/dist/Stage.mjs.map +1 -0
- package/dist/Supervisor-CYWFK3vM.d.mts +280 -0
- package/dist/Supervisor-TNKNOVzu.mjs +599 -0
- package/dist/Supervisor-TNKNOVzu.mjs.map +1 -0
- package/dist/Supervisor.d.mts +2 -0
- package/dist/Supervisor.mjs +2 -0
- package/dist/context-B6xr8nVi.mjs +261 -0
- package/dist/context-B6xr8nVi.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-BAf__WKX.mjs +5075 -0
- package/dist/page-BAf__WKX.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/url-Cd04Okid.mjs +264 -0
- package/dist/url-Cd04Okid.mjs.map +1 -0
- package/package.json +96 -61
- package/src/Browser.ts +672 -0
- package/src/BrowserError.ts +234 -0
- package/src/BrowserEvent.ts +296 -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 +132 -0
- package/src/Moment.ts +479 -0
- package/src/Motion.ts +166 -0
- package/src/Page.ts +522 -0
- package/src/Plan.ts +663 -0
- package/src/Policy.ts +325 -0
- package/src/Presentation.ts +195 -0
- package/src/Snapshot.ts +89 -0
- package/src/Stage.ts +467 -0
- package/src/Supervisor.ts +599 -0
- package/src/index.ts +20 -0
- package/src/internal/input/actions.ts +610 -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/gate.ts +224 -0
- package/src/internal/input/guard.inpage.ts +605 -0
- package/src/internal/input/guard.ts +184 -0
- package/src/internal/input/keyboard.ts +112 -0
- package/src/internal/input/keys.ts +127 -0
- package/src/internal/input/perform.ts +376 -0
- package/src/internal/input/pointer.ts +153 -0
- package/src/internal/input/replies.ts +195 -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 +391 -0
- package/src/internal/page/context.ts +314 -0
- package/src/internal/page/identity.ts +35 -0
- package/src/internal/page/lane.ts +311 -0
- package/src/internal/page/navigation.ts +183 -0
- package/src/internal/page/openings.ts +87 -0
- package/src/internal/page/page.ts +165 -0
- package/src/internal/page/url.ts +100 -0
- package/src/internal/page/viewport.ts +43 -0
- package/src/internal/pictures/capture.ts +776 -0
- package/src/internal/pictures/clock.ts +141 -0
- package/src/internal/pictures/counts.ts +92 -0
- package/src/internal/pictures/pictures.ts +521 -0
- package/src/internal/pictures/transport.ts +243 -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 +164 -0
- package/src/internal/reading/ready.ts +111 -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 +416 -0
- package/src/internal/timeline/changes.inpage.ts +216 -0
- package/src/internal/timeline/changes.ts +100 -0
- package/src/internal/timeline/events.ts +94 -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
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moving a page between documents: going to an address, back through its history, or reloading.
|
|
3
|
+
* Each move is an action, so it takes the page's turn, goes through the input guard and is
|
|
4
|
+
* recorded, but it sends no input.
|
|
5
|
+
*/
|
|
6
|
+
import { Deferred, Duration, Effect, Schedule } from "effect";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
BrowserError,
|
|
10
|
+
InvalidRequest,
|
|
11
|
+
NavigationFailed,
|
|
12
|
+
NotActionable,
|
|
13
|
+
NotFound,
|
|
14
|
+
} from "../../BrowserError.ts";
|
|
15
|
+
import type { Guard } from "../input/guard.ts";
|
|
16
|
+
import type { InputMarks, Perform } from "../input/perform.ts";
|
|
17
|
+
import { failWith, type PageContext } from "./context.ts";
|
|
18
|
+
import * as Url from "./url.ts";
|
|
19
|
+
|
|
20
|
+
export const make = (
|
|
21
|
+
page: PageContext,
|
|
22
|
+
perform: Perform,
|
|
23
|
+
preparePolicy: Guard["preparePolicy"],
|
|
24
|
+
) => {
|
|
25
|
+
const { playwright, settings, native } = page;
|
|
26
|
+
const { send } = page.protocol;
|
|
27
|
+
|
|
28
|
+
// `address` is where `run` goes, as the action, the guard and a failure report it.
|
|
29
|
+
const navigation = (name: string, run: () => Promise<unknown>, address: string) => {
|
|
30
|
+
const url = Url.redact(address);
|
|
31
|
+
|
|
32
|
+
return perform(
|
|
33
|
+
name,
|
|
34
|
+
{ target: url, input: false },
|
|
35
|
+
settings.navigationTimeout,
|
|
36
|
+
preparePolicy(name, { target: url }, [], { destination: url }),
|
|
37
|
+
(marks) =>
|
|
38
|
+
marks.sent.pipe(
|
|
39
|
+
Effect.andThen(native(name, run)),
|
|
40
|
+
Effect.mapError((error) =>
|
|
41
|
+
error.reason._tag === "Failed" &&
|
|
42
|
+
/net::|NS_ERROR|Cannot navigate/i.test(error.reason.detail)
|
|
43
|
+
? new BrowserError({
|
|
44
|
+
operation: name,
|
|
45
|
+
reason: new NavigationFailed({ url, detail: error.reason.detail }),
|
|
46
|
+
dispatched: true,
|
|
47
|
+
})
|
|
48
|
+
: error,
|
|
49
|
+
),
|
|
50
|
+
Effect.asVoid,
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const history = native("back", () => send("Page.getNavigationHistory"));
|
|
56
|
+
|
|
57
|
+
// While a traversal swaps documents, Chromium can briefly route this session to the outgoing
|
|
58
|
+
// document, which has become inactive (for instance after it entered the back-forward cache),
|
|
59
|
+
// so a read during the traversal is retried until the session reaches the active document.
|
|
60
|
+
const traversingHistory = history.pipe(
|
|
61
|
+
Effect.retry({
|
|
62
|
+
schedule: Schedule.spaced(Duration.millis(50)),
|
|
63
|
+
while: (error) =>
|
|
64
|
+
error.reason._tag === "Failed" &&
|
|
65
|
+
/not attached to an active page/i.test(error.reason.detail),
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// The current entry's title is the one the page last set, and empty where it set none, so no
|
|
70
|
+
// address stands in for it. The browser answers it, whatever the page's script is doing.
|
|
71
|
+
const title = native("title", () => send("Page.getNavigationHistory")).pipe(
|
|
72
|
+
Effect.map(({ entries, currentIndex }) => entries[currentIndex]?.title ?? ""),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const noPrevious = failWith("back", new NotFound({ target: "a previous page in this tab" }));
|
|
76
|
+
|
|
77
|
+
const prepareBack = Effect.gen(function* () {
|
|
78
|
+
const before = yield* history;
|
|
79
|
+
const current = before.entries[before.currentIndex];
|
|
80
|
+
const previous = before.entries[before.currentIndex - 1];
|
|
81
|
+
|
|
82
|
+
if (previous === undefined) return yield* noPrevious;
|
|
83
|
+
const plan = yield* preparePolicy("back", {}, [], { destination: Url.redact(previous.url) });
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
request: plan.request,
|
|
87
|
+
validate: Effect.gen(function* () {
|
|
88
|
+
const approval = yield* plan.validate;
|
|
89
|
+
const latest = yield* history;
|
|
90
|
+
|
|
91
|
+
if (
|
|
92
|
+
latest.currentIndex !== before.currentIndex ||
|
|
93
|
+
latest.entries[latest.currentIndex]?.id !== current?.id ||
|
|
94
|
+
latest.entries[latest.currentIndex - 1]?.id !== previous.id ||
|
|
95
|
+
latest.entries[latest.currentIndex - 1]?.url !== previous.url
|
|
96
|
+
)
|
|
97
|
+
return yield* failWith(
|
|
98
|
+
"back",
|
|
99
|
+
new NotActionable({
|
|
100
|
+
detail: "the navigation history changed while input policy was pending",
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return approval;
|
|
105
|
+
}),
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// One protocol traversal on every path. It has committed once the tab leaves its entry, which
|
|
110
|
+
// also covers entries that only an iframe created, where the main frame never navigates, and
|
|
111
|
+
// entries sharing a URL. A frame navigation then shows Playwright has caught up before the
|
|
112
|
+
// main document's readiness is read.
|
|
113
|
+
const goBack = (marks: InputMarks) =>
|
|
114
|
+
Effect.gen(function* () {
|
|
115
|
+
const before = yield* history;
|
|
116
|
+
const current = before.entries[before.currentIndex];
|
|
117
|
+
const previous = before.entries[before.currentIndex - 1];
|
|
118
|
+
|
|
119
|
+
if (current === undefined || previous === undefined) return yield* noPrevious;
|
|
120
|
+
const navigated = yield* Deferred.make<void>();
|
|
121
|
+
|
|
122
|
+
const onNavigation = () => {
|
|
123
|
+
Deferred.doneUnsafe(navigated, Effect.void);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
yield* Effect.acquireUseRelease(
|
|
127
|
+
Effect.sync(() => playwright.on("framenavigated", onNavigation)),
|
|
128
|
+
() =>
|
|
129
|
+
Effect.gen(function* () {
|
|
130
|
+
yield* marks.sent;
|
|
131
|
+
yield* native("back", () =>
|
|
132
|
+
send("Page.navigateToHistoryEntry", { entryId: previous.id }),
|
|
133
|
+
);
|
|
134
|
+
yield* traversingHistory.pipe(
|
|
135
|
+
Effect.repeat({
|
|
136
|
+
schedule: Schedule.spaced(Duration.millis(50)),
|
|
137
|
+
until: (latest) => latest.entries[latest.currentIndex]?.id !== current.id,
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
yield* Deferred.await(navigated);
|
|
141
|
+
}),
|
|
142
|
+
() => Effect.sync(() => playwright.off("framenavigated", onNavigation)),
|
|
143
|
+
);
|
|
144
|
+
yield* native("back", () => playwright.waitForLoadState("domcontentloaded", { timeout: 0 }));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const goto = (url: string) => {
|
|
148
|
+
const parsed = Url.parse(url);
|
|
149
|
+
|
|
150
|
+
if (
|
|
151
|
+
parsed === null ||
|
|
152
|
+
!["http:", "https:", "about:", "data:", "file:"].includes(parsed.protocol)
|
|
153
|
+
)
|
|
154
|
+
return failWith(
|
|
155
|
+
"navigate",
|
|
156
|
+
new InvalidRequest({ detail: `"${Url.redact(url)}" is not a URL` }),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
return navigation(
|
|
160
|
+
"navigate",
|
|
161
|
+
() => playwright.goto(parsed.href, { waitUntil: "domcontentloaded", timeout: 0 }),
|
|
162
|
+
parsed.href,
|
|
163
|
+
);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const back = perform(
|
|
167
|
+
"back",
|
|
168
|
+
{ target: "back", input: false },
|
|
169
|
+
settings.navigationTimeout,
|
|
170
|
+
prepareBack,
|
|
171
|
+
goBack,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const reload = Effect.suspend(() =>
|
|
175
|
+
navigation(
|
|
176
|
+
"reload",
|
|
177
|
+
() => playwright.reload({ waitUntil: "domcontentloaded", timeout: 0 }),
|
|
178
|
+
playwright.url(),
|
|
179
|
+
),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
return { goto, back, reload, title };
|
|
183
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** A page's input waits for the actual tabs it opened to finish the browser's registration. */
|
|
2
|
+
import { Deferred, Effect, Exit } from "effect";
|
|
3
|
+
import type { CDPSession, Page as PlaywrightPage } from "playwright-core";
|
|
4
|
+
|
|
5
|
+
import { BrowserError, Closed, Failed, type Reason } from "../../BrowserError.ts";
|
|
6
|
+
|
|
7
|
+
/** How the browser's registration of each tab it was told of ended, once it has. */
|
|
8
|
+
export type Registrations = WeakMap<PlaywrightPage, Deferred.Deferred<void, BrowserError>>;
|
|
9
|
+
|
|
10
|
+
export interface Mark {
|
|
11
|
+
windows: number;
|
|
12
|
+
readonly registrations: Array<Effect.Effect<void, BrowserError>>;
|
|
13
|
+
changed: Deferred.Deferred<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const make = Effect.fnUntraced(function* (
|
|
17
|
+
playwright: PlaywrightPage,
|
|
18
|
+
cdp: CDPSession,
|
|
19
|
+
registrations: Registrations,
|
|
20
|
+
closedBy: () => Closed["cause"],
|
|
21
|
+
) {
|
|
22
|
+
const failed = (reason: Reason) =>
|
|
23
|
+
Exit.fail(new BrowserError({ operation: "newPage", reason, dispatched: false }));
|
|
24
|
+
|
|
25
|
+
let active: Mark | undefined;
|
|
26
|
+
const gone = Deferred.makeUnsafe<never, BrowserError>();
|
|
27
|
+
|
|
28
|
+
const windowOpened = () => {
|
|
29
|
+
if (active !== undefined) active.windows += 1;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const popup = (opened: PlaywrightPage) => {
|
|
33
|
+
if (active === undefined) return;
|
|
34
|
+
// Playwright emits the context's page event first, so its owner already has this exact
|
|
35
|
+
// popup's registration. A window notification alone has no target id and cannot name it.
|
|
36
|
+
const registered = registrations.get(opened);
|
|
37
|
+
|
|
38
|
+
active.registrations.push(
|
|
39
|
+
registered === undefined
|
|
40
|
+
? failed(new Failed({ detail: "the popup has no registration" }))
|
|
41
|
+
: Deferred.await(registered),
|
|
42
|
+
);
|
|
43
|
+
Deferred.doneUnsafe(active.changed, Exit.void);
|
|
44
|
+
active.changed = Deferred.makeUnsafe<void>();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const clear = Effect.sync(() => {
|
|
48
|
+
active = undefined;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
yield* Effect.acquireRelease(
|
|
52
|
+
Effect.sync(() => {
|
|
53
|
+
cdp.on("Page.windowOpen", windowOpened);
|
|
54
|
+
playwright.on("popup", popup);
|
|
55
|
+
}),
|
|
56
|
+
() =>
|
|
57
|
+
Effect.sync(() => {
|
|
58
|
+
active = undefined;
|
|
59
|
+
cdp.off("Page.windowOpen", windowOpened);
|
|
60
|
+
playwright.off("popup", popup);
|
|
61
|
+
Deferred.doneUnsafe(gone, failed(new Closed({ cause: closedBy() })));
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
mark: () => {
|
|
67
|
+
const mark: Mark = { windows: 0, registrations: [], changed: Deferred.makeUnsafe<void>() };
|
|
68
|
+
|
|
69
|
+
active = mark;
|
|
70
|
+
|
|
71
|
+
return mark;
|
|
72
|
+
},
|
|
73
|
+
clear,
|
|
74
|
+
settle: (mark: Mark) =>
|
|
75
|
+
Effect.gen(function* () {
|
|
76
|
+
// A blocked window or a tab that closes as it opens may never become a Playwright popup.
|
|
77
|
+
// Discovery and registration both share the input's deadline, so an unresolved opening
|
|
78
|
+
// fails with potentially dispatched input rather than claiming the tab has been tracked.
|
|
79
|
+
while (mark.registrations.length < mark.windows) yield* Deferred.await(mark.changed);
|
|
80
|
+
// A real popup has a known registration; its failure stays a failure, and its wait shares
|
|
81
|
+
// the input's deadline rather than quietly returning before the browser tracks it.
|
|
82
|
+
yield* Effect.forEach(mark.registrations, (registration) => registration);
|
|
83
|
+
}).pipe(Effect.raceFirst(Deferred.await(gone)), Effect.ensuring(clear)),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export type Openings = Effect.Success<ReturnType<typeof make>>;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page implementation behind `Page.Page`. `Browser` constructs pages and owns the state they
|
|
3
|
+
* share (the clock mapping and event publication), so construction stays internal. A page is
|
|
4
|
+
* assembled here from its domains: the script bridge, pictures, reading, its timeline, input and
|
|
5
|
+
* navigation.
|
|
6
|
+
* What a presenter and a stage need of a page, and no caller should, it keeps out of sight.
|
|
7
|
+
*/
|
|
8
|
+
import { type Effect as Eff, Effect, Option } from "effect";
|
|
9
|
+
|
|
10
|
+
import type { BrowserError } from "../../BrowserError.ts";
|
|
11
|
+
import type { Frame } from "../../Frame.ts";
|
|
12
|
+
import { type Page, State } from "../../Page.ts";
|
|
13
|
+
import * as Actions from "../input/actions.ts";
|
|
14
|
+
import type { Controller } from "../pictures/capture.ts";
|
|
15
|
+
import * as Pictures from "../pictures/pictures.ts";
|
|
16
|
+
import * as Reading from "../reading/reading.ts";
|
|
17
|
+
import * as Ready from "../reading/ready.ts";
|
|
18
|
+
import * as Changes from "../timeline/changes.ts";
|
|
19
|
+
import * as Window from "../timeline/window.ts";
|
|
20
|
+
import * as Bridge from "./bridge.ts";
|
|
21
|
+
import * as Context from "./context.ts";
|
|
22
|
+
import * as Navigation from "./navigation.ts";
|
|
23
|
+
import * as Url from "./url.ts";
|
|
24
|
+
import * as Viewport from "./viewport.ts";
|
|
25
|
+
|
|
26
|
+
export interface Internals {
|
|
27
|
+
/** The page's input, in any style. */
|
|
28
|
+
readonly input: Actions.Input;
|
|
29
|
+
/** Drain earlier input and retain a read turn through the effect; the whole wait is bounded. */
|
|
30
|
+
readonly afterInput: <A, E, R>(effect: Eff.Effect<A, E, R>) => Eff.Effect<A, E | BrowserError, R>;
|
|
31
|
+
/** Start capture now, independently of its first frame. The calling scope keeps it running. */
|
|
32
|
+
readonly capture: Controller["open"];
|
|
33
|
+
/** Current capture/document evidence, without waiting for acknowledgement or transport. */
|
|
34
|
+
readonly current: (frame: Frame) => boolean;
|
|
35
|
+
/** A current frame, with a fresh bounded answer from the page and capture sessions. */
|
|
36
|
+
readonly confirm: (frame: Frame) => Eff.Effect<boolean, BrowserError>;
|
|
37
|
+
/** The page's documents so far, counted as `Navigated` counts them. */
|
|
38
|
+
readonly document: () => number;
|
|
39
|
+
/** Wait, in one call, until what a query finds is there, is not, or shows something else. */
|
|
40
|
+
readonly until: Reading.Reading["until"];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A page this library built: it keeps its internals where only this module reaches them. */
|
|
44
|
+
class Built {
|
|
45
|
+
readonly #internals: Internals;
|
|
46
|
+
|
|
47
|
+
constructor(internals: Internals) {
|
|
48
|
+
this.#internals = internals;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static internalsOf(page: Page): Internals | undefined {
|
|
52
|
+
return page instanceof Built ? page.#internals : undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What a page this library built keeps out of sight; nothing for any other `Page`. */
|
|
57
|
+
export const internalsOf = (page: Page) => Built.internalsOf(page);
|
|
58
|
+
|
|
59
|
+
/** `view`, made from `page` with some of its operations replaced, keeping what `page` keeps out of sight. */
|
|
60
|
+
export const viewOf = <A extends Page>(page: Page, view: A): A => {
|
|
61
|
+
const internals = internalsOf(page);
|
|
62
|
+
|
|
63
|
+
return internals === undefined ? view : Object.assign(new Built(internals), view);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const make = Effect.fnUntraced(function* (options: Context.MakeOptions) {
|
|
67
|
+
const page = Context.make(options, yield* Effect.scope);
|
|
68
|
+
const { id, playwright, native, owned, within, lane, span } = page;
|
|
69
|
+
const bridge = yield* Bridge.make(page);
|
|
70
|
+
const viewport = Viewport.make(page, bridge);
|
|
71
|
+
const input = Actions.make(page, bridge, viewport);
|
|
72
|
+
const pictures = yield* Pictures.make(page, bridge, viewport);
|
|
73
|
+
const reading = yield* Reading.make(page, bridge);
|
|
74
|
+
const navigation = Navigation.make(page, input.perform, input.preparePolicy);
|
|
75
|
+
const { capture } = pictures;
|
|
76
|
+
const changes = Changes.make(page, bridge, pictures.estimate);
|
|
77
|
+
const titles = lane.shared<string>("title", true);
|
|
78
|
+
const viewports = lane.shared<Effect.Success<Page["viewport"]>>("viewport", true);
|
|
79
|
+
|
|
80
|
+
// What the page's parts already know, asking it nothing: its documents as its own session saw
|
|
81
|
+
// them, the newest frame, and the viewport's text as last read since the document began.
|
|
82
|
+
const state = Effect.map(capture.latest, (latest) => {
|
|
83
|
+
const { document, url } = bridge.frameTag();
|
|
84
|
+
const committedAt = page.activity.documentAt;
|
|
85
|
+
|
|
86
|
+
return new State({
|
|
87
|
+
page: id,
|
|
88
|
+
at: page.now(),
|
|
89
|
+
url,
|
|
90
|
+
document,
|
|
91
|
+
committedAt: Number.isFinite(committedAt) ? committedAt : undefined,
|
|
92
|
+
load: bridge.loaded(),
|
|
93
|
+
frame: Option.getOrUndefined(latest),
|
|
94
|
+
text: reading.viewedSince(committedAt),
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const current = (frame: Frame) =>
|
|
99
|
+
capture.current(frame) &&
|
|
100
|
+
bridge.frameTag().document === frame.document &&
|
|
101
|
+
frame.hostTime - frame.timing.uncertaintyMillis > page.activity.documentAt;
|
|
102
|
+
|
|
103
|
+
const assembled: Page = {
|
|
104
|
+
id,
|
|
105
|
+
playwright,
|
|
106
|
+
url: Effect.sync(() => Url.redact(playwright.url())),
|
|
107
|
+
title: titles("", navigation.title.pipe(within("title"))).pipe(span("Page.title"), owned),
|
|
108
|
+
goto: navigation.goto,
|
|
109
|
+
back: navigation.back,
|
|
110
|
+
reload: navigation.reload,
|
|
111
|
+
bringToFront: native("bringToFront", () => playwright.bringToFront()),
|
|
112
|
+
// Playwright answers once the page has closed, and at once for one already closed.
|
|
113
|
+
close: native("close", () => playwright.close()),
|
|
114
|
+
snapshot: reading.snapshot,
|
|
115
|
+
screenshot: pictures.screenshot,
|
|
116
|
+
frame: pictures.frame,
|
|
117
|
+
zoom: pictures.zoom,
|
|
118
|
+
viewport: viewports("", viewport.viewportFor("viewport").pipe(within("viewport"))).pipe(
|
|
119
|
+
span("Page.viewport"),
|
|
120
|
+
owned,
|
|
121
|
+
),
|
|
122
|
+
find: reading.find,
|
|
123
|
+
text: reading.text,
|
|
124
|
+
changes,
|
|
125
|
+
window: Window.make(page, changes, capture.recent),
|
|
126
|
+
state,
|
|
127
|
+
click: (target, options) => input.click(target, options),
|
|
128
|
+
hover: (target) => input.hover(target),
|
|
129
|
+
drag: (from, to) => input.drag(from, to),
|
|
130
|
+
type: (text, options) => input.type(text, options),
|
|
131
|
+
press: (keys, options) => input.press(keys, options),
|
|
132
|
+
scroll: (options) => input.scroll(options),
|
|
133
|
+
select: input.select,
|
|
134
|
+
waitFor: reading.waitFor,
|
|
135
|
+
ready: Ready.make(page, bridge, capture),
|
|
136
|
+
screencast: capture.stream,
|
|
137
|
+
captureStats: pictures.captureStats,
|
|
138
|
+
recentFrames: capture.recent,
|
|
139
|
+
recentEvents: options.recentEvents,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const internals: Internals = {
|
|
143
|
+
input,
|
|
144
|
+
afterInput: (effect) =>
|
|
145
|
+
lane
|
|
146
|
+
.read(
|
|
147
|
+
"inputEnded",
|
|
148
|
+
page.settings.navigationTimeout,
|
|
149
|
+
)(Effect.andThen(input.perform.idle, effect))
|
|
150
|
+
.pipe(within("inputEnded", page.settings.navigationTimeout), owned),
|
|
151
|
+
capture: capture.open,
|
|
152
|
+
current,
|
|
153
|
+
confirm: (frame) =>
|
|
154
|
+
native("shown", () => page.protocol.send("Page.getFrameTree")).pipe(
|
|
155
|
+
Effect.flatMap(() => capture.confirm(frame)),
|
|
156
|
+
Effect.map((confirmed) => confirmed && current(frame)),
|
|
157
|
+
within("shown", "2 seconds"),
|
|
158
|
+
owned,
|
|
159
|
+
),
|
|
160
|
+
document: () => bridge.frameTag().document,
|
|
161
|
+
until: reading.until,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return Object.assign(new Built(internals), assembled);
|
|
165
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Addresses: reading one the way a person types it, for `Page.goto` and the browser tools alike,
|
|
3
|
+
* and reporting one without the values that are credentials, by one rule that the page script runs
|
|
4
|
+
* too, for the outline's links.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const scheme = /^[a-z][a-z0-9+.-]*:/i;
|
|
8
|
+
|
|
9
|
+
// `localhost:3000` and `example.com:8080/a` look like a scheme to the URL parser; a port decides.
|
|
10
|
+
const hostAndPort = /^[^\s/?#@:]+:\d+(?:[/?#]|$)/;
|
|
11
|
+
|
|
12
|
+
const loopback = /^(?:localhost|127(?:\.\d+){3}|\[::1\])(?:[:/?#]|$)/i;
|
|
13
|
+
|
|
14
|
+
/** The URL `input` names. Without a scheme it is a web address: HTTPS, or HTTP on loopback. */
|
|
15
|
+
export const parse = (input: string): URL | null => {
|
|
16
|
+
const text = input.trim();
|
|
17
|
+
|
|
18
|
+
if (scheme.test(text) && !hostAndPort.test(text)) return URL.parse(text);
|
|
19
|
+
|
|
20
|
+
return URL.parse(`${loopback.test(text) ? "http" : "https"}://${text}`);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The rule that reports an address without its credentials. The page script runs it too, so it
|
|
25
|
+
* stays self-contained, as `names.inpage.ts` says a page-side part must.
|
|
26
|
+
*/
|
|
27
|
+
export const addresses = () => {
|
|
28
|
+
// What a credential's value reads as, as a secret field's text does (`Page.redacted`).
|
|
29
|
+
const withheld = "••••••••" as const;
|
|
30
|
+
|
|
31
|
+
// A parameter's name, compared in lowercase without separators, says it is a credential: a token,
|
|
32
|
+
// secret, password, signature, assertion, session id or one-time code, or a code or key of a kind
|
|
33
|
+
// that signs someone in or signs a request, such as `verification_code` or `api_key`.
|
|
34
|
+
const credential =
|
|
35
|
+
/token|secret|passw|passcode|passphrase|signature|credential|assertion|verifier|bearer|jwt|hmac|apikey|accesskey|privatekey|sess(?:ion)?id|saml(?:response|art)|(?:sig|otp)$|(?:auth|api|access|private|secret|signing|client|encryption|licen[cs]e|session|master|app|device|verification|verify|confirmation|confirm|activation|activate|reset|recovery|login|security|magic|onetime|mfa|2fa|sms|email|otp|oob)(?:code|key)$|^(?:auth|authorization|pwd|pass)$/;
|
|
36
|
+
|
|
37
|
+
// These names as often say what an address is about, such as a stock code, a support ticket or a
|
|
38
|
+
// conference session. Under them a value is a credential when it looks generated, at least 16
|
|
39
|
+
// characters with letters and digits, as tokens, session ids and authorization codes do.
|
|
40
|
+
const ambiguous = /^(?:code|key|session|sid|ticket)$/;
|
|
41
|
+
|
|
42
|
+
const secret = (name: string, value: string) => {
|
|
43
|
+
const bare = name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
value !== "" &&
|
|
47
|
+
(credential.test(bare) ||
|
|
48
|
+
(ambiguous.test(bare) && value.length >= 16 && /\d/.test(value) && /[a-z]/i.test(value)))
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Parameters as written, each credential's value withheld: a query's, a fragment's written as a
|
|
53
|
+
// query, or a path's segments', as a Java session id is.
|
|
54
|
+
const masked = (parameters: string, separator: string) =>
|
|
55
|
+
parameters
|
|
56
|
+
.split(separator)
|
|
57
|
+
.map((parameter) => {
|
|
58
|
+
const [name = "", value = ""] = [...new URLSearchParams(parameter)][0] ?? [];
|
|
59
|
+
|
|
60
|
+
return secret(name, value)
|
|
61
|
+
? `${parameter.slice(0, parameter.indexOf("=") + 1)}${withheld}`
|
|
62
|
+
: parameter;
|
|
63
|
+
})
|
|
64
|
+
.join(separator);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `url` as the library reports it: without userinfo, and with each credential's value withheld,
|
|
68
|
+
* so the rest keeps its identity, such as a chart's `?ticker=ETH`. An address without a host,
|
|
69
|
+
* such as `data:` or `about:blank`, is content or a fixed name, and stays as it is.
|
|
70
|
+
*/
|
|
71
|
+
const redact = (url: string): string => {
|
|
72
|
+
const parsed = URL.parse(url);
|
|
73
|
+
|
|
74
|
+
if (parsed === null || parsed.host === "") return url;
|
|
75
|
+
parsed.username = "";
|
|
76
|
+
parsed.password = "";
|
|
77
|
+
parsed.pathname = masked(parsed.pathname, ";");
|
|
78
|
+
parsed.search = masked(parsed.search.slice(1), "&");
|
|
79
|
+
if (parsed.hash.includes("=")) {
|
|
80
|
+
const fragment = parsed.hash.slice(1);
|
|
81
|
+
const question = fragment.indexOf("?");
|
|
82
|
+
// A route before the parameters, as in `#/inbox?token=…`, stays.
|
|
83
|
+
const start = question >= 0 && question < fragment.indexOf("=") ? question + 1 : 0;
|
|
84
|
+
|
|
85
|
+
parsed.hash = fragment.slice(0, start) + masked(fragment.slice(start), "&");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return parsed.href.replaceAll(encodeURIComponent(withheld), withheld);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return { withheld, redact };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const { withheld, redact } = addresses();
|
|
95
|
+
|
|
96
|
+
/** An address inside text, as a failure's message quotes one. */
|
|
97
|
+
export const quoted = /[a-z][a-z\d+.-]*:\/\/[^\s"'<>]+/gi;
|
|
98
|
+
|
|
99
|
+
/** `text` with each address in it redacted. */
|
|
100
|
+
export const redactWithin = (text: string) => text.replace(quoted, (url) => redact(url));
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The viewport's size in CSS pixels. Playwright knows it only for a context it created; over CDP it
|
|
3
|
+
* reports none, so the page answers, and so does a picture taken at one device pixel per CSS
|
|
4
|
+
* pixel. Pointer bounds, page scrolls, crops, capture size and frame reuse all read this one
|
|
5
|
+
* source; the latest answer serves checks that cannot wait for the page.
|
|
6
|
+
*/
|
|
7
|
+
import { Effect } from "effect";
|
|
8
|
+
|
|
9
|
+
import { ViewportResultSchema } from "../reading/outline.inpage.ts";
|
|
10
|
+
import { type Bridge, scriptCall } from "./bridge.ts";
|
|
11
|
+
import { decodeWith, type PageContext } from "./context.ts";
|
|
12
|
+
|
|
13
|
+
export const make = (page: PageContext, bridge: Bridge) => {
|
|
14
|
+
const { playwright } = page;
|
|
15
|
+
let measuredViewport: { readonly width: number; readonly height: number } | null = null;
|
|
16
|
+
|
|
17
|
+
const knownViewport = () => playwright.viewportSize() ?? measuredViewport;
|
|
18
|
+
|
|
19
|
+
const viewportFor = (operation: string) =>
|
|
20
|
+
Effect.suspend(() => {
|
|
21
|
+
const known = playwright.viewportSize();
|
|
22
|
+
|
|
23
|
+
return known === null
|
|
24
|
+
? bridge.evaluate(operation, scriptCall("viewport")).pipe(
|
|
25
|
+
Effect.flatMap(decodeWith(operation, ViewportResultSchema)),
|
|
26
|
+
Effect.tap((measured) =>
|
|
27
|
+
Effect.sync(() => {
|
|
28
|
+
measuredViewport = measured;
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
: Effect.succeed(known);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** The size of an unscaled picture of the whole viewport, which is the viewport's own. */
|
|
36
|
+
const remember = (size: { readonly width: number; readonly height: number }) => {
|
|
37
|
+
measuredViewport = { width: size.width, height: size.height };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return { knownViewport, viewportFor, remember };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type Viewport = ReturnType<typeof make>;
|