playthrough 0.3.0 → 0.3.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 CHANGED
@@ -20,13 +20,14 @@ THE LOOP
20
20
  name. Once the work is done, add a flow for each choice you made that the person can see and didn't ask for.
21
21
  4. playthrough record <review> records the round and compares it with every round before it. In the round's round.json,
22
22
  say why each change since the round before is there, under "accounts" by its id: { "line": "<why, in your words>" },
23
- { "note": "<the note it was made for>" }, or { "notReal": "<why nothing really changed>" }.
23
+ { "note": "<the id of the note it was made for>" }, or { "notReal": "<why nothing really changed>" }.
24
24
  5. playthrough wait <review> hands the round over and waits for the notes, which can take hours: run it in the
25
25
  background, so you're told when it ends. Each note comes with a picture of what it's on, or what the person did in
26
- the app. Change the app, record the next round, and answer each note in its round.json, under "replies". Where the
27
- answer moves, like an animation, add "watch": "<the title of the step it's seen in>". For a note made in the app,
28
- record walks the person's steps again and puts your answer where they lead: answer it in words, not with a new flow,
29
- and check your fix with playthrough walk <review>. Where their steps stop short, fix that, or say why they changed.
26
+ the app. Change the app, record the next round, and answer each note in its round.json, under "replies", in its
27
+ "words": its "on" is where Playthrough found the note's thing on this round, so leave it. Where the answer moves,
28
+ like an animation, add "watch": "<the title of the step it's seen in>". For a note made in the app, record walks the
29
+ person's steps again and puts your answer where they lead: answer it in words, not with a new flow, and check your
30
+ fix with playthrough walk <review>. Where their steps stop short, fix that, or say why they changed.
30
31
 
31
32
  The person can only judge what the flows show, so keep them true to how a person uses the app.
32
33
 
@@ -91,15 +92,16 @@ tells tests by (the screen, language and time zone are Playthrough's):
91
92
 
92
93
  export async function after({ request, setup }: { request: APIRequestContext; baseURL: string; setup: Record<string, any> }) {
93
94
  const removed = await request.delete(`/api/test/accounts/${setup.account}`);
94
- if (!removed.ok()) throw new Error(`Removing the account failed: ${removed.status()}`);
95
+ if (!removed.ok() && removed.status() !== 404) throw new Error(`Removing the account failed: ${removed.status()}`);
95
96
  }
96
97
 
97
98
  Each run makes its own: what the app needs unique, like an email, of its own, and what the page shows, like a name, the
98
- same every run. Check what each request answers, so one that fails says so. Avoid what can't be given back, like an
99
- email sent: test keys, a mail catcher. Use what the project has (a test account, seed data, its own fixtures), make data
100
- through the API or database rather than on screen, and stub what you can't control at the network. Sign in with
101
- page.request, not request: it shares the page's cookies, so a person who takes the flow over is signed in too. Dates
102
- the server works out don't follow the page's clock: fix them through the app's own test hook if it has one.
99
+ same every run. Check what each request answers, so one that fails says so. An after that fails is tried again whole by
100
+ the next command, so it takes what's gone already as given back. Avoid what can't be given back, like an email sent:
101
+ test keys, a mail catcher. Use what the project has (a test account, seed data, its own fixtures), make data through the
102
+ API or database rather than on screen, and stub what you can't control at the network. Sign in with page.request, not
103
+ request: it shares the page's cookies, so a person who takes the flow over is signed in too. Dates the server works out
104
+ don't follow the page's clock: fix them through the app's own test hook if it has one.
103
105
 
104
106
  Whenever app.json or setup.ts changes, Playthrough tries the setup by itself first, twice for each of the app's people,
105
107
  and shows a picture of where each starts. Fix what the pictures show and run the command again. It's the person's app:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playthrough",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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,4 +1,4 @@
1
- import { O as list, b as Refused, g as readingsOf, k as quote, m as glimpsesOf, p as firstLine, x as Reviews } from "./reporter-4C_7joIm.js";
1
+ import { C as Refused, M as list, N as quote, h as glimpsesOf, m as firstLine, w as Reviews, y as readingsOf } from "./reporter-BEodHYRI.js";
2
2
  import * as modules from "node:module";
3
3
  import { createRequire } from "node:module";
4
4
  import { existsSync, watch } from "node:fs";
@@ -268,7 +268,7 @@ async function giveBack(after, file, baseURL, setup) {
268
268
  await rm(file, { force: true });
269
269
  return true;
270
270
  } catch (error) {
271
- 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.`);
271
+ console.log(`playthrough/setup.ts's after couldn't give back what a run made: ${firstLine(String(error?.message ?? error))}. The next command tries it again, whole, so it should take what's gone already as given back.`);
272
272
  return false;
273
273
  } finally {
274
274
  giving.delete(file);
@@ -730,7 +730,7 @@ function placeIn(place, from, to) {
730
730
  };
731
731
  if (place.point) return {
732
732
  ...moment,
733
- point: place.point
733
+ point: besideOn(place.point, place.at, from, to) ?? place.point
734
734
  };
735
735
  const there = (element) => {
736
736
  const found = foundIn(from.readings, element, place.at, to.readings);
@@ -744,6 +744,36 @@ function placeIn(place, from, to) {
744
744
  ...alike?.length && { alike }
745
745
  } : moment;
746
746
  }
747
+ const HOLDING = /^(dialog|alertdialog|group|radiogroup|table|grid|treegrid|listbox|menu)$/;
748
+ function besideIn(read, at, point) {
749
+ const reading = read.reduce((best, next) => !best || Math.abs(next.at - at) < Math.abs(best.at - at) ? next : best, void 0);
750
+ if (!reading) return void 0;
751
+ const x = point.x * reading.screen.width;
752
+ const y = point.y * reading.screen.height;
753
+ const inside = (thing) => x >= thing.box.x && x <= thing.box.x + thing.box.width && y >= thing.box.y && y <= thing.box.y + thing.box.height;
754
+ const area = (thing) => thing.box.width * thing.box.height;
755
+ const room = reading.things.filter((thing) => (thing.role === "box" || HOLDING.test(thing.role) && !/^(table|grid|treegrid)$/.test(thing.role)) && inside(thing)).sort((a, b) => area(a) - area(b))[0];
756
+ const within = (thing) => {
757
+ for (let holder = thing.holder; holder !== void 0; holder = reading.things[holder]?.holder) if (reading.things[holder] === room) return true;
758
+ return false;
759
+ };
760
+ const gap = (thing) => Math.hypot(Math.max(thing.box.x - x, 0, x - thing.box.x - thing.box.width), Math.max(thing.box.y - y, 0, y - thing.box.y - thing.box.height));
761
+ const thing = reading.things.filter((thing) => thing.name && thing.role !== "box" && !HOLDING.test(thing.role) && (!room || within(thing)) && gap(thing) <= 80).sort((a, b) => gap(a) - gap(b) || area(a) - area(b))[0];
762
+ return thing && {
763
+ reading,
764
+ thing
765
+ };
766
+ }
767
+ function besideOn(point, at, from, to) {
768
+ const beside = besideIn(from.readings, at, point);
769
+ const found = beside && sameIn(beside, from.readings, to.readings);
770
+ if (!beside || !found) return void 0;
771
+ const share = (pixels, of) => Math.min(1, Math.max(0, pixels / of));
772
+ return {
773
+ x: share(point.x * beside.reading.screen.width + found.thing.box.x - beside.thing.box.x, found.reading.screen.width),
774
+ y: share(point.y * beside.reading.screen.height + found.thing.box.y - beside.thing.box.y, found.reading.screen.height)
775
+ };
776
+ }
747
777
  function foundIn(from, element, at, to) {
748
778
  const shown = shownAt(from, element, at);
749
779
  return shown && sameIn(shown, from, to);
@@ -791,11 +821,13 @@ function changesAt(then, now, unsteady = /* @__PURE__ */ new Set(), shown = {
791
821
  const others = own.filter((difference) => !isMove(difference));
792
822
  const caused = grownByWhatsInside(moment, own);
793
823
  const along = /* @__PURE__ */ new Map();
794
- const { reordered, left } = rowsReordered(moment, own.filter(isMove));
824
+ const items = itemsReordered(moment, own.filter(isMove));
825
+ const { reordered, left } = rowsReordered(moment, items.left);
795
826
  const { shifts, loose } = layoutShifts(left, carried);
796
827
  moment.shifts = shifts.map((shift) => shift.by);
797
828
  const widened = widenedTogether(others, caused, along);
798
829
  const told = [
830
+ ...items.reordered,
799
831
  ...reordered,
800
832
  ...swappedOrMoved(moment, loose),
801
833
  ...causesOfShifts(moment, shifts, widened, others, caused, along)
@@ -907,6 +939,76 @@ function grownByWhatsInside(moment, own) {
907
939
  }
908
940
  return caused;
909
941
  }
942
+ function itemsReordered(moment, moves) {
943
+ const { then, now } = moment;
944
+ const pairs = pairItems(moment);
945
+ const reordered = [];
946
+ let left = moves;
947
+ for (const [, paired] of groupBy([...pairs], ([, b]) => now.items[b].list)) {
948
+ const byThen = [...paired].sort((p, q) => p[0] - q[0]);
949
+ const byNow = [...paired].sort((p, q) => p[1] - q[1]);
950
+ const placeThen = new Map(byThen.map(([a], i) => [a, i]));
951
+ const kept = longestRising(byNow.map(([a]) => placeThen.get(a)));
952
+ const way = (a, b) => {
953
+ const went = moveOf(asThing(then.items[a], a), asThing(now.items[b], b));
954
+ return Math.abs(went.y) >= Math.abs(went.x) ? went.y > 0 ? "down" : "up" : went.x > 0 ? "right" : "left";
955
+ };
956
+ const moved = byNow.flatMap(([a, b], i) => kept.has(i) ? [] : [{
957
+ thing: asThing(now.items[b], b),
958
+ way: way(a, b),
959
+ places: Math.abs(i - placeThen.get(a))
960
+ }]);
961
+ if (!moved.length) continue;
962
+ const changed = byNow.filter(([a], i) => placeThen.get(a) !== i);
963
+ const first = now.items[byNow[0][1]];
964
+ const of = `${first.role} of ${listOf(now, first)}`;
965
+ const holder = first.holder === void 0 ? void 0 : now.things[first.holder];
966
+ if (changed.length === 2) {
967
+ const both = changed.map(([, b]) => asThing(now.items[b], b));
968
+ reordered.push({
969
+ what: "swapped",
970
+ thing: both[0],
971
+ in: now,
972
+ words: `${together(both)} have swapped places`,
973
+ key: `${of}: ${both.map((thing) => thing.words).join(" / ")}`
974
+ });
975
+ } else if (moved.length <= 2) for (const { thing, way, places } of moved) reordered.push({
976
+ what: "moved",
977
+ thing,
978
+ in: now,
979
+ words: `${called$1(thing)} moved ${way} ${places} ${places > 1 ? "places" : "place"}`,
980
+ key: `${of}: ${thing.words}`
981
+ });
982
+ else {
983
+ const order = byNow.slice(0, 3).map(([, b]) => called$1(asThing(now.items[b], b)));
984
+ const words = first.role === "row" ? `Rows in ${holder ? the(holder) : "the table"} are in a new order` : `What's in ${holder ? the(holder) : "the list"} is in a new order: ${order.join(", ")}${byNow.length > 3 ? "…" : ""}`;
985
+ reordered.push({
986
+ what: "reordered",
987
+ thing: holder ?? moved[0].thing,
988
+ in: now,
989
+ words,
990
+ key: of
991
+ });
992
+ }
993
+ const inList = new Set(paired.map(([, b]) => b));
994
+ left = left.filter((move) => !(move.now.item !== void 0 && inList.has(move.now.item) && move.then.item !== void 0 && pairs.get(move.then.item) === move.now.item));
995
+ }
996
+ return {
997
+ reordered,
998
+ left
999
+ };
1000
+ }
1001
+ function longestRising(sequence) {
1002
+ const best = sequence.map(() => 1);
1003
+ const before = sequence.map(() => -1);
1004
+ for (let i = 0; i < sequence.length; i++) for (let j = 0; j < i; j++) if (sequence[j] < sequence[i] && best[j] + 1 > best[i]) {
1005
+ best[i] = best[j] + 1;
1006
+ before[i] = j;
1007
+ }
1008
+ const kept = /* @__PURE__ */ new Set();
1009
+ for (let i = best.indexOf(Math.max(...best)); i >= 0; i = before[i]) kept.add(i);
1010
+ return kept;
1011
+ }
910
1012
  function rowsReordered(moment, moves) {
911
1013
  const reordered = [];
912
1014
  let left = moves;
@@ -1229,8 +1331,10 @@ function identityOf({ in: reading, thing, what }, moment) {
1229
1331
  if (!item) return identity(reading, thing);
1230
1332
  const kind = reading.things.filter((other) => other.item === thing.item && other.role === thing.role && other.column === thing.column);
1231
1333
  const place = `${thing.role} ${kind.indexOf(thing) + 1}${thing.column ? ` under ${thing.column}` : ""}`;
1232
- const was = what === "reworded" && moment.was.get(thing);
1233
- return `${place} in a ${item.role} of ${listOf(reading, item)}${was ? `: ${shapeOf(was.words, thing.words)}` : ""}`;
1334
+ const was = /^(reworded|moved)$/.test(what) ? moment.was.get(thing) : void 0;
1335
+ const went = was && what === "moved" && moveOf(was, thing);
1336
+ const how = went ? `, ${wayOf(near(went.x, 0) ? 0 : went.x, near(went.y, 0) ? 0 : went.y)}` : was ? `: ${shapeOf(was.words, thing.words)}` : "";
1337
+ return `${place} in a ${item.role} of ${listOf(reading, item)}${how}`;
1234
1338
  }
1235
1339
  const listOf = (reading, item) => item.holder === void 0 ? "the page" : identity(reading, reading.things[item.holder], 0);
1236
1340
  function pairItems({ then, now, was }) {
@@ -1455,9 +1559,10 @@ async function picturesAt(page, then, now, pictures) {
1455
1559
  if (patch.nudged && found.length) continue;
1456
1560
  if (patch.moved && [...changedInside].some((holder) => contains(holder.box, patch, 8))) continue;
1457
1561
  const on = whatsThere(now, patch);
1458
- const by = patch.moved && Math.max(Math.abs(patch.moved.x), Math.abs(patch.moved.y));
1459
- const words = patch.moved ? `${on.called} moved ${wayOf(patch.moved.x, patch.moved.y)}, ${by}px` : `${on.called} looks different`;
1460
- const id = idOf(on.identity ?? `place:${Math.round(patch.x / 32)},${Math.round(patch.y / 32)}`, patch.moved ? "moved" : "looks different");
1562
+ const moved = on.identity && !on.identity.startsWith("near ") ? void 0 : patch.moved;
1563
+ const by = moved && Math.max(Math.abs(moved.x), Math.abs(moved.y));
1564
+ const words = moved ? `${on.called} moved ${wayOf(moved.x, moved.y)}, ${by}px` : `${on.called} looks different`;
1565
+ const id = idOf(on.identity ?? `place:${Math.round(patch.x / 32)},${Math.round(patch.y / 32)}`, moved ? "moved" : "looks different");
1461
1566
  const same = told.get(id);
1462
1567
  told.set(id, {
1463
1568
  words,
@@ -1881,11 +1986,11 @@ async function trySetup(repo, again) {
1881
1986
  for (const { who, did, took, at, refused, picture } of tried) console.log(line(who, `${did}${did === "nothing made" ? "" : ` in ${seconds(took)}`}, then at ${at}${refused ? `, which the app answered with ${refused}` : ""}: ${repo.show(picture)}`));
1882
1987
  const missing = [];
1883
1988
  if (!setup.after && tried.some(({ made }) => made)) 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.");
1884
- else if (tried.some(({ givenBack }) => !givenBack)) missing.push("playthrough/setup.ts's after couldn't give back what it made, for the reason above.");
1885
1989
  const slowest = Math.max(...tried.map(({ took }) => took));
1886
1990
  if (slowest > SLOW) missing.push(`Setup took ${seconds(slowest)}: each flow waits that long before its first step, and so does each moment taken over.`);
1887
1991
  console.log(`
1888
1992
  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 for someone who should be signed in, or anything a flow shouldn't start from, is for playthrough/setup.ts.`);
1993
+ if (tried.some(({ givenBack }) => !givenBack)) console.log(`\nplaythrough/setup.ts's after couldn't give back what it made, for the reason above: that's for setup.ts too.`);
1889
1994
  if (missing.length) console.log(`
1890
1995
  ${missing.map((said) => `- ${said}`).join("\n")}
1891
1996
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env -S node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON
2
- import { A as time, C as conversation, D as roundInProgress, E as problemsFinishing, O as list, S as allNotes, T as notesToAnswer, _ as readingsOn, b as Refused, h as hashOf, k as quote, p as firstLine, w as lastSeen } from "./reporter-4C_7joIm.js";
3
- import { D as pausedAt, E as repo, S as giveBackLeft, T as setupIn, _ as keepRunning, a as failedAt, c as makeFlowsFolder, d as walkFork, f as whereItStopped, g as shownAt, h as placeIn, i as called, l as runFlows, m as compareWithEarlier, o as howToWriteFlows, p as trySetup, s as isFlow, t as ALONGSIDE, v as serves, y as stopApp } from "./flows-Dt7fcPLt.js";
2
+ import { A as problemsFinishing, C as Refused, D as inOrderJoined, E as conversation, M as list, N as quote, O as lastSeen, P as time, T as allNotes, _ as onPage, b as readingsOn, g as hashOf, j as roundInProgress, k as notesToAnswer, m as firstLine } from "./reporter-BEodHYRI.js";
3
+ import { D as pausedAt, E as repo, S as giveBackLeft, T as setupIn, _ as keepRunning, a as failedAt, c as makeFlowsFolder, d as walkFork, f as whereItStopped, g as shownAt, h as placeIn, i as called, l as runFlows, m as compareWithEarlier, o as howToWriteFlows, p as trySetup, s as isFlow, t as ALONGSIDE, v as serves, y as stopApp } from "./flows-BcISV1M-.js";
4
4
  import { t as read } from "./read-Bg9XVTtn.js";
5
5
  import { createRequire } from "node:module";
6
6
  import { copyFileSync, cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -239,9 +239,9 @@ function refuseIfBroken(failed, unrecorded, again) {
239
239
  }
240
240
  const cantStart = (recorded, failed) => failed.map(({ flow }) => flow).filter((flow) => !recorded.some((kept) => kept.flow.name === flow));
241
241
  function howFar(repo, id, recorded, failed, app) {
242
- const lines = failed.map(({ flow, at, why }) => {
242
+ const lines = failed.map(({ flow, at, why, on }) => {
243
243
  const because = why ? `, where Playwright says “${why}”` : "";
244
- return !recorded.some((kept) => kept.flow.name === flow) ? `${quote(flow)} can't start on ${app}${because}, so it has no Before.` : `${quote(flow)} ${at ? `stops at ${quote(at)}${because}` : "stops after its last step"}, so its Before goes as far as that.`;
244
+ return !recorded.some((kept) => kept.flow.name === flow) ? `${quote(flow)} can't start on ${app}${because}, so it has no Before.` : `${quote(flow)} ${at ? `stops at ${quote(at)}${onPage(on)}${because}` : "stops after its last step"}, so its Before goes as far as that.`;
245
245
  });
246
246
  const where = existsSync(repo.failed(id)) ? ` ${whereItStopped(repo, id, failed)}` : "";
247
247
  if (lines.length) lines.push(`If that's the app as it was, that's Before. If it's the flow's own mistake, fix the flow and run playthrough start ${id} again: it records a flow changed since on Before again.${where}`);
@@ -1531,13 +1531,15 @@ async function record(repo, id) {
1531
1531
  ]);
1532
1532
  await recordOnBefore(repo, id, number, recorded.map(({ flow }) => flow.name));
1533
1533
  const saved = review.rounds.find((round) => round.number === number);
1534
+ const joined = inOrderJoined(review);
1535
+ const place = (name) => joined.includes(name) ? joined.indexOf(name) : joined.length;
1534
1536
  const flows = recorded.map(({ flow }) => {
1535
1537
  const line = saved?.flows.find((kept) => kept.name === flow.name)?.line;
1536
1538
  return line ? {
1537
1539
  ...flow,
1538
1540
  line
1539
1541
  } : flow;
1540
- });
1542
+ }).sort((a, b) => place(a.name) - place(b.name));
1541
1543
  for (const { files } of recorded) for (const { name, bytes } of files) await repo.reviews.saveFile(id, number, name, bytes);
1542
1544
  const written = saved?.replies ?? [];
1543
1545
  const waiting = notesToAnswer(review, number).filter((note) => !written.some((reply) => reply.note === note)).map((note) => ({
@@ -1567,7 +1569,7 @@ async function record(repo, id) {
1567
1569
  if (since && !since.changes.length && replies.some((reply) => reply.on && !reply.on.app && !reply.watch)) console.log(`Nothing at rest shows an answer that moves, like an animation: if yours does, add "watch": "<the title of the step it's seen in>" to its reply in ${file}, and the note plays that step.`);
1568
1570
  if (walked.length) console.log(`\n${walked.map(saidOf).join("\n")}`);
1569
1571
  if (left.length) console.log(`\nStill to do, in ${file}:\n${left.map((problem) => ` ${problem}`).join("\n")}`);
1570
- if (number === 1 && !saved && since?.changes.length) console.log(`\nSay why each change is there in “accounts”, by its id: { "line": "<why, in your words>" }, { "note": "<the note it was made for>" }, or { "notReal": "<why nothing really changed>" }.`);
1572
+ if (number === 1 && !saved && since?.changes.length) console.log(`\nSay why each change is there in “accounts”, by its id: { "line": "<why, in your words>" }, { "note": "<the id of the note it was made for>" }, or { "notReal": "<why nothing really changed>" }.`);
1571
1573
  if (unsaid.length) {
1572
1574
  const [are, each] = unsaid.length > 1 ? ["are", "each"] : ["is", "it"];
1573
1575
  console.log(`\n${list(unsaid.map((flow) => quote(flow.name)))} ${are} new to the person. In ${file}, ${each} can have a line at its start, as “line” in its flow under “flows”: what it starts from, or the choice it shows, in a sentence of up to 140 characters.`);
@@ -1,4 +1,4 @@
1
- import { u as waitsFor } from "./flows-Dt7fcPLt.js";
1
+ import { u as waitsFor } from "./flows-BcISV1M-.js";
2
2
  import { availableParallelism } from "node:os";
3
3
  import { defineConfig } from "@playwright/test";
4
4
  import { fileURLToPath } from "node:url";
@@ -84,7 +84,7 @@ const Comparison = z.strictObject({
84
84
  });
85
85
  const Account = z.union([
86
86
  z.strictObject({ line: text("needs words in its line") }).describe("Why it's there, in the agent's voice: what it meant or chose"),
87
- z.strictObject({ note: text("needs the note it was made for") }).describe("Made for that note: the reply on the note says why"),
87
+ z.strictObject({ note: text("needs the id of the note it was made for") }).describe("Made for that note: the reply on the note says why"),
88
88
  z.strictObject({ notReal: text("needs to say why nothing really changed") }).describe("Nothing really changed; nobody is shown it")
89
89
  ], { error: "says one of { line }, { note } or { notReal }" });
90
90
  const Reply = z.strictObject({
@@ -153,6 +153,9 @@ function roundInProgress(review) {
153
153
  if (!last) return 0;
154
154
  return last.finished ? last.number + 1 : last.number;
155
155
  }
156
+ function inOrderJoined(review) {
157
+ return [...new Set(review.rounds.flatMap((kept) => [...kept.flows.map((flow) => flow.name), ...kept.cantStart ?? []]))];
158
+ }
156
159
  function allNotes(review) {
157
160
  const notes = /* @__PURE__ */ new Map();
158
161
  for (const send of review.sends) for (const note of send.notes) notes.set(note.id, {
@@ -335,7 +338,7 @@ function problemsFinishing(review, number) {
335
338
  const comparisons = review.comparisons.filter((comparison) => comparison.round === number);
336
339
  if (comparisons.length) for (const earlier of review.rounds.filter((earlier) => earlier.number < number && !comparisons.some((comparison) => comparison.since === earlier.number))) problems.push(`${at} isn't compared with ${roundName(earlier.number)} yet, so it can't be handed over. Record it again.`);
337
340
  const changes = new Map(comparisons.flatMap((comparison) => comparison.changes.map((change) => [change.id, change])));
338
- for (const change of comparisons.find((comparison) => comparison.since === number - 1)?.changes ?? []) if (!round.accounts[change.id]) problems.push(`${at}: say why ${change.id} is there (${change.words}${where(change)}): its line, the note it was made for, or why nothing really changed.`);
341
+ for (const change of comparisons.find((comparison) => comparison.since === number - 1)?.changes ?? []) if (!round.accounts[change.id]) problems.push(`${at}: say why ${change.id} is there (${change.words}${where(change)}): its line, the id of the note it was made for, or why nothing really changed.`);
339
342
  for (const [id, account] of Object.entries(round.accounts)) {
340
343
  const change = changes.get(id);
341
344
  if (!change) problems.push(`${at} says something about the change ${id}, which isn't one of this round's changes.`);
@@ -731,6 +734,7 @@ const HANDS = "playthrough hands";
731
734
  const ACTIONS = "playthrough actions";
732
735
  const WROTE = "playthrough wrote";
733
736
  const UNMET = "playthrough unmet";
737
+ const STOPPED_ON = "playthrough stopped on";
734
738
  const HOLD = 1500;
735
739
  const readingsOf = (flow) => `readings/${flow.recording}`;
736
740
  async function readingsOn(reviews, review, round, flow) {
@@ -748,6 +752,11 @@ function firstLine(said) {
748
752
  if (found.length) line = `${line} ${found.slice(0, 2).join(", ")}${found.length > 2 ? ", …" : ""}`;
749
753
  return line.length > 300 ? `${line.slice(0, 300)}…` : line;
750
754
  }
755
+ const pathOf = (address) => {
756
+ const { pathname, search } = new URL(address);
757
+ return pathname + search;
758
+ };
759
+ const onPage = (on) => on ? `, on ${on}` : "";
751
760
  const BROKEN = /Page crashed|Target crashed|Target page, context or browser has been closed|net::ERR_CONNECTION_REFUSED/;
752
761
  const hashOf = (file) => {
753
762
  try {
@@ -809,11 +818,13 @@ var Flows = class {
809
818
  const said = (stop?.error ?? result.error)?.message ?? "";
810
819
  const broken = BROKEN.test(said);
811
820
  const why = firstLine(said);
821
+ const on = result.attachments.find((attachment) => attachment.name === STOPPED_ON)?.body?.toString();
812
822
  const failed = {
813
823
  flow: test.title,
814
824
  ...stop?.error && { at: stop.title },
815
825
  ...why && { why },
816
- ...broken && { broken }
826
+ ...broken && { broken },
827
+ ...stop?.error && on && { on }
817
828
  };
818
829
  if (!this.#before || broken || !recording || !stop || !kept.length) {
819
830
  this.#ran.push({
@@ -830,7 +841,7 @@ var Flows = class {
830
841
  failed,
831
842
  recorded
832
843
  });
833
- went(`${stop.error ? `stops at ${quote(stop.title)}` : "stops after its last step"}${yet}`);
844
+ went(`${stop.error ? `stops at ${quote(stop.title)}${onPage(failed.on)}` : "stops after its last step"}${yet}`);
834
845
  }
835
846
  onError(error) {
836
847
  console.log(`\n${error.message ?? error.value}`);
@@ -916,4 +927,4 @@ var Flows = class {
916
927
  }
917
928
  };
918
929
  const seconds = (ms) => Math.round(Math.max(0, ms)) / 1e3;
919
- export { time as A, conversation as C, roundInProgress as D, problemsFinishing as E, list as O, allNotes as S, notesToAnswer as T, readingsOn as _, HANDS as a, Refused as b, PICTURE as c, UNMET as d, WROTE as f, readingsOf as g, hashOf as h, GLIMPSES as i, quote as k, READINGS as l, glimpsesOf as m, ASSETS as n, HOLD as o, firstLine as p, Flows as r, PERSON as s, ACTIONS as t, RECORDING as u, seconds as v, lastSeen as w, Reviews as x, keepAssets as y };
930
+ export { problemsFinishing as A, Refused as C, inOrderJoined as D, conversation as E, list as M, quote as N, lastSeen as O, time as P, keepAssets as S, allNotes as T, onPage as _, HANDS as a, readingsOn as b, PICTURE as c, STOPPED_ON as d, UNMET as f, hashOf as g, glimpsesOf as h, GLIMPSES as i, roundInProgress as j, notesToAnswer as k, READINGS as l, firstLine as m, ASSETS as n, HOLD as o, WROTE as p, Flows as r, PERSON as s, ACTIONS as t, RECORDING as u, pathOf as v, Reviews as w, seconds as x, readingsOf as y };
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, d as UNMET, f as WROTE, r as default, p as firstLine, m as glimpsesOf, h as hashOf, g as readingsOf, _ as readingsOn, v as seconds } from "./reporter-4C_7joIm.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, d as STOPPED_ON, f as UNMET, p as WROTE, r as default, m as firstLine, h as glimpsesOf, g as hashOf, _ as onPage, v as pathOf, y as readingsOf, b as readingsOn, x as seconds } from "./reporter-BEodHYRI.js";
package/src/test.js CHANGED
@@ -1,5 +1,5 @@
1
- import { a as HANDS, c as PICTURE, d as UNMET, f as WROTE, i as GLIMPSES, l as READINGS, n as ASSETS, o as HOLD, p as firstLine, s as PERSON, t as ACTIONS, u as RECORDING, v as seconds, y as keepAssets } from "./reporter-4C_7joIm.js";
2
- import { C as make, E as repo, T as setupIn, b as changes, n as REACHES_OUTSIDE, r as WALKED, t as ALONGSIDE, w as personIn, x as firstOf } from "./flows-Dt7fcPLt.js";
1
+ import { S as keepAssets, a as HANDS, c as PICTURE, d as STOPPED_ON, f as UNMET, i as GLIMPSES, l as READINGS, m as firstLine, n as ASSETS, o as HOLD, p as WROTE, s as PERSON, t as ACTIONS, u as RECORDING, v as pathOf, x as seconds } from "./reporter-BEodHYRI.js";
2
+ import { C as make, E as repo, T as setupIn, b as changes, n as REACHES_OUTSIDE, r as WALKED, t as ALONGSIDE, w as personIn, x as firstOf } from "./flows-BcISV1M-.js";
3
3
  import { n as readPage, r as unhovered } from "./read-Bg9XVTtn.js";
4
4
  import { readFileSync, writeFileSync } from "node:fs";
5
5
  import { join } from "node:path";
@@ -264,6 +264,10 @@ const recorded = test$1.extend({
264
264
  body: String(flow.unmet),
265
265
  contentType: "text/plain"
266
266
  });
267
+ if (!ended && last.url() !== "about:blank") await testInfo.attach(STOPPED_ON, {
268
+ body: pathOf(last.url()),
269
+ contentType: "text/plain"
270
+ });
267
271
  for (const picture of pictures) await testInfo.attach(PICTURE, {
268
272
  body: picture,
269
273
  contentType: "image/png"
@@ -11,6 +11,7 @@ export declare const HANDS = "playthrough hands";
11
11
  export declare const ACTIONS = "playthrough actions";
12
12
  export declare const WROTE = "playthrough wrote";
13
13
  export declare const UNMET = "playthrough unmet";
14
+ export declare const STOPPED_ON = "playthrough stopped on";
14
15
  export declare const HOLD = 1500;
15
16
  export declare const readingsOf: (flow: Flow) => string;
16
17
  export declare function readingsOn(reviews: Reviews, review: string, round: number, flow: Flow): Promise<Reading[]>;
@@ -21,7 +22,10 @@ export type Failed = {
21
22
  at?: string;
22
23
  why?: string;
23
24
  broken?: true;
25
+ on?: string;
24
26
  };
27
+ export declare const pathOf: (address: string) => string;
28
+ export declare const onPage: (on?: string) => string;
25
29
  export type Recorded = {
26
30
  flow: Flow;
27
31
  files: string[];
@@ -1,5 +1,5 @@
1
- import { type Element, type Flow, type Place } from "../../review/src/index.js";
2
- import type { Reading, Thing } from "./reading.js";
1
+ import { type Element, type Flow, type Place, type Point } from "../../review/src/index.js";
2
+ import type { Glimpse, Reading, Thing } from "./reading.js";
3
3
  export type Recorded = {
4
4
  flow: Flow;
5
5
  readings: Reading[];
@@ -9,5 +9,7 @@ export type Shown = {
9
9
  thing: Thing;
10
10
  };
11
11
  export declare function placeIn(place: Place, from: Recorded, to: Recorded): Place;
12
+ export declare const HOLDING: RegExp;
13
+ export declare const nearTo: (read: Glimpse[], at: number, point: Point) => Thing | undefined;
12
14
  export declare function shownAt(readings: Reading[], element: Element, at: number): Shown | undefined;
13
15
  export declare const elementOf: (thing: Thing, name: string) => Element;
@@ -1,5 +1,6 @@
1
1
  import { Comparison, Round, Send, type Account, type Flow, type Note, type Place, type Review, type Verdict } from "./model.js";
2
2
  export declare function roundInProgress(review: Review): number;
3
+ export declare function inOrderJoined(review: Review): string[];
3
4
  export declare function allNotes(review: Review): Map<string, {
4
5
  note: Note;
5
6
  round: number;