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.
- package/INSTRUCTIONS.md +63 -73
- package/package.json +1 -1
- package/src/{read-C9bKWxxP.js → flows-BcISV1M-.js} +683 -1184
- package/src/playthrough.js +249 -87
- package/src/playwright.config.js +6 -10
- package/src/read-Bg9XVTtn.js +278 -0
- package/src/reporter-BEodHYRI.js +930 -0
- package/src/reporter.js +1 -1
- package/src/test.js +125 -16
- package/types/cli/src/app.d.ts +2 -0
- package/types/cli/src/changes.d.ts +1 -0
- package/types/cli/src/compare.d.ts +7 -2
- package/types/cli/src/flows.d.ts +8 -4
- package/types/cli/src/pictures.d.ts +1 -0
- package/types/cli/src/read.d.ts +3 -1
- package/types/cli/src/repo.d.ts +2 -0
- package/types/cli/src/reporter.d.ts +18 -4
- package/types/cli/src/setup.d.ts +4 -1
- package/types/cli/src/test.d.ts +1 -1
- package/types/page/src/found.d.ts +4 -2
- package/types/page/src/reading.d.ts +11 -0
- package/types/review/src/rules.d.ts +3 -1
- package/types/review/src/store.d.ts +3 -0
- package/viewer/assets/index-D64VrgNj.js +193 -0
- package/viewer/assets/index-gTfnUrcK.css +2 -0
- package/viewer/index.html +2 -2
- package/src/reporter-CECNk7aW.js +0 -200
- package/viewer/assets/index-BriVL50M.js +0 -193
- package/viewer/assets/index-DaS2F2-s.css +0 -2
|
@@ -1,465 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { 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
|
-
|
|
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/
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
started: about.started,
|
|
560
|
-
rounds: rounds.filter((round) => round !== void 0),
|
|
561
|
-
comparisons: comparisons.flat().filter((comparison) => comparison !== void 0).sort((a, b) => a.round - b.round || a.since - b.since),
|
|
562
|
-
sends: sends.filter((send) => send !== void 0).sort((a, b) => a.sent.localeCompare(b.sent) || a.id.localeCompare(b.id)),
|
|
563
|
-
taken: await this.#readJson(`${id}/taken.json`) ?? [],
|
|
564
|
-
...accepted && { accepted }
|
|
565
|
-
};
|
|
566
|
-
}
|
|
567
|
-
async start(id, title, asked) {
|
|
568
|
-
return this.#one(id, async () => {
|
|
569
|
-
const problems = problemsStarting(id, title, asked);
|
|
570
|
-
if (problems.length) throw new Refused(problems);
|
|
571
|
-
if (await this.storage.read(`${id}/review.json`)) throw new Refused([`There's already a review called “${id}”.`]);
|
|
572
|
-
await this.#writeJson(`${id}/review.json`, {
|
|
573
|
-
format: 1,
|
|
574
|
-
id,
|
|
575
|
-
title,
|
|
576
|
-
...asked && { asked },
|
|
577
|
-
started: now()
|
|
578
|
-
});
|
|
579
|
-
return this.get(id);
|
|
580
|
-
});
|
|
581
|
-
}
|
|
582
|
-
async saveRound(id, round) {
|
|
583
|
-
return this.#one(id, async () => {
|
|
584
|
-
const review = await this.get(id);
|
|
585
|
-
const kept = {
|
|
586
|
-
...round,
|
|
587
|
-
finished: void 0
|
|
71
|
+
// ../review/src/folder.ts
|
|
72
|
+
const LOOK_EVERY = 2e3;
|
|
73
|
+
function folder(root) {
|
|
74
|
+
return {
|
|
75
|
+
async read(path) {
|
|
76
|
+
try {
|
|
77
|
+
return new Uint8Array(await readFile(join(root, path)));
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (isMissing(error)) return void 0;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
async write(path, bytes) {
|
|
84
|
+
const file = join(root, path);
|
|
85
|
+
const unfinished = `${file}.${process.pid}.${Date.now()}.writing`;
|
|
86
|
+
await mkdir(dirname(file), { recursive: true });
|
|
87
|
+
await writeFile(unfinished, bytes);
|
|
88
|
+
await rename(unfinished, file);
|
|
89
|
+
},
|
|
90
|
+
async list(path) {
|
|
91
|
+
try {
|
|
92
|
+
return (await readdir(join(root, path))).filter((name) => !name.endsWith(".writing"));
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (isMissing(error)) return [];
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
watch(path, changed) {
|
|
99
|
+
const look = setInterval(changed, LOOK_EVERY);
|
|
100
|
+
let watcher;
|
|
101
|
+
try {
|
|
102
|
+
watcher = watch(join(root, path), { recursive: true }, changed);
|
|
103
|
+
watcher.on("error", () => watcher?.close());
|
|
104
|
+
} catch {}
|
|
105
|
+
return () => {
|
|
106
|
+
clearInterval(look);
|
|
107
|
+
watcher?.close();
|
|
588
108
|
};
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
const
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
}))]);
|
|
624
|
-
return fresh;
|
|
625
|
-
});
|
|
626
|
-
}
|
|
627
|
-
async finish(id, round) {
|
|
628
|
-
return this.#one(id, async () => {
|
|
629
|
-
const review = await this.get(id);
|
|
630
|
-
const saved = review.rounds.find((saved) => saved.number === round);
|
|
631
|
-
if (saved?.finished) return;
|
|
632
|
-
const problems = problemsFinishing(review, round);
|
|
633
|
-
if (problems.length || !saved) throw new Refused(problems);
|
|
634
|
-
await this.#writeJson(`${id}/rounds/${round}/round.json`, {
|
|
635
|
-
...saved,
|
|
636
|
-
finished: now()
|
|
637
|
-
});
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
async addToBefore(id, added) {
|
|
641
|
-
return this.#one(id, async () => {
|
|
642
|
-
const review = await this.get(id);
|
|
643
|
-
const problems = problemsAddingToBefore(review, added.flows, added.cantStart);
|
|
644
|
-
for (const { name, bytes } of added.files) {
|
|
645
|
-
const named = problemsWithFileName(name);
|
|
646
|
-
problems.push(...named);
|
|
647
|
-
const there = named.length ? void 0 : await this.storage.read(`${id}/rounds/0/files/${name}`);
|
|
648
|
-
if (there && Buffer.compare(there, bytes)) problems.push(`Before has a file called “${name}” already, with something else in it.`);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const isMissing = (error) => error.code === "ENOENT";
|
|
113
|
+
// src/repo.ts
|
|
114
|
+
function repo(root = home(process.cwd())) {
|
|
115
|
+
const reviews = join(root, ".playthrough");
|
|
116
|
+
const appFile = join(root, "playthrough", "app.json");
|
|
117
|
+
return {
|
|
118
|
+
root,
|
|
119
|
+
reviews: new Reviews(folder(reviews)),
|
|
120
|
+
flows: (review) => join(reviews, review, "flows"),
|
|
121
|
+
failed: (review) => join(reviews, review, "failed"),
|
|
122
|
+
round: (review, round) => join(reviews, review, "rounds", String(round), "round.json"),
|
|
123
|
+
ranOnBefore: (review) => join(reviews, review, "ran-on-before.json"),
|
|
124
|
+
setAside: join(reviews, "set-aside.json"),
|
|
125
|
+
appFile,
|
|
126
|
+
setup: join(root, "playthrough", "setup.ts"),
|
|
127
|
+
borrowed: join(reviews, "borrowed"),
|
|
128
|
+
tried: join(reviews, "tried"),
|
|
129
|
+
show: (path) => relative(process.cwd(), path) || ".",
|
|
130
|
+
async keepOutOfGit() {
|
|
131
|
+
await mkdir(reviews, { recursive: true });
|
|
132
|
+
await writeFile(join(reviews, ".gitignore"), "*\n");
|
|
133
|
+
},
|
|
134
|
+
async app() {
|
|
135
|
+
const how = `Say how to start the app in playthrough/app.json: { "start": "<the command that serves it>", "url": "<where it's served>" }.`;
|
|
136
|
+
const text = await readFile(appFile, "utf8").catch(() => void 0);
|
|
137
|
+
if (text === void 0) throw new Refused([`Playthrough needs to start the app to record it. ${how}`]);
|
|
138
|
+
let app;
|
|
139
|
+
try {
|
|
140
|
+
app = JSON.parse(text);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
throw new Refused([`playthrough/app.json isn't JSON: ${error.message}. ${how}`]);
|
|
649
143
|
}
|
|
650
|
-
if (
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
144
|
+
if (!isText(app.start) || !isText(app.url)) throw new Refused([`playthrough/app.json needs both “start” and “url”. ${how}`]);
|
|
145
|
+
if (app.oneAtATime !== void 0 && typeof app.oneAtATime !== "boolean") throw new Refused([`playthrough/app.json's “oneAtATime” is true or false: true runs each flow by itself.`]);
|
|
146
|
+
return {
|
|
147
|
+
start: app.start,
|
|
148
|
+
url: app.url,
|
|
149
|
+
...app.oneAtATime && { oneAtATime: true }
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function home(here) {
|
|
155
|
+
for (let folder = here;; folder = dirname(folder)) {
|
|
156
|
+
if (existsSync(join(folder, ".playthrough")) || existsSync(join(folder, "playthrough", "app.json"))) return folder;
|
|
157
|
+
if (existsSync(join(folder, ".git")) || dirname(folder) === folder) return here;
|
|
655
158
|
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
159
|
+
}
|
|
160
|
+
const isText = (value) => typeof value === "string" && value.trim() !== "";
|
|
161
|
+
// src/setup.ts
|
|
162
|
+
const OURS = [
|
|
163
|
+
"baseURL",
|
|
164
|
+
"viewport",
|
|
165
|
+
"screen",
|
|
166
|
+
"isMobile",
|
|
167
|
+
"hasTouch",
|
|
168
|
+
"deviceScaleFactor",
|
|
169
|
+
"locale",
|
|
170
|
+
"timezoneId",
|
|
171
|
+
"colorScheme",
|
|
172
|
+
"reducedMotion",
|
|
173
|
+
"forcedColors",
|
|
174
|
+
"contrast"
|
|
175
|
+
];
|
|
176
|
+
async function setupIn(file, written) {
|
|
177
|
+
if (!existsSync(file)) return {};
|
|
178
|
+
const href = pathToFileURL(file).href;
|
|
179
|
+
asModule(href);
|
|
180
|
+
const module = await import(`${href}${written ? `?${written}` : ""}`);
|
|
181
|
+
const inner = typeof module.default === "object" ? module.default : void 0;
|
|
182
|
+
const use = module.use ?? inner?.use;
|
|
183
|
+
const ours = Object.keys(use ?? {}).filter((option) => OURS.includes(option));
|
|
184
|
+
if (ours.length) throw new Refused([`playthrough/setup.ts's use sets ${list(ours)}, which Playthrough keeps the same for every flow, or a flow sets with test.use, like its screen. Take ${ours.length > 1 ? "them" : "it"} out of use.`]);
|
|
185
|
+
return {
|
|
186
|
+
make: typeof module.default === "function" ? module.default : inner?.default,
|
|
187
|
+
after: module.after ?? inner?.after,
|
|
188
|
+
signIn: module.signIn ?? inner?.signIn,
|
|
189
|
+
people: module.people ?? inner?.people,
|
|
190
|
+
...use && { use }
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const setups = /* @__PURE__ */ new Set();
|
|
194
|
+
function asModule(href) {
|
|
195
|
+
if (!setups.size) modules.registerHooks({ load: (url, context, next) => next(url, setups.has(url.split("?")[0]) ? {
|
|
196
|
+
...context,
|
|
197
|
+
format: "module-typescript"
|
|
198
|
+
} : context) });
|
|
199
|
+
setups.add(href);
|
|
200
|
+
}
|
|
201
|
+
const changes = (method) => ![
|
|
202
|
+
"GET",
|
|
203
|
+
"HEAD",
|
|
204
|
+
"OPTIONS"
|
|
205
|
+
].includes(method.toUpperCase());
|
|
206
|
+
function watching(request, changed) {
|
|
207
|
+
return new Proxy(request, { get(target, name) {
|
|
208
|
+
const value = Reflect.get(target, name);
|
|
209
|
+
if (typeof value !== "function") return value;
|
|
210
|
+
return (...args) => {
|
|
211
|
+
const [to, options] = args;
|
|
212
|
+
const method = name === "fetch" ? options?.method ?? (typeof to === "string" ? "GET" : to.method()) : String(name);
|
|
213
|
+
if ([
|
|
214
|
+
"post",
|
|
215
|
+
"put",
|
|
216
|
+
"patch",
|
|
217
|
+
"delete",
|
|
218
|
+
"fetch"
|
|
219
|
+
].includes(String(name)) && changes(method)) changed();
|
|
220
|
+
return value.apply(target, args);
|
|
221
|
+
};
|
|
222
|
+
} });
|
|
223
|
+
}
|
|
224
|
+
const firstOf$1 = ({ people }) => Object.keys(people ?? {})[0];
|
|
225
|
+
function personIn({ people }, person) {
|
|
226
|
+
if (people && !(person in people)) throw new Error(`playthrough/setup.ts names no one called ${quote(person)}. Its people: ${list(Object.keys(people).map(quote))}. Add them there, or do the flow as one of them.`);
|
|
227
|
+
}
|
|
228
|
+
async function make({ make, after }, borrowed, given) {
|
|
229
|
+
const setup = await make?.(given) ?? {};
|
|
230
|
+
if (!after) return {
|
|
231
|
+
setup,
|
|
232
|
+
giveBack: async () => false
|
|
233
|
+
};
|
|
234
|
+
await mkdir(borrowed, { recursive: true });
|
|
235
|
+
const file = join(borrowed, `${process.pid}-${randomUUID()}.json`);
|
|
236
|
+
await writeFile(file, JSON.stringify({
|
|
237
|
+
baseURL: given.baseURL,
|
|
238
|
+
setup
|
|
239
|
+
}));
|
|
240
|
+
return {
|
|
241
|
+
setup,
|
|
242
|
+
giveBack: () => giveBack(after, file, given.baseURL, setup)
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const giveBackLeft = (setup, borrowed) => giveBackOf(setup, borrowed, (pid) => !alive(pid));
|
|
246
|
+
const giveBackOwn = (setup, borrowed) => giveBackOf(setup, borrowed, (pid) => pid === process.pid);
|
|
247
|
+
async function giveBackOf({ after }, borrowed, whose) {
|
|
248
|
+
if (!after) return;
|
|
249
|
+
const left = (await readdir(borrowed).catch(() => [])).filter((name) => whose(Number(name.split("-")[0])));
|
|
250
|
+
for (const name of left) {
|
|
251
|
+
const file = join(borrowed, `${process.pid}-${name.slice(name.indexOf("-") + 1)}`);
|
|
252
|
+
if (!await rename(join(borrowed, name), file).then(() => true, () => false)) continue;
|
|
253
|
+
const { baseURL, setup } = JSON.parse(await readFile(file, "utf8"));
|
|
254
|
+
await giveBack(after, file, baseURL, setup);
|
|
662
255
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
256
|
+
}
|
|
257
|
+
const giving = /* @__PURE__ */ new Set();
|
|
258
|
+
async function giveBack(after, file, baseURL, setup) {
|
|
259
|
+
if (giving.has(file) || !existsSync(file)) return true;
|
|
260
|
+
giving.add(file);
|
|
261
|
+
const request$2 = await request.newContext({ baseURL });
|
|
262
|
+
try {
|
|
263
|
+
await after({
|
|
264
|
+
request: request$2,
|
|
265
|
+
baseURL,
|
|
266
|
+
setup
|
|
671
267
|
});
|
|
268
|
+
await rm(file, { force: true });
|
|
269
|
+
return true;
|
|
270
|
+
} catch (error) {
|
|
271
|
+
console.log(`playthrough/setup.ts's after couldn't give back what a run made: ${firstLine(String(error?.message ?? error))}. 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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
by,
|
|
681
|
-
at: now()
|
|
682
|
-
});
|
|
683
|
-
});
|
|
277
|
+
}
|
|
278
|
+
function alive(pid) {
|
|
279
|
+
try {
|
|
280
|
+
process.kill(pid, 0);
|
|
281
|
+
return true;
|
|
282
|
+
} catch (error) {
|
|
283
|
+
return error.code === "EPERM";
|
|
684
284
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
285
|
+
}
|
|
286
|
+
// src/app.ts
|
|
287
|
+
let started;
|
|
288
|
+
let ending = false;
|
|
289
|
+
async function keepRunning(app, root) {
|
|
290
|
+
if (!ending) {
|
|
291
|
+
ending = true;
|
|
292
|
+
const { setup, borrowed } = repo(root);
|
|
293
|
+
const giveBack = () => setupIn(setup).then((setup) => giveBackOwn(setup, borrowed));
|
|
294
|
+
const end = (signal) => void Promise.race([giveBack(), new Promise((wait) => setTimeout(wait, 1e4))]).finally(() => process.exit(128 + constants.signals[signal]));
|
|
295
|
+
for (const signal of [
|
|
296
|
+
"SIGINT",
|
|
297
|
+
"SIGTERM",
|
|
298
|
+
"SIGHUP"
|
|
299
|
+
]) process.once(signal, end);
|
|
300
|
+
process.once("exit", stop);
|
|
689
301
|
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
} while (again);
|
|
709
|
-
} catch (error) {
|
|
710
|
-
done(() => reject(error));
|
|
711
|
-
} finally {
|
|
712
|
-
looking = false;
|
|
713
|
-
}
|
|
714
|
-
};
|
|
715
|
-
const aborted = () => done(() => reject(signal?.reason));
|
|
716
|
-
const stop = this.storage.watch(id, look);
|
|
717
|
-
signal?.addEventListener("abort", aborted, { once: true });
|
|
718
|
-
look();
|
|
302
|
+
if (await serves(app.url)) return;
|
|
303
|
+
console.log(`Starting the app: ${app.start}`);
|
|
304
|
+
const began = Date.now();
|
|
305
|
+
started = spawn(app.start, {
|
|
306
|
+
cwd: root,
|
|
307
|
+
shell: true,
|
|
308
|
+
stdio: [
|
|
309
|
+
"ignore",
|
|
310
|
+
"pipe",
|
|
311
|
+
"pipe"
|
|
312
|
+
],
|
|
313
|
+
detached: true
|
|
314
|
+
});
|
|
315
|
+
const said = [];
|
|
316
|
+
for (const output of [started.stdout, started.stderr]) {
|
|
317
|
+
output.on("data", (chunk) => {
|
|
318
|
+
said.push(...chunk.toString().split("\n").filter((line) => line.trim() && !/^\s+at /.test(line)));
|
|
319
|
+
said.splice(0, said.length - 10);
|
|
719
320
|
});
|
|
321
|
+
output.unref();
|
|
720
322
|
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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: `${
|
|
1180
|
+
words: `${them} ${movedWords(moment, first)}${more}`,
|
|
1438
1181
|
...pushed && { pushed }
|
|
1439
1182
|
};
|
|
1440
1183
|
});
|
|
1441
1184
|
}
|
|
1442
|
-
|
|
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(
|
|
1546
|
-
const
|
|
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
|
|
1563
|
-
const words = all.
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
words:
|
|
1573
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1690
|
-
const
|
|
1691
|
-
const
|
|
1562
|
+
const moved = on.identity && !on.identity.startsWith("near ") ? void 0 : patch.moved;
|
|
1563
|
+
const by = moved && Math.max(Math.abs(moved.x), Math.abs(moved.y));
|
|
1564
|
+
const words = moved ? `${on.called} moved ${wayOf(moved.x, moved.y)}, ${by}px` : `${on.called} looks different`;
|
|
1565
|
+
const id = idOf(on.identity ?? `place:${Math.round(patch.x / 32)},${Math.round(patch.y / 32)}`, moved ? "moved" : "looks different");
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1941
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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(`
|
|
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) : "
|
|
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 ?? "
|
|
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 ${
|
|
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
|
-
|
|
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([
|
|
2427
|
-
|
|
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 ${
|
|
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 ${
|
|
2435
|
-
const
|
|
2436
|
-
|
|
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([
|
|
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.
|
|
2447
|
-
|
|
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(["
|
|
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
|
-
|
|
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 };
|