@hasna/testers 0.0.22 → 0.0.23

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 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.22",
27118
+ version: "0.0.23",
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",
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/testers",
3
- "version": "0.0.22",
3
+ "version": "0.0.23",
4
4
  "description": "AI-powered QA testing CLI — spawns cheap AI agents to test web apps with headless browsers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",