effect-browser 0.3.0-beta.6 → 0.3.0-beta.8
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 +124 -21
- package/dist/{Browser-B5AV7zPS.mjs → Browser-CNwK0r0q.mjs} +2 -2
- package/dist/{Browser-B5AV7zPS.mjs.map → Browser-CNwK0r0q.mjs.map} +1 -1
- package/dist/Browser.d.mts +2 -2
- package/dist/Browser.mjs +1 -1
- package/dist/{BrowserEvent-DzdNdvoh.d.mts → BrowserEvent-BRu2juEz.d.mts} +3 -1
- package/dist/BrowserEvent.d.mts +1 -1
- package/dist/BrowserEvent.mjs +2 -0
- package/dist/BrowserEvent.mjs.map +1 -1
- package/dist/Cdp.mjs +1 -1
- package/dist/{Change-DVP2IJWm.d.mts → Change-iMlcuzVD.d.mts} +2 -2
- package/dist/Change.d.mts +1 -1
- package/dist/Chromium.mjs +1 -1
- package/dist/Expect.d.mts +1 -1
- package/dist/Expect.mjs +1 -1
- package/dist/Moment.d.mts +1 -1
- package/dist/{Page-DYTdVhYa.d.mts → Page-C30aG2RQ.d.mts} +6 -4
- package/dist/Page-D4281dIi.mjs.map +1 -1
- package/dist/Page.d.mts +1 -1
- package/dist/Plan-BvCS9qAf.d.mts +209 -0
- package/dist/{Plan-5MdsRzE5.mjs → Plan-CyQNv49C.mjs} +232 -38
- package/dist/Plan-CyQNv49C.mjs.map +1 -0
- package/dist/Plan.d.mts +2 -2
- package/dist/Plan.mjs +2 -2
- package/dist/Policy.d.mts +1 -1
- package/dist/Presentation.d.mts +1 -1
- package/dist/Presentation.mjs +1 -1
- package/dist/Stage.d.mts +1 -1
- package/dist/Stage.mjs +1 -1
- package/dist/{Supervisor-Im6-wbg5.d.mts → Supervisor-CYWFK3vM.d.mts} +106 -56
- package/dist/Supervisor-TNKNOVzu.mjs +599 -0
- package/dist/Supervisor-TNKNOVzu.mjs.map +1 -0
- package/dist/Supervisor.d.mts +2 -2
- package/dist/Supervisor.mjs +2 -2
- package/dist/index.d.mts +5 -5
- package/dist/index.mjs +3 -3
- package/dist/{page-BkRcLCWZ.mjs → page-DHeYWEs-.mjs} +27 -14
- package/dist/page-DHeYWEs-.mjs.map +1 -0
- package/package.json +1 -1
- package/src/BrowserEvent.ts +2 -0
- package/src/Page.ts +3 -1
- package/src/Plan.ts +365 -56
- package/src/Supervisor.ts +384 -138
- package/src/internal/input/perform.ts +2 -0
- package/src/internal/page/bridge.ts +3 -3
- package/src/internal/reading/ready.inpage.ts +17 -7
- package/src/internal/reading/ready.ts +9 -3
- package/src/internal/supervisor/lifecycle.ts +291 -140
- package/dist/Plan-5MdsRzE5.mjs.map +0 -1
- package/dist/Plan-CNNOU1Tx.d.mts +0 -112
- package/dist/Supervisor-BtEG93gR.mjs +0 -420
- package/dist/Supervisor-BtEG93gR.mjs.map +0 -1
- package/dist/page-BkRcLCWZ.mjs.map +0 -1
package/package.json
CHANGED
package/src/BrowserEvent.ts
CHANGED
|
@@ -159,6 +159,8 @@ export class Action extends Schema.TaggedClass<Action>()("Action", {
|
|
|
159
159
|
startedAt: Schema.Finite,
|
|
160
160
|
page: Schema.String,
|
|
161
161
|
name: Schema.String,
|
|
162
|
+
/** The address when the action ended; absent in events recorded by older releases. */
|
|
163
|
+
url: Schema.optional(Schema.String),
|
|
162
164
|
correlation: Schema.optional(Schema.String),
|
|
163
165
|
target: Schema.optional(Schema.String),
|
|
164
166
|
options: Schema.optional(ActionOptions),
|
package/src/Page.ts
CHANGED
|
@@ -226,7 +226,9 @@ export interface WaitCondition {
|
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
export interface ReadyOptions {
|
|
229
|
-
/**
|
|
229
|
+
/** What must stay still during quietMillis: screen paint (default), or recorded content. */
|
|
230
|
+
readonly still?: "screen" | "content" | undefined;
|
|
231
|
+
/** How long the selected evidence must stay still; screen paint by default. */
|
|
230
232
|
readonly quietMillis?: number | undefined;
|
|
231
233
|
readonly timeout?: Duration.Input | undefined;
|
|
232
234
|
}
|
package/src/Plan.ts
CHANGED
|
@@ -12,13 +12,15 @@
|
|
|
12
12
|
* it. Text typed into focus was typed into the focused field, which replay finds the same way, and
|
|
13
13
|
* text typed into a secret field goes into nothing else. An element step never falls back to
|
|
14
14
|
* coordinates, and a step recorded at a point acts there only while the element found for it still
|
|
15
|
-
* covers that point.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.
|
|
18
20
|
*
|
|
19
21
|
* @since 0.3.0
|
|
20
22
|
*/
|
|
21
|
-
import { Duration, Effect, Schedule, Schema } from "effect";
|
|
23
|
+
import { Duration, Effect, Exit, Option, Schedule, Schema } from "effect";
|
|
22
24
|
|
|
23
25
|
import { BrowserError, InvalidRequest } from "./BrowserError.ts";
|
|
24
26
|
import {
|
|
@@ -59,10 +61,42 @@ export const StepAction = Schema.Literals([
|
|
|
59
61
|
|
|
60
62
|
export type StepAction = typeof StepAction.Type;
|
|
61
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
|
+
|
|
62
93
|
/** One recorded step: the action, what it acted on, and what else it was asked. */
|
|
63
94
|
export class Step extends Schema.Class<Step>("effect-browser/Plan/Step")({
|
|
64
95
|
action: StepAction,
|
|
65
|
-
/**
|
|
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
|
+
*/
|
|
66
100
|
url: Schema.String,
|
|
67
101
|
/** A navigation's requested address, or the keys a press pressed. */
|
|
68
102
|
target: Schema.optional(Schema.String),
|
|
@@ -82,23 +116,66 @@ export class Step extends Schema.Class<Step>("effect-browser/Plan/Step")({
|
|
|
82
116
|
/** The slot's text went into a secret field, so replay types it into nothing else. */
|
|
83
117
|
secret: Schema.optional(Schema.Literal(true)),
|
|
84
118
|
options: Schema.optional(ActionOptions),
|
|
119
|
+
expect: Schema.optional(Expected),
|
|
85
120
|
}) {}
|
|
86
121
|
|
|
87
|
-
|
|
88
|
-
version: Schema.
|
|
122
|
+
const PlanData = Schema.Struct({
|
|
123
|
+
version: Schema.Literals([1, 2]),
|
|
89
124
|
steps: Schema.Array(Step),
|
|
90
|
-
})
|
|
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
|
+
|
|
91
139
|
/** The input slots its steps type, in order; replay needs text for each. */
|
|
92
140
|
get inputs(): ReadonlyArray<string> {
|
|
93
141
|
return this.steps.flatMap((step) => (step.input === undefined ? [] : [step.input]));
|
|
94
142
|
}
|
|
95
143
|
}
|
|
96
144
|
|
|
97
|
-
/**
|
|
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. */
|
|
98
169
|
export class ReplayError extends Schema.TaggedError<ReplayError>()("ReplayError", {
|
|
99
170
|
step: Schema.Int,
|
|
100
|
-
reason: Schema.Union([Missing, Ambiguous, Drifted, BrowserError]),
|
|
171
|
+
reason: Schema.Union([Missing, Ambiguous, Drifted, BrowserError, Uncertain]),
|
|
172
|
+
input: Schema.Literals(["unsent", "unknown"]),
|
|
173
|
+
receipts: Schema.Array(Receipt),
|
|
101
174
|
}) {
|
|
175
|
+
get stopped(): Stopped {
|
|
176
|
+
return new Stopped({ step: this.step, input: this.input });
|
|
177
|
+
}
|
|
178
|
+
|
|
102
179
|
override get message() {
|
|
103
180
|
return `step ${this.step} failed: ${this.reason.message}`;
|
|
104
181
|
}
|
|
@@ -153,11 +230,13 @@ export const fromEvents = (events: Iterable<BrowserEvent>): Plan => {
|
|
|
153
230
|
steps: steps.map((action, index) => {
|
|
154
231
|
const drag = action.name === "drag" ? (action.target ?? "").split(" -> ") : undefined;
|
|
155
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);
|
|
156
235
|
|
|
157
236
|
// Only a navigation and a press keep their target: an address, or keys.
|
|
158
237
|
return new Step({
|
|
159
238
|
action: action.name,
|
|
160
|
-
url:
|
|
239
|
+
url: arrival?.url ?? action.url ?? before(until),
|
|
161
240
|
target: action.name === "navigate" || action.name === "press" ? action.target : undefined,
|
|
162
241
|
subject: action.subject,
|
|
163
242
|
to: action.to,
|
|
@@ -240,7 +319,7 @@ const aim = (page: Page, subject: Subject | undefined, point?: Point, recorded?:
|
|
|
240
319
|
);
|
|
241
320
|
};
|
|
242
321
|
|
|
243
|
-
const
|
|
322
|
+
const dispatch = (
|
|
244
323
|
page: Page,
|
|
245
324
|
step: Step,
|
|
246
325
|
text: string,
|
|
@@ -297,58 +376,288 @@ const take = (
|
|
|
297
376
|
}
|
|
298
377
|
};
|
|
299
378
|
|
|
300
|
-
export interface
|
|
301
|
-
/** How each step waits for the page first,
|
|
379
|
+
export interface TakeOptions {
|
|
380
|
+
/** How each step waits for the page first, or false not to wait. */
|
|
302
381
|
readonly settle?: ReadyOptions | false | undefined;
|
|
303
|
-
/** The text for each of the plan's input slots. */
|
|
304
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;
|
|
305
388
|
}
|
|
306
389
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const inputs = options.inputs ?? {};
|
|
311
|
-
const settle = options.settle === false ? Effect.void : page.ready(options.settle);
|
|
390
|
+
export interface ReplayOptions extends TakeOptions {
|
|
391
|
+
readonly from?: number | Stopped | undefined;
|
|
392
|
+
}
|
|
312
393
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
+
}),
|
|
331
418
|
);
|
|
332
|
-
};
|
|
333
419
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
);
|
|
420
|
+
const sameSite = (url: string, recorded: string) => {
|
|
421
|
+
const origin = URL.parse(recorded)?.origin;
|
|
337
422
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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,
|
|
341
466
|
);
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
yield* at(index)(
|
|
347
|
-
take(page, step, step.input === undefined ? "" : (inputs[step.input] ?? "")),
|
|
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,
|
|
348
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);
|
|
349
571
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
yield*
|
|
353
|
-
|
|
354
|
-
|
|
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
|
+
);
|