effect-browser 0.3.0-beta.6 → 0.3.0-beta.7

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 (46) hide show
  1. package/README.md +37 -2
  2. package/dist/{Browser-B5AV7zPS.mjs → Browser-CNwK0r0q.mjs} +2 -2
  3. package/dist/{Browser-B5AV7zPS.mjs.map → Browser-CNwK0r0q.mjs.map} +1 -1
  4. package/dist/Browser.d.mts +2 -2
  5. package/dist/Browser.mjs +1 -1
  6. package/dist/{BrowserEvent-DzdNdvoh.d.mts → BrowserEvent-BRu2juEz.d.mts} +3 -1
  7. package/dist/BrowserEvent.d.mts +1 -1
  8. package/dist/BrowserEvent.mjs +2 -0
  9. package/dist/BrowserEvent.mjs.map +1 -1
  10. package/dist/Cdp.mjs +1 -1
  11. package/dist/{Change-DVP2IJWm.d.mts → Change-iMlcuzVD.d.mts} +2 -2
  12. package/dist/Change.d.mts +1 -1
  13. package/dist/Chromium.mjs +1 -1
  14. package/dist/Expect.d.mts +1 -1
  15. package/dist/Expect.mjs +1 -1
  16. package/dist/Moment.d.mts +1 -1
  17. package/dist/{Page-DYTdVhYa.d.mts → Page-C30aG2RQ.d.mts} +6 -4
  18. package/dist/Page-D4281dIi.mjs.map +1 -1
  19. package/dist/Page.d.mts +1 -1
  20. package/dist/Plan-BvCS9qAf.d.mts +209 -0
  21. package/dist/{Plan-5MdsRzE5.mjs → Plan-CyQNv49C.mjs} +232 -38
  22. package/dist/Plan-CyQNv49C.mjs.map +1 -0
  23. package/dist/Plan.d.mts +2 -2
  24. package/dist/Plan.mjs +2 -2
  25. package/dist/Policy.d.mts +1 -1
  26. package/dist/Presentation.d.mts +1 -1
  27. package/dist/Presentation.mjs +1 -1
  28. package/dist/Stage.d.mts +1 -1
  29. package/dist/Stage.mjs +1 -1
  30. package/dist/{Supervisor-Im6-wbg5.d.mts → Supervisor-CLJaT7rG.d.mts} +2 -2
  31. package/dist/Supervisor.d.mts +1 -1
  32. package/dist/index.d.mts +5 -5
  33. package/dist/index.mjs +2 -2
  34. package/dist/{page-BkRcLCWZ.mjs → page-DHeYWEs-.mjs} +27 -14
  35. package/dist/page-DHeYWEs-.mjs.map +1 -0
  36. package/package.json +1 -1
  37. package/src/BrowserEvent.ts +2 -0
  38. package/src/Page.ts +3 -1
  39. package/src/Plan.ts +365 -56
  40. package/src/internal/input/perform.ts +2 -0
  41. package/src/internal/page/bridge.ts +3 -3
  42. package/src/internal/reading/ready.inpage.ts +17 -7
  43. package/src/internal/reading/ready.ts +9 -3
  44. package/dist/Plan-5MdsRzE5.mjs.map +0 -1
  45. package/dist/Plan-CNNOU1Tx.d.mts +0 -112
  46. package/dist/page-BkRcLCWZ.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.6",
3
+ "version": "0.3.0-beta.7",
4
4
  "description": "Effect browser sessions, page control, screencast and moments over Playwright and CDP.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- /** How long the screen must also stay still, as for reels coming to rest. */
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. Replay stops at the first step it cannot take and says why: `Missing`,
16
- * `Ambiguous`, `Drifted`, or the step's own `BrowserError`. Nothing is replayed automatically, and
17
- * a plan of another version, such as one 0.2 stored, does not decode.
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
- /** Where the page was once the step was done, as recorded; empty when unknown. */
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
- export class Plan extends Schema.Class<Plan>("effect-browser/Plan")({
88
- version: Schema.Literal(1),
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
- /** Where replay stopped, counting steps from 0, and why. */
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: before(steps[index + 1]?.startedAt ?? Number.POSITIVE_INFINITY),
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 take = (
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 ReplayOptions {
301
- /** How each step waits for the page first, as `Page.ready` takes it, or false not to wait. */
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
- /** Take a plan's steps on a page, in order, stopping at the first that cannot be taken. */
308
- export const replay = (page: Page, plan: Plan, options: ReplayOptions = {}) =>
309
- Effect.gen(function* () {
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
- const at =
314
- (step: number) =>
315
- <A>(effect: Effect.Effect<A, Missing | Ambiguous | Drifted | BrowserError>) =>
316
- effect.pipe(Effect.mapError((reason) => new ReplayError({ step, reason })));
317
-
318
- // A step arrived where the recording did, on the same site; a moved path is not drift.
319
- const arrived = (index: number) => {
320
- const recorded = plan.steps[index]?.url ?? "";
321
-
322
- return page.url.pipe(
323
- Effect.flatMap((url) =>
324
- recorded === "" || URL.parse(url)?.origin === URL.parse(recorded)?.origin
325
- ? Effect.void
326
- : Effect.fail(
327
- new Drifted({ detail: `the page is at ${url}, not on ${recorded}'s site` }),
328
- ),
329
- ),
330
- at(index),
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
- const unfilled = plan.steps.findIndex(
335
- (step) => step.input !== undefined && inputs[step.input] === undefined,
336
- );
420
+ const sameSite = (url: string, recorded: string) => {
421
+ const origin = URL.parse(recorded)?.origin;
337
422
 
338
- if (unfilled !== -1)
339
- return yield* at(unfilled)(
340
- Effect.fail(invalid(`no text for the input "${plan.steps[unfilled]?.input ?? ""}"`)),
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
- for (const [index, step] of plan.steps.entries()) {
343
- if (step.action !== "navigate" && step.action !== "back" && step.action !== "reload")
344
- yield* at(index)(settle);
345
- if (index > 0) yield* arrived(index - 1);
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
- if (plan.steps.length > 0) {
351
- yield* at(plan.steps.length - 1)(settle);
352
- yield* arrived(plan.steps.length - 1);
353
- }
354
- }).pipe(Effect.withSpan("Plan.replay", { attributes: { steps: plan.steps.length } }));
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
+ );
@@ -9,6 +9,7 @@ import { Action, type ActionOptions, Subject } from "../../BrowserEvent.ts";
9
9
  import { type Point, redacted, type ResolvedTarget } from "../../Page.ts";
10
10
  import { failWith, type PageContext, undispatched } from "../page/context.ts";
11
11
  import { Correlation } from "../page/lane.ts";
12
+ import { redact } from "../page/url.ts";
12
13
  import type * as BrowserClock from "../pictures/clock.ts";
13
14
  import type { Dispatch } from "./dispatch.ts";
14
15
  import type { Approval, PolicyPlan } from "./guard.ts";
@@ -281,6 +282,7 @@ export const make = (page: PageContext, sender: Dispatch) => {
281
282
  startedAt,
282
283
  page: id,
283
284
  name,
285
+ url: redact(page.playwright.url()),
284
286
  correlation: yield* Correlation,
285
287
  target: info.target,
286
288
  options: Exit.isSuccess(exit) ? info.options : undefined,
@@ -90,7 +90,7 @@ const install = (
90
90
  ): PageApi => {
91
91
  const installed = globalThis.__effectBrowser;
92
92
 
93
- if (installed !== undefined && installed.version === 18) return installed;
93
+ if (installed !== undefined && installed.version === 19) return installed;
94
94
  const named = makeNames();
95
95
  const walked = makeWalk(named);
96
96
  const placing = makeContext(named, walked);
@@ -105,10 +105,10 @@ const install = (
105
105
  const located = makeTargets(named, walked, placing);
106
106
  const guarded = makeGuard(named, located, makeEvidence(named, placing));
107
107
  const edited = makeEdit(named, guarded, placing);
108
- const readiness = makeReady(walked, texts, kept);
108
+ const readiness = makeReady(walked, texts, kept, recorder);
109
109
 
110
110
  const api: PageApi = {
111
- version: 18,
111
+ version: 19,
112
112
  snapshot: read.snapshot,
113
113
  find: subjected.find,
114
114
  until: makeExpectations(named, subjected).until,
@@ -6,12 +6,14 @@
6
6
  * frame. So a canvas mounted blank, or a spinner alone, is still loading. A screen that only says
7
7
  * "Loading…" in words reads as ready, and a WebGL canvas drawn once, without keeping its drawing,
8
8
  * reads as blank. Asked for a quiet spell, where the page's changes are recorded, nothing in view
9
- * may have changed for that long either. After input, it waits a task and a frame. See
10
- * `names.inpage.ts` for what a page-side part may use.
9
+ * may have changed for that long either. Content mode starts and refreshes the recorder and
10
+ * observes a full quiet interval from its first check in each document. After input, it waits a
11
+ * task and a frame. See `names.inpage.ts` for what a page-side part may use.
11
12
  */
12
13
  import { Schema } from "effect";
13
14
 
14
15
  import type { History } from "../timeline/history.inpage.ts";
16
+ import type { Recorder } from "../timeline/record.inpage.ts";
15
17
  import type { Texts } from "./text.inpage.ts";
16
18
  import type { Walk } from "./walk.inpage.ts";
17
19
 
@@ -31,7 +33,7 @@ export const ReadinessSchema = Schema.Array(
31
33
 
32
34
  export type Readiness = typeof ReadinessSchema.Type;
33
35
 
34
- export const ready = (walked: Walk, texts: Texts, kept: History) => {
36
+ export const ready = (walked: Walk, texts: Texts, kept: History, recorder: Recorder) => {
35
37
  const { inView, visible } = walked;
36
38
  // A canvas is read through a small copy: reading it directly would give it a context of ours.
37
39
  let copy: OffscreenCanvasRenderingContext2D | null | undefined;
@@ -86,7 +88,7 @@ export const ready = (walked: Walk, texts: Texts, kept: History) => {
86
88
  };
87
89
 
88
90
  // In a frame, before it is drawn: a canvas drawn every frame still holds its drawing then.
89
- const check = (quietMillis: number): Promise<Readiness> =>
91
+ const check = (quietMillis: number, observed: number): Promise<Readiness> =>
90
92
  painted().then((paints) => {
91
93
  const waiting: Array<Readiness[number]> = [];
92
94
 
@@ -113,7 +115,10 @@ export const ready = (walked: Walk, texts: Texts, kept: History) => {
113
115
  if (document.fonts.status !== "loaded") waiting.push("fonts");
114
116
  if (showing(document.querySelectorAll("[aria-busy=true]")).length > 0) waiting.push("busy");
115
117
  if (!content()) waiting.push("content");
116
- if (kept.changing() > performance.timeOrigin + performance.now() - quietMillis)
118
+ if (
119
+ Math.max(observed, kept.changing()) >
120
+ performance.timeOrigin + performance.now() - quietMillis
121
+ )
117
122
  waiting.push("changing");
118
123
 
119
124
  return waiting;
@@ -123,11 +128,16 @@ export const ready = (walked: Walk, texts: Texts, kept: History) => {
123
128
  * Check until the page is ready, or for `millis`, and say what it is still waiting for; with
124
129
  * `quietMillis`, until nothing the record saw in view has changed for that long, too.
125
130
  */
126
- const wait = async (millis: number, quietMillis = 0): Promise<Readiness> => {
131
+ const wait = async (millis: number, quietMillis = 0, contentOnly = false): Promise<Readiness> => {
127
132
  const until = performance.now() + millis;
133
+ let observed = contentOnly ? undefined : Number.NEGATIVE_INFINITY;
128
134
 
129
135
  for (;;) {
130
- const waiting = await check(quietMillis);
136
+ if (contentOnly) {
137
+ recorder.start();
138
+ if (kept.begun()) observed ??= performance.timeOrigin + performance.now();
139
+ }
140
+ const waiting = await check(quietMillis, observed ?? Number.POSITIVE_INFINITY);
131
141
 
132
142
  if (waiting.length === 0 || performance.now() >= until) return waiting;
133
143
  const { promise, resolve } = Promise.withResolvers<void>();
@@ -9,14 +9,16 @@
9
9
  * connection of its own, a round trip there: each answer comes behind every frame sent before it on
10
10
  * its connection, and a frame that arrives first starts the spell again. Where the page's changes
11
11
  * are recorded, its evidence includes them: nothing in view may have changed for the spell, which a
12
- * DOM page's changes show better than its paint, and no stall on the way can hide.
12
+ * DOM page's changes show better than its paint, and no stall on the way can hide. Content
13
+ * stillness starts that record itself and ignores capture silence. Each new document starts a
14
+ * new observation, while retries of destroyed script contexts share the caller's deadline.
13
15
  */
14
16
  import { Duration, Effect, Schedule, Stream } from "effect";
15
17
 
16
18
  import { Timeout } from "../../BrowserError.ts";
17
19
  import type { ReadyOptions } from "../../Page.ts";
18
20
  import { type Bridge, scriptCall } from "../page/bridge.ts";
19
- import { decodeWith, failWith, type PageContext } from "../page/context.ts";
21
+ import { contextGone, decodeWith, failWith, type PageContext } from "../page/context.ts";
20
22
  import type * as Capture from "../pictures/capture.ts";
21
23
  import { ReadinessSchema } from "./ready.inpage.ts";
22
24
 
@@ -34,9 +36,11 @@ export const make =
34
36
  "ready",
35
37
  Math.max(0, Math.round(until - page.now())),
36
38
  options.quietMillis ?? 0,
39
+ options.still === "content",
37
40
  ),
38
41
  ),
39
42
  ).pipe(
43
+ Effect.retry({ while: contextGone, schedule: Schedule.spaced(Duration.millis(10)) }),
40
44
  Effect.flatMap(decodeWith("ready", ReadinessSchema)),
41
45
  Effect.tap((waiting) => Effect.annotateCurrentSpan({ waiting: waiting.join(",") })),
42
46
  Effect.filterOrElse(
@@ -95,8 +99,10 @@ export const make =
95
99
  Effect.gen(function* () {
96
100
  const until = page.now() + Duration.toMillis(timeout);
97
101
 
102
+ if (options.still === "content") yield* bridge.registerRecorder("ready");
98
103
  yield* settled(until);
99
- if (options.quietMillis !== undefined) yield* quiet(options.quietMillis, until);
104
+ if (options.quietMillis !== undefined && options.still !== "content")
105
+ yield* quiet(options.quietMillis, until);
100
106
  }).pipe(page.within("ready", timeout)),
101
107
  ),
102
108
  page.span("Page.ready"),