playthrough 0.4.3 → 0.5.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 +14 -5
- package/README.md +8 -2
- package/package.json +1 -1
- package/src/{flows-BkG0TgHA.js → flows-DDB6AdQ-.js} +76 -29
- package/src/playthrough.js +539 -78
- package/src/playwright.config.js +1 -1
- package/src/{reporter-DE8aueAO.js → reporter-Dr7dWwSf.js} +14 -5
- package/src/reporter.js +1 -1
- package/src/test.js +2 -2
- package/types/cli/src/flows.d.ts +1 -0
- package/types/cli/src/repo.d.ts +4 -0
- package/types/cli/src/reporter.d.ts +0 -1
- package/viewer/assets/index-DVlUrKEE.js +194 -0
- package/viewer/assets/index-DhzHaV6E.css +2 -0
- package/viewer/index.html +2 -2
- package/viewer/assets/index-BkU2wK28.js +0 -193
- package/viewer/assets/index-gTfnUrcK.css +0 -2
package/INSTRUCTIONS.md
CHANGED
|
@@ -38,15 +38,24 @@ THE APP
|
|
|
38
38
|
The first time, add playthrough to the app's devDependencies with the app's own package manager, and run
|
|
39
39
|
npx playwright install chromium: flows import from it, and record in that browser.
|
|
40
40
|
|
|
41
|
-
playthrough/app.json says how to start the app, and is committed so every review uses it
|
|
41
|
+
playthrough/app.json says how to start the app, and is committed so every review uses it. Where the command can be
|
|
42
|
+
given a port, say {port} in both, and Playthrough starts this copy of the app on a port of its own each time it records
|
|
43
|
+
or the person takes a flow over, beside the person's own and any other worktree's:
|
|
44
|
+
|
|
45
|
+
{ "start": "npm run dev -- --port {port}", "url": "http://localhost:{port}" }
|
|
46
|
+
|
|
47
|
+
Where it can't, as when another part of the app expects it at one address, give the address it always has:
|
|
42
48
|
|
|
43
49
|
{ "start": "npm run dev", "url": "http://localhost:3000" }
|
|
44
50
|
|
|
51
|
+
There, every round is recorded on whatever serves that url, and Playthrough starts the app only when nothing does: leave
|
|
52
|
+
a server you didn't start running, and let it follow the working copy, as a dev server does. In a worktree, what serves
|
|
53
|
+
it could be another copy, so Playthrough needs the address free, and says so. An app of several servers, one calling
|
|
54
|
+
another at its address, takes the address it always has, or a copy on a port of its own would call the person's.
|
|
55
|
+
|
|
45
56
|
Flows run several at once. Where a page shows what every run made, as a blog's index lists every post, add
|
|
46
|
-
"oneAtATime": true.
|
|
47
|
-
|
|
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.
|
|
57
|
+
"oneAtATime": true. Recording on Before sets your work aside with git stash for a minute, puts the app as it was in its
|
|
58
|
+
place, with playthrough/ as it is now, and puts your work back after, even when Playthrough is stopped.
|
|
50
59
|
|
|
51
60
|
FLOWS
|
|
52
61
|
|
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ Playthrough records your coding agent's change the way a person would use it, be
|
|
|
6
6
|
|
|
7
7
|
To see a review before you install anything, play the one at [playthrough.sh](https://playthrough.sh).
|
|
8
8
|
|
|
9
|
+
<a href="https://playthrough.sh"><img src="https://raw.githubusercontent.com/itmostlyworks/playthrough/main/docs/viewer-light.png" alt="The viewer: the agent's recording of Send a reminder, paused on your note and the agent's answer, asking whether it fixes it, with Note, Compare and Take over on the bar"></a>
|
|
10
|
+
|
|
9
11
|
## Try it
|
|
10
12
|
|
|
11
13
|
Ask your agent for a change in your app, as you would anyway, and add:
|
|
@@ -20,9 +22,13 @@ npx playthrough open
|
|
|
20
22
|
|
|
21
23
|
Reviews stay in `.playthrough/` in your app's repo, recordings and all; nothing is uploaded. Playthrough needs Node 24 or later.
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
You can teach your agent Playthrough once, so “Show me this change” is enough from then on:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx skills add itmostlyworks/playthrough
|
|
29
|
+
```
|
|
24
30
|
|
|
25
|
-
|
|
31
|
+
In Claude Code, it's a plugin too: `/plugin install playthrough --marketplace itmostlyworks/playthrough`.
|
|
26
32
|
|
|
27
33
|
## How a review goes
|
|
28
34
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { A as
|
|
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-Dr7dWwSf.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
|
-
import { spawn } from "node:child_process";
|
|
5
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
6
6
|
import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
7
7
|
import { createHash, randomUUID } from "node:crypto";
|
|
8
8
|
import { constants, tmpdir } from "node:os";
|
|
9
|
-
import { dirname, join, relative } from "node:path";
|
|
9
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
10
|
+
import { createServer } from "node:net";
|
|
10
11
|
import { chromium, request } from "@playwright/test";
|
|
11
12
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
13
|
// ../review/src/steps.ts
|
|
@@ -82,7 +83,7 @@ function folder(root) {
|
|
|
82
83
|
},
|
|
83
84
|
async write(path, bytes) {
|
|
84
85
|
const file = join(root, path);
|
|
85
|
-
const unfinished = `${file}.${
|
|
86
|
+
const unfinished = `${file}.${randomUUID()}.writing`;
|
|
86
87
|
await mkdir(dirname(file), { recursive: true });
|
|
87
88
|
await writeFile(unfinished, bytes);
|
|
88
89
|
await rename(unfinished, file);
|
|
@@ -132,7 +133,7 @@ function repo(root = home(process.cwd())) {
|
|
|
132
133
|
await writeFile(join(reviews, ".gitignore"), "*\n");
|
|
133
134
|
},
|
|
134
135
|
async app() {
|
|
135
|
-
const how = `Say how to start the app in playthrough/app.json: {
|
|
136
|
+
const how = `Say how to start the app in playthrough/app.json: ${PORTED}, or with the address it always has where it can't be given a port.`;
|
|
136
137
|
const text = await readFile(appFile, "utf8").catch(() => void 0);
|
|
137
138
|
if (text === void 0) throw new Refused([`Playthrough needs to start the app to record it. ${how}`]);
|
|
138
139
|
let app;
|
|
@@ -141,12 +142,19 @@ function repo(root = home(process.cwd())) {
|
|
|
141
142
|
} catch (error) {
|
|
142
143
|
throw new Refused([`playthrough/app.json isn't JSON: ${error.message}. ${how}`]);
|
|
143
144
|
}
|
|
144
|
-
if (!isText(app.start) || !isText(app.url)) throw new Refused([`playthrough/app.json needs both “start” and “url”. ${how}`]);
|
|
145
|
+
if (!app || typeof app !== "object" || !isText(app.start) || !isText(app.url)) throw new Refused([`playthrough/app.json needs both “start” and “url”. ${how}`]);
|
|
146
|
+
const saying = [app.start, app.url].filter((said) => said.includes("{port}")).length;
|
|
147
|
+
if (saying === 1) throw new Refused([`playthrough/app.json says {port} in only one of “start” and “url”. Say it in both, so the app is started on the port Playthrough gives it and found there: ${PORTED}.`]);
|
|
148
|
+
const port = saying ? await portFor(root) : void 0;
|
|
149
|
+
const given = (said) => port === void 0 ? said : said.replaceAll("{port}", String(port));
|
|
150
|
+
const url = given(app.url);
|
|
151
|
+
if (!/^https?:$/.test(URL.parse(url)?.protocol ?? "")) throw new Refused([`playthrough/app.json's “url” is a whole address, as a browser takes it, like http://localhost:{port}, not “${app.url}”.`]);
|
|
145
152
|
if (app.oneAtATime !== void 0 && typeof app.oneAtATime !== "boolean") throw new Refused([`playthrough/app.json's “oneAtATime” is true or false: true runs each flow by itself.`]);
|
|
146
153
|
return {
|
|
147
|
-
start: app.start,
|
|
148
|
-
url
|
|
149
|
-
...app.oneAtATime && { oneAtATime: true }
|
|
154
|
+
start: given(app.start),
|
|
155
|
+
url,
|
|
156
|
+
...app.oneAtATime && { oneAtATime: true },
|
|
157
|
+
...port !== void 0 && { port }
|
|
150
158
|
};
|
|
151
159
|
}
|
|
152
160
|
};
|
|
@@ -157,6 +165,35 @@ function home(here) {
|
|
|
157
165
|
if (existsSync(join(folder, ".git")) || dirname(folder) === folder) return here;
|
|
158
166
|
}
|
|
159
167
|
}
|
|
168
|
+
const PORTED = `{ "start": "npm run dev -- --port {port}", "url": "http://localhost:{port}" }`;
|
|
169
|
+
const ports = /* @__PURE__ */ new Map();
|
|
170
|
+
const portFor = (root) => ports.get(root) ?? ports.set(root, free()).get(root);
|
|
171
|
+
const free = () => new Promise((resolve, reject) => {
|
|
172
|
+
const server = createServer();
|
|
173
|
+
server.once("error", reject);
|
|
174
|
+
server.listen(0, "127.0.0.1", () => {
|
|
175
|
+
const { port } = server.address();
|
|
176
|
+
server.close(() => resolve(port));
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
function copies(root) {
|
|
180
|
+
try {
|
|
181
|
+
const git = (...args) => execFileSync("git", args, {
|
|
182
|
+
cwd: root,
|
|
183
|
+
encoding: "utf8",
|
|
184
|
+
stdio: [
|
|
185
|
+
"ignore",
|
|
186
|
+
"pipe",
|
|
187
|
+
"ignore"
|
|
188
|
+
]
|
|
189
|
+
}).trim();
|
|
190
|
+
const inside = relative(git("rev-parse", "--show-toplevel"), root);
|
|
191
|
+
return git("worktree", "list", "--porcelain").split("\n").filter((line) => line.startsWith("worktree ")).map((line) => line.slice(9)).map((worktree) => resolve(worktree, inside)).filter((folder) => existsSync(folder));
|
|
192
|
+
} catch {
|
|
193
|
+
return [resolve(root)];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const inWorktree = (root) => copies(root)[0] !== resolve(root);
|
|
160
197
|
const isText = (value) => typeof value === "string" && value.trim() !== "";
|
|
161
198
|
// src/setup.ts
|
|
162
199
|
const OURS = [
|
|
@@ -250,8 +287,9 @@ async function giveBackOf({ after }, borrowed, whose) {
|
|
|
250
287
|
for (const name of left) {
|
|
251
288
|
const file = join(borrowed, `${process.pid}-${name.slice(name.indexOf("-") + 1)}`);
|
|
252
289
|
if (!await rename(join(borrowed, name), file).then(() => true, () => false)) continue;
|
|
253
|
-
const
|
|
254
|
-
await
|
|
290
|
+
const given = await readFile(file, "utf8").then(JSON.parse).catch(() => void 0);
|
|
291
|
+
if (!given) await rm(file, { force: true });
|
|
292
|
+
else await giveBack(after, file, given.baseURL, given.setup);
|
|
255
293
|
}
|
|
256
294
|
}
|
|
257
295
|
const giving = /* @__PURE__ */ new Set();
|
|
@@ -284,7 +322,7 @@ function alive(pid) {
|
|
|
284
322
|
}
|
|
285
323
|
}
|
|
286
324
|
// src/app.ts
|
|
287
|
-
|
|
325
|
+
const started = /* @__PURE__ */ new Map();
|
|
288
326
|
let ending = false;
|
|
289
327
|
async function keepRunning(app, root) {
|
|
290
328
|
if (!ending) {
|
|
@@ -297,12 +335,17 @@ async function keepRunning(app, root) {
|
|
|
297
335
|
"SIGTERM",
|
|
298
336
|
"SIGHUP"
|
|
299
337
|
]) process.once(signal, end);
|
|
300
|
-
process.once("exit", stop);
|
|
338
|
+
process.once("exit", () => [...started.keys()].forEach(stop));
|
|
339
|
+
}
|
|
340
|
+
const ours = started.get(app.url);
|
|
341
|
+
if (ours && ours.root !== root) await stopApp(app.url);
|
|
342
|
+
if (await serves(app.url)) {
|
|
343
|
+
if (ours?.root === root || app.port !== void 0 || !inWorktree(root)) return;
|
|
344
|
+
throw new Refused([`Something else answers at ${app.url}, most likely the app running from another folder. This is a worktree, so Playthrough runs this copy of the app there itself: whoever started what's there can stop it, then try again.`, `Or give each copy a port of its own in playthrough/app.json: ${PORTED}.`]);
|
|
301
345
|
}
|
|
302
|
-
if (await serves(app.url)) return;
|
|
303
346
|
console.log(`Starting the app: ${app.start}`);
|
|
304
347
|
const began = Date.now();
|
|
305
|
-
|
|
348
|
+
const running = spawn(app.start, {
|
|
306
349
|
cwd: root,
|
|
307
350
|
shell: true,
|
|
308
351
|
stdio: [
|
|
@@ -312,17 +355,21 @@ async function keepRunning(app, root) {
|
|
|
312
355
|
],
|
|
313
356
|
detached: true
|
|
314
357
|
});
|
|
358
|
+
started.set(app.url, {
|
|
359
|
+
app: running,
|
|
360
|
+
root
|
|
361
|
+
});
|
|
315
362
|
const said = [];
|
|
316
|
-
for (const output of [
|
|
363
|
+
for (const output of [running.stdout, running.stderr]) {
|
|
317
364
|
output.on("data", (chunk) => {
|
|
318
365
|
said.push(...chunk.toString().split("\n").filter((line) => line.trim() && !/^\s+at /.test(line)));
|
|
319
366
|
said.splice(0, said.length - 10);
|
|
320
367
|
});
|
|
321
368
|
output.unref();
|
|
322
369
|
}
|
|
323
|
-
|
|
370
|
+
running.unref();
|
|
324
371
|
let ended = false;
|
|
325
|
-
|
|
372
|
+
running.once("exit", () => ended = true);
|
|
326
373
|
const saying = () => said.length ? ["It said, last:", ...said.map((line) => ` ${line}`)] : [];
|
|
327
374
|
while (Date.now() - began < 18e4) {
|
|
328
375
|
if (await serves(app.url)) return Date.now() - began;
|
|
@@ -333,7 +380,7 @@ async function keepRunning(app, root) {
|
|
|
333
380
|
]);
|
|
334
381
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
335
382
|
}
|
|
336
|
-
stop();
|
|
383
|
+
stop(app.url);
|
|
337
384
|
throw new Refused([
|
|
338
385
|
`The app didn't answer at ${app.url} within three minutes of ${quote(app.start)}.`,
|
|
339
386
|
...saying(),
|
|
@@ -341,16 +388,17 @@ async function keepRunning(app, root) {
|
|
|
341
388
|
]);
|
|
342
389
|
}
|
|
343
390
|
async function stopApp(url) {
|
|
344
|
-
if (!stop()) return;
|
|
391
|
+
if (!stop(url)) return;
|
|
345
392
|
const began = Date.now();
|
|
346
393
|
while (Date.now() - began < 1e4 && await serves(url)) await new Promise((resolve) => setTimeout(resolve, 250));
|
|
347
394
|
}
|
|
348
|
-
function stop() {
|
|
349
|
-
|
|
395
|
+
function stop(url) {
|
|
396
|
+
const ours = started.get(url);
|
|
397
|
+
if (!ours) return false;
|
|
350
398
|
try {
|
|
351
|
-
process.kill(-
|
|
399
|
+
process.kill(-ours.app.pid);
|
|
352
400
|
} catch {}
|
|
353
|
-
started
|
|
401
|
+
started.delete(url);
|
|
354
402
|
return true;
|
|
355
403
|
}
|
|
356
404
|
const serves = (url) => fetch(url, { signal: AbortSignal.timeout(2e3) }).then((response) => (response.body?.cancel(), true), (error) => error?.name === "TimeoutError");
|
|
@@ -1962,7 +2010,7 @@ async function trySetup(repo, again) {
|
|
|
1962
2010
|
const app = await repo.app();
|
|
1963
2011
|
const written = await writtenNow(repo);
|
|
1964
2012
|
const last = join(repo.tried, "tried.json");
|
|
1965
|
-
if (await readFile(last, "utf8").then((text) => JSON.parse(text).written
|
|
2013
|
+
if (await readFile(last, "utf8").then((text) => JSON.parse(text).written).catch(() => void 0) === written) return;
|
|
1966
2014
|
console.log("Trying the app's setup by itself first, twice, as every flow will start…");
|
|
1967
2015
|
const browser = await chromium.launch().catch((error) => {
|
|
1968
2016
|
const { version } = createRequire(import.meta.url)("@playwright/test/package.json");
|
|
@@ -2075,6 +2123,7 @@ async function writtenNow(repo) {
|
|
|
2075
2123
|
return createHash("sha256").update(await text(repo.appFile)).update("\0").update(await text(repo.setup)).digest("hex");
|
|
2076
2124
|
}
|
|
2077
2125
|
// src/flows.ts
|
|
2126
|
+
const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
|
|
2078
2127
|
const WALKED = "walked.json";
|
|
2079
2128
|
const ALONGSIDE = Symbol.for("playthrough: flows taken over here");
|
|
2080
2129
|
const REACHES_OUTSIDE = "reaches outside";
|
|
@@ -2117,7 +2166,6 @@ async function runFlows(repo, id, round, { before, first = () => false }) {
|
|
|
2117
2166
|
const app = await repo.app();
|
|
2118
2167
|
if (!before) await trySetup(repo, `playthrough record ${id}`);
|
|
2119
2168
|
const { started, rounds } = await repo.reviews.get(id);
|
|
2120
|
-
const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
|
|
2121
2169
|
const longest = (rounds.filter((kept) => kept.number < round).at(-1)?.flows ?? []).toSorted((a, b) => b.length - a.length).map((flow) => flow.name);
|
|
2122
2170
|
const run = (only, results) => runOnce({
|
|
2123
2171
|
flows,
|
|
@@ -2198,7 +2246,7 @@ async function keepFailed(repo, id, failed) {
|
|
|
2198
2246
|
await mkdir(folder, { recursive: true });
|
|
2199
2247
|
await writeFile(join(folder, FAILED), JSON.stringify(failed.map(({ flow }) => flow)));
|
|
2200
2248
|
}
|
|
2201
|
-
const failedLastTime = (repo, id) => readFile(join(repo.failed(id), FAILED), "utf8").then(JSON.parse
|
|
2249
|
+
const failedLastTime = (repo, id) => readFile(join(repo.failed(id), FAILED), "utf8").then(JSON.parse).catch(() => []);
|
|
2202
2250
|
const FAILED = "flows.json";
|
|
2203
2251
|
const whereItStopped = (repo, id, failed) => `What the page held where ${failed.length > 1 ? "each" : "it"} stopped is in ${repo.show(repo.failed(id))}/.`;
|
|
2204
2252
|
function inMemory(number, recorded) {
|
|
@@ -2249,7 +2297,6 @@ async function runOnce(run) {
|
|
|
2249
2297
|
async function walkFork(repo, id, walk) {
|
|
2250
2298
|
const app = await repo.app();
|
|
2251
2299
|
const { started } = await repo.reviews.get(id);
|
|
2252
|
-
const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
|
|
2253
2300
|
const folder = await mkdtemp(join(tmpdir(), "playthrough-"));
|
|
2254
2301
|
try {
|
|
2255
2302
|
await playwright({
|
|
@@ -2318,4 +2365,4 @@ async function playwright(run) {
|
|
|
2318
2365
|
});
|
|
2319
2366
|
});
|
|
2320
2367
|
}
|
|
2321
|
-
export {
|
|
2368
|
+
export { repo as A, changes as C, personIn as D, make as E, 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, pausedAt 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 };
|