playthrough 0.4.3 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTRUCTIONS.md +22 -6
- package/README.md +10 -2
- package/package.json +1 -1
- package/src/{flows-BkG0TgHA.js → flows-vi0amwSo.js} +192 -41
- package/src/playthrough.js +611 -132
- package/src/playwright.config.js +1 -1
- package/src/{reporter-DE8aueAO.js → reporter-S9fOk0za.js} +16 -5
- package/src/reporter.js +1 -1
- package/src/test.js +30 -11
- 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 +1 -0
- package/types/cli/src/repo.d.ts +5 -0
- package/types/cli/src/reporter.d.ts +0 -1
- 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 +6 -0
- package/types/review/src/rules.d.ts +2 -0
- package/viewer/assets/index-C4zcB6st.css +2 -0
- package/viewer/assets/index-D_6gdncQ.js +194 -0
- package/viewer/index.html +2 -2
- package/viewer/assets/index-BkU2wK28.js +0 -193
- package/viewer/assets/index-gTfnUrcK.css +0 -2
package/src/playthrough.js
CHANGED
|
@@ -1,24 +1,88 @@
|
|
|
1
1
|
#!/usr/bin/env -S node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON
|
|
2
|
-
import { A as
|
|
3
|
-
import {
|
|
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-S9fOk0za.js";
|
|
3
|
+
import { A as repo, M as thisCommand, N as pausedAt, 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 stillAt, 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-vi0amwSo.js";
|
|
4
4
|
import { t as read } from "./read-Bg9XVTtn.js";
|
|
5
|
-
import { createRequire } from "node:module";
|
|
6
5
|
import { copyFileSync, cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
6
|
import { execFileSync } from "node:child_process";
|
|
8
7
|
import { readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
9
8
|
import { createHash, randomBytes } from "node:crypto";
|
|
10
9
|
import { constants, networkInterfaces, tmpdir, userInfo } from "node:os";
|
|
11
|
-
import { dirname, extname, join, normalize, resolve, sep } from "node:path";
|
|
10
|
+
import { basename, dirname, extname, join, normalize, resolve, sep } from "node:path";
|
|
11
|
+
import { connect } from "node:net";
|
|
12
12
|
import { chromium, request } from "@playwright/test";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
|
-
import { createServer, request as request$1 } from "node:http";
|
|
15
|
-
import { connect } from "node:net";
|
|
14
|
+
import { createServer as createServer$1, request as request$1 } from "node:http";
|
|
16
15
|
// src/node.ts
|
|
17
16
|
const [major] = process.versions.node.split(".").map(Number);
|
|
18
17
|
if (major < 24) {
|
|
19
18
|
console.error(`Playthrough needs Node 24 or later, and this is Node ${process.versions.node}. Install Node 24, then run this again.`);
|
|
20
19
|
process.exit(1);
|
|
21
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
|
+
}
|
|
22
86
|
// src/before.ts
|
|
23
87
|
function keepBefore(repo, id) {
|
|
24
88
|
if (!attempt(() => git(repo, ["rev-parse", "--is-inside-work-tree"]))) return void 0;
|
|
@@ -87,7 +151,7 @@ async function recordOnBefore(repo, id, number, { recorded, written }) {
|
|
|
87
151
|
const review = await repo.reviews.get(id);
|
|
88
152
|
const before = review.rounds.find((round) => round.number === 0);
|
|
89
153
|
if (!before) return;
|
|
90
|
-
const ran = await readFile(repo.ranOnBefore(id), "utf8").then(JSON.parse
|
|
154
|
+
const ran = await readFile(repo.ranOnBefore(id), "utf8").then(JSON.parse).catch(() => ({}));
|
|
91
155
|
const setup = await writtenNow(repo);
|
|
92
156
|
const follows = Object.fromEntries(recorded.map(({ flow, doneOn }) => [flow.name, createHash("sha256").update(JSON.stringify([
|
|
93
157
|
written[flow.name]?.hash,
|
|
@@ -162,11 +226,12 @@ const SIGNALS = [
|
|
|
162
226
|
"SIGHUP"
|
|
163
227
|
];
|
|
164
228
|
async function asItWas(repo, unrecorded, commit, shared, run) {
|
|
229
|
+
refuseWhileAside(repo);
|
|
165
230
|
await repo.keepOutOfGit();
|
|
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
|
-
|
|
234
|
+
...thisCommand(),
|
|
170
235
|
...shared && { shared }
|
|
171
236
|
};
|
|
172
237
|
writeFileSync(repo.setAside, JSON.stringify(aside));
|
|
@@ -229,8 +294,8 @@ async function asItWas(repo, unrecorded, commit, shared, run) {
|
|
|
229
294
|
}
|
|
230
295
|
}
|
|
231
296
|
async function putBack(repo) {
|
|
232
|
-
const aside =
|
|
233
|
-
if (!aside ||
|
|
297
|
+
const aside = setAside(repo);
|
|
298
|
+
if (!aside || stillAt(aside)) return;
|
|
234
299
|
const toDo = here(repo) !== aside.back || !!stashOf(repo, aside);
|
|
235
300
|
const problem = putWorkBack(repo, aside, false);
|
|
236
301
|
if (problem) throw stillAside(repo, aside, problem);
|
|
@@ -240,6 +305,11 @@ async function putBack(repo) {
|
|
|
240
305
|
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.`]);
|
|
241
306
|
console.log(`${back}\n`);
|
|
242
307
|
}
|
|
308
|
+
function refuseWhileAside(repo) {
|
|
309
|
+
const aside = setAside(repo);
|
|
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.`]);
|
|
311
|
+
}
|
|
312
|
+
const setAside = (repo) => attempt(() => JSON.parse(readFileSync(repo.setAside, "utf8")));
|
|
243
313
|
function putWorkBack(repo, aside, force) {
|
|
244
314
|
try {
|
|
245
315
|
if (here(repo) !== aside.back) {
|
|
@@ -310,15 +380,12 @@ async function caughtUp(url) {
|
|
|
310
380
|
await new Promise((done) => setTimeout(done, 1e3));
|
|
311
381
|
await fetch(url, { signal: AbortSignal.timeout(6e4) }).then((response) => response.body?.cancel(), () => {});
|
|
312
382
|
}
|
|
313
|
-
function running(pid) {
|
|
314
|
-
try {
|
|
315
|
-
return process.kill(pid, 0);
|
|
316
|
-
} catch (error) {
|
|
317
|
-
return error.code === "EPERM";
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
383
|
function git(repo, args, env) {
|
|
321
|
-
return execFileSync("git",
|
|
384
|
+
return execFileSync("git", [
|
|
385
|
+
"-c",
|
|
386
|
+
"core.hooksPath=/dev/null",
|
|
387
|
+
...args
|
|
388
|
+
], {
|
|
322
389
|
cwd: repo.root,
|
|
323
390
|
env,
|
|
324
391
|
encoding: "utf8",
|
|
@@ -430,11 +497,11 @@ async function followingFor(repo, id, to, fresh = false) {
|
|
|
430
497
|
const one = following.get(key(repo, id));
|
|
431
498
|
if (one && !one.over && !fresh && one.written === written && one.flow === to.flow) return one;
|
|
432
499
|
one?.stop();
|
|
433
|
-
const made = await start$
|
|
500
|
+
const made = await start$2(repo, id, to, files, written);
|
|
434
501
|
following.set(key(repo, id), made);
|
|
435
502
|
return made;
|
|
436
503
|
}
|
|
437
|
-
async function start$
|
|
504
|
+
async function start$2(repo, id, to, files, written) {
|
|
438
505
|
const app = await repo.app();
|
|
439
506
|
const { started } = await repo.reviews.get(id);
|
|
440
507
|
const { runAlongside, pageFor } = await alongside();
|
|
@@ -575,7 +642,8 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
|
|
|
575
642
|
const outside = ahead && await reachesOutside(repo, id, flow.name);
|
|
576
643
|
if (outside) throw new Refused([`${quote(flow.name)} reaches outside the app (${outside}), so it's run only when it's taken over.`]);
|
|
577
644
|
const app = await repo.app();
|
|
578
|
-
|
|
645
|
+
refuseWhileAside(repo);
|
|
646
|
+
await keepRunning(app, repo.root, { ready: true });
|
|
579
647
|
const began = Date.now();
|
|
580
648
|
const { handed, made } = await runTo(repo, id, {
|
|
581
649
|
flow: flow.name,
|
|
@@ -585,10 +653,14 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
|
|
|
585
653
|
},
|
|
586
654
|
...did?.length && { did }
|
|
587
655
|
});
|
|
588
|
-
|
|
589
|
-
moments
|
|
590
|
-
|
|
591
|
-
|
|
656
|
+
if (!passings.has(app.url)) {
|
|
657
|
+
const moments = /* @__PURE__ */ new Map();
|
|
658
|
+
passings.set(app.url, {
|
|
659
|
+
moments,
|
|
660
|
+
port: passOn(app, (moment) => moments.get(moment), network)
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
const passing = passings.get(app.url);
|
|
592
664
|
const moment = randomBytes(12).toString("base64url");
|
|
593
665
|
passing.moments.set(moment, did?.length ? {
|
|
594
666
|
...handed,
|
|
@@ -611,7 +683,8 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
|
|
|
611
683
|
}
|
|
612
684
|
const ready = /* @__PURE__ */ new Map();
|
|
613
685
|
async function keepUp(repo, id, flow, step, actions) {
|
|
614
|
-
|
|
686
|
+
refuseWhileAside(repo);
|
|
687
|
+
await keepRunning(await repo.app(), repo.root, { ready: true });
|
|
615
688
|
await follow(repo, id, {
|
|
616
689
|
flow,
|
|
617
690
|
...step && {
|
|
@@ -620,7 +693,7 @@ async function keepUp(repo, id, flow, step, actions) {
|
|
|
620
693
|
}
|
|
621
694
|
});
|
|
622
695
|
}
|
|
623
|
-
|
|
696
|
+
const passings = /* @__PURE__ */ new Map();
|
|
624
697
|
async function recorded(repo, id) {
|
|
625
698
|
const review = await repo.reviews.get(id);
|
|
626
699
|
const round = review.rounds.findLast((round) => round.number > 0) ?? review.rounds.at(-1);
|
|
@@ -629,7 +702,7 @@ async function recorded(repo, id) {
|
|
|
629
702
|
}
|
|
630
703
|
function passOn(app, handed, network) {
|
|
631
704
|
const target = new URL(app.url);
|
|
632
|
-
const server = createServer((request, response) => {
|
|
705
|
+
const server = createServer$1((request, response) => {
|
|
633
706
|
if (!named(request)) return turnAway(response);
|
|
634
707
|
const [first, moment] = new URL(request.url ?? "/", "http://playthrough").pathname.split("/").filter(Boolean);
|
|
635
708
|
if (first === "take-over") return handOver(handed(moment ?? ""), response, !fromThisMachine(request));
|
|
@@ -699,7 +772,6 @@ function asIfDirect(request, target) {
|
|
|
699
772
|
else delete headers.cookie;
|
|
700
773
|
return headers;
|
|
701
774
|
}
|
|
702
|
-
const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
|
|
703
775
|
let RECORDER;
|
|
704
776
|
const SAYS = `<script>(() => {
|
|
705
777
|
if (window.parent === window) return;
|
|
@@ -715,52 +787,11 @@ const SAYS = `<script>(() => {
|
|
|
715
787
|
Object.assign(Shifted, { now: () => Real.now() + ahead, parse: Real.parse, UTC: Real.UTC });
|
|
716
788
|
window.Date = Shifted;
|
|
717
789
|
}
|
|
718
|
-
// In the browser the flow was recorded in, whatever yours says: light or dark, still or moving, in its languages.
|
|
719
|
-
// page asks in its scripts and its styles, and each asking is answered before the app hears it.
|
|
790
|
+
// In the browser the flow was recorded in, whatever yours says: light or dark, still or moving, in its languages.
|
|
720
791
|
const browser = JSON.parse(sessionStorage.getItem("playthrough:browser") || "null");
|
|
721
792
|
if (browser) {
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
String(query).replace(/\\(\\s*(prefers-color-scheme|prefers-reduced-motion)\\s*(?::\\s*([a-z-]+)\\s*)?\\)/gi, (_, feature, value) => {
|
|
725
|
-
feature = feature.toLowerCase();
|
|
726
|
-
const holds = value ? value.toLowerCase() === recorded[feature] : feature === "prefers-color-scheme" || browser.still;
|
|
727
|
-
return holds ? "(min-width: 0px)" : "(max-width: 0px)";
|
|
728
|
-
});
|
|
729
|
-
const matching = window.matchMedia;
|
|
730
|
-
window.matchMedia = (query) => matching.call(window, as(query));
|
|
731
|
-
for (const name of ["insertRule", "replace", "replaceSync"]) {
|
|
732
|
-
const real = CSSStyleSheet.prototype[name];
|
|
733
|
-
if (real) CSSStyleSheet.prototype[name] = function (text, ...rest) { return real.call(this, as(text), ...rest); };
|
|
734
|
-
}
|
|
735
|
-
// light-dark(), system colours, form controls and scrollbars follow the page's colour-scheme, not a media query.
|
|
736
|
-
const scheme = browser.dark ? "dark" : "light";
|
|
737
|
-
const inScheme = (value) => (/\\blight\\b/.test(value) && /\\bdark\\b/.test(value) ? scheme : value);
|
|
738
|
-
const rewrite = (rules) => {
|
|
739
|
-
for (const rule of rules) {
|
|
740
|
-
if (rule.media && as(rule.media.mediaText) !== rule.media.mediaText) rule.media.mediaText = as(rule.media.mediaText);
|
|
741
|
-
if (rule.style?.colorScheme && inScheme(rule.style.colorScheme) !== rule.style.colorScheme) rule.style.colorScheme = inScheme(rule.style.colorScheme);
|
|
742
|
-
if (rule.styleSheet) restyle(rule.styleSheet);
|
|
743
|
-
if (rule.cssRules) rewrite(rule.cssRules);
|
|
744
|
-
}
|
|
745
|
-
};
|
|
746
|
-
const restyle = (sheet) => { try { rewrite(sheet.cssRules); } catch {} };
|
|
747
|
-
const restyleAll = () => {
|
|
748
|
-
for (const sheet of document.styleSheets) restyle(sheet);
|
|
749
|
-
for (const sheet of document.adoptedStyleSheets ?? []) restyle(sheet);
|
|
750
|
-
for (const element of document.querySelectorAll("[media]")) {
|
|
751
|
-
const media = element.getAttribute("media");
|
|
752
|
-
if (as(media) !== media) element.setAttribute("media", as(media));
|
|
753
|
-
}
|
|
754
|
-
for (const meta of document.querySelectorAll('meta[name="color-scheme"]')) {
|
|
755
|
-
const content = meta.getAttribute("content") ?? "";
|
|
756
|
-
if (inScheme(content) !== content) meta.setAttribute("content", inScheme(content));
|
|
757
|
-
}
|
|
758
|
-
};
|
|
759
|
-
const styling = (node) => /^(style|link|source|meta)$/i.test(node?.nodeName);
|
|
760
|
-
const bringsStyle = (node) => styling(node) || !!node.querySelector?.("style, link, source, meta");
|
|
761
|
-
new MutationObserver((changes) => changes.some((change) => styling(change.target) || styling(change.target.parentNode) || [...change.addedNodes].some(bringsStyle)) && restyleAll())
|
|
762
|
-
.observe(document, { subtree: true, childList: true, characterData: true, attributeFilter: ["media", "content"] });
|
|
763
|
-
document.addEventListener("load", (event) => styling(event.target) && restyleAll(), true);
|
|
793
|
+
${asRecorded.toString()}
|
|
794
|
+
(${answerAs.toString()})(window, browser);
|
|
764
795
|
const languages = browser.languages;
|
|
765
796
|
Object.defineProperty(navigator, "language", { get: () => languages[0] });
|
|
766
797
|
Object.defineProperty(navigator, "languages", { get: () => languages });
|
|
@@ -799,6 +830,9 @@ const SAYS = `<script>(() => {
|
|
|
799
830
|
const yours = (mine) => { hidden.disabled = mine; if (mine) sessionStorage.setItem("playthrough:yours", "1"); else sessionStorage.removeItem("playthrough:yours"); };
|
|
800
831
|
yours(!!sessionStorage.getItem("playthrough:yours"));
|
|
801
832
|
addEventListener("message", ({ source, data }) => { if (source === parent && data?.playthrough === "yours") yours(data.yours); });
|
|
833
|
+
// Taken over, the page is scrolled where the recording was at that moment: made ready at a step's end, it was where
|
|
834
|
+
// the step left it, a screen or more from a moment partway through a page being read.
|
|
835
|
+
addEventListener("message", ({ source, data }) => { if (source === parent && data?.playthrough === "scroll") scrollTo({ left: data.x, top: data.y, behavior: "instant" }); });
|
|
802
836
|
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]');
|
|
803
837
|
// Every move, as it comes: held for a frame, the name trailed the pointer like a smoothed one. Not the earlier raw
|
|
804
838
|
// updates: in a scaled frame, Chrome gives them the page's position rather than the app's.
|
|
@@ -1017,7 +1051,9 @@ const SAYS = `<script>(() => {
|
|
|
1017
1051
|
sampling: { mousemove: 20, scroll: 16, input: "all" },
|
|
1018
1052
|
maskAllInputs: false,
|
|
1019
1053
|
inlineStylesheet: true,
|
|
1020
|
-
}) && ((recording = true), readNow());
|
|
1054
|
+
}) && ((recording = true), said(), readNow());
|
|
1055
|
+
// Light or dark, still or moving, as the flow was recorded, for your recording to be played back as it was too.
|
|
1056
|
+
const said = () => window.rrwebRecord.record.addCustomEvent("browser", { dark: matchMedia("(prefers-color-scheme: dark)").matches, still: matchMedia("(prefers-reduced-motion: reduce)").matches });
|
|
1021
1057
|
addEventListener("message", ({ source, data }) => source === parent && data?.playthrough === "yours" && record());
|
|
1022
1058
|
const settle = async () => {
|
|
1023
1059
|
await rested();
|
|
@@ -1097,6 +1133,394 @@ function notAnswering(response, app) {
|
|
|
1097
1133
|
response.end(`The app isn't answering at ${app.url}. Start it as playthrough/app.json says (${app.start}), then reload.`);
|
|
1098
1134
|
}
|
|
1099
1135
|
const addresses = () => Object.values(networkInterfaces()).flat().filter((face) => face && face.family === "IPv4" && !face.internal).map((face) => face.address);
|
|
1136
|
+
// src/mp4.ts
|
|
1137
|
+
function mp4({ width, height, fps, avcC, frames }) {
|
|
1138
|
+
const scale = fps * 1e3;
|
|
1139
|
+
const length = frames.length * 1e3;
|
|
1140
|
+
const keys = frames.flatMap((frame, i) => frame.key ? [i + 1] : []);
|
|
1141
|
+
const moov = (offset) => box("moov", [full("mvhd", 0, 0, [
|
|
1142
|
+
u32(0),
|
|
1143
|
+
u32(0),
|
|
1144
|
+
u32(scale),
|
|
1145
|
+
u32(length),
|
|
1146
|
+
u32(65536),
|
|
1147
|
+
u16(256),
|
|
1148
|
+
zeros(10),
|
|
1149
|
+
matrix,
|
|
1150
|
+
zeros(24),
|
|
1151
|
+
u32(2)
|
|
1152
|
+
]), box("trak", [full("tkhd", 0, 3, [
|
|
1153
|
+
u32(0),
|
|
1154
|
+
u32(0),
|
|
1155
|
+
u32(1),
|
|
1156
|
+
u32(0),
|
|
1157
|
+
u32(length),
|
|
1158
|
+
zeros(8),
|
|
1159
|
+
u16(0),
|
|
1160
|
+
u16(0),
|
|
1161
|
+
u16(0),
|
|
1162
|
+
u16(0),
|
|
1163
|
+
matrix,
|
|
1164
|
+
u32(width << 16),
|
|
1165
|
+
u32(height << 16)
|
|
1166
|
+
]), box("mdia", [
|
|
1167
|
+
full("mdhd", 0, 0, [
|
|
1168
|
+
u32(0),
|
|
1169
|
+
u32(0),
|
|
1170
|
+
u32(scale),
|
|
1171
|
+
u32(length),
|
|
1172
|
+
u16(21956),
|
|
1173
|
+
u16(0)
|
|
1174
|
+
]),
|
|
1175
|
+
full("hdlr", 0, 0, [
|
|
1176
|
+
u32(0),
|
|
1177
|
+
ascii("vide"),
|
|
1178
|
+
zeros(12),
|
|
1179
|
+
ascii("Playthrough\0")
|
|
1180
|
+
]),
|
|
1181
|
+
box("minf", [
|
|
1182
|
+
full("vmhd", 0, 1, [zeros(8)]),
|
|
1183
|
+
box("dinf", [full("dref", 0, 0, [u32(1), full("url ", 0, 1, [])])]),
|
|
1184
|
+
box("stbl", [
|
|
1185
|
+
full("stsd", 0, 0, [u32(1), box("avc1", [
|
|
1186
|
+
zeros(6),
|
|
1187
|
+
u16(1),
|
|
1188
|
+
zeros(16),
|
|
1189
|
+
u16(width),
|
|
1190
|
+
u16(height),
|
|
1191
|
+
u32(4718592),
|
|
1192
|
+
u32(4718592),
|
|
1193
|
+
u32(0),
|
|
1194
|
+
u16(1),
|
|
1195
|
+
zeros(32),
|
|
1196
|
+
u16(24),
|
|
1197
|
+
u16(65535),
|
|
1198
|
+
box("avcC", [avcC])
|
|
1199
|
+
])]),
|
|
1200
|
+
full("stts", 0, 0, [
|
|
1201
|
+
u32(1),
|
|
1202
|
+
u32(frames.length),
|
|
1203
|
+
u32(1e3)
|
|
1204
|
+
]),
|
|
1205
|
+
full("stss", 0, 0, [u32(keys.length), ...keys.map(u32)]),
|
|
1206
|
+
full("stsc", 0, 0, [
|
|
1207
|
+
u32(1),
|
|
1208
|
+
u32(1),
|
|
1209
|
+
u32(frames.length),
|
|
1210
|
+
u32(1)
|
|
1211
|
+
]),
|
|
1212
|
+
full("stsz", 0, 0, [
|
|
1213
|
+
u32(0),
|
|
1214
|
+
u32(frames.length),
|
|
1215
|
+
...frames.map((frame) => u32(frame.data.length))
|
|
1216
|
+
]),
|
|
1217
|
+
full("stco", 0, 0, [u32(1), u32(offset)])
|
|
1218
|
+
])
|
|
1219
|
+
])
|
|
1220
|
+
])])]);
|
|
1221
|
+
const ftyp = box("ftyp", [
|
|
1222
|
+
ascii("isom"),
|
|
1223
|
+
u32(512),
|
|
1224
|
+
ascii("isom"),
|
|
1225
|
+
ascii("iso2"),
|
|
1226
|
+
ascii("avc1"),
|
|
1227
|
+
ascii("mp41")
|
|
1228
|
+
]);
|
|
1229
|
+
return join$1([
|
|
1230
|
+
ftyp,
|
|
1231
|
+
moov(ftyp.length + moov(0).length + 8),
|
|
1232
|
+
box("mdat", frames.map((frame) => frame.data))
|
|
1233
|
+
]);
|
|
1234
|
+
}
|
|
1235
|
+
const matrix = join$1([
|
|
1236
|
+
65536,
|
|
1237
|
+
0,
|
|
1238
|
+
0,
|
|
1239
|
+
0,
|
|
1240
|
+
65536,
|
|
1241
|
+
0,
|
|
1242
|
+
0,
|
|
1243
|
+
0,
|
|
1244
|
+
1073741824
|
|
1245
|
+
].map(u32));
|
|
1246
|
+
function box(type, parts) {
|
|
1247
|
+
const body = join$1(parts);
|
|
1248
|
+
return join$1([
|
|
1249
|
+
u32(body.length + 8),
|
|
1250
|
+
ascii(type),
|
|
1251
|
+
body
|
|
1252
|
+
]);
|
|
1253
|
+
}
|
|
1254
|
+
const full = (type, version, flags, parts) => box(type, [u32(version << 24 | flags), ...parts]);
|
|
1255
|
+
function u32(value) {
|
|
1256
|
+
const bytes = /* @__PURE__ */ new Uint8Array(4);
|
|
1257
|
+
new DataView(bytes.buffer).setUint32(0, value >>> 0);
|
|
1258
|
+
return bytes;
|
|
1259
|
+
}
|
|
1260
|
+
function u16(value) {
|
|
1261
|
+
const bytes = /* @__PURE__ */ new Uint8Array(2);
|
|
1262
|
+
new DataView(bytes.buffer).setUint16(0, value);
|
|
1263
|
+
return bytes;
|
|
1264
|
+
}
|
|
1265
|
+
const zeros = (length) => new Uint8Array(length);
|
|
1266
|
+
const ascii = (text) => new TextEncoder().encode(text);
|
|
1267
|
+
function join$1(parts) {
|
|
1268
|
+
const joined = new Uint8Array(parts.reduce((length, part) => length + part.length, 0));
|
|
1269
|
+
let at = 0;
|
|
1270
|
+
for (const part of parts) {
|
|
1271
|
+
joined.set(part, at);
|
|
1272
|
+
at += part.length;
|
|
1273
|
+
}
|
|
1274
|
+
return joined;
|
|
1275
|
+
}
|
|
1276
|
+
// src/video.ts
|
|
1277
|
+
const FPS = 60;
|
|
1278
|
+
const WIDEST = 1920;
|
|
1279
|
+
const ROOM = 32;
|
|
1280
|
+
const CAPTION = 26;
|
|
1281
|
+
const GAP = 20;
|
|
1282
|
+
const TALLEST = 1920;
|
|
1283
|
+
const LARGEST = 2;
|
|
1284
|
+
const PER_FRAME = .009;
|
|
1285
|
+
async function video(port, id, filmed, shape, onLeft) {
|
|
1286
|
+
const beside = filmed.beside !== void 0;
|
|
1287
|
+
const room = {
|
|
1288
|
+
width: (beside ? 2 * shape.width + GAP : shape.width) + 64,
|
|
1289
|
+
height: shape.height + CAPTION + 64
|
|
1290
|
+
};
|
|
1291
|
+
const larger = Math.min(WIDEST / room.width, TALLEST / (room.height - (beside ? 0 : CAPTION)), LARGEST);
|
|
1292
|
+
const viewport = {
|
|
1293
|
+
width: Math.round(larger * room.width),
|
|
1294
|
+
height: Math.round(larger * room.height)
|
|
1295
|
+
};
|
|
1296
|
+
const browser = await chromium.launch();
|
|
1297
|
+
try {
|
|
1298
|
+
const page = await (await browser.newContext({
|
|
1299
|
+
viewport,
|
|
1300
|
+
colorScheme: filmed.dark ? "dark" : "light"
|
|
1301
|
+
})).newPage();
|
|
1302
|
+
const { dark, ...shown } = filmed;
|
|
1303
|
+
await page.goto(`http://localhost:${port}/${encodeURIComponent(id)}?video=${encodeURIComponent(JSON.stringify({
|
|
1304
|
+
...shown,
|
|
1305
|
+
larger
|
|
1306
|
+
}))}`);
|
|
1307
|
+
await page.waitForFunction((frames) => !!window.video && document.querySelectorAll("[data-frame]:not([hidden]) iframe").length >= frames, beside ? 2 : 1);
|
|
1308
|
+
await page.evaluate(() => document.fonts.ready);
|
|
1309
|
+
await page.waitForTimeout(600);
|
|
1310
|
+
const crop = await page.evaluate(around, {
|
|
1311
|
+
room: larger * ROOM,
|
|
1312
|
+
caption: beside ? larger * CAPTION : 0
|
|
1313
|
+
});
|
|
1314
|
+
return await film(page, await encoder(await browser.newPage(), crop), onLeft);
|
|
1315
|
+
} finally {
|
|
1316
|
+
await browser.close();
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
function around({ room, caption }) {
|
|
1320
|
+
const boxes = [...document.querySelectorAll("[data-frame]:not([hidden])")].map((frame) => frame.getBoundingClientRect());
|
|
1321
|
+
const left = Math.max(0, Math.min(...boxes.map((box) => box.left)) - room);
|
|
1322
|
+
const top = Math.max(0, Math.min(...boxes.map((box) => box.top)) - caption - room);
|
|
1323
|
+
const right = Math.min(innerWidth, Math.max(...boxes.map((box) => box.right)) + room);
|
|
1324
|
+
const bottom = Math.min(innerHeight, Math.max(...boxes.map((box) => box.bottom)) + room);
|
|
1325
|
+
return {
|
|
1326
|
+
x: left,
|
|
1327
|
+
y: top,
|
|
1328
|
+
width: right - left,
|
|
1329
|
+
height: bottom - top
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
async function encoder(page, crop) {
|
|
1333
|
+
const k = Math.min(1, WIDEST / crop.width);
|
|
1334
|
+
const even = (value) => 2 * Math.round(value / 2);
|
|
1335
|
+
const size = {
|
|
1336
|
+
width: even(crop.width * k),
|
|
1337
|
+
height: even(crop.height * k)
|
|
1338
|
+
};
|
|
1339
|
+
await page.route("https://video.playthrough/", (route) => route.fulfill({
|
|
1340
|
+
contentType: "text/html",
|
|
1341
|
+
body: ""
|
|
1342
|
+
}));
|
|
1343
|
+
await page.goto("https://video.playthrough/");
|
|
1344
|
+
await page.evaluate(start$1, {
|
|
1345
|
+
...size,
|
|
1346
|
+
fps: FPS,
|
|
1347
|
+
crop
|
|
1348
|
+
});
|
|
1349
|
+
return {
|
|
1350
|
+
page,
|
|
1351
|
+
size
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
async function film(page, { page: putting, size }, onLeft) {
|
|
1355
|
+
const length = await page.evaluate(() => window.video.length);
|
|
1356
|
+
const browser = await page.context().newCDPSession(page);
|
|
1357
|
+
let start = Infinity;
|
|
1358
|
+
let drawn;
|
|
1359
|
+
let last;
|
|
1360
|
+
let made = 0;
|
|
1361
|
+
let done = 0;
|
|
1362
|
+
let going = Promise.resolve();
|
|
1363
|
+
const upTo = (at) => {
|
|
1364
|
+
for (; made / FPS <= at; made++) {
|
|
1365
|
+
const [jpeg, key] = [drawn === last ? void 0 : drawn, made % 120 === 0];
|
|
1366
|
+
last = drawn;
|
|
1367
|
+
going = going.then(() => putting.evaluate(add, {
|
|
1368
|
+
jpeg,
|
|
1369
|
+
key
|
|
1370
|
+
})).then(() => void done++);
|
|
1371
|
+
}
|
|
1372
|
+
};
|
|
1373
|
+
let firstDrawn = () => {};
|
|
1374
|
+
browser.on("Page.screencastFrame", ({ data, metadata, sessionId }) => {
|
|
1375
|
+
upTo((metadata.timestamp ?? Date.now() / 1e3) - start);
|
|
1376
|
+
drawn = data;
|
|
1377
|
+
firstDrawn();
|
|
1378
|
+
browser.send("Page.screencastFrameAck", { sessionId }).catch(() => {});
|
|
1379
|
+
});
|
|
1380
|
+
await new Promise((drew) => {
|
|
1381
|
+
firstDrawn = drew;
|
|
1382
|
+
browser.send("Page.startScreencast", {
|
|
1383
|
+
format: "jpeg",
|
|
1384
|
+
quality: 92,
|
|
1385
|
+
maxWidth: page.viewportSize().width,
|
|
1386
|
+
maxHeight: page.viewportSize().height
|
|
1387
|
+
});
|
|
1388
|
+
});
|
|
1389
|
+
start = Date.now() / 1e3;
|
|
1390
|
+
const telling = setInterval(() => onLeft(Math.max(0, start + length - Date.now() / 1e3) + (made - done) * PER_FRAME), 250);
|
|
1391
|
+
try {
|
|
1392
|
+
await page.evaluate(() => window.video.play());
|
|
1393
|
+
upTo(Date.now() / 1e3 - start);
|
|
1394
|
+
await browser.send("Page.stopScreencast");
|
|
1395
|
+
await going;
|
|
1396
|
+
} finally {
|
|
1397
|
+
clearInterval(telling);
|
|
1398
|
+
}
|
|
1399
|
+
const finished = await putting.evaluate(finish);
|
|
1400
|
+
const bytes = (base64) => new Uint8Array(Buffer.from(base64, "base64"));
|
|
1401
|
+
return mp4({
|
|
1402
|
+
...size,
|
|
1403
|
+
fps: FPS,
|
|
1404
|
+
avcC: bytes(finished.avcC),
|
|
1405
|
+
frames: finished.frames.map((frame) => ({
|
|
1406
|
+
data: bytes(frame.data),
|
|
1407
|
+
key: frame.key
|
|
1408
|
+
}))
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
async function start$1({ width, height, fps, crop }) {
|
|
1412
|
+
const base64 = (bytes) => btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(""));
|
|
1413
|
+
const state = {
|
|
1414
|
+
encoder: new VideoEncoder({
|
|
1415
|
+
output: (chunk, metadata) => {
|
|
1416
|
+
const data = new Uint8Array(chunk.byteLength);
|
|
1417
|
+
chunk.copyTo(data);
|
|
1418
|
+
state.frames.push({
|
|
1419
|
+
data: base64(data),
|
|
1420
|
+
key: chunk.type === "key"
|
|
1421
|
+
});
|
|
1422
|
+
const description = metadata?.decoderConfig?.description;
|
|
1423
|
+
if (description) state.avcC = base64(ArrayBuffer.isView(description) ? new Uint8Array(description.buffer, description.byteOffset, description.byteLength) : new Uint8Array(description));
|
|
1424
|
+
},
|
|
1425
|
+
error: (error) => console.error(error)
|
|
1426
|
+
}),
|
|
1427
|
+
canvas: new OffscreenCanvas(width, height),
|
|
1428
|
+
crop,
|
|
1429
|
+
fps,
|
|
1430
|
+
i: 0,
|
|
1431
|
+
picture: new Uint8Array(width * height * 3 / 2),
|
|
1432
|
+
frames: []
|
|
1433
|
+
};
|
|
1434
|
+
window.encoding = state;
|
|
1435
|
+
for (const codec of [
|
|
1436
|
+
"avc1.640033",
|
|
1437
|
+
"avc1.64002a",
|
|
1438
|
+
"avc1.640028"
|
|
1439
|
+
]) if ((await VideoEncoder.isConfigSupported({
|
|
1440
|
+
codec,
|
|
1441
|
+
width,
|
|
1442
|
+
height
|
|
1443
|
+
})).supported) {
|
|
1444
|
+
state.encoder.configure({
|
|
1445
|
+
codec,
|
|
1446
|
+
width,
|
|
1447
|
+
height,
|
|
1448
|
+
bitrate: 6e6,
|
|
1449
|
+
framerate: fps,
|
|
1450
|
+
avc: { format: "avc" }
|
|
1451
|
+
});
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
throw new Error(`This browser can't make an H.264 video ${width}×${height} wide.`);
|
|
1455
|
+
}
|
|
1456
|
+
async function add({ jpeg, key }) {
|
|
1457
|
+
const state = window.encoding;
|
|
1458
|
+
const { width, height } = state.canvas;
|
|
1459
|
+
if (jpeg) {
|
|
1460
|
+
const picture = await createImageBitmap(await (await fetch(`data:image/jpeg;base64,${jpeg}`)).blob());
|
|
1461
|
+
const draw = state.canvas.getContext("2d", { willReadFrequently: true });
|
|
1462
|
+
draw.imageSmoothingQuality = "high";
|
|
1463
|
+
draw.drawImage(picture, state.crop.x, state.crop.y, state.crop.width, state.crop.height, 0, 0, width, height);
|
|
1464
|
+
picture.close();
|
|
1465
|
+
const rgba = draw.getImageData(0, 0, width, height).data;
|
|
1466
|
+
const [brightness, blue, red] = [
|
|
1467
|
+
state.picture.subarray(0, width * height),
|
|
1468
|
+
state.picture.subarray(width * height, width * height * 5 / 4),
|
|
1469
|
+
state.picture.subarray(width * height * 5 / 4)
|
|
1470
|
+
];
|
|
1471
|
+
for (let y = 0; y < height; y += 2) for (let x = 0; x < width; x += 2) {
|
|
1472
|
+
let [r, g, b] = [
|
|
1473
|
+
0,
|
|
1474
|
+
0,
|
|
1475
|
+
0
|
|
1476
|
+
];
|
|
1477
|
+
for (const at of [
|
|
1478
|
+
y * width + x,
|
|
1479
|
+
y * width + x + 1,
|
|
1480
|
+
(y + 1) * width + x,
|
|
1481
|
+
(y + 1) * width + x + 1
|
|
1482
|
+
]) {
|
|
1483
|
+
const [dotR, dotG, dotB] = [
|
|
1484
|
+
rgba[at * 4],
|
|
1485
|
+
rgba[at * 4 + 1],
|
|
1486
|
+
rgba[at * 4 + 2]
|
|
1487
|
+
];
|
|
1488
|
+
brightness[at] = 16 + (11966 * dotR + 40254 * dotG + 4064 * dotB + 32768 >> 16);
|
|
1489
|
+
r += dotR;
|
|
1490
|
+
g += dotG;
|
|
1491
|
+
b += dotB;
|
|
1492
|
+
}
|
|
1493
|
+
const square = y / 2 * (width / 2) + x / 2;
|
|
1494
|
+
blue[square] = 128 + (-6596 * r - 22189 * g + 28785 * b + 131072 >> 18);
|
|
1495
|
+
red[square] = 128 + (28785 * r - 26145 * g - 2640 * b + 131072 >> 18);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
const frame = new VideoFrame(state.picture, {
|
|
1499
|
+
format: "I420",
|
|
1500
|
+
codedWidth: width,
|
|
1501
|
+
codedHeight: height,
|
|
1502
|
+
timestamp: state.i * 1e6 / state.fps,
|
|
1503
|
+
duration: 1e6 / state.fps,
|
|
1504
|
+
colorSpace: {
|
|
1505
|
+
primaries: "bt709",
|
|
1506
|
+
transfer: "bt709",
|
|
1507
|
+
matrix: "bt709",
|
|
1508
|
+
fullRange: false
|
|
1509
|
+
}
|
|
1510
|
+
});
|
|
1511
|
+
state.encoder.encode(frame, { keyFrame: key });
|
|
1512
|
+
frame.close();
|
|
1513
|
+
state.i++;
|
|
1514
|
+
while (state.encoder.encodeQueueSize > 8) await new Promise((done) => setTimeout(done, 5));
|
|
1515
|
+
}
|
|
1516
|
+
async function finish() {
|
|
1517
|
+
const state = window.encoding;
|
|
1518
|
+
await state.encoder.flush();
|
|
1519
|
+
return {
|
|
1520
|
+
avcC: state.avcC,
|
|
1521
|
+
frames: state.frames
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1100
1524
|
// src/open.ts
|
|
1101
1525
|
const PORT = 5790;
|
|
1102
1526
|
const TYPES = {
|
|
@@ -1116,13 +1540,16 @@ const TYPES = {
|
|
|
1116
1540
|
".ttf": "font/ttf",
|
|
1117
1541
|
".otf": "font/otf"
|
|
1118
1542
|
};
|
|
1119
|
-
async function open(
|
|
1120
|
-
|
|
1543
|
+
async function open(here, id) {
|
|
1544
|
+
const app = everyCopy(here);
|
|
1545
|
+
if (id) await (await app.holding(id)).reviews.get(id);
|
|
1546
|
+
const already = await openAlready(app.folder);
|
|
1547
|
+
if (already) return console.log(`Playthrough is already open at http://localhost:${already}/${id ?? ""}`);
|
|
1121
1548
|
const page = fileURLToPath(new URL("../viewer", import.meta.url));
|
|
1122
1549
|
if (!await readFile(join(page, "index.html")).catch(() => void 0)) throw new Refused(["The viewer isn't built yet. Build it from core with npm run build."]);
|
|
1123
|
-
const you = whoYouAre(
|
|
1124
|
-
const port = await listen(createServer((request, response) => {
|
|
1125
|
-
answer(
|
|
1550
|
+
const you = whoYouAre(here.root);
|
|
1551
|
+
const port = await listen(createServer$1((request, response) => {
|
|
1552
|
+
answer(app, page, you, request, response).catch((error) => {
|
|
1126
1553
|
if (response.headersSent) return response.end();
|
|
1127
1554
|
const problems = error instanceof Refused ? error.problems : [String(error?.message ?? error)];
|
|
1128
1555
|
json(response, error instanceof Refused ? 400 : 500, { problems });
|
|
@@ -1130,22 +1557,42 @@ async function open(repo, id) {
|
|
|
1130
1557
|
}), PORT);
|
|
1131
1558
|
console.log(`Playthrough is open at http://localhost:${port}/${id ?? ""}`);
|
|
1132
1559
|
}
|
|
1133
|
-
|
|
1560
|
+
function everyCopy(here) {
|
|
1561
|
+
const all = () => copies(here.root).map((root) => root === here.root ? here : repo(root));
|
|
1562
|
+
const found = /* @__PURE__ */ new Map();
|
|
1563
|
+
return {
|
|
1564
|
+
all,
|
|
1565
|
+
folder: all()[0]?.root ?? here.root,
|
|
1566
|
+
async holding(id) {
|
|
1567
|
+
if (!found.has(id)) {
|
|
1568
|
+
for (const copy of all()) if ((await copy.reviews.list()).includes(id) && !found.has(id)) found.set(id, copy);
|
|
1569
|
+
}
|
|
1570
|
+
return found.get(id) ?? here;
|
|
1571
|
+
}
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
async function openAlready(folder) {
|
|
1575
|
+
const ports = Array.from({ length: 21 }, (_, i) => PORT + i);
|
|
1576
|
+
const asked = (port) => fetch(`http://127.0.0.1:${port}/api/app`, { signal: AbortSignal.timeout(1e3) }).then((answer) => answer.json()).then((said) => said.folder === folder ? port : void 0, () => void 0);
|
|
1577
|
+
return (await Promise.all(ports.map(asked))).find((port) => port !== void 0);
|
|
1578
|
+
}
|
|
1579
|
+
async function answer(app, page, you, request, response) {
|
|
1134
1580
|
const stranger = fromElsewhere(request);
|
|
1135
1581
|
if (stranger) return json(response, 403, { problems: [stranger] });
|
|
1136
|
-
const reviews = repo.reviews;
|
|
1137
1582
|
const path = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
|
|
1138
1583
|
const [api, collection, id, ...rest] = path.split("/").filter(Boolean);
|
|
1139
1584
|
if (api !== "api") return serve(page, path, response);
|
|
1140
1585
|
if (request.method === "GET" && collection === "you") return json(response, 200, { name: you });
|
|
1586
|
+
if (request.method === "GET" && collection === "app") return json(response, 200, { folder: app.folder });
|
|
1141
1587
|
if (collection !== "reviews") return json(response, 404, { problems: [`There's nothing at ${path}.`] });
|
|
1142
|
-
if (request.method === "GET" && !id)
|
|
1143
|
-
id
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
})));
|
|
1588
|
+
if (request.method === "GET" && !id) {
|
|
1589
|
+
const read = async (copy) => Promise.all((await copy.reviews.list()).map((id) => copy.reviews.get(id).catch(() => void 0)));
|
|
1590
|
+
const every = (await Promise.all(app.all().map(read))).flat();
|
|
1591
|
+
return json(response, 200, every.filter((review, i) => review && every.findIndex((first) => first?.id === review.id) === i));
|
|
1592
|
+
}
|
|
1148
1593
|
if (!id) return json(response, 404, { problems: [`There's nothing at ${path}.`] });
|
|
1594
|
+
const repo = await app.holding(id);
|
|
1595
|
+
const reviews = repo.reviews;
|
|
1149
1596
|
if (request.method === "GET" && rest[0] === "live") return live(repo, id, request, response);
|
|
1150
1597
|
if (request.method === "GET" && rest[0] === "rounds" && rest[2] === "files" && rest[3]) {
|
|
1151
1598
|
const name = rest.slice(3).join("/");
|
|
@@ -1156,8 +1603,14 @@ async function answer(repo, page, you, request, response) {
|
|
|
1156
1603
|
"content-type": TYPES[extname(path)] ?? "application/octet-stream",
|
|
1157
1604
|
"cache-control": "max-age=31536000, immutable"
|
|
1158
1605
|
});
|
|
1606
|
+
const as = new URL(request.url ?? "/", "http://localhost").searchParams.get("as");
|
|
1607
|
+
if (as !== null && extname(path) === ".svg") return response.end(asRecorded(Buffer.from(bytes).toString("utf8"), {
|
|
1608
|
+
dark: as.includes("dark"),
|
|
1609
|
+
still: as.includes("still")
|
|
1610
|
+
}));
|
|
1159
1611
|
return response.end(bytes);
|
|
1160
1612
|
}
|
|
1613
|
+
if (request.method === "POST" && rest[0] === "video") return saveVideo(repo, id, request, response);
|
|
1161
1614
|
if (request.method === "POST" && rest[0] === "take-over") return takeOver(repo, id, request, response);
|
|
1162
1615
|
if (request.method === "POST" && rest[0] === "follow") return followAlong(repo, id, request, response);
|
|
1163
1616
|
if (request.method === "POST" && rest[0] === "ready-in") return readyInFor(repo, id, request, response);
|
|
@@ -1239,6 +1692,34 @@ function paced(events) {
|
|
|
1239
1692
|
});
|
|
1240
1693
|
}
|
|
1241
1694
|
const readingOf = (event) => event.type === 5 && event.data?.tag === "reading" ? event.data.payload : void 0;
|
|
1695
|
+
async function saveVideo(repo, id, request, response) {
|
|
1696
|
+
const filmed = await body(request);
|
|
1697
|
+
const rounds = (await repo.reviews.get(id)).rounds.filter((round) => round.number === filmed.round || round.number === filmed.beside);
|
|
1698
|
+
const screens = [];
|
|
1699
|
+
for (const round of rounds) {
|
|
1700
|
+
const flow = round.flows.find((flow) => flow.name === filmed.flow);
|
|
1701
|
+
const kept = flow && await repo.reviews.file(id, round.number, flow.recording);
|
|
1702
|
+
if (kept) {
|
|
1703
|
+
for (const event of JSON.parse(Buffer.from(kept).toString("utf8"))) if (event.type === 4) screens.push(event.data);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
if (!screens.length) return json(response, 404, { problems: [`Round ${filmed.round} of ${id} has no recording of “${filmed.flow}”.`] });
|
|
1707
|
+
const shape = {
|
|
1708
|
+
width: Math.max(...screens.map((screen) => screen.width)),
|
|
1709
|
+
height: Math.max(...screens.map((screen) => screen.height))
|
|
1710
|
+
};
|
|
1711
|
+
response.writeHead(200, {
|
|
1712
|
+
"content-type": "application/x-ndjson",
|
|
1713
|
+
"cache-control": "no-store"
|
|
1714
|
+
});
|
|
1715
|
+
const say = (line) => response.write(`${JSON.stringify(line)}\n`);
|
|
1716
|
+
try {
|
|
1717
|
+
say({ video: Buffer.from(await video(request.socket.localPort, id, filmed, shape, (left) => say({ left: Math.round(left * 10) / 10 }))).toString("base64") });
|
|
1718
|
+
} catch (error) {
|
|
1719
|
+
say({ problems: [`The video couldn't be made: ${error.message.split("\n")[0]}`] });
|
|
1720
|
+
}
|
|
1721
|
+
response.end();
|
|
1722
|
+
}
|
|
1242
1723
|
async function readyInFor(repo, id, request, response) {
|
|
1243
1724
|
const { flow, step, actions } = await body(request);
|
|
1244
1725
|
json(response, 200, { in: await readyIn(repo, id, {
|
|
@@ -1433,7 +1914,8 @@ async function walk(repo, id) {
|
|
|
1433
1914
|
const saved = review.rounds.find((round) => round.number === number);
|
|
1434
1915
|
const walked = await walkForks(repo, review, number, saved?.flows ?? review.rounds.findLast((round) => round.number > 0)?.flows ?? []);
|
|
1435
1916
|
if (!walked.length) return console.log(`Nothing to walk: round ${number} answers no note made in the app.`);
|
|
1436
|
-
|
|
1917
|
+
const now = saved && (await repo.reviews.get(id)).rounds.find((round) => round.number === number);
|
|
1918
|
+
if (now) {
|
|
1437
1919
|
const placed = (reply) => {
|
|
1438
1920
|
const on = walked.find((one) => one.note === reply.note)?.on;
|
|
1439
1921
|
return on ? {
|
|
@@ -1442,8 +1924,8 @@ async function walk(repo, id) {
|
|
|
1442
1924
|
} : reply;
|
|
1443
1925
|
};
|
|
1444
1926
|
await repo.reviews.saveRound(id, {
|
|
1445
|
-
...
|
|
1446
|
-
replies:
|
|
1927
|
+
...now,
|
|
1928
|
+
replies: now.replies.map(placed)
|
|
1447
1929
|
});
|
|
1448
1930
|
}
|
|
1449
1931
|
console.log(`\n${walked.map(saidOf).join("\n")}`);
|
|
@@ -1461,7 +1943,7 @@ async function record(repo, id) {
|
|
|
1461
1943
|
`Fix ${failed.length > 1 ? "them" : "it"} and record again, which runs ${failed.length > 1 ? "them" : "it"} first. ${whereItStopped(repo, id, failed)}`
|
|
1462
1944
|
]);
|
|
1463
1945
|
await recordOnBefore(repo, id, number, ran);
|
|
1464
|
-
const saved =
|
|
1946
|
+
const saved = (await repo.reviews.get(id)).rounds.find((round) => round.number === number);
|
|
1465
1947
|
const joined = inOrderJoined(review);
|
|
1466
1948
|
const place = (name) => joined.includes(name) ? joined.indexOf(name) : joined.length;
|
|
1467
1949
|
const flows = recorded.map(({ flow }) => {
|
|
@@ -1559,30 +2041,32 @@ const changed = (count, since) => {
|
|
|
1559
2041
|
return count ? `${count} ${count > 1 ? "changes" : "change"} since ${round}.` : `Nothing changed since ${round}.`;
|
|
1560
2042
|
};
|
|
1561
2043
|
// src/start.ts
|
|
1562
|
-
async function start(repo, id, title, asked) {
|
|
1563
|
-
if (!(await repo.reviews.list()).includes(id)) {
|
|
2044
|
+
async function start(repo$1, id, title, asked) {
|
|
2045
|
+
if (!(await repo$1.reviews.list()).includes(id)) {
|
|
2046
|
+
if ([repo$1.tried, repo$1.borrowed].some((own) => basename(own) === id)) throw new Refused([`“${id}” is the name of a folder Playthrough keeps for itself. Give the review another name.`]);
|
|
2047
|
+
for (const other of copies(repo$1.root).filter((root) => root !== repo$1.root)) if ((await repo(other).reviews.list()).includes(id)) throw new Refused([`There's a review called “${id}” in another worktree, at ${other}. Give this one another name.`]);
|
|
1564
2048
|
if (!title || !asked) throw new Refused([`There's no review called “${id}” yet. Start it with a few words for the change, as a pull request's title, and what was asked, in the person's words: playthrough start ${id} "<title>" "<what was asked>"`]);
|
|
1565
|
-
await repo.reviews.start(id, title, asked);
|
|
1566
|
-
await repo.keepOutOfGit();
|
|
1567
|
-
await makeFlowsFolder(repo, id);
|
|
2049
|
+
await repo$1.reviews.start(id, title, asked);
|
|
2050
|
+
await repo$1.keepOutOfGit();
|
|
2051
|
+
await makeFlowsFolder(repo$1, id);
|
|
1568
2052
|
return console.log(`Started ${id}: “${title}”.
|
|
1569
2053
|
|
|
1570
2054
|
Write its flows before you change anything. They show what was asked, so write them from the task, not from the code.
|
|
1571
2055
|
|
|
1572
|
-
${howToWriteFlows(repo, id)}
|
|
2056
|
+
${howToWriteFlows(repo$1, id)}
|
|
1573
2057
|
|
|
1574
2058
|
Then run playthrough start ${id} again. It tries the app's setup, and keeps the app as it is as Before, to record each flow on with round 1, so the person can compare. If the app is new, there's no Before: go straight to the work.`);
|
|
1575
2059
|
}
|
|
1576
|
-
const review = await repo.reviews.get(id);
|
|
2060
|
+
const review = await repo$1.reviews.get(id);
|
|
1577
2061
|
if (title && title !== review.title) throw new Refused([`There's already a review called “${id}”, of “${review.title}”. Give this one another name, or carry that one on: playthrough start ${id}`]);
|
|
1578
2062
|
const last = review.rounds.at(-1);
|
|
1579
2063
|
if (last?.number === 0) throw new Refused([`Before is kept already. Change the app, then record round 1: playthrough record ${id}`]);
|
|
1580
2064
|
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}`]);
|
|
1581
|
-
await flowsOf(repo, id);
|
|
1582
|
-
await trySetup(repo, `playthrough start ${id}`);
|
|
1583
|
-
const commit = keepBefore(repo, id);
|
|
2065
|
+
await flowsOf(repo$1, id);
|
|
2066
|
+
await trySetup(repo$1, `playthrough start ${id}`);
|
|
2067
|
+
const commit = keepBefore(repo$1, id);
|
|
1584
2068
|
if (commit) {
|
|
1585
|
-
await repo.reviews.saveRound(id, {
|
|
2069
|
+
await repo$1.reviews.saveRound(id, {
|
|
1586
2070
|
number: 0,
|
|
1587
2071
|
by: agent(),
|
|
1588
2072
|
flows: [],
|
|
@@ -1590,7 +2074,7 @@ Then run playthrough start ${id} again. It tries the app's setup, and keeps the
|
|
|
1590
2074
|
accounts: {},
|
|
1591
2075
|
commit
|
|
1592
2076
|
});
|
|
1593
|
-
await repo.reviews.finish(id, 0);
|
|
2077
|
+
await repo$1.reviews.finish(id, 0);
|
|
1594
2078
|
}
|
|
1595
2079
|
console.log(`
|
|
1596
2080
|
${commit ? "Kept Before: the app as it is now. Each flow is recorded on it with round 1, as far as the app as it was takes it, for the person to compare with." : "This folder isn't in git, or has no commits yet, so there's no Before: the app as it is can't be put back once you've changed it."}
|
|
@@ -1600,26 +2084,30 @@ Now change the app. Keep each flow's name, and each step's while it does the sam
|
|
|
1600
2084
|
// src/noted.ts
|
|
1601
2085
|
async function pictureNotes(repo, review, round, notes) {
|
|
1602
2086
|
const shown = /* @__PURE__ */ new Map();
|
|
1603
|
-
|
|
2087
|
+
let browser;
|
|
1604
2088
|
try {
|
|
1605
2089
|
for (const id of notes) {
|
|
1606
2090
|
if (allNotes(review).get(id)?.note.on?.app) continue;
|
|
1607
2091
|
const found = await whereSeen(repo, review, id, round).catch(() => void 0);
|
|
1608
|
-
|
|
2092
|
+
const picture = found && await repo.reviews.file(review.id, found.round, found.picture);
|
|
2093
|
+
if (!found || !picture) continue;
|
|
2094
|
+
browser ??= await chromium.launch();
|
|
1609
2095
|
const page = await browser.newPage({ viewport: {
|
|
1610
2096
|
width: found.width,
|
|
1611
2097
|
height: found.height
|
|
1612
2098
|
} });
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
2099
|
+
try {
|
|
2100
|
+
await page.setContent(drawn(Buffer.from(picture).toString("base64"), found));
|
|
2101
|
+
await page.locator("img").evaluate((image) => image.decode());
|
|
2102
|
+
const name = `notes/${id}.png`;
|
|
2103
|
+
await repo.reviews.saveFile(review.id, round, name, await page.screenshot());
|
|
2104
|
+
shown.set(id, repo.show(join(dirname(repo.round(review.id, round)), "files", name)));
|
|
2105
|
+
} catch {} finally {
|
|
2106
|
+
await page.close();
|
|
2107
|
+
}
|
|
1620
2108
|
}
|
|
1621
2109
|
} catch {} finally {
|
|
1622
|
-
await browser
|
|
2110
|
+
await browser?.close();
|
|
1623
2111
|
}
|
|
1624
2112
|
return shown;
|
|
1625
2113
|
}
|
|
@@ -1746,7 +2234,6 @@ const share = (fraction) => `${Math.round(fraction * 100)}%`;
|
|
|
1746
2234
|
const HELP = readFileSync(new URL("../INSTRUCTIONS.md", import.meta.url), "utf8").trimEnd();
|
|
1747
2235
|
const given = process.argv.slice(2);
|
|
1748
2236
|
const [command, review, ...words] = given;
|
|
1749
|
-
const needs = (usage) => new Refused([`That needs more: ${usage}`]);
|
|
1750
2237
|
const takes = {
|
|
1751
2238
|
start: [2, `playthrough start <review> "<title>" "<what was asked>"`],
|
|
1752
2239
|
record: [0, "playthrough record <review>"],
|
|
@@ -1758,26 +2245,18 @@ const takes = {
|
|
|
1758
2245
|
const limit = Object.hasOwn(takes, command ?? "") ? takes[command] : void 0;
|
|
1759
2246
|
try {
|
|
1760
2247
|
if (!command || command === "help" || given.some((word) => ["--help", "-h"].includes(word))) console.log(HELP);
|
|
1761
|
-
else if (limit
|
|
2248
|
+
else if (!limit) throw new Refused([`There's no command “${command}”. Run playthrough alone for what each one does.`]);
|
|
2249
|
+
else if (words.length > limit[0]) throw new Refused([`That's more than it takes: ${limit[1]}`]);
|
|
2250
|
+
else if (!review && command !== "open") throw new Refused([`That needs more: ${limit[1]}`]);
|
|
1762
2251
|
else {
|
|
1763
2252
|
await putBack(repo());
|
|
1764
|
-
if (command
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
if (!review) throw needs(takes.wait[1]);
|
|
1772
|
-
await wait(repo(), review);
|
|
1773
|
-
} else if (command === "walk") {
|
|
1774
|
-
if (!review) throw needs(takes.walk[1]);
|
|
1775
|
-
await walk(repo(), review);
|
|
1776
|
-
} else if (command === "open") await open(repo(), review);
|
|
1777
|
-
else if (command === "take-over") {
|
|
1778
|
-
if (!review) throw needs(takes["take-over"][1]);
|
|
1779
|
-
await takeOver$1(repo(), review, words[0], words[1]);
|
|
1780
|
-
} else throw new Refused([`There's no command “${command}”. Run playthrough alone for what each one does.`]);
|
|
2253
|
+
if (command !== "open" && command !== "wait") refuseWhileAside(repo());
|
|
2254
|
+
if (command === "open") await open(repo(), review);
|
|
2255
|
+
else if (command === "start") await start(repo(), review, words[0], words[1]);
|
|
2256
|
+
else if (command === "record") await record(repo(), review);
|
|
2257
|
+
else if (command === "wait") await wait(repo(), review);
|
|
2258
|
+
else if (command === "walk") await walk(repo(), review);
|
|
2259
|
+
else if (command === "take-over") await takeOver$1(repo(), review, words[0], words[1]);
|
|
1781
2260
|
}
|
|
1782
2261
|
} catch (error) {
|
|
1783
2262
|
if (!(error instanceof Refused)) throw error;
|