playthrough 0.5.0 → 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 +8 -1
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/{flows-DDB6AdQ-.js → flows-vi0amwSo.js} +118 -14
- package/src/playthrough.js +85 -67
- package/src/playwright.config.js +1 -1
- package/src/{reporter-Dr7dWwSf.js → reporter-S9fOk0za.js} +2 -0
- 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/repo.d.ts +1 -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 +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-DVlUrKEE.js +0 -194
- package/viewer/assets/index-DhzHaV6E.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
|
|
@@ -79,6 +80,12 @@ the page once it rests after each step. A dialog the browser shows is recorded,
|
|
|
79
80
|
first: page.once("dialog", (dialog) => dialog.accept()). A flow that fails stops the round: record names it and the
|
|
80
81
|
step, and keeps the page it failed on in .playthrough/<review>/failed/.
|
|
81
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
|
+
|
|
82
89
|
While the person watches, Playthrough runs the flow again in the background, so taking it over is quick. If its steps do
|
|
83
90
|
something outside the app that mustn't happen again, like sending an email, say so, and it runs only when taken over:
|
|
84
91
|
|
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>
|
package/package.json
CHANGED
|
@@ -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-S9fOk0za.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"),
|
|
@@ -324,7 +349,7 @@ function alive(pid) {
|
|
|
324
349
|
// src/app.ts
|
|
325
350
|
const started = /* @__PURE__ */ new Map();
|
|
326
351
|
let ending = false;
|
|
327
|
-
async function keepRunning(app, root) {
|
|
352
|
+
async function keepRunning(app, root, { ready = false } = {}) {
|
|
328
353
|
if (!ending) {
|
|
329
354
|
ending = true;
|
|
330
355
|
const { setup, borrowed } = repo(root);
|
|
@@ -337,6 +362,7 @@ async function keepRunning(app, root) {
|
|
|
337
362
|
]) process.once(signal, end);
|
|
338
363
|
process.once("exit", () => [...started.keys()].forEach(stop));
|
|
339
364
|
}
|
|
365
|
+
await takeTurns(root, ready);
|
|
340
366
|
const ours = started.get(app.url);
|
|
341
367
|
if (ours && ours.root !== root) await stopApp(app.url);
|
|
342
368
|
if (await serves(app.url)) {
|
|
@@ -359,6 +385,12 @@ async function keepRunning(app, root) {
|
|
|
359
385
|
app: running,
|
|
360
386
|
root
|
|
361
387
|
});
|
|
388
|
+
if (ready) writeFileSync(repo(root).turn, JSON.stringify({
|
|
389
|
+
...thisCommand(),
|
|
390
|
+
ready,
|
|
391
|
+
app: running.pid,
|
|
392
|
+
url: app.url
|
|
393
|
+
}));
|
|
362
394
|
const said = [];
|
|
363
395
|
for (const output of [running.stdout, running.stderr]) {
|
|
364
396
|
output.on("data", (chunk) => {
|
|
@@ -387,6 +419,29 @@ async function keepRunning(app, root) {
|
|
|
387
419
|
"Check that playthrough/app.json starts it there."
|
|
388
420
|
]);
|
|
389
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
|
+
}
|
|
390
445
|
async function stopApp(url) {
|
|
391
446
|
if (!stop(url)) return;
|
|
392
447
|
const began = Date.now();
|
|
@@ -483,6 +538,17 @@ function rewordedWords(a, b) {
|
|
|
483
538
|
};
|
|
484
539
|
return `“${part(a.words)}” now says “${part(b.words)}”`;
|
|
485
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
|
+
}
|
|
486
552
|
function lookWords(a, b) {
|
|
487
553
|
const parts = [];
|
|
488
554
|
if (a.look.background !== b.look.background) parts.push("background");
|
|
@@ -599,7 +665,7 @@ function differences(then, now) {
|
|
|
599
665
|
});
|
|
600
666
|
continue;
|
|
601
667
|
}
|
|
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));
|
|
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));
|
|
603
669
|
if (!a) {
|
|
604
670
|
found.push({
|
|
605
671
|
what: "new",
|
|
@@ -759,6 +825,14 @@ function gained(reading, control, was) {
|
|
|
759
825
|
}
|
|
760
826
|
const CONTROL = /^(button|link|textbox|searchbox|combobox|checkbox|radio|switch|slider|tab|menuitem|option)$/;
|
|
761
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
|
+
}
|
|
762
836
|
const mostlyWhereItWas = (a, b) => HOLDS.test(b.role) && overlap(a, b) > .5;
|
|
763
837
|
const holdsTheSameWords = (a, b) => /^(box|menu)$/.test(b.role) && !!a.wordsInside && bag(a) === bag(b);
|
|
764
838
|
const bag = (thing) => (thing.wordsInside ?? "").split(" ").sort().join(" ");
|
|
@@ -877,7 +951,7 @@ function changesAt(then, now, unsteady = /* @__PURE__ */ new Set(), shown = {
|
|
|
877
951
|
const told = [
|
|
878
952
|
...items.reordered,
|
|
879
953
|
...reordered,
|
|
880
|
-
...swappedOrMoved(moment, loose),
|
|
954
|
+
...swappedOrMoved(moment, madeRoom(loose, others, caused, along)),
|
|
881
955
|
...causesOfShifts(moment, shifts, widened, others, caused, along)
|
|
882
956
|
];
|
|
883
957
|
for (const difference of others) if (!caused.has(difference)) told.push(tell(moment, difference, along.get(difference)));
|
|
@@ -1095,6 +1169,20 @@ function layoutShifts(moves, carried) {
|
|
|
1095
1169
|
loose: all.filter((shift) => counted(shift.moves) < MANY).flatMap((shift) => shift.moves)
|
|
1096
1170
|
};
|
|
1097
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
|
+
}
|
|
1098
1186
|
function swappedOrMoved(moment, loose) {
|
|
1099
1187
|
const told = [];
|
|
1100
1188
|
for (const [holder, moves] of groupBy(loose, (move) => holderOf(moment.now, move.now))) {
|
|
@@ -1294,6 +1382,7 @@ function tell(moment, difference, along = {}) {
|
|
|
1294
1382
|
thing: difference.now,
|
|
1295
1383
|
in: now,
|
|
1296
1384
|
words: rewordedWords(difference.then, difference.now),
|
|
1385
|
+
reworded: rewordedPart(difference.then.words, difference.now.words),
|
|
1297
1386
|
...along
|
|
1298
1387
|
};
|
|
1299
1388
|
case "looks different": {
|
|
@@ -1353,7 +1442,11 @@ function said(told, moment, unsteady) {
|
|
|
1353
1442
|
words: alikeWords(alike),
|
|
1354
1443
|
...placeOf(alike[0], now),
|
|
1355
1444
|
...alikeOf(alike, now),
|
|
1356
|
-
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)
|
|
1357
1450
|
});
|
|
1358
1451
|
continue;
|
|
1359
1452
|
}
|
|
@@ -1369,11 +1462,15 @@ function said(told, moment, unsteady) {
|
|
|
1369
1462
|
id: id(changed),
|
|
1370
1463
|
words: sentence(words),
|
|
1371
1464
|
...placeOf(changed, now),
|
|
1372
|
-
pieces: all.flat().flatMap(piecesOf)
|
|
1465
|
+
pieces: all.flat().flatMap(piecesOf),
|
|
1466
|
+
...offScreen(all.flat()),
|
|
1467
|
+
top: topOf(changed, now)
|
|
1373
1468
|
});
|
|
1374
1469
|
}
|
|
1375
1470
|
return found;
|
|
1376
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 };
|
|
1377
1474
|
function identityOf({ in: reading, thing, what }, moment) {
|
|
1378
1475
|
const item = thing.item === void 0 ? void 0 : reading.items?.[thing.item];
|
|
1379
1476
|
if (!item) return identity(reading, thing);
|
|
@@ -1459,6 +1556,7 @@ function asItems(told, moment, id) {
|
|
|
1459
1556
|
...whole,
|
|
1460
1557
|
what: "reworded",
|
|
1461
1558
|
words: reworded,
|
|
1559
|
+
reworded: rewordedPart(before.words, item.words),
|
|
1462
1560
|
pieces: words.flatMap((one) => one.pieces ?? [id(one)]),
|
|
1463
1561
|
key: `${item.role} of ${listOf(now, item)}: ${shapeOf(before.words, item.words)}`
|
|
1464
1562
|
}, ...all.filter((one) => one.thing.role !== "text"));
|
|
@@ -1624,7 +1722,8 @@ async function picturesAt(page, then, now, pictures) {
|
|
|
1624
1722
|
id,
|
|
1625
1723
|
words: sentence(words),
|
|
1626
1724
|
...box && { box },
|
|
1627
|
-
pieces: [here ? `${HERE}${id}` : id]
|
|
1725
|
+
pieces: [here ? `${HERE}${id}` : id],
|
|
1726
|
+
top: patch.y / now.screen.height
|
|
1628
1727
|
};
|
|
1629
1728
|
});
|
|
1630
1729
|
}
|
|
@@ -1875,7 +1974,7 @@ function unsteadyIn(flows) {
|
|
|
1875
1974
|
async function comparing(page, earlier, round, unsteady = () => /* @__PURE__ */ new Set()) {
|
|
1876
1975
|
const changes = /* @__PURE__ */ new Map();
|
|
1877
1976
|
const pieces = /* @__PURE__ */ new Map();
|
|
1878
|
-
const see = ({ id, words, place, of = [id], pictureOnly }) => {
|
|
1977
|
+
const see = ({ id, words, place, of = [id], reworded, gone, pictureOnly }) => {
|
|
1879
1978
|
if (unsteady(place.flow).has(id)) return;
|
|
1880
1979
|
const change = changes.get(id);
|
|
1881
1980
|
for (const piece of of) (pieces.get(id) ?? pieces.set(id, /* @__PURE__ */ new Set()).get(id)).add(piece);
|
|
@@ -1883,6 +1982,8 @@ async function comparing(page, earlier, round, unsteady = () => /* @__PURE__ */
|
|
|
1883
1982
|
id,
|
|
1884
1983
|
words,
|
|
1885
1984
|
seen: [place],
|
|
1985
|
+
...reworded && { reworded },
|
|
1986
|
+
...gone && { gone },
|
|
1886
1987
|
...pictureOnly && { pictureOnly }
|
|
1887
1988
|
});
|
|
1888
1989
|
else {
|
|
@@ -1927,7 +2028,7 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
|
|
|
1927
2028
|
now: around(glimpsed.now, flow, reading.step)
|
|
1928
2029
|
};
|
|
1929
2030
|
const pictured = await picturesAt(page, was, reading, pictures);
|
|
1930
|
-
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))) {
|
|
1931
2032
|
const on = found.element ? {
|
|
1932
2033
|
element: found.element,
|
|
1933
2034
|
...found.alike && { alike: found.alike }
|
|
@@ -1941,11 +2042,14 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
|
|
|
1941
2042
|
...on
|
|
1942
2043
|
},
|
|
1943
2044
|
of: found.pieces,
|
|
1944
|
-
...
|
|
2045
|
+
...found.reworded && { reworded: found.reworded },
|
|
2046
|
+
...found.gone && { gone: true },
|
|
2047
|
+
...pictured.includes(found) && { pictureOnly: true },
|
|
2048
|
+
...found.offScreen && { offScreen: true }
|
|
1945
2049
|
});
|
|
1946
2050
|
}
|
|
1947
2051
|
}
|
|
1948
|
-
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));
|
|
1949
2053
|
}
|
|
1950
2054
|
function around(glimpses, flow, step) {
|
|
1951
2055
|
const next = flow.steps[flow.steps.findIndex((kept) => kept.title === step) + 1]?.title;
|
|
@@ -2365,4 +2469,4 @@ async function playwright(run) {
|
|
|
2365
2469
|
});
|
|
2366
2470
|
});
|
|
2367
2471
|
}
|
|
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,
|
|
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 };
|
package/src/playthrough.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env -S node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON
|
|
2
|
-
import { A as quote, C as inOrderJoined, D as roundInProgress, E as problemsFinishing, O as toAccountFor, S as conversation, T as notesToAnswer, g as readingsOn, j as time, k as list, p as firstLine, w as lastSeen, x as allNotes, y as Refused } from "./reporter-
|
|
3
|
-
import { A as repo, O as setupIn, S as stopApp, T as giveBackLeft, _ as compareWithEarlier, a as failedAt, b as keepRunning, c as isFlow, d as runFlows, g as writtenNow, h as trySetup, i as called, j as
|
|
2
|
+
import { A as quote, C as inOrderJoined, D as roundInProgress, E as problemsFinishing, O as toAccountFor, S as conversation, T as notesToAnswer, g as readingsOn, j as time, k as list, p as firstLine, w as lastSeen, x as allNotes, y as Refused } from "./reporter-S9fOk0za.js";
|
|
3
|
+
import { A as repo, M as thisCommand, N as pausedAt, O as setupIn, S as stopApp, T as giveBackLeft, _ as compareWithEarlier, a as failedAt, b as keepRunning, c as isFlow, d as runFlows, g as writtenNow, h as trySetup, i as called, j as stillAt, k as copies, l as makeFlowsFolder, m as whereItStopped, o as flowsOf, p as walkFork, s as howToWriteFlows, t as ALONGSIDE, u as recorder, v as placeIn, x as serves, y as shownAt } from "./flows-vi0amwSo.js";
|
|
4
4
|
import { t as read } from "./read-Bg9XVTtn.js";
|
|
5
5
|
import { copyFileSync, cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
7
|
import { readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
8
8
|
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
-
import { constants, networkInterfaces, tmpdir,
|
|
9
|
+
import { constants, networkInterfaces, tmpdir, userInfo } from "node:os";
|
|
10
10
|
import { basename, dirname, extname, join, normalize, resolve, sep } from "node:path";
|
|
11
11
|
import { connect } from "node:net";
|
|
12
12
|
import { chromium, request } from "@playwright/test";
|
|
@@ -18,6 +18,71 @@ if (major < 24) {
|
|
|
18
18
|
console.error(`Playthrough needs Node 24 or later, and this is Node ${process.versions.node}. Install Node 24, then run this again.`);
|
|
19
19
|
process.exit(1);
|
|
20
20
|
}
|
|
21
|
+
// ../page/src/scheme.ts
|
|
22
|
+
function asRecorded(text, recorded) {
|
|
23
|
+
const answers = {
|
|
24
|
+
"prefers-color-scheme": recorded.dark ? "dark" : "light",
|
|
25
|
+
"prefers-reduced-motion": recorded.still ? "reduce" : "no-preference"
|
|
26
|
+
};
|
|
27
|
+
return String(text).replace(/\(\s*(prefers-color-scheme|prefers-reduced-motion)\s*(?::\s*([a-z-]+)\s*)?\)/gi, (_, feature, value) => {
|
|
28
|
+
feature = feature.toLowerCase();
|
|
29
|
+
return (value ? value.toLowerCase() === answers[feature] : feature === "prefers-color-scheme" || recorded.still) ? "(min-width: 0px)" : "(max-width: 0px)";
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function answerAs(view, recorded) {
|
|
33
|
+
const page = view.document;
|
|
34
|
+
const as = (query) => asRecorded(query, recorded);
|
|
35
|
+
const matching = view.matchMedia;
|
|
36
|
+
view.matchMedia = (query) => matching.call(view, as(query));
|
|
37
|
+
const sheet = view.CSSStyleSheet.prototype;
|
|
38
|
+
for (const name of [
|
|
39
|
+
"insertRule",
|
|
40
|
+
"replace",
|
|
41
|
+
"replaceSync"
|
|
42
|
+
]) {
|
|
43
|
+
const real = sheet[name];
|
|
44
|
+
if (real) sheet[name] = function(text, ...rest) {
|
|
45
|
+
return real.call(this, as(text), ...rest);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const scheme = recorded.dark ? "dark" : "light";
|
|
49
|
+
const inScheme = (value) => /\blight\b/.test(value) && /\bdark\b/.test(value) ? scheme : value;
|
|
50
|
+
const rewrite = (rules) => {
|
|
51
|
+
for (const rule of rules) {
|
|
52
|
+
if (rule.media && as(rule.media.mediaText) !== rule.media.mediaText) rule.media.mediaText = as(rule.media.mediaText);
|
|
53
|
+
if (rule.style?.colorScheme && inScheme(rule.style.colorScheme) !== rule.style.colorScheme) rule.style.colorScheme = inScheme(rule.style.colorScheme);
|
|
54
|
+
if (rule.styleSheet) restyle(rule.styleSheet);
|
|
55
|
+
if (rule.cssRules) rewrite(rule.cssRules);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const restyle = (sheet) => {
|
|
59
|
+
try {
|
|
60
|
+
rewrite(sheet.cssRules);
|
|
61
|
+
} catch {}
|
|
62
|
+
};
|
|
63
|
+
const restyleAll = () => {
|
|
64
|
+
for (const sheet of page.styleSheets) restyle(sheet);
|
|
65
|
+
for (const sheet of page.adoptedStyleSheets ?? []) restyle(sheet);
|
|
66
|
+
for (const element of page.querySelectorAll("[media]")) {
|
|
67
|
+
const media = element.getAttribute("media");
|
|
68
|
+
if (as(media) !== media) element.setAttribute("media", as(media));
|
|
69
|
+
}
|
|
70
|
+
for (const meta of page.querySelectorAll("meta[name=\"color-scheme\"]")) {
|
|
71
|
+
const content = meta.getAttribute("content") ?? "";
|
|
72
|
+
if (inScheme(content) !== content) meta.setAttribute("content", inScheme(content));
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const styling = (node) => /^(style|link|source|meta)$/i.test(node?.nodeName ?? "");
|
|
76
|
+
const bringsStyle = (node) => styling(node) || !!node.querySelector?.("style, link, source, meta");
|
|
77
|
+
new view.MutationObserver((changes) => changes.some((change) => styling(change.target) || styling(change.target.parentNode) || [...change.addedNodes].some(bringsStyle)) && restyleAll()).observe(page, {
|
|
78
|
+
subtree: true,
|
|
79
|
+
childList: true,
|
|
80
|
+
characterData: true,
|
|
81
|
+
attributeFilter: ["media", "content"]
|
|
82
|
+
});
|
|
83
|
+
page.addEventListener("load", (event) => styling(event.target) && restyleAll(), true);
|
|
84
|
+
restyleAll();
|
|
85
|
+
}
|
|
21
86
|
// src/before.ts
|
|
22
87
|
function keepBefore(repo, id) {
|
|
23
88
|
if (!attempt(() => git(repo, ["rev-parse", "--is-inside-work-tree"]))) return void 0;
|
|
@@ -166,8 +231,7 @@ async function asItWas(repo, unrecorded, commit, shared, run) {
|
|
|
166
231
|
const aside = {
|
|
167
232
|
back: here(repo),
|
|
168
233
|
stash: `Playthrough: your work, set aside while flows record on Before, ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
169
|
-
|
|
170
|
-
boot: boot(),
|
|
234
|
+
...thisCommand(),
|
|
171
235
|
...shared && { shared }
|
|
172
236
|
};
|
|
173
237
|
writeFileSync(repo.setAside, JSON.stringify(aside));
|
|
@@ -246,14 +310,6 @@ function refuseWhileAside(repo) {
|
|
|
246
310
|
if (aside && aside.by !== process.pid && stillAt(aside)) throw new Refused([`Another playthrough command (process ${aside.by}) is recording flows on Before just now, so the app as it was is in your work's place until it's done, in a minute or so. Try again then.`]);
|
|
247
311
|
}
|
|
248
312
|
const setAside = (repo) => attempt(() => JSON.parse(readFileSync(repo.setAside, "utf8")));
|
|
249
|
-
const stillAt = (aside) => sameBoot(aside.boot) && running(aside.by);
|
|
250
|
-
function boot() {
|
|
251
|
-
return attempt(() => readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim()) || Date.now() / 1e3 - uptime();
|
|
252
|
-
}
|
|
253
|
-
function sameBoot(then, now = boot()) {
|
|
254
|
-
if (typeof then === "number" && typeof now === "number") return Math.abs(then - now) < 300;
|
|
255
|
-
return then === void 0 || then === now;
|
|
256
|
-
}
|
|
257
313
|
function putWorkBack(repo, aside, force) {
|
|
258
314
|
try {
|
|
259
315
|
if (here(repo) !== aside.back) {
|
|
@@ -324,13 +380,6 @@ async function caughtUp(url) {
|
|
|
324
380
|
await new Promise((done) => setTimeout(done, 1e3));
|
|
325
381
|
await fetch(url, { signal: AbortSignal.timeout(6e4) }).then((response) => response.body?.cancel(), () => {});
|
|
326
382
|
}
|
|
327
|
-
function running(pid) {
|
|
328
|
-
try {
|
|
329
|
-
return process.kill(pid, 0);
|
|
330
|
-
} catch (error) {
|
|
331
|
-
return error.code === "EPERM";
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
383
|
function git(repo, args, env) {
|
|
335
384
|
return execFileSync("git", [
|
|
336
385
|
"-c",
|
|
@@ -594,7 +643,7 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
|
|
|
594
643
|
if (outside) throw new Refused([`${quote(flow.name)} reaches outside the app (${outside}), so it's run only when it's taken over.`]);
|
|
595
644
|
const app = await repo.app();
|
|
596
645
|
refuseWhileAside(repo);
|
|
597
|
-
await keepRunning(app, repo.root);
|
|
646
|
+
await keepRunning(app, repo.root, { ready: true });
|
|
598
647
|
const began = Date.now();
|
|
599
648
|
const { handed, made } = await runTo(repo, id, {
|
|
600
649
|
flow: flow.name,
|
|
@@ -635,7 +684,7 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
|
|
|
635
684
|
const ready = /* @__PURE__ */ new Map();
|
|
636
685
|
async function keepUp(repo, id, flow, step, actions) {
|
|
637
686
|
refuseWhileAside(repo);
|
|
638
|
-
await keepRunning(await repo.app(), repo.root);
|
|
687
|
+
await keepRunning(await repo.app(), repo.root, { ready: true });
|
|
639
688
|
await follow(repo, id, {
|
|
640
689
|
flow,
|
|
641
690
|
...step && {
|
|
@@ -738,52 +787,11 @@ const SAYS = `<script>(() => {
|
|
|
738
787
|
Object.assign(Shifted, { now: () => Real.now() + ahead, parse: Real.parse, UTC: Real.UTC });
|
|
739
788
|
window.Date = Shifted;
|
|
740
789
|
}
|
|
741
|
-
// In the browser the flow was recorded in, whatever yours says: light or dark, still or moving, in its languages.
|
|
742
|
-
// page asks in its scripts and its styles, and each asking is answered before the app hears it.
|
|
790
|
+
// In the browser the flow was recorded in, whatever yours says: light or dark, still or moving, in its languages.
|
|
743
791
|
const browser = JSON.parse(sessionStorage.getItem("playthrough:browser") || "null");
|
|
744
792
|
if (browser) {
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
String(query).replace(/\\(\\s*(prefers-color-scheme|prefers-reduced-motion)\\s*(?::\\s*([a-z-]+)\\s*)?\\)/gi, (_, feature, value) => {
|
|
748
|
-
feature = feature.toLowerCase();
|
|
749
|
-
const holds = value ? value.toLowerCase() === recorded[feature] : feature === "prefers-color-scheme" || browser.still;
|
|
750
|
-
return holds ? "(min-width: 0px)" : "(max-width: 0px)";
|
|
751
|
-
});
|
|
752
|
-
const matching = window.matchMedia;
|
|
753
|
-
window.matchMedia = (query) => matching.call(window, as(query));
|
|
754
|
-
for (const name of ["insertRule", "replace", "replaceSync"]) {
|
|
755
|
-
const real = CSSStyleSheet.prototype[name];
|
|
756
|
-
if (real) CSSStyleSheet.prototype[name] = function (text, ...rest) { return real.call(this, as(text), ...rest); };
|
|
757
|
-
}
|
|
758
|
-
// light-dark(), system colours, form controls and scrollbars follow the page's colour-scheme, not a media query.
|
|
759
|
-
const scheme = browser.dark ? "dark" : "light";
|
|
760
|
-
const inScheme = (value) => (/\\blight\\b/.test(value) && /\\bdark\\b/.test(value) ? scheme : value);
|
|
761
|
-
const rewrite = (rules) => {
|
|
762
|
-
for (const rule of rules) {
|
|
763
|
-
if (rule.media && as(rule.media.mediaText) !== rule.media.mediaText) rule.media.mediaText = as(rule.media.mediaText);
|
|
764
|
-
if (rule.style?.colorScheme && inScheme(rule.style.colorScheme) !== rule.style.colorScheme) rule.style.colorScheme = inScheme(rule.style.colorScheme);
|
|
765
|
-
if (rule.styleSheet) restyle(rule.styleSheet);
|
|
766
|
-
if (rule.cssRules) rewrite(rule.cssRules);
|
|
767
|
-
}
|
|
768
|
-
};
|
|
769
|
-
const restyle = (sheet) => { try { rewrite(sheet.cssRules); } catch {} };
|
|
770
|
-
const restyleAll = () => {
|
|
771
|
-
for (const sheet of document.styleSheets) restyle(sheet);
|
|
772
|
-
for (const sheet of document.adoptedStyleSheets ?? []) restyle(sheet);
|
|
773
|
-
for (const element of document.querySelectorAll("[media]")) {
|
|
774
|
-
const media = element.getAttribute("media");
|
|
775
|
-
if (as(media) !== media) element.setAttribute("media", as(media));
|
|
776
|
-
}
|
|
777
|
-
for (const meta of document.querySelectorAll('meta[name="color-scheme"]')) {
|
|
778
|
-
const content = meta.getAttribute("content") ?? "";
|
|
779
|
-
if (inScheme(content) !== content) meta.setAttribute("content", inScheme(content));
|
|
780
|
-
}
|
|
781
|
-
};
|
|
782
|
-
const styling = (node) => /^(style|link|source|meta)$/i.test(node?.nodeName);
|
|
783
|
-
const bringsStyle = (node) => styling(node) || !!node.querySelector?.("style, link, source, meta");
|
|
784
|
-
new MutationObserver((changes) => changes.some((change) => styling(change.target) || styling(change.target.parentNode) || [...change.addedNodes].some(bringsStyle)) && restyleAll())
|
|
785
|
-
.observe(document, { subtree: true, childList: true, characterData: true, attributeFilter: ["media", "content"] });
|
|
786
|
-
document.addEventListener("load", (event) => styling(event.target) && restyleAll(), true);
|
|
793
|
+
${asRecorded.toString()}
|
|
794
|
+
(${answerAs.toString()})(window, browser);
|
|
787
795
|
const languages = browser.languages;
|
|
788
796
|
Object.defineProperty(navigator, "language", { get: () => languages[0] });
|
|
789
797
|
Object.defineProperty(navigator, "languages", { get: () => languages });
|
|
@@ -822,6 +830,9 @@ const SAYS = `<script>(() => {
|
|
|
822
830
|
const yours = (mine) => { hidden.disabled = mine; if (mine) sessionStorage.setItem("playthrough:yours", "1"); else sessionStorage.removeItem("playthrough:yours"); };
|
|
823
831
|
yours(!!sessionStorage.getItem("playthrough:yours"));
|
|
824
832
|
addEventListener("message", ({ source, data }) => { if (source === parent && data?.playthrough === "yours") yours(data.yours); });
|
|
833
|
+
// Taken over, the page is scrolled where the recording was at that moment: made ready at a step's end, it was where
|
|
834
|
+
// the step left it, a screen or more from a moment partway through a page being read.
|
|
835
|
+
addEventListener("message", ({ source, data }) => { if (source === parent && data?.playthrough === "scroll") scrollTo({ left: data.x, top: data.y, behavior: "instant" }); });
|
|
825
836
|
const typing = (target) => !!target?.closest?.('input:not([type=button]):not([type=submit]):not([type=checkbox]):not([type=radio]):not([type=range]), textarea, [contenteditable=""], [contenteditable=true]');
|
|
826
837
|
// Every move, as it comes: held for a frame, the name trailed the pointer like a smoothed one. Not the earlier raw
|
|
827
838
|
// updates: in a scaled frame, Chrome gives them the page's position rather than the app's.
|
|
@@ -1040,7 +1051,9 @@ const SAYS = `<script>(() => {
|
|
|
1040
1051
|
sampling: { mousemove: 20, scroll: 16, input: "all" },
|
|
1041
1052
|
maskAllInputs: false,
|
|
1042
1053
|
inlineStylesheet: true,
|
|
1043
|
-
}) && ((recording = true), readNow());
|
|
1054
|
+
}) && ((recording = true), said(), readNow());
|
|
1055
|
+
// Light or dark, still or moving, as the flow was recorded, for your recording to be played back as it was too.
|
|
1056
|
+
const said = () => window.rrwebRecord.record.addCustomEvent("browser", { dark: matchMedia("(prefers-color-scheme: dark)").matches, still: matchMedia("(prefers-reduced-motion: reduce)").matches });
|
|
1044
1057
|
addEventListener("message", ({ source, data }) => source === parent && data?.playthrough === "yours" && record());
|
|
1045
1058
|
const settle = async () => {
|
|
1046
1059
|
await rested();
|
|
@@ -1590,6 +1603,11 @@ async function answer(app, page, you, request, response) {
|
|
|
1590
1603
|
"content-type": TYPES[extname(path)] ?? "application/octet-stream",
|
|
1591
1604
|
"cache-control": "max-age=31536000, immutable"
|
|
1592
1605
|
});
|
|
1606
|
+
const as = new URL(request.url ?? "/", "http://localhost").searchParams.get("as");
|
|
1607
|
+
if (as !== null && extname(path) === ".svg") return response.end(asRecorded(Buffer.from(bytes).toString("utf8"), {
|
|
1608
|
+
dark: as.includes("dark"),
|
|
1609
|
+
still: as.includes("still")
|
|
1610
|
+
}));
|
|
1593
1611
|
return response.end(bytes);
|
|
1594
1612
|
}
|
|
1595
1613
|
if (request.method === "POST" && rest[0] === "video") return saveVideo(repo, id, request, response);
|
package/src/playwright.config.js
CHANGED
|
@@ -76,6 +76,8 @@ const Flow = z.strictObject({
|
|
|
76
76
|
const Change = z.strictObject({
|
|
77
77
|
id: text("needs an id").describe("Its thing and its kind of change, so it keeps its id in every round while it's the same change"),
|
|
78
78
|
words: text("needs its words").describe("What changed, for the agent: “Download CSV and Cancel have swapped places”"),
|
|
79
|
+
reworded: text("needs the words that read differently").optional().describe("For words reworded, the words that read differently now, which the viewer marks where they are: “on their own”"),
|
|
80
|
+
gone: z.literal(true).optional().describe("Not on the newer round: where it was, on the round before, is where it's seen, with Compare"),
|
|
79
81
|
seen: z.array(Place, { error: "needs where it's seen, a list" }).min(1, { error: "needs where it's seen: each flow, at its moment there" }).describe("Each flow it's seen in, at its moment there; a flow that's gone, with no moment"),
|
|
80
82
|
pictureOnly: z.literal(true).optional().describe("Seen only in the pictures, with nothing read there changed: shown to the person, who can see what it is, and not put to the agent, who can't see more of it than Playthrough did")
|
|
81
83
|
});
|
package/src/reporter.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { t as ACTIONS, n as ASSETS, r as DONE_ON, a as GLIMPSES, o as HANDS, s as HOLD, c as PERSON, l as PICTURE, u as READINGS, d as RECORDING, f as WROTE, i as default, p as firstLine, m as glimpsesOf, h as readingsOf, g as readingsOn, _ as seconds } from "./reporter-
|
|
1
|
+
export { t as ACTIONS, n as ASSETS, r as DONE_ON, a as GLIMPSES, o as HANDS, s as HOLD, c as PERSON, l as PICTURE, u as READINGS, d as RECORDING, f as WROTE, i as default, p as firstLine, m as glimpsesOf, h as readingsOf, g as readingsOn, _ as seconds } from "./reporter-S9fOk0za.js";
|
package/src/test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { _ as seconds, a as GLIMPSES, c as PERSON, d as RECORDING, f as WROTE, l as PICTURE, n as ASSETS, o as HANDS, p as firstLine, r as DONE_ON, s as HOLD, t as ACTIONS, u as READINGS, v as keepAssets } from "./reporter-
|
|
2
|
-
import { A as repo, C as changes, D as personIn, E as make, O as setupIn, n as REACHES_OUTSIDE, r as WALKED, t as ALONGSIDE, w as firstOf } from "./flows-
|
|
1
|
+
import { _ as seconds, a as GLIMPSES, c as PERSON, d as RECORDING, f as WROTE, l as PICTURE, n as ASSETS, o as HANDS, p as firstLine, r as DONE_ON, s as HOLD, t as ACTIONS, u as READINGS, v as keepAssets } from "./reporter-S9fOk0za.js";
|
|
2
|
+
import { A as repo, C as changes, D as personIn, E as make, O as setupIn, n as REACHES_OUTSIDE, r as WALKED, t as ALONGSIDE, w as firstOf } from "./flows-vi0amwSo.js";
|
|
3
3
|
import { n as readPage, r as unhovered } from "./read-Bg9XVTtn.js";
|
|
4
4
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
@@ -92,15 +92,20 @@ function onlyWaits(expect) {
|
|
|
92
92
|
const recorder = (rrweb) => `;((Date, setTimeout, clearTimeout, requestAnimationFrame, cancelAnimationFrame) => {
|
|
93
93
|
${readFileSync(rrweb, "utf8")}
|
|
94
94
|
})(Date, setTimeout, clearTimeout, requestAnimationFrame, cancelAnimationFrame);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
95
|
+
// With each page taken whole, whether its browser was light or dark, still or moving, for it to be played back as it
|
|
96
|
+
// was: said once rrweb has the page, as said at its very start it went before anything was listening.
|
|
97
|
+
const said = () => window.rrwebRecord.record.addCustomEvent("browser", { dark: matchMedia("(prefers-color-scheme: dark)").matches, still: matchMedia("(prefers-reduced-motion: reduce)").matches });
|
|
98
|
+
if (window.top === window) {
|
|
99
|
+
window.rrwebRecord.record({
|
|
100
|
+
// Nothing is kept until the flow itself starts, after whatever the app's setup did on the page.
|
|
101
|
+
emit: (event) => (window.__playthrough?.(event), event.type === 2 && setTimeout(said)),
|
|
102
|
+
// every input, not just the last: with "last", rrweb only hears a field change when it loses focus,
|
|
103
|
+
// so words typed into a search box that kept focus never reached the recording
|
|
104
|
+
sampling: { mousemove: 20, scroll: 16, input: "all" },
|
|
105
|
+
maskAllInputs: false,
|
|
106
|
+
inlineStylesheet: true,
|
|
107
|
+
});
|
|
108
|
+
}`;
|
|
104
109
|
const FRAME = 16;
|
|
105
110
|
const REST = 140;
|
|
106
111
|
const KEY = 60;
|
|
@@ -703,6 +708,20 @@ function pace(page) {
|
|
|
703
708
|
return result;
|
|
704
709
|
};
|
|
705
710
|
}
|
|
711
|
+
const mouse = Object.getPrototypeOf(page.mouse);
|
|
712
|
+
const wheel = mouse.wheel;
|
|
713
|
+
mouse.wheel = async function(deltaX, deltaY) {
|
|
714
|
+
if (!pointer) return wheel.call(this, deltaX, deltaY);
|
|
715
|
+
const moves = Math.max(10, Math.min(36, Math.round(Math.hypot(deltaX, deltaY) / 25)));
|
|
716
|
+
let done = 0;
|
|
717
|
+
for (let i = 1; i <= moves; i++) {
|
|
718
|
+
await wheel.call(this, deltaX * (ease(i / moves) - done), deltaY * (ease(i / moves) - done));
|
|
719
|
+
done = ease(i / moves);
|
|
720
|
+
await pointer.page.waitForTimeout(FRAME);
|
|
721
|
+
}
|
|
722
|
+
await pointer.page.waitForTimeout(650);
|
|
723
|
+
await glimpse();
|
|
724
|
+
};
|
|
706
725
|
const keyboard = Object.getPrototypeOf(page.keyboard);
|
|
707
726
|
const focused = async (page) => {
|
|
708
727
|
const on = page.locator(":focus").first();
|