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 +14 -10
- package/README.md +8 -17
- package/package.json +2 -2
- package/src/{flows-BcISV1M-.js → flows-BkG0TgHA.js} +42 -35
- package/src/playthrough.js +121 -210
- package/src/playwright.config.js +1 -1
- package/src/{reporter-BEodHYRI.js → reporter-DE8aueAO.js} +15 -24
- package/src/reporter.js +1 -1
- package/src/test.js +29 -12
- package/types/cli/src/flows.d.ts +13 -5
- package/types/cli/src/reporter.d.ts +3 -5
- package/types/cli/src/try-setup.d.ts +1 -0
- package/types/review/src/model.d.ts +3 -0
- package/types/review/src/rules.d.ts +46 -0
- package/types/review/src/store.d.ts +0 -1
- package/viewer/assets/{index-D64VrgNj.js → index-Ds2iot3O.js} +23 -23
- package/viewer/index.html +2 -1
- package/viewer/logo.svg +21 -0
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,
|
|
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
|
|
18
|
-
|
|
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
|
|
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.
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
|
11
|
-
3. The agent makes the change, and Playthrough records every flow
|
|
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
|
-
|
|
17
|
+
Ask your agent for a change in your app, as you would anyway, and add:
|
|
18
18
|
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
"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": "
|
|
16
|
+
"playthrough": "src/playthrough.js"
|
|
17
17
|
},
|
|
18
18
|
"exports": {
|
|
19
19
|
"./test": {
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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 (!
|
|
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
|
|
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:
|
|
2121
|
-
taken:
|
|
2130
|
+
before: !!before,
|
|
2131
|
+
taken: before?.taken ?? [],
|
|
2122
2132
|
started,
|
|
2123
|
-
|
|
2133
|
+
...before && { doneInRound: before.doneInRound }
|
|
2124
2134
|
});
|
|
2125
|
-
|
|
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,
|
|
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(
|
|
2141
|
-
const once = await run(
|
|
2142
|
-
await keepFailed(repo, id,
|
|
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 ||
|
|
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
|
|
2147
|
-
|
|
2148
|
-
const
|
|
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,
|
|
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 = ({
|
|
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
|
-
|
|
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 {
|
|
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 };
|