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.
@@ -1,465 +1,14 @@
1
- import { d as firstLine, f as glimpsesOf, p as readingsOf } from "./reporter-Biym3TnN.js";
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 { tmpdir } from "node:os";
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
- // ../review/src/model.ts
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/store.ts
523
- var Refused = class extends Error {
524
- problems;
525
- constructor(problems) {
526
- super(problems.join("\n"));
527
- this.name = "Refused";
528
- this.problems = problems;
529
- }
530
- };
531
- var Reviews = class {
532
- storage;
533
- #queues = /* @__PURE__ */ new Map();
534
- constructor(storage) {
535
- this.storage = storage;
536
- }
537
- async list() {
538
- const ids = (await this.storage.list("")).filter((id) => !problemsWithId(id).length);
539
- const started = await Promise.all(ids.map(async (id) => await this.storage.read(`${id}/review.json`) !== void 0));
540
- return ids.filter((_, i) => started[i]).sort();
541
- }
542
- async get(id) {
543
- if (problemsWithId(id).length) throw new Refused(problemsWithId(id));
544
- const about = await this.#readJson(`${id}/review.json`);
545
- if (!about) throw new Refused([`There's no review called “${id}”.`]);
546
- const numbers = (await this.storage.list(`${id}/rounds`)).map(Number).filter(Number.isInteger).sort((a, b) => a - b);
547
- const rounds = await Promise.all(numbers.map((n) => this.#readJson(`${id}/rounds/${n}/round.json`)));
548
- const comparisons = await Promise.all(numbers.map(async (n) => {
549
- const names = (await this.storage.list(`${id}/rounds/${n}/since`)).filter((name) => name.endsWith(".json"));
550
- return Promise.all(names.map((name) => this.#readJson(`${id}/rounds/${n}/since/${name}`)));
551
- }));
552
- const names = (await this.storage.list(`${id}/sends`)).filter((name) => name.endsWith(".json"));
553
- const sends = await Promise.all(names.map((name) => this.#readJson(`${id}/sends/${name}`)));
554
- const accepted = await this.#readJson(`${id}/accepted.json`);
555
- return {
556
- id,
557
- title: about.title,
558
- ...about.asked && { asked: about.asked },
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
- const problems = problemsWithRound(review, kept);
590
- if (problems.length) throw new Refused(problems);
591
- await this.#writeJson(`${id}/rounds/${round.number}/round.json`, kept);
592
- });
593
- }
594
- async saveFile(id, round, name, bytes) {
595
- return this.#one(id, async () => {
596
- const problems = problemsWithFile(await this.get(id), round, name);
597
- if (problems.length) throw new Refused(problems);
598
- await this.storage.write(`${id}/rounds/${round}/files/${name}`, bytes);
599
- });
600
- }
601
- async saveBranch(id, name, bytes) {
602
- const problems = [...problemsWithId(id), ...problemsWithFileName(name)];
603
- if (problems.length) throw new Refused(problems);
604
- await this.storage.write(`${id}/branches/${name}`, bytes);
605
- }
606
- async branch(id, name) {
607
- const problems = [...problemsWithId(id), ...problemsWithFileName(name)];
608
- if (problems.length) throw new Refused(problems);
609
- return this.storage.read(`${id}/branches/${name}`);
610
- }
611
- async take(id) {
612
- return this.#one(id, async () => {
613
- const review = await this.get(id);
614
- if (review.accepted) return [];
615
- const fresh = review.sends.filter((send) => !review.taken.some((taken) => taken.send === send.id));
616
- if (!fresh.length) return [];
617
- const round = roundInProgress(review);
618
- const at = now();
619
- await this.#writeJson(`${id}/taken.json`, [...review.taken, ...fresh.map((send) => ({
620
- send: send.id,
621
- round,
622
- at
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 (problems.length) throw new Refused(problems);
651
- const before = review.rounds.find((round) => round.number === 0);
652
- for (const { name, bytes } of added.files) await this.storage.write(`${id}/rounds/0/files/${name}`, bytes);
653
- await this.#writeJson(`${id}/rounds/0/round.json`, withInBefore(before, added.flows, added.cantStart));
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
- async saveComparison(id, comparison) {
657
- return this.#one(id, async () => {
658
- const problems = problemsWithComparison(await this.get(id), comparison);
659
- if (problems.length) throw new Refused(problems);
660
- await this.#writeJson(`${id}/rounds/${comparison.round}/since/${comparison.since}.json`, comparison);
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
- async send(id, send) {
664
- return this.#one(id, async () => {
665
- const review = await this.get(id);
666
- const already = review.sends.find((sent) => sent.id === send.id);
667
- if (already && JSON.stringify(already) === JSON.stringify(send)) return;
668
- const problems = problemsWithSend(review, send);
669
- if (problems.length) throw new Refused(problems);
670
- await this.#writeJson(`${id}/sends/${send.id}.json`, send);
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
- async accept(id, by) {
674
- return this.#one(id, async () => {
675
- const review = await this.get(id);
676
- if (review.accepted) return;
677
- const problems = problemsAccepting(review, by);
678
- if (problems.length) throw new Refused(problems);
679
- await this.#writeJson(`${id}/accepted.json`, {
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
- async file(id, round, name) {
686
- const problems = [...problemsWithId(id), ...problemsWithFileName(name)];
687
- if (problems.length || !Number.isInteger(round)) throw new Refused(problems.length ? problems : [`There's no round ${round}.`]);
688
- return this.storage.read(`${id}/rounds/${round}/files/${name}`);
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
- async wait(id, until, signal) {
691
- signal?.throwIfAborted();
692
- return new Promise((resolve, reject) => {
693
- let looking = false;
694
- let again = false;
695
- const done = (end) => {
696
- stop();
697
- signal?.removeEventListener("abort", aborted);
698
- end();
699
- };
700
- const look = async () => {
701
- if (looking) return void (again = true);
702
- looking = true;
703
- try {
704
- do {
705
- again = false;
706
- const review = await this.get(id);
707
- if (until(review)) return done(() => resolve(review));
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
- #one(id, step) {
722
- const next = (this.#queues.get(id) ?? Promise.resolve()).catch(() => {}).then(step);
723
- this.#queues.set(id, next);
724
- return next;
725
- }
726
- async #readJson(path) {
727
- const bytes = await this.storage.read(path);
728
- return bytes && JSON.parse(new TextDecoder().decode(bytes));
729
- }
730
- #writeJson(path, value) {
731
- return this.storage.write(path, new TextEncoder().encode(`${JSON.stringify(value, null, 2)}\n`));
732
- }
733
- };
734
- const now = () => (/* @__PURE__ */ new Date()).toISOString();
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$1(moves)) ? void 0 : causeOf(span, looking, candidates) ?? (vertical ? void 0 : causeOf(span, {
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$1(shift.moves);
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: `${called(first.now)} ${movedWords(moment, first)}${more}`,
1078
+ words: `${them} ${movedWords(moment, first)}${more}`,
1438
1079
  ...pushed && { pushed }
1439
1080
  };
1440
1081
  });
1441
1082
  }
1442
- const firstOf$1 = (moves) => [...moves].sort((p, q) => p.now.page.y - q.now.page.y || p.now.page.x - q.now.page.x)[0];
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(identity(told.in, told.thing), told.what);
1546
- const inside = groupBy([...groupBy(told.filter((one) => !unsteady.has(id(one))).sort((p, q) => p.thing.page.y - q.thing.page.y || p.thing.page.x - q.thing.page.x), id).values()], ([told]) => {
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
- const name = called(first.thing);
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 = new Set([...earlier.flatMap((round) => round.flows), ...flows].flatMap((flow) => flow.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
- return (await compare(await browser.newPage(), first, second)).changes;
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 see = ({ id, words, place }) => {
1941
- if (unsteady.has(id)) return;
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
- // ../review/src/folder.ts
2061
- const LOOK_EVERY = 2e3;
2062
- function folder(root) {
2063
- return {
2064
- async read(path) {
2065
- try {
2066
- return new Uint8Array(await readFile(join(root, path)));
2067
- } catch (error) {
2068
- if (isMissing(error)) return void 0;
2069
- throw error;
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
- async write(path, bytes) {
2073
- const file = join(root, path);
2074
- const unfinished = `${file}.${process.pid}.${Date.now()}.writing`;
2075
- await mkdir(dirname(file), { recursive: true });
2076
- await writeFile(unfinished, bytes);
2077
- await rename(unfinished, file);
2078
- },
2079
- async list(path) {
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
- return (await readdir(join(root, path))).filter((name) => !name.endsWith(".writing"));
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
- if (isMissing(error)) return [];
2084
- throw error;
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
- watch(path, changed) {
2088
- const look = setInterval(changed, LOOK_EVERY);
2089
- let watcher;
2090
- try {
2091
- watcher = watch(join(root, path), { recursive: true }, changed);
2092
- watcher.on("error", () => watcher?.close());
2093
- } catch {}
2094
- return () => {
2095
- clearInterval(look);
2096
- watcher?.close();
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 giving = /* @__PURE__ */ new Set();
2185
- async function giveBack(after, file, baseURL, setup) {
2186
- if (giving.has(file) || !existsSync(file)) return;
2187
- giving.add(file);
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
- return `Each flow is a Playwright test in ${repo.show(repo.flows(id))}, one for each thing a person does, with a step for each part of it:
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${setupMissing ? ", which this app doesn't have yet" : ""}: run playthrough alone for how.`;
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 ${list(last.map(quote))} first: ${last.length > 1 ? "they" : "it"} failed last time…`);
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
- `These flows failed again, so the other flows weren't run:`,
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([`${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)}`]);
2272
- console.log(`\n${last.length > 1 ? "They pass" : "It passes"} now, so every flow is recorded.\n`);
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 ${list(later.flows.map(quote))} 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}…`);
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 ${list(again.map(({ flow }) => quote(flow.name)))} again, to see what changes by itself…`);
2280
- const twice = await run(once.failed.length ? later?.flows : again.map(({ flow }) => flow.name), repo.failed(id));
2281
- await keepFailed(repo, id, round, twice.failed);
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([`${list(unlike.map(({ flow }) => quote(flow.name)))} didn't go the same way twice on the same app, so ${unlike.length > 1 ? "they don't" : "it doesn't"} start from the same place each time. Make what every flow starts from in playthrough/setup.ts, the same each time, and run this again.`]);
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.map((change) => change.id);
2292
- console.log(`\nIn ${quote(flow.name)}, this changes by itself, so it's left out of every comparison, in every flow:\n${unsteady.map((change) => ` ${change.words}`).join("\n")}\nIf 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.`);
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(["The flows didn't run, for the reason Playwright gives above. Fix that and run this again."]);
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
- // src/read.ts
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 };