effect-browser 0.3.0-beta.15 → 0.3.0-beta.19

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 (96) hide show
  1. package/README.md +18 -6
  2. package/dist/{Browser-BBSySyjT.mjs → Browser-H63wiekr.mjs} +5 -5
  3. package/dist/Browser-H63wiekr.mjs.map +1 -0
  4. package/dist/Browser.d.mts +2 -2
  5. package/dist/Browser.mjs +1 -1
  6. package/dist/{BrowserEvent-BRu2juEz.d.mts → BrowserEvent-dq6FRT8w.d.mts} +9 -9
  7. package/dist/BrowserEvent.d.mts +1 -1
  8. package/dist/BrowserEvent.mjs +7 -12
  9. package/dist/BrowserEvent.mjs.map +1 -1
  10. package/dist/Cdp.mjs +2 -2
  11. package/dist/{Change-iMlcuzVD.d.mts → Change-DqING3tr.d.mts} +2 -2
  12. package/dist/Change.d.mts +1 -1
  13. package/dist/Chromium.mjs +2 -2
  14. package/dist/Expect.d.mts +1 -1
  15. package/dist/Expect.mjs +2 -2
  16. package/dist/Moment.d.mts +1 -1
  17. package/dist/Moment.mjs +3 -3
  18. package/dist/Moment.mjs.map +1 -1
  19. package/dist/Motion-DND6I-64.d.mts +40 -0
  20. package/dist/Motion.d.mts +2 -40
  21. package/dist/Motion.mjs +1 -2
  22. package/dist/Motion.mjs.map +1 -1
  23. package/dist/{Page-B5cjaUp5.mjs → Page-CfB6Zn1p.mjs} +7 -15
  24. package/dist/Page-CfB6Zn1p.mjs.map +1 -0
  25. package/dist/{Page-DL9eylp3.d.mts → Page-ku6j5t-v.d.mts} +23 -25
  26. package/dist/Page.d.mts +1 -1
  27. package/dist/Page.mjs +1 -1
  28. package/dist/{Plan-CXA2BW-G.mjs → Plan-C-d5QYS8.mjs} +6 -9
  29. package/dist/Plan-C-d5QYS8.mjs.map +1 -0
  30. package/dist/{Plan-6As6vcwT.d.mts → Plan-DJ4hEEna.d.mts} +3 -3
  31. package/dist/Plan.d.mts +1 -1
  32. package/dist/Plan.mjs +1 -1
  33. package/dist/Policy.d.mts +1 -1
  34. package/dist/Policy.mjs +1 -1
  35. package/dist/Presentation.d.mts +2 -2
  36. package/dist/Presentation.mjs +3 -2
  37. package/dist/Presentation.mjs.map +1 -1
  38. package/dist/Stage.d.mts +1 -1
  39. package/dist/Stage.mjs +8 -7
  40. package/dist/Stage.mjs.map +1 -1
  41. package/dist/{Supervisor-DaGlQVjv.mjs → Supervisor-2mPjIYYs.mjs} +44 -19
  42. package/dist/Supervisor-2mPjIYYs.mjs.map +1 -0
  43. package/dist/{Supervisor-5tZ47hR5.d.mts → Supervisor-mVPeEUp8.d.mts} +12 -5
  44. package/dist/Supervisor.d.mts +1 -1
  45. package/dist/Supervisor.mjs +1 -1
  46. package/dist/{context-BAjRj7Xo.mjs → context-w3NJPRLo.mjs} +81 -9
  47. package/dist/context-w3NJPRLo.mjs.map +1 -0
  48. package/dist/index.d.mts +6 -6
  49. package/dist/index.mjs +4 -4
  50. package/dist/match.inpage-xOe9LNO0.mjs.map +1 -1
  51. package/dist/{page-B92QnRfT.mjs → page-BKwfF6z-.mjs} +213 -218
  52. package/dist/page-BKwfF6z-.mjs.map +1 -0
  53. package/package.json +1 -1
  54. package/src/Browser.ts +1 -1
  55. package/src/BrowserEvent.ts +7 -12
  56. package/src/Moment.ts +3 -6
  57. package/src/Motion.ts +1 -2
  58. package/src/Page.ts +18 -20
  59. package/src/Plan.ts +1 -2
  60. package/src/Presentation.ts +2 -1
  61. package/src/Stage.ts +10 -17
  62. package/src/Supervisor.ts +9 -9
  63. package/src/internal/input/actions.ts +53 -97
  64. package/src/internal/input/dispatch.ts +25 -11
  65. package/src/internal/input/edit.inpage.ts +26 -11
  66. package/src/internal/input/evidence.inpage.ts +1 -21
  67. package/src/internal/input/guard.inpage.ts +9 -29
  68. package/src/internal/input/guard.ts +15 -10
  69. package/src/internal/input/perform.ts +14 -10
  70. package/src/internal/input/pointer.ts +3 -6
  71. package/src/internal/input/replies.ts +19 -8
  72. package/src/internal/input/settle.ts +3 -2
  73. package/src/internal/input/targets.inpage.ts +6 -13
  74. package/src/internal/input/targets.ts +13 -32
  75. package/src/internal/page/bridge.ts +2 -2
  76. package/src/internal/page/context.ts +15 -3
  77. package/src/internal/page/navigation.ts +3 -12
  78. package/src/internal/pictures/capture.ts +11 -4
  79. package/src/internal/pictures/clock.ts +8 -3
  80. package/src/internal/pictures/motion.ts +65 -0
  81. package/src/internal/pictures/pictures.ts +22 -17
  82. package/src/internal/pictures/utilsBundle.d.ts +9 -0
  83. package/src/internal/reading/controls.inpage.ts +8 -1
  84. package/src/internal/reading/match.inpage.ts +2 -1
  85. package/src/internal/reading/outline.inpage.ts +7 -4
  86. package/src/internal/reading/reading.ts +10 -25
  87. package/src/internal/reading/ready.ts +66 -14
  88. package/src/internal/supervisor/lifecycle.ts +56 -13
  89. package/src/internal/timeline/events.ts +5 -8
  90. package/src/internal/timeline/window.ts +3 -8
  91. package/dist/Browser-BBSySyjT.mjs.map +0 -1
  92. package/dist/Page-B5cjaUp5.mjs.map +0 -1
  93. package/dist/Plan-CXA2BW-G.mjs.map +0 -1
  94. package/dist/Supervisor-DaGlQVjv.mjs.map +0 -1
  95. package/dist/context-BAjRj7Xo.mjs.map +0 -1
  96. package/dist/page-B92QnRfT.mjs.map +0 -1
@@ -5,11 +5,10 @@
5
5
  */
6
6
  import { Duration, Effect, MutableRef, Option, Schema } from "effect";
7
7
 
8
- import { InvalidRequest } from "../../BrowserError.ts";
9
8
  import { CursorChanged, TrackPerformed, TrackPlanned } from "../../BrowserEvent.ts";
10
9
  import * as Motion from "../../Motion.ts";
11
10
  import type { Point } from "../../Page.ts";
12
- import { failWith, type PageContext } from "../page/context.ts";
11
+ import { type PageContext, refuse } from "../page/context.ts";
13
12
  import type { Viewport } from "../page/viewport.ts";
14
13
  import type { Dispatch } from "./dispatch.ts";
15
14
  import type { InputMarks } from "./perform.ts";
@@ -47,11 +46,9 @@ export const make = (page: PageContext, dispatch: Dispatch, viewport: Viewport)
47
46
 
48
47
  // A custom planner is a boundary. Decoding reads each sample once into a fresh copy and
49
48
  // checks that copy, so neither accessors nor later mutation can change what is admitted.
50
- const invalid = failWith(
49
+ const invalid = refuse(
51
50
  operation,
52
- new InvalidRequest({
53
- detail: "motion must be a bounded, ordered schedule ending at the target",
54
- }),
51
+ "motion must be a bounded, ordered schedule ending at the target",
55
52
  );
56
53
 
57
54
  const samples = yield* Schema.decodeEffect(Motion.Plan)(planned).pipe(
@@ -3,7 +3,7 @@
3
3
  * Canceling a caller cannot cancel a protocol command already sent. The next action waits for
4
4
  * those replies, while a stopped run releases only held keys or buttons whose release was never submitted.
5
5
  */
6
- import { Context, Data, Effect } from "effect";
6
+ import { Context, Data, Effect, type Scope } from "effect";
7
7
 
8
8
  import { maximumSamples } from "../../Motion.ts";
9
9
 
@@ -18,6 +18,8 @@ export interface Run {
18
18
  readonly track: <A>(reply: Promise<A>) => Promise<A>;
19
19
  /** End ownership after the record and all this operation's native replies have settled. */
20
20
  readonly afterIdle: (ended: () => void) => void;
21
+ /** Retain authority through scoped cleanup after the replies already submitted. */
22
+ readonly recover: (cleanup: Effect.Effect<void>, scope: Scope.Scope) => Effect.Effect<void>;
21
23
  /** Reserve a complete stroke, including its releases, before sending its first event. */
22
24
  readonly reserve: (commands: number) => Effect.Effect<void, InputFailure>;
23
25
  /** Admit every sample before a motion starts its published clock; ordinary input stays bounded separately. */
@@ -105,17 +107,11 @@ export const make = () => {
105
107
  const submit = (command: () => Promise<unknown>) => {
106
108
  if (reserved < 1) throw new Error("input must reserve capacity before dispatch");
107
109
  reserved--;
108
- let response: Promise<unknown>;
109
-
110
110
  try {
111
- response = command();
111
+ void track(command(), true);
112
112
  } catch (cause) {
113
113
  failure ??= new InputFailure({ cause });
114
-
115
- return;
116
114
  }
117
-
118
- void track(response, true);
119
115
  };
120
116
 
121
117
  const reserve = (commands: number, maximum: number, limit: number) =>
@@ -138,6 +134,21 @@ export const make = () => {
138
134
  if (pending.size === 0) end();
139
135
  else endings.add(end);
140
136
  },
137
+ recover: (cleanup, scope) =>
138
+ Effect.gen(function* () {
139
+ const replies = [...pending];
140
+ const done = Promise.withResolvers<void>();
141
+
142
+ // This reservation prevents a new action or suspension from overtaking cleanup.
143
+ void track(done.promise);
144
+ yield* Effect.promise(() => Promise.all(replies)).pipe(
145
+ Effect.andThen(cleanup),
146
+ Effect.ensuring(Effect.sync(() => done.resolve())),
147
+ Effect.provideService(Current, run),
148
+ Effect.interruptible,
149
+ Effect.forkIn(scope),
150
+ );
151
+ }),
141
152
  reserve: (commands) => reserve(commands, capacity, capacity),
142
153
  // A dense stroke cannot wait for replies midway without changing its published timing.
143
154
  // Its finite reservation also leaves room for the ordinary pool's already-owned releases.
@@ -39,12 +39,13 @@ export const make = (page: PageContext, bridge: Bridge) => {
39
39
  // evaluate after the settling deadline, without retaining browser input authority.
40
40
  Effect.provideService(Current, undefined),
41
41
  Effect.catchIf(contextGone, () => Effect.void),
42
- Effect.onError(() => page.stopLoading),
43
42
  );
44
43
  while (document() > before.document && parsed < document()) yield* Deferred.await(changed);
45
44
  }).pipe(
46
45
  Effect.timeoutOrElse({ duration: settling, orElse: () => Effect.fail("unsettled") }),
47
- Effect.catch(() => Effect.annotateCurrentSpan("settled", false)),
46
+ Effect.catch(() =>
47
+ page.stopLoading.pipe(Effect.andThen(Effect.annotateCurrentSpan("settled", false))),
48
+ ),
48
49
  Effect.andThen(page.openings.settle(before.openings)),
49
50
  page.span("Page.settle", {}, "Debug"),
50
51
  );
@@ -5,7 +5,8 @@
5
5
  */
6
6
  import { Schema } from "effect";
7
7
 
8
- import { Box, SubjectContext } from "../../BrowserEvent.ts";
8
+ import { Box, Subject } from "../../BrowserEvent.ts";
9
+ import { Point } from "../../Motion.ts";
9
10
  import type { ContextReader } from "../reading/context.inpage.ts";
10
11
  import type { Names } from "../reading/names.inpage.ts";
11
12
  import type { Walk } from "../reading/walk.inpage.ts";
@@ -78,10 +79,7 @@ export const targets = (names: Names, walked: Walk, placing: ContextReader) => {
78
79
  };
79
80
  };
80
81
 
81
- const point = (
82
- target: string | { readonly x: number; readonly y: number },
83
- scroll = true,
84
- ): PointResult => {
82
+ const point = (target: string | Point, scroll = true): PointResult => {
85
83
  if (typeof target !== "string") {
86
84
  const { x, y } = target;
87
85
 
@@ -263,13 +261,9 @@ export const targets = (names: Names, walked: Walk, placing: ContextReader) => {
263
261
  export type Targets = ReturnType<typeof targets>;
264
262
 
265
263
  const ResolvedPointSchema = Schema.Struct({
266
- x: Schema.Finite,
267
- y: Schema.Finite,
264
+ ...Point.fields,
265
+ ...Subject.fields,
268
266
  element: Schema.String,
269
- tag: Schema.String,
270
- role: Schema.NullOr(Schema.String),
271
- name: Schema.String,
272
- context: SubjectContext,
273
267
  box: Schema.optional(Box),
274
268
  cursor: Schema.String,
275
269
  href: Schema.optional(Schema.String),
@@ -285,8 +279,7 @@ export const PointResultSchema = Schema.Union([
285
279
 
286
280
  export const ScrollPlanSchema = Schema.NullOr(
287
281
  Schema.Struct({
288
- x: Schema.Finite,
289
- y: Schema.Finite,
282
+ ...Point.fields,
290
283
  dx: Schema.Finite,
291
284
  dy: Schema.Finite,
292
285
  }),
@@ -7,11 +7,11 @@ import { Effect } from "effect";
7
7
 
8
8
  import { InvalidRequest, NotActionable, StaleRef } from "../../BrowserError.ts";
9
9
  import { ResolvedTarget, type Target } from "../../Page.ts";
10
- import { type Bridge, scriptCall } from "../page/bridge.ts";
11
- import { decodeWith, failWith } from "../page/context.ts";
10
+ import { scriptCall } from "../page/bridge.ts";
11
+ import { decodeWith, failWith, refuse } from "../page/context.ts";
12
12
  import type { Viewport } from "../page/viewport.ts";
13
13
  import type { Dispatch } from "./dispatch.ts";
14
- import type { Approval, Guard } from "./guard.ts";
14
+ import { type Approval, check, type Guard } from "./guard.ts";
15
15
  import type { InputMarks } from "./perform.ts";
16
16
  import type { Pointer } from "./pointer.ts";
17
17
  import type { Settle } from "./settle.ts";
@@ -31,29 +31,17 @@ const pointFailure = (
31
31
  : new NotActionable({ detail: result.detail }),
32
32
  );
33
33
 
34
- const resolvedTarget = (result: Script.ResolvedPoint): ResolvedTarget =>
35
- new ResolvedTarget({
36
- point: { x: result.x, y: result.y },
37
- element: result.element,
38
- tag: result.tag,
39
- role: result.role,
40
- name: result.name,
41
- context: result.context,
42
- box: result.box,
43
- cursor: result.cursor,
44
- ...(result.href === undefined ? {} : { href: result.href }),
45
- });
34
+ const resolvedTarget = ({ x, y, box, ...target }: Script.ResolvedPoint): ResolvedTarget =>
35
+ new ResolvedTarget({ point: { x, y }, box, ...target });
46
36
 
47
37
  export const make = (
48
- bridge: Bridge,
49
38
  viewport: Viewport,
50
39
  guard: Guard,
51
40
  pointer: Pointer,
52
41
  dispatch: Dispatch,
53
42
  settling: Settle,
54
43
  ) => {
55
- const { evaluate } = bridge;
56
- const { mutate } = guard;
44
+ const { evaluate, mutate } = guard;
57
45
  const { moveTo, wheel } = pointer;
58
46
  const { flush } = dispatch;
59
47
 
@@ -63,22 +51,15 @@ export const make = (
63
51
  approval: Approval | undefined,
64
52
  scroll: boolean,
65
53
  ) =>
66
- (approval === undefined
67
- ? evaluate(operation, scriptCall("point", target, scroll))
68
- : mutate(operation, scriptCall("point", target, scroll), approval)
69
- ).pipe(Effect.flatMap(decodeWith(operation, Script.PointResultSchema)));
54
+ evaluate(operation, scriptCall("point", target, scroll), approval).pipe(
55
+ Effect.flatMap(decodeWith(operation, Script.PointResultSchema)),
56
+ );
70
57
 
71
58
  const resolve = (operation: string, target: Target, approval?: Approval, scroll = true) => {
72
59
  if (typeof target === "string" && !/^e\d+$/.test(target))
73
- return failWith(
74
- operation,
75
- new InvalidRequest({ detail: target + " is not a ref; refs look like e12" }),
76
- );
60
+ return refuse(operation, target + " is not a ref; refs look like e12");
77
61
  if (typeof target !== "string" && (!Number.isFinite(target.x) || !Number.isFinite(target.y)))
78
- return failWith(
79
- operation,
80
- new InvalidRequest({ detail: "point coordinates must be finite" }),
81
- );
62
+ return refuse(operation, "point coordinates must be finite");
82
63
 
83
64
  return readPoint(operation, target, approval, scroll).pipe(
84
65
  Effect.flatMap((result) =>
@@ -127,7 +108,7 @@ export const make = (
127
108
  );
128
109
  yield* flush(operation, marks.input);
129
110
  yield* settling.settle(operation, before);
130
- if (approval !== undefined) yield* approval.check();
111
+ yield* check(approval);
131
112
  }
132
113
 
133
114
  // Wheels may be prevented or the target may need unsupported nested/frame geometry. One
@@ -137,7 +118,7 @@ export const make = (
137
118
 
138
119
  yield* resolve(operation, target, approval);
139
120
  yield* settling.settle(operation, before);
140
- if (approval !== undefined) yield* approval.check();
121
+ yield* check(approval);
141
122
 
142
123
  return yield* resolve(operation, target, approval, false);
143
124
  });
@@ -90,7 +90,7 @@ const install = (
90
90
  ): PageApi => {
91
91
  const installed = globalThis.__effectBrowser;
92
92
 
93
- if (installed !== undefined && installed.version === 19) return installed;
93
+ if (installed !== undefined && installed.version === 20) return installed;
94
94
  const named = makeNames();
95
95
  const walked = makeWalk(named);
96
96
  const placing = makeContext(named, walked);
@@ -108,7 +108,7 @@ const install = (
108
108
  const readiness = makeReady(walked, texts, kept, recorder);
109
109
 
110
110
  const api: PageApi = {
111
- version: 19,
111
+ version: 20,
112
112
  snapshot: read.snapshot,
113
113
  find: subjected.find,
114
114
  until: makeExpectations(named, subjected).until,
@@ -17,12 +17,19 @@ import {
17
17
  import type { CDPSession, Page as PlaywrightPage } from "playwright-core";
18
18
 
19
19
  import type { CaptureSource } from "../../Browser.ts";
20
- import { BrowserError, Closed, Failed, type Reason, Timeout } from "../../BrowserError.ts";
20
+ import {
21
+ BrowserError,
22
+ Closed,
23
+ Failed,
24
+ InvalidRequest,
25
+ type Reason,
26
+ Timeout,
27
+ } from "../../BrowserError.ts";
21
28
  import type { BrowserEvent } from "../../BrowserEvent.ts";
22
29
  import type { InputGuard, Point } from "../../Page.ts";
23
30
  import type { Gate } from "../input/gate.ts";
24
31
  import { Current } from "../input/replies.ts";
25
- import type * as BrowserClock from "../pictures/clock.ts";
32
+ import * as BrowserClock from "../pictures/clock.ts";
26
33
  import * as Lane from "./lane.ts";
27
34
  import type { Openings } from "./openings.ts";
28
35
  import * as Url from "./url.ts";
@@ -159,6 +166,10 @@ export const undispatched = (operation: string, reason: Reason) =>
159
166
  export const failWith = (operation: string, reason: Reason) =>
160
167
  Effect.fail(undispatched(operation, reason));
161
168
 
169
+ /** Refuse a request to `operation` that could never succeed, before sending anything. */
170
+ export const refuse = (operation: string, detail: string) =>
171
+ failWith(operation, new InvalidRequest({ detail }));
172
+
162
173
  export const decodeWith =
163
174
  <A>(operation: string, schema: Schema.Codec<A, unknown>) =>
164
175
  (value: unknown) =>
@@ -168,7 +179,7 @@ export const decodeWith =
168
179
 
169
180
  export const make = (options: MakeOptions, scope: Scope.Scope) => {
170
181
  const { id, clock } = options;
171
- const now = () => Number(clock.monotonicTimeNanosUnsafe()) / 1e6;
182
+ const now = () => BrowserClock.monotonicMillis(clock);
172
183
  // Deadlines and pacing guard the page's lane, so page operations run on the owner's clock; a
173
184
  // caller's clock, such as a TestClock, cannot stall or stretch them.
174
185
  const owned = Effect.provideService(Clock.Clock, clock);
@@ -308,6 +319,7 @@ export const make = (options: MakeOptions, scope: Scope.Scope) => {
308
319
  lane,
309
320
  pointer,
310
321
  now,
322
+ scope,
311
323
  owned,
312
324
  span,
313
325
  protocol,
@@ -5,17 +5,11 @@
5
5
  */
6
6
  import { Deferred, Duration, Effect, Schedule } from "effect";
7
7
 
8
- import {
9
- BrowserError,
10
- InvalidRequest,
11
- NavigationFailed,
12
- NotActionable,
13
- NotFound,
14
- } from "../../BrowserError.ts";
8
+ import { BrowserError, NavigationFailed, NotActionable, NotFound } from "../../BrowserError.ts";
15
9
  import type { Guard } from "../input/guard.ts";
16
10
  import type { InputMarks, Perform } from "../input/perform.ts";
17
11
  import { Current } from "../input/replies.ts";
18
- import { failWith, type PageContext } from "./context.ts";
12
+ import { failWith, type PageContext, refuse } from "./context.ts";
19
13
  import * as Url from "./url.ts";
20
14
 
21
15
  export const make = (
@@ -157,10 +151,7 @@ export const make = (
157
151
  parsed === null ||
158
152
  !["http:", "https:", "about:", "data:", "file:"].includes(parsed.protocol)
159
153
  )
160
- return failWith(
161
- "navigate",
162
- new InvalidRequest({ detail: `"${Url.redact(url)}" is not a URL` }),
163
- );
154
+ return refuse("navigate", `"${Url.redact(url)}" is not a URL`);
164
155
 
165
156
  return navigation(
166
157
  "navigate",
@@ -18,7 +18,7 @@ import {
18
18
  import { type BrowserError, InvalidRequest } from "../../BrowserError.ts";
19
19
  import { BrowserPaint, CaptureStats, Frame, type ScreencastOptions } from "../../Frame.ts";
20
20
  import { undispatched } from "../page/context.ts";
21
- import { type Estimate, toHostTime, uncertaintyAt } from "./clock.ts";
21
+ import { type Estimate, monotonicMillis, toHostTime, uncertaintyAt } from "./clock.ts";
22
22
  import { tally } from "./counts.ts";
23
23
  import * as Quiescence from "./quiescence.ts";
24
24
  import type { NativeFrame, Transport } from "./transport.ts";
@@ -47,6 +47,8 @@ interface Options {
47
47
  readonly frameHistory: Duration.Duration;
48
48
  /** The viewport in CSS pixels; a capture is scaled to fit it, as screenshots are. */
49
49
  readonly viewport: Effect.Effect<Size, BrowserError>;
50
+ /** Copies the restored whole surface before native capture can restart. */
51
+ readonly restore: () => Promise<unknown>;
50
52
  readonly imageSize: (data: Uint8Array) => Size | undefined;
51
53
  readonly error: (cause: unknown) => BrowserError;
52
54
  }
@@ -154,7 +156,7 @@ const timed = (
154
156
  hostTime,
155
157
  uncertaintyMillis: uncertaintyAt(estimate, hostTime),
156
158
  }),
157
- receivedAt: Number(options.clock.monotonicTimeNanosUnsafe()) / 1e6,
159
+ receivedAt: monotonicMillis(options.clock),
158
160
  width: size.width,
159
161
  height: size.height,
160
162
  ...generation.transport.frameTag(),
@@ -222,7 +224,7 @@ const sizeFilter = (
222
224
  Queue.offerUnsafe(wake, undefined);
223
225
  };
224
226
 
225
- const now = () => Number(options.clock.monotonicTimeNanosUnsafe()) / 1e6;
227
+ const now = () => monotonicMillis(options.clock);
226
228
 
227
229
  const adopt = (adopted: Run) => {
228
230
  run = undefined;
@@ -470,7 +472,7 @@ export const make = (options: Options) =>
470
472
  let latest = Option.none<Frame>();
471
473
  let history: ReadonlyArray<Frame> = [];
472
474
  const keepMillis = Duration.toMillis(options.frameHistory);
473
- const now = () => Number(options.clock.monotonicTimeNanosUnsafe()) / 1e6;
475
+ const now = () => monotonicMillis(options.clock);
474
476
  const counts = tally(now);
475
477
  // Frames delivered, which number them so that a reader can tell what it missed.
476
478
  let delivered = 0;
@@ -589,6 +591,11 @@ export const make = (options: Options) =>
589
591
  };
590
592
 
591
593
  if (current === undefined || !resumable()) return;
594
+ // Screenshot replies restore emulation before the compositor has necessarily painted
595
+ // it. Copy the whole restored surface while stopped, retaining its actual native reply.
596
+ void pictures.track("picture restoration", options.restore(), failed(current));
597
+ yield* pictures.wait("picture restoration");
598
+ yield* healthy(current);
592
599
  const viewport = yield* readViewport(options);
593
600
 
594
601
  if (!resumable()) return;
@@ -1,8 +1,13 @@
1
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.
2
+ * Host monotonic milliseconds and the browser's clock read on the owner's: estimates of their
3
+ * offset from timed probes, how an estimate ages, and the mapping a browser's pages share.
4
+ * `pictures.ts` takes the probes.
4
5
  */
5
- import { Duration, Effect, Option } from "effect";
6
+ import { type Clock, Duration, Effect, Option } from "effect";
7
+
8
+ /** Read the captured owner's clock, including from native callbacks, without changing its origin. */
9
+ export const monotonicMillis = (clock: Clock.Clock): number =>
10
+ Number(clock.monotonicTimeNanosUnsafe()) / 1e6;
6
11
 
7
12
  export interface Probe {
8
13
  readonly hostStart: number;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Where a page's screen moves, from its screencast frames: each frame decoded and cut into
3
+ * 16-pixel blocks, a block moving where a few of its pixels, sampled every other one, changed by
4
+ * more than JPEG's noise in any color channel.
5
+ * What moved among the frames before an input is the page's background, such as an idle
6
+ * animation; foreground stillness ignores it.
7
+ *
8
+ * Frames are decoded with the JPEG decoder playwright-core bundles and exports as
9
+ * `playwright-core/lib/utilsBundle`: effect-browser depends on nothing else, and the runtime has
10
+ * no image decoder. That entry point has no types, so `utilsBundle.d.ts` declares what is used.
11
+ */
12
+ // oxlint-disable-next-line typescript/triple-slash-reference -- a declaration of an untyped module cannot be imported, and every program that compiles this source needs it
13
+ /// <reference path="./utilsBundle.d.ts" />
14
+ import { jpegjs } from "playwright-core/lib/utilsBundle";
15
+
16
+ import type { Frame } from "../../Frame.ts";
17
+
18
+ /** A decoded frame: RGBA, `width` pixels a row. */
19
+ export type Picture = ReturnType<typeof jpegjs.decode>;
20
+
21
+ export const picture = (frame: Frame): Picture => jpegjs.decode(frame.data, { useTArray: true });
22
+
23
+ /** A sample changed where any color channel moved more than this, beyond JPEG's noise. */
24
+ const noise = 32;
25
+
26
+ /** The blocks where two pictures differ, by `row * columns + column`; -1 for different sizes. */
27
+ const moved = (from: Picture, to: Picture): ReadonlyArray<number> => {
28
+ if (from.width !== to.width || from.height !== to.height) return [-1];
29
+ const columns = Math.ceil(from.width / 16);
30
+ const counts = new Map<number, number>();
31
+ const differs = (at: number) => Math.abs((from.data[at] ?? 0) - (to.data[at] ?? 0)) > noise;
32
+
33
+ for (let y = 0; y < from.height; y += 2)
34
+ for (let x = 0; x < from.width; x += 2) {
35
+ const at = (y * from.width + x) * 4;
36
+ const block = Math.floor(y / 16) * columns + Math.floor(x / 16);
37
+
38
+ if (differs(at) || differs(at + 1) || differs(at + 2))
39
+ counts.set(block, (counts.get(block) ?? 0) + 1);
40
+ }
41
+
42
+ // At least three changed samples, so a stray one is no motion.
43
+ return [...counts].flatMap(([block, count]) => (count >= 3 ? [block] : []));
44
+ };
45
+
46
+ /** The blocks that moved from one picture to the next and their neighbours; none past a resize. */
47
+ export const background = (pictures: ReadonlyArray<Picture>): ReadonlySet<number> => {
48
+ const columns = Math.ceil((pictures[0]?.width ?? 0) / 16);
49
+
50
+ const around = (block: number) =>
51
+ [-1, 0, 1].flatMap((row) =>
52
+ [-1, 0, 1]
53
+ .filter((step) => block >= 0 && (block % columns) + step >= 0)
54
+ .filter((step) => (block % columns) + step < columns)
55
+ .map((step) => block + row * columns + step),
56
+ );
57
+
58
+ return new Set(
59
+ pictures.flatMap((to, index) => moved(pictures[index - 1] ?? to, to).flatMap(around)),
60
+ );
61
+ };
62
+
63
+ /** Whether anything moved from one picture to another outside the background. */
64
+ export const stirs = (from: Picture, to: Picture, mask: ReadonlySet<number>) =>
65
+ moved(from, to).some((block) => !mask.has(block));
@@ -8,9 +8,17 @@ import { Duration, Effect, Option, Schedule, Schema } from "effect";
8
8
 
9
9
  import { BrowserError, Failed, InvalidRequest } from "../../BrowserError.ts";
10
10
  import { Frame, Image, Screenshot } from "../../Frame.ts";
11
+ import type { Point } from "../../Motion.ts";
11
12
  import { type FrameOptions, Region, type ScreenshotOptions, Zoom } from "../../Page.ts";
12
13
  import { type Bridge, scriptCall } from "../page/bridge.ts";
13
- import { decodeWith, failWith, type PageContext, reasonOf, undispatched } from "../page/context.ts";
14
+ import {
15
+ decodeWith,
16
+ failWith,
17
+ type PageContext,
18
+ reasonOf,
19
+ refuse,
20
+ undispatched,
21
+ } from "../page/context.ts";
14
22
  import type { Viewport } from "../page/viewport.ts";
15
23
  import * as Capture from "./capture.ts";
16
24
  import * as BrowserClock from "./clock.ts";
@@ -117,9 +125,7 @@ const calibrator = (page: PageContext, bridge: Bridge) => {
117
125
  yield* Effect.forEach(
118
126
  [1, 2, 3],
119
127
  () => Effect.map(probe(contextId), (one) => probes.push(one)),
120
- {
121
- discard: true,
122
- },
128
+ { discard: true },
123
129
  ).pipe(
124
130
  within("calibrate", Duration.seconds(2)),
125
131
  Effect.catch((error) => (probes.length === 0 ? Effect.fail(error) : Effect.void)),
@@ -219,7 +225,7 @@ const camera = (
219
225
  operation: string,
220
226
  format: Format,
221
227
  quality: number,
222
- clip?: Capture.Size & { readonly x: number; readonly y: number; readonly scale: number },
228
+ clip?: Capture.Size & Point & { readonly scale: number },
223
229
  ) =>
224
230
  take(operation, format, () =>
225
231
  send("Page.captureScreenshot", {
@@ -307,10 +313,7 @@ const reuse =
307
313
  const maxAge = millisOf(options.maxAge ?? defaultMaxAge);
308
314
 
309
315
  if (!(maxAge >= 0))
310
- return yield* failWith(
311
- operation,
312
- new InvalidRequest({ detail: "maxAge must be a duration of zero or more" }),
313
- );
316
+ return yield* refuse(operation, "maxAge must be a duration of zero or more");
314
317
  const latest = yield* capture.latest;
315
318
  const known = viewport.knownViewport();
316
319
  const { activity } = page;
@@ -370,6 +373,14 @@ export const make = Effect.fnUntraced(function* (
370
373
  watch: bridge.currentDocument("screencast"),
371
374
  frameHistory: settings.frameHistory,
372
375
  viewport: Effect.suspend(() => viewport.viewportFor("screencast")),
376
+ // An uncropped snapshot waits for the restored compositor surface. Its pixels are discarded;
377
+ // a renderer animation frame or viewport size alone cannot prove the compositor caught up.
378
+ restore: () =>
379
+ page.protocol.send("Page.captureScreenshot", {
380
+ format: "jpeg",
381
+ quality: 0,
382
+ optimizeForSpeed: true,
383
+ }),
373
384
  imageSize: jpegSize,
374
385
  error,
375
386
  });
@@ -467,10 +478,7 @@ export const make = Effect.fnUntraced(function* (
467
478
  const size = viewport.knownViewport() ?? (yield* viewport.viewportFor("zoom"));
468
479
 
469
480
  if (region.x + region.width > size.width || region.y + region.height > size.height)
470
- return yield* failWith(
471
- "zoom",
472
- new InvalidRequest({ detail: "the crop must fit entirely within the viewport" }),
473
- );
481
+ return yield* refuse("zoom", "the crop must fit entirely within the viewport");
474
482
  const image = yield* picture("zoom", 80, region);
475
483
 
476
484
  return new Zoom({ page: id, region, image });
@@ -484,10 +492,7 @@ export const make = Effect.fnUntraced(function* (
484
492
 
485
493
  return millis > 0 && millis <= Duration.toMillis(Capture.countsKept)
486
494
  ? capture.stats(millis)
487
- : failWith(
488
- "captureStats",
489
- new InvalidRequest({ detail: "window must be over zero and at most a minute" }),
490
- );
495
+ : refuse("captureStats", "window must be over zero and at most a minute");
491
496
  };
492
497
 
493
498
  // The browser's clock mapping, measured on this page only while the browser has none, as the
@@ -0,0 +1,9 @@
1
+ // What `motion.ts` uses of playwright-core's bundled JPEG decoder, which its untyped entry exports.
2
+ declare module "playwright-core/lib/utilsBundle" {
3
+ export const jpegjs: {
4
+ decode(
5
+ data: Uint8Array,
6
+ options: { useTArray: true },
7
+ ): { readonly width: number; readonly height: number; readonly data: Uint8Array };
8
+ };
9
+ }
@@ -3,6 +3,8 @@
3
3
  * read to; and a condition `Page.waitFor` waits for. See `names.inpage.ts` for what a page-side part
4
4
  * may use.
5
5
  */
6
+ import { Schema } from "effect";
7
+
6
8
  import type { Control } from "../../Snapshot.ts";
7
9
  import type { Names } from "./names.inpage.ts";
8
10
  import type { Subjects } from "./subjects.inpage.ts";
@@ -17,7 +19,7 @@ export interface ConditionRequest {
17
19
  }
18
20
 
19
21
  /** Whether the condition held by the deadline, or why the selector is not CSS. */
20
- export type ConditionResult = { readonly met: boolean } | { readonly invalid: string };
22
+ export type ConditionResult = typeof ConditionResultSchema.Encoded;
21
23
 
22
24
  export const controls = (names: Names, walked: Walk, subjects: Subjects, texts: Texts) => {
23
25
  const { clean, isDisabled, isHtml, isInput, isSelect, isTextArea, refFor, textOf } = names;
@@ -156,3 +158,8 @@ export const controls = (names: Names, walked: Walk, subjects: Subjects, texts:
156
158
  };
157
159
 
158
160
  export type Controls = ReturnType<typeof controls>;
161
+
162
+ export const ConditionResultSchema = Schema.Union([
163
+ Schema.Struct({ met: Schema.Boolean }),
164
+ Schema.Struct({ invalid: Schema.String }),
165
+ ]);
@@ -10,6 +10,7 @@
10
10
  * its row, column, label or heading. Replay chooses among candidates by the same rule.
11
11
  */
12
12
  import type { SubjectContext } from "../../BrowserEvent.ts";
13
+ import type { Point } from "../../Motion.ts";
13
14
 
14
15
  /** A string, or a pattern sent to the page as its source and flags. */
15
16
  export type Wanted = string | { readonly source: string; readonly flags: string };
@@ -19,7 +20,7 @@ export interface FindRequest {
19
20
  readonly name: Wanted | null;
20
21
  readonly text: Wanted | null;
21
22
  readonly near: string | null;
22
- readonly at: { readonly x: number; readonly y: number } | null;
23
+ readonly at: Point | null;
23
24
  readonly scope: "viewport" | "document";
24
25
  readonly firstRef: number;
25
26
  }
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { Schema } from "effect";
6
6
 
7
- import type { Control, Snapshot } from "../../Snapshot.ts";
7
+ import { type Control, Snapshot } from "../../Snapshot.ts";
8
8
  import type { addresses } from "../page/url.ts";
9
9
  import type { Controls } from "./controls.inpage.ts";
10
10
  import type { Names } from "./names.inpage.ts";
@@ -25,9 +25,7 @@ export interface SnapshotRequest {
25
25
  * The outline as a `Snapshot` reads it, and the next ref the page may give, or why `within` is not
26
26
  * a selector.
27
27
  */
28
- export type SnapshotResult =
29
- | { readonly snapshot: typeof Snapshot.Encoded; readonly nextRef: number }
30
- | { readonly invalid: string };
28
+ export type SnapshotResult = typeof SnapshotResultSchema.Encoded;
31
29
 
32
30
  /** The rule every address the library reports goes by, which a link's address goes by too. */
33
31
  type Url = ReturnType<typeof addresses>;
@@ -254,6 +252,11 @@ export const outline = (
254
252
 
255
253
  export type Outline = ReturnType<typeof outline>;
256
254
 
255
+ export const SnapshotResultSchema = Schema.Union([
256
+ Schema.Struct({ snapshot: Snapshot, nextRef: Schema.Finite }),
257
+ Schema.Struct({ invalid: Schema.String }),
258
+ ]);
259
+
257
260
  export const ViewportResultSchema = Schema.Struct({
258
261
  width: Schema.Finite,
259
262
  height: Schema.Finite,