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 +27 -5
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/{flows-vi0amwSo.js → flows-CbrwgoHB.js} +43 -12
- package/src/playthrough.js +145 -84
- package/src/playwright.config.js +1 -1
- package/src/{reporter-S9fOk0za.js → reporter-DlS7qehW.js} +5 -1
- package/src/reporter.js +1 -1
- package/src/test.js +85 -67
- package/types/cli/src/flows.d.ts +21 -16
- package/types/cli/src/test.d.ts +21 -8
- package/types/page/src/found.d.ts +5 -0
- package/types/review/src/model.d.ts +40 -0
- package/types/review/src/rules.d.ts +4 -0
- package/viewer/assets/index-BbNKJRwK.css +2 -0
- package/viewer/assets/index-DwbSPQMk.js +194 -0
- package/viewer/index.html +2 -2
- package/viewer/assets/index-C4zcB6st.css +0 -2
- package/viewer/assets/index-D_6gdncQ.js +0 -194
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
|
|
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>".
|
|
32
|
-
person
|
|
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
|
|
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.
|
|
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": ">=
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
|
|
2447
|
+
fork
|
|
2416
2448
|
});
|
|
2417
|
-
const text = await readFile(join(folder,
|
|
2449
|
+
const text = await readFile(join(folder, FORK_RAN), "utf8").catch(() => void 0);
|
|
2418
2450
|
if (!text) return void 0;
|
|
2419
|
-
const
|
|
2420
|
-
const files = await Promise.all([...new Set(Object.values(
|
|
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
|
-
...
|
|
2457
|
+
...ran,
|
|
2427
2458
|
files,
|
|
2428
|
-
|
|
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 {
|
|
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 };
|