@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.
- package/README.md +273 -458
- package/dist/chunk-3EE7LWWT.js +353 -0
- package/dist/chunk-3EE7LWWT.js.map +1 -0
- package/dist/cli.js +337 -164
- package/dist/cli.js.map +1 -1
- package/dist/{gen-54KYT3RO.js → gen-IO4KKGYY.js} +2 -2
- package/dist/templates/student-framework/README.md +35 -5
- package/dist/templates/student-framework/package.json +3 -2
- package/dist/templates/student-framework/pages/BasePage.js +19 -0
- package/dist/templates/student-framework/pages/DashboardPage.js +16 -0
- package/dist/templates/student-framework/pages/FormPage.js +24 -0
- package/dist/templates/student-framework/pages/LoginPage.js +24 -0
- package/dist/templates/student-framework/tests/dashboard.spec.js +14 -0
- package/dist/templates/student-framework/tests/login.spec.js +14 -0
- package/dist/templates/student-framework-ts/README.md +20 -0
- package/dist/templates/student-framework-ts/package.json +24 -0
- package/dist/templates/student-framework-ts/pages/LandingPage.ts +15 -0
- package/dist/templates/student-framework-ts/playwright.config.ts +29 -0
- package/dist/templates/{student-framework/tests/landing.spec.js → student-framework-ts/tests/landing.spec.ts} +1 -1
- package/dist/templates/student-framework-ts/tsconfig.json +19 -0
- package/dist/templates/student-framework-ts/workflows/playwright.yml +20 -0
- package/package.json +2 -1
- package/dist/chunk-J63TUHIV.js +0 -80
- package/dist/chunk-J63TUHIV.js.map +0 -1
- package/dist/templates/student-framework/pages/LandingPage.js +0 -18
- /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-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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:
|
|
204
|
-
|
|
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-
|
|
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
|
|
385
|
+
You are a senior QA engineer and test automation specialist.
|
|
357
386
|
|
|
358
387
|
Your job:
|
|
359
|
-
-
|
|
360
|
-
-
|
|
361
|
-
-
|
|
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. <
|
|
368
|
-
2. <
|
|
396
|
+
1. <user action>
|
|
397
|
+
2. <user action>
|
|
369
398
|
|
|
370
399
|
## Expected Results
|
|
371
|
-
- <
|
|
372
|
-
- <
|
|
400
|
+
- <verifiable outcome>
|
|
401
|
+
- <verifiable outcome>
|
|
373
402
|
|
|
374
403
|
Rules:
|
|
375
|
-
-
|
|
376
|
-
-
|
|
377
|
-
|
|
378
|
-
-
|
|
379
|
-
-
|
|
380
|
-
-
|
|
381
|
-
-
|
|
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 /
|
|
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.
|
|
400
|
-
|
|
401
|
-
lines.push(
|
|
402
|
-
lines.push(
|
|
403
|
-
|
|
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
|
|
407
|
-
lines.push(
|
|
408
|
-
|
|
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(`
|
|
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
|
-
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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(`
|
|
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
|
|
490
|
-
const
|
|
491
|
-
const
|
|
492
|
-
const
|
|
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
|
-
|
|
495
|
-
|
|
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
|
|
505
|
-
buttons
|
|
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
|
-
|
|
524
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
740
|
+
if (params.useAi) {
|
|
741
|
+
let pageSummary = void 0;
|
|
584
742
|
if (url) {
|
|
585
743
|
try {
|
|
586
|
-
console.log(`\u{1F50D} Scraping page
|
|
587
|
-
|
|
744
|
+
console.log(`\u{1F50D} Scraping page: ${url}`);
|
|
745
|
+
pageSummary = await scrapePageSummary(url);
|
|
588
746
|
console.log(
|
|
589
|
-
|
|
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
|
-
|
|
758
|
+
pageSummary,
|
|
604
759
|
prefix,
|
|
605
760
|
startIndex: 1,
|
|
606
761
|
numCases
|
|
607
762
|
});
|
|
608
|
-
|
|
763
|
+
markdown = injectUrlMetadata(markdown, url);
|
|
764
|
+
console.log("\u2705 AI generated test cases.");
|
|
609
765
|
} catch (err) {
|
|
610
|
-
console.warn(
|
|
611
|
-
|
|
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
|
-
|
|
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(
|
|
634
|
-
|
|
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
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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("
|
|
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());
|