playthrough 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,44 +1,29 @@
1
1
  #!/usr/bin/env -S node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON
2
- import { A as problemsFinishing, C as shownAt, D as conversation, E as allNotes, M as list, N as quote, O as lastSeen, P as time, S as placeIn, T as pausedAt, b as repo, c as howToWriteFlows, d as runFlows, f as walkFork, g as giveBackLeft, i as ALONGSIDE, j as roundInProgress, k as notesToAnswer, l as isFlow, m as keepRunning, p as whereItStopped, s as failedAt, t as read, u as makeFlowsFolder, w as Refused, x as compareWithEarlier, y as setupIn } from "./read-C9bKWxxP.js";
3
- import { d as firstLine, m as readingsOn } from "./reporter-CECNk7aW.js";
2
+ import { A as problemsFinishing, C as Refused, D as inOrderJoined, E as conversation, M as list, N as quote, O as lastSeen, P as time, T as allNotes, _ as onPage, b as readingsOn, g as hashOf, j as roundInProgress, k as notesToAnswer, m as firstLine } from "./reporter-BEodHYRI.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-BcISV1M-.js";
4
+ import { t as read } from "./read-Bg9XVTtn.js";
4
5
  import { createRequire } from "node:module";
5
- import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { copyFileSync, cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
7
  import { execFileSync } from "node:child_process";
8
+ import { readFile, readdir, stat, writeFile } from "node:fs/promises";
7
9
  import { constants, networkInterfaces, tmpdir, userInfo } from "node:os";
8
10
  import { dirname, extname, join, normalize, resolve, sep } from "node:path";
9
- import { readFile, readdir, stat } from "node:fs/promises";
10
- import { fileURLToPath } from "node:url";
11
11
  import { chromium, request } from "@playwright/test";
12
+ import { fileURLToPath } from "node:url";
12
13
  import { createServer, request as request$1 } from "node:http";
13
14
  import { connect } from "node:net";
15
+ // src/node.ts
16
+ const [major] = process.versions.node.split(".").map(Number);
17
+ if (major < 24) {
18
+ console.error(`Playthrough needs Node 24 or later, and this is Node ${process.versions.node}. Install Node 24, then run this again.`);
19
+ process.exit(1);
20
+ }
14
21
  // src/before.ts
15
22
  function keepBefore(repo, id) {
16
23
  if (!attempt(() => git(repo, ["rev-parse", "--is-inside-work-tree"]))) return void 0;
17
24
  try {
18
- const tree = treeNow(repo);
19
- const head = attempt(() => git(repo, [
20
- "rev-parse",
21
- "--verify",
22
- "--quiet",
23
- "HEAD"
24
- ]));
25
- const commit = git(repo, [
26
- "commit-tree",
27
- "--no-gpg-sign",
28
- ...head ? ["-p", head] : [],
29
- "-m",
30
- `Before, of the review ${id}: the app as it was, with what wasn't committed`,
31
- tree
32
- ], {
33
- ...process.env,
34
- ...PLAYTHROUGH
35
- });
36
- git(repo, [
37
- "update-ref",
38
- `refs/playthrough/${id}/before`,
39
- commit
40
- ]);
41
- return commit;
25
+ const head = headOf(repo);
26
+ return keep(repo, id, treeNow(repo), head ? [head] : []);
42
27
  } catch (error) {
43
28
  throw new Refused([
44
29
  "Before can't be kept as a commit, because git says:",
@@ -47,29 +32,85 @@ function keepBefore(repo, id) {
47
32
  ]);
48
33
  }
49
34
  }
50
- function treeNow(repo) {
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) {
51
64
  const folder = mkdtempSync(join(tmpdir(), "playthrough-"));
52
65
  try {
53
66
  const index = join(folder, "index");
54
- attempt(() => copyFileSync(resolve(repo.root, git(repo, [
55
- "rev-parse",
56
- "--git-path",
57
- "index"
58
- ])), index));
59
67
  const env = {
60
68
  ...process.env,
61
69
  GIT_INDEX_FILE: index
62
70
  };
63
- git(repo, [
64
- "add",
65
- "--all",
66
- ":/"
67
- ], env);
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
+ }
68
101
  return git(repo, ["write-tree"], env);
69
102
  } finally {
70
103
  rmSync(folder, { recursive: true });
71
104
  }
72
105
  }
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;
73
114
  const PLAYTHROUGH = {
74
115
  GIT_AUTHOR_NAME: "Playthrough",
75
116
  GIT_AUTHOR_EMAIL: "playthrough@localhost",
@@ -81,19 +122,65 @@ async function recordOnBefore(repo, id, number, flows) {
81
122
  if (!before) return;
82
123
  const later = flows.filter((name) => !before.flows.some((flow) => flow.name === name) && !before.cantStart?.includes(name));
83
124
  if (!later.length) return;
84
- const [are, they] = later.length > 1 ? ["are", "they're"] : ["is", "it's"];
85
- await onBefore(repo, id, later, `${list(later.map(quote))} ${are} new, so ${they} recorded on Before too`, `Round ${number} wasn't recorded`, "Record again.");
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}.`);
86
136
  }
87
- async function tryBeforeAgain(repo, id, flows) {
88
- const [them, they] = flows.length > 1 ? ["them", "they"] : ["it", "it"];
89
- await onBefore(repo, id, flows, `Before cut ${list(flows.map(quote))} short, so ${they} ${flows.length > 1 ? "are" : "is"} tried on it again, in place of what it has of ${them}`, "Before wasn't recorded again", "Run this again.");
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);
90
144
  }
91
- async function onBefore(repo, id, flows, doing, unrecorded, again) {
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) {
168
+ const review = await repo.reviews.get(id);
169
+ 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) {
92
175
  const before = (await repo.reviews.get(id)).rounds.find((round) => round.number === 0);
93
- const named = list(flows.map(quote));
94
- if (!before.commit) return console.log(`\n${named} ${flows.length > 1 ? "have" : "has"} no Before: Before wasn't kept as a commit, so the app as it was can't be put back.`);
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
+ }
95
182
  const later = flows;
96
- const taken = before.flows.map((flow) => flow.recording);
183
+ const taken = await repo.reviews.files(id, 0);
97
184
  const run = () => runFlows(repo, id, 0, {
98
185
  later: {
99
186
  flows: later,
@@ -103,47 +190,61 @@ async function onBefore(repo, id, flows, doing, unrecorded, again) {
103
190
  }).catch((error) => {
104
191
  if (error instanceof Refused) return {
105
192
  recorded: [],
106
- failed: later.map((flow) => ({ flow }))
193
+ failed: later.map((flow) => ({ flow })),
194
+ written: {}
107
195
  };
108
196
  throw error;
109
197
  });
110
198
  let ran;
111
- if (treeNow(repo) === git(repo, ["rev-parse", `${before.commit}^{tree}`])) {
199
+ if (unchanged) {
112
200
  console.log(`\n${doing}, on the app as it is: nothing has changed since Before.`);
113
201
  ran = await run();
114
202
  } else {
115
203
  const { url } = await repo.app();
116
- const shared = await serving(url);
204
+ await stopApp(url);
205
+ const shared = await serves(url);
117
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` : ""}.`);
118
207
  ran = await asItWas(repo, unrecorded, before.commit, shared, async () => {
119
208
  if (shared) await caughtUp(url);
120
- return run();
209
+ try {
210
+ return await run();
211
+ } finally {
212
+ await stopApp(url);
213
+ }
121
214
  });
215
+ if (shared) await caughtUp(url);
122
216
  }
123
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));
124
220
  await repo.reviews.addToBefore(id, {
125
221
  flows: ran.recorded.map(({ flow }) => flow),
126
222
  cantStart: cantStart(ran.recorded, ran.failed),
127
- files: ran.recorded.flatMap(({ files }) => files)
223
+ files: ran.recorded.flatMap(({ files }) => files),
224
+ ...commit && { commit },
225
+ gone
128
226
  });
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.`);
129
229
  for (const line of howFar(repo, id, ran.recorded, ran.failed, "the app as it was")) console.log(line);
230
+ return true;
130
231
  }
131
232
  function refuseIfBroken(failed, unrecorded, again) {
132
233
  const broken = failed.filter((failure) => failure.broken);
133
234
  if (broken.length) throw new Refused([
134
- `${unrecorded}, because the browser stopped ${broken.length > 1 ? "these flows" : "a flow"}, not the app:`,
235
+ `\n${unrecorded}, because the browser stopped ${broken.length > 1 ? "these flows" : "a flow"}, not the app:`,
135
236
  ...broken.map(failedAt),
136
- `Playwright says why above. ${again}`
237
+ again
137
238
  ]);
138
239
  }
139
240
  const cantStart = (recorded, failed) => failed.map(({ flow }) => flow).filter((flow) => !recorded.some((kept) => kept.flow.name === flow));
140
241
  function howFar(repo, id, recorded, failed, app) {
141
- const lines = failed.map(({ flow, at, why }) => {
242
+ const lines = failed.map(({ flow, at, why, on }) => {
142
243
  const because = why ? `, where Playwright says “${why}”` : "";
143
- 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.`;
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)}${onPage(on)}${because}` : "stops after its last step"}, so its Before goes as far as that.`;
144
245
  });
145
246
  const where = existsSync(repo.failed(id)) ? ` ${whereItStopped(repo, id, failed)}` : "";
146
- 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 tries the flows Before cut short again.${where}`);
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}`);
147
248
  return lines;
148
249
  }
149
250
  const SIGNALS = [
@@ -169,6 +270,9 @@ async function asItWas(repo, unrecorded, commit, shared, run) {
169
270
  process.exit(128 + constants.signals[signal]);
170
271
  };
171
272
  for (const signal of SIGNALS) process.on(signal, stopped);
273
+ const ours = join(repo.root, "playthrough");
274
+ const today = existsSync(ours) ? mkdtempSync(join(tmpdir(), "playthrough-")) : void 0;
275
+ if (today) cpSync(ours, today, { recursive: true });
172
276
  let moved = false;
173
277
  try {
174
278
  try {
@@ -178,7 +282,10 @@ async function asItWas(repo, unrecorded, commit, shared, run) {
178
282
  "--include-untracked",
179
283
  "--message",
180
284
  aside.stash
181
- ]);
285
+ ], {
286
+ ...process.env,
287
+ ...PLAYTHROUGH
288
+ });
182
289
  git(repo, [
183
290
  "checkout",
184
291
  "--quiet",
@@ -193,8 +300,19 @@ async function asItWas(repo, unrecorded, commit, shared, run) {
193
300
  "Fix that, then try again."
194
301
  ]);
195
302
  }
303
+ if (today) {
304
+ rmSync(ours, {
305
+ recursive: true,
306
+ force: true
307
+ });
308
+ cpSync(today, ours, { recursive: true });
309
+ }
196
310
  return await run();
197
311
  } finally {
312
+ if (today) rmSync(today, {
313
+ recursive: true,
314
+ force: true
315
+ });
198
316
  for (const signal of SIGNALS) process.off(signal, stopped);
199
317
  const problem = putWorkBack(repo, aside, true);
200
318
  if (problem) throw stillAside(repo, aside, problem);
@@ -210,17 +328,34 @@ async function putBack(repo) {
210
328
  if (!toDo) return;
211
329
  const back = `Playthrough was stopped while your work was set aside for a flow to record on Before. It's back now, on ${aside.back}.`;
212
330
  const url = (await repo.app().catch(() => void 0))?.url;
213
- if (url && !aside.shared && await serving(url)) throw new Refused([back, `Something is still serving ${url}, most likely the app as it was, left running: stop it, then run this again.`]);
331
+ if (url && !aside.shared && await serves(url)) throw new Refused([back, `Something is still serving ${url}, most likely the app as it was, left running: stop it, then run this again.`]);
214
332
  console.log(`${back}\n`);
215
333
  }
216
334
  function putWorkBack(repo, aside, force) {
217
335
  try {
218
- if (here(repo) !== aside.back) git(repo, [
219
- "checkout",
220
- "--quiet",
221
- ...force ? ["--force"] : [],
222
- aside.back
223
- ]);
336
+ if (here(repo) !== aside.back) {
337
+ attempt(() => git(repo, [
338
+ "checkout",
339
+ "--quiet",
340
+ "HEAD",
341
+ "--",
342
+ "playthrough"
343
+ ]));
344
+ git(repo, [
345
+ "clean",
346
+ "--force",
347
+ "-d",
348
+ "--quiet",
349
+ "--",
350
+ "playthrough"
351
+ ]);
352
+ git(repo, [
353
+ "checkout",
354
+ "--quiet",
355
+ ...force ? ["--force"] : [],
356
+ aside.back
357
+ ]);
358
+ }
224
359
  const stash = stashOf(repo, aside);
225
360
  if (stash) git(repo, [
226
361
  "stash",
@@ -242,6 +377,13 @@ function stillAside(repo, aside, problem) {
242
377
  `Put it back with git checkout ${aside.back}${stash ? `, then git stash pop --index ${stash}` : ""}. Playthrough carries on once it's back.`
243
378
  ]);
244
379
  }
380
+ const headOf = (repo) => attempt(() => git(repo, [
381
+ "rev-parse",
382
+ "--verify",
383
+ "--quiet",
384
+ "HEAD"
385
+ ]));
386
+ const UNBORN = "this repo has no commits yet, and git can set your work aside to put the app as it was back only once it has one";
245
387
  const here = (repo) => attempt(() => git(repo, [
246
388
  "symbolic-ref",
247
389
  "--quiet",
@@ -255,7 +397,6 @@ function stashOf(repo, aside) {
255
397
  "--format=%gd %gs"
256
398
  ]).split("\n").find((stash) => stash.endsWith(aside.stash))?.split(" ")[0];
257
399
  }
258
- const serving = (url) => fetch(url, { signal: AbortSignal.timeout(2e3) }).then((response) => (response.body?.cancel(), true), () => false);
259
400
  async function caughtUp(url) {
260
401
  await new Promise((done) => setTimeout(done, 1e3));
261
402
  await fetch(url, { signal: AbortSignal.timeout(6e4) }).then((response) => response.body?.cancel(), () => {});
@@ -391,7 +532,7 @@ async function start$1(repo, id, to, files, written) {
391
532
  const { flows, setup } = await loadedOf(repo, id, files, written);
392
533
  const kept = flows.get(to.flow);
393
534
  if (!kept) throw new Refused([`${id} has no flow called ${quote(to.flow)} any more. Its flows: ${list([...flows.keys()].map(quote))}.`]);
394
- const { context, page } = await pageFor(await browser(), app.url, kept.use);
535
+ const { context, page } = await pageFor(await browser(), app.url, kept.use, setup.use);
395
536
  context.setDefaultTimeout(1e4);
396
537
  context.setDefaultNavigationTimeout(0);
397
538
  const request$2 = await request.newContext({ baseURL: app.url });
@@ -1378,21 +1519,27 @@ async function record(repo, id) {
1378
1519
  const number = review.rounds.length ? roundInProgress(review) : 1;
1379
1520
  const onBefore = !!review.rounds.find((round) => round.number === 0)?.commit;
1380
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
+ }
1381
1526
  const { recorded, failed } = await runFlows(repo, id, number, { first });
1382
1527
  if (failed.length) throw new Refused([
1383
- `Round ${number} wasn't recorded, because ${failed.length > 1 ? "these flows" : "a flow"} failed:`,
1528
+ `\nRound ${number} wasn't recorded, because ${failed.length > 1 ? "these flows" : "a flow"} failed:`,
1384
1529
  ...failed.map(failedAt),
1385
1530
  `Fix ${failed.length > 1 ? "them" : "it"} and record again, which runs ${failed.length > 1 ? "them" : "it"} first. ${whereItStopped(repo, id, failed)}`
1386
1531
  ]);
1387
1532
  await recordOnBefore(repo, id, number, recorded.map(({ flow }) => flow.name));
1388
1533
  const saved = review.rounds.find((round) => round.number === number);
1534
+ const joined = inOrderJoined(review);
1535
+ const place = (name) => joined.includes(name) ? joined.indexOf(name) : joined.length;
1389
1536
  const flows = recorded.map(({ flow }) => {
1390
1537
  const line = saved?.flows.find((kept) => kept.name === flow.name)?.line;
1391
1538
  return line ? {
1392
1539
  ...flow,
1393
1540
  line
1394
1541
  } : flow;
1395
- });
1542
+ }).sort((a, b) => place(a.name) - place(b.name));
1396
1543
  for (const { files } of recorded) for (const { name, bytes } of files) await repo.reviews.saveFile(id, number, name, bytes);
1397
1544
  const written = saved?.replies ?? [];
1398
1545
  const waiting = notesToAnswer(review, number).filter((note) => !written.some((reply) => reply.note === note)).map((note) => ({
@@ -1416,20 +1563,19 @@ async function record(repo, id) {
1416
1563
  const unsaid = flows.filter((flow) => !flow.line && !previous?.flows.some((kept) => kept.name === flow.name));
1417
1564
  const file = repo.show(repo.round(id, number));
1418
1565
  const left = problemsFinishing(await repo.reviews.get(id), number);
1419
- console.log(`\nRecorded round ${number}: ${list(flows.map((flow) => `${quote(flow.name)} (${Math.round(flow.length)}s)`))}.`);
1566
+ 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)`))}.`);
1420
1567
  const since = comparisons.find((comparison) => comparison.since === number - 1);
1421
1568
  if (since) console.log(`\n${changed(since.changes.length, number - 1)}`);
1422
1569
  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.`);
1423
1570
  if (walked.length) console.log(`\n${walked.map(saidOf).join("\n")}`);
1424
1571
  if (left.length) console.log(`\nStill to do, in ${file}:\n${left.map((problem) => ` ${problem}`).join("\n")}`);
1425
- if (since?.changes.some((change) => !saved?.accounts[change.id])) 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>" }.`);
1572
+ 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 id of the note it was made for>" }, or { "notReal": "<why nothing really changed>" }.`);
1426
1573
  if (unsaid.length) {
1427
1574
  const [are, each] = unsaid.length > 1 ? ["are", "each"] : ["is", "it"];
1428
- console.log(`\n${list(unsaid.map((flow) => quote(flow.name)))} ${are} new to the reviewer. 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.`);
1575
+ 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.`);
1429
1576
  }
1430
1577
  if (by === "Agent") console.log(`\nPlaythrough can't tell which agent you are. Write your name in ${file} as “by”, and each of your answers shows it.`);
1431
- console.log(left.length ? `\nThen hand it over: playthrough wait ${id}` : `\nHand it over when it's ready: playthrough wait ${id}`);
1432
- console.log("It waits for the person's notes, which can take hours: run it in the background, so you're told when it ends.");
1578
+ console.log(`\n${left.length ? "Then hand it over" : "Hand it over when it's ready"}, in the background, so you're told when the person's notes come: playthrough wait ${id}`);
1433
1579
  }
1434
1580
  async function placed(repo, review, number, flows, reply, walked) {
1435
1581
  const answer = {
@@ -1471,7 +1617,7 @@ async function start(repo, id, title, asked) {
1471
1617
  await makeFlowsFolder(repo, id);
1472
1618
  return console.log(`Started ${id}: “${title}”.
1473
1619
 
1474
- Write its flows before you change anything. They show what was asked, so write them from the task, not from the code: if you can start a fresh subagent, give it the task and what the screens look like, never the code, and use the flows it writes.
1620
+ Write its flows before you change anything. They show what was asked, so write them from the task, not from the code.
1475
1621
 
1476
1622
  ${howToWriteFlows(repo, id)}
1477
1623
 
@@ -1480,13 +1626,14 @@ Then run playthrough start ${id} again. It records Before: each flow on the app
1480
1626
  const review = await repo.reviews.get(id);
1481
1627
  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}`]);
1482
1628
  const last = review.rounds.at(-1);
1483
- const before = review.rounds.find((round) => round.number === 0);
1484
- const short = before ? [...before.flows.filter((flow) => flow.stopped).map((flow) => flow.name), ...before.cantStart ?? []] : [];
1485
- if (before && short.length) return tryBeforeAgain(repo, id, short);
1629
+ review.rounds.find((round) => round.number === 0);
1630
+ if (last?.number === 0 && await recordBeforeAgain(repo, id, `playthrough start ${id}`, "Run this again.")) return;
1631
+ const changed = await changedOnBefore(repo, review);
1632
+ if (changed.length) return tryBeforeAgain(repo, id, changed, "Run this again.");
1486
1633
  if (last?.number === 0) throw new Refused([`Before is recorded already. Change the app, then record round 1: playthrough record ${id}`]);
1487
1634
  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}`]);
1488
1635
  const commit = keepBefore(repo, id);
1489
- const { recorded, failed } = await runFlows(repo, id, 0, { first: () => true });
1636
+ const { recorded, failed, written } = await runFlows(repo, id, 0, { first: () => true });
1490
1637
  refuseIfBroken(failed, "Before wasn't recorded", "Run this again.");
1491
1638
  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}`);
1492
1639
  for (const { files } of recorded) for (const { name, bytes } of files) await repo.reviews.saveFile(id, 0, name, bytes);
@@ -1501,11 +1648,16 @@ Then run playthrough start ${id} again. It records Before: each flow on the app
1501
1648
  ...commit && { commit }
1502
1649
  });
1503
1650
  await repo.reviews.finish(id, 0);
1504
- console.log(`\nRecorded Before: ${list(flows.map((flow) => quote(flow.name)))}.`);
1651
+ await noteRanOnBefore(repo, id, written);
1652
+ console.log(`\nRecorded Before: ${called(flows.map((flow) => flow.name))}.`);
1505
1653
  for (const line of howFar(repo, id, recorded, failed, "the app as it is")) console.log(line);
1506
1654
  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.`);
1655
+ const wrote = recorded.find((kept) => kept.wrote);
1656
+ const { after } = await setupIn(repo.setup);
1657
+ if (wrote && !after) console.log(`
1658
+ ${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.`);
1507
1659
  console.log(`
1508
- 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 reviewer can see and didn't ask for. Then record round 1: playthrough record ${id}`);
1660
+ 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}`);
1509
1661
  }
1510
1662
  // src/noted.ts
1511
1663
  async function pictureNotes(repo, review, round, notes) {
@@ -1537,8 +1689,17 @@ async function whereSeen(repo, review, id, before) {
1537
1689
  const seen = lastSeen(review, id, before);
1538
1690
  const on = seen?.on;
1539
1691
  const flow = seen && review.rounds.find((round) => round.number === seen.round)?.flows.find((flow) => flow.name === on.flow);
1540
- if (!seen || !on || on.at === void 0 || !flow) return void 0;
1692
+ if (!seen || !on || !flow) return void 0;
1541
1693
  const readings = await readingsOn(repo.reviews, review.id, seen.round, flow);
1694
+ if (on.at === void 0) {
1695
+ const end = readings.at(-1);
1696
+ return end && {
1697
+ round: seen.round,
1698
+ picture: end.picture,
1699
+ width: end.screen.width,
1700
+ height: end.screen.height
1701
+ };
1702
+ }
1542
1703
  const at = on.at;
1543
1704
  const shown = on.element ? shownAt(readings, on.element, at) : void 0;
1544
1705
  const reading = shown?.reading ?? readings.reduce((best, next) => !best || Math.abs(next.at - at) < Math.abs(best.at - at) ? next : best, void 0);
@@ -1585,8 +1746,9 @@ async function wait(repo, id) {
1585
1746
  `${error.problems.length > 1 ? "Fix these" : "Fix it"} in ${repo.show(repo.round(id, last.number))}, then hand it over again: playthrough wait ${id}`
1586
1747
  ]);
1587
1748
  }
1588
- console.log(`Round ${last.number} is ready for review.`);
1589
- }
1749
+ console.log(`Round ${last.number} is ready for review. Tell the person it's theirs: npx playthrough open ${id} opens it.`);
1750
+ review = await reviews.get(id);
1751
+ } else console.log(`Round ${last.number} is handed over already.`);
1590
1752
  console.log(`Waiting for notes on ${id}…`);
1591
1753
  }
1592
1754
  for (;;) {
@@ -1620,7 +1782,7 @@ async function wait(repo, id) {
1620
1782
  }
1621
1783
  if (asking.some((note) => notes.get(note)?.note.on?.app)) console.log(`\nWhat they did in the app is walked again on the new version when you record the round, and record says whether it still gets to their note. playthrough walk ${id} does that while you work.`);
1622
1784
  const first = pictures.size ? "Look at what each note is on, change" : "Change";
1623
- console.log(`\n${first} the app, then record round ${round}: playthrough record ${id}`);
1785
+ console.log(`\n${first} the app, then record round ${round}: playthrough record ${id}. Then answer each note in that round's round.json, under “replies”: record says where.`);
1624
1786
  return console.log(`New to this review? Run playthrough alone for how Playthrough works. Its flows are in ${repo.show(repo.flows(id))}.`);
1625
1787
  }
1626
1788
  }
@@ -1,15 +1,17 @@
1
+ import { u as waitsFor } from "./flows-BcISV1M-.js";
1
2
  import { availableParallelism } from "node:os";
2
- import { fileURLToPath } from "node:url";
3
3
  import { defineConfig } from "@playwright/test";
4
+ import { fileURLToPath } from "node:url";
4
5
  // src/playwright.config.ts
5
6
  const run = JSON.parse(process.env.PLAYTHROUGH_RUN ?? "null");
6
7
  var playwright_config_default = defineConfig({
7
8
  testDir: run.flows,
8
9
  testMatch: /\.[cm]?[jt]s$/,
9
10
  outputDir: run.results ?? `${run.folder}/results`,
10
- workers: Math.max(1, Math.floor(availableParallelism() / 4)),
11
- reporter: [["list"], [fileURLToPath(new URL("./reporter.js", import.meta.url)), {
11
+ workers: run.app.oneAtATime ? 1 : Math.max(1, Math.floor(availableParallelism() / 4)),
12
+ reporter: [[fileURLToPath(new URL("./reporter.js", import.meta.url)), {
12
13
  folder: run.folder,
14
+ flows: run.flows,
13
15
  before: run.before,
14
16
  taken: run.taken
15
17
  }]],
@@ -19,15 +21,9 @@ var playwright_config_default = defineConfig({
19
21
  width: 1280,
20
22
  height: 800
21
23
  },
22
- actionTimeout: 1e4,
24
+ actionTimeout: waitsFor(run),
23
25
  screenshot: "only-on-failure"
24
26
  },
25
- webServer: {
26
- command: run.app.start,
27
- url: run.app.url,
28
- cwd: run.root,
29
- reuseExistingServer: true
30
- },
31
27
  ...run.longest?.length && { projects: [{
32
28
  name: "new",
33
29
  grepInvert: run.longest.map(titled)