@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/README.md +116 -1
- package/dist/capture.js +18 -0
- package/dist/{chunk-RG26I5FB.js → chunk-3S26OWNR.js} +5 -2
- package/dist/{chunk-RG26I5FB.js.map → chunk-3S26OWNR.js.map} +1 -1
- package/dist/chunk-XUWFEWJZ.js +622 -0
- package/dist/chunk-XUWFEWJZ.js.map +1 -0
- package/dist/cli.js +390 -642
- package/dist/cli.js.map +1 -1
- package/dist/{gen-6Y65IYXO.js → gen-AGWFMHTO.js} +2 -2
- package/dist/gen-AGWFMHTO.js.map +1 -0
- package/package.json +3 -3
- /package/dist/{gen-6Y65IYXO.js.map → capture.js.map} +0 -0
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-
|
|
9
|
+
} from "./chunk-3S26OWNR.js";
|
|
5
10
|
|
|
6
11
|
// src/cli.ts
|
|
7
12
|
import { Command as Command9 } from "commander";
|
|
8
|
-
import { createRequire
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
console.log("\
|
|
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(
|
|
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-
|
|
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
|
|
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
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
providerConfig.apiKey,
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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.
|
|
1406
|
-
13.
|
|
1407
|
-
14.
|
|
1408
|
-
15.
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
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
|
|
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 =
|
|
2596
|
+
const filePath = join3(outputDir, fileName);
|
|
2849
2597
|
writeFileSync3(filePath, JSON.stringify({
|
|
2850
2598
|
_meta: {
|
|
2851
|
-
version: "0.2.
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
3186
|
-
import { join as
|
|
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 =
|
|
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 (
|
|
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
|
|
2964
|
+
import { resolve as resolve3 } from "path";
|
|
3217
2965
|
function runStep(cmd, args, stepName) {
|
|
3218
|
-
return new Promise((
|
|
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
|
-
|
|
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 =
|
|
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
|
|
3259
|
-
import { join as
|
|
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 =
|
|
3291
|
-
const workflowsDir =
|
|
3292
|
-
const workflowFile =
|
|
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 (!
|
|
3042
|
+
if (!existsSync3(workflowsDir)) {
|
|
3295
3043
|
mkdirSync5(workflowsDir, { recursive: true });
|
|
3296
3044
|
console.log(`Created directory: ${workflowsDir}`);
|
|
3297
3045
|
}
|
|
3298
|
-
if (
|
|
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
|
|
3313
|
-
var { version } =
|
|
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());
|