effect-browser 0.3.0-beta.16 → 0.3.0-beta.20
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 +14 -7
- package/dist/{Browser-DRPthkNO.mjs → Browser-BwCldIff.mjs} +6 -5
- package/dist/Browser-BwCldIff.mjs.map +1 -0
- package/dist/Browser.d.mts +1 -1
- package/dist/Browser.mjs +1 -1
- package/dist/Cdp.mjs +2 -2
- 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/{Page-C_VkBVwI.mjs → Page-CfB6Zn1p.mjs} +3 -7
- package/dist/Page-CfB6Zn1p.mjs.map +1 -0
- package/dist/{Page-jHmSJeOS.d.mts → Page-ku6j5t-v.d.mts} +18 -13
- package/dist/Page.d.mts +1 -1
- package/dist/Page.mjs +1 -1
- package/dist/{Plan-X-JkACAB.mjs → Plan-C-d5QYS8.mjs} +3 -3
- package/dist/{Plan-X-JkACAB.mjs.map → Plan-C-d5QYS8.mjs.map} +1 -1
- package/dist/{Plan-DLtRksuu.d.mts → Plan-DJ4hEEna.d.mts} +2 -2
- 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 +1 -1
- package/dist/Presentation.mjs +2 -2
- package/dist/Stage.d.mts +1 -1
- package/dist/Stage.mjs +7 -6
- package/dist/Stage.mjs.map +1 -1
- package/dist/{Supervisor-BgWP8o9t.mjs → Supervisor-2mPjIYYs.mjs} +4 -3
- package/dist/{Supervisor-BgWP8o9t.mjs.map → Supervisor-2mPjIYYs.mjs.map} +1 -1
- package/dist/Supervisor.mjs +1 -1
- package/dist/{context-rYTk-3Cv.mjs → context-w3NJPRLo.mjs} +15 -8
- package/dist/context-w3NJPRLo.mjs.map +1 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +4 -4
- package/dist/{page-ClEMFnBG.mjs → page-BKwfF6z-.mjs} +169 -112
- package/dist/page-BKwfF6z-.mjs.map +1 -0
- package/package.json +4 -4
- package/src/Moment.ts +3 -6
- package/src/Page.ts +13 -11
- package/src/Stage.ts +8 -16
- package/src/Supervisor.ts +6 -7
- package/src/internal/input/actions.ts +28 -64
- package/src/internal/input/dispatch.ts +22 -7
- package/src/internal/input/edit.inpage.ts +23 -2
- package/src/internal/input/evidence.inpage.ts +0 -2
- package/src/internal/input/guard.ts +3 -9
- 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 +2 -5
- package/src/internal/input/targets.ts +5 -21
- package/src/internal/page/bridge.ts +2 -2
- package/src/internal/page/context.ts +13 -1
- package/src/internal/page/identity.ts +2 -4
- package/src/internal/page/navigation.ts +3 -12
- package/src/internal/pictures/capture.ts +7 -0
- package/src/internal/pictures/motion.ts +65 -0
- package/src/internal/pictures/pictures.ts +20 -16
- package/src/internal/pictures/utilsBundle.d.ts +9 -0
- package/src/internal/reading/reading.ts +5 -9
- package/src/internal/reading/ready.ts +66 -14
- package/src/internal/timeline/events.ts +5 -8
- package/src/internal/timeline/window.ts +3 -8
- package/dist/Browser-DRPthkNO.mjs.map +0 -1
- package/dist/Page-C_VkBVwI.mjs.map +0 -1
- package/dist/context-rYTk-3Cv.mjs.map +0 -1
- package/dist/page-ClEMFnBG.mjs.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "effect-browser",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.20",
|
|
4
4
|
"description": "Effect browser sessions, page control, screencast and moments over Playwright and CDP.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -115,9 +115,9 @@
|
|
|
115
115
|
"test": "vp test --run"
|
|
116
116
|
},
|
|
117
117
|
"devDependencies": {
|
|
118
|
-
"@effect/vitest": "4.0.
|
|
118
|
+
"@effect/vitest": "4.0.3",
|
|
119
119
|
"@types/node": "26.1.2",
|
|
120
|
-
"effect": "4.0.
|
|
120
|
+
"effect": "4.0.3",
|
|
121
121
|
"playwright-core": "1.63.0",
|
|
122
122
|
"typescript": "7.0.2",
|
|
123
123
|
"vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
"vitest": "5.0.3"
|
|
126
126
|
},
|
|
127
127
|
"peerDependencies": {
|
|
128
|
-
"effect": "~4.0.
|
|
128
|
+
"effect": "~4.0.3",
|
|
129
129
|
"playwright-core": "^1.63.0"
|
|
130
130
|
},
|
|
131
131
|
"engines": {
|
package/src/Moment.ts
CHANGED
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
import { Duration, Effect, Option, Result, Schema } from "effect";
|
|
28
28
|
import { Prompt } from "effect/ai";
|
|
29
29
|
|
|
30
|
-
import { BrowserError
|
|
30
|
+
import { BrowserError } from "./BrowserError.ts";
|
|
31
31
|
import { type Action, BrowserEvent } from "./BrowserEvent.ts";
|
|
32
32
|
import { type Change, Changes } from "./Change.ts";
|
|
33
33
|
import { Frame } from "./Frame.ts";
|
|
34
|
-
import {
|
|
34
|
+
import { refuse } from "./internal/page/context.ts";
|
|
35
35
|
import { describe, named, quoted, where } from "./internal/timeline/words.ts";
|
|
36
36
|
import type * as Page from "./Page.ts";
|
|
37
37
|
import { Snapshot, type SnapshotOptions } from "./Snapshot.ts";
|
|
@@ -116,10 +116,7 @@ export const capture = Effect.fn("Moment.capture")(function* (
|
|
|
116
116
|
const count = options.frames ?? 2;
|
|
117
117
|
|
|
118
118
|
if (!Number.isSafeInteger(count) || count < 1)
|
|
119
|
-
return yield*
|
|
120
|
-
"moment",
|
|
121
|
-
new InvalidRequest({ detail: "frames must be a positive safe integer" }),
|
|
122
|
-
);
|
|
119
|
+
return yield* refuse("moment", "frames must be a positive safe integer");
|
|
123
120
|
const outline = options.snapshot ?? false;
|
|
124
121
|
|
|
125
122
|
// The picture comes first, at no call with a running screencast, and the window ends at its
|
package/src/Page.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { type Duration, Effect, Schema, type Stream } from "effect";
|
|
|
19
19
|
import type { Page as PlaywrightPage } from "playwright-core";
|
|
20
20
|
|
|
21
21
|
import { type BrowserError, type PolicyDenied } from "./BrowserError.ts";
|
|
22
|
-
import { Box, type BrowserEvent, PageLoaded, Subject
|
|
22
|
+
import { Box, type BrowserEvent, PageLoaded, Subject } from "./BrowserEvent.ts";
|
|
23
23
|
import type { Changes } from "./Change.ts";
|
|
24
24
|
import { type CaptureStats, Frame, Image, type ScreencastOptions } from "./Frame.ts";
|
|
25
25
|
import { FormFieldSchema } from "./internal/input/evidence.inpage.ts";
|
|
@@ -53,12 +53,8 @@ export class Zoom extends Schema.Class<Zoom>("effect-browser/Zoom")({
|
|
|
53
53
|
/** The target as it read before input. Point coordinates are never snapped to another position. */
|
|
54
54
|
export class ResolvedTarget extends Schema.Class<ResolvedTarget>("effect-browser/ResolvedTarget")({
|
|
55
55
|
point: Motion.Point,
|
|
56
|
+
...Subject.fields,
|
|
56
57
|
element: Schema.String,
|
|
57
|
-
/** The element's lowercase tag name. */
|
|
58
|
-
tag: Schema.String,
|
|
59
|
-
role: Schema.NullOr(Schema.String),
|
|
60
|
-
name: Schema.String,
|
|
61
|
-
context: SubjectContext,
|
|
62
58
|
/** For a point target, the box of what it found there. */
|
|
63
59
|
box: Schema.optional(Box),
|
|
64
60
|
cursor: Schema.String,
|
|
@@ -222,8 +218,11 @@ export interface WaitCondition {
|
|
|
222
218
|
}
|
|
223
219
|
|
|
224
220
|
export interface ReadyOptions {
|
|
225
|
-
/**
|
|
226
|
-
|
|
221
|
+
/**
|
|
222
|
+
* What must stay still during quietMillis: screen paint (default); recorded content; or the
|
|
223
|
+
* `"foreground"`, the screen but for what was already moving before the page's latest input.
|
|
224
|
+
*/
|
|
225
|
+
readonly still?: "screen" | "content" | "foreground" | undefined;
|
|
227
226
|
/** How long the selected evidence must stay still; screen paint by default. */
|
|
228
227
|
readonly quietMillis?: number | undefined;
|
|
229
228
|
readonly timeout?: Duration.Input | undefined;
|
|
@@ -493,9 +492,12 @@ export interface Page {
|
|
|
493
492
|
* view have loaded, and the viewport shows something. With `quietMillis`, the screen must then
|
|
494
493
|
* also stay still that long: nothing in view changed, where the page's changes are recorded, and
|
|
495
494
|
* no frame comes, counted from the first frame of a capture the wait starts itself. A canvas
|
|
496
|
-
* that keeps drawing, such as a live chart, is never still
|
|
497
|
-
*
|
|
498
|
-
*
|
|
495
|
+
* that keeps drawing, such as a live chart, is never still. With `still: "foreground"`, frames
|
|
496
|
+
* may keep coming, but nothing may move in them outside what moved in the frames of the 1.5 s
|
|
497
|
+
* before the page's latest input, such as an idle animation; reels set spinning, or a counter
|
|
498
|
+
* set rolling, by the input keep the wait going. Without those frames, as where they are older
|
|
499
|
+
* than the browser's `frameHistory`, nothing may move at all. A page is never ready without a
|
|
500
|
+
* painted frame, as a hidden tab may be. `Timeout` after `timeout`, 15 seconds by default.
|
|
499
501
|
*/
|
|
500
502
|
readonly ready: (options?: ReadyOptions) => Effect.Effect<void, BrowserError>;
|
|
501
503
|
|
package/src/Stage.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
|
|
25
25
|
import { type BrowserError, Closed, consequence, InvalidRequest, Timeout } from "./BrowserError.ts";
|
|
26
26
|
import type { CaptureStats, Frame, ScreencastOptions } from "./Frame.ts";
|
|
27
|
-
import { failWith, undispatched } from "./internal/page/context.ts";
|
|
27
|
+
import { failWith, refuse, undispatched } from "./internal/page/context.ts";
|
|
28
28
|
import { internalsOf } from "./internal/page/page.ts";
|
|
29
29
|
import { monotonicMillis } from "./internal/pictures/clock.ts";
|
|
30
30
|
import type { Page } from "./Page.ts";
|
|
@@ -240,16 +240,14 @@ const evidence = (
|
|
|
240
240
|
|
|
241
241
|
const probe = Effect.gen(function* () {
|
|
242
242
|
yield* until(Math.max(checkedAt, current()?.changedAt ?? checkedAt) + millis, now);
|
|
243
|
-
checkedAt = now();
|
|
244
243
|
const before = current();
|
|
245
244
|
const frame = before?.frame;
|
|
246
245
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
)
|
|
246
|
+
// Paint during the wait moves the deadline; an early wake is not a silence check.
|
|
247
|
+
if (before !== undefined && now() - before.changedAt < millis)
|
|
248
|
+
return Option.none<Evidence>();
|
|
249
|
+
checkedAt = now();
|
|
250
|
+
if (before === undefined || frame === undefined || gone === before.feed)
|
|
253
251
|
return Option.none<Evidence>();
|
|
254
252
|
const { feed } = before;
|
|
255
253
|
|
|
@@ -399,10 +397,7 @@ export const make = Effect.fn("Stage.make")(function* (
|
|
|
399
397
|
!Duration.isFinite(waited.value) ||
|
|
400
398
|
!Duration.isPositive(waited.value)
|
|
401
399
|
)
|
|
402
|
-
return yield*
|
|
403
|
-
"stage",
|
|
404
|
-
new InvalidRequest({ detail: "firstFrame must be a finite positive duration" }),
|
|
405
|
-
);
|
|
400
|
+
return yield* refuse("stage", "firstFrame must be a finite positive duration");
|
|
406
401
|
const firstFrame = waited.value;
|
|
407
402
|
const settings = { quality: options.quality, size: options.size };
|
|
408
403
|
const output = yield* PubSub.sliding<Paint>(16);
|
|
@@ -434,10 +429,7 @@ export const make = Effect.fn("Stage.make")(function* (
|
|
|
434
429
|
return switching(
|
|
435
430
|
Effect.gen(function* () {
|
|
436
431
|
if (!Number.isFinite(asked))
|
|
437
|
-
return yield*
|
|
438
|
-
"cue",
|
|
439
|
-
new InvalidRequest({ detail: "at must be finite host milliseconds" }),
|
|
440
|
-
);
|
|
432
|
+
return yield* refuse("cue", "at must be finite host milliseconds");
|
|
441
433
|
yield* firstOf(feed, firstFrame);
|
|
442
434
|
const old = showing;
|
|
443
435
|
|
package/src/Supervisor.ts
CHANGED
|
@@ -598,21 +598,20 @@ const makeScoped = Effect.fn("Supervisor.make")(function* <E, R, A>(
|
|
|
598
598
|
|
|
599
599
|
const retire = yield* Effect.cached(stop({ _tag: "Retire" }));
|
|
600
600
|
|
|
601
|
+
// The browser of the generation serving, or of the one `target` names once it serves.
|
|
602
|
+
const browserOf = (target?: number) =>
|
|
603
|
+
Effect.map(waitFor(state, waitTimeout, clock, target), (live) => live.value.opened.browser);
|
|
604
|
+
|
|
601
605
|
yield* Effect.addFinalizer(() => (options.keep === true ? stop({ _tag: "Keep" }) : retire));
|
|
602
606
|
yield* perform(started);
|
|
603
607
|
|
|
604
608
|
return {
|
|
605
|
-
browser:
|
|
609
|
+
browser: browserOf(),
|
|
606
610
|
use: leasing(state, apply, waitTimeout, now, clock),
|
|
607
611
|
states: Stream.fromPubSubTake(events),
|
|
608
612
|
rotate: apply({ _tag: "Rotate" }).pipe(
|
|
609
613
|
Effect.flatMap(({ reply }) =>
|
|
610
|
-
reply === undefined
|
|
611
|
-
? Effect.fail(retired())
|
|
612
|
-
: Effect.map(
|
|
613
|
-
waitFor(state, waitTimeout, clock, reply),
|
|
614
|
-
(live) => live.value.opened.browser,
|
|
615
|
-
),
|
|
614
|
+
reply === undefined ? Effect.fail(retired()) : browserOf(reply),
|
|
616
615
|
),
|
|
617
616
|
),
|
|
618
617
|
retire,
|
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { Duration, Effect } from "effect";
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { NotActionable, StaleRef } from "../../BrowserError.ts";
|
|
10
10
|
import type { ClickOptions, PressOptions, ScrollOptions, Target, TypeOptions } from "../../Page.ts";
|
|
11
11
|
import { type Bridge, scriptCall } from "../page/bridge.ts";
|
|
12
|
-
import { decodeWith, failWith, type PageContext } from "../page/context.ts";
|
|
12
|
+
import { decodeWith, failWith, type PageContext, refuse } from "../page/context.ts";
|
|
13
13
|
import type { Viewport } from "../page/viewport.ts";
|
|
14
14
|
import * as Dispatch from "./dispatch.ts";
|
|
15
|
+
import { buttonAt } from "./dispatch.ts";
|
|
15
16
|
import * as Script from "./edit.inpage.ts";
|
|
16
17
|
import * as Guard from "./guard.ts";
|
|
17
18
|
import * as Keyboard from "./keyboard.ts";
|
|
@@ -22,8 +23,6 @@ import * as Settle from "./settle.ts";
|
|
|
22
23
|
import { plain, type Style } from "./style.ts";
|
|
23
24
|
import * as Targets from "./targets.ts";
|
|
24
25
|
|
|
25
|
-
const buttonMask = { none: 0, left: 1, right: 2, middle: 4 } as const;
|
|
26
|
-
|
|
27
26
|
const partsOf = (page: PageContext, bridge: Bridge, viewport: Viewport) => {
|
|
28
27
|
const dispatch = Dispatch.make(page);
|
|
29
28
|
const pointer = Pointer.make(page, dispatch, viewport);
|
|
@@ -52,25 +51,23 @@ const finite = (...values: ReadonlyArray<number | undefined>) =>
|
|
|
52
51
|
|
|
53
52
|
const named = (target: Target) => (typeof target === "string" ? target : `${target.x},${target.y}`);
|
|
54
53
|
|
|
54
|
+
/** The ref a target names, if it names one rather than a point. */
|
|
55
|
+
const refOf = (target: Target | undefined) => (typeof target === "string" ? target : undefined);
|
|
56
|
+
|
|
55
57
|
/** A click's options, once its counts are finite. */
|
|
56
58
|
const clickable = (options: ClickOptions) =>
|
|
57
59
|
finite(options.clickCount, options.holdMillis)
|
|
58
60
|
? Effect.void
|
|
59
|
-
:
|
|
61
|
+
: refuse("click", "clickCount and holdMillis must be finite");
|
|
60
62
|
|
|
61
63
|
/** The keys a press sends, once they and its options are valid. */
|
|
62
64
|
const pressed = (keys: string, options: PressOptions) => {
|
|
63
65
|
const parts = Keys.parts(keys);
|
|
64
66
|
|
|
65
67
|
if (!finite(options.times, options.holdMillis))
|
|
66
|
-
return
|
|
68
|
+
return refuse("press", "times and holdMillis must be finite");
|
|
67
69
|
if (parts === undefined)
|
|
68
|
-
return
|
|
69
|
-
"press",
|
|
70
|
-
new InvalidRequest({
|
|
71
|
-
detail: `"${keys}" is not a key; try Enter, Space, ArrowLeft or Control+A`,
|
|
72
|
-
}),
|
|
73
|
-
);
|
|
70
|
+
return refuse("press", `"${keys}" is not a key; try Enter, Space, ArrowLeft or Control+A`);
|
|
74
71
|
|
|
75
72
|
return Effect.succeed(parts);
|
|
76
73
|
};
|
|
@@ -92,11 +89,7 @@ const click = (input: Parts) => {
|
|
|
92
89
|
},
|
|
93
90
|
settings.actionTimeout,
|
|
94
91
|
Effect.suspend(() => clickable(clickOptions)).pipe(
|
|
95
|
-
Effect.andThen(
|
|
96
|
-
preparePolicy("click", { target: typeof target === "string" ? target : undefined }, [
|
|
97
|
-
target,
|
|
98
|
-
]),
|
|
99
|
-
),
|
|
92
|
+
Effect.andThen(preparePolicy("click", { target: refOf(target) }, [target])),
|
|
100
93
|
),
|
|
101
94
|
(marks, approval) =>
|
|
102
95
|
Effect.gen(function* () {
|
|
@@ -134,26 +127,14 @@ const click = (input: Parts) => {
|
|
|
134
127
|
),
|
|
135
128
|
),
|
|
136
129
|
);
|
|
137
|
-
yield* sendMouse("click", marks.input,
|
|
138
|
-
type: "mousePressed",
|
|
139
|
-
...point,
|
|
140
|
-
button,
|
|
141
|
-
buttons: buttonMask[button],
|
|
142
|
-
clickCount: index,
|
|
143
|
-
});
|
|
130
|
+
yield* sendMouse("click", marks.input, buttonAt("mousePressed", point, button, index));
|
|
144
131
|
|
|
145
132
|
// A hold the caller asks for is the click's own; the style's is presentation.
|
|
146
133
|
const hold = clickOptions.holdMillis ?? (yield* marks.style.hold);
|
|
147
134
|
|
|
148
135
|
if (clickOptions.holdMillis === undefined) yield* marks.present(hold);
|
|
149
136
|
if (hold > 0) yield* Effect.sleep(Duration.millis(hold));
|
|
150
|
-
yield* sendMouse("click", marks.input,
|
|
151
|
-
type: "mouseReleased",
|
|
152
|
-
...point,
|
|
153
|
-
button,
|
|
154
|
-
buttons: 0,
|
|
155
|
-
clickCount: index,
|
|
156
|
-
});
|
|
137
|
+
yield* sendMouse("click", marks.input, buttonAt("mouseReleased", point, button, index));
|
|
157
138
|
}
|
|
158
139
|
yield* flush("click", marks.input);
|
|
159
140
|
yield* settle("click", before);
|
|
@@ -173,7 +154,7 @@ const hover = (input: Parts) => {
|
|
|
173
154
|
"hover",
|
|
174
155
|
{ target: named(target) },
|
|
175
156
|
settings.actionTimeout,
|
|
176
|
-
preparePolicy("hover", { target:
|
|
157
|
+
preparePolicy("hover", { target: refOf(target) }, [target]),
|
|
177
158
|
(marks, approval) =>
|
|
178
159
|
Effect.gen(function* () {
|
|
179
160
|
const resolved = yield* targetFor("hover", target, approval, marks);
|
|
@@ -244,21 +225,9 @@ const drag = (input: Parts) => {
|
|
|
244
225
|
],
|
|
245
226
|
});
|
|
246
227
|
yield* marks.sent;
|
|
247
|
-
yield* sendMouse("drag", marks.input,
|
|
248
|
-
type: "mousePressed",
|
|
249
|
-
...start.point,
|
|
250
|
-
button: "left",
|
|
251
|
-
buttons: 1,
|
|
252
|
-
clickCount: 1,
|
|
253
|
-
});
|
|
228
|
+
yield* sendMouse("drag", marks.input, buttonAt("mousePressed", start.point));
|
|
254
229
|
yield* moveTo("drag", marks, end.point, end.cursor, "drag");
|
|
255
|
-
yield* sendMouse("drag", marks.input,
|
|
256
|
-
type: "mouseReleased",
|
|
257
|
-
...end.point,
|
|
258
|
-
button: "left",
|
|
259
|
-
buttons: 0,
|
|
260
|
-
clickCount: 1,
|
|
261
|
-
});
|
|
230
|
+
yield* sendMouse("drag", marks.input, buttonAt("mouseReleased", end.point));
|
|
262
231
|
yield* flush("drag", marks.input);
|
|
263
232
|
}),
|
|
264
233
|
style,
|
|
@@ -292,10 +261,7 @@ const typeText = (input: Parts) => {
|
|
|
292
261
|
const into = typeOptions.into;
|
|
293
262
|
|
|
294
263
|
if (into !== undefined && !/^e\d+$/.test(into))
|
|
295
|
-
return yield*
|
|
296
|
-
"type",
|
|
297
|
-
new InvalidRequest({ detail: `"${into}" is not a ref; refs look like e12` }),
|
|
298
|
-
);
|
|
264
|
+
return yield* refuse("type", `"${into}" is not a ref; refs look like e12`);
|
|
299
265
|
|
|
300
266
|
// A typed space or letter can press a focused button or change a control. Refuse
|
|
301
267
|
// those before any input, on every path.
|
|
@@ -328,22 +294,24 @@ const typeText = (input: Parts) => {
|
|
|
328
294
|
yield* Guard.check(approval, { presses: [{ index: 0, ...target.point }] });
|
|
329
295
|
yield* marks.sent;
|
|
330
296
|
for (const type of ["mousePressed", "mouseReleased"] as const)
|
|
331
|
-
yield* sendMouse("type", marks.input,
|
|
332
|
-
type,
|
|
333
|
-
...target.point,
|
|
334
|
-
button: "left",
|
|
335
|
-
buttons: type === "mousePressed" ? 1 : 0,
|
|
336
|
-
clickCount: 1,
|
|
337
|
-
});
|
|
297
|
+
yield* sendMouse("type", marks.input, buttonAt(type, target.point));
|
|
338
298
|
yield* flush("type", marks.input);
|
|
339
299
|
}
|
|
340
300
|
|
|
341
301
|
// Focusing can run page handlers, including navigation, before the script returns.
|
|
342
302
|
yield* marks.sent;
|
|
343
303
|
|
|
304
|
+
// A page may answer the press with another field of the same name in the field's
|
|
305
|
+
// place, focused, as a typeahead does: the keys follow it, unless a guard approved
|
|
306
|
+
// this field and no other, or the field is secret, as its replacement may not be.
|
|
307
|
+
const follow =
|
|
308
|
+
marks.style.shown && approval === undefined && !secret && target.name !== ""
|
|
309
|
+
? target.name
|
|
310
|
+
: null;
|
|
311
|
+
|
|
344
312
|
const focused = yield* mutate(
|
|
345
313
|
"type",
|
|
346
|
-
scriptCall("focus", into, replace),
|
|
314
|
+
scriptCall("focus", into, replace, follow),
|
|
347
315
|
approval,
|
|
348
316
|
).pipe(Effect.flatMap(decodeWith("type", Script.FocusResultSchema)));
|
|
349
317
|
|
|
@@ -480,7 +448,7 @@ const scroll = (input: Parts) => {
|
|
|
480
448
|
const valid =
|
|
481
449
|
Number.isFinite(scrollOptions.dx ?? 0) && Number.isFinite(scrollOptions.dy ?? 0)
|
|
482
450
|
? Effect.void
|
|
483
|
-
:
|
|
451
|
+
: refuse("scroll", "scroll deltas must be finite");
|
|
484
452
|
|
|
485
453
|
return perform(
|
|
486
454
|
"scroll",
|
|
@@ -492,11 +460,7 @@ const scroll = (input: Parts) => {
|
|
|
492
460
|
valid.pipe(
|
|
493
461
|
Effect.andThen(
|
|
494
462
|
// Scrolling the page has no target: whatever sits mid-viewport is not what is scrolled.
|
|
495
|
-
preparePolicy(
|
|
496
|
-
"scroll",
|
|
497
|
-
{ target: typeof target === "string" ? target : undefined },
|
|
498
|
-
target === undefined ? [] : [target],
|
|
499
|
-
),
|
|
463
|
+
preparePolicy("scroll", { target: refOf(target) }, target === undefined ? [] : [target]),
|
|
500
464
|
),
|
|
501
465
|
),
|
|
502
466
|
(marks, approval) =>
|
|
@@ -26,6 +26,22 @@ export interface MouseEvent extends Point {
|
|
|
26
26
|
readonly deltaY?: number;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
const buttonMask = { none: 0, left: 1, right: 2, middle: 4 } as const;
|
|
30
|
+
|
|
31
|
+
/** A mouse button going down or up at a point, as the `clickCount`th click. */
|
|
32
|
+
export const buttonAt = (
|
|
33
|
+
type: "mousePressed" | "mouseReleased",
|
|
34
|
+
point: Point,
|
|
35
|
+
button: keyof typeof buttonMask = "left",
|
|
36
|
+
clickCount = 1,
|
|
37
|
+
): MouseEvent => ({
|
|
38
|
+
type,
|
|
39
|
+
...point,
|
|
40
|
+
button,
|
|
41
|
+
buttons: type === "mousePressed" ? buttonMask[button] : 0,
|
|
42
|
+
clickCount,
|
|
43
|
+
});
|
|
44
|
+
|
|
29
45
|
export const make = (page: PageContext) => {
|
|
30
46
|
const { id, pointer, publish, now, noteInput, closedBy } = page;
|
|
31
47
|
const { send } = page.protocol;
|
|
@@ -98,13 +114,12 @@ export const make = (page: PageContext) => {
|
|
|
98
114
|
() => dispatchMouse(event, estimate, submitted),
|
|
99
115
|
() =>
|
|
100
116
|
dispatchMouse(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
},
|
|
117
|
+
buttonAt(
|
|
118
|
+
"mouseReleased",
|
|
119
|
+
Option.getOrElse(MutableRef.get(pointer), () => ({ x: event.x, y: event.y })),
|
|
120
|
+
event.button,
|
|
121
|
+
event.clickCount,
|
|
122
|
+
),
|
|
108
123
|
estimate,
|
|
109
124
|
),
|
|
110
125
|
);
|
|
@@ -26,6 +26,7 @@ export const edit = (names: Names, guard: Guard, placing: ContextReader) => {
|
|
|
26
26
|
activeElement,
|
|
27
27
|
clean,
|
|
28
28
|
describe,
|
|
29
|
+
inCurrentDocument,
|
|
29
30
|
isDisabled,
|
|
30
31
|
isHtml,
|
|
31
32
|
isInput,
|
|
@@ -72,8 +73,28 @@ export const edit = (names: Names, guard: Guard, placing: ContextReader) => {
|
|
|
72
73
|
};
|
|
73
74
|
};
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
/**
|
|
77
|
+
* The focused field, when it is a text field named `name`: one a page put in place of a field
|
|
78
|
+
* just pressed and gave focus to, as a typeahead does.
|
|
79
|
+
*/
|
|
80
|
+
const replacement = (name: string): Element | undefined => {
|
|
81
|
+
const active = activeElement();
|
|
82
|
+
|
|
83
|
+
return active !== null &&
|
|
84
|
+
textEntry(active) &&
|
|
85
|
+
inCurrentDocument(active) &&
|
|
86
|
+
nameOf(active, roleOf(active)) === name
|
|
87
|
+
? active
|
|
88
|
+
: undefined;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Focus a field to type into. `follow` is the field's name when the action pressed it first: a
|
|
93
|
+
* page may answer the press with another field of that name in its place, focused, and the keys
|
|
94
|
+
* follow focus there, as a person's would.
|
|
95
|
+
*/
|
|
96
|
+
const focus = (ref: string, replace: boolean, follow: string | null): FocusResult => {
|
|
97
|
+
const element = lookup(ref) ?? (follow === null ? undefined : replacement(follow));
|
|
77
98
|
|
|
78
99
|
if (element === undefined) return { error: `${ref} is not on the page any more`, stale: true };
|
|
79
100
|
if (isDisabled(element)) return { error: `${ref} is disabled` };
|
|
@@ -7,8 +7,6 @@ import { Schema } from "effect";
|
|
|
7
7
|
import type { ContextReader } from "../reading/context.inpage.ts";
|
|
8
8
|
import type { Names } from "../reading/names.inpage.ts";
|
|
9
9
|
|
|
10
|
-
export type FormField = typeof FormFieldSchema.Encoded;
|
|
11
|
-
|
|
12
10
|
/** Page text about the first target. It never includes what a field holds. */
|
|
13
11
|
export type Evidence = typeof EvidenceSchema.Encoded;
|
|
14
12
|
|
|
@@ -8,7 +8,7 @@ import { Effect } from "effect";
|
|
|
8
8
|
import { type BrowserError, InvalidRequest, NotActionable, StaleRef } from "../../BrowserError.ts";
|
|
9
9
|
import { InputRequest, redacted } from "../../Page.ts";
|
|
10
10
|
import { type Bridge, scriptCall } from "../page/bridge.ts";
|
|
11
|
-
import { contextGone, decodeWith, failWith, type PageContext } from "../page/context.ts";
|
|
11
|
+
import { contextGone, decodeWith, failWith, type PageContext, refuse } from "../page/context.ts";
|
|
12
12
|
import * as Url from "../page/url.ts";
|
|
13
13
|
import * as Script from "./guard.inpage.ts";
|
|
14
14
|
|
|
@@ -74,19 +74,13 @@ export const make = (page: PageContext, bridge: Bridge) => {
|
|
|
74
74
|
Effect.gen(function* () {
|
|
75
75
|
for (const target of targets) {
|
|
76
76
|
if (typeof target === "string" && !/^e\d+$/.test(target))
|
|
77
|
-
return yield*
|
|
78
|
-
action,
|
|
79
|
-
new InvalidRequest({ detail: `"${target}" is not a ref; refs look like e12` }),
|
|
80
|
-
);
|
|
77
|
+
return yield* refuse(action, `"${target}" is not a ref; refs look like e12`);
|
|
81
78
|
if (
|
|
82
79
|
target !== null &&
|
|
83
80
|
typeof target !== "string" &&
|
|
84
81
|
(!Number.isFinite(target.x) || !Number.isFinite(target.y))
|
|
85
82
|
)
|
|
86
|
-
return yield*
|
|
87
|
-
action,
|
|
88
|
-
new InvalidRequest({ detail: "point coordinates must be finite" }),
|
|
89
|
-
);
|
|
83
|
+
return yield* refuse(action, "point coordinates must be finite");
|
|
90
84
|
}
|
|
91
85
|
|
|
92
86
|
const input: Script.InputPlan = {
|
|
@@ -137,19 +137,14 @@ const useRun = <A>(
|
|
|
137
137
|
name: string,
|
|
138
138
|
run: Replies.Run,
|
|
139
139
|
action: Effect.Effect<A, BrowserError>,
|
|
140
|
+
canStop: Effect.Effect<boolean>,
|
|
140
141
|
) =>
|
|
141
142
|
action.pipe(
|
|
142
|
-
Effect.
|
|
143
|
-
|
|
144
|
-
run.close.pipe(
|
|
145
|
-
Effect.andThen(sender.flush(name, run).pipe(Effect.ignore)),
|
|
146
|
-
Effect.andThen(Effect.fail(error)),
|
|
147
|
-
),
|
|
148
|
-
onSuccess: (value) =>
|
|
149
|
-
run.close.pipe(Effect.andThen(sender.flush(name, run)), Effect.as(value)),
|
|
150
|
-
}),
|
|
143
|
+
Effect.tapError(() => run.close.pipe(Effect.andThen(sender.flush(name, run)), Effect.ignore)),
|
|
144
|
+
Effect.tap(() => run.close.pipe(Effect.andThen(sender.flush(name, run)))),
|
|
151
145
|
// Cleanup submits missing releases; the page retains replies after caller interruption.
|
|
152
146
|
Effect.ensuring(run.close.pipe(Effect.andThen(page.openings.clear))),
|
|
147
|
+
Effect.onError(() => run.recover(page.stopLoading, page.scope).pipe(Effect.when(canStop))),
|
|
153
148
|
Effect.provideService(Replies.Current, run),
|
|
154
149
|
);
|
|
155
150
|
|
|
@@ -224,7 +219,16 @@ export const make = (page: PageContext, sender: Dispatch) => {
|
|
|
224
219
|
if (estimate !== undefined) inputClocks.set(current, estimate);
|
|
225
220
|
}),
|
|
226
221
|
),
|
|
227
|
-
Effect.flatMap((run) =>
|
|
222
|
+
Effect.flatMap((run) =>
|
|
223
|
+
useRun(
|
|
224
|
+
page,
|
|
225
|
+
sender,
|
|
226
|
+
name,
|
|
227
|
+
run,
|
|
228
|
+
action(run),
|
|
229
|
+
info.input === false ? Effect.succeed(false) : Ref.get(sent),
|
|
230
|
+
),
|
|
231
|
+
),
|
|
228
232
|
);
|
|
229
233
|
|
|
230
234
|
// How long its turn and the page's replies kept the action waiting, once it had both.
|
|
@@ -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
|
);
|