@cementic/cementic-test 0.2.3 → 0.2.5

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.
Files changed (26) hide show
  1. package/README.md +300 -457
  2. package/dist/chunk-5QRDTCSM.js +368 -0
  3. package/dist/chunk-5QRDTCSM.js.map +1 -0
  4. package/dist/cli.js +1242 -210
  5. package/dist/cli.js.map +1 -1
  6. package/dist/{gen-54KYT3RO.js → gen-RMRQOAD3.js} +2 -2
  7. package/dist/templates/student-framework/README.md +35 -5
  8. package/dist/templates/student-framework/package.json +3 -2
  9. package/dist/templates/student-framework/pages/BasePage.js +19 -0
  10. package/dist/templates/student-framework/pages/DashboardPage.js +16 -0
  11. package/dist/templates/student-framework/pages/FormPage.js +24 -0
  12. package/dist/templates/student-framework/pages/LoginPage.js +24 -0
  13. package/dist/templates/student-framework/tests/dashboard.spec.js +14 -0
  14. package/dist/templates/student-framework/tests/login.spec.js +14 -0
  15. package/dist/templates/student-framework-ts/README.md +20 -0
  16. package/dist/templates/student-framework-ts/package.json +24 -0
  17. package/dist/templates/student-framework-ts/pages/LandingPage.ts +15 -0
  18. package/dist/templates/student-framework-ts/playwright.config.ts +29 -0
  19. package/dist/templates/{student-framework/tests/landing.spec.js → student-framework-ts/tests/landing.spec.ts} +1 -1
  20. package/dist/templates/student-framework-ts/tsconfig.json +19 -0
  21. package/dist/templates/student-framework-ts/workflows/playwright.yml +20 -0
  22. package/package.json +3 -2
  23. package/dist/chunk-J63TUHIV.js +0 -80
  24. package/dist/chunk-J63TUHIV.js.map +0 -1
  25. package/dist/templates/student-framework/pages/LandingPage.js +0 -18
  26. /package/dist/{gen-54KYT3RO.js.map → gen-RMRQOAD3.js.map} +0 -0
package/dist/cli.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  genCmd
4
- } from "./chunk-J63TUHIV.js";
4
+ } from "./chunk-5QRDTCSM.js";
5
5
 
6
6
  // src/cli.ts
7
7
  import { Command as Command9 } from "commander";
8
+ import { createRequire } from "module";
8
9
 
9
10
  // src/commands/new.ts
10
11
  import { Command } from "commander";
@@ -16,12 +17,22 @@ import { dirname } from "path";
16
17
  import { platform, release } from "os";
17
18
  var __filename = fileURLToPath(import.meta.url);
18
19
  var __dirname = dirname(__filename);
20
+ function resolveTemplatePath(templateDir) {
21
+ const candidates = [
22
+ resolve(__dirname, `templates/${templateDir}`),
23
+ resolve(__dirname, `../templates/${templateDir}`),
24
+ resolve(__dirname, `../../templates/${templateDir}`),
25
+ resolve(process.cwd(), `templates/${templateDir}`)
26
+ ];
27
+ return candidates.find((candidate) => existsSync(candidate));
28
+ }
19
29
  function newCmd() {
20
30
  const cmd = new Command("new").arguments("<projectName>").description("Scaffold a new CementicTest + Playwright project from scratch").addHelpText("after", `
21
31
  Examples:
22
32
  $ ct new my-awesome-test-suite
33
+ $ ct new e2e-ts --lang ts
23
34
  $ ct new e2e-tests --no-browsers
24
- `).option("--mode <mode>", "greenfield|enhance", "greenfield").option("--no-browsers", 'do not run "npx playwright install" during setup').action((projectName, opts) => {
35
+ `).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) => {
25
36
  const root = process.cwd();
26
37
  const projectPath = join(root, projectName);
27
38
  console.log(`\u{1F680} Initializing new CementicTest project in ${projectName}...`);
@@ -30,18 +41,11 @@ Examples:
30
41
  process.exit(1);
31
42
  }
32
43
  mkdirSync(projectPath, { recursive: true });
33
- let templatePath = resolve(__dirname, "templates/student-framework");
34
- if (!existsSync(templatePath)) {
35
- templatePath = resolve(__dirname, "../templates/student-framework");
36
- }
37
- if (!existsSync(templatePath)) {
38
- templatePath = resolve(__dirname, "../../templates/student-framework");
39
- }
40
- if (!existsSync(templatePath)) {
41
- templatePath = resolve(process.cwd(), "templates/student-framework");
42
- }
43
- if (!existsSync(templatePath)) {
44
- console.error(`\u274C Could not locate template at ${templatePath}`);
44
+ const lang = opts.lang === "ts" ? "ts" : "js";
45
+ const templateDir = lang === "ts" ? "student-framework-ts" : "student-framework";
46
+ const templatePath = resolveTemplatePath(templateDir);
47
+ if (!templatePath) {
48
+ console.error(`\u274C Could not locate template "${templateDir}"`);
45
49
  console.error("Please ensure the package is built correctly with templates included.");
46
50
  process.exit(1);
47
51
  }
@@ -129,6 +133,10 @@ function parseId(title) {
129
133
  const m = title.match(/\b([A-Z]+-\d+)\b/);
130
134
  return m?.[1];
131
135
  }
136
+ function stripIdPrefixFromTitle(title, id) {
137
+ if (!id) return title.trim();
138
+ return title.replace(new RegExp(`^${id}\\s*[\u2014\\-\u2013:]\\s*`), "").trim();
139
+ }
132
140
  function splitCasesByHeading(fileText) {
133
141
  const lines = fileText.split(/\r?\n/);
134
142
  const blocks = [];
@@ -174,34 +182,69 @@ function extractSections(body) {
174
182
  const stepsBlock = sections["steps"] ?? "";
175
183
  const expectedBlock = sections["expected"] ?? sections["expected results"] ?? sections["then"] ?? "";
176
184
  const bullet = /^\s*(?:\d+\.|[-*])\s+(.+)$/gm;
177
- const steps = Array.from(stepsBlock.matchAll(bullet)).map((m) => m[1].trim()) || [];
185
+ const parsedSteps = Array.from(stepsBlock.matchAll(bullet)).map((m) => parseHintedLine(m[1].trim(), "selector"));
186
+ const steps = parsedSteps.map((entry) => entry.text);
187
+ const stepHints = parsedSteps.map((entry) => ({ selector: entry.hint }));
178
188
  if (steps.length === 0) {
179
- const alt = Array.from(body.matchAll(bullet)).map((m) => m[1].trim());
180
- steps.push(...alt);
189
+ const alt = Array.from(body.matchAll(bullet)).map((m) => parseHintedLine(m[1].trim(), "selector"));
190
+ steps.push(...alt.map((entry) => entry.text));
191
+ stepHints.push(...alt.map((entry) => ({ selector: entry.hint })));
181
192
  }
182
- const expectedLines = Array.from(expectedBlock.matchAll(bullet)).map(
183
- (m) => m[1].trim()
184
- );
193
+ const parsedExpected = Array.from(expectedBlock.matchAll(bullet)).map((m) => parseHintedLine(m[1].trim(), "playwright"));
194
+ const expectedLines = parsedExpected.map((entry) => entry.text);
195
+ const assertionHints = parsedExpected.map((entry) => ({ playwright: entry.hint }));
185
196
  if (expectedLines.length === 0) {
186
197
  const exp = Array.from(
187
198
  body.matchAll(/^\s*(?:Expected|Then|Verify|Assert)[^\n]*:?[\s-]*(.+)$/gim)
188
- ).map((m) => m[1].trim());
189
- expectedLines.push(...exp);
199
+ ).map((m) => parseHintedLine(m[1].trim(), "playwright"));
200
+ expectedLines.push(...exp.map((entry) => entry.text));
201
+ assertionHints.push(...exp.map((entry) => ({ playwright: entry.hint })));
190
202
  }
191
- return { steps, expected: expectedLines };
203
+ return { steps, stepHints, expected: expectedLines, assertionHints };
204
+ }
205
+ function extractUrlMetadata(body) {
206
+ const match = body.match(/<!--\s*ct:url\s+(https?:\/\/[^\s>]+)\s*-->/i);
207
+ return match?.[1];
208
+ }
209
+ function stripCtMetadata(body) {
210
+ return body.replace(/^\s*<!--\s*ct:url\s+https?:\/\/[^\s>]+\s*-->\s*\n?/im, "");
211
+ }
212
+ function parseHintedLine(value, hintType) {
213
+ const match = value.match(new RegExp(`^(.*?)\\s*<!--\\s*${hintType}:\\s*([\\s\\S]*?)\\s*-->\\s*$`, "i"));
214
+ if (!match) return { text: value.trim() };
215
+ return {
216
+ text: match[1].trim(),
217
+ hint: match[2].trim() || void 0
218
+ };
219
+ }
220
+ function extractUrlFromSteps(steps) {
221
+ for (const step of steps) {
222
+ const match = step.match(/https?:\/\/[^\s'"]+/i);
223
+ if (match) return match[0];
224
+ }
225
+ return void 0;
192
226
  }
193
227
  function normalizeOne(titleLine, body, source) {
194
228
  const { clean, tags } = parseTags(titleLine);
195
229
  const id = parseId(clean);
196
- const { steps, expected } = extractSections(body);
230
+ const metadataUrl = extractUrlMetadata(body);
231
+ const cleanBody = stripCtMetadata(body);
232
+ const { steps, stepHints, expected, assertionHints } = extractSections(cleanBody);
233
+ const reviewReasons = [];
234
+ if (steps.length === 0) reviewReasons.push("No steps section or bullets were parsed");
235
+ if (expected.length === 0) reviewReasons.push("No expected results section or assertions were parsed");
197
236
  return {
198
237
  id,
199
- title: clean,
238
+ title: stripIdPrefixFromTitle(clean, id),
200
239
  tags: tags.length ? tags : void 0,
201
240
  steps,
241
+ step_hints: stepHints.some((hint) => hint.selector) ? stepHints : void 0,
202
242
  expected,
203
- needs_review: steps.length === 0 || expected.length === 0,
204
- source
243
+ assertion_hints: assertionHints.some((hint) => hint.playwright) ? assertionHints : void 0,
244
+ needs_review: reviewReasons.length > 0,
245
+ review_reasons: reviewReasons,
246
+ source,
247
+ url: metadataUrl ?? extractUrlFromSteps(steps)
205
248
  };
206
249
  }
207
250
  function normalizeCmd() {
@@ -265,7 +308,7 @@ Examples:
265
308
  }
266
309
  console.log(`\u2705 Normalized ${index.summary.parsed} case(s). Output \u2192 .cementic/normalized/`);
267
310
  if (opts.andGen) {
268
- const { gen } = await import("./gen-54KYT3RO.js");
311
+ const { gen } = await import("./gen-RMRQOAD3.js");
269
312
  await gen({ lang: opts.lang || "ts", out: "tests/generated" });
270
313
  }
271
314
  });
@@ -295,8 +338,8 @@ function testCmd() {
295
338
 
296
339
  // src/commands/tc.ts
297
340
  import { Command as Command4 } from "commander";
298
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
299
- import { join as join3 } from "path";
341
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
342
+ import { join as join4 } from "path";
300
343
  import { createInterface } from "readline/promises";
301
344
  import { stdin as input, stdout as output } from "process";
302
345
 
@@ -353,88 +396,136 @@ function inferPrefix(params) {
353
396
  // src/core/llm.ts
354
397
  function buildSystemMessage() {
355
398
  return `
356
- You are a senior QA engineer and test case designer.
399
+ You are a senior QA engineer and test automation specialist.
357
400
 
358
401
  Your job:
359
- - Take the context of a web feature or page,
360
- - And generate high-quality UI test cases
361
- - In a very strict Markdown format that another tool will parse.
402
+ - Receive context about a web feature or page
403
+ - Generate high-quality, realistic UI test cases
404
+ - Format them in strict Markdown that a downstream parser will process
362
405
 
363
406
  You MUST follow this format exactly for each test case:
364
407
 
365
- # <ID> \u2014 <Short title> @<tag1> @<tag2> ...
408
+ # <ID> \u2014 <Short title> @<tag1> @<tag2>
366
409
  ## Steps
367
- 1. <step>
368
- 2. <step>
410
+ 1. <user action>
411
+ 2. <user action>
369
412
 
370
413
  ## Expected Results
371
- - <assertion>
372
- - <assertion>
414
+ - <verifiable outcome>
415
+ - <verifiable outcome>
373
416
 
374
417
  Rules:
375
- - <ID> must be PREFIX-XXX where PREFIX is provided to you (e.g., AUTH, DASH, CART).
376
- - XXX must be a 3-digit number starting from the startIndex provided in the context.
377
- For example: DASH-005, DASH-006, DASH-007 if startIndex is 5.
378
- - Use 1\u20133 tags per test (e.g., @smoke, @regression, @auth, @ui, @critical).
379
- - "Steps" should describe user actions in sequence.
380
- - "Expected Results" should describe verifiable outcomes (URL change, element visible, message shown, etc.).
381
- - Do NOT add any explanation before or after the test cases.
382
- - Output ONLY the test cases, back-to-back, in Markdown.
383
- - No code blocks, no extra headings outside the pattern described.
418
+ - ID must be PREFIX-NNN where NNN is zero-padded 3 digits starting from startIndex
419
+ - Use 1\u20133 tags: @smoke @regression @auth @ui @critical @happy-path @negative
420
+ - Steps = concrete user actions ("Click the 'Sign In' button", "Fill in email with 'user@example.com'")
421
+ - Expected Results = verifiable UI outcomes ("Error message 'Invalid credentials' is visible", "Page redirects to /dashboard")
422
+ - Include both happy-path AND negative/edge-case tests
423
+ - Do NOT explain or add preamble \u2014 output ONLY the test cases
424
+ - No code blocks, no extra headings outside the pattern
384
425
  `.trim();
385
426
  }
386
427
  function buildUserMessage(ctx) {
387
428
  const lines = [];
388
- lines.push(`App / Product description (optional):`);
429
+ lines.push(`App / product description:`);
389
430
  lines.push(ctx.appDescription || "N/A");
390
431
  lines.push("");
391
432
  lines.push(`Feature or page to test:`);
392
433
  lines.push(ctx.feature);
393
434
  lines.push("");
394
435
  if (ctx.url) {
395
- lines.push(`Page URL:`);
396
- lines.push(ctx.url);
436
+ lines.push(`Page URL: ${ctx.url}`);
397
437
  lines.push("");
398
438
  }
399
- if (ctx.pageSummaryJson) {
400
- lines.push(`Page structure summary (JSON):`);
401
- lines.push("```json");
402
- lines.push(JSON.stringify(ctx.pageSummaryJson, null, 2));
403
- lines.push("```");
439
+ if (ctx.pageSummary) {
440
+ const s = ctx.pageSummary;
441
+ lines.push(`Live page analysis:`);
442
+ if (s.title) lines.push(`- Page title: ${s.title}`);
443
+ if (s.headings.length) {
444
+ lines.push(`- Headings: ${s.headings.join(" | ")}`);
445
+ }
446
+ if (s.inputs.length) {
447
+ const inputDescs = s.inputs.map((i) => {
448
+ const parts = [
449
+ i.label && `label="${i.label}"`,
450
+ i.placeholder && `placeholder="${i.placeholder}"`,
451
+ i.name && `name="${i.name}"`,
452
+ i.type && i.type !== "text" && `type="${i.type}"`,
453
+ i.testId && `data-testid="${i.testId}"`
454
+ ].filter(Boolean);
455
+ return parts.join(", ");
456
+ });
457
+ lines.push(`- Form inputs: ${inputDescs.join(" | ")}`);
458
+ }
459
+ if (s.buttons.length) {
460
+ lines.push(`- Buttons: ${s.buttons.join(" | ")}`);
461
+ }
462
+ if (s.links.length) {
463
+ lines.push(`- Links: ${s.links.slice(0, 20).join(" | ")}`);
464
+ }
404
465
  lines.push("");
405
466
  }
406
- lines.push(`Test ID prefix to use: ${ctx.prefix}`);
407
- lines.push(
408
- `Start numbering from: ${String(ctx.startIndex).padStart(3, "0")}`
409
- );
410
- lines.push(`Number of test cases to generate: ${ctx.numCases}`);
467
+ lines.push(`Test ID prefix: ${ctx.prefix}`);
468
+ lines.push(`Start numbering from: ${String(ctx.startIndex).padStart(3, "0")}`);
469
+ lines.push(`Number of test cases: ${ctx.numCases}`);
411
470
  lines.push("");
412
- lines.push(`Important formatting rules:`);
413
- lines.push(`- Use IDs like ${ctx.prefix}-NNN where NNN is 3-digit, sequential from the start index.`);
414
- lines.push(`- Each test case must follow this pattern exactly:`);
415
- lines.push(`# ${ctx.prefix}-NNN \u2014 <short title> @tag1 @tag2`);
416
- lines.push(`## Steps`);
417
- lines.push(`1. ...`);
418
- lines.push(`2. ...`);
419
- lines.push(``);
420
- lines.push(`## Expected Results`);
421
- lines.push(`- ...`);
422
- lines.push(`- ...`);
423
- lines.push("");
424
- lines.push(`Do NOT add any explanation before or after the test cases. Only output the test cases.`);
471
+ lines.push(`Include a mix of: happy-path flows, validation/error cases, and edge cases.`);
472
+ lines.push(`Output ONLY the test cases. No explanation, no preamble.`);
425
473
  return lines.join("\n");
426
474
  }
427
- async function generateTcMarkdownWithAi(ctx) {
428
- const apiKey = process.env.CT_LLM_API_KEY || process.env.OPENAI_API_KEY || "";
429
- if (!apiKey) {
430
- throw new Error(
431
- "No LLM API key found. Set CT_LLM_API_KEY or OPENAI_API_KEY."
432
- );
475
+ function detectProvider() {
476
+ const anthropicKey = process.env.ANTHROPIC_API_KEY ?? process.env.CT_ANTHROPIC_API_KEY ?? "";
477
+ const openaiKey = process.env.CT_LLM_API_KEY ?? process.env.OPENAI_API_KEY ?? "";
478
+ const explicitProvider = (process.env.CT_LLM_PROVIDER ?? "").toLowerCase();
479
+ if (explicitProvider === "anthropic" || anthropicKey && explicitProvider !== "openai") {
480
+ if (!anthropicKey) {
481
+ throw new Error(
482
+ "CT_LLM_PROVIDER=anthropic but no ANTHROPIC_API_KEY found.\nSet ANTHROPIC_API_KEY=your-key or switch to OPENAI_API_KEY."
483
+ );
484
+ }
485
+ return {
486
+ provider: "anthropic",
487
+ apiKey: anthropicKey,
488
+ model: process.env.CT_LLM_MODEL ?? "claude-sonnet-4-5",
489
+ baseUrl: "https://api.anthropic.com"
490
+ };
433
491
  }
434
- const baseUrl = process.env.CT_LLM_BASE_URL || "https://api.openai.com/v1";
435
- const model = process.env.CT_LLM_MODEL || "gpt-4.1-mini";
436
- const system = buildSystemMessage();
437
- const user = buildUserMessage(ctx);
492
+ if (openaiKey) {
493
+ return {
494
+ provider: "openai",
495
+ apiKey: openaiKey,
496
+ model: process.env.CT_LLM_MODEL ?? "gpt-4o-mini",
497
+ baseUrl: process.env.CT_LLM_BASE_URL ?? "https://api.openai.com/v1"
498
+ };
499
+ }
500
+ throw new Error(
501
+ "No LLM API key found.\n For Claude: export ANTHROPIC_API_KEY=your-key\n For OpenAI: export OPENAI_API_KEY=your-key\n Any OpenAI-compatible endpoint: set CT_LLM_API_KEY + CT_LLM_BASE_URL"
502
+ );
503
+ }
504
+ async function callAnthropic(apiKey, model, system, user) {
505
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
506
+ method: "POST",
507
+ headers: {
508
+ "x-api-key": apiKey,
509
+ "anthropic-version": "2023-06-01",
510
+ "content-type": "application/json"
511
+ },
512
+ body: JSON.stringify({
513
+ model,
514
+ max_tokens: 4096,
515
+ system,
516
+ messages: [{ role: "user", content: user }]
517
+ })
518
+ });
519
+ if (!response.ok) {
520
+ const text = await response.text();
521
+ throw new Error(`Anthropic API error ${response.status}: ${text}`);
522
+ }
523
+ const json = await response.json();
524
+ const content = json.content?.[0]?.text?.trim() ?? "";
525
+ if (!content) throw new Error("Anthropic response had no content");
526
+ return content;
527
+ }
528
+ async function callOpenAi(apiKey, model, baseUrl, system, user) {
438
529
  const response = await fetch(`${baseUrl}/chat/completions`, {
439
530
  method: "POST",
440
531
  headers: {
@@ -443,76 +534,978 @@ async function generateTcMarkdownWithAi(ctx) {
443
534
  },
444
535
  body: JSON.stringify({
445
536
  model,
537
+ temperature: 0.2,
446
538
  messages: [
447
539
  { role: "system", content: system },
448
540
  { role: "user", content: user }
449
- ],
450
- temperature: 0.2
541
+ ]
451
542
  })
452
543
  });
453
544
  if (!response.ok) {
454
545
  const text = await response.text();
455
- throw new Error(
456
- `LLM request failed: ${response.status} ${response.statusText} \u2014 ${text}`
457
- );
546
+ throw new Error(`OpenAI API error ${response.status}: ${text}`);
547
+ }
548
+ const json = await response.json();
549
+ const content = json.choices?.[0]?.message?.content?.trim() ?? "";
550
+ if (!content) throw new Error("OpenAI response had no content");
551
+ return content;
552
+ }
553
+ async function generateTcMarkdownWithAi(ctx) {
554
+ const { provider, apiKey, model, baseUrl } = detectProvider();
555
+ const system = buildSystemMessage();
556
+ const user = buildUserMessage(ctx);
557
+ console.log(`\u{1F916} Using ${provider === "anthropic" ? "Claude" : "OpenAI-compatible"} (${model})`);
558
+ if (provider === "anthropic") {
559
+ return callAnthropic(apiKey, model, system, user);
560
+ }
561
+ return callOpenAi(apiKey, model, baseUrl, system, user);
562
+ }
563
+
564
+ // src/core/analyse.ts
565
+ async function analyseElements(elementMap, options = {}) {
566
+ const { verbose = false } = options;
567
+ const { provider, displayName, apiKey, model, baseUrl } = detectProvider2();
568
+ log(verbose, `
569
+ [analyse] Sending capture to ${displayName} (${model})`);
570
+ const systemPrompt = buildSystemPrompt();
571
+ const userPrompt = buildUserPrompt(elementMap);
572
+ const rawResponse = provider === "anthropic" ? await callAnthropic2(apiKey, model, systemPrompt, userPrompt) : await callOpenAi2(apiKey, model, baseUrl ?? "https://api.openai.com/v1", systemPrompt, userPrompt);
573
+ const parsed = parseAnalysisJson(rawResponse);
574
+ return sanitizeAnalysis(parsed, elementMap);
575
+ }
576
+ function detectProvider2() {
577
+ const explicitProvider = (process.env.CT_LLM_PROVIDER ?? "").toLowerCase();
578
+ const providers = {
579
+ deepseek: {
580
+ provider: "deepseek",
581
+ displayName: "DeepSeek",
582
+ apiKey: process.env.DEEPSEEK_API_KEY ?? process.env.CT_DEEPSEEK_API_KEY ?? "",
583
+ model: process.env.CT_LLM_MODEL ?? process.env.DEEPSEEK_MODEL ?? "deepseek-chat",
584
+ baseUrl: process.env.DEEPSEEK_BASE_URL ?? "https://api.deepseek.com/v1"
585
+ },
586
+ anthropic: {
587
+ provider: "anthropic",
588
+ displayName: "Claude",
589
+ apiKey: process.env.ANTHROPIC_API_KEY ?? process.env.CT_ANTHROPIC_API_KEY ?? "",
590
+ model: process.env.CT_LLM_MODEL ?? process.env.ANTHROPIC_MODEL ?? "claude-sonnet-4-5"
591
+ },
592
+ gemini: {
593
+ provider: "gemini",
594
+ displayName: "Gemini",
595
+ apiKey: process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY ?? "",
596
+ model: process.env.CT_LLM_MODEL ?? process.env.GEMINI_MODEL ?? "gemini-2.5-flash",
597
+ baseUrl: process.env.GEMINI_BASE_URL ?? "https://generativelanguage.googleapis.com/v1beta/openai"
598
+ },
599
+ qwen: {
600
+ provider: "qwen",
601
+ displayName: "Qwen",
602
+ apiKey: process.env.QWEN_API_KEY ?? "",
603
+ model: process.env.CT_LLM_MODEL ?? process.env.QWEN_MODEL ?? "qwen-plus",
604
+ baseUrl: process.env.QWEN_BASE_URL ?? "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
605
+ },
606
+ kimi: {
607
+ provider: "kimi",
608
+ displayName: "Kimi",
609
+ apiKey: process.env.KIMI_API_KEY ?? process.env.MOONSHOT_API_KEY ?? "",
610
+ model: process.env.CT_LLM_MODEL ?? process.env.KIMI_MODEL ?? "moonshot-v1-8k",
611
+ baseUrl: process.env.KIMI_BASE_URL ?? "https://api.moonshot.ai/v1"
612
+ },
613
+ openai: {
614
+ provider: "openai",
615
+ displayName: "OpenAI-compatible",
616
+ apiKey: process.env.OPENAI_API_KEY ?? process.env.CT_LLM_API_KEY ?? "",
617
+ model: process.env.CT_LLM_MODEL ?? process.env.OPENAI_MODEL ?? "gpt-4o-mini",
618
+ baseUrl: process.env.CT_LLM_BASE_URL ?? "https://api.openai.com/v1"
619
+ }
620
+ };
621
+ if (explicitProvider) {
622
+ if (!(explicitProvider in providers)) {
623
+ throw new Error(`Unsupported CT_LLM_PROVIDER="${explicitProvider}". Use deepseek, anthropic, gemini, qwen, kimi, or openai.`);
624
+ }
625
+ const selected = providers[explicitProvider];
626
+ if (!selected.apiKey) {
627
+ throw new Error(`CT_LLM_PROVIDER=${explicitProvider} is set but the matching API key is missing.`);
628
+ }
629
+ return selected;
630
+ }
631
+ for (const providerName of ["deepseek", "anthropic", "gemini", "qwen", "kimi", "openai"]) {
632
+ if (providers[providerName].apiKey) return providers[providerName];
633
+ }
634
+ throw new Error(
635
+ "No LLM API key found.\n DeepSeek: export DEEPSEEK_API_KEY=your-key\n Anthropic: export ANTHROPIC_API_KEY=your-key\n Gemini: export GEMINI_API_KEY=your-key\n Qwen: export QWEN_API_KEY=your-key\n Kimi: export KIMI_API_KEY=your-key\n OpenAI: export OPENAI_API_KEY=your-key"
636
+ );
637
+ }
638
+ function buildSystemPrompt() {
639
+ return `
640
+ You are a senior QA automation engineer and Playwright expert working on CementicTest.
641
+
642
+ Your job is to analyse a structured map of interactive elements extracted from a live web page,
643
+ then produce a complete set of Playwright test scenarios.
644
+
645
+ RULES:
646
+ 1. Use only selectors provided in the ElementMap.
647
+ 2. Every assertion must include an exact "playwright" field with a complete await expect(...) statement.
648
+ 3. Use clearly fake data like user@example.com.
649
+ 4. Include both happy-path and negative scenarios, but stay evidence-backed.
650
+ 5. Output only valid JSON matching the requested schema.
651
+ 6. Do not invent redirect targets, success pages, error text, password clearing, or security scenarios unless the capture explicitly supports them.
652
+ 7. If no status or alert region was captured, avoid scenarios that depend on unseen server-side validation messages.
653
+ 8. Prefer 3 to 5 realistic scenarios.
654
+
655
+ OUTPUT SCHEMA:
656
+ {
657
+ "url": string,
658
+ "feature": string,
659
+ "suggestedPrefix": string,
660
+ "scenarios": [
661
+ {
662
+ "id": string,
663
+ "title": string,
664
+ "tags": string[],
665
+ "steps": [
666
+ {
667
+ "action": "navigate"|"fill"|"click"|"select"|"check"|"keyboard"|"hover",
668
+ "selector": string,
669
+ "value": string,
670
+ "human": string
671
+ }
672
+ ],
673
+ "assertions": [
674
+ {
675
+ "type": string,
676
+ "selector": string,
677
+ "expected": string,
678
+ "human": string,
679
+ "playwright": string
680
+ }
681
+ ],
682
+ "narrator": string,
683
+ "codeLevel": "beginner"|"intermediate"|"advanced"
684
+ }
685
+ ],
686
+ "analysisNotes": string,
687
+ "audioSummary": string
688
+ }`.trim();
689
+ }
690
+ function buildUserPrompt(elementMap) {
691
+ const lines = [];
692
+ lines.push("PAGE INFORMATION");
693
+ lines.push(`URL: ${elementMap.url}`);
694
+ lines.push(`Title: ${elementMap.title}`);
695
+ lines.push(`Captured in: ${elementMap.mode} mode`);
696
+ lines.push("");
697
+ for (const category of ["input", "button", "link", "heading", "status"]) {
698
+ const items = elementMap.elements.filter((element) => element.category === category);
699
+ if (items.length === 0) continue;
700
+ lines.push(`${category.toUpperCase()}S (${items.length} found):`);
701
+ for (const item of items.slice(0, category === "link" ? 10 : items.length)) {
702
+ lines.push(` - [${item.confidence}] ${item.selector}`);
703
+ lines.push(` Purpose: ${item.purpose}`);
704
+ if (item.selectorAlt.length > 0) {
705
+ lines.push(` Fallbacks: ${item.selectorAlt.slice(0, 2).join(" | ")}`);
706
+ }
707
+ }
708
+ lines.push("");
709
+ }
710
+ if (elementMap.warnings.length > 0) {
711
+ lines.push("CAPTURE WARNINGS:");
712
+ for (const warning of elementMap.warnings) {
713
+ lines.push(` - ${warning}`);
714
+ }
715
+ lines.push("");
716
+ }
717
+ const interactiveCount = elementMap.elements.filter((element) => element.category === "input" || element.category === "button" || element.category === "link").length;
718
+ const statusCount = elementMap.elements.filter((element) => element.category === "status").length;
719
+ lines.push("EVIDENCE CONSTRAINTS:");
720
+ lines.push(` - Interactive elements captured: ${interactiveCount}`);
721
+ lines.push(` - Status or alert regions captured: ${statusCount}`);
722
+ lines.push(" - If no redirect target is explicitly captured, do not assert a destination path.");
723
+ lines.push(" - If no status region was captured, avoid exact server-side credential error claims.");
724
+ lines.push("");
725
+ lines.push("Generate only the JSON response.");
726
+ return lines.join("\n");
727
+ }
728
+ async function callAnthropic2(apiKey, model, systemPrompt, userPrompt) {
729
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
730
+ method: "POST",
731
+ headers: {
732
+ "x-api-key": apiKey,
733
+ "anthropic-version": "2023-06-01",
734
+ "content-type": "application/json"
735
+ },
736
+ body: JSON.stringify({
737
+ model,
738
+ max_tokens: 4096,
739
+ system: systemPrompt,
740
+ messages: [{ role: "user", content: userPrompt }]
741
+ })
742
+ });
743
+ if (!response.ok) {
744
+ throw new Error(`Anthropic API ${response.status}: ${await response.text()}`);
458
745
  }
459
746
  const json = await response.json();
460
- const content = json.choices?.[0]?.message?.content?.trim() || "";
461
- if (!content) {
462
- throw new Error("LLM response had no content");
747
+ const content = json.content?.[0]?.text?.trim() ?? "";
748
+ if (!content) throw new Error("Anthropic returned empty content");
749
+ return content;
750
+ }
751
+ async function callOpenAi2(apiKey, model, baseUrl, systemPrompt, userPrompt) {
752
+ const response = await fetch(`${baseUrl}/chat/completions`, {
753
+ method: "POST",
754
+ headers: {
755
+ Authorization: `Bearer ${apiKey}`,
756
+ "Content-Type": "application/json"
757
+ },
758
+ body: JSON.stringify({
759
+ model,
760
+ temperature: 0.1,
761
+ messages: [
762
+ { role: "system", content: systemPrompt },
763
+ { role: "user", content: userPrompt }
764
+ ]
765
+ })
766
+ });
767
+ if (!response.ok) {
768
+ throw new Error(`OpenAI API ${response.status}: ${await response.text()}`);
463
769
  }
770
+ const json = await response.json();
771
+ const content = json.choices?.[0]?.message?.content?.trim() ?? "";
772
+ if (!content) throw new Error("OpenAI-compatible provider returned empty content");
464
773
  return content;
465
774
  }
775
+ function parseAnalysisJson(raw) {
776
+ const stripped = raw.replace(/^```(?:json)?\s*/m, "").replace(/\s*```\s*$/m, "").trim();
777
+ try {
778
+ return JSON.parse(stripped);
779
+ } catch (error) {
780
+ throw new Error(
781
+ `Failed to parse LLM response as JSON.
782
+ Parse error: ${error?.message ?? error}
783
+ Raw response:
784
+ ${raw.slice(0, 500)}`
785
+ );
786
+ }
787
+ }
788
+ function sanitizeAnalysis(analysis, elementMap) {
789
+ const selectors = new Set(elementMap.elements.map((element) => element.selector));
790
+ const rawScenarios = Array.isArray(analysis.scenarios) ? analysis.scenarios : [];
791
+ const currentUrl = new URL(elementMap.url);
792
+ const knownPaths = /* @__PURE__ */ new Set([
793
+ currentUrl.pathname,
794
+ ...elementMap.elements.filter((element) => element.category === "link").map((element) => {
795
+ const href = element.attributes?.href;
796
+ if (typeof href !== "string") return "";
797
+ try {
798
+ return new URL(href, elementMap.url).pathname;
799
+ } catch {
800
+ return href;
801
+ }
802
+ }).filter(Boolean)
803
+ ]);
804
+ const sanitizedScenarios = rawScenarios.map((scenario) => normalizeScenario(scenario, selectors)).filter((scenario) => scenario.steps.length > 0 && scenario.assertions.length > 0).map((scenario) => ({
805
+ ...scenario,
806
+ tags: scenario.tags.map(normalizeTag),
807
+ assertions: scenario.assertions.filter((assertion) => {
808
+ if (assertion.type !== "url") return true;
809
+ const combined = `${assertion.expected} ${assertion.human} ${assertion.playwright}`;
810
+ const pathMatch = combined.match(/\/[a-z0-9/_-]+/i);
811
+ if (!pathMatch) return true;
812
+ return knownPaths.has(pathMatch[0]);
813
+ })
814
+ })).filter((scenario) => scenario.assertions.length > 0).slice(0, 5);
815
+ const finalScenarios = sanitizedScenarios.length > 0 ? sanitizedScenarios : buildFallbackScenarios(elementMap, analysis.suggestedPrefix || "FLOW");
816
+ return {
817
+ ...analysis,
818
+ url: analysis.url || elementMap.url,
819
+ feature: analysis.feature || inferFeatureName(elementMap),
820
+ suggestedPrefix: (analysis.suggestedPrefix || inferPrefix2(elementMap)).toUpperCase(),
821
+ scenarios: finalScenarios,
822
+ analysisNotes: [
823
+ analysis.analysisNotes,
824
+ `Sanitized to ${finalScenarios.length} evidence-backed scenario(s) from ${rawScenarios.length} raw scenario(s).`
825
+ ].filter(Boolean).join(" "),
826
+ audioSummary: analysis.audioSummary || buildAudioSummary(analysis.feature || inferFeatureName(elementMap), finalScenarios)
827
+ };
828
+ }
829
+ function normalizeScenario(candidate, selectors) {
830
+ return {
831
+ id: candidate?.id ?? "FLOW-001",
832
+ title: candidate?.title ?? "Captured page flow",
833
+ tags: Array.isArray(candidate?.tags) ? candidate.tags : [],
834
+ steps: Array.isArray(candidate?.steps) ? candidate.steps.filter((step) => step?.selector === "page" || selectors.has(step?.selector)) : [],
835
+ assertions: Array.isArray(candidate?.assertions) ? candidate.assertions.filter((assertion) => assertion?.selector === "page" || selectors.has(assertion?.selector)) : [],
836
+ narrator: candidate?.narrator ?? "Let us run this captured test flow.",
837
+ codeLevel: candidate?.codeLevel ?? "beginner"
838
+ };
839
+ }
840
+ function buildFallbackScenarios(elementMap, prefix) {
841
+ const heading = elementMap.elements.find((element) => element.category === "heading");
842
+ const emailInput = elementMap.elements.find((element) => element.category === "input" && (element.attributes.type === "email" || /email/.test(`${element.name ?? ""} ${String(element.attributes.label ?? "")}`.toLowerCase())));
843
+ const passwordInput = elementMap.elements.find((element) => element.category === "input" && element.attributes.type === "password");
844
+ 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");
845
+ const scenarios = [];
846
+ const tag = (value) => normalizeTag(value);
847
+ const nextId = (index) => `${prefix}-${String(900 + index).padStart(3, "0")}`;
848
+ if (heading) {
849
+ scenarios.push({
850
+ id: nextId(scenarios.length + 1),
851
+ title: "Page loads with expected heading",
852
+ tags: [tag("smoke"), tag("page-load")],
853
+ steps: [
854
+ {
855
+ action: "navigate",
856
+ selector: "page",
857
+ value: elementMap.url,
858
+ human: "Navigate to the captured page"
859
+ }
860
+ ],
861
+ assertions: [
862
+ {
863
+ type: "visible",
864
+ selector: heading.selector,
865
+ expected: "visible",
866
+ human: `${heading.name || "Primary heading"} is visible`,
867
+ playwright: `await expect(page.${heading.selector}).toBeVisible();`
868
+ }
869
+ ],
870
+ narrator: "We will first confirm that the expected page heading is visible.",
871
+ codeLevel: "beginner"
872
+ });
873
+ }
874
+ if (emailInput && passwordInput && submitButton) {
875
+ scenarios.push({
876
+ id: nextId(scenarios.length + 1),
877
+ title: "Submitting without a password keeps the user on the form",
878
+ tags: [tag("validation"), tag("negative")],
879
+ steps: [
880
+ {
881
+ action: "navigate",
882
+ selector: "page",
883
+ value: elementMap.url,
884
+ human: "Navigate to the captured page"
885
+ },
886
+ {
887
+ action: "fill",
888
+ selector: emailInput.selector,
889
+ value: "user@example.com",
890
+ human: "Fill in the email field"
891
+ },
892
+ {
893
+ action: "click",
894
+ selector: submitButton.selector,
895
+ value: "",
896
+ human: "Click the submit button"
897
+ }
898
+ ],
899
+ assertions: [
900
+ {
901
+ type: "url",
902
+ selector: "page",
903
+ expected: new URL(elementMap.url).pathname,
904
+ human: "User remains on the same page",
905
+ playwright: `await expect(page).toHaveURL('${elementMap.url}');`
906
+ },
907
+ {
908
+ type: "visible",
909
+ selector: passwordInput.selector,
910
+ expected: "visible",
911
+ human: "Password field stays visible for correction",
912
+ playwright: `await expect(page.${passwordInput.selector}).toBeVisible();`
913
+ }
914
+ ],
915
+ narrator: "Next we will leave the password blank and confirm the form does not advance.",
916
+ codeLevel: "beginner"
917
+ });
918
+ }
919
+ return scenarios.slice(0, 5);
920
+ }
921
+ function inferFeatureName(elementMap) {
922
+ const heading = elementMap.elements.find((element) => element.category === "heading" && element.name);
923
+ return heading?.name || elementMap.title || "Captured page";
924
+ }
925
+ function inferPrefix2(elementMap) {
926
+ const source = `${elementMap.title} ${elementMap.url}`.toLowerCase();
927
+ if (/\blogin|sign in|auth/.test(source)) return "AUTH";
928
+ if (/\bcheckout|cart|payment/.test(source)) return "CHK";
929
+ if (/\bdashboard/.test(source)) return "DASH";
930
+ return "FLOW";
931
+ }
932
+ function buildAudioSummary(feature, scenarios) {
933
+ return `We finished validating ${feature || "the captured page"} with ${scenarios.length} evidence-backed scenario${scenarios.length === 1 ? "" : "s"}.`;
934
+ }
935
+ function normalizeTag(value) {
936
+ const cleaned = String(value ?? "").trim().replace(/^@+/, "");
937
+ return cleaned ? `@${cleaned}` : "@ui";
938
+ }
939
+ function log(verbose, message) {
940
+ if (verbose) console.log(message);
941
+ }
466
942
 
467
- // src/core/scrape.ts
468
- async function scrapePageSummary(url) {
469
- const res = await fetch(url);
470
- if (!res.ok) {
471
- throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
472
- }
473
- const html = await res.text();
474
- const rawLength = html.length;
475
- const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i);
476
- const title = titleMatch?.[1]?.trim() || void 0;
477
- const headings = Array.from(html.matchAll(/<(h1|h2)[^>]*>([^<]*)<\/\1>/gi)).map((m) => m[2].replace(/\s+/g, " ").trim()).filter(Boolean);
478
- const buttons = Array.from(html.matchAll(/<button[^>]*>([^<]*)<\/button>/gi)).map((m) => m[1].replace(/\s+/g, " ").trim()).filter(Boolean);
479
- const links = Array.from(html.matchAll(/<a[^>]*>([^<]*)<\/a>/gi)).map((m) => m[1].replace(/\s+/g, " ").trim()).filter(Boolean).slice(0, 50);
480
- const inputs = [];
481
- const labelMap = /* @__PURE__ */ new Map();
482
- for (const m of html.matchAll(/<label[^>]*for=["']?([^"'>\s]+)["']?[^>]*>([^<]*)<\/label>/gi)) {
483
- const id = m[1];
484
- const text = m[2].replace(/\s+/g, " ").trim();
485
- if (id && text) labelMap.set(id, text);
486
- }
487
- for (const m of html.matchAll(/<input([^>]*)>/gi)) {
488
- const attrs = m[1];
489
- const nameMatch = attrs.match(/\bname=["']?([^"'>\s]+)["']?/i);
490
- const idMatch = attrs.match(/\bid=["']?([^"'>\s]+)["']?/i);
491
- const phMatch = attrs.match(/\bplaceholder=["']([^"']*)["']/i);
492
- const id = idMatch?.[1];
493
- const label = id ? labelMap.get(id) : void 0;
494
- const ph = phMatch?.[1]?.trim();
495
- const name = nameMatch?.[1];
496
- const descriptor = label || ph || name;
497
- if (descriptor) {
498
- inputs.push(descriptor);
943
+ // src/core/capture.ts
944
+ var SETTLE_MS = 1200;
945
+ var DEFAULT_TIMEOUT_MS = 3e4;
946
+ var MAX_PER_CATEGORY = 50;
947
+ async function captureElements(url, options = {}) {
948
+ const {
949
+ headless = true,
950
+ timeoutMs = DEFAULT_TIMEOUT_MS,
951
+ verbose = false,
952
+ userAgent = "Mozilla/5.0 (compatible; CementicTest/0.2.5 capture)"
953
+ } = options;
954
+ const chromium = await loadChromium();
955
+ const mode = headless ? "headless" : "headed";
956
+ log2(verbose, `
957
+ [capture] Starting ${mode} capture: ${url}`);
958
+ const browser = await chromium.launch({
959
+ headless,
960
+ slowMo: headless ? 0 : 150
961
+ });
962
+ const context = await browser.newContext({
963
+ userAgent,
964
+ viewport: { width: 1280, height: 800 },
965
+ ignoreHTTPSErrors: true
966
+ });
967
+ const page = await context.newPage();
968
+ try {
969
+ log2(verbose, ` -> Navigating (timeout: ${timeoutMs}ms)`);
970
+ await page.goto(url, {
971
+ waitUntil: "domcontentloaded",
972
+ timeout: timeoutMs
973
+ });
974
+ log2(verbose, ` -> Waiting ${SETTLE_MS}ms for page settle`);
975
+ await page.waitForTimeout(SETTLE_MS);
976
+ log2(verbose, " -> Extracting accessibility snapshot");
977
+ const a11ySnapshot = await getAccessibilitySnapshot(page, verbose);
978
+ log2(verbose, " -> Extracting DOM data");
979
+ const domData = await page.evaluate(extractDomData);
980
+ const title = await page.title();
981
+ const finalUrl = page.url();
982
+ const elements = buildElementMap(domData);
983
+ const warnings = buildWarnings(elements, domData, a11ySnapshot);
984
+ const byCategory = {};
985
+ for (const element of elements) {
986
+ byCategory[element.category] = (byCategory[element.category] ?? 0) + 1;
499
987
  }
988
+ const result = {
989
+ url: finalUrl,
990
+ title,
991
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
992
+ mode,
993
+ summary: {
994
+ totalElements: elements.length,
995
+ byCategory
996
+ },
997
+ elements,
998
+ warnings
999
+ };
1000
+ log2(verbose, ` -> Captured ${elements.length} testable elements from "${title}"`);
1001
+ return result;
1002
+ } finally {
1003
+ await browser.close();
500
1004
  }
1005
+ }
1006
+ function toPageSummary(elementMap) {
1007
+ const inputs = elementMap.elements.filter((element) => element.category === "input").slice(0, 30).map((element) => ({
1008
+ label: asString(element.attributes.label),
1009
+ placeholder: asString(element.attributes.placeholder),
1010
+ name: asString(element.attributes.name),
1011
+ type: asString(element.attributes.type),
1012
+ testId: asString(element.attributes.testId)
1013
+ }));
501
1014
  return {
502
- url,
503
- title,
1015
+ url: elementMap.url,
1016
+ title: elementMap.title,
1017
+ headings: elementMap.elements.filter((element) => element.category === "heading").map((element) => element.name ?? "").filter(Boolean).slice(0, 20),
1018
+ buttons: elementMap.elements.filter((element) => element.category === "button").map((element) => element.name ?? "").filter(Boolean).slice(0, 30),
1019
+ links: elementMap.elements.filter((element) => element.category === "link").map((element) => element.name ?? "").filter(Boolean).slice(0, 50),
1020
+ inputs,
1021
+ landmarks: [],
1022
+ rawLength: elementMap.elements.length
1023
+ };
1024
+ }
1025
+ async function loadChromium() {
1026
+ try {
1027
+ return (await import("playwright-core")).chromium;
1028
+ } catch {
1029
+ }
1030
+ try {
1031
+ return (await import("@playwright/test")).chromium;
1032
+ } catch {
1033
+ }
1034
+ throw new Error(
1035
+ 'Playwright is required for live page capture. Install it in the target project with "npm install -D @playwright/test" and "npx playwright install chromium".'
1036
+ );
1037
+ }
1038
+ function extractDomData() {
1039
+ const localMaxPerCategory = 50;
1040
+ const attr = (el, name) => el.getAttribute(name)?.trim() || void 0;
1041
+ const text = (el) => el?.textContent?.replace(/\s+/g, " ").trim() || void 0;
1042
+ const jsString = (value) => JSON.stringify(value);
1043
+ const findLabel = (el) => {
1044
+ const id = attr(el, "id");
1045
+ if (id) {
1046
+ const labelEl = document.querySelector(`label[for="${id}"]`);
1047
+ if (labelEl) return text(labelEl);
1048
+ }
1049
+ const ariaLabel = attr(el, "aria-label");
1050
+ if (ariaLabel) return ariaLabel;
1051
+ const labelledBy = attr(el, "aria-labelledby");
1052
+ if (labelledBy) {
1053
+ const labelEl = document.getElementById(labelledBy);
1054
+ if (labelEl) return text(labelEl);
1055
+ }
1056
+ const closestLabel = el.closest("label");
1057
+ if (closestLabel) {
1058
+ const raw = text(closestLabel) || "";
1059
+ const placeholder = attr(el, "placeholder") || "";
1060
+ return raw.replace(placeholder, "").trim() || void 0;
1061
+ }
1062
+ const previous = el.previousElementSibling;
1063
+ if (previous?.tagName === "LABEL") return text(previous);
1064
+ return void 0;
1065
+ };
1066
+ const buildSelector = (el, labelText, buttonText) => {
1067
+ const testId = attr(el, "data-testid");
1068
+ if (testId) return `getByTestId(${jsString(testId)})`;
1069
+ const id = attr(el, "id");
1070
+ if (id && !id.match(/^(ember|react|vue|ng|auto|rand)/i)) {
1071
+ return `locator(${jsString(`#${id}`)})`;
1072
+ }
1073
+ const ariaLabel = attr(el, "aria-label");
1074
+ if (ariaLabel) {
1075
+ return `getByRole(${jsString(el.getAttribute("role") || el.tagName.toLowerCase())}, { name: ${jsString(ariaLabel)} })`;
1076
+ }
1077
+ if (labelText) {
1078
+ const tag = el.tagName.toLowerCase();
1079
+ if (tag === "input" || tag === "textarea" || tag === "select") {
1080
+ return `getByLabel(${jsString(labelText)})`;
1081
+ }
1082
+ }
1083
+ if (buttonText) return `getByRole('button', { name: ${jsString(buttonText)} })`;
1084
+ const name = attr(el, "name");
1085
+ if (name) return `locator(${jsString(`[name="${name}"]`)})`;
1086
+ return null;
1087
+ };
1088
+ const buttons = [];
1089
+ document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]').forEach((el) => {
1090
+ const buttonText = attr(el, "aria-label") || text(el) || attr(el, "value");
1091
+ const testId = attr(el, "data-testid");
1092
+ const id = attr(el, "id");
1093
+ const type = attr(el, "type");
1094
+ const disabled = el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true";
1095
+ const selector = buildSelector(el, void 0, buttonText);
1096
+ if (buttonText || testId) {
1097
+ buttons.push({
1098
+ tag: el.tagName.toLowerCase(),
1099
+ text: buttonText,
1100
+ testId,
1101
+ id,
1102
+ type,
1103
+ disabled,
1104
+ selector,
1105
+ cssPath: testId ? `[data-testid="${testId}"]` : id ? `#${id}` : null
1106
+ });
1107
+ }
1108
+ });
1109
+ const inputs = [];
1110
+ document.querySelectorAll('input:not([type="hidden"]):not([type="submit"]):not([type="button"]), textarea, select').forEach((el) => {
1111
+ const label = findLabel(el);
1112
+ const placeholder = attr(el, "placeholder");
1113
+ const name = attr(el, "name");
1114
+ const id = attr(el, "id");
1115
+ const type = attr(el, "type") || el.tagName.toLowerCase();
1116
+ const testId = attr(el, "data-testid");
1117
+ const required = el.hasAttribute("required");
1118
+ const selector = buildSelector(el, label);
1119
+ if (label || placeholder || name || testId || id) {
1120
+ inputs.push({
1121
+ tag: el.tagName.toLowerCase(),
1122
+ type,
1123
+ label,
1124
+ placeholder,
1125
+ name,
1126
+ id,
1127
+ testId,
1128
+ required,
1129
+ selector,
1130
+ cssPath: testId ? `[data-testid="${testId}"]` : id ? `#${id}` : name ? `[name="${name}"]` : null
1131
+ });
1132
+ }
1133
+ });
1134
+ const links = [];
1135
+ document.querySelectorAll("a[href]").forEach((el) => {
1136
+ const linkText = attr(el, "aria-label") || text(el);
1137
+ const href = attr(el, "href");
1138
+ const testId = attr(el, "data-testid");
1139
+ if (!linkText || href === "#") return;
1140
+ links.push({
1141
+ text: linkText,
1142
+ href,
1143
+ testId,
1144
+ external: Boolean(href?.startsWith("http") && !href.includes(window.location.hostname)),
1145
+ selector: testId ? `getByTestId(${jsString(testId)})` : `getByRole('link', { name: ${jsString(linkText)} })`
1146
+ });
1147
+ });
1148
+ const headings = [];
1149
+ document.querySelectorAll("h1, h2, h3").forEach((el) => {
1150
+ const headingText = text(el);
1151
+ if (headingText) {
1152
+ headings.push({
1153
+ level: el.tagName.toLowerCase(),
1154
+ text: headingText,
1155
+ selector: `getByRole('heading', { name: ${jsString(headingText)} })`
1156
+ });
1157
+ }
1158
+ });
1159
+ const landmarks = [];
1160
+ document.querySelectorAll("[role], main, nav, header, footer, aside, section[aria-label]").forEach((el) => {
1161
+ const role = attr(el, "role") || el.tagName.toLowerCase();
1162
+ const label = attr(el, "aria-label") || text(el)?.slice(0, 40);
1163
+ if (role && label) landmarks.push({ role, label });
1164
+ });
1165
+ const statusRegions = [];
1166
+ document.querySelectorAll('[role="alert"], [role="status"], [aria-live]').forEach((el) => {
1167
+ const role = attr(el, "role") || "live";
1168
+ statusRegions.push({
1169
+ role,
1170
+ ariaLive: attr(el, "aria-live"),
1171
+ text: text(el),
1172
+ selector: el.getAttribute("role") ? `getByRole(${jsString(role)})` : `locator('[aria-live]')`
1173
+ });
1174
+ });
1175
+ const forms = [];
1176
+ document.querySelectorAll("form").forEach((form, index) => {
1177
+ forms.push({
1178
+ id: attr(form, "id"),
1179
+ label: attr(form, "aria-label") || attr(form, "aria-labelledby"),
1180
+ action: attr(form, "action"),
1181
+ method: attr(form, "method") || "get",
1182
+ fieldCount: form.querySelectorAll("input, textarea, select").length,
1183
+ index
1184
+ });
1185
+ });
1186
+ return {
1187
+ buttons: buttons.slice(0, localMaxPerCategory),
1188
+ inputs: inputs.slice(0, localMaxPerCategory),
1189
+ links: links.slice(0, localMaxPerCategory),
504
1190
  headings: headings.slice(0, 20),
505
- buttons: buttons.slice(0, 30),
506
- links,
507
- inputs: inputs.slice(0, 30),
508
- rawLength
1191
+ landmarks,
1192
+ statusRegions,
1193
+ forms,
1194
+ pageUrl: window.location.href,
1195
+ pageTitle: document.title
509
1196
  };
510
1197
  }
1198
+ function buildElementMap(domData) {
1199
+ const elements = [];
1200
+ for (const input2 of domData.inputs) {
1201
+ const displayName = input2.label || input2.placeholder || input2.name || input2.testId;
1202
+ if (!displayName) continue;
1203
+ const selector = input2.selector || (input2.testId ? `getByTestId(${JSON.stringify(input2.testId)})` : null) || (input2.label ? `getByLabel(${JSON.stringify(input2.label)})` : null) || (input2.id ? `locator(${JSON.stringify(`#${input2.id}`)})` : null) || (input2.name ? `locator(${JSON.stringify(`[name="${input2.name}"]`)})` : null) || `locator(${JSON.stringify(`${input2.tag}[placeholder="${input2.placeholder ?? ""}"]`)})`;
1204
+ const selectorAlt = [];
1205
+ if (input2.id && !selector.includes(`#${input2.id}`)) selectorAlt.push(`locator(${JSON.stringify(`#${input2.id}`)})`);
1206
+ if (input2.name && !selector.includes(input2.name)) selectorAlt.push(`locator(${JSON.stringify(`[name="${input2.name}"]`)})`);
1207
+ if (input2.testId && !selector.includes(input2.testId)) selectorAlt.push(`getByTestId(${JSON.stringify(input2.testId)})`);
1208
+ if (input2.placeholder && !selector.includes(input2.placeholder)) selectorAlt.push(`getByPlaceholder(${JSON.stringify(input2.placeholder)})`);
1209
+ if (input2.label && !selector.includes(input2.label)) selectorAlt.push(`getByLabel(${JSON.stringify(input2.label)})`);
1210
+ elements.push({
1211
+ category: "input",
1212
+ role: input2.type === "checkbox" ? "checkbox" : "textbox",
1213
+ name: displayName,
1214
+ selector,
1215
+ selectorAlt,
1216
+ purpose: input2.required ? `Required ${input2.type} field - "${displayName}"` : `${input2.type} field - "${displayName}"`,
1217
+ confidence: input2.testId || input2.label || input2.id ? "high" : input2.placeholder ? "medium" : "low",
1218
+ attributes: {
1219
+ type: input2.type,
1220
+ label: input2.label,
1221
+ placeholder: input2.placeholder,
1222
+ name: input2.name,
1223
+ id: input2.id,
1224
+ testId: input2.testId,
1225
+ required: input2.required,
1226
+ tag: input2.tag
1227
+ }
1228
+ });
1229
+ }
1230
+ for (const button of domData.buttons) {
1231
+ const displayName = button.text || button.testId;
1232
+ if (!displayName) continue;
1233
+ const selector = button.selector || (button.testId ? `getByTestId(${JSON.stringify(button.testId)})` : null) || (button.text ? `getByRole('button', { name: ${JSON.stringify(button.text)} })` : null) || (button.id ? `locator(${JSON.stringify(`#${button.id}`)})` : null) || `locator('button')`;
1234
+ const selectorAlt = [];
1235
+ if (button.id && !selector.includes(button.id)) selectorAlt.push(`locator(${JSON.stringify(`#${button.id}`)})`);
1236
+ if (button.testId && !selector.includes(button.testId)) selectorAlt.push(`getByTestId(${JSON.stringify(button.testId)})`);
1237
+ if (button.text && !selector.includes(button.text)) selectorAlt.push(`getByText(${JSON.stringify(button.text)})`);
1238
+ if (button.cssPath) selectorAlt.push(`locator(${JSON.stringify(button.cssPath)})`);
1239
+ elements.push({
1240
+ category: "button",
1241
+ role: "button",
1242
+ name: displayName,
1243
+ selector,
1244
+ selectorAlt,
1245
+ purpose: button.disabled ? `Disabled button - "${displayName}"` : button.type === "submit" ? `Form submit button - "${displayName}"` : `Button - "${displayName}"`,
1246
+ confidence: button.testId || button.text ? "high" : button.id ? "medium" : "low",
1247
+ attributes: {
1248
+ text: button.text,
1249
+ testId: button.testId,
1250
+ id: button.id,
1251
+ type: button.type,
1252
+ disabled: button.disabled,
1253
+ tag: button.tag
1254
+ }
1255
+ });
1256
+ }
1257
+ for (const link of domData.links) {
1258
+ elements.push({
1259
+ category: "link",
1260
+ role: "link",
1261
+ name: link.text,
1262
+ selector: link.selector,
1263
+ selectorAlt: link.testId ? [`getByTestId(${JSON.stringify(link.testId)})`] : [],
1264
+ purpose: link.external ? `External link to "${link.href}" - "${link.text}"` : `Internal navigation link - "${link.text}" -> ${link.href}`,
1265
+ confidence: link.testId ? "high" : "medium",
1266
+ attributes: {
1267
+ text: link.text,
1268
+ href: link.href,
1269
+ testId: link.testId,
1270
+ external: link.external
1271
+ }
1272
+ });
1273
+ }
1274
+ for (const heading of domData.headings) {
1275
+ elements.push({
1276
+ category: "heading",
1277
+ role: "heading",
1278
+ name: heading.text,
1279
+ selector: heading.selector,
1280
+ selectorAlt: [`getByText(${JSON.stringify(heading.text)})`],
1281
+ purpose: `Page ${heading.level} heading - use to assert the correct page or section loaded`,
1282
+ confidence: "medium",
1283
+ attributes: {
1284
+ level: heading.level,
1285
+ text: heading.text
1286
+ }
1287
+ });
1288
+ }
1289
+ for (const status of domData.statusRegions) {
1290
+ elements.push({
1291
+ category: "status",
1292
+ role: status.role,
1293
+ name: status.text || status.role,
1294
+ selector: status.selector,
1295
+ selectorAlt: [],
1296
+ purpose: "Live region - use to assert error messages, success toasts, and validation feedback",
1297
+ confidence: "medium",
1298
+ attributes: {
1299
+ role: status.role,
1300
+ ariaLive: status.ariaLive,
1301
+ currentText: status.text
1302
+ }
1303
+ });
1304
+ }
1305
+ return elements;
1306
+ }
1307
+ function buildWarnings(elements, domData, a11ySnapshot) {
1308
+ const warnings = [];
1309
+ if (!a11ySnapshot) {
1310
+ warnings.push("Playwright accessibility snapshot was unavailable. Capture continued using DOM extraction only.");
1311
+ }
1312
+ const lowConfidenceInputs = elements.filter((element) => element.category === "input" && element.confidence === "low");
1313
+ if (lowConfidenceInputs.length > 0) {
1314
+ warnings.push(
1315
+ `${lowConfidenceInputs.length} input(s) have low-confidence selectors. Consider adding data-testid attributes to: ${lowConfidenceInputs.map((element) => element.name).filter(Boolean).join(", ")}`
1316
+ );
1317
+ }
1318
+ if (domData.statusRegions.length === 0) {
1319
+ warnings.push("No ARIA alert or status regions detected. Error message assertions may need manual selector adjustments after generation.");
1320
+ }
1321
+ for (const form of domData.forms) {
1322
+ const hasSubmit = domData.buttons.some((button) => button.type === "submit");
1323
+ if (form.fieldCount > 0 && !hasSubmit) {
1324
+ warnings.push(
1325
+ `Form ${form.id || `#${form.index}`} has ${form.fieldCount} field(s) but no detected submit button. It may use keyboard submit or a custom handler.`
1326
+ );
1327
+ }
1328
+ }
1329
+ if (domData.links.length >= MAX_PER_CATEGORY) {
1330
+ warnings.push(`Link count hit the ${MAX_PER_CATEGORY} capture limit. Generation will focus on forms and buttons.`);
1331
+ }
1332
+ const interactive = elements.filter((element) => element.category === "button" || element.category === "input" || element.category === "link");
1333
+ if (interactive.length === 0) {
1334
+ warnings.push("No interactive elements detected. The page may require authentication, render later than the current settle window, or be mostly static content.");
1335
+ }
1336
+ return warnings;
1337
+ }
1338
+ function log2(verbose, message) {
1339
+ if (verbose) console.log(message);
1340
+ }
1341
+ async function getAccessibilitySnapshot(page, verbose) {
1342
+ if (!page.accessibility || typeof page.accessibility.snapshot !== "function") {
1343
+ log2(verbose, " -> Accessibility snapshot API unavailable; continuing with DOM-only capture");
1344
+ return null;
1345
+ }
1346
+ try {
1347
+ return await page.accessibility.snapshot({ interestingOnly: false });
1348
+ } catch (error) {
1349
+ log2(verbose, ` -> Accessibility snapshot failed (${error?.message ?? error}); continuing with DOM-only capture`);
1350
+ return null;
1351
+ }
1352
+ }
1353
+ function asString(value) {
1354
+ return typeof value === "string" ? value : void 0;
1355
+ }
1356
+
1357
+ // src/core/report.ts
1358
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
1359
+ import { join as join3 } from "path";
1360
+ function printCaptureReport(elementMap, analysis) {
1361
+ console.log("");
1362
+ console.log("=".repeat(60));
1363
+ console.log("CementicTest Capture Report");
1364
+ console.log("=".repeat(60));
1365
+ console.log(`URL: ${elementMap.url}`);
1366
+ console.log(`Title: ${elementMap.title}`);
1367
+ console.log(`Captured: ${elementMap.timestamp} (${elementMap.mode})`);
1368
+ console.log("");
1369
+ console.log("Elements:");
1370
+ for (const [category, count] of Object.entries(elementMap.summary.byCategory)) {
1371
+ console.log(` ${category}: ${count}`);
1372
+ }
1373
+ if (elementMap.warnings.length > 0) {
1374
+ console.log("");
1375
+ console.log("Warnings:");
1376
+ for (const warning of elementMap.warnings) {
1377
+ console.log(` - ${warning}`);
1378
+ }
1379
+ }
1380
+ if (analysis) {
1381
+ console.log("");
1382
+ console.log(`AI scenarios: ${analysis.scenarios.length}`);
1383
+ console.log(`Feature: ${analysis.feature}`);
1384
+ console.log(`Prefix: ${analysis.suggestedPrefix}`);
1385
+ }
1386
+ console.log("");
1387
+ }
1388
+ function saveCaptureJson(elementMap, analysis, outputDir = ".cementic/capture") {
1389
+ mkdirSync3(outputDir, { recursive: true });
1390
+ const fileName = `capture-${slugify(elementMap.url)}-${Date.now()}.json`;
1391
+ const filePath = join3(outputDir, fileName);
1392
+ writeFileSync3(filePath, JSON.stringify({
1393
+ _meta: {
1394
+ version: "0.2.5",
1395
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1396
+ tool: "@cementic/cementic-test"
1397
+ },
1398
+ elementMap,
1399
+ analysis: analysis ?? null
1400
+ }, null, 2));
1401
+ return filePath;
1402
+ }
1403
+ function buildCasesMarkdown(analysis) {
1404
+ const lines = [];
1405
+ for (const scenario of analysis.scenarios) {
1406
+ lines.push(`# ${scenario.id} \u2014 ${scenario.title} ${scenario.tags.map(normalizeTag2).join(" ")}`.trim());
1407
+ lines.push(`<!-- ct:url ${analysis.url} -->`);
1408
+ lines.push(`<!-- ct:feature ${analysis.feature} -->`);
1409
+ lines.push(`<!-- ct:generated-by capture -->`);
1410
+ lines.push(`<!-- narrator: ${sanitizeComment(scenario.narrator)} -->`);
1411
+ lines.push(`<!-- code-level: ${scenario.codeLevel} -->`);
1412
+ lines.push("");
1413
+ lines.push("## Steps");
1414
+ scenario.steps.forEach((step, index) => {
1415
+ const hint = step.selector && step.selector !== "page" ? ` <!-- selector: ${step.selector} -->` : "";
1416
+ lines.push(`${index + 1}. ${step.human}${hint}`);
1417
+ });
1418
+ lines.push("");
1419
+ lines.push("## Expected Results");
1420
+ scenario.assertions.forEach((assertion) => {
1421
+ const hint = assertion.playwright ? ` <!-- playwright: ${sanitizeComment(assertion.playwright)} -->` : "";
1422
+ lines.push(`- ${assertion.human}${hint}`);
1423
+ });
1424
+ lines.push("");
1425
+ lines.push("---");
1426
+ lines.push("");
1427
+ }
1428
+ return lines.join("\n");
1429
+ }
1430
+ function saveCasesMarkdown(analysis, outputDir = "cases", fileName) {
1431
+ mkdirSync3(outputDir, { recursive: true });
1432
+ const resolvedFileName = fileName ?? `${slugify(analysis.feature || analysis.url)}.md`;
1433
+ const filePath = join3(outputDir, resolvedFileName);
1434
+ writeFileSync3(filePath, buildCasesMarkdown(analysis));
1435
+ return filePath;
1436
+ }
1437
+ function saveSpecPreview(analysis, outputDir = "tests/preview") {
1438
+ if (analysis.scenarios.length === 0) return null;
1439
+ mkdirSync3(outputDir, { recursive: true });
1440
+ const fileName = `spec-preview-${slugify(analysis.url)}-${Date.now()}.spec.cjs`;
1441
+ const filePath = join3(outputDir, fileName);
1442
+ const lines = [];
1443
+ lines.push("/**");
1444
+ lines.push(" * CementicTest Capture Preview");
1445
+ lines.push(` * Generated from: ${analysis.url}`);
1446
+ lines.push(` * Feature: ${analysis.feature}`);
1447
+ lines.push(" */");
1448
+ lines.push("");
1449
+ lines.push(`const { test, expect } = require('@playwright/test');`);
1450
+ lines.push("");
1451
+ for (const scenario of analysis.scenarios) {
1452
+ lines.push(`test(${JSON.stringify(`${scenario.id} \u2014 ${scenario.title}`)}, async ({ page }) => {`);
1453
+ for (const step of scenario.steps) {
1454
+ if (step.action === "navigate") {
1455
+ lines.push(` await page.goto(${JSON.stringify(step.value)});`);
1456
+ continue;
1457
+ }
1458
+ if (step.selector === "page") continue;
1459
+ const selector = `page.${step.selector}`;
1460
+ if (step.action === "fill") lines.push(` await ${selector}.fill(${JSON.stringify(step.value)});`);
1461
+ if (step.action === "click") lines.push(` await ${selector}.click();`);
1462
+ if (step.action === "select") lines.push(` await ${selector}.selectOption(${JSON.stringify(step.value)});`);
1463
+ if (step.action === "check") lines.push(` await ${selector}.check();`);
1464
+ if (step.action === "keyboard") lines.push(` await page.keyboard.press(${JSON.stringify(step.value)});`);
1465
+ if (step.action === "hover") lines.push(` await ${selector}.hover();`);
1466
+ }
1467
+ for (const assertion of scenario.assertions) {
1468
+ lines.push(` ${ensureStatement(assertion.playwright)}`);
1469
+ }
1470
+ lines.push("});");
1471
+ lines.push("");
1472
+ }
1473
+ writeFileSync3(filePath, lines.join("\n"));
1474
+ return filePath;
1475
+ }
1476
+ function slugify(value) {
1477
+ return (value || "capture").replace(/^https?:\/\//, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase().slice(0, 60);
1478
+ }
1479
+ function normalizeTag2(value) {
1480
+ const cleaned = String(value ?? "").trim();
1481
+ if (!cleaned) return "@ui";
1482
+ return cleaned.startsWith("@") ? cleaned : `@${cleaned}`;
1483
+ }
1484
+ function sanitizeComment(value) {
1485
+ return String(value ?? "").replace(/-->/g, "-- >");
1486
+ }
1487
+ function ensureStatement(value) {
1488
+ const trimmed = String(value ?? "").trim();
1489
+ if (!trimmed) return "// TODO: add assertion";
1490
+ return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
1491
+ }
511
1492
 
512
1493
  // src/commands/tc.ts
513
- function slugify(text) {
1494
+ function slugify2(text) {
514
1495
  return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "tc";
515
1496
  }
1497
+ function injectUrlMetadata(markdown, url) {
1498
+ if (!url || /<!--\s*ct:url\s+/i.test(markdown)) return markdown;
1499
+ const lines = markdown.split("\n");
1500
+ const output2 = [];
1501
+ for (let i = 0; i < lines.length; i++) {
1502
+ output2.push(lines[i]);
1503
+ if (/^\s*#\s+/.test(lines[i])) {
1504
+ output2.push(`<!-- ct:url ${url} -->`);
1505
+ }
1506
+ }
1507
+ return output2.join("\n");
1508
+ }
516
1509
  function buildManualCasesMarkdown(opts) {
517
1510
  const { prefix, feature, url, numCases } = opts;
518
1511
  const startIndex = opts.startIndex ?? 1;
@@ -520,9 +1513,8 @@ function buildManualCasesMarkdown(opts) {
520
1513
  for (let i = 0; i < numCases; i++) {
521
1514
  const idx = startIndex + i;
522
1515
  const id = `${prefix}-${String(idx).padStart(3, "0")}`;
523
- const title = `${feature} - scenario ${idx}`;
524
- const tags = "@regression @ui";
525
- lines.push(`# ${id} \u2014 ${title} ${tags}`);
1516
+ lines.push(`# ${id} \u2014 ${feature} - scenario ${idx} @regression @ui`);
1517
+ if (url) lines.push(`<!-- ct:url ${url} -->`);
526
1518
  lines.push(`## Steps`);
527
1519
  lines.push(`1. Navigate to ${url ?? "<PAGE_URL>"}`);
528
1520
  lines.push(`2. Perform the main user action for this scenario`);
@@ -538,12 +1530,10 @@ function buildManualCasesMarkdown(opts) {
538
1530
  }
539
1531
  async function promptBasicQuestions(opts) {
540
1532
  if (opts.feature) {
541
- let n = opts.numCases ?? 3;
542
- if (n < 1) n = 3;
543
- if (n > 10) n = 10;
1533
+ const n = Math.min(Math.max(opts.numCases ?? 3, 1), 10);
544
1534
  return {
545
1535
  feature: opts.feature,
546
- appDescription: opts.appDescription || "",
1536
+ appDescription: opts.appDescription ?? "",
547
1537
  numCases: n,
548
1538
  url: opts.url
549
1539
  };
@@ -551,16 +1541,13 @@ async function promptBasicQuestions(opts) {
551
1541
  const rl = createInterface({ input, output });
552
1542
  const feature = (await rl.question("\u{1F9E9} Feature or page to test: ")).trim();
553
1543
  const appDescription = (await rl.question("\u{1F4DD} Short app description (optional): ")).trim();
554
- const numCasesRaw = (await rl.question("\u{1F522} How many test cases? (1-10) [3]: ")).trim();
1544
+ const numCasesRaw = (await rl.question("\u{1F522} How many test cases? (1\u201310) [3]: ")).trim();
555
1545
  rl.close();
556
1546
  let numCases = parseInt(numCasesRaw, 10);
557
1547
  if (isNaN(numCases) || numCases < 1) numCases = 3;
558
1548
  if (numCases > 10) numCases = 10;
559
1549
  return { feature, appDescription, numCases, url: opts.url };
560
1550
  }
561
- function hasAiFlagInArgv() {
562
- return process.argv.includes("--ai");
563
- }
564
1551
  async function runTcInteractive(params) {
565
1552
  const { feature, appDescription, numCases, url } = await promptBasicQuestions({
566
1553
  url: params.url,
@@ -573,87 +1560,130 @@ async function runTcInteractive(params) {
573
1560
  url,
574
1561
  explicitPrefix: params.explicitPrefix
575
1562
  });
576
- mkdirSync3("cases", { recursive: true });
577
- const fileName = `${prefix.toLowerCase()}-${slugify(feature)}.md`;
578
- const fullPath = join3("cases", fileName);
579
- const useAi = hasAiFlagInArgv();
580
- console.log(`\u2699\uFE0F Debug: useAi=${useAi}, argv=${JSON.stringify(process.argv)}`);
1563
+ mkdirSync4("cases", { recursive: true });
1564
+ const fileName = `${prefix.toLowerCase()}-${slugify2(feature)}.md`;
1565
+ const fullPath = join4("cases", fileName);
581
1566
  let markdown;
582
- if (useAi) {
583
- let pageSummaryJson = void 0;
1567
+ if (params.useAi) {
1568
+ let pageSummary = void 0;
584
1569
  if (url) {
585
1570
  try {
586
- console.log(`\u{1F50D} Scraping page for AI context: ${url}`);
587
- pageSummaryJson = await scrapePageSummary(url);
588
- console.log(
589
- `\u{1F50E} Scrape summary: title="${pageSummaryJson.title || ""}", headings=${pageSummaryJson.headings.length}, buttons=${pageSummaryJson.buttons.length}, inputs=${pageSummaryJson.inputs.length}`
590
- );
1571
+ console.log(`\u{1F50D} Capturing page: ${url}`);
1572
+ const elementMap = await captureElements(url, {
1573
+ headless: !(params.headed ?? false),
1574
+ verbose: true
1575
+ });
1576
+ printCaptureReport(elementMap);
1577
+ const jsonPath = saveCaptureJson(elementMap);
1578
+ console.log(`\u{1F4C4} Saved capture JSON \u2192 ${jsonPath}`);
1579
+ if (params.captureOnly) {
1580
+ console.log("Capture-only mode requested. No test cases were generated.");
1581
+ return;
1582
+ }
1583
+ const analysis = await analyseElements(elementMap, { verbose: true });
1584
+ printCaptureReport(elementMap, analysis);
1585
+ const previewPath = saveSpecPreview(analysis);
1586
+ if (previewPath) {
1587
+ console.log(`\u{1F9EA} Saved preview spec \u2192 ${previewPath}`);
1588
+ }
1589
+ const captureFileName = `${prefix.toLowerCase()}-${slugify2(feature)}.md`;
1590
+ const generatedPath = saveCasesMarkdown(analysis, "cases", captureFileName);
1591
+ console.log(`\u2705 Capture-based AI generated test cases \u2192 ${generatedPath}`);
1592
+ console.log("\nNext steps:");
1593
+ console.log(` ct normalize ${generatedPath} --and-gen --lang ts`);
1594
+ console.log(" ct test");
1595
+ return;
591
1596
  } catch (e) {
592
- console.warn(
593
- `\u26A0\uFE0F Failed to scrape ${url} (${e?.message || e}). Continuing without page summary.`
594
- );
1597
+ console.warn(`\u26A0\uFE0F Capture flow failed (${e?.message ?? e}). Falling back to the legacy AI case writer.`);
1598
+ if (params.captureOnly) {
1599
+ return;
1600
+ }
595
1601
  }
596
1602
  }
597
1603
  try {
598
- console.log("\u{1F916} AI: generating test cases...");
1604
+ if (pageSummary === void 0 && url) {
1605
+ const elementMap = await captureElements(url, {
1606
+ headless: true,
1607
+ verbose: false
1608
+ });
1609
+ pageSummary = toPageSummary(elementMap);
1610
+ }
599
1611
  markdown = await generateTcMarkdownWithAi({
600
1612
  appDescription: appDescription || void 0,
601
1613
  feature,
602
1614
  url,
603
- pageSummaryJson,
1615
+ pageSummary,
604
1616
  prefix,
605
1617
  startIndex: 1,
606
1618
  numCases
607
1619
  });
608
- console.log("\u2705 AI: generated test case markdown.");
1620
+ markdown = injectUrlMetadata(markdown, url);
1621
+ console.log("\u2705 AI generated test cases.");
609
1622
  } catch (err) {
610
- console.warn(
611
- `\u26A0\uFE0F AI generation failed (${err?.message || err}). Falling back to manual templates.`
612
- );
613
- markdown = buildManualCasesMarkdown({
614
- prefix,
615
- feature,
616
- url,
617
- numCases,
618
- startIndex: 1
619
- });
620
- console.log("\u{1F4DD} Manual: generated test case templates instead.");
1623
+ console.warn(`\u26A0\uFE0F AI generation failed (${err?.message ?? err}). Falling back to manual templates.`);
1624
+ markdown = buildManualCasesMarkdown({ prefix, feature, url, numCases, startIndex: 1 });
1625
+ console.log("\u{1F4DD} Generated manual test case templates instead.");
621
1626
  }
622
1627
  } else {
623
- markdown = buildManualCasesMarkdown({
624
- prefix,
625
- feature,
626
- url,
627
- numCases,
628
- startIndex: 1
629
- });
630
- console.log("\u{1F4DD} Manual: generated test case templates (no --ai).");
1628
+ markdown = buildManualCasesMarkdown({ prefix, feature, url, numCases, startIndex: 1 });
1629
+ console.log("\u{1F4DD} Generated manual test case templates (pass --ai to use AI).");
631
1630
  }
632
- writeFileSync3(fullPath, markdown);
633
- console.log(`\u270D\uFE0F Wrote ${numCases} test case(s) \u2192 ${fullPath}`);
634
- console.log("Next steps:");
1631
+ writeFileSync4(fullPath, markdown);
1632
+ console.log(`
1633
+ \u270D\uFE0F Wrote ${numCases} case(s) \u2192 ${fullPath}`);
1634
+ console.log("\nNext steps:");
635
1635
  console.log(" ct normalize ./cases --and-gen --lang ts");
636
1636
  console.log(" ct test");
637
1637
  }
1638
+ function addSharedOptions(cmd) {
1639
+ return cmd.option("--ai", "Use AI to generate test cases (requires API key)").option("--prefix <prefix>", "Explicit ID prefix, e.g. AUTH, DASH, CART").option("--feature <name>", "Feature name \u2014 skips interactive prompt").option("--desc <text>", "App description \u2014 skips interactive prompt").option("--count <n>", "Number of cases (1\u201310)", parseInt);
1640
+ }
1641
+ function resolveCommanderOpts(opts) {
1642
+ if (typeof opts.optsWithGlobals === "function") {
1643
+ return opts.optsWithGlobals();
1644
+ }
1645
+ if (typeof opts.opts === "function") {
1646
+ return opts.opts();
1647
+ }
1648
+ return opts;
1649
+ }
638
1650
  function tcCmd() {
639
- const root = new Command4("tc").description("Create CT-style test cases (Markdown) under ./cases").option("--ai", "Use AI if configured (BYO LLM API key).", false).option("--prefix <prefix>", "Explicit ID prefix, e.g. AUTH, DASH, CART").option("--feature <name>", "Feature name (non-interactive)").option("--desc <text>", "App description (non-interactive)").option("--count <n>", "Number of cases (non-interactive)", parseInt).action(async (opts) => {
640
- await runTcInteractive({
641
- url: void 0,
642
- explicitPrefix: opts.prefix,
643
- feature: opts.feature,
644
- appDescription: opts.desc,
645
- numCases: opts.count
646
- });
647
- });
648
- root.command("url").argument("<url>", "Page URL to use as context").option("--ai", "Use AI if configured (BYO LLM API key).", false).option("--prefix <prefix>", "Explicit ID prefix, e.g. AUTH, DASH, CART").option("--feature <name>", "Feature name (non-interactive)").option("--desc <text>", "App description (non-interactive)").option("--count <n>", "Number of cases (non-interactive)", parseInt).description("Create test cases with awareness of a specific page URL").action(async (url, opts) => {
649
- await runTcInteractive({
650
- url,
651
- explicitPrefix: opts.prefix,
652
- feature: opts.feature,
653
- appDescription: opts.desc,
654
- numCases: opts.count
655
- });
656
- });
1651
+ const root = new Command4("tc").description("Create CementicTest case files (Markdown) under ./cases").addHelpText("after", `
1652
+ Examples:
1653
+ $ ct tc --feature "Login form" --ai
1654
+ $ ct tc --feature "Checkout" --count 5 --prefix CHK
1655
+ $ ct tc url https://example.com/login --ai --feature "Auth"
1656
+ `);
1657
+ addSharedOptions(root).action(
1658
+ async (opts) => {
1659
+ const resolvedOpts = resolveCommanderOpts(opts);
1660
+ await runTcInteractive({
1661
+ url: void 0,
1662
+ explicitPrefix: resolvedOpts.prefix,
1663
+ feature: resolvedOpts.feature,
1664
+ appDescription: resolvedOpts.desc,
1665
+ numCases: resolvedOpts.count,
1666
+ useAi: resolvedOpts.ai ?? false
1667
+ });
1668
+ }
1669
+ );
1670
+ addSharedOptions(
1671
+ root.command("url").argument("<url>", "Page URL to use as live capture and AI context").description("Generate test cases with capture-aware context from a specific live page").option("--headed", "Show the browser while capturing the page").option("--capture-only", "Run live page capture and save artifacts without generating cases")
1672
+ ).action(
1673
+ async (url, opts, command) => {
1674
+ const resolvedOpts = resolveCommanderOpts(command ?? opts);
1675
+ await runTcInteractive({
1676
+ url,
1677
+ explicitPrefix: resolvedOpts.prefix,
1678
+ feature: resolvedOpts.feature,
1679
+ appDescription: resolvedOpts.desc,
1680
+ numCases: resolvedOpts.count,
1681
+ useAi: resolvedOpts.ai ?? false,
1682
+ headed: resolvedOpts.headed ?? false,
1683
+ captureOnly: resolvedOpts.captureOnly ?? false
1684
+ });
1685
+ }
1686
+ );
657
1687
  return root;
658
1688
  }
659
1689
 
@@ -682,11 +1712,11 @@ function reportCmd() {
682
1712
  import { Command as Command6 } from "commander";
683
1713
  import { spawn as spawn3 } from "child_process";
684
1714
  import { existsSync as existsSync2 } from "fs";
685
- import { join as join4 } from "path";
1715
+ import { join as join5 } from "path";
686
1716
  function serveCmd() {
687
1717
  const cmd = new Command6("serve").description("Serve the Allure report").action(() => {
688
1718
  console.log("\u{1F4CA} Serving Allure report...");
689
- const localAllureBin = join4(process.cwd(), "node_modules", "allure-commandline", "bin", "allure");
1719
+ const localAllureBin = join5(process.cwd(), "node_modules", "allure-commandline", "bin", "allure");
690
1720
  let executable = "npx";
691
1721
  let args = ["allure", "serve", "./allure-results"];
692
1722
  if (existsSync2(localAllureBin)) {
@@ -754,8 +1784,8 @@ function flowCmd() {
754
1784
 
755
1785
  // src/commands/ci.ts
756
1786
  import { Command as Command8 } from "commander";
757
- import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, existsSync as existsSync3 } from "fs";
758
- import { join as join5 } from "path";
1787
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5, existsSync as existsSync3 } from "fs";
1788
+ import { join as join6 } from "path";
759
1789
  var WORKFLOW_CONTENT = `name: Playwright Tests
760
1790
  on:
761
1791
  push:
@@ -786,19 +1816,19 @@ jobs:
786
1816
  `;
787
1817
  function ciCmd() {
788
1818
  const cmd = new Command8("ci").description("Generate GitHub Actions workflow for CI").action(() => {
789
- const githubDir = join5(process.cwd(), ".github");
790
- const workflowsDir = join5(githubDir, "workflows");
791
- const workflowFile = join5(workflowsDir, "cementic.yml");
1819
+ const githubDir = join6(process.cwd(), ".github");
1820
+ const workflowsDir = join6(githubDir, "workflows");
1821
+ const workflowFile = join6(workflowsDir, "cementic.yml");
792
1822
  console.log("\u{1F916} Setting up CI/CD workflow...");
793
1823
  if (!existsSync3(workflowsDir)) {
794
- mkdirSync4(workflowsDir, { recursive: true });
1824
+ mkdirSync5(workflowsDir, { recursive: true });
795
1825
  console.log(`Created directory: ${workflowsDir}`);
796
1826
  }
797
1827
  if (existsSync3(workflowFile)) {
798
1828
  console.warn(`\u26A0\uFE0F Workflow file already exists at ${workflowFile}. Skipping.`);
799
1829
  return;
800
1830
  }
801
- writeFileSync4(workflowFile, WORKFLOW_CONTENT.trim() + "\n");
1831
+ writeFileSync5(workflowFile, WORKFLOW_CONTENT.trim() + "\n");
802
1832
  console.log(`\u2705 CI workflow generated at: ${workflowFile}`);
803
1833
  console.log("Next steps:");
804
1834
  console.log("1. Commit and push the new file");
@@ -808,8 +1838,10 @@ function ciCmd() {
808
1838
  }
809
1839
 
810
1840
  // src/cli.ts
1841
+ var require2 = createRequire(import.meta.url);
1842
+ var { version } = require2("../package.json");
811
1843
  var program = new Command9();
812
- program.name("cementic-test").description("CementicTest CLI: cases \u2192 normalized \u2192 POM tests \u2192 Playwright").version("0.2.0");
1844
+ program.name("ct").description("CementicTest CLI: cases \u2192 normalized \u2192 POM tests \u2192 Playwright").version(version);
813
1845
  program.addCommand(newCmd());
814
1846
  program.addCommand(normalizeCmd());
815
1847
  program.addCommand(genCmd());