playthrough 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/INSTRUCTIONS.md CHANGED
@@ -6,7 +6,7 @@ to its end; each command then says what's next.
6
6
  playthrough start <review> once its flows are written, try the setup and keep Before: the app as it is
7
7
  playthrough record <review> record the round being worked on, every flow, the way a person would use it
8
8
  playthrough wait <review> hand the round over, then wait for the notes to answer
9
- playthrough walk <review> walk the person's steps in the app again, on the app as it is now
9
+ playthrough forks <review> run each fork of a note made in the app, on the app as it is now
10
10
  playthrough open [<review>] open the viewer on this machine
11
11
 
12
12
  THE LOOP
@@ -28,12 +28,16 @@ THE LOOP
28
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
29
29
  the app. Change the app, record the next round, and answer each note in its round.json, under "replies", in its
30
30
  "words": its "on" is where Playthrough found the note's thing on this round, so leave it. Where the answer moves,
31
- like an animation, add "watch": "<the title of the step it's seen in>". For a note made in the app, record walks the
32
- person's steps again and puts your answer where they lead: answer it in words, not with a new flow, and check your
33
- fix with playthrough walk <review>. Where their steps stop short, fix that, or say why they changed.
31
+ like an animation, add "watch": "<the title of the step it's seen in>". A note made in the app comes with what the
32
+ person did there: write that as a fork of the flow they took over (FORKS, below), and record puts your answer on it.
34
33
 
35
34
  The person can only judge what the flows show, so keep them true to how a person uses the app.
36
35
 
36
+ A bug you can't make happen is still a round to hand over: record the flow that does what the report says, give it a
37
+ "line" in round.json saying you couldn't make it happen and how you tried, and hand it over. The person can take the
38
+ flow over where it goes wrong and show you: what they do there comes back as a note made in the app, and its fork, run
39
+ every round, shows you the bug and them your fix.
40
+
37
41
  THE APP
38
42
 
39
43
  The first time, add playthrough to the app's devDependencies with the app's own package manager, and run
@@ -56,7 +60,8 @@ another at its address, takes the address it always has, or a copy on a port of
56
60
 
57
61
  Flows run several at once. Where a page shows what every run made, as a blog's index lists every post, add
58
62
  "oneAtATime": true. Recording on Before sets your work aside with git stash for a minute, puts the app as it was in its
59
- place, with playthrough/ as it is now, and puts your work back after, even when Playthrough is stopped.
63
+ place, with playthrough/ as it is now, and puts your work back after, even when Playthrough is stopped; anything saved
64
+ meanwhile is kept in a stash of its own.
60
65
 
61
66
  FLOWS
62
67
 
@@ -91,6 +96,23 @@ something outside the app that mustn't happen again, like sending an email, say
91
96
 
92
97
  test("Invite Sam", { annotation: { type: "reaches outside", description: "sends Sam an email" } }, async ({ page }) => {
93
98
 
99
+ FORKS
100
+
101
+ Where the person took a flow over in the app and made a note there, what they did is yours to write, as a fork of that
102
+ flow, in its file, after it: steps as a flow's, by role and label, for the notes it shows:
103
+
104
+ test.fork("Remind Alder Dental", { notes: ["isikhse8"] }, async ({ page }) => {
105
+ await test.step("Open the reminder", async () => {
106
+ await page.getByRole("row", { name: /Alder Dental/ }).click();
107
+ await page.getByRole("button", { name: "Write a reminder" }).click();
108
+ });
109
+ });
110
+
111
+ Record runs the flow to where they took it over, then the fork, and puts your answer on what the note is on, where the
112
+ fork first shows it. It's run every round until its notes are settled, and the person watches it as a fork off the flow,
113
+ not a flow of its own: keep it doing what they did, on the app as it is now. playthrough forks <review> runs it while you
114
+ work. A round whose note has no fork, or whose fork fails, isn't recorded.
115
+
94
116
  WHAT EVERY FLOW STARTS FROM
95
117
 
96
118
  A flow has to start from the same place every round, or the comparison finds changes nobody made. Playthrough starts
package/README.md CHANGED
@@ -22,7 +22,7 @@ The agent does the rest. The first time, it adds Playthrough to your app and wri
22
22
  npx playthrough open
23
23
  ```
24
24
 
25
- Reviews stay in `.playthrough/` in your app's repo, recordings and all; nothing is uploaded. Playthrough needs Node 24 or later.
25
+ Reviews stay in `.playthrough/` in your app's repo, recordings and all; nothing is uploaded. Playthrough needs Node 22.18 or later.
26
26
 
27
27
  You can teach your agent Playthrough once, so “Show me this change” is enough from then on:
28
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playthrough",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Review what your coding agent built in your web app by watching recordings of it.",
5
5
  "keywords": [
6
6
  "coding-agent",
@@ -25,7 +25,7 @@
25
25
  "directory": "packages/cli"
26
26
  },
27
27
  "engines": {
28
- "node": ">=24"
28
+ "node": ">=22.18"
29
29
  },
30
30
  "bin": {
31
31
  "playthrough": "src/playthrough.js"
@@ -1,4 +1,4 @@
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";
1
+ import { A as quote, b as Reviews, h as readingsOf, k as list, m as glimpsesOf, p as firstLine, y as Refused } from "./reporter-DlS7qehW.js";
2
2
  import * as modules from "node:module";
3
3
  import { createRequire } from "node:module";
4
4
  import { existsSync, readFileSync, watch, writeFileSync } from "node:fs";
@@ -239,7 +239,9 @@ async function setupIn(file, written) {
239
239
  if (!existsSync(file)) return {};
240
240
  const href = pathToFileURL(file).href;
241
241
  asModule(href);
242
- const module = await import(`${href}${written ? `?${written}` : ""}`);
242
+ const module = await import(`${href}${written ? `?${written}` : ""}`).catch((error) => {
243
+ throw twoPlaywrights(error, file) ?? error;
244
+ });
243
245
  const inner = typeof module.default === "object" ? module.default : void 0;
244
246
  const use = module.use ?? inner?.use;
245
247
  const ours = Object.keys(use ?? {}).filter((option) => OURS.includes(option));
@@ -252,6 +254,16 @@ async function setupIn(file, written) {
252
254
  ...use && { use }
253
255
  };
254
256
  }
257
+ function twoPlaywrights(error, file) {
258
+ if (!/Requiring @playwright\/test second time/.test(String(error?.message))) return;
259
+ const version = (from) => {
260
+ try {
261
+ return modules.createRequire(from)("@playwright/test/package.json").version;
262
+ } catch {}
263
+ };
264
+ const theirs = version(file);
265
+ return new Refused([`playthrough/setup.ts loads the app's own Playwright${theirs ? ` (${theirs})` : ""} beside Playthrough's (${version(import.meta.url)}), and Playwright can't run twice at once.`, `Import only types from @playwright/test there (import type { Page } from "@playwright/test"), and expect, if it needs it, from playthrough/test. Then run this again.`]);
266
+ }
255
267
  const setups = /* @__PURE__ */ new Set();
256
268
  function asModule(href) {
257
269
  if (!setups.size) modules.registerHooks({ load: (url, context, next) => next(url, setups.has(url.split("?")[0]) ? {
@@ -928,6 +940,26 @@ const elementOf$1 = (thing, name) => ({
928
940
  name: thing.name || name,
929
941
  ...thing.node !== void 0 && { node: thing.node }
930
942
  });
943
+ function sameWords(one, other) {
944
+ const [few, many] = [wordsOf(one), wordsOf(other)].sort((a, b) => a.length - b.length);
945
+ return !few.length || few.filter((word) => many.includes(word)).length * 2 >= few.length;
946
+ }
947
+ const wordsOf = (name) => name.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
948
+ function firstShown(element, seen) {
949
+ const noted = wordsOf(element.name);
950
+ if (!noted.length) return void 0;
951
+ const closeness = (thing) => {
952
+ const words = wordsOf(thing.name);
953
+ return words.filter((word) => noted.includes(word)).length / Math.max(words.length, noted.length);
954
+ };
955
+ for (const glimpse of seen.toSorted((a, b) => a.at - b.at)) {
956
+ const thing = glimpse.things.filter((thing) => thing.role === element.role && sameWords(thing.name, element.name)).toSorted((a, b) => closeness(b) - closeness(a))[0];
957
+ if (thing) return {
958
+ at: glimpse.at,
959
+ element: elementOf$1(thing, element.name)
960
+ };
961
+ }
962
+ }
931
963
  // src/changes.ts
932
964
  const TABLES = /^(table|grid|treegrid|listbox)$/;
933
965
  const REFLOWS = /^(dialog|alertdialog|status|alert|menu|box)$/;
@@ -2228,7 +2260,7 @@ async function writtenNow(repo) {
2228
2260
  }
2229
2261
  // src/flows.ts
2230
2262
  const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
2231
- const WALKED = "walked.json";
2263
+ const FORK_RAN = "fork.json";
2232
2264
  const ALONGSIDE = Symbol.for("playthrough: flows taken over here");
2233
2265
  const REACHES_OUTSIDE = "reaches outside";
2234
2266
  const waitsFor = ({ before }) => before ? 3e3 : 1e4;
@@ -2398,7 +2430,7 @@ async function runOnce(run) {
2398
2430
  await rm(folder, { recursive: true });
2399
2431
  }
2400
2432
  }
2401
- async function walkFork(repo, id, walk) {
2433
+ async function runFork(repo, id, fork) {
2402
2434
  const app = await repo.app();
2403
2435
  const { started } = await repo.reviews.get(id);
2404
2436
  const folder = await mkdtemp(join(tmpdir(), "playthrough-"));
@@ -2412,20 +2444,19 @@ async function walkFork(repo, id, walk) {
2412
2444
  before: false,
2413
2445
  taken: [],
2414
2446
  started,
2415
- walk
2447
+ fork
2416
2448
  });
2417
- const text = await readFile(join(folder, WALKED), "utf8").catch(() => void 0);
2449
+ const text = await readFile(join(folder, FORK_RAN), "utf8").catch(() => void 0);
2418
2450
  if (!text) return void 0;
2419
- const walked = JSON.parse(text);
2420
- const files = await Promise.all([...new Set(Object.values(walked.assets))].map(async (name) => ({
2451
+ const ran = JSON.parse(text);
2452
+ const files = await Promise.all([...new Set(Object.values(ran.assets))].map(async (name) => ({
2421
2453
  name,
2422
2454
  bytes: await readFile(join(folder, name))
2423
2455
  })));
2424
- const pictures = await Promise.all(walk.look.map((_, i) => readFile(join(folder, `look-${i}.png`)).catch(() => void 0)));
2425
2456
  return {
2426
- ...walked,
2457
+ ...ran,
2427
2458
  files,
2428
- pictures
2459
+ picture: await readFile(join(folder, "end.png")).catch(() => void 0)
2429
2460
  };
2430
2461
  } finally {
2431
2462
  await rm(folder, {
@@ -2469,4 +2500,4 @@ async function playwright(run) {
2469
2500
  });
2470
2501
  });
2471
2502
  }
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 };
2503
+ export { setupIn as A, serves as C, giveBackLeft as D, firstOf$1 as E, pausedAt as F, repo as M, stillAt as N, make as O, thisCommand as P, keepRunning as S, changes as T, compareWithEarlier as _, failedAt as a, sameWords as b, isFlow as c, runFlows as d, runFork as f, writtenNow as g, trySetup as h, called as i, copies as j, personIn as k, makeFlowsFolder as l, whereItStopped as m, FORK_RAN as n, flowsOf as o, waitsFor as p, REACHES_OUTSIDE as r, howToWriteFlows as s, ALONGSIDE as t, recorder as u, firstShown as v, stopApp as w, shownAt as x, placeIn as y };