@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,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>;
|