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 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. What a run made and didn't give back, because
99
- Playthrough was stopped, the next command gives back. What can't be taken back, like an email sent, avoid instead: test
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playthrough",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Review an AI agent's work on a web app by watching recordings of it.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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 giveBackOwn, h as giveBackLeft, i as ALONGSIDE, j as roundInProgress, k as notesToAnswer, l as isFlow, p as whereItStopped, s as failedAt, t as read, u as makeFlowsFolder, w as Refused, x as compareWithEarlier, y as setupIn } from "./read-BBNVC-oH.js";
3
- import { d as firstLine, m as readingsOn } from "./reporter-Biym3TnN.js";
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, spawn } from "node:child_process";
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-Biym3TnN.js";
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(file, "utf8").catch(() => void 0);
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$1 = await request.newContext({ baseURL });
2190
+ const request$2 = await request.newContext({ baseURL });
2189
2191
  try {
2190
2192
  await after({
2191
- request: request$1,
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$1.dispose();
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${setupMissing ? ", which this app doesn't have yet" : ""}: run playthrough alone for how.`;
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, giveBackOwn as g, giveBackLeft as h, ALONGSIDE as i, roundInProgress as j, notesToAnswer as k, isFlow as l, firstOf 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 };
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
- const firstLine = (said) => said.replace(/\x1b\[[0-9;]*m/g, "").split("\n")[0]?.trim();
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-Biym3TnN.js";
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, i as ALONGSIDE, m as firstOf, n as readPage, o as WALKED, r as unhovered, v as personIn, y as setupIn } from "./read-BBNVC-oH.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-Biym3TnN.js";
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";
@@ -0,0 +1,2 @@
1
+ import { type App } from "./repo.js";
2
+ export declare function keepRunning(app: App, root: string): Promise<number | undefined>;
@@ -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 const firstLine: (said: string) => string;
16
+ export declare function firstLine(said: string): string;
17
17
  export type Failed = {
18
18
  flow: string;
19
19
  at?: string;
@@ -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<void>;
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;
@@ -0,0 +1,2 @@
1
+ import type { Repo } from "./repo.js";
2
+ export declare function trySetup(repo: Repo, again: string): Promise<void>;