@docsxai/engine 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +130 -0
- package/dist/auth/api-login.d.ts +69 -0
- package/dist/auth/api-login.js +95 -0
- package/dist/auth/browser-session.d.ts +28 -0
- package/dist/auth/browser-session.js +43 -0
- package/dist/auth/cookie-jar.d.ts +58 -0
- package/dist/auth/cookie-jar.js +212 -0
- package/dist/auth/email-otp.d.ts +210 -0
- package/dist/auth/email-otp.js +166 -0
- package/dist/auth/http-basic.d.ts +5 -0
- package/dist/auth/http-basic.js +17 -0
- package/dist/auth/index.d.ts +47 -0
- package/dist/auth/index.js +137 -0
- package/dist/auth/jwt-injection.d.ts +153 -0
- package/dist/auth/jwt-injection.js +136 -0
- package/dist/auth/manual-capture.d.ts +35 -0
- package/dist/auth/manual-capture.js +30 -0
- package/dist/auth/mtls.d.ts +15 -0
- package/dist/auth/mtls.js +53 -0
- package/dist/auth/pat-header.d.ts +19 -0
- package/dist/auth/pat-header.js +34 -0
- package/dist/auth/storage-state-cache.d.ts +38 -0
- package/dist/auth/storage-state-cache.js +143 -0
- package/dist/auth/test-backdoor.d.ts +25 -0
- package/dist/auth/test-backdoor.js +51 -0
- package/dist/auth/totp.d.ts +39 -0
- package/dist/auth/totp.js +108 -0
- package/dist/auth/types.d.ts +86 -0
- package/dist/auth/types.js +57 -0
- package/dist/auth/ui-form.d.ts +204 -0
- package/dist/auth/ui-form.js +153 -0
- package/dist/auth/webauthn.d.ts +88 -0
- package/dist/auth/webauthn.js +67 -0
- package/dist/auth.d.ts +1 -0
- package/dist/auth.js +3 -0
- package/dist/backend-client-contracts.d.ts +88 -0
- package/dist/backend-client-contracts.js +19 -0
- package/dist/backend-client-oauth-login.d.ts +7 -0
- package/dist/backend-client-oauth-login.js +90 -0
- package/dist/backend-client-state-cache.d.ts +73 -0
- package/dist/backend-client-state-cache.js +185 -0
- package/dist/backend-client-token.d.ts +18 -0
- package/dist/backend-client-token.js +94 -0
- package/dist/backend-client-transport.d.ts +66 -0
- package/dist/backend-client-transport.js +181 -0
- package/dist/backend-client.d.ts +5 -0
- package/dist/backend-client.js +18 -0
- package/dist/calibrate.d.ts +31 -0
- package/dist/calibrate.js +68 -0
- package/dist/cli-commands-authoring.d.ts +5 -0
- package/dist/cli-commands-authoring.js +403 -0
- package/dist/cli-commands-backend.d.ts +5 -0
- package/dist/cli-commands-backend.js +211 -0
- package/dist/cli-commands-docpack.d.ts +5 -0
- package/dist/cli-commands-docpack.js +280 -0
- package/dist/cli-commands-session.d.ts +4 -0
- package/dist/cli-commands-session.js +398 -0
- package/dist/cli-shared.d.ts +5 -0
- package/dist/cli-shared.js +45 -0
- package/dist/cli-usage.d.ts +1 -0
- package/dist/cli-usage.js +137 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +77 -0
- package/dist/diagnose.d.ts +50 -0
- package/dist/diagnose.js +168 -0
- package/dist/diff-compute.d.ts +13 -0
- package/dist/diff-compute.js +378 -0
- package/dist/diff-report.d.ts +7 -0
- package/dist/diff-report.js +125 -0
- package/dist/diff-types.d.ts +125 -0
- package/dist/diff-types.js +15 -0
- package/dist/diff.d.ts +3 -0
- package/dist/diff.js +16 -0
- package/dist/doc-pack-io.d.ts +30 -0
- package/dist/doc-pack-io.js +182 -0
- package/dist/doc-pack.d.ts +1814 -0
- package/dist/doc-pack.js +328 -0
- package/dist/doctor-checks-plugins.d.ts +2 -0
- package/dist/doctor-checks-plugins.js +136 -0
- package/dist/doctor-checks.d.ts +56 -0
- package/dist/doctor-checks.js +367 -0
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +62 -0
- package/dist/export/adf.d.ts +57 -0
- package/dist/export/adf.js +323 -0
- package/dist/export/playwright-test.d.ts +26 -0
- package/dist/export/playwright-test.js +221 -0
- package/dist/flow-file.d.ts +21 -0
- package/dist/flow-file.js +180 -0
- package/dist/flow-lint.d.ts +24 -0
- package/dist/flow-lint.js +203 -0
- package/dist/flow-runtime.d.ts +113 -0
- package/dist/flow-runtime.js +273 -0
- package/dist/flow-tree.d.ts +19 -0
- package/dist/flow-tree.js +104 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +31 -0
- package/dist/playwright-driver.d.ts +105 -0
- package/dist/playwright-driver.js +363 -0
- package/dist/playwright-instrumented-browser.d.ts +51 -0
- package/dist/playwright-instrumented-browser.js +189 -0
- package/dist/plugins/load.d.ts +22 -0
- package/dist/plugins/load.js +99 -0
- package/dist/plugins/lock.d.ts +40 -0
- package/dist/plugins/lock.js +122 -0
- package/dist/plugins/manifest.d.ts +70 -0
- package/dist/plugins/manifest.js +115 -0
- package/dist/plugins/plan.d.ts +51 -0
- package/dist/plugins/plan.js +279 -0
- package/dist/plugins/registry.d.ts +59 -0
- package/dist/plugins/registry.js +71 -0
- package/dist/plugins/runtime.d.ts +7 -0
- package/dist/plugins/runtime.js +27 -0
- package/dist/plugins/types.d.ts +58 -0
- package/dist/plugins/types.js +4 -0
- package/dist/plugins-cli.d.ts +1 -0
- package/dist/plugins-cli.js +191 -0
- package/dist/redact.d.ts +16 -0
- package/dist/redact.js +72 -0
- package/dist/style.d.ts +46 -0
- package/dist/style.js +151 -0
- package/dist/viewer-bin.d.ts +20 -0
- package/dist/viewer-bin.js +97 -0
- package/dist/workspace.d.ts +60 -0
- package/dist/workspace.js +172 -0
- package/dist/zip.d.ts +17 -0
- package/dist/zip.js +113 -0
- package/package.json +64 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Flow-file (`.flow.yaml`) parsing, validation, and serialization.
|
|
2
|
+
//
|
|
3
|
+
// A flow-file is the source of truth for execution: declarative YAML, hand-editable,
|
|
4
|
+
// translated to Playwright at run time (see flow-runtime, to come). This module is the
|
|
5
|
+
// parse/validate/serialize boundary — everything downstream works with the validated
|
|
6
|
+
// `FlowFile` value, never raw YAML.
|
|
7
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
8
|
+
import { FlowFile } from "./doc-pack.js";
|
|
9
|
+
export class FlowFileError extends Error {
|
|
10
|
+
cause;
|
|
11
|
+
constructor(message, cause) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.cause = cause;
|
|
14
|
+
this.name = "FlowFileError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function formatZodIssues(err) {
|
|
18
|
+
return err.issues
|
|
19
|
+
.map((i) => {
|
|
20
|
+
const path = i.path.length ? i.path.join(".") : "(root)";
|
|
21
|
+
return ` • ${path}: ${i.message}`;
|
|
22
|
+
})
|
|
23
|
+
.join("\n");
|
|
24
|
+
}
|
|
25
|
+
/** Parse + validate a flow-file from YAML text. Throws {@link FlowFileError} with a readable message on failure. */
|
|
26
|
+
export function parseFlowFile(yamlText, source = "<flow-file>") {
|
|
27
|
+
let raw;
|
|
28
|
+
try {
|
|
29
|
+
raw = parseYaml(yamlText);
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
throw new FlowFileError(`${source}: not valid YAML — ${e.message}`, e);
|
|
33
|
+
}
|
|
34
|
+
const result = FlowFile.safeParse(raw);
|
|
35
|
+
if (!result.success) {
|
|
36
|
+
throw new FlowFileError(`${source}: invalid flow-file:\n${formatZodIssues(result.error)}`, result.error);
|
|
37
|
+
}
|
|
38
|
+
const flow = result.data;
|
|
39
|
+
// A flow with `extends` may reference locators it inherits from the parent — defer the ref check to
|
|
40
|
+
// `resolveFlowExtends`, which runs it on the merged flow.
|
|
41
|
+
if (!flow.extends)
|
|
42
|
+
assertLocatorRefsResolve(flow, source);
|
|
43
|
+
assertStepIdsUnique(flow, source);
|
|
44
|
+
return flow;
|
|
45
|
+
}
|
|
46
|
+
/** Serialize a {@link FlowFile} back to canonical YAML. */
|
|
47
|
+
export function serializeFlowFile(flow) {
|
|
48
|
+
// Validate on the way out too, so we never write a malformed file.
|
|
49
|
+
const checked = FlowFile.parse(flow);
|
|
50
|
+
return stringifyYaml(checked, { lineWidth: 100 });
|
|
51
|
+
}
|
|
52
|
+
const LOCATOR_REF = /^\$([A-Za-z_][A-Za-z0-9_]*)$/;
|
|
53
|
+
/** Returns the locator name if `value` is a `$name` reference, else `null` (it's an inline selector). */
|
|
54
|
+
export function locatorRefName(value) {
|
|
55
|
+
const m = LOCATOR_REF.exec(value);
|
|
56
|
+
return m ? m[1] : null;
|
|
57
|
+
}
|
|
58
|
+
function collectLocatorRefs(step) {
|
|
59
|
+
const refs = [];
|
|
60
|
+
const push = (v) => {
|
|
61
|
+
if (v) {
|
|
62
|
+
const name = locatorRefName(v);
|
|
63
|
+
if (name)
|
|
64
|
+
refs.push(name);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
push(step.target);
|
|
68
|
+
if (step.wait_for && typeof step.wait_for === "object" && "selector" in step.wait_for)
|
|
69
|
+
push(step.wait_for.selector);
|
|
70
|
+
if (step.success) {
|
|
71
|
+
if ("visible" in step.success)
|
|
72
|
+
push(step.success.visible);
|
|
73
|
+
else if ("hidden" in step.success)
|
|
74
|
+
push(step.success.hidden);
|
|
75
|
+
else if ("text_contains" in step.success)
|
|
76
|
+
push(step.success.text_contains.selector);
|
|
77
|
+
}
|
|
78
|
+
if (step.annotation?.target)
|
|
79
|
+
push(step.annotation.target);
|
|
80
|
+
if (step.annotations)
|
|
81
|
+
for (const a of step.annotations)
|
|
82
|
+
if (a.target)
|
|
83
|
+
push(a.target);
|
|
84
|
+
if (step.redactions)
|
|
85
|
+
for (const r of step.redactions)
|
|
86
|
+
if ("selector" in r)
|
|
87
|
+
push(r.selector);
|
|
88
|
+
return refs;
|
|
89
|
+
}
|
|
90
|
+
/** Locator names referenced anywhere in `flow` (steps + flow-level redactions); inline selectors excluded. */
|
|
91
|
+
export function referencedLocatorNames(flow) {
|
|
92
|
+
const refs = new Set();
|
|
93
|
+
for (const step of flow.steps)
|
|
94
|
+
for (const ref of collectLocatorRefs(step))
|
|
95
|
+
refs.add(ref);
|
|
96
|
+
for (const r of flow.redactions ?? []) {
|
|
97
|
+
if ("selector" in r) {
|
|
98
|
+
const name = locatorRefName(r.selector);
|
|
99
|
+
if (name)
|
|
100
|
+
refs.add(name);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return refs;
|
|
104
|
+
}
|
|
105
|
+
function assertLocatorRefsResolve(flow, source) {
|
|
106
|
+
const known = new Set(Object.keys(flow.locators));
|
|
107
|
+
const missing = [];
|
|
108
|
+
for (const step of flow.steps) {
|
|
109
|
+
for (const ref of collectLocatorRefs(step)) {
|
|
110
|
+
if (!known.has(ref))
|
|
111
|
+
missing.push(`step "${step.id}" → $${ref}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const r of flow.redactions ?? []) {
|
|
115
|
+
if ("selector" in r) {
|
|
116
|
+
const name = locatorRefName(r.selector);
|
|
117
|
+
if (name && !known.has(name))
|
|
118
|
+
missing.push(`redactions → $${name}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (missing.length) {
|
|
122
|
+
throw new FlowFileError(`${source}: unresolved locator references (not in \`locators\`):\n${missing.map((m) => ` • ${m}`).join("\n")}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function assertStepIdsUnique(flow, source) {
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
const dupes = new Set();
|
|
128
|
+
for (const step of flow.steps) {
|
|
129
|
+
if (seen.has(step.id))
|
|
130
|
+
dupes.add(step.id);
|
|
131
|
+
seen.add(step.id);
|
|
132
|
+
}
|
|
133
|
+
if (dupes.size) {
|
|
134
|
+
throw new FlowFileError(`${source}: duplicate step ids: ${[...dupes].map((d) => `"${d}"`).join(", ")}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Resolve a flow's `extends` chain into a single flow: parent's steps first, then this flow's. `locators` and
|
|
139
|
+
* `prerequisites` are merged (this flow wins on locator-name collisions); `environment` merges per-key with
|
|
140
|
+
* this flow's keys winning; `redactions` concatenate (parent's first); step ids must be unique across the
|
|
141
|
+
* merge. Chains are followed recursively; cycles throw. `loadFlowFile(name)` parses `flows/<name>.flow.yaml`
|
|
142
|
+
* (a flow with its own `extends` un-resolved — this function recurses). The result has no `extends`.
|
|
143
|
+
*/
|
|
144
|
+
export async function resolveFlowExtends(flow, loadFlowFile, visited = new Set()) {
|
|
145
|
+
if (!flow.extends)
|
|
146
|
+
return flow;
|
|
147
|
+
if (visited.has(flow.name)) {
|
|
148
|
+
throw new FlowFileError(`flow "${flow.name}": \`extends\` cycle (chain: ${[...visited, flow.name].join(" → ")})`);
|
|
149
|
+
}
|
|
150
|
+
visited.add(flow.name);
|
|
151
|
+
let parentRaw;
|
|
152
|
+
try {
|
|
153
|
+
parentRaw = await loadFlowFile(flow.extends);
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
throw e instanceof FlowFileError
|
|
157
|
+
? e
|
|
158
|
+
: new FlowFileError(`flow "${flow.name}": cannot load \`extends\` target "${flow.extends}": ${e.message}`, e);
|
|
159
|
+
}
|
|
160
|
+
const parent = await resolveFlowExtends(parentRaw, loadFlowFile, visited);
|
|
161
|
+
const parentStepIds = new Set(parent.steps.map((s) => s.id));
|
|
162
|
+
for (const s of flow.steps) {
|
|
163
|
+
if (parentStepIds.has(s.id)) {
|
|
164
|
+
throw new FlowFileError(`flow "${flow.name}": step id "${s.id}" collides with a step inherited via \`extends\` from "${flow.extends}"`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// `environment` merges per-key (this flow wins); `redactions` are additive, parent's first.
|
|
168
|
+
const environment = { ...parent.environment, ...flow.environment };
|
|
169
|
+
const redactions = [...(parent.redactions ?? []), ...(flow.redactions ?? [])];
|
|
170
|
+
const merged = {
|
|
171
|
+
name: flow.name,
|
|
172
|
+
...(Object.keys(environment).length ? { environment } : {}),
|
|
173
|
+
...(redactions.length ? { redactions } : {}),
|
|
174
|
+
prerequisites: [...parent.prerequisites, ...flow.prerequisites],
|
|
175
|
+
locators: { ...parent.locators, ...flow.locators },
|
|
176
|
+
steps: [...parent.steps, ...flow.steps],
|
|
177
|
+
};
|
|
178
|
+
assertLocatorRefsResolve(merged, `<resolved flow "${flow.name}">`);
|
|
179
|
+
return merged;
|
|
180
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { FlowFile } from "./doc-pack.js";
|
|
2
|
+
export type LintSeverity = "error" | "warning" | "info";
|
|
3
|
+
export type LintIssue = {
|
|
4
|
+
code: string;
|
|
5
|
+
severity: LintSeverity;
|
|
6
|
+
flow: string;
|
|
7
|
+
stepId?: string;
|
|
8
|
+
message: string;
|
|
9
|
+
suggestion?: string;
|
|
10
|
+
};
|
|
11
|
+
/** An injectable lint rule — the open hinge for the plugins runtime. Runs after the built-ins. */
|
|
12
|
+
export type LintRule = {
|
|
13
|
+
/** Stable diagnostic code (the built-ins use `RNNN`; injected rules should pick another prefix). */
|
|
14
|
+
code: string;
|
|
15
|
+
run: (flow: FlowFile, opts: LintOptions) => Promise<LintIssue[]> | LintIssue[];
|
|
16
|
+
};
|
|
17
|
+
export type LintOptions = {
|
|
18
|
+
/** Resolver for `extends` (used by R001/R005). If omitted, inter-flow rules are skipped. */
|
|
19
|
+
loadFlow?: (name: string) => Promise<FlowFile> | FlowFile;
|
|
20
|
+
/** Additional rules run after the built-ins (same flow, same options). */
|
|
21
|
+
extraRules?: LintRule[];
|
|
22
|
+
};
|
|
23
|
+
export declare function lintFlow(flow: FlowFile, opts?: LintOptions): Promise<LintIssue[]>;
|
|
24
|
+
export declare function formatIssuesText(issues: LintIssue[]): string;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Static analysis of flow-files. Catches common authoring mistakes at write-time, before a run.
|
|
2
|
+
// Pure-static — no Playwright, no live page. Run via `docsxai lint`.
|
|
3
|
+
import { locatorRefName, referencedLocatorNames } from "./flow-file.js";
|
|
4
|
+
/** Heuristic — names that suggest a step kicks off a multi-minute backend op. */
|
|
5
|
+
const LONG_ASYNC = /generate|create|process|submit|upload|translate|render|publish|export/i;
|
|
6
|
+
/** A "bare" data-attribute selector like `[data-foo="x"]` with no further qualifier. */
|
|
7
|
+
const BARE_DATA_ATTR = /^\[data-[a-z][a-z0-9-]*="[^"]+"\]$/;
|
|
8
|
+
const DEEP_CHAIN_THRESHOLD = 4;
|
|
9
|
+
export async function lintFlow(flow, opts = {}) {
|
|
10
|
+
const issues = [];
|
|
11
|
+
// R001 — extends chain depth; R005 — extends target missing
|
|
12
|
+
if (opts.loadFlow && flow.extends) {
|
|
13
|
+
const chain = await walkChain(flow, opts.loadFlow);
|
|
14
|
+
if (chain.missing !== undefined) {
|
|
15
|
+
issues.push({
|
|
16
|
+
code: "R005",
|
|
17
|
+
severity: "error",
|
|
18
|
+
flow: flow.name,
|
|
19
|
+
message: `\`extends: ${chain.missing}\` names a flow that doesn't exist in the workspace`,
|
|
20
|
+
suggestion: "fix the name, or create flows/" + chain.missing + ".flow.yaml",
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
else if (chain.depth >= DEEP_CHAIN_THRESHOLD) {
|
|
24
|
+
issues.push({
|
|
25
|
+
code: "R001",
|
|
26
|
+
severity: "info",
|
|
27
|
+
flow: flow.name,
|
|
28
|
+
message: `extends chain depth is ${chain.depth}`,
|
|
29
|
+
suggestion: "consider flattening — deep chains add per-run setup cost and obscure the step order",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// R006 — locator defined but never referenced
|
|
34
|
+
const referenced = referencedLocatorNames(flow);
|
|
35
|
+
for (const name of Object.keys(flow.locators)) {
|
|
36
|
+
if (!referenced.has(name)) {
|
|
37
|
+
issues.push({
|
|
38
|
+
code: "R006",
|
|
39
|
+
severity: "info",
|
|
40
|
+
flow: flow.name,
|
|
41
|
+
message: `locator \`${name}\` is defined but never referenced by any step, wait, success, annotation, or redaction`,
|
|
42
|
+
suggestion: "remove it — unless a child flow references it via `extends` (references in children count only in the child)",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// R007 — terminal step lacks `success` (with `extends`, this flow's steps run last, so its
|
|
47
|
+
// final step IS the merged flow's terminal step)
|
|
48
|
+
const lastStep = flow.steps[flow.steps.length - 1];
|
|
49
|
+
if (lastStep && !lastStep.success) {
|
|
50
|
+
issues.push({
|
|
51
|
+
code: "R007",
|
|
52
|
+
severity: "warning",
|
|
53
|
+
flow: flow.name,
|
|
54
|
+
stepId: lastStep.id,
|
|
55
|
+
message: "the flow's terminal step has no `success` criterion — the run can end without verifying the end state",
|
|
56
|
+
suggestion: "add `success: { visible: $… }` (or url_matches / text_contains) to the last step",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
// Resolve a `$ref` to its selector for same-element comparison; an un-resolvable ref (e.g.
|
|
60
|
+
// inherited from an `extends` parent) compares by its raw `$name`, which still matches itself.
|
|
61
|
+
const resolveMaybe = (v) => {
|
|
62
|
+
const name = locatorRefName(v);
|
|
63
|
+
return name ? (flow.locators[name] ?? v) : v;
|
|
64
|
+
};
|
|
65
|
+
const flowRedactionSelectors = new Set((flow.redactions ?? []).flatMap((r) => ("selector" in r ? [resolveMaybe(r.selector)] : [])));
|
|
66
|
+
for (const step of flow.steps) {
|
|
67
|
+
const anns = step.annotation ? [step.annotation] : (step.annotations ?? []);
|
|
68
|
+
// R002 — annotation anchored to a likely-unmounting action target
|
|
69
|
+
if (anns.length && (step.action === "click" || step.action === "navigate") && step.target) {
|
|
70
|
+
const anyWithoutOverride = anns.some((a) => !a.target);
|
|
71
|
+
if (anyWithoutOverride) {
|
|
72
|
+
issues.push({
|
|
73
|
+
code: "R002",
|
|
74
|
+
severity: "warning",
|
|
75
|
+
flow: flow.name,
|
|
76
|
+
stepId: step.id,
|
|
77
|
+
message: `annotation has no \`target\` override on a \`${step.action}\` action; if the action unmounts its target, the halo will have nothing to anchor to`,
|
|
78
|
+
suggestion: "set `annotation.target` (or `annotations[].target`) to an element that exists in the resulting state",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// R003 — wait_for object form without timeout_ms on a long-async-looking step
|
|
83
|
+
const w = step.wait_for;
|
|
84
|
+
if (w && typeof w === "object" && !Array.isArray(w) && "selector" in w && !w.timeout_ms) {
|
|
85
|
+
const targetText = step.target ? (locatorRefName(step.target) ?? step.target) : "";
|
|
86
|
+
if (LONG_ASYNC.test(step.id) || LONG_ASYNC.test(targetText)) {
|
|
87
|
+
issues.push({
|
|
88
|
+
code: "R003",
|
|
89
|
+
severity: "warning",
|
|
90
|
+
flow: flow.name,
|
|
91
|
+
stepId: step.id,
|
|
92
|
+
message: `wait_for has no timeout_ms but the step looks long-async (keyword match)`,
|
|
93
|
+
suggestion: "add `timeout_ms: 180000` (or higher for multi-minute backend ops)",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// R004 — bare `[data-*=…]` selector — may have hidden duplicates
|
|
98
|
+
if (step.target) {
|
|
99
|
+
const sel = step.target.startsWith("$")
|
|
100
|
+
? flow.locators[locatorRefName(step.target) ?? ""]
|
|
101
|
+
: step.target;
|
|
102
|
+
if (sel && BARE_DATA_ATTR.test(sel)) {
|
|
103
|
+
issues.push({
|
|
104
|
+
code: "R004",
|
|
105
|
+
severity: "info",
|
|
106
|
+
flow: flow.name,
|
|
107
|
+
stepId: step.id,
|
|
108
|
+
message: `selector \`${sel}\` is a bare \`[data-*=…]\` match — may resolve to multiple DOM nodes (visible + hidden duplicate)`,
|
|
109
|
+
suggestion: "if duplicates exist, scope with `:visible` or add a `:has-text(...)` qualifier",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// R008 — un-guarded optional step
|
|
114
|
+
if (step.optional && !step.wait_for && !step.success) {
|
|
115
|
+
issues.push({
|
|
116
|
+
code: "R008",
|
|
117
|
+
severity: "warning",
|
|
118
|
+
flow: flow.name,
|
|
119
|
+
stepId: step.id,
|
|
120
|
+
message: "`optional: true` with no `wait_for` or `success` — every failure is silently swallowed, so a real regression on this step would be masked",
|
|
121
|
+
suggestion: "add a `wait_for: { selector: … }` or a `success:` check to make the presence test explicit",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// R009 — element_stable with no selector context (a no-op)
|
|
125
|
+
if (step.wait_for === "element_stable" && !step.target) {
|
|
126
|
+
issues.push({
|
|
127
|
+
code: "R009",
|
|
128
|
+
severity: "warning",
|
|
129
|
+
flow: flow.name,
|
|
130
|
+
stepId: step.id,
|
|
131
|
+
message: "`wait_for: element_stable` has no selector context (the step has no `target`) — it waits on nothing",
|
|
132
|
+
suggestion: "give the step a `target`, or wait on a concrete element with `wait_for: { selector: $x }`",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
// R010 — annotation anchored to a redacted element (the call-out would point at a black box)
|
|
136
|
+
const stepRedactionSelectors = new Set([
|
|
137
|
+
...flowRedactionSelectors,
|
|
138
|
+
...(step.redactions ?? []).flatMap((r) => "selector" in r ? [resolveMaybe(r.selector)] : []),
|
|
139
|
+
]);
|
|
140
|
+
if (stepRedactionSelectors.size) {
|
|
141
|
+
for (const ann of anns) {
|
|
142
|
+
const anchor = ann.target ?? step.target;
|
|
143
|
+
if (anchor && stepRedactionSelectors.has(resolveMaybe(anchor))) {
|
|
144
|
+
issues.push({
|
|
145
|
+
code: "R010",
|
|
146
|
+
severity: "warning",
|
|
147
|
+
flow: flow.name,
|
|
148
|
+
stepId: step.id,
|
|
149
|
+
message: `annotation is anchored to \`${anchor}\`, which a redaction on this step masks — the call-out would point at a black box`,
|
|
150
|
+
suggestion: "anchor the annotation to a different element, or drop the redaction",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const rule of opts.extraRules ?? []) {
|
|
157
|
+
issues.push(...(await rule.run(flow, opts)));
|
|
158
|
+
}
|
|
159
|
+
return issues;
|
|
160
|
+
}
|
|
161
|
+
async function walkChain(flow, load) {
|
|
162
|
+
let depth = 1;
|
|
163
|
+
let cur = flow;
|
|
164
|
+
const seen = new Set([flow.name]);
|
|
165
|
+
while (cur.extends) {
|
|
166
|
+
if (seen.has(cur.extends))
|
|
167
|
+
return { depth };
|
|
168
|
+
seen.add(cur.extends);
|
|
169
|
+
try {
|
|
170
|
+
cur = await load(cur.extends);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return { depth, missing: cur.extends };
|
|
174
|
+
}
|
|
175
|
+
depth++;
|
|
176
|
+
}
|
|
177
|
+
return { depth };
|
|
178
|
+
}
|
|
179
|
+
export function formatIssuesText(issues) {
|
|
180
|
+
if (issues.length === 0)
|
|
181
|
+
return "✓ no issues\n";
|
|
182
|
+
const byFlow = new Map();
|
|
183
|
+
for (const i of issues) {
|
|
184
|
+
const list = byFlow.get(i.flow) ?? [];
|
|
185
|
+
list.push(i);
|
|
186
|
+
byFlow.set(i.flow, list);
|
|
187
|
+
}
|
|
188
|
+
let out = "";
|
|
189
|
+
for (const [flow, list] of byFlow) {
|
|
190
|
+
out += `flow ${flow}\n`;
|
|
191
|
+
for (const i of list) {
|
|
192
|
+
const where = i.stepId ? `step '${i.stepId}': ` : "";
|
|
193
|
+
out += ` ${i.code} [${i.severity}] ${where}${i.message}\n`;
|
|
194
|
+
if (i.suggestion)
|
|
195
|
+
out += ` → ${i.suggestion}\n`;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
199
|
+
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
200
|
+
const infos = issues.filter((i) => i.severity === "info").length;
|
|
201
|
+
out += `\n${errors} error${errors !== 1 ? "s" : ""}, ${warnings} warning${warnings !== 1 ? "s" : ""}, ${infos} info\n`;
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { type AnnotationsFile, type BoundingBox, type FlowFile, type RedactionRegion, type RedactionStyle, type Step } from "./doc-pack.js";
|
|
2
|
+
/**
|
|
3
|
+
* A redaction with its locator ref already resolved to a concrete selector. `selector` entries are
|
|
4
|
+
* turned into bounding boxes by the driver at capture time (absent/zero-box selectors are skipped
|
|
5
|
+
* with a stderr warning — never a halt); `region` rects are in CSS pixels and the driver scales
|
|
6
|
+
* them to the screenshot's device-pixel space.
|
|
7
|
+
*/
|
|
8
|
+
export type ResolvedRedaction = {
|
|
9
|
+
selector: string;
|
|
10
|
+
style: RedactionStyle;
|
|
11
|
+
} | {
|
|
12
|
+
region: RedactionRegion;
|
|
13
|
+
style: RedactionStyle;
|
|
14
|
+
};
|
|
15
|
+
/** What the runtime needs from a browser. Selectors passed here are already resolved (no `$ref`). */
|
|
16
|
+
export interface BrowserDriver {
|
|
17
|
+
goto(url: string): Promise<void>;
|
|
18
|
+
click(selector: string): Promise<void>;
|
|
19
|
+
fill(selector: string, value: string): Promise<void>;
|
|
20
|
+
upload(selector: string, filePath: string): Promise<void>;
|
|
21
|
+
press(selector: string | null, key: string): Promise<void>;
|
|
22
|
+
hover(selector: string): Promise<void>;
|
|
23
|
+
selectOption(selector: string, value: string): Promise<void>;
|
|
24
|
+
setChecked(selector: string, checked: boolean): Promise<void>;
|
|
25
|
+
waitForNetworkIdle(): Promise<void>;
|
|
26
|
+
waitForLoad(): Promise<void>;
|
|
27
|
+
waitForElementStable(selector: string): Promise<void>;
|
|
28
|
+
/** Wait for `selector` to appear. `timeoutMs` overrides the driver's default (use for slow backend ops). */
|
|
29
|
+
waitForSelector(selector: string, timeoutMs?: number): Promise<void>;
|
|
30
|
+
waitForTimeout(ms: number): Promise<void>;
|
|
31
|
+
isVisible(selector: string): Promise<boolean>;
|
|
32
|
+
urlMatches(pattern: string): Promise<boolean>;
|
|
33
|
+
textContains(selector: string, text: string): Promise<boolean>;
|
|
34
|
+
/** Current page URL. */
|
|
35
|
+
currentUrl(): Promise<string>;
|
|
36
|
+
/** How many elements `selector` matches (so a halt can say "0" vs "8 stale ones"). */
|
|
37
|
+
count(selector: string): Promise<number>;
|
|
38
|
+
/** Text content of the first match of `selector`, or `null`. */
|
|
39
|
+
textOf(selector: string): Promise<string | null>;
|
|
40
|
+
/** Bounding box of an element, in page pixels. Pass `timeoutMs` (default = driver default) so this fails fast when the target has vanished. Returns `null` on miss. */
|
|
41
|
+
boundingBox(selector: string, timeoutMs?: number): Promise<BoundingBox | null>;
|
|
42
|
+
/** Capture a clean screenshot (no baked annotations), applying any `redactions` before it hits disk. */
|
|
43
|
+
screenshot(relPath: string, redactions?: ResolvedRedaction[]): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Probe the actionability state of `selector` at write-time / calibration-time, without trying
|
|
46
|
+
* to act on it. Returns one of {@link ActionableState}. Designed to mirror the same Playwright
|
|
47
|
+
* actionability checks the runtime would hit at execution-time — so a calibration agent (or an
|
|
48
|
+
* MCP browser bridge consuming this driver's contract) can decide "no point fill'ing a disabled
|
|
49
|
+
* input" or "scope this selector with `:visible` — it matches multiple" *before* the step is
|
|
50
|
+
* written into a flow-file. `timeoutMs` is the *budget per check*, not a wait — keep small
|
|
51
|
+
* (≤500 ms) to avoid stalling calibration. The runtime itself doesn't call this on every step
|
|
52
|
+
* (Playwright's per-action actionability already covers that); it's an exposed contract for
|
|
53
|
+
* consumers that want to read the state without acting.
|
|
54
|
+
*/
|
|
55
|
+
actionable(selector: string, timeoutMs?: number): Promise<ActionableState>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The contract `actionable()` returns. Mirrors Playwright's per-action actionability checks
|
|
59
|
+
* + a couple of states Playwright either throws on (multiple matches) or surfaces awkwardly
|
|
60
|
+
* (off-screen, covered). Listed in the order calibration usually cares about them.
|
|
61
|
+
*/
|
|
62
|
+
export type ActionableState = "actionable" | "not-found" | "multiple-matches" | "detached" | "not-visible" | "off-screen" | "covered" | "disabled";
|
|
63
|
+
export declare class FlowExecutionError extends Error {
|
|
64
|
+
readonly stepId: string;
|
|
65
|
+
readonly cause?: unknown | undefined;
|
|
66
|
+
constructor(message: string, stepId: string, cause?: unknown | undefined);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Best-effort 1-line cause extracted from a Playwright actionability log (or similar driver
|
|
70
|
+
* error). Returns undefined when nothing matches — keeps the halt message short rather than
|
|
71
|
+
* guessing. Surfaced as a `[cause]` prefix on the halt message so the agent doesn't have to
|
|
72
|
+
* scan the multi-line actionability log.
|
|
73
|
+
*/
|
|
74
|
+
export declare function inferHaltCause(rawError: string): string | undefined;
|
|
75
|
+
export interface RunFlowOptions {
|
|
76
|
+
/** Resolve a locator name → selector. Defaults to the flow-file's own `locators` map. */
|
|
77
|
+
resolveLocator?: (name: string) => string | undefined;
|
|
78
|
+
/** Where screenshots are written, relative to the doc pack root. Default: `docs/<flow>/screenshots/<step>.png`. */
|
|
79
|
+
screenshotPath?: (flow: string, stepId: string) => string;
|
|
80
|
+
/** If false, skip screenshot/annotation capture (pure flow validation). Default: true. */
|
|
81
|
+
captureDocs?: boolean;
|
|
82
|
+
/** If set, stop after executing the step with this id — run only a prefix of the flow (for calibration). */
|
|
83
|
+
stopAfter?: string;
|
|
84
|
+
/**
|
|
85
|
+
* If set, **skip** every step *before* the one with this id — start executing from this step onward.
|
|
86
|
+
* Assumes the browser is already in the state the prior steps would have produced (typical use:
|
|
87
|
+
* paired with `connectOverCdp` to attach to a Chrome that's already been driven there manually or
|
|
88
|
+
* by an earlier partial run). The skipped steps emit no annotations / screenshots / executed records;
|
|
89
|
+
* the caller is responsible for preserving the previous run's artifacts for them.
|
|
90
|
+
*/
|
|
91
|
+
startFrom?: string;
|
|
92
|
+
}
|
|
93
|
+
export interface ExecutedStep {
|
|
94
|
+
id: string;
|
|
95
|
+
action: Step["action"];
|
|
96
|
+
/** Resolved selector the action targeted, if any. */
|
|
97
|
+
selector?: string;
|
|
98
|
+
/** Screenshot path (relative to the doc pack), if one was captured for this step. */
|
|
99
|
+
screenshot?: string;
|
|
100
|
+
}
|
|
101
|
+
export interface RunFlowResult {
|
|
102
|
+
flow: string;
|
|
103
|
+
steps: ExecutedStep[];
|
|
104
|
+
annotations: AnnotationsFile;
|
|
105
|
+
}
|
|
106
|
+
/** Resolve a `target` value (`$name` ref or inline selector) using the flow-file's locators (or a custom resolver). */
|
|
107
|
+
export declare function resolveTarget(value: string, flow: FlowFile, resolver?: RunFlowOptions["resolveLocator"]): string;
|
|
108
|
+
/**
|
|
109
|
+
* Execute a flow-file against a {@link BrowserDriver}, re-capturing screenshots and emitting annotation records.
|
|
110
|
+
* Deterministic: given the same site state and driver behaviour, produces the same result. Halts on the first
|
|
111
|
+
* locator / success-criterion failure.
|
|
112
|
+
*/
|
|
113
|
+
export declare function runFlow(flow: FlowFile, driver: BrowserDriver, opts?: RunFlowOptions): Promise<RunFlowResult>;
|