@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/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  genCmd
4
- } from "./chunk-RG26I5FB.js";
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
- const legacyMacOs = isLegacyMacOs();
66
- if (legacyMacOs) {
67
- console.log("\u{1F34E} Detected macOS 13 or older. Adjusting versions for compatibility...");
68
- const pkgJsonPath = join(projectPath, "package.json");
69
- if (existsSync(pkgJsonPath)) {
70
- try {
71
- const pkgContent = readFileSync(pkgJsonPath, "utf-8");
72
- const pkg = JSON.parse(pkgContent);
73
- if (pkg.devDependencies) {
74
- pkg.devDependencies["@playwright/test"] = "^1.48.2";
75
- pkg.devDependencies["allure-playwright"] = "^2.15.1";
76
- writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, 2));
77
- console.log("\u2705 Pinned Playwright packages for legacy macOS compatibility.");
78
- }
79
- } catch (err) {
80
- console.warn("\u26A0\uFE0F Failed to adjust package.json for OS compatibility:", err);
81
- }
82
- }
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
- if (legacyMacOs) {
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
- execSync("npx playwright install chromium", { cwd: projectPath, stdio: "inherit" });
107
- console.log("\u2705 Chromium installed successfully.");
108
- } else {
109
- execSync("npx playwright install", { cwd: projectPath, stdio: "inherit" });
110
- console.log("\u2705 Playwright browsers installed successfully.");
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(" npx playwright install chromium");
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-6Y65IYXO.js");
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 system = buildSystemMessage();
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
- const systemPrompt = buildSystemPrompt();
752
- const userPrompt = buildUserPrompt(elementMap, requestedFeature);
753
- const rawResponse = providerConfig.transport === "anthropic" ? await callAnthropic2(providerConfig.apiKey, providerConfig.model, systemPrompt, userPrompt) : await callOpenAiCompatible2(
754
- providerConfig.apiKey,
755
- providerConfig.model,
756
- providerConfig.baseUrl ?? "https://api.openai.com/v1",
757
- providerConfig.displayName,
758
- systemPrompt,
759
- userPrompt
760
- );
761
- const parsed = parseAnalysisJson(rawResponse);
762
- return sanitizeAnalysis(parsed, elementMap, requestedFeature);
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
- 10. For fill steps, never use the literal word "value". If matching CT_VAR_* variables are listed in the prompt, use them in human-readable step text and use a realistic non-generic runtime value in the JSON value field. Otherwise use a field-specific fallback such as test-username or test-password.
782
- 11. The "selector" field must be a raw selector expression such as locator("#username") or getByRole('button', { name: "Login" }). Never wrap selectors in page. or page.locator("...") inside JSON.
783
- 12. Tie-breaker for multiple matching elements: prefer highest confidence, then interactive elements, then the first visible-looking candidate.
784
- 13. If a page clearly contains login or auth fields, do not create submit scenarios that only click the button. Include realistic fill steps for captured username/email and password inputs when the intent asks for auth interaction.
785
- 14. "verify title" means the main visible heading on the page, not the browser tab title, unless the intent explicitly says browser title, tab title, or page title.
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 buildFallbackScenarios(elementMap, prefix);
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: selector2,
1367
- human: `${heading?.name || "Main page heading"} is visible`
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 === "presence") return false;
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
- if (/\bwith\s+['"`]/i.test(human) || /\$\{CT_VAR_[A-Z0-9_]+\}/.test(human)) return human;
1460
- return buildFillHuman(human, selector, value);
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(` ${ensureStatement2(assertion.playwright)}`);
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) {