@hasna/testers 0.0.22 → 0.0.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +64 -22
- package/dist/lib/crawl-and-generate.d.ts +43 -0
- package/dist/lib/crawl-and-generate.d.ts.map +1 -0
- package/dist/mcp/index.js +327 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -27115,7 +27115,7 @@ import chalk6 from "chalk";
|
|
|
27115
27115
|
// package.json
|
|
27116
27116
|
var package_default = {
|
|
27117
27117
|
name: "@hasna/testers",
|
|
27118
|
-
version: "0.0.
|
|
27118
|
+
version: "0.0.24",
|
|
27119
27119
|
description: "AI-powered QA testing CLI \u2014 spawns cheap AI agents to test web apps with headless browsers",
|
|
27120
27120
|
type: "module",
|
|
27121
27121
|
main: "dist/index.js",
|
|
@@ -29833,7 +29833,11 @@ projectCmd.command("list").description("List all projects").action(() => {
|
|
|
29833
29833
|
});
|
|
29834
29834
|
projectCmd.command("show <id>").description("Show project details").action((id) => {
|
|
29835
29835
|
try {
|
|
29836
|
-
|
|
29836
|
+
let project = getProject(id);
|
|
29837
|
+
if (!project) {
|
|
29838
|
+
const all = listProjects();
|
|
29839
|
+
project = all.find((p) => p.id.startsWith(id) || p.name === id) ?? null;
|
|
29840
|
+
}
|
|
29837
29841
|
if (!project) {
|
|
29838
29842
|
logError(chalk6.red(`Project not found: ${id}`));
|
|
29839
29843
|
process.exit(1);
|
|
@@ -30120,8 +30124,8 @@ program2.command("init").description("Initialize a new testing project").option(
|
|
|
30120
30124
|
log(chalk6.yellow(` Unknown CI provider: ${opts.ci}. Supported: github`));
|
|
30121
30125
|
}
|
|
30122
30126
|
log("");
|
|
30123
|
-
const
|
|
30124
|
-
const ask = (q) => new Promise((resolve2) =>
|
|
30127
|
+
const rl2 = createInterface({ input: process.stdin, output: process.stdout });
|
|
30128
|
+
const ask = (q) => new Promise((resolve2) => rl2.question(q, resolve2));
|
|
30125
30129
|
try {
|
|
30126
30130
|
const envAnswer = await ask(" Would you like to configure environments? [y/N] ");
|
|
30127
30131
|
if (envAnswer.trim().toLowerCase() === "y") {
|
|
@@ -30151,7 +30155,7 @@ program2.command("init").description("Initialize a new testing project").option(
|
|
|
30151
30155
|
log("");
|
|
30152
30156
|
}
|
|
30153
30157
|
} finally {
|
|
30154
|
-
|
|
30158
|
+
rl2.close();
|
|
30155
30159
|
}
|
|
30156
30160
|
log(chalk6.bold(" Next steps:"));
|
|
30157
30161
|
log(` 1. Start your dev server`);
|
|
@@ -31215,10 +31219,27 @@ apiCmd.command("list").description("List API checks").option("--project <id>", "
|
|
|
31215
31219
|
process.exit(1);
|
|
31216
31220
|
}
|
|
31217
31221
|
});
|
|
31218
|
-
apiCmd.command("add").description("Add a new API check
|
|
31219
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
31220
|
-
const ask = (q) => new Promise((res) => rl.question(q, res));
|
|
31222
|
+
apiCmd.command("add").description("Add a new API check (interactive if no --url given)").option("--project <id>", "Project ID").option("-n, --name <name>", "Check name (non-interactive)").option("-u, --url <url>", "URL to check, full or path (non-interactive)").option("-m, --method <method>", "HTTP method (default: GET)").option("--status <code>", "Expected HTTP status code (default: 200)").option("--contains <text>", "Body must contain this string").option("--response-time <ms>", "Max acceptable response time in ms").option("-t, --tag <tag>", "Tag (repeatable)", []).action(async (opts) => {
|
|
31221
31223
|
try {
|
|
31224
|
+
if (opts.url) {
|
|
31225
|
+
const projectId2 = resolveProject(opts.project);
|
|
31226
|
+
const check2 = createApiCheck({
|
|
31227
|
+
name: opts.name?.trim() || opts.url,
|
|
31228
|
+
method: opts.method?.toUpperCase() ?? "GET",
|
|
31229
|
+
url: opts.url.trim(),
|
|
31230
|
+
expectedStatus: opts.status ? parseInt(opts.status, 10) : 200,
|
|
31231
|
+
expectedBodyContains: opts.contains || undefined,
|
|
31232
|
+
expectedResponseTimeMs: opts.responseTime ? parseInt(opts.responseTime, 10) : undefined,
|
|
31233
|
+
tags: opts.tag ?? [],
|
|
31234
|
+
projectId: projectId2
|
|
31235
|
+
});
|
|
31236
|
+
log("");
|
|
31237
|
+
log(chalk6.green(`\u2713 Created API check ${chalk6.bold(check2.name)} (${check2.shortId})`));
|
|
31238
|
+
log(chalk6.dim(` ${check2.method} ${check2.url} \u2192 expect ${check2.expectedStatus}`));
|
|
31239
|
+
return;
|
|
31240
|
+
}
|
|
31241
|
+
const rl2 = createInterface({ input: process.stdin, output: process.stdout });
|
|
31242
|
+
const ask = (q) => new Promise((res) => rl2.question(q, res));
|
|
31222
31243
|
const name = await ask("Name: ");
|
|
31223
31244
|
if (!name.trim()) {
|
|
31224
31245
|
logError(chalk6.red("Name is required"));
|
|
@@ -31237,7 +31258,7 @@ apiCmd.command("add").description("Add a new API check interactively").option("-
|
|
|
31237
31258
|
const expectedStatus = statusInput.trim() ? parseInt(statusInput.trim(), 10) : 200;
|
|
31238
31259
|
const bodyContains = await ask("Body must contain (optional, press enter to skip): ");
|
|
31239
31260
|
const tagsInput = await ask("Tags (comma-separated, optional): ");
|
|
31240
|
-
|
|
31261
|
+
rl2.close();
|
|
31241
31262
|
const projectId = resolveProject(opts.project);
|
|
31242
31263
|
const check = createApiCheck({
|
|
31243
31264
|
name: name.trim(),
|
|
@@ -31252,7 +31273,6 @@ apiCmd.command("add").description("Add a new API check interactively").option("-
|
|
|
31252
31273
|
log(chalk6.green(`\u2713 Created API check ${chalk6.bold(check.name)} (${check.shortId})`));
|
|
31253
31274
|
log(chalk6.dim(` ${check.method} ${check.url} \u2192 expect ${check.expectedStatus}`));
|
|
31254
31275
|
} catch (error) {
|
|
31255
|
-
rl.close();
|
|
31256
31276
|
logError(chalk6.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
31257
31277
|
process.exit(1);
|
|
31258
31278
|
}
|
|
@@ -31366,9 +31386,9 @@ apiCmd.command("delete <id>").description("Delete an API check").option("-y, --y
|
|
|
31366
31386
|
process.exit(1);
|
|
31367
31387
|
}
|
|
31368
31388
|
if (!opts.yes) {
|
|
31369
|
-
const
|
|
31370
|
-
const answer = await new Promise((res) =>
|
|
31371
|
-
|
|
31389
|
+
const rl2 = createInterface({ input: process.stdin, output: process.stdout });
|
|
31390
|
+
const answer = await new Promise((res) => rl2.question(`Delete "${check.name}" (${check.shortId})? [y/N] `, res));
|
|
31391
|
+
rl2.close();
|
|
31372
31392
|
if (answer.toLowerCase() !== "y") {
|
|
31373
31393
|
log(chalk6.dim("Cancelled."));
|
|
31374
31394
|
return;
|
|
@@ -31589,27 +31609,49 @@ personaCmd.command("list").description("List personas").option("--project <id>",
|
|
|
31589
31609
|
process.exit(1);
|
|
31590
31610
|
}
|
|
31591
31611
|
});
|
|
31592
|
-
personaCmd.command("add").description("Create a persona
|
|
31593
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
31594
|
-
const ask = (q) => new Promise((res) => rl.question(q, res));
|
|
31612
|
+
personaCmd.command("add").description("Create a persona (interactive if no --name/--role given)").option("--global", "Create as a global persona (no project scope)", false).option("--project <id>", "Project ID").option("-n, --name <name>", "Persona name (non-interactive)").option("-r, --role <role>", "Persona role (non-interactive)").option("-d, --description <text>", "Persona description").option("-i, --instructions <text>", "Behavior instructions").option("--traits <list>", "Comma-separated traits (e.g. impatient,curious)").option("--goals <list>", "Comma-separated goals").option("--auth-email <email>", "Login email for auth testing").option("--auth-password <pass>", "Login password for auth testing").option("--auth-login-path <path>", "Login page path (default: /login)").action(async (opts) => {
|
|
31595
31613
|
try {
|
|
31614
|
+
if (opts.name && opts.role) {
|
|
31615
|
+
const projectId2 = opts.global ? undefined : resolveProject(opts.project);
|
|
31616
|
+
const traits2 = opts.traits ? opts.traits.split(",").map((t) => t.trim()).filter(Boolean) : [];
|
|
31617
|
+
const goals2 = opts.goals ? opts.goals.split(",").map((g) => g.trim()).filter(Boolean) : [];
|
|
31618
|
+
const persona2 = createPersona({
|
|
31619
|
+
name: opts.name.trim(),
|
|
31620
|
+
role: opts.role.trim(),
|
|
31621
|
+
description: opts.description?.trim() ?? "",
|
|
31622
|
+
instructions: opts.instructions?.trim() ?? "",
|
|
31623
|
+
traits: traits2,
|
|
31624
|
+
goals: goals2,
|
|
31625
|
+
projectId: projectId2,
|
|
31626
|
+
authEmail: opts.authEmail,
|
|
31627
|
+
authPassword: opts.authPassword,
|
|
31628
|
+
authLoginPath: opts.authLoginPath
|
|
31629
|
+
});
|
|
31630
|
+
log("");
|
|
31631
|
+
log(chalk6.green(`Created persona ${chalk6.bold(persona2.shortId)}: ${persona2.name}`));
|
|
31632
|
+
log(chalk6.dim(` Role: ${persona2.role}`));
|
|
31633
|
+
log(chalk6.dim(` Scope: ${persona2.projectId ? "project" : "global"}`));
|
|
31634
|
+
return;
|
|
31635
|
+
}
|
|
31636
|
+
const rl2 = createInterface({ input: process.stdin, output: process.stdout });
|
|
31637
|
+
const ask = (q) => new Promise((res) => rl2.question(q, res));
|
|
31596
31638
|
const name = await ask("Name: ");
|
|
31597
31639
|
if (!name.trim()) {
|
|
31598
31640
|
logError(chalk6.red("Name is required"));
|
|
31599
|
-
|
|
31641
|
+
rl2.close();
|
|
31600
31642
|
process.exit(1);
|
|
31601
31643
|
}
|
|
31602
31644
|
const role = await ask("Role (e.g. first-time user, admin, power user): ");
|
|
31603
31645
|
if (!role.trim()) {
|
|
31604
31646
|
logError(chalk6.red("Role is required"));
|
|
31605
|
-
|
|
31647
|
+
rl2.close();
|
|
31606
31648
|
process.exit(1);
|
|
31607
31649
|
}
|
|
31608
31650
|
const description = await ask("Description (optional): ");
|
|
31609
31651
|
const instructions = await ask("Instructions \u2014 how should this persona behave? (optional): ");
|
|
31610
31652
|
const traitsInput = await ask("Traits (comma-separated, e.g. impatient,curious): ");
|
|
31611
31653
|
const goalsInput = await ask("Goals (comma-separated): ");
|
|
31612
|
-
|
|
31654
|
+
rl2.close();
|
|
31613
31655
|
const projectId = opts.global ? undefined : resolveProject(opts.project);
|
|
31614
31656
|
const traits = traitsInput.trim() ? traitsInput.split(",").map((t) => t.trim()).filter(Boolean) : [];
|
|
31615
31657
|
const goals = goalsInput.trim() ? goalsInput.split(",").map((g) => g.trim()).filter(Boolean) : [];
|
|
@@ -31913,9 +31955,9 @@ goldenCmd.command("add").description("Add a golden answer check interactively").
|
|
|
31913
31955
|
try {
|
|
31914
31956
|
const { createGoldenAnswer: createGoldenAnswer2 } = await Promise.resolve().then(() => (init_golden_answers(), exports_golden_answers));
|
|
31915
31957
|
const ask = (prompt) => {
|
|
31916
|
-
const
|
|
31917
|
-
return new Promise((resolve2) =>
|
|
31918
|
-
|
|
31958
|
+
const rl2 = createInterface({ input: process.stdin, output: process.stdout });
|
|
31959
|
+
return new Promise((resolve2) => rl2.question(prompt, (ans) => {
|
|
31960
|
+
rl2.close();
|
|
31919
31961
|
resolve2(ans.trim());
|
|
31920
31962
|
}));
|
|
31921
31963
|
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crawl_and_generate — zero-config test generation for any web app.
|
|
3
|
+
*
|
|
4
|
+
* Given any URL:
|
|
5
|
+
* 1. Crawls the app with a headless browser to discover pages
|
|
6
|
+
* 2. Visits each page, captures a screenshot + simplified HTML
|
|
7
|
+
* 3. Sends both to Claude with a prompt to write test scenarios
|
|
8
|
+
* 4. Creates the scenarios in the DB under the given project
|
|
9
|
+
*
|
|
10
|
+
* Works for any web app — no manual setup required.
|
|
11
|
+
*/
|
|
12
|
+
export interface CrawlAndGenerateOptions {
|
|
13
|
+
url: string;
|
|
14
|
+
projectId?: string;
|
|
15
|
+
maxPages?: number;
|
|
16
|
+
scenariosPerPage?: number;
|
|
17
|
+
model?: string;
|
|
18
|
+
apiKey?: string;
|
|
19
|
+
headed?: boolean;
|
|
20
|
+
skipPaths?: string[];
|
|
21
|
+
tags?: string[];
|
|
22
|
+
}
|
|
23
|
+
export interface GeneratedPage {
|
|
24
|
+
path: string;
|
|
25
|
+
title: string;
|
|
26
|
+
scenariosCreated: number;
|
|
27
|
+
scenarios: Array<{
|
|
28
|
+
id: string;
|
|
29
|
+
shortId: string;
|
|
30
|
+
name: string;
|
|
31
|
+
}>;
|
|
32
|
+
}
|
|
33
|
+
export interface CrawlAndGenerateResult {
|
|
34
|
+
projectId: string | null;
|
|
35
|
+
url: string;
|
|
36
|
+
pagesDiscovered: number;
|
|
37
|
+
pagesGenerated: number;
|
|
38
|
+
totalScenariosCreated: number;
|
|
39
|
+
pages: GeneratedPage[];
|
|
40
|
+
skipped: string[];
|
|
41
|
+
}
|
|
42
|
+
export declare function crawlAndGenerate(options: CrawlAndGenerateOptions): Promise<CrawlAndGenerateResult>;
|
|
43
|
+
//# sourceMappingURL=crawl-and-generate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crawl-and-generate.d.ts","sourceRoot":"","sources":["../../src/lib/crawl-and-generate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAUH,MAAM,WAAW,uBAAuB;IACtC,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACjE;AAED,MAAM,WAAW,sBAAsB;IACrC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;IACvB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAwJD,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAuGxG"}
|
package/dist/mcp/index.js
CHANGED
|
@@ -17792,6 +17792,232 @@ var init_army_runner = __esm(() => {
|
|
|
17792
17792
|
init_config2();
|
|
17793
17793
|
});
|
|
17794
17794
|
|
|
17795
|
+
// src/lib/crawl-and-generate.ts
|
|
17796
|
+
var exports_crawl_and_generate = {};
|
|
17797
|
+
__export(exports_crawl_and_generate, {
|
|
17798
|
+
crawlAndGenerate: () => crawlAndGenerate
|
|
17799
|
+
});
|
|
17800
|
+
function shouldSkip(href, rootOrigin, skipPaths) {
|
|
17801
|
+
try {
|
|
17802
|
+
const u = new URL(href);
|
|
17803
|
+
if (u.origin !== rootOrigin)
|
|
17804
|
+
return true;
|
|
17805
|
+
const path = u.pathname;
|
|
17806
|
+
const allSkip = [...DEFAULT_SKIP_PATTERNS, ...skipPaths];
|
|
17807
|
+
return allSkip.some((p) => path.startsWith(p) || path.includes(p));
|
|
17808
|
+
} catch {
|
|
17809
|
+
return true;
|
|
17810
|
+
}
|
|
17811
|
+
}
|
|
17812
|
+
function normaliseUrl2(href) {
|
|
17813
|
+
try {
|
|
17814
|
+
const u = new URL(href);
|
|
17815
|
+
return `${u.origin}${u.pathname}`;
|
|
17816
|
+
} catch {
|
|
17817
|
+
return href;
|
|
17818
|
+
}
|
|
17819
|
+
}
|
|
17820
|
+
async function getPageContext(browser, pageUrl, timeoutMs) {
|
|
17821
|
+
const page = await getPage(browser, {});
|
|
17822
|
+
try {
|
|
17823
|
+
await page.goto(pageUrl, { timeout: timeoutMs, waitUntil: "domcontentloaded" });
|
|
17824
|
+
await page.waitForTimeout(800).catch(() => {});
|
|
17825
|
+
const [title, html, links, screenshot] = await Promise.all([
|
|
17826
|
+
page.title().catch(() => ""),
|
|
17827
|
+
page.evaluate(() => {
|
|
17828
|
+
const body = document.body;
|
|
17829
|
+
if (!body)
|
|
17830
|
+
return "";
|
|
17831
|
+
const clone = body.cloneNode(true);
|
|
17832
|
+
clone.querySelectorAll("script,style,svg,noscript,iframe").forEach((el) => el.remove());
|
|
17833
|
+
return clone.innerText?.slice(0, 3000) ?? clone.textContent?.slice(0, 3000) ?? "";
|
|
17834
|
+
}).catch(() => ""),
|
|
17835
|
+
page.evaluate((origin) => Array.from(document.querySelectorAll("a[href]")).map((a) => a.href).filter((h) => {
|
|
17836
|
+
try {
|
|
17837
|
+
return new URL(h).origin === origin;
|
|
17838
|
+
} catch {
|
|
17839
|
+
return false;
|
|
17840
|
+
}
|
|
17841
|
+
}), new URL(pageUrl).origin).catch(() => []),
|
|
17842
|
+
page.screenshot({ fullPage: false }).catch(() => null)
|
|
17843
|
+
]);
|
|
17844
|
+
return { title, path: new URL(pageUrl).pathname, html, screenshot, links };
|
|
17845
|
+
} finally {
|
|
17846
|
+
await page.close().catch(() => {});
|
|
17847
|
+
}
|
|
17848
|
+
}
|
|
17849
|
+
async function generateScenariosForPage(client, model, pageContext, baseUrl, count) {
|
|
17850
|
+
const Anthropic4 = (await import("@anthropic-ai/sdk")).default;
|
|
17851
|
+
const anthropicClient = client;
|
|
17852
|
+
const pageDesc = [
|
|
17853
|
+
`URL: ${baseUrl.replace(/\/$/, "")}${pageContext.path}`,
|
|
17854
|
+
`Title: ${pageContext.title || pageContext.path}`,
|
|
17855
|
+
pageContext.html ? `
|
|
17856
|
+
Page content (text):
|
|
17857
|
+
${pageContext.html.slice(0, 2000)}` : ""
|
|
17858
|
+
].filter(Boolean).join(`
|
|
17859
|
+
`);
|
|
17860
|
+
const prompt = `You are a QA engineer. Analyze this web page and write ${count} practical test scenarios.
|
|
17861
|
+
|
|
17862
|
+
${pageDesc}
|
|
17863
|
+
|
|
17864
|
+
Return ONLY a JSON array (no markdown, no explanation). Each scenario:
|
|
17865
|
+
{
|
|
17866
|
+
"name": "short action-oriented name (e.g. 'User can log in with valid credentials')",
|
|
17867
|
+
"description": "what this test verifies",
|
|
17868
|
+
"steps": ["step 1", "step 2", "step 3"],
|
|
17869
|
+
"tags": ["tag1"],
|
|
17870
|
+
"priority": "low|medium|high|critical"
|
|
17871
|
+
}
|
|
17872
|
+
|
|
17873
|
+
Rules:
|
|
17874
|
+
- Focus on user flows, not implementation details
|
|
17875
|
+
- Steps should be plain English instructions the browser agent can follow
|
|
17876
|
+
- Vary priorities: 1 critical/high per page for the main flow, rest medium/low
|
|
17877
|
+
- Keep steps concise (max 8 per scenario)
|
|
17878
|
+
- Tags should reflect the page area (e.g. "auth", "dashboard", "settings", "checkout")`;
|
|
17879
|
+
const contentParts = [
|
|
17880
|
+
...pageContext.screenshot ? [{
|
|
17881
|
+
type: "image",
|
|
17882
|
+
source: {
|
|
17883
|
+
type: "base64",
|
|
17884
|
+
media_type: "image/png",
|
|
17885
|
+
data: pageContext.screenshot.toString("base64")
|
|
17886
|
+
}
|
|
17887
|
+
}] : [],
|
|
17888
|
+
{ type: "text", text: prompt }
|
|
17889
|
+
];
|
|
17890
|
+
const messages = [{ role: "user", content: contentParts }];
|
|
17891
|
+
try {
|
|
17892
|
+
const response = await anthropicClient.messages.create({
|
|
17893
|
+
model,
|
|
17894
|
+
max_tokens: 2048,
|
|
17895
|
+
messages
|
|
17896
|
+
});
|
|
17897
|
+
const text = response.content.filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
17898
|
+
const match = text.match(/\[[\s\S]*\]/);
|
|
17899
|
+
if (!match)
|
|
17900
|
+
return [];
|
|
17901
|
+
const parsed = JSON.parse(match[0]);
|
|
17902
|
+
return parsed.map((s) => ({
|
|
17903
|
+
name: s.name ?? "Untitled scenario",
|
|
17904
|
+
description: s.description ?? "",
|
|
17905
|
+
steps: s.steps ?? [],
|
|
17906
|
+
tags: s.tags ?? [],
|
|
17907
|
+
priority: s.priority ?? "medium"
|
|
17908
|
+
}));
|
|
17909
|
+
} catch {
|
|
17910
|
+
return [];
|
|
17911
|
+
}
|
|
17912
|
+
}
|
|
17913
|
+
async function crawlAndGenerate(options) {
|
|
17914
|
+
const {
|
|
17915
|
+
url,
|
|
17916
|
+
projectId,
|
|
17917
|
+
maxPages = 20,
|
|
17918
|
+
scenariosPerPage = 3,
|
|
17919
|
+
headed = false,
|
|
17920
|
+
skipPaths = [],
|
|
17921
|
+
tags: extraTags = []
|
|
17922
|
+
} = options;
|
|
17923
|
+
const config = loadConfig();
|
|
17924
|
+
const model = resolveModel2(options.model ?? config.defaultModel ?? "thorough");
|
|
17925
|
+
const client = createClient(options.apiKey ?? config.anthropicApiKey);
|
|
17926
|
+
const rootOrigin = new URL(url).origin;
|
|
17927
|
+
const visited = new Set;
|
|
17928
|
+
const queue = [url];
|
|
17929
|
+
const pageContexts = [];
|
|
17930
|
+
const skipped = [];
|
|
17931
|
+
const browser = await launchBrowser({ headless: !headed });
|
|
17932
|
+
try {
|
|
17933
|
+
while (queue.length > 0 && visited.size < maxPages) {
|
|
17934
|
+
const pageUrl = queue.shift();
|
|
17935
|
+
const norm = normaliseUrl2(pageUrl);
|
|
17936
|
+
if (visited.has(norm))
|
|
17937
|
+
continue;
|
|
17938
|
+
if (shouldSkip(pageUrl, rootOrigin, skipPaths)) {
|
|
17939
|
+
skipped.push(pageUrl);
|
|
17940
|
+
continue;
|
|
17941
|
+
}
|
|
17942
|
+
visited.add(norm);
|
|
17943
|
+
try {
|
|
17944
|
+
const ctx = await getPageContext(browser, pageUrl, 15000);
|
|
17945
|
+
pageContexts.push(ctx);
|
|
17946
|
+
for (const link of ctx.links) {
|
|
17947
|
+
const normLink = normaliseUrl2(link);
|
|
17948
|
+
if (!visited.has(normLink) && !shouldSkip(link, rootOrigin, skipPaths)) {
|
|
17949
|
+
queue.push(link);
|
|
17950
|
+
}
|
|
17951
|
+
}
|
|
17952
|
+
} catch {
|
|
17953
|
+
skipped.push(pageUrl);
|
|
17954
|
+
}
|
|
17955
|
+
}
|
|
17956
|
+
} finally {
|
|
17957
|
+
await closeBrowser(browser).catch(() => {});
|
|
17958
|
+
}
|
|
17959
|
+
const pages = [];
|
|
17960
|
+
let totalCreated = 0;
|
|
17961
|
+
for (const ctx of pageContexts) {
|
|
17962
|
+
const generated = await generateScenariosForPage(client, model, ctx, url, scenariosPerPage);
|
|
17963
|
+
const createdScenarios = [];
|
|
17964
|
+
for (const s of generated) {
|
|
17965
|
+
try {
|
|
17966
|
+
const priority = ["low", "medium", "high", "critical"].includes(s.priority) ? s.priority : "medium";
|
|
17967
|
+
const scenario = createScenario({
|
|
17968
|
+
name: s.name,
|
|
17969
|
+
description: s.description,
|
|
17970
|
+
steps: s.steps,
|
|
17971
|
+
tags: [...s.tags ?? [], ...extraTags, "generated"],
|
|
17972
|
+
priority,
|
|
17973
|
+
targetPath: ctx.path,
|
|
17974
|
+
projectId
|
|
17975
|
+
});
|
|
17976
|
+
createdScenarios.push({ id: scenario.id, shortId: scenario.shortId, name: scenario.name });
|
|
17977
|
+
totalCreated++;
|
|
17978
|
+
} catch {}
|
|
17979
|
+
}
|
|
17980
|
+
if (createdScenarios.length > 0) {
|
|
17981
|
+
pages.push({
|
|
17982
|
+
path: ctx.path,
|
|
17983
|
+
title: ctx.title,
|
|
17984
|
+
scenariosCreated: createdScenarios.length,
|
|
17985
|
+
scenarios: createdScenarios
|
|
17986
|
+
});
|
|
17987
|
+
}
|
|
17988
|
+
}
|
|
17989
|
+
return {
|
|
17990
|
+
projectId: projectId ?? null,
|
|
17991
|
+
url,
|
|
17992
|
+
pagesDiscovered: pageContexts.length,
|
|
17993
|
+
pagesGenerated: pages.length,
|
|
17994
|
+
totalScenariosCreated: totalCreated,
|
|
17995
|
+
pages,
|
|
17996
|
+
skipped
|
|
17997
|
+
};
|
|
17998
|
+
}
|
|
17999
|
+
var DEFAULT_SKIP_PATTERNS;
|
|
18000
|
+
var init_crawl_and_generate = __esm(() => {
|
|
18001
|
+
init_browser();
|
|
18002
|
+
init_scenarios();
|
|
18003
|
+
init_ai_client();
|
|
18004
|
+
init_config2();
|
|
18005
|
+
init_ai_client();
|
|
18006
|
+
DEFAULT_SKIP_PATTERNS = [
|
|
18007
|
+
"/logout",
|
|
18008
|
+
"/sign-out",
|
|
18009
|
+
"/signout",
|
|
18010
|
+
"/static/",
|
|
18011
|
+
"/assets/",
|
|
18012
|
+
"/_next/",
|
|
18013
|
+
"/__/",
|
|
18014
|
+
"/favicon",
|
|
18015
|
+
"/robots.txt",
|
|
18016
|
+
"/sitemap",
|
|
18017
|
+
"#"
|
|
18018
|
+
];
|
|
18019
|
+
});
|
|
18020
|
+
|
|
17795
18021
|
// src/mcp/index.ts
|
|
17796
18022
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17797
18023
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -24399,6 +24625,107 @@ server.tool("run_with_army", "Dispatch scenarios across multiple concurrent work
|
|
|
24399
24625
|
return errorResponse(e);
|
|
24400
24626
|
}
|
|
24401
24627
|
});
|
|
24628
|
+
server.tool("crawl_and_generate", "Crawl any web app from a URL and auto-generate test scenarios for each page discovered. Zero config \u2014 works for any app. Uses AI (Claude) to analyze each page and write practical test scenarios.", {
|
|
24629
|
+
url: exports_external.string().describe("Root URL to crawl (e.g. https://app.example.com)"),
|
|
24630
|
+
projectId: exports_external.string().optional().describe("Project ID to attach generated scenarios to"),
|
|
24631
|
+
maxPages: exports_external.number().int().min(1).max(50).optional().default(20).describe("Max pages to crawl (default 20)"),
|
|
24632
|
+
scenariosPerPage: exports_external.number().int().min(1).max(10).optional().default(3).describe("Scenarios to generate per page (default 3)"),
|
|
24633
|
+
model: exports_external.string().optional().describe(MODEL_DESC),
|
|
24634
|
+
skipPaths: exports_external.array(exports_external.string()).optional().describe("URL paths to skip (e.g. ['/logout', '/admin'])"),
|
|
24635
|
+
tags: exports_external.array(exports_external.string()).optional().describe("Extra tags to add to all generated scenarios"),
|
|
24636
|
+
headed: exports_external.boolean().optional().describe("Run browser in headed mode")
|
|
24637
|
+
}, async ({ url, projectId, maxPages, scenariosPerPage, model, skipPaths, tags, headed }) => {
|
|
24638
|
+
try {
|
|
24639
|
+
const { crawlAndGenerate: crawlAndGenerate2 } = await Promise.resolve().then(() => (init_crawl_and_generate(), exports_crawl_and_generate));
|
|
24640
|
+
const result = await crawlAndGenerate2({
|
|
24641
|
+
url,
|
|
24642
|
+
projectId,
|
|
24643
|
+
maxPages: maxPages ?? 20,
|
|
24644
|
+
scenariosPerPage: scenariosPerPage ?? 3,
|
|
24645
|
+
model,
|
|
24646
|
+
skipPaths,
|
|
24647
|
+
tags,
|
|
24648
|
+
headed
|
|
24649
|
+
});
|
|
24650
|
+
return json(result);
|
|
24651
|
+
} catch (e) {
|
|
24652
|
+
return errorResponse(e);
|
|
24653
|
+
}
|
|
24654
|
+
});
|
|
24655
|
+
server.tool("create_environment", "Register a named environment (e.g. staging, production, local) with its base URL. Use env name in run_scenarios instead of hardcoding URLs.", {
|
|
24656
|
+
name: exports_external.string().describe("Environment name (e.g. staging, production, local)"),
|
|
24657
|
+
url: exports_external.string().describe("Base URL for this environment (e.g. https://staging.example.com)"),
|
|
24658
|
+
projectId: exports_external.string().optional().describe("Scope to a specific project"),
|
|
24659
|
+
isDefault: exports_external.boolean().optional().describe("Set as default environment for this project"),
|
|
24660
|
+
variables: exports_external.record(exports_external.string()).optional().describe("Environment variables (e.g. { API_KEY: 'test-key' })")
|
|
24661
|
+
}, async ({ name, url, projectId, isDefault, variables }) => {
|
|
24662
|
+
try {
|
|
24663
|
+
const { createEnvironment: createEnvironment2 } = await Promise.resolve().then(() => (init_environments(), exports_environments));
|
|
24664
|
+
const env2 = createEnvironment2({ name, url, projectId, isDefault, variables });
|
|
24665
|
+
return json(env2);
|
|
24666
|
+
} catch (e) {
|
|
24667
|
+
return errorResponse(e);
|
|
24668
|
+
}
|
|
24669
|
+
});
|
|
24670
|
+
server.tool("list_environments", "List registered environments with their URLs", {
|
|
24671
|
+
projectId: exports_external.string().optional().describe("Filter by project ID")
|
|
24672
|
+
}, async ({ projectId }) => {
|
|
24673
|
+
try {
|
|
24674
|
+
const { listEnvironments: listEnvironments2 } = await Promise.resolve().then(() => (init_environments(), exports_environments));
|
|
24675
|
+
const envs = listEnvironments2(projectId);
|
|
24676
|
+
return json({ items: envs, total: envs.length });
|
|
24677
|
+
} catch (e) {
|
|
24678
|
+
return errorResponse(e);
|
|
24679
|
+
}
|
|
24680
|
+
});
|
|
24681
|
+
server.tool("delete_environment", "Delete a named environment", { name: exports_external.string().describe("Environment name to delete") }, async ({ name }) => {
|
|
24682
|
+
try {
|
|
24683
|
+
const { deleteEnvironment: deleteEnvironment2 } = await Promise.resolve().then(() => (init_environments(), exports_environments));
|
|
24684
|
+
const deleted = deleteEnvironment2(name);
|
|
24685
|
+
if (!deleted)
|
|
24686
|
+
return errorResponse(notFoundErr(name, "Environment"));
|
|
24687
|
+
return json({ deleted: true, name });
|
|
24688
|
+
} catch (e) {
|
|
24689
|
+
return errorResponse(e);
|
|
24690
|
+
}
|
|
24691
|
+
});
|
|
24692
|
+
server.tool("set_default_environment", "Set an environment as the default \u2014 run_scenarios will use it when no url/env is specified", { name: exports_external.string().describe("Environment name to set as default") }, async ({ name }) => {
|
|
24693
|
+
try {
|
|
24694
|
+
const { setDefaultEnvironment: setDefaultEnvironment2, getEnvironment: getEnvironment2 } = await Promise.resolve().then(() => (init_environments(), exports_environments));
|
|
24695
|
+
setDefaultEnvironment2(name);
|
|
24696
|
+
return json({ default: true, name, env: getEnvironment2(name) });
|
|
24697
|
+
} catch (e) {
|
|
24698
|
+
return errorResponse(e);
|
|
24699
|
+
}
|
|
24700
|
+
});
|
|
24701
|
+
server.tool("list_scenarios_by_page", "Group scenarios by page (targetPath). Shows which pages have test coverage and which don't. Useful for spotting gaps.", {
|
|
24702
|
+
projectId: exports_external.string().optional().describe("Filter by project ID")
|
|
24703
|
+
}, async ({ projectId }) => {
|
|
24704
|
+
try {
|
|
24705
|
+
const scenarios = listScenarios({ projectId });
|
|
24706
|
+
const byPage = {};
|
|
24707
|
+
const noPath = [];
|
|
24708
|
+
for (const s of scenarios) {
|
|
24709
|
+
if (s.targetPath) {
|
|
24710
|
+
if (!byPage[s.targetPath])
|
|
24711
|
+
byPage[s.targetPath] = [];
|
|
24712
|
+
byPage[s.targetPath].push({ id: s.id, shortId: s.shortId, name: s.name, priority: s.priority, tags: s.tags });
|
|
24713
|
+
} else {
|
|
24714
|
+
noPath.push({ id: s.id, shortId: s.shortId, name: s.name });
|
|
24715
|
+
}
|
|
24716
|
+
}
|
|
24717
|
+
const pages = Object.entries(byPage).sort(([a], [b]) => a.localeCompare(b)).map(([path, items]) => ({ path, scenarioCount: items.length, scenarios: items }));
|
|
24718
|
+
return json({
|
|
24719
|
+
pages,
|
|
24720
|
+
totalPages: pages.length,
|
|
24721
|
+
totalScenarios: scenarios.length,
|
|
24722
|
+
scenariosWithNoPage: noPath.length,
|
|
24723
|
+
noPageScenarios: noPath
|
|
24724
|
+
});
|
|
24725
|
+
} catch (e) {
|
|
24726
|
+
return errorResponse(e);
|
|
24727
|
+
}
|
|
24728
|
+
});
|
|
24402
24729
|
registerCloudTools(server, "testers");
|
|
24403
24730
|
async function main() {
|
|
24404
24731
|
const transport = new StdioServerTransport;
|