@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.
Files changed (129) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +130 -0
  3. package/dist/auth/api-login.d.ts +69 -0
  4. package/dist/auth/api-login.js +95 -0
  5. package/dist/auth/browser-session.d.ts +28 -0
  6. package/dist/auth/browser-session.js +43 -0
  7. package/dist/auth/cookie-jar.d.ts +58 -0
  8. package/dist/auth/cookie-jar.js +212 -0
  9. package/dist/auth/email-otp.d.ts +210 -0
  10. package/dist/auth/email-otp.js +166 -0
  11. package/dist/auth/http-basic.d.ts +5 -0
  12. package/dist/auth/http-basic.js +17 -0
  13. package/dist/auth/index.d.ts +47 -0
  14. package/dist/auth/index.js +137 -0
  15. package/dist/auth/jwt-injection.d.ts +153 -0
  16. package/dist/auth/jwt-injection.js +136 -0
  17. package/dist/auth/manual-capture.d.ts +35 -0
  18. package/dist/auth/manual-capture.js +30 -0
  19. package/dist/auth/mtls.d.ts +15 -0
  20. package/dist/auth/mtls.js +53 -0
  21. package/dist/auth/pat-header.d.ts +19 -0
  22. package/dist/auth/pat-header.js +34 -0
  23. package/dist/auth/storage-state-cache.d.ts +38 -0
  24. package/dist/auth/storage-state-cache.js +143 -0
  25. package/dist/auth/test-backdoor.d.ts +25 -0
  26. package/dist/auth/test-backdoor.js +51 -0
  27. package/dist/auth/totp.d.ts +39 -0
  28. package/dist/auth/totp.js +108 -0
  29. package/dist/auth/types.d.ts +86 -0
  30. package/dist/auth/types.js +57 -0
  31. package/dist/auth/ui-form.d.ts +204 -0
  32. package/dist/auth/ui-form.js +153 -0
  33. package/dist/auth/webauthn.d.ts +88 -0
  34. package/dist/auth/webauthn.js +67 -0
  35. package/dist/auth.d.ts +1 -0
  36. package/dist/auth.js +3 -0
  37. package/dist/backend-client-contracts.d.ts +88 -0
  38. package/dist/backend-client-contracts.js +19 -0
  39. package/dist/backend-client-oauth-login.d.ts +7 -0
  40. package/dist/backend-client-oauth-login.js +90 -0
  41. package/dist/backend-client-state-cache.d.ts +73 -0
  42. package/dist/backend-client-state-cache.js +185 -0
  43. package/dist/backend-client-token.d.ts +18 -0
  44. package/dist/backend-client-token.js +94 -0
  45. package/dist/backend-client-transport.d.ts +66 -0
  46. package/dist/backend-client-transport.js +181 -0
  47. package/dist/backend-client.d.ts +5 -0
  48. package/dist/backend-client.js +18 -0
  49. package/dist/calibrate.d.ts +31 -0
  50. package/dist/calibrate.js +68 -0
  51. package/dist/cli-commands-authoring.d.ts +5 -0
  52. package/dist/cli-commands-authoring.js +403 -0
  53. package/dist/cli-commands-backend.d.ts +5 -0
  54. package/dist/cli-commands-backend.js +211 -0
  55. package/dist/cli-commands-docpack.d.ts +5 -0
  56. package/dist/cli-commands-docpack.js +280 -0
  57. package/dist/cli-commands-session.d.ts +4 -0
  58. package/dist/cli-commands-session.js +398 -0
  59. package/dist/cli-shared.d.ts +5 -0
  60. package/dist/cli-shared.js +45 -0
  61. package/dist/cli-usage.d.ts +1 -0
  62. package/dist/cli-usage.js +137 -0
  63. package/dist/cli.d.ts +2 -0
  64. package/dist/cli.js +77 -0
  65. package/dist/diagnose.d.ts +50 -0
  66. package/dist/diagnose.js +168 -0
  67. package/dist/diff-compute.d.ts +13 -0
  68. package/dist/diff-compute.js +378 -0
  69. package/dist/diff-report.d.ts +7 -0
  70. package/dist/diff-report.js +125 -0
  71. package/dist/diff-types.d.ts +125 -0
  72. package/dist/diff-types.js +15 -0
  73. package/dist/diff.d.ts +3 -0
  74. package/dist/diff.js +16 -0
  75. package/dist/doc-pack-io.d.ts +30 -0
  76. package/dist/doc-pack-io.js +182 -0
  77. package/dist/doc-pack.d.ts +1814 -0
  78. package/dist/doc-pack.js +328 -0
  79. package/dist/doctor-checks-plugins.d.ts +2 -0
  80. package/dist/doctor-checks-plugins.js +136 -0
  81. package/dist/doctor-checks.d.ts +56 -0
  82. package/dist/doctor-checks.js +367 -0
  83. package/dist/doctor.d.ts +7 -0
  84. package/dist/doctor.js +62 -0
  85. package/dist/export/adf.d.ts +57 -0
  86. package/dist/export/adf.js +323 -0
  87. package/dist/export/playwright-test.d.ts +26 -0
  88. package/dist/export/playwright-test.js +221 -0
  89. package/dist/flow-file.d.ts +21 -0
  90. package/dist/flow-file.js +180 -0
  91. package/dist/flow-lint.d.ts +24 -0
  92. package/dist/flow-lint.js +203 -0
  93. package/dist/flow-runtime.d.ts +113 -0
  94. package/dist/flow-runtime.js +273 -0
  95. package/dist/flow-tree.d.ts +19 -0
  96. package/dist/flow-tree.js +104 -0
  97. package/dist/index.d.ts +27 -0
  98. package/dist/index.js +31 -0
  99. package/dist/playwright-driver.d.ts +105 -0
  100. package/dist/playwright-driver.js +363 -0
  101. package/dist/playwright-instrumented-browser.d.ts +51 -0
  102. package/dist/playwright-instrumented-browser.js +189 -0
  103. package/dist/plugins/load.d.ts +22 -0
  104. package/dist/plugins/load.js +99 -0
  105. package/dist/plugins/lock.d.ts +40 -0
  106. package/dist/plugins/lock.js +122 -0
  107. package/dist/plugins/manifest.d.ts +70 -0
  108. package/dist/plugins/manifest.js +115 -0
  109. package/dist/plugins/plan.d.ts +51 -0
  110. package/dist/plugins/plan.js +279 -0
  111. package/dist/plugins/registry.d.ts +59 -0
  112. package/dist/plugins/registry.js +71 -0
  113. package/dist/plugins/runtime.d.ts +7 -0
  114. package/dist/plugins/runtime.js +27 -0
  115. package/dist/plugins/types.d.ts +58 -0
  116. package/dist/plugins/types.js +4 -0
  117. package/dist/plugins-cli.d.ts +1 -0
  118. package/dist/plugins-cli.js +191 -0
  119. package/dist/redact.d.ts +16 -0
  120. package/dist/redact.js +72 -0
  121. package/dist/style.d.ts +46 -0
  122. package/dist/style.js +151 -0
  123. package/dist/viewer-bin.d.ts +20 -0
  124. package/dist/viewer-bin.js +97 -0
  125. package/dist/workspace.d.ts +60 -0
  126. package/dist/workspace.js +172 -0
  127. package/dist/zip.d.ts +17 -0
  128. package/dist/zip.js +113 -0
  129. 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>;