@hasna/testers 0.0.42 → 0.0.44

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/cli/index.js CHANGED
@@ -2120,6 +2120,11 @@ function cleanupValue(value) {
2120
2120
  return value;
2121
2121
  return;
2122
2122
  }
2123
+ function syncStrategyValue(value) {
2124
+ if (value === "archive" || value === "rsync")
2125
+ return value;
2126
+ return;
2127
+ }
2123
2128
  function workflowExecutionFromValue(value) {
2124
2129
  const input = isRecord(value) ? value : {};
2125
2130
  const rawTarget = stringValue(input["target"]) ?? "local";
@@ -2134,6 +2139,7 @@ function workflowExecutionFromValue(value) {
2134
2139
  const sandboxImage = stringValue(input["sandboxImage"]) ?? stringValue(input["sandboxTemplate"]);
2135
2140
  const sandboxRemoteDir = stringValue(input["sandboxRemoteDir"]);
2136
2141
  const sandboxCleanup = cleanupValue(input["sandboxCleanup"]);
2142
+ const sandboxSyncStrategy = syncStrategyValue(input["sandboxSyncStrategy"]);
2137
2143
  const setupCommand = stringValue(input["setupCommand"]);
2138
2144
  const packageSpec = stringValue(input["packageSpec"]);
2139
2145
  const timeoutMs = numberValue(input["timeoutMs"]);
@@ -2144,6 +2150,7 @@ function workflowExecutionFromValue(value) {
2144
2150
  ...sandboxImage ? { sandboxImage } : {},
2145
2151
  ...sandboxRemoteDir ? { sandboxRemoteDir } : {},
2146
2152
  ...sandboxCleanup ? { sandboxCleanup } : {},
2153
+ ...sandboxSyncStrategy ? { sandboxSyncStrategy } : {},
2147
2154
  ...setupCommand ? { setupCommand } : {},
2148
2155
  ...packageSpec ? { packageSpec } : {},
2149
2156
  ...timeoutMs !== undefined ? { timeoutMs } : {},
@@ -2175,6 +2182,8 @@ function projectFromRow(row) {
2175
2182
  baseUrl: row.base_url ?? null,
2176
2183
  port: row.port ?? null,
2177
2184
  settings: row.settings ? JSON.parse(row.settings) : {},
2185
+ scenarioPrefix: row.scenario_prefix ?? "TST",
2186
+ scenarioCounter: row.scenario_counter ?? 0,
2178
2187
  createdAt: row.created_at,
2179
2188
  updatedAt: row.updated_at
2180
2189
  };
@@ -12378,9 +12387,14 @@ function nextShortId(projectId) {
12378
12387
  if (projectId) {
12379
12388
  const project = db2.query("SELECT scenario_prefix, scenario_counter FROM projects WHERE id = ?").get(projectId);
12380
12389
  if (project) {
12381
- const next = project.scenario_counter + 1;
12390
+ let next = (project.scenario_counter ?? 0) + 1;
12391
+ let shortId = `${project.scenario_prefix || "TST"}-${next}`;
12392
+ while (db2.query("SELECT 1 FROM scenarios WHERE short_id = ?").get(shortId)) {
12393
+ next += 1;
12394
+ shortId = `${project.scenario_prefix || "TST"}-${next}`;
12395
+ }
12382
12396
  db2.query("UPDATE projects SET scenario_counter = ? WHERE id = ?").run(next, projectId);
12383
- return `${project.scenario_prefix}-${next}`;
12397
+ return shortId;
12384
12398
  }
12385
12399
  }
12386
12400
  return shortUuid();
@@ -13934,6 +13948,7 @@ import { readFileSync as readFileSync2, existsSync as existsSync7 } from "fs";
13934
13948
  function getDefaultConfig() {
13935
13949
  return {
13936
13950
  defaultModel: "claude-haiku-4-5-20251001",
13951
+ defaultImageModel: DEFAULT_IMAGE_MODEL,
13937
13952
  models: { ...MODEL_MAP },
13938
13953
  browser: {
13939
13954
  headless: true,
@@ -13960,6 +13975,7 @@ function loadConfig() {
13960
13975
  }
13961
13976
  const config = {
13962
13977
  defaultModel: fileConfig.defaultModel ?? defaults2.defaultModel,
13978
+ defaultImageModel: fileConfig.defaultImageModel ?? defaults2.defaultImageModel,
13963
13979
  models: fileConfig.models ? { ...defaults2.models, ...fileConfig.models } : { ...defaults2.models },
13964
13980
  browser: fileConfig.browser ? { ...defaults2.browser, ...fileConfig.browser } : { ...defaults2.browser },
13965
13981
  screenshots: fileConfig.screenshots ? { ...defaults2.screenshots, ...fileConfig.screenshots } : { ...defaults2.screenshots },
@@ -13975,6 +13991,10 @@ function loadConfig() {
13975
13991
  if (envModel) {
13976
13992
  config.defaultModel = envModel;
13977
13993
  }
13994
+ const envImageModel = process.env["TESTERS_IMAGE_MODEL"];
13995
+ if (envImageModel) {
13996
+ config.defaultImageModel = envImageModel;
13997
+ }
13978
13998
  const envScreenshotsDir = process.env["TESTERS_SCREENSHOTS_DIR"];
13979
13999
  if (envScreenshotsDir) {
13980
14000
  config.screenshots.dir = envScreenshotsDir;
@@ -13989,7 +14009,7 @@ function loadConfig() {
13989
14009
  }
13990
14010
  return config;
13991
14011
  }
13992
- var CONFIG_DIR3, CONFIG_PATH2;
14012
+ var CONFIG_DIR3, CONFIG_PATH2, DEFAULT_IMAGE_MODEL = "gpt-image-2";
13993
14013
  var init_config2 = __esm(() => {
13994
14014
  init_types();
13995
14015
  init_paths();
@@ -19165,14 +19185,19 @@ var init_reporter = __esm(() => {
19165
19185
  });
19166
19186
 
19167
19187
  // src/db/projects.ts
19188
+ function normalizeScenarioPrefix(prefix) {
19189
+ const normalized = (prefix ?? "TST").trim().toUpperCase().replace(/[^A-Z0-9]/g, "");
19190
+ return normalized || "TST";
19191
+ }
19168
19192
  function createProject(input) {
19169
19193
  const db2 = getDatabase();
19170
19194
  const id = uuid();
19171
19195
  const timestamp = now();
19196
+ const scenarioPrefix = normalizeScenarioPrefix(input.scenarioPrefix);
19172
19197
  db2.query(`
19173
- INSERT INTO projects (id, name, path, description, base_url, port, settings, created_at, updated_at)
19174
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
19175
- `).run(id, input.name, input.path ?? null, input.description ?? null, input.baseUrl ?? null, input.port ?? null, input.settings ? JSON.stringify(input.settings) : "{}", timestamp, timestamp);
19198
+ INSERT INTO projects (id, name, path, description, base_url, port, settings, scenario_prefix, scenario_counter, created_at, updated_at)
19199
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
19200
+ `).run(id, input.name, input.path ?? null, input.description ?? null, input.baseUrl ?? null, input.port ?? null, input.settings ? JSON.stringify(input.settings) : "{}", scenarioPrefix, timestamp, timestamp);
19176
19201
  return getProject(id);
19177
19202
  }
19178
19203
  function getProject(id) {
@@ -27001,6 +27026,178 @@ var init_workflows = __esm(() => {
27001
27026
  DEFAULT_EXECUTION = { target: "local" };
27002
27027
  });
27003
27028
 
27029
+ // src/lib/workflow-runner.ts
27030
+ import { mkdtempSync, rmSync, writeFileSync as writeFileSync4 } from "fs";
27031
+ import { tmpdir } from "os";
27032
+ import { join as join16 } from "path";
27033
+ function buildWorkflowRunPlan(workflow, options) {
27034
+ const runOptions = {
27035
+ url: options.url,
27036
+ model: options.model,
27037
+ headed: options.headed,
27038
+ parallel: options.parallel,
27039
+ timeout: options.timeout ?? workflow.execution.timeoutMs,
27040
+ projectId: workflow.projectId ?? undefined,
27041
+ scenarioIds: workflow.scenarioFilter.scenarioIds,
27042
+ tags: workflow.scenarioFilter.tags,
27043
+ priority: workflow.scenarioFilter.priority,
27044
+ personaIds: workflow.personaIds.length > 0 ? workflow.personaIds : undefined
27045
+ };
27046
+ return {
27047
+ workflow,
27048
+ runOptions,
27049
+ sandbox: workflow.execution.target === "sandbox" ? buildSandboxPlan(workflow, workflow.execution, runOptions) : null
27050
+ };
27051
+ }
27052
+ async function runTestingWorkflow(workflowId, options, dependencies = {}) {
27053
+ const workflow = getTestingWorkflow(workflowId);
27054
+ if (!workflow)
27055
+ throw new Error(`Testing workflow not found: ${workflowId}`);
27056
+ if (!workflow.enabled)
27057
+ throw new Error(`Testing workflow is disabled: ${workflow.name}`);
27058
+ validatePersonaIds(workflow);
27059
+ const plan = buildWorkflowRunPlan(workflow, options);
27060
+ if (options.dryRun)
27061
+ return { run: null, results: [], plan };
27062
+ if (workflow.execution.target === "sandbox") {
27063
+ const sandboxResult = await runViaSandbox(plan, dependencies);
27064
+ return { run: null, results: [], plan, sandboxResult };
27065
+ }
27066
+ const runLocal = dependencies.runByFilter ?? runByFilter;
27067
+ const { run, results } = await runLocal(plan.runOptions);
27068
+ return { run, results, plan };
27069
+ }
27070
+ function createWorkflowDatabaseBundle(workflow, plan) {
27071
+ if (!plan.sandbox)
27072
+ throw new Error(`Workflow is not configured for sandbox execution: ${workflow.name}`);
27073
+ const localDir = mkdtempSync(join16(tmpdir(), `testers-workflow-${workflow.id.slice(0, 8)}-`));
27074
+ writeFileSync4(join16(localDir, "testers.db"), getDatabase().serialize());
27075
+ return {
27076
+ localDir,
27077
+ remoteDir: plan.sandbox.stateRemoteDir,
27078
+ cleanup: () => rmSync(localDir, { recursive: true, force: true })
27079
+ };
27080
+ }
27081
+ function validatePersonaIds(workflow) {
27082
+ for (const personaId of workflow.personaIds) {
27083
+ if (!getPersona(personaId)) {
27084
+ throw new Error(`Persona not found for workflow ${workflow.name}: ${personaId}`);
27085
+ }
27086
+ }
27087
+ }
27088
+ function buildSandboxPlan(workflow, execution, runOptions) {
27089
+ const remoteDir = execution.sandboxRemoteDir ?? `/tmp/testers-workflow-${workflow.id.slice(0, 8)}`;
27090
+ const stateRemoteDir = `${remoteDir.replace(/\/+$/, "")}/.testers-state`;
27091
+ return {
27092
+ provider: execution.provider,
27093
+ image: execution.sandboxImage,
27094
+ name: `testers-${workflow.id.slice(0, 8)}`,
27095
+ remoteDir,
27096
+ stateRemoteDir,
27097
+ cleanup: execution.sandboxCleanup ?? "delete",
27098
+ syncStrategy: execution.sandboxSyncStrategy ?? "rsync",
27099
+ timeoutMs: execution.timeoutMs,
27100
+ env: execution.env,
27101
+ command: buildSandboxCommand({
27102
+ runOptions,
27103
+ remoteDir,
27104
+ dbPath: `${stateRemoteDir}/testers.db`,
27105
+ setupCommand: execution.setupCommand,
27106
+ packageSpec: execution.packageSpec ?? "@hasna/testers"
27107
+ })
27108
+ };
27109
+ }
27110
+ function buildSandboxCommand(input) {
27111
+ const args = [
27112
+ "bunx",
27113
+ input.packageSpec,
27114
+ "run",
27115
+ input.runOptions.url,
27116
+ ...input.runOptions.scenarioIds?.length ? ["--scenario", input.runOptions.scenarioIds.join(",")] : [],
27117
+ ...input.runOptions.tags?.length ? input.runOptions.tags.flatMap((tag) => ["--tag", tag]) : [],
27118
+ ...input.runOptions.priority ? ["--priority", input.runOptions.priority] : [],
27119
+ ...input.runOptions.projectId ? ["--project", input.runOptions.projectId] : [],
27120
+ ...input.runOptions.model ? ["--model", input.runOptions.model] : [],
27121
+ ...input.runOptions.headed ? ["--headed"] : [],
27122
+ ...input.runOptions.parallel ? ["--parallel", String(input.runOptions.parallel)] : [],
27123
+ ...input.runOptions.timeout ? ["--timeout", String(input.runOptions.timeout)] : [],
27124
+ ...input.runOptions.personaIds?.length ? ["--persona", input.runOptions.personaIds.join(",")] : [],
27125
+ "--no-auto-generate",
27126
+ "--json"
27127
+ ];
27128
+ return [
27129
+ "set -euo pipefail",
27130
+ `mkdir -p ${shellQuote(input.remoteDir)}`,
27131
+ `cd ${shellQuote(input.remoteDir)}`,
27132
+ input.setupCommand,
27133
+ `HASNA_TESTERS_DB_PATH=${shellQuote(input.dbPath)} ${args.map(shellQuote).join(" ")}`
27134
+ ].filter(Boolean).join(`
27135
+ `);
27136
+ }
27137
+ async function runViaSandbox(plan, dependencies) {
27138
+ if (!plan.sandbox)
27139
+ throw new Error("Workflow does not have a sandbox plan");
27140
+ const sandboxes = await resolveSandboxesRuntime(dependencies);
27141
+ const createBundle = dependencies.createDatabaseBundle ?? createWorkflowDatabaseBundle;
27142
+ const bundle = createBundle(plan.workflow, plan);
27143
+ try {
27144
+ const raw = await sandboxes.runCommandInSandbox({
27145
+ command: plan.sandbox.command,
27146
+ provider: plan.sandbox.provider,
27147
+ name: plan.sandbox.name,
27148
+ image: plan.sandbox.image,
27149
+ sandboxTimeout: plan.sandbox.timeoutMs,
27150
+ commandTimeoutMs: plan.sandbox.timeoutMs,
27151
+ projectId: plan.workflow.projectId ?? undefined,
27152
+ config: {
27153
+ source: "testers",
27154
+ workflowId: plan.workflow.id,
27155
+ workflowName: plan.workflow.name
27156
+ },
27157
+ sandboxEnvVars: plan.sandbox.env,
27158
+ cleanup: plan.sandbox.cleanup,
27159
+ upload: {
27160
+ localDir: bundle.localDir,
27161
+ remoteDir: bundle.remoteDir,
27162
+ syncStrategy: plan.sandbox.syncStrategy
27163
+ }
27164
+ });
27165
+ const exitCode = raw.result.exit_code ?? raw.result.exitCode ?? 0;
27166
+ const stdout = raw.result.stdout ?? "";
27167
+ const stderr = raw.result.stderr ?? "";
27168
+ if (exitCode !== 0) {
27169
+ throw new Error(`Sandbox workflow execution failed (${exitCode}): ${stderr || stdout}`);
27170
+ }
27171
+ return {
27172
+ sandboxId: raw.sandbox.id,
27173
+ sessionId: raw.session.id,
27174
+ exitCode,
27175
+ stdout,
27176
+ stderr,
27177
+ cleanup: raw.cleanup
27178
+ };
27179
+ } finally {
27180
+ bundle.cleanup?.();
27181
+ }
27182
+ }
27183
+ async function resolveSandboxesRuntime(dependencies) {
27184
+ if (dependencies.sandboxes)
27185
+ return dependencies.sandboxes;
27186
+ if (dependencies.createSandboxesSDK)
27187
+ return dependencies.createSandboxesSDK();
27188
+ const mod = await import("@hasna/sandboxes");
27189
+ return mod.createSandboxesSDK();
27190
+ }
27191
+ function shellQuote(value) {
27192
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
27193
+ }
27194
+ var init_workflow_runner = __esm(() => {
27195
+ init_database();
27196
+ init_workflows();
27197
+ init_personas();
27198
+ init_runner();
27199
+ });
27200
+
27004
27201
  // src/lib/ci.ts
27005
27202
  var exports_ci = {};
27006
27203
  __export(exports_ci, {
@@ -59383,6 +59580,115 @@ var init_agents = __esm(() => {
59383
59580
  init_database();
59384
59581
  });
59385
59582
 
59583
+ // src/lib/workflow-fanout.ts
59584
+ var exports_workflow_fanout = {};
59585
+ __export(exports_workflow_fanout, {
59586
+ runWorkflowFanout: () => runWorkflowFanout,
59587
+ resolveWorkflowFanoutSelection: () => resolveWorkflowFanoutSelection,
59588
+ normalizeFanoutWorkerCount: () => normalizeFanoutWorkerCount
59589
+ });
59590
+ function splitWorkflowIds(ids) {
59591
+ return (ids ?? []).flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean);
59592
+ }
59593
+ function normalizeFanoutWorkerCount(value) {
59594
+ const workers = Math.floor(value ?? 6);
59595
+ if (!Number.isFinite(workers) || workers < 1 || workers > 12) {
59596
+ throw new Error("workflow fanout workers must be between 1 and 12");
59597
+ }
59598
+ return workers;
59599
+ }
59600
+ function resolveWorkflowFanoutSelection(options) {
59601
+ const ids = splitWorkflowIds(options.workflowIds);
59602
+ const workflows = ids.length > 0 ? ids.map((id) => {
59603
+ const workflow = getTestingWorkflow(id);
59604
+ if (!workflow)
59605
+ throw new Error(`Testing workflow not found: ${id}`);
59606
+ return workflow;
59607
+ }) : listTestingWorkflows({
59608
+ projectId: options.projectId,
59609
+ enabled: options.includeDisabled ? undefined : true
59610
+ });
59611
+ const tagSet = new Set(options.tags ?? []);
59612
+ const filtered = tagSet.size === 0 ? workflows : workflows.filter((workflow) => workflow.scenarioFilter.tags?.some((tag) => tagSet.has(tag)));
59613
+ if (filtered.length === 0) {
59614
+ throw new Error("No testing workflows matched the fanout selection");
59615
+ }
59616
+ const nonSandbox = filtered.filter((workflow) => workflow.execution.target !== "sandbox");
59617
+ if (nonSandbox.length > 0) {
59618
+ throw new Error(`workflow fanout requires sandbox workflows. Recreate or update these with --target sandbox: ${nonSandbox.map((workflow) => workflow.name).join(", ")}`);
59619
+ }
59620
+ return filtered;
59621
+ }
59622
+ async function mapWithConcurrency(items, limit, worker) {
59623
+ const output = new Array(items.length);
59624
+ let next = 0;
59625
+ async function runWorker() {
59626
+ while (next < items.length) {
59627
+ const index = next++;
59628
+ output[index] = await worker(items[index], index);
59629
+ }
59630
+ }
59631
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => runWorker()));
59632
+ return output;
59633
+ }
59634
+ async function runWorkflowFanout(options, dependencies = {}) {
59635
+ const workers = normalizeFanoutWorkerCount(options.workers);
59636
+ const workflows = resolveWorkflowFanoutSelection(options);
59637
+ const { runTestingWorkflow: runOne = runTestingWorkflow, ...workflowDependencies } = dependencies;
59638
+ const items = await mapWithConcurrency(workflows, workers, async (workflow) => {
59639
+ try {
59640
+ const output = await runOne(workflow.id, {
59641
+ url: options.url,
59642
+ model: options.model,
59643
+ headed: options.headed,
59644
+ parallel: options.parallel,
59645
+ timeout: options.timeout,
59646
+ dryRun: options.dryRun
59647
+ }, workflowDependencies);
59648
+ if (options.dryRun) {
59649
+ return {
59650
+ workflowId: workflow.id,
59651
+ workflowName: workflow.name,
59652
+ status: "dry-run",
59653
+ plan: output.plan
59654
+ };
59655
+ }
59656
+ return {
59657
+ workflowId: workflow.id,
59658
+ workflowName: workflow.name,
59659
+ status: "passed",
59660
+ sandboxId: output.sandboxResult?.sandboxId,
59661
+ sessionId: output.sandboxResult?.sessionId,
59662
+ exitCode: output.sandboxResult?.exitCode,
59663
+ stdout: output.sandboxResult?.stdout,
59664
+ stderr: output.sandboxResult?.stderr
59665
+ };
59666
+ } catch (error) {
59667
+ return {
59668
+ workflowId: workflow.id,
59669
+ workflowName: workflow.name,
59670
+ status: "failed",
59671
+ error: error instanceof Error ? error.message : String(error)
59672
+ };
59673
+ }
59674
+ });
59675
+ const failed = items.filter((item) => item.status === "failed").length;
59676
+ const passed = items.filter((item) => item.status === "passed").length;
59677
+ const dryRun = options.dryRun === true;
59678
+ return {
59679
+ status: dryRun ? "dry-run" : failed > 0 ? "failed" : "passed",
59680
+ workers,
59681
+ total: items.length,
59682
+ passed,
59683
+ failed,
59684
+ items
59685
+ };
59686
+ }
59687
+ var init_workflow_fanout = __esm(() => {
59688
+ init_workflows();
59689
+ init_workflow_runner();
59690
+ });
59691
+
59386
59692
  // node_modules/@ai-sdk/provider/dist/index.mjs
59387
59693
  function getErrorMessage(error) {
59388
59694
  if (error == null) {
@@ -94040,7 +94346,7 @@ import chalk6 from "chalk";
94040
94346
  // package.json
94041
94347
  var package_default = {
94042
94348
  name: "@hasna/testers",
94043
- version: "0.0.42",
94349
+ version: "0.0.44",
94044
94350
  description: "AI-powered QA testing CLI \u2014 spawns cheap AI agents to test web apps with headless browsers",
94045
94351
  type: "module",
94046
94352
  main: "dist/index.js",
@@ -94086,7 +94392,7 @@ var package_default = {
94086
94392
  "@hasna/cloud": "^0.1.24",
94087
94393
  "@hasna/contacts": "^0.6.8",
94088
94394
  "@hasna/projects": "^0.1.42",
94089
- "@hasna/sandboxes": "^0.1.27",
94395
+ "@hasna/sandboxes": "^0.1.28",
94090
94396
  "@modelcontextprotocol/sdk": "^1.12.1",
94091
94397
  ai: "^6.0.175",
94092
94398
  chalk: "^5.4.1",
@@ -96152,174 +96458,7 @@ function deleteAuthPreset(name) {
96152
96458
  // src/cli/index.tsx
96153
96459
  init_flows();
96154
96460
  init_workflows();
96155
-
96156
- // src/lib/workflow-runner.ts
96157
- init_database();
96158
- init_workflows();
96159
- init_personas();
96160
- init_runner();
96161
- import { mkdtempSync, rmSync, writeFileSync as writeFileSync4 } from "fs";
96162
- import { tmpdir } from "os";
96163
- import { join as join16 } from "path";
96164
- function buildWorkflowRunPlan(workflow, options) {
96165
- const runOptions = {
96166
- url: options.url,
96167
- model: options.model,
96168
- headed: options.headed,
96169
- parallel: options.parallel,
96170
- timeout: options.timeout ?? workflow.execution.timeoutMs,
96171
- projectId: workflow.projectId ?? undefined,
96172
- scenarioIds: workflow.scenarioFilter.scenarioIds,
96173
- tags: workflow.scenarioFilter.tags,
96174
- priority: workflow.scenarioFilter.priority,
96175
- personaIds: workflow.personaIds.length > 0 ? workflow.personaIds : undefined
96176
- };
96177
- return {
96178
- workflow,
96179
- runOptions,
96180
- sandbox: workflow.execution.target === "sandbox" ? buildSandboxPlan(workflow, workflow.execution, runOptions) : null
96181
- };
96182
- }
96183
- async function runTestingWorkflow(workflowId, options, dependencies = {}) {
96184
- const workflow = getTestingWorkflow(workflowId);
96185
- if (!workflow)
96186
- throw new Error(`Testing workflow not found: ${workflowId}`);
96187
- if (!workflow.enabled)
96188
- throw new Error(`Testing workflow is disabled: ${workflow.name}`);
96189
- validatePersonaIds(workflow);
96190
- const plan = buildWorkflowRunPlan(workflow, options);
96191
- if (options.dryRun)
96192
- return { run: null, results: [], plan };
96193
- if (workflow.execution.target === "sandbox") {
96194
- const sandboxResult = await runViaSandbox(plan, dependencies);
96195
- return { run: null, results: [], plan, sandboxResult };
96196
- }
96197
- const runLocal = dependencies.runByFilter ?? runByFilter;
96198
- const { run, results } = await runLocal(plan.runOptions);
96199
- return { run, results, plan };
96200
- }
96201
- function createWorkflowDatabaseBundle(workflow, plan) {
96202
- if (!plan.sandbox)
96203
- throw new Error(`Workflow is not configured for sandbox execution: ${workflow.name}`);
96204
- const localDir = mkdtempSync(join16(tmpdir(), `testers-workflow-${workflow.id.slice(0, 8)}-`));
96205
- writeFileSync4(join16(localDir, "testers.db"), getDatabase().serialize());
96206
- return {
96207
- localDir,
96208
- remoteDir: plan.sandbox.stateRemoteDir,
96209
- cleanup: () => rmSync(localDir, { recursive: true, force: true })
96210
- };
96211
- }
96212
- function validatePersonaIds(workflow) {
96213
- for (const personaId of workflow.personaIds) {
96214
- if (!getPersona(personaId)) {
96215
- throw new Error(`Persona not found for workflow ${workflow.name}: ${personaId}`);
96216
- }
96217
- }
96218
- }
96219
- function buildSandboxPlan(workflow, execution, runOptions) {
96220
- const remoteDir = execution.sandboxRemoteDir ?? `/tmp/testers-workflow-${workflow.id.slice(0, 8)}`;
96221
- const stateRemoteDir = `${remoteDir.replace(/\/+$/, "")}/.testers-state`;
96222
- return {
96223
- provider: execution.provider,
96224
- image: execution.sandboxImage,
96225
- name: `testers-${workflow.id.slice(0, 8)}`,
96226
- remoteDir,
96227
- stateRemoteDir,
96228
- cleanup: execution.sandboxCleanup ?? "delete",
96229
- timeoutMs: execution.timeoutMs,
96230
- env: execution.env,
96231
- command: buildSandboxCommand({
96232
- runOptions,
96233
- remoteDir,
96234
- dbPath: `${stateRemoteDir}/testers.db`,
96235
- setupCommand: execution.setupCommand,
96236
- packageSpec: execution.packageSpec ?? "@hasna/testers"
96237
- })
96238
- };
96239
- }
96240
- function buildSandboxCommand(input) {
96241
- const args = [
96242
- "bunx",
96243
- input.packageSpec,
96244
- "run",
96245
- input.runOptions.url,
96246
- ...input.runOptions.scenarioIds?.length ? ["--scenario", input.runOptions.scenarioIds.join(",")] : [],
96247
- ...input.runOptions.tags?.length ? input.runOptions.tags.flatMap((tag) => ["--tag", tag]) : [],
96248
- ...input.runOptions.priority ? ["--priority", input.runOptions.priority] : [],
96249
- ...input.runOptions.projectId ? ["--project", input.runOptions.projectId] : [],
96250
- ...input.runOptions.model ? ["--model", input.runOptions.model] : [],
96251
- ...input.runOptions.headed ? ["--headed"] : [],
96252
- ...input.runOptions.parallel ? ["--parallel", String(input.runOptions.parallel)] : [],
96253
- ...input.runOptions.timeout ? ["--timeout", String(input.runOptions.timeout)] : [],
96254
- ...input.runOptions.personaIds?.length ? ["--persona", input.runOptions.personaIds.join(",")] : [],
96255
- "--no-auto-generate",
96256
- "--json"
96257
- ];
96258
- return [
96259
- "set -euo pipefail",
96260
- `mkdir -p ${shellQuote(input.remoteDir)}`,
96261
- `cd ${shellQuote(input.remoteDir)}`,
96262
- input.setupCommand,
96263
- `HASNA_TESTERS_DB_PATH=${shellQuote(input.dbPath)} ${args.map(shellQuote).join(" ")}`
96264
- ].filter(Boolean).join(`
96265
- `);
96266
- }
96267
- async function runViaSandbox(plan, dependencies) {
96268
- if (!plan.sandbox)
96269
- throw new Error("Workflow does not have a sandbox plan");
96270
- const sandboxes = await resolveSandboxesRuntime(dependencies);
96271
- const createBundle = dependencies.createDatabaseBundle ?? createWorkflowDatabaseBundle;
96272
- const bundle = createBundle(plan.workflow, plan);
96273
- try {
96274
- const raw = await sandboxes.runCommandInSandbox({
96275
- command: plan.sandbox.command,
96276
- provider: plan.sandbox.provider,
96277
- name: plan.sandbox.name,
96278
- image: plan.sandbox.image,
96279
- sandboxTimeout: plan.sandbox.timeoutMs,
96280
- commandTimeoutMs: plan.sandbox.timeoutMs,
96281
- projectId: plan.workflow.projectId ?? undefined,
96282
- config: {
96283
- source: "testers",
96284
- workflowId: plan.workflow.id,
96285
- workflowName: plan.workflow.name
96286
- },
96287
- sandboxEnvVars: plan.sandbox.env,
96288
- cleanup: plan.sandbox.cleanup,
96289
- upload: {
96290
- localDir: bundle.localDir,
96291
- remoteDir: bundle.remoteDir
96292
- }
96293
- });
96294
- const exitCode = raw.result.exit_code ?? raw.result.exitCode ?? 0;
96295
- const stdout = raw.result.stdout ?? "";
96296
- const stderr = raw.result.stderr ?? "";
96297
- if (exitCode !== 0) {
96298
- throw new Error(`Sandbox workflow execution failed (${exitCode}): ${stderr || stdout}`);
96299
- }
96300
- return {
96301
- sandboxId: raw.sandbox.id,
96302
- sessionId: raw.session.id,
96303
- exitCode,
96304
- stdout,
96305
- stderr,
96306
- cleanup: raw.cleanup
96307
- };
96308
- } finally {
96309
- bundle.cleanup?.();
96310
- }
96311
- }
96312
- async function resolveSandboxesRuntime(dependencies) {
96313
- if (dependencies.sandboxes)
96314
- return dependencies.sandboxes;
96315
- if (dependencies.createSandboxesSDK)
96316
- return dependencies.createSandboxesSDK();
96317
- const mod = await import("@hasna/sandboxes");
96318
- return mod.createSandboxesSDK();
96319
- }
96320
- function shellQuote(value) {
96321
- return `'${value.replaceAll("'", `'"'"'`)}'`;
96322
- }
96461
+ init_workflow_runner();
96323
96462
 
96324
96463
  // src/db/environments.ts
96325
96464
  init_database();
@@ -98350,6 +98489,7 @@ program2.command("status").description("Show database and auth status").action((
98350
98489
  log(` ANTHROPIC_API_KEY: ${hasApiKey ? chalk6.green("set") : chalk6.red("not set")}`);
98351
98490
  log(` Database: ${dbPath}`);
98352
98491
  log(` Default model: ${config2.defaultModel}`);
98492
+ log(` Image model: ${config2.defaultImageModel}`);
98353
98493
  log(` Screenshots dir: ${config2.screenshots.dir}`);
98354
98494
  log("");
98355
98495
  } catch (error40) {
@@ -98380,7 +98520,8 @@ projectCmd.command("create <name>").description("Create a new project").option("
98380
98520
  const project = createProject({
98381
98521
  name: name21,
98382
98522
  path: opts.path,
98383
- description: opts.description
98523
+ description: opts.description,
98524
+ scenarioPrefix: opts.prefix
98384
98525
  });
98385
98526
  log(chalk6.green(`Created project ${chalk6.bold(project.name)} (${project.id})`));
98386
98527
  } catch (error40) {
@@ -100687,7 +100828,7 @@ workflowCmd.command("create <name>").description("Save a reusable testing workfl
100687
100828
  }, []).option("--priority <level>", "Scenario priority").option("--persona <ids>", "Comma-separated persona IDs").option("--goal <prompt>", "Goal prompt for the agentic testing loop").option("--success <criteria>", "Success criteria (repeatable)", (val, acc) => {
100688
100829
  acc.push(val);
100689
100830
  return acc;
100690
- }, []).option("--max-iterations <n>", "Goal-loop iteration cap", "10").option("--target <target>", "Execution target: local or sandbox", "local").option("--sandbox-provider <provider>", "Sandbox provider: e2b, daytona, or modal").option("--sandbox-image <image>", "Sandbox image/template").option("--sandbox-remote-dir <path>", "Remote working directory for sandbox runs").option("--sandbox-cleanup <mode>", "Sandbox cleanup mode: delete, stop, or keep", "delete").option("--sandbox-setup-command <command>", "Shell command to run before testers in the sandbox").option("--sandbox-package <spec>", "Package spec to execute in the sandbox", "@hasna/testers").option("--e2b-template <name>", "Legacy alias for --sandbox-image").option("--timeout <ms>", "Workflow timeout").option("--json", "Output as JSON", false).action((name21, opts) => {
100831
+ }, []).option("--max-iterations <n>", "Goal-loop iteration cap", "10").option("--target <target>", "Execution target: local or sandbox", "local").option("--sandbox-provider <provider>", "Sandbox provider: e2b, daytona, or modal").option("--sandbox-image <image>", "Sandbox image/template").option("--sandbox-remote-dir <path>", "Remote working directory for sandbox runs").option("--sandbox-cleanup <mode>", "Sandbox cleanup mode: delete, stop, or keep", "delete").option("--sandbox-sync <strategy>", "Sandbox upload sync strategy: rsync or archive", "rsync").option("--sandbox-setup-command <command>", "Shell command to run before testers in the sandbox").option("--sandbox-package <spec>", "Package spec to execute in the sandbox", "@hasna/testers").option("--e2b-template <name>", "Legacy alias for --sandbox-image").option("--timeout <ms>", "Workflow timeout").option("--json", "Output as JSON", false).action((name21, opts) => {
100691
100832
  try {
100692
100833
  const workflow = createTestingWorkflow({
100693
100834
  name: name21,
@@ -100710,6 +100851,7 @@ workflowCmd.command("create <name>").description("Save a reusable testing workfl
100710
100851
  sandboxImage: opts.sandboxImage ?? opts.e2bTemplate,
100711
100852
  sandboxRemoteDir: opts.sandboxRemoteDir,
100712
100853
  sandboxCleanup: opts.sandboxCleanup,
100854
+ sandboxSyncStrategy: opts.sandboxSync,
100713
100855
  setupCommand: opts.sandboxSetupCommand,
100714
100856
  packageSpec: opts.sandboxPackage,
100715
100857
  timeoutMs: opts.timeout ? parseInt(opts.timeout, 10) : undefined
@@ -100778,7 +100920,7 @@ workflowCmd.command("run <id>").description("Run a saved testing workflow").requ
100778
100920
  timeout: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
100779
100921
  dryRun: opts.dryRun
100780
100922
  });
100781
- if (opts.json || opts.dryRun || output.connectorResult) {
100923
+ if (opts.json || opts.dryRun || output.sandboxResult) {
100782
100924
  log(JSON.stringify(output, null, 2));
100783
100925
  return;
100784
100926
  }
@@ -100789,6 +100931,44 @@ workflowCmd.command("run <id>").description("Run a saved testing workflow").requ
100789
100931
  process.exit(1);
100790
100932
  }
100791
100933
  });
100934
+ workflowCmd.command("fanout [ids...]").description("Run multiple saved sandbox workflows concurrently").requiredOption("-u, --url <url>", "Target URL").option("--project <id>", "Project ID").option("--tag <tag>", "Workflow scenario tag filter (repeatable)", (val, acc) => {
100935
+ acc.push(val);
100936
+ return acc;
100937
+ }, []).option("--all", "Include disabled workflows when selecting by project/tag", false).option("--workers <n>", "Concurrent sandboxes, 1-12 (default: 6)", "6").option("-m, --model <model>", "AI model").option("--headed", "Run headed", false).option("--parallel <n>", "Parallel browser workers inside each sandbox").option("--timeout <ms>", "Override workflow timeout").option("--dry-run", "Print resolved sandbox plans without spawning sandboxes", false).option("--json", "Output as JSON", false).action(async (ids, opts) => {
100938
+ try {
100939
+ const { runWorkflowFanout: runWorkflowFanout2 } = await Promise.resolve().then(() => (init_workflow_fanout(), exports_workflow_fanout));
100940
+ const result = await runWorkflowFanout2({
100941
+ workflowIds: ids,
100942
+ projectId: opts.project ? resolveProject2(opts.project) : undefined,
100943
+ tags: opts.tag,
100944
+ includeDisabled: opts.all,
100945
+ workers: opts.workers ? parseInt(opts.workers, 10) : undefined,
100946
+ url: opts.url,
100947
+ model: opts.model,
100948
+ headed: opts.headed,
100949
+ parallel: opts.parallel ? parseInt(opts.parallel, 10) : undefined,
100950
+ timeout: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
100951
+ dryRun: opts.dryRun
100952
+ });
100953
+ if (opts.json || opts.dryRun) {
100954
+ log(JSON.stringify(result, null, 2));
100955
+ } else {
100956
+ const status = result.status === "passed" ? chalk6.green("passed") : chalk6.red("failed");
100957
+ log(chalk6.bold(`Sandbox workflow fanout ${status}: ${result.passed}/${result.total} passed with ${result.workers} worker(s)`));
100958
+ for (const item of result.items) {
100959
+ const itemStatus = item.status === "passed" ? chalk6.green(item.status) : chalk6.red(item.status);
100960
+ const sandbox = item.sandboxId ? chalk6.dim(` sandbox=${item.sandboxId.slice(0, 8)}`) : "";
100961
+ const error40 = item.error ? chalk6.dim(` ${item.error}`) : "";
100962
+ log(` ${itemStatus} ${item.workflowName}${sandbox}${error40}`);
100963
+ }
100964
+ }
100965
+ if (result.status === "failed")
100966
+ process.exit(1);
100967
+ } catch (error40) {
100968
+ logError(chalk6.red(`Error: ${error40 instanceof Error ? error40.message : String(error40)}`));
100969
+ process.exit(1);
100970
+ }
100971
+ });
100792
100972
  workflowCmd.command("agent <id>").description("Run a saved workflow as a goal loop and ask the app AI to create open-todos next actions").requiredOption("-u, --url <url>", "Target URL").option("-m, --model <model>", "AI SDK model ID for planning").option("--headed", "Run headed", false).option("--parallel <n>", "Parallel workers").option("--dry-run", "Resolve the loop without launching browsers", false).option("--json", "Output as JSON", false).action(async (id, opts) => {
100793
100973
  try {
100794
100974
  const { runWorkflowGoalLoop: runWorkflowGoalLoop2 } = await Promise.resolve().then(() => (init_workflow_agent(), exports_workflow_agent));