playthrough 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,465 +1,14 @@
1
- import { d as firstLine, f as glimpsesOf, p as readingsOf } from "./reporter-CECNk7aW.js";
1
+ import { C as Refused, M as list, N as quote, h as glimpsesOf, m as firstLine, w as Reviews, y as readingsOf } from "./reporter-BEodHYRI.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 { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
6
7
  import { constants, tmpdir } from "node:os";
7
8
  import { dirname, join, relative } from "node:path";
8
- import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, 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$1 = (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$1("needs its moment, in seconds").optional().describe("Without it, the whole flow"),
47
- until: seconds$1("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$1("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$1("needs the moment it starts, in seconds"),
69
- actions: z.array(seconds$1("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))}. The next command tries it again, whole, so it should take what's gone already as given back.`);
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
  });
321
+ output.unref();
720
322
  }
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`));
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));
732
335
  }
733
- };
734
- const now = () => (/* @__PURE__ */ new Date()).toISOString();
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);
@@ -1102,7 +730,7 @@ function placeIn(place, from, to) {
1102
730
  };
1103
731
  if (place.point) return {
1104
732
  ...moment,
1105
- point: place.point
733
+ point: besideOn(place.point, place.at, from, to) ?? place.point
1106
734
  };
1107
735
  const there = (element) => {
1108
736
  const found = foundIn(from.readings, element, place.at, to.readings);
@@ -1116,6 +744,36 @@ function placeIn(place, from, to) {
1116
744
  ...alike?.length && { alike }
1117
745
  } : moment;
1118
746
  }
747
+ const HOLDING = /^(dialog|alertdialog|group|radiogroup|table|grid|treegrid|listbox|menu)$/;
748
+ function besideIn(read, at, point) {
749
+ const reading = read.reduce((best, next) => !best || Math.abs(next.at - at) < Math.abs(best.at - at) ? next : best, void 0);
750
+ if (!reading) return void 0;
751
+ const x = point.x * reading.screen.width;
752
+ const y = point.y * reading.screen.height;
753
+ const inside = (thing) => x >= thing.box.x && x <= thing.box.x + thing.box.width && y >= thing.box.y && y <= thing.box.y + thing.box.height;
754
+ const area = (thing) => thing.box.width * thing.box.height;
755
+ const room = reading.things.filter((thing) => (thing.role === "box" || HOLDING.test(thing.role) && !/^(table|grid|treegrid)$/.test(thing.role)) && inside(thing)).sort((a, b) => area(a) - area(b))[0];
756
+ const within = (thing) => {
757
+ for (let holder = thing.holder; holder !== void 0; holder = reading.things[holder]?.holder) if (reading.things[holder] === room) return true;
758
+ return false;
759
+ };
760
+ const gap = (thing) => Math.hypot(Math.max(thing.box.x - x, 0, x - thing.box.x - thing.box.width), Math.max(thing.box.y - y, 0, y - thing.box.y - thing.box.height));
761
+ const thing = reading.things.filter((thing) => thing.name && thing.role !== "box" && !HOLDING.test(thing.role) && (!room || within(thing)) && gap(thing) <= 80).sort((a, b) => gap(a) - gap(b) || area(a) - area(b))[0];
762
+ return thing && {
763
+ reading,
764
+ thing
765
+ };
766
+ }
767
+ function besideOn(point, at, from, to) {
768
+ const beside = besideIn(from.readings, at, point);
769
+ const found = beside && sameIn(beside, from.readings, to.readings);
770
+ if (!beside || !found) return void 0;
771
+ const share = (pixels, of) => Math.min(1, Math.max(0, pixels / of));
772
+ return {
773
+ x: share(point.x * beside.reading.screen.width + found.thing.box.x - beside.thing.box.x, found.reading.screen.width),
774
+ y: share(point.y * beside.reading.screen.height + found.thing.box.y - beside.thing.box.y, found.reading.screen.height)
775
+ };
776
+ }
1119
777
  function foundIn(from, element, at, to) {
1120
778
  const shown = shownAt(from, element, at);
1121
779
  return shown && sameIn(shown, from, to);
@@ -1163,11 +821,13 @@ function changesAt(then, now, unsteady = /* @__PURE__ */ new Set(), shown = {
1163
821
  const others = own.filter((difference) => !isMove(difference));
1164
822
  const caused = grownByWhatsInside(moment, own);
1165
823
  const along = /* @__PURE__ */ new Map();
1166
- const { reordered, left } = rowsReordered(moment, own.filter(isMove));
824
+ const items = itemsReordered(moment, own.filter(isMove));
825
+ const { reordered, left } = rowsReordered(moment, items.left);
1167
826
  const { shifts, loose } = layoutShifts(left, carried);
1168
827
  moment.shifts = shifts.map((shift) => shift.by);
1169
828
  const widened = widenedTogether(others, caused, along);
1170
829
  const told = [
830
+ ...items.reordered,
1171
831
  ...reordered,
1172
832
  ...swappedOrMoved(moment, loose),
1173
833
  ...causesOfShifts(moment, shifts, widened, others, caused, along)
@@ -1279,6 +939,76 @@ function grownByWhatsInside(moment, own) {
1279
939
  }
1280
940
  return caused;
1281
941
  }
942
+ function itemsReordered(moment, moves) {
943
+ const { then, now } = moment;
944
+ const pairs = pairItems(moment);
945
+ const reordered = [];
946
+ let left = moves;
947
+ for (const [, paired] of groupBy([...pairs], ([, b]) => now.items[b].list)) {
948
+ const byThen = [...paired].sort((p, q) => p[0] - q[0]);
949
+ const byNow = [...paired].sort((p, q) => p[1] - q[1]);
950
+ const placeThen = new Map(byThen.map(([a], i) => [a, i]));
951
+ const kept = longestRising(byNow.map(([a]) => placeThen.get(a)));
952
+ const way = (a, b) => {
953
+ const went = moveOf(asThing(then.items[a], a), asThing(now.items[b], b));
954
+ return Math.abs(went.y) >= Math.abs(went.x) ? went.y > 0 ? "down" : "up" : went.x > 0 ? "right" : "left";
955
+ };
956
+ const moved = byNow.flatMap(([a, b], i) => kept.has(i) ? [] : [{
957
+ thing: asThing(now.items[b], b),
958
+ way: way(a, b),
959
+ places: Math.abs(i - placeThen.get(a))
960
+ }]);
961
+ if (!moved.length) continue;
962
+ const changed = byNow.filter(([a], i) => placeThen.get(a) !== i);
963
+ const first = now.items[byNow[0][1]];
964
+ const of = `${first.role} of ${listOf(now, first)}`;
965
+ const holder = first.holder === void 0 ? void 0 : now.things[first.holder];
966
+ if (changed.length === 2) {
967
+ const both = changed.map(([, b]) => asThing(now.items[b], b));
968
+ reordered.push({
969
+ what: "swapped",
970
+ thing: both[0],
971
+ in: now,
972
+ words: `${together(both)} have swapped places`,
973
+ key: `${of}: ${both.map((thing) => thing.words).join(" / ")}`
974
+ });
975
+ } else if (moved.length <= 2) for (const { thing, way, places } of moved) reordered.push({
976
+ what: "moved",
977
+ thing,
978
+ in: now,
979
+ words: `${called$1(thing)} moved ${way} ${places} ${places > 1 ? "places" : "place"}`,
980
+ key: `${of}: ${thing.words}`
981
+ });
982
+ else {
983
+ const order = byNow.slice(0, 3).map(([, b]) => called$1(asThing(now.items[b], b)));
984
+ const words = first.role === "row" ? `Rows in ${holder ? the(holder) : "the table"} are in a new order` : `What's in ${holder ? the(holder) : "the list"} is in a new order: ${order.join(", ")}${byNow.length > 3 ? "…" : ""}`;
985
+ reordered.push({
986
+ what: "reordered",
987
+ thing: holder ?? moved[0].thing,
988
+ in: now,
989
+ words,
990
+ key: of
991
+ });
992
+ }
993
+ const inList = new Set(paired.map(([, b]) => b));
994
+ left = left.filter((move) => !(move.now.item !== void 0 && inList.has(move.now.item) && move.then.item !== void 0 && pairs.get(move.then.item) === move.now.item));
995
+ }
996
+ return {
997
+ reordered,
998
+ left
999
+ };
1000
+ }
1001
+ function longestRising(sequence) {
1002
+ const best = sequence.map(() => 1);
1003
+ const before = sequence.map(() => -1);
1004
+ for (let i = 0; i < sequence.length; i++) for (let j = 0; j < i; j++) if (sequence[j] < sequence[i] && best[j] + 1 > best[i]) {
1005
+ best[i] = best[j] + 1;
1006
+ before[i] = j;
1007
+ }
1008
+ const kept = /* @__PURE__ */ new Set();
1009
+ for (let i = best.indexOf(Math.max(...best)); i >= 0; i = before[i]) kept.add(i);
1010
+ return kept;
1011
+ }
1282
1012
  function rowsReordered(moment, moves) {
1283
1013
  const reordered = [];
1284
1014
  let left = moves;
@@ -1334,12 +1064,12 @@ function swappedOrMoved(moment, loose) {
1334
1064
  what: "moved",
1335
1065
  thing: move.now,
1336
1066
  in: moment.now,
1337
- words: `${called(move.now)} ${movedWords(moment, move)}`
1067
+ words: `${called$1(move.now)} ${movedWords(moment, move)}`
1338
1068
  });
1339
1069
  continue;
1340
1070
  }
1341
1071
  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 ? "…" : ""}`;
1072
+ 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
1073
  told.push({
1344
1074
  what: "swapped",
1345
1075
  thing: holder,
@@ -1407,7 +1137,7 @@ function causesOfShifts(moment, shifts, widened, others, caused, along) {
1407
1137
  };
1408
1138
  const candidates = others.filter((d) => !caused.has(d));
1409
1139
  const span = spanOf(pushed);
1410
- const cause = moment.into(firstOf$1(moves)) ? void 0 : causeOf(span, looking, candidates) ?? (vertical ? void 0 : causeOf(span, {
1140
+ const cause = moment.into(firstOf(moves)) ? void 0 : causeOf(span, looking, candidates) ?? (vertical ? void 0 : causeOf(span, {
1411
1141
  ...looking,
1412
1142
  share: 0
1413
1143
  }, candidates));
@@ -1427,19 +1157,37 @@ function causesOfShifts(moment, shifts, widened, others, caused, along) {
1427
1157
  passed.add(shift);
1428
1158
  }
1429
1159
  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` : "";
1160
+ const first = firstOf(shift.moves);
1432
1161
  const pushed = movedPast.get(shift);
1162
+ const other = pushed && unexplained.find((one) => passed.has(one) && one.moves.some((move) => pushed.things.includes(move.now)));
1163
+ if (other && alikeInSize(shift, other)) {
1164
+ const calledBy = (shift) => shift.moves.find((move) => move.now.role === "text")?.now ?? firstOf(shift.moves).now;
1165
+ const both = [calledBy(shift), calledBy(other)].sort((p, q) => p.page.y - q.page.y || p.page.x - q.page.x);
1166
+ return {
1167
+ what: "swapped",
1168
+ thing: both[0],
1169
+ in: moment.now,
1170
+ words: `${together(both)} have swapped places`
1171
+ };
1172
+ }
1173
+ 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;
1174
+ 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);
1175
+ const more = shift.moves.length > 1 && !alike ? `, with ${shift.moves.length - 1} more ${things(shift.moves.length - 1)} around it` : "";
1433
1176
  return {
1434
1177
  what: "moved",
1435
1178
  thing: first.now,
1436
1179
  in: moment.now,
1437
- words: `${called(first.now)} ${movedWords(moment, first)}${more}`,
1180
+ words: `${them} ${movedWords(moment, first)}${more}`,
1438
1181
  ...pushed && { pushed }
1439
1182
  };
1440
1183
  });
1441
1184
  }
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];
1185
+ function alikeInSize(one, other) {
1186
+ const [few, many] = [one.moves.length, other.moves.length].sort((p, q) => p - q);
1187
+ const [less, more] = [Math.hypot(one.by.x, one.by.y), Math.hypot(other.by.x, other.by.y)].sort((p, q) => p - q);
1188
+ return few * 2 >= many && less * 2 >= more;
1189
+ }
1190
+ 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
1191
  function wentPast(mover, moved) {
1444
1192
  const [was, is] = [spanOf(mover.moves.map((move) => move.then)), spanOf(mover.moves.map((move) => move.now))];
1445
1193
  const [wasThere, isThere] = [spanOf(moved.moves.map((move) => move.then)), spanOf(moved.moves.map((move) => move.now))];
@@ -1476,21 +1224,21 @@ function tell(moment, difference, along = {}) {
1476
1224
  what: "new",
1477
1225
  thing: difference.now,
1478
1226
  in: now,
1479
- words: `${called(difference.now)} is new`,
1227
+ words: `${called$1(difference.now)} is new`,
1480
1228
  ...along
1481
1229
  };
1482
1230
  case "gone": return {
1483
1231
  what: "gone",
1484
1232
  thing: difference.then,
1485
1233
  in: moment.then,
1486
- words: `${called(difference.then)} is gone`,
1234
+ words: `${called$1(difference.then)} is gone`,
1487
1235
  ...along
1488
1236
  };
1489
1237
  case "became": return {
1490
1238
  what: "became",
1491
1239
  thing: difference.now,
1492
1240
  in: now,
1493
- words: `${called(difference.then)} is ${a(word(difference.now))} now`,
1241
+ words: `${called$1(difference.then)} is ${a(word(difference.now))} now`,
1494
1242
  ...along
1495
1243
  };
1496
1244
  case "reworded": return {
@@ -1502,7 +1250,7 @@ function tell(moment, difference, along = {}) {
1502
1250
  };
1503
1251
  case "looks different": {
1504
1252
  const size = sizeWords(moment, difference);
1505
- const words = `${called(difference.now)} looks different: ${lookWords(difference.then, difference.now)}${size ? `, and ${size}` : ""}`;
1253
+ const words = `${called$1(difference.now)} looks different: ${lookWords(difference.then, difference.now)}${size ? `, and ${size}` : ""}`;
1506
1254
  return {
1507
1255
  what: "looks different",
1508
1256
  thing: difference.now,
@@ -1515,7 +1263,7 @@ function tell(moment, difference, along = {}) {
1515
1263
  what: difference.what,
1516
1264
  thing: difference.now,
1517
1265
  in: now,
1518
- words: `${called(difference.now)} ${sizeWords(moment, difference)}`,
1266
+ words: `${called$1(difference.now)} ${sizeWords(moment, difference)}`,
1519
1267
  ...along
1520
1268
  };
1521
1269
  }
@@ -1542,8 +1290,9 @@ function sizeWords(moment, pair) {
1542
1290
  }
1543
1291
  function said(told, moment, unsteady) {
1544
1292
  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]) => {
1293
+ const id = (told) => idOf(told.key ?? identityOf(told, moment), told.what);
1294
+ const piecesOf = (told) => told.pieces ?? [id(told)];
1295
+ 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
1296
  const holder = holderOf(told.in, told.thing);
1548
1297
  const holderNow = holder && (told.in === now ? holder : isNow.get(holder));
1549
1298
  return holderNow && TABLES.test(holderNow.role) && was.has(holderNow) ? holderNow : void 0;
@@ -1555,25 +1304,147 @@ function said(told, moment, unsteady) {
1555
1304
  id: id(alike[0]),
1556
1305
  words: alikeWords(alike),
1557
1306
  ...placeOf(alike[0], now),
1558
- ...alikeOf(alike, now)
1307
+ ...alikeOf(alike, now),
1308
+ pieces: alike.flatMap(piecesOf)
1309
+ });
1310
+ continue;
1311
+ }
1312
+ const count = all.flat().length;
1313
+ 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`;
1314
+ const changed = {
1315
+ what: "changed inside",
1316
+ thing: holder,
1317
+ in: now,
1318
+ words
1319
+ };
1320
+ found.push({
1321
+ id: id(changed),
1322
+ words: sentence(words),
1323
+ ...placeOf(changed, now),
1324
+ pieces: all.flat().flatMap(piecesOf)
1325
+ });
1326
+ }
1327
+ return found;
1328
+ }
1329
+ function identityOf({ in: reading, thing, what }, moment) {
1330
+ const item = thing.item === void 0 ? void 0 : reading.items?.[thing.item];
1331
+ if (!item) return identity(reading, thing);
1332
+ const kind = reading.things.filter((other) => other.item === thing.item && other.role === thing.role && other.column === thing.column);
1333
+ const place = `${thing.role} ${kind.indexOf(thing) + 1}${thing.column ? ` under ${thing.column}` : ""}`;
1334
+ const was = /^(reworded|moved)$/.test(what) ? moment.was.get(thing) : void 0;
1335
+ const went = was && what === "moved" && moveOf(was, thing);
1336
+ const how = went ? `, ${wayOf(near(went.x, 0) ? 0 : went.x, near(went.y, 0) ? 0 : went.y)}` : was ? `: ${shapeOf(was.words, thing.words)}` : "";
1337
+ return `${place} in a ${item.role} of ${listOf(reading, item)}${how}`;
1338
+ }
1339
+ const listOf = (reading, item) => item.holder === void 0 ? "the page" : identity(reading, reading.things[item.holder], 0);
1340
+ function pairItems({ then, now, was }) {
1341
+ const [before, after] = [then.items ?? [], now.items ?? []];
1342
+ const pairs = /* @__PURE__ */ new Map();
1343
+ const taken = /* @__PURE__ */ new Set();
1344
+ const votes = /* @__PURE__ */ new Map();
1345
+ 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);
1346
+ for (const [pair] of [...votes].sort((p, q) => q[1] - p[1])) {
1347
+ const [a, b] = pair.split(" ").map(Number);
1348
+ if (pairs.has(a) || taken.has(b)) continue;
1349
+ pairs.set(a, b);
1350
+ taken.add(b);
1351
+ }
1352
+ const lists = /* @__PURE__ */ new Map();
1353
+ for (const [a, b] of pairs) if (!lists.has(before[a].list)) lists.set(before[a].list, after[b].list);
1354
+ const left = (items, used) => [...new Set(items.map((item) => item.list))].filter((list) => !used.has(list));
1355
+ const listsNow = left(after, new Set(lists.values()));
1356
+ left(before, new Set(lists.keys())).forEach((list, i) => listsNow[i] !== void 0 && lists.set(list, listsNow[i]));
1357
+ for (const [listThen, listNow] of lists) {
1358
+ const a = before.flatMap((item, i) => item.list === listThen && !pairs.has(i) ? [i] : []);
1359
+ const b = after.flatMap((item, i) => item.list === listNow && !taken.has(i) ? [i] : []);
1360
+ if (a.length === b.length) a.forEach((i, k) => (pairs.set(i, b[k]), taken.add(b[k])));
1361
+ }
1362
+ return pairs;
1363
+ }
1364
+ function asItems(told, moment, id) {
1365
+ const { then, now } = moment;
1366
+ const items = pairItems(moment);
1367
+ const was = new Map([...items].map(([a, b]) => [b, a]));
1368
+ const itemOf = (one) => {
1369
+ if (one.thing.item === void 0 || !/^(new|gone|reworded|became)$/.test(one.what)) return void 0;
1370
+ if (one.in === now) return `now ${one.thing.item}`;
1371
+ const paired = items.get(one.thing.item);
1372
+ return paired === void 0 ? `then ${one.thing.item}` : `now ${paired}`;
1373
+ };
1374
+ const out = [];
1375
+ for (const [key, all] of groupBy(told, itemOf)) {
1376
+ if (!key || all.length < 2) {
1377
+ out.push(...all);
1378
+ continue;
1379
+ }
1380
+ const [side, at] = key.split(" ");
1381
+ const [reading, index] = [side === "then" ? then : now, Number(at)];
1382
+ const item = reading.items[index];
1383
+ const thing = asThing(item, index);
1384
+ const pieces = all.flatMap((one) => one.pieces ?? [id(one)]);
1385
+ const along = all.find((one) => one.pushed || one.sized);
1386
+ const whole = {
1387
+ thing,
1388
+ in: reading,
1389
+ pieces,
1390
+ ...along?.pushed && { pushed: along.pushed },
1391
+ ...along?.sized && { sized: along.sized }
1392
+ };
1393
+ if (side === "then" || !was.has(index) && all.every((one) => one.what === "new")) {
1394
+ const what = side === "then" ? "gone" : "new";
1395
+ out.push({
1396
+ ...whole,
1397
+ what,
1398
+ words: `${called$1(thing)} is ${what}`,
1399
+ key: `${item.role} of ${listOf(reading, item)}`
1559
1400
  });
1560
1401
  continue;
1561
1402
  }
1562
- 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`;
1564
- const changed = {
1565
- what: "changed inside",
1566
- thing: holder,
1567
- in: now,
1568
- words
1569
- };
1570
- found.push({
1571
- id: id(changed),
1572
- words: sentence(words),
1573
- ...placeOf(changed, now)
1574
- });
1403
+ const before = was.has(index) ? then.items[was.get(index)] : void 0;
1404
+ const words = all.filter((one) => one.thing.role === "text");
1405
+ if (!before || before.words === item.words || words.length < 2) {
1406
+ out.push(...all);
1407
+ continue;
1408
+ }
1409
+ const reworded = rewordedWords(asThing(before, was.get(index), "text"), asThing(item, index, "text"));
1410
+ out.push({
1411
+ ...whole,
1412
+ what: "reworded",
1413
+ words: reworded,
1414
+ pieces: words.flatMap((one) => one.pieces ?? [id(one)]),
1415
+ key: `${item.role} of ${listOf(now, item)}: ${shapeOf(before.words, item.words)}`
1416
+ }, ...all.filter((one) => one.thing.role !== "text"));
1575
1417
  }
1576
- return found;
1418
+ return out;
1419
+ }
1420
+ function asThing(item, index, role = item.role) {
1421
+ const name = item.words.replace(/^(\p{Lu})\s+(?=\1)/u, "").split(" ").filter((word) => /[\p{L}\p{N}]/u.test(word)).join(" ");
1422
+ return {
1423
+ role,
1424
+ name: name.slice(0, 120),
1425
+ words: role === "text" ? item.words : name,
1426
+ box: item.box,
1427
+ page: item.page,
1428
+ look: {
1429
+ color: "",
1430
+ background: "",
1431
+ size: "",
1432
+ weight: "",
1433
+ border: ""
1434
+ },
1435
+ item: index,
1436
+ ...item.holder !== void 0 && { holder: item.holder },
1437
+ ...item.node !== void 0 && { node: item.node }
1438
+ };
1439
+ }
1440
+ function shapeOf(then, now) {
1441
+ const [a, b] = [then.split(" "), now.split(" ")];
1442
+ let start = 0;
1443
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
1444
+ let end = 0;
1445
+ while (end < a.length - start && end < b.length - start && a.at(-1 - end) === b.at(-1 - end)) end++;
1446
+ const part = (words) => words.slice(start, words.length - end).map((word) => /\d/.test(word) ? "…" : word).join(" ");
1447
+ return `${part(a)} → ${part(b)}`;
1577
1448
  }
1578
1449
  function alikeWords(alike) {
1579
1450
  const [first] = alike;
@@ -1591,9 +1462,10 @@ function alikeWords(alike) {
1591
1462
  const them = symbolOnly(first.thing) ? together(all) : `${together(all, (thing) => thing.name)} ${plural(WORD[first.thing.role])}`;
1592
1463
  return sentence(`${them} ${verb}${along}`);
1593
1464
  }
1594
- const name = called(first.thing);
1465
+ 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}`);
1466
+ const name = called$1(first.thing);
1595
1467
  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;
1468
+ 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
1469
  return sentence(`${shared}${along}, in ${alike.length} places`);
1598
1470
  }
1599
1471
  function idOf(thing, what) {
@@ -1671,6 +1543,7 @@ function groupBy(items, key) {
1671
1543
  return groups;
1672
1544
  }
1673
1545
  // src/pictures.ts
1546
+ const HERE = "here:";
1674
1547
  async function picturesAt(page, then, now, pictures) {
1675
1548
  if (scrollOf(then) !== scrollOf(now)) return [];
1676
1549
  const { differences: found } = differences(then, now);
@@ -1686,21 +1559,24 @@ async function picturesAt(page, then, now, pictures) {
1686
1559
  if (patch.nudged && found.length) continue;
1687
1560
  if (patch.moved && [...changedInside].some((holder) => contains(holder.box, patch, 8))) continue;
1688
1561
  const on = whatsThere(now, patch);
1689
- const by = patch.moved && Math.max(Math.abs(patch.moved.x), Math.abs(patch.moved.y));
1690
- const words = patch.moved ? `${on.called} moved ${wayOf(patch.moved.x, patch.moved.y)}, ${by}px` : `${on.called} looks different`;
1691
- const id = idOf(on.identity ?? `place:${Math.round(patch.x / 32)},${Math.round(patch.y / 32)}`, patch.moved ? "moved" : "looks different");
1562
+ const moved = on.identity && !on.identity.startsWith("near ") ? void 0 : patch.moved;
1563
+ const by = moved && Math.max(Math.abs(moved.x), Math.abs(moved.y));
1564
+ const words = moved ? `${on.called} moved ${wayOf(moved.x, moved.y)}, ${by}px` : `${on.called} looks different`;
1565
+ const id = idOf(on.identity ?? `place:${Math.round(patch.x / 32)},${Math.round(patch.y / 32)}`, moved ? "moved" : "looks different");
1692
1566
  const same = told.get(id);
1693
1567
  told.set(id, {
1694
1568
  words,
1695
- patch: same ? around$1(same.patch, patch) : patch
1569
+ patch: same ? around$1(same.patch, patch) : patch,
1570
+ here: !on.identity || on.identity.startsWith("near ")
1696
1571
  });
1697
1572
  }
1698
- return [...told].map(([id, { words, patch }]) => {
1573
+ return [...told].map(([id, { words, patch, here }]) => {
1699
1574
  const box = onScreen(patch, now.screen);
1700
1575
  return {
1701
1576
  id,
1702
1577
  words: sentence(words),
1703
- ...box && { box }
1578
+ ...box && { box },
1579
+ pieces: [here ? `${HERE}${id}` : id]
1704
1580
  };
1705
1581
  });
1706
1582
  }
@@ -1717,7 +1593,7 @@ function whatsThere(reading, patch) {
1717
1593
  identity: `column:${bare(cell.column)} in ${identity(reading, table)}`
1718
1594
  };
1719
1595
  if (round) return {
1720
- called: called(round),
1596
+ called: called$1(round),
1721
1597
  identity: bare(round.name) ? identity(reading, round) : void 0
1722
1598
  };
1723
1599
  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 +1773,7 @@ async function differ({ then, now, explained }) {
1897
1773
  async function compareWithEarlier(reviews, id, number, flows) {
1898
1774
  const earlier = (await reviews.get(id)).rounds.filter((round) => round.number < number);
1899
1775
  if (!earlier.length) return [];
1900
- const unsteady = new Set([...earlier.flatMap((round) => round.flows), ...flows].flatMap((flow) => flow.unsteady ?? []));
1776
+ const unsteady = unsteadyIn([...earlier.flatMap((round) => round.flows), ...flows]);
1901
1777
  const browser = await chromium.launch();
1902
1778
  try {
1903
1779
  const page = await browser.newPage();
@@ -1912,7 +1788,11 @@ async function compareWithEarlier(reviews, id, number, flows) {
1912
1788
  async function changesByThemselves(first, second) {
1913
1789
  const browser = await chromium.launch();
1914
1790
  try {
1915
- return (await compare(await browser.newPage(), first, second)).changes;
1791
+ const { changes, pieces } = await comparing(await browser.newPage(), first, second);
1792
+ return changes.map((change) => ({
1793
+ ...change,
1794
+ pieces: [...pieces.get(change.id)]
1795
+ }));
1916
1796
  } finally {
1917
1797
  await browser.close();
1918
1798
  }
@@ -1935,11 +1815,22 @@ function kept(reviews, id, number, flows) {
1935
1815
  picture: file
1936
1816
  };
1937
1817
  }
1938
- async function compare(page, earlier, round, unsteady = /* @__PURE__ */ new Set()) {
1818
+ async function compare(page, earlier, round, unsteady = () => /* @__PURE__ */ new Set()) {
1819
+ const { pieces: _, ...comparison } = await comparing(page, earlier, round, unsteady);
1820
+ return comparison;
1821
+ }
1822
+ function unsteadyIn(flows) {
1823
+ const everywhere = flows.flatMap((flow) => (flow.unsteady ?? []).filter((piece) => !piece.startsWith(HERE)));
1824
+ const here = (name) => flows.filter((flow) => flow.name === name).flatMap((flow) => (flow.unsteady ?? []).filter((piece) => piece.startsWith(HERE)).map((piece) => piece.slice(5)));
1825
+ return (name) => /* @__PURE__ */ new Set([...everywhere, ...here(name)]);
1826
+ }
1827
+ async function comparing(page, earlier, round, unsteady = () => /* @__PURE__ */ new Set()) {
1939
1828
  const changes = /* @__PURE__ */ new Map();
1940
- const see = ({ id, words, place }) => {
1941
- if (unsteady.has(id)) return;
1829
+ const pieces = /* @__PURE__ */ new Map();
1830
+ const see = ({ id, words, place, of = [id] }) => {
1831
+ if (unsteady(place.flow).has(id)) return;
1942
1832
  const change = changes.get(id);
1833
+ for (const piece of of) (pieces.get(id) ?? pieces.set(id, /* @__PURE__ */ new Set()).get(id)).add(piece);
1943
1834
  if (!change) changes.set(id, {
1944
1835
  id,
1945
1836
  words,
@@ -1949,7 +1840,7 @@ async function compare(page, earlier, round, unsteady = /* @__PURE__ */ new Set(
1949
1840
  };
1950
1841
  for (const flow of round.flows) {
1951
1842
  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);
1843
+ if (before) for (const sighting of await compareFlow(page, earlier, before, round, flow, unsteady(flow.name))) see(sighting);
1953
1844
  }
1954
1845
  for (const gone of earlier.flows.filter((flow) => !round.flows.some((kept) => kept.name === flow.name))) see({
1955
1846
  id: idOf(`flow:${gone.name}`, "gone"),
@@ -1959,7 +1850,8 @@ async function compare(page, earlier, round, unsteady = /* @__PURE__ */ new Set(
1959
1850
  return {
1960
1851
  round: round.number,
1961
1852
  since: earlier.number,
1962
- changes: [...changes.values()]
1853
+ changes: [...changes.values()],
1854
+ pieces
1963
1855
  };
1964
1856
  }
1965
1857
  async function compareFlow(page, earlier, before, round, flow, unsteady) {
@@ -1994,7 +1886,8 @@ async function compareFlow(page, earlier, before, round, flow, unsteady) {
1994
1886
  flow: flow.name,
1995
1887
  at: reading.at,
1996
1888
  ...on
1997
- }
1889
+ },
1890
+ of: found.pieces
1998
1891
  });
1999
1892
  }
2000
1893
  }
@@ -2057,203 +1950,6 @@ function runs(count, isIn) {
2057
1950
  }
2058
1951
  return found;
2059
1952
  }
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;
2070
- }
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) {
2080
- try {
2081
- return (await readdir(join(root, path))).filter((name) => !name.endsWith(".writing"));
2082
- } catch (error) {
2083
- if (isMissing(error)) return [];
2084
- throw error;
2085
- }
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
- };
2098
- }
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
- const appFile = join(root, "playthrough", "app.json");
2106
- return {
2107
- root,
2108
- reviews: new Reviews(folder(reviews)),
2109
- flows: (review) => join(reviews, review, "flows"),
2110
- failed: (review) => join(reviews, review, "failed"),
2111
- round: (review, round) => join(reviews, review, "rounds", String(round), "round.json"),
2112
- setAside: join(reviews, "set-aside.json"),
2113
- appFile,
2114
- setup: join(root, "playthrough", "setup.ts"),
2115
- borrowed: join(reviews, "borrowed"),
2116
- tried: join(reviews, "tried"),
2117
- show: (path) => relative(process.cwd(), path) || ".",
2118
- async keepOutOfGit() {
2119
- await mkdir(reviews, { recursive: true });
2120
- await writeFile(join(reviews, ".gitignore"), "*\n");
2121
- },
2122
- async app() {
2123
- const how = `Say how to start the app in playthrough/app.json: { "start": "<the command that serves it>", "url": "<where it's served>" }.`;
2124
- const text = await readFile(appFile, "utf8").catch(() => void 0);
2125
- if (text === void 0) throw new Refused([`Playthrough needs to start the app to record it. ${how}`]);
2126
- let app;
2127
- try {
2128
- app = JSON.parse(text);
2129
- } catch (error) {
2130
- throw new Refused([`playthrough/app.json isn't JSON: ${error.message}. ${how}`]);
2131
- }
2132
- if (!isText(app.start) || !isText(app.url)) throw new Refused([`playthrough/app.json needs both “start” and “url”. ${how}`]);
2133
- return {
2134
- start: app.start,
2135
- url: app.url
2136
- };
2137
- }
2138
- };
2139
- }
2140
- const isText = (value) => typeof value === "string" && value.trim() !== "";
2141
- // src/setup.ts
2142
- async function setupIn(file, written) {
2143
- if (!existsSync(file)) return {};
2144
- const module = await import(`${pathToFileURL(file).href}${written ? `?${written}` : ""}`);
2145
- const inner = typeof module.default === "object" ? module.default : void 0;
2146
- return {
2147
- make: typeof module.default === "function" ? module.default : inner?.default,
2148
- after: module.after ?? inner?.after,
2149
- signIn: module.signIn ?? inner?.signIn,
2150
- people: module.people ?? inner?.people
2151
- };
2152
- }
2153
- const firstOf = ({ people }) => Object.keys(people ?? {})[0];
2154
- function personIn({ people }, person) {
2155
- 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.`);
2156
- }
2157
- async function make({ make, after }, borrowed, given) {
2158
- const setup = await make?.(given) ?? {};
2159
- if (!after) return {
2160
- setup,
2161
- giveBack: async () => false
2162
- };
2163
- await mkdir(borrowed, { recursive: true });
2164
- const file = join(borrowed, `${process.pid}-${randomUUID()}.json`);
2165
- await writeFile(file, JSON.stringify({
2166
- baseURL: given.baseURL,
2167
- setup
2168
- }));
2169
- return {
2170
- setup,
2171
- giveBack: () => giveBack(after, file, given.baseURL, setup)
2172
- };
2173
- }
2174
- const giveBackLeft = (setup, borrowed) => giveBackOf(setup, borrowed, (pid) => !alive(pid));
2175
- const giveBackOwn = (setup, borrowed) => giveBackOf(setup, borrowed, (pid) => pid === process.pid);
2176
- async function giveBackOf({ after }, borrowed, whose) {
2177
- if (!after) return;
2178
- const left = (await readdir(borrowed).catch(() => [])).filter((name) => whose(Number(name.split("-")[0])));
2179
- for (const name of left) {
2180
- const file = join(borrowed, `${process.pid}-${name.slice(name.indexOf("-") + 1)}`);
2181
- if (!await rename(join(borrowed, name), file).then(() => true, () => false)) continue;
2182
- const { baseURL, setup } = JSON.parse(await readFile(file, "utf8"));
2183
- await giveBack(after, file, baseURL, setup);
2184
- }
2185
- }
2186
- const giving = /* @__PURE__ */ new Set();
2187
- async function giveBack(after, file, baseURL, setup) {
2188
- if (giving.has(file) || !existsSync(file)) return true;
2189
- giving.add(file);
2190
- const request$2 = await request.newContext({ baseURL });
2191
- try {
2192
- await after({
2193
- request: request$2,
2194
- baseURL,
2195
- setup
2196
- });
2197
- await rm(file, { force: true });
2198
- return true;
2199
- } catch (error) {
2200
- 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.`);
2201
- return false;
2202
- } finally {
2203
- giving.delete(file);
2204
- await request$2.dispose();
2205
- }
2206
- }
2207
- function alive(pid) {
2208
- try {
2209
- process.kill(pid, 0);
2210
- return true;
2211
- } catch (error) {
2212
- return error.code === "EPERM";
2213
- }
2214
- }
2215
- // src/app.ts
2216
- let started;
2217
- let ending = false;
2218
- async function keepRunning(app, root) {
2219
- if (!ending) {
2220
- ending = true;
2221
- const { setup, borrowed } = repo(root);
2222
- const giveBack = () => setupIn(setup).then((setup) => giveBackOwn(setup, borrowed));
2223
- const end = (signal) => void Promise.race([giveBack(), new Promise((wait) => setTimeout(wait, 1e4))]).finally(() => process.exit(128 + constants.signals[signal]));
2224
- for (const signal of [
2225
- "SIGINT",
2226
- "SIGTERM",
2227
- "SIGHUP"
2228
- ]) process.once(signal, end);
2229
- }
2230
- if (await answers(app.url)) return;
2231
- console.log(`Starting the app: ${app.start}`);
2232
- const began = Date.now();
2233
- started = spawn(app.start, {
2234
- cwd: root,
2235
- shell: true,
2236
- stdio: "ignore",
2237
- detached: true
2238
- });
2239
- started.unref();
2240
- let ended = false;
2241
- started.once("exit", () => ended = true);
2242
- const stop = () => {
2243
- try {
2244
- process.kill(-started.pid);
2245
- } catch {}
2246
- };
2247
- process.once("exit", stop);
2248
- while (Date.now() - began < 18e4) {
2249
- if (await answers(app.url)) return Date.now() - began;
2250
- if (ended) throw new Refused([`${quote(app.start)} ended before the app answered at ${app.url}. Run it yourself to see why, and check that playthrough/app.json starts the app there.`]);
2251
- await new Promise((resolve) => setTimeout(resolve, 250));
2252
- }
2253
- stop();
2254
- throw new Refused([`The app didn't answer at ${app.url} within three minutes of ${quote(app.start)}. Check that playthrough/app.json starts it there.`]);
2255
- }
2256
- const answers = (url) => fetch(url, { signal: AbortSignal.timeout(2e3) }).then(() => true, () => false);
2257
1953
  // src/try-setup.ts
2258
1954
  const SLOW = 2e3;
2259
1955
  async function trySetup(repo, again) {
@@ -2262,21 +1958,24 @@ async function trySetup(repo, again) {
2262
1958
  const last = join(repo.tried, "tried.json");
2263
1959
  if (await readFile(last, "utf8").then((text) => JSON.parse(text).written, () => void 0) === written) return;
2264
1960
  console.log("Trying the app's setup by itself first, twice, as every flow will start…");
2265
- const startedIn = await keepRunning(app, repo.root);
2266
- const setup = await setupIn(repo.setup, (await stat(repo.setup).catch(() => void 0))?.mtimeMs);
2267
- await giveBackLeft(setup, repo.borrowed);
2268
- await rm(repo.tried, {
2269
- recursive: true,
2270
- force: true
2271
- });
2272
- await mkdir(repo.tried, { recursive: true });
2273
1961
  const browser = await chromium.launch().catch((error) => {
2274
- if (/Executable doesn't exist/.test(String(error?.message))) throw new Refused(["Playwright has no Chromium to run the flows in yet. Install it (npx playwright install chromium), then run this again."]);
1962
+ const { version } = createRequire(import.meta.url)("@playwright/test/package.json");
1963
+ 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.`]);
2275
1964
  throw error;
2276
1965
  });
2277
- const people = Object.keys(setup.people ?? {});
1966
+ let startedIn;
1967
+ let setup;
2278
1968
  const tried = [];
2279
1969
  try {
1970
+ startedIn = await keepRunning(app, repo.root);
1971
+ setup = await setupIn(repo.setup, (await stat(repo.setup).catch(() => void 0))?.mtimeMs);
1972
+ await giveBackLeft(setup, repo.borrowed);
1973
+ await rm(repo.tried, {
1974
+ recursive: true,
1975
+ force: true
1976
+ });
1977
+ await mkdir(repo.tried, { recursive: true });
1978
+ const people = Object.keys(setup.people ?? {});
2280
1979
  for (const person of people.length ? people : [void 0]) tried.push(await tryAs(repo, browser, setup, app.url, person));
2281
1980
  } finally {
2282
1981
  await browser.close();
@@ -2284,15 +1983,14 @@ async function trySetup(repo, again) {
2284
1983
  const width = Math.max(7, ...tried.map(({ who }) => who.length)) + 3;
2285
1984
  const line = (label, said) => ` ${label.padEnd(width)}${said}`;
2286
1985
  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)}`)}`);
2287
- for (const { who, did, took, at, picture } of tried) console.log(line(who, `${did}${did === "nothing made" ? "" : ` in ${seconds(took)}`}, then at ${at}: ${repo.show(picture)}`));
1986
+ 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)}`));
2288
1987
  const missing = [];
2289
- if (!setup.make && !setup.signIn) missing.push("There's no playthrough/setup.ts, so each flow starts on whatever the app holds, as the flow before it left it, signed in as nobody.");
2290
- else if (!setup.after) 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.");
2291
- else if (tried.some(({ givenBack }) => !givenBack)) missing.push("playthrough/setup.ts's after couldn't give back what it made, for the reason above.");
1988
+ 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.");
2292
1989
  const slowest = Math.max(...tried.map(({ took }) => took));
2293
- if (slowest > SLOW) missing.push(`Each flow waits up to ${seconds(slowest)} for its setup before its first step, and so does each moment taken over.`);
1990
+ 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.`);
2294
1991
  console.log(`
2295
- 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, or anything a flow shouldn't start from, is for playthrough/setup.ts.`);
1992
+ 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.`);
1993
+ if (tried.some(({ givenBack }) => !givenBack)) console.log(`\nplaythrough/setup.ts's after couldn't give back what it made, for the reason above: that's for setup.ts too.`);
2296
1994
  if (missing.length) console.log(`
2297
1995
  ${missing.map((said) => `- ${said}`).join("\n")}
2298
1996
 
@@ -2303,15 +2001,17 @@ Tell the person now, in a few lines, how flows get into their app, and for each
2303
2001
  }
2304
2002
  async function tryAs(repo, browser, setup, baseURL, person) {
2305
2003
  const tried = {
2306
- who: person ? person.charAt(0).toUpperCase() + person.slice(1) : "A flow",
2004
+ who: person ? person.charAt(0).toUpperCase() + person.slice(1) : "Every flow",
2307
2005
  did: setup.make && person && setup.signIn ? "made and signed in" : setup.make ? "made" : person && setup.signIn ? "signed in" : "nothing made",
2308
2006
  took: 0,
2309
2007
  at: "",
2310
- picture: join(repo.tried, `${(person ?? "a flow").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "person"}.png`),
2008
+ picture: join(repo.tried, `${(person ?? "every flow").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "person"}.png`),
2009
+ made: false,
2311
2010
  givenBack: true
2312
2011
  };
2313
2012
  for (const time of ["first", "second"]) {
2314
2013
  const context = await browser.newContext({
2014
+ ...setup.use,
2315
2015
  baseURL,
2316
2016
  locale: "en-US",
2317
2017
  timezoneId: "UTC",
@@ -2323,15 +2023,21 @@ async function tryAs(repo, browser, setup, baseURL, person) {
2323
2023
  const page = await context.newPage();
2324
2024
  const request$1 = await request.newContext({ baseURL });
2325
2025
  let made;
2026
+ let answered;
2027
+ page.on("response", (response) => void (response.request().isNavigationRequest() && response.frame() === page.mainFrame() && (answered = response)));
2326
2028
  try {
2327
2029
  const began = Date.now();
2030
+ const sent = (sent) => void (changes(sent.method()) && (tried.made = true));
2031
+ page.on("request", sent);
2328
2032
  try {
2329
2033
  made = await make(setup, repo.borrowed, {
2330
2034
  page,
2331
- request: request$1,
2035
+ request: watching(request$1, () => tried.made = true),
2332
2036
  baseURL,
2333
2037
  person
2334
2038
  });
2039
+ page.off("request", sent);
2040
+ if (Object.keys(made.setup).length) tried.made = true;
2335
2041
  if (person) await setup.signIn?.({
2336
2042
  page,
2337
2043
  person,
@@ -2347,6 +2053,7 @@ async function tryAs(repo, browser, setup, baseURL, person) {
2347
2053
  await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {});
2348
2054
  const url = new URL(page.url());
2349
2055
  tried.at = url.origin === new URL(baseURL).origin ? `${url.pathname}${url.search}` : url.href;
2056
+ tried.refused = answered && answered.status() >= 400 ? `${answered.status()} ${answered.statusText()}`.trim() : void 0;
2350
2057
  if (time === "second") await page.screenshot({ path: tried.picture });
2351
2058
  } finally {
2352
2059
  await context.close();
@@ -2365,17 +2072,19 @@ async function writtenNow(repo) {
2365
2072
  const WALKED = "walked.json";
2366
2073
  const ALONGSIDE = Symbol.for("playthrough: flows taken over here");
2367
2074
  const REACHES_OUTSIDE = "reaches outside";
2075
+ const waitsFor = ({ before }) => before ? 3e3 : 1e4;
2368
2076
  async function makeFlowsFolder(repo, id) {
2369
2077
  await mkdir(repo.flows(id), { recursive: true });
2370
2078
  await writeFile(join(repo.flows(id), "package.json"), `{ "type": "module" }\n`, { flag: "wx" }).catch((error) => {
2371
2079
  if (error.code !== "EEXIST") throw error;
2372
2080
  });
2373
2081
  }
2082
+ const called = (names) => names.length > 3 ? `${names.length} flows` : list(names.map(quote));
2374
2083
  const isFlow = (name) => /\.[cm]?[jt]s$/.test(name);
2375
2084
  function howToWriteFlows(repo, id) {
2376
2085
  const setupMissing = !existsSync(repo.setup);
2377
2086
  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." : ".";
2378
- 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:
2087
+ 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:
2379
2088
 
2380
2089
  import { test, expect } from "playthrough/test";
2381
2090
 
@@ -2400,7 +2109,7 @@ async function runFlows(repo, id, round, { later, first }) {
2400
2109
  const { started, rounds } = await repo.reviews.get(id);
2401
2110
  const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
2402
2111
  const longest = (rounds.filter((kept) => kept.number < round).at(-1)?.flows ?? []).toSorted((a, b) => b.length - a.length).map((flow) => flow.name);
2403
- const run = (only, results) => runOnce({
2112
+ const run = (only, results, stops) => runOnce({
2404
2113
  flows,
2405
2114
  only,
2406
2115
  longest,
@@ -2410,44 +2119,63 @@ async function runFlows(repo, id, round, { later, first }) {
2410
2119
  recorder,
2411
2120
  before: round === 0,
2412
2121
  taken: later?.taken ?? [],
2413
- started
2122
+ started,
2123
+ stops
2414
2124
  });
2415
2125
  const last = round > 0 ? await failedLastTime(repo, id) : [];
2416
2126
  if (last.length) {
2417
- console.log(`Running ${list(last.map(quote))} first: ${last.length > 1 ? "they" : "it"} failed last time…`);
2418
- const { failed } = await run(last, repo.failed(id));
2127
+ console.log(`Running ${called(last)} first: ${last.length > 1 ? "they" : "it"} failed last time…`);
2128
+ const { recorded, failed } = await run(last, repo.failed(id));
2419
2129
  await keepFailed(repo, id, round, failed);
2420
2130
  const [one] = failed;
2421
2131
  if (failed.length > 1) throw new Refused([
2422
- `These flows failed again, so the other flows weren't run:`,
2132
+ `\nThese flows failed again, so the other flows weren't run:`,
2423
2133
  ...failed.map(failedAt),
2424
2134
  `Fix them and record again. ${whereItStopped(repo, id, failed)}`
2425
2135
  ]);
2426
- 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)}`]);
2427
- console.log(`\n${last.length > 1 ? "They pass" : "It passes"} now, so every flow is recorded.\n`);
2136
+ 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)}`]);
2137
+ const gone = !recorded.length;
2138
+ 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`);
2428
2139
  }
2429
- 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}…`);
2140
+ 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}…`);
2430
2141
  const once = await run(later?.flows, repo.failed(id));
2431
2142
  await keepFailed(repo, id, round, once.failed);
2432
2143
  const again = once.recorded.filter(({ flow }) => first(flow.name));
2433
2144
  if (!again.length || round > 0 && once.failed.length) return once;
2434
- console.log(`\nRecording ${list(again.map(({ flow }) => quote(flow.name)))} again, to see what changes by itself…`);
2435
- const twice = await run(once.failed.length ? later?.flows : again.map(({ flow }) => flow.name), repo.failed(id));
2436
- await keepFailed(repo, id, round, twice.failed);
2145
+ console.log(`\nRecording ${called(again.map(({ flow }) => flow.name))} again, to see what changes by itself…`);
2146
+ const stops = Object.fromEntries(once.failed.flatMap(({ flow, at }) => at ? [[flow, at]] : []));
2147
+ const results = once.failed.length ? void 0 : repo.failed(id);
2148
+ const twice = await run(again.map(({ flow }) => flow.name), results, stops);
2149
+ if (results) await keepFailed(repo, id, round, twice.failed);
2437
2150
  const unlike = again.filter(({ flow }) => {
2438
2151
  const failed = (ran) => ran.failed.find((failure) => failure.flow === flow.name)?.at;
2439
2152
  return !twice.recorded.some((kept) => kept.flow.name === flow.name) || failed(once) !== failed(twice);
2440
2153
  });
2441
- 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.`]);
2154
+ if (unlike.length) throw new Refused([
2155
+ `\n${list(unlike.map(({ flow }) => quote(flow.name)))} didn't go the same way twice on the same app:`,
2156
+ ...unlike.map(({ flow }) => ` ${quote(flow.name)}: the first time, ${went(once, flow.name)}; the second, ${went(twice, flow.name)}.`),
2157
+ `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.`}`
2158
+ ]);
2442
2159
  const byThemselves = await changesByThemselves(inMemory(round, again), inMemory(round, twice.recorded.filter(({ flow }) => first(flow.name))));
2443
2160
  for (const { flow } of again) {
2444
2161
  const unsteady = byThemselves.filter((change) => change.seen.some((place) => place.flow === flow.name));
2445
2162
  if (!unsteady.length) continue;
2446
- flow.unsteady = unsteady.map((change) => change.id);
2447
- 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.`);
2163
+ flow.unsteady = [...new Set(unsteady.flatMap((change) => change.pieces))];
2164
+ const here = unsteady.filter((change) => change.pieces.every((piece) => piece.startsWith(HERE)));
2165
+ const everywhere = unsteady.filter((change) => !here.includes(change));
2166
+ const listed = (changes) => changes.map((change) => ` ${change.words}`).join("\n");
2167
+ 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)}`);
2168
+ 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)}`);
2169
+ 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.");
2448
2170
  }
2449
2171
  return once;
2450
2172
  }
2173
+ function went(ran, name) {
2174
+ const failure = ran.failed.find((failed) => failed.flow === name);
2175
+ if (!failure) return "it went to its end";
2176
+ const because = failure.why ? `, where Playwright says “${failure.why}”` : "";
2177
+ return failure.at ? `it stopped at ${quote(failure.at)}${because}` : `it stopped before its first step${because}`;
2178
+ }
2451
2179
  async function keepFailed(repo, id, round, failed) {
2452
2180
  const folder = repo.failed(id);
2453
2181
  if (!failed.length) return rm(folder, {
@@ -2488,21 +2216,24 @@ async function runOnce(run) {
2488
2216
  folder
2489
2217
  });
2490
2218
  const text = await readFile(join(folder, "flows.json"), "utf8").catch(() => void 0);
2491
- const { recorded, failed } = text ? JSON.parse(text) : {
2219
+ const { recorded, failed, written } = text ? JSON.parse(text) : {
2492
2220
  recorded: [],
2493
- failed: []
2221
+ failed: [],
2222
+ written: {}
2494
2223
  };
2495
- if (!ran && !failed.length) throw new Refused(["The flows didn't run, for the reason Playwright gives above. Fix that and run this again."]);
2496
- const withFiles = ({ flow, files }) => Promise.all(files.map(async (name) => ({
2224
+ if (!ran && !failed.length) throw new Refused(["\nThe flows didn't run, for the reason Playwright gives above. Fix that and run this again."]);
2225
+ const withFiles = ({ flow, files, wrote }) => Promise.all(files.map(async (name) => ({
2497
2226
  name,
2498
2227
  bytes: await readFile(join(folder, name))
2499
2228
  }))).then((files) => ({
2500
2229
  flow,
2501
- files
2230
+ files,
2231
+ ...wrote && { wrote }
2502
2232
  }));
2503
2233
  return {
2504
2234
  recorded: await Promise.all(recorded.map(withFiles)),
2505
- failed
2235
+ failed,
2236
+ written
2506
2237
  };
2507
2238
  } finally {
2508
2239
  await rm(folder, { recursive: true });
@@ -2545,15 +2276,16 @@ async function walkFork(repo, id, walk) {
2545
2276
  });
2546
2277
  }
2547
2278
  }
2548
- const failedAt = ({ flow, at }) => ` ${quote(flow)}${at ? `, at the step ${quote(at)}` : ""}`;
2279
+ const failedAt = ({ flow, at, why }) => ` ${quote(flow)}${at ? `, at the step ${quote(at)}` : ""}${why ? `, where Playwright says “${why}”` : ""}`;
2549
2280
  async function playwright(run) {
2550
- const { setup, borrowed } = repo(run.root);
2551
- await giveBackLeft(await setupIn(setup), borrowed);
2552
2281
  try {
2553
2282
  createRequire(join(run.root, "package.json")).resolve("playthrough/test");
2554
2283
  } catch {
2555
2284
  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."]);
2556
2285
  }
2286
+ await keepRunning(run.app, run.root);
2287
+ const { setup, borrowed } = repo(run.root);
2288
+ await giveBackLeft(await setupIn(setup), borrowed);
2557
2289
  const cli = createRequire(import.meta.url).resolve("@playwright/test/cli");
2558
2290
  const config = fileURLToPath(new URL("./playwright.config.js", import.meta.url));
2559
2291
  return new Promise((resolve, reject) => {
@@ -2579,237 +2311,4 @@ async function playwright(run) {
2579
2311
  });
2580
2312
  });
2581
2313
  }
2582
- // src/read.ts
2583
- function readPage(page) {
2584
- return page.evaluate(read);
2585
- }
2586
- async function unhovered(page, look) {
2587
- await page.evaluate(hovering, false);
2588
- try {
2589
- return await look();
2590
- } finally {
2591
- await page.evaluate(hovering, true);
2592
- }
2593
- }
2594
- function hovering(on) {
2595
- const kept = window;
2596
- if (on) {
2597
- for (const [rule, selector] of kept.__playthroughHover ?? []) rule.selectorText = selector;
2598
- kept.__playthroughHover = [];
2599
- return;
2600
- }
2601
- const set = kept.__playthroughHover = [];
2602
- const visit = (rules) => {
2603
- for (const rule of rules) {
2604
- if (rule instanceof CSSStyleRule && rule.selectorText.includes(":hover")) {
2605
- set.push([rule, rule.selectorText]);
2606
- rule.selectorText = rule.selectorText.replaceAll(":hover", ":not(*)");
2607
- }
2608
- if ("cssRules" in rule) visit(rule.cssRules);
2609
- }
2610
- };
2611
- for (const sheet of [...document.styleSheets, ...document.adoptedStyleSheets]) try {
2612
- visit(sheet.cssRules);
2613
- } catch {}
2614
- getComputedStyle(document.body).color;
2615
- for (const animation of document.getAnimations()) if (animation instanceof CSSTransition) animation.finish();
2616
- }
2617
- function read() {
2618
- 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)$/;
2619
- const HOLDERS = /^(dialog|alertdialog|status|alert|group|radiogroup|table|grid|treegrid|listbox|menu|box)$/;
2620
- const NAMED_BY_WORDS = /^(button|link|heading|option|tab|menuitem|radio|checkbox|switch)$/;
2621
- 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)$/;
2622
- const INPUTS = {
2623
- checkbox: "checkbox",
2624
- radio: "radio",
2625
- range: "slider",
2626
- button: "button",
2627
- submit: "button",
2628
- reset: "button",
2629
- search: "searchbox"
2630
- };
2631
- const TAGS = {
2632
- BUTTON: "button",
2633
- SUMMARY: "button",
2634
- DIALOG: "dialog",
2635
- TABLE: "table",
2636
- SELECT: "combobox",
2637
- TEXTAREA: "textbox",
2638
- OUTPUT: "status",
2639
- FIELDSET: "group",
2640
- IMG: "img",
2641
- H1: "heading",
2642
- H2: "heading",
2643
- H3: "heading",
2644
- H4: "heading",
2645
- H5: "heading",
2646
- H6: "heading"
2647
- };
2648
- const TABLES = "table, [role=table], [role=grid], [role=treegrid]";
2649
- const mirror = window.rrwebRecord?.record?.mirror;
2650
- const plain = (words) => words.replace(/\s+/g, " ").trim();
2651
- const cut = (words, most) => words.length > most ? words.slice(0, most - 1).trimEnd() + "…" : words;
2652
- const style = (element) => getComputedStyle(element);
2653
- function roleOf(element) {
2654
- const given = element.getAttribute("role")?.trim().split(/\s+/)[0];
2655
- if (given) return given;
2656
- if (element.tagName === "A") return element.hasAttribute("href") ? "link" : void 0;
2657
- if (element.tagName === "INPUT") {
2658
- const type = (element.getAttribute("type") ?? "text").toLowerCase();
2659
- return type === "hidden" ? void 0 : INPUTS[type] ?? "textbox";
2660
- }
2661
- return TAGS[element.tagName];
2662
- }
2663
- const apart = (element, side) => {
2664
- const looks = style(element);
2665
- return !looks.display.startsWith("inline") || parseFloat(looks[`margin${side}`]) + parseFloat(looks[`padding${side}`]) > 0 ? " " : "";
2666
- };
2667
- function spoken(element) {
2668
- let words = "";
2669
- for (const node of element.childNodes) if (node.nodeType === Node.TEXT_NODE) words += node.textContent;
2670
- else if (node instanceof HTMLBRElement) words += " ";
2671
- else if (node instanceof Element) words += apart(node, "Left") + spoken(node) + apart(node, "Right");
2672
- return words;
2673
- }
2674
- function ownWords(element) {
2675
- let words = "";
2676
- for (const node of element.childNodes) if (node.nodeType === Node.TEXT_NODE) words += node.textContent;
2677
- else if (node instanceof Element && MARKED.test(node.tagName) && !called(node) && !panel(node)) {
2678
- const looks = style(node);
2679
- if (looks.display === "none" || looks.visibility === "hidden") continue;
2680
- words += apart(node, "Left") + ownWords(node) + apart(node, "Right");
2681
- }
2682
- return plain(words);
2683
- }
2684
- const labelOf = (element) => {
2685
- const by = element.getAttribute("aria-labelledby");
2686
- return plain(by ? by.split(/\s+/).map((id) => document.getElementById(id)?.textContent ?? "").join(" ") : element.getAttribute("aria-label") ?? "");
2687
- };
2688
- function nameOf(element, role) {
2689
- let name = labelOf(element);
2690
- if (!name && NAMED_BY_WORDS.test(role)) name = plain(spoken(element));
2691
- if (!name && "labels" in element) name = plain([...element.labels ?? []].map((label) => label.textContent).join(" "));
2692
- if (!name) name = element.getAttribute("alt") ?? element.getAttribute("title") ?? element.getAttribute("placeholder") ?? "";
2693
- return cut(plain(name), 120);
2694
- }
2695
- function called(element) {
2696
- const role = roleOf(element);
2697
- if (!role || !ROLES.test(role)) return void 0;
2698
- const name = nameOf(element, role);
2699
- return /group$/.test(role) && !name ? void 0 : {
2700
- role,
2701
- name
2702
- };
2703
- }
2704
- const labelledPart = (element) => (/^(SECTION|ASIDE|NAV|FORM|ARTICLE)$/.test(element.tagName) || /^(region|form|navigation|complementary|search|article)$/.test(element.getAttribute("role") ?? "")) && !!labelOf(element);
2705
- function panel(element) {
2706
- const box = element.getBoundingClientRect();
2707
- if (box.width < 12 || box.height < 12 || box.width * box.height > .9 * innerWidth * innerHeight) return false;
2708
- if (/^(HTML|BODY|MAIN|TR|TBODY|THEAD|TD|TH)$/.test(element.tagName)) return false;
2709
- if (labelledPart(element)) return true;
2710
- const looks = style(element);
2711
- const background = looks.backgroundColor;
2712
- const around = element.parentElement && style(element.parentElement).backgroundColor;
2713
- if (!/^(transparent|rgba\(0, 0, 0, 0\))$/.test(background) && background !== around) return true;
2714
- if (looks.boxShadow !== "none") return true;
2715
- return [
2716
- "Top",
2717
- "Right",
2718
- "Bottom",
2719
- "Left"
2720
- ].filter((side) => parseFloat(looks[`border${side}Width`]) > 0 && looks[`border${side}Style`] !== "none").length >= 3;
2721
- }
2722
- const firstWords = (element) => {
2723
- const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
2724
- for (let node; node = walker.nextNode();) if (plain(node.textContent ?? "")) return plain(node.textContent ?? "");
2725
- return "";
2726
- };
2727
- const allWords = (element) => {
2728
- const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
2729
- const words = [];
2730
- for (let node; node = walker.nextNode();) words.push(node.textContent ?? "");
2731
- return plain(words.join(" "));
2732
- };
2733
- function headingsOf(table) {
2734
- const cells = [...table.querySelectorAll("th, [role=columnheader]")].filter((cell) => cell.closest(TABLES) === table);
2735
- const row = cells[0]?.parentElement;
2736
- const headings = cells.filter((cell) => cell.parentElement === row).map((cell) => {
2737
- const box = cell.getBoundingClientRect();
2738
- return {
2739
- name: cut(plain(spoken(cell)), 120),
2740
- from: box.left,
2741
- to: box.right,
2742
- bottom: box.bottom
2743
- };
2744
- }).filter((heading) => heading.name && heading.to > heading.from);
2745
- return headings.length ? headings : void 0;
2746
- }
2747
- const things = [];
2748
- function walk(element, holder, parent, headings) {
2749
- for (const child of element.children) {
2750
- const looks = style(child);
2751
- if (looks.display === "none" || looks.visibility === "hidden" || Number(looks.opacity) === 0) continue;
2752
- const box = child.getBoundingClientRect();
2753
- const under = child.matches(TABLES) ? headingsOf(child) : headings;
2754
- const own = plain([...child.childNodes].filter((node) => node.nodeType === Node.TEXT_NODE).map((node) => node.textContent).join(" "));
2755
- let known = called(child);
2756
- if (!known && !own && panel(child)) {
2757
- const name = labelOf(child) || firstWords(child);
2758
- if (name) known = {
2759
- role: "box",
2760
- name: cut(name, 120)
2761
- };
2762
- }
2763
- let inside = holder;
2764
- let readParent = parent;
2765
- if ((known || own) && box.width >= 1 && box.height >= 1) {
2766
- const middle = box.left + box.width / 2;
2767
- const column = headings?.find((heading) => middle >= heading.from && middle < heading.to && box.top >= heading.bottom - 2)?.name;
2768
- const node = mirror?.getId(child);
2769
- const words = cut(own ? ownWords(child) : "", 300);
2770
- const wordsInside = known && /^(box|menu)$/.test(known.role) ? cut(allWords(child), 300) : void 0;
2771
- things.push({
2772
- role: known?.role ?? "text",
2773
- name: known?.name ?? cut(words, 120),
2774
- words,
2775
- ...wordsInside && wordsInside !== words && { wordsInside },
2776
- box: {
2777
- x: Math.round(box.left),
2778
- y: Math.round(box.top),
2779
- width: Math.round(box.width),
2780
- height: Math.round(box.height)
2781
- },
2782
- page: {
2783
- x: Math.round(box.left + scrollX),
2784
- y: Math.round(box.top + scrollY)
2785
- },
2786
- look: {
2787
- color: looks.color,
2788
- background: looks.backgroundColor,
2789
- size: looks.fontSize,
2790
- weight: looks.fontWeight,
2791
- border: parseFloat(looks.borderTopWidth) && looks.borderTopStyle !== "none" ? `${looks.borderTopWidth} ${looks.borderTopStyle} ${looks.borderTopColor}` : "none"
2792
- },
2793
- ...holder !== void 0 && { holder },
2794
- ...parent !== void 0 && { parent },
2795
- ...column && { column },
2796
- ...known?.role === "box" && labelOf(child) && { labelled: true },
2797
- ...child.closest("[aria-live]:not([aria-live=\"off\"]), [role=\"status\"], [role=\"alert\"], [role=\"log\"]") && { live: true },
2798
- ...node !== void 0 && node >= 0 && { node }
2799
- });
2800
- if (known && HOLDERS.test(known.role)) inside = things.length - 1;
2801
- readParent = things.length - 1;
2802
- }
2803
- walk(child, inside, readParent, under);
2804
- }
2805
- }
2806
- walk(document.body, void 0, void 0, void 0);
2807
- return {
2808
- screen: {
2809
- width: innerWidth,
2810
- height: innerHeight
2811
- },
2812
- things
2813
- };
2814
- }
2815
- 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, giveBackLeft as g, firstOf as h, ALONGSIDE as i, roundInProgress as j, notesToAnswer as k, isFlow as l, keepRunning 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 };
2314
+ 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 };