@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/index.js CHANGED
@@ -65,7 +65,8 @@ function scenarioFromRow(row) {
65
65
  createdAt: row.created_at,
66
66
  updatedAt: row.updated_at,
67
67
  lastPassedAt: row.last_passed_at ?? null,
68
- lastPassedUrl: row.last_passed_url ?? null
68
+ lastPassedUrl: row.last_passed_url ?? null,
69
+ parameters: row.parameters ? JSON.parse(row.parameters) : null
69
70
  };
70
71
  }
71
72
  function runFromRow(row) {
@@ -85,7 +86,14 @@ function runFromRow(row) {
85
86
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
86
87
  isBaseline: row.is_baseline === 1,
87
88
  samples: row.samples ?? 1,
88
- flakinessThreshold: row.flakiness_threshold ?? 0.95
89
+ flakinessThreshold: row.flakiness_threshold ?? 0.95,
90
+ prNumber: row.pr_number ?? null,
91
+ prTitle: row.pr_title ?? null,
92
+ prBranch: row.pr_branch ?? null,
93
+ prBaseBranch: row.pr_base_branch ?? null,
94
+ prCommitSha: row.pr_commit_sha ?? null,
95
+ prUrl: row.pr_url ?? null,
96
+ ghAppInstallationId: row.gh_app_installation_id ?? null
89
97
  };
90
98
  }
91
99
  function resultFromRow(row) {
@@ -106,7 +114,8 @@ function resultFromRow(row) {
106
114
  createdAt: row.created_at,
107
115
  personaId: row.persona_id ?? null,
108
116
  personaName: row.persona_name ?? null,
109
- failureAnalysis: row.failure_analysis ? JSON.parse(row.failure_analysis) : null
117
+ failureAnalysis: row.failure_analysis ? JSON.parse(row.failure_analysis) : null,
118
+ harPath: row.har_path ?? null
110
119
  };
111
120
  }
112
121
  function screenshotFromRow(row) {
@@ -180,7 +189,10 @@ function personaFromRow(row) {
180
189
  email: row.auth_email,
181
190
  password: row.auth_password,
182
191
  loginPath: row.auth_login_path ?? "/login",
183
- cookies: row.auth_cookies ? JSON.parse(row.auth_cookies) : null
192
+ cookies: row.auth_cookies ? JSON.parse(row.auth_cookies) : null,
193
+ strategy: row.auth_strategy ?? "form-login",
194
+ headers: row.auth_headers ? JSON.parse(row.auth_headers) : undefined,
195
+ customScript: row.auth_script ?? undefined
184
196
  } : null
185
197
  };
186
198
  }
@@ -10122,6 +10134,43 @@ ALTER TABLE scenarios ADD COLUMN required_role TEXT;
10122
10134
  machine_id TEXT,
10123
10135
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
10124
10136
  );
10137
+ `,
10138
+ `
10139
+ ALTER TABLE results ADD COLUMN har_path TEXT;
10140
+ `,
10141
+ `
10142
+ ALTER TABLE scenarios ADD COLUMN parameters TEXT;
10143
+ `,
10144
+ `
10145
+ ALTER TABLE personas ADD COLUMN auth_strategy TEXT DEFAULT 'form-login';
10146
+ ALTER TABLE personas ADD COLUMN auth_headers TEXT;
10147
+ ALTER TABLE personas ADD COLUMN auth_script TEXT;
10148
+ `,
10149
+ `
10150
+ CREATE TABLE IF NOT EXISTS step_results (
10151
+ id TEXT PRIMARY KEY,
10152
+ result_id TEXT NOT NULL REFERENCES results(id) ON DELETE CASCADE,
10153
+ step_number INTEGER NOT NULL,
10154
+ action TEXT NOT NULL,
10155
+ status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('passed','failed','error','running','skipped')),
10156
+ tool_name TEXT,
10157
+ tool_input TEXT,
10158
+ tool_result TEXT,
10159
+ thinking TEXT,
10160
+ error TEXT,
10161
+ duration_ms INTEGER,
10162
+ screenshot_id TEXT REFERENCES screenshots(id),
10163
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
10164
+ );
10165
+ `,
10166
+ `
10167
+ ALTER TABLE runs ADD COLUMN pr_number INTEGER;
10168
+ ALTER TABLE runs ADD COLUMN pr_title TEXT;
10169
+ ALTER TABLE runs ADD COLUMN pr_branch TEXT;
10170
+ ALTER TABLE runs ADD COLUMN pr_base_branch TEXT;
10171
+ ALTER TABLE runs ADD COLUMN pr_commit_sha TEXT;
10172
+ ALTER TABLE runs ADD COLUMN pr_url TEXT;
10173
+ ALTER TABLE runs ADD COLUMN gh_app_installation_id TEXT;
10125
10174
  `
10126
10175
  ];
10127
10176
  });
@@ -10130,8 +10179,12 @@ ALTER TABLE scenarios ADD COLUMN required_role TEXT;
10130
10179
  var exports_runs = {};
10131
10180
  __export(exports_runs, {
10132
10181
  updateRun: () => updateRun,
10182
+ updatePrRunMetadata: () => updatePrRunMetadata,
10133
10183
  listRuns: () => listRuns,
10184
+ listPrRuns: () => listPrRuns,
10185
+ getRunsByPr: () => getRunsByPr,
10134
10186
  getRun: () => getRun,
10187
+ getLatestPrRun: () => getLatestPrRun,
10135
10188
  deleteRun: () => deleteRun,
10136
10189
  createRun: () => createRun,
10137
10190
  countRuns: () => countRuns
@@ -10141,9 +10194,9 @@ function createRun(input) {
10141
10194
  const id = uuid();
10142
10195
  const timestamp = now();
10143
10196
  db2.query(`
10144
- INSERT INTO runs (id, project_id, status, url, model, headed, parallel, total, passed, failed, started_at, finished_at, metadata, samples, flakiness_threshold)
10145
- VALUES (?, ?, 'pending', ?, ?, ?, ?, 0, 0, 0, ?, NULL, ?, ?, ?)
10146
- `).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);
10197
+ 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)
10198
+ VALUES (?, ?, 'pending', ?, ?, ?, ?, 0, 0, 0, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
10199
+ `).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);
10147
10200
  return getRun(id);
10148
10201
  }
10149
10202
  function getRun(id) {
@@ -10171,6 +10224,14 @@ function listRuns(filter) {
10171
10224
  conditions.push("status = ?");
10172
10225
  params.push(filter.status);
10173
10226
  }
10227
+ if (filter?.since) {
10228
+ conditions.push("started_at >= ?");
10229
+ params.push(filter.since);
10230
+ }
10231
+ if (filter?.until) {
10232
+ conditions.push("started_at <= ?");
10233
+ params.push(filter.until);
10234
+ }
10174
10235
  let sql = "SELECT * FROM runs";
10175
10236
  if (conditions.length > 0) {
10176
10237
  sql += " WHERE " + conditions.join(" AND ");
@@ -10202,6 +10263,14 @@ function countRuns(filter) {
10202
10263
  conditions.push("status = ?");
10203
10264
  params.push(filter.status);
10204
10265
  }
10266
+ if (filter?.since) {
10267
+ conditions.push("started_at >= ?");
10268
+ params.push(filter.since);
10269
+ }
10270
+ if (filter?.until) {
10271
+ conditions.push("started_at <= ?");
10272
+ params.push(filter.until);
10273
+ }
10205
10274
  let sql = "SELECT COUNT(*) as count FROM runs";
10206
10275
  if (conditions.length > 0)
10207
10276
  sql += " WHERE " + conditions.join(" AND ");
@@ -10279,6 +10348,52 @@ function deleteRun(id) {
10279
10348
  const result = db2.query("DELETE FROM runs WHERE id = ?").run(run.id);
10280
10349
  return result.changes > 0;
10281
10350
  }
10351
+ function getRunsByPr(prNumber) {
10352
+ const db2 = getDatabase();
10353
+ const rows = db2.query("SELECT * FROM runs WHERE pr_number = ? ORDER BY started_at DESC").all(prNumber);
10354
+ return rows.map(runFromRow);
10355
+ }
10356
+ function getLatestPrRun(prNumber) {
10357
+ const db2 = getDatabase();
10358
+ const row = db2.query("SELECT * FROM runs WHERE pr_number = ? ORDER BY started_at DESC, rowid DESC LIMIT 1").get(prNumber);
10359
+ return row ? runFromRow(row) : null;
10360
+ }
10361
+ function listPrRuns(filter) {
10362
+ const db2 = getDatabase();
10363
+ const conditions = ["pr_number IS NOT NULL"];
10364
+ const params = [];
10365
+ if (filter?.branch) {
10366
+ conditions.push("pr_branch = ?");
10367
+ params.push(filter.branch);
10368
+ }
10369
+ if (filter?.baseBranch) {
10370
+ conditions.push("pr_base_branch = ?");
10371
+ params.push(filter.baseBranch);
10372
+ }
10373
+ let sql = "SELECT * FROM runs WHERE " + conditions.join(" AND ") + " ORDER BY started_at DESC";
10374
+ if (filter?.limit) {
10375
+ sql += " LIMIT ?";
10376
+ params.push(filter.limit);
10377
+ }
10378
+ if (filter?.offset) {
10379
+ sql += " OFFSET ?";
10380
+ params.push(filter.offset);
10381
+ }
10382
+ const rows = db2.query(sql).all(...params);
10383
+ return rows.map(runFromRow);
10384
+ }
10385
+ function updatePrRunMetadata(runId, prData) {
10386
+ const db2 = getDatabase();
10387
+ const existing = getRun(runId);
10388
+ if (!existing) {
10389
+ throw new Error(`Run not found: ${runId}`);
10390
+ }
10391
+ db2.query(`
10392
+ UPDATE runs SET pr_number = ?, pr_title = ?, pr_branch = ?, pr_base_branch = ?, pr_commit_sha = ?, pr_url = ?, gh_app_installation_id = ?
10393
+ WHERE id = ?
10394
+ `).run(prData.prNumber, prData.prTitle ?? null, prData.prBranch ?? null, prData.prBaseBranch ?? null, prData.prCommitSha ?? null, prData.prUrl ?? null, prData.ghAppInstallationId ?? null, runId);
10395
+ return getRun(runId);
10396
+ }
10282
10397
  var init_runs = __esm(() => {
10283
10398
  init_types();
10284
10399
  init_database();
@@ -10467,7 +10582,7 @@ function getDefaultConfig() {
10467
10582
  browser: {
10468
10583
  headless: true,
10469
10584
  viewport: { width: 1280, height: 720 },
10470
- timeout: 60000
10585
+ timeout: 120000
10471
10586
  },
10472
10587
  screenshots: {
10473
10588
  dir: join8(getTestersDir(), "screenshots"),
@@ -11151,6 +11266,16 @@ async function launchBrowser(options) {
11151
11266
  const headless = options?.headless ?? true;
11152
11267
  const viewport = options?.viewport ?? DEFAULT_VIEWPORT;
11153
11268
  try {
11269
+ if (engine === "playwright-firefox") {
11270
+ const { firefox } = await import("playwright");
11271
+ const browser = await firefox.launch({ headless });
11272
+ return browser;
11273
+ }
11274
+ if (engine === "playwright-webkit") {
11275
+ const { webkit } = await import("playwright");
11276
+ const browser = await webkit.launch({ headless });
11277
+ return browser;
11278
+ }
11154
11279
  return await launchPlaywright({ headless, viewport });
11155
11280
  } catch (error) {
11156
11281
  const message = error instanceof Error ? error.message : String(error);
@@ -11267,8 +11392,9 @@ async function installBrowser(engine) {
11267
11392
  const { installLightpanda: installLightpanda2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
11268
11393
  return installLightpanda2();
11269
11394
  }
11395
+ const browserName = engine === "playwright-firefox" ? "firefox" : engine === "playwright-webkit" ? "webkit" : "chromium";
11270
11396
  try {
11271
- execSync("bunx playwright install chromium", {
11397
+ execSync(`bunx playwright install ${browserName}`, {
11272
11398
  stdio: "inherit"
11273
11399
  });
11274
11400
  } catch (error) {
@@ -12410,9 +12536,9 @@ function createScenario(input) {
12410
12536
  const short_id = nextShortId(input.projectId);
12411
12537
  const timestamp = now();
12412
12538
  db2.query(`
12413
- 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)
12414
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
12415
- `).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);
12539
+ 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)
12540
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
12541
+ `).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);
12416
12542
  return getScenario(id);
12417
12543
  }
12418
12544
  function getScenario(id) {
@@ -12562,6 +12688,10 @@ function updateScenario(id, input, version) {
12562
12688
  sets.push("assertions = ?");
12563
12689
  params.push(JSON.stringify(input.assertions));
12564
12690
  }
12691
+ if (input.parameters !== undefined) {
12692
+ sets.push("parameters = ?");
12693
+ params.push(JSON.stringify(input.parameters));
12694
+ }
12565
12695
  if (sets.length === 0) {
12566
12696
  return existing;
12567
12697
  }
@@ -13597,6 +13727,8 @@ async function runPipelineScenario(scenario, options) {
13597
13727
 
13598
13728
  // src/lib/runner.ts
13599
13729
  init_runs();
13730
+ import { mkdirSync as mkdirSync8 } from "fs";
13731
+ import { join as join13 } from "path";
13600
13732
 
13601
13733
  // src/lib/failure-analyzer.ts
13602
13734
  function analyzeFailure(error, reasoning) {
@@ -14378,6 +14510,74 @@ function formatCostsJSON(summary) {
14378
14510
  return JSON.stringify(summary, null, 2);
14379
14511
  }
14380
14512
 
14513
+ // src/db/step-results.ts
14514
+ init_database();
14515
+ function createStepResult(input) {
14516
+ const db2 = getDatabase();
14517
+ const id = uuid();
14518
+ const timestamp = now();
14519
+ db2.query(`
14520
+ INSERT INTO step_results (id, result_id, step_number, action, status, tool_name, tool_input, thinking, created_at)
14521
+ VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?)
14522
+ `).run(id, input.resultId, input.stepNumber, input.action, input.toolName ?? null, input.toolInput ? JSON.stringify(input.toolInput) : null, input.thinking ?? null, timestamp);
14523
+ return getStepResult(id);
14524
+ }
14525
+ function getStepResult(id) {
14526
+ const db2 = getDatabase();
14527
+ const row = db2.query("SELECT * FROM step_results WHERE id = ?").get(id);
14528
+ return row ? stepResultFromRow(row) : null;
14529
+ }
14530
+ function updateStepResult(id, updates) {
14531
+ const db2 = getDatabase();
14532
+ const existing = getStepResult(id);
14533
+ if (!existing)
14534
+ return null;
14535
+ const sets = [];
14536
+ const params = [];
14537
+ if (updates.status !== undefined) {
14538
+ sets.push("status = ?");
14539
+ params.push(updates.status);
14540
+ }
14541
+ if (updates.toolResult !== undefined) {
14542
+ sets.push("tool_result = ?");
14543
+ params.push(updates.toolResult);
14544
+ }
14545
+ if (updates.error !== undefined) {
14546
+ sets.push("error = ?");
14547
+ params.push(updates.error);
14548
+ }
14549
+ if (updates.durationMs !== undefined) {
14550
+ sets.push("duration_ms = ?");
14551
+ params.push(updates.durationMs);
14552
+ }
14553
+ if (updates.screenshotId !== undefined) {
14554
+ sets.push("screenshot_id = ?");
14555
+ params.push(updates.screenshotId);
14556
+ }
14557
+ if (sets.length === 0)
14558
+ return existing;
14559
+ params.push(id);
14560
+ db2.query(`UPDATE step_results SET ${sets.join(", ")} WHERE id = ?`).run(...params);
14561
+ return getStepResult(id);
14562
+ }
14563
+ function stepResultFromRow(row) {
14564
+ return {
14565
+ id: row.id,
14566
+ resultId: row.result_id,
14567
+ stepNumber: row.step_number,
14568
+ action: row.action,
14569
+ status: row.status,
14570
+ toolName: row.tool_name,
14571
+ toolInput: row.tool_input ? JSON.parse(row.tool_input) : null,
14572
+ toolResult: row.tool_result,
14573
+ thinking: row.thinking,
14574
+ error: row.error,
14575
+ durationMs: row.duration_ms,
14576
+ screenshotId: row.screenshot_id,
14577
+ createdAt: row.created_at
14578
+ };
14579
+ }
14580
+
14381
14581
  // src/db/personas.ts
14382
14582
  init_types();
14383
14583
  init_database();
@@ -14734,6 +14934,24 @@ function signPayload(body, secret) {
14734
14934
  }
14735
14935
  return `sha256=${Math.abs(hash).toString(16).padStart(16, "0")}`;
14736
14936
  }
14937
+ function formatDiscordPayload(payload) {
14938
+ const isPassed = payload.run.status === "passed";
14939
+ const color = isPassed ? 2278750 : 15680580;
14940
+ return {
14941
+ username: "open-testers",
14942
+ embeds: [
14943
+ {
14944
+ title: `Test Run ${payload.run.status.toUpperCase()}`,
14945
+ color,
14946
+ description: `URL: ${payload.run.url}
14947
+ ` + `Results: ${payload.run.passed}/${payload.run.total} passed` + (payload.run.failed > 0 ? ` (${payload.run.failed} failed)` : "") + (payload.schedule ? `
14948
+ Schedule: ${payload.schedule.name}` : ""),
14949
+ timestamp: payload.timestamp,
14950
+ footer: { text: "open-testers" }
14951
+ }
14952
+ ]
14953
+ };
14954
+ }
14737
14955
  function formatSlackPayload(payload) {
14738
14956
  const status = payload.run.status === "passed" ? ":white_check_mark:" : ":x:";
14739
14957
  const color = payload.run.status === "passed" ? "#22c55e" : "#ef4444";
@@ -14776,7 +14994,8 @@ async function dispatchWebhooks(event, run, schedule) {
14776
14994
  if (!webhook.events.includes(event) && !webhook.events.includes("*"))
14777
14995
  continue;
14778
14996
  const isSlack = webhook.url.includes("hooks.slack.com");
14779
- const body = isSlack ? JSON.stringify(formatSlackPayload(payload)) : JSON.stringify(payload);
14997
+ const isDiscord = webhook.url.includes("discord.com/api/webhooks") || webhook.url.includes("discordapp.com/api/webhooks");
14998
+ const body = isSlack ? JSON.stringify(formatSlackPayload(payload)) : isDiscord ? JSON.stringify(formatDiscordPayload(payload)) : JSON.stringify(payload);
14780
14999
  const headers = {
14781
15000
  "Content-Type": "application/json"
14782
15001
  };
@@ -15186,13 +15405,35 @@ async function runSingleScenario(scenario, runId, options) {
15186
15405
  emit({ type: "scenario:start", scenarioId: scenario.id, scenarioName: scenario.name, resultId: result.id, runId });
15187
15406
  let browser = null;
15188
15407
  let page = null;
15408
+ let context = null;
15409
+ let harPath = null;
15189
15410
  let stopNetworkLogging = null;
15190
15411
  const networkErrors = [];
15191
15412
  try {
15192
15413
  browser = await launchBrowser({ headless: !(effectiveOptions.headed ?? false), engine: effectiveOptions.engine });
15193
- page = await getPage(browser, {
15194
- viewport: config.browser.viewport
15195
- });
15414
+ const useHar = effectiveOptions.engine !== "lightpanda" && effectiveOptions.engine !== "bun";
15415
+ if (useHar) {
15416
+ const testersDir = process.env["HASNA_TESTERS_DIR"] || join13(process.env["HOME"] || "", ".hasna", "testers");
15417
+ const harDir = join13(testersDir, "hars");
15418
+ mkdirSync8(harDir, { recursive: true });
15419
+ harPath = join13(harDir, `${result.id}.har`);
15420
+ const contextOptions = {
15421
+ viewport: config.browser.viewport,
15422
+ recordHar: { path: harPath, mode: "full" }
15423
+ };
15424
+ if (effectiveOptions.recordVideo) {
15425
+ const videoDir = join13(testersDir, "videos");
15426
+ mkdirSync8(videoDir, { recursive: true });
15427
+ contextOptions.recordVideo = { dir: videoDir, size: config.browser.viewport };
15428
+ }
15429
+ context = await browser.newContext(contextOptions);
15430
+ page = await context.newPage();
15431
+ } else {
15432
+ page = await getPage(browser, {
15433
+ viewport: config.browser.viewport,
15434
+ engine: effectiveOptions.engine
15435
+ });
15436
+ }
15196
15437
  const targetUrl = scenario.targetPath ? `${options.url.replace(/\/$/, "")}${scenario.targetPath}` : options.url;
15197
15438
  const scenarioTimeout = scenario.timeoutMs ?? options.timeout ?? config.browser.timeout ?? 60000;
15198
15439
  registerSession({
@@ -15212,7 +15453,11 @@ async function runSingleScenario(scenario, runId, options) {
15212
15453
  }
15213
15454
  });
15214
15455
  const consoleErrors = [];
15456
+ const consoleLogs = [];
15457
+ let currentStep = 0;
15215
15458
  page.on("console", (msg) => {
15459
+ const logEntry = { step: currentStep > 0 ? currentStep : null, type: msg.type(), text: msg.text(), timestamp: Date.now() };
15460
+ consoleLogs.push(logEntry);
15216
15461
  if (msg.type() === "error")
15217
15462
  consoleErrors.push(msg.text());
15218
15463
  });
@@ -15244,6 +15489,7 @@ async function runSingleScenario(scenario, runId, options) {
15244
15489
  }
15245
15490
  await page.goto(targetUrl, { timeout: Math.min(scenarioTimeout, 30000) });
15246
15491
  const stepStartTimes = new Map;
15492
+ const stepResultIds = new Map;
15247
15493
  const agentResult = await withTimeout(runAgentLoop({
15248
15494
  client,
15249
15495
  page,
@@ -15266,13 +15512,32 @@ async function runSingleScenario(scenario, runId, options) {
15266
15512
  onStep: (stepEvent) => {
15267
15513
  let stepDurationMs;
15268
15514
  if (stepEvent.type === "tool_call") {
15515
+ currentStep = stepEvent.stepNumber;
15269
15516
  stepStartTimes.set(stepEvent.stepNumber, Date.now());
15517
+ const stepResult = createStepResult({
15518
+ resultId: result.id,
15519
+ stepNumber: stepEvent.stepNumber,
15520
+ action: stepEvent.toolName ?? `step-${stepEvent.stepNumber}`,
15521
+ toolName: stepEvent.toolName,
15522
+ toolInput: stepEvent.toolInput,
15523
+ thinking: stepEvent.thinking
15524
+ });
15525
+ stepResultIds.set(stepEvent.stepNumber, stepResult.id);
15270
15526
  } else if (stepEvent.type === "tool_result") {
15271
15527
  const startTime = stepStartTimes.get(stepEvent.stepNumber);
15272
15528
  if (startTime !== undefined) {
15273
15529
  stepDurationMs = Date.now() - startTime;
15274
15530
  stepStartTimes.delete(stepEvent.stepNumber);
15275
15531
  }
15532
+ const stepResultId = stepResultIds.get(stepEvent.stepNumber);
15533
+ if (stepResultId) {
15534
+ const isSuccess = !stepEvent.toolResult?.toLowerCase().includes("error") && !stepEvent.toolResult?.toLowerCase().includes("failed");
15535
+ updateStepResult(stepResultId, {
15536
+ status: isSuccess ? "passed" : "failed",
15537
+ toolResult: stepEvent.toolResult,
15538
+ durationMs: stepDurationMs
15539
+ });
15540
+ }
15276
15541
  }
15277
15542
  emit({
15278
15543
  type: `step:${stepEvent.type}`,
@@ -15321,7 +15586,7 @@ async function runSingleScenario(scenario, runId, options) {
15321
15586
  durationMs: Date.now() - new Date(result.createdAt).getTime(),
15322
15587
  tokensUsed: agentResult.tokensUsed,
15323
15588
  costCents: estimateCost(model, agentResult.tokensUsed),
15324
- metadata: networkErrors.length > 0 ? networkMeta : undefined
15589
+ metadata: { consoleLogs, ...networkErrors.length > 0 ? networkMeta : {} }
15325
15590
  });
15326
15591
  if (agentResult.status === "failed" || agentResult.status === "error") {
15327
15592
  const failureAnalysis = analyzeFailure(null, agentResult.reasoning ?? null);
@@ -15357,8 +15622,16 @@ async function runSingleScenario(scenario, runId, options) {
15357
15622
  emit({ type: "scenario:error", scenarioId: scenario.id, scenarioName: scenario.name, error: errorMsg, runId });
15358
15623
  return updatedResult;
15359
15624
  } finally {
15360
- if (browser)
15361
- await closeBrowser(browser, effectiveOptions.engine);
15625
+ if (harPath) {
15626
+ try {
15627
+ updateResult(result.id, { metadata: { harPath } });
15628
+ } catch {}
15629
+ }
15630
+ if (browser) {
15631
+ try {
15632
+ await closeBrowser(browser, effectiveOptions.engine);
15633
+ } catch {}
15634
+ }
15362
15635
  }
15363
15636
  }
15364
15637
  async function runBatch(scenarios, options) {
@@ -16111,10 +16384,10 @@ class Scheduler {
16111
16384
  }
16112
16385
  // src/lib/init.ts
16113
16386
  init_paths();
16114
- import { existsSync as existsSync11, readFileSync as readFileSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync8 } from "fs";
16115
- import { join as join13, basename } from "path";
16387
+ import { existsSync as existsSync11, readFileSync as readFileSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync9 } from "fs";
16388
+ import { join as join14, basename } from "path";
16116
16389
  function detectFramework(dir) {
16117
- const pkgPath = join13(dir, "package.json");
16390
+ const pkgPath = join14(dir, "package.json");
16118
16391
  if (!existsSync11(pkgPath))
16119
16392
  return null;
16120
16393
  let pkg;
@@ -16342,9 +16615,9 @@ function initProject(options) {
16342
16615
  }
16343
16616
  }).filter((s) => s !== null);
16344
16617
  const configDir = getTestersDir();
16345
- const configPath = join13(configDir, "config.json");
16618
+ const configPath = join14(configDir, "config.json");
16346
16619
  if (!existsSync11(configDir)) {
16347
- mkdirSync8(configDir, { recursive: true });
16620
+ mkdirSync9(configDir, { recursive: true });
16348
16621
  }
16349
16622
  let config = {};
16350
16623
  if (existsSync11(configPath)) {
@@ -16747,6 +17020,18 @@ var SCENARIO_TEMPLATES = {
16747
17020
  a11y: [
16748
17021
  { 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"] },
16749
17022
  { 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"] }
17023
+ ],
17024
+ checkout: [
17025
+ { 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"] },
17026
+ { 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"] },
17027
+ { 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"] },
17028
+ { 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"] }
17029
+ ],
17030
+ search: [
17031
+ { 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"] },
17032
+ { 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)"] },
17033
+ { 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"] },
17034
+ { 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"] }
16750
17035
  ]
16751
17036
  };
16752
17037
  function getTemplate(name) {
@@ -17084,6 +17369,132 @@ async function startWatcher(options) {
17084
17369
  process.on("SIGTERM", cleanup);
17085
17370
  await new Promise(() => {});
17086
17371
  }
17372
+ // src/lib/ci.ts
17373
+ function generateGitHubActionsWorkflow() {
17374
+ return `name: AI QA Tests
17375
+ on:
17376
+ pull_request:
17377
+ push:
17378
+ branches: [main]
17379
+
17380
+ permissions:
17381
+ contents: read
17382
+ pull-requests: write
17383
+
17384
+ jobs:
17385
+ test:
17386
+ runs-on: ubuntu-latest
17387
+ steps:
17388
+ - uses: actions/checkout@v4
17389
+ - uses: oven-sh/setup-bun@v2
17390
+ - run: bun install -g @hasna/testers
17391
+ - run: testers install-browser
17392
+ - run: testers run \${{ env.TEST_URL }} --json --output results.json --github-comment
17393
+ env:
17394
+ ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
17395
+ TEST_URL: \${{ vars.TEST_URL || 'http://localhost:3000' }}
17396
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
17397
+ - run: testers report --latest --output report.html
17398
+ if: always()
17399
+ - uses: actions/upload-artifact@v4
17400
+ if: always()
17401
+ with:
17402
+ name: test-report
17403
+ path: |
17404
+ report.html
17405
+ results.json
17406
+ `;
17407
+ }
17408
+ function formatPRComment(run, results, dashboardUrl) {
17409
+ const icon = run.status === "passed" ? "\u2705" : run.status === "failed" ? "\u274C" : "\u26A0\uFE0F";
17410
+ const passRate = run.total > 0 ? Math.round(run.passed / run.total * 100) : 0;
17411
+ const ordered = [...results].sort((a, b) => {
17412
+ const rank = (s) => s === "failed" ? 0 : s === "error" ? 1 : s === "flaky" ? 2 : s === "skipped" ? 3 : 4;
17413
+ return rank(a.status) - rank(b.status);
17414
+ });
17415
+ const MAX_ROWS = 20;
17416
+ const rows = ordered.slice(0, MAX_ROWS).map((r) => {
17417
+ const rowIcon = r.status === "passed" ? "\u2705" : r.status === "failed" ? "\u274C" : r.status === "error" ? "\u26A0\uFE0F" : r.status === "flaky" ? "\uD83D\uDFE1" : "\u23ED\uFE0F";
17418
+ const dur = r.durationMs > 0 ? `${(r.durationMs / 1000).toFixed(1)}s` : "\u2014";
17419
+ const scenario = (() => {
17420
+ try {
17421
+ return getScenario(r.scenarioId);
17422
+ } catch {
17423
+ return null;
17424
+ }
17425
+ })();
17426
+ const name = scenario ? scenario.name : r.scenarioId.slice(0, 8);
17427
+ const safeName = name.replace(/\|/g, "\\|");
17428
+ const errSource = r.error ?? r.reasoning ?? "";
17429
+ const err = errSource ? ` ${errSource.replace(/\s+/g, " ").slice(0, 140).replace(/\|/g, "\\|")}` : "";
17430
+ return `| ${rowIcon} | ${safeName} | ${r.status} | ${dur} |${err} |`;
17431
+ }).join(`
17432
+ `);
17433
+ const truncated = results.length > MAX_ROWS ? `
17434
+ _...and ${results.length - MAX_ROWS} more_` : "";
17435
+ const dashLink = dashboardUrl ? `
17436
+
17437
+ [View full report \u2192](${dashboardUrl}/runs/${run.id})` : "";
17438
+ const totalCostCents = results.reduce((sum, r) => sum + (r.costCents ?? 0), 0);
17439
+ const costStr = totalCostCents > 0 ? ` \xB7 $${(totalCostCents / 100).toFixed(4)}` : "";
17440
+ const headerRow = `| | Scenario | Status | Duration | Details |
17441
+ |---|---|---|---|---|`;
17442
+ const body = results.length > 0 ? `${headerRow}
17443
+ ${rows}${truncated}` : `_No scenarios ran. Use \`testers add\` to create scenarios or run with a URL to auto-generate them._`;
17444
+ return `## ${icon} AI QA Tests \u2014 ${run.status.toUpperCase()}
17445
+
17446
+ **${run.passed}/${run.total} passed** (${passRate}%) \xB7 URL: \`${run.url}\`${costStr}
17447
+
17448
+ ${body}${dashLink}
17449
+
17450
+ _Generated by [@hasna/testers](https://www.npmjs.com/package/@hasna/testers)_`;
17451
+ }
17452
+ function resolvePullRequestNumber(explicit) {
17453
+ if (explicit && Number.isFinite(explicit))
17454
+ return explicit;
17455
+ const fromEnv = process.env["GITHUB_PR_NUMBER"];
17456
+ if (fromEnv) {
17457
+ const parsed = parseInt(fromEnv, 10);
17458
+ if (Number.isFinite(parsed))
17459
+ return parsed;
17460
+ }
17461
+ const ref = process.env["GITHUB_REF"] ?? "";
17462
+ const match = ref.match(/refs\/pull\/(\d+)\//);
17463
+ if (match && match[1]) {
17464
+ const parsed = parseInt(match[1], 10);
17465
+ if (Number.isFinite(parsed))
17466
+ return parsed;
17467
+ }
17468
+ return null;
17469
+ }
17470
+ async function postGitHubComment(run, results, options) {
17471
+ const token = process.env["GITHUB_TOKEN"];
17472
+ if (!token)
17473
+ return false;
17474
+ const prNumber = resolvePullRequestNumber(options?.prNumber);
17475
+ if (prNumber === null)
17476
+ return false;
17477
+ const repo = process.env["GITHUB_REPOSITORY"];
17478
+ if (!repo)
17479
+ return false;
17480
+ const body = formatPRComment(run, results, options?.dashboardUrl ?? process.env["TESTERS_DASHBOARD_URL"]);
17481
+ const apiUrl = `https://api.github.com/repos/${repo}/issues/${prNumber}/comments`;
17482
+ try {
17483
+ const response = await fetch(apiUrl, {
17484
+ method: "POST",
17485
+ headers: {
17486
+ Authorization: `Bearer ${token}`,
17487
+ "Content-Type": "application/json",
17488
+ Accept: "application/vnd.github+json",
17489
+ "X-GitHub-Api-Version": "2022-11-28"
17490
+ },
17491
+ body: JSON.stringify({ body })
17492
+ });
17493
+ return response.ok;
17494
+ } catch {
17495
+ return false;
17496
+ }
17497
+ }
17087
17498
  export {
17088
17499
  writeScenarioMeta,
17089
17500
  writeRunMeta,
@@ -17111,6 +17522,7 @@ export {
17111
17522
  runBatch,
17112
17523
  runAgentLoop,
17113
17524
  resultFromRow,
17525
+ resolvePullRequestNumber,
17114
17526
  resolvePartialId,
17115
17527
  resolveModel as resolveModelConfig,
17116
17528
  resolveModel2 as resolveModel,
@@ -17120,6 +17532,7 @@ export {
17120
17532
  registerAgent,
17121
17533
  pullTasks,
17122
17534
  projectFromRow,
17535
+ postGitHubComment,
17123
17536
  parseSmokeIssues,
17124
17537
  parseCronField,
17125
17538
  parseCron,
@@ -17179,6 +17592,7 @@ export {
17179
17592
  getAgent,
17180
17593
  generateLatestReport,
17181
17594
  generateHtmlReport,
17595
+ generateGitHubActionsWorkflow,
17182
17596
  generateFilename,
17183
17597
  formatTerminal,
17184
17598
  formatSummary,
@@ -17186,6 +17600,7 @@ export {
17186
17600
  formatScenarioList,
17187
17601
  formatRunList,
17188
17602
  formatResultDetail,
17603
+ formatPRComment,
17189
17604
  formatJSON,
17190
17605
  formatDiffTerminal,
17191
17606
  formatDiffJSON,