playthrough 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env -S node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON
2
- import { A as time, C as conversation, D as roundInProgress, E as problemsFinishing, O as list, S as allNotes, T as notesToAnswer, _ as readingsOn, b as Refused, h as hashOf, k as quote, p as firstLine, w as lastSeen } from "./reporter-4C_7joIm.js";
3
- import { D as pausedAt, E as repo, S as giveBackLeft, T as setupIn, _ as keepRunning, a as failedAt, c as makeFlowsFolder, d as walkFork, f as whereItStopped, g as shownAt, h as placeIn, i as called, l as runFlows, m as compareWithEarlier, o as howToWriteFlows, p as trySetup, s as isFlow, t as ALONGSIDE, v as serves, y as stopApp } from "./flows-Dt7fcPLt.js";
2
+ import { A as list, C as conversation, D as problemsFinishing, E as notesToAnswer, M as time, O as roundInProgress, S as allNotes, T as lastSeen, _ as readingsOn, b as Refused, j as quote, k as toAccountFor, p as firstLine, w as inOrderJoined } from "./reporter-DE8aueAO.js";
3
+ import { D as setupIn, O as repo, _ as placeIn, a as failedAt, b as serves, c as isFlow, f as walkFork, g as compareWithEarlier, h as writtenNow, i as called, k as pausedAt, l as makeFlowsFolder, m as trySetup, o as flowsOf, p as whereItStopped, s as howToWriteFlows, t as ALONGSIDE, u as runFlows, v as shownAt, w as giveBackLeft, x as stopApp, y as keepRunning } from "./flows-BkG0TgHA.js";
4
4
  import { t as read } from "./read-Bg9XVTtn.js";
5
5
  import { createRequire } from "node:module";
6
6
  import { copyFileSync, cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { execFileSync } from "node:child_process";
8
8
  import { readFile, readdir, stat, writeFile } from "node:fs/promises";
9
+ import { createHash } from "node:crypto";
9
10
  import { constants, networkInterfaces, tmpdir, userInfo } from "node:os";
10
11
  import { dirname, extname, join, normalize, resolve, sep } from "node:path";
11
12
  import { chromium, request } from "@playwright/test";
@@ -21,9 +22,27 @@ if (major < 24) {
21
22
  // src/before.ts
22
23
  function keepBefore(repo, id) {
23
24
  if (!attempt(() => git(repo, ["rev-parse", "--is-inside-work-tree"]))) return void 0;
25
+ const head = headOf(repo);
26
+ if (!head) return void 0;
24
27
  try {
25
- const head = headOf(repo);
26
- return keep(repo, id, treeNow(repo), head ? [head] : []);
28
+ const commit = git(repo, [
29
+ "commit-tree",
30
+ "--no-gpg-sign",
31
+ "-p",
32
+ head,
33
+ "-m",
34
+ `Before, of the review ${id}: the app as it was, with what wasn't committed`,
35
+ treeNow(repo)
36
+ ], {
37
+ ...process.env,
38
+ ...PLAYTHROUGH
39
+ });
40
+ git(repo, [
41
+ "update-ref",
42
+ `refs/playthrough/${id}/before`,
43
+ commit
44
+ ]);
45
+ return commit;
27
46
  } catch (error) {
28
47
  throw new Refused([
29
48
  "Before can't be kept as a commit, because git says:",
@@ -32,179 +51,82 @@ function keepBefore(repo, id) {
32
51
  ]);
33
52
  }
34
53
  }
35
- const keptWithOursNow = (repo, id, commit) => keep(repo, id, treeNow(repo, {
36
- commit,
37
- paths: ["playthrough"]
38
- }), git(repo, [
39
- "rev-list",
40
- "--parents",
41
- "--max-count=1",
42
- commit
43
- ]).split(" ").slice(1));
44
- function keep(repo, id, tree, parents) {
45
- const commit = git(repo, [
46
- "commit-tree",
47
- "--no-gpg-sign",
48
- ...parents.flatMap((parent) => ["-p", parent]),
49
- "-m",
50
- `Before, of the review ${id}: the app as it was, with what wasn't committed`,
51
- tree
52
- ], {
53
- ...process.env,
54
- ...PLAYTHROUGH
55
- });
56
- git(repo, [
57
- "update-ref",
58
- `refs/playthrough/${id}/before`,
59
- commit
60
- ]);
61
- return commit;
62
- }
63
- function treeNow(repo, onto) {
54
+ function treeNow(repo) {
64
55
  const folder = mkdtempSync(join(tmpdir(), "playthrough-"));
65
56
  try {
66
57
  const index = join(folder, "index");
67
- const env = {
58
+ attempt(() => copyFileSync(resolve(repo.root, git(repo, [
59
+ "rev-parse",
60
+ "--git-path",
61
+ "index"
62
+ ])), index));
63
+ git(repo, [
64
+ "add",
65
+ "--all",
66
+ ":/"
67
+ ], {
68
68
  ...process.env,
69
69
  GIT_INDEX_FILE: index
70
- };
71
- if (onto) {
72
- git(repo, ["read-tree", onto.commit], env);
73
- git(repo, [
74
- "rm",
75
- "-r",
76
- "--cached",
77
- "--force",
78
- "--quiet",
79
- "--ignore-unmatch",
80
- "--",
81
- ...onto.paths
82
- ], env);
83
- git(repo, [
84
- "add",
85
- "--all",
86
- "--",
87
- ...onto.paths
88
- ], env);
89
- } else {
90
- attempt(() => copyFileSync(resolve(repo.root, git(repo, [
91
- "rev-parse",
92
- "--git-path",
93
- "index"
94
- ])), index));
95
- git(repo, [
96
- "add",
97
- "--all",
98
- ":/"
99
- ], env);
100
- }
101
- return git(repo, ["write-tree"], env);
70
+ });
71
+ return git(repo, ["write-tree"], {
72
+ ...process.env,
73
+ GIT_INDEX_FILE: index
74
+ });
102
75
  } finally {
103
76
  rmSync(folder, { recursive: true });
104
77
  }
105
78
  }
106
- const changedSince = (repo, commit, paths) => attempt(() => git(repo, [
107
- "diff",
108
- "--quiet",
109
- `${commit}^{tree}`,
110
- treeNow(repo),
111
- "--",
112
- ...paths
113
- ])) === void 0;
114
79
  const PLAYTHROUGH = {
115
80
  GIT_AUTHOR_NAME: "Playthrough",
116
81
  GIT_AUTHOR_EMAIL: "playthrough@localhost",
117
82
  GIT_COMMITTER_NAME: "Playthrough",
118
83
  GIT_COMMITTER_EMAIL: "playthrough@localhost"
119
84
  };
120
- async function recordOnBefore(repo, id, number, flows) {
121
- const before = (await repo.reviews.get(id)).rounds.find((round) => round.number === 0);
122
- if (!before) return;
123
- const later = flows.filter((name) => !before.flows.some((flow) => flow.name === name) && !before.cantStart?.includes(name));
124
- if (!later.length) return;
125
- const [are, they, have] = later.length > 1 ? [
126
- "are",
127
- "they're",
128
- "have"
129
- ] : [
130
- "is",
131
- "it's",
132
- "has"
133
- ];
134
- const named = called(later);
135
- await onBefore(repo, id, later, `${named} ${are} new, so ${they} recorded on Before too`, `Round ${number} wasn't recorded`, "Record again.", (why) => `${named} ${have} no Before: ${why}.`);
136
- }
137
- async function changedOnBefore(repo, review) {
138
- const before = review.rounds.find((round) => round.number === 0);
139
- if (!before) return [];
140
- const handedOver = review.rounds.some((round) => round.number > 0 && round.finished);
141
- const flows = [...before.flows.filter((flow) => !handedOver || flow.stopped).map((flow) => flow.name), ...before.cantStart ?? []];
142
- const ran = await ranOnBefore(repo, review.id);
143
- return flows.filter((name) => !ran[name] || hashOf(join(repo.flows(review.id), ran[name].file)) !== ran[name].hash);
144
- }
145
- const ranOnBefore = (repo, id) => readFile(repo.ranOnBefore(id), "utf8").then(JSON.parse, () => ({}));
146
- async function noteRanOnBefore(repo, id, written, gone = []) {
147
- const ran = {
148
- ...await ranOnBefore(repo, id),
149
- ...written
150
- };
151
- for (const name of gone) delete ran[name];
152
- await writeFile(repo.ranOnBefore(id), JSON.stringify(ran));
153
- }
154
- async function tryBeforeAgain(repo, id, flows, again) {
155
- const [has, they, them] = flows.length > 1 ? [
156
- "have",
157
- "they're",
158
- "them"
159
- ] : [
160
- "has",
161
- "it's",
162
- "it"
163
- ];
164
- const named = called(flows);
165
- await onBefore(repo, id, flows, `${named} ${has} changed since ${flows.length > 1 ? "they" : "it"} last ran on Before, so ${they} recorded on it again, in place of what it has of ${them}`, "Before wasn't recorded again", again, (why) => `Before can't record ${named} again: ${why}.`);
166
- }
167
- async function recordBeforeAgain(repo, id, command, again) {
85
+ const handedOver = (review, flow) => review.rounds.some((round) => round.number > 0 && round.finished && round.flows.some((kept) => kept.name === flow));
86
+ async function recordOnBefore(repo, id, number, { recorded, written }) {
168
87
  const review = await repo.reviews.get(id);
169
88
  const before = review.rounds.find((round) => round.number === 0);
170
- if (!before?.commit || review.rounds.some((round) => round.number > 0 && round.finished) || !changedSince(repo, before.commit, ["playthrough"])) return false;
171
- await trySetup(repo, command);
172
- return onBefore(repo, id, [...before.flows.map((flow) => flow.name), ...before.cantStart ?? []], `playthrough/ has changed since Before was recorded, so Before is recorded again on it, in place of what it has`, "Before wasn't recorded again", again, (why) => `Before stays as it was recorded, on playthrough/ as it was then: ${why}.`, true);
173
- }
174
- async function onBefore(repo, id, flows, doing, unrecorded, again, cant, whole = false) {
175
- const before = (await repo.reviews.get(id)).rounds.find((round) => round.number === 0);
176
- const unchanged = !!before.commit && !changedSince(repo, before.commit, [":/", ":(exclude)playthrough"]);
177
- const why = !before.commit ? "Before wasn't kept as a commit, so the app as it was can't be put back" : !unchanged && !headOf(repo) ? UNBORN : void 0;
178
- if (why) {
179
- console.log(`\n${cant(why)}`);
180
- return false;
181
- }
182
- const later = flows;
89
+ if (!before) return;
90
+ const ran = await readFile(repo.ranOnBefore(id), "utf8").then(JSON.parse, () => ({}));
91
+ const setup = await writtenNow(repo);
92
+ const follows = Object.fromEntries(recorded.map(({ flow, doneOn }) => [flow.name, createHash("sha256").update(JSON.stringify([
93
+ written[flow.name]?.hash,
94
+ setup,
95
+ doneOn ?? {}
96
+ ])).digest("hex")]));
97
+ const on = [...before.flows.map((flow) => flow.name), ...before.cantStart ?? []];
98
+ const flows = recorded.filter(({ flow }) => !on.includes(flow.name) || !handedOver(review, flow.name) && ran[flow.name] !== follows[flow.name]);
99
+ const gone = on.filter((name) => !handedOver(review, name) && !recorded.some(({ flow }) => flow.name === name));
100
+ if (!flows.length && !gone.length) return;
101
+ const names = flows.map(({ flow }) => flow.name);
102
+ const unrecorded = `Round ${number} wasn't recorded`;
103
+ const why = !before.commit ? "Before wasn't kept as a commit, so the app as it was can't be put back" : !headOf(repo) ? UNBORN : void 0;
104
+ if (why) return void (names.length && console.log(`\n${called(names)} ${names.length > 1 ? "have" : "has"} no Before: ${why}.`));
183
105
  const taken = await repo.reviews.files(id, 0);
184
- const run = () => runFlows(repo, id, 0, {
185
- later: {
186
- flows: later,
187
- taken
188
- },
189
- first: () => true
190
- }).catch((error) => {
106
+ const doneInRound = Object.fromEntries(flows.map(({ flow, doneOn }) => [flow.name, doneOn ?? {}]));
107
+ const run = () => runFlows(repo, id, 0, { before: {
108
+ flows: names,
109
+ taken,
110
+ doneInRound
111
+ } }).catch((error) => {
191
112
  if (error instanceof Refused) return {
192
113
  recorded: [],
193
- failed: later.map((flow) => ({ flow })),
114
+ failed: names.map((flow) => ({ flow })),
194
115
  written: {}
195
116
  };
196
117
  throw error;
197
118
  });
198
- let ran;
199
- if (unchanged) {
200
- console.log(`\n${doing}, on the app as it is: nothing has changed since Before.`);
201
- ran = await run();
202
- } else {
119
+ let kept = {
120
+ recorded: [],
121
+ failed: [],
122
+ written: {}
123
+ };
124
+ if (names.length) {
203
125
  const { url } = await repo.app();
204
126
  await stopApp(url);
205
127
  const shared = await serves(url);
206
- console.log(`\n${doing}. Your work is set aside with git stash meanwhile${shared ? `, and what serves ${url} serves the app as it was` : ""}.`);
207
- ran = await asItWas(repo, unrecorded, before.commit, shared, async () => {
128
+ console.log(`\nYour work is set aside with git stash while ${names.length > 1 ? "they record" : "it records"} on Before${shared ? `, and what serves ${url} serves the app as it was` : ""}.`);
129
+ kept = await asItWas(repo, unrecorded, before.commit, shared, async () => {
208
130
  if (shared) await caughtUp(url);
209
131
  try {
210
132
  return await run();
@@ -213,21 +135,17 @@ async function onBefore(repo, id, flows, doing, unrecorded, again, cant, whole =
213
135
  }
214
136
  });
215
137
  if (shared) await caughtUp(url);
138
+ refuseIfBroken(kept.failed, unrecorded, "Record again.");
216
139
  }
217
- refuseIfBroken(ran.failed, unrecorded, again);
218
- const commit = whole ? keptWithOursNow(repo, id, before.commit) : void 0;
219
- const gone = later.filter((name) => !ran.recorded.some(({ flow }) => flow.name === name) && !ran.failed.some(({ flow }) => flow === name));
220
140
  await repo.reviews.addToBefore(id, {
221
- flows: ran.recorded.map(({ flow }) => flow),
222
- cantStart: cantStart(ran.recorded, ran.failed),
223
- files: ran.recorded.flatMap(({ files }) => files),
224
- ...commit && { commit },
141
+ flows: kept.recorded.map(({ flow }) => flow),
142
+ cantStart: cantStart(kept.recorded, kept.failed),
143
+ files: kept.recorded.flatMap(({ files }) => files),
225
144
  gone
226
145
  });
227
- await noteRanOnBefore(repo, id, ran.written, gone);
228
- if (gone.length) console.log(`\n${gone.length > 3 ? `${gone.length} of them` : list(gone.map(quote))} ${gone.length > 1 ? "aren't flows" : "isn't a flow"} any more, so Before lets ${gone.length > 1 ? "them" : "it"} go.`);
229
- for (const line of howFar(repo, id, ran.recorded, ran.failed, "the app as it was")) console.log(line);
230
- return true;
146
+ for (const name of names) ran[name] = follows[name];
147
+ for (const name of gone) delete ran[name];
148
+ await writeFile(repo.ranOnBefore(id), JSON.stringify(ran));
231
149
  }
232
150
  function refuseIfBroken(failed, unrecorded, again) {
233
151
  const broken = failed.filter((failure) => failure.broken);
@@ -238,15 +156,6 @@ function refuseIfBroken(failed, unrecorded, again) {
238
156
  ]);
239
157
  }
240
158
  const cantStart = (recorded, failed) => failed.map(({ flow }) => flow).filter((flow) => !recorded.some((kept) => kept.flow.name === flow));
241
- function howFar(repo, id, recorded, failed, app) {
242
- const lines = failed.map(({ flow, at, why }) => {
243
- const because = why ? `, where Playwright says “${why}”` : "";
244
- return !recorded.some((kept) => kept.flow.name === flow) ? `${quote(flow)} can't start on ${app}${because}, so it has no Before.` : `${quote(flow)} ${at ? `stops at ${quote(at)}${because}` : "stops after its last step"}, so its Before goes as far as that.`;
245
- });
246
- const where = existsSync(repo.failed(id)) ? ` ${whereItStopped(repo, id, failed)}` : "";
247
- if (lines.length) lines.push(`If that's the app as it was, that's Before. If it's the flow's own mistake, fix the flow and run playthrough start ${id} again: it records a flow changed since on Before again.${where}`);
248
- return lines;
249
- }
250
159
  const SIGNALS = [
251
160
  "SIGINT",
252
161
  "SIGTERM",
@@ -1517,27 +1426,24 @@ async function record(repo, id) {
1517
1426
  const review = await repo.reviews.get(id);
1518
1427
  if (review.accepted) throw new Refused(["This review is accepted, so nothing more can be recorded."]);
1519
1428
  const number = review.rounds.length ? roundInProgress(review) : 1;
1520
- const onBefore = !!review.rounds.find((round) => round.number === 0)?.commit;
1521
- const first = (flow) => !onBefore && !review.rounds.some((round) => round.number !== number && round.flows.some((kept) => kept.name === flow));
1522
- if (number === 1 && !await recordBeforeAgain(repo, id, `playthrough record ${id}`, "Record again.")) {
1523
- const again = await changedOnBefore(repo, review);
1524
- if (again.length) await tryBeforeAgain(repo, id, again, "Record again.");
1525
- }
1526
- const { recorded, failed } = await runFlows(repo, id, number, { first });
1429
+ const ran = await runFlows(repo, id, number, { first: (flow) => !handedOver(review, flow) });
1430
+ const { recorded, failed } = ran;
1527
1431
  if (failed.length) throw new Refused([
1528
1432
  `\nRound ${number} wasn't recorded, because ${failed.length > 1 ? "these flows" : "a flow"} failed:`,
1529
1433
  ...failed.map(failedAt),
1530
1434
  `Fix ${failed.length > 1 ? "them" : "it"} and record again, which runs ${failed.length > 1 ? "them" : "it"} first. ${whereItStopped(repo, id, failed)}`
1531
1435
  ]);
1532
- await recordOnBefore(repo, id, number, recorded.map(({ flow }) => flow.name));
1436
+ await recordOnBefore(repo, id, number, ran);
1533
1437
  const saved = review.rounds.find((round) => round.number === number);
1438
+ const joined = inOrderJoined(review);
1439
+ const place = (name) => joined.includes(name) ? joined.indexOf(name) : joined.length;
1534
1440
  const flows = recorded.map(({ flow }) => {
1535
1441
  const line = saved?.flows.find((kept) => kept.name === flow.name)?.line;
1536
1442
  return line ? {
1537
1443
  ...flow,
1538
1444
  line
1539
1445
  } : flow;
1540
- });
1446
+ }).sort((a, b) => place(a.name) - place(b.name));
1541
1447
  for (const { files } of recorded) for (const { name, bytes } of files) await repo.reviews.saveFile(id, number, name, bytes);
1542
1448
  const written = saved?.replies ?? [];
1543
1449
  const waiting = notesToAnswer(review, number).filter((note) => !written.some((reply) => reply.note === note)).map((note) => ({
@@ -1563,11 +1469,30 @@ async function record(repo, id) {
1563
1469
  const left = problemsFinishing(await repo.reviews.get(id), number);
1564
1470
  console.log(`\nRecorded round ${number}: ${flows.length > 3 ? called(flows.map((flow) => flow.name)) : list(flows.map((flow) => `${quote(flow.name)} (${Math.round(flow.length)}s)`))}.`);
1565
1471
  const since = comparisons.find((comparison) => comparison.since === number - 1);
1566
- if (since) console.log(`\n${changed(since.changes.length, number - 1)}`);
1472
+ const named = toAccountFor(since);
1473
+ if (since) console.log(`\n${changed(named.length, number - 1)}`);
1474
+ const pictured = since?.changes.filter((change) => change.pictureOnly) ?? [];
1475
+ if (pictured.length) {
1476
+ const [parts, look, them, need] = pictured.length > 1 ? [
1477
+ `${pictured.length} parts of the page`,
1478
+ "look",
1479
+ "them",
1480
+ "they need"
1481
+ ] : [
1482
+ "One part of the page",
1483
+ "looks",
1484
+ "it",
1485
+ "it needs"
1486
+ ];
1487
+ console.log(`${parts} only ${look} different in the pictures, with nothing read there changed${pictured.length > 3 ? "" : `: ${list(pictured.map((change) => quote(change.words)))}`}. The person sees ${them} on the recording, and ${need} nothing from you, unless it's what you changed for a note: then say so in “accounts”, and it shows in your answer.`);
1488
+ }
1567
1489
  if (since && !since.changes.length && replies.some((reply) => reply.on && !reply.on.app && !reply.watch)) console.log(`Nothing at rest shows an answer that moves, like an animation: if yours does, add "watch": "<the title of the step it's seen in>" to its reply in ${file}, and the note plays that step.`);
1568
1490
  if (walked.length) console.log(`\n${walked.map(saidOf).join("\n")}`);
1569
1491
  if (left.length) console.log(`\nStill to do, in ${file}:\n${left.map((problem) => ` ${problem}`).join("\n")}`);
1570
- if (number === 1 && !saved && since?.changes.length) console.log(`\nSay why each change is there in “accounts”, by its id: { "line": "<why, in your words>" }, { "note": "<the note it was made for>" }, or { "notReal": "<why nothing really changed>" }.`);
1492
+ if (number === 1 && !saved && named.length) console.log(`\nSay why each change is there in “accounts”, by its id: { "line": "<why, in your words>" }, { "note": "<the id of the note it was made for>" }, or { "notReal": "<why nothing really changed>" }.`);
1493
+ const wrote = number === 1 && !saved && recorded.find((kept) => kept.wrote);
1494
+ if (wrote && !(await setupIn(repo.setup)).after) console.log(`
1495
+ ${quote(wrote.flow.name)} sends the app ${wrote.wrote}, which changes what it holds, and ${existsSync(repo.setup) ? "playthrough/setup.ts has no after, so what each flow makes stays in the app" : "there's no playthrough/setup.ts, so each flow starts on what the ones before it left, and what they make stays in the app"}: one for each flow recorded, three for a new one. Tell the person, with the step that would fix it; run playthrough alone for how setup.ts makes what each flow starts from, and gives it back.`);
1571
1496
  if (unsaid.length) {
1572
1497
  const [are, each] = unsaid.length > 1 ? ["are", "each"] : ["is", "it"];
1573
1498
  console.log(`\n${list(unsaid.map((flow) => quote(flow.name)))} ${are} new to the person. In ${file}, ${each} can have a line at its start, as “line” in its flow under “flows”: what it starts from, or the choice it shows, in a sentence of up to 140 characters.`);
@@ -1619,42 +1544,30 @@ Write its flows before you change anything. They show what was asked, so write t
1619
1544
 
1620
1545
  ${howToWriteFlows(repo, id)}
1621
1546
 
1622
- Then run playthrough start ${id} again. It records Before: each flow on the app as it is, as far as the app can take it. If the app is new, there's no Before: go straight to the work.`);
1547
+ Then run playthrough start ${id} again. It tries the app's setup, and keeps the app as it is as Before, to record each flow on with round 1, so the person can compare. If the app is new, there's no Before: go straight to the work.`);
1623
1548
  }
1624
1549
  const review = await repo.reviews.get(id);
1625
- if (title && title !== review.title) throw new Refused([`There's already a review called “${id}”, of “${review.title}”. Give this one another name, or record that one's Before: playthrough start ${id}`]);
1550
+ if (title && title !== review.title) throw new Refused([`There's already a review called “${id}”, of “${review.title}”. Give this one another name, or carry that one on: playthrough start ${id}`]);
1626
1551
  const last = review.rounds.at(-1);
1627
- review.rounds.find((round) => round.number === 0);
1628
- if (last?.number === 0 && await recordBeforeAgain(repo, id, `playthrough start ${id}`, "Run this again.")) return;
1629
- const changed = await changedOnBefore(repo, review);
1630
- if (changed.length) return tryBeforeAgain(repo, id, changed, "Run this again.");
1631
- if (last?.number === 0) throw new Refused([`Before is recorded already. Change the app, then record round 1: playthrough record ${id}`]);
1552
+ if (last?.number === 0) throw new Refused([`Before is kept already. Change the app, then record round 1: playthrough record ${id}`]);
1632
1553
  if (last) throw new Refused([`Before is how the app was before the work, and ${id} has round ${last.number} already. Record the round being worked on: playthrough record ${id}`]);
1554
+ await flowsOf(repo, id);
1555
+ await trySetup(repo, `playthrough start ${id}`);
1633
1556
  const commit = keepBefore(repo, id);
1634
- const { recorded, failed, written } = await runFlows(repo, id, 0, { first: () => true });
1635
- refuseIfBroken(failed, "Before wasn't recorded", "Run this again.");
1636
- if (!recorded.length) return console.log(`\nNone of the flows can start on the app as it is, so there's no Before. If one should, fix it and run this again. Otherwise change the app, then record round 1: playthrough record ${id}`);
1637
- for (const { files } of recorded) for (const { name, bytes } of files) await repo.reviews.saveFile(id, 0, name, bytes);
1638
- const flows = recorded.map(({ flow }) => flow);
1639
- await repo.reviews.saveRound(id, {
1640
- number: 0,
1641
- by: agent(),
1642
- flows,
1643
- replies: [],
1644
- accounts: {},
1645
- cantStart: cantStart(recorded, failed),
1646
- ...commit && { commit }
1647
- });
1648
- await repo.reviews.finish(id, 0);
1649
- await noteRanOnBefore(repo, id, written);
1650
- console.log(`\nRecorded Before: ${called(flows.map((flow) => flow.name))}.`);
1651
- for (const line of howFar(repo, id, recorded, failed, "the app as it is")) console.log(line);
1652
- if (!commit) console.log(`This folder isn't in git, so Before isn't kept as a commit, and a flow added later can't be recorded on it.`);
1653
- const wrote = recorded.find((kept) => kept.wrote);
1654
- const { after } = await setupIn(repo.setup);
1655
- if (wrote && !after) console.log(`
1656
- ${quote(wrote.flow.name)} sends the app ${wrote.wrote}, which changes what it holds, and ${existsSync(repo.setup) ? "playthrough/setup.ts has no after, so what each flow makes stays in the app" : "there's no playthrough/setup.ts, so each flow starts on what the ones before it left, and what they make stays in the app"}: one for each flow recorded, two for a new one. Tell the person, with the step that would fix it; run playthrough alone for how setup.ts makes what each flow starts from, and gives it back.`);
1557
+ if (commit) {
1558
+ await repo.reviews.saveRound(id, {
1559
+ number: 0,
1560
+ by: agent(),
1561
+ flows: [],
1562
+ replies: [],
1563
+ accounts: {},
1564
+ commit
1565
+ });
1566
+ await repo.reviews.finish(id, 0);
1567
+ }
1657
1568
  console.log(`
1569
+ ${commit ? "Kept Before: the app as it is now. Each flow is recorded on it with round 1, as far as the app as it was takes it, for the person to compare with." : "This folder isn't in git, or has no commits yet, so there's no Before: the app as it is can't be put back once you've changed it."}
1570
+
1658
1571
  Now change the app. Keep each flow's name, and each step's while it does the same job: they're matched across rounds by name. Once the work is done, add a flow for each choice you made that the person can see and didn't ask for. Then record round 1: playthrough record ${id}`);
1659
1572
  }
1660
1573
  // src/noted.ts
@@ -1,4 +1,4 @@
1
- import { u as waitsFor } from "./flows-Dt7fcPLt.js";
1
+ import { d as waitsFor } from "./flows-BkG0TgHA.js";
2
2
  import { availableParallelism } from "node:os";
3
3
  import { defineConfig } from "@playwright/test";
4
4
  import { fileURLToPath } from "node:url";
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { z } from "zod";
3
- import { join, relative } from "node:path";
4
3
  import { createHash } from "node:crypto";
4
+ import { join, relative } from "node:path";
5
5
  // ../review/src/model.ts
6
6
  const text = (error) => z.string({ error }).refine((value) => value.trim() !== "", { error });
7
7
  const seconds$1 = (error) => z.number({ error }).min(0, { error });
@@ -68,14 +68,15 @@ const Flow = z.strictObject({
68
68
  steps: z.array(Step),
69
69
  recording: fileName("needs its recording: the name of a file in the round"),
70
70
  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"),
71
- 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"),
71
+ stopped: text("needs the step it stopped at").optional().describe("On Before, the step the flow couldn't take there, or that the app as it was had something else for: it's kept as far as that"),
72
72
  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"),
73
73
  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")
74
74
  });
75
75
  const Change = z.strictObject({
76
76
  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"),
77
77
  words: text("needs its words").describe("What changed, for the agent: “Download CSV and Cancel have swapped places”"),
78
- 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")
78
+ 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"),
79
+ pictureOnly: z.literal(true).optional().describe("Seen only in the pictures, with nothing read there changed: shown to the person, who can see what it is, and not put to the agent, who can't see more of it than Playthrough did")
79
80
  });
80
81
  const Comparison = z.strictObject({
81
82
  round: z.number({ error: "needs its round" }).int().min(1, { error: "needs its round, after Before" }),
@@ -84,7 +85,7 @@ const Comparison = z.strictObject({
84
85
  });
85
86
  const Account = z.union([
86
87
  z.strictObject({ line: text("needs words in its line") }).describe("Why it's there, in the agent's voice: what it meant or chose"),
87
- z.strictObject({ note: text("needs the note it was made for") }).describe("Made for that note: the reply on the note says why"),
88
+ z.strictObject({ note: text("needs the id of the note it was made for") }).describe("Made for that note: the reply on the note says why"),
88
89
  z.strictObject({ notReal: text("needs to say why nothing really changed") }).describe("Nothing really changed; nobody is shown it")
89
90
  ], { error: "says one of { line }, { note } or { notReal }" });
90
91
  const Reply = z.strictObject({
@@ -153,6 +154,9 @@ function roundInProgress(review) {
153
154
  if (!last) return 0;
154
155
  return last.finished ? last.number + 1 : last.number;
155
156
  }
157
+ function inOrderJoined(review) {
158
+ return [...new Set(review.rounds.flatMap((kept) => [...kept.flows.map((flow) => flow.name), ...kept.cantStart ?? []]))];
159
+ }
156
160
  function allNotes(review) {
157
161
  const notes = /* @__PURE__ */ new Map();
158
162
  for (const send of review.sends) for (const note of send.notes) notes.set(note.id, {
@@ -315,6 +319,7 @@ function problemsWithComparison(review, comparison) {
315
319
  }
316
320
  return problems;
317
321
  }
322
+ const toAccountFor = (comparison) => (comparison?.changes ?? []).filter((change) => !change.pictureOnly);
318
323
  function problemsFinishing(review, number) {
319
324
  if (review.accepted) return ["This review is accepted, so nothing more can be recorded."];
320
325
  const round = review.rounds.find((round) => round.number === number);
@@ -322,7 +327,7 @@ function problemsFinishing(review, number) {
322
327
  const at = `Round ${number}`;
323
328
  const shape = problemsWithShape(Round, round, at);
324
329
  if (shape.length) return shape;
325
- if (!round.flows.length) return [`${at} has no flows yet. Record them first.`];
330
+ if (number > 0 && !round.flows.length) return [`${at} has no flows yet. Record them first.`];
326
331
  const problems = [];
327
332
  const notes = allNotes(review);
328
333
  for (const id of notesToAnswer(review, number)) {
@@ -335,7 +340,7 @@ function problemsFinishing(review, number) {
335
340
  const comparisons = review.comparisons.filter((comparison) => comparison.round === number);
336
341
  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.`);
337
342
  const changes = new Map(comparisons.flatMap((comparison) => comparison.changes.map((change) => [change.id, change])));
338
- 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.`);
343
+ for (const change of toAccountFor(comparisons.find((comparison) => comparison.since === number - 1))) if (!round.accounts[change.id]) problems.push(`${at}: say why ${change.id} is there (${change.words}${where(change)}): its line, the id of the note it was made for, or why nothing really changed.`);
339
344
  for (const [id, account] of Object.entries(round.accounts)) {
340
345
  const change = changes.get(id);
341
346
  if (!change) problems.push(`${at} says something about the change ${id}, which isn't one of this round's changes.`);
@@ -588,10 +593,7 @@ var Reviews = class {
588
593
  if (problems.length) throw new Refused(problems);
589
594
  const before = review.rounds.find((round) => round.number === 0);
590
595
  for (const { name, bytes } of added.files) await this.storage.write(`${id}/rounds/0/files/${name}`, bytes);
591
- await this.#writeJson(`${id}/rounds/0/round.json`, {
592
- ...withInBefore(before, added.flows, added.cantStart, added.gone),
593
- ...added.commit && { commit: added.commit }
594
- });
596
+ await this.#writeJson(`${id}/rounds/0/round.json`, withInBefore(before, added.flows, added.cantStart, added.gone));
595
597
  });
596
598
  }
597
599
  async saveComparison(id, comparison) {
@@ -730,7 +732,7 @@ const PERSON = "playthrough person";
730
732
  const HANDS = "playthrough hands";
731
733
  const ACTIONS = "playthrough actions";
732
734
  const WROTE = "playthrough wrote";
733
- const UNMET = "playthrough unmet";
735
+ const DONE_ON = "playthrough done on";
734
736
  const HOLD = 1500;
735
737
  const readingsOf = (flow) => `readings/${flow.recording}`;
736
738
  async function readingsOn(reviews, review, round, flow) {
@@ -786,14 +788,14 @@ var Flows = class {
786
788
  const steps = result.steps.filter((step) => step.category === "test.step");
787
789
  const person = result.attachments.find((attachment) => attachment.name === PERSON)?.body?.toString();
788
790
  const wrote = result.attachments.find((attachment) => attachment.name === WROTE)?.body?.toString();
791
+ const doneOn = result.attachments.find((attachment) => attachment.name === DONE_ON)?.body?.toString();
789
792
  const as = (made) => {
790
793
  if (person) made.flow.person = person;
791
794
  for (const step of made.flow.steps) if (step.person === made.flow.person) delete step.person;
792
795
  if (wrote) made.wrote = wrote;
796
+ if (doneOn) made.doneOn = JSON.parse(doneOn);
793
797
  return made;
794
798
  };
795
- const unmet = Number(result.attachments.find((attachment) => attachment.name === "playthrough unmet")?.body?.toString() ?? 0);
796
- const yet = unmet ? `, though ${unmet > 1 ? `${unmet} things it expects aren't` : "1 thing it expects isn't"} so yet` : "";
797
799
  const went = (how) => console.log(` ${quote(test.title)}: ${how}`);
798
800
  if (result.status === "passed") {
799
801
  if (!recording) return;
@@ -802,7 +804,7 @@ var Flows = class {
802
804
  ...where,
803
805
  recorded
804
806
  });
805
- return went(`to its end, ${Math.round(recorded.flow.length)}s${yet}`);
807
+ return went(`to its end, ${Math.round(recorded.flow.length)}s`);
806
808
  }
807
809
  const stop = steps.at(-1);
808
810
  const kept = stop?.error ? steps.slice(0, -1) : steps;
@@ -830,7 +832,7 @@ var Flows = class {
830
832
  failed,
831
833
  recorded
832
834
  });
833
- went(`${stop.error ? `stops at ${quote(stop.title)}` : "stops after its last step"}${yet}`);
835
+ went(stop.error ? `stops at ${quote(stop.title)}` : "stops after its last step");
834
836
  }
835
837
  onError(error) {
836
838
  console.log(`\n${error.message ?? error.value}`);
@@ -916,4 +918,4 @@ var Flows = class {
916
918
  }
917
919
  };
918
920
  const seconds = (ms) => Math.round(Math.max(0, ms)) / 1e3;
919
- export { time as A, conversation as C, roundInProgress as D, problemsFinishing as E, list as O, allNotes as S, notesToAnswer as T, readingsOn as _, HANDS as a, Refused as b, PICTURE as c, UNMET as d, WROTE as f, readingsOf as g, hashOf as h, GLIMPSES as i, quote as k, READINGS as l, glimpsesOf as m, ASSETS as n, HOLD as o, firstLine as p, Flows as r, PERSON as s, ACTIONS as t, RECORDING as u, seconds as v, lastSeen as w, Reviews as x, keepAssets as y };
921
+ export { list as A, conversation as C, problemsFinishing as D, notesToAnswer as E, time as M, roundInProgress as O, allNotes as S, lastSeen as T, readingsOn as _, GLIMPSES as a, Refused as b, PERSON as c, RECORDING as d, WROTE as f, readingsOf as g, hashOf as h, Flows as i, quote as j, toAccountFor as k, PICTURE as l, glimpsesOf as m, ASSETS as n, HANDS as o, firstLine as p, DONE_ON as r, HOLD as s, ACTIONS as t, READINGS as u, seconds as v, inOrderJoined as w, Reviews as x, keepAssets as y };
package/src/reporter.js CHANGED
@@ -1 +1 @@
1
- export { t as ACTIONS, n as ASSETS, i as GLIMPSES, a as HANDS, o as HOLD, s as PERSON, c as PICTURE, l as READINGS, u as RECORDING, d as UNMET, f as WROTE, r as default, p as firstLine, m as glimpsesOf, h as hashOf, g as readingsOf, _ as readingsOn, v as seconds } from "./reporter-4C_7joIm.js";
1
+ export { t as ACTIONS, n as ASSETS, r as DONE_ON, a as GLIMPSES, o as HANDS, s as HOLD, c as PERSON, l as PICTURE, u as READINGS, d as RECORDING, f as WROTE, i as default, p as firstLine, m as glimpsesOf, h as hashOf, g as readingsOf, _ as readingsOn, v as seconds } from "./reporter-DE8aueAO.js";