effect-browser 0.2.0-beta.9 → 0.3.0-beta.10

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 (204) hide show
  1. package/README.md +849 -1161
  2. package/dist/Browser-S4kvpmqa.mjs +605 -0
  3. package/dist/Browser-S4kvpmqa.mjs.map +1 -0
  4. package/dist/Browser.d.mts +127 -3
  5. package/dist/Browser.mjs +2 -22
  6. package/dist/BrowserError-Coy7EW1N.d.mts +140 -0
  7. package/dist/BrowserError.d.mts +2 -0
  8. package/dist/BrowserError.mjs +211 -0
  9. package/dist/BrowserError.mjs.map +1 -0
  10. package/dist/BrowserEvent-BRu2juEz.d.mts +279 -0
  11. package/dist/BrowserEvent.d.mts +2 -0
  12. package/dist/BrowserEvent.mjs +297 -0
  13. package/dist/BrowserEvent.mjs.map +1 -0
  14. package/dist/Cdp.d.mts +32 -0
  15. package/dist/Cdp.mjs +61 -0
  16. package/dist/Cdp.mjs.map +1 -0
  17. package/dist/Change-DNvztNLu.mjs +138 -0
  18. package/dist/Change-DNvztNLu.mjs.map +1 -0
  19. package/dist/Change-iMlcuzVD.d.mts +71 -0
  20. package/dist/Change.d.mts +2 -0
  21. package/dist/Change.mjs +2 -0
  22. package/dist/Chromium.d.mts +29 -81
  23. package/dist/Chromium.mjs +57 -392
  24. package/dist/Chromium.mjs.map +1 -1
  25. package/dist/Expect.d.mts +62 -0
  26. package/dist/Expect.mjs +114 -0
  27. package/dist/Expect.mjs.map +1 -0
  28. package/dist/Frame-BERmf_gM.d.mts +117 -0
  29. package/dist/Frame.d.mts +2 -0
  30. package/dist/Frame.mjs +123 -0
  31. package/dist/Frame.mjs.map +1 -0
  32. package/dist/Moment.d.mts +2 -0
  33. package/dist/Moment.mjs +288 -0
  34. package/dist/Moment.mjs.map +1 -0
  35. package/dist/Motion.d.mts +40 -0
  36. package/dist/Motion.mjs +122 -0
  37. package/dist/Motion.mjs.map +1 -0
  38. package/dist/Page-B5cjaUp5.mjs +618 -0
  39. package/dist/Page-B5cjaUp5.mjs.map +1 -0
  40. package/dist/Page-BlPjfibv.d.mts +595 -0
  41. package/dist/Page.d.mts +2 -0
  42. package/dist/Page.mjs +2 -0
  43. package/dist/Plan-BI8XGvDu.d.mts +209 -0
  44. package/dist/Plan-D1wrJVs8.mjs +508 -0
  45. package/dist/Plan-D1wrJVs8.mjs.map +1 -0
  46. package/dist/Plan.d.mts +2 -3
  47. package/dist/Plan.mjs +2 -2
  48. package/dist/Policy.d.mts +76 -0
  49. package/dist/Policy.mjs +237 -0
  50. package/dist/Policy.mjs.map +1 -0
  51. package/dist/Presentation.d.mts +48 -0
  52. package/dist/Presentation.mjs +117 -0
  53. package/dist/Presentation.mjs.map +1 -0
  54. package/dist/Snapshot-B34hZ4Jl.d.mts +68 -0
  55. package/dist/Snapshot.d.mts +2 -0
  56. package/dist/Snapshot.mjs +79 -0
  57. package/dist/Snapshot.mjs.map +1 -0
  58. package/dist/Stage.d.mts +64 -0
  59. package/dist/Stage.mjs +237 -0
  60. package/dist/Stage.mjs.map +1 -0
  61. package/dist/Supervisor-CYWFK3vM.d.mts +280 -0
  62. package/dist/Supervisor-TNKNOVzu.mjs +599 -0
  63. package/dist/Supervisor-TNKNOVzu.mjs.map +1 -0
  64. package/dist/Supervisor.d.mts +2 -0
  65. package/dist/Supervisor.mjs +2 -0
  66. package/dist/context-B6xr8nVi.mjs +261 -0
  67. package/dist/context-B6xr8nVi.mjs.map +1 -0
  68. package/dist/index.d.mts +17 -14
  69. package/dist/index.mjs +18 -13
  70. package/dist/match.inpage-xOe9LNO0.mjs +48 -0
  71. package/dist/match.inpage-xOe9LNO0.mjs.map +1 -0
  72. package/dist/page-B8tUQELg.mjs +5073 -0
  73. package/dist/page-B8tUQELg.mjs.map +1 -0
  74. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  75. package/dist/url-Cd04Okid.mjs +264 -0
  76. package/dist/url-Cd04Okid.mjs.map +1 -0
  77. package/package.json +96 -61
  78. package/src/Browser.ts +666 -0
  79. package/src/BrowserError.ts +234 -0
  80. package/src/BrowserEvent.ts +296 -0
  81. package/src/Cdp.ts +101 -0
  82. package/src/Change.ts +88 -0
  83. package/src/Chromium.ts +102 -0
  84. package/src/Expect.ts +180 -0
  85. package/src/Frame.ts +130 -0
  86. package/src/Moment.ts +479 -0
  87. package/src/Motion.ts +166 -0
  88. package/src/Page.ts +522 -0
  89. package/src/Plan.ts +663 -0
  90. package/src/Policy.ts +325 -0
  91. package/src/Presentation.ts +195 -0
  92. package/src/Snapshot.ts +89 -0
  93. package/src/Stage.ts +467 -0
  94. package/src/Supervisor.ts +599 -0
  95. package/src/index.ts +20 -0
  96. package/src/internal/input/actions.ts +610 -0
  97. package/src/internal/input/dispatch.ts +161 -0
  98. package/src/internal/input/edit.inpage.ts +162 -0
  99. package/src/internal/input/evidence.inpage.ts +164 -0
  100. package/src/internal/input/gate.ts +224 -0
  101. package/src/internal/input/guard.inpage.ts +605 -0
  102. package/src/internal/input/guard.ts +184 -0
  103. package/src/internal/input/keyboard.ts +112 -0
  104. package/src/internal/input/keys.ts +127 -0
  105. package/src/internal/input/perform.ts +376 -0
  106. package/src/internal/input/pointer.ts +153 -0
  107. package/src/internal/input/replies.ts +195 -0
  108. package/src/internal/input/settle.ts +50 -0
  109. package/src/internal/input/style.ts +186 -0
  110. package/src/internal/input/targets.inpage.ts +293 -0
  111. package/src/internal/input/targets.ts +148 -0
  112. package/src/internal/page/bridge.ts +386 -0
  113. package/src/internal/page/context.ts +312 -0
  114. package/src/internal/page/lane.ts +311 -0
  115. package/src/internal/page/navigation.ts +183 -0
  116. package/src/internal/page/openings.ts +87 -0
  117. package/src/internal/page/page.ts +165 -0
  118. package/src/internal/page/url.ts +100 -0
  119. package/src/internal/page/viewport.ts +43 -0
  120. package/src/internal/pictures/capture.ts +776 -0
  121. package/src/internal/pictures/clock.ts +141 -0
  122. package/src/internal/pictures/counts.ts +92 -0
  123. package/src/internal/pictures/pictures.ts +520 -0
  124. package/src/internal/pictures/transport.ts +242 -0
  125. package/src/internal/reading/choose.ts +100 -0
  126. package/src/internal/reading/context.inpage.ts +201 -0
  127. package/src/internal/reading/controls.inpage.ts +158 -0
  128. package/src/internal/reading/expect.inpage.ts +70 -0
  129. package/src/internal/reading/match.inpage.ts +92 -0
  130. package/src/internal/reading/names.inpage.ts +306 -0
  131. package/src/internal/reading/outline.inpage.ts +260 -0
  132. package/src/internal/reading/reading.ts +223 -0
  133. package/src/internal/reading/ready.inpage.ts +164 -0
  134. package/src/internal/reading/ready.ts +111 -0
  135. package/src/internal/reading/subjects.inpage.ts +188 -0
  136. package/src/internal/reading/text.inpage.ts +135 -0
  137. package/src/internal/reading/walk.inpage.ts +220 -0
  138. package/src/internal/supervisor/lifecycle.ts +416 -0
  139. package/src/internal/timeline/changes.inpage.ts +216 -0
  140. package/src/internal/timeline/changes.ts +100 -0
  141. package/src/internal/timeline/events.ts +94 -0
  142. package/src/internal/timeline/fold.inpage.ts +123 -0
  143. package/src/internal/timeline/history.inpage.ts +229 -0
  144. package/src/internal/timeline/marks.inpage.ts +207 -0
  145. package/src/internal/timeline/record.inpage.ts +133 -0
  146. package/src/internal/timeline/sight.inpage.ts +122 -0
  147. package/src/internal/timeline/window.ts +96 -0
  148. package/src/internal/timeline/words.ts +65 -0
  149. package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
  150. package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
  151. package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
  152. package/dist/Bootstrap.d.mts +0 -3
  153. package/dist/Bootstrap.mjs +0 -2
  154. package/dist/Browser-BF9_sORP.d.mts +0 -388
  155. package/dist/Browser.mjs.map +0 -1
  156. package/dist/BrowserData-CPIIIHol.d.mts +0 -585
  157. package/dist/BrowserData.d.mts +0 -3
  158. package/dist/BrowserData.mjs +0 -695
  159. package/dist/BrowserData.mjs.map +0 -1
  160. package/dist/BrowserRuntime-B6nT1G4X.d.mts +0 -148
  161. package/dist/BrowserRuntime-C1r2aSRK.mjs +0 -6820
  162. package/dist/BrowserRuntime-C1r2aSRK.mjs.map +0 -1
  163. package/dist/BrowserRuntime.d.mts +0 -3
  164. package/dist/BrowserRuntime.mjs +0 -3
  165. package/dist/Capture.d.mts +0 -56
  166. package/dist/Capture.mjs +0 -678
  167. package/dist/Capture.mjs.map +0 -1
  168. package/dist/CaptureData-CYriZHX1.mjs +0 -201
  169. package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
  170. package/dist/CaptureData.d.mts +0 -219
  171. package/dist/CaptureData.mjs +0 -2
  172. package/dist/Errors.d.mts +0 -158
  173. package/dist/Errors.mjs +0 -207
  174. package/dist/Errors.mjs.map +0 -1
  175. package/dist/Images-yCG9BmRU.mjs +0 -76
  176. package/dist/Images-yCG9BmRU.mjs.map +0 -1
  177. package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
  178. package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
  179. package/dist/PageControl.d.mts +0 -15
  180. package/dist/PageControl.mjs +0 -34
  181. package/dist/PageControl.mjs.map +0 -1
  182. package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
  183. package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
  184. package/dist/Plan-Dyxu2q8C.mjs +0 -543
  185. package/dist/Plan-Dyxu2q8C.mjs.map +0 -1
  186. package/dist/PlanData-B668RxbQ.d.mts +0 -4831
  187. package/dist/PlanData.d.mts +0 -3
  188. package/dist/PlanData.mjs +0 -268
  189. package/dist/PlanData.mjs.map +0 -1
  190. package/dist/PlanInput-BJcPPncs.mjs +0 -75
  191. package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
  192. package/dist/PublicSession-DCSMMqOj.mjs +0 -5602
  193. package/dist/PublicSession-DCSMMqOj.mjs.map +0 -1
  194. package/dist/Testing.d.mts +0 -479
  195. package/dist/Testing.mjs +0 -2147
  196. package/dist/Testing.mjs.map +0 -1
  197. package/dist/Timeline-fV22Xk-S.d.mts +0 -29
  198. package/dist/Timeline.d.mts +0 -4
  199. package/dist/Timeline.mjs +0 -20
  200. package/dist/Timeline.mjs.map +0 -1
  201. package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
  202. package/dist/TimelineData.d.mts +0 -3
  203. package/dist/TimelineData.mjs +0 -482
  204. package/dist/TimelineData.mjs.map +0 -1
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Performing one action: its turn on the page, the input guard's step, the input run that owns its
3
+ * replies, and the action's record. Its deadline leaves out the time its style takes to show it.
4
+ */
5
+ import { Deferred, Duration, Effect, Exit, Option, Ref } from "effect";
6
+
7
+ import { BrowserError, PolicyTimeout, Timeout } from "../../BrowserError.ts";
8
+ import { Action, type ActionOptions, Subject } from "../../BrowserEvent.ts";
9
+ import { type Point, redacted, type ResolvedTarget } from "../../Page.ts";
10
+ import { failWith, type PageContext, undispatched } from "../page/context.ts";
11
+ import { Correlation } from "../page/lane.ts";
12
+ import { redact } from "../page/url.ts";
13
+ import type * as BrowserClock from "../pictures/clock.ts";
14
+ import type { Dispatch } from "./dispatch.ts";
15
+ import type { Approval, PolicyPlan } from "./guard.ts";
16
+ import * as Replies from "./replies.ts";
17
+ import { plain, type Style } from "./style.ts";
18
+
19
+ export interface InputMarks {
20
+ /** How the action's input is sent: plainly, or performed for viewers. */
21
+ readonly style: Style;
22
+ /**
23
+ * Time the style spends showing the input, such as a glide or typing at a person's pace, planned
24
+ * from now: the deadline moves back by as much, so presentation has a budget of its own.
25
+ */
26
+ readonly present: (millis: number) => Effect.Effect<void>;
27
+ /** The action's own input, such as a press, key or wheel, has reached the page. */
28
+ readonly sent: Effect.Effect<void>;
29
+ /** Preparatory input, such as the pointer travelling to a target, has reached the page. */
30
+ readonly touched: Effect.Effect<void>;
31
+ readonly at: (point: Point) => Effect.Effect<void>;
32
+ /** What the input acts on, and for a drag where it ends, as the page names them now. */
33
+ readonly on: (subject: Named, to?: Named) => Effect.Effect<void>;
34
+ /** The action's text is not bound for a secret field, so its record may keep it. */
35
+ readonly reveal: Effect.Effect<void>;
36
+ readonly input: Replies.Run;
37
+ }
38
+
39
+ /** What the page names a target: a resolved one, or the field that has focus. */
40
+ type Named = Pick<ResolvedTarget, "role" | "name" | "tag" | "context" | "box">;
41
+
42
+ const subjectOf = (target: Named) =>
43
+ new Subject({ role: target.role, name: target.name, tag: target.tag, context: target.context });
44
+
45
+ /** What an action records of what it acted on: subjects, and a point target's box. */
46
+ const actedOn = (subject: Named, to?: Named) => ({
47
+ subject: subjectOf(subject),
48
+ box: subject.box,
49
+ ...(to === undefined ? {} : { to: subjectOf(to) }),
50
+ });
51
+
52
+ /** A subject as span attributes under `prefix`, without a role it does not have. */
53
+ const subjectAttributes = (prefix: string, subject: Subject | undefined) =>
54
+ subject === undefined
55
+ ? {}
56
+ : {
57
+ ...(subject.role === null ? {} : { [`${prefix}.role`]: subject.role }),
58
+ [`${prefix}.name`]: subject.name,
59
+ [`${prefix}.tag`]: subject.tag,
60
+ };
61
+
62
+ /** What an action's record keeps of its call. */
63
+ export interface Call {
64
+ readonly target?: string | undefined;
65
+ /** The rest of what the caller asked, recorded so a plan can ask it again. */
66
+ readonly options?: ActionOptions | undefined;
67
+ readonly text?: string | undefined;
68
+ /** The text may be a secret: it is recorded only once the action reveals that it is not. */
69
+ readonly secret?: boolean | undefined;
70
+ /** False for navigation, which sends no input. */
71
+ readonly input?: boolean | undefined;
72
+ /** False for the pointer's travel ahead of an action, which is no action of its own. */
73
+ readonly recorded?: boolean | undefined;
74
+ }
75
+
76
+ const timedOut = (operation: string, timeout: Duration.Duration) =>
77
+ failWith(operation, new Timeout({ millis: Duration.toMillis(timeout) }));
78
+
79
+ /**
80
+ * Fail `effect` with `Timeout` once `timeout` has passed, and as much later again as `presented`
81
+ * says presentation has planned meanwhile.
82
+ */
83
+ const deadline =
84
+ (operation: string, timeout: Duration.Duration, now: () => number, presented: () => number) =>
85
+ <A>(effect: Effect.Effect<A, BrowserError>) =>
86
+ Effect.suspend(() => {
87
+ const due = now() + Duration.toMillis(timeout);
88
+
89
+ const expiry: Effect.Effect<never, BrowserError> = Effect.suspend(() => {
90
+ const left = due + presented() - now();
91
+
92
+ return left <= 0
93
+ ? timedOut(operation, timeout)
94
+ : Effect.sleep(Duration.millis(left)).pipe(Effect.andThen(expiry));
95
+ });
96
+
97
+ return Effect.raceFirst(effect, expiry);
98
+ });
99
+
100
+ /** An admitted operation stays owned through its Action and any unanswered native replies. */
101
+ const admission = (page: PageContext, pending: Set<Deferred.Deferred<void>>, name: string) => {
102
+ let run: Replies.Run | undefined;
103
+ let end: (() => void) | undefined;
104
+
105
+ const enter = Effect.uninterruptible(
106
+ Effect.gen(function* () {
107
+ const released = page.inputGate === undefined ? undefined : yield* page.inputGate.enter(name);
108
+ const done = Deferred.makeUnsafe<void>();
109
+
110
+ pending.add(done);
111
+ end = () => {
112
+ const finish = () => {
113
+ released?.();
114
+ pending.delete(done);
115
+ Deferred.doneUnsafe(done, Exit.void);
116
+ };
117
+
118
+ if (run === undefined) finish();
119
+ else run.afterIdle(finish);
120
+ };
121
+ }),
122
+ );
123
+
124
+ return {
125
+ enter,
126
+ using: (current: Replies.Run) => {
127
+ run = current;
128
+ },
129
+ end: Effect.sync(() => end?.()),
130
+ };
131
+ };
132
+
133
+ /** Close a run on every exit while preserving its native replies for the page's next input. */
134
+ const useRun = <A>(
135
+ page: PageContext,
136
+ sender: Dispatch,
137
+ name: string,
138
+ run: Replies.Run,
139
+ action: Effect.Effect<A, BrowserError>,
140
+ ) =>
141
+ action.pipe(
142
+ Effect.matchEffect({
143
+ onFailure: (error) =>
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
+ }),
151
+ // Cleanup submits missing releases; the page retains replies after caller interruption.
152
+ Effect.ensuring(run.close.pipe(Effect.andThen(page.openings.clear))),
153
+ Effect.provideService(Replies.Current, run),
154
+ );
155
+
156
+ export const make = (page: PageContext, sender: Dispatch) => {
157
+ const { id, settings, mapping, lane, publish, now, noteInput, span, owned } = page;
158
+ const { inputClocks } = sender;
159
+ const input = Replies.make();
160
+ const admitted = new Set<Deferred.Deferred<void>>();
161
+
162
+ // Record the whole operation, but never hold the page's turn or spend its action timeout while
163
+ // a policy is waiting. Validation binds approval to the document and targets it saw.
164
+ const perform = <A>(
165
+ name: string,
166
+ info: Call,
167
+ timeout: Duration.Duration,
168
+ prepare: Effect.Effect<PolicyPlan, BrowserError>,
169
+ body: (marks: InputMarks, approval: Approval | undefined) => Effect.Effect<A, BrowserError>,
170
+ style: Style = plain,
171
+ ): Effect.Effect<A, BrowserError> =>
172
+ Effect.gen(function* () {
173
+ const startedAt = now();
174
+ const sent = yield* Ref.make(false);
175
+ const at = yield* Ref.make(Option.none<Point>());
176
+ const acted = yield* Ref.make<Pick<Action, "subject" | "to" | "box">>({});
177
+
178
+ // The page may react to preparatory input, as to script changes such as a selection, so
179
+ // paint from before either is not current; only the action's own input can have given it
180
+ // effect.
181
+ const touch = Effect.sync(noteInput);
182
+
183
+ // A failure before the action checks its field, a denial included, records no text.
184
+ let revealed = info.secret !== true;
185
+ // The time the style has planned to show the input, which the deadline leaves out.
186
+ let presented = 0;
187
+ const ticket = admission(page, admitted, name);
188
+
189
+ const marks = {
190
+ style,
191
+ present: (millis: number) =>
192
+ Effect.sync(() => {
193
+ presented += Math.max(0, millis);
194
+ }),
195
+ sent: Ref.set(sent, true).pipe(Effect.andThen(touch)),
196
+ touched: touch,
197
+ at: (point: Point) => Ref.set(at, Option.some(point)),
198
+ on: (subject: Named, to?: Named) => Ref.set(acted, actedOn(subject, to)),
199
+ reveal: Effect.sync(() => {
200
+ revealed = true;
201
+ }),
202
+ };
203
+
204
+ // Admission waits only on this page's own unresolved replies, in its turn, so a stalled page
205
+ // cannot delay input on other pages. Input never waits for the clock mapping: until a
206
+ // capture has measured one, it keeps Chromium's own receipt time.
207
+ let estimate: BrowserClock.Estimate | undefined;
208
+
209
+ const admit = input.idle.pipe(
210
+ Effect.tap(() =>
211
+ Effect.sync(() => {
212
+ estimate = (info.input ?? true) ? mapping.latest() : undefined;
213
+ }),
214
+ ),
215
+ );
216
+
217
+ const useInput = <Value>(action: (run: Replies.Run) => Effect.Effect<Value, BrowserError>) =>
218
+ input.begin.pipe(
219
+ // A capture can recalibrate while input is running. One run, including delayed cleanup
220
+ // releases, keeps one mapping so epoch stamps cannot jump backwards during a stroke.
221
+ Effect.tap((current) =>
222
+ Effect.sync(() => {
223
+ ticket.using(current);
224
+ if (estimate !== undefined) inputClocks.set(current, estimate);
225
+ }),
226
+ ),
227
+ Effect.flatMap((run) => useRun(page, sender, name, run, action(run))),
228
+ );
229
+
230
+ // How long its turn and the page's replies kept the action waiting, once it had both.
231
+ let queuedMillis: number | undefined;
232
+
233
+ // The page's turn and its earlier input's replies are waited for within the action's
234
+ // deadline, undispatched: a turn not given by then is `Busy`, and replies not answered by
235
+ // then a `Timeout`. The turn starts a full deadline of its own, so a wait never truncates
236
+ // input under way.
237
+ const dispatch = <Value>(action: Effect.Effect<Value, BrowserError>) =>
238
+ Effect.suspend(() => {
239
+ const asked = now();
240
+
241
+ return lane.write(
242
+ name,
243
+ timeout,
244
+ )(
245
+ ticket.enter.pipe(
246
+ Effect.andThen(
247
+ Effect.suspend(() =>
248
+ admit.pipe(
249
+ Effect.timeoutOrElse({
250
+ duration: Duration.millis(
251
+ Math.max(0, Duration.toMillis(timeout) - (now() - asked)),
252
+ ),
253
+ orElse: () => timedOut(name, timeout),
254
+ }),
255
+ ),
256
+ ),
257
+ ),
258
+ Effect.tap(() =>
259
+ Effect.sync(() => {
260
+ queuedMillis = Math.round(now() - asked);
261
+ }),
262
+ ),
263
+ Effect.andThen(deadline(name, timeout, now, () => presented)(action)),
264
+ ),
265
+ );
266
+ });
267
+
268
+ const guard = settings.guard;
269
+
270
+ // Only an approval needs binding: without a guard, input goes straight to the page.
271
+ const action =
272
+ guard === undefined
273
+ ? dispatch(useInput((run) => body({ ...marks, input: run }, undefined)))
274
+ : Effect.gen(function* () {
275
+ const plan = yield* lane
276
+ .read(name)(deadline(name, settings.actionTimeout, now, () => 0)(prepare))
277
+ .pipe(span("Page.prepare", {}, "Debug"));
278
+
279
+ // A hold lasts as long as this span; a judge's model call is its child.
280
+ yield* guard(plan.request).pipe(
281
+ Effect.mapError((reason) => undispatched(name, reason)),
282
+ Effect.timeoutOrElse({
283
+ duration: settings.policyTimeout,
284
+ orElse: () =>
285
+ failWith(
286
+ name,
287
+ new PolicyTimeout({ millis: Duration.toMillis(settings.policyTimeout) }),
288
+ ),
289
+ }),
290
+ span("Page.guard"),
291
+ );
292
+
293
+ return yield* dispatch(
294
+ useInput((run) =>
295
+ plan.validate.pipe(
296
+ Effect.flatMap((approval) => body({ ...marks, input: run }, approval)),
297
+ ),
298
+ ),
299
+ );
300
+ });
301
+
302
+ const record = info.recorded === false ? () => 0 : publish;
303
+
304
+ // Record the outcome even when the caller interrupts: its input may already be in the page.
305
+ return yield* Effect.uninterruptibleMask((restore) =>
306
+ Effect.gen(function* () {
307
+ const exit = yield* Effect.exit(
308
+ restore((page.inputGate?.check(name) ?? Effect.void).pipe(Effect.andThen(action))),
309
+ );
310
+
311
+ const dispatched = yield* Ref.get(sent);
312
+ const point = yield* Ref.get(at);
313
+ const { subject, to, box } = yield* Ref.get(acted);
314
+ const failure = Exit.isFailure(exit) ? Exit.findErrorOption(exit) : Option.none();
315
+
316
+ // What the Action records, less its text: only a revealed text's length.
317
+ yield* Effect.annotateCurrentSpan({
318
+ dispatched,
319
+ ...(queuedMillis === undefined ? {} : { queuedMillis }),
320
+ ...(info.text !== undefined && revealed ? { chars: info.text.length } : {}),
321
+ ...subjectAttributes("subject", subject),
322
+ ...subjectAttributes("to", to),
323
+ ...Option.match(failure, {
324
+ onNone: () => ({}),
325
+ onSome: (error) => ({ "error.type": error.reason._tag }),
326
+ }),
327
+ });
328
+
329
+ record(
330
+ new Action({
331
+ at: now(),
332
+ startedAt,
333
+ page: id,
334
+ name,
335
+ url: redact(page.playwright.url()),
336
+ correlation: yield* Correlation,
337
+ target: info.target,
338
+ options: Exit.isSuccess(exit) ? info.options : undefined,
339
+ subject,
340
+ to,
341
+ box,
342
+ text:
343
+ info.text === undefined ? undefined : revealed ? info.text.slice(0, 200) : redacted,
344
+ x: Option.getOrUndefined(Option.map(point, (p) => p.x)),
345
+ y: Option.getOrUndefined(Option.map(point, (p) => p.y)),
346
+ ok: Exit.isSuccess(exit),
347
+ dispatched,
348
+ error: Option.match(failure, {
349
+ onNone: () => (Exit.hasInterrupts(exit) ? "interrupted" : undefined),
350
+ onSome: (error) => error.message,
351
+ }),
352
+ }),
353
+ );
354
+ if (Exit.isSuccess(exit)) return exit.value;
355
+ if (dispatched)
356
+ return yield* Exit.mapError(exit, (error) =>
357
+ error.dispatched
358
+ ? error
359
+ : new BrowserError({
360
+ operation: error.operation,
361
+ reason: error.reason,
362
+ dispatched: true,
363
+ }),
364
+ );
365
+
366
+ return yield* exit;
367
+ }),
368
+ ).pipe(Effect.ensuring(ticket.end));
369
+ }).pipe(span(`Page.${name}`, info.target === undefined ? {} : { target: info.target }), owned);
370
+
371
+ return Object.assign(perform, {
372
+ idle: Effect.suspend(() => Effect.forEach(admitted, Deferred.await, { discard: true })),
373
+ });
374
+ };
375
+
376
+ export type Perform = ReturnType<typeof make>;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The pointer's travel and the wheel, as the action's style sends them. Travel follows a motion
3
+ * plan, published before its first move, from where viewers last saw the pointer when it is
4
+ * shown, and a wheel scroll goes in the style's steps.
5
+ */
6
+ import { Duration, Effect, MutableRef, Option, Schema } from "effect";
7
+
8
+ import { InvalidRequest } from "../../BrowserError.ts";
9
+ import { CursorChanged, TrackPerformed, TrackPlanned } from "../../BrowserEvent.ts";
10
+ import * as Motion from "../../Motion.ts";
11
+ import type { Point } from "../../Page.ts";
12
+ import { failWith, type PageContext } from "../page/context.ts";
13
+ import type { Viewport } from "../page/viewport.ts";
14
+ import type { Dispatch } from "./dispatch.ts";
15
+ import type { InputMarks } from "./perform.ts";
16
+
17
+ export const make = (page: PageContext, dispatch: Dispatch, viewport: Viewport) => {
18
+ const { id, pointer, publish, now, span } = page;
19
+ const { inputClocks, inputCall, dispatchMouse, sendMouse } = dispatch;
20
+
21
+ // Travel toward a press only prepares the action; a hover is its travel, and a drag holds the
22
+ // button it has already pressed.
23
+ const moveTo = (
24
+ operation: string,
25
+ marks: InputMarks,
26
+ to: Point,
27
+ cursor?: string,
28
+ travel: "approach" | "hover" | "drag" = "approach",
29
+ ) =>
30
+ Effect.gen(function* () {
31
+ const dragging = travel === "drag";
32
+ const { style } = marks;
33
+ const drawn = style.pointer ?? pointer;
34
+ const size = yield* viewport.viewportFor(operation);
35
+
36
+ const previous = Option.getOrElse(MutableRef.get(drawn), () => ({
37
+ x: Math.round(size.width / 2),
38
+ y: Math.round(size.height / 2),
39
+ }));
40
+
41
+ const from = {
42
+ x: Math.max(0, Math.min(size.width - 1, previous.x)),
43
+ y: Math.max(0, Math.min(size.height - 1, previous.y)),
44
+ };
45
+
46
+ const planned = yield* style.glide(from, to, dragging);
47
+
48
+ // A custom planner is a boundary. Decoding reads each sample once into a fresh copy and
49
+ // checks that copy, so neither accessors nor later mutation can change what is admitted.
50
+ const invalid = failWith(
51
+ operation,
52
+ new InvalidRequest({
53
+ detail: "motion must be a bounded, ordered schedule ending at the target",
54
+ }),
55
+ );
56
+
57
+ const samples = yield* Schema.decodeEffect(Motion.Plan)(planned).pipe(
58
+ Effect.catch(() => invalid),
59
+ );
60
+
61
+ if (samples.at(-1)?.x !== to.x || samples.at(-1)?.y !== to.y) return yield* invalid;
62
+ yield* Effect.annotateCurrentSpan("samples", samples.length);
63
+ yield* marks.present(samples.at(-1)?.afterMillis ?? 0);
64
+ const run = marks.input;
65
+
66
+ // Admit the whole motion before starting its clock. Dense original samples must not be
67
+ // stretched by per-sample reply backpressure; unsent reservations belong to this run.
68
+ yield* inputCall(operation, run.reserveMotion(samples.length));
69
+ MutableRef.set(pointer, Option.some(from));
70
+ const estimate = inputClocks.get(run);
71
+ const at = now();
72
+ let dispatched = 0;
73
+ let last = from;
74
+
75
+ // The complete plan exists before its first move. A canceled performer always clips
76
+ // that future to the prefix it actually submitted, including interruption during sleep.
77
+ yield* Effect.acquireUseRelease(
78
+ Effect.sync(() => publish(new TrackPlanned({ at, page: id, from, samples }))),
79
+ () =>
80
+ Effect.gen(function* () {
81
+ for (const sample of samples) {
82
+ const remaining = at + sample.afterMillis - now();
83
+
84
+ if (remaining > 0) yield* Effect.sleep(Duration.millis(remaining));
85
+ yield* travel === "approach" ? marks.touched : marks.sent;
86
+ yield* inputCall(
87
+ operation,
88
+ run.send(() =>
89
+ dispatchMouse(
90
+ {
91
+ type: "mouseMoved",
92
+ x: sample.x,
93
+ y: sample.y,
94
+ button: dragging ? "left" : "none",
95
+ ...(dragging ? { buttons: 1 } : {}),
96
+ },
97
+ estimate,
98
+ () => {
99
+ dispatched++;
100
+ last = { x: sample.x, y: sample.y };
101
+ MutableRef.set(drawn, Option.some(last));
102
+ if (dispatched === samples.length && cursor !== undefined)
103
+ publish(new CursorChanged({ at: now(), page: id, cursor }));
104
+ },
105
+ ),
106
+ ),
107
+ );
108
+ }
109
+ }),
110
+ (plan) =>
111
+ Effect.sync(() =>
112
+ publish(
113
+ new TrackPerformed({
114
+ at: now(),
115
+ page: id,
116
+ plan,
117
+ dispatched,
118
+ ...last,
119
+ complete: dispatched === samples.length,
120
+ }),
121
+ ),
122
+ ),
123
+ );
124
+ }).pipe(span("Page.move", {}, "Debug"));
125
+
126
+ const wheel = Effect.fnUntraced(function* (
127
+ operation: string,
128
+ marks: InputMarks,
129
+ point: Point,
130
+ dx: number,
131
+ dy: number,
132
+ ) {
133
+ const steps = yield* marks.style.wheel(dx, dy);
134
+ const at = now();
135
+
136
+ yield* marks.present(steps.at(-1)?.afterMillis ?? 0);
137
+ for (const step of steps) {
138
+ const remaining = at + step.afterMillis - now();
139
+
140
+ if (remaining > 0) yield* Effect.sleep(Duration.millis(remaining));
141
+ yield* sendMouse(operation, marks.input, {
142
+ type: "mouseWheel",
143
+ ...point,
144
+ deltaX: step.dx,
145
+ deltaY: step.dy,
146
+ });
147
+ }
148
+ });
149
+
150
+ return { moveTo, wheel };
151
+ };
152
+
153
+ export type Pointer = ReturnType<typeof make>;