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.
- package/README.md +18 -6
- package/dist/{Browser-BBSySyjT.mjs → Browser-H63wiekr.mjs} +5 -5
- package/dist/Browser-H63wiekr.mjs.map +1 -0
- package/dist/Browser.d.mts +2 -2
- package/dist/Browser.mjs +1 -1
- package/dist/{BrowserEvent-BRu2juEz.d.mts → BrowserEvent-dq6FRT8w.d.mts} +9 -9
- package/dist/BrowserEvent.d.mts +1 -1
- package/dist/BrowserEvent.mjs +7 -12
- package/dist/BrowserEvent.mjs.map +1 -1
- package/dist/Cdp.mjs +2 -2
- package/dist/{Change-iMlcuzVD.d.mts → Change-DqING3tr.d.mts} +2 -2
- package/dist/Change.d.mts +1 -1
- package/dist/Chromium.mjs +2 -2
- package/dist/Expect.d.mts +1 -1
- package/dist/Expect.mjs +2 -2
- package/dist/Moment.d.mts +1 -1
- package/dist/Moment.mjs +3 -3
- package/dist/Moment.mjs.map +1 -1
- package/dist/Motion-DND6I-64.d.mts +40 -0
- package/dist/Motion.d.mts +2 -40
- package/dist/Motion.mjs +1 -2
- package/dist/Motion.mjs.map +1 -1
- package/dist/{Page-B5cjaUp5.mjs → Page-CfB6Zn1p.mjs} +7 -15
- package/dist/Page-CfB6Zn1p.mjs.map +1 -0
- package/dist/{Page-DL9eylp3.d.mts → Page-ku6j5t-v.d.mts} +23 -25
- package/dist/Page.d.mts +1 -1
- package/dist/Page.mjs +1 -1
- package/dist/{Plan-CXA2BW-G.mjs → Plan-C-d5QYS8.mjs} +6 -9
- package/dist/Plan-C-d5QYS8.mjs.map +1 -0
- package/dist/{Plan-6As6vcwT.d.mts → Plan-DJ4hEEna.d.mts} +3 -3
- package/dist/Plan.d.mts +1 -1
- package/dist/Plan.mjs +1 -1
- package/dist/Policy.d.mts +1 -1
- package/dist/Policy.mjs +1 -1
- package/dist/Presentation.d.mts +2 -2
- package/dist/Presentation.mjs +3 -2
- package/dist/Presentation.mjs.map +1 -1
- package/dist/Stage.d.mts +1 -1
- package/dist/Stage.mjs +8 -7
- package/dist/Stage.mjs.map +1 -1
- package/dist/{Supervisor-DaGlQVjv.mjs → Supervisor-2mPjIYYs.mjs} +44 -19
- package/dist/Supervisor-2mPjIYYs.mjs.map +1 -0
- package/dist/{Supervisor-5tZ47hR5.d.mts → Supervisor-mVPeEUp8.d.mts} +12 -5
- package/dist/Supervisor.d.mts +1 -1
- package/dist/Supervisor.mjs +1 -1
- package/dist/{context-BAjRj7Xo.mjs → context-w3NJPRLo.mjs} +81 -9
- package/dist/context-w3NJPRLo.mjs.map +1 -0
- package/dist/index.d.mts +6 -6
- package/dist/index.mjs +4 -4
- package/dist/match.inpage-xOe9LNO0.mjs.map +1 -1
- package/dist/{page-B92QnRfT.mjs → page-BKwfF6z-.mjs} +213 -218
- package/dist/page-BKwfF6z-.mjs.map +1 -0
- package/package.json +1 -1
- package/src/Browser.ts +1 -1
- package/src/BrowserEvent.ts +7 -12
- package/src/Moment.ts +3 -6
- package/src/Motion.ts +1 -2
- package/src/Page.ts +18 -20
- package/src/Plan.ts +1 -2
- package/src/Presentation.ts +2 -1
- package/src/Stage.ts +10 -17
- package/src/Supervisor.ts +9 -9
- package/src/internal/input/actions.ts +53 -97
- package/src/internal/input/dispatch.ts +25 -11
- package/src/internal/input/edit.inpage.ts +26 -11
- package/src/internal/input/evidence.inpage.ts +1 -21
- package/src/internal/input/guard.inpage.ts +9 -29
- package/src/internal/input/guard.ts +15 -10
- package/src/internal/input/perform.ts +14 -10
- package/src/internal/input/pointer.ts +3 -6
- package/src/internal/input/replies.ts +19 -8
- package/src/internal/input/settle.ts +3 -2
- package/src/internal/input/targets.inpage.ts +6 -13
- package/src/internal/input/targets.ts +13 -32
- package/src/internal/page/bridge.ts +2 -2
- package/src/internal/page/context.ts +15 -3
- package/src/internal/page/navigation.ts +3 -12
- package/src/internal/pictures/capture.ts +11 -4
- package/src/internal/pictures/clock.ts +8 -3
- package/src/internal/pictures/motion.ts +65 -0
- package/src/internal/pictures/pictures.ts +22 -17
- package/src/internal/pictures/utilsBundle.d.ts +9 -0
- package/src/internal/reading/controls.inpage.ts +8 -1
- package/src/internal/reading/match.inpage.ts +2 -1
- package/src/internal/reading/outline.inpage.ts +7 -4
- package/src/internal/reading/reading.ts +10 -25
- package/src/internal/reading/ready.ts +66 -14
- package/src/internal/supervisor/lifecycle.ts +56 -13
- package/src/internal/timeline/events.ts +5 -8
- package/src/internal/timeline/window.ts +3 -8
- package/dist/Browser-BBSySyjT.mjs.map +0 -1
- package/dist/Page-B5cjaUp5.mjs.map +0 -1
- package/dist/Plan-CXA2BW-G.mjs.map +0 -1
- package/dist/Supervisor-DaGlQVjv.mjs.map +0 -1
- package/dist/context-BAjRj7Xo.mjs.map +0 -1
- 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 {
|
|
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 =
|
|
49
|
+
const invalid = refuse(
|
|
51
50
|
operation,
|
|
52
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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,
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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 = (
|
|
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 } =
|
|
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
|
-
(
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
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:
|
|
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 {
|
|
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
|
|
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 = () =>
|
|
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
|
|
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:
|
|
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 = () =>
|
|
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 = () =>
|
|
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
|
-
*
|
|
3
|
-
* estimate ages, and the
|
|
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 {
|
|
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 &
|
|
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*
|
|
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*
|
|
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
|
-
:
|
|
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 =
|
|
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:
|
|
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
|
|
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,
|