@hasna/testers 0.0.45 → 0.0.47
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 +260 -74
- package/dist/index.js +174 -43
- package/dist/lib/workflow-runner.d.ts +6 -0
- package/dist/lib/workflow-runner.d.ts.map +1 -1
- package/dist/mcp/index.js +150 -17
- package/dist/server/index.js +144 -13
- package/dist/types/index.d.ts +12 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
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.
|
|
55
|
+
version: "0.0.47",
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
`
|
|
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");
|
|
@@ -23615,7 +23723,7 @@ async function runViaSandbox(plan, dependencies) {
|
|
|
23615
23723
|
workflowId: plan.workflow.id,
|
|
23616
23724
|
workflowName: plan.workflow.name
|
|
23617
23725
|
},
|
|
23618
|
-
sandboxEnvVars: plan.sandbox.env,
|
|
23726
|
+
sandboxEnvVars: resolveSandboxEnv(plan.sandbox.env),
|
|
23619
23727
|
cleanup: plan.sandbox.cleanup,
|
|
23620
23728
|
upload: {
|
|
23621
23729
|
localDir: bundle.localDir,
|
|
@@ -23641,6 +23749,19 @@ async function runViaSandbox(plan, dependencies) {
|
|
|
23641
23749
|
bundle.cleanup?.();
|
|
23642
23750
|
}
|
|
23643
23751
|
}
|
|
23752
|
+
function resolveSandboxEnv(env2) {
|
|
23753
|
+
if (!env2 || Object.keys(env2).length === 0)
|
|
23754
|
+
return;
|
|
23755
|
+
const resolved = {};
|
|
23756
|
+
for (const [key, value] of Object.entries(env2)) {
|
|
23757
|
+
const resolvedValue = resolveCredential(value);
|
|
23758
|
+
if (resolvedValue === null) {
|
|
23759
|
+
throw new Error(`Missing sandbox env value for ${key}`);
|
|
23760
|
+
}
|
|
23761
|
+
resolved[key] = resolvedValue;
|
|
23762
|
+
}
|
|
23763
|
+
return resolved;
|
|
23764
|
+
}
|
|
23644
23765
|
async function resolveSandboxesRuntime(dependencies) {
|
|
23645
23766
|
if (dependencies.sandboxes)
|
|
23646
23767
|
return dependencies.sandboxes;
|
|
@@ -23652,11 +23773,23 @@ async function resolveSandboxesRuntime(dependencies) {
|
|
|
23652
23773
|
function shellQuote(value) {
|
|
23653
23774
|
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
23654
23775
|
}
|
|
23776
|
+
var APP_SOURCE_EXCLUDES;
|
|
23655
23777
|
var init_workflow_runner = __esm(() => {
|
|
23656
23778
|
init_database();
|
|
23657
23779
|
init_workflows();
|
|
23658
23780
|
init_personas();
|
|
23659
23781
|
init_runner();
|
|
23782
|
+
init_secrets_resolver();
|
|
23783
|
+
APP_SOURCE_EXCLUDES = [
|
|
23784
|
+
"node_modules",
|
|
23785
|
+
".git",
|
|
23786
|
+
"dist",
|
|
23787
|
+
".next",
|
|
23788
|
+
".turbo",
|
|
23789
|
+
".cache",
|
|
23790
|
+
".venv",
|
|
23791
|
+
"__pycache__"
|
|
23792
|
+
];
|
|
23660
23793
|
});
|
|
23661
23794
|
|
|
23662
23795
|
// node_modules/@ai-sdk/provider/dist/index.mjs
|
|
@@ -53711,10 +53844,10 @@ import { exec } from "child_process";
|
|
|
53711
53844
|
import { promisify } from "util";
|
|
53712
53845
|
import { readFileSync as readFileSync3 } from "fs";
|
|
53713
53846
|
import { webcrypto as crypto2 } from "crypto";
|
|
53714
|
-
import { existsSync as existsSync42, writeFileSync as
|
|
53847
|
+
import { existsSync as existsSync42, writeFileSync as writeFileSync3, readFileSync as readFileSync22, mkdirSync as mkdirSync32 } from "fs";
|
|
53715
53848
|
import { join as join42 } from "path";
|
|
53716
53849
|
import { Database as Database4 } from "bun:sqlite";
|
|
53717
|
-
import { existsSync as
|
|
53850
|
+
import { existsSync as existsSync12, mkdirSync as mkdirSync10 } from "fs";
|
|
53718
53851
|
import { dirname as dirname4, join as join15, resolve as resolve2 } from "path";
|
|
53719
53852
|
import { existsSync as existsSync22, writeFileSync as writeFileSync4 } from "fs";
|
|
53720
53853
|
import { join as join22 } from "path";
|
|
@@ -53728,10 +53861,10 @@ import * as zlib from "zlib";
|
|
|
53728
53861
|
import { Readable } from "stream";
|
|
53729
53862
|
import { Writable } from "stream";
|
|
53730
53863
|
import { createHash as createHash2 } from "crypto";
|
|
53731
|
-
import { mkdirSync as mkdirSync4, statSync, writeFileSync as writeFileSync42 } from "fs";
|
|
53864
|
+
import { mkdirSync as mkdirSync4, statSync as statSync2, writeFileSync as writeFileSync42 } from "fs";
|
|
53732
53865
|
import { dirname as dirname42, join as join62, relative as relative2 } from "path";
|
|
53733
53866
|
import { readdir, readFile as readFile2 } from "fs/promises";
|
|
53734
|
-
import { existsSync as existsSync62, readdirSync as readdirSync3, readFileSync as readFileSync42, statSync as
|
|
53867
|
+
import { existsSync as existsSync62, readdirSync as readdirSync3, readFileSync as readFileSync42, statSync as statSync22 } from "fs";
|
|
53735
53868
|
import { basename, join as join72, resolve as resolve22 } from "path";
|
|
53736
53869
|
import { execFileSync as execFileSync2 } from "child_process";
|
|
53737
53870
|
import { existsSync as existsSync72, readFileSync as readFileSync5, writeFileSync as writeFileSync5, mkdirSync as mkdirSync52 } from "fs";
|
|
@@ -53896,8 +54029,8 @@ function ensureDir2(filePath) {
|
|
|
53896
54029
|
if (filePath === ":memory:")
|
|
53897
54030
|
return;
|
|
53898
54031
|
const dir = dirname4(resolve2(filePath));
|
|
53899
|
-
if (!
|
|
53900
|
-
|
|
54032
|
+
if (!existsSync12(dir)) {
|
|
54033
|
+
mkdirSync10(dir, { recursive: true });
|
|
53901
54034
|
}
|
|
53902
54035
|
}
|
|
53903
54036
|
function getDatabase2(path) {
|
|
@@ -54178,7 +54311,7 @@ function setIntegrations(id, integrations, db2) {
|
|
|
54178
54311
|
const jsonPath = join42(project.path, ".project.json");
|
|
54179
54312
|
if (existsSync42(jsonPath)) {
|
|
54180
54313
|
const existing = JSON.parse(readFileSync22(jsonPath, "utf-8"));
|
|
54181
|
-
|
|
54314
|
+
writeFileSync3(jsonPath, JSON.stringify({ ...existing, integrations: merged }, null, 2) + `
|
|
54182
54315
|
`, "utf-8");
|
|
54183
54316
|
}
|
|
54184
54317
|
} catch {}
|
|
@@ -55256,7 +55389,7 @@ async function collectLocalFiles(rootPath) {
|
|
|
55256
55389
|
if (entry.isDirectory()) {
|
|
55257
55390
|
await walk(fullPath);
|
|
55258
55391
|
} else if (entry.isFile()) {
|
|
55259
|
-
const stat =
|
|
55392
|
+
const stat = statSync2(fullPath);
|
|
55260
55393
|
if (stat.size > MAX_FILE_SIZE)
|
|
55261
55394
|
continue;
|
|
55262
55395
|
const relPath = relative2(rootPath, fullPath);
|
|
@@ -55421,7 +55554,7 @@ async function importProject(projectPath, options = {}) {
|
|
|
55421
55554
|
if (!existsSync62(absPath)) {
|
|
55422
55555
|
return { error: `Path does not exist: ${absPath}` };
|
|
55423
55556
|
}
|
|
55424
|
-
const stat =
|
|
55557
|
+
const stat = statSync22(absPath);
|
|
55425
55558
|
if (!stat.isDirectory()) {
|
|
55426
55559
|
return { error: `Not a directory: ${absPath}` };
|
|
55427
55560
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
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.
|
|
46940
|
+
version: "0.0.47",
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
`
|
|
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");
|
|
@@ -51443,7 +51561,7 @@ async function runViaSandbox(plan, dependencies) {
|
|
|
51443
51561
|
workflowId: plan.workflow.id,
|
|
51444
51562
|
workflowName: plan.workflow.name
|
|
51445
51563
|
},
|
|
51446
|
-
sandboxEnvVars: plan.sandbox.env,
|
|
51564
|
+
sandboxEnvVars: resolveSandboxEnv(plan.sandbox.env),
|
|
51447
51565
|
cleanup: plan.sandbox.cleanup,
|
|
51448
51566
|
upload: {
|
|
51449
51567
|
localDir: bundle.localDir,
|
|
@@ -51469,6 +51587,19 @@ async function runViaSandbox(plan, dependencies) {
|
|
|
51469
51587
|
bundle.cleanup?.();
|
|
51470
51588
|
}
|
|
51471
51589
|
}
|
|
51590
|
+
function resolveSandboxEnv(env) {
|
|
51591
|
+
if (!env || Object.keys(env).length === 0)
|
|
51592
|
+
return;
|
|
51593
|
+
const resolved = {};
|
|
51594
|
+
for (const [key, value] of Object.entries(env)) {
|
|
51595
|
+
const resolvedValue = resolveCredential(value);
|
|
51596
|
+
if (resolvedValue === null) {
|
|
51597
|
+
throw new Error(`Missing sandbox env value for ${key}`);
|
|
51598
|
+
}
|
|
51599
|
+
resolved[key] = resolvedValue;
|
|
51600
|
+
}
|
|
51601
|
+
return resolved;
|
|
51602
|
+
}
|
|
51472
51603
|
async function resolveSandboxesRuntime(dependencies) {
|
|
51473
51604
|
if (dependencies.sandboxes)
|
|
51474
51605
|
return dependencies.sandboxes;
|
|
@@ -52203,7 +52334,7 @@ async function handleRequest(req) {
|
|
|
52203
52334
|
const screenshot = getScreenshot(id);
|
|
52204
52335
|
if (!screenshot)
|
|
52205
52336
|
return errorResponse("Screenshot not found", 404);
|
|
52206
|
-
if (!
|
|
52337
|
+
if (!existsSync11(screenshot.filePath)) {
|
|
52207
52338
|
return errorResponse("Screenshot file not found on disk", 404);
|
|
52208
52339
|
}
|
|
52209
52340
|
const file2 = Bun.file(screenshot.filePath);
|
|
@@ -52652,7 +52783,7 @@ async function handleRequest(req) {
|
|
|
52652
52783
|
}
|
|
52653
52784
|
if (!pathname.startsWith("/api")) {
|
|
52654
52785
|
const dashboardDir = join14(import.meta.dir, "..", "..", "dashboard", "dist");
|
|
52655
|
-
if (!
|
|
52786
|
+
if (!existsSync11(dashboardDir)) {
|
|
52656
52787
|
return new Response(`<!DOCTYPE html>
|
|
52657
52788
|
<html>
|
|
52658
52789
|
<head><title>Open Testers</title></head>
|
|
@@ -52671,7 +52802,7 @@ async function handleRequest(req) {
|
|
|
52671
52802
|
});
|
|
52672
52803
|
}
|
|
52673
52804
|
const filePath = join14(dashboardDir, pathname === "/" ? "index.html" : pathname);
|
|
52674
|
-
if (
|
|
52805
|
+
if (existsSync11(filePath)) {
|
|
52675
52806
|
const file2 = Bun.file(filePath);
|
|
52676
52807
|
return new Response(file2, {
|
|
52677
52808
|
headers: {
|
|
@@ -52681,7 +52812,7 @@ async function handleRequest(req) {
|
|
|
52681
52812
|
});
|
|
52682
52813
|
}
|
|
52683
52814
|
const indexPath = join14(dashboardDir, "index.html");
|
|
52684
|
-
if (
|
|
52815
|
+
if (existsSync11(indexPath)) {
|
|
52685
52816
|
const file2 = Bun.file(indexPath);
|
|
52686
52817
|
return new Response(file2, {
|
|
52687
52818
|
headers: {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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
|
}
|