@cementic/cementic-test 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +300 -457
- package/dist/chunk-5QRDTCSM.js +368 -0
- package/dist/chunk-5QRDTCSM.js.map +1 -0
- package/dist/cli.js +1242 -210
- package/dist/cli.js.map +1 -1
- package/dist/{gen-54KYT3RO.js → gen-RMRQOAD3.js} +2 -2
- package/dist/templates/student-framework/README.md +35 -5
- package/dist/templates/student-framework/package.json +3 -2
- package/dist/templates/student-framework/pages/BasePage.js +19 -0
- package/dist/templates/student-framework/pages/DashboardPage.js +16 -0
- package/dist/templates/student-framework/pages/FormPage.js +24 -0
- package/dist/templates/student-framework/pages/LoginPage.js +24 -0
- package/dist/templates/student-framework/tests/dashboard.spec.js +14 -0
- package/dist/templates/student-framework/tests/login.spec.js +14 -0
- package/dist/templates/student-framework-ts/README.md +20 -0
- package/dist/templates/student-framework-ts/package.json +24 -0
- package/dist/templates/student-framework-ts/pages/LandingPage.ts +15 -0
- package/dist/templates/student-framework-ts/playwright.config.ts +29 -0
- package/dist/templates/{student-framework/tests/landing.spec.js → student-framework-ts/tests/landing.spec.ts} +1 -1
- package/dist/templates/student-framework-ts/tsconfig.json +19 -0
- package/dist/templates/student-framework-ts/workflows/playwright.yml +20 -0
- package/package.json +3 -2
- package/dist/chunk-J63TUHIV.js +0 -80
- package/dist/chunk-J63TUHIV.js.map +0 -1
- package/dist/templates/student-framework/pages/LandingPage.js +0 -18
- /package/dist/{gen-54KYT3RO.js.map → gen-RMRQOAD3.js.map} +0 -0
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
genCmd
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-5QRDTCSM.js";
|
|
5
5
|
|
|
6
6
|
// src/cli.ts
|
|
7
7
|
import { Command as Command9 } from "commander";
|
|
8
|
+
import { createRequire } from "module";
|
|
8
9
|
|
|
9
10
|
// src/commands/new.ts
|
|
10
11
|
import { Command } from "commander";
|
|
@@ -16,12 +17,22 @@ import { dirname } from "path";
|
|
|
16
17
|
import { platform, release } from "os";
|
|
17
18
|
var __filename = fileURLToPath(import.meta.url);
|
|
18
19
|
var __dirname = dirname(__filename);
|
|
20
|
+
function resolveTemplatePath(templateDir) {
|
|
21
|
+
const candidates = [
|
|
22
|
+
resolve(__dirname, `templates/${templateDir}`),
|
|
23
|
+
resolve(__dirname, `../templates/${templateDir}`),
|
|
24
|
+
resolve(__dirname, `../../templates/${templateDir}`),
|
|
25
|
+
resolve(process.cwd(), `templates/${templateDir}`)
|
|
26
|
+
];
|
|
27
|
+
return candidates.find((candidate) => existsSync(candidate));
|
|
28
|
+
}
|
|
19
29
|
function newCmd() {
|
|
20
30
|
const cmd = new Command("new").arguments("<projectName>").description("Scaffold a new CementicTest + Playwright project from scratch").addHelpText("after", `
|
|
21
31
|
Examples:
|
|
22
32
|
$ ct new my-awesome-test-suite
|
|
33
|
+
$ ct new e2e-ts --lang ts
|
|
23
34
|
$ ct new e2e-tests --no-browsers
|
|
24
|
-
`).option("--mode <mode>", "greenfield|enhance", "greenfield").option("--no-browsers", 'do not run "npx playwright install" during setup').action((projectName, opts) => {
|
|
35
|
+
`).option("--mode <mode>", "greenfield|enhance", "greenfield").option("--lang <lang>", "Scaffold language (js|ts)", "js").option("--no-browsers", 'do not run "npx playwright install" during setup').action((projectName, opts) => {
|
|
25
36
|
const root = process.cwd();
|
|
26
37
|
const projectPath = join(root, projectName);
|
|
27
38
|
console.log(`\u{1F680} Initializing new CementicTest project in ${projectName}...`);
|
|
@@ -30,18 +41,11 @@ Examples:
|
|
|
30
41
|
process.exit(1);
|
|
31
42
|
}
|
|
32
43
|
mkdirSync(projectPath, { recursive: true });
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
templatePath = resolve(__dirname, "../../templates/student-framework");
|
|
39
|
-
}
|
|
40
|
-
if (!existsSync(templatePath)) {
|
|
41
|
-
templatePath = resolve(process.cwd(), "templates/student-framework");
|
|
42
|
-
}
|
|
43
|
-
if (!existsSync(templatePath)) {
|
|
44
|
-
console.error(`\u274C Could not locate template at ${templatePath}`);
|
|
44
|
+
const lang = opts.lang === "ts" ? "ts" : "js";
|
|
45
|
+
const templateDir = lang === "ts" ? "student-framework-ts" : "student-framework";
|
|
46
|
+
const templatePath = resolveTemplatePath(templateDir);
|
|
47
|
+
if (!templatePath) {
|
|
48
|
+
console.error(`\u274C Could not locate template "${templateDir}"`);
|
|
45
49
|
console.error("Please ensure the package is built correctly with templates included.");
|
|
46
50
|
process.exit(1);
|
|
47
51
|
}
|
|
@@ -129,6 +133,10 @@ function parseId(title) {
|
|
|
129
133
|
const m = title.match(/\b([A-Z]+-\d+)\b/);
|
|
130
134
|
return m?.[1];
|
|
131
135
|
}
|
|
136
|
+
function stripIdPrefixFromTitle(title, id) {
|
|
137
|
+
if (!id) return title.trim();
|
|
138
|
+
return title.replace(new RegExp(`^${id}\\s*[\u2014\\-\u2013:]\\s*`), "").trim();
|
|
139
|
+
}
|
|
132
140
|
function splitCasesByHeading(fileText) {
|
|
133
141
|
const lines = fileText.split(/\r?\n/);
|
|
134
142
|
const blocks = [];
|
|
@@ -174,34 +182,69 @@ function extractSections(body) {
|
|
|
174
182
|
const stepsBlock = sections["steps"] ?? "";
|
|
175
183
|
const expectedBlock = sections["expected"] ?? sections["expected results"] ?? sections["then"] ?? "";
|
|
176
184
|
const bullet = /^\s*(?:\d+\.|[-*])\s+(.+)$/gm;
|
|
177
|
-
const
|
|
185
|
+
const parsedSteps = Array.from(stepsBlock.matchAll(bullet)).map((m) => parseHintedLine(m[1].trim(), "selector"));
|
|
186
|
+
const steps = parsedSteps.map((entry) => entry.text);
|
|
187
|
+
const stepHints = parsedSteps.map((entry) => ({ selector: entry.hint }));
|
|
178
188
|
if (steps.length === 0) {
|
|
179
|
-
const alt = Array.from(body.matchAll(bullet)).map((m) => m[1].trim());
|
|
180
|
-
steps.push(...alt);
|
|
189
|
+
const alt = Array.from(body.matchAll(bullet)).map((m) => parseHintedLine(m[1].trim(), "selector"));
|
|
190
|
+
steps.push(...alt.map((entry) => entry.text));
|
|
191
|
+
stepHints.push(...alt.map((entry) => ({ selector: entry.hint })));
|
|
181
192
|
}
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
);
|
|
193
|
+
const parsedExpected = Array.from(expectedBlock.matchAll(bullet)).map((m) => parseHintedLine(m[1].trim(), "playwright"));
|
|
194
|
+
const expectedLines = parsedExpected.map((entry) => entry.text);
|
|
195
|
+
const assertionHints = parsedExpected.map((entry) => ({ playwright: entry.hint }));
|
|
185
196
|
if (expectedLines.length === 0) {
|
|
186
197
|
const exp = Array.from(
|
|
187
198
|
body.matchAll(/^\s*(?:Expected|Then|Verify|Assert)[^\n]*:?[\s-]*(.+)$/gim)
|
|
188
|
-
).map((m) => m[1].trim());
|
|
189
|
-
expectedLines.push(...exp);
|
|
199
|
+
).map((m) => parseHintedLine(m[1].trim(), "playwright"));
|
|
200
|
+
expectedLines.push(...exp.map((entry) => entry.text));
|
|
201
|
+
assertionHints.push(...exp.map((entry) => ({ playwright: entry.hint })));
|
|
190
202
|
}
|
|
191
|
-
return { steps, expected: expectedLines };
|
|
203
|
+
return { steps, stepHints, expected: expectedLines, assertionHints };
|
|
204
|
+
}
|
|
205
|
+
function extractUrlMetadata(body) {
|
|
206
|
+
const match = body.match(/<!--\s*ct:url\s+(https?:\/\/[^\s>]+)\s*-->/i);
|
|
207
|
+
return match?.[1];
|
|
208
|
+
}
|
|
209
|
+
function stripCtMetadata(body) {
|
|
210
|
+
return body.replace(/^\s*<!--\s*ct:url\s+https?:\/\/[^\s>]+\s*-->\s*\n?/im, "");
|
|
211
|
+
}
|
|
212
|
+
function parseHintedLine(value, hintType) {
|
|
213
|
+
const match = value.match(new RegExp(`^(.*?)\\s*<!--\\s*${hintType}:\\s*([\\s\\S]*?)\\s*-->\\s*$`, "i"));
|
|
214
|
+
if (!match) return { text: value.trim() };
|
|
215
|
+
return {
|
|
216
|
+
text: match[1].trim(),
|
|
217
|
+
hint: match[2].trim() || void 0
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function extractUrlFromSteps(steps) {
|
|
221
|
+
for (const step of steps) {
|
|
222
|
+
const match = step.match(/https?:\/\/[^\s'"]+/i);
|
|
223
|
+
if (match) return match[0];
|
|
224
|
+
}
|
|
225
|
+
return void 0;
|
|
192
226
|
}
|
|
193
227
|
function normalizeOne(titleLine, body, source) {
|
|
194
228
|
const { clean, tags } = parseTags(titleLine);
|
|
195
229
|
const id = parseId(clean);
|
|
196
|
-
const
|
|
230
|
+
const metadataUrl = extractUrlMetadata(body);
|
|
231
|
+
const cleanBody = stripCtMetadata(body);
|
|
232
|
+
const { steps, stepHints, expected, assertionHints } = extractSections(cleanBody);
|
|
233
|
+
const reviewReasons = [];
|
|
234
|
+
if (steps.length === 0) reviewReasons.push("No steps section or bullets were parsed");
|
|
235
|
+
if (expected.length === 0) reviewReasons.push("No expected results section or assertions were parsed");
|
|
197
236
|
return {
|
|
198
237
|
id,
|
|
199
|
-
title: clean,
|
|
238
|
+
title: stripIdPrefixFromTitle(clean, id),
|
|
200
239
|
tags: tags.length ? tags : void 0,
|
|
201
240
|
steps,
|
|
241
|
+
step_hints: stepHints.some((hint) => hint.selector) ? stepHints : void 0,
|
|
202
242
|
expected,
|
|
203
|
-
|
|
204
|
-
|
|
243
|
+
assertion_hints: assertionHints.some((hint) => hint.playwright) ? assertionHints : void 0,
|
|
244
|
+
needs_review: reviewReasons.length > 0,
|
|
245
|
+
review_reasons: reviewReasons,
|
|
246
|
+
source,
|
|
247
|
+
url: metadataUrl ?? extractUrlFromSteps(steps)
|
|
205
248
|
};
|
|
206
249
|
}
|
|
207
250
|
function normalizeCmd() {
|
|
@@ -265,7 +308,7 @@ Examples:
|
|
|
265
308
|
}
|
|
266
309
|
console.log(`\u2705 Normalized ${index.summary.parsed} case(s). Output \u2192 .cementic/normalized/`);
|
|
267
310
|
if (opts.andGen) {
|
|
268
|
-
const { gen } = await import("./gen-
|
|
311
|
+
const { gen } = await import("./gen-RMRQOAD3.js");
|
|
269
312
|
await gen({ lang: opts.lang || "ts", out: "tests/generated" });
|
|
270
313
|
}
|
|
271
314
|
});
|
|
@@ -295,8 +338,8 @@ function testCmd() {
|
|
|
295
338
|
|
|
296
339
|
// src/commands/tc.ts
|
|
297
340
|
import { Command as Command4 } from "commander";
|
|
298
|
-
import { mkdirSync as
|
|
299
|
-
import { join as
|
|
341
|
+
import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
342
|
+
import { join as join4 } from "path";
|
|
300
343
|
import { createInterface } from "readline/promises";
|
|
301
344
|
import { stdin as input, stdout as output } from "process";
|
|
302
345
|
|
|
@@ -353,88 +396,136 @@ function inferPrefix(params) {
|
|
|
353
396
|
// src/core/llm.ts
|
|
354
397
|
function buildSystemMessage() {
|
|
355
398
|
return `
|
|
356
|
-
You are a senior QA engineer and test
|
|
399
|
+
You are a senior QA engineer and test automation specialist.
|
|
357
400
|
|
|
358
401
|
Your job:
|
|
359
|
-
-
|
|
360
|
-
-
|
|
361
|
-
-
|
|
402
|
+
- Receive context about a web feature or page
|
|
403
|
+
- Generate high-quality, realistic UI test cases
|
|
404
|
+
- Format them in strict Markdown that a downstream parser will process
|
|
362
405
|
|
|
363
406
|
You MUST follow this format exactly for each test case:
|
|
364
407
|
|
|
365
|
-
# <ID> \u2014 <Short title> @<tag1> @<tag2>
|
|
408
|
+
# <ID> \u2014 <Short title> @<tag1> @<tag2>
|
|
366
409
|
## Steps
|
|
367
|
-
1. <
|
|
368
|
-
2. <
|
|
410
|
+
1. <user action>
|
|
411
|
+
2. <user action>
|
|
369
412
|
|
|
370
413
|
## Expected Results
|
|
371
|
-
- <
|
|
372
|
-
- <
|
|
414
|
+
- <verifiable outcome>
|
|
415
|
+
- <verifiable outcome>
|
|
373
416
|
|
|
374
417
|
Rules:
|
|
375
|
-
-
|
|
376
|
-
-
|
|
377
|
-
|
|
378
|
-
-
|
|
379
|
-
-
|
|
380
|
-
-
|
|
381
|
-
-
|
|
382
|
-
- Output ONLY the test cases, back-to-back, in Markdown.
|
|
383
|
-
- No code blocks, no extra headings outside the pattern described.
|
|
418
|
+
- ID must be PREFIX-NNN where NNN is zero-padded 3 digits starting from startIndex
|
|
419
|
+
- Use 1\u20133 tags: @smoke @regression @auth @ui @critical @happy-path @negative
|
|
420
|
+
- Steps = concrete user actions ("Click the 'Sign In' button", "Fill in email with 'user@example.com'")
|
|
421
|
+
- Expected Results = verifiable UI outcomes ("Error message 'Invalid credentials' is visible", "Page redirects to /dashboard")
|
|
422
|
+
- Include both happy-path AND negative/edge-case tests
|
|
423
|
+
- Do NOT explain or add preamble \u2014 output ONLY the test cases
|
|
424
|
+
- No code blocks, no extra headings outside the pattern
|
|
384
425
|
`.trim();
|
|
385
426
|
}
|
|
386
427
|
function buildUserMessage(ctx) {
|
|
387
428
|
const lines = [];
|
|
388
|
-
lines.push(`App /
|
|
429
|
+
lines.push(`App / product description:`);
|
|
389
430
|
lines.push(ctx.appDescription || "N/A");
|
|
390
431
|
lines.push("");
|
|
391
432
|
lines.push(`Feature or page to test:`);
|
|
392
433
|
lines.push(ctx.feature);
|
|
393
434
|
lines.push("");
|
|
394
435
|
if (ctx.url) {
|
|
395
|
-
lines.push(`Page URL
|
|
396
|
-
lines.push(ctx.url);
|
|
436
|
+
lines.push(`Page URL: ${ctx.url}`);
|
|
397
437
|
lines.push("");
|
|
398
438
|
}
|
|
399
|
-
if (ctx.
|
|
400
|
-
|
|
401
|
-
lines.push(
|
|
402
|
-
lines.push(
|
|
403
|
-
|
|
439
|
+
if (ctx.pageSummary) {
|
|
440
|
+
const s = ctx.pageSummary;
|
|
441
|
+
lines.push(`Live page analysis:`);
|
|
442
|
+
if (s.title) lines.push(`- Page title: ${s.title}`);
|
|
443
|
+
if (s.headings.length) {
|
|
444
|
+
lines.push(`- Headings: ${s.headings.join(" | ")}`);
|
|
445
|
+
}
|
|
446
|
+
if (s.inputs.length) {
|
|
447
|
+
const inputDescs = s.inputs.map((i) => {
|
|
448
|
+
const parts = [
|
|
449
|
+
i.label && `label="${i.label}"`,
|
|
450
|
+
i.placeholder && `placeholder="${i.placeholder}"`,
|
|
451
|
+
i.name && `name="${i.name}"`,
|
|
452
|
+
i.type && i.type !== "text" && `type="${i.type}"`,
|
|
453
|
+
i.testId && `data-testid="${i.testId}"`
|
|
454
|
+
].filter(Boolean);
|
|
455
|
+
return parts.join(", ");
|
|
456
|
+
});
|
|
457
|
+
lines.push(`- Form inputs: ${inputDescs.join(" | ")}`);
|
|
458
|
+
}
|
|
459
|
+
if (s.buttons.length) {
|
|
460
|
+
lines.push(`- Buttons: ${s.buttons.join(" | ")}`);
|
|
461
|
+
}
|
|
462
|
+
if (s.links.length) {
|
|
463
|
+
lines.push(`- Links: ${s.links.slice(0, 20).join(" | ")}`);
|
|
464
|
+
}
|
|
404
465
|
lines.push("");
|
|
405
466
|
}
|
|
406
|
-
lines.push(`Test ID prefix
|
|
407
|
-
lines.push(
|
|
408
|
-
|
|
409
|
-
);
|
|
410
|
-
lines.push(`Number of test cases to generate: ${ctx.numCases}`);
|
|
467
|
+
lines.push(`Test ID prefix: ${ctx.prefix}`);
|
|
468
|
+
lines.push(`Start numbering from: ${String(ctx.startIndex).padStart(3, "0")}`);
|
|
469
|
+
lines.push(`Number of test cases: ${ctx.numCases}`);
|
|
411
470
|
lines.push("");
|
|
412
|
-
lines.push(`
|
|
413
|
-
lines.push(
|
|
414
|
-
lines.push(`- Each test case must follow this pattern exactly:`);
|
|
415
|
-
lines.push(`# ${ctx.prefix}-NNN \u2014 <short title> @tag1 @tag2`);
|
|
416
|
-
lines.push(`## Steps`);
|
|
417
|
-
lines.push(`1. ...`);
|
|
418
|
-
lines.push(`2. ...`);
|
|
419
|
-
lines.push(``);
|
|
420
|
-
lines.push(`## Expected Results`);
|
|
421
|
-
lines.push(`- ...`);
|
|
422
|
-
lines.push(`- ...`);
|
|
423
|
-
lines.push("");
|
|
424
|
-
lines.push(`Do NOT add any explanation before or after the test cases. Only output the test cases.`);
|
|
471
|
+
lines.push(`Include a mix of: happy-path flows, validation/error cases, and edge cases.`);
|
|
472
|
+
lines.push(`Output ONLY the test cases. No explanation, no preamble.`);
|
|
425
473
|
return lines.join("\n");
|
|
426
474
|
}
|
|
427
|
-
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
)
|
|
475
|
+
function detectProvider() {
|
|
476
|
+
const anthropicKey = process.env.ANTHROPIC_API_KEY ?? process.env.CT_ANTHROPIC_API_KEY ?? "";
|
|
477
|
+
const openaiKey = process.env.CT_LLM_API_KEY ?? process.env.OPENAI_API_KEY ?? "";
|
|
478
|
+
const explicitProvider = (process.env.CT_LLM_PROVIDER ?? "").toLowerCase();
|
|
479
|
+
if (explicitProvider === "anthropic" || anthropicKey && explicitProvider !== "openai") {
|
|
480
|
+
if (!anthropicKey) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
"CT_LLM_PROVIDER=anthropic but no ANTHROPIC_API_KEY found.\nSet ANTHROPIC_API_KEY=your-key or switch to OPENAI_API_KEY."
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
provider: "anthropic",
|
|
487
|
+
apiKey: anthropicKey,
|
|
488
|
+
model: process.env.CT_LLM_MODEL ?? "claude-sonnet-4-5",
|
|
489
|
+
baseUrl: "https://api.anthropic.com"
|
|
490
|
+
};
|
|
433
491
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
492
|
+
if (openaiKey) {
|
|
493
|
+
return {
|
|
494
|
+
provider: "openai",
|
|
495
|
+
apiKey: openaiKey,
|
|
496
|
+
model: process.env.CT_LLM_MODEL ?? "gpt-4o-mini",
|
|
497
|
+
baseUrl: process.env.CT_LLM_BASE_URL ?? "https://api.openai.com/v1"
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
throw new Error(
|
|
501
|
+
"No LLM API key found.\n For Claude: export ANTHROPIC_API_KEY=your-key\n For OpenAI: export OPENAI_API_KEY=your-key\n Any OpenAI-compatible endpoint: set CT_LLM_API_KEY + CT_LLM_BASE_URL"
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
async function callAnthropic(apiKey, model, system, user) {
|
|
505
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
506
|
+
method: "POST",
|
|
507
|
+
headers: {
|
|
508
|
+
"x-api-key": apiKey,
|
|
509
|
+
"anthropic-version": "2023-06-01",
|
|
510
|
+
"content-type": "application/json"
|
|
511
|
+
},
|
|
512
|
+
body: JSON.stringify({
|
|
513
|
+
model,
|
|
514
|
+
max_tokens: 4096,
|
|
515
|
+
system,
|
|
516
|
+
messages: [{ role: "user", content: user }]
|
|
517
|
+
})
|
|
518
|
+
});
|
|
519
|
+
if (!response.ok) {
|
|
520
|
+
const text = await response.text();
|
|
521
|
+
throw new Error(`Anthropic API error ${response.status}: ${text}`);
|
|
522
|
+
}
|
|
523
|
+
const json = await response.json();
|
|
524
|
+
const content = json.content?.[0]?.text?.trim() ?? "";
|
|
525
|
+
if (!content) throw new Error("Anthropic response had no content");
|
|
526
|
+
return content;
|
|
527
|
+
}
|
|
528
|
+
async function callOpenAi(apiKey, model, baseUrl, system, user) {
|
|
438
529
|
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
439
530
|
method: "POST",
|
|
440
531
|
headers: {
|
|
@@ -443,76 +534,978 @@ async function generateTcMarkdownWithAi(ctx) {
|
|
|
443
534
|
},
|
|
444
535
|
body: JSON.stringify({
|
|
445
536
|
model,
|
|
537
|
+
temperature: 0.2,
|
|
446
538
|
messages: [
|
|
447
539
|
{ role: "system", content: system },
|
|
448
540
|
{ role: "user", content: user }
|
|
449
|
-
]
|
|
450
|
-
temperature: 0.2
|
|
541
|
+
]
|
|
451
542
|
})
|
|
452
543
|
});
|
|
453
544
|
if (!response.ok) {
|
|
454
545
|
const text = await response.text();
|
|
455
|
-
throw new Error(
|
|
456
|
-
|
|
457
|
-
|
|
546
|
+
throw new Error(`OpenAI API error ${response.status}: ${text}`);
|
|
547
|
+
}
|
|
548
|
+
const json = await response.json();
|
|
549
|
+
const content = json.choices?.[0]?.message?.content?.trim() ?? "";
|
|
550
|
+
if (!content) throw new Error("OpenAI response had no content");
|
|
551
|
+
return content;
|
|
552
|
+
}
|
|
553
|
+
async function generateTcMarkdownWithAi(ctx) {
|
|
554
|
+
const { provider, apiKey, model, baseUrl } = detectProvider();
|
|
555
|
+
const system = buildSystemMessage();
|
|
556
|
+
const user = buildUserMessage(ctx);
|
|
557
|
+
console.log(`\u{1F916} Using ${provider === "anthropic" ? "Claude" : "OpenAI-compatible"} (${model})`);
|
|
558
|
+
if (provider === "anthropic") {
|
|
559
|
+
return callAnthropic(apiKey, model, system, user);
|
|
560
|
+
}
|
|
561
|
+
return callOpenAi(apiKey, model, baseUrl, system, user);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// src/core/analyse.ts
|
|
565
|
+
async function analyseElements(elementMap, options = {}) {
|
|
566
|
+
const { verbose = false } = options;
|
|
567
|
+
const { provider, displayName, apiKey, model, baseUrl } = detectProvider2();
|
|
568
|
+
log(verbose, `
|
|
569
|
+
[analyse] Sending capture to ${displayName} (${model})`);
|
|
570
|
+
const systemPrompt = buildSystemPrompt();
|
|
571
|
+
const userPrompt = buildUserPrompt(elementMap);
|
|
572
|
+
const rawResponse = provider === "anthropic" ? await callAnthropic2(apiKey, model, systemPrompt, userPrompt) : await callOpenAi2(apiKey, model, baseUrl ?? "https://api.openai.com/v1", systemPrompt, userPrompt);
|
|
573
|
+
const parsed = parseAnalysisJson(rawResponse);
|
|
574
|
+
return sanitizeAnalysis(parsed, elementMap);
|
|
575
|
+
}
|
|
576
|
+
function detectProvider2() {
|
|
577
|
+
const explicitProvider = (process.env.CT_LLM_PROVIDER ?? "").toLowerCase();
|
|
578
|
+
const providers = {
|
|
579
|
+
deepseek: {
|
|
580
|
+
provider: "deepseek",
|
|
581
|
+
displayName: "DeepSeek",
|
|
582
|
+
apiKey: process.env.DEEPSEEK_API_KEY ?? process.env.CT_DEEPSEEK_API_KEY ?? "",
|
|
583
|
+
model: process.env.CT_LLM_MODEL ?? process.env.DEEPSEEK_MODEL ?? "deepseek-chat",
|
|
584
|
+
baseUrl: process.env.DEEPSEEK_BASE_URL ?? "https://api.deepseek.com/v1"
|
|
585
|
+
},
|
|
586
|
+
anthropic: {
|
|
587
|
+
provider: "anthropic",
|
|
588
|
+
displayName: "Claude",
|
|
589
|
+
apiKey: process.env.ANTHROPIC_API_KEY ?? process.env.CT_ANTHROPIC_API_KEY ?? "",
|
|
590
|
+
model: process.env.CT_LLM_MODEL ?? process.env.ANTHROPIC_MODEL ?? "claude-sonnet-4-5"
|
|
591
|
+
},
|
|
592
|
+
gemini: {
|
|
593
|
+
provider: "gemini",
|
|
594
|
+
displayName: "Gemini",
|
|
595
|
+
apiKey: process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY ?? "",
|
|
596
|
+
model: process.env.CT_LLM_MODEL ?? process.env.GEMINI_MODEL ?? "gemini-2.5-flash",
|
|
597
|
+
baseUrl: process.env.GEMINI_BASE_URL ?? "https://generativelanguage.googleapis.com/v1beta/openai"
|
|
598
|
+
},
|
|
599
|
+
qwen: {
|
|
600
|
+
provider: "qwen",
|
|
601
|
+
displayName: "Qwen",
|
|
602
|
+
apiKey: process.env.QWEN_API_KEY ?? "",
|
|
603
|
+
model: process.env.CT_LLM_MODEL ?? process.env.QWEN_MODEL ?? "qwen-plus",
|
|
604
|
+
baseUrl: process.env.QWEN_BASE_URL ?? "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
|
|
605
|
+
},
|
|
606
|
+
kimi: {
|
|
607
|
+
provider: "kimi",
|
|
608
|
+
displayName: "Kimi",
|
|
609
|
+
apiKey: process.env.KIMI_API_KEY ?? process.env.MOONSHOT_API_KEY ?? "",
|
|
610
|
+
model: process.env.CT_LLM_MODEL ?? process.env.KIMI_MODEL ?? "moonshot-v1-8k",
|
|
611
|
+
baseUrl: process.env.KIMI_BASE_URL ?? "https://api.moonshot.ai/v1"
|
|
612
|
+
},
|
|
613
|
+
openai: {
|
|
614
|
+
provider: "openai",
|
|
615
|
+
displayName: "OpenAI-compatible",
|
|
616
|
+
apiKey: process.env.OPENAI_API_KEY ?? process.env.CT_LLM_API_KEY ?? "",
|
|
617
|
+
model: process.env.CT_LLM_MODEL ?? process.env.OPENAI_MODEL ?? "gpt-4o-mini",
|
|
618
|
+
baseUrl: process.env.CT_LLM_BASE_URL ?? "https://api.openai.com/v1"
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
if (explicitProvider) {
|
|
622
|
+
if (!(explicitProvider in providers)) {
|
|
623
|
+
throw new Error(`Unsupported CT_LLM_PROVIDER="${explicitProvider}". Use deepseek, anthropic, gemini, qwen, kimi, or openai.`);
|
|
624
|
+
}
|
|
625
|
+
const selected = providers[explicitProvider];
|
|
626
|
+
if (!selected.apiKey) {
|
|
627
|
+
throw new Error(`CT_LLM_PROVIDER=${explicitProvider} is set but the matching API key is missing.`);
|
|
628
|
+
}
|
|
629
|
+
return selected;
|
|
630
|
+
}
|
|
631
|
+
for (const providerName of ["deepseek", "anthropic", "gemini", "qwen", "kimi", "openai"]) {
|
|
632
|
+
if (providers[providerName].apiKey) return providers[providerName];
|
|
633
|
+
}
|
|
634
|
+
throw new Error(
|
|
635
|
+
"No LLM API key found.\n DeepSeek: export DEEPSEEK_API_KEY=your-key\n Anthropic: export ANTHROPIC_API_KEY=your-key\n Gemini: export GEMINI_API_KEY=your-key\n Qwen: export QWEN_API_KEY=your-key\n Kimi: export KIMI_API_KEY=your-key\n OpenAI: export OPENAI_API_KEY=your-key"
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
function buildSystemPrompt() {
|
|
639
|
+
return `
|
|
640
|
+
You are a senior QA automation engineer and Playwright expert working on CementicTest.
|
|
641
|
+
|
|
642
|
+
Your job is to analyse a structured map of interactive elements extracted from a live web page,
|
|
643
|
+
then produce a complete set of Playwright test scenarios.
|
|
644
|
+
|
|
645
|
+
RULES:
|
|
646
|
+
1. Use only selectors provided in the ElementMap.
|
|
647
|
+
2. Every assertion must include an exact "playwright" field with a complete await expect(...) statement.
|
|
648
|
+
3. Use clearly fake data like user@example.com.
|
|
649
|
+
4. Include both happy-path and negative scenarios, but stay evidence-backed.
|
|
650
|
+
5. Output only valid JSON matching the requested schema.
|
|
651
|
+
6. Do not invent redirect targets, success pages, error text, password clearing, or security scenarios unless the capture explicitly supports them.
|
|
652
|
+
7. If no status or alert region was captured, avoid scenarios that depend on unseen server-side validation messages.
|
|
653
|
+
8. Prefer 3 to 5 realistic scenarios.
|
|
654
|
+
|
|
655
|
+
OUTPUT SCHEMA:
|
|
656
|
+
{
|
|
657
|
+
"url": string,
|
|
658
|
+
"feature": string,
|
|
659
|
+
"suggestedPrefix": string,
|
|
660
|
+
"scenarios": [
|
|
661
|
+
{
|
|
662
|
+
"id": string,
|
|
663
|
+
"title": string,
|
|
664
|
+
"tags": string[],
|
|
665
|
+
"steps": [
|
|
666
|
+
{
|
|
667
|
+
"action": "navigate"|"fill"|"click"|"select"|"check"|"keyboard"|"hover",
|
|
668
|
+
"selector": string,
|
|
669
|
+
"value": string,
|
|
670
|
+
"human": string
|
|
671
|
+
}
|
|
672
|
+
],
|
|
673
|
+
"assertions": [
|
|
674
|
+
{
|
|
675
|
+
"type": string,
|
|
676
|
+
"selector": string,
|
|
677
|
+
"expected": string,
|
|
678
|
+
"human": string,
|
|
679
|
+
"playwright": string
|
|
680
|
+
}
|
|
681
|
+
],
|
|
682
|
+
"narrator": string,
|
|
683
|
+
"codeLevel": "beginner"|"intermediate"|"advanced"
|
|
684
|
+
}
|
|
685
|
+
],
|
|
686
|
+
"analysisNotes": string,
|
|
687
|
+
"audioSummary": string
|
|
688
|
+
}`.trim();
|
|
689
|
+
}
|
|
690
|
+
function buildUserPrompt(elementMap) {
|
|
691
|
+
const lines = [];
|
|
692
|
+
lines.push("PAGE INFORMATION");
|
|
693
|
+
lines.push(`URL: ${elementMap.url}`);
|
|
694
|
+
lines.push(`Title: ${elementMap.title}`);
|
|
695
|
+
lines.push(`Captured in: ${elementMap.mode} mode`);
|
|
696
|
+
lines.push("");
|
|
697
|
+
for (const category of ["input", "button", "link", "heading", "status"]) {
|
|
698
|
+
const items = elementMap.elements.filter((element) => element.category === category);
|
|
699
|
+
if (items.length === 0) continue;
|
|
700
|
+
lines.push(`${category.toUpperCase()}S (${items.length} found):`);
|
|
701
|
+
for (const item of items.slice(0, category === "link" ? 10 : items.length)) {
|
|
702
|
+
lines.push(` - [${item.confidence}] ${item.selector}`);
|
|
703
|
+
lines.push(` Purpose: ${item.purpose}`);
|
|
704
|
+
if (item.selectorAlt.length > 0) {
|
|
705
|
+
lines.push(` Fallbacks: ${item.selectorAlt.slice(0, 2).join(" | ")}`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
lines.push("");
|
|
709
|
+
}
|
|
710
|
+
if (elementMap.warnings.length > 0) {
|
|
711
|
+
lines.push("CAPTURE WARNINGS:");
|
|
712
|
+
for (const warning of elementMap.warnings) {
|
|
713
|
+
lines.push(` - ${warning}`);
|
|
714
|
+
}
|
|
715
|
+
lines.push("");
|
|
716
|
+
}
|
|
717
|
+
const interactiveCount = elementMap.elements.filter((element) => element.category === "input" || element.category === "button" || element.category === "link").length;
|
|
718
|
+
const statusCount = elementMap.elements.filter((element) => element.category === "status").length;
|
|
719
|
+
lines.push("EVIDENCE CONSTRAINTS:");
|
|
720
|
+
lines.push(` - Interactive elements captured: ${interactiveCount}`);
|
|
721
|
+
lines.push(` - Status or alert regions captured: ${statusCount}`);
|
|
722
|
+
lines.push(" - If no redirect target is explicitly captured, do not assert a destination path.");
|
|
723
|
+
lines.push(" - If no status region was captured, avoid exact server-side credential error claims.");
|
|
724
|
+
lines.push("");
|
|
725
|
+
lines.push("Generate only the JSON response.");
|
|
726
|
+
return lines.join("\n");
|
|
727
|
+
}
|
|
728
|
+
async function callAnthropic2(apiKey, model, systemPrompt, userPrompt) {
|
|
729
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
730
|
+
method: "POST",
|
|
731
|
+
headers: {
|
|
732
|
+
"x-api-key": apiKey,
|
|
733
|
+
"anthropic-version": "2023-06-01",
|
|
734
|
+
"content-type": "application/json"
|
|
735
|
+
},
|
|
736
|
+
body: JSON.stringify({
|
|
737
|
+
model,
|
|
738
|
+
max_tokens: 4096,
|
|
739
|
+
system: systemPrompt,
|
|
740
|
+
messages: [{ role: "user", content: userPrompt }]
|
|
741
|
+
})
|
|
742
|
+
});
|
|
743
|
+
if (!response.ok) {
|
|
744
|
+
throw new Error(`Anthropic API ${response.status}: ${await response.text()}`);
|
|
458
745
|
}
|
|
459
746
|
const json = await response.json();
|
|
460
|
-
const content = json.
|
|
461
|
-
if (!content)
|
|
462
|
-
|
|
747
|
+
const content = json.content?.[0]?.text?.trim() ?? "";
|
|
748
|
+
if (!content) throw new Error("Anthropic returned empty content");
|
|
749
|
+
return content;
|
|
750
|
+
}
|
|
751
|
+
async function callOpenAi2(apiKey, model, baseUrl, systemPrompt, userPrompt) {
|
|
752
|
+
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
753
|
+
method: "POST",
|
|
754
|
+
headers: {
|
|
755
|
+
Authorization: `Bearer ${apiKey}`,
|
|
756
|
+
"Content-Type": "application/json"
|
|
757
|
+
},
|
|
758
|
+
body: JSON.stringify({
|
|
759
|
+
model,
|
|
760
|
+
temperature: 0.1,
|
|
761
|
+
messages: [
|
|
762
|
+
{ role: "system", content: systemPrompt },
|
|
763
|
+
{ role: "user", content: userPrompt }
|
|
764
|
+
]
|
|
765
|
+
})
|
|
766
|
+
});
|
|
767
|
+
if (!response.ok) {
|
|
768
|
+
throw new Error(`OpenAI API ${response.status}: ${await response.text()}`);
|
|
463
769
|
}
|
|
770
|
+
const json = await response.json();
|
|
771
|
+
const content = json.choices?.[0]?.message?.content?.trim() ?? "";
|
|
772
|
+
if (!content) throw new Error("OpenAI-compatible provider returned empty content");
|
|
464
773
|
return content;
|
|
465
774
|
}
|
|
775
|
+
function parseAnalysisJson(raw) {
|
|
776
|
+
const stripped = raw.replace(/^```(?:json)?\s*/m, "").replace(/\s*```\s*$/m, "").trim();
|
|
777
|
+
try {
|
|
778
|
+
return JSON.parse(stripped);
|
|
779
|
+
} catch (error) {
|
|
780
|
+
throw new Error(
|
|
781
|
+
`Failed to parse LLM response as JSON.
|
|
782
|
+
Parse error: ${error?.message ?? error}
|
|
783
|
+
Raw response:
|
|
784
|
+
${raw.slice(0, 500)}`
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
function sanitizeAnalysis(analysis, elementMap) {
|
|
789
|
+
const selectors = new Set(elementMap.elements.map((element) => element.selector));
|
|
790
|
+
const rawScenarios = Array.isArray(analysis.scenarios) ? analysis.scenarios : [];
|
|
791
|
+
const currentUrl = new URL(elementMap.url);
|
|
792
|
+
const knownPaths = /* @__PURE__ */ new Set([
|
|
793
|
+
currentUrl.pathname,
|
|
794
|
+
...elementMap.elements.filter((element) => element.category === "link").map((element) => {
|
|
795
|
+
const href = element.attributes?.href;
|
|
796
|
+
if (typeof href !== "string") return "";
|
|
797
|
+
try {
|
|
798
|
+
return new URL(href, elementMap.url).pathname;
|
|
799
|
+
} catch {
|
|
800
|
+
return href;
|
|
801
|
+
}
|
|
802
|
+
}).filter(Boolean)
|
|
803
|
+
]);
|
|
804
|
+
const sanitizedScenarios = rawScenarios.map((scenario) => normalizeScenario(scenario, selectors)).filter((scenario) => scenario.steps.length > 0 && scenario.assertions.length > 0).map((scenario) => ({
|
|
805
|
+
...scenario,
|
|
806
|
+
tags: scenario.tags.map(normalizeTag),
|
|
807
|
+
assertions: scenario.assertions.filter((assertion) => {
|
|
808
|
+
if (assertion.type !== "url") return true;
|
|
809
|
+
const combined = `${assertion.expected} ${assertion.human} ${assertion.playwright}`;
|
|
810
|
+
const pathMatch = combined.match(/\/[a-z0-9/_-]+/i);
|
|
811
|
+
if (!pathMatch) return true;
|
|
812
|
+
return knownPaths.has(pathMatch[0]);
|
|
813
|
+
})
|
|
814
|
+
})).filter((scenario) => scenario.assertions.length > 0).slice(0, 5);
|
|
815
|
+
const finalScenarios = sanitizedScenarios.length > 0 ? sanitizedScenarios : buildFallbackScenarios(elementMap, analysis.suggestedPrefix || "FLOW");
|
|
816
|
+
return {
|
|
817
|
+
...analysis,
|
|
818
|
+
url: analysis.url || elementMap.url,
|
|
819
|
+
feature: analysis.feature || inferFeatureName(elementMap),
|
|
820
|
+
suggestedPrefix: (analysis.suggestedPrefix || inferPrefix2(elementMap)).toUpperCase(),
|
|
821
|
+
scenarios: finalScenarios,
|
|
822
|
+
analysisNotes: [
|
|
823
|
+
analysis.analysisNotes,
|
|
824
|
+
`Sanitized to ${finalScenarios.length} evidence-backed scenario(s) from ${rawScenarios.length} raw scenario(s).`
|
|
825
|
+
].filter(Boolean).join(" "),
|
|
826
|
+
audioSummary: analysis.audioSummary || buildAudioSummary(analysis.feature || inferFeatureName(elementMap), finalScenarios)
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
function normalizeScenario(candidate, selectors) {
|
|
830
|
+
return {
|
|
831
|
+
id: candidate?.id ?? "FLOW-001",
|
|
832
|
+
title: candidate?.title ?? "Captured page flow",
|
|
833
|
+
tags: Array.isArray(candidate?.tags) ? candidate.tags : [],
|
|
834
|
+
steps: Array.isArray(candidate?.steps) ? candidate.steps.filter((step) => step?.selector === "page" || selectors.has(step?.selector)) : [],
|
|
835
|
+
assertions: Array.isArray(candidate?.assertions) ? candidate.assertions.filter((assertion) => assertion?.selector === "page" || selectors.has(assertion?.selector)) : [],
|
|
836
|
+
narrator: candidate?.narrator ?? "Let us run this captured test flow.",
|
|
837
|
+
codeLevel: candidate?.codeLevel ?? "beginner"
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
function buildFallbackScenarios(elementMap, prefix) {
|
|
841
|
+
const heading = elementMap.elements.find((element) => element.category === "heading");
|
|
842
|
+
const emailInput = elementMap.elements.find((element) => element.category === "input" && (element.attributes.type === "email" || /email/.test(`${element.name ?? ""} ${String(element.attributes.label ?? "")}`.toLowerCase())));
|
|
843
|
+
const passwordInput = elementMap.elements.find((element) => element.category === "input" && element.attributes.type === "password");
|
|
844
|
+
const submitButton = elementMap.elements.find((element) => element.category === "button" && /login|sign in|submit|continue/i.test(element.name ?? "")) ?? elementMap.elements.find((element) => element.category === "button");
|
|
845
|
+
const scenarios = [];
|
|
846
|
+
const tag = (value) => normalizeTag(value);
|
|
847
|
+
const nextId = (index) => `${prefix}-${String(900 + index).padStart(3, "0")}`;
|
|
848
|
+
if (heading) {
|
|
849
|
+
scenarios.push({
|
|
850
|
+
id: nextId(scenarios.length + 1),
|
|
851
|
+
title: "Page loads with expected heading",
|
|
852
|
+
tags: [tag("smoke"), tag("page-load")],
|
|
853
|
+
steps: [
|
|
854
|
+
{
|
|
855
|
+
action: "navigate",
|
|
856
|
+
selector: "page",
|
|
857
|
+
value: elementMap.url,
|
|
858
|
+
human: "Navigate to the captured page"
|
|
859
|
+
}
|
|
860
|
+
],
|
|
861
|
+
assertions: [
|
|
862
|
+
{
|
|
863
|
+
type: "visible",
|
|
864
|
+
selector: heading.selector,
|
|
865
|
+
expected: "visible",
|
|
866
|
+
human: `${heading.name || "Primary heading"} is visible`,
|
|
867
|
+
playwright: `await expect(page.${heading.selector}).toBeVisible();`
|
|
868
|
+
}
|
|
869
|
+
],
|
|
870
|
+
narrator: "We will first confirm that the expected page heading is visible.",
|
|
871
|
+
codeLevel: "beginner"
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
if (emailInput && passwordInput && submitButton) {
|
|
875
|
+
scenarios.push({
|
|
876
|
+
id: nextId(scenarios.length + 1),
|
|
877
|
+
title: "Submitting without a password keeps the user on the form",
|
|
878
|
+
tags: [tag("validation"), tag("negative")],
|
|
879
|
+
steps: [
|
|
880
|
+
{
|
|
881
|
+
action: "navigate",
|
|
882
|
+
selector: "page",
|
|
883
|
+
value: elementMap.url,
|
|
884
|
+
human: "Navigate to the captured page"
|
|
885
|
+
},
|
|
886
|
+
{
|
|
887
|
+
action: "fill",
|
|
888
|
+
selector: emailInput.selector,
|
|
889
|
+
value: "user@example.com",
|
|
890
|
+
human: "Fill in the email field"
|
|
891
|
+
},
|
|
892
|
+
{
|
|
893
|
+
action: "click",
|
|
894
|
+
selector: submitButton.selector,
|
|
895
|
+
value: "",
|
|
896
|
+
human: "Click the submit button"
|
|
897
|
+
}
|
|
898
|
+
],
|
|
899
|
+
assertions: [
|
|
900
|
+
{
|
|
901
|
+
type: "url",
|
|
902
|
+
selector: "page",
|
|
903
|
+
expected: new URL(elementMap.url).pathname,
|
|
904
|
+
human: "User remains on the same page",
|
|
905
|
+
playwright: `await expect(page).toHaveURL('${elementMap.url}');`
|
|
906
|
+
},
|
|
907
|
+
{
|
|
908
|
+
type: "visible",
|
|
909
|
+
selector: passwordInput.selector,
|
|
910
|
+
expected: "visible",
|
|
911
|
+
human: "Password field stays visible for correction",
|
|
912
|
+
playwright: `await expect(page.${passwordInput.selector}).toBeVisible();`
|
|
913
|
+
}
|
|
914
|
+
],
|
|
915
|
+
narrator: "Next we will leave the password blank and confirm the form does not advance.",
|
|
916
|
+
codeLevel: "beginner"
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
return scenarios.slice(0, 5);
|
|
920
|
+
}
|
|
921
|
+
function inferFeatureName(elementMap) {
|
|
922
|
+
const heading = elementMap.elements.find((element) => element.category === "heading" && element.name);
|
|
923
|
+
return heading?.name || elementMap.title || "Captured page";
|
|
924
|
+
}
|
|
925
|
+
function inferPrefix2(elementMap) {
|
|
926
|
+
const source = `${elementMap.title} ${elementMap.url}`.toLowerCase();
|
|
927
|
+
if (/\blogin|sign in|auth/.test(source)) return "AUTH";
|
|
928
|
+
if (/\bcheckout|cart|payment/.test(source)) return "CHK";
|
|
929
|
+
if (/\bdashboard/.test(source)) return "DASH";
|
|
930
|
+
return "FLOW";
|
|
931
|
+
}
|
|
932
|
+
function buildAudioSummary(feature, scenarios) {
|
|
933
|
+
return `We finished validating ${feature || "the captured page"} with ${scenarios.length} evidence-backed scenario${scenarios.length === 1 ? "" : "s"}.`;
|
|
934
|
+
}
|
|
935
|
+
function normalizeTag(value) {
|
|
936
|
+
const cleaned = String(value ?? "").trim().replace(/^@+/, "");
|
|
937
|
+
return cleaned ? `@${cleaned}` : "@ui";
|
|
938
|
+
}
|
|
939
|
+
function log(verbose, message) {
|
|
940
|
+
if (verbose) console.log(message);
|
|
941
|
+
}
|
|
466
942
|
|
|
467
|
-
// src/core/
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
943
|
+
// src/core/capture.ts
|
|
944
|
+
var SETTLE_MS = 1200;
|
|
945
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
946
|
+
var MAX_PER_CATEGORY = 50;
|
|
947
|
+
async function captureElements(url, options = {}) {
|
|
948
|
+
const {
|
|
949
|
+
headless = true,
|
|
950
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
951
|
+
verbose = false,
|
|
952
|
+
userAgent = "Mozilla/5.0 (compatible; CementicTest/0.2.5 capture)"
|
|
953
|
+
} = options;
|
|
954
|
+
const chromium = await loadChromium();
|
|
955
|
+
const mode = headless ? "headless" : "headed";
|
|
956
|
+
log2(verbose, `
|
|
957
|
+
[capture] Starting ${mode} capture: ${url}`);
|
|
958
|
+
const browser = await chromium.launch({
|
|
959
|
+
headless,
|
|
960
|
+
slowMo: headless ? 0 : 150
|
|
961
|
+
});
|
|
962
|
+
const context = await browser.newContext({
|
|
963
|
+
userAgent,
|
|
964
|
+
viewport: { width: 1280, height: 800 },
|
|
965
|
+
ignoreHTTPSErrors: true
|
|
966
|
+
});
|
|
967
|
+
const page = await context.newPage();
|
|
968
|
+
try {
|
|
969
|
+
log2(verbose, ` -> Navigating (timeout: ${timeoutMs}ms)`);
|
|
970
|
+
await page.goto(url, {
|
|
971
|
+
waitUntil: "domcontentloaded",
|
|
972
|
+
timeout: timeoutMs
|
|
973
|
+
});
|
|
974
|
+
log2(verbose, ` -> Waiting ${SETTLE_MS}ms for page settle`);
|
|
975
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
976
|
+
log2(verbose, " -> Extracting accessibility snapshot");
|
|
977
|
+
const a11ySnapshot = await getAccessibilitySnapshot(page, verbose);
|
|
978
|
+
log2(verbose, " -> Extracting DOM data");
|
|
979
|
+
const domData = await page.evaluate(extractDomData);
|
|
980
|
+
const title = await page.title();
|
|
981
|
+
const finalUrl = page.url();
|
|
982
|
+
const elements = buildElementMap(domData);
|
|
983
|
+
const warnings = buildWarnings(elements, domData, a11ySnapshot);
|
|
984
|
+
const byCategory = {};
|
|
985
|
+
for (const element of elements) {
|
|
986
|
+
byCategory[element.category] = (byCategory[element.category] ?? 0) + 1;
|
|
499
987
|
}
|
|
988
|
+
const result = {
|
|
989
|
+
url: finalUrl,
|
|
990
|
+
title,
|
|
991
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
992
|
+
mode,
|
|
993
|
+
summary: {
|
|
994
|
+
totalElements: elements.length,
|
|
995
|
+
byCategory
|
|
996
|
+
},
|
|
997
|
+
elements,
|
|
998
|
+
warnings
|
|
999
|
+
};
|
|
1000
|
+
log2(verbose, ` -> Captured ${elements.length} testable elements from "${title}"`);
|
|
1001
|
+
return result;
|
|
1002
|
+
} finally {
|
|
1003
|
+
await browser.close();
|
|
500
1004
|
}
|
|
1005
|
+
}
|
|
1006
|
+
function toPageSummary(elementMap) {
|
|
1007
|
+
const inputs = elementMap.elements.filter((element) => element.category === "input").slice(0, 30).map((element) => ({
|
|
1008
|
+
label: asString(element.attributes.label),
|
|
1009
|
+
placeholder: asString(element.attributes.placeholder),
|
|
1010
|
+
name: asString(element.attributes.name),
|
|
1011
|
+
type: asString(element.attributes.type),
|
|
1012
|
+
testId: asString(element.attributes.testId)
|
|
1013
|
+
}));
|
|
501
1014
|
return {
|
|
502
|
-
url,
|
|
503
|
-
title,
|
|
1015
|
+
url: elementMap.url,
|
|
1016
|
+
title: elementMap.title,
|
|
1017
|
+
headings: elementMap.elements.filter((element) => element.category === "heading").map((element) => element.name ?? "").filter(Boolean).slice(0, 20),
|
|
1018
|
+
buttons: elementMap.elements.filter((element) => element.category === "button").map((element) => element.name ?? "").filter(Boolean).slice(0, 30),
|
|
1019
|
+
links: elementMap.elements.filter((element) => element.category === "link").map((element) => element.name ?? "").filter(Boolean).slice(0, 50),
|
|
1020
|
+
inputs,
|
|
1021
|
+
landmarks: [],
|
|
1022
|
+
rawLength: elementMap.elements.length
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
async function loadChromium() {
|
|
1026
|
+
try {
|
|
1027
|
+
return (await import("playwright-core")).chromium;
|
|
1028
|
+
} catch {
|
|
1029
|
+
}
|
|
1030
|
+
try {
|
|
1031
|
+
return (await import("@playwright/test")).chromium;
|
|
1032
|
+
} catch {
|
|
1033
|
+
}
|
|
1034
|
+
throw new Error(
|
|
1035
|
+
'Playwright is required for live page capture. Install it in the target project with "npm install -D @playwright/test" and "npx playwright install chromium".'
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
function extractDomData() {
|
|
1039
|
+
const localMaxPerCategory = 50;
|
|
1040
|
+
const attr = (el, name) => el.getAttribute(name)?.trim() || void 0;
|
|
1041
|
+
const text = (el) => el?.textContent?.replace(/\s+/g, " ").trim() || void 0;
|
|
1042
|
+
const jsString = (value) => JSON.stringify(value);
|
|
1043
|
+
const findLabel = (el) => {
|
|
1044
|
+
const id = attr(el, "id");
|
|
1045
|
+
if (id) {
|
|
1046
|
+
const labelEl = document.querySelector(`label[for="${id}"]`);
|
|
1047
|
+
if (labelEl) return text(labelEl);
|
|
1048
|
+
}
|
|
1049
|
+
const ariaLabel = attr(el, "aria-label");
|
|
1050
|
+
if (ariaLabel) return ariaLabel;
|
|
1051
|
+
const labelledBy = attr(el, "aria-labelledby");
|
|
1052
|
+
if (labelledBy) {
|
|
1053
|
+
const labelEl = document.getElementById(labelledBy);
|
|
1054
|
+
if (labelEl) return text(labelEl);
|
|
1055
|
+
}
|
|
1056
|
+
const closestLabel = el.closest("label");
|
|
1057
|
+
if (closestLabel) {
|
|
1058
|
+
const raw = text(closestLabel) || "";
|
|
1059
|
+
const placeholder = attr(el, "placeholder") || "";
|
|
1060
|
+
return raw.replace(placeholder, "").trim() || void 0;
|
|
1061
|
+
}
|
|
1062
|
+
const previous = el.previousElementSibling;
|
|
1063
|
+
if (previous?.tagName === "LABEL") return text(previous);
|
|
1064
|
+
return void 0;
|
|
1065
|
+
};
|
|
1066
|
+
const buildSelector = (el, labelText, buttonText) => {
|
|
1067
|
+
const testId = attr(el, "data-testid");
|
|
1068
|
+
if (testId) return `getByTestId(${jsString(testId)})`;
|
|
1069
|
+
const id = attr(el, "id");
|
|
1070
|
+
if (id && !id.match(/^(ember|react|vue|ng|auto|rand)/i)) {
|
|
1071
|
+
return `locator(${jsString(`#${id}`)})`;
|
|
1072
|
+
}
|
|
1073
|
+
const ariaLabel = attr(el, "aria-label");
|
|
1074
|
+
if (ariaLabel) {
|
|
1075
|
+
return `getByRole(${jsString(el.getAttribute("role") || el.tagName.toLowerCase())}, { name: ${jsString(ariaLabel)} })`;
|
|
1076
|
+
}
|
|
1077
|
+
if (labelText) {
|
|
1078
|
+
const tag = el.tagName.toLowerCase();
|
|
1079
|
+
if (tag === "input" || tag === "textarea" || tag === "select") {
|
|
1080
|
+
return `getByLabel(${jsString(labelText)})`;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
if (buttonText) return `getByRole('button', { name: ${jsString(buttonText)} })`;
|
|
1084
|
+
const name = attr(el, "name");
|
|
1085
|
+
if (name) return `locator(${jsString(`[name="${name}"]`)})`;
|
|
1086
|
+
return null;
|
|
1087
|
+
};
|
|
1088
|
+
const buttons = [];
|
|
1089
|
+
document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]').forEach((el) => {
|
|
1090
|
+
const buttonText = attr(el, "aria-label") || text(el) || attr(el, "value");
|
|
1091
|
+
const testId = attr(el, "data-testid");
|
|
1092
|
+
const id = attr(el, "id");
|
|
1093
|
+
const type = attr(el, "type");
|
|
1094
|
+
const disabled = el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true";
|
|
1095
|
+
const selector = buildSelector(el, void 0, buttonText);
|
|
1096
|
+
if (buttonText || testId) {
|
|
1097
|
+
buttons.push({
|
|
1098
|
+
tag: el.tagName.toLowerCase(),
|
|
1099
|
+
text: buttonText,
|
|
1100
|
+
testId,
|
|
1101
|
+
id,
|
|
1102
|
+
type,
|
|
1103
|
+
disabled,
|
|
1104
|
+
selector,
|
|
1105
|
+
cssPath: testId ? `[data-testid="${testId}"]` : id ? `#${id}` : null
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
const inputs = [];
|
|
1110
|
+
document.querySelectorAll('input:not([type="hidden"]):not([type="submit"]):not([type="button"]), textarea, select').forEach((el) => {
|
|
1111
|
+
const label = findLabel(el);
|
|
1112
|
+
const placeholder = attr(el, "placeholder");
|
|
1113
|
+
const name = attr(el, "name");
|
|
1114
|
+
const id = attr(el, "id");
|
|
1115
|
+
const type = attr(el, "type") || el.tagName.toLowerCase();
|
|
1116
|
+
const testId = attr(el, "data-testid");
|
|
1117
|
+
const required = el.hasAttribute("required");
|
|
1118
|
+
const selector = buildSelector(el, label);
|
|
1119
|
+
if (label || placeholder || name || testId || id) {
|
|
1120
|
+
inputs.push({
|
|
1121
|
+
tag: el.tagName.toLowerCase(),
|
|
1122
|
+
type,
|
|
1123
|
+
label,
|
|
1124
|
+
placeholder,
|
|
1125
|
+
name,
|
|
1126
|
+
id,
|
|
1127
|
+
testId,
|
|
1128
|
+
required,
|
|
1129
|
+
selector,
|
|
1130
|
+
cssPath: testId ? `[data-testid="${testId}"]` : id ? `#${id}` : name ? `[name="${name}"]` : null
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
const links = [];
|
|
1135
|
+
document.querySelectorAll("a[href]").forEach((el) => {
|
|
1136
|
+
const linkText = attr(el, "aria-label") || text(el);
|
|
1137
|
+
const href = attr(el, "href");
|
|
1138
|
+
const testId = attr(el, "data-testid");
|
|
1139
|
+
if (!linkText || href === "#") return;
|
|
1140
|
+
links.push({
|
|
1141
|
+
text: linkText,
|
|
1142
|
+
href,
|
|
1143
|
+
testId,
|
|
1144
|
+
external: Boolean(href?.startsWith("http") && !href.includes(window.location.hostname)),
|
|
1145
|
+
selector: testId ? `getByTestId(${jsString(testId)})` : `getByRole('link', { name: ${jsString(linkText)} })`
|
|
1146
|
+
});
|
|
1147
|
+
});
|
|
1148
|
+
const headings = [];
|
|
1149
|
+
document.querySelectorAll("h1, h2, h3").forEach((el) => {
|
|
1150
|
+
const headingText = text(el);
|
|
1151
|
+
if (headingText) {
|
|
1152
|
+
headings.push({
|
|
1153
|
+
level: el.tagName.toLowerCase(),
|
|
1154
|
+
text: headingText,
|
|
1155
|
+
selector: `getByRole('heading', { name: ${jsString(headingText)} })`
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
const landmarks = [];
|
|
1160
|
+
document.querySelectorAll("[role], main, nav, header, footer, aside, section[aria-label]").forEach((el) => {
|
|
1161
|
+
const role = attr(el, "role") || el.tagName.toLowerCase();
|
|
1162
|
+
const label = attr(el, "aria-label") || text(el)?.slice(0, 40);
|
|
1163
|
+
if (role && label) landmarks.push({ role, label });
|
|
1164
|
+
});
|
|
1165
|
+
const statusRegions = [];
|
|
1166
|
+
document.querySelectorAll('[role="alert"], [role="status"], [aria-live]').forEach((el) => {
|
|
1167
|
+
const role = attr(el, "role") || "live";
|
|
1168
|
+
statusRegions.push({
|
|
1169
|
+
role,
|
|
1170
|
+
ariaLive: attr(el, "aria-live"),
|
|
1171
|
+
text: text(el),
|
|
1172
|
+
selector: el.getAttribute("role") ? `getByRole(${jsString(role)})` : `locator('[aria-live]')`
|
|
1173
|
+
});
|
|
1174
|
+
});
|
|
1175
|
+
const forms = [];
|
|
1176
|
+
document.querySelectorAll("form").forEach((form, index) => {
|
|
1177
|
+
forms.push({
|
|
1178
|
+
id: attr(form, "id"),
|
|
1179
|
+
label: attr(form, "aria-label") || attr(form, "aria-labelledby"),
|
|
1180
|
+
action: attr(form, "action"),
|
|
1181
|
+
method: attr(form, "method") || "get",
|
|
1182
|
+
fieldCount: form.querySelectorAll("input, textarea, select").length,
|
|
1183
|
+
index
|
|
1184
|
+
});
|
|
1185
|
+
});
|
|
1186
|
+
return {
|
|
1187
|
+
buttons: buttons.slice(0, localMaxPerCategory),
|
|
1188
|
+
inputs: inputs.slice(0, localMaxPerCategory),
|
|
1189
|
+
links: links.slice(0, localMaxPerCategory),
|
|
504
1190
|
headings: headings.slice(0, 20),
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
1191
|
+
landmarks,
|
|
1192
|
+
statusRegions,
|
|
1193
|
+
forms,
|
|
1194
|
+
pageUrl: window.location.href,
|
|
1195
|
+
pageTitle: document.title
|
|
509
1196
|
};
|
|
510
1197
|
}
|
|
1198
|
+
function buildElementMap(domData) {
|
|
1199
|
+
const elements = [];
|
|
1200
|
+
for (const input2 of domData.inputs) {
|
|
1201
|
+
const displayName = input2.label || input2.placeholder || input2.name || input2.testId;
|
|
1202
|
+
if (!displayName) continue;
|
|
1203
|
+
const selector = input2.selector || (input2.testId ? `getByTestId(${JSON.stringify(input2.testId)})` : null) || (input2.label ? `getByLabel(${JSON.stringify(input2.label)})` : null) || (input2.id ? `locator(${JSON.stringify(`#${input2.id}`)})` : null) || (input2.name ? `locator(${JSON.stringify(`[name="${input2.name}"]`)})` : null) || `locator(${JSON.stringify(`${input2.tag}[placeholder="${input2.placeholder ?? ""}"]`)})`;
|
|
1204
|
+
const selectorAlt = [];
|
|
1205
|
+
if (input2.id && !selector.includes(`#${input2.id}`)) selectorAlt.push(`locator(${JSON.stringify(`#${input2.id}`)})`);
|
|
1206
|
+
if (input2.name && !selector.includes(input2.name)) selectorAlt.push(`locator(${JSON.stringify(`[name="${input2.name}"]`)})`);
|
|
1207
|
+
if (input2.testId && !selector.includes(input2.testId)) selectorAlt.push(`getByTestId(${JSON.stringify(input2.testId)})`);
|
|
1208
|
+
if (input2.placeholder && !selector.includes(input2.placeholder)) selectorAlt.push(`getByPlaceholder(${JSON.stringify(input2.placeholder)})`);
|
|
1209
|
+
if (input2.label && !selector.includes(input2.label)) selectorAlt.push(`getByLabel(${JSON.stringify(input2.label)})`);
|
|
1210
|
+
elements.push({
|
|
1211
|
+
category: "input",
|
|
1212
|
+
role: input2.type === "checkbox" ? "checkbox" : "textbox",
|
|
1213
|
+
name: displayName,
|
|
1214
|
+
selector,
|
|
1215
|
+
selectorAlt,
|
|
1216
|
+
purpose: input2.required ? `Required ${input2.type} field - "${displayName}"` : `${input2.type} field - "${displayName}"`,
|
|
1217
|
+
confidence: input2.testId || input2.label || input2.id ? "high" : input2.placeholder ? "medium" : "low",
|
|
1218
|
+
attributes: {
|
|
1219
|
+
type: input2.type,
|
|
1220
|
+
label: input2.label,
|
|
1221
|
+
placeholder: input2.placeholder,
|
|
1222
|
+
name: input2.name,
|
|
1223
|
+
id: input2.id,
|
|
1224
|
+
testId: input2.testId,
|
|
1225
|
+
required: input2.required,
|
|
1226
|
+
tag: input2.tag
|
|
1227
|
+
}
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
for (const button of domData.buttons) {
|
|
1231
|
+
const displayName = button.text || button.testId;
|
|
1232
|
+
if (!displayName) continue;
|
|
1233
|
+
const selector = button.selector || (button.testId ? `getByTestId(${JSON.stringify(button.testId)})` : null) || (button.text ? `getByRole('button', { name: ${JSON.stringify(button.text)} })` : null) || (button.id ? `locator(${JSON.stringify(`#${button.id}`)})` : null) || `locator('button')`;
|
|
1234
|
+
const selectorAlt = [];
|
|
1235
|
+
if (button.id && !selector.includes(button.id)) selectorAlt.push(`locator(${JSON.stringify(`#${button.id}`)})`);
|
|
1236
|
+
if (button.testId && !selector.includes(button.testId)) selectorAlt.push(`getByTestId(${JSON.stringify(button.testId)})`);
|
|
1237
|
+
if (button.text && !selector.includes(button.text)) selectorAlt.push(`getByText(${JSON.stringify(button.text)})`);
|
|
1238
|
+
if (button.cssPath) selectorAlt.push(`locator(${JSON.stringify(button.cssPath)})`);
|
|
1239
|
+
elements.push({
|
|
1240
|
+
category: "button",
|
|
1241
|
+
role: "button",
|
|
1242
|
+
name: displayName,
|
|
1243
|
+
selector,
|
|
1244
|
+
selectorAlt,
|
|
1245
|
+
purpose: button.disabled ? `Disabled button - "${displayName}"` : button.type === "submit" ? `Form submit button - "${displayName}"` : `Button - "${displayName}"`,
|
|
1246
|
+
confidence: button.testId || button.text ? "high" : button.id ? "medium" : "low",
|
|
1247
|
+
attributes: {
|
|
1248
|
+
text: button.text,
|
|
1249
|
+
testId: button.testId,
|
|
1250
|
+
id: button.id,
|
|
1251
|
+
type: button.type,
|
|
1252
|
+
disabled: button.disabled,
|
|
1253
|
+
tag: button.tag
|
|
1254
|
+
}
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
for (const link of domData.links) {
|
|
1258
|
+
elements.push({
|
|
1259
|
+
category: "link",
|
|
1260
|
+
role: "link",
|
|
1261
|
+
name: link.text,
|
|
1262
|
+
selector: link.selector,
|
|
1263
|
+
selectorAlt: link.testId ? [`getByTestId(${JSON.stringify(link.testId)})`] : [],
|
|
1264
|
+
purpose: link.external ? `External link to "${link.href}" - "${link.text}"` : `Internal navigation link - "${link.text}" -> ${link.href}`,
|
|
1265
|
+
confidence: link.testId ? "high" : "medium",
|
|
1266
|
+
attributes: {
|
|
1267
|
+
text: link.text,
|
|
1268
|
+
href: link.href,
|
|
1269
|
+
testId: link.testId,
|
|
1270
|
+
external: link.external
|
|
1271
|
+
}
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
for (const heading of domData.headings) {
|
|
1275
|
+
elements.push({
|
|
1276
|
+
category: "heading",
|
|
1277
|
+
role: "heading",
|
|
1278
|
+
name: heading.text,
|
|
1279
|
+
selector: heading.selector,
|
|
1280
|
+
selectorAlt: [`getByText(${JSON.stringify(heading.text)})`],
|
|
1281
|
+
purpose: `Page ${heading.level} heading - use to assert the correct page or section loaded`,
|
|
1282
|
+
confidence: "medium",
|
|
1283
|
+
attributes: {
|
|
1284
|
+
level: heading.level,
|
|
1285
|
+
text: heading.text
|
|
1286
|
+
}
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
for (const status of domData.statusRegions) {
|
|
1290
|
+
elements.push({
|
|
1291
|
+
category: "status",
|
|
1292
|
+
role: status.role,
|
|
1293
|
+
name: status.text || status.role,
|
|
1294
|
+
selector: status.selector,
|
|
1295
|
+
selectorAlt: [],
|
|
1296
|
+
purpose: "Live region - use to assert error messages, success toasts, and validation feedback",
|
|
1297
|
+
confidence: "medium",
|
|
1298
|
+
attributes: {
|
|
1299
|
+
role: status.role,
|
|
1300
|
+
ariaLive: status.ariaLive,
|
|
1301
|
+
currentText: status.text
|
|
1302
|
+
}
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
return elements;
|
|
1306
|
+
}
|
|
1307
|
+
function buildWarnings(elements, domData, a11ySnapshot) {
|
|
1308
|
+
const warnings = [];
|
|
1309
|
+
if (!a11ySnapshot) {
|
|
1310
|
+
warnings.push("Playwright accessibility snapshot was unavailable. Capture continued using DOM extraction only.");
|
|
1311
|
+
}
|
|
1312
|
+
const lowConfidenceInputs = elements.filter((element) => element.category === "input" && element.confidence === "low");
|
|
1313
|
+
if (lowConfidenceInputs.length > 0) {
|
|
1314
|
+
warnings.push(
|
|
1315
|
+
`${lowConfidenceInputs.length} input(s) have low-confidence selectors. Consider adding data-testid attributes to: ${lowConfidenceInputs.map((element) => element.name).filter(Boolean).join(", ")}`
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
if (domData.statusRegions.length === 0) {
|
|
1319
|
+
warnings.push("No ARIA alert or status regions detected. Error message assertions may need manual selector adjustments after generation.");
|
|
1320
|
+
}
|
|
1321
|
+
for (const form of domData.forms) {
|
|
1322
|
+
const hasSubmit = domData.buttons.some((button) => button.type === "submit");
|
|
1323
|
+
if (form.fieldCount > 0 && !hasSubmit) {
|
|
1324
|
+
warnings.push(
|
|
1325
|
+
`Form ${form.id || `#${form.index}`} has ${form.fieldCount} field(s) but no detected submit button. It may use keyboard submit or a custom handler.`
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
if (domData.links.length >= MAX_PER_CATEGORY) {
|
|
1330
|
+
warnings.push(`Link count hit the ${MAX_PER_CATEGORY} capture limit. Generation will focus on forms and buttons.`);
|
|
1331
|
+
}
|
|
1332
|
+
const interactive = elements.filter((element) => element.category === "button" || element.category === "input" || element.category === "link");
|
|
1333
|
+
if (interactive.length === 0) {
|
|
1334
|
+
warnings.push("No interactive elements detected. The page may require authentication, render later than the current settle window, or be mostly static content.");
|
|
1335
|
+
}
|
|
1336
|
+
return warnings;
|
|
1337
|
+
}
|
|
1338
|
+
function log2(verbose, message) {
|
|
1339
|
+
if (verbose) console.log(message);
|
|
1340
|
+
}
|
|
1341
|
+
async function getAccessibilitySnapshot(page, verbose) {
|
|
1342
|
+
if (!page.accessibility || typeof page.accessibility.snapshot !== "function") {
|
|
1343
|
+
log2(verbose, " -> Accessibility snapshot API unavailable; continuing with DOM-only capture");
|
|
1344
|
+
return null;
|
|
1345
|
+
}
|
|
1346
|
+
try {
|
|
1347
|
+
return await page.accessibility.snapshot({ interestingOnly: false });
|
|
1348
|
+
} catch (error) {
|
|
1349
|
+
log2(verbose, ` -> Accessibility snapshot failed (${error?.message ?? error}); continuing with DOM-only capture`);
|
|
1350
|
+
return null;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
function asString(value) {
|
|
1354
|
+
return typeof value === "string" ? value : void 0;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// src/core/report.ts
|
|
1358
|
+
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
1359
|
+
import { join as join3 } from "path";
|
|
1360
|
+
function printCaptureReport(elementMap, analysis) {
|
|
1361
|
+
console.log("");
|
|
1362
|
+
console.log("=".repeat(60));
|
|
1363
|
+
console.log("CementicTest Capture Report");
|
|
1364
|
+
console.log("=".repeat(60));
|
|
1365
|
+
console.log(`URL: ${elementMap.url}`);
|
|
1366
|
+
console.log(`Title: ${elementMap.title}`);
|
|
1367
|
+
console.log(`Captured: ${elementMap.timestamp} (${elementMap.mode})`);
|
|
1368
|
+
console.log("");
|
|
1369
|
+
console.log("Elements:");
|
|
1370
|
+
for (const [category, count] of Object.entries(elementMap.summary.byCategory)) {
|
|
1371
|
+
console.log(` ${category}: ${count}`);
|
|
1372
|
+
}
|
|
1373
|
+
if (elementMap.warnings.length > 0) {
|
|
1374
|
+
console.log("");
|
|
1375
|
+
console.log("Warnings:");
|
|
1376
|
+
for (const warning of elementMap.warnings) {
|
|
1377
|
+
console.log(` - ${warning}`);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
if (analysis) {
|
|
1381
|
+
console.log("");
|
|
1382
|
+
console.log(`AI scenarios: ${analysis.scenarios.length}`);
|
|
1383
|
+
console.log(`Feature: ${analysis.feature}`);
|
|
1384
|
+
console.log(`Prefix: ${analysis.suggestedPrefix}`);
|
|
1385
|
+
}
|
|
1386
|
+
console.log("");
|
|
1387
|
+
}
|
|
1388
|
+
function saveCaptureJson(elementMap, analysis, outputDir = ".cementic/capture") {
|
|
1389
|
+
mkdirSync3(outputDir, { recursive: true });
|
|
1390
|
+
const fileName = `capture-${slugify(elementMap.url)}-${Date.now()}.json`;
|
|
1391
|
+
const filePath = join3(outputDir, fileName);
|
|
1392
|
+
writeFileSync3(filePath, JSON.stringify({
|
|
1393
|
+
_meta: {
|
|
1394
|
+
version: "0.2.5",
|
|
1395
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1396
|
+
tool: "@cementic/cementic-test"
|
|
1397
|
+
},
|
|
1398
|
+
elementMap,
|
|
1399
|
+
analysis: analysis ?? null
|
|
1400
|
+
}, null, 2));
|
|
1401
|
+
return filePath;
|
|
1402
|
+
}
|
|
1403
|
+
function buildCasesMarkdown(analysis) {
|
|
1404
|
+
const lines = [];
|
|
1405
|
+
for (const scenario of analysis.scenarios) {
|
|
1406
|
+
lines.push(`# ${scenario.id} \u2014 ${scenario.title} ${scenario.tags.map(normalizeTag2).join(" ")}`.trim());
|
|
1407
|
+
lines.push(`<!-- ct:url ${analysis.url} -->`);
|
|
1408
|
+
lines.push(`<!-- ct:feature ${analysis.feature} -->`);
|
|
1409
|
+
lines.push(`<!-- ct:generated-by capture -->`);
|
|
1410
|
+
lines.push(`<!-- narrator: ${sanitizeComment(scenario.narrator)} -->`);
|
|
1411
|
+
lines.push(`<!-- code-level: ${scenario.codeLevel} -->`);
|
|
1412
|
+
lines.push("");
|
|
1413
|
+
lines.push("## Steps");
|
|
1414
|
+
scenario.steps.forEach((step, index) => {
|
|
1415
|
+
const hint = step.selector && step.selector !== "page" ? ` <!-- selector: ${step.selector} -->` : "";
|
|
1416
|
+
lines.push(`${index + 1}. ${step.human}${hint}`);
|
|
1417
|
+
});
|
|
1418
|
+
lines.push("");
|
|
1419
|
+
lines.push("## Expected Results");
|
|
1420
|
+
scenario.assertions.forEach((assertion) => {
|
|
1421
|
+
const hint = assertion.playwright ? ` <!-- playwright: ${sanitizeComment(assertion.playwright)} -->` : "";
|
|
1422
|
+
lines.push(`- ${assertion.human}${hint}`);
|
|
1423
|
+
});
|
|
1424
|
+
lines.push("");
|
|
1425
|
+
lines.push("---");
|
|
1426
|
+
lines.push("");
|
|
1427
|
+
}
|
|
1428
|
+
return lines.join("\n");
|
|
1429
|
+
}
|
|
1430
|
+
function saveCasesMarkdown(analysis, outputDir = "cases", fileName) {
|
|
1431
|
+
mkdirSync3(outputDir, { recursive: true });
|
|
1432
|
+
const resolvedFileName = fileName ?? `${slugify(analysis.feature || analysis.url)}.md`;
|
|
1433
|
+
const filePath = join3(outputDir, resolvedFileName);
|
|
1434
|
+
writeFileSync3(filePath, buildCasesMarkdown(analysis));
|
|
1435
|
+
return filePath;
|
|
1436
|
+
}
|
|
1437
|
+
function saveSpecPreview(analysis, outputDir = "tests/preview") {
|
|
1438
|
+
if (analysis.scenarios.length === 0) return null;
|
|
1439
|
+
mkdirSync3(outputDir, { recursive: true });
|
|
1440
|
+
const fileName = `spec-preview-${slugify(analysis.url)}-${Date.now()}.spec.cjs`;
|
|
1441
|
+
const filePath = join3(outputDir, fileName);
|
|
1442
|
+
const lines = [];
|
|
1443
|
+
lines.push("/**");
|
|
1444
|
+
lines.push(" * CementicTest Capture Preview");
|
|
1445
|
+
lines.push(` * Generated from: ${analysis.url}`);
|
|
1446
|
+
lines.push(` * Feature: ${analysis.feature}`);
|
|
1447
|
+
lines.push(" */");
|
|
1448
|
+
lines.push("");
|
|
1449
|
+
lines.push(`const { test, expect } = require('@playwright/test');`);
|
|
1450
|
+
lines.push("");
|
|
1451
|
+
for (const scenario of analysis.scenarios) {
|
|
1452
|
+
lines.push(`test(${JSON.stringify(`${scenario.id} \u2014 ${scenario.title}`)}, async ({ page }) => {`);
|
|
1453
|
+
for (const step of scenario.steps) {
|
|
1454
|
+
if (step.action === "navigate") {
|
|
1455
|
+
lines.push(` await page.goto(${JSON.stringify(step.value)});`);
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
if (step.selector === "page") continue;
|
|
1459
|
+
const selector = `page.${step.selector}`;
|
|
1460
|
+
if (step.action === "fill") lines.push(` await ${selector}.fill(${JSON.stringify(step.value)});`);
|
|
1461
|
+
if (step.action === "click") lines.push(` await ${selector}.click();`);
|
|
1462
|
+
if (step.action === "select") lines.push(` await ${selector}.selectOption(${JSON.stringify(step.value)});`);
|
|
1463
|
+
if (step.action === "check") lines.push(` await ${selector}.check();`);
|
|
1464
|
+
if (step.action === "keyboard") lines.push(` await page.keyboard.press(${JSON.stringify(step.value)});`);
|
|
1465
|
+
if (step.action === "hover") lines.push(` await ${selector}.hover();`);
|
|
1466
|
+
}
|
|
1467
|
+
for (const assertion of scenario.assertions) {
|
|
1468
|
+
lines.push(` ${ensureStatement(assertion.playwright)}`);
|
|
1469
|
+
}
|
|
1470
|
+
lines.push("});");
|
|
1471
|
+
lines.push("");
|
|
1472
|
+
}
|
|
1473
|
+
writeFileSync3(filePath, lines.join("\n"));
|
|
1474
|
+
return filePath;
|
|
1475
|
+
}
|
|
1476
|
+
function slugify(value) {
|
|
1477
|
+
return (value || "capture").replace(/^https?:\/\//, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase().slice(0, 60);
|
|
1478
|
+
}
|
|
1479
|
+
function normalizeTag2(value) {
|
|
1480
|
+
const cleaned = String(value ?? "").trim();
|
|
1481
|
+
if (!cleaned) return "@ui";
|
|
1482
|
+
return cleaned.startsWith("@") ? cleaned : `@${cleaned}`;
|
|
1483
|
+
}
|
|
1484
|
+
function sanitizeComment(value) {
|
|
1485
|
+
return String(value ?? "").replace(/-->/g, "-- >");
|
|
1486
|
+
}
|
|
1487
|
+
function ensureStatement(value) {
|
|
1488
|
+
const trimmed = String(value ?? "").trim();
|
|
1489
|
+
if (!trimmed) return "// TODO: add assertion";
|
|
1490
|
+
return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
|
|
1491
|
+
}
|
|
511
1492
|
|
|
512
1493
|
// src/commands/tc.ts
|
|
513
|
-
function
|
|
1494
|
+
function slugify2(text) {
|
|
514
1495
|
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "tc";
|
|
515
1496
|
}
|
|
1497
|
+
function injectUrlMetadata(markdown, url) {
|
|
1498
|
+
if (!url || /<!--\s*ct:url\s+/i.test(markdown)) return markdown;
|
|
1499
|
+
const lines = markdown.split("\n");
|
|
1500
|
+
const output2 = [];
|
|
1501
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1502
|
+
output2.push(lines[i]);
|
|
1503
|
+
if (/^\s*#\s+/.test(lines[i])) {
|
|
1504
|
+
output2.push(`<!-- ct:url ${url} -->`);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
return output2.join("\n");
|
|
1508
|
+
}
|
|
516
1509
|
function buildManualCasesMarkdown(opts) {
|
|
517
1510
|
const { prefix, feature, url, numCases } = opts;
|
|
518
1511
|
const startIndex = opts.startIndex ?? 1;
|
|
@@ -520,9 +1513,8 @@ function buildManualCasesMarkdown(opts) {
|
|
|
520
1513
|
for (let i = 0; i < numCases; i++) {
|
|
521
1514
|
const idx = startIndex + i;
|
|
522
1515
|
const id = `${prefix}-${String(idx).padStart(3, "0")}`;
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
lines.push(`# ${id} \u2014 ${title} ${tags}`);
|
|
1516
|
+
lines.push(`# ${id} \u2014 ${feature} - scenario ${idx} @regression @ui`);
|
|
1517
|
+
if (url) lines.push(`<!-- ct:url ${url} -->`);
|
|
526
1518
|
lines.push(`## Steps`);
|
|
527
1519
|
lines.push(`1. Navigate to ${url ?? "<PAGE_URL>"}`);
|
|
528
1520
|
lines.push(`2. Perform the main user action for this scenario`);
|
|
@@ -538,12 +1530,10 @@ function buildManualCasesMarkdown(opts) {
|
|
|
538
1530
|
}
|
|
539
1531
|
async function promptBasicQuestions(opts) {
|
|
540
1532
|
if (opts.feature) {
|
|
541
|
-
|
|
542
|
-
if (n < 1) n = 3;
|
|
543
|
-
if (n > 10) n = 10;
|
|
1533
|
+
const n = Math.min(Math.max(opts.numCases ?? 3, 1), 10);
|
|
544
1534
|
return {
|
|
545
1535
|
feature: opts.feature,
|
|
546
|
-
appDescription: opts.appDescription
|
|
1536
|
+
appDescription: opts.appDescription ?? "",
|
|
547
1537
|
numCases: n,
|
|
548
1538
|
url: opts.url
|
|
549
1539
|
};
|
|
@@ -551,16 +1541,13 @@ async function promptBasicQuestions(opts) {
|
|
|
551
1541
|
const rl = createInterface({ input, output });
|
|
552
1542
|
const feature = (await rl.question("\u{1F9E9} Feature or page to test: ")).trim();
|
|
553
1543
|
const appDescription = (await rl.question("\u{1F4DD} Short app description (optional): ")).trim();
|
|
554
|
-
const numCasesRaw = (await rl.question("\u{1F522} How many test cases? (1
|
|
1544
|
+
const numCasesRaw = (await rl.question("\u{1F522} How many test cases? (1\u201310) [3]: ")).trim();
|
|
555
1545
|
rl.close();
|
|
556
1546
|
let numCases = parseInt(numCasesRaw, 10);
|
|
557
1547
|
if (isNaN(numCases) || numCases < 1) numCases = 3;
|
|
558
1548
|
if (numCases > 10) numCases = 10;
|
|
559
1549
|
return { feature, appDescription, numCases, url: opts.url };
|
|
560
1550
|
}
|
|
561
|
-
function hasAiFlagInArgv() {
|
|
562
|
-
return process.argv.includes("--ai");
|
|
563
|
-
}
|
|
564
1551
|
async function runTcInteractive(params) {
|
|
565
1552
|
const { feature, appDescription, numCases, url } = await promptBasicQuestions({
|
|
566
1553
|
url: params.url,
|
|
@@ -573,87 +1560,130 @@ async function runTcInteractive(params) {
|
|
|
573
1560
|
url,
|
|
574
1561
|
explicitPrefix: params.explicitPrefix
|
|
575
1562
|
});
|
|
576
|
-
|
|
577
|
-
const fileName = `${prefix.toLowerCase()}-${
|
|
578
|
-
const fullPath =
|
|
579
|
-
const useAi = hasAiFlagInArgv();
|
|
580
|
-
console.log(`\u2699\uFE0F Debug: useAi=${useAi}, argv=${JSON.stringify(process.argv)}`);
|
|
1563
|
+
mkdirSync4("cases", { recursive: true });
|
|
1564
|
+
const fileName = `${prefix.toLowerCase()}-${slugify2(feature)}.md`;
|
|
1565
|
+
const fullPath = join4("cases", fileName);
|
|
581
1566
|
let markdown;
|
|
582
|
-
if (useAi) {
|
|
583
|
-
let
|
|
1567
|
+
if (params.useAi) {
|
|
1568
|
+
let pageSummary = void 0;
|
|
584
1569
|
if (url) {
|
|
585
1570
|
try {
|
|
586
|
-
console.log(`\u{1F50D}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
);
|
|
1571
|
+
console.log(`\u{1F50D} Capturing page: ${url}`);
|
|
1572
|
+
const elementMap = await captureElements(url, {
|
|
1573
|
+
headless: !(params.headed ?? false),
|
|
1574
|
+
verbose: true
|
|
1575
|
+
});
|
|
1576
|
+
printCaptureReport(elementMap);
|
|
1577
|
+
const jsonPath = saveCaptureJson(elementMap);
|
|
1578
|
+
console.log(`\u{1F4C4} Saved capture JSON \u2192 ${jsonPath}`);
|
|
1579
|
+
if (params.captureOnly) {
|
|
1580
|
+
console.log("Capture-only mode requested. No test cases were generated.");
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
const analysis = await analyseElements(elementMap, { verbose: true });
|
|
1584
|
+
printCaptureReport(elementMap, analysis);
|
|
1585
|
+
const previewPath = saveSpecPreview(analysis);
|
|
1586
|
+
if (previewPath) {
|
|
1587
|
+
console.log(`\u{1F9EA} Saved preview spec \u2192 ${previewPath}`);
|
|
1588
|
+
}
|
|
1589
|
+
const captureFileName = `${prefix.toLowerCase()}-${slugify2(feature)}.md`;
|
|
1590
|
+
const generatedPath = saveCasesMarkdown(analysis, "cases", captureFileName);
|
|
1591
|
+
console.log(`\u2705 Capture-based AI generated test cases \u2192 ${generatedPath}`);
|
|
1592
|
+
console.log("\nNext steps:");
|
|
1593
|
+
console.log(` ct normalize ${generatedPath} --and-gen --lang ts`);
|
|
1594
|
+
console.log(" ct test");
|
|
1595
|
+
return;
|
|
591
1596
|
} catch (e) {
|
|
592
|
-
console.warn(
|
|
593
|
-
|
|
594
|
-
|
|
1597
|
+
console.warn(`\u26A0\uFE0F Capture flow failed (${e?.message ?? e}). Falling back to the legacy AI case writer.`);
|
|
1598
|
+
if (params.captureOnly) {
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
595
1601
|
}
|
|
596
1602
|
}
|
|
597
1603
|
try {
|
|
598
|
-
|
|
1604
|
+
if (pageSummary === void 0 && url) {
|
|
1605
|
+
const elementMap = await captureElements(url, {
|
|
1606
|
+
headless: true,
|
|
1607
|
+
verbose: false
|
|
1608
|
+
});
|
|
1609
|
+
pageSummary = toPageSummary(elementMap);
|
|
1610
|
+
}
|
|
599
1611
|
markdown = await generateTcMarkdownWithAi({
|
|
600
1612
|
appDescription: appDescription || void 0,
|
|
601
1613
|
feature,
|
|
602
1614
|
url,
|
|
603
|
-
|
|
1615
|
+
pageSummary,
|
|
604
1616
|
prefix,
|
|
605
1617
|
startIndex: 1,
|
|
606
1618
|
numCases
|
|
607
1619
|
});
|
|
608
|
-
|
|
1620
|
+
markdown = injectUrlMetadata(markdown, url);
|
|
1621
|
+
console.log("\u2705 AI generated test cases.");
|
|
609
1622
|
} catch (err) {
|
|
610
|
-
console.warn(
|
|
611
|
-
|
|
612
|
-
);
|
|
613
|
-
markdown = buildManualCasesMarkdown({
|
|
614
|
-
prefix,
|
|
615
|
-
feature,
|
|
616
|
-
url,
|
|
617
|
-
numCases,
|
|
618
|
-
startIndex: 1
|
|
619
|
-
});
|
|
620
|
-
console.log("\u{1F4DD} Manual: generated test case templates instead.");
|
|
1623
|
+
console.warn(`\u26A0\uFE0F AI generation failed (${err?.message ?? err}). Falling back to manual templates.`);
|
|
1624
|
+
markdown = buildManualCasesMarkdown({ prefix, feature, url, numCases, startIndex: 1 });
|
|
1625
|
+
console.log("\u{1F4DD} Generated manual test case templates instead.");
|
|
621
1626
|
}
|
|
622
1627
|
} else {
|
|
623
|
-
markdown = buildManualCasesMarkdown({
|
|
624
|
-
|
|
625
|
-
feature,
|
|
626
|
-
url,
|
|
627
|
-
numCases,
|
|
628
|
-
startIndex: 1
|
|
629
|
-
});
|
|
630
|
-
console.log("\u{1F4DD} Manual: generated test case templates (no --ai).");
|
|
1628
|
+
markdown = buildManualCasesMarkdown({ prefix, feature, url, numCases, startIndex: 1 });
|
|
1629
|
+
console.log("\u{1F4DD} Generated manual test case templates (pass --ai to use AI).");
|
|
631
1630
|
}
|
|
632
|
-
|
|
633
|
-
console.log(
|
|
634
|
-
|
|
1631
|
+
writeFileSync4(fullPath, markdown);
|
|
1632
|
+
console.log(`
|
|
1633
|
+
\u270D\uFE0F Wrote ${numCases} case(s) \u2192 ${fullPath}`);
|
|
1634
|
+
console.log("\nNext steps:");
|
|
635
1635
|
console.log(" ct normalize ./cases --and-gen --lang ts");
|
|
636
1636
|
console.log(" ct test");
|
|
637
1637
|
}
|
|
1638
|
+
function addSharedOptions(cmd) {
|
|
1639
|
+
return cmd.option("--ai", "Use AI to generate test cases (requires API key)").option("--prefix <prefix>", "Explicit ID prefix, e.g. AUTH, DASH, CART").option("--feature <name>", "Feature name \u2014 skips interactive prompt").option("--desc <text>", "App description \u2014 skips interactive prompt").option("--count <n>", "Number of cases (1\u201310)", parseInt);
|
|
1640
|
+
}
|
|
1641
|
+
function resolveCommanderOpts(opts) {
|
|
1642
|
+
if (typeof opts.optsWithGlobals === "function") {
|
|
1643
|
+
return opts.optsWithGlobals();
|
|
1644
|
+
}
|
|
1645
|
+
if (typeof opts.opts === "function") {
|
|
1646
|
+
return opts.opts();
|
|
1647
|
+
}
|
|
1648
|
+
return opts;
|
|
1649
|
+
}
|
|
638
1650
|
function tcCmd() {
|
|
639
|
-
const root = new Command4("tc").description("Create
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
1651
|
+
const root = new Command4("tc").description("Create CementicTest case files (Markdown) under ./cases").addHelpText("after", `
|
|
1652
|
+
Examples:
|
|
1653
|
+
$ ct tc --feature "Login form" --ai
|
|
1654
|
+
$ ct tc --feature "Checkout" --count 5 --prefix CHK
|
|
1655
|
+
$ ct tc url https://example.com/login --ai --feature "Auth"
|
|
1656
|
+
`);
|
|
1657
|
+
addSharedOptions(root).action(
|
|
1658
|
+
async (opts) => {
|
|
1659
|
+
const resolvedOpts = resolveCommanderOpts(opts);
|
|
1660
|
+
await runTcInteractive({
|
|
1661
|
+
url: void 0,
|
|
1662
|
+
explicitPrefix: resolvedOpts.prefix,
|
|
1663
|
+
feature: resolvedOpts.feature,
|
|
1664
|
+
appDescription: resolvedOpts.desc,
|
|
1665
|
+
numCases: resolvedOpts.count,
|
|
1666
|
+
useAi: resolvedOpts.ai ?? false
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
);
|
|
1670
|
+
addSharedOptions(
|
|
1671
|
+
root.command("url").argument("<url>", "Page URL to use as live capture and AI context").description("Generate test cases with capture-aware context from a specific live page").option("--headed", "Show the browser while capturing the page").option("--capture-only", "Run live page capture and save artifacts without generating cases")
|
|
1672
|
+
).action(
|
|
1673
|
+
async (url, opts, command) => {
|
|
1674
|
+
const resolvedOpts = resolveCommanderOpts(command ?? opts);
|
|
1675
|
+
await runTcInteractive({
|
|
1676
|
+
url,
|
|
1677
|
+
explicitPrefix: resolvedOpts.prefix,
|
|
1678
|
+
feature: resolvedOpts.feature,
|
|
1679
|
+
appDescription: resolvedOpts.desc,
|
|
1680
|
+
numCases: resolvedOpts.count,
|
|
1681
|
+
useAi: resolvedOpts.ai ?? false,
|
|
1682
|
+
headed: resolvedOpts.headed ?? false,
|
|
1683
|
+
captureOnly: resolvedOpts.captureOnly ?? false
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
);
|
|
657
1687
|
return root;
|
|
658
1688
|
}
|
|
659
1689
|
|
|
@@ -682,11 +1712,11 @@ function reportCmd() {
|
|
|
682
1712
|
import { Command as Command6 } from "commander";
|
|
683
1713
|
import { spawn as spawn3 } from "child_process";
|
|
684
1714
|
import { existsSync as existsSync2 } from "fs";
|
|
685
|
-
import { join as
|
|
1715
|
+
import { join as join5 } from "path";
|
|
686
1716
|
function serveCmd() {
|
|
687
1717
|
const cmd = new Command6("serve").description("Serve the Allure report").action(() => {
|
|
688
1718
|
console.log("\u{1F4CA} Serving Allure report...");
|
|
689
|
-
const localAllureBin =
|
|
1719
|
+
const localAllureBin = join5(process.cwd(), "node_modules", "allure-commandline", "bin", "allure");
|
|
690
1720
|
let executable = "npx";
|
|
691
1721
|
let args = ["allure", "serve", "./allure-results"];
|
|
692
1722
|
if (existsSync2(localAllureBin)) {
|
|
@@ -754,8 +1784,8 @@ function flowCmd() {
|
|
|
754
1784
|
|
|
755
1785
|
// src/commands/ci.ts
|
|
756
1786
|
import { Command as Command8 } from "commander";
|
|
757
|
-
import { mkdirSync as
|
|
758
|
-
import { join as
|
|
1787
|
+
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5, existsSync as existsSync3 } from "fs";
|
|
1788
|
+
import { join as join6 } from "path";
|
|
759
1789
|
var WORKFLOW_CONTENT = `name: Playwright Tests
|
|
760
1790
|
on:
|
|
761
1791
|
push:
|
|
@@ -786,19 +1816,19 @@ jobs:
|
|
|
786
1816
|
`;
|
|
787
1817
|
function ciCmd() {
|
|
788
1818
|
const cmd = new Command8("ci").description("Generate GitHub Actions workflow for CI").action(() => {
|
|
789
|
-
const githubDir =
|
|
790
|
-
const workflowsDir =
|
|
791
|
-
const workflowFile =
|
|
1819
|
+
const githubDir = join6(process.cwd(), ".github");
|
|
1820
|
+
const workflowsDir = join6(githubDir, "workflows");
|
|
1821
|
+
const workflowFile = join6(workflowsDir, "cementic.yml");
|
|
792
1822
|
console.log("\u{1F916} Setting up CI/CD workflow...");
|
|
793
1823
|
if (!existsSync3(workflowsDir)) {
|
|
794
|
-
|
|
1824
|
+
mkdirSync5(workflowsDir, { recursive: true });
|
|
795
1825
|
console.log(`Created directory: ${workflowsDir}`);
|
|
796
1826
|
}
|
|
797
1827
|
if (existsSync3(workflowFile)) {
|
|
798
1828
|
console.warn(`\u26A0\uFE0F Workflow file already exists at ${workflowFile}. Skipping.`);
|
|
799
1829
|
return;
|
|
800
1830
|
}
|
|
801
|
-
|
|
1831
|
+
writeFileSync5(workflowFile, WORKFLOW_CONTENT.trim() + "\n");
|
|
802
1832
|
console.log(`\u2705 CI workflow generated at: ${workflowFile}`);
|
|
803
1833
|
console.log("Next steps:");
|
|
804
1834
|
console.log("1. Commit and push the new file");
|
|
@@ -808,8 +1838,10 @@ function ciCmd() {
|
|
|
808
1838
|
}
|
|
809
1839
|
|
|
810
1840
|
// src/cli.ts
|
|
1841
|
+
var require2 = createRequire(import.meta.url);
|
|
1842
|
+
var { version } = require2("../package.json");
|
|
811
1843
|
var program = new Command9();
|
|
812
|
-
program.name("
|
|
1844
|
+
program.name("ct").description("CementicTest CLI: cases \u2192 normalized \u2192 POM tests \u2192 Playwright").version(version);
|
|
813
1845
|
program.addCommand(newCmd());
|
|
814
1846
|
program.addCommand(normalizeCmd());
|
|
815
1847
|
program.addCommand(genCmd());
|