pi-agent-browser-native 0.2.37 → 0.2.39
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/CHANGELOG.md +22 -0
- package/README.md +19 -6
- package/docs/ARCHITECTURE.md +4 -4
- package/docs/COMMAND_REFERENCE.md +15 -12
- package/docs/RELEASE.md +21 -9
- package/docs/SUPPORT_MATRIX.md +10 -8
- package/docs/TOOL_CONTRACT.md +1 -1
- package/docs/platform-smoke.md +176 -0
- package/extensions/agent-browser/lib/playbook.ts +2 -2
- package/extensions/agent-browser/lib/process.ts +72 -13
- package/package.json +16 -4
- package/platform-smoke.config.mjs +18 -0
- package/scripts/agent-browser-capability-baseline.mjs +9 -6
- package/scripts/platform-smoke/artifacts.mjs +94 -0
- package/scripts/platform-smoke/browser-dogfood-windows.ps1 +110 -0
- package/scripts/platform-smoke/crabbox-runner.mjs +149 -0
- package/scripts/platform-smoke/doctor.mjs +307 -0
- package/scripts/platform-smoke/linux-image/Dockerfile +23 -0
- package/scripts/platform-smoke/platform-build-windows.ps1 +103 -0
- package/scripts/platform-smoke/targets.mjs +471 -0
- package/scripts/platform-smoke.mjs +161 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[Parameter(Mandatory=$true)][string]$AgentBrowserVersion
|
|
3
|
+
)
|
|
4
|
+
|
|
5
|
+
$ErrorActionPreference = "Continue"
|
|
6
|
+
$SourceRoot = (Get-Location).Path
|
|
7
|
+
$RunRoot = Join-Path ".platform-smoke-runs" ("browser-dogfood-{0}-{1}" -f ((Get-Date).ToUniversalTime().ToString("yyyyMMddTHHmmssZ")), $PID)
|
|
8
|
+
$DogfoodDir = Join-Path $SourceRoot (Join-Path $RunRoot "dogfood")
|
|
9
|
+
$DogfoodArtifactDir = Join-Path $env:TEMP ("pi-agent-browser-dogfood-artifacts-{0}" -f $PID)
|
|
10
|
+
New-Item -ItemType Directory -Force -Path $DogfoodDir | Out-Null
|
|
11
|
+
New-Item -ItemType Directory -Force -Path $DogfoodArtifactDir | Out-Null
|
|
12
|
+
|
|
13
|
+
function Write-Section($Name, $Path) {
|
|
14
|
+
Write-Output "--- $Name START ---"
|
|
15
|
+
if (Test-Path $Path) { Get-Content -Raw $Path }
|
|
16
|
+
Write-Output "--- $Name END ---"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function Get-AgentBrowserVersion() {
|
|
20
|
+
if (-not (Get-Command agent-browser -ErrorAction SilentlyContinue)) { return "" }
|
|
21
|
+
return (& agent-browser --version 2>$null)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function Test-AgentBrowser($Version) {
|
|
25
|
+
$Expected = "agent-browser $Version"
|
|
26
|
+
$Current = Get-AgentBrowserVersion
|
|
27
|
+
Write-Output "PLATFORM_AGENT_BROWSER_VERSION=$Current"
|
|
28
|
+
$script:AgentBrowserReadyExit = if ($Current -eq $Expected) { 0 } else { 1 }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function Test-AgentBrowserBrowserCache() {
|
|
32
|
+
$Candidates = @(
|
|
33
|
+
(Join-Path $env:USERPROFILE ".agent-browser\browsers"),
|
|
34
|
+
"C:\WINDOWS\system32\config\systemprofile\.agent-browser\browsers"
|
|
35
|
+
)
|
|
36
|
+
foreach ($Candidate in $Candidates) {
|
|
37
|
+
if (-not $Candidate -or -not (Test-Path $Candidate)) { continue }
|
|
38
|
+
$Chrome = Get-ChildItem -Path $Candidate -Recurse -Filter chrome.exe -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
39
|
+
if ($Chrome) {
|
|
40
|
+
Write-Output "PLATFORM_AGENT_BROWSER_BROWSER_PATH=$($Chrome.FullName)"
|
|
41
|
+
$script:BrowserCacheExit = 0
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
Write-Output "PLATFORM_AGENT_BROWSER_BROWSER_PATH="
|
|
46
|
+
$script:BrowserCacheExit = 1
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
Write-Output "Starting browser-dogfood-smoke in $SourceRoot at $((Get-Date).ToUniversalTime().ToString('o'))"
|
|
50
|
+
Write-Output "PLATFORM_RUN_ROOT=$RunRoot"
|
|
51
|
+
Write-Output "PLATFORM_DOGFOOD_ARTIFACT_DIR=$DogfoodArtifactDir"
|
|
52
|
+
|
|
53
|
+
$NodeVersion = (& node --version 2>$null)
|
|
54
|
+
Write-Output "PLATFORM_NODE_VERSION=$NodeVersion"
|
|
55
|
+
|
|
56
|
+
& npm ci 2>&1
|
|
57
|
+
$NpmCiExit = $LASTEXITCODE
|
|
58
|
+
Write-Output "PLATFORM_NPM_CI_EXIT=$NpmCiExit"
|
|
59
|
+
|
|
60
|
+
$script:AgentBrowserReadyExit = 1
|
|
61
|
+
Test-AgentBrowser $AgentBrowserVersion
|
|
62
|
+
$AgentBrowserExit = $script:AgentBrowserReadyExit
|
|
63
|
+
Write-Output "PLATFORM_AGENT_BROWSER_READY_EXIT=$AgentBrowserExit"
|
|
64
|
+
$script:BrowserCacheExit = 1
|
|
65
|
+
if ($AgentBrowserExit -eq 0) { Test-AgentBrowserBrowserCache }
|
|
66
|
+
$BrowserCacheExit = $script:BrowserCacheExit
|
|
67
|
+
Write-Output "PLATFORM_AGENT_BROWSER_BROWSER_CACHE_EXIT=$BrowserCacheExit"
|
|
68
|
+
$BrowserPrewarmExit = 1
|
|
69
|
+
if ($BrowserCacheExit -eq 0) {
|
|
70
|
+
$PrewarmPath = Join-Path $DogfoodArtifactDir "prewarm.html"
|
|
71
|
+
"<h1>Example Domain</h1>" | Set-Content $PrewarmPath
|
|
72
|
+
$PrewarmUrl = "file:///" + ($PrewarmPath -replace "\\", "/")
|
|
73
|
+
for ($Attempt = 1; $Attempt -le 3; $Attempt++) {
|
|
74
|
+
Write-Output "PLATFORM_AGENT_BROWSER_PREWARM_ATTEMPT=$Attempt"
|
|
75
|
+
& agent-browser open --json --session "platform-smoke-prewarm-$Attempt" $PrewarmUrl 2>&1
|
|
76
|
+
$BrowserPrewarmExit = $LASTEXITCODE
|
|
77
|
+
& agent-browser close --json --session "platform-smoke-prewarm-$Attempt" 2>&1
|
|
78
|
+
if ($BrowserPrewarmExit -eq 0) { break }
|
|
79
|
+
Start-Sleep -Seconds 2
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
Write-Output "PLATFORM_AGENT_BROWSER_PREWARM_EXIT=$BrowserPrewarmExit"
|
|
83
|
+
|
|
84
|
+
$env:PI_AGENT_BROWSER_PROCESS_TIMEOUT_MS = "55000"
|
|
85
|
+
Write-Output "PLATFORM_PI_AGENT_BROWSER_PROCESS_TIMEOUT_MS=$env:PI_AGENT_BROWSER_PROCESS_TIMEOUT_MS"
|
|
86
|
+
|
|
87
|
+
$TsxCli = Join-Path $SourceRoot "node_modules/.bin/tsx.cmd"
|
|
88
|
+
if (-not (Test-Path $TsxCli)) { $TsxCli = Join-Path $SourceRoot "node_modules/.bin/tsx" }
|
|
89
|
+
if (-not (Test-Path $TsxCli)) { $TsxCli = "tsx" }
|
|
90
|
+
Write-Output "PLATFORM_TSX_CLI=$TsxCli"
|
|
91
|
+
|
|
92
|
+
$DogfoodStdout = Join-Path $DogfoodDir "dogfood.stdout.txt"
|
|
93
|
+
$DogfoodStderr = Join-Path $DogfoodDir "dogfood.stderr.txt"
|
|
94
|
+
if ($NpmCiExit -eq 0 -and $AgentBrowserExit -eq 0 -and $BrowserCacheExit -eq 0 -and $BrowserPrewarmExit -eq 0) {
|
|
95
|
+
& $TsxCli "scripts/verify-agent-browser-dogfood.ts" --artifact-dir $DogfoodArtifactDir --json >$DogfoodStdout 2>$DogfoodStderr
|
|
96
|
+
$DogfoodExit = $LASTEXITCODE
|
|
97
|
+
} else {
|
|
98
|
+
"npm ci or agent-browser setup failed" | Set-Content $DogfoodStderr
|
|
99
|
+
$DogfoodExit = 1
|
|
100
|
+
}
|
|
101
|
+
Write-Output "PLATFORM_DOGFOOD_EXIT=$DogfoodExit"
|
|
102
|
+
Write-Section "DOGFOOD_STDOUT" $DogfoodStdout
|
|
103
|
+
Write-Section "DOGFOOD_STDERR" $DogfoodStderr
|
|
104
|
+
|
|
105
|
+
if ($NpmCiExit -ne 0 -or $AgentBrowserExit -ne 0 -or $BrowserCacheExit -ne 0 -or $BrowserPrewarmExit -ne 0 -or $DogfoodExit -ne 0) {
|
|
106
|
+
Write-Output "PLATFORM_BROWSER_DOGFOOD_FAILED"
|
|
107
|
+
exit 1
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
Write-Output "PLATFORM_BROWSER_DOGFOOD_OK"
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** Thin Crabbox CLI wrapper for cross-platform smoke tests. */
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
function env(name) {
|
|
6
|
+
return process.env[name] ?? "";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function crabboxBin() {
|
|
10
|
+
return process.env.PLATFORM_SMOKE_CRABBOX || "crabbox";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function packageSlug(config = {}) {
|
|
14
|
+
return process.env.PLATFORM_SMOKE_PACKAGE_SLUG || config.packageName || "pi-agent-browser-native";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildTargetBaseArgs(targetName, config = {}) {
|
|
18
|
+
switch (targetName) {
|
|
19
|
+
case "macos": {
|
|
20
|
+
const user = env("PLATFORM_SMOKE_MAC_USER") || env("USER");
|
|
21
|
+
const host = env("PLATFORM_SMOKE_MAC_HOST") || "localhost";
|
|
22
|
+
const workRoot = env("PLATFORM_SMOKE_MAC_WORK_ROOT") || `/Users/${user}/crabbox/${packageSlug(config)}`;
|
|
23
|
+
return [
|
|
24
|
+
"--provider", "ssh",
|
|
25
|
+
"--target", "macos",
|
|
26
|
+
"--static-host", host,
|
|
27
|
+
"--static-user", user,
|
|
28
|
+
"--static-port", "22",
|
|
29
|
+
"--static-work-root", workRoot,
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
case "ubuntu": {
|
|
33
|
+
const image = env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config.ubuntuContainerImage || "pi-agent-browser-native-platform:node24-agent-browser0.27.1";
|
|
34
|
+
return [
|
|
35
|
+
"--provider", "local-container",
|
|
36
|
+
"--target", "linux",
|
|
37
|
+
"--local-container-image", image,
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
case "windows-native": {
|
|
41
|
+
const vm = env("PLATFORM_SMOKE_WINDOWS_VM") || "pi-extension-windows-template";
|
|
42
|
+
const snapshot = env("PLATFORM_SMOKE_WINDOWS_SNAPSHOT") || "crabbox-ready";
|
|
43
|
+
const user = env("PLATFORM_SMOKE_WINDOWS_USER") || env("USER");
|
|
44
|
+
const workRoot = env("PLATFORM_SMOKE_WINDOWS_WORK_ROOT") || `C:\\crabbox\\${packageSlug(config)}`;
|
|
45
|
+
return [
|
|
46
|
+
"--provider", "parallels",
|
|
47
|
+
"--target", "windows",
|
|
48
|
+
"--windows-mode", "normal",
|
|
49
|
+
"--parallels-source", vm,
|
|
50
|
+
"--parallels-source-snapshot", snapshot,
|
|
51
|
+
"--parallels-user", user,
|
|
52
|
+
"--parallels-work-root", workRoot,
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
default:
|
|
56
|
+
throw new Error(`unknown platform smoke target: ${targetName}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function leaseIdFor(targetName, slug) {
|
|
61
|
+
if (targetName === "macos") return "static_localhost";
|
|
62
|
+
return slug;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseLeaseId(text) {
|
|
66
|
+
return text.match(/\bleased\s+(\S+)/)?.[1]
|
|
67
|
+
?? text.match(/\blease=(\S+)/)?.[1]
|
|
68
|
+
?? null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function execCrabbox(args, options = {}) {
|
|
72
|
+
return new Promise((resolvePromise) => {
|
|
73
|
+
const child = spawn(crabboxBin(), args, {
|
|
74
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
75
|
+
env: { ...process.env, CRABBOX_SYNC_GIT_SEED: "false", ...options.env },
|
|
76
|
+
});
|
|
77
|
+
const stdout = [];
|
|
78
|
+
const stderr = [];
|
|
79
|
+
let timeout;
|
|
80
|
+
let killTimeout;
|
|
81
|
+
if (options.timeout) {
|
|
82
|
+
timeout = setTimeout(() => {
|
|
83
|
+
stderr.push(Buffer.from(`\n[platform-smoke] crabbox timed out after ${options.timeout}ms\n`));
|
|
84
|
+
try { child.kill("SIGTERM"); } catch {}
|
|
85
|
+
killTimeout = setTimeout(() => {
|
|
86
|
+
try { child.kill("SIGKILL"); } catch {}
|
|
87
|
+
}, 10_000);
|
|
88
|
+
}, options.timeout);
|
|
89
|
+
}
|
|
90
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
91
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
92
|
+
child.on("error", (error) => {
|
|
93
|
+
if (timeout) clearTimeout(timeout);
|
|
94
|
+
if (killTimeout) clearTimeout(killTimeout);
|
|
95
|
+
resolvePromise({ stdout: Buffer.concat(stdout).toString(), stderr: `${Buffer.concat(stderr).toString()}${error.message}\n`, code: 1, signal: null });
|
|
96
|
+
});
|
|
97
|
+
child.on("close", (code, signal) => {
|
|
98
|
+
if (timeout) clearTimeout(timeout);
|
|
99
|
+
if (killTimeout) clearTimeout(killTimeout);
|
|
100
|
+
resolvePromise({ stdout: Buffer.concat(stdout).toString(), stderr: Buffer.concat(stderr).toString(), code: code ?? (signal ? 1 : 0), signal });
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isRetryableWarmupFailure(targetName, result) {
|
|
106
|
+
if (targetName !== "windows-native" || result.code === 0) return false;
|
|
107
|
+
return /Could not create a linked clone of the virtual hard disk|due to an internal error|context canceled|timed out after 300000ms/i.test(`${result.stdout}\n${result.stderr}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function warmupLease(targetName, slug, config = {}) {
|
|
111
|
+
const args = ["warmup", ...buildTargetBaseArgs(targetName, config), "--slug", slug, "--keep"];
|
|
112
|
+
let result;
|
|
113
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
114
|
+
console.log(` [crabbox] ${args.join(" ")}${attempt > 1 ? ` (retry ${attempt})` : ""}`);
|
|
115
|
+
result = await execCrabbox(args, { timeout: 300_000 });
|
|
116
|
+
if (!isRetryableWarmupFailure(targetName, result)) break;
|
|
117
|
+
await cleanupStaleTargetState(targetName, config);
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
...result,
|
|
121
|
+
ok: result.code === 0,
|
|
122
|
+
leaseId: parseLeaseId(`${result.stdout}\n${result.stderr}`) ?? leaseIdFor(targetName, slug),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function runOnLease(targetName, leaseId, command, options = {}) {
|
|
127
|
+
const args = ["run", ...buildTargetBaseArgs(targetName, options.config ?? {}), "--id", leaseId];
|
|
128
|
+
for (const name of options.allowEnv ?? []) {
|
|
129
|
+
args.push("--allow-env", name);
|
|
130
|
+
}
|
|
131
|
+
if (options.sync === false) args.push("--no-sync");
|
|
132
|
+
else args.push("--fresh-sync");
|
|
133
|
+
args.push("--shell", command);
|
|
134
|
+
console.log(` [crabbox] run ${targetName} ${options.sync === false ? "--no-sync" : "--fresh-sync"}`);
|
|
135
|
+
return execCrabbox(args, { timeout: options.timeout ?? 900_000 });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function stopLease(targetName, leaseId, config = {}) {
|
|
139
|
+
const args = ["stop", ...buildTargetBaseArgs(targetName, config), "--id", leaseId];
|
|
140
|
+
console.log(` [crabbox] ${args.join(" ")}`);
|
|
141
|
+
return execCrabbox(args, { timeout: 90_000 });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function cleanupStaleTargetState(targetName, config = {}) {
|
|
145
|
+
if (targetName === "macos") return null;
|
|
146
|
+
const args = ["cleanup", ...buildTargetBaseArgs(targetName, config)];
|
|
147
|
+
console.log(` [crabbox] ${args.join(" ")}`);
|
|
148
|
+
return execCrabbox(args, { timeout: 120_000 });
|
|
149
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/** Platform smoke doctor. Fails before target runs when Crabbox/platform setup is missing. */
|
|
2
|
+
|
|
3
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
4
|
+
import { accessSync, constants, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
function env(name) {
|
|
8
|
+
return process.env[name] ?? "";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function ok(label) {
|
|
12
|
+
console.log(` ✓ ${label}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function warn(label) {
|
|
16
|
+
console.log(` ⚠ ${label}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fail(label, failures) {
|
|
20
|
+
console.error(` ✗ ${label}`);
|
|
21
|
+
failures.count += 1;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function silent(cmd, args, options = {}) {
|
|
25
|
+
try {
|
|
26
|
+
return execFileSync(cmd, args, { timeout: 20_000, stdio: "pipe", ...options }).toString().trim();
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function shell(command, options = {}) {
|
|
33
|
+
try {
|
|
34
|
+
return execSync(command, { timeout: 20_000, stdio: "pipe", ...options }).toString().trim();
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hasCommand(name) {
|
|
41
|
+
return silent("which", [name]) !== null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function commandPath(name) {
|
|
45
|
+
return silent("which", [name]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseVersion(version) {
|
|
49
|
+
const match = String(version).match(/\d+(?:\.\d+){0,2}/);
|
|
50
|
+
return match ? match[0].split(".").map((part) => Number(part)) : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function versionAtLeast(actual, minimum) {
|
|
54
|
+
const parsedActual = parseVersion(actual);
|
|
55
|
+
const parsedMinimum = parseVersion(minimum);
|
|
56
|
+
if (!parsedActual || !parsedMinimum) return false;
|
|
57
|
+
for (let index = 0; index < Math.max(parsedActual.length, parsedMinimum.length); index += 1) {
|
|
58
|
+
const a = parsedActual[index] ?? 0;
|
|
59
|
+
const b = parsedMinimum[index] ?? 0;
|
|
60
|
+
if (a > b) return true;
|
|
61
|
+
if (a < b) return false;
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isForbiddenProjectPath(path) {
|
|
67
|
+
return /(^|\/)\.env(?:\..*)?$/.test(path)
|
|
68
|
+
|| /(^|\/)[^/]+\.tgz$/.test(path)
|
|
69
|
+
|| /(^|\/)\.artifacts(?:\/|$)/.test(path)
|
|
70
|
+
|| /(^|\/)\.crabbox(?:\/|$)/.test(path)
|
|
71
|
+
|| /(^|\/)\.debug(?:\/|$)/.test(path)
|
|
72
|
+
|| /(^|\/)\.platform-smoke-runs(?:\/|$)/.test(path);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function npmPackFiles() {
|
|
76
|
+
const output = silent("npm", ["pack", "--dry-run", "--json"]);
|
|
77
|
+
if (!output) return null;
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(output);
|
|
80
|
+
return parsed[0]?.files?.map((file) => file.path) ?? [];
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function checkForbiddenProjectFiles(failures) {
|
|
87
|
+
const tracked = shell("git ls-files")?.split(/\r?\n/).filter(Boolean) ?? [];
|
|
88
|
+
const trackedForbidden = tracked.filter(isForbiddenProjectPath);
|
|
89
|
+
if (trackedForbidden.length === 0) ok("tracked source files exclude forbidden local artifacts");
|
|
90
|
+
else fail(`forbidden tracked source path(s): ${trackedForbidden.join(", ")}`, failures);
|
|
91
|
+
|
|
92
|
+
const localForbidden = shell("find . -maxdepth 2 \\( -name '.env' -o -name '.env.*' -o -name '*.tgz' \\) -not -path './node_modules/*' 2>/dev/null")
|
|
93
|
+
?.split(/\r?\n/).filter(Boolean) ?? [];
|
|
94
|
+
if (localForbidden.length === 0) ok("no local .env or package tarball artifacts at repo top level");
|
|
95
|
+
else fail(`forbidden local artifact(s): ${localForbidden.join(", ")}`, failures);
|
|
96
|
+
|
|
97
|
+
const packFiles = npmPackFiles();
|
|
98
|
+
if (!packFiles) {
|
|
99
|
+
fail("could not inspect npm pack contents", failures);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const packedForbidden = packFiles.filter(isForbiddenProjectPath);
|
|
103
|
+
if (packedForbidden.length === 0) ok("npm package excludes forbidden local artifacts");
|
|
104
|
+
else fail(`forbidden npm package path(s): ${packedForbidden.join(", ")}`, failures);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function crabboxProviders(cbox) {
|
|
108
|
+
const output = silent(cbox, ["providers"]);
|
|
109
|
+
if (!output) return [];
|
|
110
|
+
return output.split(/\r?\n/)
|
|
111
|
+
.filter((line) => /^\S/.test(line))
|
|
112
|
+
.map((line) => line.trim().split(/\s+/)[0])
|
|
113
|
+
.filter(Boolean);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function checkRequiredProviders(cbox, failures) {
|
|
117
|
+
const providers = crabboxProviders(cbox);
|
|
118
|
+
if (providers.length === 0) {
|
|
119
|
+
fail("could not read crabbox providers", failures);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
for (const provider of ["ssh", "local-container", "parallels"]) {
|
|
123
|
+
if (providers.includes(provider)) ok(`crabbox provider available: ${provider}`);
|
|
124
|
+
else fail(`crabbox provider missing: ${provider}`, failures);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function checkCrabboxProvider(cbox, args, label, failures) {
|
|
129
|
+
const output = silent(cbox, ["doctor", ...args, "--json"]);
|
|
130
|
+
if (!output) {
|
|
131
|
+
fail(`${label} crabbox doctor failed`, failures);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(output);
|
|
136
|
+
if (parsed.ok) ok(`${label} provider OK`);
|
|
137
|
+
else fail(`${label} provider not ready: ${parsed.error ?? "unknown error"}`, failures);
|
|
138
|
+
} catch {
|
|
139
|
+
warn(`${label} provider returned non-JSON doctor output`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function checkAgentBrowserVersion(expectedVersion, failures, command = "agent-browser") {
|
|
144
|
+
const version = shell(`${command} --version`);
|
|
145
|
+
if (!version) {
|
|
146
|
+
fail(`${command} not found or did not report a version`, failures);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const firstLine = version.split(/\r?\n/)[0];
|
|
150
|
+
if (!expectedVersion || firstLine.includes(expectedVersion)) ok(`${command}: ${firstLine}`);
|
|
151
|
+
else fail(`${command} version ${firstLine} does not match expected ${expectedVersion}`, failures);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function runDoctor(config) {
|
|
155
|
+
const failures = { count: 0 };
|
|
156
|
+
const packageName = config?.packageName ?? "pi-agent-browser-native";
|
|
157
|
+
const artifactRoot = config?.artifactRoot ?? ".artifacts/platform-smoke";
|
|
158
|
+
const nodeMajor = config?.nodeValidationMajor ?? 22;
|
|
159
|
+
const agentBrowserVersion = config?.agentBrowserVersion;
|
|
160
|
+
|
|
161
|
+
console.log("\n── Platform smoke config ──");
|
|
162
|
+
ok(`package: ${packageName}`);
|
|
163
|
+
ok(`targets: ${(config?.requiredTargets ?? []).join(", ")}`);
|
|
164
|
+
ok(`suites: ${(config?.requiredSuites ?? []).join(", ")}`);
|
|
165
|
+
ok(`agent-browser baseline: ${agentBrowserVersion ?? "not configured"}`);
|
|
166
|
+
|
|
167
|
+
console.log("\n── Crabbox binary ──");
|
|
168
|
+
const cbox = env("PLATFORM_SMOKE_CRABBOX") || "crabbox";
|
|
169
|
+
const cboxPath = env("PLATFORM_SMOKE_CRABBOX") || commandPath("crabbox");
|
|
170
|
+
if (!cboxPath) {
|
|
171
|
+
fail("crabbox not found on PATH; install with Homebrew or set PLATFORM_SMOKE_CRABBOX", failures);
|
|
172
|
+
} else {
|
|
173
|
+
if (env("PLATFORM_SMOKE_CRABBOX")) {
|
|
174
|
+
try {
|
|
175
|
+
accessSync(cboxPath, constants.X_OK);
|
|
176
|
+
ok(`binary: ${cboxPath}`);
|
|
177
|
+
} catch {
|
|
178
|
+
fail(`${cboxPath} is not executable`, failures);
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
ok(`binary: ${cboxPath}`);
|
|
182
|
+
}
|
|
183
|
+
const version = silent(cbox, ["--version"]);
|
|
184
|
+
if (version) {
|
|
185
|
+
const displayVersion = version.split(/\r?\n/)[0];
|
|
186
|
+
ok(`version: ${displayVersion}`);
|
|
187
|
+
const minVersion = config?.requiredCrabbox?.minVersion;
|
|
188
|
+
if (minVersion) {
|
|
189
|
+
if (versionAtLeast(displayVersion, minVersion)) ok(`version ${displayVersion} >= ${minVersion}`);
|
|
190
|
+
else fail(`Crabbox version ${displayVersion} < ${minVersion}`, failures);
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
fail("could not read Crabbox version", failures);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
console.log("\n── Host tools ──");
|
|
198
|
+
for (const [name, command] of [["node", "node --version"], ["npm", "npm --version"], ["git", "git --version"], ["tar", "tar --version"]]) {
|
|
199
|
+
const output = shell(command);
|
|
200
|
+
if (!output) fail(`${name} not found`, failures);
|
|
201
|
+
else ok(`${name}: ${output.split(/\r?\n/)[0]}`);
|
|
202
|
+
}
|
|
203
|
+
const localNode = shell("node --version");
|
|
204
|
+
const localNodeMajor = Number(localNode?.replace(/^v/, "").split(".")[0] ?? 0);
|
|
205
|
+
if (localNodeMajor >= nodeMajor) ok(`host Node major ${localNodeMajor} >= ${nodeMajor}`);
|
|
206
|
+
else fail(`host Node major ${localNodeMajor || "unknown"} < ${nodeMajor}`, failures);
|
|
207
|
+
checkAgentBrowserVersion(agentBrowserVersion, failures);
|
|
208
|
+
|
|
209
|
+
console.log("\n── Crabbox providers ──");
|
|
210
|
+
if (cboxPath) {
|
|
211
|
+
checkRequiredProviders(cbox, failures);
|
|
212
|
+
const ubuntuImage = env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config?.ubuntuContainerImage || "pi-agent-browser-native-platform:node24-agent-browser0.27.1";
|
|
213
|
+
checkCrabboxProvider(cbox, ["--provider", "local-container", "--local-container-image", ubuntuImage], "ubuntu local-container", failures);
|
|
214
|
+
const macUser = env("PLATFORM_SMOKE_MAC_USER") || env("USER");
|
|
215
|
+
const macHost = env("PLATFORM_SMOKE_MAC_HOST") || "localhost";
|
|
216
|
+
const macRoot = env("PLATFORM_SMOKE_MAC_WORK_ROOT") || `/Users/${macUser}/crabbox/${packageName}`;
|
|
217
|
+
checkCrabboxProvider(cbox, ["--provider", "ssh", "--target", "macos", "--static-host", macHost, "--static-user", macUser, "--static-port", "22", "--static-work-root", macRoot], "macOS ssh", failures);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
console.log("\n── Docker / Ubuntu ──");
|
|
221
|
+
const dockerVersion = shell("docker info --format '{{.ServerVersion}}'");
|
|
222
|
+
if (dockerVersion) ok(`Docker ${dockerVersion}`);
|
|
223
|
+
else fail("Docker is not available or not running", failures);
|
|
224
|
+
const ubuntuImage = env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config?.ubuntuContainerImage || "pi-agent-browser-native-platform:node24-agent-browser0.27.1";
|
|
225
|
+
ok(`Ubuntu image: ${ubuntuImage}`);
|
|
226
|
+
|
|
227
|
+
console.log("\n── macOS SSH ──");
|
|
228
|
+
const sshUser = env("PLATFORM_SMOKE_MAC_USER") || env("USER");
|
|
229
|
+
const sshHost = env("PLATFORM_SMOKE_MAC_HOST") || "localhost";
|
|
230
|
+
const sshProbe = shell(`ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${sshUser}@${sshHost} 'node --version && npm --version && git --version && agent-browser --version'`);
|
|
231
|
+
if (sshProbe) {
|
|
232
|
+
ok(`SSH ${sshUser}@${sshHost}: ${sshProbe.split(/\r?\n/).join(" | ")}`);
|
|
233
|
+
if (agentBrowserVersion && !sshProbe.includes(agentBrowserVersion)) fail(`macOS SSH agent-browser does not match expected ${agentBrowserVersion}`, failures);
|
|
234
|
+
} else {
|
|
235
|
+
fail(`SSH probe failed for ${sshUser}@${sshHost}`, failures);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if ((config?.requiredTargets ?? []).includes("windows-native")) {
|
|
239
|
+
console.log("\n── Windows native / Parallels ──");
|
|
240
|
+
if (!hasCommand("prlctl")) {
|
|
241
|
+
fail("prlctl not found", failures);
|
|
242
|
+
} else {
|
|
243
|
+
ok("prlctl found");
|
|
244
|
+
const vmName = env("PLATFORM_SMOKE_WINDOWS_VM") || "pi-extension-windows-template";
|
|
245
|
+
const snapshot = env("PLATFORM_SMOKE_WINDOWS_SNAPSHOT") || "crabbox-ready";
|
|
246
|
+
const user = env("PLATFORM_SMOKE_WINDOWS_USER") || env("USER");
|
|
247
|
+
const workRoot = env("PLATFORM_SMOKE_WINDOWS_WORK_ROOT") || `C:\\crabbox\\${packageName}`;
|
|
248
|
+
const list = shell("prlctl list -a --no-header 2>/dev/null");
|
|
249
|
+
if (!list) {
|
|
250
|
+
fail("prlctl list returned no VMs", failures);
|
|
251
|
+
} else if (!list.includes(vmName)) {
|
|
252
|
+
fail(`Windows VM ${vmName} not found`, failures);
|
|
253
|
+
} else {
|
|
254
|
+
ok(`Windows VM ${vmName} found`);
|
|
255
|
+
const status = shell(`prlctl status "${vmName.replace(/"/g, "\\\"")}" 2>/dev/null`);
|
|
256
|
+
if (/\bstopped\b/i.test(status ?? "")) ok(`Windows source VM ${vmName} is stopped`);
|
|
257
|
+
else fail(`Windows source VM ${vmName} must be stopped for forkable snapshot use; current status: ${status ?? "unknown"}`, failures);
|
|
258
|
+
const snapshotsJson = shell(`prlctl snapshot-list "${vmName.replace(/"/g, "\\\"")}" -j 2>/dev/null`);
|
|
259
|
+
let snapshotMatch = null;
|
|
260
|
+
try {
|
|
261
|
+
const snapshots = JSON.parse(snapshotsJson ?? "{}");
|
|
262
|
+
snapshotMatch = Object.entries(snapshots).find(([id, data]) => id === snapshot || data?.name === snapshot);
|
|
263
|
+
} catch {
|
|
264
|
+
// Fall through to the failure below.
|
|
265
|
+
}
|
|
266
|
+
if (snapshotMatch) {
|
|
267
|
+
ok(`snapshot ${snapshot} found`);
|
|
268
|
+
const snapshotState = snapshotMatch[1]?.state ?? "unknown";
|
|
269
|
+
if (snapshotState === "poweroff") ok(`snapshot ${snapshot} state is poweroff`);
|
|
270
|
+
else fail(`snapshot ${snapshot} must be poweroff; current snapshot state: ${snapshotState}`, failures);
|
|
271
|
+
} else {
|
|
272
|
+
fail(`snapshot ${snapshot} not found on ${vmName}`, failures);
|
|
273
|
+
}
|
|
274
|
+
checkCrabboxProvider(cbox, ["--provider", "parallels", "--target", "windows", "--windows-mode", "normal", "--parallels-source", vmName, "--parallels-source-snapshot", snapshot, "--parallels-user", user, "--parallels-work-root", workRoot], "windows parallels", failures);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
console.log("\n── Windows native / Parallels ──");
|
|
279
|
+
warn("windows-native is not listed in requiredTargets for this configuration");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
console.log("\n── Artifact root ──");
|
|
283
|
+
const artRoot = resolve(process.cwd(), artifactRoot);
|
|
284
|
+
try {
|
|
285
|
+
mkdirSync(artRoot, { recursive: true });
|
|
286
|
+
const probe = resolve(artRoot, ".doctor-write-test");
|
|
287
|
+
writeFileSync(probe, "ok");
|
|
288
|
+
unlinkSync(probe);
|
|
289
|
+
ok(`writable: ${artRoot}`);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
fail(`artifact root not writable: ${error.message}`, failures);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
console.log("\n── Repository hygiene ──");
|
|
295
|
+
const status = shell("git status --short");
|
|
296
|
+
if (status) warn(`${status.split(/\r?\n/).length} uncommitted change(s) recorded for smoke evidence`);
|
|
297
|
+
else ok("git status clean");
|
|
298
|
+
checkForbiddenProjectFiles(failures);
|
|
299
|
+
|
|
300
|
+
console.log(`\n=== Results: ${failures.count} failure(s) ===`);
|
|
301
|
+
if (failures.count > 0) {
|
|
302
|
+
console.log("Fix doctor failures before running smoke:platform:all.");
|
|
303
|
+
process.exitCode = 1;
|
|
304
|
+
} else {
|
|
305
|
+
console.log("Platform smoke setup is ready.");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Local Crabbox Ubuntu/Linux target image for pi-agent-browser-native platform smoke.
|
|
2
|
+
# Build with:
|
|
3
|
+
# docker build -t pi-agent-browser-native-platform:node24-agent-browser0.27.1 \
|
|
4
|
+
# --build-arg AGENT_BROWSER_VERSION=0.27.1 \
|
|
5
|
+
# -f scripts/platform-smoke/linux-image/Dockerfile .
|
|
6
|
+
|
|
7
|
+
FROM node:24-bookworm
|
|
8
|
+
|
|
9
|
+
ARG AGENT_BROWSER_VERSION=0.27.1
|
|
10
|
+
|
|
11
|
+
USER root
|
|
12
|
+
RUN apt-get update \
|
|
13
|
+
&& apt-get install -y --no-install-recommends ca-certificates chromium fonts-liberation git openssh-client tar \
|
|
14
|
+
&& npm install -g "agent-browser@${AGENT_BROWSER_VERSION}" \
|
|
15
|
+
&& groupadd -g 1002 circleci \
|
|
16
|
+
&& useradd -m -u 1001 -g 1002 -s /bin/bash circleci \
|
|
17
|
+
&& mkdir -p /work/crabbox /home/circleci/.cache \
|
|
18
|
+
&& chown -R circleci:circleci /work /home/circleci \
|
|
19
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
20
|
+
|
|
21
|
+
USER circleci
|
|
22
|
+
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
|
|
23
|
+
ENV AGENT_BROWSER_PLATFORM_SMOKE_IMAGE=1
|