playthrough 0.5.0 → 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 +35 -6
- package/README.md +3 -1
- package/package.json +2 -2
- package/src/{flows-DDB6AdQ-.js → flows-CbrwgoHB.js} +159 -24
- package/src/playthrough.js +228 -149
- package/src/playwright.config.js +1 -1
- package/src/{reporter-Dr7dWwSf.js → reporter-DlS7qehW.js} +7 -1
- package/src/reporter.js +1 -1
- package/src/test.js +113 -76
- 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 +21 -16
- package/types/cli/src/repo.d.ts +1 -0
- package/types/cli/src/test.d.ts +21 -8
- package/types/page/src/found.d.ts +5 -0
- 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 +46 -0
- package/types/review/src/rules.d.ts +6 -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-DVlUrKEE.js +0 -194
- package/viewer/assets/index-DhzHaV6E.css +0 -2
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
|
|
@@ -22,17 +22,22 @@ 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
|
|
29
30
|
"words": its "on" is where Playthrough found the note's thing on this round, so leave it. Where the answer moves,
|
|
30
|
-
like an animation, add "watch": "<the title of the step it's seen in>".
|
|
31
|
-
person
|
|
32
|
-
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.
|
|
33
33
|
|
|
34
34
|
The person can only judge what the flows show, so keep them true to how a person uses the app.
|
|
35
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
|
+
|
|
36
41
|
THE APP
|
|
37
42
|
|
|
38
43
|
The first time, add playthrough to the app's devDependencies with the app's own package manager, and run
|
|
@@ -55,7 +60,8 @@ another at its address, takes the address it always has, or a copy on a port of
|
|
|
55
60
|
|
|
56
61
|
Flows run several at once. Where a page shows what every run made, as a blog's index lists every post, add
|
|
57
62
|
"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
|
|
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.
|
|
59
65
|
|
|
60
66
|
FLOWS
|
|
61
67
|
|
|
@@ -79,11 +85,34 @@ the page once it rests after each step. A dialog the browser shows is recorded,
|
|
|
79
85
|
first: page.once("dialog", (dialog) => dialog.accept()). A flow that fails stops the round: record names it and the
|
|
80
86
|
step, and keeps the page it failed on in .playthrough/<review>/failed/.
|
|
81
87
|
|
|
88
|
+
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
|
|
89
|
+
doesn't have it, so Before stops short there and your change reads as new steps. Playthrough finds what changed, and
|
|
90
|
+
shows each change when the recording first has it on the screen. A page people read, like a landing page or docs, is a
|
|
91
|
+
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
|
|
92
|
+
many as the longer page needs.
|
|
93
|
+
|
|
82
94
|
While the person watches, Playthrough runs the flow again in the background, so taking it over is quick. If its steps do
|
|
83
95
|
something outside the app that mustn't happen again, like sending an email, say so, and it runs only when taken over:
|
|
84
96
|
|
|
85
97
|
test("Invite Sam", { annotation: { type: "reaches outside", description: "sends Sam an email" } }, async ({ page }) => {
|
|
86
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
|
+
|
|
87
116
|
WHAT EVERY FLOW STARTS FROM
|
|
88
117
|
|
|
89
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
|
@@ -4,6 +4,8 @@ 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
|
|
|
9
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>
|
|
@@ -20,7 +22,7 @@ The agent does the rest. The first time, it adds Playthrough to your app and wri
|
|
|
20
22
|
npx playthrough open
|
|
21
23
|
```
|
|
22
24
|
|
|
23
|
-
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.
|
|
24
26
|
|
|
25
27
|
You can teach your agent Playthrough once, so “Show me this change” is enough from then on:
|
|
26
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,11 +1,11 @@
|
|
|
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
|
-
import { existsSync, watch } from "node:fs";
|
|
4
|
+
import { existsSync, readFileSync, watch, writeFileSync } from "node:fs";
|
|
5
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";
|
|
8
|
+
import { constants, tmpdir, uptime } from "node:os";
|
|
9
9
|
import { dirname, join, relative, resolve } from "node:path";
|
|
10
10
|
import { createServer } from "node:net";
|
|
11
11
|
import { chromium, request } from "@playwright/test";
|
|
@@ -69,6 +69,30 @@ function pausedAt(flow, at) {
|
|
|
69
69
|
actions: 1
|
|
70
70
|
};
|
|
71
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
|
+
}
|
|
72
96
|
// ../review/src/folder.ts
|
|
73
97
|
const LOOK_EVERY = 2e3;
|
|
74
98
|
function folder(root) {
|
|
@@ -123,6 +147,7 @@ function repo(root = home(process.cwd())) {
|
|
|
123
147
|
round: (review, round) => join(reviews, review, "rounds", String(round), "round.json"),
|
|
124
148
|
ranOnBefore: (review) => join(reviews, review, "ran-on-before.json"),
|
|
125
149
|
setAside: join(reviews, "set-aside.json"),
|
|
150
|
+
turn: join(reviews, "turn.json"),
|
|
126
151
|
appFile,
|
|
127
152
|
setup: join(root, "playthrough", "setup.ts"),
|
|
128
153
|
borrowed: join(reviews, "borrowed"),
|
|
@@ -214,7 +239,9 @@ async function setupIn(file, written) {
|
|
|
214
239
|
if (!existsSync(file)) return {};
|
|
215
240
|
const href = pathToFileURL(file).href;
|
|
216
241
|
asModule(href);
|
|
217
|
-
const module = await import(`${href}${written ? `?${written}` : ""}`)
|
|
242
|
+
const module = await import(`${href}${written ? `?${written}` : ""}`).catch((error) => {
|
|
243
|
+
throw twoPlaywrights(error, file) ?? error;
|
|
244
|
+
});
|
|
218
245
|
const inner = typeof module.default === "object" ? module.default : void 0;
|
|
219
246
|
const use = module.use ?? inner?.use;
|
|
220
247
|
const ours = Object.keys(use ?? {}).filter((option) => OURS.includes(option));
|
|
@@ -227,6 +254,16 @@ async function setupIn(file, written) {
|
|
|
227
254
|
...use && { use }
|
|
228
255
|
};
|
|
229
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
|
+
}
|
|
230
267
|
const setups = /* @__PURE__ */ new Set();
|
|
231
268
|
function asModule(href) {
|
|
232
269
|
if (!setups.size) modules.registerHooks({ load: (url, context, next) => next(url, setups.has(url.split("?")[0]) ? {
|
|
@@ -324,7 +361,7 @@ function alive(pid) {
|
|
|
324
361
|
// src/app.ts
|
|
325
362
|
const started = /* @__PURE__ */ new Map();
|
|
326
363
|
let ending = false;
|
|
327
|
-
async function keepRunning(app, root) {
|
|
364
|
+
async function keepRunning(app, root, { ready = false } = {}) {
|
|
328
365
|
if (!ending) {
|
|
329
366
|
ending = true;
|
|
330
367
|
const { setup, borrowed } = repo(root);
|
|
@@ -337,6 +374,7 @@ async function keepRunning(app, root) {
|
|
|
337
374
|
]) process.once(signal, end);
|
|
338
375
|
process.once("exit", () => [...started.keys()].forEach(stop));
|
|
339
376
|
}
|
|
377
|
+
await takeTurns(root, ready);
|
|
340
378
|
const ours = started.get(app.url);
|
|
341
379
|
if (ours && ours.root !== root) await stopApp(app.url);
|
|
342
380
|
if (await serves(app.url)) {
|
|
@@ -359,6 +397,12 @@ async function keepRunning(app, root) {
|
|
|
359
397
|
app: running,
|
|
360
398
|
root
|
|
361
399
|
});
|
|
400
|
+
if (ready) writeFileSync(repo(root).turn, JSON.stringify({
|
|
401
|
+
...thisCommand(),
|
|
402
|
+
ready,
|
|
403
|
+
app: running.pid,
|
|
404
|
+
url: app.url
|
|
405
|
+
}));
|
|
362
406
|
const said = [];
|
|
363
407
|
for (const output of [running.stdout, running.stderr]) {
|
|
364
408
|
output.on("data", (chunk) => {
|
|
@@ -387,6 +431,29 @@ async function keepRunning(app, root) {
|
|
|
387
431
|
"Check that playthrough/app.json starts it there."
|
|
388
432
|
]);
|
|
389
433
|
}
|
|
434
|
+
async function takeTurns(root, ready) {
|
|
435
|
+
const here = repo(root);
|
|
436
|
+
let turn;
|
|
437
|
+
try {
|
|
438
|
+
turn = JSON.parse(readFileSync(here.turn, "utf8"));
|
|
439
|
+
} catch {}
|
|
440
|
+
const theirs = turn && turn.by !== process.pid && stillAt(turn) ? turn : void 0;
|
|
441
|
+
if (ready) {
|
|
442
|
+
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.`]);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
await here.keepOutOfGit();
|
|
446
|
+
writeFileSync(here.turn, JSON.stringify({
|
|
447
|
+
...thisCommand(),
|
|
448
|
+
ready
|
|
449
|
+
}));
|
|
450
|
+
if (!theirs?.ready) return;
|
|
451
|
+
try {
|
|
452
|
+
process.kill(-theirs.app);
|
|
453
|
+
} catch {}
|
|
454
|
+
const began = Date.now();
|
|
455
|
+
while (Date.now() - began < 1e4 && await serves(theirs.url)) await new Promise((resolve) => setTimeout(resolve, 250));
|
|
456
|
+
}
|
|
390
457
|
async function stopApp(url) {
|
|
391
458
|
if (!stop(url)) return;
|
|
392
459
|
const began = Date.now();
|
|
@@ -483,6 +550,17 @@ function rewordedWords(a, b) {
|
|
|
483
550
|
};
|
|
484
551
|
return `“${part(a.words)}” now says “${part(b.words)}”`;
|
|
485
552
|
}
|
|
553
|
+
function rewordedPart(a, b) {
|
|
554
|
+
const letter = /[\p{L}\p{N}’']/u;
|
|
555
|
+
let start = 0;
|
|
556
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
557
|
+
let end = 0;
|
|
558
|
+
while (end < a.length - start && end < b.length - start && a.at(-1 - end) === b.at(-1 - end)) end++;
|
|
559
|
+
let to = b.length - end;
|
|
560
|
+
while (start > 0 && letter.test(b[start - 1]) && letter.test(b[start] ?? "")) start--;
|
|
561
|
+
while (to < b.length && to > 0 && letter.test(b[to - 1]) && letter.test(b[to])) to++;
|
|
562
|
+
return b.slice(start, to).trim() || void 0;
|
|
563
|
+
}
|
|
486
564
|
function lookWords(a, b) {
|
|
487
565
|
const parts = [];
|
|
488
566
|
if (a.look.background !== b.look.background) parts.push("background");
|
|
@@ -599,7 +677,7 @@ function differences(then, now) {
|
|
|
599
677
|
});
|
|
600
678
|
continue;
|
|
601
679
|
}
|
|
602
|
-
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));
|
|
680
|
+
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));
|
|
603
681
|
if (!a) {
|
|
604
682
|
found.push({
|
|
605
683
|
what: "new",
|
|
@@ -759,6 +837,14 @@ function gained(reading, control, was) {
|
|
|
759
837
|
}
|
|
760
838
|
const CONTROL = /^(button|link|textbox|searchbox|combobox|checkbox|radio|switch|slider|tab|menuitem|option)$/;
|
|
761
839
|
const holdsControls = (reading, thing) => reading.things.some((inside) => parentOf(reading, inside) === thing && CONTROL.test(inside.role));
|
|
840
|
+
function kept$1(a, b) {
|
|
841
|
+
if (a.length < 24 || a === b) return false;
|
|
842
|
+
let start = 0;
|
|
843
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
844
|
+
let end = 0;
|
|
845
|
+
while (end < a.length - start && end < b.length - start && a.at(-1 - end) === b.at(-1 - end)) end++;
|
|
846
|
+
return (start + end) / a.length >= 2 / 3;
|
|
847
|
+
}
|
|
762
848
|
const mostlyWhereItWas = (a, b) => HOLDS.test(b.role) && overlap(a, b) > .5;
|
|
763
849
|
const holdsTheSameWords = (a, b) => /^(box|menu)$/.test(b.role) && !!a.wordsInside && bag(a) === bag(b);
|
|
764
850
|
const bag = (thing) => (thing.wordsInside ?? "").split(" ").sort().join(" ");
|
|
@@ -854,6 +940,26 @@ const elementOf$1 = (thing, name) => ({
|
|
|
854
940
|
name: thing.name || name,
|
|
855
941
|
...thing.node !== void 0 && { node: thing.node }
|
|
856
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
|
+
}
|
|
857
963
|
// src/changes.ts
|
|
858
964
|
const TABLES = /^(table|grid|treegrid|listbox)$/;
|
|
859
965
|
const REFLOWS = /^(dialog|alertdialog|status|alert|menu|box)$/;
|
|
@@ -877,7 +983,7 @@ function changesAt(then, now, unsteady = /* @__PURE__ */ new Set(), shown = {
|
|
|
877
983
|
const told = [
|
|
878
984
|
...items.reordered,
|
|
879
985
|
...reordered,
|
|
880
|
-
...swappedOrMoved(moment, loose),
|
|
986
|
+
...swappedOrMoved(moment, madeRoom(loose, others, caused, along)),
|
|
881
987
|
...causesOfShifts(moment, shifts, widened, others, caused, along)
|
|
882
988
|
];
|
|
883
989
|
for (const difference of others) if (!caused.has(difference)) told.push(tell(moment, difference, along.get(difference)));
|
|
@@ -1095,6 +1201,20 @@ function layoutShifts(moves, carried) {
|
|
|
1095
1201
|
loose: all.filter((shift) => counted(shift.moves) < MANY).flatMap((shift) => shift.moves)
|
|
1096
1202
|
};
|
|
1097
1203
|
}
|
|
1204
|
+
function madeRoom(loose, others, caused, along) {
|
|
1205
|
+
const candidates = others.filter((difference) => !caused.has(difference));
|
|
1206
|
+
return loose.filter((move) => {
|
|
1207
|
+
const by = movedBy(move);
|
|
1208
|
+
if (!near(by.x, 0) || loose.some((other) => Math.sign(movedBy(other).y) === -Math.sign(by.y))) return true;
|
|
1209
|
+
const cause = causeOf(spanOf([move.now]), {
|
|
1210
|
+
vertical: true,
|
|
1211
|
+
by: by.y,
|
|
1212
|
+
moving: true
|
|
1213
|
+
}, candidates);
|
|
1214
|
+
if (cause) addAlong(along, cause, "pushed", [move.now], wayOf(0, by.y));
|
|
1215
|
+
return !cause;
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1098
1218
|
function swappedOrMoved(moment, loose) {
|
|
1099
1219
|
const told = [];
|
|
1100
1220
|
for (const [holder, moves] of groupBy(loose, (move) => holderOf(moment.now, move.now))) {
|
|
@@ -1294,6 +1414,7 @@ function tell(moment, difference, along = {}) {
|
|
|
1294
1414
|
thing: difference.now,
|
|
1295
1415
|
in: now,
|
|
1296
1416
|
words: rewordedWords(difference.then, difference.now),
|
|
1417
|
+
reworded: rewordedPart(difference.then.words, difference.now.words),
|
|
1297
1418
|
...along
|
|
1298
1419
|
};
|
|
1299
1420
|
case "looks different": {
|
|
@@ -1353,7 +1474,11 @@ function said(told, moment, unsteady) {
|
|
|
1353
1474
|
words: alikeWords(alike),
|
|
1354
1475
|
...placeOf(alike[0], now),
|
|
1355
1476
|
...alikeOf(alike, now),
|
|
1356
|
-
pieces: alike.flatMap(piecesOf)
|
|
1477
|
+
pieces: alike.flatMap(piecesOf),
|
|
1478
|
+
...alike[0].reworded && { reworded: alike[0].reworded },
|
|
1479
|
+
...alike[0].in !== now && { gone: true },
|
|
1480
|
+
...offScreen(alike),
|
|
1481
|
+
top: topOf(alike[0], now)
|
|
1357
1482
|
});
|
|
1358
1483
|
continue;
|
|
1359
1484
|
}
|
|
@@ -1369,11 +1494,15 @@ function said(told, moment, unsteady) {
|
|
|
1369
1494
|
id: id(changed),
|
|
1370
1495
|
words: sentence(words),
|
|
1371
1496
|
...placeOf(changed, now),
|
|
1372
|
-
pieces: all.flat().flatMap(piecesOf)
|
|
1497
|
+
pieces: all.flat().flatMap(piecesOf),
|
|
1498
|
+
...offScreen(all.flat()),
|
|
1499
|
+
top: topOf(changed, now)
|
|
1373
1500
|
});
|
|
1374
1501
|
}
|
|
1375
1502
|
return found;
|
|
1376
1503
|
}
|
|
1504
|
+
const topOf = (told, now) => told.in === now ? told.thing.box.y / now.screen.height : 2;
|
|
1505
|
+
const offScreen = (told) => told.some((one) => onScreen(one.thing.box, one.in.screen)) ? {} : { offScreen: true };
|
|
1377
1506
|
function identityOf({ in: reading, thing, what }, moment) {
|
|
1378
1507
|
const item = thing.item === void 0 ? void 0 : reading.items?.[thing.item];
|
|
1379
1508
|
if (!item) return identity(reading, thing);
|
|
@@ -1459,6 +1588,7 @@ function asItems(told, moment, id) {
|
|
|
1459
1588
|
...whole,
|
|
1460
1589
|
what: "reworded",
|
|
1461
1590
|
words: reworded,
|
|
1591
|
+
reworded: rewordedPart(before.words, item.words),
|
|
1462
1592
|
pieces: words.flatMap((one) => one.pieces ?? [id(one)]),
|
|
1463
1593
|
key: `${item.role} of ${listOf(now, item)}: ${shapeOf(before.words, item.words)}`
|
|
1464
1594
|
}, ...all.filter((one) => one.thing.role !== "text"));
|
|
@@ -1624,7 +1754,8 @@ async function picturesAt(page, then, now, pictures) {
|
|
|
1624
1754
|
id,
|
|
1625
1755
|
words: sentence(words),
|
|
1626
1756
|
...box && { box },
|
|
1627
|
-
pieces: [here ? `${HERE}${id}` : id]
|
|
1757
|
+
pieces: [here ? `${HERE}${id}` : id],
|
|
1758
|
+
top: patch.y / now.screen.height
|
|
1628
1759
|
};
|
|
1629
1760
|
});
|
|
1630
1761
|
}
|
|
@@ -1875,7 +2006,7 @@ function unsteadyIn(flows) {
|
|
|
1875
2006
|
async function comparing(page, earlier, round, unsteady = () => /* @__PURE__ */ new Set()) {
|
|
1876
2007
|
const changes = /* @__PURE__ */ new Map();
|
|
1877
2008
|
const pieces = /* @__PURE__ */ new Map();
|
|
1878
|
-
const see = ({ id, words, place, of = [id], pictureOnly }) => {
|
|
2009
|
+
const see = ({ id, words, place, of = [id], reworded, gone, pictureOnly }) => {
|
|
1879
2010
|
if (unsteady(place.flow).has(id)) return;
|
|
1880
2011
|
const change = changes.get(id);
|
|
1881
2012
|
for (const piece of of) (pieces.get(id) ?? pieces.set(id, /* @__PURE__ */ new Set()).get(id)).add(piece);
|
|
@@ -1883,6 +2014,8 @@ async function comparing(page, earlier, round, unsteady = () => /* @__PURE__ */
|
|
|
1883
2014
|
id,
|
|
1884
2015
|
words,
|
|
1885
2016
|
seen: [place],
|
|
2017
|
+
...reworded && { reworded },
|
|
2018
|
+
...gone && { gone },
|
|
1886
2019
|
...pictureOnly && { pictureOnly }
|
|
1887
2020
|
});
|
|
1888
2021
|
else {
|
|
@@ -1927,7 +2060,7 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
|
|
|
1927
2060
|
now: around(glimpsed.now, flow, reading.step)
|
|
1928
2061
|
};
|
|
1929
2062
|
const pictured = await picturesAt(page, was, reading, pictures);
|
|
1930
|
-
for (const found of [...changesAt(was, reading, unsteady, shown), ...pictured]) {
|
|
2063
|
+
for (const found of [...changesAt(was, reading, unsteady, shown), ...pictured].sort((a, b) => (a.top ?? 0) - (b.top ?? 0))) {
|
|
1931
2064
|
const on = found.element ? {
|
|
1932
2065
|
element: found.element,
|
|
1933
2066
|
...found.alike && { alike: found.alike }
|
|
@@ -1941,11 +2074,14 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
|
|
|
1941
2074
|
...on
|
|
1942
2075
|
},
|
|
1943
2076
|
of: found.pieces,
|
|
1944
|
-
...
|
|
2077
|
+
...found.reworded && { reworded: found.reworded },
|
|
2078
|
+
...found.gone && { gone: true },
|
|
2079
|
+
...pictured.includes(found) && { pictureOnly: true },
|
|
2080
|
+
...found.offScreen && { offScreen: true }
|
|
1945
2081
|
});
|
|
1946
2082
|
}
|
|
1947
2083
|
}
|
|
1948
|
-
return sightings.sort((a, b) => (a.place.at ?? 0) - (b.place.at ?? 0));
|
|
2084
|
+
return sightings.sort((a, b) => Number(!!a.offScreen) - Number(!!b.offScreen) || (a.place.at ?? 0) - (b.place.at ?? 0));
|
|
1949
2085
|
}
|
|
1950
2086
|
function around(glimpses, flow, step) {
|
|
1951
2087
|
const next = flow.steps[flow.steps.findIndex((kept) => kept.title === step) + 1]?.title;
|
|
@@ -2124,7 +2260,7 @@ async function writtenNow(repo) {
|
|
|
2124
2260
|
}
|
|
2125
2261
|
// src/flows.ts
|
|
2126
2262
|
const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
|
|
2127
|
-
const
|
|
2263
|
+
const FORK_RAN = "fork.json";
|
|
2128
2264
|
const ALONGSIDE = Symbol.for("playthrough: flows taken over here");
|
|
2129
2265
|
const REACHES_OUTSIDE = "reaches outside";
|
|
2130
2266
|
const waitsFor = ({ before }) => before ? 3e3 : 1e4;
|
|
@@ -2294,7 +2430,7 @@ async function runOnce(run) {
|
|
|
2294
2430
|
await rm(folder, { recursive: true });
|
|
2295
2431
|
}
|
|
2296
2432
|
}
|
|
2297
|
-
async function
|
|
2433
|
+
async function runFork(repo, id, fork) {
|
|
2298
2434
|
const app = await repo.app();
|
|
2299
2435
|
const { started } = await repo.reviews.get(id);
|
|
2300
2436
|
const folder = await mkdtemp(join(tmpdir(), "playthrough-"));
|
|
@@ -2308,20 +2444,19 @@ async function walkFork(repo, id, walk) {
|
|
|
2308
2444
|
before: false,
|
|
2309
2445
|
taken: [],
|
|
2310
2446
|
started,
|
|
2311
|
-
|
|
2447
|
+
fork
|
|
2312
2448
|
});
|
|
2313
|
-
const text = await readFile(join(folder,
|
|
2449
|
+
const text = await readFile(join(folder, FORK_RAN), "utf8").catch(() => void 0);
|
|
2314
2450
|
if (!text) return void 0;
|
|
2315
|
-
const
|
|
2316
|
-
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) => ({
|
|
2317
2453
|
name,
|
|
2318
2454
|
bytes: await readFile(join(folder, name))
|
|
2319
2455
|
})));
|
|
2320
|
-
const pictures = await Promise.all(walk.look.map((_, i) => readFile(join(folder, `look-${i}.png`)).catch(() => void 0)));
|
|
2321
2456
|
return {
|
|
2322
|
-
...
|
|
2457
|
+
...ran,
|
|
2323
2458
|
files,
|
|
2324
|
-
|
|
2459
|
+
picture: await readFile(join(folder, "end.png")).catch(() => void 0)
|
|
2325
2460
|
};
|
|
2326
2461
|
} finally {
|
|
2327
2462
|
await rm(folder, {
|
|
@@ -2365,4 +2500,4 @@ async function playwright(run) {
|
|
|
2365
2500
|
});
|
|
2366
2501
|
});
|
|
2367
2502
|
}
|
|
2368
|
-
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 };
|