waygraph 0.6.0 → 0.7.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
@@ -103,9 +103,16 @@ a fresh tab (the run won't close a page you handed it unless you say so), and
103
103
  so the caller can keep driving it - hands a run tab back instead of losing it. See
104
104
  "Recipes" for capture-on-popup, which stays a documented pattern until a second real use
105
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
106
+ `defineNavBlock({ name, checkpoint, url })` or `defineNavBlock({ name, checkpoint, click })`
107
+ builds a Block whose only possible action is navigating - exactly one of `url`/`click` is
108
+ required, enforced at compile time. Both accept a plain string or `(mem) => string`/
109
+ `(mem) => selector` for parameterized routes; the generated `act()` is always exactly
110
+ `page.goto(url)` or `page.locator(click).click()`, never author-supplied. `click` is the
111
+ opinionated default - navigating by clicking a real element already on the page (a nav
112
+ link, a sidebar item) instead of teleporting straight to a URL, so a Block actually proves
113
+ the app's own navigation path works instead of skipping past it. `url` still exists for the
114
+ real cases with no click path to get there (a deep link from an email, a public share
115
+ link). A
109
116
  regular `defineBlock`'s `act()` receives its `page` typed as `ActionPage` instead of raw
110
117
  `Page` - structurally identical, every method still fully present and callable, except
111
118
  `goto`/`reload`/`goBack`/`goForward` are re-declared `@deprecated`, so a TypeScript-aware
package/dist/cli.js CHANGED
@@ -2003,11 +2003,9 @@ function packageRoot() {
2003
2003
  * directory the real demo files get copied into, so it resolves "waygraph"
2004
2004
  * and "@playwright/test" exactly as they will - and actually launches (then
2005
2005
  * immediately closes) chromium, a real check rather than a guess at whether
2006
- * the browser's installed. Returns null when everything's ready, or a
2007
- * friendly, actionable message naming exactly what to install when it
2008
- * isn't. Never leaves the probe script behind.
2006
+ * the browser's installed. Never leaves the probe script behind.
2009
2007
  */
2010
- async function checkTryPrereqs(projectDir) {
2008
+ async function probeTryPrereqs(projectDir) {
2011
2009
  const tsxEsm = import.meta.resolve("tsx/esm");
2012
2010
  const probeScript = [
2013
2011
  "try {",
@@ -2050,27 +2048,80 @@ async function checkTryPrereqs(projectDir) {
2050
2048
  child.on("exit", (code) => res({ code: code ?? 1, stderr: stderrBuf }));
2051
2049
  });
2052
2050
  if (result.code === 0)
2053
- return null;
2051
+ return { ok: true };
2054
2052
  const stderr = result.stderr;
2055
- if (stderr.includes("WAYGRAPH_TRY_MISSING_WAYGRAPH")) {
2056
- return `"waygraph" isn't resolvable as a dependency from ${projectDir}.\nRun this first, inside that directory:\n npm install waygraph\n npm install -D @playwright/test\n npx playwright install chromium`;
2057
- }
2058
- if (stderr.includes("WAYGRAPH_TRY_MISSING_PLAYWRIGHT_TEST")) {
2059
- return `"@playwright/test" isn't resolvable as a dependency from ${projectDir}.\nRun this first, inside that directory:\n npm install -D @playwright/test\n npx playwright install chromium`;
2060
- }
2061
- if (stderr.includes("WAYGRAPH_TRY_MISSING_BROWSER")) {
2062
- return "Playwright's chromium browser isn't installed yet.\nRun this first:\n npx playwright install chromium";
2063
- }
2064
- return `couldn't verify prerequisites:\n${stderr.trim()}`;
2053
+ if (stderr.includes("WAYGRAPH_TRY_MISSING_WAYGRAPH"))
2054
+ return { ok: false, missing: "waygraph" };
2055
+ if (stderr.includes("WAYGRAPH_TRY_MISSING_PLAYWRIGHT_TEST"))
2056
+ return { ok: false, missing: "playwright-test" };
2057
+ if (stderr.includes("WAYGRAPH_TRY_MISSING_BROWSER"))
2058
+ return { ok: false, missing: "browser" };
2059
+ return { ok: false, missing: "unknown", detail: stderr.trim() || `probe exited with code ${result.code}` };
2065
2060
  }
2066
2061
  finally {
2067
2062
  rmSync(probePath, { force: true });
2068
2063
  }
2069
2064
  }
2065
+ /** Spawns with inherited stdio (the real npm/playwright output, visible) and resolves to its exit code. */
2066
+ function runInherited(cmd, args, cwd) {
2067
+ return new Promise((res, rej) => {
2068
+ const child = spawn(cmd, args, { cwd, stdio: "inherit", env: process.env });
2069
+ child.on("error", rej);
2070
+ child.on("exit", (code) => res(code ?? 1));
2071
+ });
2072
+ }
2073
+ /**
2074
+ * `waygraph try` is meant to be a genuine one-shot `npx waygraph try` -
2075
+ * no separate init/install step the human has to remember, the same shape
2076
+ * as `npm create vite@latest`. Probes first; anything missing gets
2077
+ * installed right here (a minimal package.json if none exists, then
2078
+ * `npm install waygraph @playwright/test`, then Playwright's own chromium
2079
+ * download), with the real install output visible the whole time - not
2080
+ * silent, just not a separate step you have to run yourself first. Returns
2081
+ * null once everything's actually ready, or a message naming whatever
2082
+ * still isn't after really trying.
2083
+ */
2084
+ async function ensureTryPrereqs(projectDir) {
2085
+ let status = await probeTryPrereqs(projectDir);
2086
+ if (status.ok)
2087
+ return null;
2088
+ if (status.missing === "unknown") {
2089
+ return `couldn't verify prerequisites:\n${status.detail}`;
2090
+ }
2091
+ if (status.missing === "waygraph" || status.missing === "playwright-test") {
2092
+ if (!existsSync(join(projectDir, "package.json"))) {
2093
+ writeFileSync(join(projectDir, "package.json"), JSON.stringify({ name: basename(projectDir) || "waygraph-quickstart", private: true, type: "module" }, null, 2) + "\n");
2094
+ console.log(`waygraph try: no package.json in ${projectDir} - created a minimal one.`);
2095
+ }
2096
+ console.log(`waygraph try: installing waygraph + @playwright/test in ${projectDir} ...`);
2097
+ const installCode = await runInherited("npm", ["install", "waygraph", "@playwright/test"], projectDir);
2098
+ if (installCode !== 0) {
2099
+ return `npm install exited with code ${installCode} - see the output above.`;
2100
+ }
2101
+ status = await probeTryPrereqs(projectDir);
2102
+ if (status.ok)
2103
+ return null;
2104
+ if (status.missing === "unknown")
2105
+ return `couldn't verify prerequisites:\n${status.detail}`;
2106
+ }
2107
+ // Only the browser can still be missing at this point.
2108
+ console.log("waygraph try: downloading Playwright's chromium browser ...");
2109
+ const localPlaywrightBin = join(projectDir, "node_modules", ".bin", "playwright");
2110
+ const browserCode = existsSync(localPlaywrightBin)
2111
+ ? await runInherited(localPlaywrightBin, ["install", "chromium"], projectDir)
2112
+ : await runInherited("npx", ["--yes", "playwright", "install", "chromium"], projectDir);
2113
+ if (browserCode !== 0) {
2114
+ return `"playwright install chromium" exited with code ${browserCode} - see the output above.`;
2115
+ }
2116
+ status = await probeTryPrereqs(projectDir);
2117
+ if (status.ok)
2118
+ return null;
2119
+ return status.detail ?? "prerequisites still aren't ready after attempting to install them.";
2120
+ }
2070
2121
  async function runTry(projectDir) {
2071
- const problem = await checkTryPrereqs(projectDir);
2122
+ const problem = await ensureTryPrereqs(projectDir);
2072
2123
  if (problem) {
2073
- console.error(`waygraph try: not ready yet.\n${problem}`);
2124
+ console.error(`waygraph try: couldn't get ready.\n${problem}`);
2074
2125
  process.exitCode = 1;
2075
2126
  return;
2076
2127
  }
@@ -2208,16 +2259,20 @@ Usage:
2208
2259
  waygraph run <flow> [project] Run a named flow
2209
2260
  waygraph chain <spec> [project] Run one or more Blocks by name, ad hoc
2210
2261
  waygraph check [project] Warn about navigation outside a NavBlock
2211
- waygraph try [project] Quickstart showcase - no setup beyond
2212
- "waygraph" + "@playwright/test" installed
2213
- in [project] (default: cwd) and its
2214
- chromium browser downloaded. Checks both
2215
- for real (a real launch+close, not a
2216
- guess) and tells you exactly what to run
2217
- if either's missing, instead of a raw
2218
- Playwright stack trace. If ready, copies a
2219
- small real Flow (nav/action Blocks, Trait
2220
- verify, narrate()) into
2262
+ waygraph try [project] One-shot quickstart showcase - genuinely
2263
+ "npx waygraph try", no separate init step.
2264
+ Checks (a real launch+close, not a guess)
2265
+ whether "waygraph" + "@playwright/test"
2266
+ resolve from [project] (default: cwd) and
2267
+ its chromium browser is downloaded; if
2268
+ anything's missing, installs it right
2269
+ there (writes a minimal package.json if
2270
+ [project] doesn't have one yet, then
2271
+ npm install, then the browser download),
2272
+ real output visible the whole time. Once
2273
+ ready, copies a small real Flow
2274
+ (nav/action Blocks, Trait verify,
2275
+ narrate()) into
2221
2276
  [project]/.waygraph-quickstart/ and runs
2222
2277
  it in step mode against a public demo
2223
2278
  site - then prints the absolute path to
package/dist/engine.d.ts CHANGED
@@ -444,31 +444,59 @@ export declare function defineBlock<In extends Checkpoint<string>, Out extends C
444
444
  */
445
445
  export type NavBlock<Out extends Checkpoint<string>> = DefinedBlock<Checkpoint<string>, Out>;
446
446
  /**
447
- * Builds a {@link NavBlock}. `url` accepts a plain string or a function
448
- * `(mem) => string` for parameterized routes (e.g. `/dashboard/requests/:id`
449
- * with a real id read from mem). `checkpoint` is the tag this NavBlock
447
+ * `defineNavBlock`'s own options - exactly one of `url`/`click` required,
448
+ * enforced at compile time (giving both, or neither, is a type error - see
449
+ * `typecheck/nav-block.types.ts`). `checkpoint` is the tag this NavBlock
450
450
  * resolves to once navigation completes - a NavBlock never branches on
451
451
  * observed evidence the way a regular Block can, so there's no `resolve`
452
452
  * logic to author; it always just declares "arrived."
453
- *
454
- * The generated `act()` is always exactly `page.goto(url)` - never anything
455
- * else, never author-supplied. Callers get a real `Page` here internally
456
- * (the one legitimate place navigation belongs); `defineBlock`'s own
457
- * `ActionPage` narrowing is what discourages navigation everywhere else,
458
- * and never applies to this generated function since no author ever writes
459
- * it by hand.
460
- * @example defineNavBlock({ name: "nav-web-login", checkpoint: "LoginForm", url: "/login" })
461
- * @example defineNavBlock({ name: "nav-web-request-detail", checkpoint: "RequestDetail", url: (mem) => `/dashboard/requests/${mem.get(RequestId.key)}` })
462
453
  */
463
- export declare function defineNavBlock<Out extends Checkpoint<string>>(options: {
454
+ export type NavBlockOptions<Out extends Checkpoint<string>> = {
464
455
  name: string;
465
456
  description?: string;
466
457
  checkpoint: Out["__state"];
467
- url: string | ((mem: MemPage) => string);
468
458
  requires?: readonly MemKey<any>[];
469
459
  verify?: Trait[] | ((out: Out) => Trait[]);
470
460
  highlights?: readonly WaygraphHighlight[] | ((out: Out) => readonly WaygraphHighlight[]);
471
- }): NavBlock<Out>;
461
+ } & ({
462
+ /**
463
+ * Direct URL navigation. Accepts a plain string or a function
464
+ * `(mem) => string` for parameterized routes (e.g.
465
+ * `/dashboard/requests/:id` with a real id read from mem). The
466
+ * escape hatch for the few real cases with no click path to get
467
+ * there (a deep link from an email, a public share link) - not for
468
+ * skipping past navigation an agent didn't feel like finding. Prefer
469
+ * `click` whenever the target app actually has one.
470
+ */
471
+ url: string | ((mem: MemPage) => string);
472
+ click?: never;
473
+ } | {
474
+ /**
475
+ * Navigates by clicking a real element already on the page (a nav
476
+ * link, a sidebar item) instead of teleporting straight to a URL -
477
+ * the opinionated default. Accepts a Playwright selector string, or
478
+ * a function `(mem) => selector` for a target that depends on mem
479
+ * (e.g. clicking the row for a specific id). Verifying arrival is
480
+ * `verify`'s job (typically `Trait.url(...)`), same as a `url`-based
481
+ * NavBlock - the click itself is only ever awaited for its own
482
+ * completion, not for whatever navigation it triggers.
483
+ */
484
+ click: string | ((mem: MemPage) => string);
485
+ url?: never;
486
+ });
487
+ /**
488
+ * Builds a {@link NavBlock}. The generated `act()` is always exactly
489
+ * `page.goto(url)` or `page.locator(click).click()` - never anything else,
490
+ * never author-supplied. Callers get a real `Page` here internally (the one
491
+ * legitimate place navigation belongs); `defineBlock`'s own `ActionPage`
492
+ * narrowing is what discourages navigation everywhere else, and never
493
+ * applies to this generated function since no author ever writes it by
494
+ * hand.
495
+ * @example defineNavBlock({ name: "nav-web-login", checkpoint: "LoginForm", url: "/login" })
496
+ * @example defineNavBlock({ name: "nav-web-request-detail", checkpoint: "RequestDetail", url: (mem) => `/dashboard/requests/${mem.get(RequestId.key)}` })
497
+ * @example defineNavBlock({ name: "nav-web-documents", checkpoint: "DocumentsList", click: "nav >> text=Documents" })
498
+ */
499
+ export declare function defineNavBlock<Out extends Checkpoint<string>>(options: NavBlockOptions<Out>): NavBlock<Out>;
472
500
  /**
473
501
  * Returns a new Block that runs exactly like `block` - same act/observe/resolve,
474
502
  * same routing - but with `verify` replaced. For giving one flow stricter (or
package/dist/engine.js CHANGED
@@ -609,21 +609,16 @@ export function defineBlock(base) {
609
609
  };
610
610
  }
611
611
  /**
612
- * Builds a {@link NavBlock}. `url` accepts a plain string or a function
613
- * `(mem) => string` for parameterized routes (e.g. `/dashboard/requests/:id`
614
- * with a real id read from mem). `checkpoint` is the tag this NavBlock
615
- * resolves to once navigation completes - a NavBlock never branches on
616
- * observed evidence the way a regular Block can, so there's no `resolve`
617
- * logic to author; it always just declares "arrived."
618
- *
619
- * The generated `act()` is always exactly `page.goto(url)` - never anything
620
- * else, never author-supplied. Callers get a real `Page` here internally
621
- * (the one legitimate place navigation belongs); `defineBlock`'s own
622
- * `ActionPage` narrowing is what discourages navigation everywhere else,
623
- * and never applies to this generated function since no author ever writes
624
- * it by hand.
612
+ * Builds a {@link NavBlock}. The generated `act()` is always exactly
613
+ * `page.goto(url)` or `page.locator(click).click()` - never anything else,
614
+ * never author-supplied. Callers get a real `Page` here internally (the one
615
+ * legitimate place navigation belongs); `defineBlock`'s own `ActionPage`
616
+ * narrowing is what discourages navigation everywhere else, and never
617
+ * applies to this generated function since no author ever writes it by
618
+ * hand.
625
619
  * @example defineNavBlock({ name: "nav-web-login", checkpoint: "LoginForm", url: "/login" })
626
620
  * @example defineNavBlock({ name: "nav-web-request-detail", checkpoint: "RequestDetail", url: (mem) => `/dashboard/requests/${mem.get(RequestId.key)}` })
621
+ * @example defineNavBlock({ name: "nav-web-documents", checkpoint: "DocumentsList", click: "nav >> text=Documents" })
627
622
  */
628
623
  export function defineNavBlock(options) {
629
624
  const built = defineBlock({
@@ -632,8 +627,14 @@ export function defineNavBlock(options) {
632
627
  ...(options.requires ? { requires: options.requires } : {}),
633
628
  instruction: {
634
629
  async act(page, _input, mem) {
635
- const url = typeof options.url === "function" ? options.url(mem) : options.url;
636
- await page.goto(url);
630
+ if (options.url !== undefined) {
631
+ const url = typeof options.url === "function" ? options.url(mem) : options.url;
632
+ await page.goto(url);
633
+ }
634
+ else {
635
+ const selector = typeof options.click === "function" ? options.click(mem) : options.click;
636
+ await page.locator(selector).click();
637
+ }
637
638
  },
638
639
  resolve: () => checkpoint(options.checkpoint),
639
640
  ...(options.verify ? { verify: options.verify } : {}),
package/dist/index.d.ts CHANGED
@@ -3,5 +3,5 @@ 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
5
  export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, composeBlock, preflight, withSessionReset, withTitle, withExpectedFailure, chainFlow, } from "./engine.js";
6
- export type { Flow, EngineConfig, ComposedBlock, RunGraphOptions, BlockInfo, NavBlock } from "./engine.js";
6
+ export type { Flow, EngineConfig, ComposedBlock, RunGraphOptions, BlockInfo, NavBlock, NavBlockOptions } from "./engine.js";
7
7
  export type { ActionPage } from "./types.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",