@cementic/cementic-test 0.2.4 → 0.2.6
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/CODE_OF_CONDUCT.md +46 -0
- package/CONTRIBUTING.md +61 -0
- package/README.md +69 -25
- package/dist/{chunk-3EE7LWWT.js → chunk-5QRDTCSM.js} +20 -5
- package/dist/chunk-5QRDTCSM.js.map +1 -0
- package/dist/cli.js +991 -132
- package/dist/cli.js.map +1 -1
- package/dist/{gen-IO4KKGYY.js → gen-RMRQOAD3.js} +2 -2
- package/package.json +7 -3
- package/scripts/postinstall-banner.cjs +14 -0
- package/dist/chunk-3EE7LWWT.js.map +0 -1
- /package/dist/{gen-IO4KKGYY.js.map → gen-RMRQOAD3.js.map} +0 -0
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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";
|
|
@@ -182,21 +182,25 @@ function extractSections(body) {
|
|
|
182
182
|
const stepsBlock = sections["steps"] ?? "";
|
|
183
183
|
const expectedBlock = sections["expected"] ?? sections["expected results"] ?? sections["then"] ?? "";
|
|
184
184
|
const bullet = /^\s*(?:\d+\.|[-*])\s+(.+)$/gm;
|
|
185
|
-
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 }));
|
|
186
188
|
if (steps.length === 0) {
|
|
187
|
-
const alt = Array.from(body.matchAll(bullet)).map((m) => m[1].trim());
|
|
188
|
-
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 })));
|
|
189
192
|
}
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
);
|
|
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 }));
|
|
193
196
|
if (expectedLines.length === 0) {
|
|
194
197
|
const exp = Array.from(
|
|
195
198
|
body.matchAll(/^\s*(?:Expected|Then|Verify|Assert)[^\n]*:?[\s-]*(.+)$/gim)
|
|
196
|
-
).map((m) => m[1].trim());
|
|
197
|
-
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 })));
|
|
198
202
|
}
|
|
199
|
-
return { steps, expected: expectedLines };
|
|
203
|
+
return { steps, stepHints, expected: expectedLines, assertionHints };
|
|
200
204
|
}
|
|
201
205
|
function extractUrlMetadata(body) {
|
|
202
206
|
const match = body.match(/<!--\s*ct:url\s+(https?:\/\/[^\s>]+)\s*-->/i);
|
|
@@ -205,6 +209,14 @@ function extractUrlMetadata(body) {
|
|
|
205
209
|
function stripCtMetadata(body) {
|
|
206
210
|
return body.replace(/^\s*<!--\s*ct:url\s+https?:\/\/[^\s>]+\s*-->\s*\n?/im, "");
|
|
207
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
|
+
}
|
|
208
220
|
function extractUrlFromSteps(steps) {
|
|
209
221
|
for (const step of steps) {
|
|
210
222
|
const match = step.match(/https?:\/\/[^\s'"]+/i);
|
|
@@ -217,7 +229,7 @@ function normalizeOne(titleLine, body, source) {
|
|
|
217
229
|
const id = parseId(clean);
|
|
218
230
|
const metadataUrl = extractUrlMetadata(body);
|
|
219
231
|
const cleanBody = stripCtMetadata(body);
|
|
220
|
-
const { steps, expected } = extractSections(cleanBody);
|
|
232
|
+
const { steps, stepHints, expected, assertionHints } = extractSections(cleanBody);
|
|
221
233
|
const reviewReasons = [];
|
|
222
234
|
if (steps.length === 0) reviewReasons.push("No steps section or bullets were parsed");
|
|
223
235
|
if (expected.length === 0) reviewReasons.push("No expected results section or assertions were parsed");
|
|
@@ -226,7 +238,9 @@ function normalizeOne(titleLine, body, source) {
|
|
|
226
238
|
title: stripIdPrefixFromTitle(clean, id),
|
|
227
239
|
tags: tags.length ? tags : void 0,
|
|
228
240
|
steps,
|
|
241
|
+
step_hints: stepHints.some((hint) => hint.selector) ? stepHints : void 0,
|
|
229
242
|
expected,
|
|
243
|
+
assertion_hints: assertionHints.some((hint) => hint.playwright) ? assertionHints : void 0,
|
|
230
244
|
needs_review: reviewReasons.length > 0,
|
|
231
245
|
review_reasons: reviewReasons,
|
|
232
246
|
source,
|
|
@@ -294,7 +308,7 @@ Examples:
|
|
|
294
308
|
}
|
|
295
309
|
console.log(`\u2705 Normalized ${index.summary.parsed} case(s). Output \u2192 .cementic/normalized/`);
|
|
296
310
|
if (opts.andGen) {
|
|
297
|
-
const { gen } = await import("./gen-
|
|
311
|
+
const { gen } = await import("./gen-RMRQOAD3.js");
|
|
298
312
|
await gen({ lang: opts.lang || "ts", out: "tests/generated" });
|
|
299
313
|
}
|
|
300
314
|
});
|
|
@@ -324,8 +338,8 @@ function testCmd() {
|
|
|
324
338
|
|
|
325
339
|
// src/commands/tc.ts
|
|
326
340
|
import { Command as Command4 } from "commander";
|
|
327
|
-
import { mkdirSync as
|
|
328
|
-
import { join as
|
|
341
|
+
import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
342
|
+
import { join as join4 } from "path";
|
|
329
343
|
import { createInterface } from "readline/promises";
|
|
330
344
|
import { stdin as input, stdout as output } from "process";
|
|
331
345
|
|
|
@@ -547,124 +561,937 @@ async function generateTcMarkdownWithAi(ctx) {
|
|
|
547
561
|
return callOpenAi(apiKey, model, baseUrl, system, user);
|
|
548
562
|
}
|
|
549
563
|
|
|
550
|
-
// src/core/
|
|
551
|
-
async function
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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;
|
|
557
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
|
+
);
|
|
558
637
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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("");
|
|
565
709
|
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
+
})
|
|
569
742
|
});
|
|
570
|
-
|
|
743
|
+
if (!response.ok) {
|
|
744
|
+
throw new Error(`Anthropic API ${response.status}: ${await response.text()}`);
|
|
745
|
+
}
|
|
746
|
+
const json = await response.json();
|
|
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()}`);
|
|
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");
|
|
773
|
+
return content;
|
|
774
|
+
}
|
|
775
|
+
function parseAnalysisJson(raw) {
|
|
776
|
+
const stripped = raw.replace(/^```(?:json)?\s*/m, "").replace(/\s*```\s*$/m, "").trim();
|
|
571
777
|
try {
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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"
|
|
594
859
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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();`
|
|
598
868
|
}
|
|
599
|
-
|
|
600
|
-
|
|
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"
|
|
601
897
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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"
|
|
616
917
|
});
|
|
617
|
-
|
|
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
|
+
}
|
|
942
|
+
|
|
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;
|
|
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;
|
|
618
1002
|
} finally {
|
|
619
1003
|
await browser.close();
|
|
620
1004
|
}
|
|
621
1005
|
}
|
|
622
|
-
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
+
}));
|
|
1014
|
+
return {
|
|
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 {
|
|
640
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
|
+
});
|
|
641
1109
|
const inputs = [];
|
|
642
|
-
|
|
643
|
-
const
|
|
644
|
-
const
|
|
645
|
-
const
|
|
646
|
-
const
|
|
647
|
-
const type =
|
|
648
|
-
const testId =
|
|
649
|
-
const
|
|
650
|
-
|
|
651
|
-
|
|
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
|
+
});
|
|
652
1132
|
}
|
|
653
|
-
}
|
|
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
|
+
});
|
|
654
1186
|
return {
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
1187
|
+
buttons: buttons.slice(0, localMaxPerCategory),
|
|
1188
|
+
inputs: inputs.slice(0, localMaxPerCategory),
|
|
1189
|
+
links: links.slice(0, localMaxPerCategory),
|
|
1190
|
+
headings: headings.slice(0, 20),
|
|
1191
|
+
landmarks,
|
|
1192
|
+
statusRegions,
|
|
1193
|
+
forms,
|
|
1194
|
+
pageUrl: window.location.href,
|
|
1195
|
+
pageTitle: document.title
|
|
663
1196
|
};
|
|
664
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
|
+
}
|
|
665
1492
|
|
|
666
1493
|
// src/commands/tc.ts
|
|
667
|
-
function
|
|
1494
|
+
function slugify2(text) {
|
|
668
1495
|
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "tc";
|
|
669
1496
|
}
|
|
670
1497
|
function injectUrlMetadata(markdown, url) {
|
|
@@ -733,24 +1560,54 @@ async function runTcInteractive(params) {
|
|
|
733
1560
|
url,
|
|
734
1561
|
explicitPrefix: params.explicitPrefix
|
|
735
1562
|
});
|
|
736
|
-
|
|
737
|
-
const fileName = `${prefix.toLowerCase()}-${
|
|
738
|
-
const fullPath =
|
|
1563
|
+
mkdirSync4("cases", { recursive: true });
|
|
1564
|
+
const fileName = `${prefix.toLowerCase()}-${slugify2(feature)}.md`;
|
|
1565
|
+
const fullPath = join4("cases", fileName);
|
|
739
1566
|
let markdown;
|
|
740
1567
|
if (params.useAi) {
|
|
741
1568
|
let pageSummary = void 0;
|
|
742
1569
|
if (url) {
|
|
743
1570
|
try {
|
|
744
|
-
console.log(`\u{1F50D}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
);
|
|
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;
|
|
749
1596
|
} catch (e) {
|
|
750
|
-
console.warn(`\u26A0\uFE0F
|
|
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
|
+
}
|
|
751
1601
|
}
|
|
752
1602
|
}
|
|
753
1603
|
try {
|
|
1604
|
+
if (pageSummary === void 0 && url) {
|
|
1605
|
+
const elementMap = await captureElements(url, {
|
|
1606
|
+
headless: true,
|
|
1607
|
+
verbose: false
|
|
1608
|
+
});
|
|
1609
|
+
pageSummary = toPageSummary(elementMap);
|
|
1610
|
+
}
|
|
754
1611
|
markdown = await generateTcMarkdownWithAi({
|
|
755
1612
|
appDescription: appDescription || void 0,
|
|
756
1613
|
feature,
|
|
@@ -771,7 +1628,7 @@ async function runTcInteractive(params) {
|
|
|
771
1628
|
markdown = buildManualCasesMarkdown({ prefix, feature, url, numCases, startIndex: 1 });
|
|
772
1629
|
console.log("\u{1F4DD} Generated manual test case templates (pass --ai to use AI).");
|
|
773
1630
|
}
|
|
774
|
-
|
|
1631
|
+
writeFileSync4(fullPath, markdown);
|
|
775
1632
|
console.log(`
|
|
776
1633
|
\u270D\uFE0F Wrote ${numCases} case(s) \u2192 ${fullPath}`);
|
|
777
1634
|
console.log("\nNext steps:");
|
|
@@ -811,7 +1668,7 @@ Examples:
|
|
|
811
1668
|
}
|
|
812
1669
|
);
|
|
813
1670
|
addSharedOptions(
|
|
814
|
-
root.command("url").argument("<url>", "Page URL to use as
|
|
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")
|
|
815
1672
|
).action(
|
|
816
1673
|
async (url, opts, command) => {
|
|
817
1674
|
const resolvedOpts = resolveCommanderOpts(command ?? opts);
|
|
@@ -821,7 +1678,9 @@ Examples:
|
|
|
821
1678
|
feature: resolvedOpts.feature,
|
|
822
1679
|
appDescription: resolvedOpts.desc,
|
|
823
1680
|
numCases: resolvedOpts.count,
|
|
824
|
-
useAi: resolvedOpts.ai ?? false
|
|
1681
|
+
useAi: resolvedOpts.ai ?? false,
|
|
1682
|
+
headed: resolvedOpts.headed ?? false,
|
|
1683
|
+
captureOnly: resolvedOpts.captureOnly ?? false
|
|
825
1684
|
});
|
|
826
1685
|
}
|
|
827
1686
|
);
|
|
@@ -853,11 +1712,11 @@ function reportCmd() {
|
|
|
853
1712
|
import { Command as Command6 } from "commander";
|
|
854
1713
|
import { spawn as spawn3 } from "child_process";
|
|
855
1714
|
import { existsSync as existsSync2 } from "fs";
|
|
856
|
-
import { join as
|
|
1715
|
+
import { join as join5 } from "path";
|
|
857
1716
|
function serveCmd() {
|
|
858
1717
|
const cmd = new Command6("serve").description("Serve the Allure report").action(() => {
|
|
859
1718
|
console.log("\u{1F4CA} Serving Allure report...");
|
|
860
|
-
const localAllureBin =
|
|
1719
|
+
const localAllureBin = join5(process.cwd(), "node_modules", "allure-commandline", "bin", "allure");
|
|
861
1720
|
let executable = "npx";
|
|
862
1721
|
let args = ["allure", "serve", "./allure-results"];
|
|
863
1722
|
if (existsSync2(localAllureBin)) {
|
|
@@ -925,8 +1784,8 @@ function flowCmd() {
|
|
|
925
1784
|
|
|
926
1785
|
// src/commands/ci.ts
|
|
927
1786
|
import { Command as Command8 } from "commander";
|
|
928
|
-
import { mkdirSync as
|
|
929
|
-
import { join as
|
|
1787
|
+
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5, existsSync as existsSync3 } from "fs";
|
|
1788
|
+
import { join as join6 } from "path";
|
|
930
1789
|
var WORKFLOW_CONTENT = `name: Playwright Tests
|
|
931
1790
|
on:
|
|
932
1791
|
push:
|
|
@@ -957,19 +1816,19 @@ jobs:
|
|
|
957
1816
|
`;
|
|
958
1817
|
function ciCmd() {
|
|
959
1818
|
const cmd = new Command8("ci").description("Generate GitHub Actions workflow for CI").action(() => {
|
|
960
|
-
const githubDir =
|
|
961
|
-
const workflowsDir =
|
|
962
|
-
const workflowFile =
|
|
1819
|
+
const githubDir = join6(process.cwd(), ".github");
|
|
1820
|
+
const workflowsDir = join6(githubDir, "workflows");
|
|
1821
|
+
const workflowFile = join6(workflowsDir, "cementic.yml");
|
|
963
1822
|
console.log("\u{1F916} Setting up CI/CD workflow...");
|
|
964
1823
|
if (!existsSync3(workflowsDir)) {
|
|
965
|
-
|
|
1824
|
+
mkdirSync5(workflowsDir, { recursive: true });
|
|
966
1825
|
console.log(`Created directory: ${workflowsDir}`);
|
|
967
1826
|
}
|
|
968
1827
|
if (existsSync3(workflowFile)) {
|
|
969
1828
|
console.warn(`\u26A0\uFE0F Workflow file already exists at ${workflowFile}. Skipping.`);
|
|
970
1829
|
return;
|
|
971
1830
|
}
|
|
972
|
-
|
|
1831
|
+
writeFileSync5(workflowFile, WORKFLOW_CONTENT.trim() + "\n");
|
|
973
1832
|
console.log(`\u2705 CI workflow generated at: ${workflowFile}`);
|
|
974
1833
|
console.log("Next steps:");
|
|
975
1834
|
console.log("1. Commit and push the new file");
|