@cementic/cementic-test 0.2.6 → 0.2.8

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,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  genCmd
4
- } from "./chunk-5QRDTCSM.js";
4
+ } from "./chunk-TYWO3CBC.js";
5
5
 
6
6
  // src/cli.ts
7
7
  import { Command as Command9 } from "commander";
8
- import { createRequire } from "module";
8
+ import { createRequire as createRequire2 } from "module";
9
9
 
10
10
  // src/commands/new.ts
11
11
  import { Command } from "commander";
@@ -17,6 +17,7 @@ import { dirname } from "path";
17
17
  import { platform, release } from "os";
18
18
  var __filename = fileURLToPath(import.meta.url);
19
19
  var __dirname = dirname(__filename);
20
+ var LEGACY_MACOS_DARWIN_MAJOR = 23;
20
21
  function resolveTemplatePath(templateDir) {
21
22
  const candidates = [
22
23
  resolve(__dirname, `templates/${templateDir}`),
@@ -61,25 +62,22 @@ Examples:
61
62
  }
62
63
  }
63
64
  copyRecursive(templatePath, projectPath);
64
- if (platform() === "darwin") {
65
- const osRelease = release();
66
- const majorVersion = parseInt(osRelease.split(".")[0], 10);
67
- if (majorVersion < 23) {
68
- console.log("\u{1F34E} Detected macOS 13 or older. Adjusting versions for compatibility...");
69
- const pkgJsonPath = join(projectPath, "package.json");
70
- if (existsSync(pkgJsonPath)) {
71
- try {
72
- const pkgContent = readFileSync(pkgJsonPath, "utf-8");
73
- const pkg = JSON.parse(pkgContent);
74
- if (pkg.devDependencies) {
75
- pkg.devDependencies["@playwright/test"] = "^1.48.2";
76
- pkg.devDependencies["allure-playwright"] = "^2.15.1";
77
- writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, 2));
78
- console.log("\u2705 Downgraded @playwright/test and allure-playwright for legacy macOS support.");
79
- }
80
- } catch (err) {
81
- console.warn("\u26A0\uFE0F Failed to adjust package.json for OS compatibility:", err);
65
+ const legacyMacOs = isLegacyMacOs();
66
+ if (legacyMacOs) {
67
+ console.log("\u{1F34E} Detected macOS 13 or older. Adjusting versions for compatibility...");
68
+ const pkgJsonPath = join(projectPath, "package.json");
69
+ if (existsSync(pkgJsonPath)) {
70
+ try {
71
+ const pkgContent = readFileSync(pkgJsonPath, "utf-8");
72
+ const pkg = JSON.parse(pkgContent);
73
+ if (pkg.devDependencies) {
74
+ pkg.devDependencies["@playwright/test"] = "^1.48.2";
75
+ pkg.devDependencies["allure-playwright"] = "^2.15.1";
76
+ writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, 2));
77
+ console.log("\u2705 Pinned Playwright packages for legacy macOS compatibility.");
82
78
  }
79
+ } catch (err) {
80
+ console.warn("\u26A0\uFE0F Failed to adjust package.json for OS compatibility:", err);
83
81
  }
84
82
  }
85
83
  }
@@ -93,17 +91,27 @@ Examples:
93
91
  console.warn("\u26A0\uFE0F Failed to initialize git repository.");
94
92
  }
95
93
  console.log("\u{1F4E6} Installing dependencies...");
94
+ let dependenciesInstalled = false;
96
95
  try {
97
96
  execSync("npm install", { cwd: projectPath, stdio: "inherit" });
97
+ dependenciesInstalled = true;
98
98
  } catch (e) {
99
99
  console.error('\u274C Failed to install dependencies. Please run "npm install" manually.');
100
100
  }
101
- if (opts.browsers !== false) {
101
+ if (opts.browsers !== false && dependenciesInstalled) {
102
102
  console.log("\u{1F310} Installing Playwright browsers...");
103
103
  try {
104
- execSync("npx playwright install", { cwd: projectPath, stdio: "inherit" });
104
+ if (legacyMacOs) {
105
+ console.log("\u26A0\uFE0F WebKit is not supported on this macOS version. Installing Chromium only...");
106
+ execSync("npx playwright install chromium", { cwd: projectPath, stdio: "inherit" });
107
+ console.log("\u2705 Chromium installed successfully.");
108
+ } else {
109
+ execSync("npx playwright install", { cwd: projectPath, stdio: "inherit" });
110
+ console.log("\u2705 Playwright browsers installed successfully.");
111
+ }
105
112
  } catch (e) {
106
- console.warn('\u26A0\uFE0F Failed to install browsers. Run "npx playwright install" manually.');
113
+ console.warn("\u26A0\uFE0F Browser installation did not complete. You can finish setup with:");
114
+ console.warn(" npx playwright install chromium");
107
115
  }
108
116
  }
109
117
  console.log(`
@@ -118,6 +126,11 @@ Happy testing! \u{1F9EA}`);
118
126
  });
119
127
  return cmd;
120
128
  }
129
+ function isLegacyMacOs() {
130
+ if (platform() !== "darwin") return false;
131
+ const majorVersion = parseInt(release().split(".")[0], 10);
132
+ return Number.isFinite(majorVersion) && majorVersion < LEGACY_MACOS_DARWIN_MAJOR;
133
+ }
121
134
 
122
135
  // src/commands/normalize.ts
123
136
  import { Command as Command2 } from "commander";
@@ -308,7 +321,7 @@ Examples:
308
321
  }
309
322
  console.log(`\u2705 Normalized ${index.summary.parsed} case(s). Output \u2192 .cementic/normalized/`);
310
323
  if (opts.andGen) {
311
- const { gen } = await import("./gen-RMRQOAD3.js");
324
+ const { gen } = await import("./gen-QEQJ3Z7S.js");
312
325
  await gen({ lang: opts.lang || "ts", out: "tests/generated" });
313
326
  }
314
327
  });
@@ -339,7 +352,7 @@ function testCmd() {
339
352
  // src/commands/tc.ts
340
353
  import { Command as Command4 } from "commander";
341
354
  import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
342
- import { join as join4 } from "path";
355
+ import { join as join5 } from "path";
343
356
  import { createInterface } from "readline/promises";
344
357
  import { stdin as input, stdout as output } from "process";
345
358
 
@@ -393,6 +406,97 @@ function inferPrefix(params) {
393
406
  return "TC";
394
407
  }
395
408
 
409
+ // src/core/llm-provider.ts
410
+ var PROVIDER_ORDER = ["deepseek", "anthropic", "gemini", "qwen", "kimi", "openai"];
411
+ function resolveLlmProvider(env = process.env) {
412
+ const explicitProvider = (env.CT_LLM_PROVIDER ?? "").trim().toLowerCase();
413
+ const providers = buildProviderConfigs(env);
414
+ if (explicitProvider) {
415
+ if (!(explicitProvider in providers)) {
416
+ throw new Error(
417
+ `Unsupported CT_LLM_PROVIDER="${explicitProvider}". Use deepseek, anthropic, gemini, qwen, kimi, or openai.`
418
+ );
419
+ }
420
+ const selected = providers[explicitProvider];
421
+ if (!selected.apiKey) {
422
+ throw new Error(
423
+ `CT_LLM_PROVIDER=${explicitProvider} is set but the matching API key is missing.
424
+ ${buildProviderSetupHelp()}`
425
+ );
426
+ }
427
+ return selected;
428
+ }
429
+ for (const providerName of PROVIDER_ORDER) {
430
+ if (providers[providerName].apiKey) return providers[providerName];
431
+ }
432
+ throw new Error(`No LLM API key found.
433
+ ${buildProviderSetupHelp()}`);
434
+ }
435
+ function buildProviderSetupHelp() {
436
+ return [
437
+ " OpenAI: export OPENAI_API_KEY=your-key",
438
+ " Anthropic: export ANTHROPIC_API_KEY=your-key",
439
+ " Gemini: export GEMINI_API_KEY=your-key",
440
+ " DeepSeek: export DEEPSEEK_API_KEY=your-key",
441
+ " Qwen: export QWEN_API_KEY=your-key",
442
+ " Kimi: export KIMI_API_KEY=your-key",
443
+ " Generic: export CT_LLM_API_KEY=your-key",
444
+ " export CT_LLM_BASE_URL=https://your-openai-compatible-endpoint/v1"
445
+ ].join("\n");
446
+ }
447
+ function buildProviderConfigs(env) {
448
+ return {
449
+ deepseek: {
450
+ provider: "deepseek",
451
+ displayName: "DeepSeek",
452
+ apiKey: env.DEEPSEEK_API_KEY ?? env.CT_DEEPSEEK_API_KEY ?? "",
453
+ model: env.CT_LLM_MODEL ?? env.DEEPSEEK_MODEL ?? "deepseek-chat",
454
+ baseUrl: env.DEEPSEEK_BASE_URL ?? "https://api.deepseek.com/v1",
455
+ transport: "openai-compatible"
456
+ },
457
+ anthropic: {
458
+ provider: "anthropic",
459
+ displayName: "Claude",
460
+ apiKey: env.ANTHROPIC_API_KEY ?? env.CT_ANTHROPIC_API_KEY ?? "",
461
+ model: env.CT_LLM_MODEL ?? env.ANTHROPIC_MODEL ?? "claude-sonnet-4-5",
462
+ baseUrl: "https://api.anthropic.com",
463
+ transport: "anthropic"
464
+ },
465
+ gemini: {
466
+ provider: "gemini",
467
+ displayName: "Gemini",
468
+ apiKey: env.GEMINI_API_KEY ?? env.GOOGLE_API_KEY ?? "",
469
+ model: env.CT_LLM_MODEL ?? env.GEMINI_MODEL ?? "gemini-2.5-flash",
470
+ baseUrl: env.GEMINI_BASE_URL ?? "https://generativelanguage.googleapis.com/v1beta/openai",
471
+ transport: "openai-compatible"
472
+ },
473
+ qwen: {
474
+ provider: "qwen",
475
+ displayName: "Qwen",
476
+ apiKey: env.QWEN_API_KEY ?? "",
477
+ model: env.CT_LLM_MODEL ?? env.QWEN_MODEL ?? "qwen-plus",
478
+ baseUrl: env.QWEN_BASE_URL ?? "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
479
+ transport: "openai-compatible"
480
+ },
481
+ kimi: {
482
+ provider: "kimi",
483
+ displayName: "Kimi",
484
+ apiKey: env.KIMI_API_KEY ?? env.MOONSHOT_API_KEY ?? "",
485
+ model: env.CT_LLM_MODEL ?? env.KIMI_MODEL ?? "moonshot-v1-8k",
486
+ baseUrl: env.KIMI_BASE_URL ?? "https://api.moonshot.ai/v1",
487
+ transport: "openai-compatible"
488
+ },
489
+ openai: {
490
+ provider: "openai",
491
+ displayName: env.CT_LLM_API_KEY && env.CT_LLM_BASE_URL ? "OpenAI-compatible" : "OpenAI",
492
+ apiKey: env.CT_LLM_API_KEY ?? env.OPENAI_API_KEY ?? "",
493
+ model: env.CT_LLM_MODEL ?? env.OPENAI_MODEL ?? "gpt-4o-mini",
494
+ baseUrl: env.CT_LLM_BASE_URL ?? "https://api.openai.com/v1",
495
+ transport: "openai-compatible"
496
+ }
497
+ };
498
+ }
499
+
396
500
  // src/core/llm.ts
397
501
  function buildSystemMessage() {
398
502
  return `
@@ -472,35 +576,6 @@ function buildUserMessage(ctx) {
472
576
  lines.push(`Output ONLY the test cases. No explanation, no preamble.`);
473
577
  return lines.join("\n");
474
578
  }
475
- function detectProvider() {
476
- const anthropicKey = process.env.ANTHROPIC_API_KEY ?? process.env.CT_ANTHROPIC_API_KEY ?? "";
477
- const openaiKey = process.env.CT_LLM_API_KEY ?? process.env.OPENAI_API_KEY ?? "";
478
- const explicitProvider = (process.env.CT_LLM_PROVIDER ?? "").toLowerCase();
479
- if (explicitProvider === "anthropic" || anthropicKey && explicitProvider !== "openai") {
480
- if (!anthropicKey) {
481
- throw new Error(
482
- "CT_LLM_PROVIDER=anthropic but no ANTHROPIC_API_KEY found.\nSet ANTHROPIC_API_KEY=your-key or switch to OPENAI_API_KEY."
483
- );
484
- }
485
- return {
486
- provider: "anthropic",
487
- apiKey: anthropicKey,
488
- model: process.env.CT_LLM_MODEL ?? "claude-sonnet-4-5",
489
- baseUrl: "https://api.anthropic.com"
490
- };
491
- }
492
- if (openaiKey) {
493
- return {
494
- provider: "openai",
495
- apiKey: openaiKey,
496
- model: process.env.CT_LLM_MODEL ?? "gpt-4o-mini",
497
- baseUrl: process.env.CT_LLM_BASE_URL ?? "https://api.openai.com/v1"
498
- };
499
- }
500
- throw new Error(
501
- "No LLM API key found.\n For Claude: export ANTHROPIC_API_KEY=your-key\n For OpenAI: export OPENAI_API_KEY=your-key\n Any OpenAI-compatible endpoint: set CT_LLM_API_KEY + CT_LLM_BASE_URL"
502
- );
503
- }
504
579
  async function callAnthropic(apiKey, model, system, user) {
505
580
  const response = await fetch("https://api.anthropic.com/v1/messages", {
506
581
  method: "POST",
@@ -525,7 +600,7 @@ async function callAnthropic(apiKey, model, system, user) {
525
600
  if (!content) throw new Error("Anthropic response had no content");
526
601
  return content;
527
602
  }
528
- async function callOpenAi(apiKey, model, baseUrl, system, user) {
603
+ async function callOpenAiCompatible(apiKey, model, baseUrl, displayName, system, user) {
529
604
  const response = await fetch(`${baseUrl}/chat/completions`, {
530
605
  method: "POST",
531
606
  headers: {
@@ -543,7 +618,7 @@ async function callOpenAi(apiKey, model, baseUrl, system, user) {
543
618
  });
544
619
  if (!response.ok) {
545
620
  const text = await response.text();
546
- throw new Error(`OpenAI API error ${response.status}: ${text}`);
621
+ throw new Error(`${displayName} API error ${response.status}: ${text}`);
547
622
  }
548
623
  const json = await response.json();
549
624
  const content = json.choices?.[0]?.message?.content?.trim() ?? "";
@@ -551,90 +626,42 @@ async function callOpenAi(apiKey, model, baseUrl, system, user) {
551
626
  return content;
552
627
  }
553
628
  async function generateTcMarkdownWithAi(ctx) {
554
- const { provider, apiKey, model, baseUrl } = detectProvider();
629
+ const providerConfig = resolveLlmProvider();
555
630
  const system = buildSystemMessage();
556
631
  const user = buildUserMessage(ctx);
557
- console.log(`\u{1F916} Using ${provider === "anthropic" ? "Claude" : "OpenAI-compatible"} (${model})`);
558
- if (provider === "anthropic") {
559
- return callAnthropic(apiKey, model, system, user);
632
+ console.log(`\u{1F916} Using ${providerConfig.displayName} (${providerConfig.model})`);
633
+ if (providerConfig.transport === "anthropic") {
634
+ return callAnthropic(providerConfig.apiKey, providerConfig.model, system, user);
560
635
  }
561
- return callOpenAi(apiKey, model, baseUrl, system, user);
636
+ return callOpenAiCompatible(
637
+ providerConfig.apiKey,
638
+ providerConfig.model,
639
+ providerConfig.baseUrl ?? "https://api.openai.com/v1",
640
+ providerConfig.displayName,
641
+ system,
642
+ user
643
+ );
562
644
  }
563
645
 
564
646
  // src/core/analyse.ts
565
647
  async function analyseElements(elementMap, options = {}) {
566
648
  const { verbose = false } = options;
567
- const { provider, displayName, apiKey, model, baseUrl } = detectProvider2();
649
+ const providerConfig = resolveLlmProvider();
568
650
  log(verbose, `
569
- [analyse] Sending capture to ${displayName} (${model})`);
651
+ [analyse] Sending capture to ${providerConfig.displayName} (${providerConfig.model})`);
570
652
  const systemPrompt = buildSystemPrompt();
571
653
  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);
654
+ const rawResponse = providerConfig.transport === "anthropic" ? await callAnthropic2(providerConfig.apiKey, providerConfig.model, systemPrompt, userPrompt) : await callOpenAiCompatible2(
655
+ providerConfig.apiKey,
656
+ providerConfig.model,
657
+ providerConfig.baseUrl ?? "https://api.openai.com/v1",
658
+ providerConfig.displayName,
659
+ systemPrompt,
660
+ userPrompt
661
+ );
573
662
  const parsed = parseAnalysisJson(rawResponse);
574
663
  return sanitizeAnalysis(parsed, elementMap);
575
664
  }
576
- function detectProvider2() {
577
- const explicitProvider = (process.env.CT_LLM_PROVIDER ?? "").toLowerCase();
578
- const providers = {
579
- deepseek: {
580
- provider: "deepseek",
581
- displayName: "DeepSeek",
582
- apiKey: process.env.DEEPSEEK_API_KEY ?? process.env.CT_DEEPSEEK_API_KEY ?? "",
583
- model: process.env.CT_LLM_MODEL ?? process.env.DEEPSEEK_MODEL ?? "deepseek-chat",
584
- baseUrl: process.env.DEEPSEEK_BASE_URL ?? "https://api.deepseek.com/v1"
585
- },
586
- anthropic: {
587
- provider: "anthropic",
588
- displayName: "Claude",
589
- apiKey: process.env.ANTHROPIC_API_KEY ?? process.env.CT_ANTHROPIC_API_KEY ?? "",
590
- model: process.env.CT_LLM_MODEL ?? process.env.ANTHROPIC_MODEL ?? "claude-sonnet-4-5"
591
- },
592
- gemini: {
593
- provider: "gemini",
594
- displayName: "Gemini",
595
- apiKey: process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY ?? "",
596
- model: process.env.CT_LLM_MODEL ?? process.env.GEMINI_MODEL ?? "gemini-2.5-flash",
597
- baseUrl: process.env.GEMINI_BASE_URL ?? "https://generativelanguage.googleapis.com/v1beta/openai"
598
- },
599
- qwen: {
600
- provider: "qwen",
601
- displayName: "Qwen",
602
- apiKey: process.env.QWEN_API_KEY ?? "",
603
- model: process.env.CT_LLM_MODEL ?? process.env.QWEN_MODEL ?? "qwen-plus",
604
- baseUrl: process.env.QWEN_BASE_URL ?? "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
605
- },
606
- kimi: {
607
- provider: "kimi",
608
- displayName: "Kimi",
609
- apiKey: process.env.KIMI_API_KEY ?? process.env.MOONSHOT_API_KEY ?? "",
610
- model: process.env.CT_LLM_MODEL ?? process.env.KIMI_MODEL ?? "moonshot-v1-8k",
611
- baseUrl: process.env.KIMI_BASE_URL ?? "https://api.moonshot.ai/v1"
612
- },
613
- openai: {
614
- provider: "openai",
615
- displayName: "OpenAI-compatible",
616
- apiKey: process.env.OPENAI_API_KEY ?? process.env.CT_LLM_API_KEY ?? "",
617
- model: process.env.CT_LLM_MODEL ?? process.env.OPENAI_MODEL ?? "gpt-4o-mini",
618
- baseUrl: process.env.CT_LLM_BASE_URL ?? "https://api.openai.com/v1"
619
- }
620
- };
621
- if (explicitProvider) {
622
- if (!(explicitProvider in providers)) {
623
- throw new Error(`Unsupported CT_LLM_PROVIDER="${explicitProvider}". Use deepseek, anthropic, gemini, qwen, kimi, or openai.`);
624
- }
625
- const selected = providers[explicitProvider];
626
- if (!selected.apiKey) {
627
- throw new Error(`CT_LLM_PROVIDER=${explicitProvider} is set but the matching API key is missing.`);
628
- }
629
- return selected;
630
- }
631
- for (const providerName of ["deepseek", "anthropic", "gemini", "qwen", "kimi", "openai"]) {
632
- if (providers[providerName].apiKey) return providers[providerName];
633
- }
634
- throw new Error(
635
- "No LLM API key found.\n DeepSeek: export DEEPSEEK_API_KEY=your-key\n Anthropic: export ANTHROPIC_API_KEY=your-key\n Gemini: export GEMINI_API_KEY=your-key\n Qwen: export QWEN_API_KEY=your-key\n Kimi: export KIMI_API_KEY=your-key\n OpenAI: export OPENAI_API_KEY=your-key"
636
- );
637
- }
638
665
  function buildSystemPrompt() {
639
666
  return `
640
667
  You are a senior QA automation engineer and Playwright expert working on CementicTest.
@@ -651,6 +678,8 @@ RULES:
651
678
  6. Do not invent redirect targets, success pages, error text, password clearing, or security scenarios unless the capture explicitly supports them.
652
679
  7. If no status or alert region was captured, avoid scenarios that depend on unseen server-side validation messages.
653
680
  8. Prefer 3 to 5 realistic scenarios.
681
+ 9. The "selector" field must be the raw selector expression from the ElementMap, such as locator("#username") or getByRole('button', { name: "Login" }). Never wrap selectors in page. or page.locator("...") inside JSON.
682
+ 10. If a page clearly contains login or auth fields, do not create submit scenarios that only click the button. Include realistic fill steps for captured username/email and password inputs.
654
683
 
655
684
  OUTPUT SCHEMA:
656
685
  {
@@ -689,6 +718,7 @@ OUTPUT SCHEMA:
689
718
  }
690
719
  function buildUserPrompt(elementMap) {
691
720
  const lines = [];
721
+ const authElements = detectAuthElements(elementMap);
692
722
  lines.push("PAGE INFORMATION");
693
723
  lines.push(`URL: ${elementMap.url}`);
694
724
  lines.push(`Title: ${elementMap.title}`);
@@ -721,6 +751,10 @@ function buildUserPrompt(elementMap) {
721
751
  lines.push(` - Status or alert regions captured: ${statusCount}`);
722
752
  lines.push(" - If no redirect target is explicitly captured, do not assert a destination path.");
723
753
  lines.push(" - If no status region was captured, avoid exact server-side credential error claims.");
754
+ if (authElements.usernameInput && authElements.passwordInput && authElements.submitButton) {
755
+ lines.push(" - This page contains a captured auth form. Include fill steps for the username/email and password fields in credential-submission scenarios.");
756
+ lines.push(" - For auth pages without captured post-submit evidence, prefer evidence-backed form-state assertions over invented success redirects.");
757
+ }
724
758
  lines.push("");
725
759
  lines.push("Generate only the JSON response.");
726
760
  return lines.join("\n");
@@ -748,7 +782,7 @@ async function callAnthropic2(apiKey, model, systemPrompt, userPrompt) {
748
782
  if (!content) throw new Error("Anthropic returned empty content");
749
783
  return content;
750
784
  }
751
- async function callOpenAi2(apiKey, model, baseUrl, systemPrompt, userPrompt) {
785
+ async function callOpenAiCompatible2(apiKey, model, baseUrl, displayName, systemPrompt, userPrompt) {
752
786
  const response = await fetch(`${baseUrl}/chat/completions`, {
753
787
  method: "POST",
754
788
  headers: {
@@ -765,7 +799,7 @@ async function callOpenAi2(apiKey, model, baseUrl, systemPrompt, userPrompt) {
765
799
  })
766
800
  });
767
801
  if (!response.ok) {
768
- throw new Error(`OpenAI API ${response.status}: ${await response.text()}`);
802
+ throw new Error(`${displayName} API ${response.status}: ${await response.text()}`);
769
803
  }
770
804
  const json = await response.json();
771
805
  const content = json.choices?.[0]?.message?.content?.trim() ?? "";
@@ -786,9 +820,14 @@ ${raw.slice(0, 500)}`
786
820
  }
787
821
  }
788
822
  function sanitizeAnalysis(analysis, elementMap) {
789
- const selectors = new Set(elementMap.elements.map((element) => element.selector));
823
+ const selectors = /* @__PURE__ */ new Set();
824
+ for (const element of elementMap.elements) {
825
+ selectors.add(element.selector);
826
+ for (const alt of element.selectorAlt) selectors.add(alt);
827
+ }
790
828
  const rawScenarios = Array.isArray(analysis.scenarios) ? analysis.scenarios : [];
791
829
  const currentUrl = new URL(elementMap.url);
830
+ const authElements = detectAuthElements(elementMap);
792
831
  const knownPaths = /* @__PURE__ */ new Set([
793
832
  currentUrl.pathname,
794
833
  ...elementMap.elements.filter((element) => element.category === "link").map((element) => {
@@ -801,7 +840,7 @@ function sanitizeAnalysis(analysis, elementMap) {
801
840
  }
802
841
  }).filter(Boolean)
803
842
  ]);
804
- const sanitizedScenarios = rawScenarios.map((scenario) => normalizeScenario(scenario, selectors)).filter((scenario) => scenario.steps.length > 0 && scenario.assertions.length > 0).map((scenario) => ({
843
+ const sanitizedScenarios = rawScenarios.map((scenario) => normalizeScenario(scenario, selectors, elementMap.url)).filter((scenario) => scenario.steps.length > 0 && scenario.assertions.length > 0).map((scenario) => ({
805
844
  ...scenario,
806
845
  tags: scenario.tags.map(normalizeTag),
807
846
  assertions: scenario.assertions.filter((assertion) => {
@@ -812,7 +851,8 @@ function sanitizeAnalysis(analysis, elementMap) {
812
851
  return knownPaths.has(pathMatch[0]);
813
852
  })
814
853
  })).filter((scenario) => scenario.assertions.length > 0).slice(0, 5);
815
- const finalScenarios = sanitizedScenarios.length > 0 ? sanitizedScenarios : buildFallbackScenarios(elementMap, analysis.suggestedPrefix || "FLOW");
854
+ const useAuthFallback = shouldUseAuthFallback(authElements, sanitizedScenarios);
855
+ const finalScenarios = useAuthFallback ? buildAuthFallbackScenarios(elementMap, analysis.suggestedPrefix || inferPrefix2(elementMap), authElements) : sanitizedScenarios.length > 0 ? sanitizedScenarios : buildFallbackScenarios(elementMap, analysis.suggestedPrefix || "FLOW");
816
856
  return {
817
857
  ...analysis,
818
858
  url: analysis.url || elementMap.url,
@@ -821,22 +861,53 @@ function sanitizeAnalysis(analysis, elementMap) {
821
861
  scenarios: finalScenarios,
822
862
  analysisNotes: [
823
863
  analysis.analysisNotes,
864
+ useAuthFallback ? "Replaced low-evidence auth scenarios with deterministic evidence-backed auth coverage." : "",
824
865
  `Sanitized to ${finalScenarios.length} evidence-backed scenario(s) from ${rawScenarios.length} raw scenario(s).`
825
866
  ].filter(Boolean).join(" "),
826
867
  audioSummary: analysis.audioSummary || buildAudioSummary(analysis.feature || inferFeatureName(elementMap), finalScenarios)
827
868
  };
828
869
  }
829
- function normalizeScenario(candidate, selectors) {
870
+ function normalizeScenario(candidate, selectors, url) {
871
+ const steps = Array.isArray(candidate?.steps) ? candidate.steps.map((step) => normalizeStep(step, selectors)).filter(Boolean) : [];
872
+ const assertions = Array.isArray(candidate?.assertions) ? candidate.assertions.map((assertion) => normalizeAssertion(assertion, selectors)).filter(Boolean) : [];
873
+ const normalizedSteps = ensureNavigateStep(steps, url);
830
874
  return {
831
875
  id: candidate?.id ?? "FLOW-001",
832
876
  title: candidate?.title ?? "Captured page flow",
833
877
  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)) : [],
878
+ steps: normalizedSteps,
879
+ assertions,
836
880
  narrator: candidate?.narrator ?? "Let us run this captured test flow.",
837
881
  codeLevel: candidate?.codeLevel ?? "beginner"
838
882
  };
839
883
  }
884
+ function normalizeStep(candidate, selectors) {
885
+ if (!candidate) return null;
886
+ const selector = String(candidate.selector ?? "").trim();
887
+ if (selector !== "page" && !selectors.has(selector)) return null;
888
+ const action = String(candidate.action ?? "").trim();
889
+ if (!["navigate", "fill", "click", "select", "check", "keyboard", "hover"].includes(action)) return null;
890
+ return {
891
+ action,
892
+ selector,
893
+ value: String(candidate.value ?? ""),
894
+ human: String(candidate.human ?? "").trim() || defaultStepHuman(action, selector, String(candidate.value ?? ""))
895
+ };
896
+ }
897
+ function normalizeAssertion(candidate, selectors) {
898
+ if (!candidate) return null;
899
+ const selector = String(candidate.selector ?? "").trim();
900
+ if (selector !== "page" && !selectors.has(selector)) return null;
901
+ const assertion = {
902
+ type: String(candidate.type ?? "visible").trim() || "visible",
903
+ selector,
904
+ expected: String(candidate.expected ?? "").trim(),
905
+ human: String(candidate.human ?? "").trim() || "Expected state is verified",
906
+ playwright: String(candidate.playwright ?? "").trim()
907
+ };
908
+ assertion.playwright = normalizePlaywrightAssertion(assertion);
909
+ return assertion;
910
+ }
840
911
  function buildFallbackScenarios(elementMap, prefix) {
841
912
  const heading = elementMap.elements.find((element) => element.category === "heading");
842
913
  const emailInput = elementMap.elements.find((element) => element.category === "input" && (element.attributes.type === "email" || /email/.test(`${element.name ?? ""} ${String(element.attributes.label ?? "")}`.toLowerCase())));
@@ -918,6 +989,163 @@ function buildFallbackScenarios(elementMap, prefix) {
918
989
  }
919
990
  return scenarios.slice(0, 5);
920
991
  }
992
+ function buildAuthFallbackScenarios(elementMap, prefix, authElements) {
993
+ const { usernameInput, passwordInput, submitButton, heading } = authElements;
994
+ if (!usernameInput || !passwordInput || !submitButton) {
995
+ return buildFallbackScenarios(elementMap, prefix);
996
+ }
997
+ const scenarios = [];
998
+ const tag = (value) => normalizeTag(value);
999
+ const nextId = (index) => `${prefix}-${String(index).padStart(3, "0")}`;
1000
+ const usernameValue = inferAuthValue(usernameInput, "username");
1001
+ const passwordValue = inferAuthValue(passwordInput, "password");
1002
+ scenarios.push({
1003
+ id: nextId(1),
1004
+ title: "Login form renders expected controls",
1005
+ tags: [tag("smoke"), tag("auth")],
1006
+ steps: [
1007
+ {
1008
+ action: "navigate",
1009
+ selector: "page",
1010
+ value: elementMap.url,
1011
+ human: "Navigate to the login page"
1012
+ }
1013
+ ],
1014
+ assertions: [
1015
+ ...heading ? [{
1016
+ type: "visible",
1017
+ selector: heading.selector,
1018
+ expected: "visible",
1019
+ human: `${heading.name || "Login heading"} is visible`,
1020
+ playwright: visibleAssertion(heading.selector)
1021
+ }] : [],
1022
+ {
1023
+ type: "visible",
1024
+ selector: usernameInput.selector,
1025
+ expected: "visible",
1026
+ human: `${usernameInput.name || "Username field"} is visible`,
1027
+ playwright: visibleAssertion(usernameInput.selector)
1028
+ },
1029
+ {
1030
+ type: "visible",
1031
+ selector: passwordInput.selector,
1032
+ expected: "visible",
1033
+ human: `${passwordInput.name || "Password field"} is visible`,
1034
+ playwright: visibleAssertion(passwordInput.selector)
1035
+ },
1036
+ {
1037
+ type: "visible",
1038
+ selector: submitButton.selector,
1039
+ expected: "visible",
1040
+ human: `${submitButton.name || "Submit button"} is visible`,
1041
+ playwright: visibleAssertion(submitButton.selector)
1042
+ }
1043
+ ],
1044
+ narrator: "We first confirm that the captured login form is present and interactive.",
1045
+ codeLevel: "beginner"
1046
+ });
1047
+ scenarios.push({
1048
+ id: nextId(2),
1049
+ title: "User can enter credentials before submission",
1050
+ tags: [tag("auth"), tag("happy-path")],
1051
+ steps: [
1052
+ {
1053
+ action: "navigate",
1054
+ selector: "page",
1055
+ value: elementMap.url,
1056
+ human: "Navigate to the login page"
1057
+ },
1058
+ {
1059
+ action: "fill",
1060
+ selector: usernameInput.selector,
1061
+ value: usernameValue,
1062
+ human: "Fill in the username field"
1063
+ },
1064
+ {
1065
+ action: "fill",
1066
+ selector: passwordInput.selector,
1067
+ value: passwordValue,
1068
+ human: "Fill in the password field"
1069
+ }
1070
+ ],
1071
+ assertions: [
1072
+ {
1073
+ type: "value",
1074
+ selector: usernameInput.selector,
1075
+ expected: usernameValue,
1076
+ human: "Username input contains the entered value",
1077
+ playwright: valueAssertion(usernameInput.selector, usernameValue)
1078
+ },
1079
+ {
1080
+ type: "value",
1081
+ selector: passwordInput.selector,
1082
+ expected: passwordValue,
1083
+ human: "Password input contains the entered value",
1084
+ playwright: valueAssertion(passwordInput.selector, passwordValue)
1085
+ },
1086
+ {
1087
+ type: "visible",
1088
+ selector: submitButton.selector,
1089
+ expected: "visible",
1090
+ human: `${submitButton.name || "Submit button"} remains visible`,
1091
+ playwright: visibleAssertion(submitButton.selector)
1092
+ }
1093
+ ],
1094
+ narrator: "Next we verify that both credential fields accept input before we submit the form.",
1095
+ codeLevel: "beginner"
1096
+ });
1097
+ scenarios.push({
1098
+ id: nextId(3),
1099
+ title: "Submitting without a password keeps the login form visible",
1100
+ tags: [tag("auth"), tag("negative")],
1101
+ steps: [
1102
+ {
1103
+ action: "navigate",
1104
+ selector: "page",
1105
+ value: elementMap.url,
1106
+ human: "Navigate to the login page"
1107
+ },
1108
+ {
1109
+ action: "fill",
1110
+ selector: usernameInput.selector,
1111
+ value: usernameValue,
1112
+ human: "Fill in the username field"
1113
+ },
1114
+ {
1115
+ action: "click",
1116
+ selector: submitButton.selector,
1117
+ value: "",
1118
+ human: "Click the login button"
1119
+ }
1120
+ ],
1121
+ assertions: [
1122
+ {
1123
+ type: "url",
1124
+ selector: "page",
1125
+ expected: elementMap.url,
1126
+ human: "User remains on the same login URL",
1127
+ playwright: `await expect(page).toHaveURL('${elementMap.url}');`
1128
+ },
1129
+ {
1130
+ type: "visible",
1131
+ selector: passwordInput.selector,
1132
+ expected: "visible",
1133
+ human: "Password field remains visible for correction",
1134
+ playwright: visibleAssertion(passwordInput.selector)
1135
+ },
1136
+ {
1137
+ type: "visible",
1138
+ selector: submitButton.selector,
1139
+ expected: "visible",
1140
+ human: `${submitButton.name || "Login button"} remains visible`,
1141
+ playwright: visibleAssertion(submitButton.selector)
1142
+ }
1143
+ ],
1144
+ narrator: "Finally we submit incomplete credentials and confirm the captured login form remains available for correction.",
1145
+ codeLevel: "beginner"
1146
+ });
1147
+ return scenarios;
1148
+ }
921
1149
  function inferFeatureName(elementMap) {
922
1150
  const heading = elementMap.elements.find((element) => element.category === "heading" && element.name);
923
1151
  return heading?.name || elementMap.title || "Captured page";
@@ -936,29 +1164,206 @@ function normalizeTag(value) {
936
1164
  const cleaned = String(value ?? "").trim().replace(/^@+/, "");
937
1165
  return cleaned ? `@${cleaned}` : "@ui";
938
1166
  }
1167
+ function detectAuthElements(elementMap) {
1168
+ const usernameInput = elementMap.elements.find((element) => element.category === "input" && /user(name)?|email|login/.test(`${element.name ?? ""} ${String(element.attributes.label ?? "")} ${String(element.attributes.name ?? "")}`.toLowerCase()));
1169
+ const passwordInput = elementMap.elements.find((element) => element.category === "input" && String(element.attributes.type ?? "").toLowerCase() === "password");
1170
+ const submitButton = elementMap.elements.find((element) => element.category === "button" && /login|log in|sign in|submit|continue/.test((element.name ?? "").toLowerCase())) ?? elementMap.elements.find((element) => element.category === "button");
1171
+ const heading = elementMap.elements.find((element) => element.category === "heading" && /login|sign in|auth/.test((element.name ?? "").toLowerCase())) ?? elementMap.elements.find((element) => element.category === "heading");
1172
+ return { usernameInput, passwordInput, submitButton, heading };
1173
+ }
1174
+ function shouldUseAuthFallback(authElements, scenarios) {
1175
+ if (!authElements.usernameInput || !authElements.passwordInput || !authElements.submitButton) return false;
1176
+ if (scenarios.length === 0) return true;
1177
+ const hasCredentialEntry = scenarios.some((scenario) => {
1178
+ const filledSelectors = new Set(
1179
+ scenario.steps.filter((step) => step.action === "fill").map((step) => step.selector)
1180
+ );
1181
+ return filledSelectors.has(authElements.usernameInput.selector) && filledSelectors.has(authElements.passwordInput.selector);
1182
+ });
1183
+ const hasBrokenLocatorWrapper = scenarios.some((scenario) => scenario.assertions.some((assertion) => /page\.locator\((['"])(getBy|locator\()/i.test(assertion.playwright)));
1184
+ return hasBrokenLocatorWrapper || !hasCredentialEntry;
1185
+ }
1186
+ function defaultStepHuman(action, selector, value) {
1187
+ if (action === "navigate") return `Navigate to ${value || "the page"}`;
1188
+ if (action === "fill") return `Fill the field ${selector}`;
1189
+ if (action === "click") return `Click ${selector}`;
1190
+ if (action === "select") return `Select ${value} in ${selector}`;
1191
+ if (action === "check") return `Check ${selector}`;
1192
+ if (action === "keyboard") return `Press ${value}`;
1193
+ return `Interact with ${selector}`;
1194
+ }
1195
+ function normalizePlaywrightAssertion(assertion) {
1196
+ const repaired = scopeBareSelectorsInAssertion(unwrapWrappedSelectorAssertion(assertion.playwright));
1197
+ if (repaired) return ensureStatement(repaired);
1198
+ const built = buildPlaywrightAssertion(assertion);
1199
+ if (built) return built;
1200
+ return ensureStatement(assertion.playwright);
1201
+ }
1202
+ function unwrapWrappedSelectorAssertion(playwright) {
1203
+ const trimmed = String(playwright ?? "").trim();
1204
+ if (!trimmed) return "";
1205
+ return trimmed.replace(
1206
+ /page\.locator\((["'])(getBy(?:Role|Text|Label|Placeholder|TestId)\((?:\\.|(?!\1).)*?\)|locator\((?:\\.|(?!\1).)*?\))\1\)/g,
1207
+ "page.$2"
1208
+ );
1209
+ }
1210
+ function scopeBareSelectorsInAssertion(playwright) {
1211
+ const trimmed = String(playwright ?? "").trim();
1212
+ if (!trimmed) return "";
1213
+ return trimmed.replace(
1214
+ /\bexpect\((getByRole|getByText|getByLabel|getByPlaceholder|getByTestId|locator)\(/g,
1215
+ "expect(page.$1("
1216
+ );
1217
+ }
1218
+ function buildPlaywrightAssertion(assertion) {
1219
+ if (assertion.selector === "page") {
1220
+ if (assertion.type === "url" && assertion.expected) {
1221
+ return `await expect(page).toHaveURL(${JSON.stringify(assertion.expected)});`;
1222
+ }
1223
+ return "";
1224
+ }
1225
+ const target = selectorTarget(assertion.selector);
1226
+ if (!target) return "";
1227
+ if (assertion.type === "value" && assertion.expected) {
1228
+ return `await expect(${target}).toHaveValue(${JSON.stringify(assertion.expected)});`;
1229
+ }
1230
+ const combined = `${assertion.expected} ${assertion.human} ${assertion.playwright}`.toLowerCase();
1231
+ if (/hidden|not visible|no longer visible|disappear/.test(combined)) {
1232
+ return `await expect(${target}).not.toBeVisible();`;
1233
+ }
1234
+ return `await expect(${target}).toBeVisible();`;
1235
+ }
1236
+ function selectorTarget(selector) {
1237
+ const trimmed = String(selector ?? "").trim();
1238
+ if (!trimmed || trimmed === "page") return "page";
1239
+ if (trimmed.startsWith("page.")) return trimmed;
1240
+ return `page.${trimmed}`;
1241
+ }
1242
+ function visibleAssertion(selector) {
1243
+ return `await expect(${selectorTarget(selector)}).toBeVisible();`;
1244
+ }
1245
+ function valueAssertion(selector, value) {
1246
+ return `await expect(${selectorTarget(selector)}).toHaveValue(${JSON.stringify(value)});`;
1247
+ }
1248
+ function inferAuthValue(element, kind) {
1249
+ if (kind === "password") return "Secret123!";
1250
+ const haystack = `${element.name ?? ""} ${String(element.attributes.label ?? "")} ${String(element.attributes.name ?? "")}`.toLowerCase();
1251
+ if (/email/.test(haystack)) return "user@example.com";
1252
+ return "testuser";
1253
+ }
1254
+ function ensureStatement(value) {
1255
+ const trimmed = String(value ?? "").trim();
1256
+ if (!trimmed) return "";
1257
+ return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
1258
+ }
1259
+ function ensureNavigateStep(steps, url) {
1260
+ if (!url || steps.length === 0) return steps;
1261
+ if (steps.some((step) => step.action === "navigate")) return steps;
1262
+ return [
1263
+ {
1264
+ action: "navigate",
1265
+ selector: "page",
1266
+ value: url,
1267
+ human: `Navigate to ${url}`
1268
+ },
1269
+ ...steps
1270
+ ];
1271
+ }
939
1272
  function log(verbose, message) {
940
1273
  if (verbose) console.log(message);
941
1274
  }
942
1275
 
1276
+ // src/core/playwright.ts
1277
+ import { existsSync as existsSync2 } from "fs";
1278
+ import { dirname as dirname2, join as join3, resolve as resolve3 } from "path";
1279
+ import { createRequire } from "module";
1280
+ import { fileURLToPath as fileURLToPath2, pathToFileURL } from "url";
1281
+ var require2 = createRequire(import.meta.url);
1282
+ var moduleDir = dirname2(fileURLToPath2(import.meta.url));
1283
+ async function resolvePlaywrightChromium(cwd = process.cwd()) {
1284
+ const searchRoots = buildSearchRoots(cwd);
1285
+ for (const packageName of ["@playwright/test", "playwright-core"]) {
1286
+ for (const searchRoot of searchRoots) {
1287
+ const resolvedPath = resolveFromRoot(packageName, searchRoot);
1288
+ if (!resolvedPath) continue;
1289
+ const imported = await import(pathToFileURL(resolvedPath).href);
1290
+ const chromium = imported.chromium ?? imported.default?.chromium;
1291
+ if (chromium) {
1292
+ return {
1293
+ packageName,
1294
+ resolvedPath,
1295
+ searchRoot,
1296
+ chromium
1297
+ };
1298
+ }
1299
+ }
1300
+ }
1301
+ throw new Error(
1302
+ "Playwright runtime not found in this project.\nTry:\n npm install\n npx playwright install chromium"
1303
+ );
1304
+ }
1305
+ function buildSearchRoots(cwd) {
1306
+ const roots = [resolve3(cwd)];
1307
+ const projectRoot = findNearestProjectRoot(cwd);
1308
+ if (projectRoot && projectRoot !== roots[0]) {
1309
+ roots.push(projectRoot);
1310
+ }
1311
+ roots.push(moduleDir);
1312
+ return Array.from(new Set(roots));
1313
+ }
1314
+ function findNearestProjectRoot(startDir) {
1315
+ let currentDir = resolve3(startDir);
1316
+ while (true) {
1317
+ if (existsSync2(join3(currentDir, "package.json"))) {
1318
+ return currentDir;
1319
+ }
1320
+ const parentDir = dirname2(currentDir);
1321
+ if (parentDir === currentDir) return void 0;
1322
+ currentDir = parentDir;
1323
+ }
1324
+ }
1325
+ function resolveFromRoot(packageName, searchRoot) {
1326
+ try {
1327
+ return require2.resolve(packageName, { paths: [searchRoot] });
1328
+ } catch {
1329
+ return void 0;
1330
+ }
1331
+ }
1332
+
943
1333
  // src/core/capture.ts
944
1334
  var SETTLE_MS = 1200;
945
1335
  var DEFAULT_TIMEOUT_MS = 3e4;
946
1336
  var MAX_PER_CATEGORY = 50;
1337
+ var CaptureRuntimeError = class extends Error {
1338
+ constructor(code, message, options) {
1339
+ super(message);
1340
+ this.name = "CaptureRuntimeError";
1341
+ this.code = code;
1342
+ if (options?.cause !== void 0) {
1343
+ this.cause = options.cause;
1344
+ }
1345
+ }
1346
+ };
947
1347
  async function captureElements(url, options = {}) {
948
1348
  const {
949
1349
  headless = true,
950
1350
  timeoutMs = DEFAULT_TIMEOUT_MS,
951
1351
  verbose = false,
952
- userAgent = "Mozilla/5.0 (compatible; CementicTest/0.2.5 capture)"
1352
+ userAgent = "Mozilla/5.0 (compatible; CementicTest/0.2.7 capture)"
953
1353
  } = options;
954
1354
  const chromium = await loadChromium();
955
1355
  const mode = headless ? "headless" : "headed";
956
1356
  log2(verbose, `
957
1357
  [capture] Starting ${mode} capture: ${url}`);
958
- const browser = await chromium.launch({
959
- headless,
960
- slowMo: headless ? 0 : 150
961
- });
1358
+ let browser;
1359
+ try {
1360
+ browser = await chromium.launch({
1361
+ headless,
1362
+ slowMo: headless ? 0 : 150
1363
+ });
1364
+ } catch (error) {
1365
+ throw classifyCaptureError(error);
1366
+ }
962
1367
  const context = await browser.newContext({
963
1368
  userAgent,
964
1369
  viewport: { width: 1280, height: 800 },
@@ -967,10 +1372,18 @@ async function captureElements(url, options = {}) {
967
1372
  const page = await context.newPage();
968
1373
  try {
969
1374
  log2(verbose, ` -> Navigating (timeout: ${timeoutMs}ms)`);
970
- await page.goto(url, {
971
- waitUntil: "domcontentloaded",
972
- timeout: timeoutMs
973
- });
1375
+ try {
1376
+ await page.goto(url, {
1377
+ waitUntil: "domcontentloaded",
1378
+ timeout: timeoutMs
1379
+ });
1380
+ } catch (error) {
1381
+ throw new CaptureRuntimeError(
1382
+ "PAGE_LOAD_FAILED",
1383
+ `Page load failed for ${url}. ${error?.message ?? error}`,
1384
+ { cause: error }
1385
+ );
1386
+ }
974
1387
  log2(verbose, ` -> Waiting ${SETTLE_MS}ms for page settle`);
975
1388
  await page.waitForTimeout(SETTLE_MS);
976
1389
  log2(verbose, " -> Extracting accessibility snapshot");
@@ -999,8 +1412,10 @@ async function captureElements(url, options = {}) {
999
1412
  };
1000
1413
  log2(verbose, ` -> Captured ${elements.length} testable elements from "${title}"`);
1001
1414
  return result;
1415
+ } catch (error) {
1416
+ throw classifyCaptureError(error);
1002
1417
  } finally {
1003
- await browser.close();
1418
+ await browser?.close();
1004
1419
  }
1005
1420
  }
1006
1421
  function toPageSummary(elementMap) {
@@ -1024,16 +1439,15 @@ function toPageSummary(elementMap) {
1024
1439
  }
1025
1440
  async function loadChromium() {
1026
1441
  try {
1027
- return (await import("playwright-core")).chromium;
1028
- } catch {
1029
- }
1030
- try {
1031
- return (await import("@playwright/test")).chromium;
1032
- } catch {
1442
+ const resolution = await resolvePlaywrightChromium(process.cwd());
1443
+ return resolution.chromium;
1444
+ } catch (error) {
1445
+ throw new CaptureRuntimeError(
1446
+ "PLAYWRIGHT_NOT_FOUND",
1447
+ error?.message ?? "Playwright runtime not found in this project.\nTry:\n npm install\n npx playwright install chromium",
1448
+ { cause: error }
1449
+ );
1033
1450
  }
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
1451
  }
1038
1452
  function extractDomData() {
1039
1453
  const localMaxPerCategory = 50;
@@ -1353,10 +1767,71 @@ async function getAccessibilitySnapshot(page, verbose) {
1353
1767
  function asString(value) {
1354
1768
  return typeof value === "string" ? value : void 0;
1355
1769
  }
1770
+ function formatCaptureFailure(error) {
1771
+ const resolved = classifyCaptureError(error);
1772
+ if (resolved.code === "PLAYWRIGHT_NOT_FOUND") {
1773
+ return [
1774
+ "Playwright not resolvable.",
1775
+ "Playwright runtime not found in this project.",
1776
+ "Try:",
1777
+ " npm install",
1778
+ " npx playwright install chromium"
1779
+ ];
1780
+ }
1781
+ if (resolved.code === "BROWSER_NOT_INSTALLED") {
1782
+ return [
1783
+ "Browser not installed.",
1784
+ "Playwright resolved, but Chromium is not installed for this project.",
1785
+ "Try:",
1786
+ " npx playwright install chromium"
1787
+ ];
1788
+ }
1789
+ if (resolved.code === "PAGE_LOAD_FAILED") {
1790
+ return [
1791
+ "Page load failed.",
1792
+ resolved.message
1793
+ ];
1794
+ }
1795
+ return [
1796
+ "Capture failed.",
1797
+ resolved.message
1798
+ ];
1799
+ }
1800
+ function classifyCaptureError(error) {
1801
+ if (error instanceof CaptureRuntimeError) {
1802
+ return error;
1803
+ }
1804
+ const message = errorMessage(error);
1805
+ if (isBrowserInstallError(message)) {
1806
+ return new CaptureRuntimeError(
1807
+ "BROWSER_NOT_INSTALLED",
1808
+ "Playwright resolved, but Chromium is not installed for this project.",
1809
+ { cause: error }
1810
+ );
1811
+ }
1812
+ if (isPlaywrightNotFoundError(message)) {
1813
+ return new CaptureRuntimeError(
1814
+ "PLAYWRIGHT_NOT_FOUND",
1815
+ "Playwright runtime not found in this project.",
1816
+ { cause: error }
1817
+ );
1818
+ }
1819
+ return new CaptureRuntimeError("CAPTURE_FAILED", message || "Unknown capture failure.", { cause: error });
1820
+ }
1821
+ function isBrowserInstallError(message) {
1822
+ return /Executable doesn't exist|browserType\.launch: Executable doesn't exist|Please run the following command|playwright install/i.test(message);
1823
+ }
1824
+ function isPlaywrightNotFoundError(message) {
1825
+ return /Playwright runtime not found|Cannot find package ['"](?:@playwright\/test|playwright-core)['"]/i.test(message);
1826
+ }
1827
+ function errorMessage(error) {
1828
+ if (error instanceof Error) return error.message;
1829
+ return String(error ?? "");
1830
+ }
1356
1831
 
1357
1832
  // src/core/report.ts
1358
1833
  import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
1359
- import { join as join3 } from "path";
1834
+ import { join as join4 } from "path";
1360
1835
  function printCaptureReport(elementMap, analysis) {
1361
1836
  console.log("");
1362
1837
  console.log("=".repeat(60));
@@ -1388,10 +1863,10 @@ function printCaptureReport(elementMap, analysis) {
1388
1863
  function saveCaptureJson(elementMap, analysis, outputDir = ".cementic/capture") {
1389
1864
  mkdirSync3(outputDir, { recursive: true });
1390
1865
  const fileName = `capture-${slugify(elementMap.url)}-${Date.now()}.json`;
1391
- const filePath = join3(outputDir, fileName);
1866
+ const filePath = join4(outputDir, fileName);
1392
1867
  writeFileSync3(filePath, JSON.stringify({
1393
1868
  _meta: {
1394
- version: "0.2.5",
1869
+ version: "0.2.7",
1395
1870
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1396
1871
  tool: "@cementic/cementic-test"
1397
1872
  },
@@ -1430,7 +1905,7 @@ function buildCasesMarkdown(analysis) {
1430
1905
  function saveCasesMarkdown(analysis, outputDir = "cases", fileName) {
1431
1906
  mkdirSync3(outputDir, { recursive: true });
1432
1907
  const resolvedFileName = fileName ?? `${slugify(analysis.feature || analysis.url)}.md`;
1433
- const filePath = join3(outputDir, resolvedFileName);
1908
+ const filePath = join4(outputDir, resolvedFileName);
1434
1909
  writeFileSync3(filePath, buildCasesMarkdown(analysis));
1435
1910
  return filePath;
1436
1911
  }
@@ -1438,7 +1913,7 @@ function saveSpecPreview(analysis, outputDir = "tests/preview") {
1438
1913
  if (analysis.scenarios.length === 0) return null;
1439
1914
  mkdirSync3(outputDir, { recursive: true });
1440
1915
  const fileName = `spec-preview-${slugify(analysis.url)}-${Date.now()}.spec.cjs`;
1441
- const filePath = join3(outputDir, fileName);
1916
+ const filePath = join4(outputDir, fileName);
1442
1917
  const lines = [];
1443
1918
  lines.push("/**");
1444
1919
  lines.push(" * CementicTest Capture Preview");
@@ -1465,7 +1940,7 @@ function saveSpecPreview(analysis, outputDir = "tests/preview") {
1465
1940
  if (step.action === "hover") lines.push(` await ${selector}.hover();`);
1466
1941
  }
1467
1942
  for (const assertion of scenario.assertions) {
1468
- lines.push(` ${ensureStatement(assertion.playwright)}`);
1943
+ lines.push(` ${ensureStatement2(assertion.playwright)}`);
1469
1944
  }
1470
1945
  lines.push("});");
1471
1946
  lines.push("");
@@ -1484,7 +1959,7 @@ function normalizeTag2(value) {
1484
1959
  function sanitizeComment(value) {
1485
1960
  return String(value ?? "").replace(/-->/g, "-- >");
1486
1961
  }
1487
- function ensureStatement(value) {
1962
+ function ensureStatement2(value) {
1488
1963
  const trimmed = String(value ?? "").trim();
1489
1964
  if (!trimmed) return "// TODO: add assertion";
1490
1965
  return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
@@ -1562,10 +2037,10 @@ async function runTcInteractive(params) {
1562
2037
  });
1563
2038
  mkdirSync4("cases", { recursive: true });
1564
2039
  const fileName = `${prefix.toLowerCase()}-${slugify2(feature)}.md`;
1565
- const fullPath = join4("cases", fileName);
2040
+ const fullPath = join5("cases", fileName);
1566
2041
  let markdown;
2042
+ let pageSummary = void 0;
1567
2043
  if (params.useAi) {
1568
- let pageSummary = void 0;
1569
2044
  if (url) {
1570
2045
  try {
1571
2046
  console.log(`\u{1F50D} Capturing page: ${url}`);
@@ -1576,6 +2051,7 @@ async function runTcInteractive(params) {
1576
2051
  printCaptureReport(elementMap);
1577
2052
  const jsonPath = saveCaptureJson(elementMap);
1578
2053
  console.log(`\u{1F4C4} Saved capture JSON \u2192 ${jsonPath}`);
2054
+ pageSummary = toPageSummary(elementMap);
1579
2055
  if (params.captureOnly) {
1580
2056
  console.log("Capture-only mode requested. No test cases were generated.");
1581
2057
  return;
@@ -1594,20 +2070,14 @@ async function runTcInteractive(params) {
1594
2070
  console.log(" ct test");
1595
2071
  return;
1596
2072
  } catch (e) {
1597
- console.warn(`\u26A0\uFE0F Capture flow failed (${e?.message ?? e}). Falling back to the legacy AI case writer.`);
2073
+ printWarningBlock(formatCaptureFailure(e));
2074
+ console.warn("\u26A0\uFE0F Falling back to AI text generation without capture context.");
1598
2075
  if (params.captureOnly) {
1599
2076
  return;
1600
2077
  }
1601
2078
  }
1602
2079
  }
1603
2080
  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
- }
1611
2081
  markdown = await generateTcMarkdownWithAi({
1612
2082
  appDescription: appDescription || void 0,
1613
2083
  feature,
@@ -1620,7 +2090,10 @@ async function runTcInteractive(params) {
1620
2090
  markdown = injectUrlMetadata(markdown, url);
1621
2091
  console.log("\u2705 AI generated test cases.");
1622
2092
  } catch (err) {
1623
- console.warn(`\u26A0\uFE0F AI generation failed (${err?.message ?? err}). Falling back to manual templates.`);
2093
+ printWarningBlock([
2094
+ "AI generation failed.",
2095
+ err?.message ?? String(err)
2096
+ ]);
1624
2097
  markdown = buildManualCasesMarkdown({ prefix, feature, url, numCases, startIndex: 1 });
1625
2098
  console.log("\u{1F4DD} Generated manual test case templates instead.");
1626
2099
  }
@@ -1635,6 +2108,13 @@ async function runTcInteractive(params) {
1635
2108
  console.log(" ct normalize ./cases --and-gen --lang ts");
1636
2109
  console.log(" ct test");
1637
2110
  }
2111
+ function printWarningBlock(lines) {
2112
+ if (lines.length === 0) return;
2113
+ console.warn(`\u26A0\uFE0F ${lines[0]}`);
2114
+ for (const line of lines.slice(1)) {
2115
+ console.warn(line);
2116
+ }
2117
+ }
1638
2118
  function addSharedOptions(cmd) {
1639
2119
  return cmd.option("--ai", "Use AI to generate test cases (requires API key)").option("--prefix <prefix>", "Explicit ID prefix, e.g. AUTH, DASH, CART").option("--feature <name>", "Feature name \u2014 skips interactive prompt").option("--desc <text>", "App description \u2014 skips interactive prompt").option("--count <n>", "Number of cases (1\u201310)", parseInt);
1640
2120
  }
@@ -1711,15 +2191,15 @@ function reportCmd() {
1711
2191
  // src/commands/serve.ts
1712
2192
  import { Command as Command6 } from "commander";
1713
2193
  import { spawn as spawn3 } from "child_process";
1714
- import { existsSync as existsSync2 } from "fs";
1715
- import { join as join5 } from "path";
2194
+ import { existsSync as existsSync3 } from "fs";
2195
+ import { join as join6 } from "path";
1716
2196
  function serveCmd() {
1717
2197
  const cmd = new Command6("serve").description("Serve the Allure report").action(() => {
1718
2198
  console.log("\u{1F4CA} Serving Allure report...");
1719
- const localAllureBin = join5(process.cwd(), "node_modules", "allure-commandline", "bin", "allure");
2199
+ const localAllureBin = join6(process.cwd(), "node_modules", "allure-commandline", "bin", "allure");
1720
2200
  let executable = "npx";
1721
2201
  let args = ["allure", "serve", "./allure-results"];
1722
- if (existsSync2(localAllureBin)) {
2202
+ if (existsSync3(localAllureBin)) {
1723
2203
  executable = "node";
1724
2204
  args = [localAllureBin, "serve", "./allure-results"];
1725
2205
  }
@@ -1742,9 +2222,9 @@ function serveCmd() {
1742
2222
  // src/commands/flow.ts
1743
2223
  import { Command as Command7 } from "commander";
1744
2224
  import { spawn as spawn4 } from "child_process";
1745
- import { resolve as resolve3 } from "path";
2225
+ import { resolve as resolve4 } from "path";
1746
2226
  function runStep(cmd, args, stepName) {
1747
- return new Promise((resolve4, reject) => {
2227
+ return new Promise((resolve5, reject) => {
1748
2228
  console.log(`
1749
2229
  \u{1F30A} Flow Step: ${stepName}`);
1750
2230
  console.log(`> ${cmd} ${args.join(" ")}`);
@@ -1754,7 +2234,7 @@ function runStep(cmd, args, stepName) {
1754
2234
  });
1755
2235
  child.on("exit", (code) => {
1756
2236
  if (code === 0) {
1757
- resolve4();
2237
+ resolve5();
1758
2238
  } else {
1759
2239
  reject(new Error(`${stepName} failed with exit code ${code}`));
1760
2240
  }
@@ -1763,7 +2243,7 @@ function runStep(cmd, args, stepName) {
1763
2243
  }
1764
2244
  function flowCmd() {
1765
2245
  const cmd = new Command7("flow").description("End-to-end flow: Normalize -> Generate -> Run Tests").argument("[casesDir]", "Directory containing test cases", "./cases").option("--lang <lang>", "Target language (ts|js)", "ts").option("--no-run", "Skip running tests").action(async (casesDir, opts) => {
1766
- const cliBin = resolve3(process.argv[1]);
2246
+ const cliBin = resolve4(process.argv[1]);
1767
2247
  try {
1768
2248
  await runStep(process.execPath, [cliBin, "normalize", casesDir], "Normalize Cases");
1769
2249
  await runStep(process.execPath, [cliBin, "gen", "--lang", opts.lang], "Generate Tests");
@@ -1784,8 +2264,8 @@ function flowCmd() {
1784
2264
 
1785
2265
  // src/commands/ci.ts
1786
2266
  import { Command as Command8 } from "commander";
1787
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5, existsSync as existsSync3 } from "fs";
1788
- import { join as join6 } from "path";
2267
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5, existsSync as existsSync4 } from "fs";
2268
+ import { join as join7 } from "path";
1789
2269
  var WORKFLOW_CONTENT = `name: Playwright Tests
1790
2270
  on:
1791
2271
  push:
@@ -1816,15 +2296,15 @@ jobs:
1816
2296
  `;
1817
2297
  function ciCmd() {
1818
2298
  const cmd = new Command8("ci").description("Generate GitHub Actions workflow for CI").action(() => {
1819
- const githubDir = join6(process.cwd(), ".github");
1820
- const workflowsDir = join6(githubDir, "workflows");
1821
- const workflowFile = join6(workflowsDir, "cementic.yml");
2299
+ const githubDir = join7(process.cwd(), ".github");
2300
+ const workflowsDir = join7(githubDir, "workflows");
2301
+ const workflowFile = join7(workflowsDir, "cementic.yml");
1822
2302
  console.log("\u{1F916} Setting up CI/CD workflow...");
1823
- if (!existsSync3(workflowsDir)) {
2303
+ if (!existsSync4(workflowsDir)) {
1824
2304
  mkdirSync5(workflowsDir, { recursive: true });
1825
2305
  console.log(`Created directory: ${workflowsDir}`);
1826
2306
  }
1827
- if (existsSync3(workflowFile)) {
2307
+ if (existsSync4(workflowFile)) {
1828
2308
  console.warn(`\u26A0\uFE0F Workflow file already exists at ${workflowFile}. Skipping.`);
1829
2309
  return;
1830
2310
  }
@@ -1838,8 +2318,8 @@ function ciCmd() {
1838
2318
  }
1839
2319
 
1840
2320
  // src/cli.ts
1841
- var require2 = createRequire(import.meta.url);
1842
- var { version } = require2("../package.json");
2321
+ var require3 = createRequire2(import.meta.url);
2322
+ var { version } = require3("../package.json");
1843
2323
  var program = new Command9();
1844
2324
  program.name("ct").description("CementicTest CLI: cases \u2192 normalized \u2192 POM tests \u2192 Playwright").version(version);
1845
2325
  program.addCommand(newCmd());