playthrough 0.4.3 → 0.5.1

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
@@ -22,7 +22,8 @@ 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
@@ -38,15 +39,24 @@ THE APP
38
39
  The first time, add playthrough to the app's devDependencies with the app's own package manager, and run
39
40
  npx playwright install chromium: flows import from it, and record in that browser.
40
41
 
41
- playthrough/app.json says how to start the app, and is committed so every review uses it:
42
+ playthrough/app.json says how to start the app, and is committed so every review uses it. Where the command can be
43
+ given a port, say {port} in both, and Playthrough starts this copy of the app on a port of its own each time it records
44
+ or the person takes a flow over, beside the person's own and any other worktree's:
45
+
46
+ { "start": "npm run dev -- --port {port}", "url": "http://localhost:{port}" }
47
+
48
+ Where it can't, as when another part of the app expects it at one address, give the address it always has:
42
49
 
43
50
  { "start": "npm run dev", "url": "http://localhost:3000" }
44
51
 
52
+ There, every round is recorded on whatever serves that url, and Playthrough starts the app only when nothing does: leave
53
+ a server you didn't start running, and let it follow the working copy, as a dev server does. In a worktree, what serves
54
+ it could be another copy, so Playthrough needs the address free, and says so. An app of several servers, one calling
55
+ another at its address, takes the address it always has, or a copy on a port of its own would call the person's.
56
+
45
57
  Flows run several at once. Where a page shows what every run made, as a blog's index lists every post, add
46
- "oneAtATime": true. Every round is recorded on whatever serves that url, and Playthrough starts the app only when nothing
47
- does: leave a server you didn't start running, and let it follow the working copy, as a dev server does. Recording on
48
- Before sets your work aside with git stash for a minute, puts the app as it was in its place, with playthrough/ as it is
49
- now, and puts your work back after, even when Playthrough is stopped.
58
+ "oneAtATime": true. Recording on Before sets your work aside with git stash for a minute, puts the app as it was in its
59
+ place, with playthrough/ as it is now, and puts your work back after, even when Playthrough is stopped.
50
60
 
51
61
  FLOWS
52
62
 
@@ -70,6 +80,12 @@ the page once it rests after each step. A dialog the browser shows is recorded,
70
80
  first: page.once("dialog", (dialog) => dialog.accept()). A flow that fails stops the round: record names it and the
71
81
  step, and keeps the page it failed on in .playthrough/<review>/failed/.
72
82
 
83
+ 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
84
+ doesn't have it, so Before stops short there and your change reads as new steps. Playthrough finds what changed, and
85
+ shows each change when the recording first has it on the screen. A page people read, like a landing page or docs, is a
86
+ 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
87
+ many as the longer page needs.
88
+
73
89
  While the person watches, Playthrough runs the flow again in the background, so taking it over is quick. If its steps do
74
90
  something outside the app that mustn't happen again, like sending an email, say so, and it runs only when taken over:
75
91
 
package/README.md CHANGED
@@ -4,8 +4,12 @@ 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
 
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>
12
+
9
13
  ## Try it
10
14
 
11
15
  Ask your agent for a change in your app, as you would anyway, and add:
@@ -20,9 +24,13 @@ npx playthrough open
20
24
 
21
25
  Reviews stay in `.playthrough/` in your app's repo, recordings and all; nothing is uploaded. Playthrough needs Node 24 or later.
22
26
 
23
- ## On the example
27
+ You can teach your agent Playthrough once, so “Show me this change” is enough from then on:
28
+
29
+ ```bash
30
+ npx skills add itmostlyworks/playthrough
31
+ ```
24
32
 
25
- `example/` in this repo is a small invoices app to try it on before your own. Clone the repo, run `npm install`, `npx playwright install chromium` and `npm run build`, and from `example/`, ask your agent to show how much is overdue in total under the Invoices heading, and to show you the change with `npx playthrough`.
33
+ In Claude Code, it's a plugin too: `/plugin install playthrough --marketplace itmostlyworks/playthrough`.
26
34
 
27
35
  ## How a review goes
28
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playthrough",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
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",
@@ -1,12 +1,13 @@
1
- import { A as list, b as Refused, g as readingsOf, j as quote, m as glimpsesOf, p as firstLine, x as Reviews } from "./reporter-DE8aueAO.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-S9fOk0za.js";
2
2
  import * as modules from "node:module";
3
3
  import { createRequire } from "node:module";
4
- import { existsSync, watch } from "node:fs";
5
- import { spawn } from "node:child_process";
4
+ import { existsSync, readFileSync, watch, writeFileSync } from "node:fs";
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";
9
- import { dirname, join, relative } from "node:path";
8
+ import { constants, tmpdir, uptime } from "node:os";
9
+ import { dirname, join, relative, resolve } from "node:path";
10
+ import { createServer } from "node:net";
10
11
  import { chromium, request } from "@playwright/test";
11
12
  import { fileURLToPath, pathToFileURL } from "node:url";
12
13
  // ../review/src/steps.ts
@@ -68,6 +69,30 @@ function pausedAt(flow, at) {
68
69
  actions: 1
69
70
  };
70
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
+ }
71
96
  // ../review/src/folder.ts
72
97
  const LOOK_EVERY = 2e3;
73
98
  function folder(root) {
@@ -82,7 +107,7 @@ function folder(root) {
82
107
  },
83
108
  async write(path, bytes) {
84
109
  const file = join(root, path);
85
- const unfinished = `${file}.${process.pid}.${Date.now()}.writing`;
110
+ const unfinished = `${file}.${randomUUID()}.writing`;
86
111
  await mkdir(dirname(file), { recursive: true });
87
112
  await writeFile(unfinished, bytes);
88
113
  await rename(unfinished, file);
@@ -122,6 +147,7 @@ function repo(root = home(process.cwd())) {
122
147
  round: (review, round) => join(reviews, review, "rounds", String(round), "round.json"),
123
148
  ranOnBefore: (review) => join(reviews, review, "ran-on-before.json"),
124
149
  setAside: join(reviews, "set-aside.json"),
150
+ turn: join(reviews, "turn.json"),
125
151
  appFile,
126
152
  setup: join(root, "playthrough", "setup.ts"),
127
153
  borrowed: join(reviews, "borrowed"),
@@ -132,7 +158,7 @@ function repo(root = home(process.cwd())) {
132
158
  await writeFile(join(reviews, ".gitignore"), "*\n");
133
159
  },
134
160
  async app() {
135
- const how = `Say how to start the app in playthrough/app.json: { "start": "<the command that serves it>", "url": "<where it's served>" }.`;
161
+ const how = `Say how to start the app in playthrough/app.json: ${PORTED}, or with the address it always has where it can't be given a port.`;
136
162
  const text = await readFile(appFile, "utf8").catch(() => void 0);
137
163
  if (text === void 0) throw new Refused([`Playthrough needs to start the app to record it. ${how}`]);
138
164
  let app;
@@ -141,12 +167,19 @@ function repo(root = home(process.cwd())) {
141
167
  } catch (error) {
142
168
  throw new Refused([`playthrough/app.json isn't JSON: ${error.message}. ${how}`]);
143
169
  }
144
- if (!isText(app.start) || !isText(app.url)) throw new Refused([`playthrough/app.json needs both “start” and “url”. ${how}`]);
170
+ if (!app || typeof app !== "object" || !isText(app.start) || !isText(app.url)) throw new Refused([`playthrough/app.json needs both “start” and “url”. ${how}`]);
171
+ const saying = [app.start, app.url].filter((said) => said.includes("{port}")).length;
172
+ if (saying === 1) throw new Refused([`playthrough/app.json says {port} in only one of “start” and “url”. Say it in both, so the app is started on the port Playthrough gives it and found there: ${PORTED}.`]);
173
+ const port = saying ? await portFor(root) : void 0;
174
+ const given = (said) => port === void 0 ? said : said.replaceAll("{port}", String(port));
175
+ const url = given(app.url);
176
+ if (!/^https?:$/.test(URL.parse(url)?.protocol ?? "")) throw new Refused([`playthrough/app.json's “url” is a whole address, as a browser takes it, like http://localhost:{port}, not “${app.url}”.`]);
145
177
  if (app.oneAtATime !== void 0 && typeof app.oneAtATime !== "boolean") throw new Refused([`playthrough/app.json's “oneAtATime” is true or false: true runs each flow by itself.`]);
146
178
  return {
147
- start: app.start,
148
- url: app.url,
149
- ...app.oneAtATime && { oneAtATime: true }
179
+ start: given(app.start),
180
+ url,
181
+ ...app.oneAtATime && { oneAtATime: true },
182
+ ...port !== void 0 && { port }
150
183
  };
151
184
  }
152
185
  };
@@ -157,6 +190,35 @@ function home(here) {
157
190
  if (existsSync(join(folder, ".git")) || dirname(folder) === folder) return here;
158
191
  }
159
192
  }
193
+ const PORTED = `{ "start": "npm run dev -- --port {port}", "url": "http://localhost:{port}" }`;
194
+ const ports = /* @__PURE__ */ new Map();
195
+ const portFor = (root) => ports.get(root) ?? ports.set(root, free()).get(root);
196
+ const free = () => new Promise((resolve, reject) => {
197
+ const server = createServer();
198
+ server.once("error", reject);
199
+ server.listen(0, "127.0.0.1", () => {
200
+ const { port } = server.address();
201
+ server.close(() => resolve(port));
202
+ });
203
+ });
204
+ function copies(root) {
205
+ try {
206
+ const git = (...args) => execFileSync("git", args, {
207
+ cwd: root,
208
+ encoding: "utf8",
209
+ stdio: [
210
+ "ignore",
211
+ "pipe",
212
+ "ignore"
213
+ ]
214
+ }).trim();
215
+ const inside = relative(git("rev-parse", "--show-toplevel"), root);
216
+ return git("worktree", "list", "--porcelain").split("\n").filter((line) => line.startsWith("worktree ")).map((line) => line.slice(9)).map((worktree) => resolve(worktree, inside)).filter((folder) => existsSync(folder));
217
+ } catch {
218
+ return [resolve(root)];
219
+ }
220
+ }
221
+ const inWorktree = (root) => copies(root)[0] !== resolve(root);
160
222
  const isText = (value) => typeof value === "string" && value.trim() !== "";
161
223
  // src/setup.ts
162
224
  const OURS = [
@@ -250,8 +312,9 @@ async function giveBackOf({ after }, borrowed, whose) {
250
312
  for (const name of left) {
251
313
  const file = join(borrowed, `${process.pid}-${name.slice(name.indexOf("-") + 1)}`);
252
314
  if (!await rename(join(borrowed, name), file).then(() => true, () => false)) continue;
253
- const { baseURL, setup } = JSON.parse(await readFile(file, "utf8"));
254
- await giveBack(after, file, baseURL, setup);
315
+ const given = await readFile(file, "utf8").then(JSON.parse).catch(() => void 0);
316
+ if (!given) await rm(file, { force: true });
317
+ else await giveBack(after, file, given.baseURL, given.setup);
255
318
  }
256
319
  }
257
320
  const giving = /* @__PURE__ */ new Set();
@@ -284,9 +347,9 @@ function alive(pid) {
284
347
  }
285
348
  }
286
349
  // src/app.ts
287
- let started;
350
+ const started = /* @__PURE__ */ new Map();
288
351
  let ending = false;
289
- async function keepRunning(app, root) {
352
+ async function keepRunning(app, root, { ready = false } = {}) {
290
353
  if (!ending) {
291
354
  ending = true;
292
355
  const { setup, borrowed } = repo(root);
@@ -297,12 +360,18 @@ async function keepRunning(app, root) {
297
360
  "SIGTERM",
298
361
  "SIGHUP"
299
362
  ]) process.once(signal, end);
300
- process.once("exit", stop);
363
+ process.once("exit", () => [...started.keys()].forEach(stop));
364
+ }
365
+ await takeTurns(root, ready);
366
+ const ours = started.get(app.url);
367
+ if (ours && ours.root !== root) await stopApp(app.url);
368
+ if (await serves(app.url)) {
369
+ if (ours?.root === root || app.port !== void 0 || !inWorktree(root)) return;
370
+ throw new Refused([`Something else answers at ${app.url}, most likely the app running from another folder. This is a worktree, so Playthrough runs this copy of the app there itself: whoever started what's there can stop it, then try again.`, `Or give each copy a port of its own in playthrough/app.json: ${PORTED}.`]);
301
371
  }
302
- if (await serves(app.url)) return;
303
372
  console.log(`Starting the app: ${app.start}`);
304
373
  const began = Date.now();
305
- started = spawn(app.start, {
374
+ const running = spawn(app.start, {
306
375
  cwd: root,
307
376
  shell: true,
308
377
  stdio: [
@@ -312,17 +381,27 @@ async function keepRunning(app, root) {
312
381
  ],
313
382
  detached: true
314
383
  });
384
+ started.set(app.url, {
385
+ app: running,
386
+ root
387
+ });
388
+ if (ready) writeFileSync(repo(root).turn, JSON.stringify({
389
+ ...thisCommand(),
390
+ ready,
391
+ app: running.pid,
392
+ url: app.url
393
+ }));
315
394
  const said = [];
316
- for (const output of [started.stdout, started.stderr]) {
395
+ for (const output of [running.stdout, running.stderr]) {
317
396
  output.on("data", (chunk) => {
318
397
  said.push(...chunk.toString().split("\n").filter((line) => line.trim() && !/^\s+at /.test(line)));
319
398
  said.splice(0, said.length - 10);
320
399
  });
321
400
  output.unref();
322
401
  }
323
- started.unref();
402
+ running.unref();
324
403
  let ended = false;
325
- started.once("exit", () => ended = true);
404
+ running.once("exit", () => ended = true);
326
405
  const saying = () => said.length ? ["It said, last:", ...said.map((line) => ` ${line}`)] : [];
327
406
  while (Date.now() - began < 18e4) {
328
407
  if (await serves(app.url)) return Date.now() - began;
@@ -333,24 +412,48 @@ async function keepRunning(app, root) {
333
412
  ]);
334
413
  await new Promise((resolve) => setTimeout(resolve, 250));
335
414
  }
336
- stop();
415
+ stop(app.url);
337
416
  throw new Refused([
338
417
  `The app didn't answer at ${app.url} within three minutes of ${quote(app.start)}.`,
339
418
  ...saying(),
340
419
  "Check that playthrough/app.json starts it there."
341
420
  ]);
342
421
  }
422
+ async function takeTurns(root, ready) {
423
+ const here = repo(root);
424
+ let turn;
425
+ try {
426
+ turn = JSON.parse(readFileSync(here.turn, "utf8"));
427
+ } catch {}
428
+ const theirs = turn && turn.by !== process.pid && stillAt(turn) ? turn : void 0;
429
+ if (ready) {
430
+ 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.`]);
431
+ return;
432
+ }
433
+ await here.keepOutOfGit();
434
+ writeFileSync(here.turn, JSON.stringify({
435
+ ...thisCommand(),
436
+ ready
437
+ }));
438
+ if (!theirs?.ready) return;
439
+ try {
440
+ process.kill(-theirs.app);
441
+ } catch {}
442
+ const began = Date.now();
443
+ while (Date.now() - began < 1e4 && await serves(theirs.url)) await new Promise((resolve) => setTimeout(resolve, 250));
444
+ }
343
445
  async function stopApp(url) {
344
- if (!stop()) return;
446
+ if (!stop(url)) return;
345
447
  const began = Date.now();
346
448
  while (Date.now() - began < 1e4 && await serves(url)) await new Promise((resolve) => setTimeout(resolve, 250));
347
449
  }
348
- function stop() {
349
- if (!started) return false;
450
+ function stop(url) {
451
+ const ours = started.get(url);
452
+ if (!ours) return false;
350
453
  try {
351
- process.kill(-started.pid);
454
+ process.kill(-ours.app.pid);
352
455
  } catch {}
353
- started = void 0;
456
+ started.delete(url);
354
457
  return true;
355
458
  }
356
459
  const serves = (url) => fetch(url, { signal: AbortSignal.timeout(2e3) }).then((response) => (response.body?.cancel(), true), (error) => error?.name === "TimeoutError");
@@ -435,6 +538,17 @@ function rewordedWords(a, b) {
435
538
  };
436
539
  return `“${part(a.words)}” now says “${part(b.words)}”`;
437
540
  }
541
+ function rewordedPart(a, b) {
542
+ const letter = /[\p{L}\p{N}’']/u;
543
+ let start = 0;
544
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
545
+ let end = 0;
546
+ while (end < a.length - start && end < b.length - start && a.at(-1 - end) === b.at(-1 - end)) end++;
547
+ let to = b.length - end;
548
+ while (start > 0 && letter.test(b[start - 1]) && letter.test(b[start] ?? "")) start--;
549
+ while (to < b.length && to > 0 && letter.test(b[to - 1]) && letter.test(b[to])) to++;
550
+ return b.slice(start, to).trim() || void 0;
551
+ }
438
552
  function lookWords(a, b) {
439
553
  const parts = [];
440
554
  if (a.look.background !== b.look.background) parts.push("background");
@@ -551,7 +665,7 @@ function differences(then, now) {
551
665
  });
552
666
  continue;
553
667
  }
554
- 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));
668
+ 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));
555
669
  if (!a) {
556
670
  found.push({
557
671
  what: "new",
@@ -711,6 +825,14 @@ function gained(reading, control, was) {
711
825
  }
712
826
  const CONTROL = /^(button|link|textbox|searchbox|combobox|checkbox|radio|switch|slider|tab|menuitem|option)$/;
713
827
  const holdsControls = (reading, thing) => reading.things.some((inside) => parentOf(reading, inside) === thing && CONTROL.test(inside.role));
828
+ function kept$1(a, b) {
829
+ if (a.length < 24 || a === b) return false;
830
+ let start = 0;
831
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
832
+ let end = 0;
833
+ while (end < a.length - start && end < b.length - start && a.at(-1 - end) === b.at(-1 - end)) end++;
834
+ return (start + end) / a.length >= 2 / 3;
835
+ }
714
836
  const mostlyWhereItWas = (a, b) => HOLDS.test(b.role) && overlap(a, b) > .5;
715
837
  const holdsTheSameWords = (a, b) => /^(box|menu)$/.test(b.role) && !!a.wordsInside && bag(a) === bag(b);
716
838
  const bag = (thing) => (thing.wordsInside ?? "").split(" ").sort().join(" ");
@@ -829,7 +951,7 @@ function changesAt(then, now, unsteady = /* @__PURE__ */ new Set(), shown = {
829
951
  const told = [
830
952
  ...items.reordered,
831
953
  ...reordered,
832
- ...swappedOrMoved(moment, loose),
954
+ ...swappedOrMoved(moment, madeRoom(loose, others, caused, along)),
833
955
  ...causesOfShifts(moment, shifts, widened, others, caused, along)
834
956
  ];
835
957
  for (const difference of others) if (!caused.has(difference)) told.push(tell(moment, difference, along.get(difference)));
@@ -1047,6 +1169,20 @@ function layoutShifts(moves, carried) {
1047
1169
  loose: all.filter((shift) => counted(shift.moves) < MANY).flatMap((shift) => shift.moves)
1048
1170
  };
1049
1171
  }
1172
+ function madeRoom(loose, others, caused, along) {
1173
+ const candidates = others.filter((difference) => !caused.has(difference));
1174
+ return loose.filter((move) => {
1175
+ const by = movedBy(move);
1176
+ if (!near(by.x, 0) || loose.some((other) => Math.sign(movedBy(other).y) === -Math.sign(by.y))) return true;
1177
+ const cause = causeOf(spanOf([move.now]), {
1178
+ vertical: true,
1179
+ by: by.y,
1180
+ moving: true
1181
+ }, candidates);
1182
+ if (cause) addAlong(along, cause, "pushed", [move.now], wayOf(0, by.y));
1183
+ return !cause;
1184
+ });
1185
+ }
1050
1186
  function swappedOrMoved(moment, loose) {
1051
1187
  const told = [];
1052
1188
  for (const [holder, moves] of groupBy(loose, (move) => holderOf(moment.now, move.now))) {
@@ -1246,6 +1382,7 @@ function tell(moment, difference, along = {}) {
1246
1382
  thing: difference.now,
1247
1383
  in: now,
1248
1384
  words: rewordedWords(difference.then, difference.now),
1385
+ reworded: rewordedPart(difference.then.words, difference.now.words),
1249
1386
  ...along
1250
1387
  };
1251
1388
  case "looks different": {
@@ -1305,7 +1442,11 @@ function said(told, moment, unsteady) {
1305
1442
  words: alikeWords(alike),
1306
1443
  ...placeOf(alike[0], now),
1307
1444
  ...alikeOf(alike, now),
1308
- pieces: alike.flatMap(piecesOf)
1445
+ pieces: alike.flatMap(piecesOf),
1446
+ ...alike[0].reworded && { reworded: alike[0].reworded },
1447
+ ...alike[0].in !== now && { gone: true },
1448
+ ...offScreen(alike),
1449
+ top: topOf(alike[0], now)
1309
1450
  });
1310
1451
  continue;
1311
1452
  }
@@ -1321,11 +1462,15 @@ function said(told, moment, unsteady) {
1321
1462
  id: id(changed),
1322
1463
  words: sentence(words),
1323
1464
  ...placeOf(changed, now),
1324
- pieces: all.flat().flatMap(piecesOf)
1465
+ pieces: all.flat().flatMap(piecesOf),
1466
+ ...offScreen(all.flat()),
1467
+ top: topOf(changed, now)
1325
1468
  });
1326
1469
  }
1327
1470
  return found;
1328
1471
  }
1472
+ const topOf = (told, now) => told.in === now ? told.thing.box.y / now.screen.height : 2;
1473
+ const offScreen = (told) => told.some((one) => onScreen(one.thing.box, one.in.screen)) ? {} : { offScreen: true };
1329
1474
  function identityOf({ in: reading, thing, what }, moment) {
1330
1475
  const item = thing.item === void 0 ? void 0 : reading.items?.[thing.item];
1331
1476
  if (!item) return identity(reading, thing);
@@ -1411,6 +1556,7 @@ function asItems(told, moment, id) {
1411
1556
  ...whole,
1412
1557
  what: "reworded",
1413
1558
  words: reworded,
1559
+ reworded: rewordedPart(before.words, item.words),
1414
1560
  pieces: words.flatMap((one) => one.pieces ?? [id(one)]),
1415
1561
  key: `${item.role} of ${listOf(now, item)}: ${shapeOf(before.words, item.words)}`
1416
1562
  }, ...all.filter((one) => one.thing.role !== "text"));
@@ -1576,7 +1722,8 @@ async function picturesAt(page, then, now, pictures) {
1576
1722
  id,
1577
1723
  words: sentence(words),
1578
1724
  ...box && { box },
1579
- pieces: [here ? `${HERE}${id}` : id]
1725
+ pieces: [here ? `${HERE}${id}` : id],
1726
+ top: patch.y / now.screen.height
1580
1727
  };
1581
1728
  });
1582
1729
  }
@@ -1827,7 +1974,7 @@ function unsteadyIn(flows) {
1827
1974
  async function comparing(page, earlier, round, unsteady = () => /* @__PURE__ */ new Set()) {
1828
1975
  const changes = /* @__PURE__ */ new Map();
1829
1976
  const pieces = /* @__PURE__ */ new Map();
1830
- const see = ({ id, words, place, of = [id], pictureOnly }) => {
1977
+ const see = ({ id, words, place, of = [id], reworded, gone, pictureOnly }) => {
1831
1978
  if (unsteady(place.flow).has(id)) return;
1832
1979
  const change = changes.get(id);
1833
1980
  for (const piece of of) (pieces.get(id) ?? pieces.set(id, /* @__PURE__ */ new Set()).get(id)).add(piece);
@@ -1835,6 +1982,8 @@ async function comparing(page, earlier, round, unsteady = () => /* @__PURE__ */
1835
1982
  id,
1836
1983
  words,
1837
1984
  seen: [place],
1985
+ ...reworded && { reworded },
1986
+ ...gone && { gone },
1838
1987
  ...pictureOnly && { pictureOnly }
1839
1988
  });
1840
1989
  else {
@@ -1879,7 +2028,7 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
1879
2028
  now: around(glimpsed.now, flow, reading.step)
1880
2029
  };
1881
2030
  const pictured = await picturesAt(page, was, reading, pictures);
1882
- for (const found of [...changesAt(was, reading, unsteady, shown), ...pictured]) {
2031
+ for (const found of [...changesAt(was, reading, unsteady, shown), ...pictured].sort((a, b) => (a.top ?? 0) - (b.top ?? 0))) {
1883
2032
  const on = found.element ? {
1884
2033
  element: found.element,
1885
2034
  ...found.alike && { alike: found.alike }
@@ -1893,11 +2042,14 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
1893
2042
  ...on
1894
2043
  },
1895
2044
  of: found.pieces,
1896
- ...pictured.includes(found) && { pictureOnly: true }
2045
+ ...found.reworded && { reworded: found.reworded },
2046
+ ...found.gone && { gone: true },
2047
+ ...pictured.includes(found) && { pictureOnly: true },
2048
+ ...found.offScreen && { offScreen: true }
1897
2049
  });
1898
2050
  }
1899
2051
  }
1900
- return sightings.sort((a, b) => (a.place.at ?? 0) - (b.place.at ?? 0));
2052
+ return sightings.sort((a, b) => Number(!!a.offScreen) - Number(!!b.offScreen) || (a.place.at ?? 0) - (b.place.at ?? 0));
1901
2053
  }
1902
2054
  function around(glimpses, flow, step) {
1903
2055
  const next = flow.steps[flow.steps.findIndex((kept) => kept.title === step) + 1]?.title;
@@ -1962,7 +2114,7 @@ async function trySetup(repo, again) {
1962
2114
  const app = await repo.app();
1963
2115
  const written = await writtenNow(repo);
1964
2116
  const last = join(repo.tried, "tried.json");
1965
- if (await readFile(last, "utf8").then((text) => JSON.parse(text).written, () => void 0) === written) return;
2117
+ if (await readFile(last, "utf8").then((text) => JSON.parse(text).written).catch(() => void 0) === written) return;
1966
2118
  console.log("Trying the app's setup by itself first, twice, as every flow will start…");
1967
2119
  const browser = await chromium.launch().catch((error) => {
1968
2120
  const { version } = createRequire(import.meta.url)("@playwright/test/package.json");
@@ -2075,6 +2227,7 @@ async function writtenNow(repo) {
2075
2227
  return createHash("sha256").update(await text(repo.appFile)).update("\0").update(await text(repo.setup)).digest("hex");
2076
2228
  }
2077
2229
  // src/flows.ts
2230
+ const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
2078
2231
  const WALKED = "walked.json";
2079
2232
  const ALONGSIDE = Symbol.for("playthrough: flows taken over here");
2080
2233
  const REACHES_OUTSIDE = "reaches outside";
@@ -2117,7 +2270,6 @@ async function runFlows(repo, id, round, { before, first = () => false }) {
2117
2270
  const app = await repo.app();
2118
2271
  if (!before) await trySetup(repo, `playthrough record ${id}`);
2119
2272
  const { started, rounds } = await repo.reviews.get(id);
2120
- const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
2121
2273
  const longest = (rounds.filter((kept) => kept.number < round).at(-1)?.flows ?? []).toSorted((a, b) => b.length - a.length).map((flow) => flow.name);
2122
2274
  const run = (only, results) => runOnce({
2123
2275
  flows,
@@ -2198,7 +2350,7 @@ async function keepFailed(repo, id, failed) {
2198
2350
  await mkdir(folder, { recursive: true });
2199
2351
  await writeFile(join(folder, FAILED), JSON.stringify(failed.map(({ flow }) => flow)));
2200
2352
  }
2201
- const failedLastTime = (repo, id) => readFile(join(repo.failed(id), FAILED), "utf8").then(JSON.parse, () => []);
2353
+ const failedLastTime = (repo, id) => readFile(join(repo.failed(id), FAILED), "utf8").then(JSON.parse).catch(() => []);
2202
2354
  const FAILED = "flows.json";
2203
2355
  const whereItStopped = (repo, id, failed) => `What the page held where ${failed.length > 1 ? "each" : "it"} stopped is in ${repo.show(repo.failed(id))}/.`;
2204
2356
  function inMemory(number, recorded) {
@@ -2249,7 +2401,6 @@ async function runOnce(run) {
2249
2401
  async function walkFork(repo, id, walk) {
2250
2402
  const app = await repo.app();
2251
2403
  const { started } = await repo.reviews.get(id);
2252
- const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
2253
2404
  const folder = await mkdtemp(join(tmpdir(), "playthrough-"));
2254
2405
  try {
2255
2406
  await playwright({
@@ -2318,4 +2469,4 @@ async function playwright(run) {
2318
2469
  });
2319
2470
  });
2320
2471
  }
2321
- export { firstOf$1 as C, setupIn as D, personIn as E, repo as O, changes as S, make as T, placeIn as _, failedAt as a, serves as b, isFlow as c, waitsFor as d, walkFork as f, compareWithEarlier as g, writtenNow as h, called as i, pausedAt as k, makeFlowsFolder as l, trySetup as m, REACHES_OUTSIDE as n, flowsOf as o, whereItStopped as p, WALKED as r, howToWriteFlows as s, ALONGSIDE as t, runFlows as u, shownAt as v, giveBackLeft as w, stopApp as x, keepRunning as y };
2472
+ export { repo as A, changes as C, personIn as D, make as E, thisCommand as M, pausedAt as N, 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, stillAt 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 };