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.
Files changed (205) hide show
  1. package/README.md +861 -1161
  2. package/dist/Browser-B4qncxe7.mjs +628 -0
  3. package/dist/Browser-B4qncxe7.mjs.map +1 -0
  4. package/dist/Browser.d.mts +129 -3
  5. package/dist/Browser.mjs +2 -22
  6. package/dist/BrowserError-Coy7EW1N.d.mts +140 -0
  7. package/dist/BrowserError.d.mts +2 -0
  8. package/dist/BrowserError.mjs +211 -0
  9. package/dist/BrowserError.mjs.map +1 -0
  10. package/dist/BrowserEvent-BRu2juEz.d.mts +279 -0
  11. package/dist/BrowserEvent.d.mts +2 -0
  12. package/dist/BrowserEvent.mjs +297 -0
  13. package/dist/BrowserEvent.mjs.map +1 -0
  14. package/dist/Cdp.d.mts +32 -0
  15. package/dist/Cdp.mjs +61 -0
  16. package/dist/Cdp.mjs.map +1 -0
  17. package/dist/Change-DNvztNLu.mjs +138 -0
  18. package/dist/Change-DNvztNLu.mjs.map +1 -0
  19. package/dist/Change-iMlcuzVD.d.mts +71 -0
  20. package/dist/Change.d.mts +2 -0
  21. package/dist/Change.mjs +2 -0
  22. package/dist/Chromium.d.mts +29 -81
  23. package/dist/Chromium.mjs +57 -392
  24. package/dist/Chromium.mjs.map +1 -1
  25. package/dist/Expect.d.mts +62 -0
  26. package/dist/Expect.mjs +114 -0
  27. package/dist/Expect.mjs.map +1 -0
  28. package/dist/Frame-Di2PxyF7.d.mts +119 -0
  29. package/dist/Frame.d.mts +2 -0
  30. package/dist/Frame.mjs +125 -0
  31. package/dist/Frame.mjs.map +1 -0
  32. package/dist/Moment.d.mts +2 -0
  33. package/dist/Moment.mjs +288 -0
  34. package/dist/Moment.mjs.map +1 -0
  35. package/dist/Motion.d.mts +40 -0
  36. package/dist/Motion.mjs +122 -0
  37. package/dist/Motion.mjs.map +1 -0
  38. package/dist/Page-B5cjaUp5.mjs +618 -0
  39. package/dist/Page-B5cjaUp5.mjs.map +1 -0
  40. package/dist/Page-B7X6DQUn.d.mts +595 -0
  41. package/dist/Page.d.mts +2 -0
  42. package/dist/Page.mjs +2 -0
  43. package/dist/Plan-Akcac63g.d.mts +209 -0
  44. package/dist/Plan-D1wrJVs8.mjs +508 -0
  45. package/dist/Plan-D1wrJVs8.mjs.map +1 -0
  46. package/dist/Plan.d.mts +2 -3
  47. package/dist/Plan.mjs +2 -2
  48. package/dist/Policy.d.mts +76 -0
  49. package/dist/Policy.mjs +237 -0
  50. package/dist/Policy.mjs.map +1 -0
  51. package/dist/Presentation.d.mts +48 -0
  52. package/dist/Presentation.mjs +117 -0
  53. package/dist/Presentation.mjs.map +1 -0
  54. package/dist/Snapshot-B34hZ4Jl.d.mts +68 -0
  55. package/dist/Snapshot.d.mts +2 -0
  56. package/dist/Snapshot.mjs +79 -0
  57. package/dist/Snapshot.mjs.map +1 -0
  58. package/dist/Stage.d.mts +64 -0
  59. package/dist/Stage.mjs +237 -0
  60. package/dist/Stage.mjs.map +1 -0
  61. package/dist/Supervisor-CYWFK3vM.d.mts +280 -0
  62. package/dist/Supervisor-TNKNOVzu.mjs +599 -0
  63. package/dist/Supervisor-TNKNOVzu.mjs.map +1 -0
  64. package/dist/Supervisor.d.mts +2 -0
  65. package/dist/Supervisor.mjs +2 -0
  66. package/dist/context-B6xr8nVi.mjs +261 -0
  67. package/dist/context-B6xr8nVi.mjs.map +1 -0
  68. package/dist/index.d.mts +17 -14
  69. package/dist/index.mjs +18 -13
  70. package/dist/match.inpage-xOe9LNO0.mjs +48 -0
  71. package/dist/match.inpage-xOe9LNO0.mjs.map +1 -0
  72. package/dist/page-BAf__WKX.mjs +5075 -0
  73. package/dist/page-BAf__WKX.mjs.map +1 -0
  74. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  75. package/dist/url-Cd04Okid.mjs +264 -0
  76. package/dist/url-Cd04Okid.mjs.map +1 -0
  77. package/package.json +96 -61
  78. package/src/Browser.ts +672 -0
  79. package/src/BrowserError.ts +234 -0
  80. package/src/BrowserEvent.ts +296 -0
  81. package/src/Cdp.ts +101 -0
  82. package/src/Change.ts +88 -0
  83. package/src/Chromium.ts +102 -0
  84. package/src/Expect.ts +180 -0
  85. package/src/Frame.ts +132 -0
  86. package/src/Moment.ts +479 -0
  87. package/src/Motion.ts +166 -0
  88. package/src/Page.ts +522 -0
  89. package/src/Plan.ts +663 -0
  90. package/src/Policy.ts +325 -0
  91. package/src/Presentation.ts +195 -0
  92. package/src/Snapshot.ts +89 -0
  93. package/src/Stage.ts +467 -0
  94. package/src/Supervisor.ts +599 -0
  95. package/src/index.ts +20 -0
  96. package/src/internal/input/actions.ts +610 -0
  97. package/src/internal/input/dispatch.ts +161 -0
  98. package/src/internal/input/edit.inpage.ts +162 -0
  99. package/src/internal/input/evidence.inpage.ts +164 -0
  100. package/src/internal/input/gate.ts +224 -0
  101. package/src/internal/input/guard.inpage.ts +605 -0
  102. package/src/internal/input/guard.ts +184 -0
  103. package/src/internal/input/keyboard.ts +112 -0
  104. package/src/internal/input/keys.ts +127 -0
  105. package/src/internal/input/perform.ts +376 -0
  106. package/src/internal/input/pointer.ts +153 -0
  107. package/src/internal/input/replies.ts +195 -0
  108. package/src/internal/input/settle.ts +50 -0
  109. package/src/internal/input/style.ts +186 -0
  110. package/src/internal/input/targets.inpage.ts +293 -0
  111. package/src/internal/input/targets.ts +148 -0
  112. package/src/internal/page/bridge.ts +391 -0
  113. package/src/internal/page/context.ts +314 -0
  114. package/src/internal/page/identity.ts +35 -0
  115. package/src/internal/page/lane.ts +311 -0
  116. package/src/internal/page/navigation.ts +183 -0
  117. package/src/internal/page/openings.ts +87 -0
  118. package/src/internal/page/page.ts +165 -0
  119. package/src/internal/page/url.ts +100 -0
  120. package/src/internal/page/viewport.ts +43 -0
  121. package/src/internal/pictures/capture.ts +776 -0
  122. package/src/internal/pictures/clock.ts +141 -0
  123. package/src/internal/pictures/counts.ts +92 -0
  124. package/src/internal/pictures/pictures.ts +521 -0
  125. package/src/internal/pictures/transport.ts +243 -0
  126. package/src/internal/reading/choose.ts +100 -0
  127. package/src/internal/reading/context.inpage.ts +201 -0
  128. package/src/internal/reading/controls.inpage.ts +158 -0
  129. package/src/internal/reading/expect.inpage.ts +70 -0
  130. package/src/internal/reading/match.inpage.ts +92 -0
  131. package/src/internal/reading/names.inpage.ts +306 -0
  132. package/src/internal/reading/outline.inpage.ts +260 -0
  133. package/src/internal/reading/reading.ts +223 -0
  134. package/src/internal/reading/ready.inpage.ts +164 -0
  135. package/src/internal/reading/ready.ts +111 -0
  136. package/src/internal/reading/subjects.inpage.ts +188 -0
  137. package/src/internal/reading/text.inpage.ts +135 -0
  138. package/src/internal/reading/walk.inpage.ts +220 -0
  139. package/src/internal/supervisor/lifecycle.ts +416 -0
  140. package/src/internal/timeline/changes.inpage.ts +216 -0
  141. package/src/internal/timeline/changes.ts +100 -0
  142. package/src/internal/timeline/events.ts +94 -0
  143. package/src/internal/timeline/fold.inpage.ts +123 -0
  144. package/src/internal/timeline/history.inpage.ts +229 -0
  145. package/src/internal/timeline/marks.inpage.ts +207 -0
  146. package/src/internal/timeline/record.inpage.ts +133 -0
  147. package/src/internal/timeline/sight.inpage.ts +122 -0
  148. package/src/internal/timeline/window.ts +96 -0
  149. package/src/internal/timeline/words.ts +65 -0
  150. package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
  151. package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
  152. package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
  153. package/dist/Bootstrap.d.mts +0 -3
  154. package/dist/Bootstrap.mjs +0 -2
  155. package/dist/Browser-BF9_sORP.d.mts +0 -388
  156. package/dist/Browser.mjs.map +0 -1
  157. package/dist/BrowserData-CPIIIHol.d.mts +0 -585
  158. package/dist/BrowserData.d.mts +0 -3
  159. package/dist/BrowserData.mjs +0 -695
  160. package/dist/BrowserData.mjs.map +0 -1
  161. package/dist/BrowserRuntime-B6nT1G4X.d.mts +0 -148
  162. package/dist/BrowserRuntime-C1r2aSRK.mjs +0 -6820
  163. package/dist/BrowserRuntime-C1r2aSRK.mjs.map +0 -1
  164. package/dist/BrowserRuntime.d.mts +0 -3
  165. package/dist/BrowserRuntime.mjs +0 -3
  166. package/dist/Capture.d.mts +0 -56
  167. package/dist/Capture.mjs +0 -678
  168. package/dist/Capture.mjs.map +0 -1
  169. package/dist/CaptureData-CYriZHX1.mjs +0 -201
  170. package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
  171. package/dist/CaptureData.d.mts +0 -219
  172. package/dist/CaptureData.mjs +0 -2
  173. package/dist/Errors.d.mts +0 -158
  174. package/dist/Errors.mjs +0 -207
  175. package/dist/Errors.mjs.map +0 -1
  176. package/dist/Images-yCG9BmRU.mjs +0 -76
  177. package/dist/Images-yCG9BmRU.mjs.map +0 -1
  178. package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
  179. package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
  180. package/dist/PageControl.d.mts +0 -15
  181. package/dist/PageControl.mjs +0 -34
  182. package/dist/PageControl.mjs.map +0 -1
  183. package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
  184. package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
  185. package/dist/Plan-Dyxu2q8C.mjs +0 -543
  186. package/dist/Plan-Dyxu2q8C.mjs.map +0 -1
  187. package/dist/PlanData-B668RxbQ.d.mts +0 -4831
  188. package/dist/PlanData.d.mts +0 -3
  189. package/dist/PlanData.mjs +0 -268
  190. package/dist/PlanData.mjs.map +0 -1
  191. package/dist/PlanInput-BJcPPncs.mjs +0 -75
  192. package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
  193. package/dist/PublicSession-DCSMMqOj.mjs +0 -5602
  194. package/dist/PublicSession-DCSMMqOj.mjs.map +0 -1
  195. package/dist/Testing.d.mts +0 -479
  196. package/dist/Testing.mjs +0 -2147
  197. package/dist/Testing.mjs.map +0 -1
  198. package/dist/Timeline-fV22Xk-S.d.mts +0 -29
  199. package/dist/Timeline.d.mts +0 -4
  200. package/dist/Timeline.mjs +0 -20
  201. package/dist/Timeline.mjs.map +0 -1
  202. package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
  203. package/dist/TimelineData.d.mts +0 -3
  204. package/dist/TimelineData.mjs +0 -482
  205. 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>;