@hasna/testers 0.0.28 → 0.0.30

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 (81) hide show
  1. package/LICENSE +1 -154
  2. package/README.md +92 -0
  3. package/dist/cli/index.js +1354 -79
  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 +3 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +818 -25
  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 +6 -7
  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/config.d.ts.map +1 -1
  35. package/dist/lib/discovery.d.ts +33 -0
  36. package/dist/lib/discovery.d.ts.map +1 -0
  37. package/dist/lib/dom-mutation.d.ts +53 -0
  38. package/dist/lib/dom-mutation.d.ts.map +1 -0
  39. package/dist/lib/environment.d.ts +26 -0
  40. package/dist/lib/environment.d.ts.map +1 -0
  41. package/dist/lib/health-scan.d.ts +2 -1
  42. package/dist/lib/health-scan.d.ts.map +1 -1
  43. package/dist/lib/hybrid-runner.d.ts.map +1 -1
  44. package/dist/lib/junit-export.d.ts +24 -0
  45. package/dist/lib/junit-export.d.ts.map +1 -0
  46. package/dist/lib/network-mock.d.ts +38 -0
  47. package/dist/lib/network-mock.d.ts.map +1 -0
  48. package/dist/lib/offline-mode.d.ts +31 -0
  49. package/dist/lib/offline-mode.d.ts.map +1 -0
  50. package/dist/lib/pdf-export.d.ts +27 -0
  51. package/dist/lib/pdf-export.d.ts.map +1 -0
  52. package/dist/lib/performance.d.ts +65 -0
  53. package/dist/lib/performance.d.ts.map +1 -0
  54. package/dist/lib/pr-comment.d.ts +27 -0
  55. package/dist/lib/pr-comment.d.ts.map +1 -0
  56. package/dist/lib/preview-detect.d.ts +27 -0
  57. package/dist/lib/preview-detect.d.ts.map +1 -0
  58. package/dist/lib/prod-debug.d.ts +77 -0
  59. package/dist/lib/prod-debug.d.ts.map +1 -0
  60. package/dist/lib/recorder.d.ts +42 -0
  61. package/dist/lib/recorder.d.ts.map +1 -1
  62. package/dist/lib/repo-discovery.d.ts +102 -0
  63. package/dist/lib/repo-discovery.d.ts.map +1 -0
  64. package/dist/lib/repo-executor.d.ts +56 -0
  65. package/dist/lib/repo-executor.d.ts.map +1 -0
  66. package/dist/lib/responsive.d.ts +43 -0
  67. package/dist/lib/responsive.d.ts.map +1 -0
  68. package/dist/lib/runner.d.ts +1 -0
  69. package/dist/lib/runner.d.ts.map +1 -1
  70. package/dist/lib/scenario-chain.d.ts +52 -0
  71. package/dist/lib/scenario-chain.d.ts.map +1 -0
  72. package/dist/lib/templates.d.ts.map +1 -1
  73. package/dist/lib/webhooks.d.ts +3 -0
  74. package/dist/lib/webhooks.d.ts.map +1 -1
  75. package/dist/mcp/index.js +852 -74
  76. package/dist/sdk/index.d.ts +48 -0
  77. package/dist/sdk/index.d.ts.map +1 -0
  78. package/dist/server/index.js +276 -29
  79. package/dist/types/index.d.ts +66 -2
  80. package/dist/types/index.d.ts.map +1 -1
  81. package/package.json +2 -2
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"),
@@ -12167,7 +12253,8 @@ function loadConfig() {
12167
12253
  judgeModel: fileConfig.judgeModel,
12168
12254
  judgeProvider: fileConfig.judgeProvider,
12169
12255
  selfHeal: fileConfig.selfHeal ?? false,
12170
- conversationsSpace: fileConfig.conversationsSpace
12256
+ conversationsSpace: fileConfig.conversationsSpace,
12257
+ prodDebug: fileConfig.prodDebug
12171
12258
  };
12172
12259
  const envModel = process.env["TESTERS_MODEL"];
12173
12260
  if (envModel) {
@@ -14551,6 +14638,76 @@ var init_costs = __esm(() => {
14551
14638
  };
14552
14639
  });
14553
14640
 
14641
+ // src/db/step-results.ts
14642
+ function createStepResult(input) {
14643
+ const db2 = getDatabase();
14644
+ const id = uuid();
14645
+ const timestamp = now();
14646
+ db2.query(`
14647
+ INSERT INTO step_results (id, result_id, step_number, action, status, tool_name, tool_input, thinking, created_at)
14648
+ VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?)
14649
+ `).run(id, input.resultId, input.stepNumber, input.action, input.toolName ?? null, input.toolInput ? JSON.stringify(input.toolInput) : null, input.thinking ?? null, timestamp);
14650
+ return getStepResult(id);
14651
+ }
14652
+ function getStepResult(id) {
14653
+ const db2 = getDatabase();
14654
+ const row = db2.query("SELECT * FROM step_results WHERE id = ?").get(id);
14655
+ return row ? stepResultFromRow(row) : null;
14656
+ }
14657
+ function updateStepResult(id, updates) {
14658
+ const db2 = getDatabase();
14659
+ const existing = getStepResult(id);
14660
+ if (!existing)
14661
+ return null;
14662
+ const sets = [];
14663
+ const params = [];
14664
+ if (updates.status !== undefined) {
14665
+ sets.push("status = ?");
14666
+ params.push(updates.status);
14667
+ }
14668
+ if (updates.toolResult !== undefined) {
14669
+ sets.push("tool_result = ?");
14670
+ params.push(updates.toolResult);
14671
+ }
14672
+ if (updates.error !== undefined) {
14673
+ sets.push("error = ?");
14674
+ params.push(updates.error);
14675
+ }
14676
+ if (updates.durationMs !== undefined) {
14677
+ sets.push("duration_ms = ?");
14678
+ params.push(updates.durationMs);
14679
+ }
14680
+ if (updates.screenshotId !== undefined) {
14681
+ sets.push("screenshot_id = ?");
14682
+ params.push(updates.screenshotId);
14683
+ }
14684
+ if (sets.length === 0)
14685
+ return existing;
14686
+ params.push(id);
14687
+ db2.query(`UPDATE step_results SET ${sets.join(", ")} WHERE id = ?`).run(...params);
14688
+ return getStepResult(id);
14689
+ }
14690
+ function stepResultFromRow(row) {
14691
+ return {
14692
+ id: row.id,
14693
+ resultId: row.result_id,
14694
+ stepNumber: row.step_number,
14695
+ action: row.action,
14696
+ status: row.status,
14697
+ toolName: row.tool_name,
14698
+ toolInput: row.tool_input ? JSON.parse(row.tool_input) : null,
14699
+ toolResult: row.tool_result,
14700
+ thinking: row.thinking,
14701
+ error: row.error,
14702
+ durationMs: row.duration_ms,
14703
+ screenshotId: row.screenshot_id,
14704
+ createdAt: row.created_at
14705
+ };
14706
+ }
14707
+ var init_step_results = __esm(() => {
14708
+ init_database();
14709
+ });
14710
+
14554
14711
  // src/db/personas.ts
14555
14712
  function createPersona(input) {
14556
14713
  const db2 = getDatabase();
@@ -14558,9 +14715,9 @@ function createPersona(input) {
14558
14715
  const short_id = shortUuid();
14559
14716
  const timestamp = now();
14560
14717
  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);
14718
+ 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)
14719
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
14720
+ `).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
14721
  return getPersona(id);
14565
14722
  }
14566
14723
  function getPersona(id) {
@@ -14678,6 +14835,18 @@ function updatePersona(id, updates, version) {
14678
14835
  sets.push("auth_cookies = ?");
14679
14836
  params.push(updates.authCookies ? JSON.stringify(updates.authCookies) : null);
14680
14837
  }
14838
+ if (updates.authStrategy !== undefined) {
14839
+ sets.push("auth_strategy = ?");
14840
+ params.push(updates.authStrategy);
14841
+ }
14842
+ if (updates.authHeaders !== undefined) {
14843
+ sets.push("auth_headers = ?");
14844
+ params.push(JSON.stringify(updates.authHeaders));
14845
+ }
14846
+ if (updates.authCustomScript !== undefined) {
14847
+ sets.push("auth_script = ?");
14848
+ params.push(updates.authCustomScript);
14849
+ }
14681
14850
  if (sets.length === 0) {
14682
14851
  return existing;
14683
14852
  }
@@ -14937,6 +15106,11 @@ function resolveCredential(value) {
14937
15106
  }
14938
15107
  return value;
14939
15108
  }
15109
+ function isCredentialReference(value) {
15110
+ if (!value)
15111
+ return false;
15112
+ return value.startsWith("@secrets:") || value.startsWith("$");
15113
+ }
14940
15114
  var init_secrets_resolver = () => {};
14941
15115
 
14942
15116
  // src/lib/persona-auth.ts
@@ -15199,6 +15373,24 @@ function signPayload(body, secret) {
15199
15373
  }
15200
15374
  return `sha256=${Math.abs(hash).toString(16).padStart(16, "0")}`;
15201
15375
  }
15376
+ function formatDiscordPayload(payload) {
15377
+ const isPassed = payload.run.status === "passed";
15378
+ const color = isPassed ? 2278750 : 15680580;
15379
+ return {
15380
+ username: "open-testers",
15381
+ embeds: [
15382
+ {
15383
+ title: `Test Run ${payload.run.status.toUpperCase()}`,
15384
+ color,
15385
+ description: `URL: ${payload.run.url}
15386
+ ` + `Results: ${payload.run.passed}/${payload.run.total} passed` + (payload.run.failed > 0 ? ` (${payload.run.failed} failed)` : "") + (payload.schedule ? `
15387
+ Schedule: ${payload.schedule.name}` : ""),
15388
+ timestamp: payload.timestamp,
15389
+ footer: { text: "open-testers" }
15390
+ }
15391
+ ]
15392
+ };
15393
+ }
15202
15394
  function formatSlackPayload(payload) {
15203
15395
  const status = payload.run.status === "passed" ? ":white_check_mark:" : ":x:";
15204
15396
  const color = payload.run.status === "passed" ? "#22c55e" : "#ef4444";
@@ -15241,7 +15433,8 @@ async function dispatchWebhooks(event, run, schedule) {
15241
15433
  if (!webhook.events.includes(event) && !webhook.events.includes("*"))
15242
15434
  continue;
15243
15435
  const isSlack = webhook.url.includes("hooks.slack.com");
15244
- const body = isSlack ? JSON.stringify(formatSlackPayload(payload)) : JSON.stringify(payload);
15436
+ const isDiscord = webhook.url.includes("discord.com/api/webhooks") || webhook.url.includes("discordapp.com/api/webhooks");
15437
+ const body = isSlack ? JSON.stringify(formatSlackPayload(payload)) : isDiscord ? JSON.stringify(formatDiscordPayload(payload)) : JSON.stringify(payload);
15245
15438
  const headers = {
15246
15439
  "Content-Type": "application/json"
15247
15440
  };
@@ -15751,6 +15944,8 @@ __export(exports_runner, {
15751
15944
  runBatch: () => runBatch,
15752
15945
  onRunEvent: () => onRunEvent
15753
15946
  });
15947
+ import { mkdirSync as mkdirSync8 } from "fs";
15948
+ import { join as join13 } from "path";
15754
15949
  import { enableNetworkLogging } from "@hasna/browser";
15755
15950
  function onRunEvent(handler) {
15756
15951
  eventHandler = handler;
@@ -15836,13 +16031,35 @@ async function runSingleScenario(scenario, runId, options) {
15836
16031
  emit({ type: "scenario:start", scenarioId: scenario.id, scenarioName: scenario.name, resultId: result.id, runId });
15837
16032
  let browser = null;
15838
16033
  let page = null;
16034
+ let context = null;
16035
+ let harPath = null;
15839
16036
  let stopNetworkLogging = null;
15840
16037
  const networkErrors = [];
15841
16038
  try {
15842
16039
  browser = await launchBrowser({ headless: !(effectiveOptions.headed ?? false), engine: effectiveOptions.engine });
15843
- page = await getPage(browser, {
15844
- viewport: config.browser.viewport
15845
- });
16040
+ const useHar = effectiveOptions.engine !== "lightpanda" && effectiveOptions.engine !== "bun";
16041
+ if (useHar) {
16042
+ const testersDir = process.env["HASNA_TESTERS_DIR"] || join13(process.env["HOME"] || "", ".hasna", "testers");
16043
+ const harDir = join13(testersDir, "hars");
16044
+ mkdirSync8(harDir, { recursive: true });
16045
+ harPath = join13(harDir, `${result.id}.har`);
16046
+ const contextOptions = {
16047
+ viewport: config.browser.viewport,
16048
+ recordHar: { path: harPath, mode: "full" }
16049
+ };
16050
+ if (effectiveOptions.recordVideo) {
16051
+ const videoDir = join13(testersDir, "videos");
16052
+ mkdirSync8(videoDir, { recursive: true });
16053
+ contextOptions.recordVideo = { dir: videoDir, size: config.browser.viewport };
16054
+ }
16055
+ context = await browser.newContext(contextOptions);
16056
+ page = await context.newPage();
16057
+ } else {
16058
+ page = await getPage(browser, {
16059
+ viewport: config.browser.viewport,
16060
+ engine: effectiveOptions.engine
16061
+ });
16062
+ }
15846
16063
  const targetUrl = scenario.targetPath ? `${options.url.replace(/\/$/, "")}${scenario.targetPath}` : options.url;
15847
16064
  const scenarioTimeout = scenario.timeoutMs ?? options.timeout ?? config.browser.timeout ?? 60000;
15848
16065
  registerSession({
@@ -15862,7 +16079,11 @@ async function runSingleScenario(scenario, runId, options) {
15862
16079
  }
15863
16080
  });
15864
16081
  const consoleErrors = [];
16082
+ const consoleLogs = [];
16083
+ let currentStep = 0;
15865
16084
  page.on("console", (msg) => {
16085
+ const logEntry = { step: currentStep > 0 ? currentStep : null, type: msg.type(), text: msg.text(), timestamp: Date.now() };
16086
+ consoleLogs.push(logEntry);
15866
16087
  if (msg.type() === "error")
15867
16088
  consoleErrors.push(msg.text());
15868
16089
  });
@@ -15894,6 +16115,7 @@ async function runSingleScenario(scenario, runId, options) {
15894
16115
  }
15895
16116
  await page.goto(targetUrl, { timeout: Math.min(scenarioTimeout, 30000) });
15896
16117
  const stepStartTimes = new Map;
16118
+ const stepResultIds = new Map;
15897
16119
  const agentResult = await withTimeout(runAgentLoop({
15898
16120
  client,
15899
16121
  page,
@@ -15916,13 +16138,32 @@ async function runSingleScenario(scenario, runId, options) {
15916
16138
  onStep: (stepEvent) => {
15917
16139
  let stepDurationMs;
15918
16140
  if (stepEvent.type === "tool_call") {
16141
+ currentStep = stepEvent.stepNumber;
15919
16142
  stepStartTimes.set(stepEvent.stepNumber, Date.now());
16143
+ const stepResult = createStepResult({
16144
+ resultId: result.id,
16145
+ stepNumber: stepEvent.stepNumber,
16146
+ action: stepEvent.toolName ?? `step-${stepEvent.stepNumber}`,
16147
+ toolName: stepEvent.toolName,
16148
+ toolInput: stepEvent.toolInput,
16149
+ thinking: stepEvent.thinking
16150
+ });
16151
+ stepResultIds.set(stepEvent.stepNumber, stepResult.id);
15920
16152
  } else if (stepEvent.type === "tool_result") {
15921
16153
  const startTime = stepStartTimes.get(stepEvent.stepNumber);
15922
16154
  if (startTime !== undefined) {
15923
16155
  stepDurationMs = Date.now() - startTime;
15924
16156
  stepStartTimes.delete(stepEvent.stepNumber);
15925
16157
  }
16158
+ const stepResultId = stepResultIds.get(stepEvent.stepNumber);
16159
+ if (stepResultId) {
16160
+ const isSuccess = !stepEvent.toolResult?.toLowerCase().includes("error") && !stepEvent.toolResult?.toLowerCase().includes("failed");
16161
+ updateStepResult(stepResultId, {
16162
+ status: isSuccess ? "passed" : "failed",
16163
+ toolResult: stepEvent.toolResult,
16164
+ durationMs: stepDurationMs
16165
+ });
16166
+ }
15926
16167
  }
15927
16168
  emit({
15928
16169
  type: `step:${stepEvent.type}`,
@@ -15971,7 +16212,7 @@ async function runSingleScenario(scenario, runId, options) {
15971
16212
  durationMs: Date.now() - new Date(result.createdAt).getTime(),
15972
16213
  tokensUsed: agentResult.tokensUsed,
15973
16214
  costCents: estimateCost(model, agentResult.tokensUsed),
15974
- metadata: networkErrors.length > 0 ? networkMeta : undefined
16215
+ metadata: { consoleLogs, ...networkErrors.length > 0 ? networkMeta : {} }
15975
16216
  });
15976
16217
  if (agentResult.status === "failed" || agentResult.status === "error") {
15977
16218
  const failureAnalysis = analyzeFailure(null, agentResult.reasoning ?? null);
@@ -16007,8 +16248,16 @@ async function runSingleScenario(scenario, runId, options) {
16007
16248
  emit({ type: "scenario:error", scenarioId: scenario.id, scenarioName: scenario.name, error: errorMsg, runId });
16008
16249
  return updatedResult;
16009
16250
  } finally {
16010
- if (browser)
16011
- await closeBrowser(browser, effectiveOptions.engine);
16251
+ if (harPath) {
16252
+ try {
16253
+ updateResult(result.id, { metadata: { harPath } });
16254
+ } catch {}
16255
+ }
16256
+ if (browser) {
16257
+ try {
16258
+ await closeBrowser(browser, effectiveOptions.engine);
16259
+ } catch {}
16260
+ }
16012
16261
  }
16013
16262
  }
16014
16263
  async function runBatch(scenarios, options) {
@@ -16304,6 +16553,7 @@ var init_runner = __esm(() => {
16304
16553
  init_results();
16305
16554
  init_costs();
16306
16555
  init_screenshots();
16556
+ init_step_results();
16307
16557
  init_scenarios();
16308
16558
  init_personas();
16309
16559
  init_browser();
@@ -17341,6 +17591,16 @@ async function runHealthScan(options) {
17341
17591
  });
17342
17592
  results.push(piiResult);
17343
17593
  }
17594
+ if (scanners.includes("a11y")) {
17595
+ const a11yResult = await scanA11y({
17596
+ url,
17597
+ pages,
17598
+ wcagLevel: options.wcagLevel ?? "AA",
17599
+ headed,
17600
+ timeoutMs
17601
+ });
17602
+ results.push(a11yResult);
17603
+ }
17344
17604
  const allIssues = results.flatMap((r) => r.issues);
17345
17605
  let newCount = 0;
17346
17606
  let regressedCount = 0;
@@ -17556,10 +17816,10 @@ __export(exports_contacts_connector, {
17556
17816
  function getContactsDb() {
17557
17817
  const { Database: Database4 } = __require("bun:sqlite");
17558
17818
  const { existsSync: existsSync5 } = __require("fs");
17559
- const { join: join13 } = __require("path");
17819
+ const { join: join14 } = __require("path");
17560
17820
  const { homedir: homedir7 } = __require("os");
17561
17821
  const envPath = process.env["HASNA_CONTACTS_DB_PATH"] ?? process.env["OPEN_CONTACTS_DB"];
17562
- const dbPath = envPath ?? join13(homedir7(), ".hasna", "contacts", "contacts.db");
17822
+ const dbPath = envPath ?? join14(homedir7(), ".hasna", "contacts", "contacts.db");
17563
17823
  if (!existsSync5(dbPath))
17564
17824
  return null;
17565
17825
  const db2 = new Database4(dbPath, { readonly: true });
@@ -17680,7 +17940,7 @@ __export(exports_army_runner, {
17680
17940
  waitForArmyRun: () => waitForArmyRun,
17681
17941
  runWithArmy: () => runWithArmy
17682
17942
  });
17683
- import { join as join13 } from "path";
17943
+ import { join as join14 } from "path";
17684
17944
  function chunkArray(arr, n) {
17685
17945
  const chunks = [];
17686
17946
  const size = Math.ceil(arr.length / n);
@@ -17690,7 +17950,7 @@ function chunkArray(arr, n) {
17690
17950
  return chunks;
17691
17951
  }
17692
17952
  function getCliPath() {
17693
- const srcPath = join13(import.meta.dir, "../cli/index.tsx");
17953
+ const srcPath = join14(import.meta.dir, "../cli/index.tsx");
17694
17954
  return srcPath;
17695
17955
  }
17696
17956
  async function runWithArmy(options) {
@@ -22002,6 +22262,54 @@ var NEVER = INVALID;
22002
22262
  // src/mcp/index.ts
22003
22263
  init_dist();
22004
22264
  init_scenarios();
22265
+
22266
+ // src/lib/templates.ts
22267
+ var SCENARIO_TEMPLATES = {
22268
+ auth: [
22269
+ { 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"] },
22270
+ { 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"] },
22271
+ { 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"] }
22272
+ ],
22273
+ crud: [
22274
+ { 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"] },
22275
+ { 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"] },
22276
+ { 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"] },
22277
+ { 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"] }
22278
+ ],
22279
+ forms: [
22280
+ { 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"] },
22281
+ { 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"] },
22282
+ { 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"] }
22283
+ ],
22284
+ nav: [
22285
+ { 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"] },
22286
+ { 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"] }
22287
+ ],
22288
+ a11y: [
22289
+ { 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"] },
22290
+ { 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"] }
22291
+ ],
22292
+ checkout: [
22293
+ { 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"] },
22294
+ { 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"] },
22295
+ { 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"] },
22296
+ { 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"] }
22297
+ ],
22298
+ search: [
22299
+ { 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"] },
22300
+ { 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)"] },
22301
+ { 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"] },
22302
+ { 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"] }
22303
+ ]
22304
+ };
22305
+ function getTemplate(name) {
22306
+ return SCENARIO_TEMPLATES[name] ?? null;
22307
+ }
22308
+ function listTemplateNames() {
22309
+ return Object.keys(SCENARIO_TEMPLATES);
22310
+ }
22311
+
22312
+ // src/mcp/index.ts
22005
22313
  init_runs();
22006
22314
  init_results();
22007
22315
  init_screenshots();
@@ -22994,6 +23302,334 @@ async function runApiChecksByFilter(filter) {
22994
23302
  // src/mcp/index.ts
22995
23303
  init_personas();
22996
23304
  init_paths();
23305
+
23306
+ // src/lib/prod-debug.ts
23307
+ init_secrets_resolver();
23308
+ var UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i;
23309
+ var SENSITIVE_PARAM_RE = /token|secret|key|password|code|state|cookie|session|grant|credential|auth|jwt|access/i;
23310
+ var SENSITIVE_TEXT_RE = /\b(Bearer\s+[A-Za-z0-9._-]{12,}|sk-[A-Za-z0-9]{12,}|pk_[A-Za-z0-9]{12,}|eyJ[A-Za-z0-9._-]{12,})\b/g;
23311
+ var URL_TEXT_RE = /https?:\/\/[^\s"'<>]+/g;
23312
+ function safeUrl(raw) {
23313
+ try {
23314
+ const url = new URL(raw);
23315
+ if (url.protocol !== "http:" && url.protocol !== "https:")
23316
+ return null;
23317
+ return url;
23318
+ } catch {
23319
+ return null;
23320
+ }
23321
+ }
23322
+ function normalizeOrigin(raw) {
23323
+ const url = safeUrl(raw);
23324
+ if (url)
23325
+ return url.origin;
23326
+ const hostUrl = safeUrl(`https://${raw}`);
23327
+ return hostUrl?.origin ?? null;
23328
+ }
23329
+ function redactProdDebugText(value) {
23330
+ return value.replace(URL_TEXT_RE, (match) => {
23331
+ const url = safeUrl(match);
23332
+ return url ? redactUrl(url) : match;
23333
+ }).replace(SENSITIVE_TEXT_RE, (match) => {
23334
+ if (match.startsWith("Bearer "))
23335
+ return "Bearer [redacted]";
23336
+ return "[redacted]";
23337
+ });
23338
+ }
23339
+ function redactUrl(url) {
23340
+ const clone = new URL(url.toString());
23341
+ for (const key of Array.from(clone.searchParams.keys())) {
23342
+ if (SENSITIVE_PARAM_RE.test(key)) {
23343
+ clone.searchParams.set(key, "[redacted]");
23344
+ }
23345
+ }
23346
+ return clone.toString();
23347
+ }
23348
+ function redactUrlString(value) {
23349
+ const url = safeUrl(value);
23350
+ return url ? redactUrl(url) : redactProdDebugText(value);
23351
+ }
23352
+ function parseProdDebugTarget(target) {
23353
+ const input = target.trim();
23354
+ const url = safeUrl(input);
23355
+ if (!url) {
23356
+ const id = (input.match(UUID_RE)?.[0] ?? input) || null;
23357
+ return {
23358
+ url: null,
23359
+ origin: null,
23360
+ orgSlug: null,
23361
+ projectRef: null,
23362
+ sessionId: null,
23363
+ agentId: null,
23364
+ requestId: input.startsWith("req_") ? input : null,
23365
+ rawId: id
23366
+ };
23367
+ }
23368
+ const parts = url.pathname.split("/").filter(Boolean);
23369
+ const projectsIndex = parts.indexOf("projects");
23370
+ const sessionsIndex = parts.indexOf("sessions");
23371
+ const orgSlug = projectsIndex > 0 ? parts[0] ?? null : null;
23372
+ const projectRef = projectsIndex >= 0 ? parts[projectsIndex + 1] ?? null : null;
23373
+ const sessionId = url.searchParams.get("session") ?? (sessionsIndex >= 0 ? parts[sessionsIndex + 1] ?? null : null);
23374
+ return {
23375
+ url: redactUrl(url),
23376
+ origin: url.origin,
23377
+ orgSlug,
23378
+ projectRef,
23379
+ sessionId,
23380
+ agentId: url.searchParams.get("agent"),
23381
+ requestId: url.searchParams.get("requestId") ?? url.searchParams.get("request_id"),
23382
+ rawId: input.match(UUID_RE)?.[0] ?? null
23383
+ };
23384
+ }
23385
+ function boundedTtl(value) {
23386
+ if (!Number.isFinite(value))
23387
+ return 15;
23388
+ return Math.min(Math.max(Math.round(value ?? 15), 1), 60);
23389
+ }
23390
+ function makeCommand(command) {
23391
+ return command.replace(/\s+/g, " ").trim();
23392
+ }
23393
+ function hostnameFromOrigin(origin) {
23394
+ if (!origin)
23395
+ return null;
23396
+ return safeUrl(origin)?.hostname ?? null;
23397
+ }
23398
+ function originMatches(pattern, origin) {
23399
+ if (!origin)
23400
+ return false;
23401
+ const normalizedPattern = normalizeOrigin(pattern);
23402
+ const normalizedOrigin = normalizeOrigin(origin);
23403
+ if (!normalizedOrigin)
23404
+ return false;
23405
+ if (normalizedPattern === normalizedOrigin)
23406
+ return true;
23407
+ const targetHost = hostnameFromOrigin(normalizedOrigin);
23408
+ const patternHost = normalizedPattern ? hostnameFromOrigin(normalizedPattern) : pattern.replace(/^https?:\/\//, "");
23409
+ if (!targetHost || !patternHost)
23410
+ return false;
23411
+ if (patternHost.startsWith("*.")) {
23412
+ const suffix = patternHost.slice(1);
23413
+ return targetHost.endsWith(suffix);
23414
+ }
23415
+ return targetHost === patternHost;
23416
+ }
23417
+ function resolveProfile(input, target, config) {
23418
+ const apps = config?.apps ?? {};
23419
+ const explicitKey = input.profile?.trim() || input.app?.trim() || config?.defaultProfile;
23420
+ if (explicitKey && apps[explicitKey]) {
23421
+ return {
23422
+ key: explicitKey,
23423
+ profile: apps[explicitKey],
23424
+ matchedOrigin: target.origin
23425
+ };
23426
+ }
23427
+ for (const [key, profile] of Object.entries(apps)) {
23428
+ const match = profile.origins?.find((origin) => originMatches(origin, target.origin));
23429
+ if (match) {
23430
+ return { key, profile, matchedOrigin: match };
23431
+ }
23432
+ }
23433
+ return { key: null, profile: null, matchedOrigin: null };
23434
+ }
23435
+ function firstResolvedCredential(...values) {
23436
+ for (const value of values) {
23437
+ if (!value?.trim())
23438
+ continue;
23439
+ const resolved = resolveCredential(value);
23440
+ if (resolved)
23441
+ return resolved;
23442
+ }
23443
+ return null;
23444
+ }
23445
+ function displayCredential(value, source) {
23446
+ if (!value)
23447
+ return null;
23448
+ if (source && isCredentialReference(source))
23449
+ return "[configured]";
23450
+ return redactProdDebugText(value);
23451
+ }
23452
+ function replacementValues(target, input, supportGrant) {
23453
+ const values = {
23454
+ targetUrl: target.url ?? input.target,
23455
+ origin: target.origin ?? "",
23456
+ org: target.orgSlug ?? "",
23457
+ project: target.projectRef ?? "",
23458
+ session: target.sessionId ?? "",
23459
+ agent: target.agentId ?? "",
23460
+ request: target.requestId ?? "",
23461
+ rawId: target.rawId ?? "",
23462
+ reason: input.reason ?? "",
23463
+ supportGrant: supportGrant ?? ""
23464
+ };
23465
+ for (const [key, value] of Object.entries({ ...values })) {
23466
+ values[`${key}Encoded`] = encodeURIComponent(value);
23467
+ }
23468
+ return values;
23469
+ }
23470
+ function renderTemplate(template, values) {
23471
+ return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key) => values[key] ?? "");
23472
+ }
23473
+ function resolveSupportGrant(input, profile) {
23474
+ if (input.supportGrantId?.trim()) {
23475
+ return {
23476
+ value: input.supportGrantId.trim(),
23477
+ display: displayCredential(input.supportGrantId.trim()),
23478
+ source: "input"
23479
+ };
23480
+ }
23481
+ const source = profile?.supportGrantRef ?? profile?.supportGrantId ?? null;
23482
+ const value = firstResolvedCredential(profile?.supportGrantRef, profile?.supportGrantId);
23483
+ return { value, display: displayCredential(value, source ?? undefined), source };
23484
+ }
23485
+ function resolveSupportUrl(input, target, profile, supportGrant) {
23486
+ if (input.supportUrl?.trim())
23487
+ return input.supportUrl.trim();
23488
+ const direct = firstResolvedCredential(profile?.supportUrlRef, profile?.supportUrl);
23489
+ if (direct)
23490
+ return direct;
23491
+ if (profile?.supportUrlTemplate) {
23492
+ const rendered = renderTemplate(profile.supportUrlTemplate, replacementValues(target, input, supportGrant)).trim();
23493
+ return rendered || null;
23494
+ }
23495
+ return null;
23496
+ }
23497
+ function resolvePiiOrigin(profile, target) {
23498
+ if (!profile?.piiOrigin)
23499
+ return target.origin;
23500
+ return redactUrlString(renderTemplate(profile.piiOrigin, replacementValues(target, { target: target.url ?? "" }, null)));
23501
+ }
23502
+ function resolveSupportRunTarget(supportUrl, input, target) {
23503
+ if (supportUrl)
23504
+ return redactUrlString(supportUrl);
23505
+ return target.url ?? target.origin ?? redactProdDebugText(input.target);
23506
+ }
23507
+ function supportScenarioDescription(reason) {
23508
+ return `Prod debug: ${reason}. Reproduce the user-visible issue, capture console and network errors, and do not enter secrets.`;
23509
+ }
23510
+ function configuredMissing(profile, supportUrl, supportGrant, includeLogs) {
23511
+ const missing = [];
23512
+ if (!profile) {
23513
+ missing.push("optional: add prodDebug.apps.<profile>.origins to match this app automatically");
23514
+ }
23515
+ if (!supportUrl) {
23516
+ missing.push("supportUrl/supportUrlRef/supportUrlTemplate for scoped browser debugging");
23517
+ }
23518
+ if (!supportGrant) {
23519
+ missing.push("supportGrantId/supportGrantRef for auditable support access");
23520
+ }
23521
+ if (includeLogs && !profile?.logCommand) {
23522
+ missing.push("logCommand for sanitized app/provider log lookup");
23523
+ }
23524
+ return missing;
23525
+ }
23526
+ function createProdDebugPlan(input, config) {
23527
+ const target = parseProdDebugTarget(input.target);
23528
+ const browserRequested = input.includeBrowser !== false;
23529
+ const resolvedProfile = resolveProfile(input, target, config);
23530
+ const supportGrant = resolveSupportGrant(input, resolvedProfile.profile);
23531
+ const supportUrl = resolveSupportUrl(input, target, resolvedProfile.profile, supportGrant.value);
23532
+ const supportBrowserReady = Boolean(supportUrl);
23533
+ const app = input.app?.trim() || resolvedProfile.profile?.name || resolvedProfile.key || (target.origin ? new URL(target.origin).hostname : "app");
23534
+ const reason = input.reason?.trim() || "production debug requested";
23535
+ const actor = input.actor?.trim() || process.env["USER"] || "agent";
23536
+ const ttlMinutes = boundedTtl(input.ttlMinutes);
23537
+ const piiOrigin = resolvePiiOrigin(resolvedProfile.profile, target);
23538
+ const logCommand = resolvedProfile.profile?.logCommand ? redactUrlString(renderTemplate(resolvedProfile.profile.logCommand, replacementValues(target, { ...input, reason }, supportGrant.value))) : null;
23539
+ const safety = [
23540
+ "read-only by default",
23541
+ "no customer passwords or raw cookies",
23542
+ "redact tokens, OAuth codes, session values, support grants, and secrets",
23543
+ "verify org/user/session scope before reading data",
23544
+ "require explicit approval for production writes",
23545
+ `support access TTL capped at ${ttlMinutes} minutes`
23546
+ ];
23547
+ const checks = [];
23548
+ const blocked = [];
23549
+ if (target.url) {
23550
+ checks.push({
23551
+ id: "public-route-smoke",
23552
+ status: "ready",
23553
+ description: "Open the supplied production URL and capture console/network errors without credentials.",
23554
+ command: makeCommand(`testers scan all ${JSON.stringify(target.url)} --json`)
23555
+ });
23556
+ }
23557
+ checks.push({
23558
+ id: "pii-redaction-scan",
23559
+ status: piiOrigin ? "ready" : "blocked",
23560
+ description: "Scan public/API responses for accidental sensitive data leakage.",
23561
+ command: piiOrigin ? makeCommand(`testers scan pii ${JSON.stringify(piiOrigin)} --json`) : undefined,
23562
+ reason: piiOrigin ? undefined : "Need a URL origin or prodDebug app profile piiOrigin to run the PII scan."
23563
+ });
23564
+ if (browserRequested) {
23565
+ if (supportBrowserReady) {
23566
+ checks.push({
23567
+ id: "support-browser-repro",
23568
+ status: "ready",
23569
+ description: "Use an audited support browser/session URL to reproduce the user-visible issue.",
23570
+ command: makeCommand(`testers run ${JSON.stringify(resolveSupportRunTarget(supportUrl, input, target))} ${JSON.stringify(supportScenarioDescription(reason))} --headed --json --overall-timeout 600000`)
23571
+ });
23572
+ } else {
23573
+ const reasonText = supportGrant.value ? "An audited support grant was supplied, but open-testers still needs supportUrl/supportUrlRef/supportUrlTemplate or an app adapter to open a scoped browser session." : "No audited support browser/session grant was supplied. Do not use customer passwords, copied cookies, bearer tokens, or magic links.";
23574
+ blocked.push(reasonText);
23575
+ checks.push({
23576
+ id: "support-browser-repro",
23577
+ status: "blocked",
23578
+ description: "Browser reproduction as the target user requires a short-lived audited support session.",
23579
+ reason: reasonText
23580
+ });
23581
+ }
23582
+ }
23583
+ if (input.includeLogs) {
23584
+ if (logCommand) {
23585
+ checks.push({
23586
+ id: "log-timeline",
23587
+ status: "ready",
23588
+ description: "Read sanitized app/provider logs by request ID, session ID, project ID, or support access ID.",
23589
+ command: makeCommand(logCommand)
23590
+ });
23591
+ } else {
23592
+ checks.push({
23593
+ id: "log-timeline",
23594
+ status: "blocked",
23595
+ description: "Read sanitized app/provider logs by request ID, session ID, project ID, or support access ID.",
23596
+ reason: "Configure prodDebug.apps.<profile>.logCommand or use an app-specific log MCP. Do not paste raw provider logs with headers/secrets."
23597
+ });
23598
+ }
23599
+ }
23600
+ if (input.allowWrites) {
23601
+ blocked.push("Production writes are not part of prod-debug. Require a separate explicit approval and app-specific write tool.");
23602
+ }
23603
+ return {
23604
+ target,
23605
+ app,
23606
+ actor,
23607
+ reason,
23608
+ ttlMinutes,
23609
+ setup: {
23610
+ profile: resolvedProfile.key,
23611
+ matchedOrigin: resolvedProfile.matchedOrigin,
23612
+ configured: {
23613
+ supportUrl: Boolean(supportUrl),
23614
+ supportGrant: Boolean(supportGrant.value),
23615
+ piiOrigin: Boolean(piiOrigin),
23616
+ logCommand: Boolean(logCommand)
23617
+ },
23618
+ missing: configuredMissing(resolvedProfile.profile, supportUrl, supportGrant.value, Boolean(input.includeLogs))
23619
+ },
23620
+ supportAccess: {
23621
+ required: browserRequested,
23622
+ grantId: supportGrant.display,
23623
+ browserReady: supportBrowserReady,
23624
+ note: supportBrowserReady ? "Use the provided audited support access; never print token/cookie values." : "Configure an audited support-browser/session URL, URL ref, or template before user-scoped browser debugging."
23625
+ },
23626
+ safety,
23627
+ checks,
23628
+ blocked
23629
+ };
23630
+ }
23631
+
23632
+ // src/mcp/index.ts
22997
23633
  var cliArgs = new Set(process.argv.slice(2));
22998
23634
  if (cliArgs.has("--help") || cliArgs.has("-h")) {
22999
23635
  console.log(`Usage: testers-mcp [options]
@@ -23070,6 +23706,26 @@ var server = new McpServer({
23070
23706
  name: "testers",
23071
23707
  version: "0.0.1"
23072
23708
  });
23709
+ server.tool("create_prod_debug_plan", "Create a safe production debug plan for a URL, session ID, project ID, user report, or request ID. Does not use passwords or raw cookies; browser debugging is blocked unless an audited support URL is supplied or an app adapter can resolve a support grant.", {
23710
+ target: exports_external.string().describe("Production URL, session ID, project ID, request ID, or other target evidence"),
23711
+ app: exports_external.string().optional().describe("App name for reporting"),
23712
+ profile: exports_external.string().optional().describe("prodDebug app profile from testers config"),
23713
+ actor: exports_external.string().optional().describe("Operator/agent identity for audit context"),
23714
+ reason: exports_external.string().optional().describe("Debug reason or support context"),
23715
+ supportUrl: exports_external.string().optional().describe("Audited support browser/session URL minted by the target app"),
23716
+ supportGrantId: exports_external.string().optional().describe("Audited support access grant ID"),
23717
+ ttlMinutes: exports_external.number().optional().describe("Support access TTL in minutes, capped at 60"),
23718
+ includeBrowser: exports_external.boolean().optional().describe("Include user-scoped browser reproduction check"),
23719
+ includeLogs: exports_external.boolean().optional().describe("Include log timeline adapter requirement"),
23720
+ allowWrites: exports_external.boolean().optional().describe("Document that writes require a separate explicit approval")
23721
+ }, async (input) => {
23722
+ try {
23723
+ const config = loadConfig();
23724
+ return json(createProdDebugPlan(input, config.prodDebug));
23725
+ } catch (error) {
23726
+ return errorResponse(error);
23727
+ }
23728
+ });
23073
23729
  server.tool("create_scenario", "Create a new test scenario", {
23074
23730
  name: exports_external.string().describe("Scenario name"),
23075
23731
  description: exports_external.string().describe("What this scenario tests"),
@@ -23078,15 +23734,87 @@ server.tool("create_scenario", "Create a new test scenario", {
23078
23734
  priority: exports_external.enum(["low", "medium", "high", "critical"]).optional().describe("Scenario priority"),
23079
23735
  model: exports_external.string().optional().describe(MODEL_DESC),
23080
23736
  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 }) => {
23737
+ requiresAuth: exports_external.boolean().optional().describe("Whether scenario requires authentication"),
23738
+ projectId: exports_external.string().optional().describe("Project ID to scope this scenario to")
23739
+ }, async ({ name, description, steps, tags, priority, model, targetPath, requiresAuth, projectId }) => {
23083
23740
  try {
23084
- const scenario = createScenario({ name, description, steps, tags, priority, model, targetPath, requiresAuth });
23741
+ const scenario = createScenario({ name, description, steps, tags, priority, model, targetPath, requiresAuth, projectId });
23085
23742
  return json(scenario);
23086
23743
  } catch (error) {
23087
23744
  return errorResponse(error);
23088
23745
  }
23089
23746
  });
23747
+ server.tool("batch_create_scenarios", "Create multiple test scenarios in a single call. Each item requires name; description defaults to the name.", {
23748
+ scenarios: exports_external.array(exports_external.object({
23749
+ name: exports_external.string().describe("Scenario name"),
23750
+ description: exports_external.string().optional().describe("What this scenario tests"),
23751
+ steps: exports_external.array(exports_external.string()).optional().describe("Ordered test steps"),
23752
+ tags: exports_external.array(exports_external.string()).optional().describe("Tags for filtering"),
23753
+ priority: exports_external.enum(["low", "medium", "high", "critical"]).optional().describe("Scenario priority"),
23754
+ model: exports_external.string().optional().describe(MODEL_DESC),
23755
+ targetPath: exports_external.string().optional().describe("URL path to navigate to"),
23756
+ url: exports_external.string().optional().describe("Alias for targetPath"),
23757
+ requiresAuth: exports_external.boolean().optional().describe("Whether scenario requires authentication")
23758
+ })).min(1).max(100).describe("Array of scenarios to create"),
23759
+ projectId: exports_external.string().optional().describe("Project ID to scope all scenarios to")
23760
+ }, async ({ scenarios, projectId }) => {
23761
+ try {
23762
+ const results = [];
23763
+ for (const s of scenarios) {
23764
+ try {
23765
+ const scenario = createScenario({
23766
+ ...s,
23767
+ description: s.description ?? s.name,
23768
+ targetPath: s.targetPath ?? s.url,
23769
+ projectId
23770
+ });
23771
+ results.push({ id: scenario.id, name: scenario.name, shortId: scenario.shortId });
23772
+ } catch (e) {
23773
+ results.push({ id: "", name: s.name, shortId: "", error: e instanceof Error ? e.message : String(e) });
23774
+ }
23775
+ }
23776
+ const created = results.filter((r) => !r.error).length;
23777
+ const failed = results.filter((r) => r.error).length;
23778
+ return json({ created, failed, total: scenarios.length, results });
23779
+ } catch (error) {
23780
+ return errorResponse(error);
23781
+ }
23782
+ });
23783
+ server.tool("list_templates", "List built-in scenario templates available for quick setup. Use apply_template to create scenarios from a template.", {}, async () => {
23784
+ try {
23785
+ const templates = listTemplateNames().map((name) => {
23786
+ const scenarios = getTemplate(name);
23787
+ return { name, scenarioCount: scenarios.length, scenarios: scenarios.map((s) => ({ name: s.name, description: s.description, priority: s.priority, tags: s.tags })) };
23788
+ });
23789
+ return json({ templates });
23790
+ } catch (error) {
23791
+ return errorResponse(error);
23792
+ }
23793
+ });
23794
+ server.tool("apply_template", "Create scenarios from a built-in template. Returns the created scenario IDs and any errors.", {
23795
+ template: exports_external.string().describe("Template name to apply (auth, crud, forms, nav, a11y, checkout, search)"),
23796
+ projectId: exports_external.string().optional().describe("Project ID to scope scenarios to")
23797
+ }, async ({ template, projectId }) => {
23798
+ try {
23799
+ const scenarios = getTemplate(template);
23800
+ if (!scenarios)
23801
+ return errorResponse(new Error(`Template not found: ${template}. Available: ${listTemplateNames().join(", ")}`));
23802
+ const results = [];
23803
+ for (const s of scenarios) {
23804
+ try {
23805
+ const scenario = createScenario({ ...s, projectId });
23806
+ results.push({ id: scenario.id, name: scenario.name, shortId: scenario.shortId });
23807
+ } catch (e) {
23808
+ results.push({ id: "", name: s.name, shortId: "", error: e instanceof Error ? e.message : String(e) });
23809
+ }
23810
+ }
23811
+ const created = results.filter((r) => !r.error).length;
23812
+ const failed = results.filter((r) => r.error).length;
23813
+ return json({ template, created, failed, total: scenarios.length, results });
23814
+ } catch (error) {
23815
+ return errorResponse(error);
23816
+ }
23817
+ });
23090
23818
  server.tool("get_scenario", `Get a scenario by ID or short ID. ${ID_DESC}`, {
23091
23819
  id: exports_external.string().describe(`Scenario ID or short ID. ${ID_DESC}`)
23092
23820
  }, async ({ id }) => {
@@ -23157,12 +23885,15 @@ server.tool("run_scenarios", "Run test scenarios against a URL. Provide url dire
23157
23885
  headed: exports_external.boolean().optional().describe("Run browser in headed mode"),
23158
23886
  parallel: exports_external.number().optional().describe("Number of parallel workers"),
23159
23887
  personaId: exports_external.string().optional().describe("Override persona ID for this run"),
23888
+ personaIds: exports_external.array(exports_external.string()).optional().describe("Run with multiple personas for divergence testing (each persona runs all scenarios)"),
23160
23889
  samples: exports_external.number().int().min(1).max(20).optional().describe("Run each scenario N times for flakiness detection (default 1)"),
23161
23890
  flakinessThreshold: exports_external.number().min(0).max(1).optional().describe("Pass rate below which scenario is marked flaky (default 0.95)"),
23162
23891
  maxCostCents: exports_external.number().optional().describe("Hard budget cap in cents \u2014 run is rejected before starting if estimated cost exceeds this"),
23163
23892
  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 }) => {
23893
+ minimal: exports_external.boolean().optional().describe("Fastest mode: cheapest model, max parallelism, min turns \u2014 ideal for CI smoke checks"),
23894
+ timeoutMs: exports_external.number().optional().describe("Per-scenario timeout in ms (default 120000)"),
23895
+ recordVideo: exports_external.boolean().optional().describe("Record video of each scenario run (Playwright only)")
23896
+ }, async ({ url, env: env2, tags, scenarioIds, priority, model, headed, parallel, personaId, personaIds, samples, flakinessThreshold, maxCostCents, cacheMaxAgeMs, minimal, timeoutMs, recordVideo }) => {
23166
23897
  try {
23167
23898
  let resolvedUrl = url;
23168
23899
  if (!resolvedUrl && env2) {
@@ -23180,12 +23911,54 @@ server.tool("run_scenarios", "Run test scenarios against a URL. Provide url dire
23180
23911
  }
23181
23912
  if (!resolvedUrl)
23182
23913
  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 });
23914
+ const { runId, scenarioCount } = startRunAsync({ url: resolvedUrl, tags, scenarioIds, priority, model, headed, parallel, personaId, personaIds, samples, flakinessThreshold, maxCostCents, cacheMaxAgeMs, minimal, timeout: timeoutMs, recordVideo });
23184
23915
  return json({ runId, scenarioCount, url: resolvedUrl, status: "running", message: "Poll with get_run to check progress." });
23185
23916
  } catch (error) {
23186
23917
  return errorResponse(error);
23187
23918
  }
23188
23919
  });
23920
+ server.tool("retry_failed", "Re-run only failed/errored scenarios from a previous run. Creates a new run with only the failing scenarios.", {
23921
+ runId: exports_external.string().describe("Previous run ID to retry failures from"),
23922
+ url: exports_external.string().optional().describe("Target URL (overrides original run URL)"),
23923
+ model: exports_external.string().optional().describe(MODEL_DESC),
23924
+ headed: exports_external.boolean().optional().describe("Run browser in headed mode"),
23925
+ parallel: exports_external.number().optional().describe("Number of parallel workers"),
23926
+ maxRetries: exports_external.number().int().min(0).max(3).optional().describe("Max retries per failed scenario"),
23927
+ maxCostCents: exports_external.number().optional().describe("Hard budget cap in cents")
23928
+ }, async ({ runId, url, model, headed, parallel, maxRetries, maxCostCents }) => {
23929
+ try {
23930
+ const run = getRun(runId);
23931
+ if (!run)
23932
+ return errorResponse(notFoundErr(runId, "Run"));
23933
+ if (run.status !== "failed")
23934
+ return errorResponse(new Error("Run is not in failed state. Can only retry failures from a failed run."));
23935
+ const results = getResultsByRun(runId);
23936
+ const failedResultIds = results.filter((r) => r.status === "failed" || r.status === "error").map((r) => r.scenarioId);
23937
+ if (failedResultIds.length === 0)
23938
+ return errorResponse(new Error("No failed results found in this run."));
23939
+ const resolvedUrl = url ?? run.url;
23940
+ const { runId: newRunId, scenarioCount } = startRunAsync({
23941
+ url: resolvedUrl,
23942
+ scenarioIds: failedResultIds,
23943
+ model,
23944
+ headed,
23945
+ parallel,
23946
+ retry: maxRetries ?? 0,
23947
+ maxCostCents
23948
+ });
23949
+ return json({
23950
+ runId: newRunId,
23951
+ originalRunId: runId,
23952
+ scenarioCount,
23953
+ retriedScenarioIds: failedResultIds,
23954
+ url: resolvedUrl,
23955
+ status: "running",
23956
+ message: "Poll with get_run to check progress."
23957
+ });
23958
+ } catch (error) {
23959
+ return errorResponse(error);
23960
+ }
23961
+ });
23189
23962
  server.tool("get_run", `Get details of a test run. ${ID_DESC}`, {
23190
23963
  id: exports_external.string().describe(`Run ID. ${ID_DESC}`)
23191
23964
  }, async ({ id }) => {
@@ -23199,11 +23972,17 @@ server.tool("get_run", `Get details of a test run. ${ID_DESC}`, {
23199
23972
  }
23200
23973
  });
23201
23974
  server.tool("list_runs", "List test runs with optional filters", {
23975
+ projectId: exports_external.string().optional().describe("Filter by project ID"),
23202
23976
  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 }) => {
23977
+ since: exports_external.string().optional().describe("Filter runs started at or after this ISO date"),
23978
+ until: exports_external.string().optional().describe("Filter runs started at or before this ISO date"),
23979
+ sort: exports_external.enum(["date", "duration", "cost"]).optional().describe("Sort field"),
23980
+ desc: exports_external.boolean().optional().describe("Sort descending (default true)"),
23981
+ limit: exports_external.number().optional().describe("Max results to return"),
23982
+ offset: exports_external.number().optional().describe("Number of results to skip")
23983
+ }, async ({ projectId, status, since, until, sort, desc, limit, offset }) => {
23205
23984
  try {
23206
- const runs = listRuns({ status, limit });
23985
+ const runs = listRuns({ projectId, status, since, until, sort, desc, limit, offset });
23207
23986
  return json({ items: runs, total: runs.length });
23208
23987
  } catch (error) {
23209
23988
  return errorResponse(error);
@@ -23372,41 +24151,6 @@ server.tool("get_run_costs", "Get cost breakdown for a run, with per-scenario de
23372
24151
  return errorResponse(error);
23373
24152
  }
23374
24153
  });
23375
- server.tool("batch_create_scenarios", "Create multiple scenarios in a single call. Returns created scenarios and any failures.", {
23376
- scenarios: exports_external.array(exports_external.object({
23377
- name: exports_external.string().describe("Scenario name"),
23378
- url: exports_external.string().optional().describe("Target URL (stored as targetPath)"),
23379
- description: exports_external.string().optional().describe("What this scenario tests"),
23380
- steps: exports_external.array(exports_external.string()).optional().describe("Ordered test steps"),
23381
- tags: exports_external.array(exports_external.string()).optional().describe("Tags for filtering"),
23382
- priority: exports_external.enum(["low", "medium", "high", "critical"]).optional().describe("Scenario priority")
23383
- })).describe("Array of scenarios to create")
23384
- }, async ({ scenarios }) => {
23385
- const created = [];
23386
- const failed = [];
23387
- for (let i = 0;i < scenarios.length; i++) {
23388
- const input = scenarios[i];
23389
- try {
23390
- const scenario = createScenario({
23391
- name: input.name,
23392
- description: input.description ?? input.name,
23393
- steps: input.steps,
23394
- tags: input.tags,
23395
- priority: input.priority,
23396
- targetPath: input.url
23397
- });
23398
- created.push(scenario);
23399
- } catch (error) {
23400
- const e = error instanceof Error ? error : new Error(String(error));
23401
- failed.push({ index: i, name: input.name, error: e.message });
23402
- }
23403
- }
23404
- const lines = [
23405
- `Created: ${created.length} scenario(s)`,
23406
- ...created.map((s) => ` [${s.shortId}] ${s.name}`)
23407
- ];
23408
- return json({ created, failed });
23409
- });
23410
24154
  server.tool("cancel_run", "Mark a run as cancelled in the database. In-flight browser processes may still complete but results will be ignored.", {
23411
24155
  runId: exports_external.string().describe("Run ID to cancel")
23412
24156
  }, async ({ runId }) => {
@@ -23597,14 +24341,15 @@ server.tool("run_health_scan", "Run all scanners (console, network, links, perfo
23597
24341
  url: exports_external.string().describe("URL to scan"),
23598
24342
  pages: exports_external.array(exports_external.string()).optional().describe("Specific paths to include"),
23599
24343
  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)"),
24344
+ scanners: exports_external.array(exports_external.enum(["console", "network", "links", "performance", "a11y"])).optional().describe("Which scanners to run (default: console, network, links)"),
23601
24345
  maxPages: exports_external.number().optional().describe("Max pages for link crawl (default 20)"),
24346
+ wcagLevel: exports_external.enum(["A", "AA", "AAA"]).optional().describe("WCAG compliance level for a11y scanner (default: AA)"),
23602
24347
  headed: exports_external.boolean().optional(),
23603
24348
  timeoutMs: exports_external.number().optional()
23604
- }, async ({ url, pages, projectId, scanners, maxPages, headed, timeoutMs }) => {
24349
+ }, async ({ url, pages, projectId, scanners, maxPages, headed, timeoutMs, wcagLevel }) => {
23605
24350
  try {
23606
24351
  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 });
24352
+ const summary = await runHealthScan2({ url, pages, projectId, scanners, maxPages, headed, timeoutMs, wcagLevel });
23608
24353
  return json(summary);
23609
24354
  } catch (e) {
23610
24355
  return errorResponse(e);
@@ -24362,6 +25107,32 @@ Context: ${context}` : ""}`,
24362
25107
  return errorResponse(e);
24363
25108
  }
24364
25109
  });
25110
+ 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}`, {
25111
+ resultId: exports_external.string().describe(`Result ID. ${ID_DESC}`),
25112
+ includeContent: exports_external.boolean().optional().describe("Return full HAR JSON content (default: false)")
25113
+ }, async ({ resultId, includeContent }) => {
25114
+ try {
25115
+ const { getResult: getResult2 } = await Promise.resolve().then(() => (init_results(), exports_results));
25116
+ const result = getResult2(resultId);
25117
+ if (!result)
25118
+ return errorResponse(notFoundErr(resultId, "Result"));
25119
+ const harPath = result.harPath ?? result.metadata?.harPath;
25120
+ if (!harPath)
25121
+ return json({ resultId, harAvailable: false, message: "No HAR file recorded for this result." });
25122
+ const harFile = Bun.file(harPath);
25123
+ const exists = await harFile.exists();
25124
+ if (!exists)
25125
+ return json({ resultId, harPath, harAvailable: false, message: "HAR file was recorded but has been cleaned up." });
25126
+ if (!includeContent) {
25127
+ const size = await harFile.size();
25128
+ return json({ resultId, harPath, harAvailable: true, sizeBytes: size, message: "HAR file available. Use includeContent: true to retrieve." });
25129
+ }
25130
+ const harContent = await harFile.text();
25131
+ return json({ resultId, harPath, harAvailable: true, har: JSON.parse(harContent) });
25132
+ } catch (e) {
25133
+ return errorResponse(e);
25134
+ }
25135
+ });
24365
25136
  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
25137
  runId: exports_external.string().describe("Run ID to wait for"),
24367
25138
  timeoutMs: exports_external.number().optional().default(300000).describe("Max wait time in ms (default 5 minutes)"),
@@ -24644,6 +25415,13 @@ server.tool("list_scenarios_by_page", "Group scenarios by page (targetPath). Sho
24644
25415
  }
24645
25416
  });
24646
25417
  registerCloudTools(server, "testers");
25418
+ process.on("unhandledRejection", (reason) => {
25419
+ const msg = reason instanceof Error ? reason.stack ?? reason.message : String(reason);
25420
+ console.error(`[testers-mcp] Unhandled promise rejection: ${msg}`);
25421
+ });
25422
+ process.on("uncaughtException", (err) => {
25423
+ console.error(`[testers-mcp] Uncaught exception: ${err.stack ?? err.message}`);
25424
+ });
24647
25425
  async function main() {
24648
25426
  const transport = new StdioServerTransport;
24649
25427
  await server.connect(transport);