waygraph 0.16.2 → 0.16.4

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.
Files changed (123) hide show
  1. package/README.md +12 -0
  2. package/dist/analysis/practices-check.d.ts +1 -1
  3. package/dist/analysis/practices-check.js +36 -0
  4. package/dist/beta.d.ts +6 -0
  5. package/dist/beta.js +5 -0
  6. package/dist/cli/commands/codemod-flows.js +2 -2
  7. package/dist/cli/commands/codemod-stubs.js +43 -1
  8. package/dist/cli/commands/pilot.js +3 -16
  9. package/dist/cli/commands/session-ops.js +26 -1
  10. package/dist/cli/flags.d.ts +1 -1
  11. package/dist/cli/flags.js +1 -1
  12. package/dist/cli/usage.js +6 -0
  13. package/dist/demo/narration/stub-ctx.js +126 -124
  14. package/dist/demo/overlay/cursor.js +1 -1
  15. package/dist/demo/overlay/inpage/banner.js +5 -0
  16. package/dist/demo/overlay/inpage/core.js +1 -0
  17. package/dist/demo/overlay/inpage/network.js +1 -1
  18. package/dist/demo/overlay/inpage/todos.js +0 -15
  19. package/dist/demo/overlay/network-capture.js +2 -2
  20. package/dist/demo/overlay/overlay-css.js +18 -21
  21. package/dist/demo/overlay/overlay-stage.js +1 -1
  22. package/dist/demo/overlay/phase-fixtures.js +5 -5
  23. package/dist/demo/overlay/pilot/shell.js +7 -5
  24. package/dist/demo/overlay/ui/components/ring.js +2 -2
  25. package/dist/demo/overlay/ui/overlay.css +98 -35
  26. package/dist/demo/overlay/ui/shadow.js +69 -2
  27. package/dist/demo/stepper/demo-log.js +8 -8
  28. package/dist/demo/stepper/slides.d.ts +1 -1
  29. package/dist/demo/stepper/slides.js +1 -1
  30. package/dist/demo/stepper/stepper.js +1 -1
  31. package/dist/engine/block/kind.d.ts +3 -0
  32. package/dist/engine/block/kind.js +11 -0
  33. package/dist/engine/errors.d.ts +12 -0
  34. package/dist/engine/errors.js +12 -0
  35. package/dist/engine/read/read-block.d.ts +61 -0
  36. package/dist/engine/read/read-block.js +94 -0
  37. package/dist/engine/read/read-key.d.ts +32 -0
  38. package/dist/engine/read/read-key.js +34 -0
  39. package/dist/engine/read/read-page.d.ts +19 -0
  40. package/dist/engine/read/read-page.js +50 -0
  41. package/dist/engine/read/readers.d.ts +82 -0
  42. package/dist/engine/read/readers.js +43 -0
  43. package/dist/engine/read/run-read.d.ts +17 -0
  44. package/dist/engine/read/run-read.js +156 -0
  45. package/dist/engine/run/connect.js +3 -2
  46. package/dist/engine/run/events.d.ts +28 -10
  47. package/dist/engine/run/events.js +52 -38
  48. package/dist/engine/run/run.js +25 -13
  49. package/dist/engine/stub/stub.d.ts +26 -21
  50. package/dist/index.d.ts +7 -4
  51. package/dist/index.js +9 -4
  52. package/dist/live/auto/auto.js +2 -3
  53. package/dist/live/auto/menu.js +3 -2
  54. package/dist/live/auto-session/auto-session.d.ts +9 -2
  55. package/dist/live/auto-session/auto-session.js +23 -20
  56. package/dist/live/auto-session/feed.d.ts +16 -0
  57. package/dist/live/auto-session/feed.js +71 -0
  58. package/dist/live/auto-session/helpers.d.ts +7 -0
  59. package/dist/live/auto-session/helpers.js +9 -0
  60. package/dist/live/auto-session/ipc.d.ts +15 -47
  61. package/dist/live/auto-session/ipc.js +34 -4
  62. package/dist/live/auto-session/protocol.d.ts +127 -0
  63. package/dist/live/auto-session/protocol.js +6 -0
  64. package/dist/live/auto-session/read.d.ts +25 -0
  65. package/dist/live/auto-session/read.js +50 -0
  66. package/dist/live/traverse/worker.d.ts +1 -0
  67. package/dist/live/traverse/worker.js +6 -1
  68. package/dist/manifest/manifest.d.ts +22 -2
  69. package/dist/manifest/manifest.js +27 -1
  70. package/dist/manifest/project.d.ts +10 -1
  71. package/dist/manifest/project.js +9 -12
  72. package/dist/manifest/read-blocks.d.ts +2 -0
  73. package/dist/manifest/read-blocks.js +23 -0
  74. package/dist/manifest/scan.d.ts +3 -2
  75. package/dist/manifest/scan.js +8 -4
  76. package/dist/runner/runner.d.ts +0 -1
  77. package/dist/runner/runner.js +6 -6
  78. package/dist/runtime/full-launch.d.ts +2 -1
  79. package/dist/runtime/full-launch.js +29 -27
  80. package/dist/runtime/run-log.d.ts +2 -0
  81. package/dist/runtime/run-log.js +13 -6
  82. package/examples/saucedemo/README.md +2 -2
  83. package/examples/saucedemo/src/blocks/SITE-MAP.md +3 -0
  84. package/examples/saucedemo/src/blocks/saucedemo-web/cart/cart.read.block.ts +26 -0
  85. package/examples/saucedemo/src/blocks/saucedemo-web/header.read.block.ts +25 -0
  86. package/examples/saucedemo/src/blocks/saucedemo-web/inventory/inventory.read.block.ts +28 -0
  87. package/examples/saucedemo/src/flows/api-demo.flow.ts +1 -1
  88. package/examples/saucedemo/src/flows/cart-bulk.flow.ts +1 -1
  89. package/examples/saucedemo/src/flows/cart-continue.flow.ts +1 -1
  90. package/examples/saucedemo/src/flows/catalog-logout.flow.ts +1 -1
  91. package/examples/saucedemo/src/flows/checkout-then-browse-again.flow.ts +1 -1
  92. package/examples/saucedemo/src/flows/checkout.flow.ts +11 -11
  93. package/examples/saucedemo/src/flows/login.flow.ts +1 -1
  94. package/examples/saucedemo/src/flows/shop.flow.ts +1 -1
  95. package/examples/saucedemo/src/flows/stub-on-error.flow.ts +1 -1
  96. package/examples/saucedemo/src/flows/viewer-blocked.flow.ts +1 -1
  97. package/examples/saucedemo/src/states/checkout.reads.ts +9 -0
  98. package/examples/saucedemo/src/stubs/device-showcase.ts +37 -37
  99. package/examples/saucedemo/src/stubs/finish-order.ts +2 -2
  100. package/examples/saucedemo/src/stubs/login.ts +5 -5
  101. package/package.json +5 -1
  102. package/templates/quickstart/src/flows/api-demo.flow.ts +1 -1
  103. package/templates/quickstart/src/flows/cart-bulk.flow.ts +1 -1
  104. package/templates/quickstart/src/flows/cart-continue.flow.ts +1 -1
  105. package/templates/quickstart/src/flows/catalog-logout.flow.ts +1 -1
  106. package/templates/quickstart/src/flows/checkout-then-browse-again.flow.ts +1 -1
  107. package/templates/quickstart/src/flows/checkout.flow.ts +11 -11
  108. package/templates/quickstart/src/flows/login.flow.ts +1 -1
  109. package/templates/quickstart/src/flows/shop.flow.ts +1 -1
  110. package/templates/quickstart/src/flows/stub-on-error.flow.ts +1 -1
  111. package/templates/quickstart/src/flows/viewer-blocked.flow.ts +1 -1
  112. package/templates/quickstart/src/stubs/device-showcase.ts +37 -37
  113. package/templates/quickstart/src/stubs/finish-order.ts +2 -2
  114. package/templates/quickstart/src/stubs/login.ts +5 -5
  115. package/templates/scaffold/src/flows/(external)/hoppscotch/auth.flow.ts +1 -1
  116. package/templates/scaffold/src/flows/(external)/hoppscotch/get.flow.ts +1 -1
  117. package/templates/scaffold/src/flows/(external)/hoppscotch/post.flow.ts +1 -1
  118. package/templates/scaffold/src/flows/(user-stories)/email-verification.flow.ts +4 -4
  119. package/templates/scaffold/src/flows/README.md +1 -1
  120. package/templates/scaffold/src/flows/home/cart.flow.ts +7 -7
  121. package/templates/scaffold/src/flows/home/hello.flow.ts +8 -8
  122. package/templates/skills/waygraph-convention.skill.md +10 -2
  123. package/templates/skills/waygraph-pilot.skill.md +7 -0
@@ -0,0 +1,6 @@
1
+ /** Bumped when a request or response changes shape (adding an op or an optional field does not). */
2
+ export const SESSION_PROTOCOL_VERSION = 1;
3
+ /** Every op, once: the server, the CLI and `pilot` all check against this list. */
4
+ export const SESSION_OPS = ["hello", "subscribe", "status", "send", "dom", "trace", "console", "storage", "read", "upload", "click", "type", "press", "goto", "reload", "reach", "resync", "highlight"];
5
+ const complete = true;
6
+ void complete;
@@ -0,0 +1,25 @@
1
+ import type { Page } from "@playwright/test";
2
+ import type { MemPage } from "../../engine/mem/mem.js";
3
+ /** The answer to `waygraph pilot read <id> [path]`. */
4
+ export interface SessionRead {
5
+ page: string | null;
6
+ url: string;
7
+ read: Record<string, unknown>;
8
+ missing: string[];
9
+ notes: Record<string, string>;
10
+ readBlocks: string[];
11
+ skipped: {
12
+ block: string;
13
+ reason: string;
14
+ }[];
15
+ /** Set when no Read Block covers this page. */
16
+ hint?: string;
17
+ }
18
+ /** Reads the page the session is on (`here`); with `path`, only that part (`post.comments`). */
19
+ export declare function readSession(page: Page, mem: MemPage, here: string | null, path?: string): Promise<SessionRead>;
20
+ /** The read a status / send / reach snapshot carries: lists cut to a few items, nothing at all without Read Blocks. */
21
+ export declare function snapshotRead(page: Page, mem: MemPage, here: string | null): Promise<{
22
+ read?: Record<string, unknown>;
23
+ missing?: string[];
24
+ readNotes?: Record<string, string>;
25
+ }>;
@@ -0,0 +1,50 @@
1
+ import { readBlocksFor, readCheckpoint } from "../../engine/read/read-block.js";
2
+ /** A snapshot shows at most this many items of a list; `read <path>` returns the rest. */
3
+ const SNAPSHOT_LIST_CAP = 20;
4
+ function pick(tree, path) {
5
+ const parts = path.split(".");
6
+ let node = tree;
7
+ for (const part of parts)
8
+ node = node !== null && typeof node === "object" ? node[part] : undefined;
9
+ const out = {};
10
+ let at = out;
11
+ for (const part of parts.slice(0, -1))
12
+ at = at[part] = {};
13
+ at[parts[parts.length - 1]] = node ?? null;
14
+ return out;
15
+ }
16
+ /** Reads the page the session is on (`here`); with `path`, only that part (`post.comments`). */
17
+ export async function readSession(page, mem, here, path) {
18
+ const base = { page: here, url: page.url() };
19
+ if (readBlocksFor(here).length === 0) {
20
+ return { ...base, read: {}, missing: [], notes: {}, readBlocks: [], skipped: [], hint: "no Read Block for this page: use `dom` to look at it" };
21
+ }
22
+ const { read, blocks, skipped } = await readCheckpoint(page, mem, here);
23
+ const inPath = (p) => !path || p === path || p.startsWith(path + ".");
24
+ return {
25
+ ...base,
26
+ read: path ? pick(read.toJSON(), path) : read.toJSON(),
27
+ missing: read.missing.filter(inPath),
28
+ notes: Object.fromEntries(Object.entries(read.notes).filter(([p]) => inPath(p))),
29
+ readBlocks: blocks,
30
+ skipped,
31
+ };
32
+ }
33
+ /** The read a status / send / reach snapshot carries: lists cut to a few items, nothing at all without Read Blocks. */
34
+ export async function snapshotRead(page, mem, here) {
35
+ if (readBlocksFor(here).length === 0)
36
+ return {};
37
+ const got = await readSession(page, mem, here);
38
+ const notes = { ...got.notes };
39
+ const cap = (node, path) => {
40
+ if (Array.isArray(node)) {
41
+ if (node.length > SNAPSHOT_LIST_CAP)
42
+ notes[path] = `${SNAPSHOT_LIST_CAP} of ${node.length} shown: \`read <id> ${path}\` for all`;
43
+ return node.slice(0, SNAPSHOT_LIST_CAP);
44
+ }
45
+ if (node === null || typeof node !== "object")
46
+ return node;
47
+ return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, cap(v, path ? `${path}.${k}` : k)]));
48
+ };
49
+ return { read: cap(got.read, ""), missing: got.missing, ...(Object.keys(notes).length ? { readNotes: notes } : {}) };
50
+ }
@@ -34,4 +34,5 @@ export interface WorkerOpts {
34
34
  mem: MemPage;
35
35
  leases: EdgeLeaseCoordinator;
36
36
  }
37
+ /** One worker's walk, as one run of its own (parallel workers never share a run id or step counter). */
37
38
  export declare function runTraverseWorker(opts: WorkerOpts): Promise<TraverseWorkerResult>;
@@ -1,6 +1,7 @@
1
1
  import { envString } from "../../engine/env.js";
2
2
  import { buildFlow } from "../../engine/flow/flow.js";
3
3
  import { ignorePageGone } from "../../engine/page-gone.js";
4
+ import { inRun } from "../../engine/run/events.js";
4
5
  import { detectHere } from "../auto/auto.js";
5
6
  import { buildExploreMenu } from "../auto/explore.js";
6
7
  import { memStubValue } from "../auto/mem.js";
@@ -83,7 +84,11 @@ export async function runOneBlock(engine, entry, context, page, mem) {
83
84
  clearTimeout(timer);
84
85
  }
85
86
  }
86
- export async function runTraverseWorker(opts) {
87
+ /** One worker's walk, as one run of its own (parallel workers never share a run id or step counter). */
88
+ export function runTraverseWorker(opts) {
89
+ return inRun(`traverse ${opts.traverseId} worker ${opts.workerIndex}`, () => walk(opts), (r) => r.leaf);
90
+ }
91
+ async function walk(opts) {
87
92
  const { traverseId, workerIndex, parallel, maxSteps, maxVisitsPerNode, maxVisitsPerEdge, timeoutMs, graph, library, engine, context, mem, leases, } = opts;
88
93
  let page = opts.page;
89
94
  const startUrl = opts.startUrl;
@@ -1,5 +1,7 @@
1
- import type { BlockKind, ProjectScan, ScanOptions } from "./project.js";
2
- export declare const MANIFEST_VERSION = 1;
1
+ import type { BlockKind } from "../engine/block/kind.js";
2
+ import type { ProjectScan, ScanOptions } from "./project.js";
3
+ import { type ReadShape } from "../engine/read/read-block.js";
4
+ export declare const MANIFEST_VERSION = 2;
3
5
  export interface ManifestBlock {
4
6
  /** `file#exportName`, unique across the project. */
5
7
  id: string;
@@ -33,6 +35,22 @@ export interface ManifestFlow {
33
35
  demoPace?: unknown;
34
36
  };
35
37
  }
38
+ /** A Read Block (beta): which Checkpoints it reads and the shape of what it returns. */
39
+ export interface ManifestReadBlock {
40
+ id: string;
41
+ name: string;
42
+ exportName: string;
43
+ description: string;
44
+ /** Checkpoint tags it reads on (`"*"` = every page). */
45
+ from: string[];
46
+ requires: string[];
47
+ /** Field path -> shape (`"catalog.items": [{ name: "string", price: "number" }]`); absent when `read` needs Mem to build. */
48
+ fields?: Record<string, ReadShape>;
49
+ /** Why `fields` is absent. */
50
+ fieldsError?: string;
51
+ file: string;
52
+ map?: string;
53
+ }
36
54
  export interface Manifest {
37
55
  version: number;
38
56
  project: {
@@ -43,6 +61,8 @@ export interface Manifest {
43
61
  checkpoints: string[];
44
62
  maps: string[];
45
63
  blocks: ManifestBlock[];
64
+ /** What each Checkpoint's page shows, from its Read Blocks (version 2+). */
65
+ readBlocks: ManifestReadBlock[];
46
66
  flows: ManifestFlow[];
47
67
  skipped: ProjectScan["skipped"];
48
68
  }
@@ -3,7 +3,9 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
5
  import { scanProject } from "./project.js";
6
- export const MANIFEST_VERSION = 1;
6
+ import { MemPage } from "../engine/mem/mem.js";
7
+ import { readBlockShape } from "../engine/read/read-block.js";
8
+ export const MANIFEST_VERSION = 2;
7
9
  function packageVersion() {
8
10
  try {
9
11
  return JSON.parse(readFileSync(join(dirname(new URL(import.meta.url).pathname), "..", "..", "package.json"), "utf8")).version;
@@ -77,12 +79,36 @@ export function manifestFromScan(scan) {
77
79
  },
78
80
  };
79
81
  });
82
+ const readBlocks = scan.readBlocks.map((r) => {
83
+ let shape;
84
+ try {
85
+ shape = { fields: readBlockShape(r.block, new MemPage()) };
86
+ }
87
+ catch (err) {
88
+ shape = { fieldsError: err instanceof Error ? err.message : String(err) };
89
+ }
90
+ for (const t of r.block.from)
91
+ if (t !== "*")
92
+ checkpoints.add(t);
93
+ return {
94
+ id: `${r.file}#${r.exportName}`,
95
+ name: r.block.name,
96
+ exportName: r.exportName,
97
+ description: r.block.description,
98
+ from: [...r.block.from],
99
+ requires: r.block.requires.map((k) => k.name),
100
+ ...shape,
101
+ file: r.file,
102
+ ...(r.map ? { map: r.map } : {}),
103
+ };
104
+ });
80
105
  return {
81
106
  version: MANIFEST_VERSION,
82
107
  project: { name: projectName(scan.projectDir), dir: scan.projectDir, waygraph: packageVersion() },
83
108
  checkpoints: [...checkpoints].sort(),
84
109
  maps: [...maps].sort(),
85
110
  blocks,
111
+ readBlocks,
86
112
  flows,
87
113
  skipped: scan.skipped,
88
114
  };
@@ -1,6 +1,7 @@
1
1
  import type { BlocksSelect } from "./select.js";
2
+ import type { ReadBlock } from "../engine/read/read-block.js";
2
3
  import type { AnyBlock } from "./scan.js";
3
- export type BlockKind = "nav" | "page" | "assert" | "method" | "effect" | "api" | "block";
4
+ import { type BlockKind } from "../engine/block/kind.js";
4
5
  export interface ScannedBlock {
5
6
  block: AnyBlock;
6
7
  name: string;
@@ -34,9 +35,17 @@ export interface BlockEntry {
34
35
  kind: "nav" | "action";
35
36
  description: string;
36
37
  }
38
+ /** A Read Block found in a scanned file (beta). */
39
+ export interface ScannedReadBlock {
40
+ block: ReadBlock;
41
+ exportName: string;
42
+ file: string;
43
+ map?: string;
44
+ }
37
45
  export interface ProjectScan {
38
46
  projectDir: string;
39
47
  blocks: ScannedBlock[];
48
+ readBlocks: ScannedReadBlock[];
40
49
  flows: ScannedFlow[];
41
50
  skipped: {
42
51
  block: string;
@@ -2,17 +2,9 @@
2
2
  import { relative } from "node:path";
3
3
  import { matchBlocksSelect } from "./select.js";
4
4
  import { exploreRoots, injectTaggedFile } from "./roots.js";
5
- import { blockKind, importFresh, isBlockLike, isFlowLike, walkDir } from "./scan.js";
5
+ import { importFresh, isBlockLike, isFlowLike, isReadBlockLike, walkDir } from "./scan.js";
6
6
  import { installedMapPackages, mapPackageBlockFiles } from "./maps.js";
7
- function kindOf(block) {
8
- const kind = blockKind(block);
9
- if (kind === "nav" || kind === "page" || kind === "assert" || kind === "api")
10
- return kind;
11
- const salt = block.__waygraphSalt;
12
- if (salt === "effect" || salt === "method" || salt === "api")
13
- return salt;
14
- return "block";
15
- }
7
+ import { blockKindOf } from "../engine/block/kind.js";
16
8
  /** A file that fails to import is left out of the scan, and said so: a silent skip reads as "nothing there". */
17
9
  export function reportImportError(relFile, err) {
18
10
  const msg = err instanceof Error ? err.message : String(err);
@@ -23,6 +15,7 @@ export async function scanProject(projectDir, opts = {}) {
23
15
  const select = opts.blocksSelect;
24
16
  const onError = opts.onImportError ?? reportImportError;
25
17
  const blocks = [];
18
+ const readBlocks = [];
26
19
  const flows = [];
27
20
  const skipped = [];
28
21
  const scanBlockFile = async (absFile, rel, file, map) => {
@@ -35,6 +28,10 @@ export async function scanProject(projectDir, opts = {}) {
35
28
  return;
36
29
  }
37
30
  for (const [exportName, exported] of Object.entries(mod)) {
31
+ if (isReadBlockLike(exported)) {
32
+ readBlocks.push({ block: exported, exportName, file, ...(map ? { map } : {}) });
33
+ continue;
34
+ }
38
35
  if (!isBlockLike(exported))
39
36
  continue;
40
37
  if (select && !matchBlocksSelect(select, { relFile: file, blockName: exported.name }))
@@ -49,7 +46,7 @@ export async function scanProject(projectDir, opts = {}) {
49
46
  block: exported,
50
47
  name: exported.name,
51
48
  exportName,
52
- kind: kindOf(exported),
49
+ kind: blockKindOf(exported),
53
50
  description: typeof exported.description === "string" ? exported.description : "",
54
51
  from: [...from],
55
52
  to: [...to],
@@ -97,5 +94,5 @@ export async function scanProject(projectDir, opts = {}) {
97
94
  }
98
95
  }
99
96
  }
100
- return { projectDir, blocks, flows, skipped };
97
+ return { projectDir, blocks, readBlocks, flows, skipped };
101
98
  }
@@ -0,0 +1,2 @@
1
+ /** Imports every Read Block file of the project; returns how many loaded. */
2
+ export declare function loadReadBlocks(projectDir: string): Promise<number>;
@@ -0,0 +1,23 @@
1
+ // Loads a project's Read Blocks (`*.read.block.ts`, its own and its installed map packages'): importing one registers it,
2
+ // so every run can read the pages it lands on. Sessions get them from the full project scan; a run by flow name does not.
3
+ import { relative } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { installedMapPackages, mapPackageBlockFiles } from "./maps.js";
6
+ import { reportImportError } from "./project.js";
7
+ import { walkDir } from "./scan.js";
8
+ const READ_BLOCK = /\.read\.block\.ts$/;
9
+ /** Imports every Read Block file of the project; returns how many loaded. */
10
+ export async function loadReadBlocks(projectDir) {
11
+ const files = [...walkDir(projectDir, READ_BLOCK), ...installedMapPackages(projectDir).flatMap(mapPackageBlockFiles).filter((f) => READ_BLOCK.test(f))];
12
+ let loaded = 0;
13
+ for (const file of files) {
14
+ try {
15
+ await import(pathToFileURL(file).href);
16
+ loaded += 1;
17
+ }
18
+ catch (err) {
19
+ reportImportError(relative(projectDir, file), err);
20
+ }
21
+ }
22
+ return loaded;
23
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Block } from "../engine/block/block.js";
2
2
  import type { Checkpoint } from "../engine/checkpoint/checkpoint.js";
3
+ import type { ReadBlock } from "../engine/read/read-block.js";
3
4
  /** Every file under `dir` whose name matches, skipping `node_modules`, `dist` and `.git`. */
4
5
  export declare function walkDir(dir: string, pattern: RegExp): string[];
5
6
  /**
@@ -11,9 +12,9 @@ export type AnyBlock = Block<Checkpoint<string>, Checkpoint<string>> & {
11
12
  description?: string;
12
13
  };
13
14
  export declare function isBlockLike(val: unknown): val is AnyBlock;
15
+ /** A Read Block (`defineReadBlock`): a name, the Checkpoints it reads, and an instruction with only `read`. */
16
+ export declare function isReadBlockLike(val: unknown): val is ReadBlock;
14
17
  export declare function isFlowLike(val: unknown): val is {
15
18
  run: (...args: unknown[]) => Promise<unknown>;
16
19
  blocks: () => unknown[];
17
20
  };
18
- /** The factory kind a Block was built with (`nav`, `page`, `assert`), or undefined for Methods and Effects. */
19
- export declare function blockKind(block: object): string | undefined;
@@ -45,13 +45,17 @@ export function isBlockLike(val) {
45
45
  const instruction = obj.instruction;
46
46
  return instruction !== null && typeof instruction === "object" && typeof instruction?.act === "function";
47
47
  }
48
+ /** A Read Block (`defineReadBlock`): a name, the Checkpoints it reads, and an instruction with only `read`. */
49
+ export function isReadBlockLike(val) {
50
+ if (val === null || typeof val !== "object")
51
+ return false;
52
+ const obj = val;
53
+ const instruction = obj.instruction;
54
+ return typeof obj.name === "string" && Array.isArray(obj.from) && typeof instruction?.read === "function" && instruction.act === undefined;
55
+ }
48
56
  export function isFlowLike(val) {
49
57
  return (val !== null &&
50
58
  typeof val === "object" &&
51
59
  typeof val.run === "function" &&
52
60
  typeof val.blocks === "function");
53
61
  }
54
- /** The factory kind a Block was built with (`nav`, `page`, `assert`), or undefined for Methods and Effects. */
55
- export function blockKind(block) {
56
- return block.__waygraphKind;
57
- }
@@ -1,4 +1,3 @@
1
- import "../runtime/run-log.js";
2
1
  export declare function play(projectDir: string, spec: string): Promise<void>;
3
2
  /** The process entry the CLI's bootstrap calls: one run (and one run log) per invocation. */
4
3
  export declare function startRunner(): void;
@@ -2,9 +2,10 @@
2
2
  // Playwright is used) with the project dir and the spec ("cartFlow", "loginFlow then add-item"). Every mode plays the
3
3
  // same steps through the same engine; the mode decides what happens around each step.
4
4
  import { MemPage } from "../engine/mem/mem.js";
5
- import { beginRun, endRun } from "../engine/run/events.js";
5
+ import { inRun } from "../engine/run/events.js";
6
6
  import { withWebServer } from "../runtime/webserver.js";
7
- import "../runtime/run-log.js";
7
+ import { installRunLog } from "../runtime/run-log.js";
8
+ import { loadReadBlocks } from "../manifest/read-blocks.js";
8
9
  import { planSteps } from "./plan/plan.js";
9
10
  import { readOptions, slowMoFor } from "./options.js";
10
11
  import { openSession } from "./session.js";
@@ -16,6 +17,7 @@ import { reportMode } from "./modes/report.js";
16
17
  export async function play(projectDir, spec) {
17
18
  const options = readOptions();
18
19
  const mem = new MemPage();
20
+ await loadReadBlocks(projectDir);
19
21
  const { steps, flows } = await planSteps(projectDir, spec, mem, options);
20
22
  if (options.mode === "branches")
21
23
  return playBranches(flows, mem, options);
@@ -52,12 +54,10 @@ function finish(outcome) {
52
54
  }
53
55
  /** The process entry the CLI's bootstrap calls: one run (and one run log) per invocation. */
54
56
  export function startRunner() {
57
+ installRunLog();
55
58
  const projectDir = process.argv[2] ?? process.cwd();
56
59
  const spec = process.argv[3] ?? "";
57
- const opened = beginRun(spec);
58
- withWebServer(projectDir, () => play(projectDir, spec))
59
- .then(() => endRun(opened, {}), (err) => {
60
- endRun(opened, { error: err });
60
+ inRun(spec, () => withWebServer(projectDir, () => play(projectDir, spec))).catch((err) => {
61
61
  console.error(err && err.stack ? err.stack : String(err));
62
62
  process.exitCode = 1;
63
63
  });
@@ -1 +1,2 @@
1
- export {};
1
+ /** How an Engine launches its own browser: the selected config (--config), the Chromium override, the extensions. Called by the package entry. */
2
+ export declare function installFullLaunch(): void;
@@ -4,31 +4,33 @@ import { chromiumExecutablePath, extensionPathsFromEnv, launchContext } from "./
4
4
  import { resolveExtensions } from "./extensions.js";
5
5
  import { loadWaygraphConfig } from "./waygraph-config.js";
6
6
  import { envString } from "../engine/env.js";
7
- // An Engine's own browser follows the selected config (--config), the Chromium override and the configured extensions.
8
- setOwnContextLauncher(async (engineConfig) => {
9
- // A config chosen with `--config` / WAYGRAPH_CONFIG wins over the one the Engine was built with.
10
- const chosen = envString("WAYGRAPH_CONFIG") ? await loadWaygraphConfig(process.cwd()) : undefined;
11
- const config = chosen
12
- ? {
13
- ...engineConfig,
14
- ...(chosen.baseUrl !== undefined ? { baseUrl: chosen.baseUrl } : {}),
15
- ...(chosen.extensions !== undefined ? { extensions: chosen.extensions } : {}),
16
- }
17
- : engineConfig;
18
- const browserName = config?.browserName ?? "chromium";
19
- const executablePath = browserName === "chromium" ? chromiumExecutablePath() : undefined;
20
- const baseURL = config?.baseUrl ?? envString("WAYGRAPH_BASE_URL");
21
- return launchContext({
22
- launcher: resolveLauncher(browserName, config),
23
- launch: {
24
- headless: config?.headless ?? true,
25
- ...(config?.slowMo !== undefined ? { slowMo: config.slowMo } : {}),
26
- ...(executablePath ? { executablePath } : {}),
27
- },
28
- context: {
29
- ...(baseURL ? { baseURL } : {}),
30
- ...(config?.recordVideo ? { recordVideo: config.recordVideo } : {}),
31
- },
32
- extensions: await resolveExtensions(config?.extensions ?? extensionPathsFromEnv(), process.cwd()),
7
+ /** How an Engine launches its own browser: the selected config (--config), the Chromium override, the extensions. Called by the package entry. */
8
+ export function installFullLaunch() {
9
+ setOwnContextLauncher(async (engineConfig) => {
10
+ // A config chosen with `--config` / WAYGRAPH_CONFIG wins over the one the Engine was built with.
11
+ const chosen = envString("WAYGRAPH_CONFIG") ? await loadWaygraphConfig(process.cwd()) : undefined;
12
+ const config = chosen
13
+ ? {
14
+ ...engineConfig,
15
+ ...(chosen.baseUrl !== undefined ? { baseUrl: chosen.baseUrl } : {}),
16
+ ...(chosen.extensions !== undefined ? { extensions: chosen.extensions } : {}),
17
+ }
18
+ : engineConfig;
19
+ const browserName = config?.browserName ?? "chromium";
20
+ const executablePath = browserName === "chromium" ? chromiumExecutablePath() : undefined;
21
+ const baseURL = config?.baseUrl ?? envString("WAYGRAPH_BASE_URL");
22
+ return launchContext({
23
+ launcher: resolveLauncher(browserName, config),
24
+ launch: {
25
+ headless: config?.headless ?? true,
26
+ ...(config?.slowMo !== undefined ? { slowMo: config.slowMo } : {}),
27
+ ...(executablePath ? { executablePath } : {}),
28
+ },
29
+ context: {
30
+ ...(baseURL ? { baseURL } : {}),
31
+ ...(config?.recordVideo ? { recordVideo: config.recordVideo } : {}),
32
+ },
33
+ extensions: await resolveExtensions(config?.extensions ?? extensionPathsFromEnv(), process.cwd()),
34
+ });
33
35
  });
34
- });
36
+ }
@@ -1,2 +1,4 @@
1
+ /** Starts writing run events to the run log. Called by the package entry and the runner; a second call does nothing. */
2
+ export declare function installRunLog(): void;
1
3
  /** The folder run logs go to for a project unless the config or the environment says otherwise. */
2
4
  export declare function defaultRunLogDir(projectDir: string): string;
@@ -4,13 +4,20 @@ import { appendFileSync, mkdirSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { onRunEvent } from "../engine/run/events.js";
6
6
  import { envString } from "../engine/env.js";
7
- onRunEvent((event) => {
8
- const dir = envString("WAYGRAPH_RUN_LOG");
9
- if (!dir || dir === "0")
7
+ let installed = false;
8
+ /** Starts writing run events to the run log. Called by the package entry and the runner; a second call does nothing. */
9
+ export function installRunLog() {
10
+ if (installed)
10
11
  return;
11
- mkdirSync(dir, { recursive: true });
12
- appendFileSync(join(dir, `${event.runId}.jsonl`), JSON.stringify(event) + "\n");
13
- });
12
+ installed = true;
13
+ onRunEvent((event) => {
14
+ const dir = envString("WAYGRAPH_RUN_LOG");
15
+ if (!dir || dir === "0")
16
+ return;
17
+ mkdirSync(dir, { recursive: true });
18
+ appendFileSync(join(dir, `${event.runId}.jsonl`), JSON.stringify(event) + "\n");
19
+ });
20
+ }
14
21
  /** The folder run logs go to for a project unless the config or the environment says otherwise. */
15
22
  export function defaultRunLogDir(projectDir) {
16
23
  return join(projectDir, ".waygraph", "runs");
@@ -13,7 +13,7 @@ the **reference** for waygraph consumer conventions:
13
13
  - `defineApiBlock` - a real backend/HTTP check as one step in the same Flow as
14
14
  a UI step, with its own live network-activity panel during a demo - see
15
15
  `apiDemoFlow` (`src/flows/api-demo.flow.ts`)
16
- - `ctx.caption()` (bottom subtitle-bar) + `ctx.titlePos()` (banner
16
+ - `ctx.ui.caption()` (bottom subtitle-bar) + `ctx.ui.titlePos()` (banner
17
17
  left/center/right) - also on `checkoutFlow`
18
18
 
19
19
  **GitHub Pages walkthrough:**
@@ -48,7 +48,7 @@ npx playwright install chromium
48
48
  | **Device showcase** (mobile/tablet + portrait/landscape; narration in `src/stubs/device-showcase.ts`) | `npm run demo:device` |
49
49
  | Device showcase + `.webm` | `npm run demo:video` |
50
50
  | **Cart bulk** demo (add-all → remove-all) | `npm run demo:bulk` |
51
- | **Owner checkout** (inline `{ ff: true }`, `ctx.caption()`, banner position) | `npm run demo:checkout` |
51
+ | **Owner checkout** (inline `{ ff: true }`, `ctx.ui.caption()`, banner position) | `npm run demo:checkout` |
52
52
  | **FE + BE in one Flow** (`defineApiBlock`, live network panel) | `npm run demo:api` |
53
53
  | Record (headless `.webm`) | `npm run record` |
54
54
  | List flows | `npm run flows` |
@@ -9,6 +9,7 @@ src/blocks/
9
9
  saucedemo-web/ # URL /
10
10
  NAV.md
11
11
  nav-login.block.ts # Page definePageBlock url:/
12
+ header.read.block.ts # Read defineReadBlock (beta, every page: title, cart count)
12
13
  methods/
13
14
  fill-username.method.block.ts # Method defineMethodBlock (self-loop on LoginPage)
14
15
  fill-password.method.block.ts # Method defineMethodBlock (self-loop on LoginPage)
@@ -18,6 +19,7 @@ src/blocks/
18
19
  login.sel.ts # LoginSel (not a Block)
19
20
  inventory/ # URL /inventory.html
20
21
  inventory.page.block.ts # Page definePageBlock
22
+ inventory.read.block.ts # Read defineReadBlock (beta: products, prices, in cart, sort)
21
23
  NAV.md
22
24
  nav-item-detail.block.ts # Nav defineMemNavBlock (instance row)
23
25
  methods/
@@ -36,6 +38,7 @@ src/blocks/
36
38
  NAV.md
37
39
  cart.sel.ts # CartSel (not a Block)
38
40
  nav-cart.block.ts # Nav click header cart
41
+ cart.read.block.ts # Read defineReadBlock (beta: the cart's lines)
39
42
  nav-checkout-info.block.ts
40
43
  checkout-step-one/ # URL /checkout-step-one.html
41
44
  NAV.md
@@ -0,0 +1,26 @@
1
+ import { defineReadBlock } from "waygraph/beta";
2
+ import { CartPage } from "../../../states/checkout.states.js";
3
+ import { Cart } from "../../../states/checkout.reads.js";
4
+
5
+ /**
6
+ * Kind: Read (beta)
7
+ * Helper: defineReadBlock
8
+ * Route: saucedemo-web/cart/ (= /cart.html)
9
+ *
10
+ * The cart's lines: name, quantity and price.
11
+ */
12
+ export const CartReadBlock = defineReadBlock({
13
+ name: "read-cart",
14
+ description: "Each line in the cart: name, quantity, price.",
15
+ from: CartPage,
16
+ instruction: {
17
+ read: (ctx) =>
18
+ Cart({
19
+ lines: ctx.page.list(".cart_item", (row) => ({
20
+ name: row.text(".inventory_item_name"),
21
+ qty: row.number(".cart_quantity"),
22
+ price: row.number(".inventory_item_price"),
23
+ })),
24
+ }),
25
+ },
26
+ });
@@ -0,0 +1,25 @@
1
+ import { defineReadBlock } from "waygraph/beta";
2
+ import { Header } from "../../states/checkout.reads.js";
3
+ import { InventorySel } from "./inventory/methods/inventory-items.js";
4
+
5
+ /**
6
+ * Kind: Read (beta)
7
+ * Helper: defineReadBlock
8
+ * Route: saucedemo-web/ (every signed-in page)
9
+ *
10
+ * The header every page shares: the page title and how many items are in the cart (0 when the badge is not shown).
11
+ */
12
+ export const HeaderReadBlock = defineReadBlock({
13
+ name: "read-header",
14
+ description: "The page title and the cart count.",
15
+ instruction: {
16
+ read: (ctx) =>
17
+ Header({
18
+ title: ctx.page.text(".title"),
19
+ cartCount: ctx.page.fn(async (page) => {
20
+ const badge = page.locator(InventorySel.cartBadge);
21
+ return (await badge.count()) ? Number(await badge.first().innerText()) : 0;
22
+ }),
23
+ }),
24
+ },
25
+ });
@@ -0,0 +1,28 @@
1
+ import { defineReadBlock } from "waygraph/beta";
2
+ import { ItemInCart, LoggedIn } from "../../../states/checkout.states.js";
3
+ import { Catalog } from "../../../states/checkout.reads.js";
4
+ import { InventorySel } from "./methods/inventory-items.js";
5
+
6
+ /**
7
+ * Kind: Read (beta)
8
+ * Helper: defineReadBlock
9
+ * Route: saucedemo-web/inventory/ (= /inventory.html)
10
+ *
11
+ * The catalogue as data: every product with its price and whether it is in the cart, and the sort order.
12
+ */
13
+ export const InventoryReadBlock = defineReadBlock({
14
+ name: "read-inventory",
15
+ description: "Every product, its price and whether it is in the cart; the sort order.",
16
+ from: [LoggedIn, ItemInCart],
17
+ instruction: {
18
+ read: (ctx) =>
19
+ Catalog({
20
+ items: ctx.page.list(InventorySel.card, (row) => ({
21
+ name: row.text(InventorySel.name),
22
+ price: row.number(".inventory_item_price"),
23
+ inCart: row.visible(InventorySel.removeBtnPrefix),
24
+ })),
25
+ sort: ctx.page.value(".product_sort_container"),
26
+ }),
27
+ },
28
+ });
@@ -32,7 +32,7 @@ export const apiDemoEpisode = engine
32
32
  .start()
33
33
  .method(
34
34
  CheckBackendHealth.stubBefore((ctx) => {
35
- ctx.title("Checking backend health · real HTTP request, no browser");
35
+ ctx.ui.title("Checking backend health · real HTTP request, no browser");
36
36
  }),
37
37
  )
38
38
  .end();