waygraph 0.0.1 → 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/dist/engine.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BrowserContext } from "@playwright/test";
1
+ import type { BrowserContext, Page } from "@playwright/test";
2
2
  import type { Block, Checkpoint, Instruction, DefinedBlock } from "./types.js";
3
3
  import { MemPage } from "./mem-page.js";
4
4
  import type { Trait } from "./trait.js";
@@ -29,7 +29,45 @@ export declare function preflight(mem: MemPage, block: Block<any, any>): void;
29
29
  * reachable outcome by construction (as with a `defineFlow`-built Flow, which has
30
30
  * no routing at all) - there is nothing to validate against.
31
31
  */
32
- export declare function runGraph<TOut extends Checkpoint<string>>(entry: Block<Checkpoint<"__start__">, TOut>, terminals: ReadonlySet<string> | undefined, context: BrowserContext, mem: MemPage, maxSteps?: number): Promise<TOut>;
32
+ export declare function runGraph<TOut extends Checkpoint<string>>(entry: Block<Checkpoint<"__start__">, TOut>, terminals: ReadonlySet<string> | undefined, context: BrowserContext, mem: MemPage, maxSteps?: number, options?: RunGraphOptions): Promise<TOut>;
33
+ /** Options for `runGraph`'s trailing `options` arg and for {@link Flow.run}'s `options` overload. */
34
+ export interface RunGraphOptions {
35
+ /**
36
+ * Drive this already-open page instead of opening a new one via
37
+ * `context.newPage()`. Lets a run continue on a tab the caller already
38
+ * has open (e.g. one that's already logged in), instead of every run
39
+ * being forced to start its own fresh tab and lose access to whatever was
40
+ * already there.
41
+ */
42
+ page?: Page;
43
+ /**
44
+ * `Flow.run` defaults this to `true` only when it opened the page itself
45
+ * (no `page` given) - matching every call from before this option
46
+ * existed. If you pass your own `page`, it defaults to `false` instead:
47
+ * a page you opened is yours to close, not this run's to close out from
48
+ * under you. Either way an explicit value here always wins. `runGraph`
49
+ * itself has no such page-aware default (its caller already decided
50
+ * `page` and `closeOnFinish` together) - it just does what you ask.
51
+ */
52
+ closeOnFinish?: boolean;
53
+ }
54
+ /**
55
+ * Drives a genuinely separate second tab through its own Block/Flow, in the
56
+ * same browser context an existing `page` already belongs to - the primitive
57
+ * `observe()` is specifically allowed to reach for (see {@link Instruction.observe}),
58
+ * since `act`/`resolve` can't touch `page.context()` at all. A thin, named
59
+ * wrapper around `runGraph` (which already opens its own page from whatever
60
+ * context it's given) rather than a new mechanism - the point is making the
61
+ * pattern discoverable and giving it a real name, not inventing new tab-lifecycle
62
+ * logic. The spawned tab is closed (by `runGraph`'s own `finally`) before this
63
+ * resolves; the original `page` is never touched.
64
+ * @example
65
+ * async observe(page, mem) {
66
+ * const receipt = await spawnTab(EmailReceiptFlow, page, mem);
67
+ * mem.set(ReceiptCode, receipt.__state);
68
+ * }
69
+ */
70
+ export declare function spawnTab<TOut extends Checkpoint<string>>(entry: Block<Checkpoint<"__start__">, TOut>, page: Page, mem: MemPage, maxSteps?: number): Promise<TOut>;
33
71
  /** Reserved markers bookending a `defineFlow([start, ...blocks, end])` call. */
34
72
  export declare const start: unique symbol;
35
73
  export declare const end: unique symbol;
@@ -67,8 +105,53 @@ type EndMarker = typeof end;
67
105
  * each call, so it's still correct after a patch too. Returns a new Flow -
68
106
  * the original is untouched, same as every other decorator in this module.
69
107
  */
108
+ /**
109
+ * One Block's identity as exposed by graph introspection - {@link Flow.blocks},
110
+ * {@link ComposedBlock.steps} - without needing to execute anything. Does not
111
+ * include the Block's literal Checkpoint `In`/`Out` tags: those are TypeScript
112
+ * literal types, erased at runtime, and can only be recovered by a build-time
113
+ * static-analysis tool reading the source's own type annotations - see the
114
+ * `graph-visualization` openspec change for that piece.
115
+ */
116
+ export interface BlockInfo {
117
+ name: string;
118
+ /** `branch()`'s routing table, if this Block has one - see {@link Block.routes}. */
119
+ routes?: Readonly<Record<string, string | null>>;
120
+ /**
121
+ * The actual Block, re-runnable on its own via
122
+ * `engine.defineFlow([start, info.block, end])` - e.g. step-through
123
+ * tooling that runs a Flow's Blocks one at a time. Introspection that only
124
+ * wants to read the shape (a visualizer) can ignore this and use `name`/
125
+ * `routes` alone.
126
+ */
127
+ block: Block<any, any>;
128
+ }
70
129
  export interface Flow<Out extends Checkpoint<string>> {
130
+ /**
131
+ * This Flow's constituent Blocks, in order between `start`/`end`, as plain
132
+ * data - for introspection/visualization tools, without running anything.
133
+ * @example loginFlow.blocks() // [{ name: "login" }, { name: "add-to-cart", routes: {...} }]
134
+ */
135
+ blocks(): readonly BlockInfo[];
71
136
  run(context: BrowserContext, mem: MemPage): Promise<Out>;
137
+ /**
138
+ * `options.closeOnFinish: false` hands the driven page back instead of
139
+ * closing it - use this overload (a literal `false`, not a `boolean`) to
140
+ * get the `{ result, page }` shape typed correctly.
141
+ * @example const { result, page } = await loginFlow.run(context, mem, { closeOnFinish: false });
142
+ */
143
+ run(context: BrowserContext, mem: MemPage, options: RunGraphOptions & {
144
+ closeOnFinish: false;
145
+ }): Promise<{
146
+ result: Out;
147
+ page: Page;
148
+ }>;
149
+ /**
150
+ * `options.page` drives that already-open page instead of opening a new
151
+ * one - see {@link RunGraphOptions}.
152
+ * @example await loginFlow.run(context, mem, { page: existingPage });
153
+ */
154
+ run(context: BrowserContext, mem: MemPage, options?: RunGraphOptions): Promise<Out>;
72
155
  run(mem: MemPage, config?: EngineConfig): Promise<Out>;
73
156
  /**
74
157
  * Replaces the given Block's whole verify list, wherever it sits in this
@@ -87,6 +170,56 @@ export interface Flow<Out extends Checkpoint<string>> {
87
170
  */
88
171
  modBlockVerify(block: Block<any, any> | string | number, nameOrIndex: string | number, newCheck: Trait["check"] | Trait): Flow<Out>;
89
172
  }
173
+ /**
174
+ * A Block guaranteed to have `withStepVerify`/`modStepVerify` attached - what
175
+ * {@link composeBlock} returns. Extends plain `Block`, so it drops straight into
176
+ * `defineFlow([start, ..., composed, ..., end])` like any other Block; no special
177
+ * casing needed anywhere that only expects a `Block`.
178
+ */
179
+ export interface ComposedBlock<In extends Checkpoint<string>, Out extends Checkpoint<string>> extends Block<In, Out> {
180
+ /**
181
+ * This composed Block's constituent steps, in order, as plain data - same
182
+ * shape and purpose as {@link Flow.blocks}.
183
+ * @example GovFormBlock.steps() // [{ name: "step-1-name" }, { name: "step-2-address" }, ...]
184
+ */
185
+ steps(): readonly BlockInfo[];
186
+ /**
187
+ * Replaces one step's whole verify list, wherever it sits in this composed
188
+ * Block - same rules as {@link withVerify}. Throws if no step matches.
189
+ * @example GovFormBlock.withStepVerify("step-2-address", [])
190
+ */
191
+ withStepVerify(step: Block<any, any> | string | number, verify: Trait[] | ((out: any) => Trait[])): ComposedBlock<In, Out>;
192
+ /**
193
+ * Replaces one Trait inside one step's verify list - same rules as
194
+ * {@link modVerify}. Throws if no step matches, or that step has no verify
195
+ * trait at the given name/index.
196
+ * @example GovFormBlock.modStepVerify("step-2-address", "zip-visible", Trait.visible("#zip-confirmed"))
197
+ */
198
+ modStepVerify(step: Block<any, any> | string | number, nameOrIndex: string | number, newCheck: Trait["check"] | Trait): ComposedBlock<In, Out>;
199
+ }
200
+ /**
201
+ * Chains several Blocks into one named, exported unit - what a naturally
202
+ * multi-step feature (a stepper form with several pages, say) wants instead of
203
+ * either one giant Block with no per-step confirmation, or several separately
204
+ * exported Blocks the caller has to wire together by hand every time. Each
205
+ * step keeps its own real `act`/`resolve`/`verify`, chained the same way
206
+ * `connect()` already chains any two Blocks - `composeBlock` is not a new
207
+ * execution mechanism, just `connect()` reduced over the list plus a name and
208
+ * the same step-list closure `Flow` already keeps for its own Block list, so
209
+ * `withStepVerify`/`modStepVerify` can patch one step from outside without the
210
+ * caller needing to hand-rebuild the chain. Resolution is by `.name`, not
211
+ * object identity, so a step reference stays valid even after an earlier patch
212
+ * swapped that slot for a new object - see {@link Flow.withBlockVerify} for
213
+ * the identical guarantee one level up. Each call returns a new composed
214
+ * Block; the original is untouched.
215
+ * @example composeBlock("gov-form", [step1, step2, step3, step4])
216
+ */
217
+ export declare function composeBlock<In extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, Out>]): ComposedBlock<In, Out>;
218
+ export declare function composeBlock<In extends Checkpoint<string>, B extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, B>, Block<B, Out>]): ComposedBlock<In, Out>;
219
+ export declare function composeBlock<In extends Checkpoint<string>, B extends Checkpoint<string>, C extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, B>, Block<B, C>, Block<C, Out>]): ComposedBlock<In, Out>;
220
+ export declare function composeBlock<In extends Checkpoint<string>, B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, B>, Block<B, C>, Block<C, D>, Block<D, Out>]): ComposedBlock<In, Out>;
221
+ export declare function composeBlock<In extends Checkpoint<string>, B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, E extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, B>, Block<B, C>, Block<C, D>, Block<D, E>, Block<E, Out>]): ComposedBlock<In, Out>;
222
+ export declare function composeBlock<In extends Checkpoint<string>, B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, E extends Checkpoint<string>, F extends Checkpoint<string>, Out extends Checkpoint<string>>(name: string, steps: readonly [Block<In, B>, Block<B, C>, Block<C, D>, Block<D, E>, Block<E, F>, Block<F, Out>]): ComposedBlock<In, Out>;
90
223
  /** Configures how an Engine launches its own browser - only used by `Flow.run(mem)` (no context given); ignored by `Flow.run(context, mem)`, since that context is already launched. */
91
224
  export interface EngineConfig {
92
225
  /** Default `true`, matching Playwright's own default. */
@@ -169,6 +302,7 @@ export declare function defineBlock<In extends Checkpoint<string>, Out extends C
169
302
  instruction: Instruction<In, Out, any>;
170
303
  next?: Block<In, Out>["next"];
171
304
  requires?: Block<In, Out>["requires"];
305
+ routes?: Block<In, Out>["routes"];
172
306
  }): DefinedBlock<In, Out>;
173
307
  /**
174
308
  * Returns a new Block that runs exactly like `block` - same act/observe/resolve,
package/dist/engine.js CHANGED
@@ -37,9 +37,10 @@ export function preflight(mem, block) {
37
37
  * reachable outcome by construction (as with a `defineFlow`-built Flow, which has
38
38
  * no routing at all) - there is nothing to validate against.
39
39
  */
40
- export async function runGraph(entry, terminals, context, mem, maxSteps = 5000) {
40
+ export async function runGraph(entry, terminals, context, mem, maxSteps = 5000, options) {
41
41
  preflight(mem, entry);
42
- const page = await context.newPage();
42
+ const page = options?.page ?? (await context.newPage());
43
+ const closeOnFinish = options?.closeOnFinish ?? true;
43
44
  try {
44
45
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
46
  let current = entry;
@@ -67,9 +68,30 @@ export async function runGraph(entry, terminals, context, mem, maxSteps = 5000)
67
68
  }
68
69
  }
69
70
  finally {
70
- await page.close();
71
+ if (closeOnFinish) {
72
+ await page.close();
73
+ }
71
74
  }
72
75
  }
76
+ /**
77
+ * Drives a genuinely separate second tab through its own Block/Flow, in the
78
+ * same browser context an existing `page` already belongs to - the primitive
79
+ * `observe()` is specifically allowed to reach for (see {@link Instruction.observe}),
80
+ * since `act`/`resolve` can't touch `page.context()` at all. A thin, named
81
+ * wrapper around `runGraph` (which already opens its own page from whatever
82
+ * context it's given) rather than a new mechanism - the point is making the
83
+ * pattern discoverable and giving it a real name, not inventing new tab-lifecycle
84
+ * logic. The spawned tab is closed (by `runGraph`'s own `finally`) before this
85
+ * resolves; the original `page` is never touched.
86
+ * @example
87
+ * async observe(page, mem) {
88
+ * const receipt = await spawnTab(EmailReceiptFlow, page, mem);
89
+ * mem.set(ReceiptCode, receipt.__state);
90
+ * }
91
+ */
92
+ export async function spawnTab(entry, page, mem, maxSteps = 5000) {
93
+ return runGraph(entry, undefined, page.context(), mem, maxSteps);
94
+ }
73
95
  /** Reserved markers bookending a `defineFlow([start, ...blocks, end])` call. */
74
96
  export const start = Symbol("waygraph.start");
75
97
  export const end = Symbol("waygraph.end");
@@ -89,31 +111,77 @@ function findBlockIndex(middle, block) {
89
111
  }
90
112
  function buildFlow(middle, engineConfig) {
91
113
  const chain = middle.reduce((a, b) => connect(a, b));
92
- return {
93
- async run(contextOrMem, memOrConfig) {
94
- if (contextOrMem instanceof MemPage) {
95
- const mem = contextOrMem;
96
- const config = { ...engineConfig, ...memOrConfig };
97
- // Checked again inside runGraph too, but doing it here first means a
98
- // missing key fails before a browser process even launches, not just
99
- // before a page opens - the same "fail loud, cheaply" guarantee
100
- // run(context, mem) already has via an existing context.
101
- preflight(mem, chain);
102
- const launch = LAUNCHERS[config.browserName ?? "chromium"];
103
- const browser = await launch.launch({
104
- headless: config.headless ?? true,
105
- ...(config.slowMo !== undefined ? { slowMo: config.slowMo } : {}),
106
- });
107
- try {
108
- const context = await browser.newContext();
109
- return await runGraph(chain, undefined, context, mem);
110
- }
111
- finally {
112
- await browser.close();
113
- }
114
+ // Declared separately (not as the object-literal method below) so the
115
+ // three-overload `Flow.run` signature can be checked against the actual
116
+ // per-call-shape behavior, then attached with a cast - object-literal
117
+ // methods can't satisfy TS's per-overload implementation check the way a
118
+ // standalone function assigned to the property can.
119
+ const run = async function run(contextOrMem, memOrConfig, options) {
120
+ if (contextOrMem instanceof MemPage) {
121
+ const mem = contextOrMem;
122
+ const config = { ...engineConfig, ...memOrConfig };
123
+ // Checked again inside runGraph too, but doing it here first means a
124
+ // missing key fails before a browser process even launches, not just
125
+ // before a page opens - the same "fail loud, cheaply" guarantee
126
+ // run(context, mem) already has via an existing context.
127
+ preflight(mem, chain);
128
+ const browserName = config.browserName ?? "chromium";
129
+ const launch = LAUNCHERS[browserName];
130
+ // If CHROME_PATH/CHROMIUM_PATH is set in the environment (e.g. a system
131
+ // or Flatpak Chromium), launch that instead of Playwright's own bundled
132
+ // binary - only for chromium (the env var names a Chromium build, not a
133
+ // Firefox/WebKit one), and a no-op default anywhere that env var isn't set.
134
+ const executablePath = browserName === "chromium" ? process.env.CHROME_PATH || process.env.CHROMIUM_PATH : undefined;
135
+ const browser = await launch.launch({
136
+ headless: config.headless ?? true,
137
+ ...(config.slowMo !== undefined ? { slowMo: config.slowMo } : {}),
138
+ ...(executablePath ? { executablePath } : {}),
139
+ });
140
+ try {
141
+ const context = await browser.newContext();
142
+ return await runGraph(chain, undefined, context, mem);
114
143
  }
115
- return runGraph(chain, undefined, contextOrMem, memOrConfig);
116
- },
144
+ finally {
145
+ await browser.close();
146
+ }
147
+ }
148
+ const context = contextOrMem;
149
+ const mem = memOrConfig;
150
+ // Open (or reuse) the page here, not inside runGraph, so this method -
151
+ // not runGraph - decides whether to close it once the run is over; the
152
+ // page reference has to survive past runGraph's own return either way.
153
+ const gotOwnPage = options?.page === undefined;
154
+ const page = options?.page ?? (await context.newPage());
155
+ // Defaulting close-on-finish to `true` unconditionally would close a
156
+ // page the *caller* opened and handed in via `options.page` - the exact
157
+ // thing this option exists to let a caller keep driving. Only default to
158
+ // closing when this call opened the page itself (no `options.page`
159
+ // given), which is the one case that matches every `run(context, mem)`
160
+ // call from before this option existed. An explicit `closeOnFinish`
161
+ // always wins either way.
162
+ const closeOnFinish = options?.closeOnFinish ?? gotOwnPage;
163
+ const result = await runGraph(chain, undefined, context, mem, 5000, {
164
+ page,
165
+ closeOnFinish: false,
166
+ });
167
+ if (closeOnFinish) {
168
+ await page.close();
169
+ }
170
+ // The return *shape* only changes on an explicit `closeOnFinish: false`
171
+ // (the literal the `{ result, page }` overload is typed against) - not
172
+ // on the computed `closeOnFinish` default above. A caller who passed
173
+ // their own `page` and left `closeOnFinish` unset already holds that
174
+ // page reference; handing it back again in a different return shape
175
+ // would silently break the plain-`Out` overload their call actually
176
+ // matched at the type level.
177
+ if (options?.closeOnFinish === false) {
178
+ return { result, page };
179
+ }
180
+ return result;
181
+ };
182
+ return {
183
+ blocks: () => middle.map((b) => ({ name: b.name, block: b, ...(b.routes ? { routes: b.routes } : {}) })),
184
+ run: run,
117
185
  withBlockVerify(block, verify) {
118
186
  const index = findBlockIndex(middle, block);
119
187
  const patched = [...middle];
@@ -128,6 +196,29 @@ function buildFlow(middle, engineConfig) {
128
196
  },
129
197
  };
130
198
  }
199
+ export function composeBlock(name, steps) {
200
+ if (steps.length === 0) {
201
+ throw new Error(`composeBlock: "${name}" needs at least one step`);
202
+ }
203
+ const chain = steps.reduce((a, b) => connect(a, b));
204
+ return {
205
+ ...chain,
206
+ name,
207
+ steps: () => steps.map((b) => ({ name: b.name, block: b, ...(b.routes ? { routes: b.routes } : {}) })),
208
+ withStepVerify(step, verify) {
209
+ const index = findBlockIndex(steps, step);
210
+ const patched = [...steps];
211
+ patched[index] = withVerify(steps[index], verify);
212
+ return composeBlock(name, patched);
213
+ },
214
+ modStepVerify(step, nameOrIndex, newCheck) {
215
+ const index = findBlockIndex(steps, step);
216
+ const patched = [...steps];
217
+ patched[index] = modVerify(steps[index], nameOrIndex, newCheck);
218
+ return composeBlock(name, patched);
219
+ },
220
+ };
221
+ }
131
222
  /**
132
223
  * Holds engine-level configuration shared across every flow defined from it -
133
224
  * for now, just how to launch a browser when a Flow owns its own
@@ -160,6 +251,10 @@ export function branch(block, routes) {
160
251
  const base = stripMethods(block);
161
252
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
162
253
  let routed;
254
+ const routesAsData = {};
255
+ for (const [tag, target] of Object.entries(routes)) {
256
+ routesAsData[tag] = target === null ? null : target === block ? base.name : target.name;
257
+ }
163
258
  routed = defineBlock({
164
259
  ...base,
165
260
  next: (checkpoint) => {
@@ -171,6 +266,7 @@ export function branch(block, routes) {
171
266
  // the routed Block, not the original with no `next` of its own.
172
267
  return target === block ? routed : target;
173
268
  },
269
+ routes: routesAsData,
174
270
  });
175
271
  return routed;
176
272
  }
@@ -190,6 +286,7 @@ function stripMethods(block) {
190
286
  instruction: block.instruction,
191
287
  ...(block.next ? { next: block.next } : {}),
192
288
  ...(block.requires ? { requires: block.requires } : {}),
289
+ ...(block.routes ? { routes: block.routes } : {}),
193
290
  };
194
291
  }
195
292
  /**
@@ -207,6 +304,7 @@ export function defineBlock(base) {
207
304
  instruction: base.instruction,
208
305
  ...(base.next ? { next: base.next } : {}),
209
306
  ...(base.requires ? { requires: base.requires } : {}),
307
+ ...(base.routes ? { routes: base.routes } : {}),
210
308
  };
211
309
  return {
212
310
  ...plain,
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- export type { Checkpoint, Instruction, Block, DefinedBlock } from "./types.js";
1
+ export type { Checkpoint, Instruction, Block, DefinedBlock, Start } from "./types.js";
2
2
  export { connect, checkpoint } from "./types.js";
3
3
  export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
4
4
  export { Trait, urlMatches, textEquals, visible } from "./trait.js";
5
- export { runGraph, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, preflight, } from "./engine.js";
6
- export type { Flow, EngineConfig } from "./engine.js";
5
+ export { runGraph, spawnTab, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, composeBlock, preflight, } from "./engine.js";
6
+ export type { Flow, EngineConfig, ComposedBlock, RunGraphOptions, BlockInfo } from "./engine.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { connect, checkpoint } from "./types.js";
2
2
  export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
3
3
  export { Trait, urlMatches, textEquals, visible } from "./trait.js";
4
- export { runGraph, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, preflight, } from "./engine.js";
4
+ export { runGraph, spawnTab, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, composeBlock, preflight, } from "./engine.js";
package/dist/types.d.ts CHANGED
@@ -13,6 +13,16 @@ export type Checkpoint<Tag extends string> = {
13
13
  * @example resolve: () => checkpoint("LoggedIn")
14
14
  */
15
15
  export declare function checkpoint<Tag extends string>(tag: Tag): Checkpoint<Tag>;
16
+ /**
17
+ * The type every entry Block's `In` must be - `runGraph` always seeds a fresh
18
+ * run with `checkpoint("__start__")` as the fake initial input, so a Block
19
+ * meant to lead off a Flow needs exactly this type. Exported so no project
20
+ * has to know or hand-type the `"__start__"` literal itself - the same reason
21
+ * `checkpoint()` exists instead of every call site spelling `{ __state: tag }`
22
+ * by hand.
23
+ * @example export const LoginBlock: Block<Start, LoggedIn> = { ... }
24
+ */
25
+ export type Start = Checkpoint<"__start__">;
16
26
  /**
17
27
  * The four-phase pipeline every Block runs. Most Blocks only need `act` + a
18
28
  * one-line `resolve` - add `observe` only once a Block genuinely branches, add
@@ -69,6 +79,18 @@ export interface Block<In extends Checkpoint<string>, Out extends Checkpoint<str
69
79
  * self-loop - see `runGraph`'s `maxSteps` for the safety net that needs.
70
80
  */
71
81
  next?(checkpoint: Out): Block<any, any> | null | undefined;
82
+ /**
83
+ * `branch()`'s routing table as plain, readable data - Checkpoint tag ->
84
+ * target Block's `.name` (`null` for a terminal tag) - alongside {@link next}
85
+ * itself, which stays the actual runtime dispatch. `next` is an opaque
86
+ * closure a graph-introspection tool can't read; this is `next`'s own
87
+ * routing decisions exposed as data instead, purely additive and never
88
+ * consulted by `runGraph`/`connect` - it exists only for tools like
89
+ * `Flow`'s inspection method (and, eventually, `toMermaid`) to walk without
90
+ * executing anything. A route back to the Block itself (a self-loop) maps
91
+ * to that same Block's own name, not `null`.
92
+ */
93
+ routes?: Readonly<Record<string, string | null>>;
72
94
  /**
73
95
  * MemKeys this Block's `act` reads from `mem` and needs already set.
74
96
  * Declaring it lets {@link preflight} (built into `runGraph`) catch a missing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.0.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
6
  "main": "./dist/index.js",
@@ -11,8 +11,12 @@
11
11
  "default": "./dist/index.js"
12
12
  }
13
13
  },
14
+ "bin": {
15
+ "waygraph": "bin/waygraph"
16
+ },
14
17
  "files": [
15
- "dist"
18
+ "dist",
19
+ "bin"
16
20
  ],
17
21
  "repository": {
18
22
  "type": "git",
@@ -26,6 +30,9 @@
26
30
  "peerDependencies": {
27
31
  "@playwright/test": "^1.40.0"
28
32
  },
33
+ "dependencies": {
34
+ "tsx": "^4.0.0"
35
+ },
29
36
  "devDependencies": {
30
37
  "@playwright/test": "^1.55.0",
31
38
  "@types/node": "^26.0.0",