@cementic/cementic-test 0.2.12 → 0.2.14
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 +106 -1
- package/dist/{chunk-RG26I5FB.js → chunk-3S26OWNR.js} +5 -2
- package/dist/{chunk-RG26I5FB.js.map → chunk-3S26OWNR.js.map} +1 -1
- package/dist/cli.js +1011 -65
- package/dist/cli.js.map +1 -1
- package/dist/{gen-6Y65IYXO.js → gen-AGWFMHTO.js} +2 -2
- package/package.json +3 -3
- /package/dist/{gen-6Y65IYXO.js.map → gen-AGWFMHTO.js.map} +0 -0
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
genCmd
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-3S26OWNR.js";
|
|
5
5
|
|
|
6
6
|
// src/cli.ts
|
|
7
7
|
import { Command as Command9 } from "commander";
|
|
@@ -18,6 +18,8 @@ import { platform, release } from "os";
|
|
|
18
18
|
var __filename = fileURLToPath(import.meta.url);
|
|
19
19
|
var __dirname = dirname(__filename);
|
|
20
20
|
var LEGACY_MACOS_DARWIN_MAJOR = 23;
|
|
21
|
+
var LEGACY_PLAYWRIGHT_VERSION = "^1.48.2";
|
|
22
|
+
var LEGACY_ALLURE_VERSION = "^2.15.1";
|
|
21
23
|
function resolveTemplatePath(templateDir) {
|
|
22
24
|
const candidates = [
|
|
23
25
|
resolve(__dirname, `templates/${templateDir}`),
|
|
@@ -27,13 +29,73 @@ function resolveTemplatePath(templateDir) {
|
|
|
27
29
|
];
|
|
28
30
|
return candidates.find((candidate) => existsSync(candidate));
|
|
29
31
|
}
|
|
32
|
+
function getHostPlatform(env = process.env) {
|
|
33
|
+
return (env.CT_OS_PLATFORM_OVERRIDE ?? platform()).trim().toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
function getHostRelease(env = process.env) {
|
|
36
|
+
return (env.CT_OS_RELEASE_OVERRIDE ?? release()).trim();
|
|
37
|
+
}
|
|
38
|
+
function isLegacyMacOs(env = process.env) {
|
|
39
|
+
if (getHostPlatform(env) !== "darwin") return false;
|
|
40
|
+
const majorVersion = parseInt(getHostRelease(env).split(".")[0], 10);
|
|
41
|
+
return Number.isFinite(majorVersion) && majorVersion < LEGACY_MACOS_DARWIN_MAJOR;
|
|
42
|
+
}
|
|
43
|
+
function resolveBrowserInstallProfile(raw) {
|
|
44
|
+
const profile = String(raw ?? "auto").trim().toLowerCase();
|
|
45
|
+
if (profile === "auto" || profile === "all" || profile === "chromium") return profile;
|
|
46
|
+
console.error(`\u274C Unsupported browser install profile "${raw}". Use "auto", "all", or "chromium".`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
function resolveBootstrapPlan(browserProfile, env = process.env) {
|
|
50
|
+
const legacyMacOs = isLegacyMacOs(env);
|
|
51
|
+
const resolvedProfile = browserProfile === "auto" ? legacyMacOs ? "chromium" : "all" : browserProfile;
|
|
52
|
+
return {
|
|
53
|
+
browserInstallArgs: resolvedProfile === "chromium" ? ["playwright", "install", "chromium"] : ["playwright", "install"],
|
|
54
|
+
browserProfile: resolvedProfile,
|
|
55
|
+
isLegacyMacOs: legacyMacOs,
|
|
56
|
+
packageVersionOverrides: legacyMacOs ? {
|
|
57
|
+
"@playwright/test": LEGACY_PLAYWRIGHT_VERSION,
|
|
58
|
+
"allure-playwright": LEGACY_ALLURE_VERSION
|
|
59
|
+
} : {},
|
|
60
|
+
reason: legacyMacOs ? "Detected macOS 13 or older. Using a legacy-compatible Playwright toolchain and Chromium-only browser install." : void 0
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function applyPackageVersionOverrides(projectPath, versionOverrides) {
|
|
64
|
+
if (Object.keys(versionOverrides).length === 0) return;
|
|
65
|
+
const pkgJsonPath = join(projectPath, "package.json");
|
|
66
|
+
if (!existsSync(pkgJsonPath)) return;
|
|
67
|
+
try {
|
|
68
|
+
const pkgContent = readFileSync(pkgJsonPath, "utf-8");
|
|
69
|
+
const pkg = JSON.parse(pkgContent);
|
|
70
|
+
const devDependencies = pkg.devDependencies ?? {};
|
|
71
|
+
let changed = false;
|
|
72
|
+
for (const [name, version2] of Object.entries(versionOverrides)) {
|
|
73
|
+
if (typeof devDependencies[name] !== "string") continue;
|
|
74
|
+
devDependencies[name] = version2;
|
|
75
|
+
changed = true;
|
|
76
|
+
}
|
|
77
|
+
if (!changed) return;
|
|
78
|
+
pkg.devDependencies = devDependencies;
|
|
79
|
+
writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, 2));
|
|
80
|
+
console.log("\u2705 Applied compatibility dependency pins for this machine.");
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.warn("\u26A0\uFE0F Failed to adjust package.json for OS compatibility:", err);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
30
85
|
function newCmd() {
|
|
31
86
|
const cmd = new Command("new").arguments("<projectName>").description("Scaffold a new CementicTest + Playwright project from scratch").addHelpText("after", `
|
|
32
87
|
Examples:
|
|
33
88
|
$ ct new my-awesome-test-suite
|
|
34
89
|
$ ct new e2e-ts --lang ts
|
|
35
90
|
$ 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) => {
|
|
91
|
+
`).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) => {
|
|
92
|
+
const mode = String(opts.mode ?? "greenfield").trim().toLowerCase();
|
|
93
|
+
if (mode !== "greenfield") {
|
|
94
|
+
console.error(`\u274C Unsupported scaffold mode "${opts.mode}". Only "greenfield" is currently implemented.`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
const browserInstallProfile = resolveBrowserInstallProfile(opts.browserSet);
|
|
98
|
+
const bootstrapPlan = resolveBootstrapPlan(browserInstallProfile);
|
|
37
99
|
const root = process.cwd();
|
|
38
100
|
const projectPath = join(root, projectName);
|
|
39
101
|
console.log(`\u{1F680} Initializing new CementicTest project in ${projectName}...`);
|
|
@@ -62,25 +124,10 @@ Examples:
|
|
|
62
124
|
}
|
|
63
125
|
}
|
|
64
126
|
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
|
-
}
|
|
127
|
+
if (bootstrapPlan.reason) {
|
|
128
|
+
console.log(`\u{1F34E} ${bootstrapPlan.reason}`);
|
|
83
129
|
}
|
|
130
|
+
applyPackageVersionOverrides(projectPath, bootstrapPlan.packageVersionOverrides);
|
|
84
131
|
try {
|
|
85
132
|
execSync("git init", { cwd: projectPath, stdio: "ignore" });
|
|
86
133
|
const gitignorePath = join(projectPath, ".gitignore");
|
|
@@ -101,17 +148,19 @@ Examples:
|
|
|
101
148
|
if (opts.browsers !== false && dependenciesInstalled) {
|
|
102
149
|
console.log("\u{1F310} Installing Playwright browsers...");
|
|
103
150
|
try {
|
|
104
|
-
|
|
151
|
+
const installCommand = `npx ${bootstrapPlan.browserInstallArgs.join(" ")}`;
|
|
152
|
+
if (bootstrapPlan.browserProfile === "chromium" && bootstrapPlan.isLegacyMacOs) {
|
|
105
153
|
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.");
|
|
154
|
+
} else if (bootstrapPlan.browserProfile === "chromium") {
|
|
155
|
+
console.log("\u2139\uFE0F Installing Chromium only because --browser-set chromium was requested.");
|
|
111
156
|
}
|
|
157
|
+
execSync(installCommand, { cwd: projectPath, stdio: "inherit" });
|
|
158
|
+
console.log(
|
|
159
|
+
bootstrapPlan.browserProfile === "chromium" ? "\u2705 Chromium installed successfully." : "\u2705 Playwright browsers installed successfully."
|
|
160
|
+
);
|
|
112
161
|
} catch (e) {
|
|
113
162
|
console.warn("\u26A0\uFE0F Browser installation did not complete. You can finish setup with:");
|
|
114
|
-
console.warn(
|
|
163
|
+
console.warn(` npx ${bootstrapPlan.browserInstallArgs.join(" ")}`);
|
|
115
164
|
}
|
|
116
165
|
}
|
|
117
166
|
console.log(`
|
|
@@ -126,11 +175,6 @@ Happy testing! \u{1F9EA}`);
|
|
|
126
175
|
});
|
|
127
176
|
return cmd;
|
|
128
177
|
}
|
|
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
178
|
|
|
135
179
|
// src/commands/normalize.ts
|
|
136
180
|
import { Command as Command2 } from "commander";
|
|
@@ -321,7 +365,7 @@ Examples:
|
|
|
321
365
|
}
|
|
322
366
|
console.log(`\u2705 Normalized ${index.summary.parsed} case(s). Output \u2192 .cementic/normalized/`);
|
|
323
367
|
if (opts.andGen) {
|
|
324
|
-
const { gen } = await import("./gen-
|
|
368
|
+
const { gen } = await import("./gen-AGWFMHTO.js");
|
|
325
369
|
await gen({ lang: opts.lang || "ts", out: "tests/generated" });
|
|
326
370
|
}
|
|
327
371
|
});
|
|
@@ -501,6 +545,315 @@ function buildProviderConfigs(env) {
|
|
|
501
545
|
function getAvailableCtVarKeys() {
|
|
502
546
|
return Object.keys(process.env).filter((key) => /^CT_VAR_[A-Z0-9_]+$/.test(key)).sort();
|
|
503
547
|
}
|
|
548
|
+
var RULE_10_PLAYWRIGHT_KNOWLEDGE_BASE = `
|
|
549
|
+
RULE 10 - PLAYWRIGHT KNOWLEDGE BASE
|
|
550
|
+
(sourced from playwright.dev official documentation)
|
|
551
|
+
|
|
552
|
+
You are an expert in Playwright TypeScript. Apply this knowledge
|
|
553
|
+
when generating test code. Always use web-first assertions that
|
|
554
|
+
auto-wait. Never use manual waits or non-awaited assertions.
|
|
555
|
+
|
|
556
|
+
LOCATOR PRIORITY (use in this order)
|
|
557
|
+
1. page.getByRole('button', { name: 'Submit' }) <- best
|
|
558
|
+
2. page.getByLabel('Email address')
|
|
559
|
+
3. page.getByPlaceholder('Enter email')
|
|
560
|
+
4. page.getByText('Welcome')
|
|
561
|
+
5. page.getByAltText('logo')
|
|
562
|
+
6. page.getByTitle('Close')
|
|
563
|
+
7. page.getByTestId('submit-btn')
|
|
564
|
+
8. page.locator('#id') or page.locator('.class') <- last resort
|
|
565
|
+
|
|
566
|
+
Chain locators to narrow scope:
|
|
567
|
+
page.getByRole('listitem').filter({ hasText: 'Product 2' })
|
|
568
|
+
.getByRole('button', { name: 'Add to cart' }).click()
|
|
569
|
+
|
|
570
|
+
LOCATOR ASSERTIONS (always await, always web-first)
|
|
571
|
+
Visibility:
|
|
572
|
+
await expect(locator).toBeVisible()
|
|
573
|
+
await expect(locator).toBeHidden()
|
|
574
|
+
await expect(locator).toBeInViewport()
|
|
575
|
+
|
|
576
|
+
State:
|
|
577
|
+
await expect(locator).toBeEnabled()
|
|
578
|
+
await expect(locator).toBeDisabled()
|
|
579
|
+
await expect(locator).toBeChecked()
|
|
580
|
+
await expect(locator).not.toBeChecked()
|
|
581
|
+
await expect(locator).toBeFocused()
|
|
582
|
+
await expect(locator).toBeEditable()
|
|
583
|
+
await expect(locator).toBeEmpty()
|
|
584
|
+
await expect(locator).toBeAttached()
|
|
585
|
+
|
|
586
|
+
Text content:
|
|
587
|
+
await expect(locator).toHaveText('exact text')
|
|
588
|
+
await expect(locator).toHaveText(/regex/)
|
|
589
|
+
await expect(locator).toContainText('partial')
|
|
590
|
+
await expect(locator).toContainText(['item1', 'item2'])
|
|
591
|
+
|
|
592
|
+
Value and attributes:
|
|
593
|
+
await expect(locator).toHaveValue('input value')
|
|
594
|
+
await expect(locator).toHaveValues(['opt1', 'opt2']) // multi-select
|
|
595
|
+
await expect(locator).toHaveAttribute('href', /pattern/)
|
|
596
|
+
await expect(locator).toHaveClass(/active/)
|
|
597
|
+
await expect(locator).toHaveCSS('color', 'rgb(0,0,0)')
|
|
598
|
+
await expect(locator).toHaveId('submit-btn')
|
|
599
|
+
await expect(locator).toHaveAccessibleName('Submit form')
|
|
600
|
+
await expect(locator).toHaveAccessibleDescription('...')
|
|
601
|
+
|
|
602
|
+
Counting (PREFER toHaveCount over .count() to avoid flakiness):
|
|
603
|
+
await expect(locator).toHaveCount(3)
|
|
604
|
+
await expect(locator).toHaveCount(0) // none exist
|
|
605
|
+
// Only use .count() when you need the actual number:
|
|
606
|
+
const n = await page.getByRole('button').count();
|
|
607
|
+
console.log(\`Found \${n} buttons\`);
|
|
608
|
+
|
|
609
|
+
PAGE ASSERTIONS
|
|
610
|
+
await expect(page).toHaveTitle(/Playwright/)
|
|
611
|
+
await expect(page).toHaveTitle('Exact Title')
|
|
612
|
+
await expect(page).toHaveURL('https://example.com/dashboard')
|
|
613
|
+
await expect(page).toHaveURL(/\\/dashboard/)
|
|
614
|
+
|
|
615
|
+
ACTIONS (Playwright auto-waits before each action)
|
|
616
|
+
Navigation:
|
|
617
|
+
await page.goto('https://example.com')
|
|
618
|
+
await page.goBack()
|
|
619
|
+
await page.goForward()
|
|
620
|
+
await page.reload()
|
|
621
|
+
|
|
622
|
+
Clicking:
|
|
623
|
+
await locator.click()
|
|
624
|
+
await locator.dblclick()
|
|
625
|
+
await locator.click({ button: 'right' })
|
|
626
|
+
await locator.click({ modifiers: ['Shift'] })
|
|
627
|
+
|
|
628
|
+
Forms:
|
|
629
|
+
await locator.fill('text') // clears then types
|
|
630
|
+
await locator.clear()
|
|
631
|
+
await locator.pressSequentially('slow typing', { delay: 50 })
|
|
632
|
+
await locator.selectOption('value')
|
|
633
|
+
await locator.selectOption({ label: 'Blue' })
|
|
634
|
+
await locator.check()
|
|
635
|
+
await locator.uncheck()
|
|
636
|
+
await locator.setInputFiles('path/to/file.pdf')
|
|
637
|
+
|
|
638
|
+
Hover and focus:
|
|
639
|
+
await locator.hover()
|
|
640
|
+
await locator.focus()
|
|
641
|
+
await locator.blur()
|
|
642
|
+
|
|
643
|
+
Keyboard:
|
|
644
|
+
await page.keyboard.press('Enter')
|
|
645
|
+
await page.keyboard.press('Tab')
|
|
646
|
+
await page.keyboard.press('Escape')
|
|
647
|
+
await page.keyboard.press('Control+A')
|
|
648
|
+
|
|
649
|
+
Scroll:
|
|
650
|
+
await locator.scrollIntoViewIfNeeded()
|
|
651
|
+
await page.mouse.wheel(0, 500)
|
|
652
|
+
|
|
653
|
+
BEST PRACTICES (from playwright.dev/docs/best-practices)
|
|
654
|
+
DO use web-first assertions:
|
|
655
|
+
await expect(page.getByText('welcome')).toBeVisible()
|
|
656
|
+
|
|
657
|
+
NEVER use synchronous assertions:
|
|
658
|
+
expect(await page.getByText('welcome').isVisible()).toBe(true)
|
|
659
|
+
|
|
660
|
+
DO chain locators:
|
|
661
|
+
page.getByRole('listitem').filter({ hasText: 'Product 2' })
|
|
662
|
+
.getByRole('button', { name: 'Add to cart' })
|
|
663
|
+
|
|
664
|
+
NEVER use fragile CSS/XPath selectors:
|
|
665
|
+
page.locator('button.buttonIcon.episode-actions-later')
|
|
666
|
+
|
|
667
|
+
DO use role-based locators:
|
|
668
|
+
page.getByRole('button', { name: 'submit' })
|
|
669
|
+
|
|
670
|
+
INTENT -> PATTERN MAPPING
|
|
671
|
+
|
|
672
|
+
COUNTING:
|
|
673
|
+
"how many X" / "count X" / "number of X" / "return the count"
|
|
674
|
+
-> const n = await page.getByRole('X').count();
|
|
675
|
+
console.log(\`Found \${n} X elements\`);
|
|
676
|
+
expect(n).toBeGreaterThan(0);
|
|
677
|
+
|
|
678
|
+
"there are N X" / "exactly N X"
|
|
679
|
+
-> await expect(page.getByRole('X')).toHaveCount(N);
|
|
680
|
+
|
|
681
|
+
CRITICAL: Never use getByText("how many X") - count intent
|
|
682
|
+
means call .count() or toHaveCount(), not search for text.
|
|
683
|
+
|
|
684
|
+
PRESENCE:
|
|
685
|
+
"X is present/visible/shown/exists"
|
|
686
|
+
-> await expect(locator).toBeVisible()
|
|
687
|
+
|
|
688
|
+
"X is hidden/not present/gone"
|
|
689
|
+
-> await expect(locator).toBeHidden()
|
|
690
|
+
|
|
691
|
+
TEXT:
|
|
692
|
+
"text says X" / "shows X" / "message is X"
|
|
693
|
+
-> await expect(locator).toHaveText('X')
|
|
694
|
+
|
|
695
|
+
"contains X" / "includes X"
|
|
696
|
+
-> await expect(locator).toContainText('X')
|
|
697
|
+
|
|
698
|
+
"page title is X"
|
|
699
|
+
-> await expect(page).toHaveTitle(/X/i)
|
|
700
|
+
|
|
701
|
+
"heading says X"
|
|
702
|
+
-> await expect(page.getByRole('heading')).toContainText('X')
|
|
703
|
+
|
|
704
|
+
"error says X"
|
|
705
|
+
-> await expect(page.getByRole('alert')).toContainText('X')
|
|
706
|
+
|
|
707
|
+
STATE:
|
|
708
|
+
"X is enabled/disabled"
|
|
709
|
+
-> await expect(locator).toBeEnabled() / toBeDisabled()
|
|
710
|
+
|
|
711
|
+
"X is checked/unchecked"
|
|
712
|
+
-> await expect(locator).toBeChecked() / not.toBeChecked()
|
|
713
|
+
|
|
714
|
+
"X has value Y"
|
|
715
|
+
-> await expect(locator).toHaveValue('Y')
|
|
716
|
+
|
|
717
|
+
"X is active / has class Y"
|
|
718
|
+
-> await expect(locator).toHaveClass(/Y/)
|
|
719
|
+
|
|
720
|
+
NAVIGATION:
|
|
721
|
+
"redirects to X" / "goes to X" / "URL contains X"
|
|
722
|
+
-> await expect(page).toHaveURL(/X/)
|
|
723
|
+
|
|
724
|
+
"stays on same page"
|
|
725
|
+
-> await expect(page).toHaveURL(/currentPath/)
|
|
726
|
+
|
|
727
|
+
"opens new tab"
|
|
728
|
+
-> const [newPage] = await Promise.all([
|
|
729
|
+
context.waitForEvent('page'),
|
|
730
|
+
locator.click()
|
|
731
|
+
]);
|
|
732
|
+
|
|
733
|
+
FORMS:
|
|
734
|
+
"form submits successfully"
|
|
735
|
+
-> fill fields + click submit + assert URL change or success message
|
|
736
|
+
|
|
737
|
+
"validation error shown"
|
|
738
|
+
-> submit empty + await expect(page.getByRole('alert')).toBeVisible()
|
|
739
|
+
|
|
740
|
+
"required field X"
|
|
741
|
+
-> submit without X + assert error message for X visible
|
|
742
|
+
|
|
743
|
+
AUTH:
|
|
744
|
+
"login with valid credentials"
|
|
745
|
+
-> fill(CT_VAR_USERNAME) + fill(CT_VAR_PASSWORD) + click login
|
|
746
|
+
+ await expect(page).toHaveURL(/dashboard|home|app/)
|
|
747
|
+
|
|
748
|
+
"login fails / invalid credentials"
|
|
749
|
+
-> fill bad values + click login
|
|
750
|
+
+ await expect(page.getByRole('alert')).toBeVisible()
|
|
751
|
+
|
|
752
|
+
"logout works"
|
|
753
|
+
-> click logout + await expect(page).toHaveURL(/login|home/)
|
|
754
|
+
|
|
755
|
+
THEME / VISUAL:
|
|
756
|
+
"dark mode / theme toggle"
|
|
757
|
+
-> await locator.click()
|
|
758
|
+
+ await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
|
|
759
|
+
// OR: await expect(page.locator('body')).toHaveClass(/dark/)
|
|
760
|
+
|
|
761
|
+
TIMING:
|
|
762
|
+
"X loads / X appears"
|
|
763
|
+
-> await expect(locator).toBeVisible({ timeout: 10000 })
|
|
764
|
+
|
|
765
|
+
"spinner disappears / loader gone"
|
|
766
|
+
-> await expect(spinner).toBeHidden({ timeout: 10000 })
|
|
767
|
+
|
|
768
|
+
"modal closes"
|
|
769
|
+
-> await expect(modal).toBeHidden()
|
|
770
|
+
|
|
771
|
+
ACCESSIBILITY:
|
|
772
|
+
"has alt text"
|
|
773
|
+
-> await expect(page.locator('img')).toHaveAttribute('alt', /.+/)
|
|
774
|
+
|
|
775
|
+
"keyboard navigable"
|
|
776
|
+
-> await page.keyboard.press('Tab')
|
|
777
|
+
+ await expect(firstFocusable).toBeFocused()
|
|
778
|
+
|
|
779
|
+
CRITICAL RULES
|
|
780
|
+
1. NEVER use intent words as locator text.
|
|
781
|
+
"count buttons" != getByText("count buttons")
|
|
782
|
+
"verify heading" != getByText("verify heading")
|
|
783
|
+
|
|
784
|
+
2. ALWAYS use web-first assertions (await expect).
|
|
785
|
+
NEVER use: expect(await locator.isVisible()).toBe(true)
|
|
786
|
+
|
|
787
|
+
3. For counting, prefer toHaveCount() over .count() unless
|
|
788
|
+
you need the actual number for logging.
|
|
789
|
+
|
|
790
|
+
4. Auto-waiting: Playwright automatically waits for elements
|
|
791
|
+
to be actionable before click/fill/etc. Do not add
|
|
792
|
+
manual waitForTimeout() unless testing timing specifically.
|
|
793
|
+
|
|
794
|
+
5. Use not. prefix for negative assertions:
|
|
795
|
+
await expect(locator).not.toBeVisible()
|
|
796
|
+
await expect(locator).not.toBeChecked()
|
|
797
|
+
`.trim();
|
|
798
|
+
var ADVANCED_DOC_MAP = {
|
|
799
|
+
upload: "https://playwright.dev/docs/input",
|
|
800
|
+
"file upload": "https://playwright.dev/docs/input",
|
|
801
|
+
intercept: "https://playwright.dev/docs/mock",
|
|
802
|
+
mock: "https://playwright.dev/docs/mock",
|
|
803
|
+
"api mock": "https://playwright.dev/docs/mock",
|
|
804
|
+
accessibility: "https://playwright.dev/docs/accessibility-testing",
|
|
805
|
+
"screen reader": "https://playwright.dev/docs/accessibility-testing",
|
|
806
|
+
visual: "https://playwright.dev/docs/screenshots",
|
|
807
|
+
screenshot: "https://playwright.dev/docs/screenshots",
|
|
808
|
+
mobile: "https://playwright.dev/docs/emulation",
|
|
809
|
+
responsive: "https://playwright.dev/docs/emulation",
|
|
810
|
+
viewport: "https://playwright.dev/docs/emulation",
|
|
811
|
+
network: "https://playwright.dev/docs/network",
|
|
812
|
+
"api call": "https://playwright.dev/docs/network",
|
|
813
|
+
iframe: "https://playwright.dev/docs/frames",
|
|
814
|
+
frame: "https://playwright.dev/docs/frames",
|
|
815
|
+
auth: "https://playwright.dev/docs/auth",
|
|
816
|
+
"sign in": "https://playwright.dev/docs/auth",
|
|
817
|
+
"stay logged": "https://playwright.dev/docs/auth",
|
|
818
|
+
cookie: "https://playwright.dev/docs/auth",
|
|
819
|
+
storage: "https://playwright.dev/docs/auth",
|
|
820
|
+
download: "https://playwright.dev/docs/downloads",
|
|
821
|
+
pdf: "https://playwright.dev/docs/downloads",
|
|
822
|
+
dialog: "https://playwright.dev/docs/dialogs",
|
|
823
|
+
alert: "https://playwright.dev/docs/dialogs",
|
|
824
|
+
confirm: "https://playwright.dev/docs/dialogs",
|
|
825
|
+
"new tab": "https://playwright.dev/docs/pages",
|
|
826
|
+
"new page": "https://playwright.dev/docs/pages",
|
|
827
|
+
popup: "https://playwright.dev/docs/pages"
|
|
828
|
+
};
|
|
829
|
+
async function fetchDocContext(intent) {
|
|
830
|
+
const intentLower = intent.toLowerCase();
|
|
831
|
+
const matched = /* @__PURE__ */ new Set();
|
|
832
|
+
for (const [keyword, url] of Object.entries(ADVANCED_DOC_MAP)) {
|
|
833
|
+
if (intentLower.includes(keyword)) {
|
|
834
|
+
matched.add(url);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
if (matched.size === 0) return "";
|
|
838
|
+
const fetched = [];
|
|
839
|
+
for (const url of matched) {
|
|
840
|
+
try {
|
|
841
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(5e3) });
|
|
842
|
+
const html = await res.text();
|
|
843
|
+
const text = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s{3,}/g, "\n\n").slice(0, 3e3);
|
|
844
|
+
fetched.push(`
|
|
845
|
+
// From ${url}:
|
|
846
|
+
${text}`);
|
|
847
|
+
} catch {
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (fetched.length === 0) return "";
|
|
851
|
+
return `
|
|
852
|
+
|
|
853
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
854
|
+
ADDITIONAL PLAYWRIGHT DOCS (fetched for this intent)
|
|
855
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501${fetched.join("\n")}`;
|
|
856
|
+
}
|
|
504
857
|
function buildSystemMessage() {
|
|
505
858
|
return `
|
|
506
859
|
You are a senior software test architect with 10 years of Playwright experience.
|
|
@@ -567,6 +920,8 @@ Match scenario count to intent complexity:
|
|
|
567
920
|
- Full flow: 4 to 8 scenarios maximum
|
|
568
921
|
Never exceed 8 scenarios.
|
|
569
922
|
|
|
923
|
+
${RULE_10_PLAYWRIGHT_KNOWLEDGE_BASE}
|
|
924
|
+
|
|
570
925
|
COMMON TESTING VOCABULARY
|
|
571
926
|
"verify/check X is present/visible" -> toBeVisible()
|
|
572
927
|
"verify title/heading" -> heading is visible
|
|
@@ -725,7 +1080,8 @@ async function callOpenAiCompatible(apiKey, model, baseUrl, displayName, system,
|
|
|
725
1080
|
}
|
|
726
1081
|
async function generateTcMarkdownWithAi(ctx) {
|
|
727
1082
|
const providerConfig = resolveLlmProvider();
|
|
728
|
-
const
|
|
1083
|
+
const docContext = await fetchDocContext(ctx.feature);
|
|
1084
|
+
const system = buildSystemMessage() + docContext;
|
|
729
1085
|
const user = buildUserMessage(ctx);
|
|
730
1086
|
console.log(`\u{1F916} Using ${providerConfig.displayName} (${providerConfig.model})`);
|
|
731
1087
|
if (providerConfig.transport === "anthropic") {
|
|
@@ -742,24 +1098,351 @@ async function generateTcMarkdownWithAi(ctx) {
|
|
|
742
1098
|
}
|
|
743
1099
|
|
|
744
1100
|
// src/core/analyse.ts
|
|
1101
|
+
function escapeForRegex(value) {
|
|
1102
|
+
return value.replace(/[.*+?^${}()|[\]\\\/]/g, "\\$&");
|
|
1103
|
+
}
|
|
1104
|
+
var RULE_10_PLAYWRIGHT_KNOWLEDGE_BASE2 = `
|
|
1105
|
+
RULE 10 - PLAYWRIGHT KNOWLEDGE BASE
|
|
1106
|
+
(sourced from playwright.dev official documentation)
|
|
1107
|
+
|
|
1108
|
+
You are an expert in Playwright TypeScript. Apply this knowledge
|
|
1109
|
+
when generating test code. Always use web-first assertions that
|
|
1110
|
+
auto-wait. Never use manual waits or non-awaited assertions.
|
|
1111
|
+
|
|
1112
|
+
LOCATOR PRIORITY (use in this order)
|
|
1113
|
+
1. page.getByRole('button', { name: 'Submit' }) <- best
|
|
1114
|
+
2. page.getByLabel('Email address')
|
|
1115
|
+
3. page.getByPlaceholder('Enter email')
|
|
1116
|
+
4. page.getByText('Welcome')
|
|
1117
|
+
5. page.getByAltText('logo')
|
|
1118
|
+
6. page.getByTitle('Close')
|
|
1119
|
+
7. page.getByTestId('submit-btn')
|
|
1120
|
+
8. page.locator('#id') or page.locator('.class') <- last resort
|
|
1121
|
+
|
|
1122
|
+
Chain locators to narrow scope:
|
|
1123
|
+
page.getByRole('listitem').filter({ hasText: 'Product 2' })
|
|
1124
|
+
.getByRole('button', { name: 'Add to cart' }).click()
|
|
1125
|
+
|
|
1126
|
+
LOCATOR ASSERTIONS (always await, always web-first)
|
|
1127
|
+
Visibility:
|
|
1128
|
+
await expect(locator).toBeVisible()
|
|
1129
|
+
await expect(locator).toBeHidden()
|
|
1130
|
+
await expect(locator).toBeInViewport()
|
|
1131
|
+
|
|
1132
|
+
State:
|
|
1133
|
+
await expect(locator).toBeEnabled()
|
|
1134
|
+
await expect(locator).toBeDisabled()
|
|
1135
|
+
await expect(locator).toBeChecked()
|
|
1136
|
+
await expect(locator).not.toBeChecked()
|
|
1137
|
+
await expect(locator).toBeFocused()
|
|
1138
|
+
await expect(locator).toBeEditable()
|
|
1139
|
+
await expect(locator).toBeEmpty()
|
|
1140
|
+
await expect(locator).toBeAttached()
|
|
1141
|
+
|
|
1142
|
+
Text content:
|
|
1143
|
+
await expect(locator).toHaveText('exact text')
|
|
1144
|
+
await expect(locator).toHaveText(/regex/)
|
|
1145
|
+
await expect(locator).toContainText('partial')
|
|
1146
|
+
await expect(locator).toContainText(['item1', 'item2'])
|
|
1147
|
+
|
|
1148
|
+
Value and attributes:
|
|
1149
|
+
await expect(locator).toHaveValue('input value')
|
|
1150
|
+
await expect(locator).toHaveValues(['opt1', 'opt2']) // multi-select
|
|
1151
|
+
await expect(locator).toHaveAttribute('href', /pattern/)
|
|
1152
|
+
await expect(locator).toHaveClass(/active/)
|
|
1153
|
+
await expect(locator).toHaveCSS('color', 'rgb(0,0,0)')
|
|
1154
|
+
await expect(locator).toHaveId('submit-btn')
|
|
1155
|
+
await expect(locator).toHaveAccessibleName('Submit form')
|
|
1156
|
+
await expect(locator).toHaveAccessibleDescription('...')
|
|
1157
|
+
|
|
1158
|
+
Counting (PREFER toHaveCount over .count() to avoid flakiness):
|
|
1159
|
+
await expect(locator).toHaveCount(3)
|
|
1160
|
+
await expect(locator).toHaveCount(0) // none exist
|
|
1161
|
+
// Only use .count() when you need the actual number:
|
|
1162
|
+
const n = await page.getByRole('button').count();
|
|
1163
|
+
console.log(\`Found \${n} buttons\`);
|
|
1164
|
+
|
|
1165
|
+
PAGE ASSERTIONS
|
|
1166
|
+
await expect(page).toHaveTitle(/Playwright/)
|
|
1167
|
+
await expect(page).toHaveTitle('Exact Title')
|
|
1168
|
+
await expect(page).toHaveURL('https://example.com/dashboard')
|
|
1169
|
+
await expect(page).toHaveURL(/\\/dashboard/)
|
|
1170
|
+
|
|
1171
|
+
ACTIONS (Playwright auto-waits before each action)
|
|
1172
|
+
Navigation:
|
|
1173
|
+
await page.goto('https://example.com')
|
|
1174
|
+
await page.goBack()
|
|
1175
|
+
await page.goForward()
|
|
1176
|
+
await page.reload()
|
|
1177
|
+
|
|
1178
|
+
Clicking:
|
|
1179
|
+
await locator.click()
|
|
1180
|
+
await locator.dblclick()
|
|
1181
|
+
await locator.click({ button: 'right' })
|
|
1182
|
+
await locator.click({ modifiers: ['Shift'] })
|
|
1183
|
+
|
|
1184
|
+
Forms:
|
|
1185
|
+
await locator.fill('text') // clears then types
|
|
1186
|
+
await locator.clear()
|
|
1187
|
+
await locator.pressSequentially('slow typing', { delay: 50 })
|
|
1188
|
+
await locator.selectOption('value')
|
|
1189
|
+
await locator.selectOption({ label: 'Blue' })
|
|
1190
|
+
await locator.check()
|
|
1191
|
+
await locator.uncheck()
|
|
1192
|
+
await locator.setInputFiles('path/to/file.pdf')
|
|
1193
|
+
|
|
1194
|
+
Hover and focus:
|
|
1195
|
+
await locator.hover()
|
|
1196
|
+
await locator.focus()
|
|
1197
|
+
await locator.blur()
|
|
1198
|
+
|
|
1199
|
+
Keyboard:
|
|
1200
|
+
await page.keyboard.press('Enter')
|
|
1201
|
+
await page.keyboard.press('Tab')
|
|
1202
|
+
await page.keyboard.press('Escape')
|
|
1203
|
+
await page.keyboard.press('Control+A')
|
|
1204
|
+
|
|
1205
|
+
Scroll:
|
|
1206
|
+
await locator.scrollIntoViewIfNeeded()
|
|
1207
|
+
await page.mouse.wheel(0, 500)
|
|
1208
|
+
|
|
1209
|
+
BEST PRACTICES (from playwright.dev/docs/best-practices)
|
|
1210
|
+
DO use web-first assertions:
|
|
1211
|
+
await expect(page.getByText('welcome')).toBeVisible()
|
|
1212
|
+
|
|
1213
|
+
NEVER use synchronous assertions:
|
|
1214
|
+
expect(await page.getByText('welcome').isVisible()).toBe(true)
|
|
1215
|
+
|
|
1216
|
+
DO chain locators:
|
|
1217
|
+
page.getByRole('listitem').filter({ hasText: 'Product 2' })
|
|
1218
|
+
.getByRole('button', { name: 'Add to cart' })
|
|
1219
|
+
|
|
1220
|
+
NEVER use fragile CSS/XPath selectors:
|
|
1221
|
+
page.locator('button.buttonIcon.episode-actions-later')
|
|
1222
|
+
|
|
1223
|
+
DO use role-based locators:
|
|
1224
|
+
page.getByRole('button', { name: 'submit' })
|
|
1225
|
+
|
|
1226
|
+
INTENT -> PATTERN MAPPING
|
|
1227
|
+
|
|
1228
|
+
COUNTING:
|
|
1229
|
+
"how many X" / "count X" / "number of X" / "return the count"
|
|
1230
|
+
-> const n = await page.getByRole('X').count();
|
|
1231
|
+
console.log(\`Found \${n} X elements\`);
|
|
1232
|
+
expect(n).toBeGreaterThan(0);
|
|
1233
|
+
|
|
1234
|
+
"there are N X" / "exactly N X"
|
|
1235
|
+
-> await expect(page.getByRole('X')).toHaveCount(N);
|
|
1236
|
+
|
|
1237
|
+
CRITICAL: Never use getByText("how many X") - count intent
|
|
1238
|
+
means call .count() or toHaveCount(), not search for text.
|
|
1239
|
+
|
|
1240
|
+
PRESENCE:
|
|
1241
|
+
"X is present/visible/shown/exists"
|
|
1242
|
+
-> await expect(locator).toBeVisible()
|
|
1243
|
+
|
|
1244
|
+
"X is hidden/not present/gone"
|
|
1245
|
+
-> await expect(locator).toBeHidden()
|
|
1246
|
+
|
|
1247
|
+
TEXT:
|
|
1248
|
+
"text says X" / "shows X" / "message is X"
|
|
1249
|
+
-> await expect(locator).toHaveText('X')
|
|
1250
|
+
|
|
1251
|
+
"contains X" / "includes X"
|
|
1252
|
+
-> await expect(locator).toContainText('X')
|
|
1253
|
+
|
|
1254
|
+
"page title is X"
|
|
1255
|
+
-> await expect(page).toHaveTitle(/X/i)
|
|
1256
|
+
|
|
1257
|
+
"heading says X"
|
|
1258
|
+
-> await expect(page.getByRole('heading')).toContainText('X')
|
|
1259
|
+
|
|
1260
|
+
"error says X"
|
|
1261
|
+
-> await expect(page.getByRole('alert')).toContainText('X')
|
|
1262
|
+
|
|
1263
|
+
STATE:
|
|
1264
|
+
"X is enabled/disabled"
|
|
1265
|
+
-> await expect(locator).toBeEnabled() / toBeDisabled()
|
|
1266
|
+
|
|
1267
|
+
"X is checked/unchecked"
|
|
1268
|
+
-> await expect(locator).toBeChecked() / not.toBeChecked()
|
|
1269
|
+
|
|
1270
|
+
"X has value Y"
|
|
1271
|
+
-> await expect(locator).toHaveValue('Y')
|
|
1272
|
+
|
|
1273
|
+
"X is active / has class Y"
|
|
1274
|
+
-> await expect(locator).toHaveClass(/Y/)
|
|
1275
|
+
|
|
1276
|
+
NAVIGATION:
|
|
1277
|
+
"redirects to X" / "goes to X" / "URL contains X"
|
|
1278
|
+
-> await expect(page).toHaveURL(/X/)
|
|
1279
|
+
|
|
1280
|
+
"stays on same page"
|
|
1281
|
+
-> await expect(page).toHaveURL(/currentPath/)
|
|
1282
|
+
|
|
1283
|
+
"opens new tab"
|
|
1284
|
+
-> const [newPage] = await Promise.all([
|
|
1285
|
+
context.waitForEvent('page'),
|
|
1286
|
+
locator.click()
|
|
1287
|
+
]);
|
|
1288
|
+
|
|
1289
|
+
FORMS:
|
|
1290
|
+
"form submits successfully"
|
|
1291
|
+
-> fill fields + click submit + assert URL change or success message
|
|
1292
|
+
|
|
1293
|
+
"validation error shown"
|
|
1294
|
+
-> submit empty + await expect(page.getByRole('alert')).toBeVisible()
|
|
1295
|
+
|
|
1296
|
+
"required field X"
|
|
1297
|
+
-> submit without X + assert error message for X visible
|
|
1298
|
+
|
|
1299
|
+
AUTH:
|
|
1300
|
+
"login with valid credentials"
|
|
1301
|
+
-> fill(CT_VAR_USERNAME) + fill(CT_VAR_PASSWORD) + click login
|
|
1302
|
+
+ await expect(page).toHaveURL(/dashboard|home|app/)
|
|
1303
|
+
|
|
1304
|
+
"login fails / invalid credentials"
|
|
1305
|
+
-> fill bad values + click login
|
|
1306
|
+
+ await expect(page.getByRole('alert')).toBeVisible()
|
|
1307
|
+
|
|
1308
|
+
"logout works"
|
|
1309
|
+
-> click logout + await expect(page).toHaveURL(/login|home/)
|
|
1310
|
+
|
|
1311
|
+
THEME / VISUAL:
|
|
1312
|
+
"dark mode / theme toggle"
|
|
1313
|
+
-> await locator.click()
|
|
1314
|
+
+ await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
|
|
1315
|
+
// OR: await expect(page.locator('body')).toHaveClass(/dark/)
|
|
1316
|
+
|
|
1317
|
+
TIMING:
|
|
1318
|
+
"X loads / X appears"
|
|
1319
|
+
-> await expect(locator).toBeVisible({ timeout: 10000 })
|
|
1320
|
+
|
|
1321
|
+
"spinner disappears / loader gone"
|
|
1322
|
+
-> await expect(spinner).toBeHidden({ timeout: 10000 })
|
|
1323
|
+
|
|
1324
|
+
"modal closes"
|
|
1325
|
+
-> await expect(modal).toBeHidden()
|
|
1326
|
+
|
|
1327
|
+
ACCESSIBILITY:
|
|
1328
|
+
"has alt text"
|
|
1329
|
+
-> await expect(page.locator('img')).toHaveAttribute('alt', /.+/)
|
|
1330
|
+
|
|
1331
|
+
"keyboard navigable"
|
|
1332
|
+
-> await page.keyboard.press('Tab')
|
|
1333
|
+
+ await expect(firstFocusable).toBeFocused()
|
|
1334
|
+
|
|
1335
|
+
CRITICAL RULES
|
|
1336
|
+
1. NEVER use intent words as locator text.
|
|
1337
|
+
"count buttons" != getByText("count buttons")
|
|
1338
|
+
"verify heading" != getByText("verify heading")
|
|
1339
|
+
|
|
1340
|
+
2. ALWAYS use web-first assertions (await expect).
|
|
1341
|
+
NEVER use: expect(await locator.isVisible()).toBe(true)
|
|
1342
|
+
|
|
1343
|
+
3. For counting, prefer toHaveCount() over .count() unless
|
|
1344
|
+
you need the actual number for logging.
|
|
1345
|
+
|
|
1346
|
+
4. Auto-waiting: Playwright automatically waits for elements
|
|
1347
|
+
to be actionable before click/fill/etc. Do not add
|
|
1348
|
+
manual waitForTimeout() unless testing timing specifically.
|
|
1349
|
+
|
|
1350
|
+
5. Use not. prefix for negative assertions:
|
|
1351
|
+
await expect(locator).not.toBeVisible()
|
|
1352
|
+
await expect(locator).not.toBeChecked()
|
|
1353
|
+
`.trim();
|
|
1354
|
+
var ADVANCED_DOC_MAP2 = {
|
|
1355
|
+
upload: "https://playwright.dev/docs/input",
|
|
1356
|
+
"file upload": "https://playwright.dev/docs/input",
|
|
1357
|
+
intercept: "https://playwright.dev/docs/mock",
|
|
1358
|
+
mock: "https://playwright.dev/docs/mock",
|
|
1359
|
+
"api mock": "https://playwright.dev/docs/mock",
|
|
1360
|
+
accessibility: "https://playwright.dev/docs/accessibility-testing",
|
|
1361
|
+
"screen reader": "https://playwright.dev/docs/accessibility-testing",
|
|
1362
|
+
visual: "https://playwright.dev/docs/screenshots",
|
|
1363
|
+
screenshot: "https://playwright.dev/docs/screenshots",
|
|
1364
|
+
mobile: "https://playwright.dev/docs/emulation",
|
|
1365
|
+
responsive: "https://playwright.dev/docs/emulation",
|
|
1366
|
+
viewport: "https://playwright.dev/docs/emulation",
|
|
1367
|
+
network: "https://playwright.dev/docs/network",
|
|
1368
|
+
"api call": "https://playwright.dev/docs/network",
|
|
1369
|
+
iframe: "https://playwright.dev/docs/frames",
|
|
1370
|
+
frame: "https://playwright.dev/docs/frames",
|
|
1371
|
+
auth: "https://playwright.dev/docs/auth",
|
|
1372
|
+
"sign in": "https://playwright.dev/docs/auth",
|
|
1373
|
+
"stay logged": "https://playwright.dev/docs/auth",
|
|
1374
|
+
cookie: "https://playwright.dev/docs/auth",
|
|
1375
|
+
storage: "https://playwright.dev/docs/auth",
|
|
1376
|
+
download: "https://playwright.dev/docs/downloads",
|
|
1377
|
+
pdf: "https://playwright.dev/docs/downloads",
|
|
1378
|
+
dialog: "https://playwright.dev/docs/dialogs",
|
|
1379
|
+
alert: "https://playwright.dev/docs/dialogs",
|
|
1380
|
+
confirm: "https://playwright.dev/docs/dialogs",
|
|
1381
|
+
"new tab": "https://playwright.dev/docs/pages",
|
|
1382
|
+
"new page": "https://playwright.dev/docs/pages",
|
|
1383
|
+
popup: "https://playwright.dev/docs/pages"
|
|
1384
|
+
};
|
|
1385
|
+
async function fetchDocContext2(intent) {
|
|
1386
|
+
const intentLower = intent.toLowerCase();
|
|
1387
|
+
const matched = /* @__PURE__ */ new Set();
|
|
1388
|
+
for (const [keyword, url] of Object.entries(ADVANCED_DOC_MAP2)) {
|
|
1389
|
+
if (intentLower.includes(keyword)) {
|
|
1390
|
+
matched.add(url);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
if (matched.size === 0) return "";
|
|
1394
|
+
const fetched = [];
|
|
1395
|
+
for (const url of matched) {
|
|
1396
|
+
try {
|
|
1397
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(5e3) });
|
|
1398
|
+
const html = await res.text();
|
|
1399
|
+
const text = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s{3,}/g, "\n\n").slice(0, 3e3);
|
|
1400
|
+
fetched.push(`
|
|
1401
|
+
// From ${url}:
|
|
1402
|
+
${text}`);
|
|
1403
|
+
} catch {
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
if (fetched.length === 0) return "";
|
|
1407
|
+
return `
|
|
1408
|
+
|
|
1409
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1410
|
+
ADDITIONAL PLAYWRIGHT DOCS (fetched for this intent)
|
|
1411
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501${fetched.join("\n")}`;
|
|
1412
|
+
}
|
|
745
1413
|
async function analyseElements(elementMap, options = {}) {
|
|
746
1414
|
const { verbose = false, feature } = options;
|
|
747
|
-
const providerConfig = resolveLlmProvider();
|
|
748
1415
|
const requestedFeature = normalizeRequestedFeature(feature, elementMap);
|
|
1416
|
+
let providerConfig;
|
|
1417
|
+
try {
|
|
1418
|
+
providerConfig = resolveLlmProvider();
|
|
1419
|
+
} catch (error) {
|
|
1420
|
+
log(verbose, `
|
|
1421
|
+
[analyse] ${shortErrorMessage(error)}`);
|
|
1422
|
+
log(verbose, "[analyse] Falling back to deterministic capture analysis.");
|
|
1423
|
+
return buildDeterministicAnalysis(elementMap, requestedFeature);
|
|
1424
|
+
}
|
|
749
1425
|
log(verbose, `
|
|
750
1426
|
[analyse] Sending capture to ${providerConfig.displayName} (${providerConfig.model})`);
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
providerConfig.model,
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1427
|
+
try {
|
|
1428
|
+
const docContext = await fetchDocContext2(requestedFeature);
|
|
1429
|
+
const systemPrompt = buildSystemPrompt() + docContext;
|
|
1430
|
+
const userPrompt = buildUserPrompt(elementMap, requestedFeature);
|
|
1431
|
+
const rawResponse = providerConfig.transport === "anthropic" ? await callAnthropic2(providerConfig.apiKey, providerConfig.model, systemPrompt, userPrompt) : await callOpenAiCompatible2(
|
|
1432
|
+
providerConfig.apiKey,
|
|
1433
|
+
providerConfig.model,
|
|
1434
|
+
providerConfig.baseUrl ?? "https://api.openai.com/v1",
|
|
1435
|
+
providerConfig.displayName,
|
|
1436
|
+
systemPrompt,
|
|
1437
|
+
userPrompt
|
|
1438
|
+
);
|
|
1439
|
+
const parsed = parseAnalysisJson(rawResponse);
|
|
1440
|
+
return sanitizeAnalysis(parsed, elementMap, requestedFeature);
|
|
1441
|
+
} catch (error) {
|
|
1442
|
+
log(verbose, `[analyse] Remote analysis failed: ${shortErrorMessage(error)}`);
|
|
1443
|
+
log(verbose, "[analyse] Falling back to deterministic capture analysis.");
|
|
1444
|
+
return buildDeterministicAnalysis(elementMap, requestedFeature);
|
|
1445
|
+
}
|
|
763
1446
|
}
|
|
764
1447
|
function buildSystemPrompt() {
|
|
765
1448
|
return `
|
|
@@ -778,11 +1461,12 @@ RULES:
|
|
|
778
1461
|
7. If no status or alert region was captured, avoid scenarios that depend on unseen server-side validation messages.
|
|
779
1462
|
8. Scope must match intent complexity. Simple 1 to 2 claim requests should produce only 1 to 2 scenarios.
|
|
780
1463
|
9. For presence-only intents such as "verify X" or "X is present", only assert visibility. Do not click and do not assert outcomes after clicking.
|
|
781
|
-
|
|
782
|
-
11.
|
|
783
|
-
12.
|
|
784
|
-
13.
|
|
785
|
-
14.
|
|
1464
|
+
${RULE_10_PLAYWRIGHT_KNOWLEDGE_BASE2}
|
|
1465
|
+
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.
|
|
1466
|
+
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.
|
|
1467
|
+
13. Tie-breaker for multiple matching elements: prefer highest confidence, then interactive elements, then the first visible-looking candidate.
|
|
1468
|
+
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.
|
|
1469
|
+
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.
|
|
786
1470
|
|
|
787
1471
|
OUTPUT SCHEMA:
|
|
788
1472
|
{
|
|
@@ -982,7 +1666,7 @@ function sanitizeAnalysis(analysis, elementMap, requestedFeature) {
|
|
|
982
1666
|
resolvedPrefix
|
|
983
1667
|
).slice(0, intentProfile.maxScenarios);
|
|
984
1668
|
const useAuthFallback = shouldUseAuthFallback(authElements, intentAlignedScenarios, intentProfile);
|
|
985
|
-
const fallbackScenarios = intentProfile.mode === "presence" ? buildPresenceOnlyScenarios(elementMap, requestedFeature, resolvedPrefix) : useAuthFallback ? buildAuthFallbackScenarios(elementMap, resolvedPrefix, authElements) : buildFallbackScenarios(elementMap, resolvedPrefix);
|
|
1669
|
+
const fallbackScenarios = intentProfile.mode === "presence" ? buildPresenceOnlyScenarios(elementMap, requestedFeature, resolvedPrefix) : useAuthFallback ? buildAuthFallbackScenarios(elementMap, resolvedPrefix, authElements, requestedFeature) : buildFallbackScenarios(elementMap, resolvedPrefix, requestedFeature);
|
|
986
1670
|
const finalScenarios = useAuthFallback ? fallbackScenarios.slice(0, intentProfile.maxScenarios) : intentAlignedScenarios.length > 0 ? intentAlignedScenarios : fallbackScenarios.slice(0, intentProfile.maxScenarios);
|
|
987
1671
|
return {
|
|
988
1672
|
...analysis,
|
|
@@ -1048,13 +1732,72 @@ function normalizeAssertion(candidate, selectors) {
|
|
|
1048
1732
|
return assertion;
|
|
1049
1733
|
}
|
|
1050
1734
|
function buildFallbackScenarios(elementMap, prefix) {
|
|
1735
|
+
return buildFallbackScenariosForFeature(elementMap, prefix, "");
|
|
1736
|
+
}
|
|
1737
|
+
function buildFallbackScenariosForFeature(elementMap, prefix, feature) {
|
|
1738
|
+
const normalizedFeature = feature.toLowerCase();
|
|
1051
1739
|
const heading = elementMap.elements.find((element) => element.category === "heading");
|
|
1052
1740
|
const emailInput = elementMap.elements.find((element) => element.category === "input" && (element.attributes.type === "email" || /email/.test(`${element.name ?? ""} ${String(element.attributes.label ?? "")}`.toLowerCase())));
|
|
1053
1741
|
const passwordInput = elementMap.elements.find((element) => element.category === "input" && element.attributes.type === "password");
|
|
1054
1742
|
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");
|
|
1743
|
+
const alert = findAlertElement(elementMap);
|
|
1055
1744
|
const scenarios = [];
|
|
1056
1745
|
const tag = (value) => normalizeTag(value);
|
|
1057
1746
|
const nextId = (index) => `${prefix}-${String(900 + index).padStart(3, "0")}`;
|
|
1747
|
+
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);
|
|
1748
|
+
const wantsVisibleAlert = /\b(error|alert|message|invalid|incorrect|required|validation)\b/.test(normalizedFeature) && !wantsHiddenAlertOnLoad;
|
|
1749
|
+
if (wantsHiddenAlertOnLoad && alert) {
|
|
1750
|
+
return [{
|
|
1751
|
+
id: `${prefix}-001`,
|
|
1752
|
+
title: "Error message is hidden on initial load",
|
|
1753
|
+
tags: [tag("negative"), tag("ui")],
|
|
1754
|
+
steps: [
|
|
1755
|
+
{
|
|
1756
|
+
action: "navigate",
|
|
1757
|
+
selector: "page",
|
|
1758
|
+
value: elementMap.url,
|
|
1759
|
+
human: "Navigate to the captured page"
|
|
1760
|
+
}
|
|
1761
|
+
],
|
|
1762
|
+
assertions: [
|
|
1763
|
+
{
|
|
1764
|
+
type: "hidden",
|
|
1765
|
+
selector: alert.selector,
|
|
1766
|
+
expected: "hidden",
|
|
1767
|
+
human: `${alert.name || "Error message"} is not shown when the page first loads`,
|
|
1768
|
+
playwright: `await expect(page.${alert.selector}).toBeHidden();`
|
|
1769
|
+
}
|
|
1770
|
+
],
|
|
1771
|
+
narrator: "We verify that the page does not expose an error surface before any user action.",
|
|
1772
|
+
codeLevel: "beginner"
|
|
1773
|
+
}];
|
|
1774
|
+
}
|
|
1775
|
+
if (wantsVisibleAlert && alert) {
|
|
1776
|
+
return [{
|
|
1777
|
+
id: `${prefix}-001`,
|
|
1778
|
+
title: "Page exposes an alert message when requested",
|
|
1779
|
+
tags: [tag("negative"), tag("ui")],
|
|
1780
|
+
steps: [
|
|
1781
|
+
{
|
|
1782
|
+
action: "navigate",
|
|
1783
|
+
selector: "page",
|
|
1784
|
+
value: elementMap.url,
|
|
1785
|
+
human: "Navigate to the captured page"
|
|
1786
|
+
}
|
|
1787
|
+
],
|
|
1788
|
+
assertions: [
|
|
1789
|
+
{
|
|
1790
|
+
type: "text",
|
|
1791
|
+
selector: alert.selector,
|
|
1792
|
+
expected: alert.name || "error",
|
|
1793
|
+
human: "An alert message is shown",
|
|
1794
|
+
playwright: `await expect(page.${alert.selector}).toContainText(/invalid|error|required/i);`
|
|
1795
|
+
}
|
|
1796
|
+
],
|
|
1797
|
+
narrator: "We validate the alert channel directly when the intent asks about an error message.",
|
|
1798
|
+
codeLevel: "beginner"
|
|
1799
|
+
}];
|
|
1800
|
+
}
|
|
1058
1801
|
if (heading) {
|
|
1059
1802
|
scenarios.push({
|
|
1060
1803
|
id: nextId(scenarios.length + 1),
|
|
@@ -1129,16 +1872,115 @@ function buildFallbackScenarios(elementMap, prefix) {
|
|
|
1129
1872
|
}
|
|
1130
1873
|
return scenarios.slice(0, 5);
|
|
1131
1874
|
}
|
|
1132
|
-
function buildAuthFallbackScenarios(elementMap, prefix, authElements) {
|
|
1875
|
+
function buildAuthFallbackScenarios(elementMap, prefix, authElements, feature) {
|
|
1133
1876
|
const { usernameInput, passwordInput, submitButton, heading } = authElements;
|
|
1134
1877
|
if (!usernameInput || !passwordInput || !submitButton) {
|
|
1135
|
-
return
|
|
1878
|
+
return buildFallbackScenariosForFeature(elementMap, prefix, feature);
|
|
1136
1879
|
}
|
|
1880
|
+
const normalizedFeature = feature.toLowerCase();
|
|
1137
1881
|
const scenarios = [];
|
|
1138
1882
|
const tag = (value) => normalizeTag(value);
|
|
1139
1883
|
const nextId = (index) => `${prefix}-${String(index).padStart(3, "0")}`;
|
|
1140
1884
|
const usernameValue = inferAuthValue(usernameInput, "username");
|
|
1141
1885
|
const passwordValue = inferAuthValue(passwordInput, "password");
|
|
1886
|
+
const alert = findAlertElement(elementMap);
|
|
1887
|
+
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);
|
|
1888
|
+
const wantsError = /\b(wrong password|wrong credentials|invalid|incorrect|error message|shows an error|alert)\b/.test(normalizedFeature);
|
|
1889
|
+
const successUrlPattern = deriveSuccessUrlPattern(elementMap, normalizedFeature);
|
|
1890
|
+
if (wantsNavigation) {
|
|
1891
|
+
return [{
|
|
1892
|
+
id: `${prefix}-001`,
|
|
1893
|
+
title: "Valid credentials redirect to the authenticated area",
|
|
1894
|
+
tags: [tag("auth"), tag("happy-path")],
|
|
1895
|
+
steps: [
|
|
1896
|
+
{
|
|
1897
|
+
action: "navigate",
|
|
1898
|
+
selector: "page",
|
|
1899
|
+
value: elementMap.url,
|
|
1900
|
+
human: "Navigate to the login page"
|
|
1901
|
+
},
|
|
1902
|
+
{
|
|
1903
|
+
action: "fill",
|
|
1904
|
+
selector: usernameInput.selector,
|
|
1905
|
+
value: usernameValue,
|
|
1906
|
+
human: buildFillHuman("Fill in the username field", usernameInput.selector, usernameValue)
|
|
1907
|
+
},
|
|
1908
|
+
{
|
|
1909
|
+
action: "fill",
|
|
1910
|
+
selector: passwordInput.selector,
|
|
1911
|
+
value: passwordValue,
|
|
1912
|
+
human: buildFillHuman("Fill in the password field", passwordInput.selector, passwordValue)
|
|
1913
|
+
},
|
|
1914
|
+
{
|
|
1915
|
+
action: "click",
|
|
1916
|
+
selector: submitButton.selector,
|
|
1917
|
+
value: "",
|
|
1918
|
+
human: "Click the login button"
|
|
1919
|
+
}
|
|
1920
|
+
],
|
|
1921
|
+
assertions: [
|
|
1922
|
+
{
|
|
1923
|
+
type: "url",
|
|
1924
|
+
selector: "page",
|
|
1925
|
+
expected: successUrlPattern,
|
|
1926
|
+
human: "User is redirected to the secure area",
|
|
1927
|
+
playwright: `await expect(page).toHaveURL(/${successUrlPattern}/);`
|
|
1928
|
+
}
|
|
1929
|
+
],
|
|
1930
|
+
narrator: "We submit valid credentials and confirm that authentication changes the page URL.",
|
|
1931
|
+
codeLevel: "beginner"
|
|
1932
|
+
}];
|
|
1933
|
+
}
|
|
1934
|
+
if (wantsError) {
|
|
1935
|
+
return [{
|
|
1936
|
+
id: `${prefix}-001`,
|
|
1937
|
+
title: "Wrong password shows an authentication error",
|
|
1938
|
+
tags: [tag("auth"), tag("negative")],
|
|
1939
|
+
steps: [
|
|
1940
|
+
{
|
|
1941
|
+
action: "navigate",
|
|
1942
|
+
selector: "page",
|
|
1943
|
+
value: elementMap.url,
|
|
1944
|
+
human: "Navigate to the login page"
|
|
1945
|
+
},
|
|
1946
|
+
{
|
|
1947
|
+
action: "fill",
|
|
1948
|
+
selector: usernameInput.selector,
|
|
1949
|
+
value: usernameValue,
|
|
1950
|
+
human: buildFillHuman("Fill in the username field", usernameInput.selector, usernameValue)
|
|
1951
|
+
},
|
|
1952
|
+
{
|
|
1953
|
+
action: "fill",
|
|
1954
|
+
selector: passwordInput.selector,
|
|
1955
|
+
value: "wrong-password",
|
|
1956
|
+
human: buildFillHuman("Fill in the password field", passwordInput.selector, "wrong-password")
|
|
1957
|
+
},
|
|
1958
|
+
{
|
|
1959
|
+
action: "click",
|
|
1960
|
+
selector: submitButton.selector,
|
|
1961
|
+
value: "",
|
|
1962
|
+
human: "Click the login button"
|
|
1963
|
+
}
|
|
1964
|
+
],
|
|
1965
|
+
assertions: [
|
|
1966
|
+
alert ? {
|
|
1967
|
+
type: "text",
|
|
1968
|
+
selector: alert.selector,
|
|
1969
|
+
expected: alert.name || "error",
|
|
1970
|
+
human: "An alert communicates the login failure",
|
|
1971
|
+
playwright: `await expect(page.${alert.selector}).toContainText(/invalid|error|required/i);`
|
|
1972
|
+
} : {
|
|
1973
|
+
type: "text",
|
|
1974
|
+
selector: usernameInput.selector,
|
|
1975
|
+
expected: "error",
|
|
1976
|
+
human: "An authentication error message is shown",
|
|
1977
|
+
playwright: `await expect(page.getByRole('alert')).toContainText(/invalid|error|required/i);`
|
|
1978
|
+
}
|
|
1979
|
+
],
|
|
1980
|
+
narrator: "We use an invalid password and confirm the page surfaces an authentication error.",
|
|
1981
|
+
codeLevel: "beginner"
|
|
1982
|
+
}];
|
|
1983
|
+
}
|
|
1142
1984
|
scenarios.push({
|
|
1143
1985
|
id: nextId(1),
|
|
1144
1986
|
title: "Login form renders expected controls",
|
|
@@ -1298,17 +2140,18 @@ function getAvailableCtVarKeys2() {
|
|
|
1298
2140
|
}
|
|
1299
2141
|
function buildIntentProfile(feature) {
|
|
1300
2142
|
const normalized = feature.toLowerCase();
|
|
2143
|
+
const countIntent = /\b(?:how many|count|number of|return the count|exactly\s+\d+|there (?:is|are)\s+\d+)/.test(normalized);
|
|
1301
2144
|
const wantsHeading = /\b(?:title|heading)\b/.test(normalized) && !/\b(?:browser title|tab title|page title)\b/.test(normalized);
|
|
1302
2145
|
const presenceOnly = isPresenceOnlyIntent(normalized);
|
|
1303
2146
|
const authIntent = /\b(?:login|log in|sign in|auth|credentials?)\b/.test(normalized) && !presenceOnly;
|
|
1304
2147
|
const formIntent = /\b(?:form|submit|field|input|enter|fill|type)\b/.test(normalized) && !presenceOnly && !authIntent;
|
|
1305
2148
|
const flowIntent = /\b(?:flow|journey|checkout|purchase|complete|works?)\b/.test(normalized) && !presenceOnly && !authIntent && !formIntent;
|
|
1306
|
-
const mode = presenceOnly ? "presence" : authIntent ? "auth" : formIntent ? "form" : flowIntent ? "flow" : "generic";
|
|
2149
|
+
const mode = countIntent ? "count" : presenceOnly ? "presence" : authIntent ? "auth" : formIntent ? "form" : flowIntent ? "flow" : "generic";
|
|
1307
2150
|
const claimCount = countIntentClaims(feature);
|
|
1308
2151
|
return {
|
|
1309
2152
|
feature,
|
|
1310
2153
|
mode,
|
|
1311
|
-
maxScenarios: mode === "presence" ? Math.min(Math.max(claimCount, 1), 2) : mode === "auth" || mode === "form" ? Math.min(Math.max(claimCount, 3), 5) : mode === "flow" ? Math.min(Math.max(claimCount, 4), 6) : Math.min(Math.max(claimCount, 1), 3),
|
|
2154
|
+
maxScenarios: mode === "count" ? 1 : mode === "presence" ? Math.min(Math.max(claimCount, 1), 2) : mode === "auth" || mode === "form" ? Math.min(Math.max(claimCount, 3), 5) : mode === "flow" ? Math.min(Math.max(claimCount, 4), 6) : Math.min(Math.max(claimCount, 1), 3),
|
|
1312
2155
|
wantsHeading
|
|
1313
2156
|
};
|
|
1314
2157
|
}
|
|
@@ -1323,11 +2166,42 @@ function isPresenceOnlyIntent(normalizedFeature) {
|
|
|
1323
2166
|
return presenceWords && !actionWords;
|
|
1324
2167
|
}
|
|
1325
2168
|
function applyIntentPolicy(scenarios, elementMap, intentProfile, prefix) {
|
|
2169
|
+
if (intentProfile.mode === "count") {
|
|
2170
|
+
return buildCountScenarios(elementMap, intentProfile.feature, prefix);
|
|
2171
|
+
}
|
|
1326
2172
|
if (intentProfile.mode !== "presence") {
|
|
1327
2173
|
return scenarios.slice(0, intentProfile.maxScenarios);
|
|
1328
2174
|
}
|
|
1329
2175
|
return buildPresenceOnlyScenarios(elementMap, intentProfile.feature, prefix);
|
|
1330
2176
|
}
|
|
2177
|
+
function buildCountScenarios(elementMap, feature, prefix) {
|
|
2178
|
+
const normalized = feature.toLowerCase();
|
|
2179
|
+
const countedElements = pickCountedElements(elementMap, normalized);
|
|
2180
|
+
const selector = countSelectorForFeature(normalized, countedElements);
|
|
2181
|
+
const count = countedElements.length;
|
|
2182
|
+
return [{
|
|
2183
|
+
id: `${prefix}-${String(1).padStart(3, "0")}`,
|
|
2184
|
+
title: feature,
|
|
2185
|
+
tags: ["@smoke", "@ui"],
|
|
2186
|
+
steps: [
|
|
2187
|
+
{
|
|
2188
|
+
action: "navigate",
|
|
2189
|
+
selector: "page",
|
|
2190
|
+
value: elementMap.url,
|
|
2191
|
+
human: `Navigate to ${elementMap.url}`
|
|
2192
|
+
}
|
|
2193
|
+
],
|
|
2194
|
+
assertions: [{
|
|
2195
|
+
type: "count",
|
|
2196
|
+
selector,
|
|
2197
|
+
expected: String(count),
|
|
2198
|
+
human: `There are exactly ${count} matching elements`,
|
|
2199
|
+
playwright: `await expect(page.${selector}).toHaveCount(${count});`
|
|
2200
|
+
}],
|
|
2201
|
+
narrator: "We count only the element type requested by the user.",
|
|
2202
|
+
codeLevel: "beginner"
|
|
2203
|
+
}];
|
|
2204
|
+
}
|
|
1331
2205
|
function buildPresenceOnlyScenarios(elementMap, feature, prefix) {
|
|
1332
2206
|
const claims = extractPresenceClaims(feature, elementMap);
|
|
1333
2207
|
if (claims.length === 0) return buildFallbackScenarios(elementMap, prefix).slice(0, 1);
|
|
@@ -1360,11 +2234,9 @@ function extractPresenceClaims(feature, elementMap) {
|
|
|
1360
2234
|
for (const segment of segments) {
|
|
1361
2235
|
const normalized = segment.toLowerCase();
|
|
1362
2236
|
if (/\b(?:title|heading)\b/.test(normalized) && !/\b(?:browser title|tab title|page title)\b/.test(normalized)) {
|
|
1363
|
-
const heading = chooseHeadingElement(elementMap);
|
|
1364
|
-
const selector2 = heading?.selector ?? `getByRole('heading')`;
|
|
1365
2237
|
claims.push({
|
|
1366
|
-
selector:
|
|
1367
|
-
human: `${
|
|
2238
|
+
selector: `getByRole('heading')`,
|
|
2239
|
+
human: `${chooseHeadingElement(elementMap)?.name || "Main page heading"} is visible`
|
|
1368
2240
|
});
|
|
1369
2241
|
continue;
|
|
1370
2242
|
}
|
|
@@ -1431,8 +2303,55 @@ function detectAuthElements(elementMap) {
|
|
|
1431
2303
|
const heading = elementMap.elements.find((element) => element.category === "heading" && /login|sign in|auth/.test((element.name ?? "").toLowerCase())) ?? elementMap.elements.find((element) => element.category === "heading");
|
|
1432
2304
|
return { usernameInput, passwordInput, submitButton, heading };
|
|
1433
2305
|
}
|
|
2306
|
+
function findAlertElement(elementMap) {
|
|
2307
|
+
return elementMap.elements.find((element) => element.role === "alert" || element.category === "status" || String(element.attributes.role ?? "").toLowerCase() === "alert");
|
|
2308
|
+
}
|
|
2309
|
+
function deriveSuccessUrlPattern(elementMap, normalizedFeature) {
|
|
2310
|
+
const explicitPath = normalizedFeature.match(/\/[a-z0-9/_-]+/i)?.[0] ?? (/\bsecure area\b/.test(normalizedFeature) ? "/secure" : void 0);
|
|
2311
|
+
if (explicitPath) {
|
|
2312
|
+
return explicitPath.replace(/^\/+/, "").split("/").map(escapeForRegex).join("\\/");
|
|
2313
|
+
}
|
|
2314
|
+
const authLink = elementMap.elements.find((element) => {
|
|
2315
|
+
if (element.category !== "link") return false;
|
|
2316
|
+
const href2 = String(element.attributes.href ?? "");
|
|
2317
|
+
const label = `${element.name ?? ""} ${href2}`.toLowerCase();
|
|
2318
|
+
return /\b(secure|dashboard|home|app)\b/.test(label);
|
|
2319
|
+
});
|
|
2320
|
+
const href = String(authLink?.attributes.href ?? "");
|
|
2321
|
+
if (href.startsWith("/")) {
|
|
2322
|
+
return href.replace(/^\/+/, "").split("/").map(escapeForRegex).join("\\/");
|
|
2323
|
+
}
|
|
2324
|
+
return "secure|dashboard|home|app";
|
|
2325
|
+
}
|
|
2326
|
+
function buildDeterministicAnalysis(elementMap, requestedFeature) {
|
|
2327
|
+
const intentProfile = buildIntentProfile(requestedFeature);
|
|
2328
|
+
const suggestedPrefix = inferPrefix({
|
|
2329
|
+
featureText: requestedFeature,
|
|
2330
|
+
url: elementMap.url
|
|
2331
|
+
}).toUpperCase();
|
|
2332
|
+
const authElements = detectAuthElements(elementMap);
|
|
2333
|
+
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);
|
|
2334
|
+
if (scenarios.length === 0 && (authElements.usernameInput || authElements.passwordInput || authElements.submitButton)) {
|
|
2335
|
+
scenarios = buildAuthFallbackScenarios(elementMap, suggestedPrefix, authElements, requestedFeature);
|
|
2336
|
+
}
|
|
2337
|
+
if (scenarios.length === 0) {
|
|
2338
|
+
scenarios = buildFallbackScenariosForFeature(elementMap, suggestedPrefix, requestedFeature);
|
|
2339
|
+
}
|
|
2340
|
+
const finalScenarios = scenarios.slice(0, intentProfile.maxScenarios);
|
|
2341
|
+
return {
|
|
2342
|
+
url: elementMap.url,
|
|
2343
|
+
feature: requestedFeature,
|
|
2344
|
+
suggestedPrefix,
|
|
2345
|
+
scenarios: finalScenarios,
|
|
2346
|
+
analysisNotes: "Generated deterministic capture-backed scenarios because LLM analysis was unavailable.",
|
|
2347
|
+
audioSummary: buildAudioSummary(requestedFeature, finalScenarios)
|
|
2348
|
+
};
|
|
2349
|
+
}
|
|
2350
|
+
function shortErrorMessage(error) {
|
|
2351
|
+
return String(error?.message ?? error ?? "Unknown error").split("\n")[0];
|
|
2352
|
+
}
|
|
1434
2353
|
function shouldUseAuthFallback(authElements, scenarios, intentProfile) {
|
|
1435
|
-
if (intentProfile.mode
|
|
2354
|
+
if (intentProfile.mode !== "auth" && intentProfile.mode !== "form") return false;
|
|
1436
2355
|
if (!authElements.usernameInput || !authElements.passwordInput || !authElements.submitButton) return false;
|
|
1437
2356
|
if (scenarios.length === 0) return true;
|
|
1438
2357
|
const hasCredentialEntry = scenarios.some((scenario) => {
|
|
@@ -1456,8 +2375,26 @@ function defaultStepHuman(action, selector, value) {
|
|
|
1456
2375
|
function normalizeStepHuman(human, action, selector, value) {
|
|
1457
2376
|
if (action !== "fill" && action !== "select") return human;
|
|
1458
2377
|
if (!value) return human;
|
|
1459
|
-
|
|
1460
|
-
|
|
2378
|
+
const explicitTemplate = /\$\{CT_VAR_[A-Z0-9_]+\}/.test(human);
|
|
2379
|
+
const quotedValue = human.match(/\bwith\s+['"`]([^'"`]+)['"`]/i)?.[1];
|
|
2380
|
+
if (explicitTemplate) return human;
|
|
2381
|
+
if (quotedValue && !isGenericFillValue(quotedValue)) return human;
|
|
2382
|
+
const rewrittenBase = human.replace(/\s+with\s+['"`][^'"`]+['"`]\s*$/i, "").trim() || human;
|
|
2383
|
+
return buildFillHuman(rewrittenBase, selector, value);
|
|
2384
|
+
}
|
|
2385
|
+
function pickCountedElements(elementMap, normalizedFeature) {
|
|
2386
|
+
const category = /\bbutton/.test(normalizedFeature) ? "button" : /\blink/.test(normalizedFeature) ? "link" : /\bheading|title/.test(normalizedFeature) ? "heading" : /\binput|field|textbox|text box/.test(normalizedFeature) ? "input" : void 0;
|
|
2387
|
+
if (category) {
|
|
2388
|
+
return elementMap.elements.filter((element) => element.category === category);
|
|
2389
|
+
}
|
|
2390
|
+
return elementMap.elements.filter((element) => element.category === "button" || element.category === "link" || element.category === "input");
|
|
2391
|
+
}
|
|
2392
|
+
function countSelectorForFeature(normalizedFeature, elements) {
|
|
2393
|
+
if (/\bbutton/.test(normalizedFeature)) return `getByRole('button')`;
|
|
2394
|
+
if (/\blink/.test(normalizedFeature)) return `getByRole('link')`;
|
|
2395
|
+
if (/\bheading|title/.test(normalizedFeature)) return `getByRole('heading')`;
|
|
2396
|
+
if (/\binput|field|textbox|text box/.test(normalizedFeature)) return `getByRole('textbox')`;
|
|
2397
|
+
return elements[0]?.selector ?? `locator('*')`;
|
|
1461
2398
|
}
|
|
1462
2399
|
function normalizePlaywrightAssertion(assertion) {
|
|
1463
2400
|
const repaired = scopeBareSelectorsInAssertion(unwrapWrappedSelectorAssertion(assertion.playwright));
|
|
@@ -2251,7 +3188,7 @@ function saveSpecPreview(analysis, outputDir = "tests/preview") {
|
|
|
2251
3188
|
if (step.action === "hover") lines.push(` await ${selector}.hover();`);
|
|
2252
3189
|
}
|
|
2253
3190
|
for (const assertion of scenario.assertions) {
|
|
2254
|
-
lines.push(` ${
|
|
3191
|
+
lines.push(` ${renderPreviewAssertion(assertion.playwright, assertion.human)}`);
|
|
2255
3192
|
}
|
|
2256
3193
|
lines.push("});");
|
|
2257
3194
|
lines.push("");
|
|
@@ -2275,6 +3212,15 @@ function ensureStatement2(value) {
|
|
|
2275
3212
|
if (!trimmed) return "// TODO: add assertion";
|
|
2276
3213
|
return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
|
|
2277
3214
|
}
|
|
3215
|
+
function renderPreviewAssertion(playwright, human) {
|
|
3216
|
+
const statement = ensureStatement2(playwright);
|
|
3217
|
+
const headingMatch = statement.match(/^await expect\(page\.getByRole\((['"])heading\1\)\)\.toBeVisible\(\);$/);
|
|
3218
|
+
const visibleSubject = String(human ?? "").match(/^(.+?) is visible$/)?.[1]?.trim();
|
|
3219
|
+
if (headingMatch && visibleSubject && !/main page heading/i.test(visibleSubject)) {
|
|
3220
|
+
return `await expect(page.getByRole("heading", { name: ${JSON.stringify(visibleSubject)} })).toBeVisible();`;
|
|
3221
|
+
}
|
|
3222
|
+
return statement;
|
|
3223
|
+
}
|
|
2278
3224
|
|
|
2279
3225
|
// src/commands/tc.ts
|
|
2280
3226
|
function slugify2(text) {
|