@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,323 @@
1
+ // ADF (Atlassian Document Format) projection of a doc pack — pure, deterministic, zero HTTP.
2
+ //
3
+ // The engine emits projections only; all Confluence egress lives in the capability-declared
4
+ // publisher plugin (`@docsxai/plugin-confluence`). This module turns a workspace's
5
+ // doc pack (flow-files + step write-ups + burned screenshots) into Confluence Cloud REST v2
6
+ // `atlas_doc_format` documents plus an attachments manifest, in one of two shapes:
7
+ //
8
+ // - `single` (default): ONE consolidated document for the whole project — every flow is an
9
+ // anchored H2 section, every step an H3 — published as one page.
10
+ // - `page-tree`: a parent overview document (section "project") plus one child document per
11
+ // flow (section = flow name).
12
+ //
13
+ // Media nodes reference attachments by `alt` file name with empty `id`/`collection` — the
14
+ // publisher (or a host agent handing the projection to the Atlassian MCP) fills the file ids
15
+ // in after upload. Attachment file names are `<flow>--<step>.png`, unique per document and
16
+ // stable across modes.
17
+ import { createHash } from "node:crypto";
18
+ import { promises as fs } from "node:fs";
19
+ import { parseFlowFile, resolveFlowExtends } from "../flow-file.js";
20
+ import { resolveWorkspacePath } from "../workspace.js";
21
+ // ---------------------------------------------------------------------------
22
+ // markdown → ADF (subset converter)
23
+ // ---------------------------------------------------------------------------
24
+ // Supported: paragraphs, fenced code blocks, bullet/ordered lists, `code`, **bold**, *em* /
25
+ // _em_, [links](url). Anything else — raw HTML included — stays literal text inside an ADF
26
+ // text node (ADF text is plain text, so markup can never be smuggled through).
27
+ function text(value, marks) {
28
+ return marks.length > 0 ? { type: "text", text: value, marks } : { type: "text", text: value };
29
+ }
30
+ /** Earliest inline token in `s`, or null. Order of tie-breaks: leftmost, then longest opener. */
31
+ function findInlineToken(s) {
32
+ let best = null;
33
+ const consider = (m) => {
34
+ if (m && (best === null || m.start < best.start))
35
+ best = m;
36
+ };
37
+ const code = /`([^`]+)`/.exec(s);
38
+ if (code) {
39
+ consider({
40
+ start: code.index,
41
+ end: code.index + code[0].length,
42
+ kind: "code",
43
+ inner: code[1],
44
+ });
45
+ }
46
+ const strong = /\*\*([^*]+)\*\*/.exec(s);
47
+ if (strong) {
48
+ consider({
49
+ start: strong.index,
50
+ end: strong.index + strong[0].length,
51
+ kind: "strong",
52
+ inner: strong[1],
53
+ });
54
+ }
55
+ const em = /(^|[^*])\*([^*]+)\*/.exec(s);
56
+ if (em) {
57
+ const start = em.index + em[1].length;
58
+ consider({ start, end: start + em[2].length + 2, kind: "em", inner: em[2] });
59
+ }
60
+ const emU = /_([^_]+)_/.exec(s);
61
+ if (emU) {
62
+ consider({ start: emU.index, end: emU.index + emU[0].length, kind: "em", inner: emU[1] });
63
+ }
64
+ const link = /\[([^\]]+)\]\(([^)\s]+)\)/.exec(s);
65
+ if (link) {
66
+ consider({
67
+ start: link.index,
68
+ end: link.index + link[0].length,
69
+ kind: "link",
70
+ inner: link[1],
71
+ href: link[2],
72
+ });
73
+ }
74
+ return best;
75
+ }
76
+ /** Inline markdown → ADF text nodes, accumulating marks through nesting (bold inside link, …). */
77
+ export function inlineMarkdownToAdf(source, marks = []) {
78
+ const out = [];
79
+ let rest = source;
80
+ for (;;) {
81
+ const token = findInlineToken(rest);
82
+ if (!token) {
83
+ if (rest.length > 0)
84
+ out.push(text(rest, marks));
85
+ return out;
86
+ }
87
+ if (token.start > 0)
88
+ out.push(text(rest.slice(0, token.start), marks));
89
+ if (token.kind === "code") {
90
+ // Code spans take no nested marks — literal content.
91
+ out.push(text(token.inner, [...marks, { type: "code" }]));
92
+ }
93
+ else if (token.kind === "link") {
94
+ out.push(...inlineMarkdownToAdf(token.inner, [
95
+ ...marks,
96
+ { type: "link", attrs: { href: token.href } },
97
+ ]));
98
+ }
99
+ else {
100
+ out.push(...inlineMarkdownToAdf(token.inner, [...marks, { type: token.kind }]));
101
+ }
102
+ rest = rest.slice(token.end);
103
+ }
104
+ }
105
+ function paragraph(lines) {
106
+ return { type: "paragraph", content: inlineMarkdownToAdf(lines.join(" ")) };
107
+ }
108
+ function listItem(line) {
109
+ return { type: "listItem", content: [{ type: "paragraph", content: inlineMarkdownToAdf(line) }] };
110
+ }
111
+ const BULLET = /^\s*[-*]\s+(.*)$/;
112
+ const ORDERED = /^\s*\d+\.\s+(.*)$/;
113
+ /** Block-level markdown (subset) → ADF block nodes. */
114
+ export function markdownToAdf(markdown) {
115
+ const out = [];
116
+ const lines = markdown.replaceAll("\r\n", "\n").split("\n");
117
+ let i = 0;
118
+ while (i < lines.length) {
119
+ const line = lines[i];
120
+ if (line.trim() === "") {
121
+ i++;
122
+ continue;
123
+ }
124
+ if (line.trimStart().startsWith("```")) {
125
+ const code = [];
126
+ i++;
127
+ while (i < lines.length && !lines[i].trimStart().startsWith("```")) {
128
+ code.push(lines[i]);
129
+ i++;
130
+ }
131
+ i++; // closing fence (or EOF)
132
+ out.push({
133
+ type: "codeBlock",
134
+ attrs: {},
135
+ content: code.length > 0 ? [{ type: "text", text: code.join("\n") }] : [],
136
+ });
137
+ continue;
138
+ }
139
+ if (BULLET.test(line)) {
140
+ const items = [];
141
+ while (i < lines.length && BULLET.test(lines[i])) {
142
+ items.push(listItem(BULLET.exec(lines[i])[1]));
143
+ i++;
144
+ }
145
+ out.push({ type: "bulletList", content: items });
146
+ continue;
147
+ }
148
+ if (ORDERED.test(line)) {
149
+ const items = [];
150
+ while (i < lines.length && ORDERED.test(lines[i])) {
151
+ items.push(listItem(ORDERED.exec(lines[i])[1]));
152
+ i++;
153
+ }
154
+ out.push({ type: "orderedList", attrs: { order: 1 }, content: items });
155
+ continue;
156
+ }
157
+ // Paragraph: consume consecutive non-blank, non-list, non-fence lines.
158
+ const para = [];
159
+ while (i < lines.length &&
160
+ lines[i].trim() !== "" &&
161
+ !BULLET.test(lines[i]) &&
162
+ !ORDERED.test(lines[i]) &&
163
+ !lines[i].trimStart().startsWith("```")) {
164
+ para.push(lines[i].trim());
165
+ i++;
166
+ }
167
+ out.push(paragraph(para));
168
+ }
169
+ return out;
170
+ }
171
+ // ---------------------------------------------------------------------------
172
+ // doc pack → projection
173
+ // ---------------------------------------------------------------------------
174
+ function heading(level, value) {
175
+ return { type: "heading", attrs: { level }, content: [{ type: "text", text: value }] };
176
+ }
177
+ function mediaSingle(fileName) {
178
+ return {
179
+ type: "mediaSingle",
180
+ attrs: { layout: "center" },
181
+ content: [
182
+ // Empty id/collection: the publisher fills these in after attachment upload, matching by `alt`.
183
+ { type: "media", attrs: { type: "file", id: "", collection: "", alt: fileName } },
184
+ ],
185
+ };
186
+ }
187
+ async function readIfExists(p) {
188
+ try {
189
+ return await fs.readFile(p);
190
+ }
191
+ catch {
192
+ return null;
193
+ }
194
+ }
195
+ async function projectFlow(workspaceDir, flowName, flow, warnings) {
196
+ const nodes = [heading(2, flow.name)];
197
+ const attachments = [];
198
+ for (const step of flow.steps) {
199
+ const mdPath = resolveWorkspacePath(workspaceDir, "docs", flowName, `${step.id}.md`);
200
+ const md = await readIfExists(mdPath);
201
+ const burnedPath = resolveWorkspacePath(workspaceDir, "docs", flowName, "burned", `${step.id}.png`);
202
+ const cleanPath = resolveWorkspacePath(workspaceDir, "docs", flowName, "screenshots", `${step.id}.png`);
203
+ let shotPath = null;
204
+ let shot = await readIfExists(burnedPath);
205
+ if (shot) {
206
+ shotPath = burnedPath;
207
+ }
208
+ else {
209
+ shot = await readIfExists(cleanPath);
210
+ if (shot) {
211
+ shotPath = cleanPath;
212
+ warnings.push(`flow "${flowName}" step "${step.id}": burned screenshot missing — falling back to the clean screenshot`);
213
+ }
214
+ }
215
+ if (md === null && shot === null)
216
+ continue; // nothing documented for this step
217
+ nodes.push(heading(3, step.id));
218
+ if (md !== null)
219
+ nodes.push(...markdownToAdf(md.toString("utf8")));
220
+ if (shot !== null && shotPath !== null) {
221
+ const fileName = `${flowName}--${step.id}.png`;
222
+ attachments.push({
223
+ fileName,
224
+ sourcePath: shotPath,
225
+ sha256: createHash("sha256").update(shot).digest("hex"),
226
+ });
227
+ nodes.push(mediaSingle(fileName));
228
+ }
229
+ else {
230
+ warnings.push(`flow "${flowName}" step "${step.id}": no screenshot found (burned or clean)`);
231
+ }
232
+ }
233
+ return { flowName, title: flow.name, nodes, attachments };
234
+ }
235
+ async function loadFlows(workspaceDir, only) {
236
+ const flowsDir = resolveWorkspacePath(workspaceDir, "flows");
237
+ const entries = await fs.readdir(flowsDir).catch(() => []);
238
+ const names = entries
239
+ .filter((e) => e.endsWith(".flow.yaml"))
240
+ .map((e) => e.slice(0, -".flow.yaml".length))
241
+ .sort();
242
+ const wanted = only && only.length > 0 ? names.filter((n) => only.includes(n)) : names;
243
+ if (only) {
244
+ for (const o of only) {
245
+ if (!names.includes(o))
246
+ throw new Error(`export adf: no flow named "${o}" in ${flowsDir}`);
247
+ }
248
+ }
249
+ const load = async (name) => {
250
+ const p = resolveWorkspacePath(workspaceDir, "flows", `${name}.flow.yaml`);
251
+ return parseFlowFile(await fs.readFile(p, "utf8"), p);
252
+ };
253
+ const out = [];
254
+ for (const name of wanted) {
255
+ out.push({ flowName: name, flow: await resolveFlowExtends(await load(name), load) });
256
+ }
257
+ return out;
258
+ }
259
+ /**
260
+ * Project a workspace's doc pack into Confluence-ready ADF documents. Pure file → JSON
261
+ * transform: deterministic for a given doc pack, performs no HTTP, and never writes.
262
+ */
263
+ export async function projectDocPackToAdf(opts) {
264
+ const mode = opts.options?.mode ?? "single";
265
+ const title = opts.options?.title ?? "Site documentation";
266
+ const warnings = [];
267
+ const flows = await loadFlows(opts.workspaceDir, opts.flows);
268
+ const sections = [];
269
+ for (const { flowName, flow } of flows) {
270
+ sections.push(await projectFlow(opts.workspaceDir, flowName, flow, warnings));
271
+ }
272
+ if (mode === "page-tree") {
273
+ const overview = {
274
+ version: 1,
275
+ type: "doc",
276
+ content: [
277
+ {
278
+ type: "paragraph",
279
+ content: [{ type: "text", text: "Documentation for the flows below." }],
280
+ },
281
+ {
282
+ type: "bulletList",
283
+ content: sections.map((s) => ({
284
+ type: "listItem",
285
+ content: [{ type: "paragraph", content: [{ type: "text", text: s.title }] }],
286
+ })),
287
+ },
288
+ ],
289
+ };
290
+ return {
291
+ schema: "docsxai/adf-projection@1",
292
+ mode,
293
+ documents: [
294
+ { section: "project", title, adf: overview, attachments: [] },
295
+ ...sections.map((s) => ({
296
+ section: s.flowName,
297
+ title: s.title,
298
+ adf: { version: 1, type: "doc", content: s.nodes },
299
+ attachments: s.attachments,
300
+ })),
301
+ ],
302
+ warnings,
303
+ };
304
+ }
305
+ // single: stitch every flow's section into one consolidated document.
306
+ return {
307
+ schema: "docsxai/adf-projection@1",
308
+ mode,
309
+ documents: [
310
+ {
311
+ section: "project",
312
+ title,
313
+ adf: {
314
+ version: 1,
315
+ type: "doc",
316
+ content: sections.flatMap((s) => s.nodes),
317
+ },
318
+ attachments: sections.flatMap((s) => s.attachments),
319
+ },
320
+ ],
321
+ warnings,
322
+ };
323
+ }
@@ -0,0 +1,26 @@
1
+ import { type FlowFile } from "../doc-pack.js";
2
+ export interface PlaywrightExportOptions {
3
+ /** Flow-file base name for the header comment (default: the flow's `name`). */
4
+ flowFileName?: string;
5
+ }
6
+ /**
7
+ * Render a flow as a self-contained Playwright `.spec.ts`. The flow must have its `extends`
8
+ * chain resolved first (see `resolveFlowExtends`) so the emitted spec carries the merged steps.
9
+ * Pure string transform — deterministic for a given flow.
10
+ */
11
+ export declare function exportFlowAsPlaywrightTest(flow: FlowFile, options?: PlaywrightExportOptions): string;
12
+ export interface ExportedSpec {
13
+ flowName: string;
14
+ /** `<flow>.spec.ts` */
15
+ fileName: string;
16
+ content: string;
17
+ }
18
+ /**
19
+ * Export a workspace's flows (default: all of `flows/*.flow.yaml`, sorted) as Playwright specs.
20
+ * `extends` chains are resolved before generation. Reads only; the caller writes the files.
21
+ */
22
+ export declare function exportWorkspaceFlowsAsPlaywrightTests(opts: {
23
+ workspaceDir: string;
24
+ /** Restrict to these flow names. Unknown names throw. */
25
+ flows?: string[];
26
+ }): Promise<ExportedSpec[]>;
@@ -0,0 +1,221 @@
1
+ // Flow-file → Playwright test export — pure, deterministic, zero HTTP.
2
+ //
3
+ // Each flow becomes one self-contained `.spec.ts`: locator const declarations from the flow's
4
+ // `locators` map, steps translated to Playwright actions, `wait_for` to waits, `success` to
5
+ // `expect(...)` assertions, the `environment` block to `test.use({...})` (+ `page.clock` for a
6
+ // frozen clock), and `optional: true` steps wrapped in try/catch (conditionally-present UI — a
7
+ // miss is tolerated, mirroring the runtime). The flow-file stays the source of truth: generated
8
+ // specs carry a "regenerate, don't hand-edit" header and are meant to live in the consumer's
9
+ // Playwright suite as a drift tripwire between docs and reality.
10
+ import { promises as fs } from "node:fs";
11
+ import { VIEWPORT_PRESETS, } from "../doc-pack.js";
12
+ import { locatorRefName, parseFlowFile, resolveFlowExtends } from "../flow-file.js";
13
+ import { resolveWorkspacePath } from "../workspace.js";
14
+ const RESERVED = new Set([
15
+ // ES reserved words + the identifiers the generated scaffold itself uses.
16
+ ...`await break case catch class const continue debugger default delete do else enum export
17
+ extends false finally for function if import in instanceof let new null return static super
18
+ switch this throw true try typeof var void while with yield`.split(/\s+/),
19
+ "page",
20
+ "test",
21
+ "expect",
22
+ ]);
23
+ /** Deterministic flow-locator-name → JS identifier mapping (sanitized, collision-free). */
24
+ function locatorIdentifiers(flow) {
25
+ const taken = new Set(RESERVED);
26
+ const ids = new Map();
27
+ for (const name of Object.keys(flow.locators)) {
28
+ let id = name.replace(/[^A-Za-z0-9_$]/g, "_");
29
+ if (!/^[A-Za-z_$]/.test(id))
30
+ id = `_${id}`;
31
+ while (taken.has(id))
32
+ id = `${id}_`;
33
+ taken.add(id);
34
+ ids.set(name, id);
35
+ }
36
+ return ids;
37
+ }
38
+ /** Render a step `target` / locator-ref-or-inline-selector as a Playwright locator expression. */
39
+ function locatorExpr(value, ids) {
40
+ const ref = locatorRefName(value);
41
+ if (ref !== null) {
42
+ const id = ids.get(ref);
43
+ if (id)
44
+ return id;
45
+ }
46
+ return `page.locator(${JSON.stringify(value)})`;
47
+ }
48
+ function environmentUse(env) {
49
+ const entries = [];
50
+ if (env.viewport !== undefined) {
51
+ const v = typeof env.viewport === "string" ? VIEWPORT_PRESETS[env.viewport] : env.viewport;
52
+ entries.push(`viewport: { width: ${v.width}, height: ${v.height} }`);
53
+ }
54
+ if (env.locale !== undefined)
55
+ entries.push(`locale: ${JSON.stringify(env.locale)}`);
56
+ if (env.timezone !== undefined)
57
+ entries.push(`timezoneId: ${JSON.stringify(env.timezone)}`);
58
+ if (env.color_scheme !== undefined)
59
+ entries.push(`colorScheme: ${JSON.stringify(env.color_scheme)}`);
60
+ if (env.reduced_motion)
61
+ entries.push(`reducedMotion: "reduce"`);
62
+ if (entries.length === 0)
63
+ return [];
64
+ return ["test.use({", ...entries.map((e) => ` ${e},`), "});", ""];
65
+ }
66
+ function actionLines(step, ids) {
67
+ const target = step.target !== undefined ? locatorExpr(step.target, ids) : null;
68
+ const value = step.value;
69
+ const missing = (what) => [
70
+ `// step "${step.id}": ${step.action} without ${what} — nothing to emit`,
71
+ ];
72
+ switch (step.action) {
73
+ case "navigate":
74
+ return value === undefined
75
+ ? missing("a value")
76
+ : [`await page.goto(${JSON.stringify(value)});`];
77
+ case "click":
78
+ return target === null ? missing("a target") : [`await ${target}.click();`];
79
+ case "fill":
80
+ return target === null
81
+ ? missing("a target")
82
+ : [`await ${target}.fill(${JSON.stringify(value ?? "")});`];
83
+ case "select":
84
+ return target === null || value === undefined
85
+ ? missing("a target/value")
86
+ : [`await ${target}.selectOption(${JSON.stringify(value)});`];
87
+ case "check":
88
+ return target === null ? missing("a target") : [`await ${target}.check();`];
89
+ case "uncheck":
90
+ return target === null ? missing("a target") : [`await ${target}.uncheck();`];
91
+ case "hover":
92
+ return target === null ? missing("a target") : [`await ${target}.hover();`];
93
+ case "press":
94
+ if (value === undefined)
95
+ return missing("a key value");
96
+ return target === null
97
+ ? [`await page.keyboard.press(${JSON.stringify(value)});`]
98
+ : [`await ${target}.press(${JSON.stringify(value)});`];
99
+ case "upload":
100
+ return target === null || value === undefined
101
+ ? missing("a target/value")
102
+ : [`await ${target}.setInputFiles(${JSON.stringify(value)});`];
103
+ case "wait":
104
+ return []; // the step's wait_for carries the semantics
105
+ }
106
+ }
107
+ function waitLines(wait, step, ids) {
108
+ if (wait === "network_idle")
109
+ return [`await page.waitForLoadState("networkidle");`];
110
+ if (wait === "load")
111
+ return [`await page.waitForLoadState("load");`];
112
+ if (wait === "element_stable") {
113
+ // Playwright actions auto-wait for element stability; approximate the standalone wait with a
114
+ // visibility wait on the step target when there is one.
115
+ return step.target !== undefined
116
+ ? [`await ${locatorExpr(step.target, ids)}.waitFor({ state: "visible" }); // element_stable`]
117
+ : [`// wait_for: element_stable — Playwright auto-waits on the next action`];
118
+ }
119
+ if ("selector" in wait) {
120
+ const opts = wait.timeout_ms !== undefined
121
+ ? `{ state: "visible", timeout: ${wait.timeout_ms} }`
122
+ : `{ state: "visible" }`;
123
+ return [`await ${locatorExpr(wait.selector, ids)}.waitFor(${opts});`];
124
+ }
125
+ return [`await page.waitForTimeout(${wait.timeout_ms});`];
126
+ }
127
+ function successLines(success, ids) {
128
+ if ("visible" in success)
129
+ return [`await expect(${locatorExpr(success.visible, ids)}).toBeVisible();`];
130
+ if ("hidden" in success)
131
+ return [`await expect(${locatorExpr(success.hidden, ids)}).toBeHidden();`];
132
+ if ("url_matches" in success)
133
+ return [`await expect(page).toHaveURL(new RegExp(${JSON.stringify(success.url_matches)}));`];
134
+ return [
135
+ `await expect(${locatorExpr(success.text_contains.selector, ids)})` +
136
+ `.toContainText(${JSON.stringify(success.text_contains.text)});`,
137
+ ];
138
+ }
139
+ function stepLines(step, ids) {
140
+ const body = [
141
+ ...actionLines(step, ids),
142
+ ...(step.wait_for !== undefined ? waitLines(step.wait_for, step, ids) : []),
143
+ ...(step.success !== undefined ? successLines(step.success, ids) : []),
144
+ ];
145
+ const header = `// step: ${step.id} (${step.action}${step.optional ? ", optional" : ""})`;
146
+ if (!step.optional)
147
+ return [header, ...body];
148
+ return [
149
+ header,
150
+ "try {",
151
+ ...body.map((l) => ` ${l}`),
152
+ "} catch {",
153
+ " // optional step — conditionally-present UI; a miss is tolerated",
154
+ "}",
155
+ ];
156
+ }
157
+ /**
158
+ * Render a flow as a self-contained Playwright `.spec.ts`. The flow must have its `extends`
159
+ * chain resolved first (see `resolveFlowExtends`) so the emitted spec carries the merged steps.
160
+ * Pure string transform — deterministic for a given flow.
161
+ */
162
+ export function exportFlowAsPlaywrightTest(flow, options = {}) {
163
+ if (flow.extends) {
164
+ throw new Error(`flow "${flow.name}": resolve \`extends\` before exporting (resolveFlowExtends)`);
165
+ }
166
+ const fileName = options.flowFileName ?? flow.name;
167
+ const ids = locatorIdentifiers(flow);
168
+ const lines = [
169
+ `// generated from ${fileName}.flow.yaml — regenerate, don't hand-edit`,
170
+ `import { expect, test } from "@playwright/test";`,
171
+ "",
172
+ ];
173
+ if (flow.environment)
174
+ lines.push(...environmentUse(flow.environment));
175
+ lines.push(`test(${JSON.stringify(flow.name)}, async ({ page }) => {`);
176
+ const body = [];
177
+ if (flow.environment?.clock !== undefined) {
178
+ body.push(`await page.clock.setFixedTime(new Date(${JSON.stringify(flow.environment.clock)}));`, "");
179
+ }
180
+ const locatorDecls = [...ids.entries()].map(([name, id]) => `const ${id} = page.locator(${JSON.stringify(flow.locators[name])});`);
181
+ if (locatorDecls.length > 0)
182
+ body.push(...locatorDecls, "");
183
+ flow.steps.forEach((step, i) => {
184
+ body.push(...stepLines(step, ids));
185
+ if (i < flow.steps.length - 1)
186
+ body.push("");
187
+ });
188
+ lines.push(...body.map((l) => (l === "" ? "" : ` ${l}`)), "});", "");
189
+ return lines.join("\n");
190
+ }
191
+ /**
192
+ * Export a workspace's flows (default: all of `flows/*.flow.yaml`, sorted) as Playwright specs.
193
+ * `extends` chains are resolved before generation. Reads only; the caller writes the files.
194
+ */
195
+ export async function exportWorkspaceFlowsAsPlaywrightTests(opts) {
196
+ const flowsDir = resolveWorkspacePath(opts.workspaceDir, "flows");
197
+ const entries = await fs.readdir(flowsDir).catch(() => []);
198
+ const names = entries
199
+ .filter((e) => e.endsWith(".flow.yaml"))
200
+ .map((e) => e.slice(0, -".flow.yaml".length))
201
+ .sort();
202
+ const wanted = opts.flows && opts.flows.length > 0 ? opts.flows : names;
203
+ for (const w of wanted) {
204
+ if (!names.includes(w))
205
+ throw new Error(`export playwright: no flow named "${w}" in ${flowsDir}`);
206
+ }
207
+ const load = async (name) => {
208
+ const p = resolveWorkspacePath(opts.workspaceDir, "flows", `${name}.flow.yaml`);
209
+ return parseFlowFile(await fs.readFile(p, "utf8"), p);
210
+ };
211
+ const out = [];
212
+ for (const name of wanted) {
213
+ const flow = await resolveFlowExtends(await load(name), load);
214
+ out.push({
215
+ flowName: name,
216
+ fileName: `${name}.spec.ts`,
217
+ content: exportFlowAsPlaywrightTest(flow, { flowFileName: name }),
218
+ });
219
+ }
220
+ return out;
221
+ }
@@ -0,0 +1,21 @@
1
+ import { FlowFile } from "./doc-pack.js";
2
+ export declare class FlowFileError extends Error {
3
+ readonly cause?: unknown | undefined;
4
+ constructor(message: string, cause?: unknown | undefined);
5
+ }
6
+ /** Parse + validate a flow-file from YAML text. Throws {@link FlowFileError} with a readable message on failure. */
7
+ export declare function parseFlowFile(yamlText: string, source?: string): FlowFile;
8
+ /** Serialize a {@link FlowFile} back to canonical YAML. */
9
+ export declare function serializeFlowFile(flow: FlowFile): string;
10
+ /** Returns the locator name if `value` is a `$name` reference, else `null` (it's an inline selector). */
11
+ export declare function locatorRefName(value: string): string | null;
12
+ /** Locator names referenced anywhere in `flow` (steps + flow-level redactions); inline selectors excluded. */
13
+ export declare function referencedLocatorNames(flow: FlowFile): Set<string>;
14
+ /**
15
+ * Resolve a flow's `extends` chain into a single flow: parent's steps first, then this flow's. `locators` and
16
+ * `prerequisites` are merged (this flow wins on locator-name collisions); `environment` merges per-key with
17
+ * this flow's keys winning; `redactions` concatenate (parent's first); step ids must be unique across the
18
+ * merge. Chains are followed recursively; cycles throw. `loadFlowFile(name)` parses `flows/<name>.flow.yaml`
19
+ * (a flow with its own `extends` un-resolved — this function recurses). The result has no `extends`.
20
+ */
21
+ export declare function resolveFlowExtends(flow: FlowFile, loadFlowFile: (name: string) => Promise<FlowFile> | FlowFile, visited?: Set<string>): Promise<FlowFile>;