playthrough 0.4.3 → 0.5.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 +22 -6
- package/README.md +10 -2
- package/package.json +1 -1
- package/src/{flows-BkG0TgHA.js → flows-vi0amwSo.js} +192 -41
- package/src/playthrough.js +611 -132
- package/src/playwright.config.js +1 -1
- package/src/{reporter-DE8aueAO.js → reporter-S9fOk0za.js} +16 -5
- package/src/reporter.js +1 -1
- package/src/test.js +30 -11
- package/types/cli/src/app.d.ts +3 -1
- package/types/cli/src/changes.d.ts +4 -0
- package/types/cli/src/command.d.ts +6 -0
- package/types/cli/src/flows.d.ts +1 -0
- package/types/cli/src/repo.d.ts +5 -0
- package/types/cli/src/reporter.d.ts +0 -1
- package/types/page/src/index.d.ts +1 -0
- package/types/page/src/names.d.ts +1 -0
- package/types/page/src/scheme.d.ts +6 -0
- package/types/review/src/model.d.ts +6 -0
- package/types/review/src/rules.d.ts +2 -0
- package/viewer/assets/index-C4zcB6st.css +2 -0
- package/viewer/assets/index-D_6gdncQ.js +194 -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
|
@@ -22,7 +22,8 @@ THE LOOP
|
|
|
22
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,
|
|
23
23
|
say why each change since the round before is there, under "accounts" by its id: { "line": "<why, in your words>" },
|
|
24
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.
|
|
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. The
|
|
26
|
+
person sees what changed, marked where it is: a line says only why, in a short sentence.
|
|
26
27
|
5. playthrough wait <review> hands the round over and waits for the notes, which can take hours: run it in the
|
|
27
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
|
|
28
29
|
the app. Change the app, record the next round, and answer each note in its round.json, under "replies", in its
|
|
@@ -38,15 +39,24 @@ THE APP
|
|
|
38
39
|
The first time, add playthrough to the app's devDependencies with the app's own package manager, and run
|
|
39
40
|
npx playwright install chromium: flows import from it, and record in that browser.
|
|
40
41
|
|
|
41
|
-
playthrough/app.json says how to start the app, and is committed so every review uses it
|
|
42
|
+
playthrough/app.json says how to start the app, and is committed so every review uses it. Where the command can be
|
|
43
|
+
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
|
|
44
|
+
or the person takes a flow over, beside the person's own and any other worktree's:
|
|
45
|
+
|
|
46
|
+
{ "start": "npm run dev -- --port {port}", "url": "http://localhost:{port}" }
|
|
47
|
+
|
|
48
|
+
Where it can't, as when another part of the app expects it at one address, give the address it always has:
|
|
42
49
|
|
|
43
50
|
{ "start": "npm run dev", "url": "http://localhost:3000" }
|
|
44
51
|
|
|
52
|
+
There, every round is recorded on whatever serves that url, and Playthrough starts the app only when nothing does: leave
|
|
53
|
+
a server you didn't start running, and let it follow the working copy, as a dev server does. In a worktree, what serves
|
|
54
|
+
it could be another copy, so Playthrough needs the address free, and says so. An app of several servers, one calling
|
|
55
|
+
another at its address, takes the address it always has, or a copy on a port of its own would call the person's.
|
|
56
|
+
|
|
45
57
|
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.
|
|
58
|
+
"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.
|
|
50
60
|
|
|
51
61
|
FLOWS
|
|
52
62
|
|
|
@@ -70,6 +80,12 @@ the page once it rests after each step. A dialog the browser shows is recorded,
|
|
|
70
80
|
first: page.once("dialog", (dialog) => dialog.accept()). A flow that fails stops the round: record names it and the
|
|
71
81
|
step, and keeps the page it failed on in .playthrough/<review>/failed/.
|
|
72
82
|
|
|
83
|
+
Steps go where a person goes, by what the app had before your work too, never to what you changed: the app as it was
|
|
84
|
+
doesn't have it, so Before stops short there and your change reads as new steps. Playthrough finds what changed, and
|
|
85
|
+
shows each change when the recording first has it on the screen. A page people read, like a landing page or docs, is a
|
|
86
|
+
flow of reading it, top to bottom: a step for each screen, scrolled with page.mouse.wheel(0, 600), which Playthrough scrolls as a person does, as
|
|
87
|
+
many as the longer page needs.
|
|
88
|
+
|
|
73
89
|
While the person watches, Playthrough runs the flow again in the background, so taking it over is quick. If its steps do
|
|
74
90
|
something outside the app that mustn't happen again, like sending an email, say so, and it runs only when taken over:
|
|
75
91
|
|
package/README.md
CHANGED
|
@@ -4,8 +4,12 @@ The agent says it's done. You decide if it's good.
|
|
|
4
4
|
|
|
5
5
|
Playthrough records your coding agent's change the way a person would use it, before and after, so you can watch it instead of setting it up by hand. Point at what's wrong, right on the recording, and the next round answers it there. Take over any moment and carry on in the real app. It runs on your machine, with any coding agent that has a shell.
|
|
6
6
|
|
|
7
|
+
Many agents, one of you: each change arrives already recorded, and one page shows which ones need you. Any flow downloads as a video, for a pull request or a teammate. Keep your code review: it reads the code; Playthrough shows what the change does.
|
|
8
|
+
|
|
7
9
|
To see a review before you install anything, play the one at [playthrough.sh](https://playthrough.sh).
|
|
8
10
|
|
|
11
|
+
<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>
|
|
12
|
+
|
|
9
13
|
## Try it
|
|
10
14
|
|
|
11
15
|
Ask your agent for a change in your app, as you would anyway, and add:
|
|
@@ -20,9 +24,13 @@ npx playthrough open
|
|
|
20
24
|
|
|
21
25
|
Reviews stay in `.playthrough/` in your app's repo, recordings and all; nothing is uploaded. Playthrough needs Node 24 or later.
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
You can teach your agent Playthrough once, so “Show me this change” is enough from then on:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx skills add itmostlyworks/playthrough
|
|
31
|
+
```
|
|
24
32
|
|
|
25
|
-
|
|
33
|
+
In Claude Code, it's a plugin too: `/plugin install playthrough --marketplace itmostlyworks/playthrough`.
|
|
26
34
|
|
|
27
35
|
## How a review goes
|
|
28
36
|
|
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-S9fOk0za.js";
|
|
2
2
|
import * as modules from "node:module";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
-
import { existsSync, watch } from "node:fs";
|
|
5
|
-
import { spawn } from "node:child_process";
|
|
4
|
+
import { existsSync, readFileSync, watch, writeFileSync } from "node:fs";
|
|
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
|
-
import { constants, tmpdir } from "node:os";
|
|
9
|
-
import { dirname, join, relative } from "node:path";
|
|
8
|
+
import { constants, tmpdir, uptime } from "node:os";
|
|
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
|
|
@@ -68,6 +69,30 @@ function pausedAt(flow, at) {
|
|
|
68
69
|
actions: 1
|
|
69
70
|
};
|
|
70
71
|
}
|
|
72
|
+
// src/command.ts
|
|
73
|
+
const thisCommand = () => ({
|
|
74
|
+
by: process.pid,
|
|
75
|
+
boot: boot()
|
|
76
|
+
});
|
|
77
|
+
const stillAt = (command) => sameBoot(command.boot) && running(command.by);
|
|
78
|
+
function boot() {
|
|
79
|
+
try {
|
|
80
|
+
return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
81
|
+
} catch {
|
|
82
|
+
return Date.now() / 1e3 - uptime();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function sameBoot(then, now = boot()) {
|
|
86
|
+
if (typeof then === "number" && typeof now === "number") return Math.abs(then - now) < 300;
|
|
87
|
+
return then === void 0 || then === now;
|
|
88
|
+
}
|
|
89
|
+
function running(pid) {
|
|
90
|
+
try {
|
|
91
|
+
return process.kill(pid, 0);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return error.code === "EPERM";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
71
96
|
// ../review/src/folder.ts
|
|
72
97
|
const LOOK_EVERY = 2e3;
|
|
73
98
|
function folder(root) {
|
|
@@ -82,7 +107,7 @@ function folder(root) {
|
|
|
82
107
|
},
|
|
83
108
|
async write(path, bytes) {
|
|
84
109
|
const file = join(root, path);
|
|
85
|
-
const unfinished = `${file}.${
|
|
110
|
+
const unfinished = `${file}.${randomUUID()}.writing`;
|
|
86
111
|
await mkdir(dirname(file), { recursive: true });
|
|
87
112
|
await writeFile(unfinished, bytes);
|
|
88
113
|
await rename(unfinished, file);
|
|
@@ -122,6 +147,7 @@ function repo(root = home(process.cwd())) {
|
|
|
122
147
|
round: (review, round) => join(reviews, review, "rounds", String(round), "round.json"),
|
|
123
148
|
ranOnBefore: (review) => join(reviews, review, "ran-on-before.json"),
|
|
124
149
|
setAside: join(reviews, "set-aside.json"),
|
|
150
|
+
turn: join(reviews, "turn.json"),
|
|
125
151
|
appFile,
|
|
126
152
|
setup: join(root, "playthrough", "setup.ts"),
|
|
127
153
|
borrowed: join(reviews, "borrowed"),
|
|
@@ -132,7 +158,7 @@ function repo(root = home(process.cwd())) {
|
|
|
132
158
|
await writeFile(join(reviews, ".gitignore"), "*\n");
|
|
133
159
|
},
|
|
134
160
|
async app() {
|
|
135
|
-
const how = `Say how to start the app in playthrough/app.json: {
|
|
161
|
+
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
162
|
const text = await readFile(appFile, "utf8").catch(() => void 0);
|
|
137
163
|
if (text === void 0) throw new Refused([`Playthrough needs to start the app to record it. ${how}`]);
|
|
138
164
|
let app;
|
|
@@ -141,12 +167,19 @@ function repo(root = home(process.cwd())) {
|
|
|
141
167
|
} catch (error) {
|
|
142
168
|
throw new Refused([`playthrough/app.json isn't JSON: ${error.message}. ${how}`]);
|
|
143
169
|
}
|
|
144
|
-
if (!isText(app.start) || !isText(app.url)) throw new Refused([`playthrough/app.json needs both “start” and “url”. ${how}`]);
|
|
170
|
+
if (!app || typeof app !== "object" || !isText(app.start) || !isText(app.url)) throw new Refused([`playthrough/app.json needs both “start” and “url”. ${how}`]);
|
|
171
|
+
const saying = [app.start, app.url].filter((said) => said.includes("{port}")).length;
|
|
172
|
+
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}.`]);
|
|
173
|
+
const port = saying ? await portFor(root) : void 0;
|
|
174
|
+
const given = (said) => port === void 0 ? said : said.replaceAll("{port}", String(port));
|
|
175
|
+
const url = given(app.url);
|
|
176
|
+
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
177
|
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
178
|
return {
|
|
147
|
-
start: app.start,
|
|
148
|
-
url
|
|
149
|
-
...app.oneAtATime && { oneAtATime: true }
|
|
179
|
+
start: given(app.start),
|
|
180
|
+
url,
|
|
181
|
+
...app.oneAtATime && { oneAtATime: true },
|
|
182
|
+
...port !== void 0 && { port }
|
|
150
183
|
};
|
|
151
184
|
}
|
|
152
185
|
};
|
|
@@ -157,6 +190,35 @@ function home(here) {
|
|
|
157
190
|
if (existsSync(join(folder, ".git")) || dirname(folder) === folder) return here;
|
|
158
191
|
}
|
|
159
192
|
}
|
|
193
|
+
const PORTED = `{ "start": "npm run dev -- --port {port}", "url": "http://localhost:{port}" }`;
|
|
194
|
+
const ports = /* @__PURE__ */ new Map();
|
|
195
|
+
const portFor = (root) => ports.get(root) ?? ports.set(root, free()).get(root);
|
|
196
|
+
const free = () => new Promise((resolve, reject) => {
|
|
197
|
+
const server = createServer();
|
|
198
|
+
server.once("error", reject);
|
|
199
|
+
server.listen(0, "127.0.0.1", () => {
|
|
200
|
+
const { port } = server.address();
|
|
201
|
+
server.close(() => resolve(port));
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
function copies(root) {
|
|
205
|
+
try {
|
|
206
|
+
const git = (...args) => execFileSync("git", args, {
|
|
207
|
+
cwd: root,
|
|
208
|
+
encoding: "utf8",
|
|
209
|
+
stdio: [
|
|
210
|
+
"ignore",
|
|
211
|
+
"pipe",
|
|
212
|
+
"ignore"
|
|
213
|
+
]
|
|
214
|
+
}).trim();
|
|
215
|
+
const inside = relative(git("rev-parse", "--show-toplevel"), root);
|
|
216
|
+
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));
|
|
217
|
+
} catch {
|
|
218
|
+
return [resolve(root)];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const inWorktree = (root) => copies(root)[0] !== resolve(root);
|
|
160
222
|
const isText = (value) => typeof value === "string" && value.trim() !== "";
|
|
161
223
|
// src/setup.ts
|
|
162
224
|
const OURS = [
|
|
@@ -250,8 +312,9 @@ async function giveBackOf({ after }, borrowed, whose) {
|
|
|
250
312
|
for (const name of left) {
|
|
251
313
|
const file = join(borrowed, `${process.pid}-${name.slice(name.indexOf("-") + 1)}`);
|
|
252
314
|
if (!await rename(join(borrowed, name), file).then(() => true, () => false)) continue;
|
|
253
|
-
const
|
|
254
|
-
await
|
|
315
|
+
const given = await readFile(file, "utf8").then(JSON.parse).catch(() => void 0);
|
|
316
|
+
if (!given) await rm(file, { force: true });
|
|
317
|
+
else await giveBack(after, file, given.baseURL, given.setup);
|
|
255
318
|
}
|
|
256
319
|
}
|
|
257
320
|
const giving = /* @__PURE__ */ new Set();
|
|
@@ -284,9 +347,9 @@ function alive(pid) {
|
|
|
284
347
|
}
|
|
285
348
|
}
|
|
286
349
|
// src/app.ts
|
|
287
|
-
|
|
350
|
+
const started = /* @__PURE__ */ new Map();
|
|
288
351
|
let ending = false;
|
|
289
|
-
async function keepRunning(app, root) {
|
|
352
|
+
async function keepRunning(app, root, { ready = false } = {}) {
|
|
290
353
|
if (!ending) {
|
|
291
354
|
ending = true;
|
|
292
355
|
const { setup, borrowed } = repo(root);
|
|
@@ -297,12 +360,18 @@ async function keepRunning(app, root) {
|
|
|
297
360
|
"SIGTERM",
|
|
298
361
|
"SIGHUP"
|
|
299
362
|
]) process.once(signal, end);
|
|
300
|
-
process.once("exit", stop);
|
|
363
|
+
process.once("exit", () => [...started.keys()].forEach(stop));
|
|
364
|
+
}
|
|
365
|
+
await takeTurns(root, ready);
|
|
366
|
+
const ours = started.get(app.url);
|
|
367
|
+
if (ours && ours.root !== root) await stopApp(app.url);
|
|
368
|
+
if (await serves(app.url)) {
|
|
369
|
+
if (ours?.root === root || app.port !== void 0 || !inWorktree(root)) return;
|
|
370
|
+
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
371
|
}
|
|
302
|
-
if (await serves(app.url)) return;
|
|
303
372
|
console.log(`Starting the app: ${app.start}`);
|
|
304
373
|
const began = Date.now();
|
|
305
|
-
|
|
374
|
+
const running = spawn(app.start, {
|
|
306
375
|
cwd: root,
|
|
307
376
|
shell: true,
|
|
308
377
|
stdio: [
|
|
@@ -312,17 +381,27 @@ async function keepRunning(app, root) {
|
|
|
312
381
|
],
|
|
313
382
|
detached: true
|
|
314
383
|
});
|
|
384
|
+
started.set(app.url, {
|
|
385
|
+
app: running,
|
|
386
|
+
root
|
|
387
|
+
});
|
|
388
|
+
if (ready) writeFileSync(repo(root).turn, JSON.stringify({
|
|
389
|
+
...thisCommand(),
|
|
390
|
+
ready,
|
|
391
|
+
app: running.pid,
|
|
392
|
+
url: app.url
|
|
393
|
+
}));
|
|
315
394
|
const said = [];
|
|
316
|
-
for (const output of [
|
|
395
|
+
for (const output of [running.stdout, running.stderr]) {
|
|
317
396
|
output.on("data", (chunk) => {
|
|
318
397
|
said.push(...chunk.toString().split("\n").filter((line) => line.trim() && !/^\s+at /.test(line)));
|
|
319
398
|
said.splice(0, said.length - 10);
|
|
320
399
|
});
|
|
321
400
|
output.unref();
|
|
322
401
|
}
|
|
323
|
-
|
|
402
|
+
running.unref();
|
|
324
403
|
let ended = false;
|
|
325
|
-
|
|
404
|
+
running.once("exit", () => ended = true);
|
|
326
405
|
const saying = () => said.length ? ["It said, last:", ...said.map((line) => ` ${line}`)] : [];
|
|
327
406
|
while (Date.now() - began < 18e4) {
|
|
328
407
|
if (await serves(app.url)) return Date.now() - began;
|
|
@@ -333,24 +412,48 @@ async function keepRunning(app, root) {
|
|
|
333
412
|
]);
|
|
334
413
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
335
414
|
}
|
|
336
|
-
stop();
|
|
415
|
+
stop(app.url);
|
|
337
416
|
throw new Refused([
|
|
338
417
|
`The app didn't answer at ${app.url} within three minutes of ${quote(app.start)}.`,
|
|
339
418
|
...saying(),
|
|
340
419
|
"Check that playthrough/app.json starts it there."
|
|
341
420
|
]);
|
|
342
421
|
}
|
|
422
|
+
async function takeTurns(root, ready) {
|
|
423
|
+
const here = repo(root);
|
|
424
|
+
let turn;
|
|
425
|
+
try {
|
|
426
|
+
turn = JSON.parse(readFileSync(here.turn, "utf8"));
|
|
427
|
+
} catch {}
|
|
428
|
+
const theirs = turn && turn.by !== process.pid && stillAt(turn) ? turn : void 0;
|
|
429
|
+
if (ready) {
|
|
430
|
+
if (theirs && !theirs.ready) throw new Refused([`Another playthrough command (process ${theirs.by}) runs the app just now, until it's done, in a minute or so. Try again then.`]);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
await here.keepOutOfGit();
|
|
434
|
+
writeFileSync(here.turn, JSON.stringify({
|
|
435
|
+
...thisCommand(),
|
|
436
|
+
ready
|
|
437
|
+
}));
|
|
438
|
+
if (!theirs?.ready) return;
|
|
439
|
+
try {
|
|
440
|
+
process.kill(-theirs.app);
|
|
441
|
+
} catch {}
|
|
442
|
+
const began = Date.now();
|
|
443
|
+
while (Date.now() - began < 1e4 && await serves(theirs.url)) await new Promise((resolve) => setTimeout(resolve, 250));
|
|
444
|
+
}
|
|
343
445
|
async function stopApp(url) {
|
|
344
|
-
if (!stop()) return;
|
|
446
|
+
if (!stop(url)) return;
|
|
345
447
|
const began = Date.now();
|
|
346
448
|
while (Date.now() - began < 1e4 && await serves(url)) await new Promise((resolve) => setTimeout(resolve, 250));
|
|
347
449
|
}
|
|
348
|
-
function stop() {
|
|
349
|
-
|
|
450
|
+
function stop(url) {
|
|
451
|
+
const ours = started.get(url);
|
|
452
|
+
if (!ours) return false;
|
|
350
453
|
try {
|
|
351
|
-
process.kill(-
|
|
454
|
+
process.kill(-ours.app.pid);
|
|
352
455
|
} catch {}
|
|
353
|
-
started
|
|
456
|
+
started.delete(url);
|
|
354
457
|
return true;
|
|
355
458
|
}
|
|
356
459
|
const serves = (url) => fetch(url, { signal: AbortSignal.timeout(2e3) }).then((response) => (response.body?.cancel(), true), (error) => error?.name === "TimeoutError");
|
|
@@ -435,6 +538,17 @@ function rewordedWords(a, b) {
|
|
|
435
538
|
};
|
|
436
539
|
return `“${part(a.words)}” now says “${part(b.words)}”`;
|
|
437
540
|
}
|
|
541
|
+
function rewordedPart(a, b) {
|
|
542
|
+
const letter = /[\p{L}\p{N}’']/u;
|
|
543
|
+
let start = 0;
|
|
544
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
545
|
+
let end = 0;
|
|
546
|
+
while (end < a.length - start && end < b.length - start && a.at(-1 - end) === b.at(-1 - end)) end++;
|
|
547
|
+
let to = b.length - end;
|
|
548
|
+
while (start > 0 && letter.test(b[start - 1]) && letter.test(b[start] ?? "")) start--;
|
|
549
|
+
while (to < b.length && to > 0 && letter.test(b[to - 1]) && letter.test(b[to])) to++;
|
|
550
|
+
return b.slice(start, to).trim() || void 0;
|
|
551
|
+
}
|
|
438
552
|
function lookWords(a, b) {
|
|
439
553
|
const parts = [];
|
|
440
554
|
if (a.look.background !== b.look.background) parts.push("background");
|
|
@@ -551,7 +665,7 @@ function differences(then, now) {
|
|
|
551
665
|
});
|
|
552
666
|
continue;
|
|
553
667
|
}
|
|
554
|
-
a = b.role !== "text" ? take((a) => a.role === b.role && (a.name === b.name || samePlace(a, b) || mostlyWhereItWas(a, b) || holdsTheSameWords(a, b) || CONTROL.test(b.role) && (!!bare(b.name) && bare(a.name) === bare(b.name) || overlap(a, b) > .5))) : take((a) => a.role === "text" && samePlace(a, b)) ?? (holderOf(now, b) && goneWords.get(holderKey(now, b)) === newWords.get(holderKey(now, b)) ? take((a) => a.role === "text" && holderKey(then, a) === holderKey(now, b)) : void 0) ?? take((a) => a.role === "text" && holderKey(then, a) === holderKey(now, b) && !!bare(b.words) && bare(a.words) === bare(b.words));
|
|
668
|
+
a = b.role !== "text" ? take((a) => a.role === b.role && (a.name === b.name || samePlace(a, b) || mostlyWhereItWas(a, b) || holdsTheSameWords(a, b) || CONTROL.test(b.role) && (!!bare(b.name) && bare(a.name) === bare(b.name) || overlap(a, b) > .5))) : take((a) => a.role === "text" && samePlace(a, b)) ?? (holderOf(now, b) && goneWords.get(holderKey(now, b)) === newWords.get(holderKey(now, b)) ? take((a) => a.role === "text" && holderKey(then, a) === holderKey(now, b)) : void 0) ?? take((a) => a.role === "text" && holderKey(then, a) === holderKey(now, b) && !!bare(b.words) && bare(a.words) === bare(b.words)) ?? take((a) => a.role === "text" && kept$1(a.words, b.words));
|
|
555
669
|
if (!a) {
|
|
556
670
|
found.push({
|
|
557
671
|
what: "new",
|
|
@@ -711,6 +825,14 @@ function gained(reading, control, was) {
|
|
|
711
825
|
}
|
|
712
826
|
const CONTROL = /^(button|link|textbox|searchbox|combobox|checkbox|radio|switch|slider|tab|menuitem|option)$/;
|
|
713
827
|
const holdsControls = (reading, thing) => reading.things.some((inside) => parentOf(reading, inside) === thing && CONTROL.test(inside.role));
|
|
828
|
+
function kept$1(a, b) {
|
|
829
|
+
if (a.length < 24 || a === b) return false;
|
|
830
|
+
let start = 0;
|
|
831
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
832
|
+
let end = 0;
|
|
833
|
+
while (end < a.length - start && end < b.length - start && a.at(-1 - end) === b.at(-1 - end)) end++;
|
|
834
|
+
return (start + end) / a.length >= 2 / 3;
|
|
835
|
+
}
|
|
714
836
|
const mostlyWhereItWas = (a, b) => HOLDS.test(b.role) && overlap(a, b) > .5;
|
|
715
837
|
const holdsTheSameWords = (a, b) => /^(box|menu)$/.test(b.role) && !!a.wordsInside && bag(a) === bag(b);
|
|
716
838
|
const bag = (thing) => (thing.wordsInside ?? "").split(" ").sort().join(" ");
|
|
@@ -829,7 +951,7 @@ function changesAt(then, now, unsteady = /* @__PURE__ */ new Set(), shown = {
|
|
|
829
951
|
const told = [
|
|
830
952
|
...items.reordered,
|
|
831
953
|
...reordered,
|
|
832
|
-
...swappedOrMoved(moment, loose),
|
|
954
|
+
...swappedOrMoved(moment, madeRoom(loose, others, caused, along)),
|
|
833
955
|
...causesOfShifts(moment, shifts, widened, others, caused, along)
|
|
834
956
|
];
|
|
835
957
|
for (const difference of others) if (!caused.has(difference)) told.push(tell(moment, difference, along.get(difference)));
|
|
@@ -1047,6 +1169,20 @@ function layoutShifts(moves, carried) {
|
|
|
1047
1169
|
loose: all.filter((shift) => counted(shift.moves) < MANY).flatMap((shift) => shift.moves)
|
|
1048
1170
|
};
|
|
1049
1171
|
}
|
|
1172
|
+
function madeRoom(loose, others, caused, along) {
|
|
1173
|
+
const candidates = others.filter((difference) => !caused.has(difference));
|
|
1174
|
+
return loose.filter((move) => {
|
|
1175
|
+
const by = movedBy(move);
|
|
1176
|
+
if (!near(by.x, 0) || loose.some((other) => Math.sign(movedBy(other).y) === -Math.sign(by.y))) return true;
|
|
1177
|
+
const cause = causeOf(spanOf([move.now]), {
|
|
1178
|
+
vertical: true,
|
|
1179
|
+
by: by.y,
|
|
1180
|
+
moving: true
|
|
1181
|
+
}, candidates);
|
|
1182
|
+
if (cause) addAlong(along, cause, "pushed", [move.now], wayOf(0, by.y));
|
|
1183
|
+
return !cause;
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1050
1186
|
function swappedOrMoved(moment, loose) {
|
|
1051
1187
|
const told = [];
|
|
1052
1188
|
for (const [holder, moves] of groupBy(loose, (move) => holderOf(moment.now, move.now))) {
|
|
@@ -1246,6 +1382,7 @@ function tell(moment, difference, along = {}) {
|
|
|
1246
1382
|
thing: difference.now,
|
|
1247
1383
|
in: now,
|
|
1248
1384
|
words: rewordedWords(difference.then, difference.now),
|
|
1385
|
+
reworded: rewordedPart(difference.then.words, difference.now.words),
|
|
1249
1386
|
...along
|
|
1250
1387
|
};
|
|
1251
1388
|
case "looks different": {
|
|
@@ -1305,7 +1442,11 @@ function said(told, moment, unsteady) {
|
|
|
1305
1442
|
words: alikeWords(alike),
|
|
1306
1443
|
...placeOf(alike[0], now),
|
|
1307
1444
|
...alikeOf(alike, now),
|
|
1308
|
-
pieces: alike.flatMap(piecesOf)
|
|
1445
|
+
pieces: alike.flatMap(piecesOf),
|
|
1446
|
+
...alike[0].reworded && { reworded: alike[0].reworded },
|
|
1447
|
+
...alike[0].in !== now && { gone: true },
|
|
1448
|
+
...offScreen(alike),
|
|
1449
|
+
top: topOf(alike[0], now)
|
|
1309
1450
|
});
|
|
1310
1451
|
continue;
|
|
1311
1452
|
}
|
|
@@ -1321,11 +1462,15 @@ function said(told, moment, unsteady) {
|
|
|
1321
1462
|
id: id(changed),
|
|
1322
1463
|
words: sentence(words),
|
|
1323
1464
|
...placeOf(changed, now),
|
|
1324
|
-
pieces: all.flat().flatMap(piecesOf)
|
|
1465
|
+
pieces: all.flat().flatMap(piecesOf),
|
|
1466
|
+
...offScreen(all.flat()),
|
|
1467
|
+
top: topOf(changed, now)
|
|
1325
1468
|
});
|
|
1326
1469
|
}
|
|
1327
1470
|
return found;
|
|
1328
1471
|
}
|
|
1472
|
+
const topOf = (told, now) => told.in === now ? told.thing.box.y / now.screen.height : 2;
|
|
1473
|
+
const offScreen = (told) => told.some((one) => onScreen(one.thing.box, one.in.screen)) ? {} : { offScreen: true };
|
|
1329
1474
|
function identityOf({ in: reading, thing, what }, moment) {
|
|
1330
1475
|
const item = thing.item === void 0 ? void 0 : reading.items?.[thing.item];
|
|
1331
1476
|
if (!item) return identity(reading, thing);
|
|
@@ -1411,6 +1556,7 @@ function asItems(told, moment, id) {
|
|
|
1411
1556
|
...whole,
|
|
1412
1557
|
what: "reworded",
|
|
1413
1558
|
words: reworded,
|
|
1559
|
+
reworded: rewordedPart(before.words, item.words),
|
|
1414
1560
|
pieces: words.flatMap((one) => one.pieces ?? [id(one)]),
|
|
1415
1561
|
key: `${item.role} of ${listOf(now, item)}: ${shapeOf(before.words, item.words)}`
|
|
1416
1562
|
}, ...all.filter((one) => one.thing.role !== "text"));
|
|
@@ -1576,7 +1722,8 @@ async function picturesAt(page, then, now, pictures) {
|
|
|
1576
1722
|
id,
|
|
1577
1723
|
words: sentence(words),
|
|
1578
1724
|
...box && { box },
|
|
1579
|
-
pieces: [here ? `${HERE}${id}` : id]
|
|
1725
|
+
pieces: [here ? `${HERE}${id}` : id],
|
|
1726
|
+
top: patch.y / now.screen.height
|
|
1580
1727
|
};
|
|
1581
1728
|
});
|
|
1582
1729
|
}
|
|
@@ -1827,7 +1974,7 @@ function unsteadyIn(flows) {
|
|
|
1827
1974
|
async function comparing(page, earlier, round, unsteady = () => /* @__PURE__ */ new Set()) {
|
|
1828
1975
|
const changes = /* @__PURE__ */ new Map();
|
|
1829
1976
|
const pieces = /* @__PURE__ */ new Map();
|
|
1830
|
-
const see = ({ id, words, place, of = [id], pictureOnly }) => {
|
|
1977
|
+
const see = ({ id, words, place, of = [id], reworded, gone, pictureOnly }) => {
|
|
1831
1978
|
if (unsteady(place.flow).has(id)) return;
|
|
1832
1979
|
const change = changes.get(id);
|
|
1833
1980
|
for (const piece of of) (pieces.get(id) ?? pieces.set(id, /* @__PURE__ */ new Set()).get(id)).add(piece);
|
|
@@ -1835,6 +1982,8 @@ async function comparing(page, earlier, round, unsteady = () => /* @__PURE__ */
|
|
|
1835
1982
|
id,
|
|
1836
1983
|
words,
|
|
1837
1984
|
seen: [place],
|
|
1985
|
+
...reworded && { reworded },
|
|
1986
|
+
...gone && { gone },
|
|
1838
1987
|
...pictureOnly && { pictureOnly }
|
|
1839
1988
|
});
|
|
1840
1989
|
else {
|
|
@@ -1879,7 +2028,7 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
|
|
|
1879
2028
|
now: around(glimpsed.now, flow, reading.step)
|
|
1880
2029
|
};
|
|
1881
2030
|
const pictured = await picturesAt(page, was, reading, pictures);
|
|
1882
|
-
for (const found of [...changesAt(was, reading, unsteady, shown), ...pictured]) {
|
|
2031
|
+
for (const found of [...changesAt(was, reading, unsteady, shown), ...pictured].sort((a, b) => (a.top ?? 0) - (b.top ?? 0))) {
|
|
1883
2032
|
const on = found.element ? {
|
|
1884
2033
|
element: found.element,
|
|
1885
2034
|
...found.alike && { alike: found.alike }
|
|
@@ -1893,11 +2042,14 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
|
|
|
1893
2042
|
...on
|
|
1894
2043
|
},
|
|
1895
2044
|
of: found.pieces,
|
|
1896
|
-
...
|
|
2045
|
+
...found.reworded && { reworded: found.reworded },
|
|
2046
|
+
...found.gone && { gone: true },
|
|
2047
|
+
...pictured.includes(found) && { pictureOnly: true },
|
|
2048
|
+
...found.offScreen && { offScreen: true }
|
|
1897
2049
|
});
|
|
1898
2050
|
}
|
|
1899
2051
|
}
|
|
1900
|
-
return sightings.sort((a, b) => (a.place.at ?? 0) - (b.place.at ?? 0));
|
|
2052
|
+
return sightings.sort((a, b) => Number(!!a.offScreen) - Number(!!b.offScreen) || (a.place.at ?? 0) - (b.place.at ?? 0));
|
|
1901
2053
|
}
|
|
1902
2054
|
function around(glimpses, flow, step) {
|
|
1903
2055
|
const next = flow.steps[flow.steps.findIndex((kept) => kept.title === step) + 1]?.title;
|
|
@@ -1962,7 +2114,7 @@ async function trySetup(repo, again) {
|
|
|
1962
2114
|
const app = await repo.app();
|
|
1963
2115
|
const written = await writtenNow(repo);
|
|
1964
2116
|
const last = join(repo.tried, "tried.json");
|
|
1965
|
-
if (await readFile(last, "utf8").then((text) => JSON.parse(text).written
|
|
2117
|
+
if (await readFile(last, "utf8").then((text) => JSON.parse(text).written).catch(() => void 0) === written) return;
|
|
1966
2118
|
console.log("Trying the app's setup by itself first, twice, as every flow will start…");
|
|
1967
2119
|
const browser = await chromium.launch().catch((error) => {
|
|
1968
2120
|
const { version } = createRequire(import.meta.url)("@playwright/test/package.json");
|
|
@@ -2075,6 +2227,7 @@ async function writtenNow(repo) {
|
|
|
2075
2227
|
return createHash("sha256").update(await text(repo.appFile)).update("\0").update(await text(repo.setup)).digest("hex");
|
|
2076
2228
|
}
|
|
2077
2229
|
// src/flows.ts
|
|
2230
|
+
const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
|
|
2078
2231
|
const WALKED = "walked.json";
|
|
2079
2232
|
const ALONGSIDE = Symbol.for("playthrough: flows taken over here");
|
|
2080
2233
|
const REACHES_OUTSIDE = "reaches outside";
|
|
@@ -2117,7 +2270,6 @@ async function runFlows(repo, id, round, { before, first = () => false }) {
|
|
|
2117
2270
|
const app = await repo.app();
|
|
2118
2271
|
if (!before) await trySetup(repo, `playthrough record ${id}`);
|
|
2119
2272
|
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
2273
|
const longest = (rounds.filter((kept) => kept.number < round).at(-1)?.flows ?? []).toSorted((a, b) => b.length - a.length).map((flow) => flow.name);
|
|
2122
2274
|
const run = (only, results) => runOnce({
|
|
2123
2275
|
flows,
|
|
@@ -2198,7 +2350,7 @@ async function keepFailed(repo, id, failed) {
|
|
|
2198
2350
|
await mkdir(folder, { recursive: true });
|
|
2199
2351
|
await writeFile(join(folder, FAILED), JSON.stringify(failed.map(({ flow }) => flow)));
|
|
2200
2352
|
}
|
|
2201
|
-
const failedLastTime = (repo, id) => readFile(join(repo.failed(id), FAILED), "utf8").then(JSON.parse
|
|
2353
|
+
const failedLastTime = (repo, id) => readFile(join(repo.failed(id), FAILED), "utf8").then(JSON.parse).catch(() => []);
|
|
2202
2354
|
const FAILED = "flows.json";
|
|
2203
2355
|
const whereItStopped = (repo, id, failed) => `What the page held where ${failed.length > 1 ? "each" : "it"} stopped is in ${repo.show(repo.failed(id))}/.`;
|
|
2204
2356
|
function inMemory(number, recorded) {
|
|
@@ -2249,7 +2401,6 @@ async function runOnce(run) {
|
|
|
2249
2401
|
async function walkFork(repo, id, walk) {
|
|
2250
2402
|
const app = await repo.app();
|
|
2251
2403
|
const { started } = await repo.reviews.get(id);
|
|
2252
|
-
const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
|
|
2253
2404
|
const folder = await mkdtemp(join(tmpdir(), "playthrough-"));
|
|
2254
2405
|
try {
|
|
2255
2406
|
await playwright({
|
|
@@ -2318,4 +2469,4 @@ async function playwright(run) {
|
|
|
2318
2469
|
});
|
|
2319
2470
|
});
|
|
2320
2471
|
}
|
|
2321
|
-
export {
|
|
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 };
|