playthrough 0.1.0 → 0.2.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 +12 -4
- package/package.json +1 -1
- package/src/playthrough.js +3 -39
- package/src/{read-BBNVC-oH.js → read-C9bKWxxP.js} +173 -18
- package/src/{reporter-Biym3TnN.js → reporter-CECNk7aW.js} +4 -1
- package/src/reporter.js +1 -1
- package/src/test.js +2 -2
- package/types/cli/src/app.d.ts +2 -0
- package/types/cli/src/repo.d.ts +2 -0
- package/types/cli/src/reporter.d.ts +1 -1
- package/types/cli/src/setup.d.ts +1 -1
- package/types/cli/src/try-setup.d.ts +2 -0
package/INSTRUCTIONS.md
CHANGED
|
@@ -90,14 +90,15 @@ page, Playwright's request and the app's address as baseURL, and what it returns
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
export async function after({ request, setup }: { request: APIRequestContext; baseURL: string; setup: Record<string, any> }) {
|
|
93
|
-
await request.delete(`/api/test/accounts/${setup.account}`);
|
|
93
|
+
const removed = await request.delete(`/api/test/accounts/${setup.account}`);
|
|
94
|
+
if (!removed.ok()) throw new Error(`Removing the account failed: ${removed.status()}`);
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
Each run makes its own, so give it back: after, if setup.ts exports it, runs once the run is over (a flow recorded, the
|
|
97
98
|
app taken over and let go), given a request of its own and what the default export returned, so return what's needed to
|
|
98
|
-
find and remove it, and sign in again there if removing it needs that.
|
|
99
|
-
|
|
100
|
-
keys, a mail catcher.
|
|
99
|
+
find and remove it, and sign in again there if removing it needs that. Check what each request answers, so one that
|
|
100
|
+
fails says so rather than leaving it behind. What a run made and didn't give back, because Playthrough was stopped, the
|
|
101
|
+
next command gives back. What can't be taken back, like an email sent, avoid instead: test keys, a mail catcher.
|
|
101
102
|
|
|
102
103
|
Use what the project has (a test account, seed data, a reset hook, its own test fixtures, imported rather than copied),
|
|
103
104
|
make data through the app's API or database rather than on screen, and stub what you can't control at the network. Sign
|
|
@@ -105,6 +106,13 @@ in with page.request, not request: it shares the page's cookies, so the page is
|
|
|
105
106
|
the flow over. Dates the server works out don't follow the page's clock: fix them through the app's own test hook if it
|
|
106
107
|
has one.
|
|
107
108
|
|
|
109
|
+
The first time flows run in an app, and whenever app.json or setup.ts changes, Playthrough tries them first, and records
|
|
110
|
+
nothing yet: it starts the app, makes what a flow starts from for each of the app's people twice, giving it back each
|
|
111
|
+
time, and says what it found, with a picture of where each starts. Look at the pictures, fix what they show, and run the
|
|
112
|
+
command again. It's the person's app, so tell them how flows get into it and what's missing, and ask before you change
|
|
113
|
+
it for Playthrough's sake, as with a route only for tests, or when only they have what's needed, like a test account;
|
|
114
|
+
the review doesn't wait for their answer.
|
|
115
|
+
|
|
108
116
|
Each new flow is recorded twice. What changes by itself between the two, like a time the server stamps, is left out of
|
|
109
117
|
every comparison, in every flow, and Playthrough says what it is: steady it in playthrough/setup.ts if you can, because a real
|
|
110
118
|
change to it won't be seen.
|
package/package.json
CHANGED
package/src/playthrough.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env -S node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON
|
|
2
|
-
import { A as problemsFinishing, C as shownAt, D as conversation, E as allNotes, M as list, N as quote, O as lastSeen, P as time, S as placeIn, T as pausedAt, b as repo, c as howToWriteFlows, d as runFlows, f as walkFork, g as
|
|
3
|
-
import { d as firstLine, m as readingsOn } from "./reporter-
|
|
2
|
+
import { A as problemsFinishing, C as shownAt, D as conversation, E as allNotes, M as list, N as quote, O as lastSeen, P as time, S as placeIn, T as pausedAt, b as repo, c as howToWriteFlows, d as runFlows, f as walkFork, g as giveBackLeft, i as ALONGSIDE, j as roundInProgress, k as notesToAnswer, l as isFlow, m as keepRunning, p as whereItStopped, s as failedAt, t as read, u as makeFlowsFolder, w as Refused, x as compareWithEarlier, y as setupIn } from "./read-C9bKWxxP.js";
|
|
3
|
+
import { d as firstLine, m as readingsOn } from "./reporter-CECNk7aW.js";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
-
import { execFileSync
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
7
|
import { constants, networkInterfaces, tmpdir, userInfo } from "node:os";
|
|
8
8
|
import { dirname, extname, join, normalize, resolve, sep } from "node:path";
|
|
9
9
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
@@ -575,42 +575,6 @@ async function recorded(repo, id) {
|
|
|
575
575
|
if (!round) throw new Refused([`${id} has nothing recorded yet, so there's nothing to take over. Record it first: playthrough record ${id}`]);
|
|
576
576
|
return round;
|
|
577
577
|
}
|
|
578
|
-
let started;
|
|
579
|
-
let ending = false;
|
|
580
|
-
async function keepRunning(app, root) {
|
|
581
|
-
if (!ending) {
|
|
582
|
-
ending = true;
|
|
583
|
-
const { setup, borrowed } = repo(root);
|
|
584
|
-
const giveBack = () => setupIn(setup).then((setup) => giveBackOwn(setup, borrowed));
|
|
585
|
-
const end = (signal) => void Promise.race([giveBack(), new Promise((wait) => setTimeout(wait, 1e4))]).finally(() => process.exit(128 + constants.signals[signal]));
|
|
586
|
-
for (const signal of [
|
|
587
|
-
"SIGINT",
|
|
588
|
-
"SIGTERM",
|
|
589
|
-
"SIGHUP"
|
|
590
|
-
]) process.once(signal, end);
|
|
591
|
-
}
|
|
592
|
-
if (await answers(app.url)) return;
|
|
593
|
-
console.log(`Starting the app: ${app.start}`);
|
|
594
|
-
started = spawn(app.start, {
|
|
595
|
-
cwd: root,
|
|
596
|
-
shell: true,
|
|
597
|
-
stdio: "ignore",
|
|
598
|
-
detached: true
|
|
599
|
-
});
|
|
600
|
-
const stop = () => {
|
|
601
|
-
try {
|
|
602
|
-
process.kill(-started.pid);
|
|
603
|
-
} catch {}
|
|
604
|
-
};
|
|
605
|
-
process.once("exit", stop);
|
|
606
|
-
for (let waited = 0; waited < 180; waited++) {
|
|
607
|
-
if (await answers(app.url)) return;
|
|
608
|
-
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
609
|
-
}
|
|
610
|
-
stop();
|
|
611
|
-
throw new Refused([`The app didn't answer at ${app.url} within three minutes of ${quote(app.start)}. Check that playthrough/app.json starts it there.`]);
|
|
612
|
-
}
|
|
613
|
-
const answers = (url) => fetch(url, { signal: AbortSignal.timeout(2e3) }).then(() => true, () => false);
|
|
614
578
|
function passOn(app, handed) {
|
|
615
579
|
const target = new URL(app.url);
|
|
616
580
|
const server = createServer((request, response) => {
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import { d as firstLine, f as glimpsesOf, p as readingsOf } from "./reporter-
|
|
1
|
+
import { d as firstLine, f as glimpsesOf, p as readingsOf } from "./reporter-CECNk7aW.js";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { existsSync, watch } from "node:fs";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
-
import { tmpdir } from "node:os";
|
|
6
|
+
import { constants, tmpdir } from "node:os";
|
|
7
7
|
import { dirname, join, relative } from "node:path";
|
|
8
|
-
import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
8
|
+
import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
9
9
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
10
10
|
import { chromium, request } from "@playwright/test";
|
|
11
11
|
import { createHash, randomUUID } from "node:crypto";
|
|
12
12
|
// ../review/src/model.ts
|
|
13
13
|
const text = (error) => z.string({ error }).refine((value) => value.trim() !== "", { error });
|
|
14
|
-
const seconds = (error) => z.number({ error }).min(0, { error });
|
|
14
|
+
const seconds$1 = (error) => z.number({ error }).min(0, { error });
|
|
15
15
|
const share = (error) => z.number({ error }).min(0, { error }).max(1, { error });
|
|
16
16
|
const fileName = (error) => z.string({ error }).regex(/^[\w.-]+(\/[\w.-]+)*$/, { error }).refine((name) => !name.split("/").some((part) => part === "." || part === ".."), { error });
|
|
17
17
|
const Element$1 = z.strictObject({
|
|
@@ -43,8 +43,8 @@ const Did = z.strictObject({
|
|
|
43
43
|
});
|
|
44
44
|
const Place = z.strictObject({
|
|
45
45
|
flow: text("needs the flow it's on"),
|
|
46
|
-
at: seconds("needs its moment, in seconds").optional().describe("Without it, the whole flow"),
|
|
47
|
-
until: seconds("needs its end, in seconds").optional().describe("The end of a stretch"),
|
|
46
|
+
at: seconds$1("needs its moment, in seconds").optional().describe("Without it, the whole flow"),
|
|
47
|
+
until: seconds$1("needs its end, in seconds").optional().describe("The end of a stretch"),
|
|
48
48
|
element: Element$1.optional(),
|
|
49
49
|
alike: z.array(Element$1).optional().describe("The other things changed alike with its element, each where it is"),
|
|
50
50
|
box: Box.optional().describe("Something with no words of its own, or where only the pixels changed"),
|
|
@@ -52,7 +52,7 @@ const Place = z.strictObject({
|
|
|
52
52
|
app: z.strictObject({
|
|
53
53
|
from: z.strictObject({
|
|
54
54
|
flow: text("needs the flow it was taken over in"),
|
|
55
|
-
at: seconds("needs the moment it was taken over at, in seconds")
|
|
55
|
+
at: seconds$1("needs the moment it was taken over at, in seconds")
|
|
56
56
|
}),
|
|
57
57
|
did: z.array(Did).describe("What the person did there, in order"),
|
|
58
58
|
recording: fileName("needs its recording: the name of a file in the round").describe("The flow up to where it was taken over, then the person's own steps"),
|
|
@@ -65,8 +65,8 @@ const Place = z.strictObject({
|
|
|
65
65
|
].filter((thing) => thing).length <= 1, { error: "is on more than one thing. Choose an element, a box or a point" }).refine((place) => place.at !== void 0 || !(place.element || place.box || place.point), { error: "is on something but at no moment. Give it one" }).refine((place) => place.until === void 0 || place.at !== void 0 && place.until > place.at, { error: "ends before it starts. A stretch ends after its moment" });
|
|
66
66
|
const Step = z.strictObject({
|
|
67
67
|
title: text("needs a title").describe("Steps are matched across rounds by their title"),
|
|
68
|
-
at: seconds("needs the moment it starts, in seconds"),
|
|
69
|
-
actions: z.array(seconds("needs the moment each of its actions was done, in seconds")).optional().describe("When each of its actions was done: between them the page rests, and a person can take the flow over there"),
|
|
68
|
+
at: seconds$1("needs the moment it starts, in seconds"),
|
|
69
|
+
actions: z.array(seconds$1("needs the moment each of its actions was done, in seconds")).optional().describe("When each of its actions was done: between them the page rests, and a person can take the flow over there"),
|
|
70
70
|
person: text("needs the name of who does it, or no person").optional().describe("Who does it, once the flow has changed hands: someone other than who the flow is done as")
|
|
71
71
|
});
|
|
72
72
|
const Flow = z.strictObject({
|
|
@@ -2102,6 +2102,7 @@ const isMissing = (error) => error.code === "ENOENT";
|
|
|
2102
2102
|
// src/repo.ts
|
|
2103
2103
|
function repo(root = process.cwd()) {
|
|
2104
2104
|
const reviews = join(root, ".playthrough");
|
|
2105
|
+
const appFile = join(root, "playthrough", "app.json");
|
|
2105
2106
|
return {
|
|
2106
2107
|
root,
|
|
2107
2108
|
reviews: new Reviews(folder(reviews)),
|
|
@@ -2109,17 +2110,18 @@ function repo(root = process.cwd()) {
|
|
|
2109
2110
|
failed: (review) => join(reviews, review, "failed"),
|
|
2110
2111
|
round: (review, round) => join(reviews, review, "rounds", String(round), "round.json"),
|
|
2111
2112
|
setAside: join(reviews, "set-aside.json"),
|
|
2113
|
+
appFile,
|
|
2112
2114
|
setup: join(root, "playthrough", "setup.ts"),
|
|
2113
2115
|
borrowed: join(reviews, "borrowed"),
|
|
2116
|
+
tried: join(reviews, "tried"),
|
|
2114
2117
|
show: (path) => relative(process.cwd(), path) || ".",
|
|
2115
2118
|
async keepOutOfGit() {
|
|
2116
2119
|
await mkdir(reviews, { recursive: true });
|
|
2117
2120
|
await writeFile(join(reviews, ".gitignore"), "*\n");
|
|
2118
2121
|
},
|
|
2119
2122
|
async app() {
|
|
2120
|
-
const file = join(root, "playthrough", "app.json");
|
|
2121
2123
|
const how = `Say how to start the app in playthrough/app.json: { "start": "<the command that serves it>", "url": "<where it's served>" }.`;
|
|
2122
|
-
const text = await readFile(
|
|
2124
|
+
const text = await readFile(appFile, "utf8").catch(() => void 0);
|
|
2123
2125
|
if (text === void 0) throw new Refused([`Playthrough needs to start the app to record it. ${how}`]);
|
|
2124
2126
|
let app;
|
|
2125
2127
|
try {
|
|
@@ -2156,7 +2158,7 @@ async function make({ make, after }, borrowed, given) {
|
|
|
2156
2158
|
const setup = await make?.(given) ?? {};
|
|
2157
2159
|
if (!after) return {
|
|
2158
2160
|
setup,
|
|
2159
|
-
giveBack: async () =>
|
|
2161
|
+
giveBack: async () => false
|
|
2160
2162
|
};
|
|
2161
2163
|
await mkdir(borrowed, { recursive: true });
|
|
2162
2164
|
const file = join(borrowed, `${process.pid}-${randomUUID()}.json`);
|
|
@@ -2183,21 +2185,23 @@ async function giveBackOf({ after }, borrowed, whose) {
|
|
|
2183
2185
|
}
|
|
2184
2186
|
const giving = /* @__PURE__ */ new Set();
|
|
2185
2187
|
async function giveBack(after, file, baseURL, setup) {
|
|
2186
|
-
if (giving.has(file) || !existsSync(file)) return;
|
|
2188
|
+
if (giving.has(file) || !existsSync(file)) return true;
|
|
2187
2189
|
giving.add(file);
|
|
2188
|
-
const request$
|
|
2190
|
+
const request$2 = await request.newContext({ baseURL });
|
|
2189
2191
|
try {
|
|
2190
2192
|
await after({
|
|
2191
|
-
request: request$
|
|
2193
|
+
request: request$2,
|
|
2192
2194
|
baseURL,
|
|
2193
2195
|
setup
|
|
2194
2196
|
});
|
|
2195
2197
|
await rm(file, { force: true });
|
|
2198
|
+
return true;
|
|
2196
2199
|
} catch (error) {
|
|
2197
2200
|
console.log(`playthrough/setup.ts's after couldn't give back what a run made: ${firstLine(String(error?.message ?? error))}. It's tried again by the next command.`);
|
|
2201
|
+
return false;
|
|
2198
2202
|
} finally {
|
|
2199
2203
|
giving.delete(file);
|
|
2200
|
-
await request$
|
|
2204
|
+
await request$2.dispose();
|
|
2201
2205
|
}
|
|
2202
2206
|
}
|
|
2203
2207
|
function alive(pid) {
|
|
@@ -2208,6 +2212,155 @@ function alive(pid) {
|
|
|
2208
2212
|
return error.code === "EPERM";
|
|
2209
2213
|
}
|
|
2210
2214
|
}
|
|
2215
|
+
// src/app.ts
|
|
2216
|
+
let started;
|
|
2217
|
+
let ending = false;
|
|
2218
|
+
async function keepRunning(app, root) {
|
|
2219
|
+
if (!ending) {
|
|
2220
|
+
ending = true;
|
|
2221
|
+
const { setup, borrowed } = repo(root);
|
|
2222
|
+
const giveBack = () => setupIn(setup).then((setup) => giveBackOwn(setup, borrowed));
|
|
2223
|
+
const end = (signal) => void Promise.race([giveBack(), new Promise((wait) => setTimeout(wait, 1e4))]).finally(() => process.exit(128 + constants.signals[signal]));
|
|
2224
|
+
for (const signal of [
|
|
2225
|
+
"SIGINT",
|
|
2226
|
+
"SIGTERM",
|
|
2227
|
+
"SIGHUP"
|
|
2228
|
+
]) process.once(signal, end);
|
|
2229
|
+
}
|
|
2230
|
+
if (await answers(app.url)) return;
|
|
2231
|
+
console.log(`Starting the app: ${app.start}`);
|
|
2232
|
+
const began = Date.now();
|
|
2233
|
+
started = spawn(app.start, {
|
|
2234
|
+
cwd: root,
|
|
2235
|
+
shell: true,
|
|
2236
|
+
stdio: "ignore",
|
|
2237
|
+
detached: true
|
|
2238
|
+
});
|
|
2239
|
+
started.unref();
|
|
2240
|
+
let ended = false;
|
|
2241
|
+
started.once("exit", () => ended = true);
|
|
2242
|
+
const stop = () => {
|
|
2243
|
+
try {
|
|
2244
|
+
process.kill(-started.pid);
|
|
2245
|
+
} catch {}
|
|
2246
|
+
};
|
|
2247
|
+
process.once("exit", stop);
|
|
2248
|
+
while (Date.now() - began < 18e4) {
|
|
2249
|
+
if (await answers(app.url)) return Date.now() - began;
|
|
2250
|
+
if (ended) throw new Refused([`${quote(app.start)} ended before the app answered at ${app.url}. Run it yourself to see why, and check that playthrough/app.json starts the app there.`]);
|
|
2251
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
2252
|
+
}
|
|
2253
|
+
stop();
|
|
2254
|
+
throw new Refused([`The app didn't answer at ${app.url} within three minutes of ${quote(app.start)}. Check that playthrough/app.json starts it there.`]);
|
|
2255
|
+
}
|
|
2256
|
+
const answers = (url) => fetch(url, { signal: AbortSignal.timeout(2e3) }).then(() => true, () => false);
|
|
2257
|
+
// src/try-setup.ts
|
|
2258
|
+
const SLOW = 2e3;
|
|
2259
|
+
async function trySetup(repo, again) {
|
|
2260
|
+
const app = await repo.app();
|
|
2261
|
+
const written = await writtenNow(repo);
|
|
2262
|
+
const last = join(repo.tried, "tried.json");
|
|
2263
|
+
if (await readFile(last, "utf8").then((text) => JSON.parse(text).written, () => void 0) === written) return;
|
|
2264
|
+
console.log("Trying the app's setup by itself first, twice, as every flow will start…");
|
|
2265
|
+
const startedIn = await keepRunning(app, repo.root);
|
|
2266
|
+
const setup = await setupIn(repo.setup, (await stat(repo.setup).catch(() => void 0))?.mtimeMs);
|
|
2267
|
+
await giveBackLeft(setup, repo.borrowed);
|
|
2268
|
+
await rm(repo.tried, {
|
|
2269
|
+
recursive: true,
|
|
2270
|
+
force: true
|
|
2271
|
+
});
|
|
2272
|
+
await mkdir(repo.tried, { recursive: true });
|
|
2273
|
+
const browser = await chromium.launch().catch((error) => {
|
|
2274
|
+
if (/Executable doesn't exist/.test(String(error?.message))) throw new Refused(["Playwright has no Chromium to run the flows in yet. Install it (npx playwright install chromium), then run this again."]);
|
|
2275
|
+
throw error;
|
|
2276
|
+
});
|
|
2277
|
+
const people = Object.keys(setup.people ?? {});
|
|
2278
|
+
const tried = [];
|
|
2279
|
+
try {
|
|
2280
|
+
for (const person of people.length ? people : [void 0]) tried.push(await tryAs(repo, browser, setup, app.url, person));
|
|
2281
|
+
} finally {
|
|
2282
|
+
await browser.close();
|
|
2283
|
+
}
|
|
2284
|
+
const width = Math.max(7, ...tried.map(({ who }) => who.length)) + 3;
|
|
2285
|
+
const line = (label, said) => ` ${label.padEnd(width)}${said}`;
|
|
2286
|
+
console.log(`\n${line("The app", startedIn === void 0 ? `running already at ${app.url}` : `started with ${quote(app.start)}, answering at ${app.url} in ${seconds(startedIn)}`)}`);
|
|
2287
|
+
for (const { who, did, took, at, picture } of tried) console.log(line(who, `${did}${did === "nothing made" ? "" : ` in ${seconds(took)}`}, then at ${at}: ${repo.show(picture)}`));
|
|
2288
|
+
const missing = [];
|
|
2289
|
+
if (!setup.make && !setup.signIn) missing.push("There's no playthrough/setup.ts, so each flow starts on whatever the app holds, as the flow before it left it, signed in as nobody.");
|
|
2290
|
+
else if (!setup.after) missing.push("playthrough/setup.ts has no after, so what each run makes stays in the app: one for each flow recorded, two for a new one, and one each time the person pauses a recording, as it's made ready to take over.");
|
|
2291
|
+
else if (tried.some(({ givenBack }) => !givenBack)) missing.push("playthrough/setup.ts's after couldn't give back what it made, for the reason above.");
|
|
2292
|
+
const slowest = Math.max(...tried.map(({ took }) => took));
|
|
2293
|
+
if (slowest > SLOW) missing.push(`Each flow waits up to ${seconds(slowest)} for its setup before its first step, and so does each moment taken over.`);
|
|
2294
|
+
console.log(`
|
|
2295
|
+
Look at ${tried.length > 1 ? "each picture: it's where their flows start" : "the picture: it's where every flow starts"}. One that shows a sign-in page, or anything a flow shouldn't start from, is for playthrough/setup.ts.`);
|
|
2296
|
+
if (missing.length) console.log(`
|
|
2297
|
+
${missing.map((said) => `- ${said}`).join("\n")}
|
|
2298
|
+
|
|
2299
|
+
Tell the person now, in a few lines, how flows get into their app, and for each of these, what it costs them and the step that would fix it: something the app has already, or something only they can give, like a test account. Ask before you change their app for it, as with a route only for tests, and don't wait for their answer.`);
|
|
2300
|
+
else console.log(`\nTell the person in a line or two how flows get into their app.`);
|
|
2301
|
+
await writeFile(last, JSON.stringify({ written }));
|
|
2302
|
+
throw new Refused([`\nNothing's recorded yet. Once ${tried.length > 1 ? "each picture shows" : "the picture shows"} where a flow should start, run ${again} again.`]);
|
|
2303
|
+
}
|
|
2304
|
+
async function tryAs(repo, browser, setup, baseURL, person) {
|
|
2305
|
+
const tried = {
|
|
2306
|
+
who: person ? person.charAt(0).toUpperCase() + person.slice(1) : "A flow",
|
|
2307
|
+
did: setup.make && person && setup.signIn ? "made and signed in" : setup.make ? "made" : person && setup.signIn ? "signed in" : "nothing made",
|
|
2308
|
+
took: 0,
|
|
2309
|
+
at: "",
|
|
2310
|
+
picture: join(repo.tried, `${(person ?? "a flow").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "person"}.png`),
|
|
2311
|
+
givenBack: true
|
|
2312
|
+
};
|
|
2313
|
+
for (const time of ["first", "second"]) {
|
|
2314
|
+
const context = await browser.newContext({
|
|
2315
|
+
baseURL,
|
|
2316
|
+
locale: "en-US",
|
|
2317
|
+
timezoneId: "UTC",
|
|
2318
|
+
viewport: {
|
|
2319
|
+
width: 1280,
|
|
2320
|
+
height: 800
|
|
2321
|
+
}
|
|
2322
|
+
});
|
|
2323
|
+
const page = await context.newPage();
|
|
2324
|
+
const request$1 = await request.newContext({ baseURL });
|
|
2325
|
+
let made;
|
|
2326
|
+
try {
|
|
2327
|
+
const began = Date.now();
|
|
2328
|
+
try {
|
|
2329
|
+
made = await make(setup, repo.borrowed, {
|
|
2330
|
+
page,
|
|
2331
|
+
request: request$1,
|
|
2332
|
+
baseURL,
|
|
2333
|
+
person
|
|
2334
|
+
});
|
|
2335
|
+
if (person) await setup.signIn?.({
|
|
2336
|
+
page,
|
|
2337
|
+
person,
|
|
2338
|
+
setup: made.setup
|
|
2339
|
+
});
|
|
2340
|
+
} catch (error) {
|
|
2341
|
+
const why = firstLine(String(error?.message ?? error));
|
|
2342
|
+
const as = person ? ` for ${quote(person)}` : "";
|
|
2343
|
+
throw new Refused([time === "first" ? `playthrough/setup.ts couldn't make what a flow starts from${as}: ${why}. Fix it and run this again.` : `playthrough/setup.ts made what a flow starts from${as} once, but not a second time: ${why}. Each run makes its own, so make it new each time, as with a name or an email of its own, and run this again.`]);
|
|
2344
|
+
}
|
|
2345
|
+
tried.took = Math.max(tried.took, Date.now() - began);
|
|
2346
|
+
if (page.url() === "about:blank") await page.goto("/");
|
|
2347
|
+
await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {});
|
|
2348
|
+
const url = new URL(page.url());
|
|
2349
|
+
tried.at = url.origin === new URL(baseURL).origin ? `${url.pathname}${url.search}` : url.href;
|
|
2350
|
+
if (time === "second") await page.screenshot({ path: tried.picture });
|
|
2351
|
+
} finally {
|
|
2352
|
+
await context.close();
|
|
2353
|
+
await request$1.dispose();
|
|
2354
|
+
if (made && setup.after && !await made.giveBack()) tried.givenBack = false;
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
return tried;
|
|
2358
|
+
}
|
|
2359
|
+
const seconds = (ms) => ms < 100 ? "under 0.1 s" : `${(ms / 1e3).toFixed(1)} s`;
|
|
2360
|
+
async function writtenNow(repo) {
|
|
2361
|
+
const text = (path) => readFile(path, "utf8").catch(() => "");
|
|
2362
|
+
return createHash("sha256").update(await text(repo.appFile)).update("\0").update(await text(repo.setup)).digest("hex");
|
|
2363
|
+
}
|
|
2211
2364
|
// src/flows.ts
|
|
2212
2365
|
const WALKED = "walked.json";
|
|
2213
2366
|
const ALONGSIDE = Symbol.for("playthrough: flows taken over here");
|
|
@@ -2221,6 +2374,7 @@ async function makeFlowsFolder(repo, id) {
|
|
|
2221
2374
|
const isFlow = (name) => /\.[cm]?[jt]s$/.test(name);
|
|
2222
2375
|
function howToWriteFlows(repo, id) {
|
|
2223
2376
|
const setupMissing = !existsSync(repo.setup);
|
|
2377
|
+
const missing = !existsSync(repo.appFile) ? `, and how to start the app in playthrough/app.json. This app has ${setupMissing ? "neither" : "no app.json"} yet: run playthrough alone for how. Playthrough tries them before it records anything.` : setupMissing ? ", which this app doesn't have yet: run playthrough alone for how." : ".";
|
|
2224
2378
|
return `Each flow is a Playwright test in ${repo.show(repo.flows(id))}, one for each thing a person does, with a step for each part of it:
|
|
2225
2379
|
|
|
2226
2380
|
import { test, expect } from "playthrough/test";
|
|
@@ -2235,13 +2389,14 @@ function howToWriteFlows(repo, id) {
|
|
|
2235
2389
|
});
|
|
2236
2390
|
|
|
2237
2391
|
The test's name is the flow's, and steps are matched across rounds by their names. What every flow starts from, like
|
|
2238
|
-
the account it opens, is made in playthrough/setup.ts${
|
|
2392
|
+
the account it opens, is made in playthrough/setup.ts${missing}`;
|
|
2239
2393
|
}
|
|
2240
2394
|
async function runFlows(repo, id, round, { later, first }) {
|
|
2241
2395
|
const flows = repo.flows(id);
|
|
2242
2396
|
if (!(await readdir(flows).catch(() => [])).some(isFlow)) throw new Refused([`${id} has no flows yet. ${howToWriteFlows(repo, id)}`]);
|
|
2243
2397
|
await makeFlowsFolder(repo, id);
|
|
2244
2398
|
const app = await repo.app();
|
|
2399
|
+
if (!later) await trySetup(repo, round === 0 ? `playthrough start ${id}` : `playthrough record ${id}`);
|
|
2245
2400
|
const { started, rounds } = await repo.reviews.get(id);
|
|
2246
2401
|
const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
|
|
2247
2402
|
const longest = (rounds.filter((kept) => kept.number < round).at(-1)?.flows ?? []).toSorted((a, b) => b.length - a.length).map((flow) => flow.name);
|
|
@@ -2657,4 +2812,4 @@ function read() {
|
|
|
2657
2812
|
things
|
|
2658
2813
|
};
|
|
2659
2814
|
}
|
|
2660
|
-
export { problemsFinishing as A, shownAt as C, conversation as D, allNotes as E, list as M, quote as N, lastSeen as O, time as P, placeIn as S, pausedAt as T, make as _, REACHES_OUTSIDE as a, repo as b, howToWriteFlows as c, runFlows as d, walkFork as f,
|
|
2815
|
+
export { problemsFinishing as A, shownAt as C, conversation as D, allNotes as E, list as M, quote as N, lastSeen as O, time as P, placeIn as S, pausedAt as T, make as _, REACHES_OUTSIDE as a, repo as b, howToWriteFlows as c, runFlows as d, walkFork as f, giveBackLeft as g, firstOf as h, ALONGSIDE as i, roundInProgress as j, notesToAnswer as k, isFlow as l, keepRunning as m, readPage as n, WALKED as o, whereItStopped as p, unhovered as r, failedAt as s, read as t, makeFlowsFolder as u, personIn as v, Refused as w, compareWithEarlier as x, setupIn as y };
|
|
@@ -60,7 +60,10 @@ async function readingsOn(reviews, review, round, flow) {
|
|
|
60
60
|
}
|
|
61
61
|
const picturesOf = (flow) => `readings/${flow.recording.replace(/\.json$/, "")}`;
|
|
62
62
|
const glimpsesOf = (flow) => `${picturesOf(flow)}.glimpses.json`;
|
|
63
|
-
|
|
63
|
+
function firstLine(said) {
|
|
64
|
+
const line = said.replace(/\x1b\[[0-9;]*m/g, "").split("\n")[0]?.trim() ?? "";
|
|
65
|
+
return line.length > 300 ? `${line.slice(0, 300)}…` : line;
|
|
66
|
+
}
|
|
64
67
|
const BROKEN = /Page crashed|Target crashed|Target page, context or browser has been closed|net::ERR_CONNECTION_REFUSED/;
|
|
65
68
|
var Flows = class {
|
|
66
69
|
#folder;
|
package/src/reporter.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { t as ACTIONS, n as ASSETS, i as GLIMPSES, a as HANDS, o as HOLD, s as PERSON, c as PICTURE, l as READINGS, u as RECORDING, r as default, d as firstLine, f as glimpsesOf, p as readingsOf, m as readingsOn, h as seconds } from "./reporter-
|
|
1
|
+
export { t as ACTIONS, n as ASSETS, i as GLIMPSES, a as HANDS, o as HOLD, s as PERSON, c as PICTURE, l as READINGS, u as RECORDING, r as default, d as firstLine, f as glimpsesOf, p as readingsOf, m as readingsOn, h as seconds } from "./reporter-CECNk7aW.js";
|
package/src/test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { _ as make, a as REACHES_OUTSIDE, b as repo,
|
|
2
|
-
import { a as HANDS, c as PICTURE, d as firstLine, g as keepAssets, h as seconds, i as GLIMPSES, l as READINGS, n as ASSETS, o as HOLD, s as PERSON, t as ACTIONS, u as RECORDING } from "./reporter-
|
|
1
|
+
import { _ as make, a as REACHES_OUTSIDE, b as repo, h as firstOf, i as ALONGSIDE, n as readPage, o as WALKED, r as unhovered, v as personIn, y as setupIn } from "./read-C9bKWxxP.js";
|
|
2
|
+
import { a as HANDS, c as PICTURE, d as firstLine, g as keepAssets, h as seconds, i as GLIMPSES, l as READINGS, n as ASSETS, o as HOLD, s as PERSON, t as ACTIONS, u as RECORDING } from "./reporter-CECNk7aW.js";
|
|
3
3
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
package/types/cli/src/repo.d.ts
CHANGED
|
@@ -11,8 +11,10 @@ export declare function repo(root?: string): {
|
|
|
11
11
|
failed: (review: string) => string;
|
|
12
12
|
round: (review: string, round: number) => string;
|
|
13
13
|
setAside: string;
|
|
14
|
+
appFile: string;
|
|
14
15
|
setup: string;
|
|
15
16
|
borrowed: string;
|
|
17
|
+
tried: string;
|
|
16
18
|
show: (path: string) => string;
|
|
17
19
|
keepOutOfGit(): Promise<void>;
|
|
18
20
|
app(): Promise<App>;
|
|
@@ -13,7 +13,7 @@ export declare const HOLD = 1500;
|
|
|
13
13
|
export declare const readingsOf: (flow: Flow) => string;
|
|
14
14
|
export declare function readingsOn(reviews: Reviews, review: string, round: number, flow: Flow): Promise<Reading[]>;
|
|
15
15
|
export declare const glimpsesOf: (flow: Flow) => string;
|
|
16
|
-
export declare
|
|
16
|
+
export declare function firstLine(said: string): string;
|
|
17
17
|
export type Failed = {
|
|
18
18
|
flow: string;
|
|
19
19
|
at?: string;
|
package/types/cli/src/setup.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ export declare const firstOf: ({ people }: Setup) => string;
|
|
|
27
27
|
export declare function personIn({ people }: Setup, person: string): void;
|
|
28
28
|
export type Made = {
|
|
29
29
|
setup: Record<string, any>;
|
|
30
|
-
giveBack: () => Promise<
|
|
30
|
+
giveBack: () => Promise<boolean>;
|
|
31
31
|
};
|
|
32
32
|
export declare function make({ make, after }: Setup, borrowed: string, given: Parameters<Make>[0] & {
|
|
33
33
|
baseURL: string;
|