@hasna/testers 0.0.33 → 0.0.35

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.
@@ -4036,6 +4036,56 @@ var init_zod = __esm(() => {
4036
4036
  });
4037
4037
 
4038
4038
  // src/types/index.ts
4039
+ function isRecord(value) {
4040
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4041
+ }
4042
+ function stringValue(value) {
4043
+ return typeof value === "string" && value.trim() ? value : undefined;
4044
+ }
4045
+ function numberValue(value) {
4046
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
4047
+ }
4048
+ function stringMap(value) {
4049
+ if (!isRecord(value))
4050
+ return;
4051
+ const entries = Object.entries(value).filter((entry) => typeof entry[1] === "string");
4052
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
4053
+ }
4054
+ function cleanupValue(value) {
4055
+ if (value === "delete" || value === "stop" || value === "keep")
4056
+ return value;
4057
+ return;
4058
+ }
4059
+ function workflowExecutionFromValue(value) {
4060
+ const input = isRecord(value) ? value : {};
4061
+ const rawTarget = stringValue(input["target"]) ?? "local";
4062
+ if (rawTarget === "local") {
4063
+ const timeoutMs2 = numberValue(input["timeoutMs"]);
4064
+ return timeoutMs2 === undefined ? { target: "local" } : { target: "local", timeoutMs: timeoutMs2 };
4065
+ }
4066
+ if (rawTarget !== "sandbox" && rawTarget !== "connector:e2b") {
4067
+ throw new Error(`Unsupported workflow execution target: ${rawTarget}`);
4068
+ }
4069
+ const provider = rawTarget === "connector:e2b" ? "e2b" : stringValue(input["provider"]) ?? stringValue(input["connector"]);
4070
+ const sandboxImage = stringValue(input["sandboxImage"]) ?? stringValue(input["sandboxTemplate"]);
4071
+ const sandboxRemoteDir = stringValue(input["sandboxRemoteDir"]);
4072
+ const sandboxCleanup = cleanupValue(input["sandboxCleanup"]);
4073
+ const setupCommand = stringValue(input["setupCommand"]);
4074
+ const packageSpec = stringValue(input["packageSpec"]);
4075
+ const timeoutMs = numberValue(input["timeoutMs"]);
4076
+ const env = stringMap(input["env"]);
4077
+ return {
4078
+ target: "sandbox",
4079
+ ...provider ? { provider } : {},
4080
+ ...sandboxImage ? { sandboxImage } : {},
4081
+ ...sandboxRemoteDir ? { sandboxRemoteDir } : {},
4082
+ ...sandboxCleanup ? { sandboxCleanup } : {},
4083
+ ...setupCommand ? { setupCommand } : {},
4084
+ ...packageSpec ? { packageSpec } : {},
4085
+ ...timeoutMs !== undefined ? { timeoutMs } : {},
4086
+ ...env ? { env } : {}
4087
+ };
4088
+ }
4039
4089
  function workflowFromRow(row) {
4040
4090
  return {
4041
4091
  id: row.id,
@@ -4045,7 +4095,7 @@ function workflowFromRow(row) {
4045
4095
  scenarioFilter: JSON.parse(row.scenario_filter || "{}"),
4046
4096
  personaIds: JSON.parse(row.persona_ids || "[]"),
4047
4097
  goal: row.goal ? JSON.parse(row.goal) : null,
4048
- execution: JSON.parse(row.execution || '{"target":"local"}'),
4098
+ execution: workflowExecutionFromValue(JSON.parse(row.execution || '{"target":"local"}')),
4049
4099
  settings: JSON.parse(row.settings || "{}"),
4050
4100
  enabled: row.enabled === 1,
4051
4101
  createdAt: row.created_at,
@@ -15811,7 +15861,6 @@ async function executeTool(page, screenshotter, toolName, toolInput, context) {
15811
15861
  const assertionType = toolInput.assertion_type;
15812
15862
  const selector = toolInput.selector;
15813
15863
  const expected = toolInput.expected;
15814
- const sessionId = context.sessionId ?? "default";
15815
15864
  switch (assertionType) {
15816
15865
  case "element_exists": {
15817
15866
  if (!selector)
@@ -15876,7 +15925,6 @@ async function executeTool(page, screenshotter, toolName, toolInput, context) {
15876
15925
  case "browser_intercept": {
15877
15926
  const action = toolInput.action;
15878
15927
  const pattern = toolInput.pattern;
15879
- const interceptAction = toolInput.intercept_action;
15880
15928
  const statusCode = toolInput.status_code;
15881
15929
  const body = toolInput.body;
15882
15930
  const sessionId = context.sessionId ?? "default";
@@ -15953,7 +16001,28 @@ ${JSON.stringify(har, null, 2)}` };
15953
16001
  }
15954
16002
  case "browser_a11y": {
15955
16003
  const level = toolInput.level ?? "AA";
15956
- const snapshot = await page.accessibility.snapshot();
16004
+ const snapshot = await page.evaluate(() => {
16005
+ function readRole(el) {
16006
+ return el.getAttribute("role") ?? el.tagName.toLowerCase();
16007
+ }
16008
+ function readName(el) {
16009
+ const labelledBy = el.getAttribute("aria-labelledby");
16010
+ if (labelledBy) {
16011
+ const labelledText = labelledBy.split(/\s+/).map((id) => document.getElementById(id)?.textContent?.trim()).filter(Boolean).join(" ");
16012
+ if (labelledText)
16013
+ return labelledText;
16014
+ }
16015
+ return el.getAttribute("aria-label") ?? el.getAttribute("alt") ?? el.textContent?.trim() ?? "";
16016
+ }
16017
+ function walk(el) {
16018
+ return {
16019
+ role: readRole(el),
16020
+ name: readName(el),
16021
+ children: Array.from(el.children).map((child) => walk(child))
16022
+ };
16023
+ }
16024
+ return document.body ? walk(document.body) : null;
16025
+ });
15957
16026
  if (!snapshot)
15958
16027
  return { result: "Error: could not capture accessibility tree" };
15959
16028
  const issues = [];
@@ -15995,6 +16064,38 @@ ${filtered.join(`
15995
16064
  return { result: `Error executing ${toolName}: ${message}` };
15996
16065
  }
15997
16066
  }
16067
+ function resolveStartUrl(baseUrl, targetPath) {
16068
+ try {
16069
+ return new URL(targetPath, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
16070
+ } catch {
16071
+ return `${baseUrl.replace(/\/+$/, "")}/${targetPath.replace(/^\/+/, "")}`;
16072
+ }
16073
+ }
16074
+ function buildScenarioUserMessage(scenario, baseUrl) {
16075
+ const userParts = [
16076
+ `**Scenario:** ${scenario.name}`,
16077
+ `**Description:** ${scenario.description}`
16078
+ ];
16079
+ if (baseUrl) {
16080
+ const normalizedBaseUrl = baseUrl.replace(/\/+$/, "");
16081
+ userParts.push(`**Base URL:** ${normalizedBaseUrl}`);
16082
+ if (scenario.targetPath) {
16083
+ userParts.push(`**Start URL:** ${resolveStartUrl(normalizedBaseUrl, scenario.targetPath)}`);
16084
+ }
16085
+ userParts.push("**Navigation Boundary:** Treat the Base URL as the application under test. Resolve relative paths and in-app navigation against this origin. Do not navigate to another host unless a step explicitly includes an absolute external URL.");
16086
+ }
16087
+ if (scenario.targetPath) {
16088
+ userParts.push(`**Target Path:** ${scenario.targetPath}`);
16089
+ }
16090
+ if (scenario.steps.length > 0) {
16091
+ userParts.push("**Steps:**");
16092
+ for (let i = 0;i < scenario.steps.length; i++) {
16093
+ userParts.push(`${i + 1}. ${scenario.steps[i]}`);
16094
+ }
16095
+ }
16096
+ return userParts.join(`
16097
+ `);
16098
+ }
15998
16099
  async function runAgentLoop(options) {
15999
16100
  const {
16000
16101
  client,
@@ -16004,6 +16105,7 @@ async function runAgentLoop(options) {
16004
16105
  model,
16005
16106
  runId,
16006
16107
  sessionId,
16108
+ baseUrl,
16007
16109
  maxTurns = 30,
16008
16110
  onStep,
16009
16111
  persona,
@@ -16051,21 +16153,7 @@ Instructions: ${persona.instructions}` : "",
16051
16153
  "- Verify both positive and negative states"
16052
16154
  ].join(`
16053
16155
  `) + personaSection;
16054
- const userParts = [
16055
- `**Scenario:** ${scenario.name}`,
16056
- `**Description:** ${scenario.description}`
16057
- ];
16058
- if (scenario.targetPath) {
16059
- userParts.push(`**Target Path:** ${scenario.targetPath}`);
16060
- }
16061
- if (scenario.steps.length > 0) {
16062
- userParts.push("**Steps:**");
16063
- for (let i = 0;i < scenario.steps.length; i++) {
16064
- userParts.push(`${i + 1}. ${scenario.steps[i]}`);
16065
- }
16066
- }
16067
- const userMessage = userParts.join(`
16068
- `);
16156
+ const userMessage = buildScenarioUserMessage(scenario, baseUrl);
16069
16157
  const screenshots = [];
16070
16158
  let tokensUsed = 0;
16071
16159
  let stepNumber = 0;
@@ -16128,7 +16216,7 @@ Instructions: ${persona.instructions}` : "",
16128
16216
  if (onStep) {
16129
16217
  onStep({ type: "tool_call", toolName: toolBlock.name, toolInput, stepNumber });
16130
16218
  }
16131
- const execResult = await executeTool(page, screenshotter, toolBlock.name, toolInput, { runId, scenarioSlug, stepNumber, sessionId, a11y });
16219
+ const execResult = await executeTool(page, screenshotter, toolBlock.name, toolInput, { runId, scenarioSlug, stepNumber, sessionId: sessionId ?? runId, a11y });
16132
16220
  if (onStep) {
16133
16221
  onStep({ type: "tool_result", toolName: toolBlock.name, toolResult: execResult.result, stepNumber });
16134
16222
  }
@@ -46800,11 +46888,11 @@ var init_scan_issues = __esm(() => {
46800
46888
 
46801
46889
  // src/server/index.ts
46802
46890
  import { existsSync as existsSync10 } from "fs";
46803
- import { join as join13 } from "path";
46891
+ import { join as join14 } from "path";
46804
46892
  // package.json
46805
46893
  var package_default = {
46806
46894
  name: "@hasna/testers",
46807
- version: "0.0.33",
46895
+ version: "0.0.35",
46808
46896
  description: "AI-powered QA testing CLI \u2014 spawns cheap AI agents to test web apps with headless browsers",
46809
46897
  type: "module",
46810
46898
  main: "dist/index.js",
@@ -46828,10 +46916,10 @@ var package_default = {
46828
46916
  ],
46829
46917
  scripts: {
46830
46918
  build: "bun run build:dashboard && bun run build:cli && bun run build:mcp && bun run build:server && bun run build:lib && bun run build:types",
46831
- "build:cli": "bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external @anthropic-ai/sdk --external playwright --external @hasna/browser",
46832
- "build:mcp": "bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external @anthropic-ai/sdk --external playwright --external @hasna/browser",
46833
- "build:server": "bun build src/server/index.ts --outdir dist/server --target bun --external @anthropic-ai/sdk --external playwright --external @hasna/browser",
46834
- "build:lib": "bun build src/index.ts --outdir dist --target bun --external playwright --external @anthropic-ai/sdk --external @modelcontextprotocol/sdk --external @hasna/browser",
46919
+ "build:cli": "bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external @anthropic-ai/sdk --external playwright --external @hasna/browser --external @hasna/sandboxes",
46920
+ "build:mcp": "bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external @anthropic-ai/sdk --external playwright --external @hasna/browser --external @hasna/sandboxes",
46921
+ "build:server": "bun build src/server/index.ts --outdir dist/server --target bun --external @anthropic-ai/sdk --external playwright --external @hasna/browser --external @hasna/sandboxes",
46922
+ "build:lib": "bun build src/index.ts --outdir dist --target bun --external playwright --external @anthropic-ai/sdk --external @modelcontextprotocol/sdk --external @hasna/browser --external @hasna/sandboxes",
46835
46923
  "build:types": "NODE_OPTIONS='--max-old-space-size=8192' tsc --emitDeclarationOnly --outDir dist --skipLibCheck || true",
46836
46924
  "build:dashboard": "cd dashboard && bun run build",
46837
46925
  "build:ext": "cd extension && bun run build",
@@ -46845,10 +46933,11 @@ var package_default = {
46845
46933
  },
46846
46934
  dependencies: {
46847
46935
  "@anthropic-ai/sdk": "^0.52.0",
46848
- "@hasna/browser": "^0.4.5",
46936
+ "@hasna/browser": "^0.4.12",
46849
46937
  "@hasna/cloud": "^0.1.24",
46850
46938
  "@hasna/contacts": "^0.6.8",
46851
46939
  "@hasna/projects": "^0.1.42",
46940
+ "@hasna/sandboxes": "^0.1.27",
46852
46941
  "@modelcontextprotocol/sdk": "^1.12.1",
46853
46942
  ai: "^6.0.175",
46854
46943
  chalk: "^5.4.1",
@@ -49002,12 +49091,345 @@ async function notifyRunToConversations(run, results, options) {
49002
49091
  } catch {}
49003
49092
  }
49004
49093
 
49094
+ // src/lib/a11y-audit.ts
49095
+ async function runA11yAudit(page, options = {}) {
49096
+ const { level = "AA", rules, exclude = [] } = options;
49097
+ await page.addScriptTag({ url: "https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.9.1/axe.min.js" });
49098
+ const config = {
49099
+ runOnly: {
49100
+ type: level === "AAA" ? "standard" : "tag",
49101
+ values: level === "AAA" ? undefined : [level, "best-practice"]
49102
+ }
49103
+ };
49104
+ if (rules && rules.length > 0) {
49105
+ config.rules = Object.fromEntries(rules.map((r) => [r, { enabled: true }]));
49106
+ }
49107
+ if (exclude.length > 0) {
49108
+ config.exclude = exclude;
49109
+ }
49110
+ const result = await page.evaluate(async (auditConfig) => {
49111
+ const axeResult = await window.axe.run(auditConfig);
49112
+ return axeResult;
49113
+ }, config);
49114
+ const violations = (result.violations ?? []).map((v) => ({
49115
+ id: v.id,
49116
+ impact: v.impact,
49117
+ description: v.description,
49118
+ help: v.help,
49119
+ helpUrl: v.helpUrl,
49120
+ nodes: (v.nodes ?? []).map((n) => ({
49121
+ html: n.html,
49122
+ target: n.target,
49123
+ failureSummary: n.failureSummary
49124
+ }))
49125
+ }));
49126
+ const passes = (result.passes ?? []).map((p) => ({
49127
+ id: p.id,
49128
+ description: p.description
49129
+ }));
49130
+ const incomplete = (result.incomplete ?? []).map((i) => ({
49131
+ id: i.id,
49132
+ description: i.description,
49133
+ impact: i.impact
49134
+ }));
49135
+ const criticalCount = violations.filter((v) => v.impact === "critical").length;
49136
+ const seriousCount = violations.filter((v) => v.impact === "serious").length;
49137
+ const moderateCount = violations.filter((v) => v.impact === "moderate").length;
49138
+ const minorCount = violations.filter((v) => v.impact === "minor").length;
49139
+ return {
49140
+ violations,
49141
+ passes,
49142
+ incomplete,
49143
+ url: page.url(),
49144
+ timestamp: new Date().toISOString(),
49145
+ totalViolations: violations.length,
49146
+ criticalCount,
49147
+ seriousCount,
49148
+ moderateCount,
49149
+ minorCount
49150
+ };
49151
+ }
49152
+
49153
+ // src/lib/assertions.ts
49154
+ async function evaluateAssertions(page, assertions, context = {}) {
49155
+ const results = [];
49156
+ for (const assertion of assertions) {
49157
+ try {
49158
+ const result = await evaluateOne(page, assertion, context);
49159
+ results.push(result);
49160
+ } catch (err) {
49161
+ results.push({
49162
+ assertion,
49163
+ passed: false,
49164
+ actual: "",
49165
+ error: err instanceof Error ? err.message : String(err)
49166
+ });
49167
+ }
49168
+ }
49169
+ return results;
49170
+ }
49171
+ async function evaluateOne(page, assertion, context) {
49172
+ switch (assertion.type) {
49173
+ case "visible": {
49174
+ const visible = await page.locator(assertion.selector).isVisible();
49175
+ return {
49176
+ assertion,
49177
+ passed: visible,
49178
+ actual: String(visible)
49179
+ };
49180
+ }
49181
+ case "not_visible": {
49182
+ const visible = await page.locator(assertion.selector).isVisible();
49183
+ return {
49184
+ assertion,
49185
+ passed: !visible,
49186
+ actual: String(visible)
49187
+ };
49188
+ }
49189
+ case "text_contains": {
49190
+ const text = await page.locator(assertion.selector).textContent() ?? "";
49191
+ const expected = String(assertion.expected ?? "");
49192
+ return {
49193
+ assertion,
49194
+ passed: text.includes(expected),
49195
+ actual: text
49196
+ };
49197
+ }
49198
+ case "text_equals": {
49199
+ const text = await page.locator(assertion.selector).textContent() ?? "";
49200
+ const expected = String(assertion.expected ?? "");
49201
+ return {
49202
+ assertion,
49203
+ passed: text.trim() === expected.trim(),
49204
+ actual: text
49205
+ };
49206
+ }
49207
+ case "element_count": {
49208
+ const count = await page.locator(assertion.selector).count();
49209
+ const expected = Number(assertion.expected ?? 0);
49210
+ return {
49211
+ assertion,
49212
+ passed: count === expected,
49213
+ actual: String(count)
49214
+ };
49215
+ }
49216
+ case "no_console_errors": {
49217
+ if (context.consoleErrors !== undefined) {
49218
+ const errors2 = context.consoleErrors.filter(Boolean);
49219
+ return {
49220
+ assertion,
49221
+ passed: errors2.length === 0,
49222
+ actual: errors2.length === 0 ? "No console errors captured" : errors2.slice(0, 3).join(" | ")
49223
+ };
49224
+ }
49225
+ const errorElements = await page.locator('[role="alert"], .error, .error-message, [data-testid="error"]').count();
49226
+ return {
49227
+ assertion,
49228
+ passed: errorElements === 0,
49229
+ actual: `${errorElements} error element(s) found`
49230
+ };
49231
+ }
49232
+ case "no_a11y_violations": {
49233
+ try {
49234
+ const auditResult = await runA11yAudit(page);
49235
+ const hasIssues = auditResult.violations.length > 0;
49236
+ return {
49237
+ assertion,
49238
+ passed: !hasIssues,
49239
+ actual: hasIssues ? `${auditResult.totalViolations} violation(s): ${auditResult.violations.map((v) => v.id).join(", ")}` : "No accessibility violations found"
49240
+ };
49241
+ } catch (err) {
49242
+ return {
49243
+ assertion,
49244
+ passed: false,
49245
+ actual: "",
49246
+ error: err instanceof Error ? err.message : String(err)
49247
+ };
49248
+ }
49249
+ }
49250
+ case "url_contains": {
49251
+ const url = page.url();
49252
+ const expected = String(assertion.expected ?? "");
49253
+ return {
49254
+ assertion,
49255
+ passed: url.includes(expected),
49256
+ actual: url
49257
+ };
49258
+ }
49259
+ case "title_contains": {
49260
+ const title = await page.title();
49261
+ const expected = String(assertion.expected ?? "");
49262
+ return {
49263
+ assertion,
49264
+ passed: title.includes(expected),
49265
+ actual: title
49266
+ };
49267
+ }
49268
+ case "cookie_exists": {
49269
+ const cookieName = assertion.expected;
49270
+ const cookies = await page.context().cookies();
49271
+ const found = cookies.some((c) => c.name === cookieName);
49272
+ return {
49273
+ assertion,
49274
+ passed: found,
49275
+ actual: found ? `Cookie "${cookieName}" exists` : `Cookie "${cookieName}" not found`
49276
+ };
49277
+ }
49278
+ case "cookie_not_exists": {
49279
+ const cookieName = assertion.expected;
49280
+ const cookies = await page.context().cookies();
49281
+ const found = cookies.some((c) => c.name === cookieName);
49282
+ return {
49283
+ assertion,
49284
+ passed: !found,
49285
+ actual: found ? `Cookie "${cookieName}" found (unexpected)` : `Cookie "${cookieName}" does not exist`
49286
+ };
49287
+ }
49288
+ case "cookie_value": {
49289
+ const [cookieName, expectedValue] = assertion.expected.split("=", 2);
49290
+ const cookies = await page.context().cookies();
49291
+ const cookie = cookies.find((c) => c.name === cookieName);
49292
+ const actualValue = cookie?.value ?? "";
49293
+ return {
49294
+ assertion,
49295
+ passed: actualValue === expectedValue,
49296
+ actual: cookie ? `${cookieName}=${actualValue}` : `Cookie "${cookieName}" not found`
49297
+ };
49298
+ }
49299
+ case "local_storage_exists": {
49300
+ const key = assertion.expected;
49301
+ const value = await page.evaluate((k) => localStorage.getItem(k), key);
49302
+ return {
49303
+ assertion,
49304
+ passed: value !== null,
49305
+ actual: value !== null ? `Key "${key}" exists with value "${value}"` : `Key "${key}" not found in localStorage`
49306
+ };
49307
+ }
49308
+ case "local_storage_not_exists": {
49309
+ const key = assertion.expected;
49310
+ const value = await page.evaluate((k) => localStorage.getItem(k), key);
49311
+ return {
49312
+ assertion,
49313
+ passed: value === null,
49314
+ actual: value !== null ? `Key "${key}" exists (unexpected)` : `Key "${key}" does not exist in localStorage`
49315
+ };
49316
+ }
49317
+ case "local_storage_value": {
49318
+ const [lsKey, expectedValue] = assertion.expected.split("=", 2);
49319
+ const value = await page.evaluate((k) => localStorage.getItem(k), lsKey ?? "");
49320
+ return {
49321
+ assertion,
49322
+ passed: value === expectedValue,
49323
+ actual: value !== null ? `${lsKey}=${value}` : `Key "${lsKey}" not found in localStorage`
49324
+ };
49325
+ }
49326
+ case "session_storage_value": {
49327
+ const [ssKey, expectedValue] = assertion.expected.split("=", 2);
49328
+ const value = await page.evaluate((k) => sessionStorage.getItem(k), ssKey ?? "");
49329
+ return {
49330
+ assertion,
49331
+ passed: value === expectedValue,
49332
+ actual: value !== null ? `${ssKey}=${value}` : `Key "${ssKey}" not found in sessionStorage`
49333
+ };
49334
+ }
49335
+ case "session_storage_not_exists": {
49336
+ const key = assertion.expected;
49337
+ const value = await page.evaluate((k) => sessionStorage.getItem(k), key);
49338
+ return {
49339
+ assertion,
49340
+ passed: value === null,
49341
+ actual: value !== null ? `Key "${key}" exists (unexpected)` : `Key "${key}" does not exist in sessionStorage`
49342
+ };
49343
+ }
49344
+ default: {
49345
+ return {
49346
+ assertion,
49347
+ passed: false,
49348
+ actual: "",
49349
+ error: `Unknown assertion type: ${assertion.type}`
49350
+ };
49351
+ }
49352
+ }
49353
+ }
49354
+ function allAssertionsPassed(results) {
49355
+ return results.every((r) => r.passed);
49356
+ }
49357
+ function formatAssertionResults(results) {
49358
+ if (results.length === 0)
49359
+ return "No assertions.";
49360
+ const lines = [];
49361
+ for (const r of results) {
49362
+ const icon = r.passed ? "PASS" : "FAIL";
49363
+ const desc = r.assertion.description || `${r.assertion.type}${r.assertion.selector ? ` ${r.assertion.selector}` : ""}`;
49364
+ let line = ` [${icon}] ${desc}`;
49365
+ if (!r.passed) {
49366
+ line += ` (actual: ${r.actual})`;
49367
+ if (r.error)
49368
+ line += ` \u2014 ${r.error}`;
49369
+ }
49370
+ lines.push(line);
49371
+ }
49372
+ const passed = results.filter((r) => r.passed).length;
49373
+ lines.push(`
49374
+ ${passed}/${results.length} assertions passed.`);
49375
+ return lines.join(`
49376
+ `);
49377
+ }
49378
+
49005
49379
  // src/lib/runner.ts
49006
49380
  var eventHandler = null;
49007
49381
  function emit(event) {
49008
49382
  if (eventHandler)
49009
49383
  eventHandler(event);
49010
49384
  }
49385
+ function assertionDescription(result) {
49386
+ return result.assertion.description || `${result.assertion.type}${result.assertion.selector ? ` ${result.assertion.selector}` : ""}`;
49387
+ }
49388
+ function summarizeAssertionResult(result) {
49389
+ const description = assertionDescription(result);
49390
+ if (result.passed)
49391
+ return description;
49392
+ const suffix = result.error ? `; ${result.error}` : "";
49393
+ return `${description} (actual: ${result.actual}${suffix})`;
49394
+ }
49395
+ async function applyStructuredAssertionsToResult(input) {
49396
+ const assertions = input.scenario.assertions ?? [];
49397
+ if (assertions.length === 0) {
49398
+ return {
49399
+ status: input.status,
49400
+ reasoning: input.reasoning,
49401
+ assertionsPassed: [],
49402
+ assertionsFailed: [],
49403
+ assertionResults: []
49404
+ };
49405
+ }
49406
+ const results = await evaluateAssertions(input.page, assertions, {
49407
+ consoleErrors: input.consoleErrors
49408
+ });
49409
+ const assertionsPassed = results.filter((r) => r.passed).map(summarizeAssertionResult);
49410
+ const assertionsFailed = results.filter((r) => !r.passed).map(summarizeAssertionResult);
49411
+ const assertionResults = results.map((result) => ({
49412
+ type: result.assertion.type,
49413
+ description: assertionDescription(result),
49414
+ passed: result.passed,
49415
+ actual: result.actual,
49416
+ ...result.error ? { error: result.error } : {}
49417
+ }));
49418
+ const assertionsOk = allAssertionsPassed(results);
49419
+ const status = assertionsOk || input.status !== "passed" ? input.status : "failed";
49420
+ const assertionHeading = assertionsOk ? "Structured assertions passed:" : "Structured assertions failed:";
49421
+ const reasoningParts = [input.reasoning, `${assertionHeading}
49422
+ ${formatAssertionResults(results)}`].map((part) => part.trim()).filter(Boolean);
49423
+ return {
49424
+ status,
49425
+ reasoning: reasoningParts.join(`
49426
+
49427
+ `),
49428
+ assertionsPassed,
49429
+ assertionsFailed,
49430
+ assertionResults
49431
+ };
49432
+ }
49011
49433
  function withTimeout(promise, ms, label) {
49012
49434
  return new Promise((resolve, reject) => {
49013
49435
  const warningAt = Math.floor(ms * 0.8);
@@ -49178,6 +49600,7 @@ async function runSingleScenario(scenario, runId, options) {
49178
49600
  model,
49179
49601
  runId,
49180
49602
  sessionId: result.id,
49603
+ baseUrl: options.url,
49181
49604
  maxTurns: effectiveOptions.minimal ? 10 : 30,
49182
49605
  a11y: effectiveOptions.a11y,
49183
49606
  persona: persona ? {
@@ -49260,27 +49683,46 @@ async function runSingleScenario(scenario, runId, options) {
49260
49683
  closeSession(result.id);
49261
49684
  const lightpandaNote = options.engine === "lightpanda" ? " (Running with Lightpanda \u2014 no screenshots)" : options.engine === "bun" ? " (Running with Bun.WebView \u2014 native, ~11x faster)" : "";
49262
49685
  const networkMeta = networkErrors.length > 0 ? { networkErrors: networkErrors.slice(0, 20) } : {};
49263
- let updatedResult = updateResult(result.id, {
49686
+ const baseReasoning = agentResult.reasoning ? agentResult.reasoning + lightpandaNote : lightpandaNote || "";
49687
+ const assertionOutcome = await applyStructuredAssertionsToResult({
49688
+ page,
49689
+ scenario,
49690
+ consoleErrors,
49264
49691
  status: agentResult.status,
49265
- reasoning: agentResult.reasoning ? agentResult.reasoning + lightpandaNote : lightpandaNote || undefined,
49692
+ reasoning: baseReasoning
49693
+ });
49694
+ const structuredAssertionMeta = assertionOutcome.assertionResults.length > 0 ? {
49695
+ structuredAssertions: {
49696
+ passed: assertionOutcome.assertionsPassed,
49697
+ failed: assertionOutcome.assertionsFailed,
49698
+ results: assertionOutcome.assertionResults
49699
+ }
49700
+ } : {};
49701
+ let updatedResult = updateResult(result.id, {
49702
+ status: assertionOutcome.status,
49703
+ reasoning: assertionOutcome.reasoning || undefined,
49266
49704
  stepsCompleted: agentResult.stepsCompleted,
49267
49705
  durationMs: Date.now() - new Date(result.createdAt).getTime(),
49268
49706
  tokensUsed: agentResult.tokensUsed,
49269
49707
  costCents: estimateCost(model, agentResult.tokensUsed),
49270
- metadata: { consoleLogs, ...networkErrors.length > 0 ? networkMeta : {} }
49708
+ metadata: {
49709
+ consoleLogs,
49710
+ ...networkErrors.length > 0 ? networkMeta : {},
49711
+ ...structuredAssertionMeta
49712
+ }
49271
49713
  });
49272
- if (agentResult.status === "failed" || agentResult.status === "error") {
49273
- const failureAnalysis = analyzeFailure(null, agentResult.reasoning ?? null);
49714
+ if (assertionOutcome.status === "failed" || assertionOutcome.status === "error") {
49715
+ const failureAnalysis = analyzeFailure(null, assertionOutcome.reasoning ?? null);
49274
49716
  if (failureAnalysis) {
49275
49717
  updatedResult = updateResult(result.id, { failureAnalysis });
49276
49718
  }
49277
49719
  }
49278
- if (agentResult.status === "passed") {
49720
+ if (assertionOutcome.status === "passed") {
49279
49721
  try {
49280
49722
  updateScenarioPassedCache(scenario.id, options.url);
49281
49723
  } catch {}
49282
49724
  }
49283
- const eventType = agentResult.status === "passed" ? "scenario:pass" : "scenario:fail";
49725
+ const eventType = assertionOutcome.status === "passed" ? "scenario:pass" : "scenario:fail";
49284
49726
  emit({ type: eventType, scenarioId: scenario.id, scenarioName: scenario.name, resultId: result.id, runId });
49285
49727
  return updatedResult;
49286
49728
  } catch (error) {
@@ -49305,7 +49747,8 @@ async function runSingleScenario(scenario, runId, options) {
49305
49747
  } finally {
49306
49748
  if (harPath) {
49307
49749
  try {
49308
- updateResult(result.id, { metadata: { harPath } });
49750
+ const existing = getResult(result.id);
49751
+ updateResult(result.id, { metadata: { ...existing?.metadata ?? {}, harPath } });
49309
49752
  } catch {}
49310
49753
  }
49311
49754
  if (browser) {
@@ -49477,22 +49920,31 @@ async function runBatch(scenarios, options) {
49477
49920
  }
49478
49921
  return { run: finalRun, results };
49479
49922
  }
49480
- async function runByFilter(options) {
49481
- let scenarios;
49923
+ function findScenarioInList(scenarios, id) {
49924
+ return scenarios.find((scenario) => scenario.id === id || scenario.shortId === id || scenario.id.startsWith(id)) ?? null;
49925
+ }
49926
+ function resolveScenariosForRun(options) {
49482
49927
  if (options.scenarioIds && options.scenarioIds.length > 0) {
49483
- const all = listScenarios({ projectId: options.projectId });
49484
- scenarios = all.filter((s) => options.scenarioIds.includes(s.id) || options.scenarioIds.includes(s.shortId));
49485
- if (scenarios.length === 0 && options.projectId) {
49486
- const global2 = listScenarios({});
49487
- scenarios = global2.filter((s) => options.scenarioIds.includes(s.id) || options.scenarioIds.includes(s.shortId));
49928
+ const scoped = listScenarios({ projectId: options.projectId });
49929
+ const resolved = [];
49930
+ const seen = new Set;
49931
+ for (const id of options.scenarioIds) {
49932
+ const scenario = findScenarioInList(scoped, id) ?? getScenario(id);
49933
+ if (scenario && !seen.has(scenario.id)) {
49934
+ resolved.push(scenario);
49935
+ seen.add(scenario.id);
49936
+ }
49488
49937
  }
49489
- } else {
49490
- scenarios = listScenarios({
49491
- projectId: options.projectId,
49492
- tags: options.tags,
49493
- priority: options.priority
49494
- });
49938
+ return resolved;
49495
49939
  }
49940
+ return listScenarios({
49941
+ projectId: options.projectId,
49942
+ tags: options.tags,
49943
+ priority: options.priority
49944
+ });
49945
+ }
49946
+ async function runByFilter(options) {
49947
+ const scenarios = resolveScenariosForRun(options);
49496
49948
  if (scenarios.length === 0) {
49497
49949
  const config = loadConfig();
49498
49950
  const model = resolveModel(options.model ?? config.defaultModel);
@@ -50656,18 +51108,7 @@ function normalizeFilter(input) {
50656
51108
  };
50657
51109
  }
50658
51110
  function normalizeExecution(input) {
50659
- const target = input?.target ?? "local";
50660
- if (target === "connector:e2b") {
50661
- return {
50662
- target,
50663
- connector: input?.connector ?? "e2b",
50664
- operation: input?.operation ?? "run",
50665
- sandboxTemplate: input?.sandboxTemplate,
50666
- timeoutMs: input?.timeoutMs,
50667
- env: input?.env
50668
- };
50669
- }
50670
- return { ...DEFAULT_EXECUTION, timeoutMs: input?.timeoutMs };
51111
+ return input ? workflowExecutionFromValue(input) : DEFAULT_EXECUTION;
50671
51112
  }
50672
51113
  function createTestingWorkflow(input) {
50673
51114
  const db2 = getDatabase();
@@ -50766,6 +51207,10 @@ function db2() {
50766
51207
  }
50767
51208
 
50768
51209
  // src/lib/workflow-runner.ts
51210
+ init_database();
51211
+ import { mkdtempSync, rmSync, writeFileSync as writeFileSync3 } from "fs";
51212
+ import { tmpdir } from "os";
51213
+ import { join as join13 } from "path";
50769
51214
  function buildWorkflowRunPlan(workflow, options) {
50770
51215
  const runOptions = {
50771
51216
  url: options.url,
@@ -50782,10 +51227,10 @@ function buildWorkflowRunPlan(workflow, options) {
50782
51227
  return {
50783
51228
  workflow,
50784
51229
  runOptions,
50785
- connectorCommand: workflow.execution.target === "connector:e2b" ? buildConnectorCommand(workflow.execution, runOptions) : null
51230
+ sandbox: workflow.execution.target === "sandbox" ? buildSandboxPlan(workflow, workflow.execution, runOptions) : null
50786
51231
  };
50787
51232
  }
50788
- async function runTestingWorkflow(workflowId, options) {
51233
+ async function runTestingWorkflow(workflowId, options, dependencies = {}) {
50789
51234
  const workflow = getTestingWorkflow(workflowId);
50790
51235
  if (!workflow)
50791
51236
  throw new Error(`Testing workflow not found: ${workflowId}`);
@@ -50795,13 +51240,25 @@ async function runTestingWorkflow(workflowId, options) {
50795
51240
  const plan = buildWorkflowRunPlan(workflow, options);
50796
51241
  if (options.dryRun)
50797
51242
  return { run: null, results: [], plan };
50798
- if (workflow.execution.target === "connector:e2b") {
50799
- const connectorResult = await runViaConnector(plan);
50800
- return { run: null, results: [], plan, connectorResult };
51243
+ if (workflow.execution.target === "sandbox") {
51244
+ const sandboxResult = await runViaSandbox(plan, dependencies);
51245
+ return { run: null, results: [], plan, sandboxResult };
50801
51246
  }
50802
- const { run, results } = await runByFilter(plan.runOptions);
51247
+ const runLocal = dependencies.runByFilter ?? runByFilter;
51248
+ const { run, results } = await runLocal(plan.runOptions);
50803
51249
  return { run, results, plan };
50804
51250
  }
51251
+ function createWorkflowDatabaseBundle(workflow, plan) {
51252
+ if (!plan.sandbox)
51253
+ throw new Error(`Workflow is not configured for sandbox execution: ${workflow.name}`);
51254
+ const localDir = mkdtempSync(join13(tmpdir(), `testers-workflow-${workflow.id.slice(0, 8)}-`));
51255
+ writeFileSync3(join13(localDir, "testers.db"), getDatabase().serialize());
51256
+ return {
51257
+ localDir,
51258
+ remoteDir: plan.sandbox.stateRemoteDir,
51259
+ cleanup: () => rmSync(localDir, { recursive: true, force: true })
51260
+ };
51261
+ }
50805
51262
  function validatePersonaIds(workflow) {
50806
51263
  for (const personaId of workflow.personaIds) {
50807
51264
  if (!getPersona(personaId)) {
@@ -50809,46 +51266,109 @@ function validatePersonaIds(workflow) {
50809
51266
  }
50810
51267
  }
50811
51268
  }
50812
- function buildConnectorCommand(execution, runOptions) {
50813
- const connector = execution.connector ?? "e2b";
50814
- const operation = execution.operation ?? "run";
50815
- const payload = JSON.stringify({
50816
- operation,
50817
- template: execution.sandboxTemplate,
51269
+ function buildSandboxPlan(workflow, execution, runOptions) {
51270
+ const remoteDir = execution.sandboxRemoteDir ?? `/tmp/testers-workflow-${workflow.id.slice(0, 8)}`;
51271
+ const stateRemoteDir = `${remoteDir.replace(/\/+$/, "")}/.testers-state`;
51272
+ return {
51273
+ provider: execution.provider,
51274
+ image: execution.sandboxImage,
51275
+ name: `testers-${workflow.id.slice(0, 8)}`,
51276
+ remoteDir,
51277
+ stateRemoteDir,
51278
+ cleanup: execution.sandboxCleanup ?? "delete",
50818
51279
  timeoutMs: execution.timeoutMs,
50819
- env: execution.env ?? {},
50820
- command: [
50821
- "bunx",
50822
- "@hasna/testers",
50823
- "run",
50824
- runOptions.url,
50825
- ...runOptions.scenarioIds?.length ? ["--scenario", runOptions.scenarioIds.join(",")] : [],
50826
- ...runOptions.tags?.length ? runOptions.tags.flatMap((tag) => ["--tag", tag]) : [],
50827
- ...runOptions.priority ? ["--priority", runOptions.priority] : [],
50828
- ...runOptions.projectId ? ["--project", runOptions.projectId] : [],
50829
- ...runOptions.model ? ["--model", runOptions.model] : [],
50830
- "--json"
50831
- ]
50832
- });
50833
- return ["connectors", "run", connector, operation, payload];
51280
+ env: execution.env,
51281
+ command: buildSandboxCommand({
51282
+ runOptions,
51283
+ remoteDir,
51284
+ dbPath: `${stateRemoteDir}/testers.db`,
51285
+ setupCommand: execution.setupCommand,
51286
+ packageSpec: execution.packageSpec ?? "@hasna/testers"
51287
+ })
51288
+ };
50834
51289
  }
50835
- async function runViaConnector(plan) {
50836
- if (!plan.connectorCommand)
50837
- throw new Error("Workflow does not have a connector command");
50838
- const proc = Bun.spawn(plan.connectorCommand, {
50839
- stdout: "pipe",
50840
- stderr: "pipe",
50841
- env: process.env
50842
- });
50843
- const [stdout, stderr, exitCode] = await Promise.all([
50844
- new Response(proc.stdout).text(),
50845
- new Response(proc.stderr).text(),
50846
- proc.exited
50847
- ]);
50848
- if (exitCode !== 0) {
50849
- throw new Error(`Connector execution failed (${exitCode}): ${stderr || stdout}`);
51290
+ function buildSandboxCommand(input) {
51291
+ const args = [
51292
+ "bunx",
51293
+ input.packageSpec,
51294
+ "run",
51295
+ input.runOptions.url,
51296
+ ...input.runOptions.scenarioIds?.length ? ["--scenario", input.runOptions.scenarioIds.join(",")] : [],
51297
+ ...input.runOptions.tags?.length ? input.runOptions.tags.flatMap((tag) => ["--tag", tag]) : [],
51298
+ ...input.runOptions.priority ? ["--priority", input.runOptions.priority] : [],
51299
+ ...input.runOptions.projectId ? ["--project", input.runOptions.projectId] : [],
51300
+ ...input.runOptions.model ? ["--model", input.runOptions.model] : [],
51301
+ ...input.runOptions.headed ? ["--headed"] : [],
51302
+ ...input.runOptions.parallel ? ["--parallel", String(input.runOptions.parallel)] : [],
51303
+ ...input.runOptions.timeout ? ["--timeout", String(input.runOptions.timeout)] : [],
51304
+ ...input.runOptions.personaIds?.length ? ["--persona", input.runOptions.personaIds.join(",")] : [],
51305
+ "--no-auto-generate",
51306
+ "--json"
51307
+ ];
51308
+ return [
51309
+ "set -euo pipefail",
51310
+ `mkdir -p ${shellQuote(input.remoteDir)}`,
51311
+ `cd ${shellQuote(input.remoteDir)}`,
51312
+ input.setupCommand,
51313
+ `HASNA_TESTERS_DB_PATH=${shellQuote(input.dbPath)} ${args.map(shellQuote).join(" ")}`
51314
+ ].filter(Boolean).join(`
51315
+ `);
51316
+ }
51317
+ async function runViaSandbox(plan, dependencies) {
51318
+ if (!plan.sandbox)
51319
+ throw new Error("Workflow does not have a sandbox plan");
51320
+ const sandboxes = await resolveSandboxesRuntime(dependencies);
51321
+ const createBundle = dependencies.createDatabaseBundle ?? createWorkflowDatabaseBundle;
51322
+ const bundle = createBundle(plan.workflow, plan);
51323
+ try {
51324
+ const raw = await sandboxes.runCommandInSandbox({
51325
+ command: plan.sandbox.command,
51326
+ provider: plan.sandbox.provider,
51327
+ name: plan.sandbox.name,
51328
+ image: plan.sandbox.image,
51329
+ sandboxTimeout: plan.sandbox.timeoutMs,
51330
+ commandTimeoutMs: plan.sandbox.timeoutMs,
51331
+ projectId: plan.workflow.projectId ?? undefined,
51332
+ config: {
51333
+ source: "testers",
51334
+ workflowId: plan.workflow.id,
51335
+ workflowName: plan.workflow.name
51336
+ },
51337
+ sandboxEnvVars: plan.sandbox.env,
51338
+ cleanup: plan.sandbox.cleanup,
51339
+ upload: {
51340
+ localDir: bundle.localDir,
51341
+ remoteDir: bundle.remoteDir
51342
+ }
51343
+ });
51344
+ const exitCode = raw.result.exit_code ?? raw.result.exitCode ?? 0;
51345
+ const stdout = raw.result.stdout ?? "";
51346
+ const stderr = raw.result.stderr ?? "";
51347
+ if (exitCode !== 0) {
51348
+ throw new Error(`Sandbox workflow execution failed (${exitCode}): ${stderr || stdout}`);
51349
+ }
51350
+ return {
51351
+ sandboxId: raw.sandbox.id,
51352
+ sessionId: raw.session.id,
51353
+ exitCode,
51354
+ stdout,
51355
+ stderr,
51356
+ cleanup: raw.cleanup
51357
+ };
51358
+ } finally {
51359
+ bundle.cleanup?.();
50850
51360
  }
50851
- return stdout.trim();
51361
+ }
51362
+ async function resolveSandboxesRuntime(dependencies) {
51363
+ if (dependencies.sandboxes)
51364
+ return dependencies.sandboxes;
51365
+ if (dependencies.createSandboxesSDK)
51366
+ return dependencies.createSandboxesSDK();
51367
+ const mod = await import("@hasna/sandboxes");
51368
+ return mod.createSandboxesSDK();
51369
+ }
51370
+ function shellQuote(value) {
51371
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
50852
51372
  }
50853
51373
 
50854
51374
  // src/lib/workflow-agent.ts
@@ -51200,10 +51720,16 @@ var WorkflowFilterSchema = exports_external.object({
51200
51720
  priority: exports_external.enum(["low", "medium", "high", "critical"]).optional()
51201
51721
  }).optional();
51202
51722
  var WorkflowExecutionSchema = exports_external.object({
51203
- target: exports_external.enum(["local", "connector:e2b"]).default("local"),
51723
+ target: exports_external.enum(["local", "sandbox", "connector:e2b"]).default("local"),
51204
51724
  connector: exports_external.string().optional(),
51205
51725
  operation: exports_external.string().optional(),
51726
+ provider: exports_external.string().optional(),
51727
+ sandboxImage: exports_external.string().optional(),
51728
+ sandboxRemoteDir: exports_external.string().optional(),
51729
+ sandboxCleanup: exports_external.enum(["delete", "stop", "keep"]).optional(),
51206
51730
  sandboxTemplate: exports_external.string().optional(),
51731
+ setupCommand: exports_external.string().optional(),
51732
+ packageSpec: exports_external.string().optional(),
51207
51733
  timeoutMs: exports_external.number().int().positive().optional(),
51208
51734
  env: exports_external.record(exports_external.string()).optional()
51209
51735
  }).optional();
@@ -51285,7 +51811,7 @@ async function handleRequest(req) {
51285
51811
  if (pathname === "/api/status" && method === "GET") {
51286
51812
  const config2 = loadConfig();
51287
51813
  getDatabase();
51288
- const dbPath = process.env["HASNA_TESTERS_DB_PATH"] ?? process.env["TESTERS_DB_PATH"] ?? join13(getTestersDir(), "testers.db");
51814
+ const dbPath = process.env["HASNA_TESTERS_DB_PATH"] ?? process.env["TESTERS_DB_PATH"] ?? join14(getTestersDir(), "testers.db");
51289
51815
  const scenarios = listScenarios();
51290
51816
  const runs = listRuns();
51291
51817
  return jsonResponse({
@@ -52015,7 +52541,7 @@ async function handleRequest(req) {
52015
52541
  return jsonResponse({ routes, apiRoutes, totalCovered: coverageMap.size });
52016
52542
  }
52017
52543
  if (!pathname.startsWith("/api")) {
52018
- const dashboardDir = join13(import.meta.dir, "..", "..", "dashboard", "dist");
52544
+ const dashboardDir = join14(import.meta.dir, "..", "..", "dashboard", "dist");
52019
52545
  if (!existsSync10(dashboardDir)) {
52020
52546
  return new Response(`<!DOCTYPE html>
52021
52547
  <html>
@@ -52034,7 +52560,7 @@ async function handleRequest(req) {
52034
52560
  }
52035
52561
  });
52036
52562
  }
52037
- const filePath = join13(dashboardDir, pathname === "/" ? "index.html" : pathname);
52563
+ const filePath = join14(dashboardDir, pathname === "/" ? "index.html" : pathname);
52038
52564
  if (existsSync10(filePath)) {
52039
52565
  const file2 = Bun.file(filePath);
52040
52566
  return new Response(file2, {
@@ -52044,7 +52570,7 @@ async function handleRequest(req) {
52044
52570
  }
52045
52571
  });
52046
52572
  }
52047
- const indexPath = join13(dashboardDir, "index.html");
52573
+ const indexPath = join14(dashboardDir, "index.html");
52048
52574
  if (existsSync10(indexPath)) {
52049
52575
  const file2 = Bun.file(indexPath);
52050
52576
  return new Response(file2, {