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

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 (205) hide show
  1. package/README.md +861 -1161
  2. package/dist/Browser-B4qncxe7.mjs +628 -0
  3. package/dist/Browser-B4qncxe7.mjs.map +1 -0
  4. package/dist/Browser.d.mts +129 -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-Di2PxyF7.d.mts +119 -0
  29. package/dist/Frame.d.mts +2 -0
  30. package/dist/Frame.mjs +125 -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-B7X6DQUn.d.mts +595 -0
  41. package/dist/Page.d.mts +2 -0
  42. package/dist/Page.mjs +2 -0
  43. package/dist/Plan-Akcac63g.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-BAf__WKX.mjs +5075 -0
  73. package/dist/page-BAf__WKX.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 +672 -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 +132 -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 +391 -0
  113. package/src/internal/page/context.ts +314 -0
  114. package/src/internal/page/identity.ts +35 -0
  115. package/src/internal/page/lane.ts +311 -0
  116. package/src/internal/page/navigation.ts +183 -0
  117. package/src/internal/page/openings.ts +87 -0
  118. package/src/internal/page/page.ts +165 -0
  119. package/src/internal/page/url.ts +100 -0
  120. package/src/internal/page/viewport.ts +43 -0
  121. package/src/internal/pictures/capture.ts +776 -0
  122. package/src/internal/pictures/clock.ts +141 -0
  123. package/src/internal/pictures/counts.ts +92 -0
  124. package/src/internal/pictures/pictures.ts +521 -0
  125. package/src/internal/pictures/transport.ts +243 -0
  126. package/src/internal/reading/choose.ts +100 -0
  127. package/src/internal/reading/context.inpage.ts +201 -0
  128. package/src/internal/reading/controls.inpage.ts +158 -0
  129. package/src/internal/reading/expect.inpage.ts +70 -0
  130. package/src/internal/reading/match.inpage.ts +92 -0
  131. package/src/internal/reading/names.inpage.ts +306 -0
  132. package/src/internal/reading/outline.inpage.ts +260 -0
  133. package/src/internal/reading/reading.ts +223 -0
  134. package/src/internal/reading/ready.inpage.ts +164 -0
  135. package/src/internal/reading/ready.ts +111 -0
  136. package/src/internal/reading/subjects.inpage.ts +188 -0
  137. package/src/internal/reading/text.inpage.ts +135 -0
  138. package/src/internal/reading/walk.inpage.ts +220 -0
  139. package/src/internal/supervisor/lifecycle.ts +416 -0
  140. package/src/internal/timeline/changes.inpage.ts +216 -0
  141. package/src/internal/timeline/changes.ts +100 -0
  142. package/src/internal/timeline/events.ts +94 -0
  143. package/src/internal/timeline/fold.inpage.ts +123 -0
  144. package/src/internal/timeline/history.inpage.ts +229 -0
  145. package/src/internal/timeline/marks.inpage.ts +207 -0
  146. package/src/internal/timeline/record.inpage.ts +133 -0
  147. package/src/internal/timeline/sight.inpage.ts +122 -0
  148. package/src/internal/timeline/window.ts +96 -0
  149. package/src/internal/timeline/words.ts +65 -0
  150. package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
  151. package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
  152. package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
  153. package/dist/Bootstrap.d.mts +0 -3
  154. package/dist/Bootstrap.mjs +0 -2
  155. package/dist/Browser-BF9_sORP.d.mts +0 -388
  156. package/dist/Browser.mjs.map +0 -1
  157. package/dist/BrowserData-CPIIIHol.d.mts +0 -585
  158. package/dist/BrowserData.d.mts +0 -3
  159. package/dist/BrowserData.mjs +0 -695
  160. package/dist/BrowserData.mjs.map +0 -1
  161. package/dist/BrowserRuntime-B6nT1G4X.d.mts +0 -148
  162. package/dist/BrowserRuntime-C1r2aSRK.mjs +0 -6820
  163. package/dist/BrowserRuntime-C1r2aSRK.mjs.map +0 -1
  164. package/dist/BrowserRuntime.d.mts +0 -3
  165. package/dist/BrowserRuntime.mjs +0 -3
  166. package/dist/Capture.d.mts +0 -56
  167. package/dist/Capture.mjs +0 -678
  168. package/dist/Capture.mjs.map +0 -1
  169. package/dist/CaptureData-CYriZHX1.mjs +0 -201
  170. package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
  171. package/dist/CaptureData.d.mts +0 -219
  172. package/dist/CaptureData.mjs +0 -2
  173. package/dist/Errors.d.mts +0 -158
  174. package/dist/Errors.mjs +0 -207
  175. package/dist/Errors.mjs.map +0 -1
  176. package/dist/Images-yCG9BmRU.mjs +0 -76
  177. package/dist/Images-yCG9BmRU.mjs.map +0 -1
  178. package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
  179. package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
  180. package/dist/PageControl.d.mts +0 -15
  181. package/dist/PageControl.mjs +0 -34
  182. package/dist/PageControl.mjs.map +0 -1
  183. package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
  184. package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
  185. package/dist/Plan-Dyxu2q8C.mjs +0 -543
  186. package/dist/Plan-Dyxu2q8C.mjs.map +0 -1
  187. package/dist/PlanData-B668RxbQ.d.mts +0 -4831
  188. package/dist/PlanData.d.mts +0 -3
  189. package/dist/PlanData.mjs +0 -268
  190. package/dist/PlanData.mjs.map +0 -1
  191. package/dist/PlanInput-BJcPPncs.mjs +0 -75
  192. package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
  193. package/dist/PublicSession-DCSMMqOj.mjs +0 -5602
  194. package/dist/PublicSession-DCSMMqOj.mjs.map +0 -1
  195. package/dist/Testing.d.mts +0 -479
  196. package/dist/Testing.mjs +0 -2147
  197. package/dist/Testing.mjs.map +0 -1
  198. package/dist/Timeline-fV22Xk-S.d.mts +0 -29
  199. package/dist/Timeline.d.mts +0 -4
  200. package/dist/Timeline.mjs +0 -20
  201. package/dist/Timeline.mjs.map +0 -1
  202. package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
  203. package/dist/TimelineData.d.mts +0 -3
  204. package/dist/TimelineData.mjs +0 -482
  205. package/dist/TimelineData.mjs.map +0 -1
package/src/Plan.ts ADDED
@@ -0,0 +1,663 @@
1
+ /**
2
+ * A walk recorded once from a page's events and replayed later, on a fresh page, with no model
3
+ * call.
4
+ *
5
+ * `fromEvents` keeps one page's completed actions with their subjects and the options they were
6
+ * given, its navigations with both the address asked for and the one reached, and what was typed
7
+ * as a named input slot, never the text itself. A navigation to an address whose credentials the
8
+ * events withheld is an input slot too, so replay goes where the caller says, or nowhere, and
9
+ * never to the address without them. `replay` takes the steps in turn. Before a step
10
+ * that acts on the page, it waits for `Page.ready`; then it finds the one element the step's
11
+ * subject names, by role and name among those that repeat all its recorded context, and acts on
12
+ * it. Text typed into focus was typed into the focused field, which replay finds the same way, and
13
+ * text typed into a secret field goes into nothing else. An element step never falls back to
14
+ * coordinates, and a step recorded at a point acts there only while the element found for it still
15
+ * covers that point. `take` and `replay` return receipts; failure preserves a stopping point and
16
+ * earlier receipts. Unknown consequential input is never resent. Only a positive durable read-back
17
+ * can reconcile it; restoration follows the same rule. The consumer journals begun steps before
18
+ * calling, treating interruption or a missing receipt as unknown. Plain recordings use version 1;
19
+ * authored expected effects use version 2. Old 0.2 plans and unknown versions do not decode.
20
+ *
21
+ * @since 0.3.0
22
+ */
23
+ import { Duration, Effect, Exit, Option, Schedule, Schema } from "effect";
24
+
25
+ import { BrowserError, InvalidRequest } from "./BrowserError.ts";
26
+ import {
27
+ type Action,
28
+ ActionOptions,
29
+ Box,
30
+ type BrowserEvent,
31
+ type Navigated,
32
+ Subject,
33
+ } from "./BrowserEvent.ts";
34
+ import { undispatched } from "./internal/page/context.ts";
35
+ import { Ambiguous, choose, Drifted, Missing } from "./internal/reading/choose.ts";
36
+ import {
37
+ type Found,
38
+ type Page,
39
+ type Point,
40
+ type ReadyOptions,
41
+ redacted,
42
+ type Target,
43
+ } from "./Page.ts";
44
+
45
+ export { Ambiguous, Drifted, Missing } from "./internal/reading/choose.ts";
46
+
47
+ const ViewportPoint = Schema.Struct({ x: Schema.Finite, y: Schema.Finite });
48
+
49
+ export const StepAction = Schema.Literals([
50
+ "navigate",
51
+ "back",
52
+ "reload",
53
+ "click",
54
+ "hover",
55
+ "drag",
56
+ "type",
57
+ "press",
58
+ "scroll",
59
+ "select",
60
+ ]);
61
+
62
+ export type StepAction = typeof StepAction.Type;
63
+
64
+ const Query = Schema.Struct({
65
+ role: Schema.optional(Schema.String),
66
+ name: Schema.optional(Schema.String),
67
+ text: Schema.optional(Schema.String),
68
+ });
69
+
70
+ /**
71
+ * What a step leaves. `durable` asserts an author-chosen, lasting read-back of this operation,
72
+ * such as its uniquely named saved receipt. A toast or an intermediate address is not durable.
73
+ * Even a durable check's absence never authorizes repeating unknown consequential input.
74
+ */
75
+ export const Expected = Schema.Union([
76
+ Schema.TaggedStruct("Checked", { checked: Schema.Boolean }),
77
+ Schema.TaggedStruct("Appeared", {
78
+ query: Query,
79
+ durable: Schema.optional(Schema.Literal(true)),
80
+ }),
81
+ Schema.TaggedStruct("Gone", {
82
+ query: Query,
83
+ durable: Schema.optional(Schema.Literal(true)),
84
+ }),
85
+ Schema.TaggedStruct("Navigated", {
86
+ url: Schema.optional(Schema.String),
87
+ durable: Schema.optional(Schema.Literal(true)),
88
+ }),
89
+ ]);
90
+
91
+ export type Expected = typeof Expected.Type;
92
+
93
+ /** One recorded step: the action, what it acted on, and what else it was asked. */
94
+ export class Step extends Schema.Class<Step>("effect-browser/Plan/Step")({
95
+ action: StepAction,
96
+ /**
97
+ * The ending address, or a later recorded navigation before the next action began. Empty when
98
+ * unknown; a delayed navigation can be stronger arrival evidence than the action's ending URL.
99
+ */
100
+ url: Schema.String,
101
+ /** A navigation's requested address, or the keys a press pressed. */
102
+ target: Schema.optional(Schema.String),
103
+ subject: Schema.optional(Subject),
104
+ /** Where a drag ended. */
105
+ to: Schema.optional(Subject),
106
+ /** The viewport point a step aimed at, when it aimed at a point, and a drag's end point. */
107
+ point: Schema.optional(ViewportPoint),
108
+ toPoint: Schema.optional(ViewportPoint),
109
+ /** The box of what a step aimed at by point found there, so replay aims at the same place in it. */
110
+ box: Schema.optional(Box),
111
+ /**
112
+ * The input slot whose text a `type` step types, or whose address a `navigate` step goes to, as
113
+ * its recorded address had a credential withheld.
114
+ */
115
+ input: Schema.optional(Schema.String),
116
+ /** The slot's text went into a secret field, so replay types it into nothing else. */
117
+ secret: Schema.optional(Schema.Literal(true)),
118
+ options: Schema.optional(ActionOptions),
119
+ expect: Schema.optional(Expected),
120
+ }) {}
121
+
122
+ const PlanData = Schema.Struct({
123
+ version: Schema.Literals([1, 2]),
124
+ steps: Schema.Array(Step),
125
+ }).check(
126
+ Schema.makeFilter((plan) =>
127
+ plan.version === (plan.steps.some((step) => step.expect !== undefined) ? 2 : 1)
128
+ ? undefined
129
+ : "only plans with expected effects use version 2",
130
+ ),
131
+ );
132
+
133
+ export class Plan extends Schema.Class<Plan>("effect-browser/Plan")(PlanData) {
134
+ /** Choose the encoding version from the steps; simple recordings remain version 1. */
135
+ static new(steps: ReadonlyArray<Step>): Plan {
136
+ return new Plan({ version: steps.some((step) => step.expect !== undefined) ? 2 : 1, steps });
137
+ }
138
+
139
+ /** The input slots its steps type, in order; replay needs text for each. */
140
+ get inputs(): ReadonlyArray<string> {
141
+ return this.steps.flatMap((step) => (step.input === undefined ? [] : [step.input]));
142
+ }
143
+ }
144
+
145
+ /** One completed step, on the page's host clock. `verified` means its expected effect was seen. */
146
+ export class Receipt extends Schema.Class<Receipt>("effect-browser/Plan/Receipt")({
147
+ step: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
148
+ outcome: Schema.Literals(["taken", "verified", "skipped"]),
149
+ startedAt: Schema.Finite,
150
+ at: Schema.Finite,
151
+ }) {}
152
+
153
+ /** Durable stopping data. A begun step without a receipt must be restored as `unknown`. */
154
+ export class Stopped extends Schema.Class<Stopped>("effect-browser/Plan/Stopped")({
155
+ step: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
156
+ input: Schema.Literals(["unsent", "unknown"]),
157
+ }) {}
158
+
159
+ /** Repeating this step could repeat an unresolved effect; the consumer must reconcile it. */
160
+ export class Uncertain extends Schema.TaggedError<Uncertain>()("Uncertain", {
161
+ detail: Schema.String,
162
+ }) {
163
+ override get message() {
164
+ return this.detail;
165
+ }
166
+ }
167
+
168
+ /** Where replay stopped, counting steps from 0, why, and the steps that completed before it. */
169
+ export class ReplayError extends Schema.TaggedError<ReplayError>()("ReplayError", {
170
+ step: Schema.Int,
171
+ reason: Schema.Union([Missing, Ambiguous, Drifted, BrowserError, Uncertain]),
172
+ input: Schema.Literals(["unsent", "unknown"]),
173
+ receipts: Schema.Array(Receipt),
174
+ }) {
175
+ get stopped(): Stopped {
176
+ return new Stopped({ step: this.step, input: this.input });
177
+ }
178
+
179
+ override get message() {
180
+ return `step ${this.step} failed: ${this.reason.message}`;
181
+ }
182
+ }
183
+
184
+ const isStep = (event: BrowserEvent): event is Action & { readonly name: StepAction } =>
185
+ event._tag === "Action" &&
186
+ event.ok &&
187
+ (StepAction.literals as ReadonlyArray<string>).includes(event.name);
188
+
189
+ // A point target reads `x,y`, as `{"x":…,"y":…}` inside a drag's; a ref reads like e12.
190
+ const pointIn = (target: string | undefined): Point | undefined => {
191
+ const parsed = /^\{?(?:"x":)?(-?[\d.]+),(?:"y":)?(-?[\d.]+)\}?$/.exec(target ?? "");
192
+
193
+ return parsed === null ? undefined : { x: Number(parsed[1]), y: Number(parsed[2]) };
194
+ };
195
+
196
+ /** A slot named for the field it fills, such as `email`, unique within the plan. */
197
+ const slotFor = (taken: Set<string>, field: string | undefined) => {
198
+ const base =
199
+ field
200
+ ?.toLowerCase()
201
+ .match(/[\p{L}\p{N}]+/gu)
202
+ ?.join("-") ?? "input";
203
+
204
+ let slot = base;
205
+
206
+ for (let count = 2; taken.has(slot); count++) slot = `${base}-${count}`;
207
+ taken.add(slot);
208
+
209
+ return slot;
210
+ };
211
+
212
+ /**
213
+ * The walk one page's events record: the completed actions of the page that acted first, in
214
+ * order. `page.recentEvents` gives them, or `browser.events()` read until the walk ends.
215
+ */
216
+ export const fromEvents = (events: Iterable<BrowserEvent>): Plan => {
217
+ const all = Array.from(events);
218
+ const page = all.find((event) => event._tag === "Action")?.page;
219
+ const mine = all.filter((event) => "page" in event && event.page === page);
220
+ const steps = mine.filter(isStep);
221
+ const opened = mine.find((event) => event._tag === "PageOpened")?.url ?? "";
222
+ const moves = mine.filter((event): event is Navigated => event._tag === "Navigated");
223
+ const taken = new Set<string>();
224
+
225
+ // Where the page was just before a time: the last address it reached by then.
226
+ const before = (time: number) => moves.findLast((move) => move.at <= time)?.url ?? opened;
227
+
228
+ return new Plan({
229
+ version: 1,
230
+ steps: steps.map((action, index) => {
231
+ const drag = action.name === "drag" ? (action.target ?? "").split(" -> ") : undefined;
232
+ const point = pointIn(drag === undefined ? action.target : drag[0]);
233
+ const until = steps[index + 1]?.startedAt ?? Number.POSITIVE_INFINITY;
234
+ const arrival = moves.findLast((move) => move.at > action.at && move.at <= until);
235
+
236
+ // Only a navigation and a press keep their target: an address, or keys.
237
+ return new Step({
238
+ action: action.name,
239
+ url: arrival?.url ?? action.url ?? before(until),
240
+ target: action.name === "navigate" || action.name === "press" ? action.target : undefined,
241
+ subject: action.subject,
242
+ to: action.to,
243
+ point,
244
+ toPoint: pointIn(drag?.[1]),
245
+ box: point === undefined ? undefined : action.box,
246
+ input:
247
+ action.name === "type"
248
+ ? slotFor(taken, action.subject?.name)
249
+ : action.name === "navigate" && action.target?.includes(redacted) === true
250
+ ? slotFor(taken, "address")
251
+ : undefined,
252
+ secret: action.name === "type" && action.text === redacted ? true : undefined,
253
+ options: action.options,
254
+ });
255
+ }),
256
+ });
257
+ };
258
+
259
+ /**
260
+ * The one element on the page that a subject names, by role, name and context (see `Plan`),
261
+ * looking again for two seconds while the page may still be drawing it.
262
+ */
263
+ export const locate = (page: Page, subject: Subject) =>
264
+ page
265
+ .find({
266
+ name: subject.name,
267
+ scope: "document",
268
+ ...(subject.role === null ? {} : { role: subject.role }),
269
+ })
270
+ .pipe(
271
+ Effect.flatMap((found) => choose(subject, found)),
272
+ Effect.retry({
273
+ schedule: Schedule.spaced(Duration.millis(250)),
274
+ times: 8,
275
+ while: (error) => error._tag !== "BrowserError",
276
+ }),
277
+ Effect.withSpan("Plan.locate", { attributes: { role: subject.role ?? "" } }),
278
+ );
279
+
280
+ const invalid = (detail: string) => undispatched("replay", new InvalidRequest({ detail }));
281
+
282
+ /**
283
+ * Where the step acts: the element its subject names, or, for a step recorded at a point, the same
284
+ * place within that element, as long as a press there reaches that element and not one over it.
285
+ * An element out of view is first brought into it, by hovering it, as a press by ref would be.
286
+ */
287
+ const aim = (page: Page, subject: Subject | undefined, point?: Point, recorded?: Box) => {
288
+ if (subject === undefined) return Effect.fail(invalid("the step names no element"));
289
+ if (point === undefined) return Effect.map(locate(page, subject), ({ ref }): Target => ref);
290
+
291
+ const reach = ({ ref, box }: Found) => {
292
+ const at =
293
+ recorded === undefined || recorded.width <= 0 || recorded.height <= 0
294
+ ? point
295
+ : {
296
+ x: box.x + ((point.x - recorded.x) / recorded.width) * box.width,
297
+ y: box.y + ((point.y - recorded.y) / recorded.height) * box.height,
298
+ };
299
+
300
+ return Effect.map(page.find({ at }), ([reached]) => (reached?.ref === ref ? at : undefined));
301
+ };
302
+
303
+ const missed = new Drifted({ detail: "a press at the subject's point reaches another element" });
304
+
305
+ return locate(page, subject).pipe(
306
+ Effect.flatMap((found) =>
307
+ Effect.flatMap(reach(found), (at) =>
308
+ at !== undefined
309
+ ? Effect.succeed<Target>(at)
310
+ : page.hover(found.ref).pipe(
311
+ Effect.andThen(locate(page, subject)),
312
+ Effect.flatMap(reach),
313
+ Effect.flatMap((again) =>
314
+ again === undefined ? Effect.fail(missed) : Effect.succeed<Target>(again),
315
+ ),
316
+ ),
317
+ ),
318
+ ),
319
+ );
320
+ };
321
+
322
+ const dispatch = (
323
+ page: Page,
324
+ step: Step,
325
+ text: string,
326
+ ): Effect.Effect<void, Missing | Ambiguous | Drifted | BrowserError> => {
327
+ const options = step.options ?? {};
328
+
329
+ switch (step.action) {
330
+ case "navigate":
331
+ return page.goto(step.input === undefined ? (step.target ?? step.url) : text);
332
+ case "back":
333
+ return page.back;
334
+ case "reload":
335
+ return page.reload;
336
+ case "press":
337
+ return step.subject === undefined
338
+ ? page.press(step.target ?? "", options)
339
+ : Effect.flatMap(locate(page, step.subject), ({ ref }) =>
340
+ page.press(step.target ?? "", { ...options, on: ref }),
341
+ );
342
+ case "click":
343
+ return aim(page, step.subject, step.point, step.box).pipe(
344
+ Effect.flatMap((at) => page.click(at, options)),
345
+ Effect.asVoid,
346
+ );
347
+ case "hover":
348
+ return Effect.flatMap(aim(page, step.subject, step.point, step.box), page.hover);
349
+ case "drag":
350
+ return Effect.all([
351
+ aim(page, step.subject, step.point),
352
+ aim(page, step.to, step.toPoint),
353
+ ]).pipe(Effect.flatMap(([from, to]) => page.drag(from, to)));
354
+ case "select":
355
+ return aim(page, step.subject).pipe(
356
+ Effect.flatMap((at) => page.select(typeof at === "string" ? at : "", options.values ?? [])),
357
+ Effect.asVoid,
358
+ );
359
+ // A scroll or a typing step may name no element: the page, or whatever has focus.
360
+ case "scroll":
361
+ return step.subject === undefined
362
+ ? page.scroll(options)
363
+ : Effect.flatMap(aim(page, step.subject, step.point, step.box), (at) =>
364
+ page.scroll({ ...options, at }),
365
+ );
366
+ case "type":
367
+ return step.subject === undefined
368
+ ? page.type(text, { ...options, secret: step.secret })
369
+ : Effect.flatMap(aim(page, step.subject), (into) =>
370
+ page.type(text, {
371
+ ...options,
372
+ into: typeof into === "string" ? into : undefined,
373
+ secret: step.secret,
374
+ }),
375
+ );
376
+ }
377
+ };
378
+
379
+ export interface TakeOptions {
380
+ /** How each step waits for the page first, or false not to wait. */
381
+ readonly settle?: ReadyOptions | false | undefined;
382
+ readonly inputs?: Readonly<Record<string, string>> | undefined;
383
+ /**
384
+ * Bound for recorded arrival and expected effect, each. Defaults to 5 seconds. Zero makes one
385
+ * read without polling; that read retains the page operation's timeout.
386
+ */
387
+ readonly arrival?: Duration.Input | undefined;
388
+ }
389
+
390
+ export interface ReplayOptions extends TakeOptions {
391
+ readonly from?: number | Stopped | undefined;
392
+ }
393
+
394
+ /**
395
+ * Input that can be repeated without repeating a commitment. `take` also validates a Checked
396
+ * target's boolean state before input. Back/reload can resubmit a form.
397
+ */
398
+ export const repeatable = (step: Step): boolean =>
399
+ ["navigate", "hover", "scroll", "select"].includes(step.action) ||
400
+ (step.action === "type" &&
401
+ step.subject !== undefined &&
402
+ step.options?.replace !== false &&
403
+ step.options?.submit !== true) ||
404
+ (step.action === "click" && step.expect?._tag === "Checked");
405
+
406
+ type StepError = Missing | Ambiguous | Drifted | BrowserError | Uncertain;
407
+
408
+ // Zero waits make one read; positive waits bound all reads and never repeat input.
409
+ const within = (check: Effect.Effect<boolean, StepError>, millis: number) =>
410
+ millis === 0
411
+ ? check
412
+ : check.pipe(
413
+ Effect.repeat({ until: (held) => held, schedule: Schedule.spaced(Duration.millis(25)) }),
414
+ Effect.timeoutOrElse({
415
+ duration: Duration.millis(millis),
416
+ orElse: () => Effect.succeed(false),
417
+ }),
418
+ );
419
+
420
+ const sameSite = (url: string, recorded: string) => {
421
+ const origin = URL.parse(recorded)?.origin;
422
+
423
+ return (
424
+ recorded === "" ||
425
+ (origin === undefined || origin === "null"
426
+ ? url === recorded
427
+ : URL.parse(url)?.origin === origin)
428
+ );
429
+ };
430
+
431
+ const arrived = (page: Page, urls: ReadonlyArray<string>, millis: number) =>
432
+ within(
433
+ Effect.map(page.url, (url) => urls.some((recorded) => sameSite(url, recorded))),
434
+ millis,
435
+ ).pipe(
436
+ Effect.filterOrFail(
437
+ (held) => held,
438
+ () =>
439
+ new Drifted({ detail: "the page did not reach the recorded site within the arrival wait" }),
440
+ ),
441
+ Effect.asVoid,
442
+ );
443
+
444
+ const checkEffect = (
445
+ page: Page,
446
+ step: Step,
447
+ first: { readonly url: string; readonly document: number },
448
+ ) => {
449
+ const expected = step.expect;
450
+
451
+ if (expected === undefined) return Effect.succeed(false);
452
+ switch (expected._tag) {
453
+ case "Checked":
454
+ return step.subject === undefined
455
+ ? Effect.fail(invalid("a checked effect needs a subject"))
456
+ : Effect.flatMap(locate(page, step.subject), (found) =>
457
+ found.state.checked === undefined
458
+ ? Effect.fail(invalid("a checked effect needs a checkable subject"))
459
+ : Effect.succeed(found.state.checked === expected.checked),
460
+ );
461
+ case "Navigated":
462
+ return Effect.map(page.state, (now) =>
463
+ expected.url === undefined
464
+ ? now.url !== first.url || now.document !== first.document
465
+ : now.url === expected.url,
466
+ );
467
+ case "Appeared":
468
+ case "Gone":
469
+ return Effect.map(page.find({ ...expected.query, scope: "document" }), (found) =>
470
+ expected._tag === "Appeared" ? found.length > 0 : found.length === 0,
471
+ );
472
+ }
473
+ };
474
+
475
+ /**
476
+ * Take one step. Unknown non-repeatable input is never resent: only positive durable read-back
477
+ * can skip it. An interrupted call remains interrupted; persist begun-before-call in the consumer
478
+ * and treat a missing receipt as Stopped{input: "unknown"}, including after process restart.
479
+ */
480
+ export const take = Effect.fn("Plan.take")(function* (
481
+ page: Page,
482
+ plan: Plan,
483
+ at: number | Stopped,
484
+ options: TakeOptions = {},
485
+ ): Effect.fn.Return<Receipt, ReplayError> {
486
+ const index = typeof at === "number" ? at : at.step;
487
+ let input: Stopped["input"] = typeof at === "number" ? "unsent" : at.input;
488
+
489
+ const work = Effect.gen(function* (): Effect.fn.Return<Receipt, StepError> {
490
+ const step = plan.steps[index];
491
+
492
+ if (!Number.isInteger(index) || index < 0 || step === undefined)
493
+ return yield* invalid("the step index is outside the plan");
494
+ const duration = Duration.fromInput(options.arrival ?? Duration.seconds(5));
495
+
496
+ if (
497
+ Option.isNone(duration) ||
498
+ Duration.isNegative(duration.value) ||
499
+ !Duration.isFinite(duration.value)
500
+ )
501
+ return yield* invalid("arrival must be a finite nonnegative duration");
502
+ const millis = Duration.toMillis(duration.value);
503
+ const first = yield* page.state;
504
+
505
+ const uncertain = new Uncertain({
506
+ detail: "the step may have been sent; reconcile its durable effect before continuing",
507
+ });
508
+
509
+ const unresolved = input === "unknown" && !repeatable(step);
510
+ const expected = step.expect;
511
+
512
+ if (
513
+ unresolved &&
514
+ (expected === undefined || expected._tag === "Checked" || expected.durable !== true)
515
+ )
516
+ return yield* uncertain;
517
+ const settle = options.settle === false ? Effect.void : page.ready(options.settle);
518
+
519
+ if (!["navigate", "back", "reload"].includes(step.action)) yield* settle;
520
+ const before = plan.steps[index - 1]?.url;
521
+
522
+ if (before !== undefined)
523
+ yield* arrived(page, unresolved ? [before, step.url] : [before], millis);
524
+ const check = checkEffect(page, step, first);
525
+
526
+ const read =
527
+ millis === 0
528
+ ? check
529
+ : check.pipe(
530
+ Effect.timeoutOrElse({
531
+ duration: Duration.millis(millis),
532
+ orElse: () =>
533
+ Effect.fail(new Drifted({ detail: "the expected-effect read timed out" })),
534
+ }),
535
+ );
536
+
537
+ const held = yield* read.pipe(Effect.mapError((error) => (unresolved ? uncertain : error)));
538
+
539
+ const receipt = (outcome: Receipt["outcome"]) =>
540
+ Effect.gen(function* () {
541
+ if (index === plan.steps.length - 1) yield* settle;
542
+ yield* arrived(page, [step.url], millis);
543
+ const now = yield* page.state;
544
+
545
+ return new Receipt({ step: index, outcome, startedAt: first.at, at: now.at });
546
+ });
547
+
548
+ if (held) return yield* receipt("skipped");
549
+ if (unresolved) return yield* uncertain;
550
+ const text = step.input === undefined ? "" : options.inputs?.[step.input];
551
+
552
+ if (text === undefined) return yield* invalid(`no text for the input "${step.input ?? ""}"`);
553
+ const acted = yield* Effect.exit(dispatch(page, step, text));
554
+
555
+ if (Exit.isFailure(acted)) {
556
+ const error = Exit.findErrorOption(acted);
557
+
558
+ if (Option.isSome(error) && error.value._tag === "BrowserError" && error.value.dispatched) {
559
+ input = "unknown";
560
+ if (
561
+ expected !== undefined &&
562
+ (repeatable(step) || (expected._tag !== "Checked" && expected.durable === true))
563
+ ) {
564
+ const observed = yield* Effect.exit(within(check, millis));
565
+
566
+ if (Exit.isSuccess(observed) && observed.value) return yield* receipt("verified");
567
+ }
568
+ }
569
+
570
+ return yield* Effect.failCause(acted.cause);
571
+ }
572
+ input = "unknown";
573
+ if (expected !== undefined && !(yield* within(check, millis)))
574
+ return yield* new Drifted({ detail: "the step's expected effect did not arrive" });
575
+
576
+ return yield* receipt(expected === undefined ? "taken" : "verified");
577
+ });
578
+
579
+ return yield* work.pipe(
580
+ Effect.mapError(
581
+ (reason) =>
582
+ new ReplayError({
583
+ step: index,
584
+ reason,
585
+ input,
586
+ receipts: [],
587
+ }),
588
+ ),
589
+ );
590
+ });
591
+
592
+ const run = Effect.fnUntraced(function* (
593
+ page: Page,
594
+ plan: Plan,
595
+ options: TakeOptions,
596
+ from: number | Stopped,
597
+ until: number,
598
+ restoring: boolean,
599
+ ) {
600
+ const receipts: Array<Receipt> = [];
601
+ const start = typeof from === "number" ? from : from.step;
602
+
603
+ if (
604
+ !Number.isInteger(start) ||
605
+ start < 0 ||
606
+ start > plan.steps.length ||
607
+ !Number.isInteger(until) ||
608
+ until < start ||
609
+ until > plan.steps.length
610
+ )
611
+ return yield* new ReplayError({
612
+ step: start,
613
+ input: "unsent",
614
+ receipts,
615
+ reason: invalid("the run bounds are outside the plan"),
616
+ });
617
+ // Fresh runs still ask for all input slots before changing the page. Restoration checks first.
618
+ if (!restoring && typeof from === "number") {
619
+ const unfilled = plan.steps.findIndex(
620
+ (step, index) =>
621
+ index >= start && step.input !== undefined && options.inputs?.[step.input] === undefined,
622
+ );
623
+
624
+ if (unfilled !== -1)
625
+ return yield* new ReplayError({
626
+ step: start,
627
+ input: "unsent",
628
+ receipts,
629
+ reason: invalid(`no text for the input "${plan.steps[unfilled]?.input ?? ""}"`),
630
+ });
631
+ }
632
+ for (let index = start; index < until; index++) {
633
+ const at = restoring
634
+ ? new Stopped({ step: index, input: "unknown" })
635
+ : index === start
636
+ ? from
637
+ : index;
638
+
639
+ const receipt = yield* take(page, plan, at, options).pipe(
640
+ Effect.mapError((error) => new ReplayError({ ...error, receipts: [...receipts] })),
641
+ );
642
+
643
+ receipts.push(receipt);
644
+ }
645
+
646
+ return receipts;
647
+ });
648
+
649
+ /** Take a plan's steps from a fresh index or durable stopping point, returning completed receipts. */
650
+ export const replay = (page: Page, plan: Plan, options: ReplayOptions = {}) =>
651
+ run(page, plan, options, options.from ?? 0, plan.steps.length, false).pipe(
652
+ Effect.withSpan("Plan.replay"),
653
+ );
654
+
655
+ /** Restore prior steps on a fresh page; unresolved commitments require positive durable evidence. */
656
+ export const restore = (
657
+ page: Page,
658
+ plan: Plan,
659
+ options: TakeOptions & { readonly upTo?: number | undefined } = {},
660
+ ) =>
661
+ run(page, plan, options, 0, options.upTo ?? plan.steps.length, true).pipe(
662
+ Effect.withSpan("Plan.restore"),
663
+ );