@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/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  genCmd
4
- } from "./chunk-3EE7LWWT.js";
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 steps = Array.from(stepsBlock.matchAll(bullet)).map((m) => m[1].trim()) || [];
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 expectedLines = Array.from(expectedBlock.matchAll(bullet)).map(
191
- (m) => m[1].trim()
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-IO4KKGYY.js");
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 mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
328
- import { join as join3 } from "path";
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/scrape.ts
551
- async function scrapePageSummary(url) {
552
- try {
553
- return await scrapeWithPlaywright(url);
554
- } catch (pwErr) {
555
- console.warn(`\u26A0\uFE0F Playwright scrape failed (${pwErr?.message ?? pwErr}). Falling back to static fetch.`);
556
- return scrapeWithFetch(url);
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
- async function scrapeWithPlaywright(url) {
560
- let chromium;
561
- try {
562
- ({ chromium } = await import("playwright-core"));
563
- } catch {
564
- ({ chromium } = await import("@playwright/test"));
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
- const browser = await chromium.launch({ headless: true });
567
- const context = await browser.newContext({
568
- userAgent: "Mozilla/5.0 (compatible; CementicTest-scraper/1.0)"
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
- const page = await context.newPage();
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
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: 2e4 });
573
- await page.waitForTimeout(800);
574
- const summary = await page.evaluate(() => {
575
- const text = (el) => el?.textContent?.replace(/\s+/g, " ").trim() ?? "";
576
- const attr = (el, a) => el?.getAttribute(a)?.trim() ?? "";
577
- const title = document.title?.trim() || void 0;
578
- const headings = Array.from(document.querySelectorAll("h1, h2")).map((h) => text(h)).filter(Boolean).slice(0, 20);
579
- const buttons = Array.from(
580
- document.querySelectorAll('button, [role="button"]')
581
- ).map((b) => attr(b, "aria-label") || text(b)).filter(Boolean).slice(0, 30);
582
- const links = Array.from(document.querySelectorAll("a[href]")).map((a) => attr(a, "aria-label") || text(a)).filter(Boolean).slice(0, 50);
583
- const inputs = [];
584
- document.querySelectorAll("input, textarea, select").forEach((el) => {
585
- const id = attr(el, "id");
586
- const name = attr(el, "name") || void 0;
587
- const ph = attr(el, "placeholder") || void 0;
588
- const type = attr(el, "type") || void 0;
589
- const testId = attr(el, "data-testid") || void 0;
590
- let labelText;
591
- if (id) {
592
- const labelEl = document.querySelector(`label[for="${id}"]`);
593
- if (labelEl) labelText = text(labelEl) || void 0;
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
- if (!labelText) {
596
- const closest = el.closest("label");
597
- if (closest) labelText = text(closest).replace(ph ?? "", "").trim() || void 0;
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
- if (labelText || ph || name || testId) {
600
- inputs.push({ label: labelText, placeholder: ph, name, type, testId });
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
- const landmarks = Array.from(
604
- document.querySelectorAll('[role="navigation"], [role="main"], [role="form"], nav, main, form')
605
- ).map((el) => attr(el, "aria-label") || el.tagName.toLowerCase()).filter(Boolean);
606
- const rawLength = document.documentElement.outerHTML.length;
607
- return {
608
- title,
609
- headings,
610
- buttons,
611
- links,
612
- inputs: inputs.slice(0, 30),
613
- landmarks,
614
- rawLength
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
- return { url, ...summary };
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
- async function scrapeWithFetch(url) {
623
- const res = await fetch(url, {
624
- headers: { "User-Agent": "CementicTest-scraper/1.0" }
625
- });
626
- if (!res.ok) {
627
- throw new Error(`HTTP ${res.status} ${res.statusText} \u2014 ${url}`);
628
- }
629
- const html = await res.text();
630
- const rawLength = html.length;
631
- const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i);
632
- const title = titleMatch?.[1]?.trim() || void 0;
633
- const headings = Array.from(html.matchAll(/<(h1|h2)[^>]*>([^<]*)<\/\1>/gi)).map((m) => m[2].replace(/\s+/g, " ").trim()).filter(Boolean).slice(0, 20);
634
- const buttons = Array.from(html.matchAll(/<button[^>]*>([^<]*)<\/button>/gi)).map((m) => m[1].replace(/\s+/g, " ").trim()).filter(Boolean).slice(0, 30);
635
- const links = Array.from(html.matchAll(/<a[^>]*>([^<]*)<\/a>/gi)).map((m) => m[1].replace(/\s+/g, " ").trim()).filter(Boolean).slice(0, 50);
636
- const labelMap = /* @__PURE__ */ new Map();
637
- for (const m of html.matchAll(/<label[^>]*for=["']?([^"'>\s]+)["']?[^>]*>([^<]*)<\/label>/gi)) {
638
- const id = m[1], text = m[2].replace(/\s+/g, " ").trim();
639
- if (id && text) labelMap.set(id, text);
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
- for (const m of html.matchAll(/<input([^>]*)>/gi)) {
643
- const attrs = m[1];
644
- const name = attrs.match(/\bname=["']?([^"'>\s]+)["']?/i)?.[1];
645
- const id = attrs.match(/\bid=["']?([^"'>\s]+)["']?/i)?.[1];
646
- const ph = attrs.match(/\bplaceholder=["']([^"']*)["']/i)?.[1]?.trim();
647
- const type = attrs.match(/\btype=["']?([^"'>\s]+)["']?/i)?.[1];
648
- const testId = attrs.match(/\bdata-testid=["']([^"']*)["']/i)?.[1]?.trim();
649
- const label = id ? labelMap.get(id) : void 0;
650
- if (label || ph || name || testId) {
651
- inputs.push({ label, placeholder: ph, name, type, testId });
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
- url,
656
- title,
657
- headings,
658
- buttons,
659
- links,
660
- inputs: inputs.slice(0, 30),
661
- landmarks: [],
662
- rawLength
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 slugify(text) {
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
- mkdirSync3("cases", { recursive: true });
737
- const fileName = `${prefix.toLowerCase()}-${slugify(feature)}.md`;
738
- const fullPath = join3("cases", fileName);
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} Scraping page: ${url}`);
745
- pageSummary = await scrapePageSummary(url);
746
- console.log(
747
- ` Found: ${pageSummary.headings.length} heading(s), ${pageSummary.inputs.length} input(s), ${pageSummary.buttons.length} button(s)`
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 Scrape failed (${e?.message ?? e}). Continuing without page context.`);
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
- writeFileSync3(fullPath, markdown);
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 scraping and AI context").description("Generate test cases with awareness of a specific live page")
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 join4 } from "path";
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 = join4(process.cwd(), "node_modules", "allure-commandline", "bin", "allure");
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 mkdirSync4, writeFileSync as writeFileSync4, existsSync as existsSync3 } from "fs";
929
- import { join as join5 } from "path";
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 = join5(process.cwd(), ".github");
961
- const workflowsDir = join5(githubDir, "workflows");
962
- const workflowFile = join5(workflowsDir, "cementic.yml");
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
- mkdirSync4(workflowsDir, { recursive: true });
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
- writeFileSync4(workflowFile, WORKFLOW_CONTENT.trim() + "\n");
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");