@cementic/cementic-test 0.2.3 → 0.2.4

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 +273 -458
  2. package/dist/chunk-3EE7LWWT.js +353 -0
  3. package/dist/chunk-3EE7LWWT.js.map +1 -0
  4. package/dist/cli.js +337 -164
  5. package/dist/cli.js.map +1 -1
  6. package/dist/{gen-54KYT3RO.js → gen-IO4KKGYY.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 +2 -1
  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-IO4KKGYY.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-3EE7LWWT.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 = [];
@@ -190,18 +198,39 @@ function extractSections(body) {
190
198
  }
191
199
  return { steps, expected: expectedLines };
192
200
  }
201
+ function extractUrlMetadata(body) {
202
+ const match = body.match(/<!--\s*ct:url\s+(https?:\/\/[^\s>]+)\s*-->/i);
203
+ return match?.[1];
204
+ }
205
+ function stripCtMetadata(body) {
206
+ return body.replace(/^\s*<!--\s*ct:url\s+https?:\/\/[^\s>]+\s*-->\s*\n?/im, "");
207
+ }
208
+ function extractUrlFromSteps(steps) {
209
+ for (const step of steps) {
210
+ const match = step.match(/https?:\/\/[^\s'"]+/i);
211
+ if (match) return match[0];
212
+ }
213
+ return void 0;
214
+ }
193
215
  function normalizeOne(titleLine, body, source) {
194
216
  const { clean, tags } = parseTags(titleLine);
195
217
  const id = parseId(clean);
196
- const { steps, expected } = extractSections(body);
218
+ const metadataUrl = extractUrlMetadata(body);
219
+ const cleanBody = stripCtMetadata(body);
220
+ const { steps, expected } = extractSections(cleanBody);
221
+ const reviewReasons = [];
222
+ if (steps.length === 0) reviewReasons.push("No steps section or bullets were parsed");
223
+ if (expected.length === 0) reviewReasons.push("No expected results section or assertions were parsed");
197
224
  return {
198
225
  id,
199
- title: clean,
226
+ title: stripIdPrefixFromTitle(clean, id),
200
227
  tags: tags.length ? tags : void 0,
201
228
  steps,
202
229
  expected,
203
- needs_review: steps.length === 0 || expected.length === 0,
204
- source
230
+ needs_review: reviewReasons.length > 0,
231
+ review_reasons: reviewReasons,
232
+ source,
233
+ url: metadataUrl ?? extractUrlFromSteps(steps)
205
234
  };
206
235
  }
207
236
  function normalizeCmd() {
@@ -265,7 +294,7 @@ Examples:
265
294
  }
266
295
  console.log(`\u2705 Normalized ${index.summary.parsed} case(s). Output \u2192 .cementic/normalized/`);
267
296
  if (opts.andGen) {
268
- const { gen } = await import("./gen-54KYT3RO.js");
297
+ const { gen } = await import("./gen-IO4KKGYY.js");
269
298
  await gen({ lang: opts.lang || "ts", out: "tests/generated" });
270
299
  }
271
300
  });
@@ -353,88 +382,136 @@ function inferPrefix(params) {
353
382
  // src/core/llm.ts
354
383
  function buildSystemMessage() {
355
384
  return `
356
- You are a senior QA engineer and test case designer.
385
+ You are a senior QA engineer and test automation specialist.
357
386
 
358
387
  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.
388
+ - Receive context about a web feature or page
389
+ - Generate high-quality, realistic UI test cases
390
+ - Format them in strict Markdown that a downstream parser will process
362
391
 
363
392
  You MUST follow this format exactly for each test case:
364
393
 
365
- # <ID> \u2014 <Short title> @<tag1> @<tag2> ...
394
+ # <ID> \u2014 <Short title> @<tag1> @<tag2>
366
395
  ## Steps
367
- 1. <step>
368
- 2. <step>
396
+ 1. <user action>
397
+ 2. <user action>
369
398
 
370
399
  ## Expected Results
371
- - <assertion>
372
- - <assertion>
400
+ - <verifiable outcome>
401
+ - <verifiable outcome>
373
402
 
374
403
  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.
404
+ - ID must be PREFIX-NNN where NNN is zero-padded 3 digits starting from startIndex
405
+ - Use 1\u20133 tags: @smoke @regression @auth @ui @critical @happy-path @negative
406
+ - Steps = concrete user actions ("Click the 'Sign In' button", "Fill in email with 'user@example.com'")
407
+ - Expected Results = verifiable UI outcomes ("Error message 'Invalid credentials' is visible", "Page redirects to /dashboard")
408
+ - Include both happy-path AND negative/edge-case tests
409
+ - Do NOT explain or add preamble \u2014 output ONLY the test cases
410
+ - No code blocks, no extra headings outside the pattern
384
411
  `.trim();
385
412
  }
386
413
  function buildUserMessage(ctx) {
387
414
  const lines = [];
388
- lines.push(`App / Product description (optional):`);
415
+ lines.push(`App / product description:`);
389
416
  lines.push(ctx.appDescription || "N/A");
390
417
  lines.push("");
391
418
  lines.push(`Feature or page to test:`);
392
419
  lines.push(ctx.feature);
393
420
  lines.push("");
394
421
  if (ctx.url) {
395
- lines.push(`Page URL:`);
396
- lines.push(ctx.url);
422
+ lines.push(`Page URL: ${ctx.url}`);
397
423
  lines.push("");
398
424
  }
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("```");
425
+ if (ctx.pageSummary) {
426
+ const s = ctx.pageSummary;
427
+ lines.push(`Live page analysis:`);
428
+ if (s.title) lines.push(`- Page title: ${s.title}`);
429
+ if (s.headings.length) {
430
+ lines.push(`- Headings: ${s.headings.join(" | ")}`);
431
+ }
432
+ if (s.inputs.length) {
433
+ const inputDescs = s.inputs.map((i) => {
434
+ const parts = [
435
+ i.label && `label="${i.label}"`,
436
+ i.placeholder && `placeholder="${i.placeholder}"`,
437
+ i.name && `name="${i.name}"`,
438
+ i.type && i.type !== "text" && `type="${i.type}"`,
439
+ i.testId && `data-testid="${i.testId}"`
440
+ ].filter(Boolean);
441
+ return parts.join(", ");
442
+ });
443
+ lines.push(`- Form inputs: ${inputDescs.join(" | ")}`);
444
+ }
445
+ if (s.buttons.length) {
446
+ lines.push(`- Buttons: ${s.buttons.join(" | ")}`);
447
+ }
448
+ if (s.links.length) {
449
+ lines.push(`- Links: ${s.links.slice(0, 20).join(" | ")}`);
450
+ }
404
451
  lines.push("");
405
452
  }
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}`);
411
- 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(`- ...`);
453
+ lines.push(`Test ID prefix: ${ctx.prefix}`);
454
+ lines.push(`Start numbering from: ${String(ctx.startIndex).padStart(3, "0")}`);
455
+ lines.push(`Number of test cases: ${ctx.numCases}`);
423
456
  lines.push("");
424
- lines.push(`Do NOT add any explanation before or after the test cases. Only output the test cases.`);
457
+ lines.push(`Include a mix of: happy-path flows, validation/error cases, and edge cases.`);
458
+ lines.push(`Output ONLY the test cases. No explanation, no preamble.`);
425
459
  return lines.join("\n");
426
460
  }
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
- );
461
+ function detectProvider() {
462
+ const anthropicKey = process.env.ANTHROPIC_API_KEY ?? process.env.CT_ANTHROPIC_API_KEY ?? "";
463
+ const openaiKey = process.env.CT_LLM_API_KEY ?? process.env.OPENAI_API_KEY ?? "";
464
+ const explicitProvider = (process.env.CT_LLM_PROVIDER ?? "").toLowerCase();
465
+ if (explicitProvider === "anthropic" || anthropicKey && explicitProvider !== "openai") {
466
+ if (!anthropicKey) {
467
+ throw new Error(
468
+ "CT_LLM_PROVIDER=anthropic but no ANTHROPIC_API_KEY found.\nSet ANTHROPIC_API_KEY=your-key or switch to OPENAI_API_KEY."
469
+ );
470
+ }
471
+ return {
472
+ provider: "anthropic",
473
+ apiKey: anthropicKey,
474
+ model: process.env.CT_LLM_MODEL ?? "claude-sonnet-4-5",
475
+ baseUrl: "https://api.anthropic.com"
476
+ };
433
477
  }
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);
478
+ if (openaiKey) {
479
+ return {
480
+ provider: "openai",
481
+ apiKey: openaiKey,
482
+ model: process.env.CT_LLM_MODEL ?? "gpt-4o-mini",
483
+ baseUrl: process.env.CT_LLM_BASE_URL ?? "https://api.openai.com/v1"
484
+ };
485
+ }
486
+ throw new Error(
487
+ "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"
488
+ );
489
+ }
490
+ async function callAnthropic(apiKey, model, system, user) {
491
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
492
+ method: "POST",
493
+ headers: {
494
+ "x-api-key": apiKey,
495
+ "anthropic-version": "2023-06-01",
496
+ "content-type": "application/json"
497
+ },
498
+ body: JSON.stringify({
499
+ model,
500
+ max_tokens: 4096,
501
+ system,
502
+ messages: [{ role: "user", content: user }]
503
+ })
504
+ });
505
+ if (!response.ok) {
506
+ const text = await response.text();
507
+ throw new Error(`Anthropic API error ${response.status}: ${text}`);
508
+ }
509
+ const json = await response.json();
510
+ const content = json.content?.[0]?.text?.trim() ?? "";
511
+ if (!content) throw new Error("Anthropic response had no content");
512
+ return content;
513
+ }
514
+ async function callOpenAi(apiKey, model, baseUrl, system, user) {
438
515
  const response = await fetch(`${baseUrl}/chat/completions`, {
439
516
  method: "POST",
440
517
  headers: {
@@ -443,68 +520,145 @@ async function generateTcMarkdownWithAi(ctx) {
443
520
  },
444
521
  body: JSON.stringify({
445
522
  model,
523
+ temperature: 0.2,
446
524
  messages: [
447
525
  { role: "system", content: system },
448
526
  { role: "user", content: user }
449
- ],
450
- temperature: 0.2
527
+ ]
451
528
  })
452
529
  });
453
530
  if (!response.ok) {
454
531
  const text = await response.text();
455
- throw new Error(
456
- `LLM request failed: ${response.status} ${response.statusText} \u2014 ${text}`
457
- );
532
+ throw new Error(`OpenAI API error ${response.status}: ${text}`);
458
533
  }
459
534
  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");
463
- }
535
+ const content = json.choices?.[0]?.message?.content?.trim() ?? "";
536
+ if (!content) throw new Error("OpenAI response had no content");
464
537
  return content;
465
538
  }
539
+ async function generateTcMarkdownWithAi(ctx) {
540
+ const { provider, apiKey, model, baseUrl } = detectProvider();
541
+ const system = buildSystemMessage();
542
+ const user = buildUserMessage(ctx);
543
+ console.log(`\u{1F916} Using ${provider === "anthropic" ? "Claude" : "OpenAI-compatible"} (${model})`);
544
+ if (provider === "anthropic") {
545
+ return callAnthropic(apiKey, model, system, user);
546
+ }
547
+ return callOpenAi(apiKey, model, baseUrl, system, user);
548
+ }
466
549
 
467
550
  // src/core/scrape.ts
468
551
  async function scrapePageSummary(url) {
469
- const res = await fetch(url);
552
+ try {
553
+ return await scrapeWithPlaywright(url);
554
+ } catch (pwErr) {
555
+ console.warn(`\u26A0\uFE0F Playwright scrape failed (${pwErr?.message ?? pwErr}). Falling back to static fetch.`);
556
+ return scrapeWithFetch(url);
557
+ }
558
+ }
559
+ async function scrapeWithPlaywright(url) {
560
+ let chromium;
561
+ try {
562
+ ({ chromium } = await import("playwright-core"));
563
+ } catch {
564
+ ({ chromium } = await import("@playwright/test"));
565
+ }
566
+ const browser = await chromium.launch({ headless: true });
567
+ const context = await browser.newContext({
568
+ userAgent: "Mozilla/5.0 (compatible; CementicTest-scraper/1.0)"
569
+ });
570
+ const page = await context.newPage();
571
+ try {
572
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 2e4 });
573
+ await page.waitForTimeout(800);
574
+ const summary = await page.evaluate(() => {
575
+ const text = (el) => el?.textContent?.replace(/\s+/g, " ").trim() ?? "";
576
+ const attr = (el, a) => el?.getAttribute(a)?.trim() ?? "";
577
+ const title = document.title?.trim() || void 0;
578
+ const headings = Array.from(document.querySelectorAll("h1, h2")).map((h) => text(h)).filter(Boolean).slice(0, 20);
579
+ const buttons = Array.from(
580
+ document.querySelectorAll('button, [role="button"]')
581
+ ).map((b) => attr(b, "aria-label") || text(b)).filter(Boolean).slice(0, 30);
582
+ const links = Array.from(document.querySelectorAll("a[href]")).map((a) => attr(a, "aria-label") || text(a)).filter(Boolean).slice(0, 50);
583
+ const inputs = [];
584
+ document.querySelectorAll("input, textarea, select").forEach((el) => {
585
+ const id = attr(el, "id");
586
+ const name = attr(el, "name") || void 0;
587
+ const ph = attr(el, "placeholder") || void 0;
588
+ const type = attr(el, "type") || void 0;
589
+ const testId = attr(el, "data-testid") || void 0;
590
+ let labelText;
591
+ if (id) {
592
+ const labelEl = document.querySelector(`label[for="${id}"]`);
593
+ if (labelEl) labelText = text(labelEl) || void 0;
594
+ }
595
+ if (!labelText) {
596
+ const closest = el.closest("label");
597
+ if (closest) labelText = text(closest).replace(ph ?? "", "").trim() || void 0;
598
+ }
599
+ if (labelText || ph || name || testId) {
600
+ inputs.push({ label: labelText, placeholder: ph, name, type, testId });
601
+ }
602
+ });
603
+ const landmarks = Array.from(
604
+ document.querySelectorAll('[role="navigation"], [role="main"], [role="form"], nav, main, form')
605
+ ).map((el) => attr(el, "aria-label") || el.tagName.toLowerCase()).filter(Boolean);
606
+ const rawLength = document.documentElement.outerHTML.length;
607
+ return {
608
+ title,
609
+ headings,
610
+ buttons,
611
+ links,
612
+ inputs: inputs.slice(0, 30),
613
+ landmarks,
614
+ rawLength
615
+ };
616
+ });
617
+ return { url, ...summary };
618
+ } finally {
619
+ await browser.close();
620
+ }
621
+ }
622
+ async function scrapeWithFetch(url) {
623
+ const res = await fetch(url, {
624
+ headers: { "User-Agent": "CementicTest-scraper/1.0" }
625
+ });
470
626
  if (!res.ok) {
471
- throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
627
+ throw new Error(`HTTP ${res.status} ${res.statusText} \u2014 ${url}`);
472
628
  }
473
629
  const html = await res.text();
474
630
  const rawLength = html.length;
475
631
  const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i);
476
632
  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);
633
+ const headings = Array.from(html.matchAll(/<(h1|h2)[^>]*>([^<]*)<\/\1>/gi)).map((m) => m[2].replace(/\s+/g, " ").trim()).filter(Boolean).slice(0, 20);
634
+ const buttons = Array.from(html.matchAll(/<button[^>]*>([^<]*)<\/button>/gi)).map((m) => m[1].replace(/\s+/g, " ").trim()).filter(Boolean).slice(0, 30);
479
635
  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
636
  const labelMap = /* @__PURE__ */ new Map();
482
637
  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();
638
+ const id = m[1], text = m[2].replace(/\s+/g, " ").trim();
485
639
  if (id && text) labelMap.set(id, text);
486
640
  }
641
+ const inputs = [];
487
642
  for (const m of html.matchAll(/<input([^>]*)>/gi)) {
488
643
  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];
644
+ const name = attrs.match(/\bname=["']?([^"'>\s]+)["']?/i)?.[1];
645
+ const id = attrs.match(/\bid=["']?([^"'>\s]+)["']?/i)?.[1];
646
+ const ph = attrs.match(/\bplaceholder=["']([^"']*)["']/i)?.[1]?.trim();
647
+ const type = attrs.match(/\btype=["']?([^"'>\s]+)["']?/i)?.[1];
648
+ const testId = attrs.match(/\bdata-testid=["']([^"']*)["']/i)?.[1]?.trim();
493
649
  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);
650
+ if (label || ph || name || testId) {
651
+ inputs.push({ label, placeholder: ph, name, type, testId });
499
652
  }
500
653
  }
501
654
  return {
502
655
  url,
503
656
  title,
504
- headings: headings.slice(0, 20),
505
- buttons: buttons.slice(0, 30),
657
+ headings,
658
+ buttons,
506
659
  links,
507
660
  inputs: inputs.slice(0, 30),
661
+ landmarks: [],
508
662
  rawLength
509
663
  };
510
664
  }
@@ -513,6 +667,18 @@ async function scrapePageSummary(url) {
513
667
  function slugify(text) {
514
668
  return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "tc";
515
669
  }
670
+ function injectUrlMetadata(markdown, url) {
671
+ if (!url || /<!--\s*ct:url\s+/i.test(markdown)) return markdown;
672
+ const lines = markdown.split("\n");
673
+ const output2 = [];
674
+ for (let i = 0; i < lines.length; i++) {
675
+ output2.push(lines[i]);
676
+ if (/^\s*#\s+/.test(lines[i])) {
677
+ output2.push(`<!-- ct:url ${url} -->`);
678
+ }
679
+ }
680
+ return output2.join("\n");
681
+ }
516
682
  function buildManualCasesMarkdown(opts) {
517
683
  const { prefix, feature, url, numCases } = opts;
518
684
  const startIndex = opts.startIndex ?? 1;
@@ -520,9 +686,8 @@ function buildManualCasesMarkdown(opts) {
520
686
  for (let i = 0; i < numCases; i++) {
521
687
  const idx = startIndex + i;
522
688
  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}`);
689
+ lines.push(`# ${id} \u2014 ${feature} - scenario ${idx} @regression @ui`);
690
+ if (url) lines.push(`<!-- ct:url ${url} -->`);
526
691
  lines.push(`## Steps`);
527
692
  lines.push(`1. Navigate to ${url ?? "<PAGE_URL>"}`);
528
693
  lines.push(`2. Perform the main user action for this scenario`);
@@ -538,12 +703,10 @@ function buildManualCasesMarkdown(opts) {
538
703
  }
539
704
  async function promptBasicQuestions(opts) {
540
705
  if (opts.feature) {
541
- let n = opts.numCases ?? 3;
542
- if (n < 1) n = 3;
543
- if (n > 10) n = 10;
706
+ const n = Math.min(Math.max(opts.numCases ?? 3, 1), 10);
544
707
  return {
545
708
  feature: opts.feature,
546
- appDescription: opts.appDescription || "",
709
+ appDescription: opts.appDescription ?? "",
547
710
  numCases: n,
548
711
  url: opts.url
549
712
  };
@@ -551,16 +714,13 @@ async function promptBasicQuestions(opts) {
551
714
  const rl = createInterface({ input, output });
552
715
  const feature = (await rl.question("\u{1F9E9} Feature or page to test: ")).trim();
553
716
  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();
717
+ const numCasesRaw = (await rl.question("\u{1F522} How many test cases? (1\u201310) [3]: ")).trim();
555
718
  rl.close();
556
719
  let numCases = parseInt(numCasesRaw, 10);
557
720
  if (isNaN(numCases) || numCases < 1) numCases = 3;
558
721
  if (numCases > 10) numCases = 10;
559
722
  return { feature, appDescription, numCases, url: opts.url };
560
723
  }
561
- function hasAiFlagInArgv() {
562
- return process.argv.includes("--ai");
563
- }
564
724
  async function runTcInteractive(params) {
565
725
  const { feature, appDescription, numCases, url } = await promptBasicQuestions({
566
726
  url: params.url,
@@ -576,84 +736,95 @@ async function runTcInteractive(params) {
576
736
  mkdirSync3("cases", { recursive: true });
577
737
  const fileName = `${prefix.toLowerCase()}-${slugify(feature)}.md`;
578
738
  const fullPath = join3("cases", fileName);
579
- const useAi = hasAiFlagInArgv();
580
- console.log(`\u2699\uFE0F Debug: useAi=${useAi}, argv=${JSON.stringify(process.argv)}`);
581
739
  let markdown;
582
- if (useAi) {
583
- let pageSummaryJson = void 0;
740
+ if (params.useAi) {
741
+ let pageSummary = void 0;
584
742
  if (url) {
585
743
  try {
586
- console.log(`\u{1F50D} Scraping page for AI context: ${url}`);
587
- pageSummaryJson = await scrapePageSummary(url);
744
+ console.log(`\u{1F50D} Scraping page: ${url}`);
745
+ pageSummary = await scrapePageSummary(url);
588
746
  console.log(
589
- `\u{1F50E} Scrape summary: title="${pageSummaryJson.title || ""}", headings=${pageSummaryJson.headings.length}, buttons=${pageSummaryJson.buttons.length}, inputs=${pageSummaryJson.inputs.length}`
747
+ ` Found: ${pageSummary.headings.length} heading(s), ${pageSummary.inputs.length} input(s), ${pageSummary.buttons.length} button(s)`
590
748
  );
591
749
  } catch (e) {
592
- console.warn(
593
- `\u26A0\uFE0F Failed to scrape ${url} (${e?.message || e}). Continuing without page summary.`
594
- );
750
+ console.warn(`\u26A0\uFE0F Scrape failed (${e?.message ?? e}). Continuing without page context.`);
595
751
  }
596
752
  }
597
753
  try {
598
- console.log("\u{1F916} AI: generating test cases...");
599
754
  markdown = await generateTcMarkdownWithAi({
600
755
  appDescription: appDescription || void 0,
601
756
  feature,
602
757
  url,
603
- pageSummaryJson,
758
+ pageSummary,
604
759
  prefix,
605
760
  startIndex: 1,
606
761
  numCases
607
762
  });
608
- console.log("\u2705 AI: generated test case markdown.");
763
+ markdown = injectUrlMetadata(markdown, url);
764
+ console.log("\u2705 AI generated test cases.");
609
765
  } 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.");
766
+ console.warn(`\u26A0\uFE0F AI generation failed (${err?.message ?? err}). Falling back to manual templates.`);
767
+ markdown = buildManualCasesMarkdown({ prefix, feature, url, numCases, startIndex: 1 });
768
+ console.log("\u{1F4DD} Generated manual test case templates instead.");
621
769
  }
622
770
  } 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).");
771
+ markdown = buildManualCasesMarkdown({ prefix, feature, url, numCases, startIndex: 1 });
772
+ console.log("\u{1F4DD} Generated manual test case templates (pass --ai to use AI).");
631
773
  }
632
774
  writeFileSync3(fullPath, markdown);
633
- console.log(`\u270D\uFE0F Wrote ${numCases} test case(s) \u2192 ${fullPath}`);
634
- console.log("Next steps:");
775
+ console.log(`
776
+ \u270D\uFE0F Wrote ${numCases} case(s) \u2192 ${fullPath}`);
777
+ console.log("\nNext steps:");
635
778
  console.log(" ct normalize ./cases --and-gen --lang ts");
636
779
  console.log(" ct test");
637
780
  }
781
+ function addSharedOptions(cmd) {
782
+ 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);
783
+ }
784
+ function resolveCommanderOpts(opts) {
785
+ if (typeof opts.optsWithGlobals === "function") {
786
+ return opts.optsWithGlobals();
787
+ }
788
+ if (typeof opts.opts === "function") {
789
+ return opts.opts();
790
+ }
791
+ return opts;
792
+ }
638
793
  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
- });
794
+ const root = new Command4("tc").description("Create CementicTest case files (Markdown) under ./cases").addHelpText("after", `
795
+ Examples:
796
+ $ ct tc --feature "Login form" --ai
797
+ $ ct tc --feature "Checkout" --count 5 --prefix CHK
798
+ $ ct tc url https://example.com/login --ai --feature "Auth"
799
+ `);
800
+ addSharedOptions(root).action(
801
+ async (opts) => {
802
+ const resolvedOpts = resolveCommanderOpts(opts);
803
+ await runTcInteractive({
804
+ url: void 0,
805
+ explicitPrefix: resolvedOpts.prefix,
806
+ feature: resolvedOpts.feature,
807
+ appDescription: resolvedOpts.desc,
808
+ numCases: resolvedOpts.count,
809
+ useAi: resolvedOpts.ai ?? false
810
+ });
811
+ }
812
+ );
813
+ addSharedOptions(
814
+ root.command("url").argument("<url>", "Page URL to use as scraping and AI context").description("Generate test cases with awareness of a specific live page")
815
+ ).action(
816
+ async (url, opts, command) => {
817
+ const resolvedOpts = resolveCommanderOpts(command ?? opts);
818
+ await runTcInteractive({
819
+ url,
820
+ explicitPrefix: resolvedOpts.prefix,
821
+ feature: resolvedOpts.feature,
822
+ appDescription: resolvedOpts.desc,
823
+ numCases: resolvedOpts.count,
824
+ useAi: resolvedOpts.ai ?? false
825
+ });
826
+ }
827
+ );
657
828
  return root;
658
829
  }
659
830
 
@@ -808,8 +979,10 @@ function ciCmd() {
808
979
  }
809
980
 
810
981
  // src/cli.ts
982
+ var require2 = createRequire(import.meta.url);
983
+ var { version } = require2("../package.json");
811
984
  var program = new Command9();
812
- program.name("cementic-test").description("CementicTest CLI: cases \u2192 normalized \u2192 POM tests \u2192 Playwright").version("0.2.0");
985
+ program.name("ct").description("CementicTest CLI: cases \u2192 normalized \u2192 POM tests \u2192 Playwright").version(version);
813
986
  program.addCommand(newCmd());
814
987
  program.addCommand(normalizeCmd());
815
988
  program.addCommand(genCmd());