waygraph 0.4.0 → 0.5.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/README.md CHANGED
@@ -6,6 +6,9 @@ flat pile of ad hoc helper functions.
6
6
  This repo is the `waygraph` npm package itself. Consumer projects proving it against real
7
7
  sites/apps (saucedemo.com, zsign-app) live in the sibling `projects_waygraph/` repo, not here.
8
8
 
9
+ See [ROADMAP.md](./ROADMAP.md) for what's shipped, spec'd, and planned by version, and
10
+ `openspec/changes/` for the actual planning artifacts behind each roadmap item.
11
+
9
12
  ## Install
10
13
 
11
14
  ```bash
@@ -100,6 +103,22 @@ a fresh tab (the run won't close a page you handed it unless you say so), and
100
103
  so the caller can keep driving it - hands a run tab back instead of losing it. See
101
104
  "Recipes" for capture-on-popup, which stays a documented pattern until a second real use
102
105
  case promotes it to an engine API.
106
+ `defineNavBlock({ name, checkpoint, url })` builds a Block whose only possible action is
107
+ navigating - `url` accepts a plain string or `(mem) => string` for parameterized routes,
108
+ and the generated `act()` is always exactly `page.goto(url)`, never author-supplied. A
109
+ regular `defineBlock`'s `act()` receives its `page` typed as `ActionPage` instead of raw
110
+ `Page` - structurally identical, every method still fully present and callable, except
111
+ `goto`/`reload`/`goBack`/`goForward` are re-declared `@deprecated`, so a TypeScript-aware
112
+ editor shows navigation struck through the instant it's typed in a regular Block, pointing
113
+ at `defineNavBlock` - nothing is blocked, no build ever fails, existing code that already
114
+ navigates from a regular Block keeps compiling and running unchanged. `waygraph check
115
+ [project]` is the complementary whole-project sweep for contexts with no editor watching
116
+ (CI, generated code) - same warning, on demand, across every `*.block.ts` file at once.
117
+ `new Engine({ browsers: { chromium, firefox, webkit } })` overrides which `BrowserType`
118
+ actually launches for a `mem`-only run, per browser name - waygraph is deliberately "just
119
+ an opinionated Playwright," so a stealth-patched or otherwise customized launcher (e.g.
120
+ `playwright-extra` plus a stealth plugin) drops in unmodified, since those already expose
121
+ the same `.launch()` shape as Playwright's own `chromium`/`firefox`/`webkit`.
103
122
 
104
123
  ```bash
105
124
  npx create-waygraph my-project && cd my-project && npm install && npm test
package/dist/cli.js CHANGED
@@ -1687,6 +1687,64 @@ async function validateFlows(projectDir) {
1687
1687
  return results;
1688
1688
  }
1689
1689
  // ---------------------------------------------------------------------------
1690
+ // `check` - secondary/complementary to the ActionPage @deprecated warning
1691
+ // (src/types.ts). That's the primary, always-on signal an editor shows the
1692
+ // instant a regular Block's act() calls a navigation method; this command
1693
+ // sweeps a whole project in one shot for contexts with no editor watching
1694
+ // (CI, an autonomous agent writing Block files without a language server).
1695
+ // See openspec/changes/nav-block-and-check/ for the full rationale.
1696
+ // ---------------------------------------------------------------------------
1697
+ function discoverBlocks(projectDir) {
1698
+ return walkDir(projectDir, /\.block\.ts$/);
1699
+ }
1700
+ function isBlockLike(val) {
1701
+ if (val === null || typeof val !== "object")
1702
+ return false;
1703
+ const obj = val;
1704
+ if (typeof obj.name !== "string")
1705
+ return false;
1706
+ const instruction = obj.instruction;
1707
+ return instruction !== null && typeof instruction === "object" && typeof instruction?.act === "function";
1708
+ }
1709
+ /** Set by `defineNavBlock` (non-enumerable) - see src/engine.ts. */
1710
+ function isNavBlockMarked(val) {
1711
+ return val.__waygraphKind === "nav";
1712
+ }
1713
+ const NAV_METHOD_CALLS = ["page.goto(", "page.reload(", "page.goBack(", "page.goForward("];
1714
+ /**
1715
+ * Walks every `*.block.ts` file under `projectDir`, imports each to find its
1716
+ * exported Blocks, and for any Block NOT built via `defineNavBlock`, scans
1717
+ * that file's own source text for a navigation call. Warning only - never
1718
+ * throws, never changes the caller's exit code on its own account (a Block
1719
+ * file that fails to import for unrelated reasons is silently skipped here;
1720
+ * `validate` is the command that reports import failures).
1721
+ */
1722
+ async function checkCommand(projectDir) {
1723
+ const files = discoverBlocks(projectDir);
1724
+ const warnings = [];
1725
+ for (const file of files) {
1726
+ let mod;
1727
+ try {
1728
+ mod = await importModule(file);
1729
+ }
1730
+ catch {
1731
+ continue;
1732
+ }
1733
+ let src;
1734
+ for (const [exportName, exported] of Object.entries(mod)) {
1735
+ if (!isBlockLike(exported))
1736
+ continue;
1737
+ if (isNavBlockMarked(exported))
1738
+ continue;
1739
+ src ??= readFileSync(file, "utf-8");
1740
+ if (NAV_METHOD_CALLS.some((needle) => src.includes(needle))) {
1741
+ warnings.push({ file, blockName: exported.name, exportName });
1742
+ }
1743
+ }
1744
+ }
1745
+ return warnings;
1746
+ }
1747
+ // ---------------------------------------------------------------------------
1690
1748
  // `run`
1691
1749
  // ---------------------------------------------------------------------------
1692
1750
  async function runFlow(projectDir, flowName) {
@@ -1725,6 +1783,13 @@ Usage:
1725
1783
  waygraph validate [project] Import and validate all flows
1726
1784
  waygraph run <flow> [project] Run a named flow
1727
1785
  waygraph chain <spec> [project] Run one or more Blocks by name, ad hoc
1786
+ waygraph check [project] Warn about navigation outside a NavBlock
1787
+
1788
+ "check" is a secondary sweep, complementary to the always-on TypeScript
1789
+ warning every regular Block's act() already gets (a struck-through
1790
+ page.goto/reload/goBack/goForward, pointing at defineNavBlock) - useful
1791
+ where nothing is watching in an editor (CI, generated code). Warning
1792
+ only, never fails - see openspec/changes/nav-block-and-check/.
1728
1793
 
1729
1794
  "chain" needs no .flow.ts file - reference Blocks (by their own
1730
1795
  runtime .name, or their export identifier) straight from the command
@@ -1844,6 +1909,23 @@ async function main() {
1844
1909
  await runChain(proj, spec);
1845
1910
  break;
1846
1911
  }
1912
+ case "check": {
1913
+ const proj = resolve(args[1] ?? process.cwd());
1914
+ const warnings = await checkCommand(proj);
1915
+ if (warnings.length === 0) {
1916
+ console.log(`waygraph check: no navigation found outside NavBlocks under ${proj}`);
1917
+ }
1918
+ else {
1919
+ for (const w of warnings) {
1920
+ const rel = relative(proj, w.file);
1921
+ console.warn(`waygraph check: ${rel} (${w.blockName}) calls page.goto/reload/goBack/goForward outside a NavBlock - consider defineNavBlock instead`);
1922
+ }
1923
+ console.log(`waygraph check: ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`);
1924
+ }
1925
+ // Warning only - never fails the command, matching the spec's
1926
+ // "never a build failure" requirement.
1927
+ break;
1928
+ }
1847
1929
  default:
1848
1930
  console.error(`waygraph: unknown command "${command}"`);
1849
1931
  process.exit(1);
package/dist/engine.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- import type { BrowserContext, Page, Locator } from "@playwright/test";
2
- import type { Block, Checkpoint, Instruction, DefinedBlock } from "./types.js";
1
+ import type { BrowserContext, BrowserType, Page, Locator } from "@playwright/test";
2
+ import type { Block, Checkpoint, Instruction, DefinedBlock, ActionPage, WaygraphHighlight } from "./types.js";
3
3
  import { MemPage } from "./mem-page.js";
4
+ import type { MemKey } from "./mem-page.js";
4
5
  import type { Trait } from "./trait.js";
5
6
  /**
6
7
  * Checks `block.requires` against `mem` and throws, naming every missing key at
@@ -313,6 +314,18 @@ export interface EngineConfig {
313
314
  browserName?: "chromium" | "firefox" | "webkit";
314
315
  /** Milliseconds Playwright pauses before each operation - for watching a non-headless run with your own eyes, not for real runs. Default `0`. */
315
316
  slowMo?: number;
317
+ /**
318
+ * Overrides which `BrowserType` actually launches, per browser name -
319
+ * default is the real `@playwright/test` export for whichever one
320
+ * `browserName` picks. Accepts anything shaped like Playwright's own
321
+ * `chromium`/`firefox`/`webkit` (same `.launch()` signature) - a
322
+ * stealth-patched or otherwise customized launcher (e.g. `playwright-extra`
323
+ * plus a stealth plugin) drops in here unmodified. Puppeteer is a
324
+ * different `Page`/`BrowserContext` shape entirely and isn't supported by
325
+ * this seam.
326
+ * @example new Engine({ browsers: { chromium: stealthChromium } })
327
+ */
328
+ browsers?: Partial<Record<"chromium" | "firefox" | "webkit", BrowserType>>;
316
329
  }
317
330
  type S = Checkpoint<"__start__">;
318
331
  /**
@@ -385,11 +398,48 @@ export declare function branch<In extends Checkpoint<string>, Out extends Checkp
385
398
  export declare function defineBlock<In extends Checkpoint<string>, Out extends Checkpoint<string>>(base: {
386
399
  name: string;
387
400
  description?: Block<In, Out>["description"];
388
- instruction: Instruction<In, Out, any>;
401
+ instruction: Omit<Instruction<In, Out, any>, "act"> & {
402
+ act(page: ActionPage, input: In, mem: MemPage): Promise<void>;
403
+ };
389
404
  next?: Block<In, Out>["next"];
390
405
  requires?: Block<In, Out>["requires"];
391
406
  routes?: Block<In, Out>["routes"];
392
407
  }): DefinedBlock<In, Out>;
408
+ /**
409
+ * A Block whose only possible action is navigating to a URL - built via
410
+ * {@link defineNavBlock}, never with a hand-authored `act()`. Extends `Block`
411
+ * (same shape `DefinedBlock`/`ComposedBlock` already use), so it drops into
412
+ * `defineFlow([...])`, `connect()`, `composeBlock()` exactly like any other
413
+ * Block - no special-casing needed anywhere that only expects a `Block`.
414
+ * See `openspec/changes/nav-block-and-check/` for why this exists.
415
+ */
416
+ export type NavBlock<Out extends Checkpoint<string>> = DefinedBlock<Checkpoint<string>, Out>;
417
+ /**
418
+ * Builds a {@link NavBlock}. `url` accepts a plain string or a function
419
+ * `(mem) => string` for parameterized routes (e.g. `/dashboard/requests/:id`
420
+ * with a real id read from mem). `checkpoint` is the tag this NavBlock
421
+ * resolves to once navigation completes - a NavBlock never branches on
422
+ * observed evidence the way a regular Block can, so there's no `resolve`
423
+ * logic to author; it always just declares "arrived."
424
+ *
425
+ * The generated `act()` is always exactly `page.goto(url)` - never anything
426
+ * else, never author-supplied. Callers get a real `Page` here internally
427
+ * (the one legitimate place navigation belongs); `defineBlock`'s own
428
+ * `ActionPage` narrowing is what discourages navigation everywhere else,
429
+ * and never applies to this generated function since no author ever writes
430
+ * it by hand.
431
+ * @example defineNavBlock({ name: "nav-web-login", checkpoint: "LoginForm", url: "/login" })
432
+ * @example defineNavBlock({ name: "nav-web-request-detail", checkpoint: "RequestDetail", url: (mem) => `/dashboard/requests/${mem.get(RequestId.key)}` })
433
+ */
434
+ export declare function defineNavBlock<Out extends Checkpoint<string>>(options: {
435
+ name: string;
436
+ description?: string;
437
+ checkpoint: Out["__state"];
438
+ url: string | ((mem: MemPage) => string);
439
+ requires?: readonly MemKey<any>[];
440
+ verify?: Trait[] | ((out: Out) => Trait[]);
441
+ highlights?: readonly WaygraphHighlight[] | ((out: Out) => readonly WaygraphHighlight[]);
442
+ }): NavBlock<Out>;
393
443
  /**
394
444
  * Returns a new Block that runs exactly like `block` - same act/observe/resolve,
395
445
  * same routing - but with `verify` replaced. For giving one flow stricter (or
package/dist/engine.js CHANGED
@@ -3,6 +3,20 @@ import { connect, checkpoint } from "./types.js";
3
3
  import { MemPage } from "./mem-page.js";
4
4
  import { runVerify } from "./trait.js";
5
5
  const LAUNCHERS = { chromium, firefox, webkit };
6
+ /**
7
+ * Resolves which `BrowserType` actually launches for a `mem`-only run (no
8
+ * caller-supplied context) - `config.browsers[browserName]` if given,
9
+ * otherwise the real `@playwright/test` export. Waygraph is deliberately
10
+ * "just an opinionated Playwright" - it doesn't own browser automation
11
+ * itself, so it shouldn't force every consumer onto the vanilla launcher.
12
+ * A stealth-patched or otherwise customized launcher (e.g. `playwright-extra`
13
+ * plus a stealth plugin) is a drop-in replacement for `chromium`/`firefox`/
14
+ * `webkit` - same `.launch()` shape - so accepting one here costs nothing for
15
+ * a consumer who never sets it.
16
+ */
17
+ function resolveLauncher(browserName, config) {
18
+ return config?.browsers?.[browserName] ?? LAUNCHERS[browserName];
19
+ }
6
20
  /**
7
21
  * Checks `block.requires` against `mem` and throws, naming every missing key at
8
22
  * once, if any are unset. `connect()` unions each Block's `requires` into the
@@ -218,7 +232,7 @@ function buildFlow(middle, engineConfig) {
218
232
  // run(context, mem) already has via an existing context.
219
233
  preflight(mem, chain);
220
234
  const browserName = config.browserName ?? "chromium";
221
- const launch = LAUNCHERS[browserName];
235
+ const launch = resolveLauncher(browserName, config);
222
236
  // If CHROME_PATH/CHROMIUM_PATH is set in the environment (e.g. a system
223
237
  // or Flatpak Chromium), launch that instead of Playwright's own bundled
224
238
  // binary - only for chromium (the env var names a Chromium build, not a
@@ -391,7 +405,7 @@ export function chainFlow(...flows) {
391
405
  // the point of sequencing them (a fresh browser is itself a full
392
406
  // session reset, silently making withSessionReset a no-op).
393
407
  const browserName = config?.browserName ?? "chromium";
394
- const launch = LAUNCHERS[browserName];
408
+ const launch = resolveLauncher(browserName, config);
395
409
  const executablePath = browserName === "chromium" ? process.env.CHROME_PATH || process.env.CHROMIUM_PATH : undefined;
396
410
  const browser = await launch.launch({
397
411
  headless: config?.headless ?? true,
@@ -568,6 +582,50 @@ export function defineBlock(base) {
568
582
  modVerifyAll: (patches) => modVerifyAll(plain, patches),
569
583
  };
570
584
  }
585
+ /**
586
+ * Builds a {@link NavBlock}. `url` accepts a plain string or a function
587
+ * `(mem) => string` for parameterized routes (e.g. `/dashboard/requests/:id`
588
+ * with a real id read from mem). `checkpoint` is the tag this NavBlock
589
+ * resolves to once navigation completes - a NavBlock never branches on
590
+ * observed evidence the way a regular Block can, so there's no `resolve`
591
+ * logic to author; it always just declares "arrived."
592
+ *
593
+ * The generated `act()` is always exactly `page.goto(url)` - never anything
594
+ * else, never author-supplied. Callers get a real `Page` here internally
595
+ * (the one legitimate place navigation belongs); `defineBlock`'s own
596
+ * `ActionPage` narrowing is what discourages navigation everywhere else,
597
+ * and never applies to this generated function since no author ever writes
598
+ * it by hand.
599
+ * @example defineNavBlock({ name: "nav-web-login", checkpoint: "LoginForm", url: "/login" })
600
+ * @example defineNavBlock({ name: "nav-web-request-detail", checkpoint: "RequestDetail", url: (mem) => `/dashboard/requests/${mem.get(RequestId.key)}` })
601
+ */
602
+ export function defineNavBlock(options) {
603
+ const built = defineBlock({
604
+ name: options.name,
605
+ ...(options.description ? { description: options.description } : {}),
606
+ ...(options.requires ? { requires: options.requires } : {}),
607
+ instruction: {
608
+ async act(page, _input, mem) {
609
+ const url = typeof options.url === "function" ? options.url(mem) : options.url;
610
+ await page.goto(url);
611
+ },
612
+ resolve: () => checkpoint(options.checkpoint),
613
+ ...(options.verify ? { verify: options.verify } : {}),
614
+ ...(options.highlights ? { highlights: options.highlights } : {}),
615
+ },
616
+ });
617
+ // Non-enumerable so it never shows up in Object.keys/JSON/autocomplete -
618
+ // purely a runtime marker `waygraph check` (or anything else walking a
619
+ // project's Blocks) can test for to tell a NavBlock apart from a regular
620
+ // one, since the TypeScript type alone (NavBlock = DefinedBlock) doesn't
621
+ // survive to runtime.
622
+ Object.defineProperty(built, "__waygraphKind", {
623
+ value: "nav",
624
+ enumerable: false,
625
+ configurable: false,
626
+ });
627
+ return built;
628
+ }
571
629
  /**
572
630
  * Returns a new Block that runs exactly like `block` - same act/observe/resolve,
573
631
  * same routing - but with `verify` replaced. For giving one flow stricter (or
package/dist/index.d.ts CHANGED
@@ -2,5 +2,6 @@ export type { Checkpoint, Instruction, Block, DefinedBlock, Start } from "./type
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, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, composeBlock, preflight, withSessionReset, withTitle, chainFlow, } from "./engine.js";
6
- export type { Flow, EngineConfig, ComposedBlock, RunGraphOptions, BlockInfo } from "./engine.js";
5
+ export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, composeBlock, preflight, withSessionReset, withTitle, chainFlow, } from "./engine.js";
6
+ export type { Flow, EngineConfig, ComposedBlock, RunGraphOptions, BlockInfo, NavBlock } from "./engine.js";
7
+ export type { ActionPage } from "./types.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, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, composeBlock, preflight, withSessionReset, withTitle, chainFlow, } from "./engine.js";
4
+ export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, composeBlock, preflight, withSessionReset, withTitle, chainFlow, } from "./engine.js";
package/dist/types.d.ts CHANGED
@@ -23,6 +23,29 @@ export declare function checkpoint<Tag extends string>(tag: Tag): Checkpoint<Tag
23
23
  * @example export const LoginBlock: Block<Start, LoggedIn> = { ... }
24
24
  */
25
25
  export type Start = Checkpoint<"__start__">;
26
+ /**
27
+ * The `page` a regular Block's `act()` is typed against via `defineBlock` -
28
+ * structurally identical to Playwright's own `Page` (the same real object is
29
+ * passed at runtime; this is a type-only narrowing), except `goto`, `reload`,
30
+ * `goBack`, and `goForward` are re-declared under `@deprecated`. Nothing is
31
+ * blocked - every method is still fully present and callable, and this never
32
+ * causes a build to fail - but a TypeScript-aware editor shows the call
33
+ * struck through with a hover warning the instant it's typed, pointing at
34
+ * `defineNavBlock` instead. `NavBlock`'s own generated `act()` (the one place
35
+ * navigation actually belongs) uses the real `Page`, not this type - that
36
+ * distinction lives entirely inside `defineNavBlock` and never reaches a
37
+ * NavBlock's own author, who never writes its `act()` by hand.
38
+ */
39
+ export type ActionPage = Omit<Page, "goto" | "reload" | "goBack" | "goForward"> & {
40
+ /** @deprecated Navigation doesn't belong in a regular Block's act() - use defineNavBlock instead. */
41
+ goto: Page["goto"];
42
+ /** @deprecated Navigation doesn't belong in a regular Block's act() - use defineNavBlock instead. */
43
+ reload: Page["reload"];
44
+ /** @deprecated Navigation doesn't belong in a regular Block's act() - use defineNavBlock instead. */
45
+ goBack: Page["goBack"];
46
+ /** @deprecated Navigation doesn't belong in a regular Block's act() - use defineNavBlock instead. */
47
+ goForward: Page["goForward"];
48
+ };
26
49
  /**
27
50
  * The four-phase pipeline every Block runs. Most Blocks only need `act` + a
28
51
  * one-line `resolve` - add `observe` only once a Block genuinely branches, add
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",