effect-browser 0.2.0-beta.8 → 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.
Files changed (202) hide show
  1. package/README.md +705 -1166
  2. package/dist/Browser-CM65Uo7f.mjs +437 -0
  3. package/dist/Browser-CM65Uo7f.mjs.map +1 -0
  4. package/dist/Browser.d.mts +115 -3
  5. package/dist/Browser.mjs +2 -22
  6. package/dist/BrowserError-DXXIA6Rk.d.mts +135 -0
  7. package/dist/BrowserError.d.mts +2 -0
  8. package/dist/BrowserError.mjs +202 -0
  9. package/dist/BrowserError.mjs.map +1 -0
  10. package/dist/BrowserEvent-DzdNdvoh.d.mts +277 -0
  11. package/dist/BrowserEvent.d.mts +2 -0
  12. package/dist/BrowserEvent.mjs +295 -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-DVP2IJWm.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-BERmf_gM.d.mts +117 -0
  29. package/dist/Frame.d.mts +2 -0
  30. package/dist/Frame.mjs +123 -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-D4281dIi.mjs +618 -0
  39. package/dist/Page-D4281dIi.mjs.map +1 -0
  40. package/dist/Page-DYTdVhYa.d.mts +593 -0
  41. package/dist/Page.d.mts +2 -0
  42. package/dist/Page.mjs +2 -0
  43. package/dist/Plan-CNNOU1Tx.d.mts +112 -0
  44. package/dist/Plan-Dtrb-8im.mjs +311 -0
  45. package/dist/Plan-Dtrb-8im.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 +54 -0
  59. package/dist/Stage.mjs +133 -0
  60. package/dist/Stage.mjs.map +1 -0
  61. package/dist/Supervisor-BtEG93gR.mjs +420 -0
  62. package/dist/Supervisor-BtEG93gR.mjs.map +1 -0
  63. package/dist/Supervisor-Im6-wbg5.d.mts +230 -0
  64. package/dist/Supervisor.d.mts +2 -0
  65. package/dist/Supervisor.mjs +2 -0
  66. package/dist/context-DAYjATgC.mjs +137 -0
  67. package/dist/context-DAYjATgC.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-Banfm_zc.mjs +5097 -0
  73. package/dist/page-Banfm_zc.mjs.map +1 -0
  74. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  75. package/dist/url-CcL0K7S0.mjs +262 -0
  76. package/dist/url-CcL0K7S0.mjs.map +1 -0
  77. package/package.json +96 -61
  78. package/src/Browser.ts +625 -0
  79. package/src/BrowserError.ts +225 -0
  80. package/src/BrowserEvent.ts +294 -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 +130 -0
  86. package/src/Moment.ts +479 -0
  87. package/src/Motion.ts +166 -0
  88. package/src/Page.ts +520 -0
  89. package/src/Plan.ts +350 -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 +223 -0
  94. package/src/Supervisor.ts +353 -0
  95. package/src/index.ts +20 -0
  96. package/src/internal/input/actions.ts +607 -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/guard.inpage.ts +605 -0
  101. package/src/internal/input/guard.ts +183 -0
  102. package/src/internal/input/keyboard.ts +112 -0
  103. package/src/internal/input/keys.ts +127 -0
  104. package/src/internal/input/perform.ts +322 -0
  105. package/src/internal/input/pointer.ts +153 -0
  106. package/src/internal/input/replies.ts +159 -0
  107. package/src/internal/input/settle.ts +50 -0
  108. package/src/internal/input/style.ts +186 -0
  109. package/src/internal/input/targets.inpage.ts +293 -0
  110. package/src/internal/input/targets.ts +148 -0
  111. package/src/internal/page/bridge.ts +386 -0
  112. package/src/internal/page/context.ts +302 -0
  113. package/src/internal/page/lane.ts +301 -0
  114. package/src/internal/page/navigation.ts +183 -0
  115. package/src/internal/page/openings.ts +87 -0
  116. package/src/internal/page/page.ts +137 -0
  117. package/src/internal/page/url.ts +100 -0
  118. package/src/internal/page/viewport.ts +43 -0
  119. package/src/internal/pictures/capture.ts +778 -0
  120. package/src/internal/pictures/clock.ts +141 -0
  121. package/src/internal/pictures/pictures.ts +520 -0
  122. package/src/internal/pictures/transport.ts +242 -0
  123. package/src/internal/reading/choose.ts +100 -0
  124. package/src/internal/reading/context.inpage.ts +201 -0
  125. package/src/internal/reading/controls.inpage.ts +158 -0
  126. package/src/internal/reading/expect.inpage.ts +70 -0
  127. package/src/internal/reading/match.inpage.ts +92 -0
  128. package/src/internal/reading/names.inpage.ts +306 -0
  129. package/src/internal/reading/outline.inpage.ts +260 -0
  130. package/src/internal/reading/reading.ts +223 -0
  131. package/src/internal/reading/ready.inpage.ts +154 -0
  132. package/src/internal/reading/ready.ts +105 -0
  133. package/src/internal/reading/subjects.inpage.ts +188 -0
  134. package/src/internal/reading/text.inpage.ts +135 -0
  135. package/src/internal/reading/walk.inpage.ts +220 -0
  136. package/src/internal/supervisor/lifecycle.ts +265 -0
  137. package/src/internal/timeline/changes.inpage.ts +216 -0
  138. package/src/internal/timeline/changes.ts +100 -0
  139. package/src/internal/timeline/events.ts +93 -0
  140. package/src/internal/timeline/fold.inpage.ts +123 -0
  141. package/src/internal/timeline/history.inpage.ts +229 -0
  142. package/src/internal/timeline/marks.inpage.ts +207 -0
  143. package/src/internal/timeline/record.inpage.ts +133 -0
  144. package/src/internal/timeline/sight.inpage.ts +122 -0
  145. package/src/internal/timeline/window.ts +96 -0
  146. package/src/internal/timeline/words.ts +65 -0
  147. package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
  148. package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
  149. package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
  150. package/dist/Bootstrap.d.mts +0 -3
  151. package/dist/Bootstrap.mjs +0 -2
  152. package/dist/Browser-n-bKgBNN.d.mts +0 -372
  153. package/dist/Browser.mjs.map +0 -1
  154. package/dist/BrowserData-CPIIIHol.d.mts +0 -585
  155. package/dist/BrowserData.d.mts +0 -3
  156. package/dist/BrowserData.mjs +0 -695
  157. package/dist/BrowserData.mjs.map +0 -1
  158. package/dist/BrowserRuntime-CFe9uUTv.d.mts +0 -148
  159. package/dist/BrowserRuntime-HJ1yYlFS.mjs +0 -6741
  160. package/dist/BrowserRuntime-HJ1yYlFS.mjs.map +0 -1
  161. package/dist/BrowserRuntime.d.mts +0 -3
  162. package/dist/BrowserRuntime.mjs +0 -3
  163. package/dist/Capture.d.mts +0 -56
  164. package/dist/Capture.mjs +0 -678
  165. package/dist/Capture.mjs.map +0 -1
  166. package/dist/CaptureData-CYriZHX1.mjs +0 -201
  167. package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
  168. package/dist/CaptureData.d.mts +0 -219
  169. package/dist/CaptureData.mjs +0 -2
  170. package/dist/Errors.d.mts +0 -158
  171. package/dist/Errors.mjs +0 -207
  172. package/dist/Errors.mjs.map +0 -1
  173. package/dist/Images-CaaPNeed.mjs +0 -76
  174. package/dist/Images-CaaPNeed.mjs.map +0 -1
  175. package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
  176. package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
  177. package/dist/PageControl.d.mts +0 -15
  178. package/dist/PageControl.mjs +0 -34
  179. package/dist/PageControl.mjs.map +0 -1
  180. package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
  181. package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
  182. package/dist/Plan-D8qQ48Ic.mjs +0 -512
  183. package/dist/Plan-D8qQ48Ic.mjs.map +0 -1
  184. package/dist/PlanData-B668RxbQ.d.mts +0 -4831
  185. package/dist/PlanData.d.mts +0 -3
  186. package/dist/PlanData.mjs +0 -268
  187. package/dist/PlanData.mjs.map +0 -1
  188. package/dist/PlanInput-BJcPPncs.mjs +0 -75
  189. package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
  190. package/dist/PublicSession-Bs61Eal8.mjs +0 -5601
  191. package/dist/PublicSession-Bs61Eal8.mjs.map +0 -1
  192. package/dist/Testing.d.mts +0 -479
  193. package/dist/Testing.mjs +0 -2147
  194. package/dist/Testing.mjs.map +0 -1
  195. package/dist/Timeline-fV22Xk-S.d.mts +0 -29
  196. package/dist/Timeline.d.mts +0 -4
  197. package/dist/Timeline.mjs +0 -20
  198. package/dist/Timeline.mjs.map +0 -1
  199. package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
  200. package/dist/TimelineData.d.mts +0 -3
  201. package/dist/TimelineData.mjs +0 -482
  202. package/dist/TimelineData.mjs.map +0 -1
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The browser's clock read on the owner's: estimates of their offset from timed probes, how an
3
+ * estimate ages, and the one mapping a browser's pages share. `pictures.ts` takes the probes.
4
+ */
5
+ import { Duration, Effect, Option } from "effect";
6
+
7
+ export interface Probe {
8
+ readonly hostStart: number;
9
+ readonly hostEnd: number;
10
+ readonly browserTime: number;
11
+ }
12
+
13
+ export interface Estimate {
14
+ /** Browser epoch milliseconds minus the owner's host monotonic milliseconds. */
15
+ readonly offsetMillis: number;
16
+ /** Either transport direction can own the entire round trip; symmetry is not assumed. */
17
+ readonly uncertaintyMillis: number;
18
+ readonly roundTripMillis: number;
19
+ readonly sampledAt: number;
20
+ }
21
+
22
+ /** The fastest complete probe limits uncertainty without mistaking transit delay for clock offset. */
23
+ export const estimate = (probes: ReadonlyArray<Probe>): Option.Option<Estimate> => {
24
+ let best: Estimate | undefined;
25
+
26
+ for (const probe of probes) {
27
+ const roundTripMillis = probe.hostEnd - probe.hostStart;
28
+
29
+ if (
30
+ !Number.isFinite(probe.hostStart) ||
31
+ !Number.isFinite(probe.hostEnd) ||
32
+ !Number.isFinite(probe.browserTime) ||
33
+ !Number.isFinite(roundTripMillis) ||
34
+ roundTripMillis < 0
35
+ )
36
+ continue;
37
+ const sampledAt = probe.hostStart + roundTripMillis / 2;
38
+ const offsetMillis = probe.browserTime - sampledAt;
39
+
40
+ if (
41
+ !Number.isFinite(offsetMillis) ||
42
+ !Number.isFinite(probe.browserTime - probe.hostStart) ||
43
+ !Number.isFinite(probe.browserTime - probe.hostEnd)
44
+ )
45
+ continue;
46
+ if (best === undefined || roundTripMillis < best.roundTripMillis)
47
+ best = {
48
+ offsetMillis,
49
+ uncertaintyMillis: roundTripMillis / 2,
50
+ roundTripMillis,
51
+ sampledAt,
52
+ };
53
+ }
54
+
55
+ return Option.fromUndefinedOr(best);
56
+ };
57
+
58
+ // Two machines' clocks drift apart by up to about 100 parts per million, so an estimate says this
59
+ // much less about the offset for each millisecond of its age.
60
+ const driftPerMillis = 1e-4;
61
+
62
+ /** An estimate's uncertainty at `hostTime`, wider the further that is from when it was sampled. */
63
+ export const uncertaintyAt = (estimate: Estimate, hostTime: number): number =>
64
+ estimate.uncertaintyMillis + Math.abs(hostTime - estimate.sampledAt) * driftPerMillis;
65
+
66
+ // Ten seconds widen an estimate by a millisecond, far less than a remote round trip, so it is
67
+ // measured again no more often than this.
68
+ const renewAfter = Duration.seconds(10);
69
+
70
+ /**
71
+ * The browser's one epoch mapping, shared by its pages, input runs and capture generations. It is
72
+ * measured on first need, by the first capture, never ahead of it.
73
+ */
74
+ export interface Mapping {
75
+ /** The latest estimate, without measuring: input uses it, and never waits for one. */
76
+ readonly latest: () => Estimate | undefined;
77
+ /** The latest estimate, measured through `measure` only while the browser has none. */
78
+ readonly current: <E>(measure: Effect.Effect<Estimate, E>) => Effect.Effect<Estimate, E>;
79
+ /**
80
+ * Measure again once ten seconds have passed since the latest measurement began. A failed
81
+ * measurement keeps the estimate, which keeps widening with its age.
82
+ */
83
+ readonly renew: <E>(measure: Effect.Effect<Estimate, E>) => Effect.Effect<void>;
84
+ }
85
+
86
+ /**
87
+ * Whether a new measurement should replace the current estimate. Each interval contains the true
88
+ * offset, so overlapping intervals agree and the narrower one is kept, the current one widened by
89
+ * its age: a probe delayed behind a busy page cannot widen every tab's stamps. A disjoint
90
+ * measurement means the clocks moved (a wall clock step, or drift between a remote browser and
91
+ * this host), so the newer evidence wins.
92
+ */
93
+ export const supersedes = (next: Estimate, current: Estimate): boolean => {
94
+ const aged = uncertaintyAt(current, next.sampledAt);
95
+
96
+ return (
97
+ next.uncertaintyMillis <= aged ||
98
+ Math.abs(next.offsetMillis - current.offsetMillis) > next.uncertaintyMillis + aged
99
+ );
100
+ };
101
+
102
+ /**
103
+ * Every renderer of a browser reads the same host wall clock, so the offset belongs to the
104
+ * browser rather than a page. A busy page cannot fail work that an earlier estimate can serve.
105
+ */
106
+ export const mapping = (now: () => number): Mapping => {
107
+ let latest: Estimate | undefined;
108
+ let measuredAt = Number.NEGATIVE_INFINITY;
109
+
110
+ // Called where a measurement is decided, so no other can be decided before this one begins.
111
+ const adopt = <E>(measure: Effect.Effect<Estimate, E>) => {
112
+ measuredAt = now();
113
+
114
+ return measure.pipe(
115
+ Effect.map((estimate) => {
116
+ if (latest === undefined || supersedes(estimate, latest)) latest = estimate;
117
+
118
+ return latest;
119
+ }),
120
+ );
121
+ };
122
+
123
+ return {
124
+ latest: () => latest,
125
+ current: (measure) =>
126
+ Effect.suspend(() => (latest === undefined ? adopt(measure) : Effect.succeed(latest))),
127
+ renew: (measure) =>
128
+ Effect.suspend(() =>
129
+ latest === undefined || now() - measuredAt < Duration.toMillis(renewAfter)
130
+ ? Effect.void
131
+ : Effect.ignore(adopt(measure)),
132
+ ),
133
+ };
134
+ };
135
+
136
+ export const toHostTime = (calibration: Estimate, browserEpochMillis: number): number =>
137
+ browserEpochMillis - calibration.offsetMillis;
138
+
139
+ /** CDP input timestamps use seconds, unlike browser frame timestamps and the owner's clock. */
140
+ export const toBrowserSeconds = (calibration: Estimate, hostMillis: number): number =>
141
+ (hostMillis + calibration.offsetMillis) / 1000;
@@ -0,0 +1,520 @@
1
+ /**
2
+ * Pictures of a page: screenshots, frames of a stated age, zooms, and its screencast with the clock
3
+ * that times its frames. A new picture is taken on the page's own protocol session, one at a time:
4
+ * one call where a device pixel is a CSS pixel and nothing is cropped, and two otherwise, unless
5
+ * Playwright emulates the viewport. The page's first clipped picture also holds the screen it reads.
6
+ */
7
+ import { Duration, Effect, Option, Schedule, Schema, Semaphore } from "effect";
8
+
9
+ import { BrowserError, Failed, InvalidRequest } from "../../BrowserError.ts";
10
+ import { Frame, Image, Screenshot } from "../../Frame.ts";
11
+ import { type FrameOptions, Region, type ScreenshotOptions, Zoom } from "../../Page.ts";
12
+ import { type Bridge, scriptCall } from "../page/bridge.ts";
13
+ import { decodeWith, failWith, type PageContext, reasonOf, undispatched } from "../page/context.ts";
14
+ import type { Viewport } from "../page/viewport.ts";
15
+ import * as Capture from "./capture.ts";
16
+ import * as BrowserClock from "./clock.ts";
17
+ import * as Transport from "./transport.ts";
18
+
19
+ /** Read a JPEG's dimensions from its start-of-frame marker. */
20
+ export const jpegSize = (bytes: Uint8Array): Capture.Size | undefined => {
21
+ let offset = 2;
22
+
23
+ while (offset + 9 < bytes.length) {
24
+ if (bytes[offset] !== 0xff) return undefined;
25
+ const marker = bytes[offset + 1] ?? 0;
26
+ const length = ((bytes[offset + 2] ?? 0) << 8) | (bytes[offset + 3] ?? 0);
27
+
28
+ if (marker >= 0xc0 && marker <= 0xc3)
29
+ return {
30
+ height: ((bytes[offset + 5] ?? 0) << 8) | (bytes[offset + 6] ?? 0),
31
+ width: ((bytes[offset + 7] ?? 0) << 8) | (bytes[offset + 8] ?? 0),
32
+ };
33
+ offset += 2 + length;
34
+ }
35
+
36
+ return undefined;
37
+ };
38
+
39
+ /** Read a PNG's dimensions from its header chunk. */
40
+ export const pngSize = (bytes: Uint8Array): Capture.Size | undefined => {
41
+ if (bytes.length < 24 || bytes[1] !== 0x50 || bytes[2] !== 0x4e || bytes[3] !== 0x47)
42
+ return undefined;
43
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
44
+
45
+ return { width: view.getUint32(16), height: view.getUint32(20) };
46
+ };
47
+
48
+ /** The format a new picture is taken in. */
49
+ export type Format = "jpeg" | "png";
50
+
51
+ // The part of `Page.getLayoutMetrics` a clip needs: the visual viewport's place in the document and
52
+ // its pinch scale, and the content's size in device and CSS pixels, whose ratio is the device's.
53
+ const LayoutMetrics = Schema.Struct({
54
+ cssVisualViewport: Schema.Struct({
55
+ pageX: Schema.Finite,
56
+ pageY: Schema.Finite,
57
+ scale: Schema.Finite.check(Schema.isGreaterThan(0)),
58
+ }),
59
+ contentSize: Schema.Struct({ width: Schema.Finite }),
60
+ cssContentSize: Schema.Struct({ width: Schema.Finite }),
61
+ });
62
+
63
+ // A screencast frame shows the page as it was when it was painted, and a new picture costs a round
64
+ // trip, so by default a frame painted this recently is reused.
65
+ const defaultMaxAge = Duration.millis(250);
66
+
67
+ // Chromium answers this while the page has no surface to copy yet, as just after a navigation. The
68
+ // page has one from its first frame, so the picture is taken again a frame later, within the
69
+ // picture's own deadline.
70
+ const surfaceless = (error: BrowserError) =>
71
+ error.reason._tag === "Failed" && error.reason.detail.includes("Unable to capture screenshot");
72
+
73
+ const untilPainted = <A, R>(capture: Effect.Effect<A, BrowserError, R>) =>
74
+ capture.pipe(Effect.retry({ while: surfaceless, schedule: Schedule.spaced("16 millis") }));
75
+
76
+ /**
77
+ * Measure this page's clock against the browser's, in a world without the page script: no DOM
78
+ * markers, input, navigation or extra tabs. Each of three probes reads the browser's time between
79
+ * two stamps of the owner's clock, and the fastest bounds the offset best. A busy page answers each
80
+ * probe late, behind its own work, so the probes that answered within two seconds serve, and the
81
+ * measurement fails only if none did: a wide estimate keeps a capture going, and a renewal while
82
+ * its frames flow narrows it.
83
+ */
84
+ const calibrator = (page: PageContext, bridge: Bridge) => {
85
+ const { native, now, within } = page;
86
+ const { send } = page.protocol;
87
+
88
+ // A round trip to the page like any other read, so traced as one.
89
+ const probe = (contextId: number) =>
90
+ Effect.gen(function* () {
91
+ const hostStart = now();
92
+
93
+ const { result, exceptionDetails } = yield* native("calibrate", () =>
94
+ send("Runtime.evaluate", {
95
+ contextId,
96
+ expression: "performance.timeOrigin + performance.now()",
97
+ returnByValue: true,
98
+ }),
99
+ ).pipe(page.span("Page.evaluate", { function: "clock" }, "Trace"));
100
+
101
+ const hostEnd = now();
102
+ const browserTime: unknown = result.value;
103
+
104
+ if (exceptionDetails !== undefined || typeof browserTime !== "number")
105
+ return yield* failWith(
106
+ "calibrate",
107
+ new Failed({ detail: "the browser clock probe did not return a timestamp" }),
108
+ );
109
+
110
+ return { hostStart, hostEnd, browserTime };
111
+ });
112
+
113
+ const answered = (contextId: number) =>
114
+ Effect.gen(function* () {
115
+ const probes: Array<BrowserClock.Probe> = [];
116
+
117
+ yield* Effect.forEach(
118
+ [1, 2, 3],
119
+ () => Effect.map(probe(contextId), (one) => probes.push(one)),
120
+ {
121
+ discard: true,
122
+ },
123
+ ).pipe(
124
+ within("calibrate", Duration.seconds(2)),
125
+ Effect.catch((error) => (probes.length === 0 ? Effect.fail(error) : Effect.void)),
126
+ );
127
+
128
+ return probes;
129
+ });
130
+
131
+ return bridge.bareWorld("calibrate").pipe(
132
+ Effect.flatMap(answered),
133
+ Effect.flatMap((probes) =>
134
+ Option.match(BrowserClock.estimate(probes), {
135
+ onNone: () =>
136
+ failWith(
137
+ "calibrate",
138
+ new Failed({ detail: "the browser clock probes did not define a finite interval" }),
139
+ ),
140
+ onSome: Effect.succeed,
141
+ }),
142
+ ),
143
+ within("calibrate", Duration.seconds(4)),
144
+ // The fastest probe's round trip: the transport's, plus a trivial script.
145
+ Effect.tap((estimate) =>
146
+ Effect.annotateCurrentSpan("roundTripMillis", Math.round(estimate.roundTripMillis * 10) / 10),
147
+ ),
148
+ page.span("Page.calibrateClock", {}, "Debug"),
149
+ page.owned,
150
+ );
151
+ };
152
+
153
+ // The screen as the page reads it.
154
+ const ScreenSize = Schema.Struct({ width: Schema.Int, height: Schema.Int });
155
+
156
+ // A new picture in CSS pixels. Where a device pixel is not a CSS pixel, or for a crop, it is
157
+ // Playwright's clip over fresh layout metrics: document coordinates from the visual viewport's
158
+ // place, and its pinch scale divided by the device's. Chromium draws such a capture into a
159
+ // running screencast, so the capture leaves out what is painted meanwhile.
160
+ //
161
+ // Chromium takes a clipped picture through the device emulation of the session that asks for it,
162
+ // then restores the view's size and what that session emulated. The page's own session emulated
163
+ // nothing, so its crops cleared a screen another session emulates, as Browserbase's own does: so
164
+ // before its first clipped picture, it holds a copy of the screen the page reads, which every later
165
+ // picture restores. And a picture taken during another would restore the other's clip, so they go
166
+ // one at a time.
167
+ const camera = (
168
+ page: PageContext,
169
+ bridge: Bridge,
170
+ viewport: Viewport,
171
+ capture: Capture.Controller,
172
+ ) => {
173
+ const { playwright, settings, native, within } = page;
174
+ const { send } = page.protocol;
175
+ const actionMillis = Duration.toMillis(settings.actionTimeout);
176
+ // Device pixels per CSS pixel, as the page's layout metrics last gave it. A picture taken in one
177
+ // call is checked by its size, so a page whose ratio changes is measured again.
178
+ let ratio = 1;
179
+ const alone = Semaphore.makeUnsafe(1).withPermits(1);
180
+ let screenHeld = false;
181
+
182
+ // The copy has the screen alone: the view keeps its own size, and the device its pixel ratio.
183
+ const holdScreen = (operation: string) =>
184
+ Effect.suspend(() =>
185
+ screenHeld
186
+ ? Effect.void
187
+ : bridge.evaluate(operation, scriptCall("screenSize")).pipe(
188
+ Effect.flatMap(decodeWith(operation, ScreenSize)),
189
+ Effect.flatMap(({ width, height }) =>
190
+ native(operation, () =>
191
+ send("Emulation.setDeviceMetricsOverride", {
192
+ width: 0,
193
+ height: 0,
194
+ deviceScaleFactor: 0,
195
+ mobile: false,
196
+ screenWidth: width,
197
+ screenHeight: height,
198
+ }),
199
+ ).pipe(alone),
200
+ ),
201
+ Effect.andThen(
202
+ Effect.sync(() => {
203
+ screenHeld = true;
204
+ }),
205
+ ),
206
+ ),
207
+ );
208
+
209
+ const decoded = (operation: string, data: Uint8Array, format: Format) => {
210
+ const size = format === "png" ? pngSize(data) : jpegSize(data);
211
+
212
+ return size === undefined
213
+ ? failWith(
214
+ operation,
215
+ new Failed({ detail: `the browser's picture is not a ${format.toUpperCase()}` }),
216
+ )
217
+ : Effect.succeed(new Image({ data, mediaType: `image/${format}`, ...size }));
218
+ };
219
+
220
+ const shot = (
221
+ operation: string,
222
+ format: Format,
223
+ quality: number,
224
+ clip?: Capture.Size & { readonly x: number; readonly y: number; readonly scale: number },
225
+ ) =>
226
+ native(operation, () =>
227
+ send("Page.captureScreenshot", {
228
+ format,
229
+ ...(format === "jpeg" ? { quality } : {}),
230
+ ...(clip === undefined ? {} : { clip }),
231
+ }),
232
+ ).pipe(
233
+ alone,
234
+ untilPainted,
235
+ Effect.flatMap(({ data }) => decoded(operation, Buffer.from(data, "base64"), format)),
236
+ );
237
+
238
+ // Playwright's own picture, taken on the session where it emulates the viewport.
239
+ const emulatedShot = (operation: string, format: Format, quality: number, crop?: Region) =>
240
+ native(
241
+ operation,
242
+ () =>
243
+ playwright.screenshot({
244
+ type: format,
245
+ ...(format === "jpeg" ? { quality } : {}),
246
+ scale: "css",
247
+ timeout: actionMillis,
248
+ ...(crop === undefined ? {} : { clip: crop }),
249
+ }),
250
+ actionMillis,
251
+ ).pipe(
252
+ alone,
253
+ untilPainted,
254
+ Effect.flatMap((data) => decoded(operation, data, format)),
255
+ );
256
+
257
+ return (operation: string, quality: number, crop?: Region, format: Format = "jpeg") =>
258
+ Effect.gen(function* () {
259
+ const known = viewport.knownViewport();
260
+ let whole: Image | undefined;
261
+
262
+ if (crop === undefined && ratio === 1) {
263
+ whole = yield* shot(operation, format, quality);
264
+ if (whole.width === known?.width && whole.height === known.height) return whole;
265
+ }
266
+ // Where Playwright emulates the viewport, on its own session, its own picture restores it.
267
+ const emulated = playwright.viewportSize();
268
+
269
+ if (emulated !== null) {
270
+ if (whole !== undefined) ratio = whole.width / emulated.width;
271
+
272
+ return yield* capture.excluding(emulatedShot(operation, format, quality, crop));
273
+ }
274
+
275
+ const [metrics] = yield* Effect.all(
276
+ [
277
+ native(operation, () => send("Page.getLayoutMetrics")).pipe(
278
+ Effect.flatMap(decodeWith(operation, LayoutMetrics)),
279
+ ),
280
+ crop === undefined ? Effect.void : holdScreen(operation),
281
+ ],
282
+ { concurrency: 2 },
283
+ );
284
+
285
+ const measured = metrics.contentSize.width / metrics.cssContentSize.width;
286
+
287
+ ratio = Number.isFinite(measured) && measured > 0 ? measured : 1;
288
+ if (crop === undefined && ratio === 1) {
289
+ const image = whole ?? (yield* shot(operation, format, quality));
290
+
291
+ viewport.remember(image);
292
+
293
+ return image;
294
+ }
295
+ const rect = crop ?? { x: 0, y: 0, ...(known ?? (yield* viewport.viewportFor(operation))) };
296
+ const { pageX, pageY, scale } = metrics.cssVisualViewport;
297
+
298
+ yield* holdScreen(operation);
299
+
300
+ // A crop on this session draws its frames at the crop's own size, so only those leave the
301
+ // capture, and the page's own keep flowing.
302
+ return yield* capture.excluding(
303
+ shot(operation, format, quality, {
304
+ x: pageX + rect.x,
305
+ y: pageY + rect.y,
306
+ width: Math.floor(rect.width / scale + 1e-3),
307
+ height: Math.floor(rect.height / scale + 1e-3),
308
+ scale: scale / ratio,
309
+ }),
310
+ crop !== undefined,
311
+ );
312
+ }).pipe(within(operation));
313
+ };
314
+
315
+ // A duration in milliseconds, or NaN for an input that is no duration.
316
+ const millisOf = (input: Duration.Input) =>
317
+ Option.match(Duration.fromInput(input), { onNone: () => Number.NaN, onSome: Duration.toMillis });
318
+
319
+ // The newest screencast frame, if it shows the page as the caller asked: painted at most `maxAge`
320
+ // ago at the earliest its timing allows, after the current document began, and with
321
+ // `after: "input"` after the page's latest input, which a read asks in its turn, once the action
322
+ // in flight has ended. It must have the viewport's size, as a new picture would. A screencast sends
323
+ // only changes and can miss a final paint, so a frame is only ever as current as its age, and a
324
+ // page that keeps painting while the next document loads leaves its own frames newest when the
325
+ // navigation returns.
326
+ const reuse =
327
+ (page: PageContext, viewport: Viewport, capture: Capture.Controller) =>
328
+ (operation: string, options: FrameOptions) =>
329
+ Effect.gen(function* () {
330
+ const maxAge = millisOf(options.maxAge ?? defaultMaxAge);
331
+
332
+ if (!(maxAge >= 0))
333
+ return yield* failWith(
334
+ operation,
335
+ new InvalidRequest({ detail: "maxAge must be a duration of zero or more" }),
336
+ );
337
+ const latest = yield* capture.latest;
338
+ const known = viewport.knownViewport();
339
+ const { activity } = page;
340
+
341
+ return Option.filter(latest, (frame) => {
342
+ const earliest = frame.hostTime - frame.timing.uncertaintyMillis;
343
+
344
+ return (
345
+ page.now() - earliest <= maxAge &&
346
+ earliest > activity.documentAt &&
347
+ (options.after !== "input" || earliest > activity.inputAt) &&
348
+ frame.width === known?.width &&
349
+ frame.height === known.height
350
+ );
351
+ });
352
+ });
353
+
354
+ /** Where a picture came from: a frame the screencast had delivered, or a new screenshot. */
355
+ const sourceOf = (reused: Option.Option<Frame>) => (Option.isSome(reused) ? "frame" : "screenshot");
356
+
357
+ export const make = Effect.fnUntraced(function* (
358
+ page: PageContext,
359
+ bridge: Bridge,
360
+ viewport: Viewport,
361
+ ) {
362
+ const { id, clock, settings, mapping, now, span, owned, lane, within } = page;
363
+ const calibrateClock = calibrator(page, bridge);
364
+
365
+ // A failure from a capture connection is already the capture's own.
366
+ const error = (cause: unknown) =>
367
+ Schema.is(BrowserError)(cause)
368
+ ? cause
369
+ : undispatched("screencast", reasonOf(cause, undefined, page.closedBy()));
370
+
371
+ // Input and capture share the owner's monotonic clock; caller-provided clocks cannot move it.
372
+ // Registration measures nothing: a page that is busy while it opens, such as a popup running
373
+ // its first script, must still be tracked. The browser's mapping serves every other page, so
374
+ // only a browser with no estimate yet needs this page's renderer to answer.
375
+ const capture = yield* Capture.make({
376
+ id,
377
+ transport:
378
+ page.capture === undefined
379
+ ? Effect.succeed(Transport.own(page, bridge))
380
+ : Transport.apart(page, bridge, page.capture, error, yield* Effect.scope),
381
+ clock,
382
+ // A capture starts once the page's own session keeps it painting behind other tabs, which a
383
+ // renderer stuck in a script never confirms, and with the browser's clock mapped. Only a
384
+ // browser's first capture waits for a measurement; every frame takes the newest estimate.
385
+ calibrate: page.focused.pipe(within("focus"), Effect.andThen(mapping.current(calibrateClock))),
386
+ latest: mapping.latest,
387
+ // Once the estimate is ten seconds old, the page measures again while its frames flow. A
388
+ // failure there shows in its `Page.calibrateClock` span, and the estimate stays.
389
+ renew: mapping.renew(calibrateClock),
390
+ // Frames are reused only after the current document began, which the page's own session sees
391
+ // only with the Page domain on: a read turns it on, and so does a capture as it starts.
392
+ watch: bridge.currentDocument("screencast"),
393
+ frameHistory: settings.frameHistory,
394
+ viewport: Effect.suspend(() => viewport.viewportFor("screencast")),
395
+ imageSize: jpegSize,
396
+ error,
397
+ });
398
+
399
+ const picture = camera(page, bridge, viewport, capture);
400
+ const reusable = reuse(page, viewport, capture);
401
+ // Pictures in flight are shared, never kept: their callers say how old one may be.
402
+ const screenshots = lane.shared<Image>("screenshot", false);
403
+ const frames = lane.shared<Frame>("frame", false);
404
+
405
+ // A caller that acts and then looks must see what its action did, so a screenshot waits for the
406
+ // action in flight and reuses only a frame painted after the page's latest input.
407
+ const screenshot = (options: ScreenshotOptions = {}) =>
408
+ screenshots(
409
+ JSON.stringify([
410
+ millisOf(options.maxAge ?? defaultMaxAge),
411
+ options.clip,
412
+ options.quality,
413
+ options.format,
414
+ ]),
415
+ Effect.gen(function* () {
416
+ // Screencast frames are JPEGs, so a PNG is always new.
417
+ const reused =
418
+ options.clip === undefined && options.format !== "png"
419
+ ? yield* reusable("screenshot", { maxAge: options.maxAge, after: "input" })
420
+ : Option.none<Frame>();
421
+
422
+ const image = Option.isSome(reused)
423
+ ? reused.value.image
424
+ : yield* picture("screenshot", options.quality ?? 80, options.clip, options.format);
425
+
426
+ yield* Effect.annotateCurrentSpan({ source: sourceOf(reused), bytes: image.data.length });
427
+
428
+ return image;
429
+ }),
430
+ ).pipe(span("Page.screenshot"), owned);
431
+
432
+ // A new screenshot has no paint time, only the host interval in which it was taken.
433
+ const taken = (options: FrameOptions) =>
434
+ Effect.gen(function* () {
435
+ const reused = yield* reusable("frame", options);
436
+
437
+ yield* Effect.annotateCurrentSpan("source", sourceOf(reused));
438
+ if (Option.isSome(reused)) return reused.value;
439
+ const startedAt = now();
440
+ const image = yield* picture("frame", 80);
441
+ const finishedAt = now();
442
+
443
+ return new Frame({
444
+ page: id,
445
+ data: image.data,
446
+ timing: new Screenshot({
447
+ hostTime: startedAt + (finishedAt - startedAt) / 2,
448
+ uncertaintyMillis: (finishedAt - startedAt) / 2,
449
+ }),
450
+ receivedAt: finishedAt,
451
+ width: image.width,
452
+ height: image.height,
453
+ ...bridge.frameTag(),
454
+ });
455
+ });
456
+
457
+ // A frame of a stated age that the screencast has costs nothing and waits for no action; one
458
+ // that must follow the latest input, or a new screenshot, waits for the action in flight.
459
+ const frame = (options: FrameOptions = {}) =>
460
+ (options.after === "input"
461
+ ? Effect.succeed(Option.none<Frame>())
462
+ : reusable("frame", options)
463
+ ).pipe(
464
+ Effect.flatMap(
465
+ Option.match({
466
+ onSome: (reused) => Effect.as(Effect.annotateCurrentSpan("source", "frame"), reused),
467
+ onNone: () =>
468
+ frames(
469
+ JSON.stringify([millisOf(options.maxAge ?? defaultMaxAge), options.after]),
470
+ taken(options),
471
+ ),
472
+ }),
473
+ ),
474
+ span("Page.frame"),
475
+ owned,
476
+ );
477
+
478
+ const zoom = (requested: Region) =>
479
+ lane
480
+ .read("zoom")(
481
+ Effect.gen(function* () {
482
+ const region = yield* Schema.decodeEffect(Region)(requested).pipe(
483
+ Effect.mapError((error) =>
484
+ undispatched("zoom", new InvalidRequest({ detail: error.message })),
485
+ ),
486
+ );
487
+
488
+ // The viewport's latest known size, as the pictures the region was chosen from had it.
489
+ const size = viewport.knownViewport() ?? (yield* viewport.viewportFor("zoom"));
490
+
491
+ if (region.x + region.width > size.width || region.y + region.height > size.height)
492
+ return yield* failWith(
493
+ "zoom",
494
+ new InvalidRequest({ detail: "the crop must fit entirely within the viewport" }),
495
+ );
496
+ const image = yield* picture("zoom", 80, region);
497
+
498
+ return new Zoom({ page: id, region, image });
499
+ }).pipe(within("zoom")),
500
+ )
501
+ .pipe(span("Page.zoom"), owned);
502
+
503
+ const captureStats = (options: { readonly window?: Duration.Input | undefined } = {}) => {
504
+ if (options.window === undefined) return capture.stats();
505
+ const millis = millisOf(options.window);
506
+
507
+ return millis > 0 && millis <= Duration.toMillis(Capture.countsKept)
508
+ ? capture.stats(millis)
509
+ : failWith(
510
+ "captureStats",
511
+ new InvalidRequest({ detail: "window must be over zero and at most a minute" }),
512
+ );
513
+ };
514
+
515
+ // The browser's clock mapping, measured on this page only while the browser has none, as the
516
+ // change record's times need it.
517
+ const estimate = mapping.current(calibrateClock);
518
+
519
+ return { capture, screenshot, frame, zoom, captureStats, estimate };
520
+ });