effect-browser 0.3.0-beta.13 → 0.3.0-beta.15

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 (52) hide show
  1. package/README.md +59 -19
  2. package/dist/{Browser-QiT6Cdn_.mjs → Browser-BBSySyjT.mjs} +2 -2
  3. package/dist/{Browser-QiT6Cdn_.mjs.map → Browser-BBSySyjT.mjs.map} +1 -1
  4. package/dist/Browser.d.mts +1 -1
  5. package/dist/Browser.mjs +1 -1
  6. package/dist/Cdp.mjs +1 -1
  7. package/dist/Chromium.mjs +1 -1
  8. package/dist/Expect.d.mts +1 -1
  9. package/dist/Expect.mjs +1 -1
  10. package/dist/{Frame-Di2PxyF7.d.mts → Frame-DkNdjjjr.d.mts} +3 -5
  11. package/dist/Frame.d.mts +1 -1
  12. package/dist/Frame.mjs +2 -4
  13. package/dist/Frame.mjs.map +1 -1
  14. package/dist/Moment.d.mts +1 -1
  15. package/dist/Moment.mjs +7 -3
  16. package/dist/Moment.mjs.map +1 -1
  17. package/dist/{Page-B7X6DQUn.d.mts → Page-DL9eylp3.d.mts} +2 -2
  18. package/dist/Page.d.mts +1 -1
  19. package/dist/{Plan-BS0jpENY.d.mts → Plan-6As6vcwT.d.mts} +3 -3
  20. package/dist/{Plan-Bl2NM1D5.mjs → Plan-CXA2BW-G.mjs} +4 -6
  21. package/dist/Plan-CXA2BW-G.mjs.map +1 -0
  22. package/dist/Plan.d.mts +1 -1
  23. package/dist/Plan.mjs +1 -1
  24. package/dist/Policy.d.mts +1 -1
  25. package/dist/Presentation.d.mts +1 -1
  26. package/dist/Presentation.mjs +1 -1
  27. package/dist/Stage.d.mts +5 -4
  28. package/dist/Stage.mjs +53 -20
  29. package/dist/Stage.mjs.map +1 -1
  30. package/dist/{Supervisor-CYWFK3vM.d.mts → Supervisor-5tZ47hR5.d.mts} +40 -10
  31. package/dist/{Supervisor-TNKNOVzu.mjs → Supervisor-DaGlQVjv.mjs} +120 -68
  32. package/dist/Supervisor-DaGlQVjv.mjs.map +1 -0
  33. package/dist/Supervisor.d.mts +2 -2
  34. package/dist/Supervisor.mjs +2 -2
  35. package/dist/index.d.mts +4 -4
  36. package/dist/index.mjs +3 -3
  37. package/dist/{page-ZxMhw16l.mjs → page-B92QnRfT.mjs} +196 -166
  38. package/dist/page-B92QnRfT.mjs.map +1 -0
  39. package/package.json +1 -1
  40. package/src/Frame.ts +2 -4
  41. package/src/Moment.ts +14 -3
  42. package/src/Plan.ts +7 -6
  43. package/src/Stage.ts +61 -21
  44. package/src/Supervisor.ts +205 -172
  45. package/src/internal/pictures/capture.ts +172 -188
  46. package/src/internal/pictures/pictures.ts +50 -73
  47. package/src/internal/pictures/quiescence.ts +94 -0
  48. package/src/internal/reading/ready.ts +9 -3
  49. package/src/internal/supervisor/lifecycle.ts +40 -15
  50. package/dist/Plan-Bl2NM1D5.mjs.map +0 -1
  51. package/dist/Supervisor-TNKNOVzu.mjs.map +0 -1
  52. package/dist/page-ZxMhw16l.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.13",
3
+ "version": "0.3.0-beta.15",
4
4
  "description": "Effect browser sessions, page control, screencast and moments over Playwright and CDP.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/Frame.ts CHANGED
@@ -93,10 +93,8 @@ export class CaptureStats extends Schema.Class<CaptureStats>("effect-browser/Cap
93
93
  */
94
94
  foreignSize: Count,
95
95
  /**
96
- * Frames dropped because they may have been painted, or arrived, while the library took a clipped
97
- * or scaled picture of the page, such as a zoom: Chromium draws those into the screencast too.
98
- * Where Playwright emulates the viewport, the page's own frames from that time are among them; a
99
- * crop on the page's own session leaves out only frames of another size than the page's.
96
+ * Frames discarded while native capture is paused for a library picture and its restoration.
97
+ * A pause can produce no callbacks, so this count may remain zero even when pictures were taken.
100
98
  */
101
99
  duringPictures: Count,
102
100
  /**
package/src/Moment.ts CHANGED
@@ -19,8 +19,8 @@
19
19
  * part it could not read, which it names with why.
20
20
  *
21
21
  * Frames come from a running screencast, as far back as the browser's `frameHistory` keeps them.
22
- * A moment's picture is the newest frame if it was painted after the page's latest input and at
23
- * most 250 ms ago, else a new screenshot timed by its capture.
22
+ * A moment's picture is the newest frame if it was painted after the page's latest input and
23
+ * completed action, at most 250 ms ago, else a new screenshot timed by its capture.
24
24
  *
25
25
  * @since 0.3.0
26
26
  */
@@ -98,6 +98,17 @@ const spread = (frames: ReadonlyArray<Frame>, count: number): ReadonlyArray<Fram
98
98
  );
99
99
  };
100
100
 
101
+ // An action ends after its navigation settles, which can follow the last paint. A fresh picture
102
+ // keeps that completed action inside the moment without moving events between adjacent windows.
103
+ const pictureOf = Effect.fnUntraced(function* (page: Page.Page) {
104
+ const frame = yield* page.frame({ after: "input" });
105
+ const events = yield* page.recentEvents;
106
+
107
+ return events.some((event) => event._tag === "Action" && event.at > frame.hostTime)
108
+ ? yield* page.frame({ maxAge: 0 })
109
+ : frame;
110
+ });
111
+
101
112
  export const capture = Effect.fn("Moment.capture")(function* (
102
113
  page: Page.Page,
103
114
  options: CaptureOptions = {},
@@ -116,7 +127,7 @@ export const capture = Effect.fn("Moment.capture")(function* (
116
127
  // outline is taken alongside. What cannot be read is missing from the moment, not its end.
117
128
  const { pictured, outlined } = yield* Effect.all(
118
129
  {
119
- pictured: Effect.result(page.frame({ after: "input" })).pipe(
130
+ pictured: Effect.result(pictureOf(page)).pipe(
120
131
  Effect.flatMap((picture) =>
121
132
  Effect.map(
122
133
  page.window({
package/src/Plan.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * as a named input slot, never the text itself. A navigation to an address whose credentials the
8
8
  * events withheld is an input slot too, so replay goes where the caller says, or nowhere, and
9
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
10
+ * except an opening navigate, back or reload, it waits for `Page.ready`; then it finds the element its
11
11
  * subject names, by role and name among those that repeat all its recorded context, and acts on
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
@@ -382,7 +382,7 @@ const dispatch = (
382
382
  };
383
383
 
384
384
  export interface TakeOptions {
385
- /** How each step waits for the page first, or false not to wait. */
385
+ /** How each step waits first, except an opening navigation, or false. No final-step wait. */
386
386
  readonly settle?: ReadyOptions | false | undefined;
387
387
  readonly inputs?: Readonly<Record<string, string>> | undefined;
388
388
  /**
@@ -527,9 +527,11 @@ export const take = Effect.fn("Plan.take")(function* (
527
527
  (unresolved || (expected !== undefined && "durable" in expected && expected.durable === true))
528
528
  )
529
529
  return yield* uncertain;
530
- const settle = options.settle === false ? Effect.void : page.ready(options.settle);
531
-
532
- if (!["navigate", "back", "reload"].includes(step.action)) yield* settle;
530
+ if (
531
+ options.settle !== false &&
532
+ (index > 0 || !["navigate", "back", "reload"].includes(step.action))
533
+ )
534
+ yield* page.ready(options.settle);
533
535
  const before = plan.steps[index - 1]?.url;
534
536
 
535
537
  if (before !== undefined)
@@ -551,7 +553,6 @@ export const take = Effect.fn("Plan.take")(function* (
551
553
 
552
554
  const receipt = (outcome: Receipt["outcome"]) =>
553
555
  Effect.gen(function* () {
554
- if (index === plan.steps.length - 1) yield* settle;
555
556
  yield* arrived(page, [step.url], millis);
556
557
  const now = yield* page.state;
557
558
 
package/src/Stage.ts CHANGED
@@ -50,9 +50,9 @@ export interface Cue {
50
50
  }) => Effect.Effect<Presented, BrowserError>;
51
51
  }
52
52
 
53
- /** What the output establishes about its last image; loss ends this page's evidence. */
53
+ /** What the output establishes at nondecreasing host times; frames retain their capture times. */
54
54
  export type Shown =
55
- | { readonly _tag: "Painted"; readonly frame: Frame }
55
+ | { readonly _tag: "Painted"; readonly frame: Frame; readonly at: number }
56
56
  | { readonly _tag: "Still"; readonly frame: Frame; readonly at: number }
57
57
  | { readonly _tag: "Stalled"; readonly frame: Frame; readonly at: number }
58
58
  | { readonly _tag: "Gone"; readonly page: string; readonly at: number };
@@ -65,7 +65,7 @@ export interface Stage {
65
65
  readonly cue: (page: Page) => Effect.Effect<Cue, BrowserError, Scope.Scope>;
66
66
  /** Paint and, after every finite positive hold without paint, current evidence or loss. */
67
67
  readonly shown: (options: { readonly hold: Duration.Input }) => Stream.Stream<Shown>;
68
- /** Current-page paint; a reader more than 16 frames behind loses the oldest. */
68
+ /** Capture-timed current-page paint; use `shown` for output time. Readers retain 16 frames. */
69
69
  readonly frames: Stream.Stream<Frame>;
70
70
  readonly current: Effect.Effect<Option.Option<Page>>;
71
71
  readonly stats: (options?: {
@@ -87,9 +87,17 @@ interface Feed {
87
87
  interface Paint {
88
88
  readonly feed: Feed;
89
89
  readonly frame: Frame;
90
+ readonly at: number;
90
91
  }
91
92
 
92
- const closed = () => undispatched("cue", new Closed({ cause: "released" }));
93
+ interface Evidence {
94
+ readonly feed: Feed;
95
+ readonly frame: Frame;
96
+ readonly value: Shown;
97
+ }
98
+
99
+ const unavailable = () =>
100
+ undispatched("cue", new InvalidRequest({ detail: "the cue's capture is no longer available" }));
93
101
 
94
102
  /** The sole writer of a cue's capture lifecycle; presentation ownership belongs to the stage. */
95
103
  const update = (
@@ -131,13 +139,13 @@ const ended = (feed: Feed): Effect.Effect<never, BrowserError> =>
131
139
  Effect.flatMap((exit) =>
132
140
  Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)
133
141
  ? Effect.failCause(exit.cause)
134
- : Effect.fail(closed()),
142
+ : Effect.fail(unavailable()),
135
143
  ),
136
144
  );
137
145
 
138
146
  const firstOf = (feed: Feed, wait: Duration.Duration) =>
139
147
  Effect.suspend(() => {
140
- if (feed.state._tag === "Stopped") return Effect.fail(closed());
148
+ if (feed.state._tag === "Stopped") return Effect.fail(unavailable());
141
149
  if (feed.page.playwright.isClosed()) return failWith("cue", new Closed({ cause: "page" }));
142
150
  if (frameOf(feed) !== undefined) return Effect.void;
143
151
 
@@ -225,6 +233,8 @@ const evidence = (
225
233
  throw new RangeError("shown hold must be a finite positive duration");
226
234
  const millis = Duration.toMillis(parsed.value);
227
235
  let checkedAt = now();
236
+ let shownAt = Number.NEGATIVE_INFINITY;
237
+ let seen: Pick<Paint, "feed" | "frame"> | undefined = current();
228
238
  let gone: Feed | undefined;
229
239
 
230
240
  const probe = Effect.gen(function* () {
@@ -239,37 +249,67 @@ const evidence = (
239
249
  gone === before.feed ||
240
250
  checkedAt - before.changedAt < millis
241
251
  )
242
- return Option.none<Shown>();
252
+ return Option.none<Evidence>();
243
253
  const { feed } = before;
244
254
 
245
255
  const confirmed = yield* Effect.result(confirm(feed, frame));
246
256
 
247
- if (current()?.feed !== feed || current()?.frame !== frame) return Option.none<Shown>();
257
+ if (current()?.feed !== feed || current()?.frame !== frame) return Option.none<Evidence>();
248
258
  const at = now();
249
259
 
250
260
  checkedAt = at;
251
261
  if (Result.isFailure(confirmed) && consequence(confirmed.failure).lost !== "nothing") {
252
- gone = feed;
253
-
254
- return Option.some<Shown>({ _tag: "Gone", page: feed.page.id, at });
262
+ return Option.some<Evidence>({
263
+ feed,
264
+ frame,
265
+ value: { _tag: "Gone", page: feed.page.id, at },
266
+ });
255
267
  }
256
268
 
257
- return Option.some<Shown>({
258
- _tag: Result.isSuccess(confirmed) && confirmed.success ? "Still" : "Stalled",
269
+ return Option.some<Evidence>({
270
+ feed,
259
271
  frame,
260
- at,
272
+ value: {
273
+ _tag: Result.isSuccess(confirmed) && confirmed.success ? "Still" : "Stalled",
274
+ frame,
275
+ at,
276
+ },
261
277
  });
262
278
  });
263
279
 
264
280
  return Stream.merge(
265
281
  Stream.fromPubSub(output).pipe(
266
- Stream.filter(({ feed }) => gone !== feed),
267
- Stream.map(({ frame }): Shown => ({ _tag: "Painted", frame })),
282
+ Stream.map(({ feed, frame, at }): Evidence => ({
283
+ feed,
284
+ frame,
285
+ value: { _tag: "Painted", frame, at },
286
+ })),
268
287
  ),
269
288
  Stream.fromEffectRepeat(probe).pipe(
270
289
  Stream.filter(Option.isSome),
271
290
  Stream.map(({ value }) => value),
272
291
  ),
292
+ ).pipe(
293
+ // A probe can overtake queued paint while its reader is paused. It establishes nothing
294
+ // about that reader's output until the matching frame has reached it.
295
+ Stream.filter(({ feed, frame, value }) => {
296
+ if (value._tag === "Painted" && gone !== feed) {
297
+ seen = { feed, frame };
298
+
299
+ return true;
300
+ }
301
+ // The subscription may have begun after its first publication.
302
+ seen ??= { feed, frame };
303
+
304
+ return value._tag !== "Painted" && seen?.feed === feed && seen.frame === frame;
305
+ }),
306
+ Stream.map(({ feed, value }) => {
307
+ if (value._tag === "Gone") gone = feed;
308
+ // A frame captured before a silence probe can arrive after the probe's evidence.
309
+ shownAt = Math.max(shownAt, value.at);
310
+
311
+ return value.at === shownAt ? value : { ...value, at: shownAt };
312
+ }),
273
313
  );
274
314
  }),
275
315
  );
@@ -375,15 +415,15 @@ export const make = Effect.fn("Stage.make")(function* (
375
415
 
376
416
  yield* Effect.addFinalizer(() => PubSub.shutdown(output));
377
417
 
378
- const publish = (feed: Feed, frame: Frame) =>
418
+ const publish = (feed: Feed, frame: Frame, at = frame.hostTime) =>
379
419
  Effect.suspend(() => {
380
- painted = Math.max(painted, frame.hostTime);
420
+ painted = Math.max(painted, at);
381
421
  if (showing !== undefined) {
382
422
  showing.changedAt = now();
383
423
  showing.frame = frame;
384
424
  }
385
425
 
386
- return PubSub.publish(output, { feed, frame });
426
+ return PubSub.publish(output, { feed, frame, at: painted });
387
427
  });
388
428
 
389
429
  const commit = (feed: Feed, options: { readonly at?: number | undefined } = {}) =>
@@ -407,7 +447,7 @@ export const make = Effect.fn("Stage.make")(function* (
407
447
  const switchTo = Effect.suspend(() => {
408
448
  const frame = frameOf(feed);
409
449
 
410
- if (frame === undefined) return Effect.fail(closed());
450
+ if (frame === undefined) return Effect.fail(unavailable());
411
451
  const at = Math.max(now(), asked, frame.hostTime, painted);
412
452
 
413
453
  showing = {
@@ -417,7 +457,7 @@ export const make = Effect.fn("Stage.make")(function* (
417
457
  changedAt: at,
418
458
  };
419
459
 
420
- return publish(feed, frame).pipe(Effect.as(showing.presented));
460
+ return publish(feed, frame, at).pipe(Effect.as(showing.presented));
421
461
  }).pipe(Effect.uninterruptible);
422
462
 
423
463
  const readyToSwitch = firstOf(feed, firstFrame).pipe(