playthrough 0.3.1 → 0.4.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
@@ -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,13 +14,15 @@ 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 id of 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
28
  the app. Change the app, record the next round, and answer each note in its round.json, under "replies", in its
@@ -33,16 +35,18 @@ The person can only judge what the flows show, so keep them true to how a person
33
35
 
34
36
  THE APP
35
37
 
38
+ The first time, add playthrough to the app's devDependencies with the app's own package manager, and run
39
+ npx playwright install chromium: flows import from it, and record in that browser.
40
+
36
41
  playthrough/app.json says how to start the app, and is committed so every review uses it:
37
42
 
38
43
  { "start": "npm run dev", "url": "http://localhost:3000" }
39
44
 
40
45
  Flows run several at once. Where a page shows what every run made, as a blog's index lists every post, add
41
46
  "oneAtATime": true. Every round is recorded on whatever serves that url, and Playthrough starts the app only when nothing
42
- does: leave a server you didn't start running, and let it follow the working copy, as a dev server does. A flow recorded
43
- on Before later sets your work aside with git stash for a minute, puts the app as it was in its place, with playthrough/
44
- as it is now, and puts your work back after, even when Playthrough is stopped. Until round 1 is handed over, Before
45
- follows what you fix: a flow whose file changes is recorded on it again, and so is every flow once playthrough/ changes.
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.
46
50
 
47
51
  FLOWS
48
52
 
@@ -64,7 +68,7 @@ name is the flow's:
64
68
  Act on the page as a person would, by role and label. Playthrough glides the pointer, types a key at a time, and reads
65
69
  the page once it rests after each step. A dialog the browser shows is recorded, and answered No unless the flow says Yes
66
70
  first: page.once("dialog", (dialog) => dialog.accept()). A flow that fails stops the round: record names it and the
67
- step, and keeps the page it failed on in .playthrough/<review>/failed/, as Before does where each flow stops.
71
+ step, and keeps the page it failed on in .playthrough/<review>/failed/.
68
72
 
69
73
  While the person watches, Playthrough runs the flow again in the background, so taking it over is quick. If its steps do
70
74
  something outside the app that mustn't happen again, like sending an email, say so, and it runs only when taken over:
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Playthrough
1
+ # <img src="https://raw.githubusercontent.com/itmostlyworks/playthrough/main/packages/viewer/public/logo.svg" alt="" height="28"> Playthrough
2
2
 
3
3
  The agent says it's done. You decide if it's good.
4
4
 
@@ -7,37 +7,28 @@ 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
 
15
15
  ## Try it
16
16
 
17
- Playthrough needs Node 24 or later. In your app's repo:
17
+ Ask your agent for a change in your app, as you would anyway, and add:
18
18
 
19
- ```bash
20
- npm install --save-dev playthrough
21
- npx playwright install chromium
22
- ```
23
-
24
- Then ask your agent for a change, and to show it to you:
19
+ > Show me this change with `npx playthrough`.
25
20
 
26
- > Show only the overdue invoices. Use Playthrough to show me the change: run `npx playthrough` and follow what it says.
27
-
28
- The agent starts a review, records it, and hands it to you with `playthrough wait`. Open it:
21
+ The agent does the rest. The first time, it adds Playthrough to your app and writes down how to start it and sign in, to commit with your app. Each time, it records the change and hands it to you. Open it:
29
22
 
30
23
  ```bash
31
24
  npx playthrough open
32
25
  ```
33
26
 
34
- The first time, the agent writes `playthrough/app.json`, which says how to start your app, and `playthrough/setup.ts` if flows need something first: a signed-in user, an account with data in it, or the people who use the app. Both are committed with your app, so every review after uses them.
35
-
36
- Reviews are kept in `.playthrough/` in your app's repo, recordings and all. Nothing is uploaded anywhere.
27
+ Reviews stay in `.playthrough/` in your app's repo, recordings and all; nothing is uploaded. Playthrough needs Node 24 or later.
37
28
 
38
29
  ## On the example
39
30
 
40
- `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 ask your agent for the same change from `example/`.
31
+ `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 only the overdue invoices, and to show you the change with `npx playthrough`.
41
32
 
42
33
  ## In the viewer
43
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playthrough",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
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 { C as Refused, M as list, N as quote, h as glimpsesOf, m as firstLine, w as Reviews, y as readingsOf } from "./reporter-BEodHYRI.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) {
@@ -1827,16 +1827,20 @@ function unsteadyIn(flows) {
1827
1827
  async function comparing(page, earlier, round, unsteady = () => /* @__PURE__ */ new Set()) {
1828
1828
  const changes = /* @__PURE__ */ new Map();
1829
1829
  const pieces = /* @__PURE__ */ new Map();
1830
- const see = ({ id, words, place, of = [id] }) => {
1830
+ const see = ({ id, words, place, of = [id], pictureOnly }) => {
1831
1831
  if (unsteady(place.flow).has(id)) return;
1832
1832
  const change = changes.get(id);
1833
1833
  for (const piece of of) (pieces.get(id) ?? pieces.set(id, /* @__PURE__ */ new Set()).get(id)).add(piece);
1834
1834
  if (!change) changes.set(id, {
1835
1835
  id,
1836
1836
  words,
1837
- seen: [place]
1837
+ seen: [place],
1838
+ ...pictureOnly && { pictureOnly }
1838
1839
  });
1839
- 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
+ }
1840
1844
  };
1841
1845
  for (const flow of round.flows) {
1842
1846
  const before = earlier.flows.find((kept) => kept.name === flow.name);
@@ -1874,7 +1878,8 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
1874
1878
  then: around(glimpsed.then, before, was.step),
1875
1879
  now: around(glimpsed.now, flow, reading.step)
1876
1880
  };
1877
- 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]) {
1878
1883
  const on = found.element ? {
1879
1884
  element: found.element,
1880
1885
  ...found.alike && { alike: found.alike }
@@ -1887,7 +1892,8 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
1887
1892
  at: reading.at,
1888
1893
  ...on
1889
1894
  },
1890
- of: found.pieces
1895
+ of: found.pieces,
1896
+ ...pictured.includes(found) && { pictureOnly: true }
1891
1897
  });
1892
1898
  }
1893
1899
  }
@@ -1985,7 +1991,7 @@ async function trySetup(repo, again) {
1985
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)}`)}`);
1986
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)}`));
1987
1993
  const missing = [];
1988
- 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.");
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.");
1989
1995
  const slowest = Math.max(...tried.map(({ took }) => took));
1990
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.`);
1991
1997
  console.log(`
@@ -2100,16 +2106,20 @@ function howToWriteFlows(repo, id) {
2100
2106
  The test's name is the flow's, and steps are matched across rounds by their names. What every flow starts from, like
2101
2107
  the account it opens, is made in playthrough/setup.ts${missing}`;
2102
2108
  }
2103
- async function runFlows(repo, id, round, { later, first }) {
2109
+ async function flowsOf(repo, id) {
2104
2110
  const flows = repo.flows(id);
2105
2111
  if (!(await readdir(flows).catch(() => [])).some(isFlow)) throw new Refused([`${id} has no flows yet. ${howToWriteFlows(repo, id)}`]);
2106
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);
2107
2117
  const app = await repo.app();
2108
- if (!later) await trySetup(repo, round === 0 ? `playthrough start ${id}` : `playthrough record ${id}`);
2118
+ if (!before) await trySetup(repo, `playthrough record ${id}`);
2109
2119
  const { started, rounds } = await repo.reviews.get(id);
2110
2120
  const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
2111
2121
  const longest = (rounds.filter((kept) => kept.number < round).at(-1)?.flows ?? []).toSorted((a, b) => b.length - a.length).map((flow) => flow.name);
2112
- const run = (only, results, stops) => runOnce({
2122
+ const run = (only, results) => runOnce({
2113
2123
  flows,
2114
2124
  only,
2115
2125
  longest,
@@ -2117,16 +2127,20 @@ async function runFlows(repo, id, round, { later, first }) {
2117
2127
  app,
2118
2128
  root: repo.root,
2119
2129
  recorder,
2120
- before: round === 0,
2121
- taken: later?.taken ?? [],
2130
+ before: !!before,
2131
+ taken: before?.taken ?? [],
2122
2132
  started,
2123
- stops
2133
+ ...before && { doneInRound: before.doneInRound }
2124
2134
  });
2125
- 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);
2126
2140
  if (last.length) {
2127
2141
  console.log(`Running ${called(last)} first: ${last.length > 1 ? "they" : "it"} failed last time…`);
2128
2142
  const { recorded, failed } = await run(last, repo.failed(id));
2129
- await keepFailed(repo, id, round, failed);
2143
+ await keepFailed(repo, id, failed);
2130
2144
  const [one] = failed;
2131
2145
  if (failed.length > 1) throw new Refused([
2132
2146
  `\nThese flows failed again, so the other flows weren't run:`,
@@ -2137,20 +2151,15 @@ async function runFlows(repo, id, round, { later, first }) {
2137
2151
  const gone = !recorded.length;
2138
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`);
2139
2153
  }
2140
- 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}…`);
2141
- const once = await run(later?.flows, repo.failed(id));
2142
- 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);
2143
2157
  const again = once.recorded.filter(({ flow }) => first(flow.name));
2144
- if (!again.length || round > 0 && once.failed.length) return once;
2158
+ if (!again.length || once.failed.length) return once;
2145
2159
  console.log(`\nRecording ${called(again.map(({ flow }) => flow.name))} again, to see what changes by itself…`);
2146
- const stops = Object.fromEntries(once.failed.flatMap(({ flow, at }) => at ? [[flow, at]] : []));
2147
- const results = once.failed.length ? void 0 : repo.failed(id);
2148
- const twice = await run(again.map(({ flow }) => flow.name), results, stops);
2149
- if (results) await keepFailed(repo, id, round, twice.failed);
2150
- const unlike = again.filter(({ flow }) => {
2151
- const failed = (ran) => ran.failed.find((failure) => failure.flow === flow.name)?.at;
2152
- return !twice.recorded.some((kept) => kept.flow.name === flow.name) || failed(once) !== failed(twice);
2153
- });
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));
2154
2163
  if (unlike.length) throw new Refused([
2155
2164
  `\n${list(unlike.map(({ flow }) => quote(flow.name)))} didn't go the same way twice on the same app:`,
2156
2165
  ...unlike.map(({ flow }) => ` ${quote(flow.name)}: the first time, ${went(once, flow.name)}; the second, ${went(twice, flow.name)}.`),
@@ -2176,7 +2185,7 @@ function went(ran, name) {
2176
2185
  const because = failure.why ? `, where Playwright says “${failure.why}”` : "";
2177
2186
  return failure.at ? `it stopped at ${quote(failure.at)}${because}` : `it stopped before its first step${because}`;
2178
2187
  }
2179
- async function keepFailed(repo, id, round, failed) {
2188
+ async function keepFailed(repo, id, failed) {
2180
2189
  const folder = repo.failed(id);
2181
2190
  if (!failed.length) return rm(folder, {
2182
2191
  recursive: true,
@@ -2186,7 +2195,6 @@ async function keepFailed(repo, id, round, failed) {
2186
2195
  const path = join(folder, entry);
2187
2196
  if (entry === ".last-run.json" || !(await readdir(path)).length) await rm(path, { recursive: true });
2188
2197
  }
2189
- if (round === 0) return;
2190
2198
  await mkdir(folder, { recursive: true });
2191
2199
  await writeFile(join(folder, FAILED), JSON.stringify(failed.map(({ flow }) => flow)));
2192
2200
  }
@@ -2222,13 +2230,12 @@ async function runOnce(run) {
2222
2230
  written: {}
2223
2231
  };
2224
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."]);
2225
- const withFiles = ({ flow, files, wrote }) => Promise.all(files.map(async (name) => ({
2233
+ const withFiles = ({ files, ...recorded }) => Promise.all(files.map(async (name) => ({
2226
2234
  name,
2227
2235
  bytes: await readFile(join(folder, name))
2228
2236
  }))).then((files) => ({
2229
- flow,
2230
- files,
2231
- ...wrote && { wrote }
2237
+ ...recorded,
2238
+ files
2232
2239
  }));
2233
2240
  return {
2234
2241
  recorded: await Promise.all(recorded.map(withFiles)),
@@ -2311,4 +2318,4 @@ async function playwright(run) {
2311
2318
  });
2312
2319
  });
2313
2320
  }
2314
- 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 };