playthrough 0.5.0 → 0.6.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,23 +1,88 @@
1
1
  #!/usr/bin/env -S node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON
2
- import { A as quote, C as inOrderJoined, D as roundInProgress, E as problemsFinishing, O as toAccountFor, S as conversation, T as notesToAnswer, g as readingsOn, j as time, k as list, p as firstLine, w as lastSeen, x as allNotes, y as Refused } from "./reporter-Dr7dWwSf.js";
3
- import { A as repo, O as setupIn, S as stopApp, T as giveBackLeft, _ as compareWithEarlier, a as failedAt, b as keepRunning, c as isFlow, d as runFlows, g as writtenNow, h as trySetup, i as called, j as pausedAt, k as copies, l as makeFlowsFolder, m as whereItStopped, o as flowsOf, p as walkFork, s as howToWriteFlows, t as ALONGSIDE, u as recorder, v as placeIn, x as serves, y as shownAt } from "./flows-DDB6AdQ-.js";
2
+ import { A as quote, C as inOrderJoined, D as roundInProgress, E as problemsFinishing, O as toAccountFor, S as conversation, T as notesToAnswer, g as readingsOn, j as time, k as list, p as firstLine, w as lastSeen, x as allNotes, y as Refused } from "./reporter-DlS7qehW.js";
3
+ import { A as setupIn, C as serves, D as giveBackLeft, F as pausedAt, M as repo, N as stillAt, P as thisCommand, S as keepRunning, _ as compareWithEarlier, a as failedAt, c as isFlow, d as runFlows, f as runFork, g as writtenNow, h as trySetup, i as called, j as copies, l as makeFlowsFolder, m as whereItStopped, o as flowsOf, s as howToWriteFlows, t as ALONGSIDE, u as recorder, v as firstShown, w as stopApp, x as shownAt, y as placeIn } from "./flows-CbrwgoHB.js";
4
4
  import { t as read } from "./read-Bg9XVTtn.js";
5
5
  import { copyFileSync, cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { execFileSync } from "node:child_process";
7
7
  import { readFile, readdir, stat, writeFile } from "node:fs/promises";
8
8
  import { createHash, randomBytes } from "node:crypto";
9
- import { constants, networkInterfaces, tmpdir, uptime, userInfo } from "node:os";
9
+ import { constants, networkInterfaces, tmpdir, userInfo } from "node:os";
10
10
  import { basename, dirname, extname, join, normalize, resolve, sep } from "node:path";
11
11
  import { connect } from "node:net";
12
12
  import { chromium, request } from "@playwright/test";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { createServer as createServer$1, request as request$1 } from "node:http";
15
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.`);
16
+ const [major, minor] = process.versions.node.split(".").map(Number);
17
+ if (major < 22 || major === 22 && minor < 18) {
18
+ console.error(`Playthrough needs Node 22.18 or later, and this is Node ${process.versions.node}. Install Node 24, then run this again.`);
19
19
  process.exit(1);
20
20
  }
21
+ // ../page/src/scheme.ts
22
+ function asRecorded(text, recorded) {
23
+ const answers = {
24
+ "prefers-color-scheme": recorded.dark ? "dark" : "light",
25
+ "prefers-reduced-motion": recorded.still ? "reduce" : "no-preference"
26
+ };
27
+ return String(text).replace(/\(\s*(prefers-color-scheme|prefers-reduced-motion)\s*(?::\s*([a-z-]+)\s*)?\)/gi, (_, feature, value) => {
28
+ feature = feature.toLowerCase();
29
+ return (value ? value.toLowerCase() === answers[feature] : feature === "prefers-color-scheme" || recorded.still) ? "(min-width: 0px)" : "(max-width: 0px)";
30
+ });
31
+ }
32
+ function answerAs(view, recorded) {
33
+ const page = view.document;
34
+ const as = (query) => asRecorded(query, recorded);
35
+ const matching = view.matchMedia;
36
+ view.matchMedia = (query) => matching.call(view, as(query));
37
+ const sheet = view.CSSStyleSheet.prototype;
38
+ for (const name of [
39
+ "insertRule",
40
+ "replace",
41
+ "replaceSync"
42
+ ]) {
43
+ const real = sheet[name];
44
+ if (real) sheet[name] = function(text, ...rest) {
45
+ return real.call(this, as(text), ...rest);
46
+ };
47
+ }
48
+ const scheme = recorded.dark ? "dark" : "light";
49
+ const inScheme = (value) => /\blight\b/.test(value) && /\bdark\b/.test(value) ? scheme : value;
50
+ const rewrite = (rules) => {
51
+ for (const rule of rules) {
52
+ if (rule.media && as(rule.media.mediaText) !== rule.media.mediaText) rule.media.mediaText = as(rule.media.mediaText);
53
+ if (rule.style?.colorScheme && inScheme(rule.style.colorScheme) !== rule.style.colorScheme) rule.style.colorScheme = inScheme(rule.style.colorScheme);
54
+ if (rule.styleSheet) restyle(rule.styleSheet);
55
+ if (rule.cssRules) rewrite(rule.cssRules);
56
+ }
57
+ };
58
+ const restyle = (sheet) => {
59
+ try {
60
+ rewrite(sheet.cssRules);
61
+ } catch {}
62
+ };
63
+ const restyleAll = () => {
64
+ for (const sheet of page.styleSheets) restyle(sheet);
65
+ for (const sheet of page.adoptedStyleSheets ?? []) restyle(sheet);
66
+ for (const element of page.querySelectorAll("[media]")) {
67
+ const media = element.getAttribute("media");
68
+ if (as(media) !== media) element.setAttribute("media", as(media));
69
+ }
70
+ for (const meta of page.querySelectorAll("meta[name=\"color-scheme\"]")) {
71
+ const content = meta.getAttribute("content") ?? "";
72
+ if (inScheme(content) !== content) meta.setAttribute("content", inScheme(content));
73
+ }
74
+ };
75
+ const styling = (node) => /^(style|link|source|meta)$/i.test(node?.nodeName ?? "");
76
+ const bringsStyle = (node) => styling(node) || !!node.querySelector?.("style, link, source, meta");
77
+ new view.MutationObserver((changes) => changes.some((change) => styling(change.target) || styling(change.target.parentNode) || [...change.addedNodes].some(bringsStyle)) && restyleAll()).observe(page, {
78
+ subtree: true,
79
+ childList: true,
80
+ characterData: true,
81
+ attributeFilter: ["media", "content"]
82
+ });
83
+ page.addEventListener("load", (event) => styling(event.target) && restyleAll(), true);
84
+ restyleAll();
85
+ }
21
86
  // src/before.ts
22
87
  function keepBefore(repo, id) {
23
88
  if (!attempt(() => git(repo, ["rev-parse", "--is-inside-work-tree"]))) return void 0;
@@ -166,8 +231,7 @@ async function asItWas(repo, unrecorded, commit, shared, run) {
166
231
  const aside = {
167
232
  back: here(repo),
168
233
  stash: `Playthrough: your work, set aside while flows record on Before, ${(/* @__PURE__ */ new Date()).toISOString()}`,
169
- by: process.pid,
170
- boot: boot(),
234
+ ...thisCommand(),
171
235
  ...shared && { shared }
172
236
  };
173
237
  writeFileSync(repo.setAside, JSON.stringify(aside));
@@ -246,14 +310,6 @@ function refuseWhileAside(repo) {
246
310
  if (aside && aside.by !== process.pid && stillAt(aside)) throw new Refused([`Another playthrough command (process ${aside.by}) is recording flows on Before just now, so the app as it was is in your work's place until it's done, in a minute or so. Try again then.`]);
247
311
  }
248
312
  const setAside = (repo) => attempt(() => JSON.parse(readFileSync(repo.setAside, "utf8")));
249
- const stillAt = (aside) => sameBoot(aside.boot) && running(aside.by);
250
- function boot() {
251
- return attempt(() => readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim()) || Date.now() / 1e3 - uptime();
252
- }
253
- function sameBoot(then, now = boot()) {
254
- if (typeof then === "number" && typeof now === "number") return Math.abs(then - now) < 300;
255
- return then === void 0 || then === now;
256
- }
257
313
  function putWorkBack(repo, aside, force) {
258
314
  try {
259
315
  if (here(repo) !== aside.back) {
@@ -272,6 +328,7 @@ function putWorkBack(repo, aside, force) {
272
328
  "--",
273
329
  "playthrough"
274
330
  ]);
331
+ if (force) keepWhatChanged(repo);
275
332
  git(repo, [
276
333
  "checkout",
277
334
  "--quiet",
@@ -292,6 +349,33 @@ function putWorkBack(repo, aside, force) {
292
349
  return said(error);
293
350
  }
294
351
  }
352
+ function keepWhatChanged(repo) {
353
+ const changed = git(repo, [
354
+ "status",
355
+ "--porcelain",
356
+ "--untracked-files=all"
357
+ ]).split("\n").filter(Boolean).map((line) => line.replace(/^\s*\S+\s+/, ""));
358
+ if (!changed.length) return;
359
+ const kept = `Playthrough: saved while flows recorded on Before, ${(/* @__PURE__ */ new Date()).toISOString()}`;
360
+ git(repo, [
361
+ "stash",
362
+ "push",
363
+ "--include-untracked",
364
+ "--quiet",
365
+ "--message",
366
+ kept
367
+ ], {
368
+ ...process.env,
369
+ ...PLAYTHROUGH
370
+ });
371
+ const ref = git(repo, [
372
+ "stash",
373
+ "list",
374
+ "--format=%gd %gs"
375
+ ]).split("\n").find((stash) => stash.endsWith(kept))?.split(" ")[0];
376
+ const files = changed.length > 3 ? `${changed.slice(0, 3).join(", ")} and ${changed.length - 3} more` : changed.join(", ");
377
+ console.log(`\nWhile flows recorded on Before, ${files} changed, in the app as it was. That's kept, not lost: git stash pop ${ref} puts it over your work, if it's something you saved.`);
378
+ }
295
379
  function stillAside(repo, aside, problem) {
296
380
  const stash = stashOf(repo, aside);
297
381
  return new Refused([
@@ -324,13 +408,6 @@ async function caughtUp(url) {
324
408
  await new Promise((done) => setTimeout(done, 1e3));
325
409
  await fetch(url, { signal: AbortSignal.timeout(6e4) }).then((response) => response.body?.cancel(), () => {});
326
410
  }
327
- function running(pid) {
328
- try {
329
- return process.kill(pid, 0);
330
- } catch (error) {
331
- return error.code === "EPERM";
332
- }
333
- }
334
411
  function git(repo, args, env) {
335
412
  return execFileSync("git", [
336
413
  "-c",
@@ -378,7 +455,7 @@ async function runTo(repo, id, to) {
378
455
  order(one, resting);
379
456
  const { handed, stopped, made } = await one.ran;
380
457
  if (handed) {
381
- if (one.passed.length > passed && !to.did?.length) timesOf(repo, id, one).handing.set(momentOf(to.step, to.actions), Date.now() - one.last.when);
458
+ if (one.passed.length > passed && !to.did?.length && !to.notes) timesOf(repo, id, one).handing.set(momentOf(to.step, to.actions), Date.now() - one.last.when);
382
459
  return {
383
460
  handed,
384
461
  made
@@ -498,7 +575,7 @@ async function start$2(repo, id, to, files, written) {
498
575
  borrowed: repo.borrowed,
499
576
  arrived,
500
577
  person: kept.use.person
501
- }, kept.body, orders).catch((error) => ({ stopped: { why: firstLine(String(error?.message ?? error)) } }));
578
+ }, kept.body, orders, kept.forks).catch((error) => ({ stopped: { why: firstLine(String(error?.message ?? error)) } }));
502
579
  one.ran.finally(() => (one.over = true, close()));
503
580
  one.ran.then(({ handed, made }) => handed ? void 0 : made?.giveBack());
504
581
  context.on("close", one.stop);
@@ -540,6 +617,7 @@ function loadedOf(repo, id, files, written) {
540
617
  const kept = loaded.get(key(repo, id));
541
618
  if (kept?.written === written) return kept.flows;
542
619
  const load = (file, by) => by(file, written).catch((error) => {
620
+ if (error instanceof Refused) throw error;
543
621
  throw new Refused([`${repo.show(file)} couldn't be loaded, because Node says “${firstLine(String(error?.message ?? error))}”, so no flow of ${id} can be taken over. Fix it, then take the flow over again.`]);
544
622
  });
545
623
  const flows = alongside().then(({ flowsIn }) => Promise.all([Promise.all(files.map((file) => load(file, flowsIn))), load(repo.setup, setupIn)])).then(([each, setup]) => {
@@ -585,7 +663,7 @@ async function takeOver$1(repo, id, flowName, stepName) {
585
663
  Ready in ${seconds}s. Carry on from there in your browser: ${here}${away.length ? `\nOr on your phone, on the same network: ${list(away)}` : ""}
586
664
  Each address hands over the same copy, and the app is passed on through Playthrough while this runs. Ctrl+C ends it.`);
587
665
  }
588
- async function getReady(repo, id, flowName, { step: stepName, actions, did, ahead, network } = {}) {
666
+ async function getReady(repo, id, flowName, { step: stepName, actions, did, notes, ahead, network } = {}) {
589
667
  const round = await recorded(repo, id);
590
668
  const flow = round.flows.find((flow) => flow.name === flowName);
591
669
  if (!flow) throw new Refused([`${id} has no flow called ${quote(flowName)}. Its flows: ${list(round.flows.map((flow) => quote(flow.name)))}.`]);
@@ -594,7 +672,7 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
594
672
  if (outside) throw new Refused([`${quote(flow.name)} reaches outside the app (${outside}), so it's run only when it's taken over.`]);
595
673
  const app = await repo.app();
596
674
  refuseWhileAside(repo);
597
- await keepRunning(app, repo.root);
675
+ await keepRunning(app, repo.root, { ready: true });
598
676
  const began = Date.now();
599
677
  const { handed, made } = await runTo(repo, id, {
600
678
  flow: flow.name,
@@ -602,7 +680,8 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
602
680
  step: stepName,
603
681
  ...actions && { actions }
604
682
  },
605
- ...did?.length && { did }
683
+ ...did?.length && { did },
684
+ ...notes?.length && { notes }
606
685
  });
607
686
  if (!passings.has(app.url)) {
608
687
  const moments = /* @__PURE__ */ new Map();
@@ -635,7 +714,7 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
635
714
  const ready = /* @__PURE__ */ new Map();
636
715
  async function keepUp(repo, id, flow, step, actions) {
637
716
  refuseWhileAside(repo);
638
- await keepRunning(await repo.app(), repo.root);
717
+ await keepRunning(await repo.app(), repo.root, { ready: true });
639
718
  await follow(repo, id, {
640
719
  flow,
641
720
  ...step && {
@@ -738,52 +817,11 @@ const SAYS = `<script>(() => {
738
817
  Object.assign(Shifted, { now: () => Real.now() + ahead, parse: Real.parse, UTC: Real.UTC });
739
818
  window.Date = Shifted;
740
819
  }
741
- // In the browser the flow was recorded in, whatever yours says: light or dark, still or moving, in its languages. A
742
- // page asks in its scripts and its styles, and each asking is answered before the app hears it.
820
+ // In the browser the flow was recorded in, whatever yours says: light or dark, still or moving, in its languages.
743
821
  const browser = JSON.parse(sessionStorage.getItem("playthrough:browser") || "null");
744
822
  if (browser) {
745
- const recorded = { "prefers-color-scheme": browser.dark ? "dark" : "light", "prefers-reduced-motion": browser.still ? "reduce" : "no-preference" };
746
- const as = (query) =>
747
- String(query).replace(/\\(\\s*(prefers-color-scheme|prefers-reduced-motion)\\s*(?::\\s*([a-z-]+)\\s*)?\\)/gi, (_, feature, value) => {
748
- feature = feature.toLowerCase();
749
- const holds = value ? value.toLowerCase() === recorded[feature] : feature === "prefers-color-scheme" || browser.still;
750
- return holds ? "(min-width: 0px)" : "(max-width: 0px)";
751
- });
752
- const matching = window.matchMedia;
753
- window.matchMedia = (query) => matching.call(window, as(query));
754
- for (const name of ["insertRule", "replace", "replaceSync"]) {
755
- const real = CSSStyleSheet.prototype[name];
756
- if (real) CSSStyleSheet.prototype[name] = function (text, ...rest) { return real.call(this, as(text), ...rest); };
757
- }
758
- // light-dark(), system colours, form controls and scrollbars follow the page's colour-scheme, not a media query.
759
- const scheme = browser.dark ? "dark" : "light";
760
- const inScheme = (value) => (/\\blight\\b/.test(value) && /\\bdark\\b/.test(value) ? scheme : value);
761
- const rewrite = (rules) => {
762
- for (const rule of rules) {
763
- if (rule.media && as(rule.media.mediaText) !== rule.media.mediaText) rule.media.mediaText = as(rule.media.mediaText);
764
- if (rule.style?.colorScheme && inScheme(rule.style.colorScheme) !== rule.style.colorScheme) rule.style.colorScheme = inScheme(rule.style.colorScheme);
765
- if (rule.styleSheet) restyle(rule.styleSheet);
766
- if (rule.cssRules) rewrite(rule.cssRules);
767
- }
768
- };
769
- const restyle = (sheet) => { try { rewrite(sheet.cssRules); } catch {} };
770
- const restyleAll = () => {
771
- for (const sheet of document.styleSheets) restyle(sheet);
772
- for (const sheet of document.adoptedStyleSheets ?? []) restyle(sheet);
773
- for (const element of document.querySelectorAll("[media]")) {
774
- const media = element.getAttribute("media");
775
- if (as(media) !== media) element.setAttribute("media", as(media));
776
- }
777
- for (const meta of document.querySelectorAll('meta[name="color-scheme"]')) {
778
- const content = meta.getAttribute("content") ?? "";
779
- if (inScheme(content) !== content) meta.setAttribute("content", inScheme(content));
780
- }
781
- };
782
- const styling = (node) => /^(style|link|source|meta)$/i.test(node?.nodeName);
783
- const bringsStyle = (node) => styling(node) || !!node.querySelector?.("style, link, source, meta");
784
- new MutationObserver((changes) => changes.some((change) => styling(change.target) || styling(change.target.parentNode) || [...change.addedNodes].some(bringsStyle)) && restyleAll())
785
- .observe(document, { subtree: true, childList: true, characterData: true, attributeFilter: ["media", "content"] });
786
- document.addEventListener("load", (event) => styling(event.target) && restyleAll(), true);
823
+ ${asRecorded.toString()}
824
+ (${answerAs.toString()})(window, browser);
787
825
  const languages = browser.languages;
788
826
  Object.defineProperty(navigator, "language", { get: () => languages[0] });
789
827
  Object.defineProperty(navigator, "languages", { get: () => languages });
@@ -822,6 +860,9 @@ const SAYS = `<script>(() => {
822
860
  const yours = (mine) => { hidden.disabled = mine; if (mine) sessionStorage.setItem("playthrough:yours", "1"); else sessionStorage.removeItem("playthrough:yours"); };
823
861
  yours(!!sessionStorage.getItem("playthrough:yours"));
824
862
  addEventListener("message", ({ source, data }) => { if (source === parent && data?.playthrough === "yours") yours(data.yours); });
863
+ // Taken over, the page is scrolled where the recording was at that moment: made ready at a step's end, it was where
864
+ // the step left it, a screen or more from a moment partway through a page being read.
865
+ addEventListener("message", ({ source, data }) => { if (source === parent && data?.playthrough === "scroll") scrollTo({ left: data.x, top: data.y, behavior: "instant" }); });
825
866
  const typing = (target) => !!target?.closest?.('input:not([type=button]):not([type=submit]):not([type=checkbox]):not([type=radio]):not([type=range]), textarea, [contenteditable=""], [contenteditable=true]');
826
867
  // Every move, as it comes: held for a frame, the name trailed the pointer like a smoothed one. Not the earlier raw
827
868
  // updates: in a scaled frame, Chrome gives them the page's position rather than the app's.
@@ -856,7 +897,8 @@ const SAYS = `<script>(() => {
856
897
  for (let at = target; at && at !== (pressed ?? document.body); at = at.parentElement) if (at.nodeType === 1 && words(at)) return at;
857
898
  return pressed ?? target;
858
899
  };
859
- const words = (element) => (element.getAttribute?.("aria-label") || element.innerText || element.value || element.placeholder || "").trim().replace(/\\s+/g, " ").slice(0, 80);
900
+ // The words you saw on it, as you'd name it; its label only when it shows none, as an icon's.
901
+ const words = (element) => (element.innerText?.trim() || element.getAttribute?.("aria-label") || element.value || element.placeholder || "").trim().replace(/\\s+/g, " ").slice(0, 80);
860
902
  const roles = { A: "link", BUTTON: "button", SELECT: "combobox", TEXTAREA: "textbox", H1: "heading", H2: "heading", H3: "heading", H4: "heading", LI: "listitem", TD: "cell", TH: "columnheader", LABEL: "text", P: "text" };
861
903
  const role = (element) => element.getAttribute("role") || (element.tagName === "INPUT" ? (["checkbox", "radio"].includes(element.type) ? element.type : "textbox") : roles[element.tagName]) || "text";
862
904
  const path = (element) => {
@@ -927,11 +969,14 @@ const SAYS = `<script>(() => {
927
969
  e.stopImmediatePropagation();
928
970
  if (e.type === "pointerdown") {
929
971
  const on = thing(e.target);
930
- const name = words(on);
931
972
  readNow();
932
973
  send();
933
974
  const node = window.rrwebRecord?.record?.mirror?.getId(on);
934
- parent.postMessage({ playthrough: "noted", ...(name ? { element: { role: role(on), name, css: path(on), ...(node > 0 && { node }) } } : { point: { x: e.clientX / innerWidth, y: e.clientY / innerHeight } }), did, x: e.clientX, y: e.clientY }, "*");
975
+ // Named as the page is read for every flow, so a later round's flow finds it by the same name: a reminder's
976
+ // message box is "Message" there, where its own words named it here.
977
+ const known = node > 0 && recording ? read().things.find((thing) => thing.node === node) : undefined;
978
+ const [kind, name] = known ? [known.role, known.name] : [role(on), words(on)];
979
+ parent.postMessage({ playthrough: "noted", ...(name ? { element: { role: kind, name, css: path(on), ...(node > 0 && { node }) } } : { point: { x: e.clientX / innerWidth, y: e.clientY / innerHeight } }), did, x: e.clientX, y: e.clientY }, "*");
935
980
  noting = false;
936
981
  swallowing = true;
937
982
  ringed();
@@ -1040,7 +1085,9 @@ const SAYS = `<script>(() => {
1040
1085
  sampling: { mousemove: 20, scroll: 16, input: "all" },
1041
1086
  maskAllInputs: false,
1042
1087
  inlineStylesheet: true,
1043
- }) && ((recording = true), readNow());
1088
+ }) && ((recording = true), said(), readNow());
1089
+ // Light or dark, still or moving, as the flow was recorded, for your recording to be played back as it was too.
1090
+ const said = () => window.rrwebRecord.record.addCustomEvent("browser", { dark: matchMedia("(prefers-color-scheme: dark)").matches, still: matchMedia("(prefers-reduced-motion: reduce)").matches });
1044
1091
  addEventListener("message", ({ source, data }) => source === parent && data?.playthrough === "yours" && record());
1045
1092
  const settle = async () => {
1046
1093
  await rested();
@@ -1590,6 +1637,11 @@ async function answer(app, page, you, request, response) {
1590
1637
  "content-type": TYPES[extname(path)] ?? "application/octet-stream",
1591
1638
  "cache-control": "max-age=31536000, immutable"
1592
1639
  });
1640
+ const as = new URL(request.url ?? "/", "http://localhost").searchParams.get("as");
1641
+ if (as !== null && extname(path) === ".svg") return response.end(asRecorded(Buffer.from(bytes).toString("utf8"), {
1642
+ dark: as.includes("dark"),
1643
+ still: as.includes("still")
1644
+ }));
1593
1645
  return response.end(bytes);
1594
1646
  }
1595
1647
  if (request.method === "POST" && rest[0] === "video") return saveVideo(repo, id, request, response);
@@ -1626,11 +1678,12 @@ async function live(repo, id, request, response) {
1626
1678
  response.end();
1627
1679
  }
1628
1680
  async function takeOver(repo, id, request, response) {
1629
- const { flow, step, actions, did, ahead } = await body(request);
1681
+ const { flow, step, actions, did, notes, ahead } = await body(request);
1630
1682
  const { port, moment } = await getReady(repo, id, flow, {
1631
1683
  step,
1632
1684
  actions,
1633
1685
  did,
1686
+ notes,
1634
1687
  ahead
1635
1688
  });
1636
1689
  json(response, 200, {
@@ -1791,9 +1844,9 @@ const known = [
1791
1844
  function agent(written, shell = process.env) {
1792
1845
  return known.find((agent) => agent.says.some((name) => shell[name]))?.name ?? written ?? "Agent";
1793
1846
  }
1794
- // src/walk.ts
1795
- const forkOf = (recording) => recording.match(/^(?:branches\/(.+)-\d+|walks\/(.+))\.json$/)?.slice(1).find(Boolean) ?? recording.replace(/\W+/g, "-");
1796
- async function walkForks(repo, review, round, flows) {
1847
+ // src/forks.ts
1848
+ const forkOf = (recording) => recording.match(/^(?:branches\/(.+)-\d+|forks\/(.+))\.json$/)?.slice(1).find(Boolean) ?? recording.replace(/\W+/g, "-");
1849
+ async function runForks(repo, review, round, flows) {
1797
1850
  const everyNote = allNotes(review);
1798
1851
  const open = notesToAnswer(review, round).flatMap((id) => {
1799
1852
  const kept = everyNote.get(id);
@@ -1803,103 +1856,123 @@ async function walkForks(repo, review, round, flows) {
1803
1856
  }] : [];
1804
1857
  });
1805
1858
  const forks = Map.groupBy(open, ({ note }) => forkOf(note.on.app.recording));
1806
- const walked = [];
1859
+ const ran = [];
1807
1860
  for (const [fork, notes] of forks) {
1808
1861
  const from = notes[0].note.on.app.from;
1809
1862
  const then = review.rounds.find((kept) => kept.number === notes[0].round)?.flows.find((flow) => flow.name === from.flow);
1810
1863
  const now = flows.find((flow) => flow.name === from.flow);
1811
1864
  const paused = then && pausedAt(then, from.at);
1812
- const unwalked = (stopped) => notes.map(({ id }) => ({
1865
+ const unrun = (failed) => notes.map(({ id }) => ({
1813
1866
  note: id,
1814
1867
  flow: from.flow,
1815
- reached: false,
1816
- stopped
1868
+ found: false,
1869
+ failed: { why: failed }
1817
1870
  }));
1818
1871
  if (!now || !paused) {
1819
- walked.push(...unwalked(`${quote(from.flow)} isn't in round ${round}`));
1872
+ ran.push(...unrun(`${quote(from.flow)} isn't in round ${round}`));
1820
1873
  continue;
1821
1874
  }
1822
- const did = notes.map(({ note }) => note.on.app.did).reduce((longest, one) => one.length > longest.length ? one : longest);
1823
- console.log(`\nWalking ${quote(from.flow)} again, as ${list([...new Set(notes.map(({ note }) => quote(note.id)))])} left it in the app…`);
1824
- const run = await walkFork(repo, review.id, {
1875
+ console.log(`\nRunning ${quote(from.flow)} to where ${list([...new Set(notes.map(({ note }) => quote(note.id)))])} took it over, then its fork…`);
1876
+ const run = await runFork(repo, review.id, {
1825
1877
  flow: from.flow,
1826
1878
  step: paused.step,
1827
1879
  actions: paused.actions,
1828
- did,
1829
- look: notes.map(({ note }) => ({
1830
- after: note.on.app.did.length,
1831
- css: note.on.element?.css
1832
- }))
1880
+ notes: notes.map(({ id }) => id)
1833
1881
  });
1834
1882
  if (!run) {
1835
- walked.push(...unwalked(`${quote(from.flow)} doesn't get to where they took it over`));
1883
+ ran.push(...unrun(`${quote(from.flow)} doesn't get to where they took it over, the step ${quote(paused.step)}`));
1884
+ continue;
1885
+ }
1886
+ const step = paused.step;
1887
+ if (!run.fork || run.failed) {
1888
+ ran.push(...notes.map(({ id }) => ({
1889
+ note: id,
1890
+ flow: from.flow,
1891
+ step,
1892
+ found: false,
1893
+ ...run.fork && { fork: run.fork },
1894
+ ...run.failed && { failed: run.failed }
1895
+ })));
1836
1896
  continue;
1837
1897
  }
1838
- const recording = `walks/${fork}.json`;
1898
+ const recording = `forks/${fork}.json`;
1839
1899
  const start = run.events[0].timestamp;
1840
- const length = run.stopped ? Math.round(((run.done.at(-1) ?? run.from) + .08) * 1e3) / 1e3 : Math.round(run.events.at(-1).timestamp - start) / 1e3;
1900
+ const length = Math.round(run.events.at(-1).timestamp - start) / 1e3;
1841
1901
  await repo.reviews.saveFile(review.id, round, recording, Buffer.from(JSON.stringify(run.events)));
1842
1902
  await repo.reviews.saveFile(review.id, round, `readings/${recording}`, Buffer.from(JSON.stringify(run.readings)));
1843
1903
  await repo.reviews.saveFile(review.id, round, `assets/${recording}`, Buffer.from(JSON.stringify(run.assets)));
1844
1904
  for (const { name, bytes } of run.files) await repo.reviews.saveFile(review.id, round, name, bytes);
1845
- const app = {
1846
- from: {
1847
- flow: from.flow,
1848
- at: run.from
1849
- },
1850
- did: did.slice(0, run.done.length),
1851
- recording,
1852
- length
1853
- };
1854
- for (const [i, { id, note }] of notes.entries()) {
1855
- const steps = note.on.app.did.length;
1856
- const reached = run.done.length >= steps;
1857
- const at = (reached ? run.done[steps - 1] : run.done.at(-1)) ?? run.from;
1858
- const node = reached ? run.found[i] : null;
1905
+ const picture = run.picture && await keepPicture(repo, review.id, round, `notes/${fork}-fork.png`, run.picture);
1906
+ const seen = [...run.readings, ...run.glimpses].filter((glimpse) => glimpse.at >= run.from);
1907
+ for (const { id, note } of notes) {
1908
+ const app = {
1909
+ from: {
1910
+ flow: from.flow,
1911
+ at: run.from
1912
+ },
1913
+ did: note.on.app.did,
1914
+ recording,
1915
+ length,
1916
+ steps: run.steps
1917
+ };
1918
+ const shown = note.on.element && firstShown(note.on.element, seen);
1859
1919
  const on = {
1860
1920
  flow: from.flow,
1861
- at,
1862
- ...node && note.on.element && { element: {
1863
- role: note.on.element.role,
1864
- name: note.on.element.name,
1865
- node
1866
- } },
1921
+ at: shown ? shown.at : Math.max(run.from, length - .1),
1922
+ ...shown && { element: shown.element },
1867
1923
  app
1868
1924
  };
1869
- const picture = run.pictures[i] && await keepPicture(repo, review.id, round, `notes/${id}-walked.png`, run.pictures[i]);
1870
- walked.push({
1925
+ ran.push({
1871
1926
  note: id,
1872
1927
  flow: from.flow,
1928
+ step,
1929
+ fork: run.fork,
1873
1930
  on,
1874
- reached,
1875
- ...!reached && { stopped: run.stopped ?? "" },
1931
+ found: !!shown,
1876
1932
  ...picture && { picture }
1877
1933
  });
1878
1934
  }
1879
1935
  }
1880
- return walked;
1936
+ return ran;
1881
1937
  }
1882
1938
  async function keepPicture(repo, id, round, name, bytes) {
1883
1939
  await repo.reviews.saveFile(id, round, name, bytes);
1884
1940
  return repo.show(join(dirname(repo.round(id, round)), "files", name));
1885
1941
  }
1886
- function saidOf(walked) {
1887
- const fork = `the steps note ${walked.note} was made after, on ${quote(walked.flow)}`;
1888
- if (walked.reached) return `Walked ${fork} again: they get to the note.${walked.picture ? ` As it is now: ${walked.picture}` : ""}`;
1889
- if (walked.on) return `Walked ${fork} again: they stop at ${quote(walked.stopped ?? "")}, which they can't do now, and the person sees that there. Fix what it's on, or say in your answer to note ${walked.note} why their steps changed.`;
1890
- return `Couldn't walk ${fork} again: ${walked.stopped}.`;
1942
+ function unfinished(ran) {
1943
+ const unwritten = ran.filter((one) => !one.fork && one.step);
1944
+ const lines = ran.filter((one) => one.fork && one.failed).map(({ note, fork, failed }) => `The fork ${quote(fork)} for note ${note} failed${failed.at ? ` at its step ${quote(failed.at)}` : ""}, where Playwright says “${failed.why}”. Fix it and record again.`);
1945
+ for (const [flow, notes] of Map.groupBy(unwritten, (one) => one.flow)) {
1946
+ const ids = notes.map((one) => one.note);
1947
+ lines.push(`${ids.length > 1 ? `Notes ${list(ids)} were` : `Note ${ids[0]} was`} made in the app, and ${ids.length > 1 ? "have" : "has"} no fork yet. Write what the person did there as a fork of ${quote(flow)}, after it in its file, finding things by role and name as its steps do:
1948
+
1949
+ test.fork("<what they do there>", { notes: [${ids.map((id) => `"${id}"`).join(", ")}] }, async ({ page }) => {
1950
+ await test.step("<a step of theirs>", async () => { … });
1951
+ });
1952
+
1953
+ It runs from where they took the flow over, in its step ${quote(notes[0].step)}, every round until the note is settled, and your answer goes on what the note is on, where the fork first shows it. Then record again.`);
1954
+ }
1955
+ return lines;
1956
+ }
1957
+ function saidOf(ran) {
1958
+ if (!ran.fork) return `Couldn't run note ${ran.note}'s fork: ${ran.failed?.why}.`;
1959
+ const noted = ran.on?.element ? `the ${ran.on.element.role} ${quote(ran.on.element.name)}` : "what it's on";
1960
+ if (ran.found) return `Ran ${quote(ran.fork)}, note ${ran.note}'s fork: it shows ${noted}, and your answer is there.${ran.picture ? ` Where it ends: ${ran.picture}` : ""}`;
1961
+ return `Ran ${quote(ran.fork)}, note ${ran.note}'s fork: it never shows what the note is on, so your answer is at its end.${ran.picture ? ` Where it ends: ${ran.picture}` : ""}`;
1891
1962
  }
1892
- async function walk(repo, id) {
1963
+ async function forks(repo, id) {
1893
1964
  const review = await repo.reviews.get(id);
1894
- if (review.accepted) throw new Refused(["This review is accepted, so there's nothing left to walk."]);
1965
+ if (review.accepted) throw new Refused(["This review is accepted, so there's no fork left to run."]);
1895
1966
  const number = roundInProgress(review);
1896
1967
  const saved = review.rounds.find((round) => round.number === number);
1897
- const walked = await walkForks(repo, review, number, saved?.flows ?? review.rounds.findLast((round) => round.number > 0)?.flows ?? []);
1898
- if (!walked.length) return console.log(`Nothing to walk: round ${number} answers no note made in the app.`);
1968
+ const ran = await runForks(repo, review, number, saved?.flows ?? review.rounds.findLast((round) => round.number > 0)?.flows ?? []);
1969
+ if (!ran.length) return console.log(`Nothing to run: round ${number} answers no note made in the app.`);
1970
+ const left = unfinished(ran);
1971
+ if (left.length) throw new Refused(left);
1899
1972
  const now = saved && (await repo.reviews.get(id)).rounds.find((round) => round.number === number);
1900
1973
  if (now) {
1901
1974
  const placed = (reply) => {
1902
- const on = walked.find((one) => one.note === reply.note)?.on;
1975
+ const on = ran.find((one) => one.note === reply.note)?.on;
1903
1976
  return on ? {
1904
1977
  ...reply,
1905
1978
  on
@@ -1910,7 +1983,7 @@ async function walk(repo, id) {
1910
1983
  replies: now.replies.map(placed)
1911
1984
  });
1912
1985
  }
1913
- console.log(`\n${walked.map(saidOf).join("\n")}`);
1986
+ console.log(`\n${ran.map(saidOf).join("\n")}`);
1914
1987
  }
1915
1988
  // src/record.ts
1916
1989
  async function record(repo, id) {
@@ -1942,8 +2015,10 @@ async function record(repo, id) {
1942
2015
  words: ""
1943
2016
  }));
1944
2017
  const by = agent(saved?.by);
1945
- const walked = await walkForks(repo, review, number, flows);
1946
- const replies = await Promise.all([...written, ...waiting].map((reply) => placed(repo, review, number, flows, reply, walked)));
2018
+ const forked = await runForks(repo, review, number, flows);
2019
+ const forkless = unfinished(forked);
2020
+ if (forkless.length) throw new Refused([`\nRound ${number} wasn't recorded:`, ...forkless]);
2021
+ const replies = await Promise.all([...written, ...waiting].map((reply) => placed(repo, review, number, flows, reply, forked)));
1947
2022
  console.log(`\nComparing round ${number} with each round before it…`);
1948
2023
  const comparisons = await compareWithEarlier(repo.reviews, id, number, flows);
1949
2024
  await repo.reviews.saveRound(id, {
@@ -1978,7 +2053,7 @@ async function record(repo, id) {
1978
2053
  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.`);
1979
2054
  }
1980
2055
  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.`);
1981
- if (walked.length) console.log(`\n${walked.map(saidOf).join("\n")}`);
2056
+ if (forked.length) console.log(`\n${forked.map(saidOf).join("\n")}`);
1982
2057
  if (left.length) console.log(`\nStill to do, in ${file}:\n${left.map((problem) => ` ${problem}`).join("\n")}`);
1983
2058
  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>" }.`);
1984
2059
  const wrote = number === 1 && !saved && recorded.find((kept) => kept.wrote);
@@ -1991,21 +2066,21 @@ ${quote(wrote.flow.name)} sends the app ${wrote.wrote}, which changes what it ho
1991
2066
  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.`);
1992
2067
  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}`);
1993
2068
  }
1994
- async function placed(repo, review, number, flows, reply, walked) {
2069
+ async function placed(repo, review, number, flows, reply, forked) {
1995
2070
  const answer = {
1996
2071
  note: reply.note,
1997
2072
  words: reply.words,
1998
2073
  ...reply.watch && { watch: reply.watch }
1999
2074
  };
2000
- const seen = lastSeen(review, reply.note, number);
2001
- const walk = seen?.on.app && walked.find((one) => one.note === reply.note)?.on;
2002
- if (walk) return {
2075
+ const fork = forked.find((one) => one.note === reply.note);
2076
+ if (fork) return fork.on ? {
2003
2077
  ...answer,
2004
- on: walk
2005
- };
2078
+ on: fork.on
2079
+ } : answer;
2080
+ const seen = lastSeen(review, reply.note, number);
2006
2081
  const flow = seen && flows.find((flow) => flow.name === seen.on.flow);
2007
2082
  const then = seen && review.rounds.find((round) => round.number === seen.round)?.flows.find((kept) => kept.name === seen.on.flow);
2008
- if (!seen || seen.on.app || !flow || !then) return answer;
2083
+ if (!seen || !flow || !then) return answer;
2009
2084
  const from = {
2010
2085
  flow: then,
2011
2086
  readings: await readingsOn(repo.reviews, review.id, seen.round, then)
@@ -2186,21 +2261,25 @@ async function wait(repo, id) {
2186
2261
  return ` ${turn.agent ? `${turn.by === you ? "You" : turn.by}, in round ${turn.round}` : turn.verdict ? `${turn.by}, ${turn.verdict}` : turn.by}${turn.words.trim() ? `: ${turn.words}` : ""}`;
2187
2262
  });
2188
2263
  const picture = pictures.get(note);
2189
- console.log(`\n ${note} ${where(notes.get(note)?.note.on)}${picture ? `\n As they saw it: ${picture}` : ""}\n${turns.join("\n")}`);
2264
+ const kept = notes.get(note);
2265
+ const from = kept?.note.on?.app && review.rounds.find((one) => one.number === kept.round)?.flows.find((flow) => flow.name === kept.note.on.app.from.flow);
2266
+ console.log(`\n ${note} ${where(kept?.note.on, from)}${picture ? `\n As they saw it: ${picture}` : ""}\n${turns.join("\n")}`);
2190
2267
  }
2191
- 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.`);
2268
+ if (asking.some((note) => notes.get(note)?.note.on?.app)) console.log(`\nWhat they did in the app is yours to write, as a fork of the flow they took over, in its file: test.fork("<what they do there>", { notes: ["<the note's id>"] }, async ({ page }) => { … }), with steps as a flow's. Record runs the flow to where they took it over, then the fork, every round until the note is settled, and puts your answer on what the note is on, where the fork first shows it. playthrough forks ${id} runs it while you work.`);
2192
2269
  const first = pictures.size ? "Look at what each note is on, change" : "Change";
2193
2270
  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.`);
2194
2271
  return console.log(`New to this review? Run playthrough alone for how Playthrough works. Its flows are in ${repo.show(repo.flows(id))}.`);
2195
2272
  }
2196
2273
  }
2197
- function where(place) {
2274
+ function where(place, from) {
2198
2275
  if (!place) return "on the work in general";
2199
2276
  const at = place.at === void 0 ? "" : ` at ${time(place.at)}${place.until === void 0 ? "" : ` to ${time(place.until)}`}`;
2200
2277
  const on = place.element ? `, the ${place.element.role} “${place.element.name}”` : place.point ? `, ${share(place.point.x)} across and ${share(place.point.y)} down the screen` : place.box ? `, the part of the screen ${share(place.box.x)} across and ${share(place.box.y)} down` : "";
2201
2278
  if (place.app) {
2202
2279
  const did = place.app.did.length ? ` and carried on by hand (${list(place.app.did.map(done))})` : "";
2203
- return `on “${place.app.from.flow}”, in the app: they took the flow over at ${time(place.app.from.at)}${did}${on}`;
2280
+ const paused = from && pausedAt(from, place.app.from.at);
2281
+ const step = paused ? `, ${paused.actions ? `after ${paused.actions} of the actions in` : "at the end of"} its step “${paused.step}”` : "";
2282
+ return `on “${place.app.from.flow}”, in the app: they took the flow over at ${time(place.app.from.at)}${step}${did}${on}`;
2204
2283
  }
2205
2284
  return `on “${place.flow}”${at}${on}`;
2206
2285
  }
@@ -2220,7 +2299,7 @@ const takes = {
2220
2299
  start: [2, `playthrough start <review> "<title>" "<what was asked>"`],
2221
2300
  record: [0, "playthrough record <review>"],
2222
2301
  wait: [0, "playthrough wait <review>"],
2223
- walk: [0, "playthrough walk <review>"],
2302
+ forks: [0, "playthrough forks <review>"],
2224
2303
  open: [0, "playthrough open [<review>]"],
2225
2304
  "take-over": [2, `playthrough take-over <review> "<flow>" ["<step>"]`]
2226
2305
  };
@@ -2237,7 +2316,7 @@ try {
2237
2316
  else if (command === "start") await start(repo(), review, words[0], words[1]);
2238
2317
  else if (command === "record") await record(repo(), review);
2239
2318
  else if (command === "wait") await wait(repo(), review);
2240
- else if (command === "walk") await walk(repo(), review);
2319
+ else if (command === "forks") await forks(repo(), review);
2241
2320
  else if (command === "take-over") await takeOver$1(repo(), review, words[0], words[1]);
2242
2321
  }
2243
2322
  } catch (error) {