@hasna/testers 0.0.28 → 0.0.29

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 (77) hide show
  1. package/LICENSE +1 -154
  2. package/README.md +60 -0
  3. package/dist/cli/index.js +944 -76
  4. package/dist/db/database.d.ts.map +1 -1
  5. package/dist/db/personas.d.ts.map +1 -1
  6. package/dist/db/runs.d.ts +29 -0
  7. package/dist/db/runs.d.ts.map +1 -1
  8. package/dist/db/scenarios.d.ts +12 -0
  9. package/dist/db/scenarios.d.ts.map +1 -1
  10. package/dist/db/sessions.d.ts +36 -0
  11. package/dist/db/sessions.d.ts.map +1 -0
  12. package/dist/db/step-results.d.ts +30 -0
  13. package/dist/db/step-results.d.ts.map +1 -0
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +439 -24
  17. package/dist/lib/a11y-audit.d.ts +54 -0
  18. package/dist/lib/a11y-audit.d.ts.map +1 -0
  19. package/dist/lib/api-discovery.d.ts +46 -0
  20. package/dist/lib/api-discovery.d.ts.map +1 -0
  21. package/dist/lib/assertions.d.ts.map +1 -1
  22. package/dist/lib/auth-profiles.d.ts +16 -0
  23. package/dist/lib/auth-profiles.d.ts.map +1 -0
  24. package/dist/lib/auth-session-pool.d.ts +57 -0
  25. package/dist/lib/auth-session-pool.d.ts.map +1 -0
  26. package/dist/lib/batch-actions.d.ts +44 -0
  27. package/dist/lib/batch-actions.d.ts.map +1 -0
  28. package/dist/lib/browser-compat.d.ts +14 -0
  29. package/dist/lib/browser-compat.d.ts.map +1 -0
  30. package/dist/lib/browser.d.ts +7 -8
  31. package/dist/lib/browser.d.ts.map +1 -1
  32. package/dist/lib/ci.d.ts +12 -0
  33. package/dist/lib/ci.d.ts.map +1 -1
  34. package/dist/lib/discovery.d.ts +23 -0
  35. package/dist/lib/discovery.d.ts.map +1 -0
  36. package/dist/lib/dom-mutation.d.ts +53 -0
  37. package/dist/lib/dom-mutation.d.ts.map +1 -0
  38. package/dist/lib/environment.d.ts +26 -0
  39. package/dist/lib/environment.d.ts.map +1 -0
  40. package/dist/lib/health-scan.d.ts +2 -1
  41. package/dist/lib/health-scan.d.ts.map +1 -1
  42. package/dist/lib/junit-export.d.ts +24 -0
  43. package/dist/lib/junit-export.d.ts.map +1 -0
  44. package/dist/lib/network-mock.d.ts +38 -0
  45. package/dist/lib/network-mock.d.ts.map +1 -0
  46. package/dist/lib/offline-mode.d.ts +31 -0
  47. package/dist/lib/offline-mode.d.ts.map +1 -0
  48. package/dist/lib/pdf-export.d.ts +27 -0
  49. package/dist/lib/pdf-export.d.ts.map +1 -0
  50. package/dist/lib/performance.d.ts +65 -0
  51. package/dist/lib/performance.d.ts.map +1 -0
  52. package/dist/lib/pr-comment.d.ts +27 -0
  53. package/dist/lib/pr-comment.d.ts.map +1 -0
  54. package/dist/lib/preview-detect.d.ts +27 -0
  55. package/dist/lib/preview-detect.d.ts.map +1 -0
  56. package/dist/lib/recorder.d.ts +42 -0
  57. package/dist/lib/recorder.d.ts.map +1 -1
  58. package/dist/lib/repo-discovery.d.ts +102 -0
  59. package/dist/lib/repo-discovery.d.ts.map +1 -0
  60. package/dist/lib/repo-executor.d.ts +56 -0
  61. package/dist/lib/repo-executor.d.ts.map +1 -0
  62. package/dist/lib/responsive.d.ts +43 -0
  63. package/dist/lib/responsive.d.ts.map +1 -0
  64. package/dist/lib/runner.d.ts +1 -0
  65. package/dist/lib/runner.d.ts.map +1 -1
  66. package/dist/lib/scenario-chain.d.ts +52 -0
  67. package/dist/lib/scenario-chain.d.ts.map +1 -0
  68. package/dist/lib/templates.d.ts.map +1 -1
  69. package/dist/lib/webhooks.d.ts +3 -0
  70. package/dist/lib/webhooks.d.ts.map +1 -1
  71. package/dist/mcp/index.js +491 -38
  72. package/dist/sdk/index.d.ts +47 -0
  73. package/dist/sdk/index.d.ts.map +1 -0
  74. package/dist/server/index.js +274 -28
  75. package/dist/types/index.d.ts +64 -2
  76. package/dist/types/index.d.ts.map +1 -1
  77. package/package.json +1 -1
package/dist/mcp/index.js CHANGED
@@ -10095,7 +10095,8 @@ function scenarioFromRow(row) {
10095
10095
  createdAt: row.created_at,
10096
10096
  updatedAt: row.updated_at,
10097
10097
  lastPassedAt: row.last_passed_at ?? null,
10098
- lastPassedUrl: row.last_passed_url ?? null
10098
+ lastPassedUrl: row.last_passed_url ?? null,
10099
+ parameters: row.parameters ? JSON.parse(row.parameters) : null
10099
10100
  };
10100
10101
  }
10101
10102
  function runFromRow(row) {
@@ -10115,7 +10116,14 @@ function runFromRow(row) {
10115
10116
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
10116
10117
  isBaseline: row.is_baseline === 1,
10117
10118
  samples: row.samples ?? 1,
10118
- flakinessThreshold: row.flakiness_threshold ?? 0.95
10119
+ flakinessThreshold: row.flakiness_threshold ?? 0.95,
10120
+ prNumber: row.pr_number ?? null,
10121
+ prTitle: row.pr_title ?? null,
10122
+ prBranch: row.pr_branch ?? null,
10123
+ prBaseBranch: row.pr_base_branch ?? null,
10124
+ prCommitSha: row.pr_commit_sha ?? null,
10125
+ prUrl: row.pr_url ?? null,
10126
+ ghAppInstallationId: row.gh_app_installation_id ?? null
10119
10127
  };
10120
10128
  }
10121
10129
  function resultFromRow(row) {
@@ -10136,7 +10144,8 @@ function resultFromRow(row) {
10136
10144
  createdAt: row.created_at,
10137
10145
  personaId: row.persona_id ?? null,
10138
10146
  personaName: row.persona_name ?? null,
10139
- failureAnalysis: row.failure_analysis ? JSON.parse(row.failure_analysis) : null
10147
+ failureAnalysis: row.failure_analysis ? JSON.parse(row.failure_analysis) : null,
10148
+ harPath: row.har_path ?? null
10140
10149
  };
10141
10150
  }
10142
10151
  function screenshotFromRow(row) {
@@ -10228,7 +10237,10 @@ function personaFromRow(row) {
10228
10237
  email: row.auth_email,
10229
10238
  password: row.auth_password,
10230
10239
  loginPath: row.auth_login_path ?? "/login",
10231
- cookies: row.auth_cookies ? JSON.parse(row.auth_cookies) : null
10240
+ cookies: row.auth_cookies ? JSON.parse(row.auth_cookies) : null,
10241
+ strategy: row.auth_strategy ?? "form-login",
10242
+ headers: row.auth_headers ? JSON.parse(row.auth_headers) : undefined,
10243
+ customScript: row.auth_script ?? undefined
10232
10244
  } : null
10233
10245
  };
10234
10246
  }
@@ -10776,6 +10788,43 @@ ALTER TABLE scenarios ADD COLUMN required_role TEXT;
10776
10788
  machine_id TEXT,
10777
10789
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
10778
10790
  );
10791
+ `,
10792
+ `
10793
+ ALTER TABLE results ADD COLUMN har_path TEXT;
10794
+ `,
10795
+ `
10796
+ ALTER TABLE scenarios ADD COLUMN parameters TEXT;
10797
+ `,
10798
+ `
10799
+ ALTER TABLE personas ADD COLUMN auth_strategy TEXT DEFAULT 'form-login';
10800
+ ALTER TABLE personas ADD COLUMN auth_headers TEXT;
10801
+ ALTER TABLE personas ADD COLUMN auth_script TEXT;
10802
+ `,
10803
+ `
10804
+ CREATE TABLE IF NOT EXISTS step_results (
10805
+ id TEXT PRIMARY KEY,
10806
+ result_id TEXT NOT NULL REFERENCES results(id) ON DELETE CASCADE,
10807
+ step_number INTEGER NOT NULL,
10808
+ action TEXT NOT NULL,
10809
+ status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('passed','failed','error','running','skipped')),
10810
+ tool_name TEXT,
10811
+ tool_input TEXT,
10812
+ tool_result TEXT,
10813
+ thinking TEXT,
10814
+ error TEXT,
10815
+ duration_ms INTEGER,
10816
+ screenshot_id TEXT REFERENCES screenshots(id),
10817
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
10818
+ );
10819
+ `,
10820
+ `
10821
+ ALTER TABLE runs ADD COLUMN pr_number INTEGER;
10822
+ ALTER TABLE runs ADD COLUMN pr_title TEXT;
10823
+ ALTER TABLE runs ADD COLUMN pr_branch TEXT;
10824
+ ALTER TABLE runs ADD COLUMN pr_base_branch TEXT;
10825
+ ALTER TABLE runs ADD COLUMN pr_commit_sha TEXT;
10826
+ ALTER TABLE runs ADD COLUMN pr_url TEXT;
10827
+ ALTER TABLE runs ADD COLUMN gh_app_installation_id TEXT;
10779
10828
  `
10780
10829
  ];
10781
10830
  });
@@ -10799,9 +10848,9 @@ function createScenario(input) {
10799
10848
  const short_id = nextShortId(input.projectId);
10800
10849
  const timestamp = now();
10801
10850
  db2.query(`
10802
- INSERT INTO scenarios (id, short_id, project_id, name, description, steps, tags, priority, model, timeout_ms, target_path, requires_auth, auth_config, metadata, assertions, version, created_at, updated_at)
10803
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
10804
- `).run(id, short_id, input.projectId ?? null, input.name, input.description, JSON.stringify(input.steps ?? []), JSON.stringify(input.tags ?? []), input.priority ?? "medium", input.model ?? null, input.timeoutMs ?? null, input.targetPath ?? null, input.requiresAuth ? 1 : 0, input.authConfig ? JSON.stringify(input.authConfig) : null, input.metadata ? JSON.stringify(input.metadata) : null, JSON.stringify(input.assertions ?? []), timestamp, timestamp);
10851
+ INSERT INTO scenarios (id, short_id, project_id, name, description, steps, tags, priority, model, timeout_ms, target_path, requires_auth, auth_config, metadata, assertions, parameters, version, created_at, updated_at)
10852
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
10853
+ `).run(id, short_id, input.projectId ?? null, input.name, input.description, JSON.stringify(input.steps ?? []), JSON.stringify(input.tags ?? []), input.priority ?? "medium", input.model ?? null, input.timeoutMs ?? null, input.targetPath ?? null, input.requiresAuth ? 1 : 0, input.authConfig ? JSON.stringify(input.authConfig) : null, input.metadata ? JSON.stringify(input.metadata) : null, JSON.stringify(input.assertions ?? []), input.parameters ? JSON.stringify(input.parameters) : null, timestamp, timestamp);
10805
10854
  return getScenario(id);
10806
10855
  }
10807
10856
  function getScenario(id) {
@@ -10951,6 +11000,10 @@ function updateScenario(id, input, version) {
10951
11000
  sets.push("assertions = ?");
10952
11001
  params.push(JSON.stringify(input.assertions));
10953
11002
  }
11003
+ if (input.parameters !== undefined) {
11004
+ sets.push("parameters = ?");
11005
+ params.push(JSON.stringify(input.parameters));
11006
+ }
10954
11007
  if (sets.length === 0) {
10955
11008
  return existing;
10956
11009
  }
@@ -11005,9 +11058,9 @@ function createRun(input) {
11005
11058
  const id = uuid();
11006
11059
  const timestamp = now();
11007
11060
  db2.query(`
11008
- INSERT INTO runs (id, project_id, status, url, model, headed, parallel, total, passed, failed, started_at, finished_at, metadata, samples, flakiness_threshold)
11009
- VALUES (?, ?, 'pending', ?, ?, ?, ?, 0, 0, 0, ?, NULL, ?, ?, ?)
11010
- `).run(id, input.projectId ?? null, input.url, input.model, input.headed ? 1 : 0, input.parallel ?? 1, timestamp, input.model ? JSON.stringify({}) : null, input.samples ?? 1, input.flakinessThreshold ?? 0.95);
11061
+ INSERT INTO runs (id, project_id, status, url, model, headed, parallel, total, passed, failed, started_at, finished_at, metadata, samples, flakiness_threshold, pr_number, pr_title, pr_branch, pr_base_branch, pr_commit_sha, pr_url, gh_app_installation_id)
11062
+ VALUES (?, ?, 'pending', ?, ?, ?, ?, 0, 0, 0, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
11063
+ `).run(id, input.projectId ?? null, input.url, input.model, input.headed ? 1 : 0, input.parallel ?? 1, timestamp, JSON.stringify({}), input.samples ?? 1, input.flakinessThreshold ?? 0.95, input.prNumber ?? null, input.prTitle ?? null, input.prBranch ?? null, input.prBaseBranch ?? null, input.prCommitSha ?? null, input.prUrl ?? null, input.ghAppInstallationId ?? null);
11011
11064
  return getRun(id);
11012
11065
  }
11013
11066
  function getRun(id) {
@@ -11035,6 +11088,14 @@ function listRuns(filter) {
11035
11088
  conditions.push("status = ?");
11036
11089
  params.push(filter.status);
11037
11090
  }
11091
+ if (filter?.since) {
11092
+ conditions.push("started_at >= ?");
11093
+ params.push(filter.since);
11094
+ }
11095
+ if (filter?.until) {
11096
+ conditions.push("started_at <= ?");
11097
+ params.push(filter.until);
11098
+ }
11038
11099
  let sql = "SELECT * FROM runs";
11039
11100
  if (conditions.length > 0) {
11040
11101
  sql += " WHERE " + conditions.join(" AND ");
@@ -11123,6 +11184,15 @@ var init_runs = __esm(() => {
11123
11184
  });
11124
11185
 
11125
11186
  // src/db/results.ts
11187
+ var exports_results = {};
11188
+ __export(exports_results, {
11189
+ updateResult: () => updateResult,
11190
+ listResults: () => listResults,
11191
+ getResultsByRun: () => getResultsByRun,
11192
+ getResult: () => getResult,
11193
+ createResult: () => createResult,
11194
+ countResultsByRun: () => countResultsByRun
11195
+ });
11126
11196
  function createResult(input) {
11127
11197
  const db2 = getDatabase();
11128
11198
  const id = uuid();
@@ -11205,6 +11275,11 @@ function updateResult(id, updates) {
11205
11275
  function getResultsByRun(runId) {
11206
11276
  return listResults(runId);
11207
11277
  }
11278
+ function countResultsByRun(runId) {
11279
+ const db2 = getDatabase();
11280
+ const row = db2.query("SELECT COUNT(*) as count FROM results WHERE run_id = ?").get(runId);
11281
+ return row.count;
11282
+ }
11208
11283
  var init_results = __esm(() => {
11209
11284
  init_types2();
11210
11285
  init_database();
@@ -11882,6 +11957,16 @@ async function launchBrowser(options) {
11882
11957
  const headless = options?.headless ?? true;
11883
11958
  const viewport = options?.viewport ?? DEFAULT_VIEWPORT;
11884
11959
  try {
11960
+ if (engine === "playwright-firefox") {
11961
+ const { firefox } = await import("playwright");
11962
+ const browser = await firefox.launch({ headless });
11963
+ return browser;
11964
+ }
11965
+ if (engine === "playwright-webkit") {
11966
+ const { webkit } = await import("playwright");
11967
+ const browser = await webkit.launch({ headless });
11968
+ return browser;
11969
+ }
11885
11970
  return await launchPlaywright({ headless, viewport });
11886
11971
  } catch (error) {
11887
11972
  const message = error instanceof Error ? error.message : String(error);
@@ -11998,8 +12083,9 @@ async function installBrowser(engine) {
11998
12083
  const { installLightpanda: installLightpanda2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
11999
12084
  return installLightpanda2();
12000
12085
  }
12086
+ const browserName = engine === "playwright-firefox" ? "firefox" : engine === "playwright-webkit" ? "webkit" : "chromium";
12001
12087
  try {
12002
- execSync("bunx playwright install chromium", {
12088
+ execSync(`bunx playwright install ${browserName}`, {
12003
12089
  stdio: "inherit"
12004
12090
  });
12005
12091
  } catch (error) {
@@ -12137,7 +12223,7 @@ function getDefaultConfig() {
12137
12223
  browser: {
12138
12224
  headless: true,
12139
12225
  viewport: { width: 1280, height: 720 },
12140
- timeout: 60000
12226
+ timeout: 120000
12141
12227
  },
12142
12228
  screenshots: {
12143
12229
  dir: join9(getTestersDir(), "screenshots"),
@@ -14551,6 +14637,76 @@ var init_costs = __esm(() => {
14551
14637
  };
14552
14638
  });
14553
14639
 
14640
+ // src/db/step-results.ts
14641
+ function createStepResult(input) {
14642
+ const db2 = getDatabase();
14643
+ const id = uuid();
14644
+ const timestamp = now();
14645
+ db2.query(`
14646
+ INSERT INTO step_results (id, result_id, step_number, action, status, tool_name, tool_input, thinking, created_at)
14647
+ VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?)
14648
+ `).run(id, input.resultId, input.stepNumber, input.action, input.toolName ?? null, input.toolInput ? JSON.stringify(input.toolInput) : null, input.thinking ?? null, timestamp);
14649
+ return getStepResult(id);
14650
+ }
14651
+ function getStepResult(id) {
14652
+ const db2 = getDatabase();
14653
+ const row = db2.query("SELECT * FROM step_results WHERE id = ?").get(id);
14654
+ return row ? stepResultFromRow(row) : null;
14655
+ }
14656
+ function updateStepResult(id, updates) {
14657
+ const db2 = getDatabase();
14658
+ const existing = getStepResult(id);
14659
+ if (!existing)
14660
+ return null;
14661
+ const sets = [];
14662
+ const params = [];
14663
+ if (updates.status !== undefined) {
14664
+ sets.push("status = ?");
14665
+ params.push(updates.status);
14666
+ }
14667
+ if (updates.toolResult !== undefined) {
14668
+ sets.push("tool_result = ?");
14669
+ params.push(updates.toolResult);
14670
+ }
14671
+ if (updates.error !== undefined) {
14672
+ sets.push("error = ?");
14673
+ params.push(updates.error);
14674
+ }
14675
+ if (updates.durationMs !== undefined) {
14676
+ sets.push("duration_ms = ?");
14677
+ params.push(updates.durationMs);
14678
+ }
14679
+ if (updates.screenshotId !== undefined) {
14680
+ sets.push("screenshot_id = ?");
14681
+ params.push(updates.screenshotId);
14682
+ }
14683
+ if (sets.length === 0)
14684
+ return existing;
14685
+ params.push(id);
14686
+ db2.query(`UPDATE step_results SET ${sets.join(", ")} WHERE id = ?`).run(...params);
14687
+ return getStepResult(id);
14688
+ }
14689
+ function stepResultFromRow(row) {
14690
+ return {
14691
+ id: row.id,
14692
+ resultId: row.result_id,
14693
+ stepNumber: row.step_number,
14694
+ action: row.action,
14695
+ status: row.status,
14696
+ toolName: row.tool_name,
14697
+ toolInput: row.tool_input ? JSON.parse(row.tool_input) : null,
14698
+ toolResult: row.tool_result,
14699
+ thinking: row.thinking,
14700
+ error: row.error,
14701
+ durationMs: row.duration_ms,
14702
+ screenshotId: row.screenshot_id,
14703
+ createdAt: row.created_at
14704
+ };
14705
+ }
14706
+ var init_step_results = __esm(() => {
14707
+ init_database();
14708
+ });
14709
+
14554
14710
  // src/db/personas.ts
14555
14711
  function createPersona(input) {
14556
14712
  const db2 = getDatabase();
@@ -14558,9 +14714,9 @@ function createPersona(input) {
14558
14714
  const short_id = shortUuid();
14559
14715
  const timestamp = now();
14560
14716
  db2.query(`
14561
- INSERT INTO personas (id, short_id, project_id, name, description, role, instructions, traits, goals, behaviors, expertise_level, demographics, pain_points, metadata, enabled, auth_email, auth_password, auth_login_path, version, created_at, updated_at)
14562
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
14563
- `).run(id, short_id, input.projectId ?? null, input.name, input.description ?? "", input.role, input.instructions ?? "", JSON.stringify(input.traits ?? []), JSON.stringify(input.goals ?? []), JSON.stringify(input.behaviors ?? []), input.expertiseLevel ?? "intermediate", JSON.stringify(input.demographics ?? {}), JSON.stringify(input.painPoints ?? []), input.metadata ? JSON.stringify(input.metadata) : "{}", input.enabled === false ? 0 : 1, input.authEmail ?? null, input.authPassword ?? null, input.authLoginPath ?? null, timestamp, timestamp);
14717
+ INSERT INTO personas (id, short_id, project_id, name, description, role, instructions, traits, goals, behaviors, expertise_level, demographics, pain_points, metadata, enabled, auth_email, auth_password, auth_login_path, auth_cookies, auth_strategy, auth_headers, auth_script, version, created_at, updated_at)
14718
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
14719
+ `).run(id, short_id, input.projectId ?? null, input.name, input.description ?? "", input.role, input.instructions ?? "", JSON.stringify(input.traits ?? []), JSON.stringify(input.goals ?? []), JSON.stringify(input.behaviors ?? []), input.expertiseLevel ?? "intermediate", JSON.stringify(input.demographics ?? {}), JSON.stringify(input.painPoints ?? []), input.metadata ? JSON.stringify(input.metadata) : "{}", input.enabled === false ? 0 : 1, input.authEmail ?? null, input.authPassword ?? null, input.authLoginPath ?? null, null, input.authStrategy ?? "form-login", input.authHeaders ? JSON.stringify(input.authHeaders) : null, input.authCustomScript ?? null, timestamp, timestamp);
14564
14720
  return getPersona(id);
14565
14721
  }
14566
14722
  function getPersona(id) {
@@ -14678,6 +14834,18 @@ function updatePersona(id, updates, version) {
14678
14834
  sets.push("auth_cookies = ?");
14679
14835
  params.push(updates.authCookies ? JSON.stringify(updates.authCookies) : null);
14680
14836
  }
14837
+ if (updates.authStrategy !== undefined) {
14838
+ sets.push("auth_strategy = ?");
14839
+ params.push(updates.authStrategy);
14840
+ }
14841
+ if (updates.authHeaders !== undefined) {
14842
+ sets.push("auth_headers = ?");
14843
+ params.push(JSON.stringify(updates.authHeaders));
14844
+ }
14845
+ if (updates.authCustomScript !== undefined) {
14846
+ sets.push("auth_script = ?");
14847
+ params.push(updates.authCustomScript);
14848
+ }
14681
14849
  if (sets.length === 0) {
14682
14850
  return existing;
14683
14851
  }
@@ -15199,6 +15367,24 @@ function signPayload(body, secret) {
15199
15367
  }
15200
15368
  return `sha256=${Math.abs(hash).toString(16).padStart(16, "0")}`;
15201
15369
  }
15370
+ function formatDiscordPayload(payload) {
15371
+ const isPassed = payload.run.status === "passed";
15372
+ const color = isPassed ? 2278750 : 15680580;
15373
+ return {
15374
+ username: "open-testers",
15375
+ embeds: [
15376
+ {
15377
+ title: `Test Run ${payload.run.status.toUpperCase()}`,
15378
+ color,
15379
+ description: `URL: ${payload.run.url}
15380
+ ` + `Results: ${payload.run.passed}/${payload.run.total} passed` + (payload.run.failed > 0 ? ` (${payload.run.failed} failed)` : "") + (payload.schedule ? `
15381
+ Schedule: ${payload.schedule.name}` : ""),
15382
+ timestamp: payload.timestamp,
15383
+ footer: { text: "open-testers" }
15384
+ }
15385
+ ]
15386
+ };
15387
+ }
15202
15388
  function formatSlackPayload(payload) {
15203
15389
  const status = payload.run.status === "passed" ? ":white_check_mark:" : ":x:";
15204
15390
  const color = payload.run.status === "passed" ? "#22c55e" : "#ef4444";
@@ -15241,7 +15427,8 @@ async function dispatchWebhooks(event, run, schedule) {
15241
15427
  if (!webhook.events.includes(event) && !webhook.events.includes("*"))
15242
15428
  continue;
15243
15429
  const isSlack = webhook.url.includes("hooks.slack.com");
15244
- const body = isSlack ? JSON.stringify(formatSlackPayload(payload)) : JSON.stringify(payload);
15430
+ const isDiscord = webhook.url.includes("discord.com/api/webhooks") || webhook.url.includes("discordapp.com/api/webhooks");
15431
+ const body = isSlack ? JSON.stringify(formatSlackPayload(payload)) : isDiscord ? JSON.stringify(formatDiscordPayload(payload)) : JSON.stringify(payload);
15245
15432
  const headers = {
15246
15433
  "Content-Type": "application/json"
15247
15434
  };
@@ -15751,6 +15938,8 @@ __export(exports_runner, {
15751
15938
  runBatch: () => runBatch,
15752
15939
  onRunEvent: () => onRunEvent
15753
15940
  });
15941
+ import { mkdirSync as mkdirSync8 } from "fs";
15942
+ import { join as join13 } from "path";
15754
15943
  import { enableNetworkLogging } from "@hasna/browser";
15755
15944
  function onRunEvent(handler) {
15756
15945
  eventHandler = handler;
@@ -15836,13 +16025,35 @@ async function runSingleScenario(scenario, runId, options) {
15836
16025
  emit({ type: "scenario:start", scenarioId: scenario.id, scenarioName: scenario.name, resultId: result.id, runId });
15837
16026
  let browser = null;
15838
16027
  let page = null;
16028
+ let context = null;
16029
+ let harPath = null;
15839
16030
  let stopNetworkLogging = null;
15840
16031
  const networkErrors = [];
15841
16032
  try {
15842
16033
  browser = await launchBrowser({ headless: !(effectiveOptions.headed ?? false), engine: effectiveOptions.engine });
15843
- page = await getPage(browser, {
15844
- viewport: config.browser.viewport
15845
- });
16034
+ const useHar = effectiveOptions.engine !== "lightpanda" && effectiveOptions.engine !== "bun";
16035
+ if (useHar) {
16036
+ const testersDir = process.env["HASNA_TESTERS_DIR"] || join13(process.env["HOME"] || "", ".hasna", "testers");
16037
+ const harDir = join13(testersDir, "hars");
16038
+ mkdirSync8(harDir, { recursive: true });
16039
+ harPath = join13(harDir, `${result.id}.har`);
16040
+ const contextOptions = {
16041
+ viewport: config.browser.viewport,
16042
+ recordHar: { path: harPath, mode: "full" }
16043
+ };
16044
+ if (effectiveOptions.recordVideo) {
16045
+ const videoDir = join13(testersDir, "videos");
16046
+ mkdirSync8(videoDir, { recursive: true });
16047
+ contextOptions.recordVideo = { dir: videoDir, size: config.browser.viewport };
16048
+ }
16049
+ context = await browser.newContext(contextOptions);
16050
+ page = await context.newPage();
16051
+ } else {
16052
+ page = await getPage(browser, {
16053
+ viewport: config.browser.viewport,
16054
+ engine: effectiveOptions.engine
16055
+ });
16056
+ }
15846
16057
  const targetUrl = scenario.targetPath ? `${options.url.replace(/\/$/, "")}${scenario.targetPath}` : options.url;
15847
16058
  const scenarioTimeout = scenario.timeoutMs ?? options.timeout ?? config.browser.timeout ?? 60000;
15848
16059
  registerSession({
@@ -15862,7 +16073,11 @@ async function runSingleScenario(scenario, runId, options) {
15862
16073
  }
15863
16074
  });
15864
16075
  const consoleErrors = [];
16076
+ const consoleLogs = [];
16077
+ let currentStep = 0;
15865
16078
  page.on("console", (msg) => {
16079
+ const logEntry = { step: currentStep > 0 ? currentStep : null, type: msg.type(), text: msg.text(), timestamp: Date.now() };
16080
+ consoleLogs.push(logEntry);
15866
16081
  if (msg.type() === "error")
15867
16082
  consoleErrors.push(msg.text());
15868
16083
  });
@@ -15894,6 +16109,7 @@ async function runSingleScenario(scenario, runId, options) {
15894
16109
  }
15895
16110
  await page.goto(targetUrl, { timeout: Math.min(scenarioTimeout, 30000) });
15896
16111
  const stepStartTimes = new Map;
16112
+ const stepResultIds = new Map;
15897
16113
  const agentResult = await withTimeout(runAgentLoop({
15898
16114
  client,
15899
16115
  page,
@@ -15916,13 +16132,32 @@ async function runSingleScenario(scenario, runId, options) {
15916
16132
  onStep: (stepEvent) => {
15917
16133
  let stepDurationMs;
15918
16134
  if (stepEvent.type === "tool_call") {
16135
+ currentStep = stepEvent.stepNumber;
15919
16136
  stepStartTimes.set(stepEvent.stepNumber, Date.now());
16137
+ const stepResult = createStepResult({
16138
+ resultId: result.id,
16139
+ stepNumber: stepEvent.stepNumber,
16140
+ action: stepEvent.toolName ?? `step-${stepEvent.stepNumber}`,
16141
+ toolName: stepEvent.toolName,
16142
+ toolInput: stepEvent.toolInput,
16143
+ thinking: stepEvent.thinking
16144
+ });
16145
+ stepResultIds.set(stepEvent.stepNumber, stepResult.id);
15920
16146
  } else if (stepEvent.type === "tool_result") {
15921
16147
  const startTime = stepStartTimes.get(stepEvent.stepNumber);
15922
16148
  if (startTime !== undefined) {
15923
16149
  stepDurationMs = Date.now() - startTime;
15924
16150
  stepStartTimes.delete(stepEvent.stepNumber);
15925
16151
  }
16152
+ const stepResultId = stepResultIds.get(stepEvent.stepNumber);
16153
+ if (stepResultId) {
16154
+ const isSuccess = !stepEvent.toolResult?.toLowerCase().includes("error") && !stepEvent.toolResult?.toLowerCase().includes("failed");
16155
+ updateStepResult(stepResultId, {
16156
+ status: isSuccess ? "passed" : "failed",
16157
+ toolResult: stepEvent.toolResult,
16158
+ durationMs: stepDurationMs
16159
+ });
16160
+ }
15926
16161
  }
15927
16162
  emit({
15928
16163
  type: `step:${stepEvent.type}`,
@@ -15971,7 +16206,7 @@ async function runSingleScenario(scenario, runId, options) {
15971
16206
  durationMs: Date.now() - new Date(result.createdAt).getTime(),
15972
16207
  tokensUsed: agentResult.tokensUsed,
15973
16208
  costCents: estimateCost(model, agentResult.tokensUsed),
15974
- metadata: networkErrors.length > 0 ? networkMeta : undefined
16209
+ metadata: { consoleLogs, ...networkErrors.length > 0 ? networkMeta : {} }
15975
16210
  });
15976
16211
  if (agentResult.status === "failed" || agentResult.status === "error") {
15977
16212
  const failureAnalysis = analyzeFailure(null, agentResult.reasoning ?? null);
@@ -16007,8 +16242,16 @@ async function runSingleScenario(scenario, runId, options) {
16007
16242
  emit({ type: "scenario:error", scenarioId: scenario.id, scenarioName: scenario.name, error: errorMsg, runId });
16008
16243
  return updatedResult;
16009
16244
  } finally {
16010
- if (browser)
16011
- await closeBrowser(browser, effectiveOptions.engine);
16245
+ if (harPath) {
16246
+ try {
16247
+ updateResult(result.id, { metadata: { harPath } });
16248
+ } catch {}
16249
+ }
16250
+ if (browser) {
16251
+ try {
16252
+ await closeBrowser(browser, effectiveOptions.engine);
16253
+ } catch {}
16254
+ }
16012
16255
  }
16013
16256
  }
16014
16257
  async function runBatch(scenarios, options) {
@@ -16304,6 +16547,7 @@ var init_runner = __esm(() => {
16304
16547
  init_results();
16305
16548
  init_costs();
16306
16549
  init_screenshots();
16550
+ init_step_results();
16307
16551
  init_scenarios();
16308
16552
  init_personas();
16309
16553
  init_browser();
@@ -17341,6 +17585,16 @@ async function runHealthScan(options) {
17341
17585
  });
17342
17586
  results.push(piiResult);
17343
17587
  }
17588
+ if (scanners.includes("a11y")) {
17589
+ const a11yResult = await scanA11y({
17590
+ url,
17591
+ pages,
17592
+ wcagLevel: options.wcagLevel ?? "AA",
17593
+ headed,
17594
+ timeoutMs
17595
+ });
17596
+ results.push(a11yResult);
17597
+ }
17344
17598
  const allIssues = results.flatMap((r) => r.issues);
17345
17599
  let newCount = 0;
17346
17600
  let regressedCount = 0;
@@ -17556,10 +17810,10 @@ __export(exports_contacts_connector, {
17556
17810
  function getContactsDb() {
17557
17811
  const { Database: Database4 } = __require("bun:sqlite");
17558
17812
  const { existsSync: existsSync5 } = __require("fs");
17559
- const { join: join13 } = __require("path");
17813
+ const { join: join14 } = __require("path");
17560
17814
  const { homedir: homedir7 } = __require("os");
17561
17815
  const envPath = process.env["HASNA_CONTACTS_DB_PATH"] ?? process.env["OPEN_CONTACTS_DB"];
17562
- const dbPath = envPath ?? join13(homedir7(), ".hasna", "contacts", "contacts.db");
17816
+ const dbPath = envPath ?? join14(homedir7(), ".hasna", "contacts", "contacts.db");
17563
17817
  if (!existsSync5(dbPath))
17564
17818
  return null;
17565
17819
  const db2 = new Database4(dbPath, { readonly: true });
@@ -17680,7 +17934,7 @@ __export(exports_army_runner, {
17680
17934
  waitForArmyRun: () => waitForArmyRun,
17681
17935
  runWithArmy: () => runWithArmy
17682
17936
  });
17683
- import { join as join13 } from "path";
17937
+ import { join as join14 } from "path";
17684
17938
  function chunkArray(arr, n) {
17685
17939
  const chunks = [];
17686
17940
  const size = Math.ceil(arr.length / n);
@@ -17690,7 +17944,7 @@ function chunkArray(arr, n) {
17690
17944
  return chunks;
17691
17945
  }
17692
17946
  function getCliPath() {
17693
- const srcPath = join13(import.meta.dir, "../cli/index.tsx");
17947
+ const srcPath = join14(import.meta.dir, "../cli/index.tsx");
17694
17948
  return srcPath;
17695
17949
  }
17696
17950
  async function runWithArmy(options) {
@@ -22002,6 +22256,54 @@ var NEVER = INVALID;
22002
22256
  // src/mcp/index.ts
22003
22257
  init_dist();
22004
22258
  init_scenarios();
22259
+
22260
+ // src/lib/templates.ts
22261
+ var SCENARIO_TEMPLATES = {
22262
+ auth: [
22263
+ { name: "Login with valid credentials", description: "Navigate to the login page, enter valid credentials, submit the form, and verify redirect to authenticated area. Check that user menu/avatar is visible.", tags: ["auth", "smoke"], priority: "critical", requiresAuth: false, steps: ["Navigate to login page", "Enter email and password", "Submit login form", "Verify redirect to dashboard/home", "Verify user menu or avatar is visible"] },
22264
+ { name: "Signup flow", description: "Navigate to signup page, fill all required fields with valid data, submit, and verify account creation succeeds.", tags: ["auth"], priority: "high", steps: ["Navigate to signup page", "Fill all required fields", "Submit registration form", "Verify success message or redirect"] },
22265
+ { name: "Logout flow", description: "While authenticated, find and click the logout button/link, verify redirect to public page.", tags: ["auth"], priority: "medium", requiresAuth: true, steps: ["Click user menu or profile", "Click logout", "Verify redirect to login or home page"] }
22266
+ ],
22267
+ crud: [
22268
+ { name: "Create new item", description: "Navigate to the create form, fill all fields, submit, and verify the new item appears in the list.", tags: ["crud"], priority: "high", steps: ["Navigate to the list/index page", "Click create/add button", "Fill all required fields", "Submit the form", "Verify new item appears in list"] },
22269
+ { name: "Read/view item details", description: "Click on an existing item to view its details page. Verify all fields are displayed correctly.", tags: ["crud"], priority: "medium", steps: ["Navigate to list page", "Click on an item", "Verify detail page shows all fields"] },
22270
+ { name: "Update existing item", description: "Edit an existing item, change some fields, save, and verify changes persisted.", tags: ["crud"], priority: "high", steps: ["Navigate to item detail", "Click edit button", "Modify fields", "Save changes", "Verify updated values"] },
22271
+ { name: "Delete item", description: "Delete an existing item and verify it's removed from the list.", tags: ["crud"], priority: "medium", steps: ["Navigate to list page", "Click delete on an item", "Confirm deletion", "Verify item removed from list"] }
22272
+ ],
22273
+ forms: [
22274
+ { name: "Form validation - empty submission", description: "Submit a form with all fields empty and verify validation errors appear for required fields.", tags: ["forms", "validation"], priority: "high", steps: ["Navigate to form page", "Click submit without filling fields", "Verify validation errors appear for each required field"] },
22275
+ { name: "Form validation - invalid data", description: "Submit a form with invalid data (bad email, short password, etc) and verify appropriate error messages.", tags: ["forms", "validation"], priority: "high", steps: ["Navigate to form page", "Enter invalid email format", "Enter too-short password", "Submit form", "Verify specific validation error messages"] },
22276
+ { name: "Form successful submission", description: "Fill form with valid data, submit, and verify success state (redirect, success message, or data saved).", tags: ["forms"], priority: "high", steps: ["Navigate to form page", "Fill all fields with valid data", "Submit form", "Verify success state"] }
22277
+ ],
22278
+ nav: [
22279
+ { name: "Main navigation links work", description: "Click through each main navigation link and verify each page loads correctly without errors.", tags: ["navigation", "smoke"], priority: "high", steps: ["Click each nav link", "Verify page loads", "Verify no error states", "Verify breadcrumbs if present"] },
22280
+ { name: "Mobile navigation", description: "At mobile viewport, verify hamburger menu opens, navigation links are accessible, and pages load correctly.", tags: ["navigation", "responsive"], priority: "medium", steps: ["Resize to mobile viewport", "Click hamburger/menu icon", "Verify nav links appear", "Click a nav link", "Verify page loads"] }
22281
+ ],
22282
+ a11y: [
22283
+ { name: "Keyboard navigation", description: "Navigate the page using only keyboard (Tab, Enter, Escape). Verify all interactive elements are reachable and focusable.", tags: ["a11y", "keyboard"], priority: "high", steps: ["Press Tab to move through elements", "Verify focus indicators are visible", "Press Enter on buttons/links", "Verify actions trigger correctly", "Press Escape to close modals/dropdowns"] },
22284
+ { name: "Image alt text", description: "Check that all images have meaningful alt text attributes.", tags: ["a11y"], priority: "medium", steps: ["Find all images on the page", "Check each image has an alt attribute", "Verify alt text is descriptive, not empty or generic"] }
22285
+ ],
22286
+ checkout: [
22287
+ { name: "Add item to cart", description: "Navigate to a product page, add an item to cart, and verify cart count increments.", tags: ["checkout", "cart"], priority: "critical", steps: ["Navigate to product detail page", "Click add to cart button", "Verify cart count badge updates", "Verify success notification appears"] },
22288
+ { name: "Cart page shows added items", description: "Navigate to the cart page and verify previously added items are listed with correct prices and quantities.", tags: ["checkout", "cart"], priority: "high", steps: ["Navigate to cart page", "Verify all added items are listed", "Verify prices and quantities are correct", "Verify subtotal is calculated"] },
22289
+ { name: "Checkout flow completion", description: "Complete the full checkout flow: cart -> shipping -> payment -> confirmation. Verify order is placed successfully.", tags: ["checkout", "smoke"], priority: "critical", steps: ["Navigate to cart", "Click proceed to checkout", "Fill shipping address", "Select shipping method", "Fill payment details", "Review order summary", "Place order", "Verify order confirmation page"] },
22290
+ { name: "Apply coupon/discount code", description: "Enter a valid coupon code on the cart or checkout page and verify discount is applied to the total.", tags: ["checkout", "discount"], priority: "high", steps: ["Navigate to cart or checkout", "Enter coupon code", "Click apply", "Verify discount amount is shown", "Verify total is updated"] }
22291
+ ],
22292
+ search: [
22293
+ { name: "Search returns relevant results", description: "Enter a known search term and verify relevant results appear. Check that results match the query intent.", tags: ["search"], priority: "high", steps: ["Navigate to search page or focus search bar", "Enter known search term", "Submit search", "Verify results appear", "Verify result titles contain search term"] },
22294
+ { name: "Empty search handling", description: "Submit an empty search and verify the system handles it gracefully (show all results or a prompt, no errors).", tags: ["search", "validation"], priority: "medium", steps: ["Focus search bar", "Submit empty search", "Verify no crash or error state", "Verify fallback behavior (all results or prompt)"] },
22295
+ { name: "No results handling", description: "Search for a term that returns no results and verify appropriate 'no results found' messaging is displayed.", tags: ["search"], priority: "medium", steps: ["Navigate to search", "Enter nonsense term like 'xyzqwrtp'", "Submit search", "Verify no results found message", "Verify no error states"] },
22296
+ { name: "Search filters work", description: "Perform a search, apply a filter (category, date, price range), and verify results are narrowed down.", tags: ["search", "filter"], priority: "high", steps: ["Perform initial search", "Note initial result count", "Apply a filter", "Verify result count decreased", "Verify results match filter criteria"] }
22297
+ ]
22298
+ };
22299
+ function getTemplate(name) {
22300
+ return SCENARIO_TEMPLATES[name] ?? null;
22301
+ }
22302
+ function listTemplateNames() {
22303
+ return Object.keys(SCENARIO_TEMPLATES);
22304
+ }
22305
+
22306
+ // src/mcp/index.ts
22005
22307
  init_runs();
22006
22308
  init_results();
22007
22309
  init_screenshots();
@@ -23078,15 +23380,81 @@ server.tool("create_scenario", "Create a new test scenario", {
23078
23380
  priority: exports_external.enum(["low", "medium", "high", "critical"]).optional().describe("Scenario priority"),
23079
23381
  model: exports_external.string().optional().describe(MODEL_DESC),
23080
23382
  targetPath: exports_external.string().optional().describe("URL path to navigate to"),
23081
- requiresAuth: exports_external.boolean().optional().describe("Whether scenario requires authentication")
23082
- }, async ({ name, description, steps, tags, priority, model, targetPath, requiresAuth }) => {
23383
+ requiresAuth: exports_external.boolean().optional().describe("Whether scenario requires authentication"),
23384
+ projectId: exports_external.string().optional().describe("Project ID to scope this scenario to")
23385
+ }, async ({ name, description, steps, tags, priority, model, targetPath, requiresAuth, projectId }) => {
23083
23386
  try {
23084
- const scenario = createScenario({ name, description, steps, tags, priority, model, targetPath, requiresAuth });
23387
+ const scenario = createScenario({ name, description, steps, tags, priority, model, targetPath, requiresAuth, projectId });
23085
23388
  return json(scenario);
23086
23389
  } catch (error) {
23087
23390
  return errorResponse(error);
23088
23391
  }
23089
23392
  });
23393
+ server.tool("batch_create_scenarios", "Create multiple test scenarios in a single call. Each item requires name and description.", {
23394
+ scenarios: exports_external.array(exports_external.object({
23395
+ name: exports_external.string().describe("Scenario name"),
23396
+ description: exports_external.string().describe("What this scenario tests"),
23397
+ steps: exports_external.array(exports_external.string()).optional().describe("Ordered test steps"),
23398
+ tags: exports_external.array(exports_external.string()).optional().describe("Tags for filtering"),
23399
+ priority: exports_external.enum(["low", "medium", "high", "critical"]).optional().describe("Scenario priority"),
23400
+ model: exports_external.string().optional().describe(MODEL_DESC),
23401
+ targetPath: exports_external.string().optional().describe("URL path to navigate to"),
23402
+ requiresAuth: exports_external.boolean().optional().describe("Whether scenario requires authentication")
23403
+ })).min(1).max(100).describe("Array of scenarios to create"),
23404
+ projectId: exports_external.string().optional().describe("Project ID to scope all scenarios to")
23405
+ }, async ({ scenarios, projectId }) => {
23406
+ try {
23407
+ const results = [];
23408
+ for (const s of scenarios) {
23409
+ try {
23410
+ const scenario = createScenario({ ...s, projectId });
23411
+ results.push({ id: scenario.id, name: scenario.name, shortId: scenario.shortId });
23412
+ } catch (e) {
23413
+ results.push({ id: "", name: s.name, shortId: "", error: e instanceof Error ? e.message : String(e) });
23414
+ }
23415
+ }
23416
+ const created = results.filter((r) => !r.error).length;
23417
+ const failed = results.filter((r) => r.error).length;
23418
+ return json({ created, failed, total: scenarios.length, results });
23419
+ } catch (error) {
23420
+ return errorResponse(error);
23421
+ }
23422
+ });
23423
+ server.tool("list_templates", "List built-in scenario templates available for quick setup. Use apply_template to create scenarios from a template.", {}, async () => {
23424
+ try {
23425
+ const templates = listTemplateNames().map((name) => {
23426
+ const scenarios = getTemplate(name);
23427
+ return { name, scenarioCount: scenarios.length, scenarios: scenarios.map((s) => ({ name: s.name, description: s.description, priority: s.priority, tags: s.tags })) };
23428
+ });
23429
+ return json({ templates });
23430
+ } catch (error) {
23431
+ return errorResponse(error);
23432
+ }
23433
+ });
23434
+ server.tool("apply_template", "Create scenarios from a built-in template. Returns the created scenario IDs and any errors.", {
23435
+ template: exports_external.string().describe("Template name to apply (auth, crud, forms, nav, a11y, checkout, search)"),
23436
+ projectId: exports_external.string().optional().describe("Project ID to scope scenarios to")
23437
+ }, async ({ template, projectId }) => {
23438
+ try {
23439
+ const scenarios = getTemplate(template);
23440
+ if (!scenarios)
23441
+ return errorResponse(new Error(`Template not found: ${template}. Available: ${listTemplateNames().join(", ")}`));
23442
+ const results = [];
23443
+ for (const s of scenarios) {
23444
+ try {
23445
+ const scenario = createScenario({ ...s, projectId });
23446
+ results.push({ id: scenario.id, name: scenario.name, shortId: scenario.shortId });
23447
+ } catch (e) {
23448
+ results.push({ id: "", name: s.name, shortId: "", error: e instanceof Error ? e.message : String(e) });
23449
+ }
23450
+ }
23451
+ const created = results.filter((r) => !r.error).length;
23452
+ const failed = results.filter((r) => r.error).length;
23453
+ return json({ template, created, failed, total: scenarios.length, results });
23454
+ } catch (error) {
23455
+ return errorResponse(error);
23456
+ }
23457
+ });
23090
23458
  server.tool("get_scenario", `Get a scenario by ID or short ID. ${ID_DESC}`, {
23091
23459
  id: exports_external.string().describe(`Scenario ID or short ID. ${ID_DESC}`)
23092
23460
  }, async ({ id }) => {
@@ -23157,12 +23525,15 @@ server.tool("run_scenarios", "Run test scenarios against a URL. Provide url dire
23157
23525
  headed: exports_external.boolean().optional().describe("Run browser in headed mode"),
23158
23526
  parallel: exports_external.number().optional().describe("Number of parallel workers"),
23159
23527
  personaId: exports_external.string().optional().describe("Override persona ID for this run"),
23528
+ personaIds: exports_external.array(exports_external.string()).optional().describe("Run with multiple personas for divergence testing (each persona runs all scenarios)"),
23160
23529
  samples: exports_external.number().int().min(1).max(20).optional().describe("Run each scenario N times for flakiness detection (default 1)"),
23161
23530
  flakinessThreshold: exports_external.number().min(0).max(1).optional().describe("Pass rate below which scenario is marked flaky (default 0.95)"),
23162
23531
  maxCostCents: exports_external.number().optional().describe("Hard budget cap in cents \u2014 run is rejected before starting if estimated cost exceeds this"),
23163
23532
  cacheMaxAgeMs: exports_external.number().optional().describe("Skip scenarios that passed at the same URL within this many ms (0 = disabled)"),
23164
- minimal: exports_external.boolean().optional().describe("Fastest mode: cheapest model, max parallelism, min turns \u2014 ideal for CI smoke checks")
23165
- }, async ({ url, env: env2, tags, scenarioIds, priority, model, headed, parallel, personaId, samples, flakinessThreshold, maxCostCents, cacheMaxAgeMs, minimal }) => {
23533
+ minimal: exports_external.boolean().optional().describe("Fastest mode: cheapest model, max parallelism, min turns \u2014 ideal for CI smoke checks"),
23534
+ timeoutMs: exports_external.number().optional().describe("Per-scenario timeout in ms (default 120000)"),
23535
+ recordVideo: exports_external.boolean().optional().describe("Record video of each scenario run (Playwright only)")
23536
+ }, async ({ url, env: env2, tags, scenarioIds, priority, model, headed, parallel, personaId, personaIds, samples, flakinessThreshold, maxCostCents, cacheMaxAgeMs, minimal, timeoutMs, recordVideo }) => {
23166
23537
  try {
23167
23538
  let resolvedUrl = url;
23168
23539
  if (!resolvedUrl && env2) {
@@ -23180,12 +23551,54 @@ server.tool("run_scenarios", "Run test scenarios against a URL. Provide url dire
23180
23551
  }
23181
23552
  if (!resolvedUrl)
23182
23553
  return errorResponse(new Error("No URL provided and no default environment set. Pass url or env."));
23183
- const { runId, scenarioCount } = startRunAsync({ url: resolvedUrl, tags, scenarioIds, priority, model, headed, parallel, personaId, samples, flakinessThreshold, maxCostCents, cacheMaxAgeMs, minimal });
23554
+ const { runId, scenarioCount } = startRunAsync({ url: resolvedUrl, tags, scenarioIds, priority, model, headed, parallel, personaId, personaIds, samples, flakinessThreshold, maxCostCents, cacheMaxAgeMs, minimal, timeout: timeoutMs, recordVideo });
23184
23555
  return json({ runId, scenarioCount, url: resolvedUrl, status: "running", message: "Poll with get_run to check progress." });
23185
23556
  } catch (error) {
23186
23557
  return errorResponse(error);
23187
23558
  }
23188
23559
  });
23560
+ server.tool("retry_failed", "Re-run only failed/errored scenarios from a previous run. Creates a new run with only the failing scenarios.", {
23561
+ runId: exports_external.string().describe("Previous run ID to retry failures from"),
23562
+ url: exports_external.string().optional().describe("Target URL (overrides original run URL)"),
23563
+ model: exports_external.string().optional().describe(MODEL_DESC),
23564
+ headed: exports_external.boolean().optional().describe("Run browser in headed mode"),
23565
+ parallel: exports_external.number().optional().describe("Number of parallel workers"),
23566
+ maxRetries: exports_external.number().int().min(0).max(3).optional().describe("Max retries per failed scenario"),
23567
+ maxCostCents: exports_external.number().optional().describe("Hard budget cap in cents")
23568
+ }, async ({ runId, url, model, headed, parallel, maxRetries, maxCostCents }) => {
23569
+ try {
23570
+ const run = getRun(runId);
23571
+ if (!run)
23572
+ return errorResponse(notFoundErr(runId, "Run"));
23573
+ if (run.status !== "failed")
23574
+ return errorResponse(new Error("Run is not in failed state. Can only retry failures from a failed run."));
23575
+ const results = getResultsByRun(runId);
23576
+ const failedResultIds = results.filter((r) => r.status === "failed" || r.status === "error").map((r) => r.scenarioId);
23577
+ if (failedResultIds.length === 0)
23578
+ return errorResponse(new Error("No failed results found in this run."));
23579
+ const resolvedUrl = url ?? run.url;
23580
+ const { runId: newRunId, scenarioCount } = startRunAsync({
23581
+ url: resolvedUrl,
23582
+ scenarioIds: failedResultIds,
23583
+ model,
23584
+ headed,
23585
+ parallel,
23586
+ retry: maxRetries ?? 0,
23587
+ maxCostCents
23588
+ });
23589
+ return json({
23590
+ runId: newRunId,
23591
+ originalRunId: runId,
23592
+ scenarioCount,
23593
+ retriedScenarioIds: failedResultIds,
23594
+ url: resolvedUrl,
23595
+ status: "running",
23596
+ message: "Poll with get_run to check progress."
23597
+ });
23598
+ } catch (error) {
23599
+ return errorResponse(error);
23600
+ }
23601
+ });
23189
23602
  server.tool("get_run", `Get details of a test run. ${ID_DESC}`, {
23190
23603
  id: exports_external.string().describe(`Run ID. ${ID_DESC}`)
23191
23604
  }, async ({ id }) => {
@@ -23199,11 +23612,17 @@ server.tool("get_run", `Get details of a test run. ${ID_DESC}`, {
23199
23612
  }
23200
23613
  });
23201
23614
  server.tool("list_runs", "List test runs with optional filters", {
23615
+ projectId: exports_external.string().optional().describe("Filter by project ID"),
23202
23616
  status: exports_external.enum(["pending", "running", "passed", "failed", "cancelled"]).optional().describe("Filter by status"),
23203
- limit: exports_external.number().optional().describe("Max results to return")
23204
- }, async ({ status, limit }) => {
23617
+ since: exports_external.string().optional().describe("Filter runs started at or after this ISO date"),
23618
+ until: exports_external.string().optional().describe("Filter runs started at or before this ISO date"),
23619
+ sort: exports_external.enum(["date", "duration", "cost"]).optional().describe("Sort field"),
23620
+ desc: exports_external.boolean().optional().describe("Sort descending (default true)"),
23621
+ limit: exports_external.number().optional().describe("Max results to return"),
23622
+ offset: exports_external.number().optional().describe("Number of results to skip")
23623
+ }, async ({ projectId, status, since, until, sort, desc, limit, offset }) => {
23205
23624
  try {
23206
- const runs = listRuns({ status, limit });
23625
+ const runs = listRuns({ projectId, status, since, until, sort, desc, limit, offset });
23207
23626
  return json({ items: runs, total: runs.length });
23208
23627
  } catch (error) {
23209
23628
  return errorResponse(error);
@@ -23597,14 +24016,15 @@ server.tool("run_health_scan", "Run all scanners (console, network, links, perfo
23597
24016
  url: exports_external.string().describe("URL to scan"),
23598
24017
  pages: exports_external.array(exports_external.string()).optional().describe("Specific paths to include"),
23599
24018
  projectId: exports_external.string().optional().describe("Project ID"),
23600
- scanners: exports_external.array(exports_external.enum(["console", "network", "links", "performance"])).optional().describe("Which scanners to run (default: console, network, links)"),
24019
+ scanners: exports_external.array(exports_external.enum(["console", "network", "links", "performance", "a11y"])).optional().describe("Which scanners to run (default: console, network, links)"),
23601
24020
  maxPages: exports_external.number().optional().describe("Max pages for link crawl (default 20)"),
24021
+ wcagLevel: exports_external.enum(["A", "AA", "AAA"]).optional().describe("WCAG compliance level for a11y scanner (default: AA)"),
23602
24022
  headed: exports_external.boolean().optional(),
23603
24023
  timeoutMs: exports_external.number().optional()
23604
- }, async ({ url, pages, projectId, scanners, maxPages, headed, timeoutMs }) => {
24024
+ }, async ({ url, pages, projectId, scanners, maxPages, headed, timeoutMs, wcagLevel }) => {
23605
24025
  try {
23606
24026
  const { runHealthScan: runHealthScan2 } = await Promise.resolve().then(() => (init_health_scan(), exports_health_scan));
23607
- const summary = await runHealthScan2({ url, pages, projectId, scanners, maxPages, headed, timeoutMs });
24027
+ const summary = await runHealthScan2({ url, pages, projectId, scanners, maxPages, headed, timeoutMs, wcagLevel });
23608
24028
  return json(summary);
23609
24029
  } catch (e) {
23610
24030
  return errorResponse(e);
@@ -24362,6 +24782,32 @@ Context: ${context}` : ""}`,
24362
24782
  return errorResponse(e);
24363
24783
  }
24364
24784
  });
24785
+ server.tool("get_har", `Get HAR (HTTP Archive) file for a test result. Returns metadata by default, or full HAR content when includeContent is true. Useful for debugging network requests, API calls, and CORS issues. ${ID_DESC}`, {
24786
+ resultId: exports_external.string().describe(`Result ID. ${ID_DESC}`),
24787
+ includeContent: exports_external.boolean().optional().describe("Return full HAR JSON content (default: false)")
24788
+ }, async ({ resultId, includeContent }) => {
24789
+ try {
24790
+ const { getResult: getResult2 } = await Promise.resolve().then(() => (init_results(), exports_results));
24791
+ const result = getResult2(resultId);
24792
+ if (!result)
24793
+ return errorResponse(notFoundErr(resultId, "Result"));
24794
+ const harPath = result.harPath ?? result.metadata?.harPath;
24795
+ if (!harPath)
24796
+ return json({ resultId, harAvailable: false, message: "No HAR file recorded for this result." });
24797
+ const harFile = Bun.file(harPath);
24798
+ const exists = await harFile.exists();
24799
+ if (!exists)
24800
+ return json({ resultId, harPath, harAvailable: false, message: "HAR file was recorded but has been cleaned up." });
24801
+ if (!includeContent) {
24802
+ const size = await harFile.size();
24803
+ return json({ resultId, harPath, harAvailable: true, sizeBytes: size, message: "HAR file available. Use includeContent: true to retrieve." });
24804
+ }
24805
+ const harContent = await harFile.text();
24806
+ return json({ resultId, harPath, harAvailable: true, har: JSON.parse(harContent) });
24807
+ } catch (e) {
24808
+ return errorResponse(e);
24809
+ }
24810
+ });
24365
24811
  server.tool("wait_for_run", "Poll a run until it reaches a terminal state (passed/failed/cancelled) and return the final results summary. Synchronous \u2014 blocks until complete or timeout.", {
24366
24812
  runId: exports_external.string().describe("Run ID to wait for"),
24367
24813
  timeoutMs: exports_external.number().optional().default(300000).describe("Max wait time in ms (default 5 minutes)"),
@@ -24644,6 +25090,13 @@ server.tool("list_scenarios_by_page", "Group scenarios by page (targetPath). Sho
24644
25090
  }
24645
25091
  });
24646
25092
  registerCloudTools(server, "testers");
25093
+ process.on("unhandledRejection", (reason) => {
25094
+ const msg = reason instanceof Error ? reason.stack ?? reason.message : String(reason);
25095
+ console.error(`[testers-mcp] Unhandled promise rejection: ${msg}`);
25096
+ });
25097
+ process.on("uncaughtException", (err) => {
25098
+ console.error(`[testers-mcp] Uncaught exception: ${err.stack ?? err.message}`);
25099
+ });
24647
25100
  async function main() {
24648
25101
  const transport = new StdioServerTransport;
24649
25102
  await server.connect(transport);