@hasna/testers 0.0.45 → 0.0.46

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
@@ -52,7 +52,7 @@ var package_default;
52
52
  var init_package = __esm(() => {
53
53
  package_default = {
54
54
  name: "@hasna/testers",
55
- version: "0.0.45",
55
+ version: "0.0.46",
56
56
  description: "AI-powered QA testing CLI \u2014 spawns cheap AI agents to test web apps with headless browsers",
57
57
  type: "module",
58
58
  main: "dist/index.js",
@@ -14178,6 +14178,12 @@ function workflowExecutionFromValue(value) {
14178
14178
  const sandboxSyncStrategy = syncStrategyValue(input["sandboxSyncStrategy"]);
14179
14179
  const setupCommand = stringValue(input["setupCommand"]);
14180
14180
  const packageSpec = stringValue(input["packageSpec"]);
14181
+ const appSourceDir = stringValue(input["appSourceDir"]);
14182
+ const appRemoteDir = stringValue(input["appRemoteDir"]);
14183
+ const appStartCommand = stringValue(input["appStartCommand"]);
14184
+ const appUrl = stringValue(input["appUrl"]);
14185
+ const appWaitUrl = stringValue(input["appWaitUrl"]);
14186
+ const appWaitTimeoutMs = numberValue(input["appWaitTimeoutMs"]);
14181
14187
  const timeoutMs = numberValue(input["timeoutMs"]);
14182
14188
  const env = stringMap(input["env"]);
14183
14189
  return {
@@ -14189,6 +14195,12 @@ function workflowExecutionFromValue(value) {
14189
14195
  ...sandboxSyncStrategy ? { sandboxSyncStrategy } : {},
14190
14196
  ...setupCommand ? { setupCommand } : {},
14191
14197
  ...packageSpec ? { packageSpec } : {},
14198
+ ...appSourceDir ? { appSourceDir } : {},
14199
+ ...appRemoteDir ? { appRemoteDir } : {},
14200
+ ...appStartCommand ? { appStartCommand } : {},
14201
+ ...appUrl ? { appUrl } : {},
14202
+ ...appWaitUrl ? { appWaitUrl } : {},
14203
+ ...appWaitTimeoutMs !== undefined ? { appWaitTimeoutMs } : {},
14192
14204
  ...timeoutMs !== undefined ? { timeoutMs } : {},
14193
14205
  ...env ? { env } : {}
14194
14206
  };
@@ -23488,9 +23500,10 @@ var init_workflows = __esm(() => {
23488
23500
  });
23489
23501
 
23490
23502
  // src/lib/workflow-runner.ts
23491
- import { mkdtempSync, rmSync, writeFileSync as writeFileSync3 } from "fs";
23503
+ import { spawnSync } from "child_process";
23504
+ import { existsSync as existsSync11, mkdirSync as mkdirSync9, mkdtempSync, rmSync, statSync } from "fs";
23492
23505
  import { tmpdir } from "os";
23493
- import { join as join14 } from "path";
23506
+ import { join as join14, posix as pathPosix } from "path";
23494
23507
  function buildWorkflowRunPlan(workflow, options) {
23495
23508
  const runOptions = {
23496
23509
  url: options.url,
@@ -23532,10 +23545,16 @@ function createWorkflowDatabaseBundle(workflow, plan) {
23532
23545
  if (!plan.sandbox)
23533
23546
  throw new Error(`Workflow is not configured for sandbox execution: ${workflow.name}`);
23534
23547
  const localDir = mkdtempSync(join14(tmpdir(), `testers-workflow-${workflow.id.slice(0, 8)}-`));
23535
- writeFileSync3(join14(localDir, "testers.db"), getDatabase().serialize());
23548
+ const stateDir = join14(localDir, ".testers-state");
23549
+ mkdirSync9(stateDir, { recursive: true });
23550
+ writeDatabaseSnapshot(join14(stateDir, "testers.db"));
23551
+ if (plan.sandbox.appSourceDir && plan.sandbox.appRemoteDir) {
23552
+ const relativeAppDir = relativeRemotePath(plan.sandbox.remoteDir, plan.sandbox.appRemoteDir);
23553
+ copyAppSource(plan.sandbox.appSourceDir, join14(localDir, relativeAppDir));
23554
+ }
23536
23555
  return {
23537
23556
  localDir,
23538
- remoteDir: plan.sandbox.stateRemoteDir,
23557
+ remoteDir: plan.sandbox.remoteDir,
23539
23558
  cleanup: () => rmSync(localDir, { recursive: true, force: true })
23540
23559
  };
23541
23560
  }
@@ -23546,15 +23565,63 @@ function validatePersonaIds(workflow) {
23546
23565
  }
23547
23566
  }
23548
23567
  }
23568
+ function relativeRemotePath(remoteDir, remoteChildDir) {
23569
+ if (!remoteChildDir.startsWith("/")) {
23570
+ const relative3 = remoteChildDir.replace(/^\/+/, "").replace(/\/+$/, "");
23571
+ if (!relative3 || relative3 === ".") {
23572
+ throw new Error("Sandbox app remote directory must be a child directory, not the workflow root");
23573
+ }
23574
+ return relative3;
23575
+ }
23576
+ const base = remoteDir.replace(/\/+$/, "") || "/";
23577
+ const child = remoteChildDir.replace(/\/+$/, "") || "/";
23578
+ const relative2 = pathPosix.relative(base, child);
23579
+ if (!relative2 || relative2 === "." || relative2.startsWith("..") || pathPosix.isAbsolute(relative2)) {
23580
+ throw new Error(`Sandbox app remote directory must be inside the workflow remote directory (${remoteDir}): ${remoteChildDir}`);
23581
+ }
23582
+ return relative2;
23583
+ }
23584
+ function copyAppSource(sourceDir, targetDir) {
23585
+ if (!existsSync11(sourceDir) || !statSync(sourceDir).isDirectory()) {
23586
+ throw new Error(`Sandbox app source directory does not exist or is not a directory: ${sourceDir}`);
23587
+ }
23588
+ mkdirSync9(targetDir, { recursive: true });
23589
+ const result = spawnSync("rsync", [
23590
+ "-a",
23591
+ "--delete",
23592
+ ...APP_SOURCE_EXCLUDES.flatMap((item) => ["--exclude", item]),
23593
+ `${sourceDir.replace(/\/+$/, "")}/`,
23594
+ `${targetDir.replace(/\/+$/, "")}/`
23595
+ ], { encoding: "utf8" });
23596
+ if (result.error) {
23597
+ throw new Error(`Failed to rsync sandbox app source: ${result.error.message}`);
23598
+ }
23599
+ if (result.status !== 0) {
23600
+ throw new Error(`Failed to rsync sandbox app source (${result.status}): ${result.stderr.trim()}`);
23601
+ }
23602
+ }
23603
+ function writeDatabaseSnapshot(targetPath) {
23604
+ getDatabase().exec(`VACUUM INTO ${sqlString(targetPath)}`);
23605
+ }
23606
+ function sqlString(value) {
23607
+ return `'${value.replaceAll("'", "''")}'`;
23608
+ }
23549
23609
  function buildSandboxPlan(workflow, execution, runOptions) {
23550
23610
  const remoteDir = execution.sandboxRemoteDir ?? `/tmp/testers-workflow-${workflow.id.slice(0, 8)}`;
23551
23611
  const stateRemoteDir = `${remoteDir.replace(/\/+$/, "")}/.testers-state`;
23612
+ const appRemoteDir = execution.appSourceDir ? execution.appRemoteDir ?? `${remoteDir.replace(/\/+$/, "")}/app` : execution.appRemoteDir;
23552
23613
  return {
23553
23614
  provider: execution.provider,
23554
23615
  image: execution.sandboxImage,
23555
23616
  name: `testers-${workflow.id.slice(0, 8)}`,
23556
23617
  remoteDir,
23557
23618
  stateRemoteDir,
23619
+ ...execution.appSourceDir ? { appSourceDir: execution.appSourceDir } : {},
23620
+ ...appRemoteDir ? { appRemoteDir } : {},
23621
+ ...execution.appStartCommand ? { appStartCommand: execution.appStartCommand } : {},
23622
+ ...execution.appUrl ? { appUrl: execution.appUrl } : {},
23623
+ ...execution.appWaitUrl ? { appWaitUrl: execution.appWaitUrl } : {},
23624
+ ...execution.appWaitTimeoutMs !== undefined ? { appWaitTimeoutMs: execution.appWaitTimeoutMs } : {},
23558
23625
  cleanup: execution.sandboxCleanup ?? "delete",
23559
23626
  syncStrategy: execution.sandboxSyncStrategy ?? "rsync",
23560
23627
  timeoutMs: execution.timeoutMs,
@@ -23562,6 +23629,12 @@ function buildSandboxPlan(workflow, execution, runOptions) {
23562
23629
  command: buildSandboxCommand({
23563
23630
  runOptions,
23564
23631
  remoteDir,
23632
+ stateRemoteDir,
23633
+ appRemoteDir,
23634
+ appStartCommand: execution.appStartCommand,
23635
+ appUrl: execution.appUrl,
23636
+ appWaitUrl: execution.appWaitUrl,
23637
+ appWaitTimeoutMs: execution.appWaitTimeoutMs,
23565
23638
  dbPath: `${stateRemoteDir}/testers.db`,
23566
23639
  setupCommand: execution.setupCommand,
23567
23640
  packageSpec: execution.packageSpec ?? "@hasna/testers"
@@ -23569,11 +23642,12 @@ function buildSandboxPlan(workflow, execution, runOptions) {
23569
23642
  };
23570
23643
  }
23571
23644
  function buildSandboxCommand(input) {
23645
+ const targetUrl = input.appUrl ?? input.runOptions.url;
23572
23646
  const args = [
23573
23647
  "bunx",
23574
23648
  input.packageSpec,
23575
23649
  "run",
23576
- input.runOptions.url,
23650
+ targetUrl,
23577
23651
  ...input.runOptions.scenarioIds?.length ? ["--scenario", input.runOptions.scenarioIds.join(",")] : [],
23578
23652
  ...input.runOptions.tags?.length ? input.runOptions.tags.flatMap((tag) => ["--tag", tag]) : [],
23579
23653
  ...input.runOptions.priority ? ["--priority", input.runOptions.priority] : [],
@@ -23589,12 +23663,46 @@ function buildSandboxCommand(input) {
23589
23663
  return [
23590
23664
  "set -euo pipefail",
23591
23665
  `mkdir -p ${shellQuote(input.remoteDir)}`,
23592
- `cd ${shellQuote(input.remoteDir)}`,
23666
+ `mkdir -p ${shellQuote(input.stateRemoteDir)}`,
23667
+ input.appRemoteDir ? `mkdir -p ${shellQuote(input.appRemoteDir)}` : undefined,
23668
+ `cd ${shellQuote(input.appRemoteDir ?? input.remoteDir)}`,
23593
23669
  input.setupCommand,
23670
+ buildAppStartCommand(input),
23594
23671
  `HASNA_TESTERS_DB_PATH=${shellQuote(input.dbPath)} ${args.map(shellQuote).join(" ")}`
23595
23672
  ].filter(Boolean).join(`
23596
23673
  `);
23597
23674
  }
23675
+ function buildAppStartCommand(input) {
23676
+ if (!input.appStartCommand)
23677
+ return;
23678
+ const waitUrl = input.appWaitUrl ?? input.appUrl ?? input.runOptions.url;
23679
+ const waitTimeoutMs = input.appWaitTimeoutMs ?? 120000;
23680
+ return [
23681
+ `( ${input.appStartCommand} ) > ${shellQuote(`${input.stateRemoteDir}/app.log`)} 2>&1 &`,
23682
+ "APP_PID=$!",
23683
+ `echo "$APP_PID" > ${shellQuote(`${input.stateRemoteDir}/app.pid`)}`,
23684
+ `trap 'kill "$APP_PID" 2>/dev/null || true' EXIT`,
23685
+ waitUrl ? buildWaitForUrlCommand(waitUrl, waitTimeoutMs) : undefined
23686
+ ].filter(Boolean).join(`
23687
+ `);
23688
+ }
23689
+ function buildWaitForUrlCommand(url, timeoutMs) {
23690
+ const script = `
23691
+ const url = ${JSON.stringify(url)};
23692
+ const timeoutMs = ${JSON.stringify(timeoutMs)};
23693
+ const deadline = Date.now() + timeoutMs;
23694
+ while (Date.now() <= deadline) {
23695
+ try {
23696
+ const response = await fetch(url);
23697
+ if (response.status >= 200 && response.status < 500) process.exit(0);
23698
+ } catch {}
23699
+ await new Promise((resolve) => setTimeout(resolve, 1000));
23700
+ }
23701
+ console.error(\`Timed out waiting for \${url} after \${timeoutMs}ms\`);
23702
+ process.exit(1);
23703
+ `.trim();
23704
+ return `bun -e ${shellQuote(script)}`;
23705
+ }
23598
23706
  async function runViaSandbox(plan, dependencies) {
23599
23707
  if (!plan.sandbox)
23600
23708
  throw new Error("Workflow does not have a sandbox plan");
@@ -23652,11 +23760,22 @@ async function resolveSandboxesRuntime(dependencies) {
23652
23760
  function shellQuote(value) {
23653
23761
  return `'${value.replaceAll("'", `'"'"'`)}'`;
23654
23762
  }
23763
+ var APP_SOURCE_EXCLUDES;
23655
23764
  var init_workflow_runner = __esm(() => {
23656
23765
  init_database();
23657
23766
  init_workflows();
23658
23767
  init_personas();
23659
23768
  init_runner();
23769
+ APP_SOURCE_EXCLUDES = [
23770
+ "node_modules",
23771
+ ".git",
23772
+ "dist",
23773
+ ".next",
23774
+ ".turbo",
23775
+ ".cache",
23776
+ ".venv",
23777
+ "__pycache__"
23778
+ ];
23660
23779
  });
23661
23780
 
23662
23781
  // node_modules/@ai-sdk/provider/dist/index.mjs
@@ -53711,10 +53830,10 @@ import { exec } from "child_process";
53711
53830
  import { promisify } from "util";
53712
53831
  import { readFileSync as readFileSync3 } from "fs";
53713
53832
  import { webcrypto as crypto2 } from "crypto";
53714
- import { existsSync as existsSync42, writeFileSync as writeFileSync32, readFileSync as readFileSync22, mkdirSync as mkdirSync32 } from "fs";
53833
+ import { existsSync as existsSync42, writeFileSync as writeFileSync3, readFileSync as readFileSync22, mkdirSync as mkdirSync32 } from "fs";
53715
53834
  import { join as join42 } from "path";
53716
53835
  import { Database as Database4 } from "bun:sqlite";
53717
- import { existsSync as existsSync11, mkdirSync as mkdirSync9 } from "fs";
53836
+ import { existsSync as existsSync12, mkdirSync as mkdirSync10 } from "fs";
53718
53837
  import { dirname as dirname4, join as join15, resolve as resolve2 } from "path";
53719
53838
  import { existsSync as existsSync22, writeFileSync as writeFileSync4 } from "fs";
53720
53839
  import { join as join22 } from "path";
@@ -53728,10 +53847,10 @@ import * as zlib from "zlib";
53728
53847
  import { Readable } from "stream";
53729
53848
  import { Writable } from "stream";
53730
53849
  import { createHash as createHash2 } from "crypto";
53731
- import { mkdirSync as mkdirSync4, statSync, writeFileSync as writeFileSync42 } from "fs";
53850
+ import { mkdirSync as mkdirSync4, statSync as statSync2, writeFileSync as writeFileSync42 } from "fs";
53732
53851
  import { dirname as dirname42, join as join62, relative as relative2 } from "path";
53733
53852
  import { readdir, readFile as readFile2 } from "fs/promises";
53734
- import { existsSync as existsSync62, readdirSync as readdirSync3, readFileSync as readFileSync42, statSync as statSync2 } from "fs";
53853
+ import { existsSync as existsSync62, readdirSync as readdirSync3, readFileSync as readFileSync42, statSync as statSync22 } from "fs";
53735
53854
  import { basename, join as join72, resolve as resolve22 } from "path";
53736
53855
  import { execFileSync as execFileSync2 } from "child_process";
53737
53856
  import { existsSync as existsSync72, readFileSync as readFileSync5, writeFileSync as writeFileSync5, mkdirSync as mkdirSync52 } from "fs";
@@ -53896,8 +54015,8 @@ function ensureDir2(filePath) {
53896
54015
  if (filePath === ":memory:")
53897
54016
  return;
53898
54017
  const dir = dirname4(resolve2(filePath));
53899
- if (!existsSync11(dir)) {
53900
- mkdirSync9(dir, { recursive: true });
54018
+ if (!existsSync12(dir)) {
54019
+ mkdirSync10(dir, { recursive: true });
53901
54020
  }
53902
54021
  }
53903
54022
  function getDatabase2(path) {
@@ -54178,7 +54297,7 @@ function setIntegrations(id, integrations, db2) {
54178
54297
  const jsonPath = join42(project.path, ".project.json");
54179
54298
  if (existsSync42(jsonPath)) {
54180
54299
  const existing = JSON.parse(readFileSync22(jsonPath, "utf-8"));
54181
- writeFileSync32(jsonPath, JSON.stringify({ ...existing, integrations: merged }, null, 2) + `
54300
+ writeFileSync3(jsonPath, JSON.stringify({ ...existing, integrations: merged }, null, 2) + `
54182
54301
  `, "utf-8");
54183
54302
  }
54184
54303
  } catch {}
@@ -55256,7 +55375,7 @@ async function collectLocalFiles(rootPath) {
55256
55375
  if (entry.isDirectory()) {
55257
55376
  await walk(fullPath);
55258
55377
  } else if (entry.isFile()) {
55259
- const stat = statSync(fullPath);
55378
+ const stat = statSync2(fullPath);
55260
55379
  if (stat.size > MAX_FILE_SIZE)
55261
55380
  continue;
55262
55381
  const relPath = relative2(rootPath, fullPath);
@@ -55421,7 +55540,7 @@ async function importProject(projectPath, options = {}) {
55421
55540
  if (!existsSync62(absPath)) {
55422
55541
  return { error: `Path does not exist: ${absPath}` };
55423
55542
  }
55424
- const stat = statSync2(absPath);
55543
+ const stat = statSync22(absPath);
55425
55544
  if (!stat.isDirectory()) {
55426
55545
  return { error: `Not a directory: ${absPath}` };
55427
55546
  }
@@ -4078,6 +4078,12 @@ function workflowExecutionFromValue(value) {
4078
4078
  const sandboxSyncStrategy = syncStrategyValue(input["sandboxSyncStrategy"]);
4079
4079
  const setupCommand = stringValue(input["setupCommand"]);
4080
4080
  const packageSpec = stringValue(input["packageSpec"]);
4081
+ const appSourceDir = stringValue(input["appSourceDir"]);
4082
+ const appRemoteDir = stringValue(input["appRemoteDir"]);
4083
+ const appStartCommand = stringValue(input["appStartCommand"]);
4084
+ const appUrl = stringValue(input["appUrl"]);
4085
+ const appWaitUrl = stringValue(input["appWaitUrl"]);
4086
+ const appWaitTimeoutMs = numberValue(input["appWaitTimeoutMs"]);
4081
4087
  const timeoutMs = numberValue(input["timeoutMs"]);
4082
4088
  const env = stringMap(input["env"]);
4083
4089
  return {
@@ -4089,6 +4095,12 @@ function workflowExecutionFromValue(value) {
4089
4095
  ...sandboxSyncStrategy ? { sandboxSyncStrategy } : {},
4090
4096
  ...setupCommand ? { setupCommand } : {},
4091
4097
  ...packageSpec ? { packageSpec } : {},
4098
+ ...appSourceDir ? { appSourceDir } : {},
4099
+ ...appRemoteDir ? { appRemoteDir } : {},
4100
+ ...appStartCommand ? { appStartCommand } : {},
4101
+ ...appUrl ? { appUrl } : {},
4102
+ ...appWaitUrl ? { appWaitUrl } : {},
4103
+ ...appWaitTimeoutMs !== undefined ? { appWaitTimeoutMs } : {},
4092
4104
  ...timeoutMs !== undefined ? { timeoutMs } : {},
4093
4105
  ...env ? { env } : {}
4094
4106
  };
@@ -46920,12 +46932,12 @@ var init_scan_issues = __esm(() => {
46920
46932
  });
46921
46933
 
46922
46934
  // src/server/index.ts
46923
- import { existsSync as existsSync10 } from "fs";
46935
+ import { existsSync as existsSync11 } from "fs";
46924
46936
  import { join as join14 } from "path";
46925
46937
  // package.json
46926
46938
  var package_default = {
46927
46939
  name: "@hasna/testers",
46928
- version: "0.0.45",
46940
+ version: "0.0.46",
46929
46941
  description: "AI-powered QA testing CLI \u2014 spawns cheap AI agents to test web apps with headless browsers",
46930
46942
  type: "module",
46931
46943
  main: "dist/index.js",
@@ -51316,9 +51328,20 @@ function db2() {
51316
51328
 
51317
51329
  // src/lib/workflow-runner.ts
51318
51330
  init_database();
51319
- import { mkdtempSync, rmSync, writeFileSync as writeFileSync3 } from "fs";
51331
+ import { spawnSync } from "child_process";
51332
+ import { existsSync as existsSync10, mkdirSync as mkdirSync7, mkdtempSync, rmSync, statSync } from "fs";
51320
51333
  import { tmpdir } from "os";
51321
- import { join as join13 } from "path";
51334
+ import { join as join13, posix as pathPosix } from "path";
51335
+ var APP_SOURCE_EXCLUDES = [
51336
+ "node_modules",
51337
+ ".git",
51338
+ "dist",
51339
+ ".next",
51340
+ ".turbo",
51341
+ ".cache",
51342
+ ".venv",
51343
+ "__pycache__"
51344
+ ];
51322
51345
  function buildWorkflowRunPlan(workflow, options) {
51323
51346
  const runOptions = {
51324
51347
  url: options.url,
@@ -51360,10 +51383,16 @@ function createWorkflowDatabaseBundle(workflow, plan) {
51360
51383
  if (!plan.sandbox)
51361
51384
  throw new Error(`Workflow is not configured for sandbox execution: ${workflow.name}`);
51362
51385
  const localDir = mkdtempSync(join13(tmpdir(), `testers-workflow-${workflow.id.slice(0, 8)}-`));
51363
- writeFileSync3(join13(localDir, "testers.db"), getDatabase().serialize());
51386
+ const stateDir = join13(localDir, ".testers-state");
51387
+ mkdirSync7(stateDir, { recursive: true });
51388
+ writeDatabaseSnapshot(join13(stateDir, "testers.db"));
51389
+ if (plan.sandbox.appSourceDir && plan.sandbox.appRemoteDir) {
51390
+ const relativeAppDir = relativeRemotePath(plan.sandbox.remoteDir, plan.sandbox.appRemoteDir);
51391
+ copyAppSource(plan.sandbox.appSourceDir, join13(localDir, relativeAppDir));
51392
+ }
51364
51393
  return {
51365
51394
  localDir,
51366
- remoteDir: plan.sandbox.stateRemoteDir,
51395
+ remoteDir: plan.sandbox.remoteDir,
51367
51396
  cleanup: () => rmSync(localDir, { recursive: true, force: true })
51368
51397
  };
51369
51398
  }
@@ -51374,15 +51403,63 @@ function validatePersonaIds(workflow) {
51374
51403
  }
51375
51404
  }
51376
51405
  }
51406
+ function relativeRemotePath(remoteDir, remoteChildDir) {
51407
+ if (!remoteChildDir.startsWith("/")) {
51408
+ const relative3 = remoteChildDir.replace(/^\/+/, "").replace(/\/+$/, "");
51409
+ if (!relative3 || relative3 === ".") {
51410
+ throw new Error("Sandbox app remote directory must be a child directory, not the workflow root");
51411
+ }
51412
+ return relative3;
51413
+ }
51414
+ const base = remoteDir.replace(/\/+$/, "") || "/";
51415
+ const child = remoteChildDir.replace(/\/+$/, "") || "/";
51416
+ const relative2 = pathPosix.relative(base, child);
51417
+ if (!relative2 || relative2 === "." || relative2.startsWith("..") || pathPosix.isAbsolute(relative2)) {
51418
+ throw new Error(`Sandbox app remote directory must be inside the workflow remote directory (${remoteDir}): ${remoteChildDir}`);
51419
+ }
51420
+ return relative2;
51421
+ }
51422
+ function copyAppSource(sourceDir, targetDir) {
51423
+ if (!existsSync10(sourceDir) || !statSync(sourceDir).isDirectory()) {
51424
+ throw new Error(`Sandbox app source directory does not exist or is not a directory: ${sourceDir}`);
51425
+ }
51426
+ mkdirSync7(targetDir, { recursive: true });
51427
+ const result = spawnSync("rsync", [
51428
+ "-a",
51429
+ "--delete",
51430
+ ...APP_SOURCE_EXCLUDES.flatMap((item) => ["--exclude", item]),
51431
+ `${sourceDir.replace(/\/+$/, "")}/`,
51432
+ `${targetDir.replace(/\/+$/, "")}/`
51433
+ ], { encoding: "utf8" });
51434
+ if (result.error) {
51435
+ throw new Error(`Failed to rsync sandbox app source: ${result.error.message}`);
51436
+ }
51437
+ if (result.status !== 0) {
51438
+ throw new Error(`Failed to rsync sandbox app source (${result.status}): ${result.stderr.trim()}`);
51439
+ }
51440
+ }
51441
+ function writeDatabaseSnapshot(targetPath) {
51442
+ getDatabase().exec(`VACUUM INTO ${sqlString(targetPath)}`);
51443
+ }
51444
+ function sqlString(value) {
51445
+ return `'${value.replaceAll("'", "''")}'`;
51446
+ }
51377
51447
  function buildSandboxPlan(workflow, execution, runOptions) {
51378
51448
  const remoteDir = execution.sandboxRemoteDir ?? `/tmp/testers-workflow-${workflow.id.slice(0, 8)}`;
51379
51449
  const stateRemoteDir = `${remoteDir.replace(/\/+$/, "")}/.testers-state`;
51450
+ const appRemoteDir = execution.appSourceDir ? execution.appRemoteDir ?? `${remoteDir.replace(/\/+$/, "")}/app` : execution.appRemoteDir;
51380
51451
  return {
51381
51452
  provider: execution.provider,
51382
51453
  image: execution.sandboxImage,
51383
51454
  name: `testers-${workflow.id.slice(0, 8)}`,
51384
51455
  remoteDir,
51385
51456
  stateRemoteDir,
51457
+ ...execution.appSourceDir ? { appSourceDir: execution.appSourceDir } : {},
51458
+ ...appRemoteDir ? { appRemoteDir } : {},
51459
+ ...execution.appStartCommand ? { appStartCommand: execution.appStartCommand } : {},
51460
+ ...execution.appUrl ? { appUrl: execution.appUrl } : {},
51461
+ ...execution.appWaitUrl ? { appWaitUrl: execution.appWaitUrl } : {},
51462
+ ...execution.appWaitTimeoutMs !== undefined ? { appWaitTimeoutMs: execution.appWaitTimeoutMs } : {},
51386
51463
  cleanup: execution.sandboxCleanup ?? "delete",
51387
51464
  syncStrategy: execution.sandboxSyncStrategy ?? "rsync",
51388
51465
  timeoutMs: execution.timeoutMs,
@@ -51390,6 +51467,12 @@ function buildSandboxPlan(workflow, execution, runOptions) {
51390
51467
  command: buildSandboxCommand({
51391
51468
  runOptions,
51392
51469
  remoteDir,
51470
+ stateRemoteDir,
51471
+ appRemoteDir,
51472
+ appStartCommand: execution.appStartCommand,
51473
+ appUrl: execution.appUrl,
51474
+ appWaitUrl: execution.appWaitUrl,
51475
+ appWaitTimeoutMs: execution.appWaitTimeoutMs,
51393
51476
  dbPath: `${stateRemoteDir}/testers.db`,
51394
51477
  setupCommand: execution.setupCommand,
51395
51478
  packageSpec: execution.packageSpec ?? "@hasna/testers"
@@ -51397,11 +51480,12 @@ function buildSandboxPlan(workflow, execution, runOptions) {
51397
51480
  };
51398
51481
  }
51399
51482
  function buildSandboxCommand(input) {
51483
+ const targetUrl = input.appUrl ?? input.runOptions.url;
51400
51484
  const args = [
51401
51485
  "bunx",
51402
51486
  input.packageSpec,
51403
51487
  "run",
51404
- input.runOptions.url,
51488
+ targetUrl,
51405
51489
  ...input.runOptions.scenarioIds?.length ? ["--scenario", input.runOptions.scenarioIds.join(",")] : [],
51406
51490
  ...input.runOptions.tags?.length ? input.runOptions.tags.flatMap((tag) => ["--tag", tag]) : [],
51407
51491
  ...input.runOptions.priority ? ["--priority", input.runOptions.priority] : [],
@@ -51417,12 +51501,46 @@ function buildSandboxCommand(input) {
51417
51501
  return [
51418
51502
  "set -euo pipefail",
51419
51503
  `mkdir -p ${shellQuote(input.remoteDir)}`,
51420
- `cd ${shellQuote(input.remoteDir)}`,
51504
+ `mkdir -p ${shellQuote(input.stateRemoteDir)}`,
51505
+ input.appRemoteDir ? `mkdir -p ${shellQuote(input.appRemoteDir)}` : undefined,
51506
+ `cd ${shellQuote(input.appRemoteDir ?? input.remoteDir)}`,
51421
51507
  input.setupCommand,
51508
+ buildAppStartCommand(input),
51422
51509
  `HASNA_TESTERS_DB_PATH=${shellQuote(input.dbPath)} ${args.map(shellQuote).join(" ")}`
51423
51510
  ].filter(Boolean).join(`
51424
51511
  `);
51425
51512
  }
51513
+ function buildAppStartCommand(input) {
51514
+ if (!input.appStartCommand)
51515
+ return;
51516
+ const waitUrl = input.appWaitUrl ?? input.appUrl ?? input.runOptions.url;
51517
+ const waitTimeoutMs = input.appWaitTimeoutMs ?? 120000;
51518
+ return [
51519
+ `( ${input.appStartCommand} ) > ${shellQuote(`${input.stateRemoteDir}/app.log`)} 2>&1 &`,
51520
+ "APP_PID=$!",
51521
+ `echo "$APP_PID" > ${shellQuote(`${input.stateRemoteDir}/app.pid`)}`,
51522
+ `trap 'kill "$APP_PID" 2>/dev/null || true' EXIT`,
51523
+ waitUrl ? buildWaitForUrlCommand(waitUrl, waitTimeoutMs) : undefined
51524
+ ].filter(Boolean).join(`
51525
+ `);
51526
+ }
51527
+ function buildWaitForUrlCommand(url, timeoutMs) {
51528
+ const script = `
51529
+ const url = ${JSON.stringify(url)};
51530
+ const timeoutMs = ${JSON.stringify(timeoutMs)};
51531
+ const deadline = Date.now() + timeoutMs;
51532
+ while (Date.now() <= deadline) {
51533
+ try {
51534
+ const response = await fetch(url);
51535
+ if (response.status >= 200 && response.status < 500) process.exit(0);
51536
+ } catch {}
51537
+ await new Promise((resolve) => setTimeout(resolve, 1000));
51538
+ }
51539
+ console.error(\`Timed out waiting for \${url} after \${timeoutMs}ms\`);
51540
+ process.exit(1);
51541
+ `.trim();
51542
+ return `bun -e ${shellQuote(script)}`;
51543
+ }
51426
51544
  async function runViaSandbox(plan, dependencies) {
51427
51545
  if (!plan.sandbox)
51428
51546
  throw new Error("Workflow does not have a sandbox plan");
@@ -52203,7 +52321,7 @@ async function handleRequest(req) {
52203
52321
  const screenshot = getScreenshot(id);
52204
52322
  if (!screenshot)
52205
52323
  return errorResponse("Screenshot not found", 404);
52206
- if (!existsSync10(screenshot.filePath)) {
52324
+ if (!existsSync11(screenshot.filePath)) {
52207
52325
  return errorResponse("Screenshot file not found on disk", 404);
52208
52326
  }
52209
52327
  const file2 = Bun.file(screenshot.filePath);
@@ -52652,7 +52770,7 @@ async function handleRequest(req) {
52652
52770
  }
52653
52771
  if (!pathname.startsWith("/api")) {
52654
52772
  const dashboardDir = join14(import.meta.dir, "..", "..", "dashboard", "dist");
52655
- if (!existsSync10(dashboardDir)) {
52773
+ if (!existsSync11(dashboardDir)) {
52656
52774
  return new Response(`<!DOCTYPE html>
52657
52775
  <html>
52658
52776
  <head><title>Open Testers</title></head>
@@ -52671,7 +52789,7 @@ async function handleRequest(req) {
52671
52789
  });
52672
52790
  }
52673
52791
  const filePath = join14(dashboardDir, pathname === "/" ? "index.html" : pathname);
52674
- if (existsSync10(filePath)) {
52792
+ if (existsSync11(filePath)) {
52675
52793
  const file2 = Bun.file(filePath);
52676
52794
  return new Response(file2, {
52677
52795
  headers: {
@@ -52681,7 +52799,7 @@ async function handleRequest(req) {
52681
52799
  });
52682
52800
  }
52683
52801
  const indexPath = join14(dashboardDir, "index.html");
52684
- if (existsSync10(indexPath)) {
52802
+ if (existsSync11(indexPath)) {
52685
52803
  const file2 = Bun.file(indexPath);
52686
52804
  return new Response(file2, {
52687
52805
  headers: {
@@ -397,6 +397,12 @@ export interface WorkflowExecutionConfig {
397
397
  sandboxSyncStrategy?: WorkflowSandboxSyncStrategy;
398
398
  setupCommand?: string;
399
399
  packageSpec?: string;
400
+ appSourceDir?: string;
401
+ appRemoteDir?: string;
402
+ appStartCommand?: string;
403
+ appUrl?: string;
404
+ appWaitUrl?: string;
405
+ appWaitTimeoutMs?: number;
400
406
  timeoutMs?: number;
401
407
  env?: Record<string, string>;
402
408
  }
@@ -412,6 +418,12 @@ export interface WorkflowExecutionInput {
412
418
  sandboxSyncStrategy?: WorkflowSandboxSyncStrategy;
413
419
  setupCommand?: string;
414
420
  packageSpec?: string;
421
+ appSourceDir?: string;
422
+ appRemoteDir?: string;
423
+ appStartCommand?: string;
424
+ appUrl?: string;
425
+ appWaitUrl?: string;
426
+ appWaitTimeoutMs?: number;
415
427
  timeoutMs?: number;
416
428
  env?: Record<string, string>;
417
429
  }