Use real desktop stack in tests

This commit is contained in:
Nathan Flurry 2026-03-08 13:35:38 -07:00
parent 5e20011fd1
commit 153480256a
8 changed files with 305 additions and 435 deletions

View file

@ -17,12 +17,25 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
FROM node:22-bookworm-slim FROM node:22-bookworm-slim
RUN apt-get update -qq && \ RUN apt-get update -qq && \
apt-get install -y -qq --no-install-recommends ca-certificates bash libstdc++6 > /dev/null 2>&1 && \ apt-get install -y -qq --no-install-recommends \
ca-certificates \
bash \
libstdc++6 \
xvfb \
openbox \
xdotool \
imagemagick \
x11-xserver-utils \
dbus-x11 \
xauth \
fonts-dejavu-core \
xterm \
> /dev/null 2>&1 && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
COPY --from=builder /sandbox-agent /usr/local/bin/sandbox-agent COPY --from=builder /sandbox-agent /usr/local/bin/sandbox-agent
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT ["sandbox-agent"] ENTRYPOINT ["/usr/local/bin/sandbox-agent"]
CMD ["server", "--host", "0.0.0.0", "--port", "3000", "--no-token"] CMD ["server", "--host", "0.0.0.0", "--port", "3000", "--no-token"]

View file

@ -2,149 +2,149 @@
import { act } from "react"; import { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { SandboxAgent } from "sandbox-agent"; import { SandboxAgent } from "sandbox-agent";
import {
createDockerTestLayout,
disposeDockerTestLayout,
startDockerSandboxAgent,
type DockerSandboxAgentHandle,
} from "../../../../../../sdks/typescript/tests/helpers/docker.ts";
import DesktopTab from "./DesktopTab"; import DesktopTab from "./DesktopTab";
type MockDesktopClient = Pick< type DockerTestLayout = ReturnType<typeof createDockerTestLayout>;
SandboxAgent,
"getDesktopStatus" | "startDesktop" | "stopDesktop" | "takeDesktopScreenshot"
>;
describe("DesktopTab", () => { function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitFor<T>(
fn: () => T | undefined | null,
timeoutMs = 20_000,
stepMs = 50,
): Promise<T> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const value = fn();
if (value !== undefined && value !== null) {
return value;
}
await sleep(stepMs);
}
throw new Error("timed out waiting for condition");
}
function findButton(container: HTMLElement, label: string): HTMLButtonElement | undefined {
return Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes(label),
) as HTMLButtonElement | undefined;
}
describe.sequential("DesktopTab", () => {
let container: HTMLDivElement; let container: HTMLDivElement;
let root: Root; let root: Root;
let createObjectUrl: ReturnType<typeof vi.fn>; let layout: DockerTestLayout | undefined;
let revokeObjectUrl: ReturnType<typeof vi.fn>; let handle: DockerSandboxAgentHandle | undefined;
let client: SandboxAgent | undefined;
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
root = createRoot(container); root = createRoot(container);
createObjectUrl = vi.fn(() => "blob:test-screenshot");
revokeObjectUrl = vi.fn();
vi.stubGlobal(
"URL",
Object.assign(URL, {
createObjectURL: createObjectUrl,
revokeObjectURL: revokeObjectUrl,
}),
);
}); });
afterEach(async () => { afterEach(async () => {
await act(async () => { await act(async () => {
root.unmount(); root.unmount();
}); });
if (client) {
await client.stopDesktop().catch(() => {});
await client.dispose().catch(() => {});
}
if (handle) {
await handle.dispose();
}
if (layout) {
disposeDockerTestLayout(layout);
}
container.remove(); container.remove();
vi.runOnlyPendingTimers(); delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
vi.useRealTimers(); client = undefined;
vi.unstubAllGlobals(); handle = undefined;
layout = undefined;
}); });
async function connectDesktopClient(options?: { pathMode?: "merge" | "replace" }): Promise<SandboxAgent> {
layout = createDockerTestLayout();
handle = await startDockerSandboxAgent(layout, {
timeoutMs: 30_000,
pathMode: options?.pathMode,
env: options?.pathMode === "replace"
? { PATH: layout.rootDir }
: undefined,
});
client = await SandboxAgent.connect({
baseUrl: handle.baseUrl,
token: handle.token,
});
return client;
}
it("renders install remediation when desktop deps are missing", async () => { it("renders install remediation when desktop deps are missing", async () => {
const client = { const connectedClient = await connectDesktopClient({ pathMode: "replace" });
getDesktopStatus: vi.fn().mockResolvedValue({
state: "install_required",
display: null,
resolution: null,
startedAt: null,
lastError: {
code: "desktop_dependencies_missing",
message: "Desktop dependencies are not installed",
},
missingDependencies: ["Xvfb", "openbox"],
installCommand: "sandbox-agent install desktop --yes",
processes: [],
runtimeLogPath: "/tmp/runtime.log",
}),
startDesktop: vi.fn(),
stopDesktop: vi.fn(),
takeDesktopScreenshot: vi.fn(),
} as unknown as MockDesktopClient;
await act(async () => { await act(async () => {
root.render(<DesktopTab getClient={() => client as unknown as SandboxAgent} />); root.render(<DesktopTab getClient={() => connectedClient} />);
});
await waitFor(() => {
const text = container.textContent ?? "";
return text.includes("install_required") ? text : undefined;
}); });
expect(container.textContent).toContain("install_required"); expect(container.textContent).toContain("install_required");
expect(container.textContent).toContain("sandbox-agent install desktop --yes"); expect(container.textContent).toContain("sandbox-agent install desktop --yes");
expect(container.textContent).toContain("Xvfb"); expect(container.textContent).toContain("Xvfb");
expect(client.getDesktopStatus).toHaveBeenCalledTimes(1);
}); });
it("starts desktop, refreshes screenshot, and stops desktop", async () => { it("starts desktop, refreshes screenshot, and stops desktop", async () => {
const client = { const connectedClient = await connectDesktopClient();
getDesktopStatus: vi.fn().mockResolvedValue({
state: "inactive",
display: null,
resolution: null,
startedAt: null,
lastError: null,
missingDependencies: [],
installCommand: null,
processes: [],
runtimeLogPath: null,
}),
startDesktop: vi.fn().mockResolvedValue({
state: "active",
display: ":99",
resolution: { width: 1440, height: 900, dpi: 96 },
startedAt: "2026-03-07T00:00:00Z",
lastError: null,
missingDependencies: [],
installCommand: null,
processes: [],
runtimeLogPath: "/tmp/runtime.log",
}),
stopDesktop: vi.fn().mockResolvedValue({
state: "inactive",
display: null,
resolution: null,
startedAt: null,
lastError: null,
missingDependencies: [],
installCommand: null,
processes: [],
runtimeLogPath: "/tmp/runtime.log",
}),
takeDesktopScreenshot: vi.fn().mockResolvedValue(
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
),
} as unknown as MockDesktopClient;
await act(async () => { await act(async () => {
root.render(<DesktopTab getClient={() => client as unknown as SandboxAgent} />); root.render(<DesktopTab getClient={() => connectedClient} />);
}); });
const startButton = Array.from(container.querySelectorAll("button")).find((button) => await waitFor(() => {
button.textContent?.includes("Start Desktop"), const text = container.textContent ?? "";
); return text.includes("inactive") ? true : undefined;
expect(startButton).toBeTruthy(); });
const startButton = await waitFor(() => findButton(container, "Start Desktop"));
await act(async () => { await act(async () => {
startButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); startButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await vi.runAllTimersAsync(); });
await waitFor(() => {
const screenshot = container.querySelector("img[alt='Desktop screenshot']") as HTMLImageElement | null;
return screenshot?.src ? screenshot : undefined;
}); });
expect(client.startDesktop).toHaveBeenCalledTimes(1);
expect(client.takeDesktopScreenshot).toHaveBeenCalled();
const screenshot = container.querySelector("img[alt='Desktop screenshot']") as HTMLImageElement | null; const screenshot = container.querySelector("img[alt='Desktop screenshot']") as HTMLImageElement | null;
expect(screenshot?.src).toContain("blob:test-screenshot"); expect(screenshot).toBeTruthy();
expect(screenshot?.src.startsWith("blob:") || screenshot?.src.startsWith("data:image/png")).toBe(true);
const stopButton = Array.from(container.querySelectorAll("button")).find((button) => expect(container.textContent).toContain("active");
button.textContent?.includes("Stop Desktop"),
);
expect(stopButton).toBeTruthy();
const stopButton = await waitFor(() => findButton(container, "Stop Desktop"));
await act(async () => { await act(async () => {
stopButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); stopButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await vi.runAllTimersAsync(); });
await waitFor(() => {
const text = container.textContent ?? "";
return text.includes("inactive") ? true : undefined;
}); });
expect(client.stopDesktop).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain("inactive"); expect(container.textContent).toContain("inactive");
}); });
}); });

View file

@ -22,6 +22,29 @@ const formatStartedAt = (value: string | null | undefined): string => {
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString(); return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}; };
const createScreenshotUrl = async (bytes: Uint8Array): Promise<string> => {
const payload = new Uint8Array(bytes.byteLength);
payload.set(bytes);
const blob = new Blob([payload.buffer], { type: "image/png" });
if (typeof URL.createObjectURL === "function") {
return URL.createObjectURL(blob);
}
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error("Unable to read screenshot blob."));
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Unable to read screenshot blob."));
}
};
reader.readAsDataURL(blob);
});
};
const DesktopTab = ({ const DesktopTab = ({
getClient, getClient,
}: { }: {
@ -43,7 +66,7 @@ const DesktopTab = ({
const revokeScreenshotUrl = useCallback(() => { const revokeScreenshotUrl = useCallback(() => {
setScreenshotUrl((current) => { setScreenshotUrl((current) => {
if (current) { if (current?.startsWith("blob:") && typeof URL.revokeObjectURL === "function") {
URL.revokeObjectURL(current); URL.revokeObjectURL(current);
} }
return null; return null;
@ -76,10 +99,7 @@ const DesktopTab = ({
try { try {
const bytes = await getClient().takeDesktopScreenshot(); const bytes = await getClient().takeDesktopScreenshot();
revokeScreenshotUrl(); revokeScreenshotUrl();
const payload = new Uint8Array(bytes.byteLength); setScreenshotUrl(await createScreenshotUrl(bytes));
payload.set(bytes);
const blob = new Blob([payload.buffer], { type: "image/png" });
setScreenshotUrl(URL.createObjectURL(blob));
} catch (captureError) { } catch (captureError) {
revokeScreenshotUrl(); revokeScreenshotUrl();
setScreenshotError(extractErrorMessage(captureError, "Unable to capture desktop screenshot.")); setScreenshotError(extractErrorMessage(captureError, "Unable to capture desktop screenshot."));

View file

@ -28,6 +28,7 @@ export type DockerSandboxAgentHandle = {
export type DockerSandboxAgentOptions = { export type DockerSandboxAgentOptions = {
env?: Record<string, string>; env?: Record<string, string>;
pathMode?: "merge" | "replace";
timeoutMs?: number; timeoutMs?: number;
}; };
@ -107,7 +108,7 @@ export async function startDockerSandboxAgent(
): Promise<DockerSandboxAgentHandle> { ): Promise<DockerSandboxAgentHandle> {
const image = ensureImage(); const image = ensureImage();
const containerId = uniqueContainerId(); const containerId = uniqueContainerId();
const env = buildEnv(layout, options.env ?? {}); const env = buildEnv(layout, options.env ?? {}, options.pathMode ?? "merge");
const mounts = buildMounts(layout.rootDir, env); const mounts = buildMounts(layout.rootDir, env);
const args = [ const args = [
@ -190,7 +191,11 @@ function ensureImage(): string {
return cachedImage; return cachedImage;
} }
function buildEnv(layout: TestLayout, extraEnv: Record<string, string>): Record<string, string> { function buildEnv(
layout: TestLayout,
extraEnv: Record<string, string>,
pathMode: "merge" | "replace",
): Record<string, string> {
const env: Record<string, string> = { const env: Record<string, string> = {
HOME: layout.homeDir, HOME: layout.homeDir,
USERPROFILE: layout.homeDir, USERPROFILE: layout.homeDir,
@ -208,7 +213,9 @@ function buildEnv(layout: TestLayout, extraEnv: Record<string, string>): Record<
customPathEntries.add(entry); customPathEntries.add(entry);
} }
} }
if (customPathEntries.size > 0) { if (pathMode === "replace") {
env.PATH = extraEnv.PATH ?? "";
} else if (customPathEntries.size > 0) {
env.PATH = `${Array.from(customPathEntries).join(":")}:${DEFAULT_PATH}`; env.PATH = `${Array.from(customPathEntries).join(":")}:${DEFAULT_PATH}`;
} }

View file

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { import {
@ -87,6 +87,15 @@ async function waitForAsync<T>(fn: () => Promise<T | undefined | null>, timeoutM
throw new Error("timed out waiting for condition"); throw new Error("timed out waiting for condition");
} }
async function withTimeout<T>(promise: Promise<T>, label: string, timeoutMs = 15_000): Promise<T> {
return await Promise.race([
promise,
sleep(timeoutMs).then(() => {
throw new Error(`${label} timed out after ${timeoutMs}ms`);
}),
]);
}
function buildTarArchive(entries: Array<{ name: string; content: string }>): Uint8Array { function buildTarArchive(entries: Array<{ name: string; content: string }>): Uint8Array {
const blocks: Buffer[] = []; const blocks: Buffer[] = [];
@ -177,146 +186,37 @@ function forwardRequest(
return defaultFetch(forwardedUrl, forwardedInit); return defaultFetch(forwardedUrl, forwardedInit);
} }
function writeExecutable(path: string, source: string): void { async function launchDesktopFocusWindow(sdk: SandboxAgent, display: string): Promise<string> {
writeFileSync(path, source, "utf8"); const windowProcess = await sdk.createProcess({
chmodSync(path, 0o755); command: "xterm",
} args: [
"-geometry",
"80x24+40+40",
"-title",
"Sandbox Desktop Test",
"-e",
"sh",
"-lc",
"sleep 60",
],
env: { DISPLAY: display },
});
function prepareFakeDesktopEnv(root: string): Record<string, string> { await waitForAsync(async () => {
const binDir = join(root, "bin"); const result = await sdk.runProcess({
const xdgStateHome = join(root, "xdg-state"); command: "sh",
const fakeStateDir = join(root, "fake-state"); args: [
mkdirSync(binDir, { recursive: true }); "-lc",
mkdirSync(xdgStateHome, { recursive: true }); "wid=\"$(xdotool search --onlyvisible --name 'Sandbox Desktop Test' 2>/dev/null | head -n 1 || true)\"; if [ -z \"$wid\" ]; then exit 3; fi; xdotool windowactivate \"$wid\"",
mkdirSync(fakeStateDir, { recursive: true }); ],
env: { DISPLAY: display },
timeoutMs: 5_000,
});
writeExecutable( return result.exitCode === 0 ? true : undefined;
join(binDir, "Xvfb"), }, 10_000, 200);
`#!/usr/bin/env sh
set -eu
display="\${1:-:191}"
number="\${display#:}"
socket="/tmp/.X11-unix/X\${number}"
mkdir -p /tmp/.X11-unix
touch "$socket"
cleanup() {
rm -f "$socket"
exit 0
}
trap cleanup INT TERM EXIT
while :; do
sleep 1
done
`,
);
writeExecutable( return windowProcess.id;
join(binDir, "openbox"),
`#!/usr/bin/env sh
set -eu
trap 'exit 0' INT TERM
while :; do
sleep 1
done
`,
);
writeExecutable(
join(binDir, "dbus-launch"),
`#!/usr/bin/env sh
set -eu
echo "DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/sandbox-agent-test-bus"
echo "DBUS_SESSION_BUS_PID=$$"
`,
);
writeExecutable(
join(binDir, "xrandr"),
`#!/usr/bin/env sh
set -eu
cat <<'EOF'
Screen 0: minimum 1 x 1, current 1440 x 900, maximum 32767 x 32767
EOF
`,
);
writeExecutable(
join(binDir, "import"),
`#!/usr/bin/env sh
set -eu
printf '\\211PNG\\r\\n\\032\\n\\000\\000\\000\\rIHDR\\000\\000\\000\\001\\000\\000\\000\\001\\010\\006\\000\\000\\000\\037\\025\\304\\211\\000\\000\\000\\013IDATx\\234c\\000\\001\\000\\000\\005\\000\\001\\r\\n-\\264\\000\\000\\000\\000IEND\\256B\`\\202'
`,
);
writeExecutable(
join(binDir, "xdotool"),
`#!/usr/bin/env sh
set -eu
state_dir="\${SANDBOX_AGENT_DESKTOP_FAKE_STATE_DIR:?missing fake state dir}"
state_file="\${state_dir}/mouse"
mkdir -p "$state_dir"
if [ ! -f "$state_file" ]; then
printf '0 0\\n' > "$state_file"
fi
read_state() {
read -r x y < "$state_file"
}
write_state() {
printf '%s %s\\n' "$1" "$2" > "$state_file"
}
command="\${1:-}"
case "$command" in
getmouselocation)
read_state
printf 'X=%s\\nY=%s\\nSCREEN=0\\nWINDOW=0\\n' "$x" "$y"
;;
mousemove)
shift
x="\${1:-0}"
y="\${2:-0}"
shift 2 || true
while [ "$#" -gt 0 ]; do
token="$1"
shift
case "$token" in
mousemove)
x="\${1:-0}"
y="\${2:-0}"
shift 2 || true
;;
mousedown|mouseup)
shift 1 || true
;;
click)
if [ "\${1:-}" = "--repeat" ]; then
shift 2 || true
fi
shift 1 || true
;;
esac
done
write_state "$x" "$y"
;;
type|key)
exit 0
;;
*)
exit 0
;;
esac
`,
);
return {
SANDBOX_AGENT_DESKTOP_TEST_ASSUME_LINUX: "1",
SANDBOX_AGENT_DESKTOP_DISPLAY_NUM: "191",
SANDBOX_AGENT_DESKTOP_FAKE_STATE_DIR: fakeStateDir,
XDG_STATE_HOME: xdgStateHome,
PATH: `${binDir}:${process.env.PATH ?? ""}`,
};
} }
describe("Integration: TypeScript SDK flat session API", () => { describe("Integration: TypeScript SDK flat session API", () => {
@ -328,11 +228,9 @@ describe("Integration: TypeScript SDK flat session API", () => {
beforeEach(async () => { beforeEach(async () => {
layout = createDockerTestLayout(); layout = createDockerTestLayout();
prepareMockAgentDataHome(layout.xdgDataHome); prepareMockAgentDataHome(layout.xdgDataHome);
const desktopEnv = prepareFakeDesktopEnv(layout.rootDir);
handle = await startDockerSandboxAgent(layout, { handle = await startDockerSandboxAgent(layout, {
timeoutMs: 30000, timeoutMs: 30000,
env: desktopEnv,
}); });
baseUrl = handle.baseUrl; baseUrl = handle.baseUrl;
token = handle.token; token = handle.token;
@ -490,16 +388,26 @@ describe("Integration: TypeScript SDK flat session API", () => {
token, token,
fetch: customFetch, fetch: customFetch,
}); });
let sessionId: string | undefined;
await sdk.getHealth(); try {
const session = await sdk.createSession({ agent: "mock" }); await withTimeout(sdk.getHealth(), "custom fetch getHealth");
expect(session.agent).toBe("mock"); const session = await withTimeout(
await sdk.destroySession(session.id); sdk.createSession({ agent: "mock" }),
"custom fetch createSession",
);
sessionId = session.id;
expect(session.agent).toBe("mock");
await withTimeout(sdk.destroySession(session.id), "custom fetch destroySession");
expect(seenPaths).toContain("/v1/health"); expect(seenPaths).toContain("/v1/health");
expect(seenPaths.some((path) => path.startsWith("/v1/acp/"))).toBe(true); expect(seenPaths.some((path) => path.startsWith("/v1/acp/"))).toBe(true);
} finally {
await sdk.dispose(); if (sessionId) {
await sdk.destroySession(sessionId).catch(() => {});
}
await withTimeout(sdk.dispose(), "custom fetch dispose");
}
}, 60_000); }, 60_000);
it("requires baseUrl when fetch is not provided", async () => { it("requires baseUrl when fetch is not provided", async () => {
@ -1103,6 +1011,7 @@ describe("Integration: TypeScript SDK flat session API", () => {
baseUrl, baseUrl,
token, token,
}); });
let focusWindowProcessId: string | undefined;
try { try {
const initialStatus = await sdk.getDesktopStatus(); const initialStatus = await sdk.getDesktopStatus();
@ -1168,6 +1077,8 @@ describe("Integration: TypeScript SDK flat session API", () => {
expect(position.x).toBe(80); expect(position.x).toBe(80);
expect(position.y).toBe(90); expect(position.y).toBe(90);
focusWindowProcessId = await launchDesktopFocusWindow(sdk, started.display!);
const typed = await sdk.typeDesktopText({ const typed = await sdk.typeDesktopText({
text: "hello desktop", text: "hello desktop",
delayMs: 5, delayMs: 5,
@ -1180,6 +1091,10 @@ describe("Integration: TypeScript SDK flat session API", () => {
const stopped = await sdk.stopDesktop(); const stopped = await sdk.stopDesktop();
expect(stopped.state).toBe("inactive"); expect(stopped.state).toBe("inactive");
} finally { } finally {
if (focusWindowProcessId) {
await sdk.killProcess(focusWindowProcessId, { waitMs: 5_000 }).catch(() => {});
await sdk.deleteProcess(focusWindowProcessId).catch(() => {});
}
await sdk.stopDesktop().catch(() => {}); await sdk.stopDesktop().catch(() => {});
await sdk.dispose(); await sdk.dispose();
} }

View file

@ -53,12 +53,25 @@ pub struct TestApp {
container_id: String, container_id: String,
} }
#[derive(Default)]
pub struct TestAppOptions {
pub env: BTreeMap<String, String>,
pub replace_path: bool,
}
impl TestApp { impl TestApp {
pub fn new(auth: AuthConfig) -> Self { pub fn new(auth: AuthConfig) -> Self {
Self::with_setup(auth, |_| {}) Self::with_setup(auth, |_| {})
} }
pub fn with_setup<F>(auth: AuthConfig, setup: F) -> Self pub fn with_setup<F>(auth: AuthConfig, setup: F) -> Self
where
F: FnOnce(&Path),
{
Self::with_options(auth, TestAppOptions::default(), setup)
}
pub fn with_options<F>(auth: AuthConfig, options: TestAppOptions, setup: F) -> Self
where where
F: FnOnce(&Path), F: FnOnce(&Path),
{ {
@ -69,7 +82,7 @@ impl TestApp {
let container_id = unique_container_id(); let container_id = unique_container_id();
let image = ensure_test_image(); let image = ensure_test_image();
let env = build_env(&layout, &auth); let env = build_env(&layout, &auth, &options);
let mounts = build_mounts(root.path(), &env); let mounts = build_mounts(root.path(), &env);
let base_url = run_container(&container_id, &image, &mounts, &env, &auth); let base_url = run_container(&container_id, &image, &mounts, &env, &auth);
@ -191,7 +204,11 @@ fn ensure_test_image() -> String {
.clone() .clone()
} }
fn build_env(layout: &TestLayout, auth: &AuthConfig) -> BTreeMap<String, String> { fn build_env(
layout: &TestLayout,
auth: &AuthConfig,
options: &TestAppOptions,
) -> BTreeMap<String, String> {
let mut env = BTreeMap::new(); let mut env = BTreeMap::new();
env.insert( env.insert(
"HOME".to_string(), "HOME".to_string(),
@ -240,20 +257,35 @@ fn build_env(layout: &TestLayout, auth: &AuthConfig) -> BTreeMap<String, String>
env.insert("SANDBOX_AGENT_TEST_AUTH_TOKEN".to_string(), token.clone()); env.insert("SANDBOX_AGENT_TEST_AUTH_TOKEN".to_string(), token.clone());
} }
let mut custom_path_entries = custom_path_entries(layout.install_dir.parent().expect("install base")); if options.replace_path {
custom_path_entries.extend(explicit_path_entries()); env.insert(
custom_path_entries.sort(); "PATH".to_string(),
custom_path_entries.dedup(); options.env.get("PATH").cloned().unwrap_or_default(),
);
if custom_path_entries.is_empty() {
env.insert("PATH".to_string(), DEFAULT_PATH.to_string());
} else { } else {
let joined = custom_path_entries let mut custom_path_entries =
.iter() custom_path_entries(layout.install_dir.parent().expect("install base"));
.map(|path| path.to_string_lossy().to_string()) custom_path_entries.extend(explicit_path_entries());
.collect::<Vec<_>>() custom_path_entries.sort();
.join(":"); custom_path_entries.dedup();
env.insert("PATH".to_string(), format!("{joined}:{DEFAULT_PATH}"));
if custom_path_entries.is_empty() {
env.insert("PATH".to_string(), DEFAULT_PATH.to_string());
} else {
let joined = custom_path_entries
.iter()
.map(|path| path.to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(":");
env.insert("PATH".to_string(), format!("{joined}:{DEFAULT_PATH}"));
}
}
for (key, value) in &options.env {
if key == "PATH" {
continue;
}
env.insert(key.clone(), value.clone());
} }
env env

View file

@ -10,7 +10,6 @@ use reqwest::{Method, StatusCode};
use sandbox_agent::router::AuthConfig; use sandbox_agent::router::AuthConfig;
use serde_json::{json, Value}; use serde_json::{json, Value};
use serial_test::serial; use serial_test::serial;
use tempfile::TempDir;
#[path = "support/docker.rs"] #[path = "support/docker.rs"]
mod docker_support; mod docker_support;
@ -21,15 +20,6 @@ struct EnvVarGuard {
previous: Option<std::ffi::OsString>, previous: Option<std::ffi::OsString>,
} }
struct FakeDesktopEnv {
_temp: TempDir,
_path: EnvVarGuard,
_xdg_state_home: EnvVarGuard,
_assume_linux: EnvVarGuard,
_display_num: EnvVarGuard,
_fake_state_dir: EnvVarGuard,
}
impl EnvVarGuard { impl EnvVarGuard {
fn set(key: &'static str, value: &str) -> Self { fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var_os(key); let previous = std::env::var_os(key);
@ -97,153 +87,6 @@ exit 0
); );
} }
fn setup_fake_desktop_env() -> FakeDesktopEnv {
let temp = tempfile::tempdir().expect("create fake desktop tempdir");
let bin_dir = temp.path().join("bin");
let xdg_state_home = temp.path().join("state");
let fake_state_dir = temp.path().join("desktop-state");
fs::create_dir_all(&bin_dir).expect("create fake desktop bin dir");
fs::create_dir_all(&xdg_state_home).expect("create xdg state home");
fs::create_dir_all(&fake_state_dir).expect("create fake state dir");
write_executable(
&bin_dir.join("Xvfb"),
r#"#!/usr/bin/env sh
set -eu
display="${1:-:99}"
number="${display#:}"
socket="/tmp/.X11-unix/X${number}"
mkdir -p /tmp/.X11-unix
touch "$socket"
cleanup() {
rm -f "$socket"
exit 0
}
trap cleanup INT TERM EXIT
while :; do
sleep 1
done
"#,
);
write_executable(
&bin_dir.join("openbox"),
r#"#!/usr/bin/env sh
set -eu
trap 'exit 0' INT TERM
while :; do
sleep 1
done
"#,
);
write_executable(
&bin_dir.join("dbus-launch"),
r#"#!/usr/bin/env sh
set -eu
echo "DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/sandbox-agent-test-bus"
echo "DBUS_SESSION_BUS_PID=$$"
"#,
);
write_executable(
&bin_dir.join("xrandr"),
r#"#!/usr/bin/env sh
set -eu
cat <<'EOF'
Screen 0: minimum 1 x 1, current 1440 x 900, maximum 32767 x 32767
EOF
"#,
);
write_executable(
&bin_dir.join("import"),
r#"#!/usr/bin/env sh
set -eu
printf '\211PNG\r\n\032\n\000\000\000\rIHDR\000\000\000\001\000\000\000\001\010\006\000\000\000\037\025\304\211\000\000\000\013IDATx\234c\000\001\000\000\005\000\001\r\n-\264\000\000\000\000IEND\256B`\202'
"#,
);
write_executable(
&bin_dir.join("xdotool"),
r#"#!/usr/bin/env sh
set -eu
state_dir="${SANDBOX_AGENT_DESKTOP_FAKE_STATE_DIR:?missing fake state dir}"
state_file="${state_dir}/mouse"
mkdir -p "$state_dir"
if [ ! -f "$state_file" ]; then
printf '0 0\n' > "$state_file"
fi
read_state() {
read -r x y < "$state_file"
}
write_state() {
printf '%s %s\n' "$1" "$2" > "$state_file"
}
command="${1:-}"
case "$command" in
getmouselocation)
read_state
printf 'X=%s\nY=%s\nSCREEN=0\nWINDOW=0\n' "$x" "$y"
;;
mousemove)
shift
x="${1:-0}"
y="${2:-0}"
shift 2 || true
while [ "$#" -gt 0 ]; do
token="$1"
shift
case "$token" in
mousemove)
x="${1:-0}"
y="${2:-0}"
shift 2 || true
;;
mousedown|mouseup)
shift 1 || true
;;
click)
if [ "${1:-}" = "--repeat" ]; then
shift 2 || true
fi
shift 1 || true
;;
esac
done
write_state "$x" "$y"
;;
type|key)
exit 0
;;
*)
exit 0
;;
esac
"#,
);
let original_path = std::env::var_os("PATH").unwrap_or_default();
let mut paths = vec![bin_dir];
paths.extend(std::env::split_paths(&original_path));
let merged_path = std::env::join_paths(paths).expect("join PATH");
FakeDesktopEnv {
_temp: temp,
_path: EnvVarGuard::set_os("PATH", merged_path.as_os_str()),
_xdg_state_home: EnvVarGuard::set_os("XDG_STATE_HOME", xdg_state_home.as_os_str()),
_assume_linux: EnvVarGuard::set("SANDBOX_AGENT_DESKTOP_TEST_ASSUME_LINUX", "1"),
_display_num: EnvVarGuard::set("SANDBOX_AGENT_DESKTOP_DISPLAY_NUM", "190"),
_fake_state_dir: EnvVarGuard::set_os(
"SANDBOX_AGENT_DESKTOP_FAKE_STATE_DIR",
fake_state_dir.as_os_str(),
),
}
}
fn serve_registry_once(document: Value) -> String { fn serve_registry_once(document: Value) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind registry server"); let listener = TcpListener::bind("127.0.0.1:0").expect("bind registry server");
let address = listener.local_addr().expect("registry address"); let address = listener.local_addr().expect("registry address");
@ -335,6 +178,34 @@ async fn send_request_raw(
(status, headers, bytes.to_vec()) (status, headers, bytes.to_vec())
} }
async fn launch_desktop_focus_window(app: &docker_support::DockerApp, display: &str) {
let command = r#"nohup xterm -geometry 80x24+40+40 -title 'Sandbox Desktop Test' -e sh -lc 'sleep 60' >/tmp/sandbox-agent-xterm.log 2>&1 < /dev/null & for _ in $(seq 1 50); do wid="$(xdotool search --onlyvisible --name 'Sandbox Desktop Test' 2>/dev/null | head -n 1 || true)"; if [ -n "$wid" ]; then xdotool windowactivate "$wid"; exit 0; fi; sleep 0.1; done; exit 1"#;
let (status, _, body) = send_request(
app,
Method::POST,
"/v1/processes/run",
Some(json!({
"command": "sh",
"args": ["-lc", command],
"env": {
"DISPLAY": display,
},
"timeoutMs": 10_000
})),
&[],
)
.await;
assert_eq!(
status,
StatusCode::OK,
"unexpected desktop focus window launch response: {}",
String::from_utf8_lossy(&body)
);
let parsed = parse_json(&body);
assert_eq!(parsed["exitCode"], 0);
}
fn parse_json(bytes: &[u8]) -> Value { fn parse_json(bytes: &[u8]) -> Value {
if bytes.is_empty() { if bytes.is_empty() {
Value::Null Value::Null

View file

@ -1,14 +1,25 @@
use super::*; use super::*;
use serial_test::serial; use serial_test::serial;
use std::collections::BTreeMap;
#[tokio::test] #[tokio::test]
#[serial] #[serial]
async fn v1_desktop_status_reports_install_required_when_dependencies_are_missing() { async fn v1_desktop_status_reports_install_required_when_dependencies_are_missing() {
let temp = tempfile::tempdir().expect("create empty path tempdir"); let temp = tempfile::tempdir().expect("create empty path tempdir");
let _path = EnvVarGuard::set_os("PATH", temp.path().as_os_str()); let mut env = BTreeMap::new();
let _assume_linux = EnvVarGuard::set("SANDBOX_AGENT_DESKTOP_TEST_ASSUME_LINUX", "1"); env.insert(
"PATH".to_string(),
temp.path().to_string_lossy().to_string(),
);
let test_app = TestApp::new(AuthConfig::disabled()); let test_app = TestApp::with_options(
AuthConfig::disabled(),
docker_support::TestAppOptions {
env,
replace_path: true,
},
|_| {},
);
let (status, _, body) = let (status, _, body) =
send_request(&test_app.app, Method::GET, "/v1/desktop/status", None, &[]).await; send_request(&test_app.app, Method::GET, "/v1/desktop/status", None, &[]).await;
@ -29,8 +40,7 @@ async fn v1_desktop_status_reports_install_required_when_dependencies_are_missin
#[tokio::test] #[tokio::test]
#[serial] #[serial]
async fn v1_desktop_lifecycle_and_actions_work_with_fake_runtime() { async fn v1_desktop_lifecycle_and_actions_work_with_real_runtime() {
let _fake = setup_fake_desktop_env();
let test_app = TestApp::new(AuthConfig::disabled()); let test_app = TestApp::new(AuthConfig::disabled());
let (status, _, body) = send_request( let (status, _, body) = send_request(
@ -184,6 +194,8 @@ async fn v1_desktop_lifecycle_and_actions_work_with_fake_runtime() {
assert_eq!(position["x"], 220); assert_eq!(position["x"], 220);
assert_eq!(position["y"], 230); assert_eq!(position["y"], 230);
launch_desktop_focus_window(&test_app.app, &display).await;
let (status, _, body) = send_request( let (status, _, body) = send_request(
&test_app.app, &test_app.app,
Method::POST, Method::POST,