@hasna/testers 0.0.50 → 0.0.52

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/README.md CHANGED
@@ -41,7 +41,7 @@ testers workflow fanout --project alumia --workers 6 --url https://preview.examp
41
41
  testers workflow fanout wf_abc,wf_def wf_xyz --workers 12 --url https://preview.example.com --json
42
42
  ```
43
43
 
44
- `--workers` is bounded to 1-12 concurrent sandboxes. Use `--dry-run` to inspect the remote commands and upload plans without spawning sandboxes.
44
+ `--workers` is bounded to 1-12 concurrent sandboxes. Fanout preflights provider credentials, required sandbox environment references, `rsync`, and app source directories before launching workers. Use `--dry-run` to inspect the remote commands, upload plans, and preflight checks without spawning sandboxes.
45
45
 
46
46
  ### Common Flags
47
47
 
package/dist/cli/index.js CHANGED
@@ -12394,6 +12394,18 @@ __export(exports_scenarios, {
12394
12394
  createScenario: () => createScenario,
12395
12395
  countScenarios: () => countScenarios
12396
12396
  });
12397
+ function stableJson(value) {
12398
+ if (value === undefined)
12399
+ return "";
12400
+ if (value === null)
12401
+ return "null";
12402
+ if (Array.isArray(value))
12403
+ return `[${value.map(stableJson).join(",")}]`;
12404
+ if (typeof value === "object") {
12405
+ return `{${Object.entries(value).filter(([, val]) => val !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([key, val]) => `${JSON.stringify(key)}:${stableJson(val)}`).join(",")}}`;
12406
+ }
12407
+ return JSON.stringify(value);
12408
+ }
12397
12409
  function nextShortId(projectId) {
12398
12410
  const db2 = getDatabase();
12399
12411
  if (projectId) {
@@ -12653,9 +12665,12 @@ function upsertScenario(input) {
12653
12665
  }
12654
12666
  const existingSteps = JSON.parse(existing.steps);
12655
12667
  const existingTags = JSON.parse(existing.tags);
12668
+ const existingMetadata = existing.metadata ? JSON.parse(existing.metadata) : undefined;
12669
+ const existingAssertions = JSON.parse(existing.assertions || "[]");
12670
+ const existingParameters = existing.parameters ? JSON.parse(existing.parameters) : undefined;
12656
12671
  const newSteps = input.steps ?? [];
12657
12672
  const newTags = input.tags ?? [];
12658
- const isIdentical = existing.description === (input.description ?? "") && existingSteps.length === newSteps.length && existingSteps.every((s, i) => s === newSteps[i]) && existingTags.length === newTags.length && existingTags.every((t, i) => t === newTags[i]) && existing.priority === (input.priority ?? "medium");
12673
+ const isIdentical = existing.description === (input.description ?? "") && existingSteps.length === newSteps.length && existingSteps.every((s, i) => s === newSteps[i]) && existingTags.length === newTags.length && existingTags.every((t, i) => t === newTags[i]) && existing.priority === (input.priority ?? "medium") && existing.target_path === (input.targetPath ?? null) && Boolean(existing.requires_auth) === Boolean(input.requiresAuth) && stableJson(existingMetadata) === stableJson(input.metadata) && stableJson(existingAssertions) === stableJson(input.assertions ?? []) && stableJson(existingParameters) === stableJson(input.parameters);
12659
12674
  if (isIdentical) {
12660
12675
  return { scenario: scenarioFromRow(existing), action: "deduped" };
12661
12676
  }
@@ -12705,6 +12720,10 @@ function upsertScenario(input) {
12705
12720
  sets.push("assertions = ?");
12706
12721
  params.push(JSON.stringify(input.assertions));
12707
12722
  }
12723
+ if (input.parameters !== undefined) {
12724
+ sets.push("parameters = ?");
12725
+ params.push(JSON.stringify(input.parameters));
12726
+ }
12708
12727
  sets.push("version = ?", "updated_at = ?");
12709
12728
  params.push(existing.version + 1, now());
12710
12729
  params.push(existing.id);
@@ -13057,6 +13076,159 @@ var init_screenshots = __esm(() => {
13057
13076
  init_database();
13058
13077
  });
13059
13078
 
13079
+ // src/lib/route-fixtures.ts
13080
+ function isRecord2(value) {
13081
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13082
+ }
13083
+ function envNameForParam(prefix, param) {
13084
+ return `${prefix}_${param.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9]+/g, "_").toUpperCase()}`;
13085
+ }
13086
+ function readString(value) {
13087
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
13088
+ }
13089
+ function resolveReference(value, env) {
13090
+ if (value.startsWith("$?"))
13091
+ return env[value.slice(2)]?.trim() || undefined;
13092
+ if (value.startsWith("$"))
13093
+ return env[value.slice(1)]?.trim() || undefined;
13094
+ return value;
13095
+ }
13096
+ function scenarioFixtureValue(params, name, env) {
13097
+ if (!params)
13098
+ return;
13099
+ const routeFixtures = isRecord2(params["routeFixtures"]) ? params["routeFixtures"] : {};
13100
+ const raw = readString(routeFixtures[name]) ?? readString(params[name]);
13101
+ return raw ? resolveReference(raw, env) : undefined;
13102
+ }
13103
+ function envFixtureValue(name, env) {
13104
+ const candidates = [
13105
+ envNameForParam("TESTERS_ROUTE", name),
13106
+ envNameForParam("TESTERS_FIXTURE", name),
13107
+ envNameForParam("ALUMIA_FIXTURE", name),
13108
+ ...PARAM_ENV_CANDIDATES[name] ?? []
13109
+ ];
13110
+ for (const candidate of candidates) {
13111
+ const value = env[candidate]?.trim();
13112
+ if (value)
13113
+ return value;
13114
+ }
13115
+ return;
13116
+ }
13117
+ function defaultFixtureValue(name) {
13118
+ if (name === "orgSlug")
13119
+ return "test-org";
13120
+ if (name.toLowerCase().endsWith("slug")) {
13121
+ return `test-${name.replace(/Slug$/i, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase() || "slug"}`;
13122
+ }
13123
+ if (name === "id" || name.toLowerCase().endsWith("id"))
13124
+ return DEFAULT_UUID;
13125
+ if (name.toLowerCase().includes("token"))
13126
+ return "test-token";
13127
+ return `test-${name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()}`;
13128
+ }
13129
+ function routeParamsFromPath(path) {
13130
+ if (!path)
13131
+ return [];
13132
+ const params = new Set;
13133
+ for (const match of path.matchAll(/:([A-Za-z0-9_]+)(?:\*\??)?/g)) {
13134
+ if (match[1])
13135
+ params.add(match[1]);
13136
+ }
13137
+ return [...params];
13138
+ }
13139
+ function defaultRouteFixturesForParams(params) {
13140
+ return Object.fromEntries(params.map((param) => [param, defaultFixtureValue(param)]));
13141
+ }
13142
+ function resolveRouteFixtures(scenario, env = process.env) {
13143
+ const metadataParams = Array.isArray(scenario.metadata?.["fixtureParams"]) ? scenario.metadata["fixtureParams"].filter((value) => typeof value === "string") : [];
13144
+ const params = [...new Set([...metadataParams, ...routeParamsFromPath(scenario.targetPath)])];
13145
+ const values = {};
13146
+ const sources = {};
13147
+ const synthetic = [];
13148
+ for (const param of params) {
13149
+ const scenarioValue = scenarioFixtureValue(scenario.parameters, param, env);
13150
+ if (scenarioValue) {
13151
+ values[param] = scenarioValue;
13152
+ sources[param] = "scenario";
13153
+ continue;
13154
+ }
13155
+ const envValue = envFixtureValue(param, env);
13156
+ if (envValue) {
13157
+ values[param] = envValue;
13158
+ sources[param] = "env";
13159
+ continue;
13160
+ }
13161
+ values[param] = defaultFixtureValue(param);
13162
+ sources[param] = "default";
13163
+ synthetic.push(param);
13164
+ }
13165
+ const resolvedPath = scenario.targetPath ? resolveRoutePath(scenario.targetPath, values) : null;
13166
+ return {
13167
+ originalPath: scenario.targetPath,
13168
+ resolvedPath,
13169
+ params,
13170
+ values,
13171
+ sources,
13172
+ synthetic
13173
+ };
13174
+ }
13175
+ function resolveRoutePath(path, values) {
13176
+ return path.replace(/\/:([A-Za-z0-9_]+)\*\?/g, (_match, name) => {
13177
+ const value = values[name];
13178
+ return value ? `/${encodeRouteFixture(value, true)}` : "";
13179
+ }).replace(/:([A-Za-z0-9_]+)\*/g, (_match, name) => encodeRouteFixture(values[name] ?? defaultFixtureValue(name), true)).replace(/:([A-Za-z0-9_]+)/g, (_match, name) => encodeRouteFixture(values[name] ?? defaultFixtureValue(name), false));
13180
+ }
13181
+ function encodeRouteFixture(value, allowSlash) {
13182
+ if (allowSlash)
13183
+ return value.split("/").filter(Boolean).map(encodeURIComponent).join("/");
13184
+ return encodeURIComponent(value);
13185
+ }
13186
+ function materializeScenarioRoute(scenario, env = process.env) {
13187
+ const resolution = resolveRouteFixtures(scenario, env);
13188
+ if (!resolution.resolvedPath || resolution.resolvedPath === scenario.targetPath) {
13189
+ return { scenario, resolution };
13190
+ }
13191
+ const steps = scenario.steps.map((step) => {
13192
+ let next = step;
13193
+ for (const [name, value] of Object.entries(resolution.values)) {
13194
+ next = next.replaceAll(`:${name}`, value).replaceAll(`[${name}]`, value).replaceAll(`{${name}}`, value);
13195
+ }
13196
+ return next.replaceAll(scenario.targetPath ?? "", resolution.resolvedPath ?? "");
13197
+ });
13198
+ return {
13199
+ scenario: {
13200
+ ...scenario,
13201
+ targetPath: resolution.resolvedPath,
13202
+ steps,
13203
+ metadata: {
13204
+ ...scenario.metadata ?? {},
13205
+ routeFixtureResolution: resolution
13206
+ }
13207
+ },
13208
+ resolution
13209
+ };
13210
+ }
13211
+ function resolveStartUrl(baseUrl, targetPath) {
13212
+ try {
13213
+ return new URL(targetPath, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
13214
+ } catch {
13215
+ return `${baseUrl.replace(/\/+$/, "")}/${targetPath.replace(/^\/+/, "")}`;
13216
+ }
13217
+ }
13218
+ var DEFAULT_UUID = "00000000-0000-4000-8000-000000000000", PARAM_ENV_CANDIDATES;
13219
+ var init_route_fixtures = __esm(() => {
13220
+ PARAM_ENV_CANDIDATES = {
13221
+ orgSlug: ["TESTERS_ORG_SLUG", "SMOKE_ORG_SLUG", "ORG_SLUG"],
13222
+ orgId: ["TESTERS_ORG_ID", "SMOKE_ORG_ID", "ORG_ID"],
13223
+ projectSlug: ["TESTERS_PROJECT_SLUG", "SMOKE_PROJECT_SLUG", "PROJECT_SLUG"],
13224
+ projectId: ["TESTERS_PROJECT_ID", "SMOKE_PROJECT_ID", "PROJECT_ID"],
13225
+ workspaceId: ["TESTERS_WORKSPACE_ID", "SMOKE_WORKSPACE_ID", "WORKSPACE_ID"],
13226
+ agentId: ["TESTERS_AGENT_ID", "SMOKE_AGENT_ID", "AGENT_ID"],
13227
+ sessionId: ["TESTERS_SESSION_ID", "SMOKE_SESSION_ID", "SESSION_ID"],
13228
+ userId: ["TESTERS_USER_ID", "SMOKE_USER_ID", "USER_ID"]
13229
+ };
13230
+ });
13231
+
13060
13232
  // src/lib/browser-lightpanda.ts
13061
13233
  var exports_browser_lightpanda = {};
13062
13234
  __export(exports_browser_lightpanda, {
@@ -14777,33 +14949,35 @@ ${filtered.join(`
14777
14949
  return { result: `Error executing ${toolName}: ${message}` };
14778
14950
  }
14779
14951
  }
14780
- function resolveStartUrl(baseUrl, targetPath) {
14781
- try {
14782
- return new URL(targetPath, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
14783
- } catch {
14784
- return `${baseUrl.replace(/\/+$/, "")}/${targetPath.replace(/^\/+/, "")}`;
14785
- }
14786
- }
14787
14952
  function buildScenarioUserMessage(scenario, baseUrl) {
14953
+ const { scenario: materializedScenario, resolution } = materializeScenarioRoute(scenario);
14788
14954
  const userParts = [
14789
- `**Scenario:** ${scenario.name}`,
14790
- `**Description:** ${scenario.description}`
14955
+ `**Scenario:** ${materializedScenario.name}`,
14956
+ `**Description:** ${materializedScenario.description}`
14791
14957
  ];
14792
14958
  if (baseUrl) {
14793
14959
  const normalizedBaseUrl = baseUrl.replace(/\/+$/, "");
14794
14960
  userParts.push(`**Base URL:** ${normalizedBaseUrl}`);
14795
- if (scenario.targetPath) {
14796
- userParts.push(`**Start URL:** ${resolveStartUrl(normalizedBaseUrl, scenario.targetPath)}`);
14961
+ if (materializedScenario.targetPath) {
14962
+ userParts.push(`**Start URL:** ${resolveStartUrl(normalizedBaseUrl, materializedScenario.targetPath)}`);
14797
14963
  }
14798
14964
  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.");
14799
14965
  }
14800
- if (scenario.targetPath) {
14801
- userParts.push(`**Target Path:** ${scenario.targetPath}`);
14966
+ if (materializedScenario.targetPath) {
14967
+ userParts.push(`**Target Path:** ${materializedScenario.targetPath}`);
14802
14968
  }
14803
- if (scenario.steps.length > 0) {
14969
+ if (resolution.params.length > 0) {
14970
+ userParts.push("**Route Fixtures:**");
14971
+ for (const param of resolution.params) {
14972
+ const source = resolution.sources[param];
14973
+ const synthetic = source === "default" ? " synthetic" : "";
14974
+ userParts.push(`- :${param} = ${resolution.values[param]} (${source}${synthetic})`);
14975
+ }
14976
+ }
14977
+ if (materializedScenario.steps.length > 0) {
14804
14978
  userParts.push("**Steps:**");
14805
- for (let i = 0;i < scenario.steps.length; i++) {
14806
- userParts.push(`${i + 1}. ${scenario.steps[i]}`);
14979
+ for (let i = 0;i < materializedScenario.steps.length; i++) {
14980
+ userParts.push(`${i + 1}. ${materializedScenario.steps[i]}`);
14807
14981
  }
14808
14982
  }
14809
14983
  return userParts.join(`
@@ -15101,6 +15275,7 @@ function createClientForModel(model, apiKey) {
15101
15275
  var activeHARs, activeCoverage, BROWSER_TOOLS;
15102
15276
  var init_ai_client = __esm(() => {
15103
15277
  init_types();
15278
+ init_route_fixtures();
15104
15279
  activeHARs = new Map;
15105
15280
  activeCoverage = new Map;
15106
15281
  BROWSER_TOOLS = [
@@ -18321,9 +18496,10 @@ function withTimeout(promise, ms, label) {
18321
18496
  });
18322
18497
  }
18323
18498
  async function runSingleScenario(scenario, runId, options) {
18324
- const scenarioType = scenario.scenarioType ?? "browser";
18499
+ const { scenario: materializedScenario, resolution: routeFixtureResolution } = materializeScenarioRoute(scenario);
18500
+ const scenarioType = materializedScenario.scenarioType ?? "browser";
18325
18501
  if (scenarioType === "eval") {
18326
- return runEvalScenario(scenario, { runId, baseUrl: options.url });
18502
+ return runEvalScenario(materializedScenario, { runId, baseUrl: options.url });
18327
18503
  }
18328
18504
  const config = loadConfig();
18329
18505
  if (options.selfHeal !== undefined)
@@ -18364,7 +18540,7 @@ async function runSingleScenario(scenario, runId, options) {
18364
18540
  runId,
18365
18541
  scenarioId: scenario.id,
18366
18542
  model,
18367
- stepsTotal: scenario.steps.length || 10,
18543
+ stepsTotal: materializedScenario.steps.length || 10,
18368
18544
  personaId: persona?.id ?? null,
18369
18545
  personaName: persona?.name ?? null
18370
18546
  });
@@ -18400,12 +18576,12 @@ async function runSingleScenario(scenario, runId, options) {
18400
18576
  engine: effectiveOptions.engine
18401
18577
  });
18402
18578
  }
18403
- const targetUrl = scenario.targetPath ? `${options.url.replace(/\/$/, "")}${scenario.targetPath}` : options.url;
18404
- const scenarioTimeout = scenario.timeoutMs ?? options.timeout ?? config.browser.timeout ?? 60000;
18579
+ const targetUrl = materializedScenario.targetPath ? resolveStartUrl(options.url.replace(/\/$/, ""), materializedScenario.targetPath) : options.url;
18580
+ const scenarioTimeout = materializedScenario.timeoutMs ?? options.timeout ?? config.browser.timeout ?? 60000;
18405
18581
  registerSession({
18406
18582
  resultId: result.id,
18407
18583
  runId,
18408
- scenarioId: scenario.id,
18584
+ scenarioId: materializedScenario.id,
18409
18585
  engine: effectiveOptions.engine ?? "playwright",
18410
18586
  startUrl: targetUrl
18411
18587
  });
@@ -18459,7 +18635,7 @@ async function runSingleScenario(scenario, runId, options) {
18459
18635
  const agentResult = await withTimeout(runAgentLoop({
18460
18636
  client,
18461
18637
  page,
18462
- scenario,
18638
+ scenario: materializedScenario,
18463
18639
  screenshotter,
18464
18640
  model,
18465
18641
  runId,
@@ -18550,7 +18726,7 @@ async function runSingleScenario(scenario, runId, options) {
18550
18726
  const baseReasoning = agentResult.reasoning ? agentResult.reasoning + lightpandaNote : lightpandaNote || "";
18551
18727
  const assertionOutcome = await applyStructuredAssertionsToResult({
18552
18728
  page,
18553
- scenario,
18729
+ scenario: materializedScenario,
18554
18730
  consoleErrors,
18555
18731
  status: agentResult.status,
18556
18732
  reasoning: baseReasoning
@@ -18571,6 +18747,7 @@ async function runSingleScenario(scenario, runId, options) {
18571
18747
  costCents: estimateCost(model, agentResult.tokensUsed),
18572
18748
  metadata: {
18573
18749
  consoleLogs,
18750
+ ...routeFixtureResolution.params.length > 0 ? { routeFixtureResolution } : {},
18574
18751
  ...networkErrors.length > 0 ? networkMeta : {},
18575
18752
  ...structuredAssertionMeta
18576
18753
  }
@@ -18920,6 +19097,7 @@ var init_runner = __esm(() => {
18920
19097
  init_browser();
18921
19098
  init_screenshotter();
18922
19099
  init_ai_client();
19100
+ init_route_fixtures();
18923
19101
  init_config2();
18924
19102
  init_persona_auth();
18925
19103
  init_session_tracker();
@@ -59225,6 +59403,10 @@ function scenarioInputForNextRoute(item, projectId) {
59225
59403
  actionCount: item.actions.length,
59226
59404
  groups: item.groups
59227
59405
  },
59406
+ parameters: item.fixtureParams.length > 0 ? {
59407
+ routeFixtures: defaultRouteFixturesForParams(item.fixtureParams),
59408
+ routeFixtureParams: item.fixtureParams
59409
+ } : undefined,
59228
59410
  projectId
59229
59411
  };
59230
59412
  }
@@ -59627,6 +59809,7 @@ var ROUTE_FILE_NAMES, WALK_EXCLUDES, SAFE_PAGE_ASSERTIONS, IMPORT_SCAN_LIMIT = 4
59627
59809
  var init_next_route_inventory = __esm(() => {
59628
59810
  init_scenarios();
59629
59811
  init_workflows();
59812
+ init_route_fixtures();
59630
59813
  ROUTE_FILE_NAMES = new Set([
59631
59814
  "page.tsx",
59632
59815
  "page.ts",
@@ -60280,8 +60463,11 @@ var exports_workflow_fanout = {};
60280
60463
  __export(exports_workflow_fanout, {
60281
60464
  runWorkflowFanout: () => runWorkflowFanout,
60282
60465
  resolveWorkflowFanoutSelection: () => resolveWorkflowFanoutSelection,
60283
- normalizeFanoutWorkerCount: () => normalizeFanoutWorkerCount
60466
+ normalizeFanoutWorkerCount: () => normalizeFanoutWorkerCount,
60467
+ checkWorkflowFanoutReadiness: () => checkWorkflowFanoutReadiness
60284
60468
  });
60469
+ import { spawnSync as spawnSync2 } from "child_process";
60470
+ import { existsSync as existsSync19, statSync as statSync5 } from "fs";
60285
60471
  function splitWorkflowIds(ids) {
60286
60472
  return (ids ?? []).flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean);
60287
60473
  }
@@ -60314,6 +60500,89 @@ function resolveWorkflowFanoutSelection(options) {
60314
60500
  }
60315
60501
  return filtered;
60316
60502
  }
60503
+ async function checkWorkflowFanoutReadiness(workflows, dependencies = {}) {
60504
+ const checks = [];
60505
+ const env = dependencies.env ?? process.env;
60506
+ for (const [provider, providerWorkflows] of groupWorkflowsByProvider(workflows)) {
60507
+ const envKey = PROVIDER_ENV_KEYS[provider];
60508
+ if (!envKey) {
60509
+ checks.push({
60510
+ name: `provider:${provider}`,
60511
+ ok: true,
60512
+ required: false,
60513
+ message: `No built-in credential preflight for sandbox provider "${provider}"`,
60514
+ workflows: providerWorkflows.map((workflow) => workflow.name)
60515
+ });
60516
+ continue;
60517
+ }
60518
+ const apiKey = await resolveProviderApiKey(provider, env, dependencies.providerApiKeyResolver);
60519
+ checks.push({
60520
+ name: `provider:${provider}`,
60521
+ ok: Boolean(apiKey),
60522
+ required: true,
60523
+ message: apiKey ? `Sandbox provider "${provider}" credential is available` : `Missing sandbox provider credential for "${provider}". Set ${envKey} or configure providers.${provider}.api_key with sandboxes config`,
60524
+ workflows: providerWorkflows.map((workflow) => workflow.name),
60525
+ details: { provider, envKey }
60526
+ });
60527
+ }
60528
+ const needsRsync = workflows.filter((workflow) => (workflow.execution.sandboxSyncStrategy ?? "rsync") === "rsync" || Boolean(workflow.execution.appSourceDir));
60529
+ if (needsRsync.length > 0) {
60530
+ const commandExists = dependencies.commandExists ?? defaultCommandExists;
60531
+ const rsyncOk = commandExists("rsync");
60532
+ checks.push({
60533
+ name: "tool:rsync",
60534
+ ok: rsyncOk,
60535
+ required: true,
60536
+ message: rsyncOk ? "rsync is available for sandbox uploads and app source bundling" : "Missing rsync. Install rsync or use --sandbox-sync archive for workflows without app source bundling",
60537
+ workflows: needsRsync.map((workflow) => workflow.name)
60538
+ });
60539
+ }
60540
+ const missingAppSources = workflows.filter((workflow) => workflow.execution.appSourceDir).filter((workflow) => {
60541
+ const sourceDir = workflow.execution.appSourceDir;
60542
+ return !existsSync19(sourceDir) || !statSync5(sourceDir).isDirectory();
60543
+ });
60544
+ if (missingAppSources.length > 0) {
60545
+ checks.push({
60546
+ name: "app-source",
60547
+ ok: false,
60548
+ required: true,
60549
+ message: "One or more workflow app source directories are missing or are not directories",
60550
+ workflows: missingAppSources.map((workflow) => workflow.name),
60551
+ details: {
60552
+ sources: missingAppSources.map((workflow) => ({
60553
+ workflowId: workflow.id,
60554
+ workflowName: workflow.name,
60555
+ appSourceDir: workflow.execution.appSourceDir
60556
+ }))
60557
+ }
60558
+ });
60559
+ }
60560
+ const { requiredMissing, optionalMissing } = collectMissingSandboxEnvRefs(workflows, env, dependencies.credentialResolver);
60561
+ if (requiredMissing.length > 0) {
60562
+ checks.push({
60563
+ name: "env:required",
60564
+ ok: false,
60565
+ required: true,
60566
+ message: "One or more required sandbox environment references could not be resolved",
60567
+ workflows: [...new Set(requiredMissing.map((item) => item.workflowName))],
60568
+ details: { missing: requiredMissing }
60569
+ });
60570
+ }
60571
+ if (optionalMissing.length > 0) {
60572
+ checks.push({
60573
+ name: "env:optional",
60574
+ ok: false,
60575
+ required: false,
60576
+ message: "One or more optional sandbox environment references are not set and will be omitted",
60577
+ workflows: [...new Set(optionalMissing.map((item) => item.workflowName))],
60578
+ details: { missing: optionalMissing }
60579
+ });
60580
+ }
60581
+ return {
60582
+ ok: checks.every((check) => check.ok || !check.required),
60583
+ checks
60584
+ };
60585
+ }
60317
60586
  async function mapWithConcurrency(items, limit, worker) {
60318
60587
  const output = new Array(items.length);
60319
60588
  let next = 0;
@@ -60329,7 +60598,38 @@ async function mapWithConcurrency(items, limit, worker) {
60329
60598
  async function runWorkflowFanout(options, dependencies = {}) {
60330
60599
  const workers = normalizeFanoutWorkerCount(options.workers);
60331
60600
  const workflows = resolveWorkflowFanoutSelection(options);
60332
- const { runTestingWorkflow: runOne = runTestingWorkflow, ...workflowDependencies } = dependencies;
60601
+ const {
60602
+ runTestingWorkflow: runOne = runTestingWorkflow,
60603
+ preflight: preflightOverride,
60604
+ providerApiKeyResolver,
60605
+ commandExists,
60606
+ credentialResolver,
60607
+ env,
60608
+ ...workflowDependencies
60609
+ } = dependencies;
60610
+ const preflight = preflightOverride ? await preflightOverride(workflows) : await checkWorkflowFanoutReadiness(workflows, {
60611
+ providerApiKeyResolver,
60612
+ commandExists,
60613
+ credentialResolver,
60614
+ env
60615
+ });
60616
+ if (!options.dryRun && !preflight.ok) {
60617
+ const error = `Preflight failed: ${summarizePreflightFailures(preflight)}`;
60618
+ return {
60619
+ status: "failed",
60620
+ workers,
60621
+ total: workflows.length,
60622
+ passed: 0,
60623
+ failed: workflows.length,
60624
+ preflight,
60625
+ items: workflows.map((workflow) => ({
60626
+ workflowId: workflow.id,
60627
+ workflowName: workflow.name,
60628
+ status: "failed",
60629
+ error
60630
+ }))
60631
+ };
60632
+ }
60333
60633
  const items = await mapWithConcurrency(workflows, workers, async (workflow) => {
60334
60634
  try {
60335
60635
  const output = await runOne(workflow.id, {
@@ -60376,12 +60676,81 @@ async function runWorkflowFanout(options, dependencies = {}) {
60376
60676
  total: items.length,
60377
60677
  passed,
60378
60678
  failed,
60379
- items
60679
+ items,
60680
+ preflight
60380
60681
  };
60381
60682
  }
60683
+ function groupWorkflowsByProvider(workflows) {
60684
+ const byProvider = new Map;
60685
+ for (const workflow of workflows) {
60686
+ const provider = workflow.execution.provider ?? "e2b";
60687
+ byProvider.set(provider, [...byProvider.get(provider) ?? [], workflow]);
60688
+ }
60689
+ return byProvider;
60690
+ }
60691
+ async function resolveProviderApiKey(provider, env, resolver) {
60692
+ if (resolver)
60693
+ return resolver(provider, env);
60694
+ const envKey = PROVIDER_ENV_KEYS[provider];
60695
+ if (envKey && env[envKey])
60696
+ return env[envKey];
60697
+ try {
60698
+ const mod = await import("@hasna/sandboxes");
60699
+ if (provider === "e2b" || provider === "daytona" || provider === "modal") {
60700
+ return mod.getProviderApiKey?.(provider);
60701
+ }
60702
+ } catch {}
60703
+ return;
60704
+ }
60705
+ function defaultCommandExists(command) {
60706
+ const result = spawnSync2(command, ["--version"], { encoding: "utf8" });
60707
+ return !result.error && result.status === 0;
60708
+ }
60709
+ function collectMissingSandboxEnvRefs(workflows, env, credentialResolver) {
60710
+ const requiredMissing = [];
60711
+ const optionalMissing = [];
60712
+ for (const workflow of workflows) {
60713
+ for (const [key, value] of Object.entries(workflow.execution.env ?? {})) {
60714
+ if (value.startsWith("$?")) {
60715
+ const name = value.slice(2).trim();
60716
+ if (name && env[name] === undefined) {
60717
+ optionalMissing.push({ workflowId: workflow.id, workflowName: workflow.name, key, reference: value });
60718
+ }
60719
+ continue;
60720
+ }
60721
+ if (!isResolvableEnvReference(value))
60722
+ continue;
60723
+ if (resolveSandboxEnvReference(value, env, credentialResolver) === null) {
60724
+ requiredMissing.push({ workflowId: workflow.id, workflowName: workflow.name, key, reference: value });
60725
+ }
60726
+ }
60727
+ }
60728
+ return { requiredMissing, optionalMissing };
60729
+ }
60730
+ function isResolvableEnvReference(value) {
60731
+ return value.startsWith("$") || value.startsWith("@secrets:");
60732
+ }
60733
+ function resolveSandboxEnvReference(value, env, credentialResolver) {
60734
+ if (value.startsWith("$")) {
60735
+ const varName = value.slice(1).trim();
60736
+ return varName ? env[varName] ?? null : null;
60737
+ }
60738
+ return (credentialResolver ?? resolveCredential)(value);
60739
+ }
60740
+ function summarizePreflightFailures(preflight) {
60741
+ const requiredFailures = preflight.checks.filter((check) => !check.ok && check.required);
60742
+ return requiredFailures.length > 0 ? requiredFailures.map((check) => check.message).join("; ") : "required checks did not pass";
60743
+ }
60744
+ var PROVIDER_ENV_KEYS;
60382
60745
  var init_workflow_fanout = __esm(() => {
60383
60746
  init_workflows();
60384
60747
  init_workflow_runner();
60748
+ init_secrets_resolver();
60749
+ PROVIDER_ENV_KEYS = {
60750
+ e2b: "E2B_API_KEY",
60751
+ daytona: "DAYTONA_API_KEY",
60752
+ modal: "MODAL_TOKEN_ID"
60753
+ };
60385
60754
  });
60386
60755
 
60387
60756
  // node_modules/@ai-sdk/provider/dist/index.mjs
@@ -95041,7 +95410,7 @@ import chalk6 from "chalk";
95041
95410
  // package.json
95042
95411
  var package_default = {
95043
95412
  name: "@hasna/testers",
95044
- version: "0.0.50",
95413
+ version: "0.0.52",
95045
95414
  description: "AI-powered QA testing CLI \u2014 spawns cheap AI agents to test web apps with headless browsers",
95046
95415
  type: "module",
95047
95416
  main: "dist/index.js",
@@ -97228,7 +97597,7 @@ init_ci();
97228
97597
  init_assertions();
97229
97598
  init_paths();
97230
97599
  init_sessions();
97231
- import { existsSync as existsSync19, mkdirSync as mkdirSync15 } from "fs";
97600
+ import { existsSync as existsSync20, mkdirSync as mkdirSync15 } from "fs";
97232
97601
 
97233
97602
  // src/lib/repo-discovery.ts
97234
97603
  init_paths();
@@ -98257,7 +98626,7 @@ var CONFIG_DIR5 = getTestersDir();
98257
98626
  var CONFIG_PATH4 = join21(CONFIG_DIR5, "config.json");
98258
98627
  function getActiveProject() {
98259
98628
  try {
98260
- if (existsSync19(CONFIG_PATH4)) {
98629
+ if (existsSync20(CONFIG_PATH4)) {
98261
98630
  const raw = JSON.parse(readFileSync11(CONFIG_PATH4, "utf-8"));
98262
98631
  return raw.activeProject ?? undefined;
98263
98632
  }
@@ -99188,7 +99557,7 @@ program2.command("export [format]").description("Export scenarios as JSON (defau
99188
99557
  return;
99189
99558
  }
99190
99559
  const outputDir = opts.output ?? ".";
99191
- if (!existsSync19(outputDir)) {
99560
+ if (!existsSync20(outputDir)) {
99192
99561
  mkdirSync15(outputDir, { recursive: true });
99193
99562
  }
99194
99563
  for (const s2 of scenarios) {
@@ -99391,11 +99760,11 @@ projectCmd.command("export-open <id>").description("Register a testers project i
99391
99760
  projectCmd.command("use <name>").description("Set active project (find or create)").option("--json", "Output as JSON", false).action((name21, opts) => {
99392
99761
  try {
99393
99762
  const project = ensureProject(name21, process.cwd());
99394
- if (!existsSync19(CONFIG_DIR5)) {
99763
+ if (!existsSync20(CONFIG_DIR5)) {
99395
99764
  mkdirSync15(CONFIG_DIR5, { recursive: true });
99396
99765
  }
99397
99766
  let config2 = {};
99398
- if (existsSync19(CONFIG_PATH4)) {
99767
+ if (existsSync20(CONFIG_PATH4)) {
99399
99768
  try {
99400
99769
  config2 = JSON.parse(readFileSync11(CONFIG_PATH4, "utf-8"));
99401
99770
  } catch {}
@@ -100010,7 +100379,7 @@ program2.command("ci [provider]").description("Print or write a CI workflow (def
100010
100379
  if (opts.output) {
100011
100380
  const outPath = resolve5(opts.output);
100012
100381
  const outDir = outPath.replace(/\/[^/]*$/, "");
100013
- if (outDir && !existsSync19(outDir)) {
100382
+ if (outDir && !existsSync20(outDir)) {
100014
100383
  mkdirSync15(outDir, { recursive: true });
100015
100384
  }
100016
100385
  writeFileSync7(outPath, workflow, "utf-8");
@@ -100045,7 +100414,7 @@ program2.command("init").description("Initialize a new testing project").option(
100045
100414
  }
100046
100415
  if (opts.ci === "github") {
100047
100416
  const workflowDir = join21(process.cwd(), ".github", "workflows");
100048
- if (!existsSync19(workflowDir)) {
100417
+ if (!existsSync20(workflowDir)) {
100049
100418
  mkdirSync15(workflowDir, { recursive: true });
100050
100419
  }
100051
100420
  const workflowPath = join21(workflowDir, "testers.yml");
@@ -101821,6 +102190,19 @@ workflowCmd.command("fanout [ids...]").description("Run multiple saved sandbox w
101821
102190
  if (opts.json || opts.dryRun) {
101822
102191
  log(JSON.stringify(result, null, 2));
101823
102192
  } else {
102193
+ const preflightChecks = result.preflight?.checks ?? [];
102194
+ const failedRequiredChecks = preflightChecks.filter((check2) => !check2.ok && check2.required);
102195
+ const warnings = preflightChecks.filter((check2) => !check2.ok && !check2.required);
102196
+ if (failedRequiredChecks.length > 0 || warnings.length > 0) {
102197
+ const label = failedRequiredChecks.length > 0 ? chalk6.red("failed") : chalk6.yellow("warnings");
102198
+ log(chalk6.bold(`Preflight ${label}:`));
102199
+ for (const check2 of failedRequiredChecks) {
102200
+ log(` ${chalk6.red("failed")} ${check2.message}`);
102201
+ }
102202
+ for (const check2 of warnings) {
102203
+ log(` ${chalk6.yellow("warning")} ${check2.message}`);
102204
+ }
102205
+ }
101824
102206
  const status = result.status === "passed" ? chalk6.green("passed") : chalk6.red("failed");
101825
102207
  log(chalk6.bold(`Sandbox workflow fanout ${status}: ${result.passed}/${result.total} passed with ${result.workers} worker(s)`));
101826
102208
  for (const item of result.items) {
@@ -1 +1 @@
1
- {"version":3,"file":"scenarios.d.ts","sourceRoot":"","sources":["../../src/db/scenarios.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,QAAQ,EAEb,KAAK,mBAAmB,EACxB,KAAK,mBAAmB,EACxB,KAAK,cAAc,EAGpB,MAAM,mBAAmB,CAAC;AA6B3B,wBAAgB,cAAc,CAAC,KAAK,EAAE,mBAAmB,GAAG,QAAQ,CA+BnE;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAmBvD;AAED,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAIrE;AAED,wBAAgB,aAAa,CAAC,MAAM,CAAC,EAAE,cAAc,GAAG,QAAQ,EAAE,CAoFjE;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,mBAAmB,EAAE,OAAO,EAAE,MAAM,GAAG,QAAQ,CAwFhG;AAED,wBAAgB,cAAc,CAAC,MAAM,CAAC,EAAE,cAAc,GAAG,MAAM,CA8B9D;AAED,MAAM,WAAW,aAAc,SAAQ,QAAQ;IAC7C,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,EAAE,CAmBhE;AAED,wBAAgB,yBAAyB,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAIvE;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAOlD;AAED,MAAM,MAAM,oBAAoB,GAAG;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,SAAS,GAAG,SAAS,GAAG,SAAS,CAAA;CAAE,CAAC;AAErG;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,mBAAmB,GAAG;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,oBAAoB,CAuDlG"}
1
+ {"version":3,"file":"scenarios.d.ts","sourceRoot":"","sources":["../../src/db/scenarios.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,QAAQ,EAEb,KAAK,mBAAmB,EACxB,KAAK,mBAAmB,EACxB,KAAK,cAAc,EAGpB,MAAM,mBAAmB,CAAC;AA2C3B,wBAAgB,cAAc,CAAC,KAAK,EAAE,mBAAmB,GAAG,QAAQ,CA+BnE;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAmBvD;AAED,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAIrE;AAED,wBAAgB,aAAa,CAAC,MAAM,CAAC,EAAE,cAAc,GAAG,QAAQ,EAAE,CAoFjE;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,mBAAmB,EAAE,OAAO,EAAE,MAAM,GAAG,QAAQ,CAwFhG;AAED,wBAAgB,cAAc,CAAC,MAAM,CAAC,EAAE,cAAc,GAAG,MAAM,CA8B9D;AAED,MAAM,WAAW,aAAc,SAAQ,QAAQ;IAC7C,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,EAAE,CAmBhE;AAED,wBAAgB,yBAAyB,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAIvE;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAOlD;AAED,MAAM,MAAM,oBAAoB,GAAG;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,SAAS,GAAG,SAAS,GAAG,SAAS,CAAA;CAAE,CAAC;AAErG;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,mBAAmB,GAAG;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,oBAAoB,CAgElG"}