waygraph 0.16.2 → 0.16.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/dist/analysis/practices-check.d.ts +1 -1
- package/dist/analysis/practices-check.js +36 -0
- package/dist/beta.d.ts +6 -0
- package/dist/beta.js +5 -0
- package/dist/cli/commands/codemod-flows.js +2 -2
- package/dist/cli/commands/codemod-stubs.js +43 -1
- package/dist/cli/commands/pilot.js +3 -16
- package/dist/cli/commands/session-ops.js +26 -1
- package/dist/cli/flags.d.ts +1 -1
- package/dist/cli/flags.js +1 -1
- package/dist/cli/usage.js +6 -0
- package/dist/demo/narration/stub-ctx.js +126 -124
- package/dist/demo/overlay/cursor.js +1 -1
- package/dist/demo/overlay/inpage/banner.js +5 -0
- package/dist/demo/overlay/inpage/core.js +1 -0
- package/dist/demo/overlay/inpage/network.js +1 -1
- package/dist/demo/overlay/inpage/todos.js +0 -15
- package/dist/demo/overlay/network-capture.js +2 -2
- package/dist/demo/overlay/overlay-css.js +18 -21
- package/dist/demo/overlay/overlay-stage.js +1 -1
- package/dist/demo/overlay/phase-fixtures.js +5 -5
- package/dist/demo/overlay/pilot/shell.js +7 -5
- package/dist/demo/overlay/ui/components/ring.js +2 -2
- package/dist/demo/overlay/ui/overlay.css +98 -35
- package/dist/demo/overlay/ui/shadow.js +69 -2
- package/dist/demo/stepper/demo-log.js +8 -8
- package/dist/demo/stepper/slides.d.ts +1 -1
- package/dist/demo/stepper/slides.js +1 -1
- package/dist/demo/stepper/stepper.js +1 -1
- package/dist/engine/block/kind.d.ts +3 -0
- package/dist/engine/block/kind.js +11 -0
- package/dist/engine/errors.d.ts +12 -0
- package/dist/engine/errors.js +12 -0
- package/dist/engine/read/read-block.d.ts +61 -0
- package/dist/engine/read/read-block.js +94 -0
- package/dist/engine/read/read-key.d.ts +32 -0
- package/dist/engine/read/read-key.js +34 -0
- package/dist/engine/read/read-page.d.ts +19 -0
- package/dist/engine/read/read-page.js +50 -0
- package/dist/engine/read/readers.d.ts +82 -0
- package/dist/engine/read/readers.js +43 -0
- package/dist/engine/read/run-read.d.ts +17 -0
- package/dist/engine/read/run-read.js +156 -0
- package/dist/engine/run/connect.js +3 -2
- package/dist/engine/run/events.d.ts +28 -10
- package/dist/engine/run/events.js +52 -38
- package/dist/engine/run/run.js +25 -13
- package/dist/engine/stub/stub.d.ts +26 -21
- package/dist/index.d.ts +7 -4
- package/dist/index.js +9 -4
- package/dist/live/auto/auto.js +2 -3
- package/dist/live/auto/menu.js +3 -2
- package/dist/live/auto-session/auto-session.d.ts +9 -2
- package/dist/live/auto-session/auto-session.js +23 -20
- package/dist/live/auto-session/feed.d.ts +16 -0
- package/dist/live/auto-session/feed.js +71 -0
- package/dist/live/auto-session/helpers.d.ts +7 -0
- package/dist/live/auto-session/helpers.js +9 -0
- package/dist/live/auto-session/ipc.d.ts +15 -47
- package/dist/live/auto-session/ipc.js +34 -4
- package/dist/live/auto-session/protocol.d.ts +127 -0
- package/dist/live/auto-session/protocol.js +6 -0
- package/dist/live/auto-session/read.d.ts +25 -0
- package/dist/live/auto-session/read.js +50 -0
- package/dist/live/traverse/worker.d.ts +1 -0
- package/dist/live/traverse/worker.js +6 -1
- package/dist/manifest/manifest.d.ts +22 -2
- package/dist/manifest/manifest.js +27 -1
- package/dist/manifest/project.d.ts +10 -1
- package/dist/manifest/project.js +9 -12
- package/dist/manifest/read-blocks.d.ts +2 -0
- package/dist/manifest/read-blocks.js +23 -0
- package/dist/manifest/scan.d.ts +3 -2
- package/dist/manifest/scan.js +8 -4
- package/dist/runner/runner.d.ts +0 -1
- package/dist/runner/runner.js +6 -6
- package/dist/runtime/full-launch.d.ts +2 -1
- package/dist/runtime/full-launch.js +29 -27
- package/dist/runtime/run-log.d.ts +2 -0
- package/dist/runtime/run-log.js +13 -6
- package/examples/saucedemo/README.md +2 -2
- package/examples/saucedemo/src/blocks/SITE-MAP.md +3 -0
- package/examples/saucedemo/src/blocks/saucedemo-web/cart/cart.read.block.ts +26 -0
- package/examples/saucedemo/src/blocks/saucedemo-web/header.read.block.ts +25 -0
- package/examples/saucedemo/src/blocks/saucedemo-web/inventory/inventory.read.block.ts +28 -0
- package/examples/saucedemo/src/flows/api-demo.flow.ts +1 -1
- package/examples/saucedemo/src/flows/cart-bulk.flow.ts +1 -1
- package/examples/saucedemo/src/flows/cart-continue.flow.ts +1 -1
- package/examples/saucedemo/src/flows/catalog-logout.flow.ts +1 -1
- package/examples/saucedemo/src/flows/checkout-then-browse-again.flow.ts +1 -1
- package/examples/saucedemo/src/flows/checkout.flow.ts +11 -11
- package/examples/saucedemo/src/flows/login.flow.ts +1 -1
- package/examples/saucedemo/src/flows/shop.flow.ts +1 -1
- package/examples/saucedemo/src/flows/stub-on-error.flow.ts +1 -1
- package/examples/saucedemo/src/flows/viewer-blocked.flow.ts +1 -1
- package/examples/saucedemo/src/states/checkout.reads.ts +9 -0
- package/examples/saucedemo/src/stubs/device-showcase.ts +37 -37
- package/examples/saucedemo/src/stubs/finish-order.ts +2 -2
- package/examples/saucedemo/src/stubs/login.ts +5 -5
- package/package.json +5 -1
- package/templates/quickstart/src/flows/api-demo.flow.ts +1 -1
- package/templates/quickstart/src/flows/cart-bulk.flow.ts +1 -1
- package/templates/quickstart/src/flows/cart-continue.flow.ts +1 -1
- package/templates/quickstart/src/flows/catalog-logout.flow.ts +1 -1
- package/templates/quickstart/src/flows/checkout-then-browse-again.flow.ts +1 -1
- package/templates/quickstart/src/flows/checkout.flow.ts +11 -11
- package/templates/quickstart/src/flows/login.flow.ts +1 -1
- package/templates/quickstart/src/flows/shop.flow.ts +1 -1
- package/templates/quickstart/src/flows/stub-on-error.flow.ts +1 -1
- package/templates/quickstart/src/flows/viewer-blocked.flow.ts +1 -1
- package/templates/quickstart/src/stubs/device-showcase.ts +37 -37
- package/templates/quickstart/src/stubs/finish-order.ts +2 -2
- package/templates/quickstart/src/stubs/login.ts +5 -5
- package/templates/scaffold/src/flows/(external)/hoppscotch/auth.flow.ts +1 -1
- package/templates/scaffold/src/flows/(external)/hoppscotch/get.flow.ts +1 -1
- package/templates/scaffold/src/flows/(external)/hoppscotch/post.flow.ts +1 -1
- package/templates/scaffold/src/flows/(user-stories)/email-verification.flow.ts +4 -4
- package/templates/scaffold/src/flows/README.md +1 -1
- package/templates/scaffold/src/flows/home/cart.flow.ts +7 -7
- package/templates/scaffold/src/flows/home/hello.flow.ts +8 -8
- package/templates/skills/waygraph-convention.skill.md +10 -2
- package/templates/skills/waygraph-pilot.skill.md +7 -0
|
@@ -231,7 +231,7 @@ export async function presentSlides(page, slides, opts) {
|
|
|
231
231
|
}
|
|
232
232
|
await hideRing(page);
|
|
233
233
|
}
|
|
234
|
-
/** Plays a stub phase's `ctx.slides(...)` for the step on screen, in the flow's highlight style. */
|
|
234
|
+
/** Plays a stub phase's `ctx.ui.slides(...)` for the step on screen, in the flow's highlight style. */
|
|
235
235
|
export async function showSlides(ctx, view, slides) {
|
|
236
236
|
if (slides.length === 0)
|
|
237
237
|
return;
|
|
@@ -83,7 +83,7 @@ export async function createStepper(page, mem, steps, options) {
|
|
|
83
83
|
}
|
|
84
84
|
/**
|
|
85
85
|
* Some Blocks call page.setViewportSize() in their own act for their own tests; in a watched demo that would resize
|
|
86
|
-
* the window, so it does nothing here. An authored ctx.device() still resizes through __wgRealSetViewportSize.
|
|
86
|
+
* the window, so it does nothing here. An authored ctx.ui.device() still resizes through __wgRealSetViewportSize.
|
|
87
87
|
*/
|
|
88
88
|
function keepWindowSize(page) {
|
|
89
89
|
page.__wgRealSetViewportSize = page.setViewportSize.bind(page);
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** Which factory made a Block: `nav`, `page`, `assert`, `method`, `effect`, `api`, or a plain `block` (`defineBlock`). */
|
|
2
|
+
export type BlockKind = "nav" | "page" | "assert" | "method" | "effect" | "api" | "block";
|
|
3
|
+
export declare function blockKindOf(block: object): BlockKind;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// A Block's kind, from the marker its factory stamps on it: what the manifest lists, what a run event carries, what a
|
|
2
|
+
// viewer colours a step by.
|
|
3
|
+
export function blockKindOf(block) {
|
|
4
|
+
const kind = block.__waygraphKind;
|
|
5
|
+
if (kind === "nav" || kind === "page" || kind === "assert" || kind === "api")
|
|
6
|
+
return kind;
|
|
7
|
+
const salt = block.__waygraphSalt;
|
|
8
|
+
if (salt === "effect" || salt === "method" || salt === "api")
|
|
9
|
+
return salt;
|
|
10
|
+
return "block";
|
|
11
|
+
}
|
package/dist/engine/errors.d.ts
CHANGED
|
@@ -108,6 +108,16 @@ export declare const WAYGRAPH_ERRORS: {
|
|
|
108
108
|
readonly means: "The Trait name/index given doesn't exist in this Block's verify list.";
|
|
109
109
|
readonly fix: "Check the Block's actual verify list for the right name/index.";
|
|
110
110
|
};
|
|
111
|
+
readonly WG_READ_MISSING: {
|
|
112
|
+
readonly where: "engine/read/read-page.ts - ReadPage.get()";
|
|
113
|
+
readonly means: "read.get(key) asked for a value the page did not show, or that no Read Block for this page reads.";
|
|
114
|
+
readonly fix: "Check read.has(key) first, or read.missing / read.notes for why; add the field to the page's Read Block.";
|
|
115
|
+
};
|
|
116
|
+
readonly WG_READ_DUPLICATE: {
|
|
117
|
+
readonly where: "engine/read/read-block.ts - readCheckpoint()";
|
|
118
|
+
readonly means: "Two Read Blocks for the same page both read one key, so it has two answers.";
|
|
119
|
+
readonly fix: "Read each key in one Read Block per Checkpoint (an every-page Read Block counts for every Checkpoint).";
|
|
120
|
+
};
|
|
111
121
|
readonly WG_MEM_READ_BEFORE_SET: {
|
|
112
122
|
readonly where: "engine/mem/mem.ts - MemPage.get()";
|
|
113
123
|
readonly means: "mem.get(key) was called for a key that was never mem.set() on this MemPage.";
|
|
@@ -143,6 +153,8 @@ export declare const MSG_MAP_FF_NOT_CLOSED: (ffName: string) => string;
|
|
|
143
153
|
export declare const MSG_MAP_EMPTY: string;
|
|
144
154
|
export declare const MSG_BRANCH_REGRESSION_NO_BROWSER: string;
|
|
145
155
|
export declare const MSG_MEM_READ_BEFORE_SET: (keyName: string) => string;
|
|
156
|
+
export declare const MSG_READ_MISSING: (path: string, note?: string) => string;
|
|
157
|
+
export declare const MSG_READ_DUPLICATE: (path: string, first: string, second: string) => string;
|
|
146
158
|
export declare const MSG_COMPOSE_EMPTY: (name: string) => string;
|
|
147
159
|
export declare const MSG_FASTFORWARD_EMPTY: (name: string) => string;
|
|
148
160
|
export declare const MSG_CHAINFLOW_BLOCK_NOT_FOUND_BY_INDEX: (index: number, count: number) => string;
|
package/dist/engine/errors.js
CHANGED
|
@@ -108,6 +108,16 @@ export const WAYGRAPH_ERRORS = {
|
|
|
108
108
|
means: "The Trait name/index given doesn't exist in this Block's verify list.",
|
|
109
109
|
fix: "Check the Block's actual verify list for the right name/index.",
|
|
110
110
|
},
|
|
111
|
+
WG_READ_MISSING: {
|
|
112
|
+
where: "engine/read/read-page.ts - ReadPage.get()",
|
|
113
|
+
means: "read.get(key) asked for a value the page did not show, or that no Read Block for this page reads.",
|
|
114
|
+
fix: "Check read.has(key) first, or read.missing / read.notes for why; add the field to the page's Read Block.",
|
|
115
|
+
},
|
|
116
|
+
WG_READ_DUPLICATE: {
|
|
117
|
+
where: "engine/read/read-block.ts - readCheckpoint()",
|
|
118
|
+
means: "Two Read Blocks for the same page both read one key, so it has two answers.",
|
|
119
|
+
fix: "Read each key in one Read Block per Checkpoint (an every-page Read Block counts for every Checkpoint).",
|
|
120
|
+
},
|
|
111
121
|
WG_MEM_READ_BEFORE_SET: {
|
|
112
122
|
where: "engine/mem/mem.ts - MemPage.get()",
|
|
113
123
|
means: "mem.get(key) was called for a key that was never mem.set() on this MemPage.",
|
|
@@ -156,6 +166,8 @@ export const MSG_MAP_EMPTY = "Waygraph map: .end() called with zero steps - add
|
|
|
156
166
|
export const MSG_BRANCH_REGRESSION_NO_BROWSER = "Waygraph runBranchRegression: cloneSession needs context.browser() (a persistent context " +
|
|
157
167
|
"has none) - pass { cloneSession: false } to run the single live path in this context instead.";
|
|
158
168
|
export const MSG_MEM_READ_BEFORE_SET = (keyName) => `MemPage: "${keyName}" read before it was set`;
|
|
169
|
+
export const MSG_READ_MISSING = (path, note) => `ReadPage: "${path}" was not shown on this page${note ? ` (${note})` : ""}`;
|
|
170
|
+
export const MSG_READ_DUPLICATE = (path, first, second) => `Read Blocks "${first}" and "${second}" both read "${path}" on the same page`;
|
|
159
171
|
export const MSG_COMPOSE_EMPTY = (name) => `composeBlock: "${name}" needs at least one step`;
|
|
160
172
|
export const MSG_FASTFORWARD_EMPTY = (name) => `fastForwardComposeBlock: "${name}" needs at least one step`;
|
|
161
173
|
export const MSG_CHAINFLOW_BLOCK_NOT_FOUND_BY_INDEX = (index, count) => `chainFlow: no Block at index ${index} across ${count} chained flows`;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Page } from "@playwright/test";
|
|
2
|
+
import type { Checkpoint } from "../checkpoint/checkpoint.js";
|
|
3
|
+
import type { MemKey, MemPage } from "../mem/mem.js";
|
|
4
|
+
import type { ReadSpec } from "./read-key.js";
|
|
5
|
+
import { ReadPage } from "./read-page.js";
|
|
6
|
+
import { type ReadCtx } from "./readers.js";
|
|
7
|
+
/** Options for {@link defineReadBlock}: shaped like a Method's, with one instruction phase. */
|
|
8
|
+
export interface ReadBlockOptions {
|
|
9
|
+
name: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
/** The Checkpoint(s) whose page this reads. Leave it out to read on every page (a header, the signed-in user). */
|
|
12
|
+
from?: Checkpoint<string> | readonly Checkpoint<string>[];
|
|
13
|
+
/** Mem keys `read` uses through `ctx.mem`; without them set, this Read Block is skipped (and said so). */
|
|
14
|
+
requires?: readonly MemKey<unknown>[];
|
|
15
|
+
instruction: {
|
|
16
|
+
/** Returns one namespace's spec (`Post({ ... })`) or several. Runs on every read, so it can use `ctx.mem`. */
|
|
17
|
+
read(ctx: ReadCtx): ReadSpec | readonly ReadSpec[];
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** A defined Read Block. */
|
|
21
|
+
export interface ReadBlock {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly description: string;
|
|
24
|
+
/** Checkpoint names, or `["*"]` for every page. */
|
|
25
|
+
readonly from: readonly string[];
|
|
26
|
+
readonly requires: readonly MemKey<unknown>[];
|
|
27
|
+
readonly instruction: ReadBlockOptions["instruction"];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Declares what a page shows, as typed data an agent or a test can read.
|
|
31
|
+
* @example
|
|
32
|
+
* export const PostReadBlock = defineReadBlock({
|
|
33
|
+
* name: "read-post",
|
|
34
|
+
* from: PostOpen,
|
|
35
|
+
* instruction: {
|
|
36
|
+
* read: (ctx) => Post({ title: ctx.page.text("h1"), likes: ctx.page.number(".likes") }),
|
|
37
|
+
* },
|
|
38
|
+
* });
|
|
39
|
+
*/
|
|
40
|
+
export declare function defineReadBlock(options: ReadBlockOptions): ReadBlock;
|
|
41
|
+
/** The Read Blocks that read `checkpoint`'s page (its own, then the every-page ones). `null`: only the every-page ones. */
|
|
42
|
+
export declare function readBlocksFor(checkpoint: string | null): ReadBlock[];
|
|
43
|
+
/** What a read of one Checkpoint's page found, and which Read Blocks took part. */
|
|
44
|
+
export interface CheckpointRead {
|
|
45
|
+
read: ReadPage;
|
|
46
|
+
blocks: string[];
|
|
47
|
+
/** Read Blocks left out, and why (a Mem key they need is not set). */
|
|
48
|
+
skipped: {
|
|
49
|
+
block: string;
|
|
50
|
+
reason: string;
|
|
51
|
+
}[];
|
|
52
|
+
}
|
|
53
|
+
/** Reads `checkpoint`'s page with every Read Block for it, merged into one {@link ReadPage}. */
|
|
54
|
+
export declare function readCheckpoint(page: Page, mem: MemPage, checkpoint: string | null): Promise<CheckpointRead>;
|
|
55
|
+
/** What a field reads, as a type a viewer can show: `"string"`, `"number"`, `"boolean"`, `"string[]"`, a row shape. */
|
|
56
|
+
export type ReadShape = string | readonly [Record<string, string>];
|
|
57
|
+
/**
|
|
58
|
+
* The fields a Read Block reads and their shapes, by path (`"catalog.items": [{ name: "string", price: "number" }]`),
|
|
59
|
+
* without a page: `read` only builds plans. Throws when `read` needs a Mem value that `mem` does not have.
|
|
60
|
+
*/
|
|
61
|
+
export declare function readBlockShape(block: ReadBlock, mem: MemPage): Record<string, ReadShape>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { WaygraphError, MSG_READ_DUPLICATE } from "../errors.js";
|
|
2
|
+
import { ReadPage } from "./read-page.js";
|
|
3
|
+
import { createReadCtx, Reader } from "./readers.js";
|
|
4
|
+
import { runReadSpecs, settlePage } from "./run-read.js";
|
|
5
|
+
/** Every Read Block a loaded file defined, by name (the scanner re-imports files; a re-import replaces, never duplicates). */
|
|
6
|
+
const registry = new Map();
|
|
7
|
+
/**
|
|
8
|
+
* Declares what a page shows, as typed data an agent or a test can read.
|
|
9
|
+
* @example
|
|
10
|
+
* export const PostReadBlock = defineReadBlock({
|
|
11
|
+
* name: "read-post",
|
|
12
|
+
* from: PostOpen,
|
|
13
|
+
* instruction: {
|
|
14
|
+
* read: (ctx) => Post({ title: ctx.page.text("h1"), likes: ctx.page.number(".likes") }),
|
|
15
|
+
* },
|
|
16
|
+
* });
|
|
17
|
+
*/
|
|
18
|
+
export function defineReadBlock(options) {
|
|
19
|
+
const from = options.from === undefined ? ["*"] : (Array.isArray(options.from) ? options.from : [options.from]).map((c) => c.__state);
|
|
20
|
+
const block = {
|
|
21
|
+
name: options.name,
|
|
22
|
+
description: options.description ?? "",
|
|
23
|
+
from,
|
|
24
|
+
requires: options.requires ?? [],
|
|
25
|
+
instruction: options.instruction,
|
|
26
|
+
};
|
|
27
|
+
registry.set(block.name, block);
|
|
28
|
+
return block;
|
|
29
|
+
}
|
|
30
|
+
/** The Read Blocks that read `checkpoint`'s page (its own, then the every-page ones). `null`: only the every-page ones. */
|
|
31
|
+
export function readBlocksFor(checkpoint) {
|
|
32
|
+
const blocks = [...registry.values()];
|
|
33
|
+
return [...blocks.filter((b) => checkpoint !== null && b.from.includes(checkpoint)), ...blocks.filter((b) => b.from.includes("*"))];
|
|
34
|
+
}
|
|
35
|
+
/** Reads `checkpoint`'s page with every Read Block for it, merged into one {@link ReadPage}. */
|
|
36
|
+
export async function readCheckpoint(page, mem, checkpoint) {
|
|
37
|
+
const specs = [];
|
|
38
|
+
const owner = new Map();
|
|
39
|
+
const blocks = [];
|
|
40
|
+
const skipped = [];
|
|
41
|
+
for (const block of readBlocksFor(checkpoint)) {
|
|
42
|
+
const unset = block.requires.filter((k) => !mem.has(k)).map((k) => k.name);
|
|
43
|
+
if (unset.length) {
|
|
44
|
+
skipped.push({ block: block.name, reason: `needs mem ${unset.join(", ")}` });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const got = block.instruction.read(createReadCtx(mem));
|
|
48
|
+
for (const spec of Array.isArray(got) ? got : [got]) {
|
|
49
|
+
for (const field of Object.keys(spec.fields)) {
|
|
50
|
+
const path = `${spec.namespace}.${field}`;
|
|
51
|
+
const other = owner.get(path);
|
|
52
|
+
if (other)
|
|
53
|
+
throw new WaygraphError("WG_READ_DUPLICATE", MSG_READ_DUPLICATE(path, other, block.name));
|
|
54
|
+
owner.set(path, block.name);
|
|
55
|
+
}
|
|
56
|
+
specs.push(spec);
|
|
57
|
+
}
|
|
58
|
+
blocks.push(block.name);
|
|
59
|
+
}
|
|
60
|
+
if (specs.length)
|
|
61
|
+
await settlePage(page);
|
|
62
|
+
const { values, notes } = await runReadSpecs(page, mem, specs);
|
|
63
|
+
return { read: new ReadPage(values, notes), blocks, skipped };
|
|
64
|
+
}
|
|
65
|
+
const SHAPE = { text: "string", attr: "string", value: "string", number: "number", count: "number", visible: "boolean", texts: "string[]", fn: "unknown" };
|
|
66
|
+
/**
|
|
67
|
+
* The fields a Read Block reads and their shapes, by path (`"catalog.items": [{ name: "string", price: "number" }]`),
|
|
68
|
+
* without a page: `read` only builds plans. Throws when `read` needs a Mem value that `mem` does not have.
|
|
69
|
+
*/
|
|
70
|
+
export function readBlockShape(block, mem) {
|
|
71
|
+
const out = {};
|
|
72
|
+
const walk = (fields, prefix) => {
|
|
73
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
74
|
+
if (value === undefined)
|
|
75
|
+
continue;
|
|
76
|
+
const path = `${prefix}.${name}`;
|
|
77
|
+
if (!(value instanceof Reader)) {
|
|
78
|
+
walk(value, path);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const plan = value.plan;
|
|
82
|
+
if (plan.kind === "list")
|
|
83
|
+
out[path] = [Object.fromEntries(Object.entries(plan.fields).map(([k, f]) => [k, SHAPE[f.kind] ?? "unknown"]))];
|
|
84
|
+
else if (plan.kind === "table")
|
|
85
|
+
out[path] = [Object.fromEntries(Object.keys(plan.columns).map((k) => [k, "string"]))];
|
|
86
|
+
else
|
|
87
|
+
out[path] = SHAPE[plan.kind] ?? "unknown";
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const got = block.instruction.read(createReadCtx(mem));
|
|
91
|
+
for (const spec of Array.isArray(got) ? got : [got])
|
|
92
|
+
walk(spec.fields, spec.namespace);
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Reader, ReadFields } from "./readers.js";
|
|
2
|
+
/** A typed key into a {@link ReadPage}, named by its path (`"post.likes"`). Made by a {@link reads} namespace. */
|
|
3
|
+
export declare class ReadKey<T> {
|
|
4
|
+
readonly path: string;
|
|
5
|
+
/** Type-only: carries `T` for `ReadPage.get`. Never set. */
|
|
6
|
+
readonly __type?: T;
|
|
7
|
+
constructor(path: string);
|
|
8
|
+
}
|
|
9
|
+
/** One namespace's fields and the reader that fills each: what calling a namespace returns (`Post({ ... })`). */
|
|
10
|
+
export interface ReadSpec {
|
|
11
|
+
readonly namespace: string;
|
|
12
|
+
readonly fields: ReadFields;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The readers a namespace's fields take, checked against its type: each field optional (a page may read part of a
|
|
16
|
+
* namespace), a reader of that field's type, or for an object field, the same shape one level down.
|
|
17
|
+
*/
|
|
18
|
+
export type ReadFieldsOf<T> = {
|
|
19
|
+
readonly [K in keyof T]?: Reader<T[K]> | (T[K] extends readonly unknown[] ? never : T[K] extends object ? ReadFieldsOf<T[K]> : never);
|
|
20
|
+
};
|
|
21
|
+
/** A read namespace: each property is that field's {@link ReadKey}; calling it builds the namespace's {@link ReadSpec}. */
|
|
22
|
+
export type ReadNamespace<T> = {
|
|
23
|
+
readonly [K in keyof T & string]: ReadKey<T[K]>;
|
|
24
|
+
} & ((fields: ReadFieldsOf<T>) => ReadSpec);
|
|
25
|
+
/**
|
|
26
|
+
* Declares a read namespace: one type, its property names become key paths, no `const` per key.
|
|
27
|
+
* @example
|
|
28
|
+
* export const Post = reads("post")<{ title: string; likes: number; comments: { author: string; body: string }[] }>();
|
|
29
|
+
* Post.likes; // ReadKey<number>, path "post.likes"
|
|
30
|
+
* Post({ title: ctx.page.text("h1") }); // the spec a Read Block returns
|
|
31
|
+
*/
|
|
32
|
+
export declare function reads(namespace: string): <T extends object>() => ReadNamespace<T>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** A typed key into a {@link ReadPage}, named by its path (`"post.likes"`). Made by a {@link reads} namespace. */
|
|
2
|
+
export class ReadKey {
|
|
3
|
+
path;
|
|
4
|
+
constructor(path) {
|
|
5
|
+
this.path = path;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/** Names that must not mint a key: awaiting, printing or inspecting a namespace reads these. */
|
|
9
|
+
const NOT_KEYS = new Set(["then", "toJSON", "constructor", "prototype", "name", "length", "call", "apply", "bind"]);
|
|
10
|
+
/**
|
|
11
|
+
* Declares a read namespace: one type, its property names become key paths, no `const` per key.
|
|
12
|
+
* @example
|
|
13
|
+
* export const Post = reads("post")<{ title: string; likes: number; comments: { author: string; body: string }[] }>();
|
|
14
|
+
* Post.likes; // ReadKey<number>, path "post.likes"
|
|
15
|
+
* Post({ title: ctx.page.text("h1") }); // the spec a Read Block returns
|
|
16
|
+
*/
|
|
17
|
+
export function reads(namespace) {
|
|
18
|
+
return () => {
|
|
19
|
+
const cache = new Map();
|
|
20
|
+
const build = (fields) => ({ namespace, fields });
|
|
21
|
+
return new Proxy(build, {
|
|
22
|
+
get(target, prop) {
|
|
23
|
+
if (typeof prop !== "string" || NOT_KEYS.has(prop))
|
|
24
|
+
return Reflect.get(target, prop);
|
|
25
|
+
let key = cache.get(prop);
|
|
26
|
+
if (!key) {
|
|
27
|
+
key = new ReadKey(`${namespace}.${prop}`);
|
|
28
|
+
cache.set(prop, key);
|
|
29
|
+
}
|
|
30
|
+
return key;
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ReadKey } from "./read-key.js";
|
|
2
|
+
export declare class ReadPage {
|
|
3
|
+
private readonly tree;
|
|
4
|
+
/** Paths the page did not show (`null` in {@link toJSON}). */
|
|
5
|
+
readonly missing: readonly string[];
|
|
6
|
+
/** Why a path is missing or partial, e.g. a table column that isn't there, or rows not rendered yet. */
|
|
7
|
+
readonly notes: Readonly<Record<string, string>>;
|
|
8
|
+
constructor(values: ReadonlyMap<string, unknown>, notes?: ReadonlyMap<string, string>);
|
|
9
|
+
private at;
|
|
10
|
+
/** True when the page showed `key` (a read that found nothing is not shown). */
|
|
11
|
+
has<T>(key: ReadKey<T>): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* The value the page showed for `key`, typed by the key. Throws (naming the key) when this read did not include it
|
|
14
|
+
* or the page did not show it - like `mem.get` on a key never set.
|
|
15
|
+
*/
|
|
16
|
+
get<T>(key: ReadKey<T>): T;
|
|
17
|
+
/** The read as one object, nested by key path (`{ post: { title, comments: [...] } }`); missing paths are `null`. */
|
|
18
|
+
toJSON(): Record<string, unknown>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// A ReadPage: what a page showed, read once, kept like a MemPage - typed `get` by key, and loud when a value is missing.
|
|
2
|
+
import { WaygraphError, MSG_READ_MISSING } from "../errors.js";
|
|
3
|
+
export class ReadPage {
|
|
4
|
+
tree = {};
|
|
5
|
+
/** Paths the page did not show (`null` in {@link toJSON}). */
|
|
6
|
+
missing;
|
|
7
|
+
/** Why a path is missing or partial, e.g. a table column that isn't there, or rows not rendered yet. */
|
|
8
|
+
notes;
|
|
9
|
+
constructor(values, notes = new Map()) {
|
|
10
|
+
const missing = [];
|
|
11
|
+
for (const [path, value] of values) {
|
|
12
|
+
if (value === null || value === undefined)
|
|
13
|
+
missing.push(path);
|
|
14
|
+
const parts = path.split(".");
|
|
15
|
+
let node = this.tree;
|
|
16
|
+
for (const part of parts.slice(0, -1))
|
|
17
|
+
node = (node[part] ??= {});
|
|
18
|
+
node[parts[parts.length - 1]] = value ?? null;
|
|
19
|
+
}
|
|
20
|
+
this.missing = missing;
|
|
21
|
+
this.notes = Object.fromEntries(notes);
|
|
22
|
+
}
|
|
23
|
+
at(path) {
|
|
24
|
+
let node = this.tree;
|
|
25
|
+
for (const part of path.split(".")) {
|
|
26
|
+
if (node === null || typeof node !== "object")
|
|
27
|
+
return undefined;
|
|
28
|
+
node = node[part];
|
|
29
|
+
}
|
|
30
|
+
return node;
|
|
31
|
+
}
|
|
32
|
+
/** True when the page showed `key` (a read that found nothing is not shown). */
|
|
33
|
+
has(key) {
|
|
34
|
+
const v = this.at(key.path);
|
|
35
|
+
return v !== undefined && v !== null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The value the page showed for `key`, typed by the key. Throws (naming the key) when this read did not include it
|
|
39
|
+
* or the page did not show it - like `mem.get` on a key never set.
|
|
40
|
+
*/
|
|
41
|
+
get(key) {
|
|
42
|
+
if (!this.has(key))
|
|
43
|
+
throw new WaygraphError("WG_READ_MISSING", MSG_READ_MISSING(key.path, this.notes[key.path]));
|
|
44
|
+
return this.at(key.path);
|
|
45
|
+
}
|
|
46
|
+
/** The read as one object, nested by key path (`{ post: { title, comments: [...] } }`); missing paths are `null`. */
|
|
47
|
+
toJSON() {
|
|
48
|
+
return this.tree;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Page } from "@playwright/test";
|
|
2
|
+
import type { MemPage } from "../mem/mem.js";
|
|
3
|
+
/** What to take from the matched element(s). */
|
|
4
|
+
export type FieldKind = "text" | "texts" | "number" | "attr" | "value" | "visible" | "count";
|
|
5
|
+
/** One field: a CSS selector (inside a list row; `""` is the row itself) and what to take from it. */
|
|
6
|
+
export interface FieldPlan {
|
|
7
|
+
kind: FieldKind;
|
|
8
|
+
selector: string;
|
|
9
|
+
attr?: string;
|
|
10
|
+
}
|
|
11
|
+
/** A reader's plan. Top-level selectors are Playwright selectors; selectors inside a list row are CSS. */
|
|
12
|
+
export type ReaderPlan = FieldPlan | {
|
|
13
|
+
kind: "list";
|
|
14
|
+
selector: string;
|
|
15
|
+
fields: Record<string, FieldPlan>;
|
|
16
|
+
} | {
|
|
17
|
+
kind: "table";
|
|
18
|
+
selector: string;
|
|
19
|
+
columns: Record<string, string>;
|
|
20
|
+
} | {
|
|
21
|
+
kind: "fn";
|
|
22
|
+
fn: (page: Page, mem: MemPage) => unknown;
|
|
23
|
+
};
|
|
24
|
+
/** A reader of a `T`, handed out by a {@link ReadCtx}. */
|
|
25
|
+
export declare class Reader<T> {
|
|
26
|
+
readonly plan: ReaderPlan;
|
|
27
|
+
/** Type-only: carries `T` so a reader of the wrong type doesn't compile. Never set. */
|
|
28
|
+
readonly __type?: T;
|
|
29
|
+
constructor(plan: ReaderPlan);
|
|
30
|
+
}
|
|
31
|
+
/** A namespace's fields after its type is checked: a reader, or nested fields one level down. */
|
|
32
|
+
export interface ReadFields {
|
|
33
|
+
readonly [field: string]: Reader<unknown> | ReadFields | undefined;
|
|
34
|
+
}
|
|
35
|
+
/** The readers a list row offers, each scoped to the row (no selector: the row itself). */
|
|
36
|
+
export interface RowCtx {
|
|
37
|
+
text(selector?: string): Reader<string>;
|
|
38
|
+
texts(selector?: string): Reader<string[]>;
|
|
39
|
+
/** The first number in the text (`"$1,299.00"` -> 1299, `"41 likes"` -> 41). */
|
|
40
|
+
number(selector?: string): Reader<number>;
|
|
41
|
+
attr(selector: string, name: string): Reader<string>;
|
|
42
|
+
value(selector?: string): Reader<string>;
|
|
43
|
+
visible(selector?: string): Reader<boolean>;
|
|
44
|
+
count(selector: string): Reader<number>;
|
|
45
|
+
}
|
|
46
|
+
/** What a Read Block's `read` is given: the run's Mem, and `page`, the readers for what the page shows. */
|
|
47
|
+
export interface ReadCtx {
|
|
48
|
+
readonly mem: MemPage;
|
|
49
|
+
/** Readers for the page: `ctx.page.text(sel)`, `ctx.page.list(sel, (row) => ...)`, ... */
|
|
50
|
+
readonly page: PageReaders;
|
|
51
|
+
}
|
|
52
|
+
/** The readers a Read Block reads the page with. */
|
|
53
|
+
export interface PageReaders {
|
|
54
|
+
/** Text of the first match (textContent, whitespace collapsed). */
|
|
55
|
+
text(selector: string): Reader<string>;
|
|
56
|
+
/** Text of every match (textContent, whitespace collapsed). */
|
|
57
|
+
texts(selector: string): Reader<string[]>;
|
|
58
|
+
/** The first number in the first match's text (`"$1,299.00"` -> 1299, `"41 likes"` -> 41). */
|
|
59
|
+
number(selector: string): Reader<number>;
|
|
60
|
+
attr(selector: string, name: string): Reader<string>;
|
|
61
|
+
/** A form field's current value. */
|
|
62
|
+
value(selector: string): Reader<string>;
|
|
63
|
+
/** Whether any match is visible (false, not missing, when nothing matches). */
|
|
64
|
+
visible(selector: string): Reader<boolean>;
|
|
65
|
+
count(selector: string): Reader<number>;
|
|
66
|
+
/**
|
|
67
|
+
* One object per row, each field read inside its row, in one round trip for the whole list.
|
|
68
|
+
* @example ctx.page.list(".comment", (row) => ({ author: row.text(".author"), body: row.text(".body") }))
|
|
69
|
+
*/
|
|
70
|
+
list<R>(rowSelector: string, row: (row: RowCtx) => {
|
|
71
|
+
readonly [K in keyof R]: Reader<R[K]>;
|
|
72
|
+
}): Reader<R[]>;
|
|
73
|
+
/**
|
|
74
|
+
* One object per table row, each field the cell under the column with that header text (`<th>` or
|
|
75
|
+
* `role=columnheader`), in one round trip. A header that isn't there is reported as missing.
|
|
76
|
+
* @example ctx.page.table("#orders", { id: "Order #", total: "Total" })
|
|
77
|
+
*/
|
|
78
|
+
table<C extends string>(selector: string, columns: Readonly<Record<C, string>>): Reader<Record<C, string>[]>;
|
|
79
|
+
/** Anything the helpers don't cover; `null` or `undefined` is reported as missing. */
|
|
80
|
+
fn<T>(read: (page: Page, mem: MemPage) => T | Promise<T>): Reader<Awaited<T>>;
|
|
81
|
+
}
|
|
82
|
+
export declare function createReadCtx(mem: MemPage): ReadCtx;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** A reader of a `T`, handed out by a {@link ReadCtx}. */
|
|
2
|
+
export class Reader {
|
|
3
|
+
plan;
|
|
4
|
+
constructor(plan) {
|
|
5
|
+
this.plan = plan;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
function fieldReaders() {
|
|
9
|
+
const field = (kind, selector = "", attr) => new Reader(attr === undefined ? { kind, selector } : { kind, selector, attr });
|
|
10
|
+
return {
|
|
11
|
+
text: (s) => field("text", s),
|
|
12
|
+
texts: (s) => field("texts", s),
|
|
13
|
+
number: (s) => field("number", s),
|
|
14
|
+
attr: (s, name) => field("attr", s, name),
|
|
15
|
+
value: (s) => field("value", s),
|
|
16
|
+
visible: (s) => field("visible", s),
|
|
17
|
+
count: (s) => field("count", s),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function createReadCtx(mem) {
|
|
21
|
+
const row = fieldReaders();
|
|
22
|
+
return {
|
|
23
|
+
mem,
|
|
24
|
+
page: {
|
|
25
|
+
text: (s) => row.text(s),
|
|
26
|
+
texts: (s) => row.texts(s),
|
|
27
|
+
number: (s) => row.number(s),
|
|
28
|
+
attr: (s, name) => row.attr(s, name),
|
|
29
|
+
value: (s) => row.value(s),
|
|
30
|
+
visible: (s) => row.visible(s),
|
|
31
|
+
count: (s) => row.count(s),
|
|
32
|
+
list: (selector, build) => {
|
|
33
|
+
const fields = {};
|
|
34
|
+
for (const [name, reader] of Object.entries(build(fieldReaders()))) {
|
|
35
|
+
fields[name] = reader.plan;
|
|
36
|
+
}
|
|
37
|
+
return new Reader({ kind: "list", selector, fields });
|
|
38
|
+
},
|
|
39
|
+
table: (selector, columns) => new Reader({ kind: "table", selector, columns: { ...columns } }),
|
|
40
|
+
fn: (read) => new Reader({ kind: "fn", fn: read }),
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Page } from "@playwright/test";
|
|
2
|
+
import type { MemPage } from "../mem/mem.js";
|
|
3
|
+
import type { ReadSpec } from "./read-key.js";
|
|
4
|
+
/** A read's leaves by path (`post.likes`), `null` where the page did not show it, plus why a path is missing. */
|
|
5
|
+
export interface ReadResult {
|
|
6
|
+
values: Map<string, unknown>;
|
|
7
|
+
notes: Map<string, string>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Waits for the page to stop changing before a read. A step's verify can pass before the app has rendered where it
|
|
11
|
+
* landed (a URL check passes the moment a client-side route changes), so a read right after it can see the old page.
|
|
12
|
+
*/
|
|
13
|
+
export declare function settlePage(page: Page, quietMs?: number, capMs?: number): Promise<void>;
|
|
14
|
+
/** The first number in a display text: `"$1,299.00"` -> 1299, `"41 likes"` -> 41, no number -> null. */
|
|
15
|
+
export declare function firstNumber(text: unknown): number | null;
|
|
16
|
+
/** Reads every field of every spec, in parallel, into leaves by path. */
|
|
17
|
+
export declare function runReadSpecs(page: Page, mem: MemPage, specs: readonly ReadSpec[]): Promise<ReadResult>;
|