playthrough 0.5.0 → 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
@@ -79,6 +80,12 @@ the page once it rests after each step. A dialog the browser shows is recorded,
79
80
  first: page.once("dialog", (dialog) => dialog.accept()). A flow that fails stops the round: record names it and the
80
81
  step, and keeps the page it failed on in .playthrough/<review>/failed/.
81
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
+
82
89
  While the person watches, Playthrough runs the flow again in the background, so taking it over is quick. If its steps do
83
90
  something outside the app that mustn't happen again, like sending an email, say so, and it runs only when taken over:
84
91
 
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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playthrough",
3
- "version": "0.5.0",
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,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-S9fOk0za.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"),
@@ -324,7 +349,7 @@ function alive(pid) {
324
349
  // src/app.ts
325
350
  const started = /* @__PURE__ */ new Map();
326
351
  let ending = false;
327
- async function keepRunning(app, root) {
352
+ async function keepRunning(app, root, { ready = false } = {}) {
328
353
  if (!ending) {
329
354
  ending = true;
330
355
  const { setup, borrowed } = repo(root);
@@ -337,6 +362,7 @@ async function keepRunning(app, root) {
337
362
  ]) process.once(signal, end);
338
363
  process.once("exit", () => [...started.keys()].forEach(stop));
339
364
  }
365
+ await takeTurns(root, ready);
340
366
  const ours = started.get(app.url);
341
367
  if (ours && ours.root !== root) await stopApp(app.url);
342
368
  if (await serves(app.url)) {
@@ -359,6 +385,12 @@ async function keepRunning(app, root) {
359
385
  app: running,
360
386
  root
361
387
  });
388
+ if (ready) writeFileSync(repo(root).turn, JSON.stringify({
389
+ ...thisCommand(),
390
+ ready,
391
+ app: running.pid,
392
+ url: app.url
393
+ }));
362
394
  const said = [];
363
395
  for (const output of [running.stdout, running.stderr]) {
364
396
  output.on("data", (chunk) => {
@@ -387,6 +419,29 @@ async function keepRunning(app, root) {
387
419
  "Check that playthrough/app.json starts it there."
388
420
  ]);
389
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
+ }
390
445
  async function stopApp(url) {
391
446
  if (!stop(url)) return;
392
447
  const began = Date.now();
@@ -483,6 +538,17 @@ function rewordedWords(a, b) {
483
538
  };
484
539
  return `“${part(a.words)}” now says “${part(b.words)}”`;
485
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
+ }
486
552
  function lookWords(a, b) {
487
553
  const parts = [];
488
554
  if (a.look.background !== b.look.background) parts.push("background");
@@ -599,7 +665,7 @@ function differences(then, now) {
599
665
  });
600
666
  continue;
601
667
  }
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));
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));
603
669
  if (!a) {
604
670
  found.push({
605
671
  what: "new",
@@ -759,6 +825,14 @@ function gained(reading, control, was) {
759
825
  }
760
826
  const CONTROL = /^(button|link|textbox|searchbox|combobox|checkbox|radio|switch|slider|tab|menuitem|option)$/;
761
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
+ }
762
836
  const mostlyWhereItWas = (a, b) => HOLDS.test(b.role) && overlap(a, b) > .5;
763
837
  const holdsTheSameWords = (a, b) => /^(box|menu)$/.test(b.role) && !!a.wordsInside && bag(a) === bag(b);
764
838
  const bag = (thing) => (thing.wordsInside ?? "").split(" ").sort().join(" ");
@@ -877,7 +951,7 @@ function changesAt(then, now, unsteady = /* @__PURE__ */ new Set(), shown = {
877
951
  const told = [
878
952
  ...items.reordered,
879
953
  ...reordered,
880
- ...swappedOrMoved(moment, loose),
954
+ ...swappedOrMoved(moment, madeRoom(loose, others, caused, along)),
881
955
  ...causesOfShifts(moment, shifts, widened, others, caused, along)
882
956
  ];
883
957
  for (const difference of others) if (!caused.has(difference)) told.push(tell(moment, difference, along.get(difference)));
@@ -1095,6 +1169,20 @@ function layoutShifts(moves, carried) {
1095
1169
  loose: all.filter((shift) => counted(shift.moves) < MANY).flatMap((shift) => shift.moves)
1096
1170
  };
1097
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
+ }
1098
1186
  function swappedOrMoved(moment, loose) {
1099
1187
  const told = [];
1100
1188
  for (const [holder, moves] of groupBy(loose, (move) => holderOf(moment.now, move.now))) {
@@ -1294,6 +1382,7 @@ function tell(moment, difference, along = {}) {
1294
1382
  thing: difference.now,
1295
1383
  in: now,
1296
1384
  words: rewordedWords(difference.then, difference.now),
1385
+ reworded: rewordedPart(difference.then.words, difference.now.words),
1297
1386
  ...along
1298
1387
  };
1299
1388
  case "looks different": {
@@ -1353,7 +1442,11 @@ function said(told, moment, unsteady) {
1353
1442
  words: alikeWords(alike),
1354
1443
  ...placeOf(alike[0], now),
1355
1444
  ...alikeOf(alike, now),
1356
- 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)
1357
1450
  });
1358
1451
  continue;
1359
1452
  }
@@ -1369,11 +1462,15 @@ function said(told, moment, unsteady) {
1369
1462
  id: id(changed),
1370
1463
  words: sentence(words),
1371
1464
  ...placeOf(changed, now),
1372
- pieces: all.flat().flatMap(piecesOf)
1465
+ pieces: all.flat().flatMap(piecesOf),
1466
+ ...offScreen(all.flat()),
1467
+ top: topOf(changed, now)
1373
1468
  });
1374
1469
  }
1375
1470
  return found;
1376
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 };
1377
1474
  function identityOf({ in: reading, thing, what }, moment) {
1378
1475
  const item = thing.item === void 0 ? void 0 : reading.items?.[thing.item];
1379
1476
  if (!item) return identity(reading, thing);
@@ -1459,6 +1556,7 @@ function asItems(told, moment, id) {
1459
1556
  ...whole,
1460
1557
  what: "reworded",
1461
1558
  words: reworded,
1559
+ reworded: rewordedPart(before.words, item.words),
1462
1560
  pieces: words.flatMap((one) => one.pieces ?? [id(one)]),
1463
1561
  key: `${item.role} of ${listOf(now, item)}: ${shapeOf(before.words, item.words)}`
1464
1562
  }, ...all.filter((one) => one.thing.role !== "text"));
@@ -1624,7 +1722,8 @@ async function picturesAt(page, then, now, pictures) {
1624
1722
  id,
1625
1723
  words: sentence(words),
1626
1724
  ...box && { box },
1627
- pieces: [here ? `${HERE}${id}` : id]
1725
+ pieces: [here ? `${HERE}${id}` : id],
1726
+ top: patch.y / now.screen.height
1628
1727
  };
1629
1728
  });
1630
1729
  }
@@ -1875,7 +1974,7 @@ function unsteadyIn(flows) {
1875
1974
  async function comparing(page, earlier, round, unsteady = () => /* @__PURE__ */ new Set()) {
1876
1975
  const changes = /* @__PURE__ */ new Map();
1877
1976
  const pieces = /* @__PURE__ */ new Map();
1878
- const see = ({ id, words, place, of = [id], pictureOnly }) => {
1977
+ const see = ({ id, words, place, of = [id], reworded, gone, pictureOnly }) => {
1879
1978
  if (unsteady(place.flow).has(id)) return;
1880
1979
  const change = changes.get(id);
1881
1980
  for (const piece of of) (pieces.get(id) ?? pieces.set(id, /* @__PURE__ */ new Set()).get(id)).add(piece);
@@ -1883,6 +1982,8 @@ async function comparing(page, earlier, round, unsteady = () => /* @__PURE__ */
1883
1982
  id,
1884
1983
  words,
1885
1984
  seen: [place],
1985
+ ...reworded && { reworded },
1986
+ ...gone && { gone },
1886
1987
  ...pictureOnly && { pictureOnly }
1887
1988
  });
1888
1989
  else {
@@ -1927,7 +2028,7 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
1927
2028
  now: around(glimpsed.now, flow, reading.step)
1928
2029
  };
1929
2030
  const pictured = await picturesAt(page, was, reading, pictures);
1930
- 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))) {
1931
2032
  const on = found.element ? {
1932
2033
  element: found.element,
1933
2034
  ...found.alike && { alike: found.alike }
@@ -1941,11 +2042,14 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
1941
2042
  ...on
1942
2043
  },
1943
2044
  of: found.pieces,
1944
- ...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 }
1945
2049
  });
1946
2050
  }
1947
2051
  }
1948
- 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));
1949
2053
  }
1950
2054
  function around(glimpses, flow, step) {
1951
2055
  const next = flow.steps[flow.steps.findIndex((kept) => kept.title === step) + 1]?.title;
@@ -2365,4 +2469,4 @@ async function playwright(run) {
2365
2469
  });
2366
2470
  });
2367
2471
  }
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 };
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 };
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env -S node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON
2
- import { A as quote, C as inOrderJoined, D as roundInProgress, E as problemsFinishing, O as toAccountFor, S as conversation, T as notesToAnswer, g as readingsOn, j as time, k as list, p as firstLine, w as lastSeen, x as allNotes, y as Refused } from "./reporter-Dr7dWwSf.js";
3
- import { A as repo, O as setupIn, S as stopApp, T as giveBackLeft, _ as compareWithEarlier, a as failedAt, b as keepRunning, c as isFlow, d as runFlows, g as writtenNow, h as trySetup, i as called, j as pausedAt, k as copies, l as makeFlowsFolder, m as whereItStopped, o as flowsOf, p as walkFork, s as howToWriteFlows, t as ALONGSIDE, u as recorder, v as placeIn, x as serves, y as shownAt } from "./flows-DDB6AdQ-.js";
2
+ import { A as quote, C as inOrderJoined, D as roundInProgress, E as problemsFinishing, O as toAccountFor, S as conversation, T as notesToAnswer, g as readingsOn, j as time, k as list, p as firstLine, w as lastSeen, x as allNotes, y as Refused } from "./reporter-S9fOk0za.js";
3
+ import { A as repo, M as thisCommand, N as pausedAt, O as setupIn, S as stopApp, T as giveBackLeft, _ as compareWithEarlier, a as failedAt, b as keepRunning, c as isFlow, d as runFlows, g as writtenNow, h as trySetup, i as called, j as stillAt, k as copies, l as makeFlowsFolder, m as whereItStopped, o as flowsOf, p as walkFork, s as howToWriteFlows, t as ALONGSIDE, u as recorder, v as placeIn, x as serves, y as shownAt } from "./flows-vi0amwSo.js";
4
4
  import { t as read } from "./read-Bg9XVTtn.js";
5
5
  import { copyFileSync, cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { execFileSync } from "node:child_process";
7
7
  import { readFile, readdir, stat, writeFile } from "node:fs/promises";
8
8
  import { createHash, randomBytes } from "node:crypto";
9
- import { constants, networkInterfaces, tmpdir, uptime, userInfo } from "node:os";
9
+ import { constants, networkInterfaces, tmpdir, userInfo } from "node:os";
10
10
  import { basename, dirname, extname, join, normalize, resolve, sep } from "node:path";
11
11
  import { connect } from "node:net";
12
12
  import { chromium, request } from "@playwright/test";
@@ -18,6 +18,71 @@ if (major < 24) {
18
18
  console.error(`Playthrough needs Node 24 or later, and this is Node ${process.versions.node}. Install Node 24, then run this again.`);
19
19
  process.exit(1);
20
20
  }
21
+ // ../page/src/scheme.ts
22
+ function asRecorded(text, recorded) {
23
+ const answers = {
24
+ "prefers-color-scheme": recorded.dark ? "dark" : "light",
25
+ "prefers-reduced-motion": recorded.still ? "reduce" : "no-preference"
26
+ };
27
+ return String(text).replace(/\(\s*(prefers-color-scheme|prefers-reduced-motion)\s*(?::\s*([a-z-]+)\s*)?\)/gi, (_, feature, value) => {
28
+ feature = feature.toLowerCase();
29
+ return (value ? value.toLowerCase() === answers[feature] : feature === "prefers-color-scheme" || recorded.still) ? "(min-width: 0px)" : "(max-width: 0px)";
30
+ });
31
+ }
32
+ function answerAs(view, recorded) {
33
+ const page = view.document;
34
+ const as = (query) => asRecorded(query, recorded);
35
+ const matching = view.matchMedia;
36
+ view.matchMedia = (query) => matching.call(view, as(query));
37
+ const sheet = view.CSSStyleSheet.prototype;
38
+ for (const name of [
39
+ "insertRule",
40
+ "replace",
41
+ "replaceSync"
42
+ ]) {
43
+ const real = sheet[name];
44
+ if (real) sheet[name] = function(text, ...rest) {
45
+ return real.call(this, as(text), ...rest);
46
+ };
47
+ }
48
+ const scheme = recorded.dark ? "dark" : "light";
49
+ const inScheme = (value) => /\blight\b/.test(value) && /\bdark\b/.test(value) ? scheme : value;
50
+ const rewrite = (rules) => {
51
+ for (const rule of rules) {
52
+ if (rule.media && as(rule.media.mediaText) !== rule.media.mediaText) rule.media.mediaText = as(rule.media.mediaText);
53
+ if (rule.style?.colorScheme && inScheme(rule.style.colorScheme) !== rule.style.colorScheme) rule.style.colorScheme = inScheme(rule.style.colorScheme);
54
+ if (rule.styleSheet) restyle(rule.styleSheet);
55
+ if (rule.cssRules) rewrite(rule.cssRules);
56
+ }
57
+ };
58
+ const restyle = (sheet) => {
59
+ try {
60
+ rewrite(sheet.cssRules);
61
+ } catch {}
62
+ };
63
+ const restyleAll = () => {
64
+ for (const sheet of page.styleSheets) restyle(sheet);
65
+ for (const sheet of page.adoptedStyleSheets ?? []) restyle(sheet);
66
+ for (const element of page.querySelectorAll("[media]")) {
67
+ const media = element.getAttribute("media");
68
+ if (as(media) !== media) element.setAttribute("media", as(media));
69
+ }
70
+ for (const meta of page.querySelectorAll("meta[name=\"color-scheme\"]")) {
71
+ const content = meta.getAttribute("content") ?? "";
72
+ if (inScheme(content) !== content) meta.setAttribute("content", inScheme(content));
73
+ }
74
+ };
75
+ const styling = (node) => /^(style|link|source|meta)$/i.test(node?.nodeName ?? "");
76
+ const bringsStyle = (node) => styling(node) || !!node.querySelector?.("style, link, source, meta");
77
+ new view.MutationObserver((changes) => changes.some((change) => styling(change.target) || styling(change.target.parentNode) || [...change.addedNodes].some(bringsStyle)) && restyleAll()).observe(page, {
78
+ subtree: true,
79
+ childList: true,
80
+ characterData: true,
81
+ attributeFilter: ["media", "content"]
82
+ });
83
+ page.addEventListener("load", (event) => styling(event.target) && restyleAll(), true);
84
+ restyleAll();
85
+ }
21
86
  // src/before.ts
22
87
  function keepBefore(repo, id) {
23
88
  if (!attempt(() => git(repo, ["rev-parse", "--is-inside-work-tree"]))) return void 0;
@@ -166,8 +231,7 @@ async function asItWas(repo, unrecorded, commit, shared, run) {
166
231
  const aside = {
167
232
  back: here(repo),
168
233
  stash: `Playthrough: your work, set aside while flows record on Before, ${(/* @__PURE__ */ new Date()).toISOString()}`,
169
- by: process.pid,
170
- boot: boot(),
234
+ ...thisCommand(),
171
235
  ...shared && { shared }
172
236
  };
173
237
  writeFileSync(repo.setAside, JSON.stringify(aside));
@@ -246,14 +310,6 @@ function refuseWhileAside(repo) {
246
310
  if (aside && aside.by !== process.pid && stillAt(aside)) throw new Refused([`Another playthrough command (process ${aside.by}) is recording flows on Before just now, so the app as it was is in your work's place until it's done, in a minute or so. Try again then.`]);
247
311
  }
248
312
  const setAside = (repo) => attempt(() => JSON.parse(readFileSync(repo.setAside, "utf8")));
249
- const stillAt = (aside) => sameBoot(aside.boot) && running(aside.by);
250
- function boot() {
251
- return attempt(() => readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim()) || Date.now() / 1e3 - uptime();
252
- }
253
- function sameBoot(then, now = boot()) {
254
- if (typeof then === "number" && typeof now === "number") return Math.abs(then - now) < 300;
255
- return then === void 0 || then === now;
256
- }
257
313
  function putWorkBack(repo, aside, force) {
258
314
  try {
259
315
  if (here(repo) !== aside.back) {
@@ -324,13 +380,6 @@ async function caughtUp(url) {
324
380
  await new Promise((done) => setTimeout(done, 1e3));
325
381
  await fetch(url, { signal: AbortSignal.timeout(6e4) }).then((response) => response.body?.cancel(), () => {});
326
382
  }
327
- function running(pid) {
328
- try {
329
- return process.kill(pid, 0);
330
- } catch (error) {
331
- return error.code === "EPERM";
332
- }
333
- }
334
383
  function git(repo, args, env) {
335
384
  return execFileSync("git", [
336
385
  "-c",
@@ -594,7 +643,7 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
594
643
  if (outside) throw new Refused([`${quote(flow.name)} reaches outside the app (${outside}), so it's run only when it's taken over.`]);
595
644
  const app = await repo.app();
596
645
  refuseWhileAside(repo);
597
- await keepRunning(app, repo.root);
646
+ await keepRunning(app, repo.root, { ready: true });
598
647
  const began = Date.now();
599
648
  const { handed, made } = await runTo(repo, id, {
600
649
  flow: flow.name,
@@ -635,7 +684,7 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
635
684
  const ready = /* @__PURE__ */ new Map();
636
685
  async function keepUp(repo, id, flow, step, actions) {
637
686
  refuseWhileAside(repo);
638
- await keepRunning(await repo.app(), repo.root);
687
+ await keepRunning(await repo.app(), repo.root, { ready: true });
639
688
  await follow(repo, id, {
640
689
  flow,
641
690
  ...step && {
@@ -738,52 +787,11 @@ const SAYS = `<script>(() => {
738
787
  Object.assign(Shifted, { now: () => Real.now() + ahead, parse: Real.parse, UTC: Real.UTC });
739
788
  window.Date = Shifted;
740
789
  }
741
- // In the browser the flow was recorded in, whatever yours says: light or dark, still or moving, in its languages. A
742
- // page asks in its scripts and its styles, and each asking is answered before the app hears it.
790
+ // In the browser the flow was recorded in, whatever yours says: light or dark, still or moving, in its languages.
743
791
  const browser = JSON.parse(sessionStorage.getItem("playthrough:browser") || "null");
744
792
  if (browser) {
745
- const recorded = { "prefers-color-scheme": browser.dark ? "dark" : "light", "prefers-reduced-motion": browser.still ? "reduce" : "no-preference" };
746
- const as = (query) =>
747
- String(query).replace(/\\(\\s*(prefers-color-scheme|prefers-reduced-motion)\\s*(?::\\s*([a-z-]+)\\s*)?\\)/gi, (_, feature, value) => {
748
- feature = feature.toLowerCase();
749
- const holds = value ? value.toLowerCase() === recorded[feature] : feature === "prefers-color-scheme" || browser.still;
750
- return holds ? "(min-width: 0px)" : "(max-width: 0px)";
751
- });
752
- const matching = window.matchMedia;
753
- window.matchMedia = (query) => matching.call(window, as(query));
754
- for (const name of ["insertRule", "replace", "replaceSync"]) {
755
- const real = CSSStyleSheet.prototype[name];
756
- if (real) CSSStyleSheet.prototype[name] = function (text, ...rest) { return real.call(this, as(text), ...rest); };
757
- }
758
- // light-dark(), system colours, form controls and scrollbars follow the page's colour-scheme, not a media query.
759
- const scheme = browser.dark ? "dark" : "light";
760
- const inScheme = (value) => (/\\blight\\b/.test(value) && /\\bdark\\b/.test(value) ? scheme : value);
761
- const rewrite = (rules) => {
762
- for (const rule of rules) {
763
- if (rule.media && as(rule.media.mediaText) !== rule.media.mediaText) rule.media.mediaText = as(rule.media.mediaText);
764
- if (rule.style?.colorScheme && inScheme(rule.style.colorScheme) !== rule.style.colorScheme) rule.style.colorScheme = inScheme(rule.style.colorScheme);
765
- if (rule.styleSheet) restyle(rule.styleSheet);
766
- if (rule.cssRules) rewrite(rule.cssRules);
767
- }
768
- };
769
- const restyle = (sheet) => { try { rewrite(sheet.cssRules); } catch {} };
770
- const restyleAll = () => {
771
- for (const sheet of document.styleSheets) restyle(sheet);
772
- for (const sheet of document.adoptedStyleSheets ?? []) restyle(sheet);
773
- for (const element of document.querySelectorAll("[media]")) {
774
- const media = element.getAttribute("media");
775
- if (as(media) !== media) element.setAttribute("media", as(media));
776
- }
777
- for (const meta of document.querySelectorAll('meta[name="color-scheme"]')) {
778
- const content = meta.getAttribute("content") ?? "";
779
- if (inScheme(content) !== content) meta.setAttribute("content", inScheme(content));
780
- }
781
- };
782
- const styling = (node) => /^(style|link|source|meta)$/i.test(node?.nodeName);
783
- const bringsStyle = (node) => styling(node) || !!node.querySelector?.("style, link, source, meta");
784
- new MutationObserver((changes) => changes.some((change) => styling(change.target) || styling(change.target.parentNode) || [...change.addedNodes].some(bringsStyle)) && restyleAll())
785
- .observe(document, { subtree: true, childList: true, characterData: true, attributeFilter: ["media", "content"] });
786
- document.addEventListener("load", (event) => styling(event.target) && restyleAll(), true);
793
+ ${asRecorded.toString()}
794
+ (${answerAs.toString()})(window, browser);
787
795
  const languages = browser.languages;
788
796
  Object.defineProperty(navigator, "language", { get: () => languages[0] });
789
797
  Object.defineProperty(navigator, "languages", { get: () => languages });
@@ -822,6 +830,9 @@ const SAYS = `<script>(() => {
822
830
  const yours = (mine) => { hidden.disabled = mine; if (mine) sessionStorage.setItem("playthrough:yours", "1"); else sessionStorage.removeItem("playthrough:yours"); };
823
831
  yours(!!sessionStorage.getItem("playthrough:yours"));
824
832
  addEventListener("message", ({ source, data }) => { if (source === parent && data?.playthrough === "yours") yours(data.yours); });
833
+ // Taken over, the page is scrolled where the recording was at that moment: made ready at a step's end, it was where
834
+ // the step left it, a screen or more from a moment partway through a page being read.
835
+ addEventListener("message", ({ source, data }) => { if (source === parent && data?.playthrough === "scroll") scrollTo({ left: data.x, top: data.y, behavior: "instant" }); });
825
836
  const typing = (target) => !!target?.closest?.('input:not([type=button]):not([type=submit]):not([type=checkbox]):not([type=radio]):not([type=range]), textarea, [contenteditable=""], [contenteditable=true]');
826
837
  // Every move, as it comes: held for a frame, the name trailed the pointer like a smoothed one. Not the earlier raw
827
838
  // updates: in a scaled frame, Chrome gives them the page's position rather than the app's.
@@ -1040,7 +1051,9 @@ const SAYS = `<script>(() => {
1040
1051
  sampling: { mousemove: 20, scroll: 16, input: "all" },
1041
1052
  maskAllInputs: false,
1042
1053
  inlineStylesheet: true,
1043
- }) && ((recording = true), readNow());
1054
+ }) && ((recording = true), said(), readNow());
1055
+ // Light or dark, still or moving, as the flow was recorded, for your recording to be played back as it was too.
1056
+ const said = () => window.rrwebRecord.record.addCustomEvent("browser", { dark: matchMedia("(prefers-color-scheme: dark)").matches, still: matchMedia("(prefers-reduced-motion: reduce)").matches });
1044
1057
  addEventListener("message", ({ source, data }) => source === parent && data?.playthrough === "yours" && record());
1045
1058
  const settle = async () => {
1046
1059
  await rested();
@@ -1590,6 +1603,11 @@ async function answer(app, page, you, request, response) {
1590
1603
  "content-type": TYPES[extname(path)] ?? "application/octet-stream",
1591
1604
  "cache-control": "max-age=31536000, immutable"
1592
1605
  });
1606
+ const as = new URL(request.url ?? "/", "http://localhost").searchParams.get("as");
1607
+ if (as !== null && extname(path) === ".svg") return response.end(asRecorded(Buffer.from(bytes).toString("utf8"), {
1608
+ dark: as.includes("dark"),
1609
+ still: as.includes("still")
1610
+ }));
1593
1611
  return response.end(bytes);
1594
1612
  }
1595
1613
  if (request.method === "POST" && rest[0] === "video") return saveVideo(repo, id, request, response);
@@ -1,4 +1,4 @@
1
- import { f as waitsFor } from "./flows-DDB6AdQ-.js";
1
+ import { f as waitsFor } from "./flows-vi0amwSo.js";
2
2
  import { availableParallelism } from "node:os";
3
3
  import { defineConfig } from "@playwright/test";
4
4
  import { fileURLToPath } from "node:url";
@@ -76,6 +76,8 @@ const Flow = z.strictObject({
76
76
  const Change = z.strictObject({
77
77
  id: text("needs an id").describe("Its thing and its kind of change, so it keeps its id in every round while it's the same change"),
78
78
  words: text("needs its words").describe("What changed, for the agent: “Download CSV and Cancel have swapped places”"),
79
+ reworded: text("needs the words that read differently").optional().describe("For words reworded, the words that read differently now, which the viewer marks where they are: “on their own”"),
80
+ gone: z.literal(true).optional().describe("Not on the newer round: where it was, on the round before, is where it's seen, with Compare"),
79
81
  seen: z.array(Place, { error: "needs where it's seen, a list" }).min(1, { error: "needs where it's seen: each flow, at its moment there" }).describe("Each flow it's seen in, at its moment there; a flow that's gone, with no moment"),
80
82
  pictureOnly: z.literal(true).optional().describe("Seen only in the pictures, with nothing read there changed: shown to the person, who can see what it is, and not put to the agent, who can't see more of it than Playthrough did")
81
83
  });
package/src/reporter.js CHANGED
@@ -1 +1 @@
1
- export { t as ACTIONS, n as ASSETS, r as DONE_ON, a as GLIMPSES, o as HANDS, s as HOLD, c as PERSON, l as PICTURE, u as READINGS, d as RECORDING, f as WROTE, i as default, p as firstLine, m as glimpsesOf, h as readingsOf, g as readingsOn, _ as seconds } from "./reporter-Dr7dWwSf.js";
1
+ export { t as ACTIONS, n as ASSETS, r as DONE_ON, a as GLIMPSES, o as HANDS, s as HOLD, c as PERSON, l as PICTURE, u as READINGS, d as RECORDING, f as WROTE, i as default, p as firstLine, m as glimpsesOf, h as readingsOf, g as readingsOn, _ as seconds } from "./reporter-S9fOk0za.js";
package/src/test.js CHANGED
@@ -1,5 +1,5 @@
1
- import { _ as seconds, a as GLIMPSES, c as PERSON, d as RECORDING, f as WROTE, l as PICTURE, n as ASSETS, o as HANDS, p as firstLine, r as DONE_ON, s as HOLD, t as ACTIONS, u as READINGS, v as keepAssets } from "./reporter-Dr7dWwSf.js";
2
- import { A as repo, C as changes, D as personIn, E as make, O as setupIn, n as REACHES_OUTSIDE, r as WALKED, t as ALONGSIDE, w as firstOf } from "./flows-DDB6AdQ-.js";
1
+ import { _ as seconds, a as GLIMPSES, c as PERSON, d as RECORDING, f as WROTE, l as PICTURE, n as ASSETS, o as HANDS, p as firstLine, r as DONE_ON, s as HOLD, t as ACTIONS, u as READINGS, v as keepAssets } from "./reporter-S9fOk0za.js";
2
+ import { A as repo, C as changes, D as personIn, E as make, O as setupIn, n as REACHES_OUTSIDE, r as WALKED, t as ALONGSIDE, w as firstOf } from "./flows-vi0amwSo.js";
3
3
  import { n as readPage, r as unhovered } from "./read-Bg9XVTtn.js";
4
4
  import { readFileSync, writeFileSync } from "node:fs";
5
5
  import { join } from "node:path";
@@ -92,15 +92,20 @@ function onlyWaits(expect) {
92
92
  const recorder = (rrweb) => `;((Date, setTimeout, clearTimeout, requestAnimationFrame, cancelAnimationFrame) => {
93
93
  ${readFileSync(rrweb, "utf8")}
94
94
  })(Date, setTimeout, clearTimeout, requestAnimationFrame, cancelAnimationFrame);
95
- if (window.top === window) window.rrwebRecord.record({
96
- // Nothing is kept until the flow itself starts, after whatever the app's setup did on the page.
97
- emit: (event) => window.__playthrough?.(event),
98
- // every input, not just the last: with "last", rrweb only hears a field change when it loses focus,
99
- // so words typed into a search box that kept focus never reached the recording
100
- sampling: { mousemove: 20, scroll: 16, input: "all" },
101
- maskAllInputs: false,
102
- inlineStylesheet: true,
103
- });`;
95
+ // With each page taken whole, whether its browser was light or dark, still or moving, for it to be played back as it
96
+ // was: said once rrweb has the page, as said at its very start it went before anything was listening.
97
+ const said = () => window.rrwebRecord.record.addCustomEvent("browser", { dark: matchMedia("(prefers-color-scheme: dark)").matches, still: matchMedia("(prefers-reduced-motion: reduce)").matches });
98
+ if (window.top === window) {
99
+ window.rrwebRecord.record({
100
+ // Nothing is kept until the flow itself starts, after whatever the app's setup did on the page.
101
+ emit: (event) => (window.__playthrough?.(event), event.type === 2 && setTimeout(said)),
102
+ // every input, not just the last: with "last", rrweb only hears a field change when it loses focus,
103
+ // so words typed into a search box that kept focus never reached the recording
104
+ sampling: { mousemove: 20, scroll: 16, input: "all" },
105
+ maskAllInputs: false,
106
+ inlineStylesheet: true,
107
+ });
108
+ }`;
104
109
  const FRAME = 16;
105
110
  const REST = 140;
106
111
  const KEY = 60;
@@ -703,6 +708,20 @@ function pace(page) {
703
708
  return result;
704
709
  };
705
710
  }
711
+ const mouse = Object.getPrototypeOf(page.mouse);
712
+ const wheel = mouse.wheel;
713
+ mouse.wheel = async function(deltaX, deltaY) {
714
+ if (!pointer) return wheel.call(this, deltaX, deltaY);
715
+ const moves = Math.max(10, Math.min(36, Math.round(Math.hypot(deltaX, deltaY) / 25)));
716
+ let done = 0;
717
+ for (let i = 1; i <= moves; i++) {
718
+ await wheel.call(this, deltaX * (ease(i / moves) - done), deltaY * (ease(i / moves) - done));
719
+ done = ease(i / moves);
720
+ await pointer.page.waitForTimeout(FRAME);
721
+ }
722
+ await pointer.page.waitForTimeout(650);
723
+ await glimpse();
724
+ };
706
725
  const keyboard = Object.getPrototypeOf(page.keyboard);
707
726
  const focused = async (page) => {
708
727
  const on = page.locator(":focus").first();