@cementic/cementic-test 0.2.13 → 0.2.15

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,16 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ captureElements,
4
+ formatCaptureFailure,
5
+ toPageSummary
6
+ } from "./chunk-XUWFEWJZ.js";
2
7
  import {
3
8
  genCmd
4
- } from "./chunk-RG26I5FB.js";
9
+ } from "./chunk-3S26OWNR.js";
5
10
 
6
11
  // src/cli.ts
7
12
  import { Command as Command9 } from "commander";
8
- import { createRequire as createRequire2 } from "module";
13
+ import { createRequire } from "module";
9
14
 
10
15
  // src/commands/new.ts
11
16
  import { Command } from "commander";
@@ -18,6 +23,8 @@ import { platform, release } from "os";
18
23
  var __filename = fileURLToPath(import.meta.url);
19
24
  var __dirname = dirname(__filename);
20
25
  var LEGACY_MACOS_DARWIN_MAJOR = 23;
26
+ var LEGACY_PLAYWRIGHT_VERSION = "^1.48.2";
27
+ var LEGACY_ALLURE_VERSION = "^2.15.1";
21
28
  function resolveTemplatePath(templateDir) {
22
29
  const candidates = [
23
30
  resolve(__dirname, `templates/${templateDir}`),
@@ -27,13 +34,73 @@ function resolveTemplatePath(templateDir) {
27
34
  ];
28
35
  return candidates.find((candidate) => existsSync(candidate));
29
36
  }
37
+ function getHostPlatform(env = process.env) {
38
+ return (env.CT_OS_PLATFORM_OVERRIDE ?? platform()).trim().toLowerCase();
39
+ }
40
+ function getHostRelease(env = process.env) {
41
+ return (env.CT_OS_RELEASE_OVERRIDE ?? release()).trim();
42
+ }
43
+ function isLegacyMacOs(env = process.env) {
44
+ if (getHostPlatform(env) !== "darwin") return false;
45
+ const majorVersion = parseInt(getHostRelease(env).split(".")[0], 10);
46
+ return Number.isFinite(majorVersion) && majorVersion < LEGACY_MACOS_DARWIN_MAJOR;
47
+ }
48
+ function resolveBrowserInstallProfile(raw) {
49
+ const profile = String(raw ?? "auto").trim().toLowerCase();
50
+ if (profile === "auto" || profile === "all" || profile === "chromium") return profile;
51
+ console.error(`\u274C Unsupported browser install profile "${raw}". Use "auto", "all", or "chromium".`);
52
+ process.exit(1);
53
+ }
54
+ function resolveBootstrapPlan(browserProfile, env = process.env) {
55
+ const legacyMacOs = isLegacyMacOs(env);
56
+ const resolvedProfile = browserProfile === "auto" ? legacyMacOs ? "chromium" : "all" : browserProfile;
57
+ return {
58
+ browserInstallArgs: resolvedProfile === "chromium" ? ["playwright", "install", "chromium"] : ["playwright", "install"],
59
+ browserProfile: resolvedProfile,
60
+ isLegacyMacOs: legacyMacOs,
61
+ packageVersionOverrides: legacyMacOs ? {
62
+ "@playwright/test": LEGACY_PLAYWRIGHT_VERSION,
63
+ "allure-playwright": LEGACY_ALLURE_VERSION
64
+ } : {},
65
+ reason: legacyMacOs ? "Detected macOS 13 or older. Using a legacy-compatible Playwright toolchain and Chromium-only browser install." : void 0
66
+ };
67
+ }
68
+ function applyPackageVersionOverrides(projectPath, versionOverrides) {
69
+ if (Object.keys(versionOverrides).length === 0) return;
70
+ const pkgJsonPath = join(projectPath, "package.json");
71
+ if (!existsSync(pkgJsonPath)) return;
72
+ try {
73
+ const pkgContent = readFileSync(pkgJsonPath, "utf-8");
74
+ const pkg = JSON.parse(pkgContent);
75
+ const devDependencies = pkg.devDependencies ?? {};
76
+ let changed = false;
77
+ for (const [name, version2] of Object.entries(versionOverrides)) {
78
+ if (typeof devDependencies[name] !== "string") continue;
79
+ devDependencies[name] = version2;
80
+ changed = true;
81
+ }
82
+ if (!changed) return;
83
+ pkg.devDependencies = devDependencies;
84
+ writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, 2));
85
+ console.log("\u2705 Applied compatibility dependency pins for this machine.");
86
+ } catch (err) {
87
+ console.warn("\u26A0\uFE0F Failed to adjust package.json for OS compatibility:", err);
88
+ }
89
+ }
30
90
  function newCmd() {
31
91
  const cmd = new Command("new").arguments("<projectName>").description("Scaffold a new CementicTest + Playwright project from scratch").addHelpText("after", `
32
92
  Examples:
33
93
  $ ct new my-awesome-test-suite
34
94
  $ ct new e2e-ts --lang ts
35
95
  $ ct new e2e-tests --no-browsers
36
- `).option("--mode <mode>", "greenfield|enhance", "greenfield").option("--lang <lang>", "Scaffold language (js|ts)", "js").option("--no-browsers", 'do not run "npx playwright install" during setup').action((projectName, opts) => {
96
+ `).option("--mode <mode>", "greenfield|enhance", "greenfield").option("--lang <lang>", "Scaffold language (js|ts)", "js").option("--browser-set <profile>", "Browser install profile (auto|all|chromium)", "auto").option("--no-browsers", 'do not run "npx playwright install" during setup').action((projectName, opts) => {
97
+ const mode = String(opts.mode ?? "greenfield").trim().toLowerCase();
98
+ if (mode !== "greenfield") {
99
+ console.error(`\u274C Unsupported scaffold mode "${opts.mode}". Only "greenfield" is currently implemented.`);
100
+ process.exit(1);
101
+ }
102
+ const browserInstallProfile = resolveBrowserInstallProfile(opts.browserSet);
103
+ const bootstrapPlan = resolveBootstrapPlan(browserInstallProfile);
37
104
  const root = process.cwd();
38
105
  const projectPath = join(root, projectName);
39
106
  console.log(`\u{1F680} Initializing new CementicTest project in ${projectName}...`);
@@ -62,25 +129,10 @@ Examples:
62
129
  }
63
130
  }
64
131
  copyRecursive(templatePath, projectPath);
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.");
78
- }
79
- } catch (err) {
80
- console.warn("\u26A0\uFE0F Failed to adjust package.json for OS compatibility:", err);
81
- }
82
- }
132
+ if (bootstrapPlan.reason) {
133
+ console.log(`\u{1F34E} ${bootstrapPlan.reason}`);
83
134
  }
135
+ applyPackageVersionOverrides(projectPath, bootstrapPlan.packageVersionOverrides);
84
136
  try {
85
137
  execSync("git init", { cwd: projectPath, stdio: "ignore" });
86
138
  const gitignorePath = join(projectPath, ".gitignore");
@@ -101,17 +153,19 @@ Examples:
101
153
  if (opts.browsers !== false && dependenciesInstalled) {
102
154
  console.log("\u{1F310} Installing Playwright browsers...");
103
155
  try {
104
- if (legacyMacOs) {
156
+ const installCommand = `npx ${bootstrapPlan.browserInstallArgs.join(" ")}`;
157
+ if (bootstrapPlan.browserProfile === "chromium" && bootstrapPlan.isLegacyMacOs) {
105
158
  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.");
159
+ } else if (bootstrapPlan.browserProfile === "chromium") {
160
+ console.log("\u2139\uFE0F Installing Chromium only because --browser-set chromium was requested.");
111
161
  }
162
+ execSync(installCommand, { cwd: projectPath, stdio: "inherit" });
163
+ console.log(
164
+ bootstrapPlan.browserProfile === "chromium" ? "\u2705 Chromium installed successfully." : "\u2705 Playwright browsers installed successfully."
165
+ );
112
166
  } catch (e) {
113
167
  console.warn("\u26A0\uFE0F Browser installation did not complete. You can finish setup with:");
114
- console.warn(" npx playwright install chromium");
168
+ console.warn(` npx ${bootstrapPlan.browserInstallArgs.join(" ")}`);
115
169
  }
116
170
  }
117
171
  console.log(`
@@ -126,11 +180,6 @@ Happy testing! \u{1F9EA}`);
126
180
  });
127
181
  return cmd;
128
182
  }
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
- }
134
183
 
135
184
  // src/commands/normalize.ts
136
185
  import { Command as Command2 } from "commander";
@@ -321,7 +370,7 @@ Examples:
321
370
  }
322
371
  console.log(`\u2705 Normalized ${index.summary.parsed} case(s). Output \u2192 .cementic/normalized/`);
323
372
  if (opts.andGen) {
324
- const { gen } = await import("./gen-6Y65IYXO.js");
373
+ const { gen } = await import("./gen-AGWFMHTO.js");
325
374
  await gen({ lang: opts.lang || "ts", out: "tests/generated" });
326
375
  }
327
376
  });
@@ -352,7 +401,7 @@ function testCmd() {
352
401
  // src/commands/tc.ts
353
402
  import { Command as Command4 } from "commander";
354
403
  import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
355
- import { join as join5 } from "path";
404
+ import { join as join4 } from "path";
356
405
  import { createInterface } from "readline/promises";
357
406
  import { stdin as input, stdout as output } from "process";
358
407
 
@@ -1054,6 +1103,9 @@ async function generateTcMarkdownWithAi(ctx) {
1054
1103
  }
1055
1104
 
1056
1105
  // src/core/analyse.ts
1106
+ function escapeForRegex(value) {
1107
+ return value.replace(/[.*+?^${}()|[\]\\\/]/g, "\\$&");
1108
+ }
1057
1109
  var RULE_10_PLAYWRIGHT_KNOWLEDGE_BASE2 = `
1058
1110
  RULE 10 - PLAYWRIGHT KNOWLEDGE BASE
1059
1111
  (sourced from playwright.dev official documentation)
@@ -1365,23 +1417,37 @@ ADDITIONAL PLAYWRIGHT DOCS (fetched for this intent)
1365
1417
  }
1366
1418
  async function analyseElements(elementMap, options = {}) {
1367
1419
  const { verbose = false, feature } = options;
1368
- const providerConfig = resolveLlmProvider();
1369
1420
  const requestedFeature = normalizeRequestedFeature(feature, elementMap);
1421
+ let providerConfig;
1422
+ try {
1423
+ providerConfig = resolveLlmProvider();
1424
+ } catch (error) {
1425
+ log(verbose, `
1426
+ [analyse] ${shortErrorMessage(error)}`);
1427
+ log(verbose, "[analyse] Falling back to deterministic capture analysis.");
1428
+ return buildDeterministicAnalysis(elementMap, requestedFeature);
1429
+ }
1370
1430
  log(verbose, `
1371
1431
  [analyse] Sending capture to ${providerConfig.displayName} (${providerConfig.model})`);
1372
- const docContext = await fetchDocContext2(requestedFeature);
1373
- const systemPrompt = buildSystemPrompt() + docContext;
1374
- const userPrompt = buildUserPrompt(elementMap, requestedFeature);
1375
- const rawResponse = providerConfig.transport === "anthropic" ? await callAnthropic2(providerConfig.apiKey, providerConfig.model, systemPrompt, userPrompt) : await callOpenAiCompatible2(
1376
- providerConfig.apiKey,
1377
- providerConfig.model,
1378
- providerConfig.baseUrl ?? "https://api.openai.com/v1",
1379
- providerConfig.displayName,
1380
- systemPrompt,
1381
- userPrompt
1382
- );
1383
- const parsed = parseAnalysisJson(rawResponse);
1384
- return sanitizeAnalysis(parsed, elementMap, requestedFeature);
1432
+ try {
1433
+ const docContext = await fetchDocContext2(requestedFeature);
1434
+ const systemPrompt = buildSystemPrompt() + docContext;
1435
+ const userPrompt = buildUserPrompt(elementMap, requestedFeature);
1436
+ const rawResponse = providerConfig.transport === "anthropic" ? await callAnthropic2(providerConfig.apiKey, providerConfig.model, systemPrompt, userPrompt) : await callOpenAiCompatible2(
1437
+ providerConfig.apiKey,
1438
+ providerConfig.model,
1439
+ providerConfig.baseUrl ?? "https://api.openai.com/v1",
1440
+ providerConfig.displayName,
1441
+ systemPrompt,
1442
+ userPrompt
1443
+ );
1444
+ const parsed = parseAnalysisJson(rawResponse);
1445
+ return sanitizeAnalysis(parsed, elementMap, requestedFeature);
1446
+ } catch (error) {
1447
+ log(verbose, `[analyse] Remote analysis failed: ${shortErrorMessage(error)}`);
1448
+ log(verbose, "[analyse] Falling back to deterministic capture analysis.");
1449
+ return buildDeterministicAnalysis(elementMap, requestedFeature);
1450
+ }
1385
1451
  }
1386
1452
  function buildSystemPrompt() {
1387
1453
  return `
@@ -1402,10 +1468,11 @@ RULES:
1402
1468
  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.
1403
1469
  ${RULE_10_PLAYWRIGHT_KNOWLEDGE_BASE2}
1404
1470
  11. 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.
1405
- 12. 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.
1406
- 13. Tie-breaker for multiple matching elements: prefer highest confidence, then interactive elements, then the first visible-looking candidate.
1407
- 14. 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.
1408
- 15. "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.
1471
+ 12. When a CTA could realistically be a button or a link, prefer a resilient selector such as getByRole('button', { name: /Join Now/i }).or(page.getByRole('link', { name: /Join Now/i })).
1472
+ 13. 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, except inside Locator.or(...) where the alternate locator must be page-scoped.
1473
+ 14. Tie-breaker for multiple matching elements: prefer highest confidence, then interactive elements, then the first visible-looking candidate.
1474
+ 15. 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.
1475
+ 16. "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.
1409
1476
 
1410
1477
  OUTPUT SCHEMA:
1411
1478
  {
@@ -1605,7 +1672,7 @@ function sanitizeAnalysis(analysis, elementMap, requestedFeature) {
1605
1672
  resolvedPrefix
1606
1673
  ).slice(0, intentProfile.maxScenarios);
1607
1674
  const useAuthFallback = shouldUseAuthFallback(authElements, intentAlignedScenarios, intentProfile);
1608
- const fallbackScenarios = intentProfile.mode === "presence" ? buildPresenceOnlyScenarios(elementMap, requestedFeature, resolvedPrefix) : useAuthFallback ? buildAuthFallbackScenarios(elementMap, resolvedPrefix, authElements) : buildFallbackScenarios(elementMap, resolvedPrefix);
1675
+ const fallbackScenarios = intentProfile.mode === "presence" ? buildPresenceOnlyScenarios(elementMap, requestedFeature, resolvedPrefix) : useAuthFallback ? buildAuthFallbackScenarios(elementMap, resolvedPrefix, authElements, requestedFeature) : buildFallbackScenarios(elementMap, resolvedPrefix, requestedFeature);
1609
1676
  const finalScenarios = useAuthFallback ? fallbackScenarios.slice(0, intentProfile.maxScenarios) : intentAlignedScenarios.length > 0 ? intentAlignedScenarios : fallbackScenarios.slice(0, intentProfile.maxScenarios);
1610
1677
  return {
1611
1678
  ...analysis,
@@ -1671,13 +1738,72 @@ function normalizeAssertion(candidate, selectors) {
1671
1738
  return assertion;
1672
1739
  }
1673
1740
  function buildFallbackScenarios(elementMap, prefix) {
1741
+ return buildFallbackScenariosForFeature(elementMap, prefix, "");
1742
+ }
1743
+ function buildFallbackScenariosForFeature(elementMap, prefix, feature) {
1744
+ const normalizedFeature = feature.toLowerCase();
1674
1745
  const heading = elementMap.elements.find((element) => element.category === "heading");
1675
1746
  const emailInput = elementMap.elements.find((element) => element.category === "input" && (element.attributes.type === "email" || /email/.test(`${element.name ?? ""} ${String(element.attributes.label ?? "")}`.toLowerCase())));
1676
1747
  const passwordInput = elementMap.elements.find((element) => element.category === "input" && element.attributes.type === "password");
1677
1748
  const submitButton = elementMap.elements.find((element) => element.category === "button" && /login|sign in|submit|continue/i.test(element.name ?? "")) ?? elementMap.elements.find((element) => element.category === "button");
1749
+ const alert = findAlertElement(elementMap);
1678
1750
  const scenarios = [];
1679
1751
  const tag = (value) => normalizeTag(value);
1680
1752
  const nextId = (index) => `${prefix}-${String(900 + index).padStart(3, "0")}`;
1753
+ const wantsHiddenAlertOnLoad = /\b(error|alert|message)\b/.test(normalizedFeature) && /\b(not shown|not visible|hidden|not present|gone|absent)\b/.test(normalizedFeature) && /\b(first load|first loads|first loads?|page first loads|initial load|on load)\b/.test(normalizedFeature);
1754
+ const wantsVisibleAlert = /\b(error|alert|message|invalid|incorrect|required|validation)\b/.test(normalizedFeature) && !wantsHiddenAlertOnLoad;
1755
+ if (wantsHiddenAlertOnLoad && alert) {
1756
+ return [{
1757
+ id: `${prefix}-001`,
1758
+ title: "Error message is hidden on initial load",
1759
+ tags: [tag("negative"), tag("ui")],
1760
+ steps: [
1761
+ {
1762
+ action: "navigate",
1763
+ selector: "page",
1764
+ value: elementMap.url,
1765
+ human: "Navigate to the captured page"
1766
+ }
1767
+ ],
1768
+ assertions: [
1769
+ {
1770
+ type: "hidden",
1771
+ selector: alert.selector,
1772
+ expected: "hidden",
1773
+ human: `${alert.name || "Error message"} is not shown when the page first loads`,
1774
+ playwright: `await expect(page.${alert.selector}).toBeHidden();`
1775
+ }
1776
+ ],
1777
+ narrator: "We verify that the page does not expose an error surface before any user action.",
1778
+ codeLevel: "beginner"
1779
+ }];
1780
+ }
1781
+ if (wantsVisibleAlert && alert) {
1782
+ return [{
1783
+ id: `${prefix}-001`,
1784
+ title: "Page exposes an alert message when requested",
1785
+ tags: [tag("negative"), tag("ui")],
1786
+ steps: [
1787
+ {
1788
+ action: "navigate",
1789
+ selector: "page",
1790
+ value: elementMap.url,
1791
+ human: "Navigate to the captured page"
1792
+ }
1793
+ ],
1794
+ assertions: [
1795
+ {
1796
+ type: "text",
1797
+ selector: alert.selector,
1798
+ expected: alert.name || "error",
1799
+ human: "An alert message is shown",
1800
+ playwright: `await expect(page.${alert.selector}).toContainText(/invalid|error|required/i);`
1801
+ }
1802
+ ],
1803
+ narrator: "We validate the alert channel directly when the intent asks about an error message.",
1804
+ codeLevel: "beginner"
1805
+ }];
1806
+ }
1681
1807
  if (heading) {
1682
1808
  scenarios.push({
1683
1809
  id: nextId(scenarios.length + 1),
@@ -1752,16 +1878,115 @@ function buildFallbackScenarios(elementMap, prefix) {
1752
1878
  }
1753
1879
  return scenarios.slice(0, 5);
1754
1880
  }
1755
- function buildAuthFallbackScenarios(elementMap, prefix, authElements) {
1881
+ function buildAuthFallbackScenarios(elementMap, prefix, authElements, feature) {
1756
1882
  const { usernameInput, passwordInput, submitButton, heading } = authElements;
1757
1883
  if (!usernameInput || !passwordInput || !submitButton) {
1758
- return buildFallbackScenarios(elementMap, prefix);
1884
+ return buildFallbackScenariosForFeature(elementMap, prefix, feature);
1759
1885
  }
1886
+ const normalizedFeature = feature.toLowerCase();
1760
1887
  const scenarios = [];
1761
1888
  const tag = (value) => normalizeTag(value);
1762
1889
  const nextId = (index) => `${prefix}-${String(index).padStart(3, "0")}`;
1763
1890
  const usernameValue = inferAuthValue(usernameInput, "username");
1764
1891
  const passwordValue = inferAuthValue(passwordInput, "password");
1892
+ const alert = findAlertElement(elementMap);
1893
+ const wantsNavigation = /\b(valid credentials|correct credentials|successful login|log in successfully|login succeeds|sign in succeeds)\b/.test(normalizedFeature) || /\b(redirect|redirects|redirected|secure area|dashboard|redirected to|goes to|navigates? to)\b/.test(normalizedFeature);
1894
+ const wantsError = /\b(wrong password|wrong credentials|invalid|incorrect|error message|shows an error|alert)\b/.test(normalizedFeature);
1895
+ const successUrlPattern = deriveSuccessUrlPattern(elementMap, normalizedFeature);
1896
+ if (wantsNavigation) {
1897
+ return [{
1898
+ id: `${prefix}-001`,
1899
+ title: "Valid credentials redirect to the authenticated area",
1900
+ tags: [tag("auth"), tag("happy-path")],
1901
+ steps: [
1902
+ {
1903
+ action: "navigate",
1904
+ selector: "page",
1905
+ value: elementMap.url,
1906
+ human: "Navigate to the login page"
1907
+ },
1908
+ {
1909
+ action: "fill",
1910
+ selector: usernameInput.selector,
1911
+ value: usernameValue,
1912
+ human: buildFillHuman("Fill in the username field", usernameInput.selector, usernameValue)
1913
+ },
1914
+ {
1915
+ action: "fill",
1916
+ selector: passwordInput.selector,
1917
+ value: passwordValue,
1918
+ human: buildFillHuman("Fill in the password field", passwordInput.selector, passwordValue)
1919
+ },
1920
+ {
1921
+ action: "click",
1922
+ selector: submitButton.selector,
1923
+ value: "",
1924
+ human: "Click the login button"
1925
+ }
1926
+ ],
1927
+ assertions: [
1928
+ {
1929
+ type: "url",
1930
+ selector: "page",
1931
+ expected: successUrlPattern,
1932
+ human: "User is redirected to the secure area",
1933
+ playwright: `await expect(page).toHaveURL(/${successUrlPattern}/);`
1934
+ }
1935
+ ],
1936
+ narrator: "We submit valid credentials and confirm that authentication changes the page URL.",
1937
+ codeLevel: "beginner"
1938
+ }];
1939
+ }
1940
+ if (wantsError) {
1941
+ return [{
1942
+ id: `${prefix}-001`,
1943
+ title: "Wrong password shows an authentication error",
1944
+ tags: [tag("auth"), tag("negative")],
1945
+ steps: [
1946
+ {
1947
+ action: "navigate",
1948
+ selector: "page",
1949
+ value: elementMap.url,
1950
+ human: "Navigate to the login page"
1951
+ },
1952
+ {
1953
+ action: "fill",
1954
+ selector: usernameInput.selector,
1955
+ value: usernameValue,
1956
+ human: buildFillHuman("Fill in the username field", usernameInput.selector, usernameValue)
1957
+ },
1958
+ {
1959
+ action: "fill",
1960
+ selector: passwordInput.selector,
1961
+ value: "wrong-password",
1962
+ human: buildFillHuman("Fill in the password field", passwordInput.selector, "wrong-password")
1963
+ },
1964
+ {
1965
+ action: "click",
1966
+ selector: submitButton.selector,
1967
+ value: "",
1968
+ human: "Click the login button"
1969
+ }
1970
+ ],
1971
+ assertions: [
1972
+ alert ? {
1973
+ type: "text",
1974
+ selector: alert.selector,
1975
+ expected: alert.name || "error",
1976
+ human: "An alert communicates the login failure",
1977
+ playwright: `await expect(page.${alert.selector}).toContainText(/invalid|error|required/i);`
1978
+ } : {
1979
+ type: "text",
1980
+ selector: usernameInput.selector,
1981
+ expected: "error",
1982
+ human: "An authentication error message is shown",
1983
+ playwright: `await expect(page.getByRole('alert')).toContainText(/invalid|error|required/i);`
1984
+ }
1985
+ ],
1986
+ narrator: "We use an invalid password and confirm the page surfaces an authentication error.",
1987
+ codeLevel: "beginner"
1988
+ }];
1989
+ }
1765
1990
  scenarios.push({
1766
1991
  id: nextId(1),
1767
1992
  title: "Login form renders expected controls",
@@ -2024,7 +2249,7 @@ function extractPresenceClaims(feature, elementMap) {
2024
2249
  const text = extractIntentLabel(segment);
2025
2250
  if (!text) continue;
2026
2251
  const match = findBestMatchingElement(elementMap, text, normalized);
2027
- const selector = match?.selector ?? buildFallbackSelector(text, normalized);
2252
+ const selector = buildIntentSelector(text, normalized, match);
2028
2253
  claims.push({
2029
2254
  selector,
2030
2255
  human: `${text} is visible`
@@ -2040,16 +2265,23 @@ function chooseHeadingElement(elementMap) {
2040
2265
  function extractIntentLabel(segment) {
2041
2266
  const unquoted = Array.from(segment.matchAll(/["“”']([^"“”']+)["“”']/g)).map((match) => match[1].trim());
2042
2267
  if (unquoted.length > 0) return unquoted[0];
2043
- 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();
2268
+ 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(?:on|in)\s+(?:the\s+)?page\b/gi, " ").replace(/\b(?:button|link|cta|label|text)\b/gi, " ").replace(/[.?!,:;]+/g, " ").replace(/\s+/g, " ").trim();
2044
2269
  }
2045
2270
  function findBestMatchingElement(elementMap, text, segment) {
2046
2271
  const preferredCategory = /\bbutton\b/.test(segment) ? "button" : /\blink\b/.test(segment) ? "link" : /\bheading\b/.test(segment) ? "heading" : void 0;
2047
2272
  const query = text.toLowerCase();
2048
2273
  const candidates = elementMap.elements.filter((element) => {
2049
- if (preferredCategory && element.category !== preferredCategory) return false;
2050
2274
  const haystack = `${element.name ?? ""} ${element.purpose}`.toLowerCase();
2051
2275
  return haystack.includes(query);
2052
2276
  });
2277
+ if (!preferredCategory) return rankElements(candidates)[0];
2278
+ const preferred = candidates.filter((element) => element.category === preferredCategory);
2279
+ if (preferred.length > 0) return rankElements(preferred)[0];
2280
+ if ((preferredCategory === "button" || preferredCategory === "link") && shouldUseAmbiguousCtaSelector(segment, text)) {
2281
+ const alternateCategory = preferredCategory === "button" ? "link" : "button";
2282
+ const alternates = candidates.filter((element) => element.category === alternateCategory);
2283
+ if (alternates.length > 0) return rankElements(alternates)[0];
2284
+ }
2053
2285
  return rankElements(candidates)[0];
2054
2286
  }
2055
2287
  function rankElements(elements) {
@@ -2063,11 +2295,32 @@ function rankElements(elements) {
2063
2295
  return 0;
2064
2296
  });
2065
2297
  }
2066
- function buildFallbackSelector(text, segment) {
2298
+ function buildIntentSelector(text, segment, match) {
2299
+ if (shouldUseAmbiguousCtaSelector(segment, text)) {
2300
+ if (/\blink\b/.test(segment)) return buildAmbiguousRoleSelector(text, "link");
2301
+ if (/\bbutton\b/.test(segment) || match?.category === "link" || match?.category === "button") {
2302
+ return buildAmbiguousRoleSelector(text, "button");
2303
+ }
2304
+ }
2305
+ return match?.selector ?? buildFallbackSelector(text, segment);
2306
+ }
2307
+ function shouldUseAmbiguousCtaSelector(segment, text) {
2308
+ const haystack = `${segment} ${text}`.toLowerCase();
2309
+ return /\b(?:button|link|cta)\b/.test(haystack) && (/\b(?:join|start|get started|sign up|signup|subscribe|buy|purchase|learn more|see how it works|continue)\b/.test(haystack) || /[$€£]/.test(text));
2310
+ }
2311
+ function buildAmbiguousRoleSelector(text, preferredRole) {
2312
+ const primary = buildRoleSelector(preferredRole, text);
2313
+ const alternate = buildRoleSelector(preferredRole === "button" ? "link" : "button", text);
2314
+ return `${primary}.or(page.${alternate})`;
2315
+ }
2316
+ function buildRoleSelector(role, text) {
2067
2317
  const safeRegex = text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2068
- if (/\bbutton\b/.test(segment)) return `getByRole('button', { name: /${safeRegex}/i })`;
2069
- if (/\blink\b/.test(segment)) return `getByRole('link', { name: /${safeRegex}/i })`;
2070
- if (/\b(?:title|heading)\b/.test(segment)) return `getByRole('heading', { name: /${safeRegex}/i })`;
2318
+ return `getByRole('${role}', { name: /${safeRegex}/i })`;
2319
+ }
2320
+ function buildFallbackSelector(text, segment) {
2321
+ if (/\bbutton\b/.test(segment)) return buildRoleSelector("button", text);
2322
+ if (/\blink\b/.test(segment)) return buildRoleSelector("link", text);
2323
+ if (/\b(?:title|heading)\b/.test(segment)) return buildRoleSelector("heading", text);
2071
2324
  return `getByText(${JSON.stringify(text)})`;
2072
2325
  }
2073
2326
  function buildAudioSummary(feature, scenarios) {
@@ -2084,6 +2337,53 @@ function detectAuthElements(elementMap) {
2084
2337
  const heading = elementMap.elements.find((element) => element.category === "heading" && /login|sign in|auth/.test((element.name ?? "").toLowerCase())) ?? elementMap.elements.find((element) => element.category === "heading");
2085
2338
  return { usernameInput, passwordInput, submitButton, heading };
2086
2339
  }
2340
+ function findAlertElement(elementMap) {
2341
+ return elementMap.elements.find((element) => element.role === "alert" || element.category === "status" || String(element.attributes.role ?? "").toLowerCase() === "alert");
2342
+ }
2343
+ function deriveSuccessUrlPattern(elementMap, normalizedFeature) {
2344
+ const explicitPath = normalizedFeature.match(/\/[a-z0-9/_-]+/i)?.[0] ?? (/\bsecure area\b/.test(normalizedFeature) ? "/secure" : void 0);
2345
+ if (explicitPath) {
2346
+ return explicitPath.replace(/^\/+/, "").split("/").map(escapeForRegex).join("\\/");
2347
+ }
2348
+ const authLink = elementMap.elements.find((element) => {
2349
+ if (element.category !== "link") return false;
2350
+ const href2 = String(element.attributes.href ?? "");
2351
+ const label = `${element.name ?? ""} ${href2}`.toLowerCase();
2352
+ return /\b(secure|dashboard|home|app)\b/.test(label);
2353
+ });
2354
+ const href = String(authLink?.attributes.href ?? "");
2355
+ if (href.startsWith("/")) {
2356
+ return href.replace(/^\/+/, "").split("/").map(escapeForRegex).join("\\/");
2357
+ }
2358
+ return "secure|dashboard|home|app";
2359
+ }
2360
+ function buildDeterministicAnalysis(elementMap, requestedFeature) {
2361
+ const intentProfile = buildIntentProfile(requestedFeature);
2362
+ const suggestedPrefix = inferPrefix({
2363
+ featureText: requestedFeature,
2364
+ url: elementMap.url
2365
+ }).toUpperCase();
2366
+ const authElements = detectAuthElements(elementMap);
2367
+ let scenarios = intentProfile.mode === "count" ? buildCountScenarios(elementMap, requestedFeature, suggestedPrefix) : intentProfile.mode === "presence" ? buildPresenceOnlyScenarios(elementMap, requestedFeature, suggestedPrefix) : intentProfile.mode === "auth" || intentProfile.mode === "form" ? buildAuthFallbackScenarios(elementMap, suggestedPrefix, authElements, requestedFeature) : buildFallbackScenariosForFeature(elementMap, suggestedPrefix, requestedFeature);
2368
+ if (scenarios.length === 0 && (authElements.usernameInput || authElements.passwordInput || authElements.submitButton)) {
2369
+ scenarios = buildAuthFallbackScenarios(elementMap, suggestedPrefix, authElements, requestedFeature);
2370
+ }
2371
+ if (scenarios.length === 0) {
2372
+ scenarios = buildFallbackScenariosForFeature(elementMap, suggestedPrefix, requestedFeature);
2373
+ }
2374
+ const finalScenarios = scenarios.slice(0, intentProfile.maxScenarios);
2375
+ return {
2376
+ url: elementMap.url,
2377
+ feature: requestedFeature,
2378
+ suggestedPrefix,
2379
+ scenarios: finalScenarios,
2380
+ analysisNotes: "Generated deterministic capture-backed scenarios because LLM analysis was unavailable.",
2381
+ audioSummary: buildAudioSummary(requestedFeature, finalScenarios)
2382
+ };
2383
+ }
2384
+ function shortErrorMessage(error) {
2385
+ return String(error?.message ?? error ?? "Unknown error").split("\n")[0];
2386
+ }
2087
2387
  function shouldUseAuthFallback(authElements, scenarios, intentProfile) {
2088
2388
  if (intentProfile.mode !== "auth" && intentProfile.mode !== "form") return false;
2089
2389
  if (!authElements.usernameInput || !authElements.passwordInput || !authElements.submitButton) return false;
@@ -2231,6 +2531,10 @@ function extractCtVarTemplate(value) {
2231
2531
  function isAllowedSelector(selector, selectors) {
2232
2532
  if (selector === "page") return true;
2233
2533
  if (selectors.has(selector)) return true;
2534
+ if (/\.or\(page\.(?:getByRole|getByText|getByLabel|getByPlaceholder|getByTestId|locator)\(/.test(selector)) {
2535
+ const primary = selector.replace(/\.or\(page\.(?:getByRole|getByText|getByLabel|getByPlaceholder|getByTestId|locator)\([\s\S]*\)\)\s*$/, "");
2536
+ return isAllowedSelector(primary, selectors);
2537
+ }
2234
2538
  return /^(?:getByRole|getByText|getByLabel|getByPlaceholder|getByTestId)\(/.test(selector) || /^locator\((['"])(#.+?)\1\)$/.test(selector);
2235
2539
  }
2236
2540
  function ensureStatement(value) {
@@ -2255,565 +2559,9 @@ function log(verbose, message) {
2255
2559
  if (verbose) console.log(message);
2256
2560
  }
2257
2561
 
2258
- // src/core/playwright.ts
2259
- import { existsSync as existsSync2 } from "fs";
2260
- import { dirname as dirname2, join as join3, resolve as resolve3 } from "path";
2261
- import { createRequire } from "module";
2262
- import { fileURLToPath as fileURLToPath2, pathToFileURL } from "url";
2263
- var require2 = createRequire(import.meta.url);
2264
- var moduleDir = dirname2(fileURLToPath2(import.meta.url));
2265
- async function resolvePlaywrightChromium(cwd = process.cwd()) {
2266
- const searchRoots = buildSearchRoots(cwd);
2267
- for (const packageName of ["@playwright/test", "playwright-core"]) {
2268
- for (const searchRoot of searchRoots) {
2269
- const resolvedPath = resolveFromRoot(packageName, searchRoot);
2270
- if (!resolvedPath) continue;
2271
- const imported = await import(pathToFileURL(resolvedPath).href);
2272
- const chromium = imported.chromium ?? imported.default?.chromium;
2273
- if (chromium) {
2274
- return {
2275
- packageName,
2276
- resolvedPath,
2277
- searchRoot,
2278
- chromium
2279
- };
2280
- }
2281
- }
2282
- }
2283
- throw new Error(
2284
- "Playwright runtime not found in this project.\nTry:\n npm install\n npx playwright install chromium"
2285
- );
2286
- }
2287
- function buildSearchRoots(cwd) {
2288
- const roots = [resolve3(cwd)];
2289
- const projectRoot = findNearestProjectRoot(cwd);
2290
- if (projectRoot && projectRoot !== roots[0]) {
2291
- roots.push(projectRoot);
2292
- }
2293
- roots.push(moduleDir);
2294
- return Array.from(new Set(roots));
2295
- }
2296
- function findNearestProjectRoot(startDir) {
2297
- let currentDir = resolve3(startDir);
2298
- while (true) {
2299
- if (existsSync2(join3(currentDir, "package.json"))) {
2300
- return currentDir;
2301
- }
2302
- const parentDir = dirname2(currentDir);
2303
- if (parentDir === currentDir) return void 0;
2304
- currentDir = parentDir;
2305
- }
2306
- }
2307
- function resolveFromRoot(packageName, searchRoot) {
2308
- try {
2309
- return require2.resolve(packageName, { paths: [searchRoot] });
2310
- } catch {
2311
- return void 0;
2312
- }
2313
- }
2314
-
2315
- // src/core/capture.ts
2316
- var SETTLE_MS = 1200;
2317
- var DEFAULT_TIMEOUT_MS = 3e4;
2318
- var MAX_PER_CATEGORY = 50;
2319
- var CaptureRuntimeError = class extends Error {
2320
- constructor(code, message, options) {
2321
- super(message);
2322
- this.name = "CaptureRuntimeError";
2323
- this.code = code;
2324
- if (options?.cause !== void 0) {
2325
- this.cause = options.cause;
2326
- }
2327
- }
2328
- };
2329
- async function captureElements(url, options = {}) {
2330
- const {
2331
- headless = true,
2332
- timeoutMs = DEFAULT_TIMEOUT_MS,
2333
- verbose = false,
2334
- userAgent = "Mozilla/5.0 (compatible; CementicTest/0.2.7 capture)"
2335
- } = options;
2336
- const chromium = await loadChromium();
2337
- const mode = headless ? "headless" : "headed";
2338
- log2(verbose, `
2339
- [capture] Starting ${mode} capture: ${url}`);
2340
- let browser;
2341
- try {
2342
- browser = await chromium.launch({
2343
- headless,
2344
- slowMo: headless ? 0 : 150
2345
- });
2346
- } catch (error) {
2347
- throw classifyCaptureError(error);
2348
- }
2349
- const context = await browser.newContext({
2350
- userAgent,
2351
- viewport: { width: 1280, height: 800 },
2352
- ignoreHTTPSErrors: true
2353
- });
2354
- const page = await context.newPage();
2355
- try {
2356
- log2(verbose, ` -> Navigating (timeout: ${timeoutMs}ms)`);
2357
- try {
2358
- await page.goto(url, {
2359
- waitUntil: "domcontentloaded",
2360
- timeout: timeoutMs
2361
- });
2362
- } catch (error) {
2363
- throw new CaptureRuntimeError(
2364
- "PAGE_LOAD_FAILED",
2365
- `Page load failed for ${url}. ${error?.message ?? error}`,
2366
- { cause: error }
2367
- );
2368
- }
2369
- log2(verbose, ` -> Waiting ${SETTLE_MS}ms for page settle`);
2370
- await page.waitForTimeout(SETTLE_MS);
2371
- log2(verbose, " -> Extracting accessibility snapshot");
2372
- const a11ySnapshot = await getAccessibilitySnapshot(page, verbose);
2373
- log2(verbose, " -> Extracting DOM data");
2374
- const domData = await page.evaluate(extractDomData);
2375
- const title = await page.title();
2376
- const finalUrl = page.url();
2377
- const elements = buildElementMap(domData);
2378
- const warnings = buildWarnings(elements, domData, a11ySnapshot);
2379
- const byCategory = {};
2380
- for (const element of elements) {
2381
- byCategory[element.category] = (byCategory[element.category] ?? 0) + 1;
2382
- }
2383
- const result = {
2384
- url: finalUrl,
2385
- title,
2386
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2387
- mode,
2388
- summary: {
2389
- totalElements: elements.length,
2390
- byCategory
2391
- },
2392
- elements,
2393
- warnings
2394
- };
2395
- log2(verbose, ` -> Captured ${elements.length} testable elements from "${title}"`);
2396
- return result;
2397
- } catch (error) {
2398
- throw classifyCaptureError(error);
2399
- } finally {
2400
- await browser?.close();
2401
- }
2402
- }
2403
- function toPageSummary(elementMap) {
2404
- const inputs = elementMap.elements.filter((element) => element.category === "input").slice(0, 30).map((element) => ({
2405
- label: asString(element.attributes.label),
2406
- placeholder: asString(element.attributes.placeholder),
2407
- name: asString(element.attributes.name),
2408
- type: asString(element.attributes.type),
2409
- testId: asString(element.attributes.testId)
2410
- }));
2411
- return {
2412
- url: elementMap.url,
2413
- title: elementMap.title,
2414
- headings: elementMap.elements.filter((element) => element.category === "heading").map((element) => element.name ?? "").filter(Boolean).slice(0, 20),
2415
- buttons: elementMap.elements.filter((element) => element.category === "button").map((element) => element.name ?? "").filter(Boolean).slice(0, 30),
2416
- links: elementMap.elements.filter((element) => element.category === "link").map((element) => element.name ?? "").filter(Boolean).slice(0, 50),
2417
- inputs,
2418
- landmarks: [],
2419
- rawLength: elementMap.elements.length
2420
- };
2421
- }
2422
- async function loadChromium() {
2423
- try {
2424
- const resolution = await resolvePlaywrightChromium(process.cwd());
2425
- return resolution.chromium;
2426
- } catch (error) {
2427
- throw new CaptureRuntimeError(
2428
- "PLAYWRIGHT_NOT_FOUND",
2429
- error?.message ?? "Playwright runtime not found in this project.\nTry:\n npm install\n npx playwright install chromium",
2430
- { cause: error }
2431
- );
2432
- }
2433
- }
2434
- function extractDomData() {
2435
- const localMaxPerCategory = 50;
2436
- const attr = (el, name) => el.getAttribute(name)?.trim() || void 0;
2437
- const text = (el) => el?.textContent?.replace(/\s+/g, " ").trim() || void 0;
2438
- const jsString = (value) => JSON.stringify(value);
2439
- const findLabel = (el) => {
2440
- const id = attr(el, "id");
2441
- if (id) {
2442
- const labelEl = document.querySelector(`label[for="${id}"]`);
2443
- if (labelEl) return text(labelEl);
2444
- }
2445
- const ariaLabel = attr(el, "aria-label");
2446
- if (ariaLabel) return ariaLabel;
2447
- const labelledBy = attr(el, "aria-labelledby");
2448
- if (labelledBy) {
2449
- const labelEl = document.getElementById(labelledBy);
2450
- if (labelEl) return text(labelEl);
2451
- }
2452
- const closestLabel = el.closest("label");
2453
- if (closestLabel) {
2454
- const raw = text(closestLabel) || "";
2455
- const placeholder = attr(el, "placeholder") || "";
2456
- return raw.replace(placeholder, "").trim() || void 0;
2457
- }
2458
- const previous = el.previousElementSibling;
2459
- if (previous?.tagName === "LABEL") return text(previous);
2460
- return void 0;
2461
- };
2462
- const buildSelector = (el, labelText, buttonText) => {
2463
- const testId = attr(el, "data-testid");
2464
- if (testId) return `getByTestId(${jsString(testId)})`;
2465
- const id = attr(el, "id");
2466
- if (id && !id.match(/^(ember|react|vue|ng|auto|rand)/i)) {
2467
- return `locator(${jsString(`#${id}`)})`;
2468
- }
2469
- const ariaLabel = attr(el, "aria-label");
2470
- if (ariaLabel) {
2471
- return `getByRole(${jsString(el.getAttribute("role") || el.tagName.toLowerCase())}, { name: ${jsString(ariaLabel)} })`;
2472
- }
2473
- if (labelText) {
2474
- const tag = el.tagName.toLowerCase();
2475
- if (tag === "input" || tag === "textarea" || tag === "select") {
2476
- return `getByLabel(${jsString(labelText)})`;
2477
- }
2478
- }
2479
- if (buttonText) return `getByRole('button', { name: ${jsString(buttonText)} })`;
2480
- const name = attr(el, "name");
2481
- if (name) return `locator(${jsString(`[name="${name}"]`)})`;
2482
- return null;
2483
- };
2484
- const buttons = [];
2485
- document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]').forEach((el) => {
2486
- const buttonText = attr(el, "aria-label") || text(el) || attr(el, "value");
2487
- const testId = attr(el, "data-testid");
2488
- const id = attr(el, "id");
2489
- const type = attr(el, "type");
2490
- const disabled = el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true";
2491
- const selector = buildSelector(el, void 0, buttonText);
2492
- if (buttonText || testId) {
2493
- buttons.push({
2494
- tag: el.tagName.toLowerCase(),
2495
- text: buttonText,
2496
- testId,
2497
- id,
2498
- type,
2499
- disabled,
2500
- selector,
2501
- cssPath: testId ? `[data-testid="${testId}"]` : id ? `#${id}` : null
2502
- });
2503
- }
2504
- });
2505
- const inputs = [];
2506
- document.querySelectorAll('input:not([type="hidden"]):not([type="submit"]):not([type="button"]), textarea, select').forEach((el) => {
2507
- const label = findLabel(el);
2508
- const placeholder = attr(el, "placeholder");
2509
- const name = attr(el, "name");
2510
- const id = attr(el, "id");
2511
- const type = attr(el, "type") || el.tagName.toLowerCase();
2512
- const testId = attr(el, "data-testid");
2513
- const required = el.hasAttribute("required");
2514
- const selector = buildSelector(el, label);
2515
- if (label || placeholder || name || testId || id) {
2516
- inputs.push({
2517
- tag: el.tagName.toLowerCase(),
2518
- type,
2519
- label,
2520
- placeholder,
2521
- name,
2522
- id,
2523
- testId,
2524
- required,
2525
- selector,
2526
- cssPath: testId ? `[data-testid="${testId}"]` : id ? `#${id}` : name ? `[name="${name}"]` : null
2527
- });
2528
- }
2529
- });
2530
- const links = [];
2531
- document.querySelectorAll("a[href]").forEach((el) => {
2532
- const linkText = attr(el, "aria-label") || text(el);
2533
- const href = attr(el, "href");
2534
- const testId = attr(el, "data-testid");
2535
- if (!linkText || href === "#") return;
2536
- links.push({
2537
- text: linkText,
2538
- href,
2539
- testId,
2540
- external: Boolean(href?.startsWith("http") && !href.includes(window.location.hostname)),
2541
- selector: testId ? `getByTestId(${jsString(testId)})` : `getByRole('link', { name: ${jsString(linkText)} })`
2542
- });
2543
- });
2544
- const headings = [];
2545
- document.querySelectorAll("h1, h2, h3").forEach((el) => {
2546
- const headingText = text(el);
2547
- if (headingText) {
2548
- headings.push({
2549
- level: el.tagName.toLowerCase(),
2550
- text: headingText,
2551
- selector: `getByRole('heading', { name: ${jsString(headingText)} })`
2552
- });
2553
- }
2554
- });
2555
- const landmarks = [];
2556
- document.querySelectorAll("[role], main, nav, header, footer, aside, section[aria-label]").forEach((el) => {
2557
- const role = attr(el, "role") || el.tagName.toLowerCase();
2558
- const label = attr(el, "aria-label") || text(el)?.slice(0, 40);
2559
- if (role && label) landmarks.push({ role, label });
2560
- });
2561
- const statusRegions = [];
2562
- document.querySelectorAll('[role="alert"], [role="status"], [aria-live]').forEach((el) => {
2563
- const role = attr(el, "role") || "live";
2564
- statusRegions.push({
2565
- role,
2566
- ariaLive: attr(el, "aria-live"),
2567
- text: text(el),
2568
- selector: el.getAttribute("role") ? `getByRole(${jsString(role)})` : `locator('[aria-live]')`
2569
- });
2570
- });
2571
- const forms = [];
2572
- document.querySelectorAll("form").forEach((form, index) => {
2573
- forms.push({
2574
- id: attr(form, "id"),
2575
- label: attr(form, "aria-label") || attr(form, "aria-labelledby"),
2576
- action: attr(form, "action"),
2577
- method: attr(form, "method") || "get",
2578
- fieldCount: form.querySelectorAll("input, textarea, select").length,
2579
- index
2580
- });
2581
- });
2582
- return {
2583
- buttons: buttons.slice(0, localMaxPerCategory),
2584
- inputs: inputs.slice(0, localMaxPerCategory),
2585
- links: links.slice(0, localMaxPerCategory),
2586
- headings: headings.slice(0, 20),
2587
- landmarks,
2588
- statusRegions,
2589
- forms,
2590
- pageUrl: window.location.href,
2591
- pageTitle: document.title
2592
- };
2593
- }
2594
- function buildElementMap(domData) {
2595
- const elements = [];
2596
- for (const input2 of domData.inputs) {
2597
- const displayName = input2.label || input2.placeholder || input2.name || input2.testId;
2598
- if (!displayName) continue;
2599
- const selector = input2.selector || (input2.testId ? `getByTestId(${JSON.stringify(input2.testId)})` : null) || (input2.label ? `getByLabel(${JSON.stringify(input2.label)})` : null) || (input2.id ? `locator(${JSON.stringify(`#${input2.id}`)})` : null) || (input2.name ? `locator(${JSON.stringify(`[name="${input2.name}"]`)})` : null) || `locator(${JSON.stringify(`${input2.tag}[placeholder="${input2.placeholder ?? ""}"]`)})`;
2600
- const selectorAlt = [];
2601
- if (input2.id && !selector.includes(`#${input2.id}`)) selectorAlt.push(`locator(${JSON.stringify(`#${input2.id}`)})`);
2602
- if (input2.name && !selector.includes(input2.name)) selectorAlt.push(`locator(${JSON.stringify(`[name="${input2.name}"]`)})`);
2603
- if (input2.testId && !selector.includes(input2.testId)) selectorAlt.push(`getByTestId(${JSON.stringify(input2.testId)})`);
2604
- if (input2.placeholder && !selector.includes(input2.placeholder)) selectorAlt.push(`getByPlaceholder(${JSON.stringify(input2.placeholder)})`);
2605
- if (input2.label && !selector.includes(input2.label)) selectorAlt.push(`getByLabel(${JSON.stringify(input2.label)})`);
2606
- elements.push({
2607
- category: "input",
2608
- role: input2.type === "checkbox" ? "checkbox" : "textbox",
2609
- name: displayName,
2610
- selector,
2611
- selectorAlt,
2612
- purpose: input2.required ? `Required ${input2.type} field - "${displayName}"` : `${input2.type} field - "${displayName}"`,
2613
- confidence: input2.testId || input2.label || input2.id ? "high" : input2.placeholder ? "medium" : "low",
2614
- attributes: {
2615
- type: input2.type,
2616
- label: input2.label,
2617
- placeholder: input2.placeholder,
2618
- name: input2.name,
2619
- id: input2.id,
2620
- testId: input2.testId,
2621
- required: input2.required,
2622
- tag: input2.tag
2623
- }
2624
- });
2625
- }
2626
- for (const button of domData.buttons) {
2627
- const displayName = button.text || button.testId;
2628
- if (!displayName) continue;
2629
- const selector = button.selector || (button.testId ? `getByTestId(${JSON.stringify(button.testId)})` : null) || (button.text ? `getByRole('button', { name: ${JSON.stringify(button.text)} })` : null) || (button.id ? `locator(${JSON.stringify(`#${button.id}`)})` : null) || `locator('button')`;
2630
- const selectorAlt = [];
2631
- if (button.id && !selector.includes(button.id)) selectorAlt.push(`locator(${JSON.stringify(`#${button.id}`)})`);
2632
- if (button.testId && !selector.includes(button.testId)) selectorAlt.push(`getByTestId(${JSON.stringify(button.testId)})`);
2633
- if (button.text && !selector.includes(button.text)) selectorAlt.push(`getByText(${JSON.stringify(button.text)})`);
2634
- if (button.cssPath) selectorAlt.push(`locator(${JSON.stringify(button.cssPath)})`);
2635
- elements.push({
2636
- category: "button",
2637
- role: "button",
2638
- name: displayName,
2639
- selector,
2640
- selectorAlt,
2641
- purpose: button.disabled ? `Disabled button - "${displayName}"` : button.type === "submit" ? `Form submit button - "${displayName}"` : `Button - "${displayName}"`,
2642
- confidence: button.testId || button.text ? "high" : button.id ? "medium" : "low",
2643
- attributes: {
2644
- text: button.text,
2645
- testId: button.testId,
2646
- id: button.id,
2647
- type: button.type,
2648
- disabled: button.disabled,
2649
- tag: button.tag
2650
- }
2651
- });
2652
- }
2653
- for (const link of domData.links) {
2654
- elements.push({
2655
- category: "link",
2656
- role: "link",
2657
- name: link.text,
2658
- selector: link.selector,
2659
- selectorAlt: link.testId ? [`getByTestId(${JSON.stringify(link.testId)})`] : [],
2660
- purpose: link.external ? `External link to "${link.href}" - "${link.text}"` : `Internal navigation link - "${link.text}" -> ${link.href}`,
2661
- confidence: link.testId ? "high" : "medium",
2662
- attributes: {
2663
- text: link.text,
2664
- href: link.href,
2665
- testId: link.testId,
2666
- external: link.external
2667
- }
2668
- });
2669
- }
2670
- for (const heading of domData.headings) {
2671
- elements.push({
2672
- category: "heading",
2673
- role: "heading",
2674
- name: heading.text,
2675
- selector: heading.selector,
2676
- selectorAlt: [`getByText(${JSON.stringify(heading.text)})`],
2677
- purpose: `Page ${heading.level} heading - use to assert the correct page or section loaded`,
2678
- confidence: "medium",
2679
- attributes: {
2680
- level: heading.level,
2681
- text: heading.text
2682
- }
2683
- });
2684
- }
2685
- for (const status of domData.statusRegions) {
2686
- elements.push({
2687
- category: "status",
2688
- role: status.role,
2689
- name: status.text || status.role,
2690
- selector: status.selector,
2691
- selectorAlt: [],
2692
- purpose: "Live region - use to assert error messages, success toasts, and validation feedback",
2693
- confidence: "medium",
2694
- attributes: {
2695
- role: status.role,
2696
- ariaLive: status.ariaLive,
2697
- currentText: status.text
2698
- }
2699
- });
2700
- }
2701
- return elements;
2702
- }
2703
- function buildWarnings(elements, domData, a11ySnapshot) {
2704
- const warnings = [];
2705
- if (!a11ySnapshot) {
2706
- warnings.push("Playwright accessibility snapshot was unavailable. Capture continued using DOM extraction only.");
2707
- }
2708
- const lowConfidenceInputs = elements.filter((element) => element.category === "input" && element.confidence === "low");
2709
- if (lowConfidenceInputs.length > 0) {
2710
- warnings.push(
2711
- `${lowConfidenceInputs.length} input(s) have low-confidence selectors. Consider adding data-testid attributes to: ${lowConfidenceInputs.map((element) => element.name).filter(Boolean).join(", ")}`
2712
- );
2713
- }
2714
- if (domData.statusRegions.length === 0) {
2715
- warnings.push("No ARIA alert or status regions detected. Error message assertions may need manual selector adjustments after generation.");
2716
- }
2717
- for (const form of domData.forms) {
2718
- const hasSubmit = domData.buttons.some((button) => button.type === "submit");
2719
- if (form.fieldCount > 0 && !hasSubmit) {
2720
- warnings.push(
2721
- `Form ${form.id || `#${form.index}`} has ${form.fieldCount} field(s) but no detected submit button. It may use keyboard submit or a custom handler.`
2722
- );
2723
- }
2724
- }
2725
- if (domData.links.length >= MAX_PER_CATEGORY) {
2726
- warnings.push(`Link count hit the ${MAX_PER_CATEGORY} capture limit. Generation will focus on forms and buttons.`);
2727
- }
2728
- const interactive = elements.filter((element) => element.category === "button" || element.category === "input" || element.category === "link");
2729
- if (interactive.length === 0) {
2730
- warnings.push("No interactive elements detected. The page may require authentication, render later than the current settle window, or be mostly static content.");
2731
- }
2732
- return warnings;
2733
- }
2734
- function log2(verbose, message) {
2735
- if (verbose) console.log(message);
2736
- }
2737
- async function getAccessibilitySnapshot(page, verbose) {
2738
- if (!page.accessibility || typeof page.accessibility.snapshot !== "function") {
2739
- log2(verbose, " -> Accessibility snapshot API unavailable; continuing with DOM-only capture");
2740
- return null;
2741
- }
2742
- try {
2743
- return await page.accessibility.snapshot({ interestingOnly: false });
2744
- } catch (error) {
2745
- log2(verbose, ` -> Accessibility snapshot failed (${error?.message ?? error}); continuing with DOM-only capture`);
2746
- return null;
2747
- }
2748
- }
2749
- function asString(value) {
2750
- return typeof value === "string" ? value : void 0;
2751
- }
2752
- function formatCaptureFailure(error) {
2753
- const resolved = classifyCaptureError(error);
2754
- if (resolved.code === "PLAYWRIGHT_NOT_FOUND") {
2755
- return [
2756
- "Playwright not resolvable.",
2757
- "Playwright runtime not found in this project.",
2758
- "Try:",
2759
- " npm install",
2760
- " npx playwright install chromium"
2761
- ];
2762
- }
2763
- if (resolved.code === "BROWSER_NOT_INSTALLED") {
2764
- return [
2765
- "Browser not installed.",
2766
- "Playwright resolved, but Chromium is not installed for this project.",
2767
- "Try:",
2768
- " npx playwright install chromium"
2769
- ];
2770
- }
2771
- if (resolved.code === "PAGE_LOAD_FAILED") {
2772
- return [
2773
- "Page load failed.",
2774
- resolved.message
2775
- ];
2776
- }
2777
- return [
2778
- "Capture failed.",
2779
- resolved.message
2780
- ];
2781
- }
2782
- function classifyCaptureError(error) {
2783
- if (error instanceof CaptureRuntimeError) {
2784
- return error;
2785
- }
2786
- const message = errorMessage(error);
2787
- if (isBrowserInstallError(message)) {
2788
- return new CaptureRuntimeError(
2789
- "BROWSER_NOT_INSTALLED",
2790
- "Playwright resolved, but Chromium is not installed for this project.",
2791
- { cause: error }
2792
- );
2793
- }
2794
- if (isPlaywrightNotFoundError(message)) {
2795
- return new CaptureRuntimeError(
2796
- "PLAYWRIGHT_NOT_FOUND",
2797
- "Playwright runtime not found in this project.",
2798
- { cause: error }
2799
- );
2800
- }
2801
- return new CaptureRuntimeError("CAPTURE_FAILED", message || "Unknown capture failure.", { cause: error });
2802
- }
2803
- function isBrowserInstallError(message) {
2804
- return /Executable doesn't exist|browserType\.launch: Executable doesn't exist|Please run the following command|playwright install/i.test(message);
2805
- }
2806
- function isPlaywrightNotFoundError(message) {
2807
- return /Playwright runtime not found|Cannot find package ['"](?:@playwright\/test|playwright-core)['"]/i.test(message);
2808
- }
2809
- function errorMessage(error) {
2810
- if (error instanceof Error) return error.message;
2811
- return String(error ?? "");
2812
- }
2813
-
2814
2562
  // src/core/report.ts
2815
2563
  import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
2816
- import { join as join4 } from "path";
2564
+ import { join as join3 } from "path";
2817
2565
  function printCaptureReport(elementMap, analysis) {
2818
2566
  console.log("");
2819
2567
  console.log("=".repeat(60));
@@ -2845,10 +2593,10 @@ function printCaptureReport(elementMap, analysis) {
2845
2593
  function saveCaptureJson(elementMap, analysis, outputDir = ".cementic/capture") {
2846
2594
  mkdirSync3(outputDir, { recursive: true });
2847
2595
  const fileName = `capture-${slugify(elementMap.url)}-${Date.now()}.json`;
2848
- const filePath = join4(outputDir, fileName);
2596
+ const filePath = join3(outputDir, fileName);
2849
2597
  writeFileSync3(filePath, JSON.stringify({
2850
2598
  _meta: {
2851
- version: "0.2.7",
2599
+ version: "0.2.15",
2852
2600
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2853
2601
  tool: "@cementic/cementic-test"
2854
2602
  },
@@ -2887,7 +2635,7 @@ function buildCasesMarkdown(analysis) {
2887
2635
  function saveCasesMarkdown(analysis, outputDir = "cases", fileName) {
2888
2636
  mkdirSync3(outputDir, { recursive: true });
2889
2637
  const resolvedFileName = fileName ?? `${slugify(analysis.feature || analysis.url)}.md`;
2890
- const filePath = join4(outputDir, resolvedFileName);
2638
+ const filePath = join3(outputDir, resolvedFileName);
2891
2639
  writeFileSync3(filePath, buildCasesMarkdown(analysis));
2892
2640
  return filePath;
2893
2641
  }
@@ -2895,7 +2643,7 @@ function saveSpecPreview(analysis, outputDir = "tests/preview") {
2895
2643
  if (analysis.scenarios.length === 0) return null;
2896
2644
  mkdirSync3(outputDir, { recursive: true });
2897
2645
  const fileName = `spec-preview-${slugify(analysis.url)}-${Date.now()}.spec.cjs`;
2898
- const filePath = join4(outputDir, fileName);
2646
+ const filePath = join3(outputDir, fileName);
2899
2647
  const lines = [];
2900
2648
  lines.push("/**");
2901
2649
  lines.push(" * CementicTest Capture Preview");
@@ -3028,7 +2776,7 @@ async function runTcInteractive(params) {
3028
2776
  });
3029
2777
  mkdirSync4("cases", { recursive: true });
3030
2778
  const fileName = `${prefix.toLowerCase()}-${slugify2(feature)}.md`;
3031
- const fullPath = join5("cases", fileName);
2779
+ const fullPath = join4("cases", fileName);
3032
2780
  let markdown;
3033
2781
  let pageSummary = void 0;
3034
2782
  if (params.useAi) {
@@ -3182,15 +2930,15 @@ function reportCmd() {
3182
2930
  // src/commands/serve.ts
3183
2931
  import { Command as Command6 } from "commander";
3184
2932
  import { spawn as spawn3 } from "child_process";
3185
- import { existsSync as existsSync3 } from "fs";
3186
- import { join as join6 } from "path";
2933
+ import { existsSync as existsSync2 } from "fs";
2934
+ import { join as join5 } from "path";
3187
2935
  function serveCmd() {
3188
2936
  const cmd = new Command6("serve").description("Serve the Allure report").action(() => {
3189
2937
  console.log("\u{1F4CA} Serving Allure report...");
3190
- const localAllureBin = join6(process.cwd(), "node_modules", "allure-commandline", "bin", "allure");
2938
+ const localAllureBin = join5(process.cwd(), "node_modules", "allure-commandline", "bin", "allure");
3191
2939
  let executable = "npx";
3192
2940
  let args = ["allure", "serve", "./allure-results"];
3193
- if (existsSync3(localAllureBin)) {
2941
+ if (existsSync2(localAllureBin)) {
3194
2942
  executable = "node";
3195
2943
  args = [localAllureBin, "serve", "./allure-results"];
3196
2944
  }
@@ -3213,9 +2961,9 @@ function serveCmd() {
3213
2961
  // src/commands/flow.ts
3214
2962
  import { Command as Command7 } from "commander";
3215
2963
  import { spawn as spawn4 } from "child_process";
3216
- import { resolve as resolve4 } from "path";
2964
+ import { resolve as resolve3 } from "path";
3217
2965
  function runStep(cmd, args, stepName) {
3218
- return new Promise((resolve5, reject) => {
2966
+ return new Promise((resolve4, reject) => {
3219
2967
  console.log(`
3220
2968
  \u{1F30A} Flow Step: ${stepName}`);
3221
2969
  console.log(`> ${cmd} ${args.join(" ")}`);
@@ -3225,7 +2973,7 @@ function runStep(cmd, args, stepName) {
3225
2973
  });
3226
2974
  child.on("exit", (code) => {
3227
2975
  if (code === 0) {
3228
- resolve5();
2976
+ resolve4();
3229
2977
  } else {
3230
2978
  reject(new Error(`${stepName} failed with exit code ${code}`));
3231
2979
  }
@@ -3234,7 +2982,7 @@ function runStep(cmd, args, stepName) {
3234
2982
  }
3235
2983
  function flowCmd() {
3236
2984
  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) => {
3237
- const cliBin = resolve4(process.argv[1]);
2985
+ const cliBin = resolve3(process.argv[1]);
3238
2986
  try {
3239
2987
  await runStep(process.execPath, [cliBin, "normalize", casesDir], "Normalize Cases");
3240
2988
  await runStep(process.execPath, [cliBin, "gen", "--lang", opts.lang], "Generate Tests");
@@ -3255,8 +3003,8 @@ function flowCmd() {
3255
3003
 
3256
3004
  // src/commands/ci.ts
3257
3005
  import { Command as Command8 } from "commander";
3258
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5, existsSync as existsSync4 } from "fs";
3259
- import { join as join7 } from "path";
3006
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5, existsSync as existsSync3 } from "fs";
3007
+ import { join as join6 } from "path";
3260
3008
  var WORKFLOW_CONTENT = `name: Playwright Tests
3261
3009
  on:
3262
3010
  push:
@@ -3287,15 +3035,15 @@ jobs:
3287
3035
  `;
3288
3036
  function ciCmd() {
3289
3037
  const cmd = new Command8("ci").description("Generate GitHub Actions workflow for CI").action(() => {
3290
- const githubDir = join7(process.cwd(), ".github");
3291
- const workflowsDir = join7(githubDir, "workflows");
3292
- const workflowFile = join7(workflowsDir, "cementic.yml");
3038
+ const githubDir = join6(process.cwd(), ".github");
3039
+ const workflowsDir = join6(githubDir, "workflows");
3040
+ const workflowFile = join6(workflowsDir, "cementic.yml");
3293
3041
  console.log("\u{1F916} Setting up CI/CD workflow...");
3294
- if (!existsSync4(workflowsDir)) {
3042
+ if (!existsSync3(workflowsDir)) {
3295
3043
  mkdirSync5(workflowsDir, { recursive: true });
3296
3044
  console.log(`Created directory: ${workflowsDir}`);
3297
3045
  }
3298
- if (existsSync4(workflowFile)) {
3046
+ if (existsSync3(workflowFile)) {
3299
3047
  console.warn(`\u26A0\uFE0F Workflow file already exists at ${workflowFile}. Skipping.`);
3300
3048
  return;
3301
3049
  }
@@ -3309,8 +3057,8 @@ function ciCmd() {
3309
3057
  }
3310
3058
 
3311
3059
  // src/cli.ts
3312
- var require3 = createRequire2(import.meta.url);
3313
- var { version } = require3("../package.json");
3060
+ var require2 = createRequire(import.meta.url);
3061
+ var { version } = require2("../package.json");
3314
3062
  var program = new Command9();
3315
3063
  program.name("ct").description("CementicTest CLI: cases \u2192 normalized \u2192 POM tests \u2192 Playwright").version(version);
3316
3064
  program.addCommand(newCmd());