waygraph 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -0
- package/dist/engine.d.ts +214 -0
- package/dist/engine.js +282 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/mem-page.d.ts +70 -0
- package/dist/mem-page.js +71 -0
- package/dist/trait.d.ts +62 -0
- package/dist/trait.js +80 -0
- package/dist/types.d.ts +124 -0
- package/dist/types.js +39 -0
- package/package.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Waygraph
|
|
2
|
+
|
|
3
|
+
A typed graph of reusable Blocks for driving a browser through E2E flows, in place of a
|
|
4
|
+
flat pile of ad hoc helper functions - see
|
|
5
|
+
`openspec/changes/archive/2026-08-27-core-spine/proposal.md` for the full motivation.
|
|
6
|
+
|
|
7
|
+
This repo is the `waygraph` npm package itself. Consumer projects proving it against real
|
|
8
|
+
sites/apps (saucedemo.com, zsign-app) live in the sibling `projects_waygraph/` repo, not here.
|
|
9
|
+
|
|
10
|
+
**Status:** `Checkpoint`, `MemPage`, `Instruction`/`Block` (`act`/`observe`/`resolve`/`verify`),
|
|
11
|
+
`connect()`, `Trait`, `Engine.defineFlow([start, ...blocks, end])`, and `branch()`/self-loops
|
|
12
|
+
with a `maxSteps` safety cap. A Block's output type is the only contract the next Block can
|
|
13
|
+
rely on, `resolve` is structurally incapable of touching the browser or shared memory,
|
|
14
|
+
`verify` runs only after `resolve` has already decided (confirms or fails loud, never
|
|
15
|
+
redirects), `branch()` attaches routing to a Block - including back to itself - without ever
|
|
16
|
+
touching that Block's own phases, a Block's `requires: MemKey[]` gets checked by
|
|
17
|
+
`preflight()` (built into `runGraph`) before a tab even opens - a missing credential fails
|
|
18
|
+
in milliseconds, not after several real browser actions already ran - and `new Engine({ headless, browserName, slowMo })` configures a browser the Engine
|
|
19
|
+
launches and owns itself: `flow.run(mem)` (no context) uses it, and `flow.run(mem, {
|
|
20
|
+
...overrides })` overrides it per call - so one module-level `Flow`, imported by many
|
|
21
|
+
specs, doesn't force every caller into the same settings. `flow.run(context, mem)` still
|
|
22
|
+
works exactly as before and ignores all of this, since that context is already launched.
|
|
23
|
+
`withVerify(block, verify)` decorates any existing Block with different confirmation - same
|
|
24
|
+
`act`/`resolve`, same routing - without that Block needing to have been authored as a
|
|
25
|
+
parameterized factory (`withVerify(LoginBlock, [])` is the "I only care that I navigated
|
|
26
|
+
this far, no DOM check" case). Built-in Trait factories are reachable two ways - as free
|
|
27
|
+
functions (`urlMatches`, `textEquals`, `visible`) or, for autocomplete discoverability,
|
|
28
|
+
off `Trait.` itself (`Trait.url`, `Trait.text`, `Trait.visible` - the exact same functions,
|
|
29
|
+
just findable by typing `Trait.` without needing to already know their names). `Trait.url`
|
|
30
|
+
takes a structured `URLPatternInit` (`{ pathname, hostname, search, hash, ... }`, the same
|
|
31
|
+
shape the standard `URLPattern` Web API takes), not a hand-written regex - whichever
|
|
32
|
+
components you leave out default to "match anything," so `Trait.url({ pathname:
|
|
33
|
+
"/inventory.html" })` already ignores whatever query params a hybrid SPA tacks on, with no
|
|
34
|
+
separate flag needed for that. Once Blocks are composed into a `Flow`, `flow.withBlockVerify`/
|
|
35
|
+
`flow.modBlockVerify` patch one Block's verify from outside - a spec that only imports the
|
|
36
|
+
finished flow, addressed by the Block reference (preferred), its name, or its numeric
|
|
37
|
+
position in the flow - without editing the flow's own file.
|
|
38
|
+
|
|
39
|
+
Deliberately **not yet implemented** (each tracked as its own future OpenSpec change,
|
|
40
|
+
building on this one):
|
|
41
|
+
- Interstitials/Watchers - background overlay handling (cookie banners, popups).
|
|
42
|
+
- `spawnTab` - a second browser tab.
|
|
43
|
+
- A patchable way to compose several Blocks into one named unit with per-step verify
|
|
44
|
+
addressable from outside (`connect()`-composed chains don't get `withVerify`/`modVerify`
|
|
45
|
+
attached the way `defineBlock()`/`branch()`/`Flow` outputs do) - useful for a multi-step
|
|
46
|
+
form (e.g. a government form with several stepper pages) that's naturally "one feature" but
|
|
47
|
+
still wants each step's own confirmation independently overridable.
|
|
48
|
+
|
|
49
|
+
## Example
|
|
50
|
+
|
|
51
|
+
Two Tier-0 Blocks (only `act` + `resolve`, no branching), each confirmed with `verify`
|
|
52
|
+
after the fact rather than asserting mid-`observe`, defined as a flow and run against a
|
|
53
|
+
real page:
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import { Engine, start, end, MemPage, key, checkpoint } from "waygraph";
|
|
57
|
+
import type { Block, Checkpoint } from "waygraph";
|
|
58
|
+
|
|
59
|
+
type Start = Checkpoint<"__start__">;
|
|
60
|
+
type FormLoaded = Checkpoint<"FormLoaded">;
|
|
61
|
+
type Submitted = Checkpoint<"Submitted">;
|
|
62
|
+
|
|
63
|
+
const EmailKey = key<string>("form.email");
|
|
64
|
+
const PasswordKey = key<string>("form.password");
|
|
65
|
+
|
|
66
|
+
const GotoForm: Block<Start, FormLoaded> = {
|
|
67
|
+
name: "goto-form",
|
|
68
|
+
instruction: {
|
|
69
|
+
async act(page) {
|
|
70
|
+
await page.goto("https://example.com/login");
|
|
71
|
+
},
|
|
72
|
+
resolve: () => checkpoint("FormLoaded"),
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const FillAndSubmit: Block<FormLoaded, Submitted> = {
|
|
77
|
+
name: "fill-and-submit",
|
|
78
|
+
instruction: {
|
|
79
|
+
async act(page, _input, mem) {
|
|
80
|
+
await page.getByPlaceholder("Email").fill(mem.get(EmailKey));
|
|
81
|
+
await page.getByPlaceholder("Password").fill(mem.get(PasswordKey));
|
|
82
|
+
await page.getByRole("button", { name: "Submit" }).click();
|
|
83
|
+
},
|
|
84
|
+
resolve: () => checkpoint("Submitted"),
|
|
85
|
+
verify: [
|
|
86
|
+
{
|
|
87
|
+
name: "submitted-heading-shown",
|
|
88
|
+
check: async (page) => {
|
|
89
|
+
await page.getByRole("heading", { name: "Submitted" }).waitFor();
|
|
90
|
+
return true;
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const mem = new MemPage();
|
|
98
|
+
mem.set(EmailKey, "alice@test.com");
|
|
99
|
+
mem.set(PasswordKey, "hunter2");
|
|
100
|
+
|
|
101
|
+
const engine = new Engine();
|
|
102
|
+
const flow = engine.defineFlow([start, GotoForm, FillAndSubmit, end]);
|
|
103
|
+
const result = await flow.run(context, mem);
|
|
104
|
+
// result === { __state: "Submitted" }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
See `tests/define-flow.spec.ts` here, and `saucedemo/tests/checkout-flow.spec.ts` in
|
|
108
|
+
`projects_waygraph/` (a real 4-Block flow against the live saucedemo.com), for this running
|
|
109
|
+
for real. The lower-level `connect()`/`runGraph()` still exist and are what `defineFlow`
|
|
110
|
+
builds on - reach for them directly only if you need a shape `defineFlow`'s array can't
|
|
111
|
+
express yet.
|
|
112
|
+
|
|
113
|
+
## Commands
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
npm install
|
|
117
|
+
npm run typecheck # tsc --noEmit
|
|
118
|
+
npm run test # playwright test
|
|
119
|
+
npm run build # tsc -p tsconfig.build.json, emits dist/
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Layout
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
package.json the "waygraph" npm package itself
|
|
126
|
+
src/
|
|
127
|
+
types.ts Checkpoint, Instruction, Block, connect()
|
|
128
|
+
mem-page.ts MemKey, key(), MemPage
|
|
129
|
+
trait.ts Trait (type + discoverable Trait.url/.text/.visible), runVerify()
|
|
130
|
+
engine.ts runGraph(), Engine, start, end, Flow (+ withBlockVerify/modBlockVerify)
|
|
131
|
+
index.ts public barrel
|
|
132
|
+
typecheck/ compile-time-only fixtures (assignability, @ts-expect-error cases)
|
|
133
|
+
tests/ runtime unit + integration tests (@playwright/test)
|
|
134
|
+
tsconfig.json base config - typecheck script includes src/ + typecheck/ + tests/
|
|
135
|
+
tsconfig.build.json extends base, src/ only, emits to dist/
|
|
136
|
+
openspec/ the OpenSpec change history for this repo (propose -> apply per change)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Consumer projects (saucedemo.com proof, zsign-app integration) live in the sibling
|
|
140
|
+
`projects_waygraph/` repo, depending on this package via `"waygraph": "file:../../waygraph"`
|
|
141
|
+
during local dev (see its own `.npmrc` - `install-links=true` is required there, or npm will
|
|
142
|
+
symlink instead of copy and pull this package's own `node_modules` in through the symlink,
|
|
143
|
+
causing a duplicate-Playwright-installation error).
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { BrowserContext } from "@playwright/test";
|
|
2
|
+
import type { Block, Checkpoint, Instruction, DefinedBlock } from "./types.js";
|
|
3
|
+
import { MemPage } from "./mem-page.js";
|
|
4
|
+
import type { Trait } from "./trait.js";
|
|
5
|
+
/**
|
|
6
|
+
* Checks `block.requires` against `mem` and throws, naming every missing key at
|
|
7
|
+
* once, if any are unset. `connect()` unions each Block's `requires` into the
|
|
8
|
+
* composed Block, so calling this on a `defineFlow`-built Flow's entry - which
|
|
9
|
+
* `runGraph` does automatically, before opening a tab - covers every Block in
|
|
10
|
+
* that flow. It does not see Blocks only reachable through `branch()` routing,
|
|
11
|
+
* since those depend on a runtime decision that hasn't happened yet.
|
|
12
|
+
*/
|
|
13
|
+
export declare function preflight(mem: MemPage, block: Block<any, any>): void;
|
|
14
|
+
/**
|
|
15
|
+
* Runs `entry` against a fresh tab and returns the resulting terminal Checkpoint.
|
|
16
|
+
* The engine owns the tab's lifecycle, not any individual Block: the tab is
|
|
17
|
+
* created before the first `act` runs and closed in `finally`, so it closes even
|
|
18
|
+
* if a step throws.
|
|
19
|
+
*
|
|
20
|
+
* After each Block resolves, its `next` (if any, from `branch()`) is followed to
|
|
21
|
+
* the following Block, in the same tab - a Block with no `next` runs exactly once,
|
|
22
|
+
* unchanged from before `branch()` existed. `next` returning the same Block is a
|
|
23
|
+
* self-loop; `maxSteps` bounds the loop so a routing bug fails loud instead of
|
|
24
|
+
* hanging.
|
|
25
|
+
*
|
|
26
|
+
* `terminals`, if given, is a sanity check: the Checkpoint the run stops on must be
|
|
27
|
+
* one of the tags the caller actually expects, or the run fails loud instead of
|
|
28
|
+
* silently returning an unexpected result. Omit it when there is only ever one
|
|
29
|
+
* reachable outcome by construction (as with a `defineFlow`-built Flow, which has
|
|
30
|
+
* no routing at all) - there is nothing to validate against.
|
|
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>;
|
|
33
|
+
/** Reserved markers bookending a `defineFlow([start, ...blocks, end])` call. */
|
|
34
|
+
export declare const start: unique symbol;
|
|
35
|
+
export declare const end: unique symbol;
|
|
36
|
+
type StartMarker = typeof start;
|
|
37
|
+
type EndMarker = typeof end;
|
|
38
|
+
/**
|
|
39
|
+
* A composed, runnable flow. Always begins from Checkpoint<"__start__">, same as
|
|
40
|
+
* runGraph's `entry`. `run(context, mem)` uses a context you already have (the
|
|
41
|
+
* usual case inside a `@playwright/test` file, via its `context` fixture) -
|
|
42
|
+
* there's no browser for this Flow to launch, so no config to pass. `run(mem,
|
|
43
|
+
* config?)` - no context - has the Engine launch and own its own browser for
|
|
44
|
+
* this one run instead, and closes it in `finally`; use this outside a
|
|
45
|
+
* Playwright test file, or when a flow genuinely needs its own browser instance
|
|
46
|
+
* rather than sharing the test's. `config` here overrides, per key, whatever
|
|
47
|
+
* `new Engine(...)` was constructed with - so one flow (module-level, imported
|
|
48
|
+
* by many specs) can still have every run choose its own `headless`/`slowMo`
|
|
49
|
+
* rather than being stuck with whatever the flow's own file happened to set.
|
|
50
|
+
*
|
|
51
|
+
* `withBlockVerify`/`modBlockVerify` do what {@link withVerify}/{@link modVerify}
|
|
52
|
+
* do for a single Block, one level up - a spec that only imports the finished
|
|
53
|
+
* Flow (not necessarily the individual Blocks it was built from) can still
|
|
54
|
+
* patch one Block's verify for just that call, without editing the flow's own
|
|
55
|
+
* file or hand-rebuilding its Block list. Addressed the same three ways
|
|
56
|
+
* `modVerify` already addresses a Trait - the Block reference itself
|
|
57
|
+
* (`LoginBlock`, imported like any other Block; typo-proof, refactor-safe,
|
|
58
|
+
* jump-to-definition works), a plain string name, or a numeric index into this
|
|
59
|
+
* Flow's Block list (position between `start`/`end`, 0-based) for a spec that
|
|
60
|
+
* genuinely doesn't have the Block imported and doesn't want to spell its
|
|
61
|
+
* name. A reference or string resolves by `.name` under the hood (a Block's
|
|
62
|
+
* own stable identity in this codebase, same as every error message elsewhere
|
|
63
|
+
* in this file already uses) rather than object identity - it stays
|
|
64
|
+
* resolvable even after an earlier patch already swapped that slot for a new
|
|
65
|
+
* object, since the name survives every `withVerify`/`modVerify` in the
|
|
66
|
+
* chain; an index is checked fresh against this Flow's current Block list
|
|
67
|
+
* each call, so it's still correct after a patch too. Returns a new Flow -
|
|
68
|
+
* the original is untouched, same as every other decorator in this module.
|
|
69
|
+
*/
|
|
70
|
+
export interface Flow<Out extends Checkpoint<string>> {
|
|
71
|
+
run(context: BrowserContext, mem: MemPage): Promise<Out>;
|
|
72
|
+
run(mem: MemPage, config?: EngineConfig): Promise<Out>;
|
|
73
|
+
/**
|
|
74
|
+
* Replaces the given Block's whole verify list, wherever it sits in this
|
|
75
|
+
* Flow - same rules as {@link withVerify}. Throws if no Block in this Flow
|
|
76
|
+
* matches.
|
|
77
|
+
* @example checkoutFlow.withBlockVerify(LoginBlock, [])
|
|
78
|
+
* @example checkoutFlow.withBlockVerify("login", [])
|
|
79
|
+
* @example checkoutFlow.withBlockVerify(0, [])
|
|
80
|
+
*/
|
|
81
|
+
withBlockVerify(block: Block<any, any> | string | number, verify: Trait[] | ((out: any) => Trait[])): Flow<Out>;
|
|
82
|
+
/**
|
|
83
|
+
* Replaces one Trait inside the given Block's verify list - same rules as
|
|
84
|
+
* {@link modVerify}. Throws if no Block in this Flow matches, or if that
|
|
85
|
+
* Block has no verify trait at the given name/index.
|
|
86
|
+
* @example checkoutFlow.modBlockVerify(LoginBlock, "url-matches", Trait.url({ pathname: "/inventory.html" }))
|
|
87
|
+
*/
|
|
88
|
+
modBlockVerify(block: Block<any, any> | string | number, nameOrIndex: string | number, newCheck: Trait["check"] | Trait): Flow<Out>;
|
|
89
|
+
}
|
|
90
|
+
/** 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
|
+
export interface EngineConfig {
|
|
92
|
+
/** Default `true`, matching Playwright's own default. */
|
|
93
|
+
headless?: boolean;
|
|
94
|
+
/** Default `"chromium"`. */
|
|
95
|
+
browserName?: "chromium" | "firefox" | "webkit";
|
|
96
|
+
/** Milliseconds Playwright pauses before each operation - for watching a non-headless run with your own eyes, not for real runs. Default `0`. */
|
|
97
|
+
slowMo?: number;
|
|
98
|
+
}
|
|
99
|
+
type S = Checkpoint<"__start__">;
|
|
100
|
+
/**
|
|
101
|
+
* Holds engine-level configuration shared across every flow defined from it -
|
|
102
|
+
* for now, just how to launch a browser when a Flow owns its own
|
|
103
|
+
* (`headless`/`browserName`/`slowMo`); traits/interstitials/watchers land here
|
|
104
|
+
* in a later change.
|
|
105
|
+
* @example const engine = new Engine({ headless: false, slowMo: 250 });
|
|
106
|
+
*/
|
|
107
|
+
export declare class Engine {
|
|
108
|
+
private readonly config;
|
|
109
|
+
constructor(config?: EngineConfig);
|
|
110
|
+
/**
|
|
111
|
+
* Builds a runnable {@link Flow} from `[start, ...Blocks, end]`, typechecked
|
|
112
|
+
* so each Block's `In` must match the previous Block's `Out` - the same
|
|
113
|
+
* check `connect()` does, just declared as a flat array instead of hand-nested
|
|
114
|
+
* calls. Overloaded for 2-7 array slots (1-6 real Blocks between `start`/`end`)
|
|
115
|
+
* rather than one fully-generic recursive tuple type, so each arity is as
|
|
116
|
+
* reliably checked as `connect<A,B,C>` itself.
|
|
117
|
+
* @example engine.defineFlow([start, LoginBlock, AddToCartBlock, end])
|
|
118
|
+
*/
|
|
119
|
+
defineFlow<B extends Checkpoint<string>>(blocks: readonly [StartMarker, Block<S, B>, EndMarker]): Flow<B>;
|
|
120
|
+
defineFlow<B extends Checkpoint<string>, C extends Checkpoint<string>>(blocks: readonly [StartMarker, Block<S, B>, Block<B, C>, EndMarker]): Flow<C>;
|
|
121
|
+
defineFlow<B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>>(blocks: readonly [StartMarker, Block<S, B>, Block<B, C>, Block<C, D>, EndMarker]): Flow<D>;
|
|
122
|
+
defineFlow<B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, E extends Checkpoint<string>>(blocks: readonly [StartMarker, Block<S, B>, Block<B, C>, Block<C, D>, Block<D, E>, EndMarker]): Flow<E>;
|
|
123
|
+
defineFlow<B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, E extends Checkpoint<string>, F extends Checkpoint<string>>(blocks: readonly [
|
|
124
|
+
StartMarker,
|
|
125
|
+
Block<S, B>,
|
|
126
|
+
Block<B, C>,
|
|
127
|
+
Block<C, D>,
|
|
128
|
+
Block<D, E>,
|
|
129
|
+
Block<E, F>,
|
|
130
|
+
EndMarker
|
|
131
|
+
]): Flow<F>;
|
|
132
|
+
defineFlow<B extends Checkpoint<string>, C extends Checkpoint<string>, D extends Checkpoint<string>, E extends Checkpoint<string>, F extends Checkpoint<string>, G extends Checkpoint<string>>(blocks: readonly [
|
|
133
|
+
StartMarker,
|
|
134
|
+
Block<S, B>,
|
|
135
|
+
Block<B, C>,
|
|
136
|
+
Block<C, D>,
|
|
137
|
+
Block<D, E>,
|
|
138
|
+
Block<E, F>,
|
|
139
|
+
Block<F, G>,
|
|
140
|
+
EndMarker
|
|
141
|
+
]): Flow<G>;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Attaches routing to `block` without touching its act/observe/resolve/verify -
|
|
145
|
+
* `routes` must cover every tag of `block`'s output (a missing route is a compile
|
|
146
|
+
* error), and the returned Block runs exactly as `block` always did, just followed
|
|
147
|
+
* by whichever next Block the resolved tag routes to. `null` for a tag means that
|
|
148
|
+
* tag is terminal - the run stops there, same as a Block with no routing at all.
|
|
149
|
+
* A route pointing back at `block` itself is a self-loop; see `runGraph`'s
|
|
150
|
+
* `maxSteps` for its safety net. The return type is deliberately `Block<In, any>` -
|
|
151
|
+
* a route can lead anywhere, including back through itself, so there is no fixed
|
|
152
|
+
* Out type to express; state the expected outcomes to `runGraph` via `terminals`
|
|
153
|
+
* instead.
|
|
154
|
+
*/
|
|
155
|
+
export declare function branch<In extends Checkpoint<string>, Out extends Checkpoint<string>>(block: Block<In, Out>, routes: {
|
|
156
|
+
[K in Out["__state"]]: Block<Extract<Out, Checkpoint<K>>, any> | null;
|
|
157
|
+
}): DefinedBlock<In, any>;
|
|
158
|
+
/**
|
|
159
|
+
* Builds a Block with `withVerify`/`modVerify`/`modVerifyAll` attached as
|
|
160
|
+
* methods - `LoginBlock.withVerify([...])`, discoverable by typing
|
|
161
|
+
* `LoginBlock.` in an editor, not just callable as `withVerify(LoginBlock, ...)`
|
|
162
|
+
* if you already know it exists. A raw object literal is still a perfectly
|
|
163
|
+
* valid Block (every test fixture in this codebase is one) - it just won't have
|
|
164
|
+
* these attached. The methods are thin: they delegate to the identically-named
|
|
165
|
+
* free functions, which remain the one place this logic actually lives.
|
|
166
|
+
*/
|
|
167
|
+
export declare function defineBlock<In extends Checkpoint<string>, Out extends Checkpoint<string>>(base: {
|
|
168
|
+
name: string;
|
|
169
|
+
instruction: Instruction<In, Out, any>;
|
|
170
|
+
next?: Block<In, Out>["next"];
|
|
171
|
+
requires?: Block<In, Out>["requires"];
|
|
172
|
+
}): DefinedBlock<In, Out>;
|
|
173
|
+
/**
|
|
174
|
+
* Returns a new Block that runs exactly like `block` - same act/observe/resolve,
|
|
175
|
+
* same routing - but with `verify` replaced. For giving one flow stricter (or
|
|
176
|
+
* looser, or just different) confirmation on a Block it didn't author, without
|
|
177
|
+
* that Block needing to have been written as a parameterized factory in
|
|
178
|
+
* anticipation. Same move as `branch()`: decorate an existing Block, don't ask
|
|
179
|
+
* every Block to pre-build its own override mechanism.
|
|
180
|
+
*
|
|
181
|
+
* Wrap the one Block you want to change, in place, wherever it already sits in
|
|
182
|
+
* a `defineFlow([...])` array - every other Block stays untouched:
|
|
183
|
+
* `defineFlow([start, LoginBlock, withVerify(AddToCartBlock, [...]), end])`.
|
|
184
|
+
* There is no separate positional array to keep in sync and no `null`
|
|
185
|
+
* placeholder for "skip this one" - `defineFlow` has no per-slot verify
|
|
186
|
+
* argument at all, only whatever `verify` each Block's own `instruction` (or
|
|
187
|
+
* a `withVerify(...)` wrapping it) carries with it.
|
|
188
|
+
*/
|
|
189
|
+
export declare function withVerify<In extends Checkpoint<string>, Out extends Checkpoint<string>>(block: Block<In, Out>, verify: Trait[] | ((out: Out) => Trait[])): DefinedBlock<In, Out>;
|
|
190
|
+
/**
|
|
191
|
+
* Replaces one Trait inside `block`'s verify list, leaving every other check
|
|
192
|
+
* untouched - the v1/v2/legacy-variant case, where one check needs a different
|
|
193
|
+
* selector or URL pattern but the rest of the Block is identical. Addressed by
|
|
194
|
+
* the Trait's own `name` (recommended - inserting or reordering other checks
|
|
195
|
+
* can't silently retarget it) or, if you genuinely know the position and don't
|
|
196
|
+
* need that safety, a numeric index. Either way a bad address fails loud
|
|
197
|
+
* immediately instead of quietly doing nothing. `newCheck` can be a bare
|
|
198
|
+
* `check` function (keeps the existing name) or a full `Trait` (to rename too).
|
|
199
|
+
* Only works on a flat `verify` array - a function-form `verify` (from
|
|
200
|
+
* `verify: (out) => Trait[]`) has no fixed list to address into, so this
|
|
201
|
+
* throws rather than guess; use `withVerify` to replace it wholesale instead.
|
|
202
|
+
*/
|
|
203
|
+
export declare function modVerify<In extends Checkpoint<string>, Out extends Checkpoint<string>>(block: Block<In, Out>, nameOrIndex: string | number, newCheck: Trait["check"] | Trait): DefinedBlock<In, Out>;
|
|
204
|
+
/**
|
|
205
|
+
* `modVerify`, but for several named checks in one call - a v2 variant that
|
|
206
|
+
* differs in five ways from v1 shouldn't need five nested `modVerify(modVerify(
|
|
207
|
+
* modVerify(...)))` calls to track by eye. `patches` keys are plain strings (a
|
|
208
|
+
* Trait's `name`, not a `MemKey` instance), so an ordinary object literal is
|
|
209
|
+
* fine here - unlike `MemPage`, there's no identity-collision reason to need
|
|
210
|
+
* `[name, check]` pairs. Applies each patch in turn via `modVerify`, so the
|
|
211
|
+
* same fail-loud-on-unknown-name behavior holds for every entry.
|
|
212
|
+
*/
|
|
213
|
+
export declare function modVerifyAll<In extends Checkpoint<string>, Out extends Checkpoint<string>>(block: Block<In, Out>, patches: Record<string, Trait["check"] | Trait>): DefinedBlock<In, Out>;
|
|
214
|
+
export {};
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { chromium, firefox, webkit } from "@playwright/test";
|
|
2
|
+
import { connect, checkpoint } from "./types.js";
|
|
3
|
+
import { MemPage } from "./mem-page.js";
|
|
4
|
+
import { runVerify } from "./trait.js";
|
|
5
|
+
const LAUNCHERS = { chromium, firefox, webkit };
|
|
6
|
+
/**
|
|
7
|
+
* Checks `block.requires` against `mem` and throws, naming every missing key at
|
|
8
|
+
* once, if any are unset. `connect()` unions each Block's `requires` into the
|
|
9
|
+
* composed Block, so calling this on a `defineFlow`-built Flow's entry - which
|
|
10
|
+
* `runGraph` does automatically, before opening a tab - covers every Block in
|
|
11
|
+
* that flow. It does not see Blocks only reachable through `branch()` routing,
|
|
12
|
+
* since those depend on a runtime decision that hasn't happened yet.
|
|
13
|
+
*/
|
|
14
|
+
export function preflight(mem, block) {
|
|
15
|
+
const missing = (block.requires ?? []).filter((k) => !mem.has(k));
|
|
16
|
+
if (missing.length > 0) {
|
|
17
|
+
throw new Error(`preflight: MemPage is missing required key(s) before "${block.name}" can run: ${missing
|
|
18
|
+
.map((k) => `"${k.name}"`)
|
|
19
|
+
.join(", ")}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Runs `entry` against a fresh tab and returns the resulting terminal Checkpoint.
|
|
24
|
+
* The engine owns the tab's lifecycle, not any individual Block: the tab is
|
|
25
|
+
* created before the first `act` runs and closed in `finally`, so it closes even
|
|
26
|
+
* if a step throws.
|
|
27
|
+
*
|
|
28
|
+
* After each Block resolves, its `next` (if any, from `branch()`) is followed to
|
|
29
|
+
* the following Block, in the same tab - a Block with no `next` runs exactly once,
|
|
30
|
+
* unchanged from before `branch()` existed. `next` returning the same Block is a
|
|
31
|
+
* self-loop; `maxSteps` bounds the loop so a routing bug fails loud instead of
|
|
32
|
+
* hanging.
|
|
33
|
+
*
|
|
34
|
+
* `terminals`, if given, is a sanity check: the Checkpoint the run stops on must be
|
|
35
|
+
* one of the tags the caller actually expects, or the run fails loud instead of
|
|
36
|
+
* silently returning an unexpected result. Omit it when there is only ever one
|
|
37
|
+
* reachable outcome by construction (as with a `defineFlow`-built Flow, which has
|
|
38
|
+
* no routing at all) - there is nothing to validate against.
|
|
39
|
+
*/
|
|
40
|
+
export async function runGraph(entry, terminals, context, mem, maxSteps = 5000) {
|
|
41
|
+
preflight(mem, entry);
|
|
42
|
+
const page = await context.newPage();
|
|
43
|
+
try {
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
|
+
let current = entry;
|
|
46
|
+
let input = checkpoint("__start__");
|
|
47
|
+
let steps = 0;
|
|
48
|
+
for (;;) {
|
|
49
|
+
if (++steps > maxSteps) {
|
|
50
|
+
throw new Error(`runGraph: exceeded ${maxSteps} steps - check for an unintended self-loop`);
|
|
51
|
+
}
|
|
52
|
+
await current.instruction.act(page, input, mem);
|
|
53
|
+
const observed = current.instruction.observe
|
|
54
|
+
? await current.instruction.observe(page, mem)
|
|
55
|
+
: undefined;
|
|
56
|
+
const checkpoint = await current.instruction.resolve(observed);
|
|
57
|
+
await runVerify(current.instruction.verify, checkpoint, page, mem, current.name);
|
|
58
|
+
const next = current.next?.(checkpoint);
|
|
59
|
+
if (!next) {
|
|
60
|
+
if (terminals && !terminals.has(checkpoint.__state)) {
|
|
61
|
+
throw new Error(`runGraph: "${current.name}" resolved to "${checkpoint.__state}", which is not a registered terminal (expected one of: ${[...terminals].join(", ")})`);
|
|
62
|
+
}
|
|
63
|
+
return checkpoint;
|
|
64
|
+
}
|
|
65
|
+
current = next;
|
|
66
|
+
input = checkpoint;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
await page.close();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Reserved markers bookending a `defineFlow([start, ...blocks, end])` call. */
|
|
74
|
+
export const start = Symbol("waygraph.start");
|
|
75
|
+
export const end = Symbol("waygraph.end");
|
|
76
|
+
function findBlockIndex(middle, block) {
|
|
77
|
+
if (typeof block === "number") {
|
|
78
|
+
if (block < 0 || block >= middle.length) {
|
|
79
|
+
throw new Error(`Flow: no Block at index ${block} in this flow (has ${middle.length})`);
|
|
80
|
+
}
|
|
81
|
+
return block;
|
|
82
|
+
}
|
|
83
|
+
const blockName = typeof block === "string" ? block : block.name;
|
|
84
|
+
const index = middle.findIndex((b) => b.name === blockName);
|
|
85
|
+
if (index === -1) {
|
|
86
|
+
throw new Error(`Flow: no Block named "${blockName}" in this flow (has: ${middle.map((b) => b.name).join(", ")})`);
|
|
87
|
+
}
|
|
88
|
+
return index;
|
|
89
|
+
}
|
|
90
|
+
function buildFlow(middle, engineConfig) {
|
|
91
|
+
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
|
+
}
|
|
115
|
+
return runGraph(chain, undefined, contextOrMem, memOrConfig);
|
|
116
|
+
},
|
|
117
|
+
withBlockVerify(block, verify) {
|
|
118
|
+
const index = findBlockIndex(middle, block);
|
|
119
|
+
const patched = [...middle];
|
|
120
|
+
patched[index] = withVerify(middle[index], verify);
|
|
121
|
+
return buildFlow(patched, engineConfig);
|
|
122
|
+
},
|
|
123
|
+
modBlockVerify(block, nameOrIndex, newCheck) {
|
|
124
|
+
const index = findBlockIndex(middle, block);
|
|
125
|
+
const patched = [...middle];
|
|
126
|
+
patched[index] = modVerify(middle[index], nameOrIndex, newCheck);
|
|
127
|
+
return buildFlow(patched, engineConfig);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Holds engine-level configuration shared across every flow defined from it -
|
|
133
|
+
* for now, just how to launch a browser when a Flow owns its own
|
|
134
|
+
* (`headless`/`browserName`/`slowMo`); traits/interstitials/watchers land here
|
|
135
|
+
* in a later change.
|
|
136
|
+
* @example const engine = new Engine({ headless: false, slowMo: 250 });
|
|
137
|
+
*/
|
|
138
|
+
export class Engine {
|
|
139
|
+
config;
|
|
140
|
+
constructor(config = {}) {
|
|
141
|
+
this.config = config;
|
|
142
|
+
}
|
|
143
|
+
defineFlow(blocks) {
|
|
144
|
+
return buildFlow(blocks.slice(1, -1), this.config);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Attaches routing to `block` without touching its act/observe/resolve/verify -
|
|
149
|
+
* `routes` must cover every tag of `block`'s output (a missing route is a compile
|
|
150
|
+
* error), and the returned Block runs exactly as `block` always did, just followed
|
|
151
|
+
* by whichever next Block the resolved tag routes to. `null` for a tag means that
|
|
152
|
+
* tag is terminal - the run stops there, same as a Block with no routing at all.
|
|
153
|
+
* A route pointing back at `block` itself is a self-loop; see `runGraph`'s
|
|
154
|
+
* `maxSteps` for its safety net. The return type is deliberately `Block<In, any>` -
|
|
155
|
+
* a route can lead anywhere, including back through itself, so there is no fixed
|
|
156
|
+
* Out type to express; state the expected outcomes to `runGraph` via `terminals`
|
|
157
|
+
* instead.
|
|
158
|
+
*/
|
|
159
|
+
export function branch(block, routes) {
|
|
160
|
+
const base = stripMethods(block);
|
|
161
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
162
|
+
let routed;
|
|
163
|
+
routed = defineBlock({
|
|
164
|
+
...base,
|
|
165
|
+
next: (checkpoint) => {
|
|
166
|
+
const target = routes[checkpoint.__state];
|
|
167
|
+
if (target === undefined || target === null)
|
|
168
|
+
return undefined;
|
|
169
|
+
// A route naming `block` itself (the only self-reference in scope when
|
|
170
|
+
// `routes` was written) means "loop back with routing intact" - substitute
|
|
171
|
+
// the routed Block, not the original with no `next` of its own.
|
|
172
|
+
return target === block ? routed : target;
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
return routed;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Strips `withVerify`/`modVerify`/`modVerifyAll` off a Block, keeping everything
|
|
179
|
+
* else - `name`, `instruction`, `next`, `requires`. Every function below that
|
|
180
|
+
* returns a new Block calls this on its input first, then rebuilds through
|
|
181
|
+
* `defineBlock`. Without it, a Block's attached methods close over the shape it
|
|
182
|
+
* had *when they were attached* - call `.withVerify()` on a Block that
|
|
183
|
+
* `branch()` added routing to afterward, and without this step the result would
|
|
184
|
+
* silently drop that routing, since the stale closure never knew about it.
|
|
185
|
+
*/
|
|
186
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
187
|
+
function stripMethods(block) {
|
|
188
|
+
return {
|
|
189
|
+
name: block.name,
|
|
190
|
+
instruction: block.instruction,
|
|
191
|
+
...(block.next ? { next: block.next } : {}),
|
|
192
|
+
...(block.requires ? { requires: block.requires } : {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Builds a Block with `withVerify`/`modVerify`/`modVerifyAll` attached as
|
|
197
|
+
* methods - `LoginBlock.withVerify([...])`, discoverable by typing
|
|
198
|
+
* `LoginBlock.` in an editor, not just callable as `withVerify(LoginBlock, ...)`
|
|
199
|
+
* if you already know it exists. A raw object literal is still a perfectly
|
|
200
|
+
* valid Block (every test fixture in this codebase is one) - it just won't have
|
|
201
|
+
* these attached. The methods are thin: they delegate to the identically-named
|
|
202
|
+
* free functions, which remain the one place this logic actually lives.
|
|
203
|
+
*/
|
|
204
|
+
export function defineBlock(base) {
|
|
205
|
+
const plain = {
|
|
206
|
+
name: base.name,
|
|
207
|
+
instruction: base.instruction,
|
|
208
|
+
...(base.next ? { next: base.next } : {}),
|
|
209
|
+
...(base.requires ? { requires: base.requires } : {}),
|
|
210
|
+
};
|
|
211
|
+
return {
|
|
212
|
+
...plain,
|
|
213
|
+
withVerify: (verify) => withVerify(plain, verify),
|
|
214
|
+
modVerify: (nameOrIndex, newCheck) => modVerify(plain, nameOrIndex, newCheck),
|
|
215
|
+
modVerifyAll: (patches) => modVerifyAll(plain, patches),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Returns a new Block that runs exactly like `block` - same act/observe/resolve,
|
|
220
|
+
* same routing - but with `verify` replaced. For giving one flow stricter (or
|
|
221
|
+
* looser, or just different) confirmation on a Block it didn't author, without
|
|
222
|
+
* that Block needing to have been written as a parameterized factory in
|
|
223
|
+
* anticipation. Same move as `branch()`: decorate an existing Block, don't ask
|
|
224
|
+
* every Block to pre-build its own override mechanism.
|
|
225
|
+
*
|
|
226
|
+
* Wrap the one Block you want to change, in place, wherever it already sits in
|
|
227
|
+
* a `defineFlow([...])` array - every other Block stays untouched:
|
|
228
|
+
* `defineFlow([start, LoginBlock, withVerify(AddToCartBlock, [...]), end])`.
|
|
229
|
+
* There is no separate positional array to keep in sync and no `null`
|
|
230
|
+
* placeholder for "skip this one" - `defineFlow` has no per-slot verify
|
|
231
|
+
* argument at all, only whatever `verify` each Block's own `instruction` (or
|
|
232
|
+
* a `withVerify(...)` wrapping it) carries with it.
|
|
233
|
+
*/
|
|
234
|
+
export function withVerify(block, verify) {
|
|
235
|
+
const base = stripMethods(block);
|
|
236
|
+
return defineBlock({ ...base, instruction: { ...base.instruction, verify } });
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Replaces one Trait inside `block`'s verify list, leaving every other check
|
|
240
|
+
* untouched - the v1/v2/legacy-variant case, where one check needs a different
|
|
241
|
+
* selector or URL pattern but the rest of the Block is identical. Addressed by
|
|
242
|
+
* the Trait's own `name` (recommended - inserting or reordering other checks
|
|
243
|
+
* can't silently retarget it) or, if you genuinely know the position and don't
|
|
244
|
+
* need that safety, a numeric index. Either way a bad address fails loud
|
|
245
|
+
* immediately instead of quietly doing nothing. `newCheck` can be a bare
|
|
246
|
+
* `check` function (keeps the existing name) or a full `Trait` (to rename too).
|
|
247
|
+
* Only works on a flat `verify` array - a function-form `verify` (from
|
|
248
|
+
* `verify: (out) => Trait[]`) has no fixed list to address into, so this
|
|
249
|
+
* throws rather than guess; use `withVerify` to replace it wholesale instead.
|
|
250
|
+
*/
|
|
251
|
+
export function modVerify(block, nameOrIndex, newCheck) {
|
|
252
|
+
const verify = block.instruction.verify;
|
|
253
|
+
if (typeof verify === "function") {
|
|
254
|
+
throw new Error(`modVerify: "${block.name}" has a function-form verify, which has no fixed list to address into - use withVerify to replace it wholesale instead`);
|
|
255
|
+
}
|
|
256
|
+
const traits = verify ?? [];
|
|
257
|
+
const index = typeof nameOrIndex === "number" ? nameOrIndex : traits.findIndex((t) => t.name === nameOrIndex);
|
|
258
|
+
const existing = traits[index];
|
|
259
|
+
if (!existing) {
|
|
260
|
+
throw new Error(typeof nameOrIndex === "number"
|
|
261
|
+
? `modVerify: "${block.name}" has no verify trait at index ${nameOrIndex} (has ${traits.length})`
|
|
262
|
+
: `modVerify: "${block.name}" has no verify trait named "${nameOrIndex}" (has: ${traits.map((t) => t.name).join(", ") || "none"})`);
|
|
263
|
+
}
|
|
264
|
+
const replacement = typeof newCheck === "function"
|
|
265
|
+
? { name: typeof nameOrIndex === "string" ? nameOrIndex : existing.name, check: newCheck }
|
|
266
|
+
: newCheck;
|
|
267
|
+
const updated = [...traits];
|
|
268
|
+
updated[index] = replacement;
|
|
269
|
+
return withVerify(block, updated);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* `modVerify`, but for several named checks in one call - a v2 variant that
|
|
273
|
+
* differs in five ways from v1 shouldn't need five nested `modVerify(modVerify(
|
|
274
|
+
* modVerify(...)))` calls to track by eye. `patches` keys are plain strings (a
|
|
275
|
+
* Trait's `name`, not a `MemKey` instance), so an ordinary object literal is
|
|
276
|
+
* fine here - unlike `MemPage`, there's no identity-collision reason to need
|
|
277
|
+
* `[name, check]` pairs. Applies each patch in turn via `modVerify`, so the
|
|
278
|
+
* same fail-loud-on-unknown-name behavior holds for every entry.
|
|
279
|
+
*/
|
|
280
|
+
export function modVerifyAll(block, patches) {
|
|
281
|
+
return Object.entries(patches).reduce((current, [name, newCheck]) => modVerify(current, name, newCheck), defineBlock(stripMethods(block)));
|
|
282
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { Checkpoint, Instruction, Block, DefinedBlock } from "./types.js";
|
|
2
|
+
export { connect, checkpoint } from "./types.js";
|
|
3
|
+
export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
|
|
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";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { connect, checkpoint } from "./types.js";
|
|
2
|
+
export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
|
|
3
|
+
export { Trait, urlMatches, textEquals, visible } from "./trait.js";
|
|
4
|
+
export { runGraph, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, preflight, } from "./engine.js";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A typed key into MemPage. The phantom `_phantom` field is never assigned - it
|
|
3
|
+
* exists only so TypeScript can infer `T` at each call site. The store is keyed by
|
|
4
|
+
* the MemKey *instance* (object identity), not by `name`, so two keys can share a
|
|
5
|
+
* debug name and never collide - `name` is for error messages only.
|
|
6
|
+
*/
|
|
7
|
+
export declare class MemKey<T> {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
private readonly _phantom;
|
|
10
|
+
constructor(name: string);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Builds a MemKey for a single typed value - the `T` you give here is what
|
|
14
|
+
* `mem.get`/`mem.set` enforce everywhere this key is used.
|
|
15
|
+
* @example const Username = key<string>("saucedemo.username");
|
|
16
|
+
*/
|
|
17
|
+
export declare function key<T>(name: string): MemKey<T>;
|
|
18
|
+
/**
|
|
19
|
+
* A named shorthand for a single MemKey holding a whole shaped value (a
|
|
20
|
+
* credentials object, not two separate primitive keys). `LoginCreds({ username,
|
|
21
|
+
* password })` returns a `[key, value]` pair ready for `setAll`; `LoginCreds.key`
|
|
22
|
+
* is the underlying MemKey, for `mem.get(LoginCreds.key)` or `Block.requires`.
|
|
23
|
+
* `T` is given explicitly at the `keyGroup<T>(...)` call site, not inferred from
|
|
24
|
+
* how it's later called, so this doesn't carry the same inference risk `setAll`'s
|
|
25
|
+
* first attempt did.
|
|
26
|
+
*/
|
|
27
|
+
export declare function keyGroup<T>(name: string): ((value: T) => readonly [MemKey<T>, T]) & {
|
|
28
|
+
key: MemKey<T>;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Typed, identity-keyed shared memory for one graph run. Reading a key that was
|
|
32
|
+
* never written throws immediately, naming the key, instead of returning
|
|
33
|
+
* `undefined` - a misuse fails loud at the read site, not silently downstream.
|
|
34
|
+
*/
|
|
35
|
+
export declare class MemPage {
|
|
36
|
+
private readonly store;
|
|
37
|
+
/** `mem.set(key, value)`, or `mem.set(LoginCreds({ ... }))` with a `keyGroup` pair directly - no "All" for just one. */
|
|
38
|
+
set<T>(k: MemKey<T>, value: T): void;
|
|
39
|
+
set<T>(pair: readonly [MemKey<T>, T]): void;
|
|
40
|
+
/**
|
|
41
|
+
* Reads `k`'s value, throwing (naming `k`) if it was never `set` - never
|
|
42
|
+
* returns `undefined` for a genuinely missing key, so a misuse fails loud at
|
|
43
|
+
* the read site instead of surfacing as a confusing error deep in `act`.
|
|
44
|
+
* @example const { username, password } = mem.get(LoginCreds.key);
|
|
45
|
+
*/
|
|
46
|
+
get<T>(k: MemKey<T>): T;
|
|
47
|
+
/** True once `k` has been `set` - use before a `get` that's genuinely optional. */
|
|
48
|
+
has<T>(k: MemKey<T>): boolean;
|
|
49
|
+
/** Covers add/remove/insert/nest generically - MemPage never needs to know the shape of T. */
|
|
50
|
+
update<T>(k: MemKey<T>, fn: (current: T) => T): void;
|
|
51
|
+
/**
|
|
52
|
+
* Sets several keys in one call - `mem.setAll([UsernameKey, "standard_user"], [PasswordKey, "secret_sauce"])`
|
|
53
|
+
* instead of one `.set()` per line. Can't take a plain `{ ... }` object literal:
|
|
54
|
+
* MemKey instances are the identity the store is keyed by, and object literals
|
|
55
|
+
* can only have string/symbol property names, not object instances - a `[key,
|
|
56
|
+
* value]` pair is the shape that keeps that identity-based guarantee.
|
|
57
|
+
*
|
|
58
|
+
* Overloaded per pair count (2-6, same reasoning as `Engine.defineFlow`'s
|
|
59
|
+
* overloads) rather than one fully-generic variadic signature - each pair binds
|
|
60
|
+
* its own type parameter independently, which TS checks reliably; a single
|
|
61
|
+
* self-referential mapped-tuple-over-a-rest-parameter type was tried first and
|
|
62
|
+
* silently failed to catch a mismatched pair, so it was not trusted over this.
|
|
63
|
+
*/
|
|
64
|
+
setAll<A>(p1: readonly [MemKey<A>, A]): void;
|
|
65
|
+
setAll<A, B>(p1: readonly [MemKey<A>, A], p2: readonly [MemKey<B>, B]): void;
|
|
66
|
+
setAll<A, B, C>(p1: readonly [MemKey<A>, A], p2: readonly [MemKey<B>, B], p3: readonly [MemKey<C>, C]): void;
|
|
67
|
+
setAll<A, B, C, D>(p1: readonly [MemKey<A>, A], p2: readonly [MemKey<B>, B], p3: readonly [MemKey<C>, C], p4: readonly [MemKey<D>, D]): void;
|
|
68
|
+
setAll<A, B, C, D, E>(p1: readonly [MemKey<A>, A], p2: readonly [MemKey<B>, B], p3: readonly [MemKey<C>, C], p4: readonly [MemKey<D>, D], p5: readonly [MemKey<E>, E]): void;
|
|
69
|
+
setAll<A, B, C, D, E, F>(p1: readonly [MemKey<A>, A], p2: readonly [MemKey<B>, B], p3: readonly [MemKey<C>, C], p4: readonly [MemKey<D>, D], p5: readonly [MemKey<E>, E], p6: readonly [MemKey<F>, F]): void;
|
|
70
|
+
}
|
package/dist/mem-page.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A typed key into MemPage. The phantom `_phantom` field is never assigned - it
|
|
3
|
+
* exists only so TypeScript can infer `T` at each call site. The store is keyed by
|
|
4
|
+
* the MemKey *instance* (object identity), not by `name`, so two keys can share a
|
|
5
|
+
* debug name and never collide - `name` is for error messages only.
|
|
6
|
+
*/
|
|
7
|
+
export class MemKey {
|
|
8
|
+
name;
|
|
9
|
+
constructor(name) {
|
|
10
|
+
this.name = name;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Builds a MemKey for a single typed value - the `T` you give here is what
|
|
15
|
+
* `mem.get`/`mem.set` enforce everywhere this key is used.
|
|
16
|
+
* @example const Username = key<string>("saucedemo.username");
|
|
17
|
+
*/
|
|
18
|
+
export function key(name) {
|
|
19
|
+
return new MemKey(name);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A named shorthand for a single MemKey holding a whole shaped value (a
|
|
23
|
+
* credentials object, not two separate primitive keys). `LoginCreds({ username,
|
|
24
|
+
* password })` returns a `[key, value]` pair ready for `setAll`; `LoginCreds.key`
|
|
25
|
+
* is the underlying MemKey, for `mem.get(LoginCreds.key)` or `Block.requires`.
|
|
26
|
+
* `T` is given explicitly at the `keyGroup<T>(...)` call site, not inferred from
|
|
27
|
+
* how it's later called, so this doesn't carry the same inference risk `setAll`'s
|
|
28
|
+
* first attempt did.
|
|
29
|
+
*/
|
|
30
|
+
export function keyGroup(name) {
|
|
31
|
+
const k = key(name);
|
|
32
|
+
const factory = (value) => [k, value];
|
|
33
|
+
return Object.assign(factory, { key: k });
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Typed, identity-keyed shared memory for one graph run. Reading a key that was
|
|
37
|
+
* never written throws immediately, naming the key, instead of returning
|
|
38
|
+
* `undefined` - a misuse fails loud at the read site, not silently downstream.
|
|
39
|
+
*/
|
|
40
|
+
export class MemPage {
|
|
41
|
+
store = new Map();
|
|
42
|
+
set(kOrPair, value) {
|
|
43
|
+
const [k, v] = kOrPair instanceof MemKey ? [kOrPair, value] : kOrPair;
|
|
44
|
+
this.store.set(k, v);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Reads `k`'s value, throwing (naming `k`) if it was never `set` - never
|
|
48
|
+
* returns `undefined` for a genuinely missing key, so a misuse fails loud at
|
|
49
|
+
* the read site instead of surfacing as a confusing error deep in `act`.
|
|
50
|
+
* @example const { username, password } = mem.get(LoginCreds.key);
|
|
51
|
+
*/
|
|
52
|
+
get(k) {
|
|
53
|
+
if (!this.store.has(k)) {
|
|
54
|
+
throw new Error(`MemPage: "${k.name}" read before it was set`);
|
|
55
|
+
}
|
|
56
|
+
return this.store.get(k);
|
|
57
|
+
}
|
|
58
|
+
/** True once `k` has been `set` - use before a `get` that's genuinely optional. */
|
|
59
|
+
has(k) {
|
|
60
|
+
return this.store.has(k);
|
|
61
|
+
}
|
|
62
|
+
/** Covers add/remove/insert/nest generically - MemPage never needs to know the shape of T. */
|
|
63
|
+
update(k, fn) {
|
|
64
|
+
this.set(k, fn(this.get(k)));
|
|
65
|
+
}
|
|
66
|
+
setAll(...pairs) {
|
|
67
|
+
for (const [k, v] of pairs) {
|
|
68
|
+
this.set(k, v);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
package/dist/trait.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Page } from "@playwright/test";
|
|
2
|
+
import type { MemPage } from "./mem-page.js";
|
|
3
|
+
/**
|
|
4
|
+
* A named, independently-reportable check. `check` never runs before `resolve` has
|
|
5
|
+
* already decided the Checkpoint, and it can only confirm or fail loud - it has no
|
|
6
|
+
* way to redirect the run.
|
|
7
|
+
*/
|
|
8
|
+
export interface Trait {
|
|
9
|
+
/** Shows up in the error a failed check throws: `trait "${name}" failed after "${blockName}"`. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** Returns `true` if the check passes. May wait (e.g. `page.waitForURL`) - just don't redirect the run. */
|
|
12
|
+
check(page: Page, mem: MemPage): Promise<boolean>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Common, generic Trait factories - reusable across any Block, not one-off
|
|
16
|
+
* `{ name, check }` objects written inline per Block. Reach for one of these
|
|
17
|
+
* first; write a bespoke Trait only for a check that's genuinely app-specific.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Passes once the page's URL matches `pattern`, waiting for real navigation.
|
|
21
|
+
* `pattern` is a structured `URLPatternInit` (the same shape the standard
|
|
22
|
+
* `URLPattern` Web API takes) - `{ pathname, hostname, search, hash, ... }` -
|
|
23
|
+
* not a hand-written regex. Whichever components you don't specify default to
|
|
24
|
+
* "match anything" there, so a hybrid SPA tacking its own `?tab=cart&session=xyz`
|
|
25
|
+
* onto the URL never breaks a `pathname`-only check - there's no separate flag
|
|
26
|
+
* for that, omission already means "don't care." Reach for a bespoke Trait with
|
|
27
|
+
* a raw regex only if a route genuinely needs pattern power `URLPatternInit`'s
|
|
28
|
+
* fields can't express.
|
|
29
|
+
* @example Trait.url({ pathname: "/inventory.html" })
|
|
30
|
+
* @example Trait.url({ hostname: "checkout.example.com", pathname: "/pay" })
|
|
31
|
+
* @example Trait.url({ pathname: "/users/:id" }) // named path params, matched structurally
|
|
32
|
+
*/
|
|
33
|
+
export declare function urlMatches(pattern: URLPatternInit): Trait;
|
|
34
|
+
/** Passes when `selector`'s text content is exactly `expected`. */
|
|
35
|
+
export declare function textEquals(selector: string, expected: string): Trait;
|
|
36
|
+
/** Passes once `selector` is visible on the page, waiting for it to appear. */
|
|
37
|
+
export declare function visible(selector: string): Trait;
|
|
38
|
+
/**
|
|
39
|
+
* Discoverable entry point for the built-in Trait factories - type `Trait.` in
|
|
40
|
+
* an editor to see `url`/`text`/`visible` offered, instead of needing to
|
|
41
|
+
* already know `urlMatches`/`textEquals`/`visible` exist as free functions to
|
|
42
|
+
* import by name. Same functions, just reachable off the type's own name -
|
|
43
|
+
* `urlMatches`/`textEquals`/`visible` stay exported too, so nothing that
|
|
44
|
+
* already imports them by name breaks.
|
|
45
|
+
* @example verify: [Trait.url({ pathname: "/inventory.html" })]
|
|
46
|
+
* @example verify: [Trait.text(".shopping_cart_badge", "1")]
|
|
47
|
+
*/
|
|
48
|
+
export declare const Trait: {
|
|
49
|
+
url: typeof urlMatches;
|
|
50
|
+
text: typeof textEquals;
|
|
51
|
+
visible: typeof visible;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Runs every Trait for a Block that just resolved, throwing on the first failure
|
|
55
|
+
* with the failing Trait's own name and the Block's name - never called before
|
|
56
|
+
* resolve, never able to change what resolve already decided. `verify` may be a
|
|
57
|
+
* flat list or a function of the resolved Checkpoint (for a branching Block whose
|
|
58
|
+
* different outcomes need different checks). Called automatically by `runGraph`
|
|
59
|
+
* and `connect()` after every `resolve` - not something a Block normally calls
|
|
60
|
+
* directly.
|
|
61
|
+
*/
|
|
62
|
+
export declare function runVerify<Out>(verify: Trait[] | ((out: Out) => Trait[]) | undefined, out: Out, page: Page, mem: MemPage, blockName: string): Promise<void>;
|
package/dist/trait.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Common, generic Trait factories - reusable across any Block, not one-off
|
|
3
|
+
* `{ name, check }` objects written inline per Block. Reach for one of these
|
|
4
|
+
* first; write a bespoke Trait only for a check that's genuinely app-specific.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Passes once the page's URL matches `pattern`, waiting for real navigation.
|
|
8
|
+
* `pattern` is a structured `URLPatternInit` (the same shape the standard
|
|
9
|
+
* `URLPattern` Web API takes) - `{ pathname, hostname, search, hash, ... }` -
|
|
10
|
+
* not a hand-written regex. Whichever components you don't specify default to
|
|
11
|
+
* "match anything" there, so a hybrid SPA tacking its own `?tab=cart&session=xyz`
|
|
12
|
+
* onto the URL never breaks a `pathname`-only check - there's no separate flag
|
|
13
|
+
* for that, omission already means "don't care." Reach for a bespoke Trait with
|
|
14
|
+
* a raw regex only if a route genuinely needs pattern power `URLPatternInit`'s
|
|
15
|
+
* fields can't express.
|
|
16
|
+
* @example Trait.url({ pathname: "/inventory.html" })
|
|
17
|
+
* @example Trait.url({ hostname: "checkout.example.com", pathname: "/pay" })
|
|
18
|
+
* @example Trait.url({ pathname: "/users/:id" }) // named path params, matched structurally
|
|
19
|
+
*/
|
|
20
|
+
export function urlMatches(pattern) {
|
|
21
|
+
const urlPattern = new URLPattern(pattern);
|
|
22
|
+
return {
|
|
23
|
+
name: `url-matches(${JSON.stringify(pattern)})`,
|
|
24
|
+
async check(page) {
|
|
25
|
+
await page.waitForURL(urlPattern);
|
|
26
|
+
return true;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Passes when `selector`'s text content is exactly `expected`. */
|
|
31
|
+
export function textEquals(selector, expected) {
|
|
32
|
+
return {
|
|
33
|
+
name: `text-equals(${selector}, ${JSON.stringify(expected)})`,
|
|
34
|
+
async check(page) {
|
|
35
|
+
return (await page.locator(selector).textContent()) === expected;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Passes once `selector` is visible on the page, waiting for it to appear. */
|
|
40
|
+
export function visible(selector) {
|
|
41
|
+
return {
|
|
42
|
+
name: `visible(${selector})`,
|
|
43
|
+
async check(page) {
|
|
44
|
+
await page.locator(selector).waitFor({ state: "visible" });
|
|
45
|
+
return true;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Discoverable entry point for the built-in Trait factories - type `Trait.` in
|
|
51
|
+
* an editor to see `url`/`text`/`visible` offered, instead of needing to
|
|
52
|
+
* already know `urlMatches`/`textEquals`/`visible` exist as free functions to
|
|
53
|
+
* import by name. Same functions, just reachable off the type's own name -
|
|
54
|
+
* `urlMatches`/`textEquals`/`visible` stay exported too, so nothing that
|
|
55
|
+
* already imports them by name breaks.
|
|
56
|
+
* @example verify: [Trait.url({ pathname: "/inventory.html" })]
|
|
57
|
+
* @example verify: [Trait.text(".shopping_cart_badge", "1")]
|
|
58
|
+
*/
|
|
59
|
+
export const Trait = {
|
|
60
|
+
url: urlMatches,
|
|
61
|
+
text: textEquals,
|
|
62
|
+
visible,
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Runs every Trait for a Block that just resolved, throwing on the first failure
|
|
66
|
+
* with the failing Trait's own name and the Block's name - never called before
|
|
67
|
+
* resolve, never able to change what resolve already decided. `verify` may be a
|
|
68
|
+
* flat list or a function of the resolved Checkpoint (for a branching Block whose
|
|
69
|
+
* different outcomes need different checks). Called automatically by `runGraph`
|
|
70
|
+
* and `connect()` after every `resolve` - not something a Block normally calls
|
|
71
|
+
* directly.
|
|
72
|
+
*/
|
|
73
|
+
export async function runVerify(verify, out, page, mem, blockName) {
|
|
74
|
+
const traits = typeof verify === "function" ? verify(out) : (verify ?? []);
|
|
75
|
+
for (const trait of traits) {
|
|
76
|
+
if (!(await trait.check(page, mem))) {
|
|
77
|
+
throw new Error(`trait "${trait.name}" failed after "${blockName}"`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { Page } from "@playwright/test";
|
|
2
|
+
import type { MemPage, MemKey } from "./mem-page.js";
|
|
3
|
+
import type { Trait } from "./trait.js";
|
|
4
|
+
/**
|
|
5
|
+
* A Checkpoint is identified solely by its string tag - a phantom marker with no
|
|
6
|
+
* embedded data. Two Checkpoints with the same tag are the same state everywhere.
|
|
7
|
+
*/
|
|
8
|
+
export type Checkpoint<Tag extends string> = {
|
|
9
|
+
readonly __state: Tag;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Builds a Checkpoint - spells `__state` for you so no call site does it by hand.
|
|
13
|
+
* @example resolve: () => checkpoint("LoggedIn")
|
|
14
|
+
*/
|
|
15
|
+
export declare function checkpoint<Tag extends string>(tag: Tag): Checkpoint<Tag>;
|
|
16
|
+
/**
|
|
17
|
+
* The four-phase pipeline every Block runs. Most Blocks only need `act` + a
|
|
18
|
+
* one-line `resolve` - add `observe` only once a Block genuinely branches, add
|
|
19
|
+
* `verify` only once you want to confirm the reached state, not just classify it.
|
|
20
|
+
*/
|
|
21
|
+
export interface Instruction<In extends Checkpoint<string>, Out extends Checkpoint<string>, Observed = void> {
|
|
22
|
+
/** Drives the browser only, this tab only. Never touches `context` - see `observe` for that. */
|
|
23
|
+
act(page: Page, input: In, mem: MemPage): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Optional. Gathers evidence for `resolve` to classify, runs assertions,
|
|
26
|
+
* writes memory - the only phase allowed to look at the DOM before a
|
|
27
|
+
* decision is made, or open a second tab via `page.context()`.
|
|
28
|
+
*/
|
|
29
|
+
observe?(page: Page, mem: MemPage): Promise<Observed>;
|
|
30
|
+
/**
|
|
31
|
+
* Mandatory, pure. Its only parameter is the evidence `observe` handed it -
|
|
32
|
+
* no `page`, no `mem` in scope, not by convention but because they're
|
|
33
|
+
* missing from the signature. Reproducible from `observed` alone.
|
|
34
|
+
*/
|
|
35
|
+
resolve(observed: Observed): Out | Promise<Out>;
|
|
36
|
+
/**
|
|
37
|
+
* Optional. Runs once, only after `resolve` has already decided `Out` -
|
|
38
|
+
* confirms or fails loud, can never redirect what was decided. A function
|
|
39
|
+
* form picks different checks per branch (a failed login shouldn't be
|
|
40
|
+
* checked against "reached the dashboard"); see {@link Block.withVerify} and
|
|
41
|
+
* {@link Block.modVerify} for changing this from outside the Block's own file.
|
|
42
|
+
*/
|
|
43
|
+
verify?: Trait[] | ((out: Out) => Trait[]);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A Block wraps one instruction. Its Observed type is an internal detail, erased here.
|
|
47
|
+
* `next` is optional routing attached by `branch()` - unset on a plain or
|
|
48
|
+
* `connect()`-composed Block, meaning "terminal after one step" (today's behavior,
|
|
49
|
+
* unchanged). Returning the Block itself from `next` is a self-loop. `requires` is
|
|
50
|
+
* MemKeys this Block's `act` reads from `mem` and needs already set - declaring it
|
|
51
|
+
* lets `preflight()` catch a missing value before any browser action runs, instead
|
|
52
|
+
* of failing loud deep inside `act` after several real steps already happened.
|
|
53
|
+
*
|
|
54
|
+
* `withVerify`/`modVerify`/`modVerifyAll` are optional - present (and discoverable
|
|
55
|
+
* by typing `someBlock.`) on any Block built via `defineBlock()`, absent on a raw
|
|
56
|
+
* object literal (which is still a perfectly valid Block, e.g. every test fixture
|
|
57
|
+
* in this codebase). They delegate to the identically-named free functions - same
|
|
58
|
+
* logic, chainable as methods for the common case, still callable as
|
|
59
|
+
* `withVerify(block, ...)` for a block that doesn't have them attached.
|
|
60
|
+
*/
|
|
61
|
+
export interface Block<In extends Checkpoint<string>, Out extends Checkpoint<string>> {
|
|
62
|
+
/** A short, stable label - shows up in every error this Block can produce ("trait X failed after Y"). */
|
|
63
|
+
name: string;
|
|
64
|
+
/** The act/observe/resolve/verify pipeline itself. See {@link Instruction}. */
|
|
65
|
+
instruction: Instruction<In, Out, any>;
|
|
66
|
+
/**
|
|
67
|
+
* Routing attached by {@link branch}. Unset on a plain or `connect()`-composed
|
|
68
|
+
* Block, meaning "terminal after one step". Returning the Block itself is a
|
|
69
|
+
* self-loop - see `runGraph`'s `maxSteps` for the safety net that needs.
|
|
70
|
+
*/
|
|
71
|
+
next?(checkpoint: Out): Block<any, any> | null | undefined;
|
|
72
|
+
/**
|
|
73
|
+
* MemKeys this Block's `act` reads from `mem` and needs already set.
|
|
74
|
+
* Declaring it lets {@link preflight} (built into `runGraph`) catch a missing
|
|
75
|
+
* value before any browser action runs, instead of failing loud deep inside
|
|
76
|
+
* `act`.
|
|
77
|
+
* @example requires: [LoginCreds.key]
|
|
78
|
+
*/
|
|
79
|
+
requires?: readonly MemKey<any>[];
|
|
80
|
+
/**
|
|
81
|
+
* Replaces this Block's whole `verify` list - the only way to clear it to
|
|
82
|
+
* empty (the "I only care that I navigated this far" case) or go from no
|
|
83
|
+
* verify to some. For tweaking one existing check without restating the
|
|
84
|
+
* rest, use {@link Block.modVerify} instead.
|
|
85
|
+
* @example LoginBlock.withVerify([]) // navigation-only, no DOM confirmation
|
|
86
|
+
* @example LoginBlock.withVerify([urlMatches(/dashboard/)])
|
|
87
|
+
*/
|
|
88
|
+
withVerify?(verify: Trait[] | ((out: Out) => Trait[])): Block<In, Out>;
|
|
89
|
+
/**
|
|
90
|
+
* Replaces one existing Trait in this Block's `verify` list, by its `name`
|
|
91
|
+
* (recommended) or numeric index, leaving every other check untouched - the
|
|
92
|
+
* v1/v2/legacy-app case, where one check needs a different selector but the
|
|
93
|
+
* rest of the Block is identical. Throws loud if the name/index doesn't
|
|
94
|
+
* exist - use {@link Block.withVerify} if there's nothing there yet to replace.
|
|
95
|
+
* @example LoginBlock.modVerify("url-matches", urlMatches(/dashboard\.html/))
|
|
96
|
+
*/
|
|
97
|
+
modVerify?(nameOrIndex: string | number, newCheck: Trait["check"] | Trait): Block<In, Out>;
|
|
98
|
+
/**
|
|
99
|
+
* {@link Block.modVerify}, but for several named checks in one call - a v2
|
|
100
|
+
* variant that differs in five ways from v1 shouldn't need five nested calls.
|
|
101
|
+
* @example LoginBlock.modVerifyAll({ "url-matches": urlMatches(/v2/), "cart-badge": textEquals(".badge-v2", "1") })
|
|
102
|
+
*/
|
|
103
|
+
modVerifyAll?(patches: Record<string, Trait["check"] | Trait>): Block<In, Out>;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* A Block guaranteed to have `withVerify`/`modVerify`/`modVerifyAll` attached -
|
|
107
|
+
* what `defineBlock()` and, since they're built on it, `withVerify()`/`modVerify()`/
|
|
108
|
+
* `modVerifyAll()`/`branch()` all return. Lets `LoginBlock.withVerify(...)` be
|
|
109
|
+
* called directly, no `!`/`?.` needed - the type says the method is there, not
|
|
110
|
+
* "maybe". A plain `Block<In, Out>` (a raw object literal) doesn't get this;
|
|
111
|
+
* `DefinedBlock` is still assignable anywhere a `Block` is expected.
|
|
112
|
+
*/
|
|
113
|
+
export type DefinedBlock<In extends Checkpoint<string>, Out extends Checkpoint<string>> = Omit<Block<In, Out>, "withVerify" | "modVerify" | "modVerifyAll"> & {
|
|
114
|
+
withVerify(verify: Trait[] | ((out: Out) => Trait[])): DefinedBlock<In, Out>;
|
|
115
|
+
modVerify(nameOrIndex: string | number, newCheck: Trait["check"] | Trait): DefinedBlock<In, Out>;
|
|
116
|
+
modVerifyAll(patches: Record<string, Trait["check"] | Trait>): DefinedBlock<In, Out>;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Composes two Blocks into one, typechecked so this only compiles when A's output
|
|
120
|
+
* tag equals B's input tag. The composed Block's act runs A's full instruction
|
|
121
|
+
* (act -> observe? -> resolve) then hands the resulting Checkpoint into B's act;
|
|
122
|
+
* its own observe/resolve are simply B's, since B is what decides the final Out.
|
|
123
|
+
*/
|
|
124
|
+
export declare function connect<A extends Checkpoint<string>, B extends Checkpoint<string>, C extends Checkpoint<string>>(a: Block<A, B>, b: Block<B, C>): Block<A, C>;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { runVerify } from "./trait.js";
|
|
2
|
+
/**
|
|
3
|
+
* Builds a Checkpoint - spells `__state` for you so no call site does it by hand.
|
|
4
|
+
* @example resolve: () => checkpoint("LoggedIn")
|
|
5
|
+
*/
|
|
6
|
+
export function checkpoint(tag) {
|
|
7
|
+
return { __state: tag };
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Composes two Blocks into one, typechecked so this only compiles when A's output
|
|
11
|
+
* tag equals B's input tag. The composed Block's act runs A's full instruction
|
|
12
|
+
* (act -> observe? -> resolve) then hands the resulting Checkpoint into B's act;
|
|
13
|
+
* its own observe/resolve are simply B's, since B is what decides the final Out.
|
|
14
|
+
*/
|
|
15
|
+
export function connect(a, b) {
|
|
16
|
+
const bObserve = b.instruction.observe;
|
|
17
|
+
const bVerify = b.instruction.verify;
|
|
18
|
+
const requires = [...(a.requires ?? []), ...(b.requires ?? [])];
|
|
19
|
+
return {
|
|
20
|
+
name: `${a.name} -> ${b.name}`,
|
|
21
|
+
...(requires.length > 0 ? { requires } : {}),
|
|
22
|
+
instruction: {
|
|
23
|
+
async act(page, input, mem) {
|
|
24
|
+
await a.instruction.act(page, input, mem);
|
|
25
|
+
const aObserved = a.instruction.observe
|
|
26
|
+
? await a.instruction.observe(page, mem)
|
|
27
|
+
: undefined;
|
|
28
|
+
const mid = await a.instruction.resolve(aObserved);
|
|
29
|
+
await runVerify(a.instruction.verify, mid, page, mem, a.name);
|
|
30
|
+
await b.instruction.act(page, mid, mem);
|
|
31
|
+
},
|
|
32
|
+
resolve: (observed) => b.instruction.resolve(observed),
|
|
33
|
+
// Only attach optional fields when B actually has them - exactOptionalPropertyTypes
|
|
34
|
+
// forbids assigning `undefined` to an optional property directly.
|
|
35
|
+
...(bObserve ? { observe: (page, mem) => bObserve(page, mem) } : {}),
|
|
36
|
+
...(bVerify ? { verify: bVerify } : {}),
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "waygraph",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/deviate-dv8/waygraph.git"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -p tsconfig.build.json",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"test": "playwright test"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@playwright/test": "^1.40.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@playwright/test": "^1.55.0",
|
|
31
|
+
"@types/node": "^26.0.0",
|
|
32
|
+
"typescript": "^5.6.0"
|
|
33
|
+
}
|
|
34
|
+
}
|