playthrough 0.3.0 → 0.4.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 +23 -20
- package/README.md +2 -2
- package/package.json +2 -2
- package/src/{flows-Dt7fcPLt.js → flows-BkG0TgHA.js} +156 -44
- package/src/playthrough.js +124 -211
- package/src/playwright.config.js +1 -1
- package/src/{reporter-4C_7joIm.js → reporter-DE8aueAO.js} +18 -16
- package/src/reporter.js +1 -1
- package/src/test.js +29 -8
- package/types/cli/src/flows.d.ts +13 -5
- package/types/cli/src/reporter.d.ts +3 -1
- package/types/cli/src/try-setup.d.ts +1 -0
- package/types/page/src/found.d.ts +4 -2
- package/types/review/src/model.d.ts +3 -0
- package/types/review/src/rules.d.ts +47 -0
- package/types/review/src/store.d.ts +0 -1
- package/viewer/assets/index-Ds2iot3O.js +193 -0
- package/viewer/index.html +1 -1
- package/viewer/assets/index-Bbi9sjtm.js +0 -193
package/INSTRUCTIONS.md
CHANGED
|
@@ -3,7 +3,7 @@ to its end; each command then says what's next.
|
|
|
3
3
|
|
|
4
4
|
playthrough start <review> "<title>" "<what was asked>"
|
|
5
5
|
start a review: a title for the change, and what was asked, in the person's words
|
|
6
|
-
playthrough start <review> once its flows are written,
|
|
6
|
+
playthrough start <review> once its flows are written, try the setup and keep Before: the app as it is
|
|
7
7
|
playthrough record <review> record the round being worked on, every flow, the way a person would use it
|
|
8
8
|
playthrough wait <review> hand the round over, then wait for the notes to answer
|
|
9
9
|
playthrough walk <review> walk the person's steps in the app again, on the app as it is now
|
|
@@ -14,19 +14,22 @@ THE LOOP
|
|
|
14
14
|
1. Start a review with a title, as you'd title its pull request, and what was asked, word for word: it's what the person
|
|
15
15
|
judges each round against. Before you change anything, write its flows, one for each thing a person does that the
|
|
16
16
|
task touches, from the task, not from the code.
|
|
17
|
-
2. Run playthrough start <review> again. It
|
|
18
|
-
|
|
17
|
+
2. Run playthrough start <review> again. It tries the app's setup, then keeps the app as it is as Before, for the person
|
|
18
|
+
to compare each round with. Nothing's recorded yet: the flows are written for the work.
|
|
19
19
|
3. Change the app. Keep each flow's name, and each step's while it does the same job: they're matched across rounds by
|
|
20
20
|
name. Once the work is done, add a flow for each choice you made that the person can see and didn't ask for.
|
|
21
|
-
4. playthrough record <review> records the round and
|
|
21
|
+
4. playthrough record <review> records the round, and each flow new to the review on Before too, doing what the round
|
|
22
|
+
does as far as the app as it was can, then compares the round with every round before it. In the round's round.json,
|
|
22
23
|
say why each change since the round before is there, under "accounts" by its id: { "line": "<why, in your words>" },
|
|
23
|
-
{ "note": "<the note it was made for>" }, or { "notReal": "<why nothing really changed>" }.
|
|
24
|
+
{ "note": "<the id of the note it was made for>" }, or { "notReal": "<why nothing really changed>" }. What only the
|
|
25
|
+
pictures show is the person's to look at, and asks nothing of you, unless you made it for a note: then say so.
|
|
24
26
|
5. playthrough wait <review> hands the round over and waits for the notes, which can take hours: run it in the
|
|
25
27
|
background, so you're told when it ends. Each note comes with a picture of what it's on, or what the person did in
|
|
26
|
-
the app. Change the app, record the next round, and answer each note in its round.json, under "replies"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
and
|
|
28
|
+
the app. Change the app, record the next round, and answer each note in its round.json, under "replies", in its
|
|
29
|
+
"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>". For a note made in the app, record walks the
|
|
31
|
+
person's steps again and puts your answer where they lead: answer it in words, not with a new flow, and check your
|
|
32
|
+
fix with playthrough walk <review>. Where their steps stop short, fix that, or say why they changed.
|
|
30
33
|
|
|
31
34
|
The person can only judge what the flows show, so keep them true to how a person uses the app.
|
|
32
35
|
|
|
@@ -38,10 +41,9 @@ playthrough/app.json says how to start the app, and is committed so every review
|
|
|
38
41
|
|
|
39
42
|
Flows run several at once. Where a page shows what every run made, as a blog's index lists every post, add
|
|
40
43
|
"oneAtATime": true. Every round is recorded on whatever serves that url, and Playthrough starts the app only when nothing
|
|
41
|
-
does: leave a server you didn't start running, and let it follow the working copy, as a dev server does.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
follows what you fix: a flow whose file changes is recorded on it again, and so is every flow once playthrough/ changes.
|
|
44
|
+
does: leave a server you didn't start running, and let it follow the working copy, as a dev server does. Recording on
|
|
45
|
+
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
|
|
46
|
+
now, and puts your work back after, even when Playthrough is stopped.
|
|
45
47
|
|
|
46
48
|
FLOWS
|
|
47
49
|
|
|
@@ -63,7 +65,7 @@ name is the flow's:
|
|
|
63
65
|
Act on the page as a person would, by role and label. Playthrough glides the pointer, types a key at a time, and reads
|
|
64
66
|
the page once it rests after each step. A dialog the browser shows is recorded, and answered No unless the flow says Yes
|
|
65
67
|
first: page.once("dialog", (dialog) => dialog.accept()). A flow that fails stops the round: record names it and the
|
|
66
|
-
step, and keeps the page it failed on in .playthrough/<review>/failed
|
|
68
|
+
step, and keeps the page it failed on in .playthrough/<review>/failed/.
|
|
67
69
|
|
|
68
70
|
While the person watches, Playthrough runs the flow again in the background, so taking it over is quick. If its steps do
|
|
69
71
|
something outside the app that mustn't happen again, like sending an email, say so, and it runs only when taken over:
|
|
@@ -91,15 +93,16 @@ tells tests by (the screen, language and time zone are Playthrough's):
|
|
|
91
93
|
|
|
92
94
|
export async function after({ request, setup }: { request: APIRequestContext; baseURL: string; setup: Record<string, any> }) {
|
|
93
95
|
const removed = await request.delete(`/api/test/accounts/${setup.account}`);
|
|
94
|
-
if (!removed.ok()) throw new Error(`Removing the account failed: ${removed.status()}`);
|
|
96
|
+
if (!removed.ok() && removed.status() !== 404) throw new Error(`Removing the account failed: ${removed.status()}`);
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
Each run makes its own: what the app needs unique, like an email, of its own, and what the page shows, like a name, the
|
|
98
|
-
same every run. Check what each request answers, so one that fails says so.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
same every run. Check what each request answers, so one that fails says so. An after that fails is tried again whole by
|
|
101
|
+
the next command, so it takes what's gone already as given back. Avoid what can't be given back, like an email sent:
|
|
102
|
+
test keys, a mail catcher. Use what the project has (a test account, seed data, its own fixtures), make data through the
|
|
103
|
+
API or database rather than on screen, and stub what you can't control at the network. Sign in with page.request, not
|
|
104
|
+
request: it shares the page's cookies, so a person who takes the flow over is signed in too. Dates the server works out
|
|
105
|
+
don't follow the page's clock: fix them through the app's own test hook if it has one.
|
|
103
106
|
|
|
104
107
|
Whenever app.json or setup.ts changes, Playthrough tries the setup by itself first, twice for each of the app's people,
|
|
105
108
|
and shows a picture of where each starts. Fix what the pictures show and run the command again. It's the person's app:
|
package/README.md
CHANGED
|
@@ -7,8 +7,8 @@ Playthrough records your coding agent's change the way a person would use it, be
|
|
|
7
7
|
## How a review goes
|
|
8
8
|
|
|
9
9
|
1. You ask your agent for a change, and to show it to you with Playthrough.
|
|
10
|
-
2. Before it touches the code, the agent writes a flow for each thing a person does that the change touches, and Playthrough
|
|
11
|
-
3. The agent makes the change, and Playthrough records every flow
|
|
10
|
+
2. Before it touches the code, the agent writes a flow for each thing a person does that the change touches, and Playthrough keeps the app as it is: Before.
|
|
11
|
+
3. The agent makes the change, and Playthrough records every flow, on the app as it is now and on Before, as far as the app went before. It finds what changed itself, including what the agent didn't mention, and the agent says why each change is there.
|
|
12
12
|
4. You watch. Leave a note on what's wrong, where it's wrong; compare with Before; take over and try it yourself. Then send.
|
|
13
13
|
5. The agent answers each note in the next round, on the spot the note is on. You check its answers, and accept once every flow looks good.
|
|
14
14
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "playthrough",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Review an AI agent's work on a web app by watching recordings of it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"node": ">=24"
|
|
14
14
|
},
|
|
15
15
|
"bin": {
|
|
16
|
-
"playthrough": "
|
|
16
|
+
"playthrough": "src/playthrough.js"
|
|
17
17
|
},
|
|
18
18
|
"exports": {
|
|
19
19
|
"./test": {
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { A as list, b as Refused, g as readingsOf, j as quote, m as glimpsesOf, p as firstLine, x as Reviews } from "./reporter-DE8aueAO.js";
|
|
2
2
|
import * as modules from "node:module";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { existsSync, watch } from "node:fs";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
8
|
import { constants, tmpdir } from "node:os";
|
|
8
9
|
import { dirname, join, relative } from "node:path";
|
|
9
10
|
import { chromium, request } from "@playwright/test";
|
|
10
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
11
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
12
|
// ../review/src/steps.ts
|
|
13
13
|
function inStep(at, from, to, clicks) {
|
|
@@ -268,7 +268,7 @@ async function giveBack(after, file, baseURL, setup) {
|
|
|
268
268
|
await rm(file, { force: true });
|
|
269
269
|
return true;
|
|
270
270
|
} catch (error) {
|
|
271
|
-
console.log(`playthrough/setup.ts's after couldn't give back what a run made: ${firstLine(String(error?.message ?? error))}.
|
|
271
|
+
console.log(`playthrough/setup.ts's after couldn't give back what a run made: ${firstLine(String(error?.message ?? error))}. The next command tries it again, whole, so it should take what's gone already as given back.`);
|
|
272
272
|
return false;
|
|
273
273
|
} finally {
|
|
274
274
|
giving.delete(file);
|
|
@@ -730,7 +730,7 @@ function placeIn(place, from, to) {
|
|
|
730
730
|
};
|
|
731
731
|
if (place.point) return {
|
|
732
732
|
...moment,
|
|
733
|
-
point: place.point
|
|
733
|
+
point: besideOn(place.point, place.at, from, to) ?? place.point
|
|
734
734
|
};
|
|
735
735
|
const there = (element) => {
|
|
736
736
|
const found = foundIn(from.readings, element, place.at, to.readings);
|
|
@@ -744,6 +744,36 @@ function placeIn(place, from, to) {
|
|
|
744
744
|
...alike?.length && { alike }
|
|
745
745
|
} : moment;
|
|
746
746
|
}
|
|
747
|
+
const HOLDING = /^(dialog|alertdialog|group|radiogroup|table|grid|treegrid|listbox|menu)$/;
|
|
748
|
+
function besideIn(read, at, point) {
|
|
749
|
+
const reading = read.reduce((best, next) => !best || Math.abs(next.at - at) < Math.abs(best.at - at) ? next : best, void 0);
|
|
750
|
+
if (!reading) return void 0;
|
|
751
|
+
const x = point.x * reading.screen.width;
|
|
752
|
+
const y = point.y * reading.screen.height;
|
|
753
|
+
const inside = (thing) => x >= thing.box.x && x <= thing.box.x + thing.box.width && y >= thing.box.y && y <= thing.box.y + thing.box.height;
|
|
754
|
+
const area = (thing) => thing.box.width * thing.box.height;
|
|
755
|
+
const room = reading.things.filter((thing) => (thing.role === "box" || HOLDING.test(thing.role) && !/^(table|grid|treegrid)$/.test(thing.role)) && inside(thing)).sort((a, b) => area(a) - area(b))[0];
|
|
756
|
+
const within = (thing) => {
|
|
757
|
+
for (let holder = thing.holder; holder !== void 0; holder = reading.things[holder]?.holder) if (reading.things[holder] === room) return true;
|
|
758
|
+
return false;
|
|
759
|
+
};
|
|
760
|
+
const gap = (thing) => Math.hypot(Math.max(thing.box.x - x, 0, x - thing.box.x - thing.box.width), Math.max(thing.box.y - y, 0, y - thing.box.y - thing.box.height));
|
|
761
|
+
const thing = reading.things.filter((thing) => thing.name && thing.role !== "box" && !HOLDING.test(thing.role) && (!room || within(thing)) && gap(thing) <= 80).sort((a, b) => gap(a) - gap(b) || area(a) - area(b))[0];
|
|
762
|
+
return thing && {
|
|
763
|
+
reading,
|
|
764
|
+
thing
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
function besideOn(point, at, from, to) {
|
|
768
|
+
const beside = besideIn(from.readings, at, point);
|
|
769
|
+
const found = beside && sameIn(beside, from.readings, to.readings);
|
|
770
|
+
if (!beside || !found) return void 0;
|
|
771
|
+
const share = (pixels, of) => Math.min(1, Math.max(0, pixels / of));
|
|
772
|
+
return {
|
|
773
|
+
x: share(point.x * beside.reading.screen.width + found.thing.box.x - beside.thing.box.x, found.reading.screen.width),
|
|
774
|
+
y: share(point.y * beside.reading.screen.height + found.thing.box.y - beside.thing.box.y, found.reading.screen.height)
|
|
775
|
+
};
|
|
776
|
+
}
|
|
747
777
|
function foundIn(from, element, at, to) {
|
|
748
778
|
const shown = shownAt(from, element, at);
|
|
749
779
|
return shown && sameIn(shown, from, to);
|
|
@@ -791,11 +821,13 @@ function changesAt(then, now, unsteady = /* @__PURE__ */ new Set(), shown = {
|
|
|
791
821
|
const others = own.filter((difference) => !isMove(difference));
|
|
792
822
|
const caused = grownByWhatsInside(moment, own);
|
|
793
823
|
const along = /* @__PURE__ */ new Map();
|
|
794
|
-
const
|
|
824
|
+
const items = itemsReordered(moment, own.filter(isMove));
|
|
825
|
+
const { reordered, left } = rowsReordered(moment, items.left);
|
|
795
826
|
const { shifts, loose } = layoutShifts(left, carried);
|
|
796
827
|
moment.shifts = shifts.map((shift) => shift.by);
|
|
797
828
|
const widened = widenedTogether(others, caused, along);
|
|
798
829
|
const told = [
|
|
830
|
+
...items.reordered,
|
|
799
831
|
...reordered,
|
|
800
832
|
...swappedOrMoved(moment, loose),
|
|
801
833
|
...causesOfShifts(moment, shifts, widened, others, caused, along)
|
|
@@ -907,6 +939,76 @@ function grownByWhatsInside(moment, own) {
|
|
|
907
939
|
}
|
|
908
940
|
return caused;
|
|
909
941
|
}
|
|
942
|
+
function itemsReordered(moment, moves) {
|
|
943
|
+
const { then, now } = moment;
|
|
944
|
+
const pairs = pairItems(moment);
|
|
945
|
+
const reordered = [];
|
|
946
|
+
let left = moves;
|
|
947
|
+
for (const [, paired] of groupBy([...pairs], ([, b]) => now.items[b].list)) {
|
|
948
|
+
const byThen = [...paired].sort((p, q) => p[0] - q[0]);
|
|
949
|
+
const byNow = [...paired].sort((p, q) => p[1] - q[1]);
|
|
950
|
+
const placeThen = new Map(byThen.map(([a], i) => [a, i]));
|
|
951
|
+
const kept = longestRising(byNow.map(([a]) => placeThen.get(a)));
|
|
952
|
+
const way = (a, b) => {
|
|
953
|
+
const went = moveOf(asThing(then.items[a], a), asThing(now.items[b], b));
|
|
954
|
+
return Math.abs(went.y) >= Math.abs(went.x) ? went.y > 0 ? "down" : "up" : went.x > 0 ? "right" : "left";
|
|
955
|
+
};
|
|
956
|
+
const moved = byNow.flatMap(([a, b], i) => kept.has(i) ? [] : [{
|
|
957
|
+
thing: asThing(now.items[b], b),
|
|
958
|
+
way: way(a, b),
|
|
959
|
+
places: Math.abs(i - placeThen.get(a))
|
|
960
|
+
}]);
|
|
961
|
+
if (!moved.length) continue;
|
|
962
|
+
const changed = byNow.filter(([a], i) => placeThen.get(a) !== i);
|
|
963
|
+
const first = now.items[byNow[0][1]];
|
|
964
|
+
const of = `${first.role} of ${listOf(now, first)}`;
|
|
965
|
+
const holder = first.holder === void 0 ? void 0 : now.things[first.holder];
|
|
966
|
+
if (changed.length === 2) {
|
|
967
|
+
const both = changed.map(([, b]) => asThing(now.items[b], b));
|
|
968
|
+
reordered.push({
|
|
969
|
+
what: "swapped",
|
|
970
|
+
thing: both[0],
|
|
971
|
+
in: now,
|
|
972
|
+
words: `${together(both)} have swapped places`,
|
|
973
|
+
key: `${of}: ${both.map((thing) => thing.words).join(" / ")}`
|
|
974
|
+
});
|
|
975
|
+
} else if (moved.length <= 2) for (const { thing, way, places } of moved) reordered.push({
|
|
976
|
+
what: "moved",
|
|
977
|
+
thing,
|
|
978
|
+
in: now,
|
|
979
|
+
words: `${called$1(thing)} moved ${way} ${places} ${places > 1 ? "places" : "place"}`,
|
|
980
|
+
key: `${of}: ${thing.words}`
|
|
981
|
+
});
|
|
982
|
+
else {
|
|
983
|
+
const order = byNow.slice(0, 3).map(([, b]) => called$1(asThing(now.items[b], b)));
|
|
984
|
+
const words = first.role === "row" ? `Rows in ${holder ? the(holder) : "the table"} are in a new order` : `What's in ${holder ? the(holder) : "the list"} is in a new order: ${order.join(", ")}${byNow.length > 3 ? "…" : ""}`;
|
|
985
|
+
reordered.push({
|
|
986
|
+
what: "reordered",
|
|
987
|
+
thing: holder ?? moved[0].thing,
|
|
988
|
+
in: now,
|
|
989
|
+
words,
|
|
990
|
+
key: of
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
const inList = new Set(paired.map(([, b]) => b));
|
|
994
|
+
left = left.filter((move) => !(move.now.item !== void 0 && inList.has(move.now.item) && move.then.item !== void 0 && pairs.get(move.then.item) === move.now.item));
|
|
995
|
+
}
|
|
996
|
+
return {
|
|
997
|
+
reordered,
|
|
998
|
+
left
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
function longestRising(sequence) {
|
|
1002
|
+
const best = sequence.map(() => 1);
|
|
1003
|
+
const before = sequence.map(() => -1);
|
|
1004
|
+
for (let i = 0; i < sequence.length; i++) for (let j = 0; j < i; j++) if (sequence[j] < sequence[i] && best[j] + 1 > best[i]) {
|
|
1005
|
+
best[i] = best[j] + 1;
|
|
1006
|
+
before[i] = j;
|
|
1007
|
+
}
|
|
1008
|
+
const kept = /* @__PURE__ */ new Set();
|
|
1009
|
+
for (let i = best.indexOf(Math.max(...best)); i >= 0; i = before[i]) kept.add(i);
|
|
1010
|
+
return kept;
|
|
1011
|
+
}
|
|
910
1012
|
function rowsReordered(moment, moves) {
|
|
911
1013
|
const reordered = [];
|
|
912
1014
|
let left = moves;
|
|
@@ -1229,8 +1331,10 @@ function identityOf({ in: reading, thing, what }, moment) {
|
|
|
1229
1331
|
if (!item) return identity(reading, thing);
|
|
1230
1332
|
const kind = reading.things.filter((other) => other.item === thing.item && other.role === thing.role && other.column === thing.column);
|
|
1231
1333
|
const place = `${thing.role} ${kind.indexOf(thing) + 1}${thing.column ? ` under ${thing.column}` : ""}`;
|
|
1232
|
-
const was = what
|
|
1233
|
-
|
|
1334
|
+
const was = /^(reworded|moved)$/.test(what) ? moment.was.get(thing) : void 0;
|
|
1335
|
+
const went = was && what === "moved" && moveOf(was, thing);
|
|
1336
|
+
const how = went ? `, ${wayOf(near(went.x, 0) ? 0 : went.x, near(went.y, 0) ? 0 : went.y)}` : was ? `: ${shapeOf(was.words, thing.words)}` : "";
|
|
1337
|
+
return `${place} in a ${item.role} of ${listOf(reading, item)}${how}`;
|
|
1234
1338
|
}
|
|
1235
1339
|
const listOf = (reading, item) => item.holder === void 0 ? "the page" : identity(reading, reading.things[item.holder], 0);
|
|
1236
1340
|
function pairItems({ then, now, was }) {
|
|
@@ -1455,9 +1559,10 @@ async function picturesAt(page, then, now, pictures) {
|
|
|
1455
1559
|
if (patch.nudged && found.length) continue;
|
|
1456
1560
|
if (patch.moved && [...changedInside].some((holder) => contains(holder.box, patch, 8))) continue;
|
|
1457
1561
|
const on = whatsThere(now, patch);
|
|
1458
|
-
const
|
|
1459
|
-
const
|
|
1460
|
-
const
|
|
1562
|
+
const moved = on.identity && !on.identity.startsWith("near ") ? void 0 : patch.moved;
|
|
1563
|
+
const by = moved && Math.max(Math.abs(moved.x), Math.abs(moved.y));
|
|
1564
|
+
const words = moved ? `${on.called} moved ${wayOf(moved.x, moved.y)}, ${by}px` : `${on.called} looks different`;
|
|
1565
|
+
const id = idOf(on.identity ?? `place:${Math.round(patch.x / 32)},${Math.round(patch.y / 32)}`, moved ? "moved" : "looks different");
|
|
1461
1566
|
const same = told.get(id);
|
|
1462
1567
|
told.set(id, {
|
|
1463
1568
|
words,
|
|
@@ -1722,16 +1827,20 @@ function unsteadyIn(flows) {
|
|
|
1722
1827
|
async function comparing(page, earlier, round, unsteady = () => /* @__PURE__ */ new Set()) {
|
|
1723
1828
|
const changes = /* @__PURE__ */ new Map();
|
|
1724
1829
|
const pieces = /* @__PURE__ */ new Map();
|
|
1725
|
-
const see = ({ id, words, place, of = [id] }) => {
|
|
1830
|
+
const see = ({ id, words, place, of = [id], pictureOnly }) => {
|
|
1726
1831
|
if (unsteady(place.flow).has(id)) return;
|
|
1727
1832
|
const change = changes.get(id);
|
|
1728
1833
|
for (const piece of of) (pieces.get(id) ?? pieces.set(id, /* @__PURE__ */ new Set()).get(id)).add(piece);
|
|
1729
1834
|
if (!change) changes.set(id, {
|
|
1730
1835
|
id,
|
|
1731
1836
|
words,
|
|
1732
|
-
seen: [place]
|
|
1837
|
+
seen: [place],
|
|
1838
|
+
...pictureOnly && { pictureOnly }
|
|
1733
1839
|
});
|
|
1734
|
-
else
|
|
1840
|
+
else {
|
|
1841
|
+
if (!change.seen.some((seen) => seen.flow === place.flow)) change.seen.push(place);
|
|
1842
|
+
if (!pictureOnly) delete change.pictureOnly;
|
|
1843
|
+
}
|
|
1735
1844
|
};
|
|
1736
1845
|
for (const flow of round.flows) {
|
|
1737
1846
|
const before = earlier.flows.find((kept) => kept.name === flow.name);
|
|
@@ -1769,7 +1878,8 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
|
|
|
1769
1878
|
then: around(glimpsed.then, before, was.step),
|
|
1770
1879
|
now: around(glimpsed.now, flow, reading.step)
|
|
1771
1880
|
};
|
|
1772
|
-
|
|
1881
|
+
const pictured = await picturesAt(page, was, reading, pictures);
|
|
1882
|
+
for (const found of [...changesAt(was, reading, unsteady, shown), ...pictured]) {
|
|
1773
1883
|
const on = found.element ? {
|
|
1774
1884
|
element: found.element,
|
|
1775
1885
|
...found.alike && { alike: found.alike }
|
|
@@ -1782,7 +1892,8 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
|
|
|
1782
1892
|
at: reading.at,
|
|
1783
1893
|
...on
|
|
1784
1894
|
},
|
|
1785
|
-
of: found.pieces
|
|
1895
|
+
of: found.pieces,
|
|
1896
|
+
...pictured.includes(found) && { pictureOnly: true }
|
|
1786
1897
|
});
|
|
1787
1898
|
}
|
|
1788
1899
|
}
|
|
@@ -1880,12 +1991,12 @@ async function trySetup(repo, again) {
|
|
|
1880
1991
|
console.log(`\n${line("The app", startedIn === void 0 ? `running already at ${app.url}` : `started with ${quote(app.start)}, answering at ${app.url} in ${seconds(startedIn)}`)}`);
|
|
1881
1992
|
for (const { who, did, took, at, refused, picture } of tried) console.log(line(who, `${did}${did === "nothing made" ? "" : ` in ${seconds(took)}`}, then at ${at}${refused ? `, which the app answered with ${refused}` : ""}: ${repo.show(picture)}`));
|
|
1882
1993
|
const missing = [];
|
|
1883
|
-
if (!setup.after && tried.some(({ made }) => made)) missing.push("playthrough/setup.ts has no after, so what each run makes stays in the app: one for each flow recorded,
|
|
1884
|
-
else if (tried.some(({ givenBack }) => !givenBack)) missing.push("playthrough/setup.ts's after couldn't give back what it made, for the reason above.");
|
|
1994
|
+
if (!setup.after && tried.some(({ made }) => made)) missing.push("playthrough/setup.ts has no after, so what each run makes stays in the app: one for each flow recorded, three for a new one, and one each time the person pauses a recording, as it's made ready to take over.");
|
|
1885
1995
|
const slowest = Math.max(...tried.map(({ took }) => took));
|
|
1886
1996
|
if (slowest > SLOW) missing.push(`Setup took ${seconds(slowest)}: each flow waits that long before its first step, and so does each moment taken over.`);
|
|
1887
1997
|
console.log(`
|
|
1888
1998
|
Look at ${tried.length > 1 ? "each picture: it's where their flows start" : "the picture: it's where every flow starts"}. One that shows a sign-in page for someone who should be signed in, or anything a flow shouldn't start from, is for playthrough/setup.ts.`);
|
|
1999
|
+
if (tried.some(({ givenBack }) => !givenBack)) console.log(`\nplaythrough/setup.ts's after couldn't give back what it made, for the reason above: that's for setup.ts too.`);
|
|
1889
2000
|
if (missing.length) console.log(`
|
|
1890
2001
|
${missing.map((said) => `- ${said}`).join("\n")}
|
|
1891
2002
|
|
|
@@ -1995,16 +2106,20 @@ function howToWriteFlows(repo, id) {
|
|
|
1995
2106
|
The test's name is the flow's, and steps are matched across rounds by their names. What every flow starts from, like
|
|
1996
2107
|
the account it opens, is made in playthrough/setup.ts${missing}`;
|
|
1997
2108
|
}
|
|
1998
|
-
async function
|
|
2109
|
+
async function flowsOf(repo, id) {
|
|
1999
2110
|
const flows = repo.flows(id);
|
|
2000
2111
|
if (!(await readdir(flows).catch(() => [])).some(isFlow)) throw new Refused([`${id} has no flows yet. ${howToWriteFlows(repo, id)}`]);
|
|
2001
2112
|
await makeFlowsFolder(repo, id);
|
|
2113
|
+
return flows;
|
|
2114
|
+
}
|
|
2115
|
+
async function runFlows(repo, id, round, { before, first = () => false }) {
|
|
2116
|
+
const flows = await flowsOf(repo, id);
|
|
2002
2117
|
const app = await repo.app();
|
|
2003
|
-
if (!
|
|
2118
|
+
if (!before) await trySetup(repo, `playthrough record ${id}`);
|
|
2004
2119
|
const { started, rounds } = await repo.reviews.get(id);
|
|
2005
2120
|
const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
|
|
2006
2121
|
const longest = (rounds.filter((kept) => kept.number < round).at(-1)?.flows ?? []).toSorted((a, b) => b.length - a.length).map((flow) => flow.name);
|
|
2007
|
-
const run = (only, results
|
|
2122
|
+
const run = (only, results) => runOnce({
|
|
2008
2123
|
flows,
|
|
2009
2124
|
only,
|
|
2010
2125
|
longest,
|
|
@@ -2012,16 +2127,20 @@ async function runFlows(repo, id, round, { later, first }) {
|
|
|
2012
2127
|
app,
|
|
2013
2128
|
root: repo.root,
|
|
2014
2129
|
recorder,
|
|
2015
|
-
before:
|
|
2016
|
-
taken:
|
|
2130
|
+
before: !!before,
|
|
2131
|
+
taken: before?.taken ?? [],
|
|
2017
2132
|
started,
|
|
2018
|
-
|
|
2133
|
+
...before && { doneInRound: before.doneInRound }
|
|
2019
2134
|
});
|
|
2020
|
-
|
|
2135
|
+
if (before) {
|
|
2136
|
+
console.log(`Recording ${called(before.flows)} on Before, the app as it was…`);
|
|
2137
|
+
return run(before.flows);
|
|
2138
|
+
}
|
|
2139
|
+
const last = await failedLastTime(repo, id);
|
|
2021
2140
|
if (last.length) {
|
|
2022
2141
|
console.log(`Running ${called(last)} first: ${last.length > 1 ? "they" : "it"} failed last time…`);
|
|
2023
2142
|
const { recorded, failed } = await run(last, repo.failed(id));
|
|
2024
|
-
await keepFailed(repo, id,
|
|
2143
|
+
await keepFailed(repo, id, failed);
|
|
2025
2144
|
const [one] = failed;
|
|
2026
2145
|
if (failed.length > 1) throw new Refused([
|
|
2027
2146
|
`\nThese flows failed again, so the other flows weren't run:`,
|
|
@@ -2032,20 +2151,15 @@ async function runFlows(repo, id, round, { later, first }) {
|
|
|
2032
2151
|
const gone = !recorded.length;
|
|
2033
2152
|
console.log(`\n${last.length > 1 ? gone ? "They aren't flows any more" : "They pass now" : gone ? "It isn't a flow any more" : "It passes now"}, so every flow is recorded.\n`);
|
|
2034
2153
|
}
|
|
2035
|
-
console.log(
|
|
2036
|
-
const once = await run(
|
|
2037
|
-
await keepFailed(repo, id,
|
|
2154
|
+
console.log(`Recording round ${round} of ${id}…`);
|
|
2155
|
+
const once = await run(void 0, repo.failed(id));
|
|
2156
|
+
await keepFailed(repo, id, once.failed);
|
|
2038
2157
|
const again = once.recorded.filter(({ flow }) => first(flow.name));
|
|
2039
|
-
if (!again.length ||
|
|
2158
|
+
if (!again.length || once.failed.length) return once;
|
|
2040
2159
|
console.log(`\nRecording ${called(again.map(({ flow }) => flow.name))} again, to see what changes by itself…`);
|
|
2041
|
-
const
|
|
2042
|
-
|
|
2043
|
-
const
|
|
2044
|
-
if (results) await keepFailed(repo, id, round, twice.failed);
|
|
2045
|
-
const unlike = again.filter(({ flow }) => {
|
|
2046
|
-
const failed = (ran) => ran.failed.find((failure) => failure.flow === flow.name)?.at;
|
|
2047
|
-
return !twice.recorded.some((kept) => kept.flow.name === flow.name) || failed(once) !== failed(twice);
|
|
2048
|
-
});
|
|
2160
|
+
const twice = await run(again.map(({ flow }) => flow.name), repo.failed(id));
|
|
2161
|
+
await keepFailed(repo, id, twice.failed);
|
|
2162
|
+
const unlike = again.filter(({ flow }) => !twice.recorded.some((kept) => kept.flow.name === flow.name));
|
|
2049
2163
|
if (unlike.length) throw new Refused([
|
|
2050
2164
|
`\n${list(unlike.map(({ flow }) => quote(flow.name)))} didn't go the same way twice on the same app:`,
|
|
2051
2165
|
...unlike.map(({ flow }) => ` ${quote(flow.name)}: the first time, ${went(once, flow.name)}; the second, ${went(twice, flow.name)}.`),
|
|
@@ -2071,7 +2185,7 @@ function went(ran, name) {
|
|
|
2071
2185
|
const because = failure.why ? `, where Playwright says “${failure.why}”` : "";
|
|
2072
2186
|
return failure.at ? `it stopped at ${quote(failure.at)}${because}` : `it stopped before its first step${because}`;
|
|
2073
2187
|
}
|
|
2074
|
-
async function keepFailed(repo, id,
|
|
2188
|
+
async function keepFailed(repo, id, failed) {
|
|
2075
2189
|
const folder = repo.failed(id);
|
|
2076
2190
|
if (!failed.length) return rm(folder, {
|
|
2077
2191
|
recursive: true,
|
|
@@ -2081,7 +2195,6 @@ async function keepFailed(repo, id, round, failed) {
|
|
|
2081
2195
|
const path = join(folder, entry);
|
|
2082
2196
|
if (entry === ".last-run.json" || !(await readdir(path)).length) await rm(path, { recursive: true });
|
|
2083
2197
|
}
|
|
2084
|
-
if (round === 0) return;
|
|
2085
2198
|
await mkdir(folder, { recursive: true });
|
|
2086
2199
|
await writeFile(join(folder, FAILED), JSON.stringify(failed.map(({ flow }) => flow)));
|
|
2087
2200
|
}
|
|
@@ -2117,13 +2230,12 @@ async function runOnce(run) {
|
|
|
2117
2230
|
written: {}
|
|
2118
2231
|
};
|
|
2119
2232
|
if (!ran && !failed.length) throw new Refused(["\nThe flows didn't run, for the reason Playwright gives above. Fix that and run this again."]);
|
|
2120
|
-
const withFiles = ({
|
|
2233
|
+
const withFiles = ({ files, ...recorded }) => Promise.all(files.map(async (name) => ({
|
|
2121
2234
|
name,
|
|
2122
2235
|
bytes: await readFile(join(folder, name))
|
|
2123
2236
|
}))).then((files) => ({
|
|
2124
|
-
|
|
2125
|
-
files
|
|
2126
|
-
...wrote && { wrote }
|
|
2237
|
+
...recorded,
|
|
2238
|
+
files
|
|
2127
2239
|
}));
|
|
2128
2240
|
return {
|
|
2129
2241
|
recorded: await Promise.all(recorded.map(withFiles)),
|
|
@@ -2206,4 +2318,4 @@ async function playwright(run) {
|
|
|
2206
2318
|
});
|
|
2207
2319
|
});
|
|
2208
2320
|
}
|
|
2209
|
-
export {
|
|
2321
|
+
export { firstOf$1 as C, setupIn as D, personIn as E, repo as O, changes as S, make as T, placeIn as _, failedAt as a, serves as b, isFlow as c, waitsFor as d, walkFork as f, compareWithEarlier as g, writtenNow as h, called as i, pausedAt as k, makeFlowsFolder as l, trySetup as m, REACHES_OUTSIDE as n, flowsOf as o, whereItStopped as p, WALKED as r, howToWriteFlows as s, ALONGSIDE as t, runFlows as u, shownAt as v, giveBackLeft as w, stopApp as x, keepRunning as y };
|