@cementic/cementic-test 0.2.10 → 0.2.12

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-3TBS4PBV.js";
4
+ } from "./chunk-RG26I5FB.js";
5
5
 
6
6
  // src/cli.ts
7
7
  import { Command as Command9 } from "commander";
@@ -321,7 +321,7 @@ Examples:
321
321
  }
322
322
  console.log(`\u2705 Normalized ${index.summary.parsed} case(s). Output \u2192 .cementic/normalized/`);
323
323
  if (opts.andGen) {
324
- const { gen } = await import("./gen-YF22KYYU.js");
324
+ const { gen } = await import("./gen-6Y65IYXO.js");
325
325
  await gen({ lang: opts.lang || "ts", out: "tests/generated" });
326
326
  }
327
327
  });
@@ -498,44 +498,133 @@ function buildProviderConfigs(env) {
498
498
  }
499
499
 
500
500
  // src/core/llm.ts
501
+ function getAvailableCtVarKeys() {
502
+ return Object.keys(process.env).filter((key) => /^CT_VAR_[A-Z0-9_]+$/.test(key)).sort();
503
+ }
501
504
  function buildSystemMessage() {
502
505
  return `
503
- You are a senior QA engineer and test automation specialist.
506
+ You are a senior software test architect with 10 years of Playwright experience.
507
+ You translate user requirements into precise, minimal, executable test scenarios.
508
+
509
+ RULE 1 - INTENT IS THE REQUIREMENT
510
+ Parse the user's feature description into specific testable claims BEFORE examining page context.
511
+ Generate scenarios for the user's intent, not for every element on the page.
512
+
513
+ RULE 2 - GENERATE TESTS FOR THE INTENT, NOT THE PAGE INVENTORY
514
+ Only generate tests for claims extracted from the intent.
515
+ Never generate tests for page elements that were not requested.
516
+ Page context improves selector quality and wording, but it does not define test scope.
517
+
518
+ RULE 3 - SELECTOR HIERARCHY
519
+ When choosing selectors, prefer:
520
+ 1. Exact page-context match when available
521
+ 2. getByRole() with text from the intent
522
+ 3. getByLabel() for form inputs
523
+ 4. getByText() with text from the intent
524
+ 5. getByPlaceholder() for inputs
525
+ 6. locator('#id') only when the id appears stable
526
+
527
+ Tie-breaker when multiple elements match:
528
+ 1. Prefer the highest-confidence page-context match
529
+ 2. Prefer visible elements over hidden
530
+ 3. Prefer interactive elements over static elements
531
+ 4. If still ambiguous, use the first match and note it with a short comment in the step text
532
+
533
+ RULE 4 - TITLE DISAMBIGUATION
534
+ "verify title" means the main visible heading on the page.
535
+ Use a heading assertion first.
536
+ Do NOT treat "title" as the browser tab title unless the intent explicitly says "browser title", "tab title", or "page title".
537
+
538
+ RULE 5 - PRESENCE-ONLY INTENTS
539
+ For presence-only intents such as "verify X", "check X is present", or "X is visible":
540
+ - Only assert visibility
541
+ - Do NOT click the element
542
+ - Do NOT assert redirects, state changes, or side effects
543
+
544
+ RULE 6 - NEGATIVE CASES
545
+ Generate negative scenarios only when the intent implies interaction:
546
+ - Form testing: always include an invalid-input scenario
547
+ - Authentication: always include a wrong-credentials scenario
548
+ - Presence checks: no negative case needed
549
+ - Button click: negative case only if the button can be disabled
550
+
551
+ RULE 7 - SETUP DEPENDENCIES
552
+ If authentication is required to reach the page:
553
+ - Add setup steps using CT_VAR_USERNAME and CT_VAR_PASSWORD
554
+ - Tag the scenario with @requires-auth
555
+ - Mention the dependency in a short step or assertion note when needed
556
+
557
+ RULE 8 - TEST DATA
558
+ Never use the literal word 'value' as a placeholder.
559
+ When filling a field:
560
+ - If the user context lists a matching CT_VAR_* variable, write it exactly as '\${CT_VAR_FIELDNAME}` + `'
561
+ - Otherwise use a field-specific fallback such as 'test-username', 'test-email@example.com', 'test-password', or 'test-search'
562
+
563
+ RULE 9 - SCOPE DISCIPLINE
564
+ Match scenario count to intent complexity:
565
+ - 1 to 2 claims: 1 to 2 scenarios maximum
566
+ - 3 to 5 claims: 3 to 5 scenarios maximum
567
+ - Full flow: 4 to 8 scenarios maximum
568
+ Never exceed 8 scenarios.
569
+
570
+ COMMON TESTING VOCABULARY
571
+ "verify/check X is present/visible" -> toBeVisible()
572
+ "verify title/heading" -> heading is visible
573
+ "button/link is present" -> button or link is visible
574
+ "can click X" -> click then assert visible outcome
575
+ "form submits" -> fill + click + assert success or URL
576
+ "error shows" -> trigger error + assert error visible
577
+ "redirects to X" -> URL contains or equals X
578
+ "text says X" -> text equals or contains X
579
+ "page loads" -> main heading or key element is visible
580
+
581
+ OUTPUT FORMAT - EXACT MARKDOWN SCHEMA
582
+ Output valid markdown in this exact format. No preamble. No explanation. Just the markdown.
504
583
 
505
- Your job:
506
- - Receive context about a web feature or page
507
- - Generate high-quality, realistic UI test cases
508
- - Format them in strict Markdown that a downstream parser will process
584
+ # {PREFIX}-001 - {scenario-slug} - {Human readable title} @tag1 @tag2
509
585
 
510
- You MUST follow this format exactly for each test case:
586
+ ## Steps
587
+ 1. Navigate to {url}
588
+ 2. {action verb} {target description}
589
+
590
+ ## Expected Results
591
+ - {specific observable assertion}
592
+ - {specific observable assertion}
593
+
594
+ ---
595
+
596
+ # {PREFIX}-002 - {scenario-slug} - {Human readable title} @tag1 @tag2
511
597
 
512
- # <ID> \u2014 <Short title> @<tag1> @<tag2>
513
598
  ## Steps
514
- 1. <user action>
515
- 2. <user action>
599
+ 1. Navigate to {url}
600
+ 2. {action verb} {target description}
516
601
 
517
602
  ## Expected Results
518
- - <verifiable outcome>
519
- - <verifiable outcome>
603
+ - {specific observable assertion}
520
604
 
521
- Rules:
522
- - ID must be PREFIX-NNN where NNN is zero-padded 3 digits starting from startIndex
523
- - Use 1\u20133 tags: @smoke @regression @auth @ui @critical @happy-path @negative
524
- - Steps = concrete user actions ("Click the 'Sign In' button", "Fill in email with 'user@example.com'")
525
- - Expected Results = verifiable UI outcomes ("Error message 'Invalid credentials' is visible", "Page redirects to /dashboard")
526
- - Include both happy-path AND negative/edge-case tests
527
- - Do NOT explain or add preamble \u2014 output ONLY the test cases
528
- - No code blocks, no extra headings outside the pattern
605
+ Rules for the markdown schema:
606
+ - id format: PREFIX-NNN using the provided prefix and numbering
607
+ - slug format: kebab-case, max 5 words, derived from the scenario subject
608
+ - title: human readable and aligned to the user's intent
609
+ - tags: include at least one of @smoke @regression @negative @happy-path plus a relevant domain tag such as @auth @ui @navigation @form @checkout
610
+ - steps: numbered, start with Navigate, then actions, written in plain English
611
+ - assertions: bullet points with observable outcomes only
612
+ - never write "test passes" or "works correctly"
613
+ - do NOT wrap the output in code fences
529
614
  `.trim();
530
615
  }
531
616
  function buildUserMessage(ctx) {
532
617
  const lines = [];
618
+ const availableCtVarKeys = getAvailableCtVarKeys();
533
619
  lines.push(`App / product description:`);
534
620
  lines.push(ctx.appDescription || "N/A");
535
621
  lines.push("");
536
- lines.push(`Feature or page to test:`);
622
+ lines.push(`Primary test intent:`);
537
623
  lines.push(ctx.feature);
538
624
  lines.push("");
625
+ lines.push(`Interpret the feature text above as the source of truth.`);
626
+ lines.push(`Only generate scenarios for claims that are explicitly implied by that intent.`);
627
+ lines.push("");
539
628
  if (ctx.url) {
540
629
  lines.push(`Page URL: ${ctx.url}`);
541
630
  lines.push("");
@@ -568,11 +657,20 @@ function buildUserMessage(ctx) {
568
657
  }
569
658
  lines.push("");
570
659
  }
660
+ if (availableCtVarKeys.length > 0) {
661
+ lines.push("Available CT_VAR_* test variables:");
662
+ for (const envKey of availableCtVarKeys) {
663
+ lines.push(`- ${envKey}`);
664
+ }
665
+ lines.push("");
666
+ lines.push(`Use a listed variable when it clearly matches a form field, written exactly as '\${CT_VAR_NAME}'.`);
667
+ lines.push("");
668
+ }
571
669
  lines.push(`Test ID prefix: ${ctx.prefix}`);
572
670
  lines.push(`Start numbering from: ${String(ctx.startIndex).padStart(3, "0")}`);
573
- lines.push(`Number of test cases: ${ctx.numCases}`);
671
+ lines.push(`Requested number of test cases: ${ctx.numCases}`);
574
672
  lines.push("");
575
- lines.push(`Include a mix of: happy-path flows, validation/error cases, and edge cases.`);
673
+ lines.push(`Use the requested count only when it fits the intent complexity. Prefer fewer scenarios when the request is simple.`);
576
674
  lines.push(`Output ONLY the test cases. No explanation, no preamble.`);
577
675
  return lines.join("\n");
578
676
  }
@@ -645,12 +743,13 @@ async function generateTcMarkdownWithAi(ctx) {
645
743
 
646
744
  // src/core/analyse.ts
647
745
  async function analyseElements(elementMap, options = {}) {
648
- const { verbose = false } = options;
746
+ const { verbose = false, feature } = options;
649
747
  const providerConfig = resolveLlmProvider();
748
+ const requestedFeature = normalizeRequestedFeature(feature, elementMap);
650
749
  log(verbose, `
651
750
  [analyse] Sending capture to ${providerConfig.displayName} (${providerConfig.model})`);
652
751
  const systemPrompt = buildSystemPrompt();
653
- const userPrompt = buildUserPrompt(elementMap);
752
+ const userPrompt = buildUserPrompt(elementMap, requestedFeature);
654
753
  const rawResponse = providerConfig.transport === "anthropic" ? await callAnthropic2(providerConfig.apiKey, providerConfig.model, systemPrompt, userPrompt) : await callOpenAiCompatible2(
655
754
  providerConfig.apiKey,
656
755
  providerConfig.model,
@@ -660,7 +759,7 @@ async function analyseElements(elementMap, options = {}) {
660
759
  userPrompt
661
760
  );
662
761
  const parsed = parseAnalysisJson(rawResponse);
663
- return sanitizeAnalysis(parsed, elementMap);
762
+ return sanitizeAnalysis(parsed, elementMap, requestedFeature);
664
763
  }
665
764
  function buildSystemPrompt() {
666
765
  return `
@@ -670,16 +769,20 @@ Your job is to analyse a structured map of interactive elements extracted from a
670
769
  then produce a complete set of Playwright test scenarios.
671
770
 
672
771
  RULES:
673
- 1. Use only selectors provided in the ElementMap.
772
+ 1. The user's feature description is the PRIMARY requirement. Generate scenarios that test what the user asked for.
674
773
  2. Every assertion must include an exact "playwright" field with a complete await expect(...) statement.
675
- 3. Use clearly fake data like user@example.com.
676
- 4. Include both happy-path and negative scenarios, but stay evidence-backed.
774
+ 3. Use captured elements as the first selector source. If the intent names text that was not captured, you may fall back to a safe Playwright selector derived from the intent such as getByRole(), getByText(), getByLabel(), getByPlaceholder(), or locator('#id').
775
+ 4. Include happy-path and negative scenarios only when the intent implies interaction, and stay evidence-backed.
677
776
  5. Output only valid JSON matching the requested schema.
678
777
  6. Do not invent redirect targets, success pages, error text, password clearing, or security scenarios unless the capture explicitly supports them.
679
778
  7. If no status or alert region was captured, avoid scenarios that depend on unseen server-side validation messages.
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.
779
+ 8. Scope must match intent complexity. Simple 1 to 2 claim requests should produce only 1 to 2 scenarios.
780
+ 9. For presence-only intents such as "verify X" or "X is present", only assert visibility. Do not click and do not assert outcomes after clicking.
781
+ 10. For fill steps, never use the literal word "value". If matching CT_VAR_* variables are listed in the prompt, use them in human-readable step text and use a realistic non-generic runtime value in the JSON value field. Otherwise use a field-specific fallback such as test-username or test-password.
782
+ 11. The "selector" field must be a raw selector expression such as locator("#username") or getByRole('button', { name: "Login" }). Never wrap selectors in page. or page.locator("...") inside JSON.
783
+ 12. Tie-breaker for multiple matching elements: prefer highest confidence, then interactive elements, then the first visible-looking candidate.
784
+ 13. 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 when the intent asks for auth interaction.
785
+ 14. "verify title" means the main visible heading on the page, not the browser tab title, unless the intent explicitly says browser title, tab title, or page title.
683
786
 
684
787
  OUTPUT SCHEMA:
685
788
  {
@@ -716,9 +819,16 @@ OUTPUT SCHEMA:
716
819
  "audioSummary": string
717
820
  }`.trim();
718
821
  }
719
- function buildUserPrompt(elementMap) {
822
+ function buildUserPrompt(elementMap, feature) {
720
823
  const lines = [];
721
824
  const authElements = detectAuthElements(elementMap);
825
+ const availableCtVarKeys = getAvailableCtVarKeys2();
826
+ const intentProfile = buildIntentProfile(feature);
827
+ lines.push("USER INTENT");
828
+ lines.push(`Feature description: ${feature}`);
829
+ lines.push(`Intent mode: ${intentProfile.mode}`);
830
+ lines.push(`Maximum scenarios for this request: ${intentProfile.maxScenarios}`);
831
+ lines.push("");
722
832
  lines.push("PAGE INFORMATION");
723
833
  lines.push(`URL: ${elementMap.url}`);
724
834
  lines.push(`Title: ${elementMap.title}`);
@@ -744,6 +854,13 @@ function buildUserPrompt(elementMap) {
744
854
  }
745
855
  lines.push("");
746
856
  }
857
+ if (availableCtVarKeys.length > 0) {
858
+ lines.push("AVAILABLE CT_VAR_* VARIABLES:");
859
+ for (const envKey of availableCtVarKeys) {
860
+ lines.push(` - ${envKey}`);
861
+ }
862
+ lines.push("");
863
+ }
747
864
  const interactiveCount = elementMap.elements.filter((element) => element.category === "input" || element.category === "button" || element.category === "link").length;
748
865
  const statusCount = elementMap.elements.filter((element) => element.category === "status").length;
749
866
  lines.push("EVIDENCE CONSTRAINTS:");
@@ -751,6 +868,7 @@ function buildUserPrompt(elementMap) {
751
868
  lines.push(` - Status or alert regions captured: ${statusCount}`);
752
869
  lines.push(" - If no redirect target is explicitly captured, do not assert a destination path.");
753
870
  lines.push(" - If no status region was captured, avoid exact server-side credential error claims.");
871
+ lines.push(" - Generate scenarios only for the requested feature description.");
754
872
  if (authElements.usernameInput && authElements.passwordInput && authElements.submitButton) {
755
873
  lines.push(" - This page contains a captured auth form. Include fill steps for the username/email and password fields in credential-submission scenarios.");
756
874
  lines.push(" - For auth pages without captured post-submit evidence, prefer evidence-backed form-state assertions over invented success redirects.");
@@ -819,15 +937,21 @@ ${raw.slice(0, 500)}`
819
937
  );
820
938
  }
821
939
  }
822
- function sanitizeAnalysis(analysis, elementMap) {
940
+ function sanitizeAnalysis(analysis, elementMap, requestedFeature) {
823
941
  const selectors = /* @__PURE__ */ new Set();
824
942
  for (const element of elementMap.elements) {
825
943
  selectors.add(element.selector);
826
944
  for (const alt of element.selectorAlt) selectors.add(alt);
827
945
  }
828
946
  const rawScenarios = Array.isArray(analysis.scenarios) ? analysis.scenarios : [];
947
+ const intentProfile = buildIntentProfile(requestedFeature);
829
948
  const currentUrl = new URL(elementMap.url);
830
949
  const authElements = detectAuthElements(elementMap);
950
+ const resolvedFeature = requestedFeature || analysis.feature || inferFeatureName(elementMap);
951
+ const resolvedPrefix = (analysis.suggestedPrefix || inferPrefix({
952
+ featureText: resolvedFeature,
953
+ url: elementMap.url
954
+ })).toUpperCase();
831
955
  const knownPaths = /* @__PURE__ */ new Set([
832
956
  currentUrl.pathname,
833
957
  ...elementMap.elements.filter((element) => element.category === "link").map((element) => {
@@ -850,21 +974,28 @@ function sanitizeAnalysis(analysis, elementMap) {
850
974
  if (!pathMatch) return true;
851
975
  return knownPaths.has(pathMatch[0]);
852
976
  })
853
- })).filter((scenario) => scenario.assertions.length > 0).slice(0, 5);
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");
977
+ })).filter((scenario) => scenario.assertions.length > 0);
978
+ const intentAlignedScenarios = applyIntentPolicy(
979
+ sanitizedScenarios,
980
+ elementMap,
981
+ intentProfile,
982
+ resolvedPrefix
983
+ ).slice(0, intentProfile.maxScenarios);
984
+ const useAuthFallback = shouldUseAuthFallback(authElements, intentAlignedScenarios, intentProfile);
985
+ const fallbackScenarios = intentProfile.mode === "presence" ? buildPresenceOnlyScenarios(elementMap, requestedFeature, resolvedPrefix) : useAuthFallback ? buildAuthFallbackScenarios(elementMap, resolvedPrefix, authElements) : buildFallbackScenarios(elementMap, resolvedPrefix);
986
+ const finalScenarios = useAuthFallback ? fallbackScenarios.slice(0, intentProfile.maxScenarios) : intentAlignedScenarios.length > 0 ? intentAlignedScenarios : fallbackScenarios.slice(0, intentProfile.maxScenarios);
856
987
  return {
857
988
  ...analysis,
858
989
  url: analysis.url || elementMap.url,
859
- feature: analysis.feature || inferFeatureName(elementMap),
860
- suggestedPrefix: (analysis.suggestedPrefix || inferPrefix2(elementMap)).toUpperCase(),
990
+ feature: resolvedFeature,
991
+ suggestedPrefix: resolvedPrefix,
861
992
  scenarios: finalScenarios,
862
993
  analysisNotes: [
863
994
  analysis.analysisNotes,
864
995
  useAuthFallback ? "Replaced low-evidence auth scenarios with deterministic evidence-backed auth coverage." : "",
865
996
  `Sanitized to ${finalScenarios.length} evidence-backed scenario(s) from ${rawScenarios.length} raw scenario(s).`
866
997
  ].filter(Boolean).join(" "),
867
- audioSummary: analysis.audioSummary || buildAudioSummary(analysis.feature || inferFeatureName(elementMap), finalScenarios)
998
+ audioSummary: analysis.audioSummary || buildAudioSummary(resolvedFeature, finalScenarios)
868
999
  };
869
1000
  }
870
1001
  function normalizeScenario(candidate, selectors, url) {
@@ -884,20 +1015,28 @@ function normalizeScenario(candidate, selectors, url) {
884
1015
  function normalizeStep(candidate, selectors) {
885
1016
  if (!candidate) return null;
886
1017
  const selector = String(candidate.selector ?? "").trim();
887
- if (selector !== "page" && !selectors.has(selector)) return null;
1018
+ if (!isAllowedSelector(selector, selectors)) return null;
888
1019
  const action = String(candidate.action ?? "").trim();
889
1020
  if (!["navigate", "fill", "click", "select", "check", "keyboard", "hover"].includes(action)) return null;
1021
+ const rawValue = String(candidate.value ?? "");
1022
+ const value = action === "fill" || action === "select" ? resolveFieldRuntimeValue(selector, rawValue) : rawValue;
1023
+ const human = normalizeStepHuman(
1024
+ String(candidate.human ?? "").trim() || defaultStepHuman(action, selector, value),
1025
+ action,
1026
+ selector,
1027
+ value
1028
+ );
890
1029
  return {
891
1030
  action,
892
1031
  selector,
893
- value: String(candidate.value ?? ""),
894
- human: String(candidate.human ?? "").trim() || defaultStepHuman(action, selector, String(candidate.value ?? ""))
1032
+ value,
1033
+ human
895
1034
  };
896
1035
  }
897
1036
  function normalizeAssertion(candidate, selectors) {
898
1037
  if (!candidate) return null;
899
1038
  const selector = String(candidate.selector ?? "").trim();
900
- if (selector !== "page" && !selectors.has(selector)) return null;
1039
+ if (!isAllowedSelector(selector, selectors)) return null;
901
1040
  const assertion = {
902
1041
  type: String(candidate.type ?? "visible").trim() || "visible",
903
1042
  selector,
@@ -943,6 +1082,7 @@ function buildFallbackScenarios(elementMap, prefix) {
943
1082
  });
944
1083
  }
945
1084
  if (emailInput && passwordInput && submitButton) {
1085
+ const emailValue = resolveFieldRuntimeValue(emailInput.selector, "");
946
1086
  scenarios.push({
947
1087
  id: nextId(scenarios.length + 1),
948
1088
  title: "Submitting without a password keeps the user on the form",
@@ -957,8 +1097,8 @@ function buildFallbackScenarios(elementMap, prefix) {
957
1097
  {
958
1098
  action: "fill",
959
1099
  selector: emailInput.selector,
960
- value: "user@example.com",
961
- human: "Fill in the email field"
1100
+ value: emailValue,
1101
+ human: buildFillHuman("Fill in the email field", emailInput.selector, emailValue)
962
1102
  },
963
1103
  {
964
1104
  action: "click",
@@ -1059,13 +1199,13 @@ function buildAuthFallbackScenarios(elementMap, prefix, authElements) {
1059
1199
  action: "fill",
1060
1200
  selector: usernameInput.selector,
1061
1201
  value: usernameValue,
1062
- human: "Fill in the username field"
1202
+ human: buildFillHuman("Fill in the username field", usernameInput.selector, usernameValue)
1063
1203
  },
1064
1204
  {
1065
1205
  action: "fill",
1066
1206
  selector: passwordInput.selector,
1067
1207
  value: passwordValue,
1068
- human: "Fill in the password field"
1208
+ human: buildFillHuman("Fill in the password field", passwordInput.selector, passwordValue)
1069
1209
  }
1070
1210
  ],
1071
1211
  assertions: [
@@ -1109,7 +1249,7 @@ function buildAuthFallbackScenarios(elementMap, prefix, authElements) {
1109
1249
  action: "fill",
1110
1250
  selector: usernameInput.selector,
1111
1251
  value: usernameValue,
1112
- human: "Fill in the username field"
1252
+ human: buildFillHuman("Fill in the username field", usernameInput.selector, usernameValue)
1113
1253
  },
1114
1254
  {
1115
1255
  action: "click",
@@ -1146,16 +1286,136 @@ function buildAuthFallbackScenarios(elementMap, prefix, authElements) {
1146
1286
  });
1147
1287
  return scenarios;
1148
1288
  }
1289
+ function normalizeRequestedFeature(feature, elementMap) {
1290
+ return String(feature ?? "").trim() || inferFeatureName(elementMap);
1291
+ }
1149
1292
  function inferFeatureName(elementMap) {
1150
1293
  const heading = elementMap.elements.find((element) => element.category === "heading" && element.name);
1151
1294
  return heading?.name || elementMap.title || "Captured page";
1152
1295
  }
1153
- function inferPrefix2(elementMap) {
1154
- const source = `${elementMap.title} ${elementMap.url}`.toLowerCase();
1155
- if (/\blogin|sign in|auth/.test(source)) return "AUTH";
1156
- if (/\bcheckout|cart|payment/.test(source)) return "CHK";
1157
- if (/\bdashboard/.test(source)) return "DASH";
1158
- return "FLOW";
1296
+ function getAvailableCtVarKeys2() {
1297
+ return Object.keys(process.env).filter((key) => /^CT_VAR_[A-Z0-9_]+$/.test(key)).sort();
1298
+ }
1299
+ function buildIntentProfile(feature) {
1300
+ const normalized = feature.toLowerCase();
1301
+ const wantsHeading = /\b(?:title|heading)\b/.test(normalized) && !/\b(?:browser title|tab title|page title)\b/.test(normalized);
1302
+ const presenceOnly = isPresenceOnlyIntent(normalized);
1303
+ const authIntent = /\b(?:login|log in|sign in|auth|credentials?)\b/.test(normalized) && !presenceOnly;
1304
+ const formIntent = /\b(?:form|submit|field|input|enter|fill|type)\b/.test(normalized) && !presenceOnly && !authIntent;
1305
+ const flowIntent = /\b(?:flow|journey|checkout|purchase|complete|works?)\b/.test(normalized) && !presenceOnly && !authIntent && !formIntent;
1306
+ const mode = presenceOnly ? "presence" : authIntent ? "auth" : formIntent ? "form" : flowIntent ? "flow" : "generic";
1307
+ const claimCount = countIntentClaims(feature);
1308
+ return {
1309
+ feature,
1310
+ mode,
1311
+ maxScenarios: mode === "presence" ? Math.min(Math.max(claimCount, 1), 2) : mode === "auth" || mode === "form" ? Math.min(Math.max(claimCount, 3), 5) : mode === "flow" ? Math.min(Math.max(claimCount, 4), 6) : Math.min(Math.max(claimCount, 1), 3),
1312
+ wantsHeading
1313
+ };
1314
+ }
1315
+ function countIntentClaims(feature) {
1316
+ const quoted = Array.from(feature.matchAll(/["“”']([^"“”']+)["“”']/g)).length;
1317
+ const segments = feature.split(/\s+(?:and|&)\s+|,\s*/i).map((segment) => segment.trim()).filter(Boolean);
1318
+ return Math.max(quoted, segments.length || 1);
1319
+ }
1320
+ function isPresenceOnlyIntent(normalizedFeature) {
1321
+ const presenceWords = /\b(?:verify|check|confirm|ensure|present|visible|shown|showing|available|exists?|title|heading)\b/.test(normalizedFeature);
1322
+ const actionWords = /\b(?:click|submit|fill|type|enter|login|log in|sign in|checkout|purchase|complete|flow|journey|error|invalid|disabled|enabled)\b/.test(normalizedFeature);
1323
+ return presenceWords && !actionWords;
1324
+ }
1325
+ function applyIntentPolicy(scenarios, elementMap, intentProfile, prefix) {
1326
+ if (intentProfile.mode !== "presence") {
1327
+ return scenarios.slice(0, intentProfile.maxScenarios);
1328
+ }
1329
+ return buildPresenceOnlyScenarios(elementMap, intentProfile.feature, prefix);
1330
+ }
1331
+ function buildPresenceOnlyScenarios(elementMap, feature, prefix) {
1332
+ const claims = extractPresenceClaims(feature, elementMap);
1333
+ if (claims.length === 0) return buildFallbackScenarios(elementMap, prefix).slice(0, 1);
1334
+ return [{
1335
+ id: `${prefix}-${String(1).padStart(3, "0")}`,
1336
+ title: feature,
1337
+ tags: ["@smoke", "@ui"],
1338
+ steps: [
1339
+ {
1340
+ action: "navigate",
1341
+ selector: "page",
1342
+ value: elementMap.url,
1343
+ human: `Navigate to ${elementMap.url}`
1344
+ }
1345
+ ],
1346
+ assertions: claims.map((claim) => ({
1347
+ type: "visible",
1348
+ selector: claim.selector,
1349
+ expected: "visible",
1350
+ human: claim.human,
1351
+ playwright: visibleAssertion(claim.selector)
1352
+ })),
1353
+ narrator: "We verify only the elements explicitly requested by the user.",
1354
+ codeLevel: "beginner"
1355
+ }];
1356
+ }
1357
+ function extractPresenceClaims(feature, elementMap) {
1358
+ const segments = feature.split(/\s+(?:and|&)\s+|,\s*/i).map((segment) => segment.trim()).filter(Boolean);
1359
+ const claims = [];
1360
+ for (const segment of segments) {
1361
+ const normalized = segment.toLowerCase();
1362
+ if (/\b(?:title|heading)\b/.test(normalized) && !/\b(?:browser title|tab title|page title)\b/.test(normalized)) {
1363
+ const heading = chooseHeadingElement(elementMap);
1364
+ const selector2 = heading?.selector ?? `getByRole('heading')`;
1365
+ claims.push({
1366
+ selector: selector2,
1367
+ human: `${heading?.name || "Main page heading"} is visible`
1368
+ });
1369
+ continue;
1370
+ }
1371
+ const text = extractIntentLabel(segment);
1372
+ if (!text) continue;
1373
+ const match = findBestMatchingElement(elementMap, text, normalized);
1374
+ const selector = match?.selector ?? buildFallbackSelector(text, normalized);
1375
+ claims.push({
1376
+ selector,
1377
+ human: `${text} is visible`
1378
+ });
1379
+ }
1380
+ return claims;
1381
+ }
1382
+ function chooseHeadingElement(elementMap) {
1383
+ return rankElements(
1384
+ elementMap.elements.filter((element) => element.category === "heading")
1385
+ )[0];
1386
+ }
1387
+ function extractIntentLabel(segment) {
1388
+ const unquoted = Array.from(segment.matchAll(/["“”']([^"“”']+)["“”']/g)).map((match) => match[1].trim());
1389
+ if (unquoted.length > 0) return unquoted[0];
1390
+ return segment.replace(/\b(?:verify|check|confirm|ensure)\b/gi, " ").replace(/\b(?:the|a|an)\b/gi, " ").replace(/\b(?:is|are|be|should be)\b/gi, " ").replace(/\b(?:present|visible|shown|showing|available|exists?)\b/gi, " ").replace(/\b(?:button|link|cta|label|text)\b/gi, " ").replace(/[.?!,:;]+/g, " ").replace(/\s+/g, " ").trim();
1391
+ }
1392
+ function findBestMatchingElement(elementMap, text, segment) {
1393
+ const preferredCategory = /\bbutton\b/.test(segment) ? "button" : /\blink\b/.test(segment) ? "link" : /\bheading\b/.test(segment) ? "heading" : void 0;
1394
+ const query = text.toLowerCase();
1395
+ const candidates = elementMap.elements.filter((element) => {
1396
+ if (preferredCategory && element.category !== preferredCategory) return false;
1397
+ const haystack = `${element.name ?? ""} ${element.purpose}`.toLowerCase();
1398
+ return haystack.includes(query);
1399
+ });
1400
+ return rankElements(candidates)[0];
1401
+ }
1402
+ function rankElements(elements) {
1403
+ const confidenceWeight = (confidence) => confidence === "high" ? 3 : confidence === "medium" ? 2 : 1;
1404
+ const interactiveWeight = (category) => category === "button" || category === "link" || category === "input" ? 2 : 1;
1405
+ return [...elements].sort((left, right) => {
1406
+ const confidenceDelta = confidenceWeight(right.confidence) - confidenceWeight(left.confidence);
1407
+ if (confidenceDelta !== 0) return confidenceDelta;
1408
+ const interactiveDelta = interactiveWeight(right.category) - interactiveWeight(left.category);
1409
+ if (interactiveDelta !== 0) return interactiveDelta;
1410
+ return 0;
1411
+ });
1412
+ }
1413
+ function buildFallbackSelector(text, segment) {
1414
+ const safeRegex = text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1415
+ if (/\bbutton\b/.test(segment)) return `getByRole('button', { name: /${safeRegex}/i })`;
1416
+ if (/\blink\b/.test(segment)) return `getByRole('link', { name: /${safeRegex}/i })`;
1417
+ if (/\b(?:title|heading)\b/.test(segment)) return `getByRole('heading', { name: /${safeRegex}/i })`;
1418
+ return `getByText(${JSON.stringify(text)})`;
1159
1419
  }
1160
1420
  function buildAudioSummary(feature, scenarios) {
1161
1421
  return `We finished validating ${feature || "the captured page"} with ${scenarios.length} evidence-backed scenario${scenarios.length === 1 ? "" : "s"}.`;
@@ -1171,7 +1431,8 @@ function detectAuthElements(elementMap) {
1171
1431
  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
1432
  return { usernameInput, passwordInput, submitButton, heading };
1173
1433
  }
1174
- function shouldUseAuthFallback(authElements, scenarios) {
1434
+ function shouldUseAuthFallback(authElements, scenarios, intentProfile) {
1435
+ if (intentProfile.mode === "presence") return false;
1175
1436
  if (!authElements.usernameInput || !authElements.passwordInput || !authElements.submitButton) return false;
1176
1437
  if (scenarios.length === 0) return true;
1177
1438
  const hasCredentialEntry = scenarios.some((scenario) => {
@@ -1185,13 +1446,19 @@ function shouldUseAuthFallback(authElements, scenarios) {
1185
1446
  }
1186
1447
  function defaultStepHuman(action, selector, value) {
1187
1448
  if (action === "navigate") return `Navigate to ${value || "the page"}`;
1188
- if (action === "fill") return `Fill the field ${selector}`;
1449
+ if (action === "fill") return buildFillHuman(`Fill the field ${selector}`, selector, value);
1189
1450
  if (action === "click") return `Click ${selector}`;
1190
1451
  if (action === "select") return `Select ${value} in ${selector}`;
1191
1452
  if (action === "check") return `Check ${selector}`;
1192
1453
  if (action === "keyboard") return `Press ${value}`;
1193
1454
  return `Interact with ${selector}`;
1194
1455
  }
1456
+ function normalizeStepHuman(human, action, selector, value) {
1457
+ if (action !== "fill" && action !== "select") return human;
1458
+ if (!value) return human;
1459
+ if (/\bwith\s+['"`]/i.test(human) || /\$\{CT_VAR_[A-Z0-9_]+\}/.test(human)) return human;
1460
+ return buildFillHuman(human, selector, value);
1461
+ }
1195
1462
  function normalizePlaywrightAssertion(assertion) {
1196
1463
  const repaired = scopeBareSelectorsInAssertion(unwrapWrappedSelectorAssertion(assertion.playwright));
1197
1464
  if (repaired) return ensureStatement(repaired);
@@ -1246,10 +1513,54 @@ function valueAssertion(selector, value) {
1246
1513
  return `await expect(${selectorTarget(selector)}).toHaveValue(${JSON.stringify(value)});`;
1247
1514
  }
1248
1515
  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";
1516
+ if (kind === "password") return resolveFieldRuntimeValue(element.selector, "test-password");
1517
+ return resolveFieldRuntimeValue(element.selector, "");
1518
+ }
1519
+ function buildFillHuman(base, selector, runtimeValue) {
1520
+ const displayValue = resolveFieldDisplayValue(selector, runtimeValue);
1521
+ return `${base} with '${displayValue}'`;
1522
+ }
1523
+ function resolveFieldRuntimeValue(selector, currentValue) {
1524
+ if (currentValue && !isGenericFillValue(currentValue) && !extractCtVarTemplate(currentValue)) {
1525
+ return currentValue;
1526
+ }
1527
+ const envKey = matchCtVarKey(selector);
1528
+ if (envKey && process.env[envKey]) {
1529
+ return String(process.env[envKey]);
1530
+ }
1531
+ return fallbackFieldValue(selector);
1532
+ }
1533
+ function resolveFieldDisplayValue(selector, runtimeValue) {
1534
+ const envKey = matchCtVarKey(selector);
1535
+ if (envKey) return `\${${envKey}}`;
1536
+ return runtimeValue;
1537
+ }
1538
+ function matchCtVarKey(selectorOrText) {
1539
+ const haystack = selectorOrText.toLowerCase();
1540
+ const preferred = /\bemail\b/.test(haystack) ? "CT_VAR_EMAIL" : /\bpassword\b/.test(haystack) ? "CT_VAR_PASSWORD" : /\bsearch\b/.test(haystack) ? "CT_VAR_SEARCH" : /\bphone|tel\b/.test(haystack) ? "CT_VAR_PHONE" : /\bname\b/.test(haystack) ? "CT_VAR_NAME" : /\buser(?:name)?|login\b/.test(haystack) ? "CT_VAR_USERNAME" : void 0;
1541
+ if (preferred && process.env[preferred] !== void 0) return preferred;
1542
+ return getAvailableCtVarKeys2().find((key) => new RegExp(key.replace(/^CT_VAR_/, "").toLowerCase()).test(haystack));
1543
+ }
1544
+ function fallbackFieldValue(selectorOrText) {
1545
+ const haystack = selectorOrText.toLowerCase();
1546
+ if (/\bemail\b/.test(haystack)) return "test-email@example.com";
1547
+ if (/\bpassword\b/.test(haystack)) return "test-password";
1548
+ if (/\bsearch\b/.test(haystack)) return "test-search";
1549
+ if (/\bphone|tel\b/.test(haystack)) return "test-phone";
1550
+ if (/\bname\b/.test(haystack) && !/\buser(?:name)?\b/.test(haystack)) return "test-name";
1551
+ if (/\buser(?:name)?|login\b/.test(haystack)) return "test-username";
1552
+ return "test-input";
1553
+ }
1554
+ function isGenericFillValue(value) {
1555
+ return /^(?:value|text|input|option|selection|selected value|default)$/i.test(value.trim());
1556
+ }
1557
+ function extractCtVarTemplate(value) {
1558
+ return value.match(/^\$\{(CT_VAR_[A-Z0-9_]+)\}$/)?.[1];
1559
+ }
1560
+ function isAllowedSelector(selector, selectors) {
1561
+ if (selector === "page") return true;
1562
+ if (selectors.has(selector)) return true;
1563
+ return /^(?:getByRole|getByText|getByLabel|getByPlaceholder|getByTestId)\(/.test(selector) || /^locator\((['"])(#.+?)\1\)$/.test(selector);
1253
1564
  }
1254
1565
  function ensureStatement(value) {
1255
1566
  const trimmed = String(value ?? "").trim();
@@ -2056,7 +2367,7 @@ async function runTcInteractive(params) {
2056
2367
  console.log("Capture-only mode requested. No test cases were generated.");
2057
2368
  return;
2058
2369
  }
2059
- const analysis = await analyseElements(elementMap, { verbose: true });
2370
+ const analysis = await analyseElements(elementMap, { verbose: true, feature });
2060
2371
  printCaptureReport(elementMap, analysis);
2061
2372
  const previewPath = saveSpecPreview(analysis);
2062
2373
  if (previewPath) {