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.
- package/INSTRUCTIONS.md +35 -6
- package/README.md +3 -1
- package/package.json +2 -2
- package/src/{flows-DDB6AdQ-.js → flows-CbrwgoHB.js} +159 -24
- package/src/playthrough.js +228 -149
- package/src/playwright.config.js +1 -1
- package/src/{reporter-Dr7dWwSf.js → reporter-DlS7qehW.js} +7 -1
- package/src/reporter.js +1 -1
- package/src/test.js +113 -76
- package/types/cli/src/app.d.ts +3 -1
- package/types/cli/src/changes.d.ts +4 -0
- package/types/cli/src/command.d.ts +6 -0
- package/types/cli/src/flows.d.ts +21 -16
- package/types/cli/src/repo.d.ts +1 -0
- package/types/cli/src/test.d.ts +21 -8
- package/types/page/src/found.d.ts +5 -0
- package/types/page/src/index.d.ts +1 -0
- package/types/page/src/names.d.ts +1 -0
- package/types/page/src/scheme.d.ts +6 -0
- package/types/review/src/model.d.ts +46 -0
- package/types/review/src/rules.d.ts +6 -0
- package/viewer/assets/index-BbNKJRwK.css +2 -0
- package/viewer/assets/index-DwbSPQMk.js +194 -0
- package/viewer/index.html +2 -2
- package/viewer/assets/index-DVlUrKEE.js +0 -194
- package/viewer/assets/index-DhzHaV6E.css +0 -2
package/src/playthrough.js
CHANGED
|
@@ -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-
|
|
3
|
-
import { A as
|
|
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,
|
|
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 <
|
|
18
|
-
console.error(`Playthrough needs Node
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
746
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
1795
|
-
const forkOf = (recording) => recording.match(/^(?:branches\/(.+)-\d+|
|
|
1796
|
-
async function
|
|
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
|
|
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
|
|
1865
|
+
const unrun = (failed) => notes.map(({ id }) => ({
|
|
1813
1866
|
note: id,
|
|
1814
1867
|
flow: from.flow,
|
|
1815
|
-
|
|
1816
|
-
|
|
1868
|
+
found: false,
|
|
1869
|
+
failed: { why: failed }
|
|
1817
1870
|
}));
|
|
1818
1871
|
if (!now || !paused) {
|
|
1819
|
-
|
|
1872
|
+
ran.push(...unrun(`${quote(from.flow)} isn't in round ${round}`));
|
|
1820
1873
|
continue;
|
|
1821
1874
|
}
|
|
1822
|
-
|
|
1823
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = `
|
|
1898
|
+
const recording = `forks/${fork}.json`;
|
|
1839
1899
|
const start = run.events[0].timestamp;
|
|
1840
|
-
const length =
|
|
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
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
const
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
1875
|
-
...!reached && { stopped: run.stopped ?? "" },
|
|
1931
|
+
found: !!shown,
|
|
1876
1932
|
...picture && { picture }
|
|
1877
1933
|
});
|
|
1878
1934
|
}
|
|
1879
1935
|
}
|
|
1880
|
-
return
|
|
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
|
|
1887
|
-
const
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
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
|
|
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
|
|
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
|
|
1898
|
-
if (!
|
|
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 =
|
|
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${
|
|
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
|
|
1946
|
-
const
|
|
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 (
|
|
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,
|
|
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
|
|
2001
|
-
|
|
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:
|
|
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 ||
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 === "
|
|
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) {
|