playthrough 0.1.0 → 0.3.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 +60 -64
- package/package.json +1 -1
- package/src/{read-BBNVC-oH.js → flows-Dt7fcPLt.js} +647 -1098
- package/src/playthrough.js +246 -122
- package/src/playwright.config.js +6 -10
- package/src/read-Bg9XVTtn.js +278 -0
- package/src/reporter-4C_7joIm.js +919 -0
- package/src/reporter.js +1 -1
- package/src/test.js +121 -16
- package/types/cli/src/app.d.ts +4 -0
- package/types/cli/src/changes.d.ts +1 -0
- package/types/cli/src/compare.d.ts +7 -2
- package/types/cli/src/flows.d.ts +8 -4
- package/types/cli/src/pictures.d.ts +1 -0
- package/types/cli/src/read.d.ts +3 -1
- package/types/cli/src/repo.d.ts +4 -0
- package/types/cli/src/reporter.d.ts +15 -5
- package/types/cli/src/setup.d.ts +5 -2
- package/types/cli/src/test.d.ts +1 -1
- package/types/cli/src/try-setup.d.ts +2 -0
- package/types/page/src/reading.d.ts +11 -0
- package/types/review/src/rules.d.ts +2 -1
- package/types/review/src/store.d.ts +3 -0
- package/viewer/assets/index-Bbi9sjtm.js +193 -0
- package/viewer/assets/index-gTfnUrcK.css +2 -0
- package/viewer/index.html +2 -2
- package/src/reporter-Biym3TnN.js +0 -197
- package/viewer/assets/index-BriVL50M.js +0 -193
- package/viewer/assets/index-DaS2F2-s.css +0 -2
|
@@ -1,465 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { O as list, b as Refused, g as readingsOf, k as quote, m as glimpsesOf, p as firstLine, x as Reviews } from "./reporter-4C_7joIm.js";
|
|
2
|
+
import * as modules from "node:module";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
3
4
|
import { existsSync, watch } from "node:fs";
|
|
4
|
-
import { z } from "zod";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
-
import {
|
|
6
|
+
import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { constants, tmpdir } from "node:os";
|
|
7
8
|
import { dirname, join, relative } from "node:path";
|
|
8
|
-
import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
9
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
10
9
|
import { chromium, request } from "@playwright/test";
|
|
11
10
|
import { createHash, randomUUID } from "node:crypto";
|
|
12
|
-
|
|
13
|
-
const text = (error) => z.string({ error }).refine((value) => value.trim() !== "", { error });
|
|
14
|
-
const seconds = (error) => z.number({ error }).min(0, { error });
|
|
15
|
-
const share = (error) => z.number({ error }).min(0, { error }).max(1, { error });
|
|
16
|
-
const fileName = (error) => z.string({ error }).regex(/^[\w.-]+(\/[\w.-]+)*$/, { error }).refine((name) => !name.split("/").some((part) => part === "." || part === ".."), { error });
|
|
17
|
-
const Element$1 = z.strictObject({
|
|
18
|
-
role: text("needs its role").describe("As getByRole takes it, or text, row or box for something with no role of its own"),
|
|
19
|
-
name: text("needs its name"),
|
|
20
|
-
css: z.string().optional(),
|
|
21
|
-
node: z.number().int().optional().describe("The element in that round's recording")
|
|
22
|
-
});
|
|
23
|
-
const onScreen$1 = "isn't on the screen. Boxes and points are shares of the screen, from 0 to 1";
|
|
24
|
-
const Box = z.strictObject({
|
|
25
|
-
x: share(onScreen$1),
|
|
26
|
-
y: share(onScreen$1),
|
|
27
|
-
width: share(onScreen$1),
|
|
28
|
-
height: share(onScreen$1)
|
|
29
|
-
}).refine((box) => box.x + box.width <= 1 && box.y + box.height <= 1, { error: onScreen$1 });
|
|
30
|
-
const Point = z.strictObject({
|
|
31
|
-
x: share(onScreen$1),
|
|
32
|
-
y: share(onScreen$1)
|
|
33
|
-
});
|
|
34
|
-
const Did = z.strictObject({
|
|
35
|
-
does: text("needs what it did"),
|
|
36
|
-
on: text("needs the path to what it was done on"),
|
|
37
|
-
with: z.union([
|
|
38
|
-
z.string(),
|
|
39
|
-
z.boolean(),
|
|
40
|
-
z.array(z.string())
|
|
41
|
-
]).optional(),
|
|
42
|
-
what: z.string().optional().describe("The words of what it was done on")
|
|
43
|
-
});
|
|
44
|
-
const Place = z.strictObject({
|
|
45
|
-
flow: text("needs the flow it's on"),
|
|
46
|
-
at: seconds("needs its moment, in seconds").optional().describe("Without it, the whole flow"),
|
|
47
|
-
until: seconds("needs its end, in seconds").optional().describe("The end of a stretch"),
|
|
48
|
-
element: Element$1.optional(),
|
|
49
|
-
alike: z.array(Element$1).optional().describe("The other things changed alike with its element, each where it is"),
|
|
50
|
-
box: Box.optional().describe("Something with no words of its own, or where only the pixels changed"),
|
|
51
|
-
point: Point.optional().describe("Empty space"),
|
|
52
|
-
app: z.strictObject({
|
|
53
|
-
from: z.strictObject({
|
|
54
|
-
flow: text("needs the flow it was taken over in"),
|
|
55
|
-
at: seconds("needs the moment it was taken over at, in seconds")
|
|
56
|
-
}),
|
|
57
|
-
did: z.array(Did).describe("What the person did there, in order"),
|
|
58
|
-
recording: fileName("needs its recording: the name of a file in the round").describe("The flow up to where it was taken over, then the person's own steps"),
|
|
59
|
-
length: z.number({ error: "needs its length, in seconds" }).positive({ error: "needs its length, in seconds" })
|
|
60
|
-
}).optional().describe("In the app: on the person's own recording, which branches off a flow where they took it over. Its flow names the branch")
|
|
61
|
-
}).refine((place) => [
|
|
62
|
-
place.element,
|
|
63
|
-
place.box,
|
|
64
|
-
place.point
|
|
65
|
-
].filter((thing) => thing).length <= 1, { error: "is on more than one thing. Choose an element, a box or a point" }).refine((place) => place.at !== void 0 || !(place.element || place.box || place.point), { error: "is on something but at no moment. Give it one" }).refine((place) => place.until === void 0 || place.at !== void 0 && place.until > place.at, { error: "ends before it starts. A stretch ends after its moment" });
|
|
66
|
-
const Step = z.strictObject({
|
|
67
|
-
title: text("needs a title").describe("Steps are matched across rounds by their title"),
|
|
68
|
-
at: seconds("needs the moment it starts, in seconds"),
|
|
69
|
-
actions: z.array(seconds("needs the moment each of its actions was done, in seconds")).optional().describe("When each of its actions was done: between them the page rests, and a person can take the flow over there"),
|
|
70
|
-
person: text("needs the name of who does it, or no person").optional().describe("Who does it, once the flow has changed hands: someone other than who the flow is done as")
|
|
71
|
-
});
|
|
72
|
-
const Flow = z.strictObject({
|
|
73
|
-
name: text("needs a name").describe("Its identity: a new name is a new flow"),
|
|
74
|
-
length: z.number({ error: "needs its length, in seconds" }).positive({ error: "needs its length, in seconds" }),
|
|
75
|
-
steps: z.array(Step),
|
|
76
|
-
recording: fileName("needs its recording: the name of a file in the round"),
|
|
77
|
-
line: text("needs words in its line, or no line").optional().describe("The agent's, at the flow's start: what it starts from, or the choice it shows"),
|
|
78
|
-
stopped: text("needs the step it stopped at").optional().describe("On Before, the step the flow couldn't take there: it's kept as far as that, and tried again by playthrough start"),
|
|
79
|
-
person: text("needs the name of who it acts as, or no person").optional().describe("Who the flow is done as, as its file or the app's setup names them: the name on the recorded pointer, handed to you when you take over"),
|
|
80
|
-
unsteady: z.array(text("needs the ids of the changes it makes by itself")).optional().describe("Changes found between two recordings of the same app, like a time the server stamps: left out of every comparison, in every flow")
|
|
81
|
-
});
|
|
82
|
-
const Change = z.strictObject({
|
|
83
|
-
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"),
|
|
84
|
-
words: text("needs its words").describe("What changed, for the agent: “Download CSV and Cancel have swapped places”"),
|
|
85
|
-
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")
|
|
86
|
-
});
|
|
87
|
-
const Comparison = z.strictObject({
|
|
88
|
-
round: z.number({ error: "needs its round" }).int().min(1, { error: "needs its round, after Before" }),
|
|
89
|
-
since: z.number({ error: "needs the round it's compared with" }).int().min(0).describe("0 is Before"),
|
|
90
|
-
changes: z.array(Change, { error: "needs its changes, a list, even if it's empty" })
|
|
91
|
-
});
|
|
92
|
-
const Account = z.union([
|
|
93
|
-
z.strictObject({ line: text("needs words in its line") }).describe("Why it's there, in the agent's voice: what it meant or chose"),
|
|
94
|
-
z.strictObject({ note: text("needs the note it was made for") }).describe("Made for that note: the reply on the note says why"),
|
|
95
|
-
z.strictObject({ notReal: text("needs to say why nothing really changed") }).describe("Nothing really changed; nobody is shown it")
|
|
96
|
-
], { error: "says one of { line }, { note } or { notReal }" });
|
|
97
|
-
const Reply = z.strictObject({
|
|
98
|
-
note: text("needs the note it answers"),
|
|
99
|
-
words: z.string({ error: "needs its words, even if empty until it's answered" }),
|
|
100
|
-
on: Place.optional().describe("Where it shows on this round's recording; without it, the note's place was lost"),
|
|
101
|
-
watch: text("needs a step's title").optional().describe("For an answer that moves: the step of its flow it's seen in, which the note then plays")
|
|
102
|
-
});
|
|
103
|
-
const Note = z.strictObject({
|
|
104
|
-
id: text("needs an id"),
|
|
105
|
-
on: Place.optional().describe("Without it, about the work in general"),
|
|
106
|
-
words: text("needs words")
|
|
107
|
-
});
|
|
108
|
-
const Round = z.strictObject({
|
|
109
|
-
number: z.number().int().min(0).describe("Round 0 is Before: the app as it was"),
|
|
110
|
-
by: text("needs to say which agent recorded it").describe("By the name it goes by, “Claude Code” or “Codex”: one review can pass between agents"),
|
|
111
|
-
flows: z.array(Flow),
|
|
112
|
-
replies: z.array(Reply).describe("The agent's answer to each note that asks for one"),
|
|
113
|
-
accounts: z.record(z.string(), Account).describe("What the agent says about each change new since the round before, by its id"),
|
|
114
|
-
commit: z.string().optional().describe("What it was recorded from"),
|
|
115
|
-
cantStart: z.array(text("needs the flow's name")).optional().describe("Before: the flows the app as it was can't start, so they have no Before"),
|
|
116
|
-
finished: z.iso.datetime().optional()
|
|
117
|
-
});
|
|
118
|
-
const Verdict = z.strictObject({
|
|
119
|
-
note: text("needs the note it's on"),
|
|
120
|
-
verdict: z.enum(["looks good", "not quite"], { error: "is “looks good” or “not quite”" }),
|
|
121
|
-
words: z.string().optional()
|
|
122
|
-
}).refine((verdict) => verdict.verdict !== "not quite" || verdict.words?.trim(), { error: "is not quite, so it needs words saying what's still wrong" });
|
|
123
|
-
const Send = z.strictObject({
|
|
124
|
-
id: text("needs an id").describe("Chosen by the sender, so sending twice sends once"),
|
|
125
|
-
round: z.number().int().min(0),
|
|
126
|
-
by: text("needs to say who sent it"),
|
|
127
|
-
sent: z.iso.datetime({ error: "needs to say when it was sent, as an ISO date" }),
|
|
128
|
-
notes: z.array(Note).describe("New ones"),
|
|
129
|
-
verdicts: z.array(Verdict),
|
|
130
|
-
looksGood: z.array(text("needs the flow's name")).describe("Flows that look good as a whole; one that isn't quite right gets a note"),
|
|
131
|
-
takenBack: z.array(text("needs the flow's name")).optional().describe("Flows whose Looks good, said before, is taken back: they ask again")
|
|
132
|
-
});
|
|
133
|
-
const Taken = z.strictObject({
|
|
134
|
-
send: z.string(),
|
|
135
|
-
round: z.number().int(),
|
|
136
|
-
at: z.iso.datetime()
|
|
137
|
-
});
|
|
138
|
-
const Accepted = z.strictObject({
|
|
139
|
-
by: z.string(),
|
|
140
|
-
at: z.iso.datetime()
|
|
141
|
-
});
|
|
142
|
-
z.strictObject({
|
|
143
|
-
id: z.string(),
|
|
144
|
-
title: z.string().describe("A few words for the change, as a pull request's title"),
|
|
145
|
-
asked: z.string().optional().describe("What was asked, in the person's words"),
|
|
146
|
-
started: z.iso.datetime(),
|
|
147
|
-
rounds: z.array(Round),
|
|
148
|
-
comparisons: z.array(Comparison),
|
|
149
|
-
sends: z.array(Send),
|
|
150
|
-
taken: z.array(Taken),
|
|
151
|
-
accepted: Accepted.optional()
|
|
152
|
-
});
|
|
153
|
-
// ../review/src/words.ts
|
|
154
|
-
const list = (things) => things.length > 1 ? `${things.slice(0, -1).join(", ")} and ${things.at(-1)}` : things[0] ?? "";
|
|
155
|
-
const time = (seconds) => `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`;
|
|
156
|
-
const quote = (name) => `“${name}”`;
|
|
157
|
-
// ../review/src/rules.ts
|
|
158
|
-
function roundInProgress(review) {
|
|
159
|
-
const last = review.rounds.at(-1);
|
|
160
|
-
if (!last) return 0;
|
|
161
|
-
return last.finished ? last.number + 1 : last.number;
|
|
162
|
-
}
|
|
163
|
-
function allNotes(review) {
|
|
164
|
-
const notes = /* @__PURE__ */ new Map();
|
|
165
|
-
for (const send of review.sends) for (const note of send.notes) notes.set(note.id, {
|
|
166
|
-
note,
|
|
167
|
-
round: send.round,
|
|
168
|
-
by: send.by
|
|
169
|
-
});
|
|
170
|
-
return notes;
|
|
171
|
-
}
|
|
172
|
-
function lastSeen(review, id, before) {
|
|
173
|
-
const started = allNotes(review).get(id);
|
|
174
|
-
if (!started?.note.on) return void 0;
|
|
175
|
-
const placed = review.rounds.findLast((round) => round.number > started.round && round.number < before && round.replies.some((reply) => reply.note === id && reply.on));
|
|
176
|
-
const on = placed?.replies.find((reply) => reply.note === id)?.on;
|
|
177
|
-
return placed && on ? {
|
|
178
|
-
round: placed.number,
|
|
179
|
-
on
|
|
180
|
-
} : {
|
|
181
|
-
round: started.round,
|
|
182
|
-
on: started.note.on
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
function conversation(review, id) {
|
|
186
|
-
const started = allNotes(review).get(id);
|
|
187
|
-
if (!started) return [];
|
|
188
|
-
const turns = [{
|
|
189
|
-
turn: {
|
|
190
|
-
by: started.by,
|
|
191
|
-
agent: false,
|
|
192
|
-
round: started.round,
|
|
193
|
-
words: started.note.words
|
|
194
|
-
},
|
|
195
|
-
order: 0
|
|
196
|
-
}];
|
|
197
|
-
for (const round of review.rounds.filter((round) => round.number > started.round)) {
|
|
198
|
-
const reply = round.replies.find((reply) => reply.note === id);
|
|
199
|
-
if (reply?.words.trim()) turns.push({
|
|
200
|
-
turn: {
|
|
201
|
-
by: round.by,
|
|
202
|
-
agent: true,
|
|
203
|
-
round: round.number,
|
|
204
|
-
words: reply.words
|
|
205
|
-
},
|
|
206
|
-
order: 1
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
for (const send of review.sends) for (const verdict of send.verdicts.filter((verdict) => verdict.note === id)) turns.push({
|
|
210
|
-
turn: {
|
|
211
|
-
by: send.by,
|
|
212
|
-
agent: false,
|
|
213
|
-
round: send.round,
|
|
214
|
-
words: verdict.words ?? "",
|
|
215
|
-
verdict: verdict.verdict
|
|
216
|
-
},
|
|
217
|
-
order: 2
|
|
218
|
-
});
|
|
219
|
-
return turns.sort((a, b) => a.turn.round - b.turn.round || a.order - b.order).map(({ turn }) => turn);
|
|
220
|
-
}
|
|
221
|
-
function notesToAnswer(review, round) {
|
|
222
|
-
return asking(review.sends.filter((send) => review.taken.some((taken) => taken.send === send.id && taken.round === round)));
|
|
223
|
-
}
|
|
224
|
-
function waitingForTheAgent(review) {
|
|
225
|
-
const answered = (send) => {
|
|
226
|
-
const taken = review.taken.find((taken) => taken.send === send.id);
|
|
227
|
-
return !!taken && review.rounds.some((round) => round.number === taken.round && round.finished);
|
|
228
|
-
};
|
|
229
|
-
return asking(review.sends.filter((send) => !answered(send)));
|
|
230
|
-
}
|
|
231
|
-
function asking(sends) {
|
|
232
|
-
const asks = /* @__PURE__ */ new Set();
|
|
233
|
-
const last = /* @__PURE__ */ new Map();
|
|
234
|
-
for (const send of sends) {
|
|
235
|
-
for (const note of send.notes) asks.add(note.id);
|
|
236
|
-
for (const verdict of send.verdicts) last.set(verdict.note, verdict.verdict);
|
|
237
|
-
}
|
|
238
|
-
for (const [note, verdict] of last) if (verdict === "not quite") asks.add(note);
|
|
239
|
-
return [...asks];
|
|
240
|
-
}
|
|
241
|
-
function problemsWithId(id) {
|
|
242
|
-
return typeof id === "string" && /^[a-z0-9]+(-[a-z0-9]+)*$/.test(id) ? [] : [`A review's id is lowercase words joined by dashes, like “export-a-quarter”, not “${id}”.`];
|
|
243
|
-
}
|
|
244
|
-
function problemsStarting(id, title, asked) {
|
|
245
|
-
const problems = problemsWithId(id);
|
|
246
|
-
if (!isText$1(title)) problems.push("A review needs a title: a few words for the change, as a pull request's title.");
|
|
247
|
-
if (asked !== void 0 && !isText$1(asked)) problems.push("What was asked is the person's words, as they put it.");
|
|
248
|
-
return problems;
|
|
249
|
-
}
|
|
250
|
-
function problemsWithRound(review, round) {
|
|
251
|
-
const shape = problemsWithShape(Round, round, Number.isInteger(round?.number) ? `Round ${round.number}` : "This round");
|
|
252
|
-
if (shape.length) return shape;
|
|
253
|
-
if (review.accepted) return ["This review is accepted, so nothing more can be recorded."];
|
|
254
|
-
const due = roundInProgress(review);
|
|
255
|
-
if (round.number !== due && !(review.rounds.length === 0 && round.number === 1)) {
|
|
256
|
-
if (review.rounds.length === 0) return ["The first round is 0, Before, or 1 when the app is new and there's no Before."];
|
|
257
|
-
if (round.number < due) return [`Round ${round.number} is finished, so it can't change.`];
|
|
258
|
-
return [`Round ${round.number} can't be recorded yet: the round being worked on is ${due}.`];
|
|
259
|
-
}
|
|
260
|
-
const at = `Round ${round.number}`;
|
|
261
|
-
const problems = problemsWithFlows(round, at);
|
|
262
|
-
const notes = allNotes(review);
|
|
263
|
-
problems.push(...duplicates(round.replies.map((reply) => reply.note), (id) => `${at} has two replies to note ${id}. Say it all in one.`));
|
|
264
|
-
for (const reply of round.replies) {
|
|
265
|
-
if (!notes.has(reply.note)) problems.push(`${at} replies to note ${reply.note}, which doesn't exist.`);
|
|
266
|
-
if (reply.on) problems.push(...problemsWithPlace(reply.on.app ? { flow: reply.on.app.from.flow } : reply.on, round.flows, `${at}: the reply to note ${reply.note}`));
|
|
267
|
-
if (reply.watch) problems.push(...problemsWatching(reply, round, at));
|
|
268
|
-
}
|
|
269
|
-
return problems;
|
|
270
|
-
}
|
|
271
|
-
function problemsWatching(reply, round, at) {
|
|
272
|
-
const flow = reply.on && !reply.on.app ? round.flows.find((flow) => flow.name === reply.on.flow) : void 0;
|
|
273
|
-
if (!flow) return [`${at}: the reply to note ${reply.note} watches ${quote(reply.watch)}, but it isn't on one of this round's flows. Take "watch" out.`];
|
|
274
|
-
if (flow.steps.some((step) => step.title === reply.watch)) return [];
|
|
275
|
-
return [`${at}: the reply to note ${reply.note} watches ${quote(reply.watch)}, which isn't a step of ${quote(flow.name)}. Name one of its steps: ${list(flow.steps.map((step) => quote(step.title)))}.`];
|
|
276
|
-
}
|
|
277
|
-
function problemsAddingToBefore(review, flows, cantStart) {
|
|
278
|
-
if (review.accepted) return ["This review is accepted, so nothing more can be recorded."];
|
|
279
|
-
const before = review.rounds.find((round) => round.number === 0);
|
|
280
|
-
if (!before) return ["There's no Before to add a flow to: the app was new."];
|
|
281
|
-
const after = withInBefore(before, flows, cantStart);
|
|
282
|
-
const shape = problemsWithShape(Round, after, "Before");
|
|
283
|
-
return shape.length ? shape : problemsWithFlows(after, "Before");
|
|
284
|
-
}
|
|
285
|
-
function withInBefore(before, flows, cantStart) {
|
|
286
|
-
const names = /* @__PURE__ */ new Set([...flows.map((flow) => flow.name), ...cantStart]);
|
|
287
|
-
return {
|
|
288
|
-
...before,
|
|
289
|
-
flows: [...before.flows.filter((flow) => !names.has(flow.name)), ...flows],
|
|
290
|
-
cantStart: [...(before.cantStart ?? []).filter((name) => !names.has(name)), ...cantStart]
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
function problemsWithFlows(round, at) {
|
|
294
|
-
const problems = duplicates([...round.flows.map((flow) => flow.name), ...round.cantStart ?? []], (name) => `${at} has two flows called “${name}”.`);
|
|
295
|
-
for (const flow of round.flows) {
|
|
296
|
-
const steps = flow.steps.map((step) => step.title);
|
|
297
|
-
problems.push(...duplicates(steps, (title) => `${at}: the flow “${flow.name}” has two steps called “${title}”. Steps are matched by title.`));
|
|
298
|
-
for (const step of flow.steps.filter((step) => step.at > flow.length)) problems.push(`${at}: the step “${step.title}” in the flow “${flow.name}” starts after the flow ends.`);
|
|
299
|
-
}
|
|
300
|
-
return problems;
|
|
301
|
-
}
|
|
302
|
-
function problemsWithComparison(review, comparison) {
|
|
303
|
-
const shape = problemsWithShape(Comparison, comparison, "This comparison");
|
|
304
|
-
if (shape.length) return shape;
|
|
305
|
-
if (review.accepted) return ["This review is accepted, so nothing more can be recorded."];
|
|
306
|
-
const at = `Round ${comparison.round} against ${roundName(comparison.since)}`;
|
|
307
|
-
const round = review.rounds.find((kept) => kept.number === comparison.round);
|
|
308
|
-
const earlier = review.rounds.find((kept) => kept.number === comparison.since);
|
|
309
|
-
if (!round) return [`Round ${comparison.round} hasn't been recorded yet, so it can't be compared.`];
|
|
310
|
-
if (round.finished) return [`Round ${comparison.round} is finished, so what it's compared with can't change.`];
|
|
311
|
-
if (comparison.since >= comparison.round) return [`${at}: a round is only compared with a round before it.`];
|
|
312
|
-
if (!earlier) return [`${at}: there's no ${roundName(comparison.since)} to compare it with.`];
|
|
313
|
-
const problems = duplicates(comparison.changes.map((change) => change.id), (id) => `${at} has two changes with the id ${id}.`);
|
|
314
|
-
for (const change of comparison.changes) for (const seen of change.seen) {
|
|
315
|
-
const gone = seen.at === void 0 && !round.flows.some((flow) => flow.name === seen.flow);
|
|
316
|
-
problems.push(...problemsWithPlace(seen, gone ? earlier.flows : round.flows, `${at}: ${describe(change)}`));
|
|
317
|
-
}
|
|
318
|
-
return problems;
|
|
319
|
-
}
|
|
320
|
-
const LINE = 140;
|
|
321
|
-
function problemsFinishing(review, number) {
|
|
322
|
-
if (review.accepted) return ["This review is accepted, so nothing more can be recorded."];
|
|
323
|
-
const round = review.rounds.find((round) => round.number === number);
|
|
324
|
-
if (!round) return [`Round ${number} hasn't been recorded yet.`];
|
|
325
|
-
const at = `Round ${number}`;
|
|
326
|
-
const shape = problemsWithShape(Round, round, at);
|
|
327
|
-
if (shape.length) return shape;
|
|
328
|
-
if (!round.flows.length) return [`${at} has no flows yet. Record them first.`];
|
|
329
|
-
const problems = [];
|
|
330
|
-
const notes = allNotes(review);
|
|
331
|
-
for (const id of notesToAnswer(review, number)) {
|
|
332
|
-
const reply = round.replies.find((reply) => reply.note === id);
|
|
333
|
-
const note = notes.get(id)?.note;
|
|
334
|
-
if (!isText$1(reply?.words)) problems.push(`${at} needs an answer to note ${id}${note ? `, “${cut$1(note.words)}”` : ""}.`);
|
|
335
|
-
else if (reply?.on && note?.on && reply.on.flow !== note.on.flow) problems.push(`${at}: the reply to note ${id} is on “${reply.on.flow}”, but the note is on “${note.on.flow}”. A reply goes in its note's flow.`);
|
|
336
|
-
}
|
|
337
|
-
for (const flow of round.flows) if (flow.line && flow.line.length > LINE) problems.push(`${at}: the line on “${flow.name}” is ${flow.line.length} characters. Say it in a sentence of up to ${LINE}: what it starts from, or the choice it shows.`);
|
|
338
|
-
const comparisons = review.comparisons.filter((comparison) => comparison.round === number);
|
|
339
|
-
if (comparisons.length) for (const earlier of review.rounds.filter((earlier) => earlier.number < number && !comparisons.some((comparison) => comparison.since === earlier.number))) problems.push(`${at} isn't compared with ${roundName(earlier.number)} yet, so it can't be handed over. Record it again.`);
|
|
340
|
-
const changes = new Map(comparisons.flatMap((comparison) => comparison.changes.map((change) => [change.id, change])));
|
|
341
|
-
for (const change of comparisons.find((comparison) => comparison.since === number - 1)?.changes ?? []) if (!round.accounts[change.id]) problems.push(`${at}: say why ${change.id} is there (${change.words}${where(change)}): its line, the note it was made for, or why nothing really changed.`);
|
|
342
|
-
for (const [id, account] of Object.entries(round.accounts)) {
|
|
343
|
-
const change = changes.get(id);
|
|
344
|
-
if (!change) problems.push(`${at} says something about the change ${id}, which isn't one of this round's changes.`);
|
|
345
|
-
else if ("note" in account && !notes.has(account.note)) problems.push(`${at}: ${describe(change)} was made for note ${account.note}, which doesn't exist.`);
|
|
346
|
-
}
|
|
347
|
-
return problems;
|
|
348
|
-
}
|
|
349
|
-
function problemsWithFile(review, round, name) {
|
|
350
|
-
if (review.accepted) return ["This review is accepted, so nothing more can be recorded."];
|
|
351
|
-
const due = roundInProgress(review);
|
|
352
|
-
if (round !== due && !(review.rounds.length === 0 && round === 1)) return [`Files go in the round being worked on, ${due}, not round ${round}.`];
|
|
353
|
-
return problemsWithFileName(name);
|
|
354
|
-
}
|
|
355
|
-
function problemsWithFileName(name) {
|
|
356
|
-
return fileName("").safeParse(name).success ? [] : [`“${name}” can't be a file's name: use letters, digits, dots, dashes and underscores, and slashes between folders.`];
|
|
357
|
-
}
|
|
358
|
-
function problemsWithSend(review, send) {
|
|
359
|
-
const shape = problemsWithShape(Send, send, "This send");
|
|
360
|
-
if (shape.length) return shape;
|
|
361
|
-
if (review.accepted) return ["This review is accepted, so nothing more can be sent."];
|
|
362
|
-
if (review.sends.some((other) => other.id === send.id)) return [`Something else was already sent as ${send.id}. Send this with a new id.`];
|
|
363
|
-
const takenBack = send.takenBack ?? [];
|
|
364
|
-
if (!send.notes.length && !send.verdicts.length && !send.looksGood.length && !takenBack.length) return ["There's nothing in this send."];
|
|
365
|
-
const round = review.rounds.find((round) => round.number === send.round);
|
|
366
|
-
if (send.round === 0) return ["Round 0 is Before: notes go on the rounds after it."];
|
|
367
|
-
if (!round?.finished) return [`Round ${send.round} isn't finished, so it can't take notes yet.`];
|
|
368
|
-
const problems = [];
|
|
369
|
-
const notes = allNotes(review);
|
|
370
|
-
problems.push(...duplicates(send.notes.map((note) => note.id), (id) => `This send has two notes with the id ${id}.`));
|
|
371
|
-
for (const note of send.notes) {
|
|
372
|
-
if (notes.has(note.id)) problems.push(`The id ${note.id} is already a note's. Make a new one.`);
|
|
373
|
-
if (note.on) problems.push(...problemsWithPlace(note.on.app ? { flow: note.on.app.from.flow } : note.on, round.flows, `Note ${note.id}`));
|
|
374
|
-
}
|
|
375
|
-
problems.push(...duplicates(send.verdicts.map((verdict) => verdict.note), (id) => `This send has two verdicts on note ${id}.`));
|
|
376
|
-
for (const verdict of send.verdicts) {
|
|
377
|
-
const known = notes.get(verdict.note);
|
|
378
|
-
if (send.notes.some((note) => note.id === verdict.note)) problems.push(`Note ${verdict.note} is new: it needs words, not a verdict.`);
|
|
379
|
-
else if (!known || known.round > send.round) problems.push(`There's no note ${verdict.note} on round ${send.round} to judge.`);
|
|
380
|
-
}
|
|
381
|
-
problems.push(...duplicates(send.looksGood, (flow) => `This send says “${flow}” looks good twice.`));
|
|
382
|
-
problems.push(...duplicates(takenBack, (flow) => `This send takes back Looks good on “${flow}” twice.`));
|
|
383
|
-
for (const flow of send.looksGood.filter((flow) => takenBack.includes(flow))) problems.push(`This send says “${flow}” looks good and takes that back: say one.`);
|
|
384
|
-
for (const flow of [...send.looksGood, ...takenBack].filter((flow) => !round.flows.some((recorded) => recorded.name === flow))) problems.push(`Round ${send.round} has no flow called “${flow}”.`);
|
|
385
|
-
return problems;
|
|
386
|
-
}
|
|
387
|
-
function problemsAccepting(review, by) {
|
|
388
|
-
if (!isText$1(by)) return ["Accepting needs to say who accepted."];
|
|
389
|
-
if (!review.rounds.some((round) => round.finished && round.number > 0)) return ["There's no finished round to accept yet."];
|
|
390
|
-
const notes = allNotes(review);
|
|
391
|
-
return waitingForTheAgent(review).map((id) => {
|
|
392
|
-
const note = notes.get(id);
|
|
393
|
-
return `Note ${id}${note ? `, “${cut$1(note.note.words)}”,` : ""} still waits for the agent's answer, so the work can't be accepted yet.`;
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
function problemsWithShape(schema, value, subject) {
|
|
397
|
-
const result = schema.safeParse(value, { error: plainly });
|
|
398
|
-
if (result.success) return [];
|
|
399
|
-
return result.error.issues.map((issue) => `${whose(subject, issue.path, value)} ${issue.message}.`);
|
|
400
|
-
}
|
|
401
|
-
const plainly = (issue) => {
|
|
402
|
-
const field = issue.path?.findLast((key) => typeof key === "string");
|
|
403
|
-
if (issue.code === "unrecognized_keys") return `has ${issue.keys.map((key) => `“${key}”`).join(" and ")}, which ${issue.keys.length > 1 ? "aren't" : "isn't"} one of its fields`;
|
|
404
|
-
if (issue.code === "invalid_type") {
|
|
405
|
-
if (field === void 0) return `isn't ${KINDS[issue.expected] ?? issue.expected}`;
|
|
406
|
-
if (issue.input === void 0) return issue.expected === "array" ? `needs its ${field}, a list, even if it's empty` : `needs its ${field}`;
|
|
407
|
-
return `has a ${field} that isn't ${KINDS[issue.expected] ?? issue.expected}`;
|
|
408
|
-
}
|
|
409
|
-
return field === void 0 ? "isn't right" : `has a ${field} that isn't right`;
|
|
410
|
-
};
|
|
411
|
-
const KINDS = {
|
|
412
|
-
string: "text",
|
|
413
|
-
number: "a number",
|
|
414
|
-
array: "a list",
|
|
415
|
-
object: "an object",
|
|
416
|
-
boolean: "true or false"
|
|
417
|
-
};
|
|
418
|
-
function whose(subject, path, value) {
|
|
419
|
-
const things = [];
|
|
420
|
-
let here = value;
|
|
421
|
-
for (const [i, key] of path.entries()) {
|
|
422
|
-
here = here && typeof here === "object" ? here[key] : void 0;
|
|
423
|
-
if (typeof key === "number") things.unshift(nameOf(String(path[i - 1]), here, key));
|
|
424
|
-
else if (path[i - 1] === "accounts") things.unshift(`what it says about the change ${String(key)}`);
|
|
425
|
-
}
|
|
426
|
-
return things.length ? `${subject}: ${things.join(" in ")}` : subject;
|
|
427
|
-
}
|
|
428
|
-
function nameOf(list, item, index) {
|
|
429
|
-
const { name, title, words, id, note } = item ?? {};
|
|
430
|
-
return {
|
|
431
|
-
flows: isText$1(name) && `the flow “${name}”`,
|
|
432
|
-
steps: isText$1(title) && `the step “${title}”`,
|
|
433
|
-
changes: isText$1(words) && `the change “${cut$1(words)}”` || isText$1(id) && `the change ${id}`,
|
|
434
|
-
replies: isText$1(note) && `the reply to note ${note}`,
|
|
435
|
-
notes: isText$1(id) && `note ${id}`,
|
|
436
|
-
verdicts: isText$1(note) && `the verdict on note ${note}`
|
|
437
|
-
}[list] || `${{
|
|
438
|
-
flows: "flow",
|
|
439
|
-
steps: "step",
|
|
440
|
-
changes: "change",
|
|
441
|
-
seen: "place it's seen",
|
|
442
|
-
replies: "reply",
|
|
443
|
-
notes: "note",
|
|
444
|
-
verdicts: "verdict",
|
|
445
|
-
looksGood: "flow said to look good"
|
|
446
|
-
}[list] ?? list} ${index + 1}`;
|
|
447
|
-
}
|
|
448
|
-
function problemsWithPlace(place, flows, what) {
|
|
449
|
-
const flow = flows.find((flow) => flow.name === place.flow);
|
|
450
|
-
if (!flow) return [`${what} is on the flow “${place.flow}”, which isn't there.`];
|
|
451
|
-
const end = Math.max(place.at ?? 0, place.until ?? 0);
|
|
452
|
-
return end <= flow.length ? [] : [`${what} is at ${end}s, but “${flow.name}” is ${flow.length}s long.`];
|
|
453
|
-
}
|
|
454
|
-
function duplicates(values, problem) {
|
|
455
|
-
const seen = /* @__PURE__ */ new Set();
|
|
456
|
-
return [...new Set(values.filter((value) => seen.has(value) || !seen.add(value)))].map(problem);
|
|
457
|
-
}
|
|
458
|
-
const roundName = (number) => number === 0 ? "Before" : `round ${number}`;
|
|
459
|
-
const describe = (change) => `the change ${change.id} (${cut$1(change.words)})`;
|
|
460
|
-
const where = ({ seen: [first] }) => first ? `, in ${quote(first.flow)}${first.at === void 0 ? "" : ` at ${time(first.at)}`}` : "";
|
|
461
|
-
const cut$1 = (words) => words.length > 48 ? `${words.slice(0, 47).trimEnd()}…` : words;
|
|
462
|
-
const isText$1 = (value) => typeof value === "string" && value.trim() !== "";
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
463
12
|
// ../review/src/steps.ts
|
|
464
13
|
function inStep(at, from, to, clicks) {
|
|
465
14
|
if (at <= 0) return at;
|
|
@@ -519,219 +68,292 @@ function pausedAt(flow, at) {
|
|
|
519
68
|
actions: 1
|
|
520
69
|
};
|
|
521
70
|
}
|
|
522
|
-
// ../review/src/
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
started: about.started,
|
|
560
|
-
rounds: rounds.filter((round) => round !== void 0),
|
|
561
|
-
comparisons: comparisons.flat().filter((comparison) => comparison !== void 0).sort((a, b) => a.round - b.round || a.since - b.since),
|
|
562
|
-
sends: sends.filter((send) => send !== void 0).sort((a, b) => a.sent.localeCompare(b.sent) || a.id.localeCompare(b.id)),
|
|
563
|
-
taken: await this.#readJson(`${id}/taken.json`) ?? [],
|
|
564
|
-
...accepted && { accepted }
|
|
565
|
-
};
|
|
566
|
-
}
|
|
567
|
-
async start(id, title, asked) {
|
|
568
|
-
return this.#one(id, async () => {
|
|
569
|
-
const problems = problemsStarting(id, title, asked);
|
|
570
|
-
if (problems.length) throw new Refused(problems);
|
|
571
|
-
if (await this.storage.read(`${id}/review.json`)) throw new Refused([`There's already a review called “${id}”.`]);
|
|
572
|
-
await this.#writeJson(`${id}/review.json`, {
|
|
573
|
-
format: 1,
|
|
574
|
-
id,
|
|
575
|
-
title,
|
|
576
|
-
...asked && { asked },
|
|
577
|
-
started: now()
|
|
578
|
-
});
|
|
579
|
-
return this.get(id);
|
|
580
|
-
});
|
|
581
|
-
}
|
|
582
|
-
async saveRound(id, round) {
|
|
583
|
-
return this.#one(id, async () => {
|
|
584
|
-
const review = await this.get(id);
|
|
585
|
-
const kept = {
|
|
586
|
-
...round,
|
|
587
|
-
finished: void 0
|
|
71
|
+
// ../review/src/folder.ts
|
|
72
|
+
const LOOK_EVERY = 2e3;
|
|
73
|
+
function folder(root) {
|
|
74
|
+
return {
|
|
75
|
+
async read(path) {
|
|
76
|
+
try {
|
|
77
|
+
return new Uint8Array(await readFile(join(root, path)));
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (isMissing(error)) return void 0;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
async write(path, bytes) {
|
|
84
|
+
const file = join(root, path);
|
|
85
|
+
const unfinished = `${file}.${process.pid}.${Date.now()}.writing`;
|
|
86
|
+
await mkdir(dirname(file), { recursive: true });
|
|
87
|
+
await writeFile(unfinished, bytes);
|
|
88
|
+
await rename(unfinished, file);
|
|
89
|
+
},
|
|
90
|
+
async list(path) {
|
|
91
|
+
try {
|
|
92
|
+
return (await readdir(join(root, path))).filter((name) => !name.endsWith(".writing"));
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (isMissing(error)) return [];
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
watch(path, changed) {
|
|
99
|
+
const look = setInterval(changed, LOOK_EVERY);
|
|
100
|
+
let watcher;
|
|
101
|
+
try {
|
|
102
|
+
watcher = watch(join(root, path), { recursive: true }, changed);
|
|
103
|
+
watcher.on("error", () => watcher?.close());
|
|
104
|
+
} catch {}
|
|
105
|
+
return () => {
|
|
106
|
+
clearInterval(look);
|
|
107
|
+
watcher?.close();
|
|
588
108
|
};
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
const
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
}))]);
|
|
624
|
-
return fresh;
|
|
625
|
-
});
|
|
626
|
-
}
|
|
627
|
-
async finish(id, round) {
|
|
628
|
-
return this.#one(id, async () => {
|
|
629
|
-
const review = await this.get(id);
|
|
630
|
-
const saved = review.rounds.find((saved) => saved.number === round);
|
|
631
|
-
if (saved?.finished) return;
|
|
632
|
-
const problems = problemsFinishing(review, round);
|
|
633
|
-
if (problems.length || !saved) throw new Refused(problems);
|
|
634
|
-
await this.#writeJson(`${id}/rounds/${round}/round.json`, {
|
|
635
|
-
...saved,
|
|
636
|
-
finished: now()
|
|
637
|
-
});
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
async addToBefore(id, added) {
|
|
641
|
-
return this.#one(id, async () => {
|
|
642
|
-
const review = await this.get(id);
|
|
643
|
-
const problems = problemsAddingToBefore(review, added.flows, added.cantStart);
|
|
644
|
-
for (const { name, bytes } of added.files) {
|
|
645
|
-
const named = problemsWithFileName(name);
|
|
646
|
-
problems.push(...named);
|
|
647
|
-
const there = named.length ? void 0 : await this.storage.read(`${id}/rounds/0/files/${name}`);
|
|
648
|
-
if (there && Buffer.compare(there, bytes)) problems.push(`Before has a file called “${name}” already, with something else in it.`);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const isMissing = (error) => error.code === "ENOENT";
|
|
113
|
+
// src/repo.ts
|
|
114
|
+
function repo(root = home(process.cwd())) {
|
|
115
|
+
const reviews = join(root, ".playthrough");
|
|
116
|
+
const appFile = join(root, "playthrough", "app.json");
|
|
117
|
+
return {
|
|
118
|
+
root,
|
|
119
|
+
reviews: new Reviews(folder(reviews)),
|
|
120
|
+
flows: (review) => join(reviews, review, "flows"),
|
|
121
|
+
failed: (review) => join(reviews, review, "failed"),
|
|
122
|
+
round: (review, round) => join(reviews, review, "rounds", String(round), "round.json"),
|
|
123
|
+
ranOnBefore: (review) => join(reviews, review, "ran-on-before.json"),
|
|
124
|
+
setAside: join(reviews, "set-aside.json"),
|
|
125
|
+
appFile,
|
|
126
|
+
setup: join(root, "playthrough", "setup.ts"),
|
|
127
|
+
borrowed: join(reviews, "borrowed"),
|
|
128
|
+
tried: join(reviews, "tried"),
|
|
129
|
+
show: (path) => relative(process.cwd(), path) || ".",
|
|
130
|
+
async keepOutOfGit() {
|
|
131
|
+
await mkdir(reviews, { recursive: true });
|
|
132
|
+
await writeFile(join(reviews, ".gitignore"), "*\n");
|
|
133
|
+
},
|
|
134
|
+
async app() {
|
|
135
|
+
const how = `Say how to start the app in playthrough/app.json: { "start": "<the command that serves it>", "url": "<where it's served>" }.`;
|
|
136
|
+
const text = await readFile(appFile, "utf8").catch(() => void 0);
|
|
137
|
+
if (text === void 0) throw new Refused([`Playthrough needs to start the app to record it. ${how}`]);
|
|
138
|
+
let app;
|
|
139
|
+
try {
|
|
140
|
+
app = JSON.parse(text);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
throw new Refused([`playthrough/app.json isn't JSON: ${error.message}. ${how}`]);
|
|
649
143
|
}
|
|
650
|
-
if (
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
144
|
+
if (!isText(app.start) || !isText(app.url)) throw new Refused([`playthrough/app.json needs both “start” and “url”. ${how}`]);
|
|
145
|
+
if (app.oneAtATime !== void 0 && typeof app.oneAtATime !== "boolean") throw new Refused([`playthrough/app.json's “oneAtATime” is true or false: true runs each flow by itself.`]);
|
|
146
|
+
return {
|
|
147
|
+
start: app.start,
|
|
148
|
+
url: app.url,
|
|
149
|
+
...app.oneAtATime && { oneAtATime: true }
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function home(here) {
|
|
155
|
+
for (let folder = here;; folder = dirname(folder)) {
|
|
156
|
+
if (existsSync(join(folder, ".playthrough")) || existsSync(join(folder, "playthrough", "app.json"))) return folder;
|
|
157
|
+
if (existsSync(join(folder, ".git")) || dirname(folder) === folder) return here;
|
|
655
158
|
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
159
|
+
}
|
|
160
|
+
const isText = (value) => typeof value === "string" && value.trim() !== "";
|
|
161
|
+
// src/setup.ts
|
|
162
|
+
const OURS = [
|
|
163
|
+
"baseURL",
|
|
164
|
+
"viewport",
|
|
165
|
+
"screen",
|
|
166
|
+
"isMobile",
|
|
167
|
+
"hasTouch",
|
|
168
|
+
"deviceScaleFactor",
|
|
169
|
+
"locale",
|
|
170
|
+
"timezoneId",
|
|
171
|
+
"colorScheme",
|
|
172
|
+
"reducedMotion",
|
|
173
|
+
"forcedColors",
|
|
174
|
+
"contrast"
|
|
175
|
+
];
|
|
176
|
+
async function setupIn(file, written) {
|
|
177
|
+
if (!existsSync(file)) return {};
|
|
178
|
+
const href = pathToFileURL(file).href;
|
|
179
|
+
asModule(href);
|
|
180
|
+
const module = await import(`${href}${written ? `?${written}` : ""}`);
|
|
181
|
+
const inner = typeof module.default === "object" ? module.default : void 0;
|
|
182
|
+
const use = module.use ?? inner?.use;
|
|
183
|
+
const ours = Object.keys(use ?? {}).filter((option) => OURS.includes(option));
|
|
184
|
+
if (ours.length) throw new Refused([`playthrough/setup.ts's use sets ${list(ours)}, which Playthrough keeps the same for every flow, or a flow sets with test.use, like its screen. Take ${ours.length > 1 ? "them" : "it"} out of use.`]);
|
|
185
|
+
return {
|
|
186
|
+
make: typeof module.default === "function" ? module.default : inner?.default,
|
|
187
|
+
after: module.after ?? inner?.after,
|
|
188
|
+
signIn: module.signIn ?? inner?.signIn,
|
|
189
|
+
people: module.people ?? inner?.people,
|
|
190
|
+
...use && { use }
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const setups = /* @__PURE__ */ new Set();
|
|
194
|
+
function asModule(href) {
|
|
195
|
+
if (!setups.size) modules.registerHooks({ load: (url, context, next) => next(url, setups.has(url.split("?")[0]) ? {
|
|
196
|
+
...context,
|
|
197
|
+
format: "module-typescript"
|
|
198
|
+
} : context) });
|
|
199
|
+
setups.add(href);
|
|
200
|
+
}
|
|
201
|
+
const changes = (method) => ![
|
|
202
|
+
"GET",
|
|
203
|
+
"HEAD",
|
|
204
|
+
"OPTIONS"
|
|
205
|
+
].includes(method.toUpperCase());
|
|
206
|
+
function watching(request, changed) {
|
|
207
|
+
return new Proxy(request, { get(target, name) {
|
|
208
|
+
const value = Reflect.get(target, name);
|
|
209
|
+
if (typeof value !== "function") return value;
|
|
210
|
+
return (...args) => {
|
|
211
|
+
const [to, options] = args;
|
|
212
|
+
const method = name === "fetch" ? options?.method ?? (typeof to === "string" ? "GET" : to.method()) : String(name);
|
|
213
|
+
if ([
|
|
214
|
+
"post",
|
|
215
|
+
"put",
|
|
216
|
+
"patch",
|
|
217
|
+
"delete",
|
|
218
|
+
"fetch"
|
|
219
|
+
].includes(String(name)) && changes(method)) changed();
|
|
220
|
+
return value.apply(target, args);
|
|
221
|
+
};
|
|
222
|
+
} });
|
|
223
|
+
}
|
|
224
|
+
const firstOf$1 = ({ people }) => Object.keys(people ?? {})[0];
|
|
225
|
+
function personIn({ people }, person) {
|
|
226
|
+
if (people && !(person in people)) throw new Error(`playthrough/setup.ts names no one called ${quote(person)}. Its people: ${list(Object.keys(people).map(quote))}. Add them there, or do the flow as one of them.`);
|
|
227
|
+
}
|
|
228
|
+
async function make({ make, after }, borrowed, given) {
|
|
229
|
+
const setup = await make?.(given) ?? {};
|
|
230
|
+
if (!after) return {
|
|
231
|
+
setup,
|
|
232
|
+
giveBack: async () => false
|
|
233
|
+
};
|
|
234
|
+
await mkdir(borrowed, { recursive: true });
|
|
235
|
+
const file = join(borrowed, `${process.pid}-${randomUUID()}.json`);
|
|
236
|
+
await writeFile(file, JSON.stringify({
|
|
237
|
+
baseURL: given.baseURL,
|
|
238
|
+
setup
|
|
239
|
+
}));
|
|
240
|
+
return {
|
|
241
|
+
setup,
|
|
242
|
+
giveBack: () => giveBack(after, file, given.baseURL, setup)
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const giveBackLeft = (setup, borrowed) => giveBackOf(setup, borrowed, (pid) => !alive(pid));
|
|
246
|
+
const giveBackOwn = (setup, borrowed) => giveBackOf(setup, borrowed, (pid) => pid === process.pid);
|
|
247
|
+
async function giveBackOf({ after }, borrowed, whose) {
|
|
248
|
+
if (!after) return;
|
|
249
|
+
const left = (await readdir(borrowed).catch(() => [])).filter((name) => whose(Number(name.split("-")[0])));
|
|
250
|
+
for (const name of left) {
|
|
251
|
+
const file = join(borrowed, `${process.pid}-${name.slice(name.indexOf("-") + 1)}`);
|
|
252
|
+
if (!await rename(join(borrowed, name), file).then(() => true, () => false)) continue;
|
|
253
|
+
const { baseURL, setup } = JSON.parse(await readFile(file, "utf8"));
|
|
254
|
+
await giveBack(after, file, baseURL, setup);
|
|
662
255
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
256
|
+
}
|
|
257
|
+
const giving = /* @__PURE__ */ new Set();
|
|
258
|
+
async function giveBack(after, file, baseURL, setup) {
|
|
259
|
+
if (giving.has(file) || !existsSync(file)) return true;
|
|
260
|
+
giving.add(file);
|
|
261
|
+
const request$2 = await request.newContext({ baseURL });
|
|
262
|
+
try {
|
|
263
|
+
await after({
|
|
264
|
+
request: request$2,
|
|
265
|
+
baseURL,
|
|
266
|
+
setup
|
|
671
267
|
});
|
|
268
|
+
await rm(file, { force: true });
|
|
269
|
+
return true;
|
|
270
|
+
} catch (error) {
|
|
271
|
+
console.log(`playthrough/setup.ts's after couldn't give back what a run made: ${firstLine(String(error?.message ?? error))}. It's tried again by the next command.`);
|
|
272
|
+
return false;
|
|
273
|
+
} finally {
|
|
274
|
+
giving.delete(file);
|
|
275
|
+
await request$2.dispose();
|
|
672
276
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
by,
|
|
681
|
-
at: now()
|
|
682
|
-
});
|
|
683
|
-
});
|
|
277
|
+
}
|
|
278
|
+
function alive(pid) {
|
|
279
|
+
try {
|
|
280
|
+
process.kill(pid, 0);
|
|
281
|
+
return true;
|
|
282
|
+
} catch (error) {
|
|
283
|
+
return error.code === "EPERM";
|
|
684
284
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
285
|
+
}
|
|
286
|
+
// src/app.ts
|
|
287
|
+
let started;
|
|
288
|
+
let ending = false;
|
|
289
|
+
async function keepRunning(app, root) {
|
|
290
|
+
if (!ending) {
|
|
291
|
+
ending = true;
|
|
292
|
+
const { setup, borrowed } = repo(root);
|
|
293
|
+
const giveBack = () => setupIn(setup).then((setup) => giveBackOwn(setup, borrowed));
|
|
294
|
+
const end = (signal) => void Promise.race([giveBack(), new Promise((wait) => setTimeout(wait, 1e4))]).finally(() => process.exit(128 + constants.signals[signal]));
|
|
295
|
+
for (const signal of [
|
|
296
|
+
"SIGINT",
|
|
297
|
+
"SIGTERM",
|
|
298
|
+
"SIGHUP"
|
|
299
|
+
]) process.once(signal, end);
|
|
300
|
+
process.once("exit", stop);
|
|
689
301
|
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
} while (again);
|
|
709
|
-
} catch (error) {
|
|
710
|
-
done(() => reject(error));
|
|
711
|
-
} finally {
|
|
712
|
-
looking = false;
|
|
713
|
-
}
|
|
714
|
-
};
|
|
715
|
-
const aborted = () => done(() => reject(signal?.reason));
|
|
716
|
-
const stop = this.storage.watch(id, look);
|
|
717
|
-
signal?.addEventListener("abort", aborted, { once: true });
|
|
718
|
-
look();
|
|
302
|
+
if (await serves(app.url)) return;
|
|
303
|
+
console.log(`Starting the app: ${app.start}`);
|
|
304
|
+
const began = Date.now();
|
|
305
|
+
started = spawn(app.start, {
|
|
306
|
+
cwd: root,
|
|
307
|
+
shell: true,
|
|
308
|
+
stdio: [
|
|
309
|
+
"ignore",
|
|
310
|
+
"pipe",
|
|
311
|
+
"pipe"
|
|
312
|
+
],
|
|
313
|
+
detached: true
|
|
314
|
+
});
|
|
315
|
+
const said = [];
|
|
316
|
+
for (const output of [started.stdout, started.stderr]) {
|
|
317
|
+
output.on("data", (chunk) => {
|
|
318
|
+
said.push(...chunk.toString().split("\n").filter((line) => line.trim() && !/^\s+at /.test(line)));
|
|
319
|
+
said.splice(0, said.length - 10);
|
|
719
320
|
});
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
321
|
+
output.unref();
|
|
322
|
+
}
|
|
323
|
+
started.unref();
|
|
324
|
+
let ended = false;
|
|
325
|
+
started.once("exit", () => ended = true);
|
|
326
|
+
const saying = () => said.length ? ["It said, last:", ...said.map((line) => ` ${line}`)] : [];
|
|
327
|
+
while (Date.now() - began < 18e4) {
|
|
328
|
+
if (await serves(app.url)) return Date.now() - began;
|
|
329
|
+
if (ended) throw new Refused([
|
|
330
|
+
`${quote(app.start)} ended before the app answered at ${app.url}.`,
|
|
331
|
+
...saying(),
|
|
332
|
+
"Check that playthrough/app.json starts the app there."
|
|
333
|
+
]);
|
|
334
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
335
|
+
}
|
|
336
|
+
stop();
|
|
337
|
+
throw new Refused([
|
|
338
|
+
`The app didn't answer at ${app.url} within three minutes of ${quote(app.start)}.`,
|
|
339
|
+
...saying(),
|
|
340
|
+
"Check that playthrough/app.json starts it there."
|
|
341
|
+
]);
|
|
342
|
+
}
|
|
343
|
+
async function stopApp(url) {
|
|
344
|
+
if (!stop()) return;
|
|
345
|
+
const began = Date.now();
|
|
346
|
+
while (Date.now() - began < 1e4 && await serves(url)) await new Promise((resolve) => setTimeout(resolve, 250));
|
|
347
|
+
}
|
|
348
|
+
function stop() {
|
|
349
|
+
if (!started) return false;
|
|
350
|
+
try {
|
|
351
|
+
process.kill(-started.pid);
|
|
352
|
+
} catch {}
|
|
353
|
+
started = void 0;
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
const serves = (url) => fetch(url, { signal: AbortSignal.timeout(2e3) }).then((response) => (response.body?.cancel(), true), (error) => error?.name === "TimeoutError");
|
|
735
357
|
// ../page/src/names.ts
|
|
736
358
|
const WORD = {
|
|
737
359
|
button: "button",
|
|
@@ -761,7 +383,7 @@ const WORD = {
|
|
|
761
383
|
const word = (thing) => WORD[thing.role] ?? thing.role;
|
|
762
384
|
const symbolOnly = (thing) => (thing.role === "button" || thing.role === "link") && !!thing.name && thing.words.replace(/\s/g, "").length <= 2 && !/[\p{L}\p{N}]/u.test(thing.words);
|
|
763
385
|
const cut = (words, most = 60) => words.length > most ? words.slice(0, most - 1).trimEnd() + "…" : words;
|
|
764
|
-
function called(thing) {
|
|
386
|
+
function called$1(thing) {
|
|
765
387
|
if (thing.role === "status" || thing.role === "alert") return "Message";
|
|
766
388
|
if (thing.role === "box") return `“${cut(areaName(thing))}” ${thing.live ? "message" : "area"}`;
|
|
767
389
|
if (thing.role === "text") return `“${cut(thing.words)}”`;
|
|
@@ -775,7 +397,7 @@ function areaName({ name, wordsInside }) {
|
|
|
775
397
|
return wordsInside.slice(name.length).trim() || name;
|
|
776
398
|
}
|
|
777
399
|
const the = (thing) => {
|
|
778
|
-
const name = called(thing);
|
|
400
|
+
const name = called$1(thing);
|
|
779
401
|
return name === "Message" ? "the message" : name.startsWith("“") && thing.role === "text" ? name : `the ${name}`;
|
|
780
402
|
};
|
|
781
403
|
const a = (word) => /^[aeiou]/.test(word) ? `an ${word}` : `a ${word}`;
|
|
@@ -784,7 +406,7 @@ const things = (count) => count > 1 ? "things" : "thing";
|
|
|
784
406
|
const dataLeftOut = (words) => words.replace(/\d[\d.,:/]*/g, "…");
|
|
785
407
|
const sentence = (words) => words.charAt(0).toUpperCase() + words.slice(1);
|
|
786
408
|
const wayOf = (across, down) => [down > 0 ? "down" : down < 0 ? "up" : "", across > 0 ? "right" : across < 0 ? "left" : ""].filter(Boolean).join(" and ");
|
|
787
|
-
function together(all, name = called) {
|
|
409
|
+
function together(all, name = called$1) {
|
|
788
410
|
if (all.every((thing) => thing.role === "menuitem" && thing.name)) return `${together(all, (thing) => `“${thing.name}”`)} in the menu`;
|
|
789
411
|
const names = all.map(name);
|
|
790
412
|
let shared = names.reduce((common, next) => {
|
|
@@ -799,7 +421,7 @@ function together(all, name = called) {
|
|
|
799
421
|
return `${shown.slice(0, -1).join(", ")} and ${shown.at(-1)}`;
|
|
800
422
|
}
|
|
801
423
|
function rewordedWords(a, b) {
|
|
802
|
-
if (a.role !== "text") return `${called(a)} now says “${cut(b.name)}”`;
|
|
424
|
+
if (a.role !== "text") return `${called$1(a)} now says “${cut(b.name)}”`;
|
|
803
425
|
let start = 0;
|
|
804
426
|
while (start < a.words.length && start < b.words.length && a.words[start] === b.words[start]) start++;
|
|
805
427
|
let end = 0;
|
|
@@ -929,7 +551,7 @@ function differences(then, now) {
|
|
|
929
551
|
});
|
|
930
552
|
continue;
|
|
931
553
|
}
|
|
932
|
-
a = b.role !== "text" ? take((a) => a.role === b.role && (a.name === b.name || samePlace(a, b) || mostlyWhereItWas(a, b) || holdsTheSameWords(a, b))) : 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));
|
|
554
|
+
a = b.role !== "text" ? take((a) => a.role === b.role && (a.name === b.name || samePlace(a, b) || mostlyWhereItWas(a, b) || holdsTheSameWords(a, b) || CONTROL.test(b.role) && (!!bare(b.name) && bare(a.name) === bare(b.name) || overlap(a, b) > .5))) : take((a) => a.role === "text" && samePlace(a, b)) ?? (holderOf(now, b) && goneWords.get(holderKey(now, b)) === newWords.get(holderKey(now, b)) ? take((a) => a.role === "text" && holderKey(then, a) === holderKey(now, b)) : void 0) ?? take((a) => a.role === "text" && holderKey(then, a) === holderKey(now, b) && !!bare(b.words) && bare(a.words) === bare(b.words));
|
|
933
555
|
if (!a) {
|
|
934
556
|
found.push({
|
|
935
557
|
what: "new",
|
|
@@ -1065,6 +687,7 @@ function compared(a, b) {
|
|
|
1065
687
|
then: a,
|
|
1066
688
|
now: b
|
|
1067
689
|
};
|
|
690
|
+
if (unseenBox(a, b)) return void 0;
|
|
1068
691
|
if (!near(a.box.width, b.box.width) || !near(a.box.height, b.box.height)) return {
|
|
1069
692
|
what: "resized",
|
|
1070
693
|
then: a,
|
|
@@ -1076,6 +699,11 @@ function compared(a, b) {
|
|
|
1076
699
|
now: b
|
|
1077
700
|
};
|
|
1078
701
|
}
|
|
702
|
+
function unseenBox(a, b) {
|
|
703
|
+
const boxless = (thing) => /^(transparent|rgba\(0, 0, 0, 0\))$/.test(thing.look.background) && thing.look.border === "none";
|
|
704
|
+
const edges = near(a.page.x, b.page.x) || near(a.page.x + a.box.width, b.page.x + b.box.width);
|
|
705
|
+
return a.role === "text" && a.words === b.words && boxless(a) && boxless(b) && near(a.box.height, b.box.height) && near(a.page.y, b.page.y) && edges;
|
|
706
|
+
}
|
|
1079
707
|
function gained(reading, control, was) {
|
|
1080
708
|
if (!was || !control.name.startsWith(was + " ")) return false;
|
|
1081
709
|
const more = control.name.slice(was.length + 1);
|
|
@@ -1334,12 +962,12 @@ function swappedOrMoved(moment, loose) {
|
|
|
1334
962
|
what: "moved",
|
|
1335
963
|
thing: move.now,
|
|
1336
964
|
in: moment.now,
|
|
1337
|
-
words: `${called(move.now)} ${movedWords(moment, move)}`
|
|
965
|
+
words: `${called$1(move.now)} ${movedWords(moment, move)}`
|
|
1338
966
|
});
|
|
1339
967
|
continue;
|
|
1340
968
|
}
|
|
1341
969
|
const order = [...moves].sort((p, q) => p.now.page.x - q.now.page.x || p.now.page.y - q.now.page.y);
|
|
1342
|
-
const words = moves.length === 2 ? `${together(order.map((move) => move.now))} have swapped places` : `What's in ${the(holder)} is in a new order: ${order.slice(0, 3).map((move) => called(move.now)).join(", ")}${moves.length > 3 ? "…" : ""}`;
|
|
970
|
+
const words = moves.length === 2 ? `${together(order.map((move) => move.now))} have swapped places` : `What's in ${the(holder)} is in a new order: ${order.slice(0, 3).map((move) => called$1(move.now)).join(", ")}${moves.length > 3 ? "…" : ""}`;
|
|
1343
971
|
told.push({
|
|
1344
972
|
what: "swapped",
|
|
1345
973
|
thing: holder,
|
|
@@ -1407,7 +1035,7 @@ function causesOfShifts(moment, shifts, widened, others, caused, along) {
|
|
|
1407
1035
|
};
|
|
1408
1036
|
const candidates = others.filter((d) => !caused.has(d));
|
|
1409
1037
|
const span = spanOf(pushed);
|
|
1410
|
-
const cause = moment.into(firstOf
|
|
1038
|
+
const cause = moment.into(firstOf(moves)) ? void 0 : causeOf(span, looking, candidates) ?? (vertical ? void 0 : causeOf(span, {
|
|
1411
1039
|
...looking,
|
|
1412
1040
|
share: 0
|
|
1413
1041
|
}, candidates));
|
|
@@ -1427,19 +1055,37 @@ function causesOfShifts(moment, shifts, widened, others, caused, along) {
|
|
|
1427
1055
|
passed.add(shift);
|
|
1428
1056
|
}
|
|
1429
1057
|
return unexplained.filter((shift) => !passed.has(shift)).map((shift) => {
|
|
1430
|
-
const first = firstOf
|
|
1431
|
-
const more = shift.moves.length > 1 ? `, with ${shift.moves.length - 1} more ${things(shift.moves.length - 1)} around it` : "";
|
|
1058
|
+
const first = firstOf(shift.moves);
|
|
1432
1059
|
const pushed = movedPast.get(shift);
|
|
1060
|
+
const other = pushed && unexplained.find((one) => passed.has(one) && one.moves.some((move) => pushed.things.includes(move.now)));
|
|
1061
|
+
if (other && alikeInSize(shift, other)) {
|
|
1062
|
+
const calledBy = (shift) => shift.moves.find((move) => move.now.role === "text")?.now ?? firstOf(shift.moves).now;
|
|
1063
|
+
const both = [calledBy(shift), calledBy(other)].sort((p, q) => p.page.y - q.page.y || p.page.x - q.page.x);
|
|
1064
|
+
return {
|
|
1065
|
+
what: "swapped",
|
|
1066
|
+
thing: both[0],
|
|
1067
|
+
in: moment.now,
|
|
1068
|
+
words: `${together(both)} have swapped places`
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
const alike = shift.moves.every((move) => move.now.item !== void 0 && move.now.role === first.now.role) && new Set(shift.moves.map((move) => move.now.item)).size === shift.moves.length;
|
|
1072
|
+
const them = alike ? together([...shift.moves].sort((p, q) => p.now.page.y - q.now.page.y || p.now.page.x - q.now.page.x).map((move) => move.now)) : called$1(first.now);
|
|
1073
|
+
const more = shift.moves.length > 1 && !alike ? `, with ${shift.moves.length - 1} more ${things(shift.moves.length - 1)} around it` : "";
|
|
1433
1074
|
return {
|
|
1434
1075
|
what: "moved",
|
|
1435
1076
|
thing: first.now,
|
|
1436
1077
|
in: moment.now,
|
|
1437
|
-
words: `${
|
|
1078
|
+
words: `${them} ${movedWords(moment, first)}${more}`,
|
|
1438
1079
|
...pushed && { pushed }
|
|
1439
1080
|
};
|
|
1440
1081
|
});
|
|
1441
1082
|
}
|
|
1442
|
-
|
|
1083
|
+
function alikeInSize(one, other) {
|
|
1084
|
+
const [few, many] = [one.moves.length, other.moves.length].sort((p, q) => p - q);
|
|
1085
|
+
const [less, more] = [Math.hypot(one.by.x, one.by.y), Math.hypot(other.by.x, other.by.y)].sort((p, q) => p - q);
|
|
1086
|
+
return few * 2 >= many && less * 2 >= more;
|
|
1087
|
+
}
|
|
1088
|
+
const firstOf = (moves) => [...moves].sort((p, q) => p.now.page.y - q.now.page.y || p.now.page.x - q.now.page.x)[0];
|
|
1443
1089
|
function wentPast(mover, moved) {
|
|
1444
1090
|
const [was, is] = [spanOf(mover.moves.map((move) => move.then)), spanOf(mover.moves.map((move) => move.now))];
|
|
1445
1091
|
const [wasThere, isThere] = [spanOf(moved.moves.map((move) => move.then)), spanOf(moved.moves.map((move) => move.now))];
|
|
@@ -1476,21 +1122,21 @@ function tell(moment, difference, along = {}) {
|
|
|
1476
1122
|
what: "new",
|
|
1477
1123
|
thing: difference.now,
|
|
1478
1124
|
in: now,
|
|
1479
|
-
words: `${called(difference.now)} is new`,
|
|
1125
|
+
words: `${called$1(difference.now)} is new`,
|
|
1480
1126
|
...along
|
|
1481
1127
|
};
|
|
1482
1128
|
case "gone": return {
|
|
1483
1129
|
what: "gone",
|
|
1484
1130
|
thing: difference.then,
|
|
1485
1131
|
in: moment.then,
|
|
1486
|
-
words: `${called(difference.then)} is gone`,
|
|
1132
|
+
words: `${called$1(difference.then)} is gone`,
|
|
1487
1133
|
...along
|
|
1488
1134
|
};
|
|
1489
1135
|
case "became": return {
|
|
1490
1136
|
what: "became",
|
|
1491
1137
|
thing: difference.now,
|
|
1492
1138
|
in: now,
|
|
1493
|
-
words: `${called(difference.then)} is ${a(word(difference.now))} now`,
|
|
1139
|
+
words: `${called$1(difference.then)} is ${a(word(difference.now))} now`,
|
|
1494
1140
|
...along
|
|
1495
1141
|
};
|
|
1496
1142
|
case "reworded": return {
|
|
@@ -1502,7 +1148,7 @@ function tell(moment, difference, along = {}) {
|
|
|
1502
1148
|
};
|
|
1503
1149
|
case "looks different": {
|
|
1504
1150
|
const size = sizeWords(moment, difference);
|
|
1505
|
-
const words = `${called(difference.now)} looks different: ${lookWords(difference.then, difference.now)}${size ? `, and ${size}` : ""}`;
|
|
1151
|
+
const words = `${called$1(difference.now)} looks different: ${lookWords(difference.then, difference.now)}${size ? `, and ${size}` : ""}`;
|
|
1506
1152
|
return {
|
|
1507
1153
|
what: "looks different",
|
|
1508
1154
|
thing: difference.now,
|
|
@@ -1515,7 +1161,7 @@ function tell(moment, difference, along = {}) {
|
|
|
1515
1161
|
what: difference.what,
|
|
1516
1162
|
thing: difference.now,
|
|
1517
1163
|
in: now,
|
|
1518
|
-
words: `${called(difference.now)} ${sizeWords(moment, difference)}`,
|
|
1164
|
+
words: `${called$1(difference.now)} ${sizeWords(moment, difference)}`,
|
|
1519
1165
|
...along
|
|
1520
1166
|
};
|
|
1521
1167
|
}
|
|
@@ -1542,8 +1188,9 @@ function sizeWords(moment, pair) {
|
|
|
1542
1188
|
}
|
|
1543
1189
|
function said(told, moment, unsteady) {
|
|
1544
1190
|
const { now, was, isNow } = moment;
|
|
1545
|
-
const id = (told) => idOf(
|
|
1546
|
-
const
|
|
1191
|
+
const id = (told) => idOf(told.key ?? identityOf(told, moment), told.what);
|
|
1192
|
+
const piecesOf = (told) => told.pieces ?? [id(told)];
|
|
1193
|
+
const inside = groupBy([...groupBy(asItems(told.filter((one) => !unsteady.has(id(one))), moment, id).sort((p, q) => p.thing.page.y - q.thing.page.y || p.thing.page.x - q.thing.page.x), id).values()], ([told]) => {
|
|
1547
1194
|
const holder = holderOf(told.in, told.thing);
|
|
1548
1195
|
const holderNow = holder && (told.in === now ? holder : isNow.get(holder));
|
|
1549
1196
|
return holderNow && TABLES.test(holderNow.role) && was.has(holderNow) ? holderNow : void 0;
|
|
@@ -1555,12 +1202,13 @@ function said(told, moment, unsteady) {
|
|
|
1555
1202
|
id: id(alike[0]),
|
|
1556
1203
|
words: alikeWords(alike),
|
|
1557
1204
|
...placeOf(alike[0], now),
|
|
1558
|
-
...alikeOf(alike, now)
|
|
1205
|
+
...alikeOf(alike, now),
|
|
1206
|
+
pieces: alike.flatMap(piecesOf)
|
|
1559
1207
|
});
|
|
1560
1208
|
continue;
|
|
1561
1209
|
}
|
|
1562
1210
|
const count = all.flat().length;
|
|
1563
|
-
const words = all.flat().every((told) => told.what === "resized" || told.what === "moved") ? `Columns in ${the(holder)} changed width` : `${called(holder)}: ${count} ${things(count)} changed inside`;
|
|
1211
|
+
const words = all.flat().every((told) => told.what === "resized" || told.what === "moved") ? `Columns in ${the(holder)} changed width` : `${called$1(holder)}: ${count} ${things(count)} changed inside`;
|
|
1564
1212
|
const changed = {
|
|
1565
1213
|
what: "changed inside",
|
|
1566
1214
|
thing: holder,
|
|
@@ -1570,11 +1218,130 @@ function said(told, moment, unsteady) {
|
|
|
1570
1218
|
found.push({
|
|
1571
1219
|
id: id(changed),
|
|
1572
1220
|
words: sentence(words),
|
|
1573
|
-
...placeOf(changed, now)
|
|
1221
|
+
...placeOf(changed, now),
|
|
1222
|
+
pieces: all.flat().flatMap(piecesOf)
|
|
1574
1223
|
});
|
|
1575
1224
|
}
|
|
1576
1225
|
return found;
|
|
1577
1226
|
}
|
|
1227
|
+
function identityOf({ in: reading, thing, what }, moment) {
|
|
1228
|
+
const item = thing.item === void 0 ? void 0 : reading.items?.[thing.item];
|
|
1229
|
+
if (!item) return identity(reading, thing);
|
|
1230
|
+
const kind = reading.things.filter((other) => other.item === thing.item && other.role === thing.role && other.column === thing.column);
|
|
1231
|
+
const place = `${thing.role} ${kind.indexOf(thing) + 1}${thing.column ? ` under ${thing.column}` : ""}`;
|
|
1232
|
+
const was = what === "reworded" && moment.was.get(thing);
|
|
1233
|
+
return `${place} in a ${item.role} of ${listOf(reading, item)}${was ? `: ${shapeOf(was.words, thing.words)}` : ""}`;
|
|
1234
|
+
}
|
|
1235
|
+
const listOf = (reading, item) => item.holder === void 0 ? "the page" : identity(reading, reading.things[item.holder], 0);
|
|
1236
|
+
function pairItems({ then, now, was }) {
|
|
1237
|
+
const [before, after] = [then.items ?? [], now.items ?? []];
|
|
1238
|
+
const pairs = /* @__PURE__ */ new Map();
|
|
1239
|
+
const taken = /* @__PURE__ */ new Set();
|
|
1240
|
+
const votes = /* @__PURE__ */ new Map();
|
|
1241
|
+
for (const [b, a] of was) if (a.item !== void 0 && b.item !== void 0 && a.name === b.name && a.words === b.words) votes.set(`${a.item} ${b.item}`, (votes.get(`${a.item} ${b.item}`) ?? 0) + 1);
|
|
1242
|
+
for (const [pair] of [...votes].sort((p, q) => q[1] - p[1])) {
|
|
1243
|
+
const [a, b] = pair.split(" ").map(Number);
|
|
1244
|
+
if (pairs.has(a) || taken.has(b)) continue;
|
|
1245
|
+
pairs.set(a, b);
|
|
1246
|
+
taken.add(b);
|
|
1247
|
+
}
|
|
1248
|
+
const lists = /* @__PURE__ */ new Map();
|
|
1249
|
+
for (const [a, b] of pairs) if (!lists.has(before[a].list)) lists.set(before[a].list, after[b].list);
|
|
1250
|
+
const left = (items, used) => [...new Set(items.map((item) => item.list))].filter((list) => !used.has(list));
|
|
1251
|
+
const listsNow = left(after, new Set(lists.values()));
|
|
1252
|
+
left(before, new Set(lists.keys())).forEach((list, i) => listsNow[i] !== void 0 && lists.set(list, listsNow[i]));
|
|
1253
|
+
for (const [listThen, listNow] of lists) {
|
|
1254
|
+
const a = before.flatMap((item, i) => item.list === listThen && !pairs.has(i) ? [i] : []);
|
|
1255
|
+
const b = after.flatMap((item, i) => item.list === listNow && !taken.has(i) ? [i] : []);
|
|
1256
|
+
if (a.length === b.length) a.forEach((i, k) => (pairs.set(i, b[k]), taken.add(b[k])));
|
|
1257
|
+
}
|
|
1258
|
+
return pairs;
|
|
1259
|
+
}
|
|
1260
|
+
function asItems(told, moment, id) {
|
|
1261
|
+
const { then, now } = moment;
|
|
1262
|
+
const items = pairItems(moment);
|
|
1263
|
+
const was = new Map([...items].map(([a, b]) => [b, a]));
|
|
1264
|
+
const itemOf = (one) => {
|
|
1265
|
+
if (one.thing.item === void 0 || !/^(new|gone|reworded|became)$/.test(one.what)) return void 0;
|
|
1266
|
+
if (one.in === now) return `now ${one.thing.item}`;
|
|
1267
|
+
const paired = items.get(one.thing.item);
|
|
1268
|
+
return paired === void 0 ? `then ${one.thing.item}` : `now ${paired}`;
|
|
1269
|
+
};
|
|
1270
|
+
const out = [];
|
|
1271
|
+
for (const [key, all] of groupBy(told, itemOf)) {
|
|
1272
|
+
if (!key || all.length < 2) {
|
|
1273
|
+
out.push(...all);
|
|
1274
|
+
continue;
|
|
1275
|
+
}
|
|
1276
|
+
const [side, at] = key.split(" ");
|
|
1277
|
+
const [reading, index] = [side === "then" ? then : now, Number(at)];
|
|
1278
|
+
const item = reading.items[index];
|
|
1279
|
+
const thing = asThing(item, index);
|
|
1280
|
+
const pieces = all.flatMap((one) => one.pieces ?? [id(one)]);
|
|
1281
|
+
const along = all.find((one) => one.pushed || one.sized);
|
|
1282
|
+
const whole = {
|
|
1283
|
+
thing,
|
|
1284
|
+
in: reading,
|
|
1285
|
+
pieces,
|
|
1286
|
+
...along?.pushed && { pushed: along.pushed },
|
|
1287
|
+
...along?.sized && { sized: along.sized }
|
|
1288
|
+
};
|
|
1289
|
+
if (side === "then" || !was.has(index) && all.every((one) => one.what === "new")) {
|
|
1290
|
+
const what = side === "then" ? "gone" : "new";
|
|
1291
|
+
out.push({
|
|
1292
|
+
...whole,
|
|
1293
|
+
what,
|
|
1294
|
+
words: `${called$1(thing)} is ${what}`,
|
|
1295
|
+
key: `${item.role} of ${listOf(reading, item)}`
|
|
1296
|
+
});
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
const before = was.has(index) ? then.items[was.get(index)] : void 0;
|
|
1300
|
+
const words = all.filter((one) => one.thing.role === "text");
|
|
1301
|
+
if (!before || before.words === item.words || words.length < 2) {
|
|
1302
|
+
out.push(...all);
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
const reworded = rewordedWords(asThing(before, was.get(index), "text"), asThing(item, index, "text"));
|
|
1306
|
+
out.push({
|
|
1307
|
+
...whole,
|
|
1308
|
+
what: "reworded",
|
|
1309
|
+
words: reworded,
|
|
1310
|
+
pieces: words.flatMap((one) => one.pieces ?? [id(one)]),
|
|
1311
|
+
key: `${item.role} of ${listOf(now, item)}: ${shapeOf(before.words, item.words)}`
|
|
1312
|
+
}, ...all.filter((one) => one.thing.role !== "text"));
|
|
1313
|
+
}
|
|
1314
|
+
return out;
|
|
1315
|
+
}
|
|
1316
|
+
function asThing(item, index, role = item.role) {
|
|
1317
|
+
const name = item.words.replace(/^(\p{Lu})\s+(?=\1)/u, "").split(" ").filter((word) => /[\p{L}\p{N}]/u.test(word)).join(" ");
|
|
1318
|
+
return {
|
|
1319
|
+
role,
|
|
1320
|
+
name: name.slice(0, 120),
|
|
1321
|
+
words: role === "text" ? item.words : name,
|
|
1322
|
+
box: item.box,
|
|
1323
|
+
page: item.page,
|
|
1324
|
+
look: {
|
|
1325
|
+
color: "",
|
|
1326
|
+
background: "",
|
|
1327
|
+
size: "",
|
|
1328
|
+
weight: "",
|
|
1329
|
+
border: ""
|
|
1330
|
+
},
|
|
1331
|
+
item: index,
|
|
1332
|
+
...item.holder !== void 0 && { holder: item.holder },
|
|
1333
|
+
...item.node !== void 0 && { node: item.node }
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
function shapeOf(then, now) {
|
|
1337
|
+
const [a, b] = [then.split(" "), now.split(" ")];
|
|
1338
|
+
let start = 0;
|
|
1339
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
1340
|
+
let end = 0;
|
|
1341
|
+
while (end < a.length - start && end < b.length - start && a.at(-1 - end) === b.at(-1 - end)) end++;
|
|
1342
|
+
const part = (words) => words.slice(start, words.length - end).map((word) => /\d/.test(word) ? "…" : word).join(" ");
|
|
1343
|
+
return `${part(a)} → ${part(b)}`;
|
|
1344
|
+
}
|
|
1578
1345
|
function alikeWords(alike) {
|
|
1579
1346
|
const [first] = alike;
|
|
1580
1347
|
const them = (along) => along.things.length === 1 ? the(along.things[0]) : `${along.things.length} things`;
|
|
@@ -1591,9 +1358,10 @@ function alikeWords(alike) {
|
|
|
1591
1358
|
const them = symbolOnly(first.thing) ? together(all) : `${together(all, (thing) => thing.name)} ${plural(WORD[first.thing.role])}`;
|
|
1592
1359
|
return sentence(`${them} ${verb}${along}`);
|
|
1593
1360
|
}
|
|
1594
|
-
|
|
1361
|
+
if (names.length > 1 && /^(new|gone)$/.test(first.what) && !WORD[first.thing.role]) return sentence(`${together(alike.map((told) => told.thing))} are ${first.what}${along}`);
|
|
1362
|
+
const name = called$1(first.thing);
|
|
1595
1363
|
const bare = dataLeftOut(name);
|
|
1596
|
-
const shared = new Set(alike.map((told) => called(told.thing))).size > 1 && first.words.startsWith(name) && /[^“”…\s]/.test(bare) ? bare + first.words.slice(name.length) : first.words;
|
|
1364
|
+
const shared = new Set(alike.map((told) => called$1(told.thing))).size > 1 && first.words.startsWith(name) && /[^“”…\s]/.test(bare) ? bare + first.words.slice(name.length) : first.words;
|
|
1597
1365
|
return sentence(`${shared}${along}, in ${alike.length} places`);
|
|
1598
1366
|
}
|
|
1599
1367
|
function idOf(thing, what) {
|
|
@@ -1671,6 +1439,7 @@ function groupBy(items, key) {
|
|
|
1671
1439
|
return groups;
|
|
1672
1440
|
}
|
|
1673
1441
|
// src/pictures.ts
|
|
1442
|
+
const HERE = "here:";
|
|
1674
1443
|
async function picturesAt(page, then, now, pictures) {
|
|
1675
1444
|
if (scrollOf(then) !== scrollOf(now)) return [];
|
|
1676
1445
|
const { differences: found } = differences(then, now);
|
|
@@ -1692,15 +1461,17 @@ async function picturesAt(page, then, now, pictures) {
|
|
|
1692
1461
|
const same = told.get(id);
|
|
1693
1462
|
told.set(id, {
|
|
1694
1463
|
words,
|
|
1695
|
-
patch: same ? around$1(same.patch, patch) : patch
|
|
1464
|
+
patch: same ? around$1(same.patch, patch) : patch,
|
|
1465
|
+
here: !on.identity || on.identity.startsWith("near ")
|
|
1696
1466
|
});
|
|
1697
1467
|
}
|
|
1698
|
-
return [...told].map(([id, { words, patch }]) => {
|
|
1468
|
+
return [...told].map(([id, { words, patch, here }]) => {
|
|
1699
1469
|
const box = onScreen(patch, now.screen);
|
|
1700
1470
|
return {
|
|
1701
1471
|
id,
|
|
1702
1472
|
words: sentence(words),
|
|
1703
|
-
...box && { box }
|
|
1473
|
+
...box && { box },
|
|
1474
|
+
pieces: [here ? `${HERE}${id}` : id]
|
|
1704
1475
|
};
|
|
1705
1476
|
});
|
|
1706
1477
|
}
|
|
@@ -1717,7 +1488,7 @@ function whatsThere(reading, patch) {
|
|
|
1717
1488
|
identity: `column:${bare(cell.column)} in ${identity(reading, table)}`
|
|
1718
1489
|
};
|
|
1719
1490
|
if (round) return {
|
|
1720
|
-
called: called(round),
|
|
1491
|
+
called: called$1(round),
|
|
1721
1492
|
identity: bare(round.name) ? identity(reading, round) : void 0
|
|
1722
1493
|
};
|
|
1723
1494
|
const gap = (thing) => Math.hypot(Math.max(thing.box.x - patch.x - patch.width, 0, patch.x - thing.box.x - thing.box.width), Math.max(thing.box.y - patch.y - patch.height, 0, patch.y - thing.box.y - thing.box.height));
|
|
@@ -1897,7 +1668,7 @@ async function differ({ then, now, explained }) {
|
|
|
1897
1668
|
async function compareWithEarlier(reviews, id, number, flows) {
|
|
1898
1669
|
const earlier = (await reviews.get(id)).rounds.filter((round) => round.number < number);
|
|
1899
1670
|
if (!earlier.length) return [];
|
|
1900
|
-
const unsteady =
|
|
1671
|
+
const unsteady = unsteadyIn([...earlier.flatMap((round) => round.flows), ...flows]);
|
|
1901
1672
|
const browser = await chromium.launch();
|
|
1902
1673
|
try {
|
|
1903
1674
|
const page = await browser.newPage();
|
|
@@ -1912,7 +1683,11 @@ async function compareWithEarlier(reviews, id, number, flows) {
|
|
|
1912
1683
|
async function changesByThemselves(first, second) {
|
|
1913
1684
|
const browser = await chromium.launch();
|
|
1914
1685
|
try {
|
|
1915
|
-
|
|
1686
|
+
const { changes, pieces } = await comparing(await browser.newPage(), first, second);
|
|
1687
|
+
return changes.map((change) => ({
|
|
1688
|
+
...change,
|
|
1689
|
+
pieces: [...pieces.get(change.id)]
|
|
1690
|
+
}));
|
|
1916
1691
|
} finally {
|
|
1917
1692
|
await browser.close();
|
|
1918
1693
|
}
|
|
@@ -1935,11 +1710,22 @@ function kept(reviews, id, number, flows) {
|
|
|
1935
1710
|
picture: file
|
|
1936
1711
|
};
|
|
1937
1712
|
}
|
|
1938
|
-
async function compare(page, earlier, round, unsteady = /* @__PURE__ */ new Set()) {
|
|
1713
|
+
async function compare(page, earlier, round, unsteady = () => /* @__PURE__ */ new Set()) {
|
|
1714
|
+
const { pieces: _, ...comparison } = await comparing(page, earlier, round, unsteady);
|
|
1715
|
+
return comparison;
|
|
1716
|
+
}
|
|
1717
|
+
function unsteadyIn(flows) {
|
|
1718
|
+
const everywhere = flows.flatMap((flow) => (flow.unsteady ?? []).filter((piece) => !piece.startsWith(HERE)));
|
|
1719
|
+
const here = (name) => flows.filter((flow) => flow.name === name).flatMap((flow) => (flow.unsteady ?? []).filter((piece) => piece.startsWith(HERE)).map((piece) => piece.slice(5)));
|
|
1720
|
+
return (name) => /* @__PURE__ */ new Set([...everywhere, ...here(name)]);
|
|
1721
|
+
}
|
|
1722
|
+
async function comparing(page, earlier, round, unsteady = () => /* @__PURE__ */ new Set()) {
|
|
1939
1723
|
const changes = /* @__PURE__ */ new Map();
|
|
1940
|
-
const
|
|
1941
|
-
|
|
1724
|
+
const pieces = /* @__PURE__ */ new Map();
|
|
1725
|
+
const see = ({ id, words, place, of = [id] }) => {
|
|
1726
|
+
if (unsteady(place.flow).has(id)) return;
|
|
1942
1727
|
const change = changes.get(id);
|
|
1728
|
+
for (const piece of of) (pieces.get(id) ?? pieces.set(id, /* @__PURE__ */ new Set()).get(id)).add(piece);
|
|
1943
1729
|
if (!change) changes.set(id, {
|
|
1944
1730
|
id,
|
|
1945
1731
|
words,
|
|
@@ -1949,7 +1735,7 @@ async function compare(page, earlier, round, unsteady = /* @__PURE__ */ new Set(
|
|
|
1949
1735
|
};
|
|
1950
1736
|
for (const flow of round.flows) {
|
|
1951
1737
|
const before = earlier.flows.find((kept) => kept.name === flow.name);
|
|
1952
|
-
if (before) for (const sighting of await compareFlow(page, earlier, before, round, flow, unsteady)) see(sighting);
|
|
1738
|
+
if (before) for (const sighting of await compareFlow(page, earlier, before, round, flow, unsteady(flow.name))) see(sighting);
|
|
1953
1739
|
}
|
|
1954
1740
|
for (const gone of earlier.flows.filter((flow) => !round.flows.some((kept) => kept.name === flow.name))) see({
|
|
1955
1741
|
id: idOf(`flow:${gone.name}`, "gone"),
|
|
@@ -1959,7 +1745,8 @@ async function compare(page, earlier, round, unsteady = /* @__PURE__ */ new Set(
|
|
|
1959
1745
|
return {
|
|
1960
1746
|
round: round.number,
|
|
1961
1747
|
since: earlier.number,
|
|
1962
|
-
changes: [...changes.values()]
|
|
1748
|
+
changes: [...changes.values()],
|
|
1749
|
+
pieces
|
|
1963
1750
|
};
|
|
1964
1751
|
}
|
|
1965
1752
|
async function compareFlow(page, earlier, before, round, flow, unsteady) {
|
|
@@ -1994,7 +1781,8 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
|
|
|
1994
1781
|
flow: flow.name,
|
|
1995
1782
|
at: reading.at,
|
|
1996
1783
|
...on
|
|
1997
|
-
}
|
|
1784
|
+
},
|
|
1785
|
+
of: found.pieces
|
|
1998
1786
|
});
|
|
1999
1787
|
}
|
|
2000
1788
|
}
|
|
@@ -2057,171 +1845,141 @@ function runs(count, isIn) {
|
|
|
2057
1845
|
}
|
|
2058
1846
|
return found;
|
|
2059
1847
|
}
|
|
2060
|
-
//
|
|
2061
|
-
const
|
|
2062
|
-
function
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
1848
|
+
// src/try-setup.ts
|
|
1849
|
+
const SLOW = 2e3;
|
|
1850
|
+
async function trySetup(repo, again) {
|
|
1851
|
+
const app = await repo.app();
|
|
1852
|
+
const written = await writtenNow(repo);
|
|
1853
|
+
const last = join(repo.tried, "tried.json");
|
|
1854
|
+
if (await readFile(last, "utf8").then((text) => JSON.parse(text).written, () => void 0) === written) return;
|
|
1855
|
+
console.log("Trying the app's setup by itself first, twice, as every flow will start…");
|
|
1856
|
+
const browser = await chromium.launch().catch((error) => {
|
|
1857
|
+
const { version } = createRequire(import.meta.url)("@playwright/test/package.json");
|
|
1858
|
+
if (/Executable doesn't exist/.test(String(error?.message))) throw new Refused([`Playwright has no Chromium to run the flows in yet. Install the one Playthrough's Playwright runs (npx playwright@${version} install chromium), then run this again.`]);
|
|
1859
|
+
throw error;
|
|
1860
|
+
});
|
|
1861
|
+
let startedIn;
|
|
1862
|
+
let setup;
|
|
1863
|
+
const tried = [];
|
|
1864
|
+
try {
|
|
1865
|
+
startedIn = await keepRunning(app, repo.root);
|
|
1866
|
+
setup = await setupIn(repo.setup, (await stat(repo.setup).catch(() => void 0))?.mtimeMs);
|
|
1867
|
+
await giveBackLeft(setup, repo.borrowed);
|
|
1868
|
+
await rm(repo.tried, {
|
|
1869
|
+
recursive: true,
|
|
1870
|
+
force: true
|
|
1871
|
+
});
|
|
1872
|
+
await mkdir(repo.tried, { recursive: true });
|
|
1873
|
+
const people = Object.keys(setup.people ?? {});
|
|
1874
|
+
for (const person of people.length ? people : [void 0]) tried.push(await tryAs(repo, browser, setup, app.url, person));
|
|
1875
|
+
} finally {
|
|
1876
|
+
await browser.close();
|
|
1877
|
+
}
|
|
1878
|
+
const width = Math.max(7, ...tried.map(({ who }) => who.length)) + 3;
|
|
1879
|
+
const line = (label, said) => ` ${label.padEnd(width)}${said}`;
|
|
1880
|
+
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
|
+
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
|
+
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, two for a new one, and one each time the person pauses a recording, as it's made ready to take over.");
|
|
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.");
|
|
1885
|
+
const slowest = Math.max(...tried.map(({ took }) => took));
|
|
1886
|
+
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
|
+
console.log(`
|
|
1888
|
+
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.`);
|
|
1889
|
+
if (missing.length) console.log(`
|
|
1890
|
+
${missing.map((said) => `- ${said}`).join("\n")}
|
|
1891
|
+
|
|
1892
|
+
Tell the person now, in a few lines, how flows get into their app, and for each of these, what it costs them and the step that would fix it: something the app has already, or something only they can give, like a test account. Ask before you change their app for it, as with a route only for tests, and don't wait for their answer.`);
|
|
1893
|
+
else console.log(`\nTell the person in a line or two how flows get into their app.`);
|
|
1894
|
+
await writeFile(last, JSON.stringify({ written }));
|
|
1895
|
+
throw new Refused([`\nNothing's recorded yet. Once ${tried.length > 1 ? "each picture shows" : "the picture shows"} where a flow should start, run ${again} again.`]);
|
|
1896
|
+
}
|
|
1897
|
+
async function tryAs(repo, browser, setup, baseURL, person) {
|
|
1898
|
+
const tried = {
|
|
1899
|
+
who: person ? person.charAt(0).toUpperCase() + person.slice(1) : "Every flow",
|
|
1900
|
+
did: setup.make && person && setup.signIn ? "made and signed in" : setup.make ? "made" : person && setup.signIn ? "signed in" : "nothing made",
|
|
1901
|
+
took: 0,
|
|
1902
|
+
at: "",
|
|
1903
|
+
picture: join(repo.tried, `${(person ?? "every flow").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "person"}.png`),
|
|
1904
|
+
made: false,
|
|
1905
|
+
givenBack: true
|
|
1906
|
+
};
|
|
1907
|
+
for (const time of ["first", "second"]) {
|
|
1908
|
+
const context = await browser.newContext({
|
|
1909
|
+
...setup.use,
|
|
1910
|
+
baseURL,
|
|
1911
|
+
locale: "en-US",
|
|
1912
|
+
timezoneId: "UTC",
|
|
1913
|
+
viewport: {
|
|
1914
|
+
width: 1280,
|
|
1915
|
+
height: 800
|
|
2070
1916
|
}
|
|
2071
|
-
}
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
1917
|
+
});
|
|
1918
|
+
const page = await context.newPage();
|
|
1919
|
+
const request$1 = await request.newContext({ baseURL });
|
|
1920
|
+
let made;
|
|
1921
|
+
let answered;
|
|
1922
|
+
page.on("response", (response) => void (response.request().isNavigationRequest() && response.frame() === page.mainFrame() && (answered = response)));
|
|
1923
|
+
try {
|
|
1924
|
+
const began = Date.now();
|
|
1925
|
+
const sent = (sent) => void (changes(sent.method()) && (tried.made = true));
|
|
1926
|
+
page.on("request", sent);
|
|
2080
1927
|
try {
|
|
2081
|
-
|
|
1928
|
+
made = await make(setup, repo.borrowed, {
|
|
1929
|
+
page,
|
|
1930
|
+
request: watching(request$1, () => tried.made = true),
|
|
1931
|
+
baseURL,
|
|
1932
|
+
person
|
|
1933
|
+
});
|
|
1934
|
+
page.off("request", sent);
|
|
1935
|
+
if (Object.keys(made.setup).length) tried.made = true;
|
|
1936
|
+
if (person) await setup.signIn?.({
|
|
1937
|
+
page,
|
|
1938
|
+
person,
|
|
1939
|
+
setup: made.setup
|
|
1940
|
+
});
|
|
2082
1941
|
} catch (error) {
|
|
2083
|
-
|
|
2084
|
-
|
|
1942
|
+
const why = firstLine(String(error?.message ?? error));
|
|
1943
|
+
const as = person ? ` for ${quote(person)}` : "";
|
|
1944
|
+
throw new Refused([time === "first" ? `playthrough/setup.ts couldn't make what a flow starts from${as}: ${why}. Fix it and run this again.` : `playthrough/setup.ts made what a flow starts from${as} once, but not a second time: ${why}. Each run makes its own, so make it new each time, as with a name or an email of its own, and run this again.`]);
|
|
2085
1945
|
}
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
};
|
|
1946
|
+
tried.took = Math.max(tried.took, Date.now() - began);
|
|
1947
|
+
if (page.url() === "about:blank") await page.goto("/");
|
|
1948
|
+
await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {});
|
|
1949
|
+
const url = new URL(page.url());
|
|
1950
|
+
tried.at = url.origin === new URL(baseURL).origin ? `${url.pathname}${url.search}` : url.href;
|
|
1951
|
+
tried.refused = answered && answered.status() >= 400 ? `${answered.status()} ${answered.statusText()}`.trim() : void 0;
|
|
1952
|
+
if (time === "second") await page.screenshot({ path: tried.picture });
|
|
1953
|
+
} finally {
|
|
1954
|
+
await context.close();
|
|
1955
|
+
await request$1.dispose();
|
|
1956
|
+
if (made && setup.after && !await made.giveBack()) tried.givenBack = false;
|
|
2098
1957
|
}
|
|
2099
|
-
};
|
|
2100
|
-
}
|
|
2101
|
-
const isMissing = (error) => error.code === "ENOENT";
|
|
2102
|
-
// src/repo.ts
|
|
2103
|
-
function repo(root = process.cwd()) {
|
|
2104
|
-
const reviews = join(root, ".playthrough");
|
|
2105
|
-
return {
|
|
2106
|
-
root,
|
|
2107
|
-
reviews: new Reviews(folder(reviews)),
|
|
2108
|
-
flows: (review) => join(reviews, review, "flows"),
|
|
2109
|
-
failed: (review) => join(reviews, review, "failed"),
|
|
2110
|
-
round: (review, round) => join(reviews, review, "rounds", String(round), "round.json"),
|
|
2111
|
-
setAside: join(reviews, "set-aside.json"),
|
|
2112
|
-
setup: join(root, "playthrough", "setup.ts"),
|
|
2113
|
-
borrowed: join(reviews, "borrowed"),
|
|
2114
|
-
show: (path) => relative(process.cwd(), path) || ".",
|
|
2115
|
-
async keepOutOfGit() {
|
|
2116
|
-
await mkdir(reviews, { recursive: true });
|
|
2117
|
-
await writeFile(join(reviews, ".gitignore"), "*\n");
|
|
2118
|
-
},
|
|
2119
|
-
async app() {
|
|
2120
|
-
const file = join(root, "playthrough", "app.json");
|
|
2121
|
-
const how = `Say how to start the app in playthrough/app.json: { "start": "<the command that serves it>", "url": "<where it's served>" }.`;
|
|
2122
|
-
const text = await readFile(file, "utf8").catch(() => void 0);
|
|
2123
|
-
if (text === void 0) throw new Refused([`Playthrough needs to start the app to record it. ${how}`]);
|
|
2124
|
-
let app;
|
|
2125
|
-
try {
|
|
2126
|
-
app = JSON.parse(text);
|
|
2127
|
-
} catch (error) {
|
|
2128
|
-
throw new Refused([`playthrough/app.json isn't JSON: ${error.message}. ${how}`]);
|
|
2129
|
-
}
|
|
2130
|
-
if (!isText(app.start) || !isText(app.url)) throw new Refused([`playthrough/app.json needs both “start” and “url”. ${how}`]);
|
|
2131
|
-
return {
|
|
2132
|
-
start: app.start,
|
|
2133
|
-
url: app.url
|
|
2134
|
-
};
|
|
2135
|
-
}
|
|
2136
|
-
};
|
|
2137
|
-
}
|
|
2138
|
-
const isText = (value) => typeof value === "string" && value.trim() !== "";
|
|
2139
|
-
// src/setup.ts
|
|
2140
|
-
async function setupIn(file, written) {
|
|
2141
|
-
if (!existsSync(file)) return {};
|
|
2142
|
-
const module = await import(`${pathToFileURL(file).href}${written ? `?${written}` : ""}`);
|
|
2143
|
-
const inner = typeof module.default === "object" ? module.default : void 0;
|
|
2144
|
-
return {
|
|
2145
|
-
make: typeof module.default === "function" ? module.default : inner?.default,
|
|
2146
|
-
after: module.after ?? inner?.after,
|
|
2147
|
-
signIn: module.signIn ?? inner?.signIn,
|
|
2148
|
-
people: module.people ?? inner?.people
|
|
2149
|
-
};
|
|
2150
|
-
}
|
|
2151
|
-
const firstOf = ({ people }) => Object.keys(people ?? {})[0];
|
|
2152
|
-
function personIn({ people }, person) {
|
|
2153
|
-
if (people && !(person in people)) throw new Error(`playthrough/setup.ts names no one called ${quote(person)}. Its people: ${list(Object.keys(people).map(quote))}. Add them there, or do the flow as one of them.`);
|
|
2154
|
-
}
|
|
2155
|
-
async function make({ make, after }, borrowed, given) {
|
|
2156
|
-
const setup = await make?.(given) ?? {};
|
|
2157
|
-
if (!after) return {
|
|
2158
|
-
setup,
|
|
2159
|
-
giveBack: async () => {}
|
|
2160
|
-
};
|
|
2161
|
-
await mkdir(borrowed, { recursive: true });
|
|
2162
|
-
const file = join(borrowed, `${process.pid}-${randomUUID()}.json`);
|
|
2163
|
-
await writeFile(file, JSON.stringify({
|
|
2164
|
-
baseURL: given.baseURL,
|
|
2165
|
-
setup
|
|
2166
|
-
}));
|
|
2167
|
-
return {
|
|
2168
|
-
setup,
|
|
2169
|
-
giveBack: () => giveBack(after, file, given.baseURL, setup)
|
|
2170
|
-
};
|
|
2171
|
-
}
|
|
2172
|
-
const giveBackLeft = (setup, borrowed) => giveBackOf(setup, borrowed, (pid) => !alive(pid));
|
|
2173
|
-
const giveBackOwn = (setup, borrowed) => giveBackOf(setup, borrowed, (pid) => pid === process.pid);
|
|
2174
|
-
async function giveBackOf({ after }, borrowed, whose) {
|
|
2175
|
-
if (!after) return;
|
|
2176
|
-
const left = (await readdir(borrowed).catch(() => [])).filter((name) => whose(Number(name.split("-")[0])));
|
|
2177
|
-
for (const name of left) {
|
|
2178
|
-
const file = join(borrowed, `${process.pid}-${name.slice(name.indexOf("-") + 1)}`);
|
|
2179
|
-
if (!await rename(join(borrowed, name), file).then(() => true, () => false)) continue;
|
|
2180
|
-
const { baseURL, setup } = JSON.parse(await readFile(file, "utf8"));
|
|
2181
|
-
await giveBack(after, file, baseURL, setup);
|
|
2182
1958
|
}
|
|
1959
|
+
return tried;
|
|
2183
1960
|
}
|
|
2184
|
-
const
|
|
2185
|
-
async function
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
const request$1 = await request.newContext({ baseURL });
|
|
2189
|
-
try {
|
|
2190
|
-
await after({
|
|
2191
|
-
request: request$1,
|
|
2192
|
-
baseURL,
|
|
2193
|
-
setup
|
|
2194
|
-
});
|
|
2195
|
-
await rm(file, { force: true });
|
|
2196
|
-
} catch (error) {
|
|
2197
|
-
console.log(`playthrough/setup.ts's after couldn't give back what a run made: ${firstLine(String(error?.message ?? error))}. It's tried again by the next command.`);
|
|
2198
|
-
} finally {
|
|
2199
|
-
giving.delete(file);
|
|
2200
|
-
await request$1.dispose();
|
|
2201
|
-
}
|
|
2202
|
-
}
|
|
2203
|
-
function alive(pid) {
|
|
2204
|
-
try {
|
|
2205
|
-
process.kill(pid, 0);
|
|
2206
|
-
return true;
|
|
2207
|
-
} catch (error) {
|
|
2208
|
-
return error.code === "EPERM";
|
|
2209
|
-
}
|
|
1961
|
+
const seconds = (ms) => ms < 100 ? "under 0.1 s" : `${(ms / 1e3).toFixed(1)} s`;
|
|
1962
|
+
async function writtenNow(repo) {
|
|
1963
|
+
const text = (path) => readFile(path, "utf8").catch(() => "");
|
|
1964
|
+
return createHash("sha256").update(await text(repo.appFile)).update("\0").update(await text(repo.setup)).digest("hex");
|
|
2210
1965
|
}
|
|
2211
1966
|
// src/flows.ts
|
|
2212
1967
|
const WALKED = "walked.json";
|
|
2213
1968
|
const ALONGSIDE = Symbol.for("playthrough: flows taken over here");
|
|
2214
1969
|
const REACHES_OUTSIDE = "reaches outside";
|
|
1970
|
+
const waitsFor = ({ before }) => before ? 3e3 : 1e4;
|
|
2215
1971
|
async function makeFlowsFolder(repo, id) {
|
|
2216
1972
|
await mkdir(repo.flows(id), { recursive: true });
|
|
2217
1973
|
await writeFile(join(repo.flows(id), "package.json"), `{ "type": "module" }\n`, { flag: "wx" }).catch((error) => {
|
|
2218
1974
|
if (error.code !== "EEXIST") throw error;
|
|
2219
1975
|
});
|
|
2220
1976
|
}
|
|
1977
|
+
const called = (names) => names.length > 3 ? `${names.length} flows` : list(names.map(quote));
|
|
2221
1978
|
const isFlow = (name) => /\.[cm]?[jt]s$/.test(name);
|
|
2222
1979
|
function howToWriteFlows(repo, id) {
|
|
2223
1980
|
const setupMissing = !existsSync(repo.setup);
|
|
2224
|
-
|
|
1981
|
+
const missing = !existsSync(repo.appFile) ? `, and how to start the app in playthrough/app.json. This app has ${setupMissing ? "neither" : "no app.json"} yet: run playthrough alone for how. Playthrough tries them before it records anything.` : setupMissing ? ", which this app doesn't have yet: run playthrough alone for how." : ".";
|
|
1982
|
+
return `Each flow is a Playwright test in a .ts file in ${repo.show(repo.flows(id))}, one for each thing a person does, with a step for each part of it:
|
|
2225
1983
|
|
|
2226
1984
|
import { test, expect } from "playthrough/test";
|
|
2227
1985
|
|
|
@@ -2235,17 +1993,18 @@ function howToWriteFlows(repo, id) {
|
|
|
2235
1993
|
});
|
|
2236
1994
|
|
|
2237
1995
|
The test's name is the flow's, and steps are matched across rounds by their names. What every flow starts from, like
|
|
2238
|
-
the account it opens, is made in playthrough/setup.ts${
|
|
1996
|
+
the account it opens, is made in playthrough/setup.ts${missing}`;
|
|
2239
1997
|
}
|
|
2240
1998
|
async function runFlows(repo, id, round, { later, first }) {
|
|
2241
1999
|
const flows = repo.flows(id);
|
|
2242
2000
|
if (!(await readdir(flows).catch(() => [])).some(isFlow)) throw new Refused([`${id} has no flows yet. ${howToWriteFlows(repo, id)}`]);
|
|
2243
2001
|
await makeFlowsFolder(repo, id);
|
|
2244
2002
|
const app = await repo.app();
|
|
2003
|
+
if (!later) await trySetup(repo, round === 0 ? `playthrough start ${id}` : `playthrough record ${id}`);
|
|
2245
2004
|
const { started, rounds } = await repo.reviews.get(id);
|
|
2246
2005
|
const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
|
|
2247
2006
|
const longest = (rounds.filter((kept) => kept.number < round).at(-1)?.flows ?? []).toSorted((a, b) => b.length - a.length).map((flow) => flow.name);
|
|
2248
|
-
const run = (only, results) => runOnce({
|
|
2007
|
+
const run = (only, results, stops) => runOnce({
|
|
2249
2008
|
flows,
|
|
2250
2009
|
only,
|
|
2251
2010
|
longest,
|
|
@@ -2255,44 +2014,63 @@ async function runFlows(repo, id, round, { later, first }) {
|
|
|
2255
2014
|
recorder,
|
|
2256
2015
|
before: round === 0,
|
|
2257
2016
|
taken: later?.taken ?? [],
|
|
2258
|
-
started
|
|
2017
|
+
started,
|
|
2018
|
+
stops
|
|
2259
2019
|
});
|
|
2260
2020
|
const last = round > 0 ? await failedLastTime(repo, id) : [];
|
|
2261
2021
|
if (last.length) {
|
|
2262
|
-
console.log(`Running ${
|
|
2263
|
-
const { failed } = await run(last, repo.failed(id));
|
|
2022
|
+
console.log(`Running ${called(last)} first: ${last.length > 1 ? "they" : "it"} failed last time…`);
|
|
2023
|
+
const { recorded, failed } = await run(last, repo.failed(id));
|
|
2264
2024
|
await keepFailed(repo, id, round, failed);
|
|
2265
2025
|
const [one] = failed;
|
|
2266
2026
|
if (failed.length > 1) throw new Refused([
|
|
2267
|
-
|
|
2027
|
+
`\nThese flows failed again, so the other flows weren't run:`,
|
|
2268
2028
|
...failed.map(failedAt),
|
|
2269
2029
|
`Fix them and record again. ${whereItStopped(repo, id, failed)}`
|
|
2270
2030
|
]);
|
|
2271
|
-
if (one) throw new Refused([
|
|
2272
|
-
|
|
2031
|
+
if (one) throw new Refused([`\n${quote(one.flow)} failed again${one.at ? `, at the step ${quote(one.at)}` : ""}, so the other flows weren't run.`, `Fix it and record again. ${whereItStopped(repo, id, failed)}`]);
|
|
2032
|
+
const gone = !recorded.length;
|
|
2033
|
+
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`);
|
|
2273
2034
|
}
|
|
2274
|
-
console.log(later ? `Recording ${
|
|
2035
|
+
console.log(later ? `Recording ${called(later.flows)} on Before, the app as it was…` : round === 0 ? `Recording Before: each flow of ${id} on the app as it is…` : `Recording round ${round} of ${id}…`);
|
|
2275
2036
|
const once = await run(later?.flows, repo.failed(id));
|
|
2276
2037
|
await keepFailed(repo, id, round, once.failed);
|
|
2277
2038
|
const again = once.recorded.filter(({ flow }) => first(flow.name));
|
|
2278
2039
|
if (!again.length || round > 0 && once.failed.length) return once;
|
|
2279
|
-
console.log(`\nRecording ${
|
|
2280
|
-
const
|
|
2281
|
-
|
|
2040
|
+
console.log(`\nRecording ${called(again.map(({ flow }) => flow.name))} again, to see what changes by itself…`);
|
|
2041
|
+
const stops = Object.fromEntries(once.failed.flatMap(({ flow, at }) => at ? [[flow, at]] : []));
|
|
2042
|
+
const results = once.failed.length ? void 0 : repo.failed(id);
|
|
2043
|
+
const twice = await run(again.map(({ flow }) => flow.name), results, stops);
|
|
2044
|
+
if (results) await keepFailed(repo, id, round, twice.failed);
|
|
2282
2045
|
const unlike = again.filter(({ flow }) => {
|
|
2283
2046
|
const failed = (ran) => ran.failed.find((failure) => failure.flow === flow.name)?.at;
|
|
2284
2047
|
return !twice.recorded.some((kept) => kept.flow.name === flow.name) || failed(once) !== failed(twice);
|
|
2285
2048
|
});
|
|
2286
|
-
if (unlike.length) throw new Refused([
|
|
2049
|
+
if (unlike.length) throw new Refused([
|
|
2050
|
+
`\n${list(unlike.map(({ flow }) => quote(flow.name)))} didn't go the same way twice on the same app:`,
|
|
2051
|
+
...unlike.map(({ flow }) => ` ${quote(flow.name)}: the first time, ${went(once, flow.name)}; the second, ${went(twice, flow.name)}.`),
|
|
2052
|
+
`So ${unlike.length > 1 ? "they don't" : "it doesn't"} start from the same place each time. ${app.oneAtATime ? "Make what every flow starts from in playthrough/setup.ts, the same each time and new for each run, and run this again." : `Flows run several at once on the one app, so what one makes, another can meet: make what every flow starts from in playthrough/setup.ts, the same each time and new for each run. Where a page shows what every run made, as a blog's index lists every post, say "oneAtATime": true in playthrough/app.json instead. Then run this again.`}`
|
|
2053
|
+
]);
|
|
2287
2054
|
const byThemselves = await changesByThemselves(inMemory(round, again), inMemory(round, twice.recorded.filter(({ flow }) => first(flow.name))));
|
|
2288
2055
|
for (const { flow } of again) {
|
|
2289
2056
|
const unsteady = byThemselves.filter((change) => change.seen.some((place) => place.flow === flow.name));
|
|
2290
2057
|
if (!unsteady.length) continue;
|
|
2291
|
-
flow.unsteady = unsteady.
|
|
2292
|
-
|
|
2058
|
+
flow.unsteady = [...new Set(unsteady.flatMap((change) => change.pieces))];
|
|
2059
|
+
const here = unsteady.filter((change) => change.pieces.every((piece) => piece.startsWith(HERE)));
|
|
2060
|
+
const everywhere = unsteady.filter((change) => !here.includes(change));
|
|
2061
|
+
const listed = (changes) => changes.map((change) => ` ${change.words}`).join("\n");
|
|
2062
|
+
if (everywhere.length) console.log(`\nIn ${quote(flow.name)}, this changes by itself, so it's left out of every comparison, in every flow:\n${listed(everywhere)}`);
|
|
2063
|
+
if (here.length) console.log(`\nIn ${quote(flow.name)}, this part of a picture changes by itself, so it's left out of this flow's comparisons, where it's known only by its place:\n${listed(here)}`);
|
|
2064
|
+
console.log("If it can be made the same each time, in playthrough/setup.ts, do that and record again: a real change to it won't be seen.");
|
|
2293
2065
|
}
|
|
2294
2066
|
return once;
|
|
2295
2067
|
}
|
|
2068
|
+
function went(ran, name) {
|
|
2069
|
+
const failure = ran.failed.find((failed) => failed.flow === name);
|
|
2070
|
+
if (!failure) return "it went to its end";
|
|
2071
|
+
const because = failure.why ? `, where Playwright says “${failure.why}”` : "";
|
|
2072
|
+
return failure.at ? `it stopped at ${quote(failure.at)}${because}` : `it stopped before its first step${because}`;
|
|
2073
|
+
}
|
|
2296
2074
|
async function keepFailed(repo, id, round, failed) {
|
|
2297
2075
|
const folder = repo.failed(id);
|
|
2298
2076
|
if (!failed.length) return rm(folder, {
|
|
@@ -2333,21 +2111,24 @@ async function runOnce(run) {
|
|
|
2333
2111
|
folder
|
|
2334
2112
|
});
|
|
2335
2113
|
const text = await readFile(join(folder, "flows.json"), "utf8").catch(() => void 0);
|
|
2336
|
-
const { recorded, failed } = text ? JSON.parse(text) : {
|
|
2114
|
+
const { recorded, failed, written } = text ? JSON.parse(text) : {
|
|
2337
2115
|
recorded: [],
|
|
2338
|
-
failed: []
|
|
2116
|
+
failed: [],
|
|
2117
|
+
written: {}
|
|
2339
2118
|
};
|
|
2340
|
-
if (!ran && !failed.length) throw new Refused(["
|
|
2341
|
-
const withFiles = ({ flow, files }) => Promise.all(files.map(async (name) => ({
|
|
2119
|
+
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 = ({ flow, files, wrote }) => Promise.all(files.map(async (name) => ({
|
|
2342
2121
|
name,
|
|
2343
2122
|
bytes: await readFile(join(folder, name))
|
|
2344
2123
|
}))).then((files) => ({
|
|
2345
2124
|
flow,
|
|
2346
|
-
files
|
|
2125
|
+
files,
|
|
2126
|
+
...wrote && { wrote }
|
|
2347
2127
|
}));
|
|
2348
2128
|
return {
|
|
2349
2129
|
recorded: await Promise.all(recorded.map(withFiles)),
|
|
2350
|
-
failed
|
|
2130
|
+
failed,
|
|
2131
|
+
written
|
|
2351
2132
|
};
|
|
2352
2133
|
} finally {
|
|
2353
2134
|
await rm(folder, { recursive: true });
|
|
@@ -2390,15 +2171,16 @@ async function walkFork(repo, id, walk) {
|
|
|
2390
2171
|
});
|
|
2391
2172
|
}
|
|
2392
2173
|
}
|
|
2393
|
-
const failedAt = ({ flow, at }) => ` ${quote(flow)}${at ? `, at the step ${quote(at)}` : ""}`;
|
|
2174
|
+
const failedAt = ({ flow, at, why }) => ` ${quote(flow)}${at ? `, at the step ${quote(at)}` : ""}${why ? `, where Playwright says “${why}”` : ""}`;
|
|
2394
2175
|
async function playwright(run) {
|
|
2395
|
-
const { setup, borrowed } = repo(run.root);
|
|
2396
|
-
await giveBackLeft(await setupIn(setup), borrowed);
|
|
2397
2176
|
try {
|
|
2398
2177
|
createRequire(join(run.root, "package.json")).resolve("playthrough/test");
|
|
2399
2178
|
} catch {
|
|
2400
2179
|
throw new Refused(["Playthrough isn't installed in this app, so its flows can't import playthrough/test. Install it as a dev dependency (npm install --save-dev playthrough), then run this again."]);
|
|
2401
2180
|
}
|
|
2181
|
+
await keepRunning(run.app, run.root);
|
|
2182
|
+
const { setup, borrowed } = repo(run.root);
|
|
2183
|
+
await giveBackLeft(await setupIn(setup), borrowed);
|
|
2402
2184
|
const cli = createRequire(import.meta.url).resolve("@playwright/test/cli");
|
|
2403
2185
|
const config = fileURLToPath(new URL("./playwright.config.js", import.meta.url));
|
|
2404
2186
|
return new Promise((resolve, reject) => {
|
|
@@ -2424,237 +2206,4 @@ async function playwright(run) {
|
|
|
2424
2206
|
});
|
|
2425
2207
|
});
|
|
2426
2208
|
}
|
|
2427
|
-
|
|
2428
|
-
function readPage(page) {
|
|
2429
|
-
return page.evaluate(read);
|
|
2430
|
-
}
|
|
2431
|
-
async function unhovered(page, look) {
|
|
2432
|
-
await page.evaluate(hovering, false);
|
|
2433
|
-
try {
|
|
2434
|
-
return await look();
|
|
2435
|
-
} finally {
|
|
2436
|
-
await page.evaluate(hovering, true);
|
|
2437
|
-
}
|
|
2438
|
-
}
|
|
2439
|
-
function hovering(on) {
|
|
2440
|
-
const kept = window;
|
|
2441
|
-
if (on) {
|
|
2442
|
-
for (const [rule, selector] of kept.__playthroughHover ?? []) rule.selectorText = selector;
|
|
2443
|
-
kept.__playthroughHover = [];
|
|
2444
|
-
return;
|
|
2445
|
-
}
|
|
2446
|
-
const set = kept.__playthroughHover = [];
|
|
2447
|
-
const visit = (rules) => {
|
|
2448
|
-
for (const rule of rules) {
|
|
2449
|
-
if (rule instanceof CSSStyleRule && rule.selectorText.includes(":hover")) {
|
|
2450
|
-
set.push([rule, rule.selectorText]);
|
|
2451
|
-
rule.selectorText = rule.selectorText.replaceAll(":hover", ":not(*)");
|
|
2452
|
-
}
|
|
2453
|
-
if ("cssRules" in rule) visit(rule.cssRules);
|
|
2454
|
-
}
|
|
2455
|
-
};
|
|
2456
|
-
for (const sheet of [...document.styleSheets, ...document.adoptedStyleSheets]) try {
|
|
2457
|
-
visit(sheet.cssRules);
|
|
2458
|
-
} catch {}
|
|
2459
|
-
getComputedStyle(document.body).color;
|
|
2460
|
-
for (const animation of document.getAnimations()) if (animation instanceof CSSTransition) animation.finish();
|
|
2461
|
-
}
|
|
2462
|
-
function read() {
|
|
2463
|
-
const ROLES = /^(button|link|dialog|alertdialog|group|radiogroup|table|grid|treegrid|heading|status|alert|textbox|searchbox|combobox|checkbox|radio|switch|slider|listbox|option|tab|menu|menuitem|img)$/;
|
|
2464
|
-
const HOLDERS = /^(dialog|alertdialog|status|alert|group|radiogroup|table|grid|treegrid|listbox|menu|box)$/;
|
|
2465
|
-
const NAMED_BY_WORDS = /^(button|link|heading|option|tab|menuitem|radio|checkbox|switch)$/;
|
|
2466
|
-
const MARKED = /^(B|STRONG|I|EM|U|S|MARK|SMALL|CODE|KBD|SPAN|ABBR|TIME|SUB|SUP|Q|DATA|VAR|DEL|INS|BDI|CITE|DFN)$/;
|
|
2467
|
-
const INPUTS = {
|
|
2468
|
-
checkbox: "checkbox",
|
|
2469
|
-
radio: "radio",
|
|
2470
|
-
range: "slider",
|
|
2471
|
-
button: "button",
|
|
2472
|
-
submit: "button",
|
|
2473
|
-
reset: "button",
|
|
2474
|
-
search: "searchbox"
|
|
2475
|
-
};
|
|
2476
|
-
const TAGS = {
|
|
2477
|
-
BUTTON: "button",
|
|
2478
|
-
SUMMARY: "button",
|
|
2479
|
-
DIALOG: "dialog",
|
|
2480
|
-
TABLE: "table",
|
|
2481
|
-
SELECT: "combobox",
|
|
2482
|
-
TEXTAREA: "textbox",
|
|
2483
|
-
OUTPUT: "status",
|
|
2484
|
-
FIELDSET: "group",
|
|
2485
|
-
IMG: "img",
|
|
2486
|
-
H1: "heading",
|
|
2487
|
-
H2: "heading",
|
|
2488
|
-
H3: "heading",
|
|
2489
|
-
H4: "heading",
|
|
2490
|
-
H5: "heading",
|
|
2491
|
-
H6: "heading"
|
|
2492
|
-
};
|
|
2493
|
-
const TABLES = "table, [role=table], [role=grid], [role=treegrid]";
|
|
2494
|
-
const mirror = window.rrwebRecord?.record?.mirror;
|
|
2495
|
-
const plain = (words) => words.replace(/\s+/g, " ").trim();
|
|
2496
|
-
const cut = (words, most) => words.length > most ? words.slice(0, most - 1).trimEnd() + "…" : words;
|
|
2497
|
-
const style = (element) => getComputedStyle(element);
|
|
2498
|
-
function roleOf(element) {
|
|
2499
|
-
const given = element.getAttribute("role")?.trim().split(/\s+/)[0];
|
|
2500
|
-
if (given) return given;
|
|
2501
|
-
if (element.tagName === "A") return element.hasAttribute("href") ? "link" : void 0;
|
|
2502
|
-
if (element.tagName === "INPUT") {
|
|
2503
|
-
const type = (element.getAttribute("type") ?? "text").toLowerCase();
|
|
2504
|
-
return type === "hidden" ? void 0 : INPUTS[type] ?? "textbox";
|
|
2505
|
-
}
|
|
2506
|
-
return TAGS[element.tagName];
|
|
2507
|
-
}
|
|
2508
|
-
const apart = (element, side) => {
|
|
2509
|
-
const looks = style(element);
|
|
2510
|
-
return !looks.display.startsWith("inline") || parseFloat(looks[`margin${side}`]) + parseFloat(looks[`padding${side}`]) > 0 ? " " : "";
|
|
2511
|
-
};
|
|
2512
|
-
function spoken(element) {
|
|
2513
|
-
let words = "";
|
|
2514
|
-
for (const node of element.childNodes) if (node.nodeType === Node.TEXT_NODE) words += node.textContent;
|
|
2515
|
-
else if (node instanceof HTMLBRElement) words += " ";
|
|
2516
|
-
else if (node instanceof Element) words += apart(node, "Left") + spoken(node) + apart(node, "Right");
|
|
2517
|
-
return words;
|
|
2518
|
-
}
|
|
2519
|
-
function ownWords(element) {
|
|
2520
|
-
let words = "";
|
|
2521
|
-
for (const node of element.childNodes) if (node.nodeType === Node.TEXT_NODE) words += node.textContent;
|
|
2522
|
-
else if (node instanceof Element && MARKED.test(node.tagName) && !called(node) && !panel(node)) {
|
|
2523
|
-
const looks = style(node);
|
|
2524
|
-
if (looks.display === "none" || looks.visibility === "hidden") continue;
|
|
2525
|
-
words += apart(node, "Left") + ownWords(node) + apart(node, "Right");
|
|
2526
|
-
}
|
|
2527
|
-
return plain(words);
|
|
2528
|
-
}
|
|
2529
|
-
const labelOf = (element) => {
|
|
2530
|
-
const by = element.getAttribute("aria-labelledby");
|
|
2531
|
-
return plain(by ? by.split(/\s+/).map((id) => document.getElementById(id)?.textContent ?? "").join(" ") : element.getAttribute("aria-label") ?? "");
|
|
2532
|
-
};
|
|
2533
|
-
function nameOf(element, role) {
|
|
2534
|
-
let name = labelOf(element);
|
|
2535
|
-
if (!name && NAMED_BY_WORDS.test(role)) name = plain(spoken(element));
|
|
2536
|
-
if (!name && "labels" in element) name = plain([...element.labels ?? []].map((label) => label.textContent).join(" "));
|
|
2537
|
-
if (!name) name = element.getAttribute("alt") ?? element.getAttribute("title") ?? element.getAttribute("placeholder") ?? "";
|
|
2538
|
-
return cut(plain(name), 120);
|
|
2539
|
-
}
|
|
2540
|
-
function called(element) {
|
|
2541
|
-
const role = roleOf(element);
|
|
2542
|
-
if (!role || !ROLES.test(role)) return void 0;
|
|
2543
|
-
const name = nameOf(element, role);
|
|
2544
|
-
return /group$/.test(role) && !name ? void 0 : {
|
|
2545
|
-
role,
|
|
2546
|
-
name
|
|
2547
|
-
};
|
|
2548
|
-
}
|
|
2549
|
-
const labelledPart = (element) => (/^(SECTION|ASIDE|NAV|FORM|ARTICLE)$/.test(element.tagName) || /^(region|form|navigation|complementary|search|article)$/.test(element.getAttribute("role") ?? "")) && !!labelOf(element);
|
|
2550
|
-
function panel(element) {
|
|
2551
|
-
const box = element.getBoundingClientRect();
|
|
2552
|
-
if (box.width < 12 || box.height < 12 || box.width * box.height > .9 * innerWidth * innerHeight) return false;
|
|
2553
|
-
if (/^(HTML|BODY|MAIN|TR|TBODY|THEAD|TD|TH)$/.test(element.tagName)) return false;
|
|
2554
|
-
if (labelledPart(element)) return true;
|
|
2555
|
-
const looks = style(element);
|
|
2556
|
-
const background = looks.backgroundColor;
|
|
2557
|
-
const around = element.parentElement && style(element.parentElement).backgroundColor;
|
|
2558
|
-
if (!/^(transparent|rgba\(0, 0, 0, 0\))$/.test(background) && background !== around) return true;
|
|
2559
|
-
if (looks.boxShadow !== "none") return true;
|
|
2560
|
-
return [
|
|
2561
|
-
"Top",
|
|
2562
|
-
"Right",
|
|
2563
|
-
"Bottom",
|
|
2564
|
-
"Left"
|
|
2565
|
-
].filter((side) => parseFloat(looks[`border${side}Width`]) > 0 && looks[`border${side}Style`] !== "none").length >= 3;
|
|
2566
|
-
}
|
|
2567
|
-
const firstWords = (element) => {
|
|
2568
|
-
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
2569
|
-
for (let node; node = walker.nextNode();) if (plain(node.textContent ?? "")) return plain(node.textContent ?? "");
|
|
2570
|
-
return "";
|
|
2571
|
-
};
|
|
2572
|
-
const allWords = (element) => {
|
|
2573
|
-
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
2574
|
-
const words = [];
|
|
2575
|
-
for (let node; node = walker.nextNode();) words.push(node.textContent ?? "");
|
|
2576
|
-
return plain(words.join(" "));
|
|
2577
|
-
};
|
|
2578
|
-
function headingsOf(table) {
|
|
2579
|
-
const cells = [...table.querySelectorAll("th, [role=columnheader]")].filter((cell) => cell.closest(TABLES) === table);
|
|
2580
|
-
const row = cells[0]?.parentElement;
|
|
2581
|
-
const headings = cells.filter((cell) => cell.parentElement === row).map((cell) => {
|
|
2582
|
-
const box = cell.getBoundingClientRect();
|
|
2583
|
-
return {
|
|
2584
|
-
name: cut(plain(spoken(cell)), 120),
|
|
2585
|
-
from: box.left,
|
|
2586
|
-
to: box.right,
|
|
2587
|
-
bottom: box.bottom
|
|
2588
|
-
};
|
|
2589
|
-
}).filter((heading) => heading.name && heading.to > heading.from);
|
|
2590
|
-
return headings.length ? headings : void 0;
|
|
2591
|
-
}
|
|
2592
|
-
const things = [];
|
|
2593
|
-
function walk(element, holder, parent, headings) {
|
|
2594
|
-
for (const child of element.children) {
|
|
2595
|
-
const looks = style(child);
|
|
2596
|
-
if (looks.display === "none" || looks.visibility === "hidden" || Number(looks.opacity) === 0) continue;
|
|
2597
|
-
const box = child.getBoundingClientRect();
|
|
2598
|
-
const under = child.matches(TABLES) ? headingsOf(child) : headings;
|
|
2599
|
-
const own = plain([...child.childNodes].filter((node) => node.nodeType === Node.TEXT_NODE).map((node) => node.textContent).join(" "));
|
|
2600
|
-
let known = called(child);
|
|
2601
|
-
if (!known && !own && panel(child)) {
|
|
2602
|
-
const name = labelOf(child) || firstWords(child);
|
|
2603
|
-
if (name) known = {
|
|
2604
|
-
role: "box",
|
|
2605
|
-
name: cut(name, 120)
|
|
2606
|
-
};
|
|
2607
|
-
}
|
|
2608
|
-
let inside = holder;
|
|
2609
|
-
let readParent = parent;
|
|
2610
|
-
if ((known || own) && box.width >= 1 && box.height >= 1) {
|
|
2611
|
-
const middle = box.left + box.width / 2;
|
|
2612
|
-
const column = headings?.find((heading) => middle >= heading.from && middle < heading.to && box.top >= heading.bottom - 2)?.name;
|
|
2613
|
-
const node = mirror?.getId(child);
|
|
2614
|
-
const words = cut(own ? ownWords(child) : "", 300);
|
|
2615
|
-
const wordsInside = known && /^(box|menu)$/.test(known.role) ? cut(allWords(child), 300) : void 0;
|
|
2616
|
-
things.push({
|
|
2617
|
-
role: known?.role ?? "text",
|
|
2618
|
-
name: known?.name ?? cut(words, 120),
|
|
2619
|
-
words,
|
|
2620
|
-
...wordsInside && wordsInside !== words && { wordsInside },
|
|
2621
|
-
box: {
|
|
2622
|
-
x: Math.round(box.left),
|
|
2623
|
-
y: Math.round(box.top),
|
|
2624
|
-
width: Math.round(box.width),
|
|
2625
|
-
height: Math.round(box.height)
|
|
2626
|
-
},
|
|
2627
|
-
page: {
|
|
2628
|
-
x: Math.round(box.left + scrollX),
|
|
2629
|
-
y: Math.round(box.top + scrollY)
|
|
2630
|
-
},
|
|
2631
|
-
look: {
|
|
2632
|
-
color: looks.color,
|
|
2633
|
-
background: looks.backgroundColor,
|
|
2634
|
-
size: looks.fontSize,
|
|
2635
|
-
weight: looks.fontWeight,
|
|
2636
|
-
border: parseFloat(looks.borderTopWidth) && looks.borderTopStyle !== "none" ? `${looks.borderTopWidth} ${looks.borderTopStyle} ${looks.borderTopColor}` : "none"
|
|
2637
|
-
},
|
|
2638
|
-
...holder !== void 0 && { holder },
|
|
2639
|
-
...parent !== void 0 && { parent },
|
|
2640
|
-
...column && { column },
|
|
2641
|
-
...known?.role === "box" && labelOf(child) && { labelled: true },
|
|
2642
|
-
...child.closest("[aria-live]:not([aria-live=\"off\"]), [role=\"status\"], [role=\"alert\"], [role=\"log\"]") && { live: true },
|
|
2643
|
-
...node !== void 0 && node >= 0 && { node }
|
|
2644
|
-
});
|
|
2645
|
-
if (known && HOLDERS.test(known.role)) inside = things.length - 1;
|
|
2646
|
-
readParent = things.length - 1;
|
|
2647
|
-
}
|
|
2648
|
-
walk(child, inside, readParent, under);
|
|
2649
|
-
}
|
|
2650
|
-
}
|
|
2651
|
-
walk(document.body, void 0, void 0, void 0);
|
|
2652
|
-
return {
|
|
2653
|
-
screen: {
|
|
2654
|
-
width: innerWidth,
|
|
2655
|
-
height: innerHeight
|
|
2656
|
-
},
|
|
2657
|
-
things
|
|
2658
|
-
};
|
|
2659
|
-
}
|
|
2660
|
-
export { problemsFinishing as A, shownAt as C, conversation as D, allNotes as E, list as M, quote as N, lastSeen as O, time as P, placeIn as S, pausedAt as T, make as _, REACHES_OUTSIDE as a, repo as b, howToWriteFlows as c, runFlows as d, walkFork as f, giveBackOwn as g, giveBackLeft as h, ALONGSIDE as i, roundInProgress as j, notesToAnswer as k, isFlow as l, firstOf as m, readPage as n, WALKED as o, whereItStopped as p, unhovered as r, failedAt as s, read as t, makeFlowsFolder as u, personIn as v, Refused as w, compareWithEarlier as x, setupIn as y };
|
|
2209
|
+
export { make as C, pausedAt as D, repo as E, giveBackLeft as S, setupIn as T, keepRunning as _, failedAt as a, changes as b, makeFlowsFolder as c, walkFork as d, whereItStopped as f, shownAt as g, placeIn as h, called as i, runFlows as l, compareWithEarlier as m, REACHES_OUTSIDE as n, howToWriteFlows as o, trySetup as p, WALKED as r, isFlow as s, ALONGSIDE as t, waitsFor as u, serves as v, personIn as w, firstOf$1 as x, stopApp as y };
|