playthrough 0.5.0 → 0.6.0

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/INSTRUCTIONS.md CHANGED
@@ -6,7 +6,7 @@ to its end; each command then says what's next.
6
6
  playthrough start <review> once its flows are written, try the setup and keep Before: the app as it is
7
7
  playthrough record <review> record the round being worked on, every flow, the way a person would use it
8
8
  playthrough wait <review> hand the round over, then wait for the notes to answer
9
- playthrough walk <review> walk the person's steps in the app again, on the app as it is now
9
+ playthrough forks <review> run each fork of a note made in the app, on the app as it is now
10
10
  playthrough open [<review>] open the viewer on this machine
11
11
 
12
12
  THE LOOP
@@ -22,17 +22,22 @@ THE LOOP
22
22
  does as far as the app as it was can, then compares the round with every round before it. In the round's round.json,
23
23
  say why each change since the round before is there, under "accounts" by its id: { "line": "<why, in your words>" },
24
24
  { "note": "<the id of the note it was made for>" }, or { "notReal": "<why nothing really changed>" }. What only the
25
- pictures show is the person's to look at, and asks nothing of you, unless you made it for a note: then say so.
25
+ pictures show is the person's to look at, and asks nothing of you, unless you made it for a note: then say so. The
26
+ person sees what changed, marked where it is: a line says only why, in a short sentence.
26
27
  5. playthrough wait <review> hands the round over and waits for the notes, which can take hours: run it in the
27
28
  background, so you're told when it ends. Each note comes with a picture of what it's on, or what the person did in
28
29
  the app. Change the app, record the next round, and answer each note in its round.json, under "replies", in its
29
30
  "words": its "on" is where Playthrough found the note's thing on this round, so leave it. Where the answer moves,
30
- like an animation, add "watch": "<the title of the step it's seen in>". For a note made in the app, record walks the
31
- person's steps again and puts your answer where they lead: answer it in words, not with a new flow, and check your
32
- fix with playthrough walk <review>. Where their steps stop short, fix that, or say why they changed.
31
+ like an animation, add "watch": "<the title of the step it's seen in>". A note made in the app comes with what the
32
+ person did there: write that as a fork of the flow they took over (FORKS, below), and record puts your answer on it.
33
33
 
34
34
  The person can only judge what the flows show, so keep them true to how a person uses the app.
35
35
 
36
+ A bug you can't make happen is still a round to hand over: record the flow that does what the report says, give it a
37
+ "line" in round.json saying you couldn't make it happen and how you tried, and hand it over. The person can take the
38
+ flow over where it goes wrong and show you: what they do there comes back as a note made in the app, and its fork, run
39
+ every round, shows you the bug and them your fix.
40
+
36
41
  THE APP
37
42
 
38
43
  The first time, add playthrough to the app's devDependencies with the app's own package manager, and run
@@ -55,7 +60,8 @@ another at its address, takes the address it always has, or a copy on a port of
55
60
 
56
61
  Flows run several at once. Where a page shows what every run made, as a blog's index lists every post, add
57
62
  "oneAtATime": true. Recording on Before sets your work aside with git stash for a minute, puts the app as it was in its
58
- place, with playthrough/ as it is now, and puts your work back after, even when Playthrough is stopped.
63
+ place, with playthrough/ as it is now, and puts your work back after, even when Playthrough is stopped; anything saved
64
+ meanwhile is kept in a stash of its own.
59
65
 
60
66
  FLOWS
61
67
 
@@ -79,11 +85,34 @@ the page once it rests after each step. A dialog the browser shows is recorded,
79
85
  first: page.once("dialog", (dialog) => dialog.accept()). A flow that fails stops the round: record names it and the
80
86
  step, and keeps the page it failed on in .playthrough/<review>/failed/.
81
87
 
88
+ Steps go where a person goes, by what the app had before your work too, never to what you changed: the app as it was
89
+ doesn't have it, so Before stops short there and your change reads as new steps. Playthrough finds what changed, and
90
+ shows each change when the recording first has it on the screen. A page people read, like a landing page or docs, is a
91
+ flow of reading it, top to bottom: a step for each screen, scrolled with page.mouse.wheel(0, 600), which Playthrough scrolls as a person does, as
92
+ many as the longer page needs.
93
+
82
94
  While the person watches, Playthrough runs the flow again in the background, so taking it over is quick. If its steps do
83
95
  something outside the app that mustn't happen again, like sending an email, say so, and it runs only when taken over:
84
96
 
85
97
  test("Invite Sam", { annotation: { type: "reaches outside", description: "sends Sam an email" } }, async ({ page }) => {
86
98
 
99
+ FORKS
100
+
101
+ Where the person took a flow over in the app and made a note there, what they did is yours to write, as a fork of that
102
+ flow, in its file, after it: steps as a flow's, by role and label, for the notes it shows:
103
+
104
+ test.fork("Remind Alder Dental", { notes: ["isikhse8"] }, async ({ page }) => {
105
+ await test.step("Open the reminder", async () => {
106
+ await page.getByRole("row", { name: /Alder Dental/ }).click();
107
+ await page.getByRole("button", { name: "Write a reminder" }).click();
108
+ });
109
+ });
110
+
111
+ Record runs the flow to where they took it over, then the fork, and puts your answer on what the note is on, where the
112
+ fork first shows it. It's run every round until its notes are settled, and the person watches it as a fork off the flow,
113
+ not a flow of its own: keep it doing what they did, on the app as it is now. playthrough forks <review> runs it while you
114
+ work. A round whose note has no fork, or whose fork fails, isn't recorded.
115
+
87
116
  WHAT EVERY FLOW STARTS FROM
88
117
 
89
118
  A flow has to start from the same place every round, or the comparison finds changes nobody made. Playthrough starts
package/README.md CHANGED
@@ -4,6 +4,8 @@ The agent says it's done. You decide if it's good.
4
4
 
5
5
  Playthrough records your coding agent's change the way a person would use it, before and after, so you can watch it instead of setting it up by hand. Point at what's wrong, right on the recording, and the next round answers it there. Take over any moment and carry on in the real app. It runs on your machine, with any coding agent that has a shell.
6
6
 
7
+ Many agents, one of you: each change arrives already recorded, and one page shows which ones need you. Any flow downloads as a video, for a pull request or a teammate. Keep your code review: it reads the code; Playthrough shows what the change does.
8
+
7
9
  To see a review before you install anything, play the one at [playthrough.sh](https://playthrough.sh).
8
10
 
9
11
  <a href="https://playthrough.sh"><img src="https://raw.githubusercontent.com/itmostlyworks/playthrough/main/docs/viewer-light.png" alt="The viewer: the agent's recording of Send a reminder, paused on your note and the agent's answer, asking whether it fixes it, with Note, Compare and Take over on the bar"></a>
@@ -20,7 +22,7 @@ The agent does the rest. The first time, it adds Playthrough to your app and wri
20
22
  npx playthrough open
21
23
  ```
22
24
 
23
- Reviews stay in `.playthrough/` in your app's repo, recordings and all; nothing is uploaded. Playthrough needs Node 24 or later.
25
+ Reviews stay in `.playthrough/` in your app's repo, recordings and all; nothing is uploaded. Playthrough needs Node 22.18 or later.
24
26
 
25
27
  You can teach your agent Playthrough once, so “Show me this change” is enough from then on:
26
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playthrough",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Review what your coding agent built in your web app by watching recordings of it.",
5
5
  "keywords": [
6
6
  "coding-agent",
@@ -25,7 +25,7 @@
25
25
  "directory": "packages/cli"
26
26
  },
27
27
  "engines": {
28
- "node": ">=24"
28
+ "node": ">=22.18"
29
29
  },
30
30
  "bin": {
31
31
  "playthrough": "src/playthrough.js"
@@ -1,11 +1,11 @@
1
- import { A as quote, b as Reviews, h as readingsOf, k as list, m as glimpsesOf, p as firstLine, y as Refused } from "./reporter-Dr7dWwSf.js";
1
+ import { A as quote, b as Reviews, h as readingsOf, k as list, m as glimpsesOf, p as firstLine, y as Refused } from "./reporter-DlS7qehW.js";
2
2
  import * as modules from "node:module";
3
3
  import { createRequire } from "node:module";
4
- import { existsSync, watch } from "node:fs";
4
+ import { existsSync, readFileSync, watch, writeFileSync } from "node:fs";
5
5
  import { execFileSync, spawn } from "node:child_process";
6
6
  import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
7
7
  import { createHash, randomUUID } from "node:crypto";
8
- import { constants, tmpdir } from "node:os";
8
+ import { constants, tmpdir, uptime } from "node:os";
9
9
  import { dirname, join, relative, resolve } from "node:path";
10
10
  import { createServer } from "node:net";
11
11
  import { chromium, request } from "@playwright/test";
@@ -69,6 +69,30 @@ function pausedAt(flow, at) {
69
69
  actions: 1
70
70
  };
71
71
  }
72
+ // src/command.ts
73
+ const thisCommand = () => ({
74
+ by: process.pid,
75
+ boot: boot()
76
+ });
77
+ const stillAt = (command) => sameBoot(command.boot) && running(command.by);
78
+ function boot() {
79
+ try {
80
+ return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
81
+ } catch {
82
+ return Date.now() / 1e3 - uptime();
83
+ }
84
+ }
85
+ function sameBoot(then, now = boot()) {
86
+ if (typeof then === "number" && typeof now === "number") return Math.abs(then - now) < 300;
87
+ return then === void 0 || then === now;
88
+ }
89
+ function running(pid) {
90
+ try {
91
+ return process.kill(pid, 0);
92
+ } catch (error) {
93
+ return error.code === "EPERM";
94
+ }
95
+ }
72
96
  // ../review/src/folder.ts
73
97
  const LOOK_EVERY = 2e3;
74
98
  function folder(root) {
@@ -123,6 +147,7 @@ function repo(root = home(process.cwd())) {
123
147
  round: (review, round) => join(reviews, review, "rounds", String(round), "round.json"),
124
148
  ranOnBefore: (review) => join(reviews, review, "ran-on-before.json"),
125
149
  setAside: join(reviews, "set-aside.json"),
150
+ turn: join(reviews, "turn.json"),
126
151
  appFile,
127
152
  setup: join(root, "playthrough", "setup.ts"),
128
153
  borrowed: join(reviews, "borrowed"),
@@ -214,7 +239,9 @@ async function setupIn(file, written) {
214
239
  if (!existsSync(file)) return {};
215
240
  const href = pathToFileURL(file).href;
216
241
  asModule(href);
217
- const module = await import(`${href}${written ? `?${written}` : ""}`);
242
+ const module = await import(`${href}${written ? `?${written}` : ""}`).catch((error) => {
243
+ throw twoPlaywrights(error, file) ?? error;
244
+ });
218
245
  const inner = typeof module.default === "object" ? module.default : void 0;
219
246
  const use = module.use ?? inner?.use;
220
247
  const ours = Object.keys(use ?? {}).filter((option) => OURS.includes(option));
@@ -227,6 +254,16 @@ async function setupIn(file, written) {
227
254
  ...use && { use }
228
255
  };
229
256
  }
257
+ function twoPlaywrights(error, file) {
258
+ if (!/Requiring @playwright\/test second time/.test(String(error?.message))) return;
259
+ const version = (from) => {
260
+ try {
261
+ return modules.createRequire(from)("@playwright/test/package.json").version;
262
+ } catch {}
263
+ };
264
+ const theirs = version(file);
265
+ return new Refused([`playthrough/setup.ts loads the app's own Playwright${theirs ? ` (${theirs})` : ""} beside Playthrough's (${version(import.meta.url)}), and Playwright can't run twice at once.`, `Import only types from @playwright/test there (import type { Page } from "@playwright/test"), and expect, if it needs it, from playthrough/test. Then run this again.`]);
266
+ }
230
267
  const setups = /* @__PURE__ */ new Set();
231
268
  function asModule(href) {
232
269
  if (!setups.size) modules.registerHooks({ load: (url, context, next) => next(url, setups.has(url.split("?")[0]) ? {
@@ -324,7 +361,7 @@ function alive(pid) {
324
361
  // src/app.ts
325
362
  const started = /* @__PURE__ */ new Map();
326
363
  let ending = false;
327
- async function keepRunning(app, root) {
364
+ async function keepRunning(app, root, { ready = false } = {}) {
328
365
  if (!ending) {
329
366
  ending = true;
330
367
  const { setup, borrowed } = repo(root);
@@ -337,6 +374,7 @@ async function keepRunning(app, root) {
337
374
  ]) process.once(signal, end);
338
375
  process.once("exit", () => [...started.keys()].forEach(stop));
339
376
  }
377
+ await takeTurns(root, ready);
340
378
  const ours = started.get(app.url);
341
379
  if (ours && ours.root !== root) await stopApp(app.url);
342
380
  if (await serves(app.url)) {
@@ -359,6 +397,12 @@ async function keepRunning(app, root) {
359
397
  app: running,
360
398
  root
361
399
  });
400
+ if (ready) writeFileSync(repo(root).turn, JSON.stringify({
401
+ ...thisCommand(),
402
+ ready,
403
+ app: running.pid,
404
+ url: app.url
405
+ }));
362
406
  const said = [];
363
407
  for (const output of [running.stdout, running.stderr]) {
364
408
  output.on("data", (chunk) => {
@@ -387,6 +431,29 @@ async function keepRunning(app, root) {
387
431
  "Check that playthrough/app.json starts it there."
388
432
  ]);
389
433
  }
434
+ async function takeTurns(root, ready) {
435
+ const here = repo(root);
436
+ let turn;
437
+ try {
438
+ turn = JSON.parse(readFileSync(here.turn, "utf8"));
439
+ } catch {}
440
+ const theirs = turn && turn.by !== process.pid && stillAt(turn) ? turn : void 0;
441
+ if (ready) {
442
+ if (theirs && !theirs.ready) throw new Refused([`Another playthrough command (process ${theirs.by}) runs the app just now, until it's done, in a minute or so. Try again then.`]);
443
+ return;
444
+ }
445
+ await here.keepOutOfGit();
446
+ writeFileSync(here.turn, JSON.stringify({
447
+ ...thisCommand(),
448
+ ready
449
+ }));
450
+ if (!theirs?.ready) return;
451
+ try {
452
+ process.kill(-theirs.app);
453
+ } catch {}
454
+ const began = Date.now();
455
+ while (Date.now() - began < 1e4 && await serves(theirs.url)) await new Promise((resolve) => setTimeout(resolve, 250));
456
+ }
390
457
  async function stopApp(url) {
391
458
  if (!stop(url)) return;
392
459
  const began = Date.now();
@@ -483,6 +550,17 @@ function rewordedWords(a, b) {
483
550
  };
484
551
  return `“${part(a.words)}” now says “${part(b.words)}”`;
485
552
  }
553
+ function rewordedPart(a, b) {
554
+ const letter = /[\p{L}\p{N}’']/u;
555
+ let start = 0;
556
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
557
+ let end = 0;
558
+ while (end < a.length - start && end < b.length - start && a.at(-1 - end) === b.at(-1 - end)) end++;
559
+ let to = b.length - end;
560
+ while (start > 0 && letter.test(b[start - 1]) && letter.test(b[start] ?? "")) start--;
561
+ while (to < b.length && to > 0 && letter.test(b[to - 1]) && letter.test(b[to])) to++;
562
+ return b.slice(start, to).trim() || void 0;
563
+ }
486
564
  function lookWords(a, b) {
487
565
  const parts = [];
488
566
  if (a.look.background !== b.look.background) parts.push("background");
@@ -599,7 +677,7 @@ function differences(then, now) {
599
677
  });
600
678
  continue;
601
679
  }
602
- a = b.role !== "text" ? take((a) => a.role === b.role && (a.name === b.name || samePlace(a, b) || mostlyWhereItWas(a, b) || holdsTheSameWords(a, b) || CONTROL.test(b.role) && (!!bare(b.name) && bare(a.name) === bare(b.name) || overlap(a, b) > .5))) : take((a) => a.role === "text" && samePlace(a, b)) ?? (holderOf(now, b) && goneWords.get(holderKey(now, b)) === newWords.get(holderKey(now, b)) ? take((a) => a.role === "text" && holderKey(then, a) === holderKey(now, b)) : void 0) ?? take((a) => a.role === "text" && holderKey(then, a) === holderKey(now, b) && !!bare(b.words) && bare(a.words) === bare(b.words));
680
+ a = b.role !== "text" ? take((a) => a.role === b.role && (a.name === b.name || samePlace(a, b) || mostlyWhereItWas(a, b) || holdsTheSameWords(a, b) || CONTROL.test(b.role) && (!!bare(b.name) && bare(a.name) === bare(b.name) || overlap(a, b) > .5))) : take((a) => a.role === "text" && samePlace(a, b)) ?? (holderOf(now, b) && goneWords.get(holderKey(now, b)) === newWords.get(holderKey(now, b)) ? take((a) => a.role === "text" && holderKey(then, a) === holderKey(now, b)) : void 0) ?? take((a) => a.role === "text" && holderKey(then, a) === holderKey(now, b) && !!bare(b.words) && bare(a.words) === bare(b.words)) ?? take((a) => a.role === "text" && kept$1(a.words, b.words));
603
681
  if (!a) {
604
682
  found.push({
605
683
  what: "new",
@@ -759,6 +837,14 @@ function gained(reading, control, was) {
759
837
  }
760
838
  const CONTROL = /^(button|link|textbox|searchbox|combobox|checkbox|radio|switch|slider|tab|menuitem|option)$/;
761
839
  const holdsControls = (reading, thing) => reading.things.some((inside) => parentOf(reading, inside) === thing && CONTROL.test(inside.role));
840
+ function kept$1(a, b) {
841
+ if (a.length < 24 || a === b) return false;
842
+ let start = 0;
843
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
844
+ let end = 0;
845
+ while (end < a.length - start && end < b.length - start && a.at(-1 - end) === b.at(-1 - end)) end++;
846
+ return (start + end) / a.length >= 2 / 3;
847
+ }
762
848
  const mostlyWhereItWas = (a, b) => HOLDS.test(b.role) && overlap(a, b) > .5;
763
849
  const holdsTheSameWords = (a, b) => /^(box|menu)$/.test(b.role) && !!a.wordsInside && bag(a) === bag(b);
764
850
  const bag = (thing) => (thing.wordsInside ?? "").split(" ").sort().join(" ");
@@ -854,6 +940,26 @@ const elementOf$1 = (thing, name) => ({
854
940
  name: thing.name || name,
855
941
  ...thing.node !== void 0 && { node: thing.node }
856
942
  });
943
+ function sameWords(one, other) {
944
+ const [few, many] = [wordsOf(one), wordsOf(other)].sort((a, b) => a.length - b.length);
945
+ return !few.length || few.filter((word) => many.includes(word)).length * 2 >= few.length;
946
+ }
947
+ const wordsOf = (name) => name.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
948
+ function firstShown(element, seen) {
949
+ const noted = wordsOf(element.name);
950
+ if (!noted.length) return void 0;
951
+ const closeness = (thing) => {
952
+ const words = wordsOf(thing.name);
953
+ return words.filter((word) => noted.includes(word)).length / Math.max(words.length, noted.length);
954
+ };
955
+ for (const glimpse of seen.toSorted((a, b) => a.at - b.at)) {
956
+ const thing = glimpse.things.filter((thing) => thing.role === element.role && sameWords(thing.name, element.name)).toSorted((a, b) => closeness(b) - closeness(a))[0];
957
+ if (thing) return {
958
+ at: glimpse.at,
959
+ element: elementOf$1(thing, element.name)
960
+ };
961
+ }
962
+ }
857
963
  // src/changes.ts
858
964
  const TABLES = /^(table|grid|treegrid|listbox)$/;
859
965
  const REFLOWS = /^(dialog|alertdialog|status|alert|menu|box)$/;
@@ -877,7 +983,7 @@ function changesAt(then, now, unsteady = /* @__PURE__ */ new Set(), shown = {
877
983
  const told = [
878
984
  ...items.reordered,
879
985
  ...reordered,
880
- ...swappedOrMoved(moment, loose),
986
+ ...swappedOrMoved(moment, madeRoom(loose, others, caused, along)),
881
987
  ...causesOfShifts(moment, shifts, widened, others, caused, along)
882
988
  ];
883
989
  for (const difference of others) if (!caused.has(difference)) told.push(tell(moment, difference, along.get(difference)));
@@ -1095,6 +1201,20 @@ function layoutShifts(moves, carried) {
1095
1201
  loose: all.filter((shift) => counted(shift.moves) < MANY).flatMap((shift) => shift.moves)
1096
1202
  };
1097
1203
  }
1204
+ function madeRoom(loose, others, caused, along) {
1205
+ const candidates = others.filter((difference) => !caused.has(difference));
1206
+ return loose.filter((move) => {
1207
+ const by = movedBy(move);
1208
+ if (!near(by.x, 0) || loose.some((other) => Math.sign(movedBy(other).y) === -Math.sign(by.y))) return true;
1209
+ const cause = causeOf(spanOf([move.now]), {
1210
+ vertical: true,
1211
+ by: by.y,
1212
+ moving: true
1213
+ }, candidates);
1214
+ if (cause) addAlong(along, cause, "pushed", [move.now], wayOf(0, by.y));
1215
+ return !cause;
1216
+ });
1217
+ }
1098
1218
  function swappedOrMoved(moment, loose) {
1099
1219
  const told = [];
1100
1220
  for (const [holder, moves] of groupBy(loose, (move) => holderOf(moment.now, move.now))) {
@@ -1294,6 +1414,7 @@ function tell(moment, difference, along = {}) {
1294
1414
  thing: difference.now,
1295
1415
  in: now,
1296
1416
  words: rewordedWords(difference.then, difference.now),
1417
+ reworded: rewordedPart(difference.then.words, difference.now.words),
1297
1418
  ...along
1298
1419
  };
1299
1420
  case "looks different": {
@@ -1353,7 +1474,11 @@ function said(told, moment, unsteady) {
1353
1474
  words: alikeWords(alike),
1354
1475
  ...placeOf(alike[0], now),
1355
1476
  ...alikeOf(alike, now),
1356
- pieces: alike.flatMap(piecesOf)
1477
+ pieces: alike.flatMap(piecesOf),
1478
+ ...alike[0].reworded && { reworded: alike[0].reworded },
1479
+ ...alike[0].in !== now && { gone: true },
1480
+ ...offScreen(alike),
1481
+ top: topOf(alike[0], now)
1357
1482
  });
1358
1483
  continue;
1359
1484
  }
@@ -1369,11 +1494,15 @@ function said(told, moment, unsteady) {
1369
1494
  id: id(changed),
1370
1495
  words: sentence(words),
1371
1496
  ...placeOf(changed, now),
1372
- pieces: all.flat().flatMap(piecesOf)
1497
+ pieces: all.flat().flatMap(piecesOf),
1498
+ ...offScreen(all.flat()),
1499
+ top: topOf(changed, now)
1373
1500
  });
1374
1501
  }
1375
1502
  return found;
1376
1503
  }
1504
+ const topOf = (told, now) => told.in === now ? told.thing.box.y / now.screen.height : 2;
1505
+ const offScreen = (told) => told.some((one) => onScreen(one.thing.box, one.in.screen)) ? {} : { offScreen: true };
1377
1506
  function identityOf({ in: reading, thing, what }, moment) {
1378
1507
  const item = thing.item === void 0 ? void 0 : reading.items?.[thing.item];
1379
1508
  if (!item) return identity(reading, thing);
@@ -1459,6 +1588,7 @@ function asItems(told, moment, id) {
1459
1588
  ...whole,
1460
1589
  what: "reworded",
1461
1590
  words: reworded,
1591
+ reworded: rewordedPart(before.words, item.words),
1462
1592
  pieces: words.flatMap((one) => one.pieces ?? [id(one)]),
1463
1593
  key: `${item.role} of ${listOf(now, item)}: ${shapeOf(before.words, item.words)}`
1464
1594
  }, ...all.filter((one) => one.thing.role !== "text"));
@@ -1624,7 +1754,8 @@ async function picturesAt(page, then, now, pictures) {
1624
1754
  id,
1625
1755
  words: sentence(words),
1626
1756
  ...box && { box },
1627
- pieces: [here ? `${HERE}${id}` : id]
1757
+ pieces: [here ? `${HERE}${id}` : id],
1758
+ top: patch.y / now.screen.height
1628
1759
  };
1629
1760
  });
1630
1761
  }
@@ -1875,7 +2006,7 @@ function unsteadyIn(flows) {
1875
2006
  async function comparing(page, earlier, round, unsteady = () => /* @__PURE__ */ new Set()) {
1876
2007
  const changes = /* @__PURE__ */ new Map();
1877
2008
  const pieces = /* @__PURE__ */ new Map();
1878
- const see = ({ id, words, place, of = [id], pictureOnly }) => {
2009
+ const see = ({ id, words, place, of = [id], reworded, gone, pictureOnly }) => {
1879
2010
  if (unsteady(place.flow).has(id)) return;
1880
2011
  const change = changes.get(id);
1881
2012
  for (const piece of of) (pieces.get(id) ?? pieces.set(id, /* @__PURE__ */ new Set()).get(id)).add(piece);
@@ -1883,6 +2014,8 @@ async function comparing(page, earlier, round, unsteady = () => /* @__PURE__ */
1883
2014
  id,
1884
2015
  words,
1885
2016
  seen: [place],
2017
+ ...reworded && { reworded },
2018
+ ...gone && { gone },
1886
2019
  ...pictureOnly && { pictureOnly }
1887
2020
  });
1888
2021
  else {
@@ -1927,7 +2060,7 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
1927
2060
  now: around(glimpsed.now, flow, reading.step)
1928
2061
  };
1929
2062
  const pictured = await picturesAt(page, was, reading, pictures);
1930
- for (const found of [...changesAt(was, reading, unsteady, shown), ...pictured]) {
2063
+ for (const found of [...changesAt(was, reading, unsteady, shown), ...pictured].sort((a, b) => (a.top ?? 0) - (b.top ?? 0))) {
1931
2064
  const on = found.element ? {
1932
2065
  element: found.element,
1933
2066
  ...found.alike && { alike: found.alike }
@@ -1941,11 +2074,14 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
1941
2074
  ...on
1942
2075
  },
1943
2076
  of: found.pieces,
1944
- ...pictured.includes(found) && { pictureOnly: true }
2077
+ ...found.reworded && { reworded: found.reworded },
2078
+ ...found.gone && { gone: true },
2079
+ ...pictured.includes(found) && { pictureOnly: true },
2080
+ ...found.offScreen && { offScreen: true }
1945
2081
  });
1946
2082
  }
1947
2083
  }
1948
- return sightings.sort((a, b) => (a.place.at ?? 0) - (b.place.at ?? 0));
2084
+ return sightings.sort((a, b) => Number(!!a.offScreen) - Number(!!b.offScreen) || (a.place.at ?? 0) - (b.place.at ?? 0));
1949
2085
  }
1950
2086
  function around(glimpses, flow, step) {
1951
2087
  const next = flow.steps[flow.steps.findIndex((kept) => kept.title === step) + 1]?.title;
@@ -2124,7 +2260,7 @@ async function writtenNow(repo) {
2124
2260
  }
2125
2261
  // src/flows.ts
2126
2262
  const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
2127
- const WALKED = "walked.json";
2263
+ const FORK_RAN = "fork.json";
2128
2264
  const ALONGSIDE = Symbol.for("playthrough: flows taken over here");
2129
2265
  const REACHES_OUTSIDE = "reaches outside";
2130
2266
  const waitsFor = ({ before }) => before ? 3e3 : 1e4;
@@ -2294,7 +2430,7 @@ async function runOnce(run) {
2294
2430
  await rm(folder, { recursive: true });
2295
2431
  }
2296
2432
  }
2297
- async function walkFork(repo, id, walk) {
2433
+ async function runFork(repo, id, fork) {
2298
2434
  const app = await repo.app();
2299
2435
  const { started } = await repo.reviews.get(id);
2300
2436
  const folder = await mkdtemp(join(tmpdir(), "playthrough-"));
@@ -2308,20 +2444,19 @@ async function walkFork(repo, id, walk) {
2308
2444
  before: false,
2309
2445
  taken: [],
2310
2446
  started,
2311
- walk
2447
+ fork
2312
2448
  });
2313
- const text = await readFile(join(folder, WALKED), "utf8").catch(() => void 0);
2449
+ const text = await readFile(join(folder, FORK_RAN), "utf8").catch(() => void 0);
2314
2450
  if (!text) return void 0;
2315
- const walked = JSON.parse(text);
2316
- const files = await Promise.all([...new Set(Object.values(walked.assets))].map(async (name) => ({
2451
+ const ran = JSON.parse(text);
2452
+ const files = await Promise.all([...new Set(Object.values(ran.assets))].map(async (name) => ({
2317
2453
  name,
2318
2454
  bytes: await readFile(join(folder, name))
2319
2455
  })));
2320
- const pictures = await Promise.all(walk.look.map((_, i) => readFile(join(folder, `look-${i}.png`)).catch(() => void 0)));
2321
2456
  return {
2322
- ...walked,
2457
+ ...ran,
2323
2458
  files,
2324
- pictures
2459
+ picture: await readFile(join(folder, "end.png")).catch(() => void 0)
2325
2460
  };
2326
2461
  } finally {
2327
2462
  await rm(folder, {
@@ -2365,4 +2500,4 @@ async function playwright(run) {
2365
2500
  });
2366
2501
  });
2367
2502
  }
2368
- export { repo as A, changes as C, personIn as D, make as E, setupIn as O, stopApp as S, giveBackLeft as T, compareWithEarlier as _, failedAt as a, keepRunning as b, isFlow as c, runFlows as d, waitsFor as f, writtenNow as g, trySetup as h, called as i, pausedAt as j, copies as k, makeFlowsFolder as l, whereItStopped as m, REACHES_OUTSIDE as n, flowsOf as o, walkFork as p, WALKED as r, howToWriteFlows as s, ALONGSIDE as t, recorder as u, placeIn as v, firstOf$1 as w, serves as x, shownAt as y };
2503
+ export { setupIn as A, serves as C, giveBackLeft as D, firstOf$1 as E, pausedAt as F, repo as M, stillAt as N, make as O, thisCommand as P, keepRunning as S, changes as T, compareWithEarlier as _, failedAt as a, sameWords as b, isFlow as c, runFlows as d, runFork as f, writtenNow as g, trySetup as h, called as i, copies as j, personIn as k, makeFlowsFolder as l, whereItStopped as m, FORK_RAN as n, flowsOf as o, waitsFor as p, REACHES_OUTSIDE as r, howToWriteFlows as s, ALONGSIDE as t, recorder as u, firstShown as v, stopApp as w, shownAt as x, placeIn as y };