playthrough 0.3.0 → 0.4.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
@@ -3,7 +3,7 @@ to its end; each command then says what's next.
3
3
 
4
4
  playthrough start <review> "<title>" "<what was asked>"
5
5
  start a review: a title for the change, and what was asked, in the person's words
6
- playthrough start <review> once its flows are written, record Before: the app as it is
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
9
  playthrough walk <review> walk the person's steps in the app again, on the app as it is now
@@ -14,19 +14,22 @@ THE LOOP
14
14
  1. Start a review with a title, as you'd title its pull request, and what was asked, word for word: it's what the person
15
15
  judges each round against. Before you change anything, write its flows, one for each thing a person does that the
16
16
  task touches, from the task, not from the code.
17
- 2. Run playthrough start <review> again. It records Before: each flow on the app as it is, as far as it can go. There,
18
- what a flow expects of the finished work only waits a moment; the flow stops only at a step the app can't do yet.
17
+ 2. Run playthrough start <review> again. It tries the app's setup, then keeps the app as it is as Before, for the person
18
+ to compare each round with. Nothing's recorded yet: the flows are written for the work.
19
19
  3. Change the app. Keep each flow's name, and each step's while it does the same job: they're matched across rounds by
20
20
  name. Once the work is done, add a flow for each choice you made that the person can see and didn't ask for.
21
- 4. playthrough record <review> records the round and compares it with every round before it. In the round's round.json,
21
+ 4. playthrough record <review> records the round, and each flow new to the review on Before too, doing what the round
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,
22
23
  say why each change since the round before is there, under "accounts" by its id: { "line": "<why, in your words>" },
23
- { "note": "<the note it was made for>" }, or { "notReal": "<why nothing really changed>" }.
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.
24
26
  5. playthrough wait <review> hands the round over and waits for the notes, which can take hours: run it in the
25
27
  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
26
- the app. Change the app, record the next round, and answer each note in its round.json, under "replies". Where the
27
- answer moves, like an animation, add "watch": "<the title of the step it's seen in>". For a note made in the app,
28
- record walks the person's steps again and puts your answer where they lead: answer it in words, not with a new flow,
29
- and check your fix with playthrough walk <review>. Where their steps stop short, fix that, or say why they changed.
28
+ the app. Change the app, record the next round, and answer each note in its round.json, under "replies", in its
29
+ "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.
30
33
 
31
34
  The person can only judge what the flows show, so keep them true to how a person uses the app.
32
35
 
@@ -38,10 +41,9 @@ playthrough/app.json says how to start the app, and is committed so every review
38
41
 
39
42
  Flows run several at once. Where a page shows what every run made, as a blog's index lists every post, add
40
43
  "oneAtATime": true. Every round is recorded on whatever serves that url, and Playthrough starts the app only when nothing
41
- does: leave a server you didn't start running, and let it follow the working copy, as a dev server does. A flow recorded
42
- on Before later sets your work aside with git stash for a minute, puts the app as it was in its place, with playthrough/
43
- as it is now, and puts your work back after, even when Playthrough is stopped. Until round 1 is handed over, Before
44
- follows what you fix: a flow whose file changes is recorded on it again, and so is every flow once playthrough/ changes.
44
+ does: leave a server you didn't start running, and let it follow the working copy, as a dev server does. Recording on
45
+ 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
46
+ now, and puts your work back after, even when Playthrough is stopped.
45
47
 
46
48
  FLOWS
47
49
 
@@ -63,7 +65,7 @@ name is the flow's:
63
65
  Act on the page as a person would, by role and label. Playthrough glides the pointer, types a key at a time, and reads
64
66
  the page once it rests after each step. A dialog the browser shows is recorded, and answered No unless the flow says Yes
65
67
  first: page.once("dialog", (dialog) => dialog.accept()). A flow that fails stops the round: record names it and the
66
- step, and keeps the page it failed on in .playthrough/<review>/failed/, as Before does where each flow stops.
68
+ step, and keeps the page it failed on in .playthrough/<review>/failed/.
67
69
 
68
70
  While the person watches, Playthrough runs the flow again in the background, so taking it over is quick. If its steps do
69
71
  something outside the app that mustn't happen again, like sending an email, say so, and it runs only when taken over:
@@ -91,15 +93,16 @@ tells tests by (the screen, language and time zone are Playthrough's):
91
93
 
92
94
  export async function after({ request, setup }: { request: APIRequestContext; baseURL: string; setup: Record<string, any> }) {
93
95
  const removed = await request.delete(`/api/test/accounts/${setup.account}`);
94
- if (!removed.ok()) throw new Error(`Removing the account failed: ${removed.status()}`);
96
+ if (!removed.ok() && removed.status() !== 404) throw new Error(`Removing the account failed: ${removed.status()}`);
95
97
  }
96
98
 
97
99
  Each run makes its own: what the app needs unique, like an email, of its own, and what the page shows, like a name, the
98
- same every run. Check what each request answers, so one that fails says so. Avoid what can't be given back, like an
99
- email sent: test keys, a mail catcher. Use what the project has (a test account, seed data, its own fixtures), make data
100
- through the API or database rather than on screen, and stub what you can't control at the network. Sign in with
101
- page.request, not request: it shares the page's cookies, so a person who takes the flow over is signed in too. Dates
102
- the server works out don't follow the page's clock: fix them through the app's own test hook if it has one.
100
+ same every run. Check what each request answers, so one that fails says so. An after that fails is tried again whole by
101
+ the next command, so it takes what's gone already as given back. Avoid what can't be given back, like an email sent:
102
+ test keys, a mail catcher. Use what the project has (a test account, seed data, its own fixtures), make data through the
103
+ API or database rather than on screen, and stub what you can't control at the network. Sign in with page.request, not
104
+ request: it shares the page's cookies, so a person who takes the flow over is signed in too. Dates the server works out
105
+ don't follow the page's clock: fix them through the app's own test hook if it has one.
103
106
 
104
107
  Whenever app.json or setup.ts changes, Playthrough tries the setup by itself first, twice for each of the app's people,
105
108
  and shows a picture of where each starts. Fix what the pictures show and run the command again. It's the person's app:
package/README.md CHANGED
@@ -7,8 +7,8 @@ Playthrough records your coding agent's change the way a person would use it, be
7
7
  ## How a review goes
8
8
 
9
9
  1. You ask your agent for a change, and to show it to you with Playthrough.
10
- 2. Before it touches the code, the agent writes a flow for each thing a person does that the change touches, and Playthrough records them on the app as it is: Before.
11
- 3. The agent makes the change, and Playthrough records every flow again. It finds what changed itself, including what the agent didn't mention, and the agent says why each change is there.
10
+ 2. Before it touches the code, the agent writes a flow for each thing a person does that the change touches, and Playthrough keeps the app as it is: Before.
11
+ 3. The agent makes the change, and Playthrough records every flow, on the app as it is now and on Before, as far as the app went before. It finds what changed itself, including what the agent didn't mention, and the agent says why each change is there.
12
12
  4. You watch. Leave a note on what's wrong, where it's wrong; compare with Before; take over and try it yourself. Then send.
13
13
  5. The agent answers each note in the next round, on the spot the note is on. You check its answers, and accept once every flow looks good.
14
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playthrough",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Review an AI agent's work on a web app by watching recordings of it.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -13,7 +13,7 @@
13
13
  "node": ">=24"
14
14
  },
15
15
  "bin": {
16
- "playthrough": "./src/playthrough.js"
16
+ "playthrough": "src/playthrough.js"
17
17
  },
18
18
  "exports": {
19
19
  "./test": {
@@ -1,13 +1,13 @@
1
- import { O as list, b as Refused, g as readingsOf, k as quote, m as glimpsesOf, p as firstLine, x as Reviews } from "./reporter-4C_7joIm.js";
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";
2
2
  import * as modules from "node:module";
3
3
  import { createRequire } from "node:module";
4
4
  import { existsSync, watch } from "node:fs";
5
5
  import { spawn } from "node:child_process";
6
6
  import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
7
+ import { createHash, randomUUID } from "node:crypto";
7
8
  import { constants, tmpdir } from "node:os";
8
9
  import { dirname, join, relative } from "node:path";
9
10
  import { chromium, request } from "@playwright/test";
10
- import { createHash, randomUUID } from "node:crypto";
11
11
  import { fileURLToPath, pathToFileURL } from "node:url";
12
12
  // ../review/src/steps.ts
13
13
  function inStep(at, from, to, clicks) {
@@ -268,7 +268,7 @@ async function giveBack(after, file, baseURL, setup) {
268
268
  await rm(file, { force: true });
269
269
  return true;
270
270
  } catch (error) {
271
- console.log(`playthrough/setup.ts's after couldn't give back what a run made: ${firstLine(String(error?.message ?? error))}. It's tried again by the next command.`);
271
+ console.log(`playthrough/setup.ts's after couldn't give back what a run made: ${firstLine(String(error?.message ?? error))}. The next command tries it again, whole, so it should take what's gone already as given back.`);
272
272
  return false;
273
273
  } finally {
274
274
  giving.delete(file);
@@ -730,7 +730,7 @@ function placeIn(place, from, to) {
730
730
  };
731
731
  if (place.point) return {
732
732
  ...moment,
733
- point: place.point
733
+ point: besideOn(place.point, place.at, from, to) ?? place.point
734
734
  };
735
735
  const there = (element) => {
736
736
  const found = foundIn(from.readings, element, place.at, to.readings);
@@ -744,6 +744,36 @@ function placeIn(place, from, to) {
744
744
  ...alike?.length && { alike }
745
745
  } : moment;
746
746
  }
747
+ const HOLDING = /^(dialog|alertdialog|group|radiogroup|table|grid|treegrid|listbox|menu)$/;
748
+ function besideIn(read, at, point) {
749
+ const reading = read.reduce((best, next) => !best || Math.abs(next.at - at) < Math.abs(best.at - at) ? next : best, void 0);
750
+ if (!reading) return void 0;
751
+ const x = point.x * reading.screen.width;
752
+ const y = point.y * reading.screen.height;
753
+ const inside = (thing) => x >= thing.box.x && x <= thing.box.x + thing.box.width && y >= thing.box.y && y <= thing.box.y + thing.box.height;
754
+ const area = (thing) => thing.box.width * thing.box.height;
755
+ const room = reading.things.filter((thing) => (thing.role === "box" || HOLDING.test(thing.role) && !/^(table|grid|treegrid)$/.test(thing.role)) && inside(thing)).sort((a, b) => area(a) - area(b))[0];
756
+ const within = (thing) => {
757
+ for (let holder = thing.holder; holder !== void 0; holder = reading.things[holder]?.holder) if (reading.things[holder] === room) return true;
758
+ return false;
759
+ };
760
+ const gap = (thing) => Math.hypot(Math.max(thing.box.x - x, 0, x - thing.box.x - thing.box.width), Math.max(thing.box.y - y, 0, y - thing.box.y - thing.box.height));
761
+ const thing = reading.things.filter((thing) => thing.name && thing.role !== "box" && !HOLDING.test(thing.role) && (!room || within(thing)) && gap(thing) <= 80).sort((a, b) => gap(a) - gap(b) || area(a) - area(b))[0];
762
+ return thing && {
763
+ reading,
764
+ thing
765
+ };
766
+ }
767
+ function besideOn(point, at, from, to) {
768
+ const beside = besideIn(from.readings, at, point);
769
+ const found = beside && sameIn(beside, from.readings, to.readings);
770
+ if (!beside || !found) return void 0;
771
+ const share = (pixels, of) => Math.min(1, Math.max(0, pixels / of));
772
+ return {
773
+ x: share(point.x * beside.reading.screen.width + found.thing.box.x - beside.thing.box.x, found.reading.screen.width),
774
+ y: share(point.y * beside.reading.screen.height + found.thing.box.y - beside.thing.box.y, found.reading.screen.height)
775
+ };
776
+ }
747
777
  function foundIn(from, element, at, to) {
748
778
  const shown = shownAt(from, element, at);
749
779
  return shown && sameIn(shown, from, to);
@@ -791,11 +821,13 @@ function changesAt(then, now, unsteady = /* @__PURE__ */ new Set(), shown = {
791
821
  const others = own.filter((difference) => !isMove(difference));
792
822
  const caused = grownByWhatsInside(moment, own);
793
823
  const along = /* @__PURE__ */ new Map();
794
- const { reordered, left } = rowsReordered(moment, own.filter(isMove));
824
+ const items = itemsReordered(moment, own.filter(isMove));
825
+ const { reordered, left } = rowsReordered(moment, items.left);
795
826
  const { shifts, loose } = layoutShifts(left, carried);
796
827
  moment.shifts = shifts.map((shift) => shift.by);
797
828
  const widened = widenedTogether(others, caused, along);
798
829
  const told = [
830
+ ...items.reordered,
799
831
  ...reordered,
800
832
  ...swappedOrMoved(moment, loose),
801
833
  ...causesOfShifts(moment, shifts, widened, others, caused, along)
@@ -907,6 +939,76 @@ function grownByWhatsInside(moment, own) {
907
939
  }
908
940
  return caused;
909
941
  }
942
+ function itemsReordered(moment, moves) {
943
+ const { then, now } = moment;
944
+ const pairs = pairItems(moment);
945
+ const reordered = [];
946
+ let left = moves;
947
+ for (const [, paired] of groupBy([...pairs], ([, b]) => now.items[b].list)) {
948
+ const byThen = [...paired].sort((p, q) => p[0] - q[0]);
949
+ const byNow = [...paired].sort((p, q) => p[1] - q[1]);
950
+ const placeThen = new Map(byThen.map(([a], i) => [a, i]));
951
+ const kept = longestRising(byNow.map(([a]) => placeThen.get(a)));
952
+ const way = (a, b) => {
953
+ const went = moveOf(asThing(then.items[a], a), asThing(now.items[b], b));
954
+ return Math.abs(went.y) >= Math.abs(went.x) ? went.y > 0 ? "down" : "up" : went.x > 0 ? "right" : "left";
955
+ };
956
+ const moved = byNow.flatMap(([a, b], i) => kept.has(i) ? [] : [{
957
+ thing: asThing(now.items[b], b),
958
+ way: way(a, b),
959
+ places: Math.abs(i - placeThen.get(a))
960
+ }]);
961
+ if (!moved.length) continue;
962
+ const changed = byNow.filter(([a], i) => placeThen.get(a) !== i);
963
+ const first = now.items[byNow[0][1]];
964
+ const of = `${first.role} of ${listOf(now, first)}`;
965
+ const holder = first.holder === void 0 ? void 0 : now.things[first.holder];
966
+ if (changed.length === 2) {
967
+ const both = changed.map(([, b]) => asThing(now.items[b], b));
968
+ reordered.push({
969
+ what: "swapped",
970
+ thing: both[0],
971
+ in: now,
972
+ words: `${together(both)} have swapped places`,
973
+ key: `${of}: ${both.map((thing) => thing.words).join(" / ")}`
974
+ });
975
+ } else if (moved.length <= 2) for (const { thing, way, places } of moved) reordered.push({
976
+ what: "moved",
977
+ thing,
978
+ in: now,
979
+ words: `${called$1(thing)} moved ${way} ${places} ${places > 1 ? "places" : "place"}`,
980
+ key: `${of}: ${thing.words}`
981
+ });
982
+ else {
983
+ const order = byNow.slice(0, 3).map(([, b]) => called$1(asThing(now.items[b], b)));
984
+ const words = first.role === "row" ? `Rows in ${holder ? the(holder) : "the table"} are in a new order` : `What's in ${holder ? the(holder) : "the list"} is in a new order: ${order.join(", ")}${byNow.length > 3 ? "…" : ""}`;
985
+ reordered.push({
986
+ what: "reordered",
987
+ thing: holder ?? moved[0].thing,
988
+ in: now,
989
+ words,
990
+ key: of
991
+ });
992
+ }
993
+ const inList = new Set(paired.map(([, b]) => b));
994
+ left = left.filter((move) => !(move.now.item !== void 0 && inList.has(move.now.item) && move.then.item !== void 0 && pairs.get(move.then.item) === move.now.item));
995
+ }
996
+ return {
997
+ reordered,
998
+ left
999
+ };
1000
+ }
1001
+ function longestRising(sequence) {
1002
+ const best = sequence.map(() => 1);
1003
+ const before = sequence.map(() => -1);
1004
+ for (let i = 0; i < sequence.length; i++) for (let j = 0; j < i; j++) if (sequence[j] < sequence[i] && best[j] + 1 > best[i]) {
1005
+ best[i] = best[j] + 1;
1006
+ before[i] = j;
1007
+ }
1008
+ const kept = /* @__PURE__ */ new Set();
1009
+ for (let i = best.indexOf(Math.max(...best)); i >= 0; i = before[i]) kept.add(i);
1010
+ return kept;
1011
+ }
910
1012
  function rowsReordered(moment, moves) {
911
1013
  const reordered = [];
912
1014
  let left = moves;
@@ -1229,8 +1331,10 @@ function identityOf({ in: reading, thing, what }, moment) {
1229
1331
  if (!item) return identity(reading, thing);
1230
1332
  const kind = reading.things.filter((other) => other.item === thing.item && other.role === thing.role && other.column === thing.column);
1231
1333
  const place = `${thing.role} ${kind.indexOf(thing) + 1}${thing.column ? ` under ${thing.column}` : ""}`;
1232
- const was = what === "reworded" && moment.was.get(thing);
1233
- return `${place} in a ${item.role} of ${listOf(reading, item)}${was ? `: ${shapeOf(was.words, thing.words)}` : ""}`;
1334
+ const was = /^(reworded|moved)$/.test(what) ? moment.was.get(thing) : void 0;
1335
+ const went = was && what === "moved" && moveOf(was, thing);
1336
+ const how = went ? `, ${wayOf(near(went.x, 0) ? 0 : went.x, near(went.y, 0) ? 0 : went.y)}` : was ? `: ${shapeOf(was.words, thing.words)}` : "";
1337
+ return `${place} in a ${item.role} of ${listOf(reading, item)}${how}`;
1234
1338
  }
1235
1339
  const listOf = (reading, item) => item.holder === void 0 ? "the page" : identity(reading, reading.things[item.holder], 0);
1236
1340
  function pairItems({ then, now, was }) {
@@ -1455,9 +1559,10 @@ async function picturesAt(page, then, now, pictures) {
1455
1559
  if (patch.nudged && found.length) continue;
1456
1560
  if (patch.moved && [...changedInside].some((holder) => contains(holder.box, patch, 8))) continue;
1457
1561
  const on = whatsThere(now, patch);
1458
- const by = patch.moved && Math.max(Math.abs(patch.moved.x), Math.abs(patch.moved.y));
1459
- const words = patch.moved ? `${on.called} moved ${wayOf(patch.moved.x, patch.moved.y)}, ${by}px` : `${on.called} looks different`;
1460
- const id = idOf(on.identity ?? `place:${Math.round(patch.x / 32)},${Math.round(patch.y / 32)}`, patch.moved ? "moved" : "looks different");
1562
+ const moved = on.identity && !on.identity.startsWith("near ") ? void 0 : patch.moved;
1563
+ const by = moved && Math.max(Math.abs(moved.x), Math.abs(moved.y));
1564
+ const words = moved ? `${on.called} moved ${wayOf(moved.x, moved.y)}, ${by}px` : `${on.called} looks different`;
1565
+ const id = idOf(on.identity ?? `place:${Math.round(patch.x / 32)},${Math.round(patch.y / 32)}`, moved ? "moved" : "looks different");
1461
1566
  const same = told.get(id);
1462
1567
  told.set(id, {
1463
1568
  words,
@@ -1722,16 +1827,20 @@ function unsteadyIn(flows) {
1722
1827
  async function comparing(page, earlier, round, unsteady = () => /* @__PURE__ */ new Set()) {
1723
1828
  const changes = /* @__PURE__ */ new Map();
1724
1829
  const pieces = /* @__PURE__ */ new Map();
1725
- const see = ({ id, words, place, of = [id] }) => {
1830
+ const see = ({ id, words, place, of = [id], pictureOnly }) => {
1726
1831
  if (unsteady(place.flow).has(id)) return;
1727
1832
  const change = changes.get(id);
1728
1833
  for (const piece of of) (pieces.get(id) ?? pieces.set(id, /* @__PURE__ */ new Set()).get(id)).add(piece);
1729
1834
  if (!change) changes.set(id, {
1730
1835
  id,
1731
1836
  words,
1732
- seen: [place]
1837
+ seen: [place],
1838
+ ...pictureOnly && { pictureOnly }
1733
1839
  });
1734
- else if (!change.seen.some((seen) => seen.flow === place.flow)) change.seen.push(place);
1840
+ else {
1841
+ if (!change.seen.some((seen) => seen.flow === place.flow)) change.seen.push(place);
1842
+ if (!pictureOnly) delete change.pictureOnly;
1843
+ }
1735
1844
  };
1736
1845
  for (const flow of round.flows) {
1737
1846
  const before = earlier.flows.find((kept) => kept.name === flow.name);
@@ -1769,7 +1878,8 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
1769
1878
  then: around(glimpsed.then, before, was.step),
1770
1879
  now: around(glimpsed.now, flow, reading.step)
1771
1880
  };
1772
- for (const found of [...changesAt(was, reading, unsteady, shown), ...await picturesAt(page, was, reading, pictures)]) {
1881
+ const pictured = await picturesAt(page, was, reading, pictures);
1882
+ for (const found of [...changesAt(was, reading, unsteady, shown), ...pictured]) {
1773
1883
  const on = found.element ? {
1774
1884
  element: found.element,
1775
1885
  ...found.alike && { alike: found.alike }
@@ -1782,7 +1892,8 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
1782
1892
  at: reading.at,
1783
1893
  ...on
1784
1894
  },
1785
- of: found.pieces
1895
+ of: found.pieces,
1896
+ ...pictured.includes(found) && { pictureOnly: true }
1786
1897
  });
1787
1898
  }
1788
1899
  }
@@ -1880,12 +1991,12 @@ async function trySetup(repo, again) {
1880
1991
  console.log(`\n${line("The app", startedIn === void 0 ? `running already at ${app.url}` : `started with ${quote(app.start)}, answering at ${app.url} in ${seconds(startedIn)}`)}`);
1881
1992
  for (const { who, did, took, at, refused, picture } of tried) console.log(line(who, `${did}${did === "nothing made" ? "" : ` in ${seconds(took)}`}, then at ${at}${refused ? `, which the app answered with ${refused}` : ""}: ${repo.show(picture)}`));
1882
1993
  const missing = [];
1883
- if (!setup.after && tried.some(({ made }) => made)) missing.push("playthrough/setup.ts has no after, so what each run makes stays in the app: one for each flow recorded, two for a new one, and one each time the person pauses a recording, as it's made ready to take over.");
1884
- else if (tried.some(({ givenBack }) => !givenBack)) missing.push("playthrough/setup.ts's after couldn't give back what it made, for the reason above.");
1994
+ if (!setup.after && tried.some(({ made }) => made)) missing.push("playthrough/setup.ts has no after, so what each run makes stays in the app: one for each flow recorded, three for a new one, and one each time the person pauses a recording, as it's made ready to take over.");
1885
1995
  const slowest = Math.max(...tried.map(({ took }) => took));
1886
1996
  if (slowest > SLOW) missing.push(`Setup took ${seconds(slowest)}: each flow waits that long before its first step, and so does each moment taken over.`);
1887
1997
  console.log(`
1888
1998
  Look at ${tried.length > 1 ? "each picture: it's where their flows start" : "the picture: it's where every flow starts"}. One that shows a sign-in page for someone who should be signed in, or anything a flow shouldn't start from, is for playthrough/setup.ts.`);
1999
+ if (tried.some(({ givenBack }) => !givenBack)) console.log(`\nplaythrough/setup.ts's after couldn't give back what it made, for the reason above: that's for setup.ts too.`);
1889
2000
  if (missing.length) console.log(`
1890
2001
  ${missing.map((said) => `- ${said}`).join("\n")}
1891
2002
 
@@ -1995,16 +2106,20 @@ function howToWriteFlows(repo, id) {
1995
2106
  The test's name is the flow's, and steps are matched across rounds by their names. What every flow starts from, like
1996
2107
  the account it opens, is made in playthrough/setup.ts${missing}`;
1997
2108
  }
1998
- async function runFlows(repo, id, round, { later, first }) {
2109
+ async function flowsOf(repo, id) {
1999
2110
  const flows = repo.flows(id);
2000
2111
  if (!(await readdir(flows).catch(() => [])).some(isFlow)) throw new Refused([`${id} has no flows yet. ${howToWriteFlows(repo, id)}`]);
2001
2112
  await makeFlowsFolder(repo, id);
2113
+ return flows;
2114
+ }
2115
+ async function runFlows(repo, id, round, { before, first = () => false }) {
2116
+ const flows = await flowsOf(repo, id);
2002
2117
  const app = await repo.app();
2003
- if (!later) await trySetup(repo, round === 0 ? `playthrough start ${id}` : `playthrough record ${id}`);
2118
+ if (!before) await trySetup(repo, `playthrough record ${id}`);
2004
2119
  const { started, rounds } = await repo.reviews.get(id);
2005
2120
  const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
2006
2121
  const longest = (rounds.filter((kept) => kept.number < round).at(-1)?.flows ?? []).toSorted((a, b) => b.length - a.length).map((flow) => flow.name);
2007
- const run = (only, results, stops) => runOnce({
2122
+ const run = (only, results) => runOnce({
2008
2123
  flows,
2009
2124
  only,
2010
2125
  longest,
@@ -2012,16 +2127,20 @@ async function runFlows(repo, id, round, { later, first }) {
2012
2127
  app,
2013
2128
  root: repo.root,
2014
2129
  recorder,
2015
- before: round === 0,
2016
- taken: later?.taken ?? [],
2130
+ before: !!before,
2131
+ taken: before?.taken ?? [],
2017
2132
  started,
2018
- stops
2133
+ ...before && { doneInRound: before.doneInRound }
2019
2134
  });
2020
- const last = round > 0 ? await failedLastTime(repo, id) : [];
2135
+ if (before) {
2136
+ console.log(`Recording ${called(before.flows)} on Before, the app as it was…`);
2137
+ return run(before.flows);
2138
+ }
2139
+ const last = await failedLastTime(repo, id);
2021
2140
  if (last.length) {
2022
2141
  console.log(`Running ${called(last)} first: ${last.length > 1 ? "they" : "it"} failed last time…`);
2023
2142
  const { recorded, failed } = await run(last, repo.failed(id));
2024
- await keepFailed(repo, id, round, failed);
2143
+ await keepFailed(repo, id, failed);
2025
2144
  const [one] = failed;
2026
2145
  if (failed.length > 1) throw new Refused([
2027
2146
  `\nThese flows failed again, so the other flows weren't run:`,
@@ -2032,20 +2151,15 @@ async function runFlows(repo, id, round, { later, first }) {
2032
2151
  const gone = !recorded.length;
2033
2152
  console.log(`\n${last.length > 1 ? gone ? "They aren't flows any more" : "They pass now" : gone ? "It isn't a flow any more" : "It passes now"}, so every flow is recorded.\n`);
2034
2153
  }
2035
- console.log(later ? `Recording ${called(later.flows)} on Before, the app as it was…` : round === 0 ? `Recording Before: each flow of ${id} on the app as it is…` : `Recording round ${round} of ${id}…`);
2036
- const once = await run(later?.flows, repo.failed(id));
2037
- await keepFailed(repo, id, round, once.failed);
2154
+ console.log(`Recording round ${round} of ${id}…`);
2155
+ const once = await run(void 0, repo.failed(id));
2156
+ await keepFailed(repo, id, once.failed);
2038
2157
  const again = once.recorded.filter(({ flow }) => first(flow.name));
2039
- if (!again.length || round > 0 && once.failed.length) return once;
2158
+ if (!again.length || once.failed.length) return once;
2040
2159
  console.log(`\nRecording ${called(again.map(({ flow }) => flow.name))} again, to see what changes by itself…`);
2041
- const stops = Object.fromEntries(once.failed.flatMap(({ flow, at }) => at ? [[flow, at]] : []));
2042
- const results = once.failed.length ? void 0 : repo.failed(id);
2043
- const twice = await run(again.map(({ flow }) => flow.name), results, stops);
2044
- if (results) await keepFailed(repo, id, round, twice.failed);
2045
- const unlike = again.filter(({ flow }) => {
2046
- const failed = (ran) => ran.failed.find((failure) => failure.flow === flow.name)?.at;
2047
- return !twice.recorded.some((kept) => kept.flow.name === flow.name) || failed(once) !== failed(twice);
2048
- });
2160
+ const twice = await run(again.map(({ flow }) => flow.name), repo.failed(id));
2161
+ await keepFailed(repo, id, twice.failed);
2162
+ const unlike = again.filter(({ flow }) => !twice.recorded.some((kept) => kept.flow.name === flow.name));
2049
2163
  if (unlike.length) throw new Refused([
2050
2164
  `\n${list(unlike.map(({ flow }) => quote(flow.name)))} didn't go the same way twice on the same app:`,
2051
2165
  ...unlike.map(({ flow }) => ` ${quote(flow.name)}: the first time, ${went(once, flow.name)}; the second, ${went(twice, flow.name)}.`),
@@ -2071,7 +2185,7 @@ function went(ran, name) {
2071
2185
  const because = failure.why ? `, where Playwright says “${failure.why}”` : "";
2072
2186
  return failure.at ? `it stopped at ${quote(failure.at)}${because}` : `it stopped before its first step${because}`;
2073
2187
  }
2074
- async function keepFailed(repo, id, round, failed) {
2188
+ async function keepFailed(repo, id, failed) {
2075
2189
  const folder = repo.failed(id);
2076
2190
  if (!failed.length) return rm(folder, {
2077
2191
  recursive: true,
@@ -2081,7 +2195,6 @@ async function keepFailed(repo, id, round, failed) {
2081
2195
  const path = join(folder, entry);
2082
2196
  if (entry === ".last-run.json" || !(await readdir(path)).length) await rm(path, { recursive: true });
2083
2197
  }
2084
- if (round === 0) return;
2085
2198
  await mkdir(folder, { recursive: true });
2086
2199
  await writeFile(join(folder, FAILED), JSON.stringify(failed.map(({ flow }) => flow)));
2087
2200
  }
@@ -2117,13 +2230,12 @@ async function runOnce(run) {
2117
2230
  written: {}
2118
2231
  };
2119
2232
  if (!ran && !failed.length) throw new Refused(["\nThe flows didn't run, for the reason Playwright gives above. Fix that and run this again."]);
2120
- const withFiles = ({ flow, files, wrote }) => Promise.all(files.map(async (name) => ({
2233
+ const withFiles = ({ files, ...recorded }) => Promise.all(files.map(async (name) => ({
2121
2234
  name,
2122
2235
  bytes: await readFile(join(folder, name))
2123
2236
  }))).then((files) => ({
2124
- flow,
2125
- files,
2126
- ...wrote && { wrote }
2237
+ ...recorded,
2238
+ files
2127
2239
  }));
2128
2240
  return {
2129
2241
  recorded: await Promise.all(recorded.map(withFiles)),
@@ -2206,4 +2318,4 @@ async function playwright(run) {
2206
2318
  });
2207
2319
  });
2208
2320
  }
2209
- export { make as C, pausedAt as D, repo as E, giveBackLeft as S, setupIn as T, keepRunning as _, failedAt as a, changes as b, makeFlowsFolder as c, walkFork as d, whereItStopped as f, shownAt as g, placeIn as h, called as i, runFlows as l, compareWithEarlier as m, REACHES_OUTSIDE as n, howToWriteFlows as o, trySetup as p, WALKED as r, isFlow as s, ALONGSIDE as t, waitsFor as u, serves as v, personIn as w, firstOf$1 as x, stopApp as y };
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 };