@hasna/testers 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp/index.js CHANGED
@@ -11,6 +11,7 @@ var __export = (target, all) => {
11
11
  });
12
12
  };
13
13
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
14
+ var __require = import.meta.require;
14
15
 
15
16
  // src/types/index.ts
16
17
  function projectFromRow(row) {
@@ -50,6 +51,7 @@ function scenarioFromRow(row) {
50
51
  requiresAuth: row.requires_auth === 1,
51
52
  authConfig: row.auth_config ? JSON.parse(row.auth_config) : null,
52
53
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
54
+ assertions: JSON.parse(row.assertions || "[]"),
53
55
  version: row.version,
54
56
  createdAt: row.created_at,
55
57
  updatedAt: row.updated_at
@@ -69,7 +71,8 @@ function runFromRow(row) {
69
71
  failed: row.failed,
70
72
  startedAt: row.started_at,
71
73
  finishedAt: row.finished_at,
72
- metadata: row.metadata ? JSON.parse(row.metadata) : null
74
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
75
+ isBaseline: row.is_baseline === 1
73
76
  };
74
77
  }
75
78
  function resultFromRow(row) {
@@ -424,10 +427,190 @@ var init_database = __esm(() => {
424
427
  CREATE INDEX IF NOT EXISTS idx_deps_scenario ON scenario_dependencies(scenario_id);
425
428
  CREATE INDEX IF NOT EXISTS idx_deps_depends ON scenario_dependencies(depends_on);
426
429
  CREATE INDEX IF NOT EXISTS idx_flows_project ON flows(project_id);
430
+ `,
431
+ `
432
+ ALTER TABLE scenarios ADD COLUMN assertions TEXT DEFAULT '[]';
433
+ `,
434
+ `
435
+ CREATE TABLE IF NOT EXISTS environments (
436
+ id TEXT PRIMARY KEY,
437
+ name TEXT NOT NULL UNIQUE,
438
+ url TEXT NOT NULL,
439
+ auth_preset_name TEXT,
440
+ project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
441
+ is_default INTEGER NOT NULL DEFAULT 0,
442
+ metadata TEXT DEFAULT '{}',
443
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
444
+ );
445
+ `,
446
+ `
447
+ ALTER TABLE runs ADD COLUMN is_baseline INTEGER NOT NULL DEFAULT 0;
427
448
  `
428
449
  ];
429
450
  });
430
451
 
452
+ // src/lib/browser-lightpanda.ts
453
+ var exports_browser_lightpanda = {};
454
+ __export(exports_browser_lightpanda, {
455
+ startLightpandaServer: () => startLightpandaServer,
456
+ launchLightpanda: () => launchLightpanda,
457
+ isLightpandaAvailable: () => isLightpandaAvailable,
458
+ installLightpanda: () => installLightpanda,
459
+ getLightpandaPage: () => getLightpandaPage,
460
+ closeLightpanda: () => closeLightpanda
461
+ });
462
+ import { chromium } from "playwright";
463
+ import { spawn } from "child_process";
464
+ function isLightpandaAvailable() {
465
+ try {
466
+ const possiblePaths = [
467
+ `${process.env["HOME"]}/.cache/lightpanda-node/lightpanda`,
468
+ process.env["LIGHTPANDA_EXECUTABLE_PATH"]
469
+ ];
470
+ for (const p of possiblePaths) {
471
+ if (p) {
472
+ try {
473
+ const { existsSync: existsSync2 } = __require("fs");
474
+ if (existsSync2(p))
475
+ return true;
476
+ } catch {
477
+ continue;
478
+ }
479
+ }
480
+ }
481
+ const { execSync } = __require("child_process");
482
+ execSync("lightpanda --version", { stdio: "ignore", timeout: 5000 });
483
+ return true;
484
+ } catch {
485
+ return false;
486
+ }
487
+ }
488
+ function findLightpandaBinary() {
489
+ const envPath = process.env["LIGHTPANDA_EXECUTABLE_PATH"];
490
+ if (envPath)
491
+ return envPath;
492
+ const cachePath = `${process.env["HOME"]}/.cache/lightpanda-node/lightpanda`;
493
+ try {
494
+ const { existsSync: existsSync2 } = __require("fs");
495
+ if (existsSync2(cachePath))
496
+ return cachePath;
497
+ } catch {}
498
+ return "lightpanda";
499
+ }
500
+ async function startLightpandaServer(port) {
501
+ const binary = findLightpandaBinary();
502
+ const cdpPort = port ?? 9222 + Math.floor(Math.random() * 1000);
503
+ return new Promise((resolve, reject) => {
504
+ const proc = spawn(binary, ["serve", "--port", String(cdpPort)], {
505
+ stdio: ["ignore", "pipe", "pipe"]
506
+ });
507
+ let resolved = false;
508
+ const timeout = setTimeout(() => {
509
+ if (!resolved) {
510
+ resolved = true;
511
+ resolve({
512
+ process: proc,
513
+ wsEndpoint: `ws://127.0.0.1:${cdpPort}`
514
+ });
515
+ }
516
+ }, 5000);
517
+ proc.stdout?.on("data", (data) => {
518
+ const output = data.toString();
519
+ if (output.includes("127.0.0.1") || output.includes("listening") || output.includes("DevTools")) {
520
+ if (!resolved) {
521
+ resolved = true;
522
+ clearTimeout(timeout);
523
+ resolve({
524
+ process: proc,
525
+ wsEndpoint: `ws://127.0.0.1:${cdpPort}`
526
+ });
527
+ }
528
+ }
529
+ });
530
+ proc.stderr?.on("data", (data) => {
531
+ const output = data.toString();
532
+ if (output.includes("127.0.0.1") || output.includes("listening")) {
533
+ if (!resolved) {
534
+ resolved = true;
535
+ clearTimeout(timeout);
536
+ resolve({
537
+ process: proc,
538
+ wsEndpoint: `ws://127.0.0.1:${cdpPort}`
539
+ });
540
+ }
541
+ }
542
+ });
543
+ proc.on("error", (err) => {
544
+ clearTimeout(timeout);
545
+ if (!resolved) {
546
+ resolved = true;
547
+ reject(new BrowserError(`Failed to start Lightpanda: ${err.message}. ` + `Install it with: bun install @lightpanda/browser`));
548
+ }
549
+ });
550
+ proc.on("exit", (code) => {
551
+ if (!resolved) {
552
+ resolved = true;
553
+ clearTimeout(timeout);
554
+ reject(new BrowserError(`Lightpanda exited with code ${code}. ` + `Install it with: bun install @lightpanda/browser`));
555
+ }
556
+ });
557
+ });
558
+ }
559
+ async function launchLightpanda(_options) {
560
+ try {
561
+ const { process: proc, wsEndpoint } = await startLightpandaServer();
562
+ lightpandaProcess = proc;
563
+ const browser = await chromium.connectOverCDP(wsEndpoint);
564
+ return browser;
565
+ } catch (error) {
566
+ const message = error instanceof Error ? error.message : String(error);
567
+ throw new BrowserError(`Failed to launch Lightpanda: ${message}`);
568
+ }
569
+ }
570
+ async function getLightpandaPage(browser, options) {
571
+ try {
572
+ const contexts = browser.contexts();
573
+ const context = contexts.length > 0 ? contexts[0] : await browser.newContext({
574
+ viewport: options?.viewport ?? { width: 1280, height: 720 },
575
+ userAgent: options?.userAgent,
576
+ locale: options?.locale
577
+ });
578
+ const page = await context.newPage();
579
+ return page;
580
+ } catch (error) {
581
+ const message = error instanceof Error ? error.message : String(error);
582
+ throw new BrowserError(`Failed to create Lightpanda page: ${message}`);
583
+ }
584
+ }
585
+ async function closeLightpanda(browser) {
586
+ try {
587
+ await browser.close();
588
+ } catch {}
589
+ if (lightpandaProcess) {
590
+ try {
591
+ lightpandaProcess.kill("SIGTERM");
592
+ lightpandaProcess = null;
593
+ } catch {}
594
+ }
595
+ }
596
+ async function installLightpanda() {
597
+ const { execSync } = __require("child_process");
598
+ try {
599
+ execSync("bun install @lightpanda/browser", {
600
+ stdio: "inherit",
601
+ cwd: process.env["HOME"]
602
+ });
603
+ } catch (error) {
604
+ const message = error instanceof Error ? error.message : String(error);
605
+ throw new BrowserError(`Failed to install Lightpanda: ${message}
606
+ ` + `Try manually: bun install @lightpanda/browser`);
607
+ }
608
+ }
609
+ var lightpandaProcess = null;
610
+ var init_browser_lightpanda = __esm(() => {
611
+ init_types();
612
+ });
613
+
431
614
  // src/db/flows.ts
432
615
  var exports_flows = {};
433
616
  __export(exports_flows, {
@@ -4577,9 +4760,9 @@ function createScenario(input) {
4577
4760
  const short_id = nextShortId(input.projectId);
4578
4761
  const timestamp = now();
4579
4762
  db2.query(`
4580
- INSERT INTO scenarios (id, short_id, project_id, name, description, steps, tags, priority, model, timeout_ms, target_path, requires_auth, auth_config, metadata, version, created_at, updated_at)
4581
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
4582
- `).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, timestamp, timestamp);
4763
+ 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)
4764
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
4765
+ `).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);
4583
4766
  return getScenario(id);
4584
4767
  }
4585
4768
  function getScenario(id) {
@@ -4697,6 +4880,10 @@ function updateScenario(id, input, version) {
4697
4880
  sets.push("metadata = ?");
4698
4881
  params.push(JSON.stringify(input.metadata));
4699
4882
  }
4883
+ if (input.assertions !== undefined) {
4884
+ sets.push("assertions = ?");
4885
+ params.push(JSON.stringify(input.assertions));
4886
+ }
4700
4887
  if (sets.length === 0) {
4701
4888
  return existing;
4702
4889
  }
@@ -4827,6 +5014,10 @@ function updateRun(id, updates) {
4827
5014
  sets.push("metadata = ?");
4828
5015
  params.push(updates.metadata);
4829
5016
  }
5017
+ if (updates.is_baseline !== undefined) {
5018
+ sets.push("is_baseline = ?");
5019
+ params.push(updates.is_baseline);
5020
+ }
4830
5021
  if (sets.length === 0) {
4831
5022
  return existing;
4832
5023
  }
@@ -4998,14 +5189,22 @@ function listAgents() {
4998
5189
  }
4999
5190
 
5000
5191
  // src/lib/browser.ts
5001
- import { chromium } from "playwright";
5192
+ import { chromium as chromium2 } from "playwright";
5002
5193
  init_types();
5003
5194
  var DEFAULT_VIEWPORT = { width: 1280, height: 720 };
5004
5195
  async function launchBrowser(options) {
5196
+ const engine = options?.engine ?? process.env["TESTERS_BROWSER_ENGINE"] ?? "playwright";
5197
+ if (engine === "lightpanda") {
5198
+ const { launchLightpanda: launchLightpanda2, isLightpandaAvailable: isLightpandaAvailable2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
5199
+ if (!isLightpandaAvailable2()) {
5200
+ throw new BrowserError("Lightpanda not installed. Run: testers install-browser --engine lightpanda");
5201
+ }
5202
+ return launchLightpanda2({ viewport: options?.viewport });
5203
+ }
5005
5204
  const headless = options?.headless ?? true;
5006
5205
  const viewport = options?.viewport ?? DEFAULT_VIEWPORT;
5007
5206
  try {
5008
- const browser = await chromium.launch({
5207
+ const browser = await chromium2.launch({
5009
5208
  headless,
5010
5209
  args: [
5011
5210
  `--window-size=${viewport.width},${viewport.height}`
@@ -5018,6 +5217,11 @@ async function launchBrowser(options) {
5018
5217
  }
5019
5218
  }
5020
5219
  async function getPage(browser, options) {
5220
+ const engine = options?.engine ?? "playwright";
5221
+ if (engine === "lightpanda") {
5222
+ const { getLightpandaPage: getLightpandaPage2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
5223
+ return getLightpandaPage2(browser, options);
5224
+ }
5021
5225
  const viewport = options?.viewport ?? DEFAULT_VIEWPORT;
5022
5226
  try {
5023
5227
  const context = await browser.newContext({
@@ -5032,7 +5236,11 @@ async function getPage(browser, options) {
5032
5236
  throw new BrowserError(`Failed to create page: ${message}`);
5033
5237
  }
5034
5238
  }
5035
- async function closeBrowser(browser) {
5239
+ async function closeBrowser(browser, engine) {
5240
+ if (engine === "lightpanda") {
5241
+ const { closeLightpanda: closeLightpanda2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
5242
+ return closeLightpanda2(browser);
5243
+ }
5036
5244
  try {
5037
5245
  await browser.close();
5038
5246
  } catch (error) {
@@ -5966,12 +6174,124 @@ function loadConfig() {
5966
6174
  return config;
5967
6175
  }
5968
6176
 
6177
+ // src/lib/webhooks.ts
6178
+ init_database();
6179
+ function fromRow(row) {
6180
+ return {
6181
+ id: row.id,
6182
+ url: row.url,
6183
+ events: JSON.parse(row.events),
6184
+ projectId: row.project_id,
6185
+ secret: row.secret,
6186
+ active: row.active === 1,
6187
+ createdAt: row.created_at
6188
+ };
6189
+ }
6190
+ function listWebhooks(projectId) {
6191
+ const db2 = getDatabase();
6192
+ let query = "SELECT * FROM webhooks WHERE active = 1";
6193
+ const params = [];
6194
+ if (projectId) {
6195
+ query += " AND (project_id = ? OR project_id IS NULL)";
6196
+ params.push(projectId);
6197
+ }
6198
+ query += " ORDER BY created_at DESC";
6199
+ const rows = db2.query(query).all(...params);
6200
+ return rows.map(fromRow);
6201
+ }
6202
+ function signPayload(body, secret) {
6203
+ const encoder = new TextEncoder;
6204
+ const key = encoder.encode(secret);
6205
+ const data = encoder.encode(body);
6206
+ let hash = 0;
6207
+ for (let i = 0;i < data.length; i++) {
6208
+ hash = (hash << 5) - hash + data[i] + (key[i % key.length] ?? 0) | 0;
6209
+ }
6210
+ return `sha256=${Math.abs(hash).toString(16).padStart(16, "0")}`;
6211
+ }
6212
+ function formatSlackPayload(payload) {
6213
+ const status = payload.run.status === "passed" ? ":white_check_mark:" : ":x:";
6214
+ const color = payload.run.status === "passed" ? "#22c55e" : "#ef4444";
6215
+ return {
6216
+ attachments: [
6217
+ {
6218
+ color,
6219
+ blocks: [
6220
+ {
6221
+ type: "section",
6222
+ text: {
6223
+ type: "mrkdwn",
6224
+ text: `${status} *Test Run ${payload.run.status.toUpperCase()}*
6225
+ ` + `URL: ${payload.run.url}
6226
+ ` + `Results: ${payload.run.passed}/${payload.run.total} passed` + (payload.run.failed > 0 ? ` (${payload.run.failed} failed)` : "") + (payload.schedule ? `
6227
+ Schedule: ${payload.schedule.name}` : "")
6228
+ }
6229
+ }
6230
+ ]
6231
+ }
6232
+ ]
6233
+ };
6234
+ }
6235
+ async function dispatchWebhooks(event, run, schedule) {
6236
+ const webhooks = listWebhooks(run.projectId ?? undefined);
6237
+ const payload = {
6238
+ event,
6239
+ run: {
6240
+ id: run.id,
6241
+ url: run.url,
6242
+ status: run.status,
6243
+ passed: run.passed,
6244
+ failed: run.failed,
6245
+ total: run.total
6246
+ },
6247
+ schedule,
6248
+ timestamp: new Date().toISOString()
6249
+ };
6250
+ for (const webhook of webhooks) {
6251
+ if (!webhook.events.includes(event) && !webhook.events.includes("*"))
6252
+ continue;
6253
+ const isSlack = webhook.url.includes("hooks.slack.com");
6254
+ const body = isSlack ? JSON.stringify(formatSlackPayload(payload)) : JSON.stringify(payload);
6255
+ const headers = {
6256
+ "Content-Type": "application/json"
6257
+ };
6258
+ if (webhook.secret) {
6259
+ headers["X-Testers-Signature"] = signPayload(body, webhook.secret);
6260
+ }
6261
+ try {
6262
+ const response = await fetch(webhook.url, {
6263
+ method: "POST",
6264
+ headers,
6265
+ body
6266
+ });
6267
+ if (!response.ok) {
6268
+ await new Promise((r) => setTimeout(r, 5000));
6269
+ await fetch(webhook.url, { method: "POST", headers, body });
6270
+ }
6271
+ } catch {}
6272
+ }
6273
+ }
6274
+
5969
6275
  // src/lib/runner.ts
5970
6276
  var eventHandler = null;
5971
6277
  function emit(event) {
5972
6278
  if (eventHandler)
5973
6279
  eventHandler(event);
5974
6280
  }
6281
+ function withTimeout(promise, ms, label) {
6282
+ return new Promise((resolve, reject) => {
6283
+ const timer = setTimeout(() => {
6284
+ reject(new Error(`Scenario timeout after ${ms}ms: ${label}`));
6285
+ }, ms);
6286
+ promise.then((val) => {
6287
+ clearTimeout(timer);
6288
+ resolve(val);
6289
+ }, (err) => {
6290
+ clearTimeout(timer);
6291
+ reject(err);
6292
+ });
6293
+ });
6294
+ }
5975
6295
  async function runSingleScenario(scenario, runId, options) {
5976
6296
  const config = loadConfig();
5977
6297
  const model = resolveModel(options.model ?? scenario.model ?? config.defaultModel);
@@ -5989,13 +6309,14 @@ async function runSingleScenario(scenario, runId, options) {
5989
6309
  let browser = null;
5990
6310
  let page = null;
5991
6311
  try {
5992
- browser = await launchBrowser({ headless: !(options.headed ?? false) });
6312
+ browser = await launchBrowser({ headless: !(options.headed ?? false), engine: options.engine });
5993
6313
  page = await getPage(browser, {
5994
6314
  viewport: config.browser.viewport
5995
6315
  });
5996
6316
  const targetUrl = scenario.targetPath ? `${options.url.replace(/\/$/, "")}${scenario.targetPath}` : options.url;
5997
- await page.goto(targetUrl, { timeout: options.timeout ?? config.browser.timeout });
5998
- const agentResult = await runAgentLoop({
6317
+ const scenarioTimeout = scenario.timeoutMs ?? options.timeout ?? config.browser.timeout ?? 60000;
6318
+ await page.goto(targetUrl, { timeout: Math.min(scenarioTimeout, 30000) });
6319
+ const agentResult = await withTimeout(runAgentLoop({
5999
6320
  client,
6000
6321
  page,
6001
6322
  scenario,
@@ -6016,7 +6337,7 @@ async function runSingleScenario(scenario, runId, options) {
6016
6337
  stepNumber: stepEvent.stepNumber
6017
6338
  });
6018
6339
  }
6019
- });
6340
+ }), scenarioTimeout, scenario.name);
6020
6341
  for (const ss of agentResult.screenshots) {
6021
6342
  createScreenshot({
6022
6343
  resultId: result.id,
@@ -6053,7 +6374,7 @@ async function runSingleScenario(scenario, runId, options) {
6053
6374
  return updatedResult;
6054
6375
  } finally {
6055
6376
  if (browser)
6056
- await closeBrowser(browser);
6377
+ await closeBrowser(browser, options.engine);
6057
6378
  }
6058
6379
  }
6059
6380
  async function runBatch(scenarios, options) {
@@ -6148,6 +6469,8 @@ async function runBatch(scenarios, options) {
6148
6469
  finished_at: new Date().toISOString()
6149
6470
  });
6150
6471
  emit({ type: "run:complete", runId: run.id });
6472
+ const eventType = finalRun.status === "failed" ? "failed" : "completed";
6473
+ dispatchWebhooks(eventType, finalRun).catch(() => {});
6151
6474
  return { run: finalRun, results };
6152
6475
  }
6153
6476
  async function runByFilter(options) {
@@ -6233,6 +6556,9 @@ function startRunAsync(options) {
6233
6556
  finished_at: new Date().toISOString()
6234
6557
  });
6235
6558
  emit({ type: "run:complete", runId: run.id });
6559
+ const asyncRun = getRun(run.id);
6560
+ if (asyncRun)
6561
+ dispatchWebhooks(asyncRun.status === "failed" ? "failed" : "completed", asyncRun).catch(() => {});
6236
6562
  } catch (error) {
6237
6563
  const errorMsg = error instanceof Error ? error.message : String(error);
6238
6564
  updateRun(run.id, {
@@ -6240,6 +6566,9 @@ function startRunAsync(options) {
6240
6566
  finished_at: new Date().toISOString()
6241
6567
  });
6242
6568
  emit({ type: "run:complete", runId: run.id, error: errorMsg });
6569
+ const failedRun = getRun(run.id);
6570
+ if (failedRun)
6571
+ dispatchWebhooks("failed", failedRun).catch(() => {});
6243
6572
  }
6244
6573
  })();
6245
6574
  return { runId: run.id, scenarioCount: scenarios.length };