mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-19 23:01:36 +00:00
Rename Factory to Foundry
This commit is contained in:
parent
0a8fda040b
commit
324de36577
256 changed files with 605 additions and 603 deletions
197
foundry/packages/client/test/e2e/full-integration-e2e.test.ts
Normal file
197
foundry/packages/client/test/e2e/full-integration-e2e.test.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HistoryEvent, RepoOverview } from "@sandbox-agent/foundry-shared";
|
||||
import { createBackendClient } from "../../src/backend-client.js";
|
||||
|
||||
const RUN_FULL_E2E = process.env.HF_ENABLE_DAEMON_FULL_E2E === "1";
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Missing required env var: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseGithubRepo(input: string): { fullName: string } {
|
||||
const trimmed = input.trim();
|
||||
const shorthand = trimmed.match(/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/);
|
||||
if (shorthand) {
|
||||
return { fullName: `${shorthand[1]}/${shorthand[2]}` };
|
||||
}
|
||||
|
||||
const url = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`);
|
||||
const parts = url.pathname.replace(/^\/+/, "").split("/").filter(Boolean);
|
||||
if (url.hostname.toLowerCase().includes("github.com") && parts.length >= 2) {
|
||||
return { fullName: `${parts[0]}/${(parts[1] ?? "").replace(/\.git$/, "")}` };
|
||||
}
|
||||
|
||||
throw new Error(`Unable to parse GitHub repo from: ${input}`);
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function poll<T>(
|
||||
label: string,
|
||||
timeoutMs: number,
|
||||
intervalMs: number,
|
||||
fn: () => Promise<T>,
|
||||
isDone: (value: T) => boolean
|
||||
): Promise<T> {
|
||||
const start = Date.now();
|
||||
let last: T;
|
||||
for (;;) {
|
||||
last = await fn();
|
||||
if (isDone(last)) {
|
||||
return last;
|
||||
}
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
function parseHistoryPayload(event: HistoryEvent): Record<string, unknown> {
|
||||
try {
|
||||
return JSON.parse(event.payloadJson) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function githubApi(token: string, path: string, init?: RequestInit): Promise<Response> {
|
||||
const url = `https://api.github.com/${path.replace(/^\/+/, "")}`;
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureRemoteBranchExists(
|
||||
token: string,
|
||||
fullName: string,
|
||||
branchName: string
|
||||
): Promise<void> {
|
||||
const repoRes = await githubApi(token, `repos/${fullName}`, { method: "GET" });
|
||||
if (!repoRes.ok) {
|
||||
throw new Error(`GitHub repo lookup failed: ${repoRes.status} ${await repoRes.text()}`);
|
||||
}
|
||||
const repo = (await repoRes.json()) as { default_branch?: string };
|
||||
const defaultBranch = repo.default_branch;
|
||||
if (!defaultBranch) {
|
||||
throw new Error(`GitHub repo default branch is missing for ${fullName}`);
|
||||
}
|
||||
|
||||
const defaultRefRes = await githubApi(
|
||||
token,
|
||||
`repos/${fullName}/git/ref/heads/${encodeURIComponent(defaultBranch)}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
if (!defaultRefRes.ok) {
|
||||
throw new Error(`GitHub default ref lookup failed: ${defaultRefRes.status} ${await defaultRefRes.text()}`);
|
||||
}
|
||||
const defaultRef = (await defaultRefRes.json()) as { object?: { sha?: string } };
|
||||
const sha = defaultRef.object?.sha;
|
||||
if (!sha) {
|
||||
throw new Error(`GitHub default ref sha missing for ${fullName}:${defaultBranch}`);
|
||||
}
|
||||
|
||||
const createRefRes = await githubApi(token, `repos/${fullName}/git/refs`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
ref: `refs/heads/${branchName}`,
|
||||
sha,
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (createRefRes.ok || createRefRes.status === 422) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`GitHub create ref failed: ${createRefRes.status} ${await createRefRes.text()}`);
|
||||
}
|
||||
|
||||
describe("e2e(client): full integration stack workflow", () => {
|
||||
it.skipIf(!RUN_FULL_E2E)(
|
||||
"adds repo, loads branch graph, and executes a stack restack action",
|
||||
{ timeout: 8 * 60_000 },
|
||||
async () => {
|
||||
const endpoint =
|
||||
process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet";
|
||||
const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
|
||||
const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO");
|
||||
const githubToken = requiredEnv("GITHUB_TOKEN");
|
||||
const { fullName } = parseGithubRepo(repoRemote);
|
||||
const normalizedRepoRemote = `https://github.com/${fullName}.git`;
|
||||
const seededBranch = `e2e/full-seed-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
const client = createBackendClient({
|
||||
endpoint,
|
||||
defaultWorkspaceId: workspaceId,
|
||||
});
|
||||
|
||||
try {
|
||||
await ensureRemoteBranchExists(githubToken, fullName, seededBranch);
|
||||
|
||||
const repo = await client.addRepo(workspaceId, repoRemote);
|
||||
expect(repo.remoteUrl).toBe(normalizedRepoRemote);
|
||||
|
||||
const overview = await poll<RepoOverview>(
|
||||
"repo overview includes seeded branch",
|
||||
90_000,
|
||||
1_000,
|
||||
async () => client.getRepoOverview(workspaceId, repo.repoId),
|
||||
(value) => value.branches.some((row) => row.branchName === seededBranch)
|
||||
);
|
||||
|
||||
if (!overview.stackAvailable) {
|
||||
throw new Error(
|
||||
"git-spice is unavailable for this repo during full integration e2e; set HF_GIT_SPICE_BIN or install git-spice in the backend container"
|
||||
);
|
||||
}
|
||||
|
||||
const stackResult = await client.runRepoStackAction({
|
||||
workspaceId,
|
||||
repoId: repo.repoId,
|
||||
action: "restack_repo",
|
||||
});
|
||||
expect(stackResult.executed).toBe(true);
|
||||
expect(stackResult.action).toBe("restack_repo");
|
||||
|
||||
await poll<HistoryEvent[]>(
|
||||
"repo stack action history event",
|
||||
60_000,
|
||||
1_000,
|
||||
async () => client.listHistory({ workspaceId, limit: 200 }),
|
||||
(events) =>
|
||||
events.some((event) => {
|
||||
if (event.kind !== "repo.stack_action") {
|
||||
return false;
|
||||
}
|
||||
const payload = parseHistoryPayload(event);
|
||||
return payload.action === "restack_repo";
|
||||
})
|
||||
);
|
||||
|
||||
const postActionOverview = await client.getRepoOverview(workspaceId, repo.repoId);
|
||||
const seededRow = postActionOverview.branches.find((row) => row.branchName === seededBranch);
|
||||
expect(Boolean(seededRow)).toBe(true);
|
||||
expect(postActionOverview.fetchedAt).toBeGreaterThan(overview.fetchedAt);
|
||||
} finally {
|
||||
await githubApi(
|
||||
githubToken,
|
||||
`repos/${fullName}/git/refs/heads/${encodeURIComponent(seededBranch)}`,
|
||||
{ method: "DELETE" }
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
351
foundry/packages/client/test/e2e/github-pr-e2e.test.ts
Normal file
351
foundry/packages/client/test/e2e/github-pr-e2e.test.ts
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskRecord, HistoryEvent } from "@sandbox-agent/foundry-shared";
|
||||
import { createBackendClient } from "../../src/backend-client.js";
|
||||
|
||||
const RUN_E2E = process.env.HF_ENABLE_DAEMON_E2E === "1";
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Missing required env var: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseGithubRepo(input: string): { owner: string; repo: string; fullName: string } {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("HF_E2E_GITHUB_REPO is empty");
|
||||
}
|
||||
|
||||
// owner/repo shorthand
|
||||
const shorthand = trimmed.match(/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/);
|
||||
if (shorthand) {
|
||||
const owner = shorthand[1]!;
|
||||
const repo = shorthand[2]!;
|
||||
return { owner, repo, fullName: `${owner}/${repo}` };
|
||||
}
|
||||
|
||||
// https://github.com/owner/repo(.git)?(/...)?
|
||||
try {
|
||||
const url = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`);
|
||||
const parts = url.pathname.replace(/^\/+/, "").split("/").filter(Boolean);
|
||||
if (url.hostname.toLowerCase().includes("github.com") && parts.length >= 2) {
|
||||
const owner = parts[0]!;
|
||||
const repo = (parts[1] ?? "").replace(/\.git$/, "");
|
||||
if (owner && repo) {
|
||||
return { owner, repo, fullName: `${owner}/${repo}` };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
throw new Error(`Unable to parse GitHub repo from: ${input}`);
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
async function poll<T>(
|
||||
label: string,
|
||||
timeoutMs: number,
|
||||
intervalMs: number,
|
||||
fn: () => Promise<T>,
|
||||
isDone: (value: T) => boolean,
|
||||
onTick?: (value: T) => void
|
||||
): Promise<T> {
|
||||
const start = Date.now();
|
||||
let last: T;
|
||||
for (;;) {
|
||||
last = await fn();
|
||||
onTick?.(last);
|
||||
if (isDone(last)) {
|
||||
return last;
|
||||
}
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
function parseHistoryPayload(event: HistoryEvent): Record<string, unknown> {
|
||||
try {
|
||||
return JSON.parse(event.payloadJson) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function debugDump(client: ReturnType<typeof createBackendClient>, workspaceId: string, taskId: string): Promise<string> {
|
||||
try {
|
||||
const task = await client.getTask(workspaceId, taskId);
|
||||
const history = await client.listHistory({ workspaceId, taskId, limit: 80 }).catch(() => []);
|
||||
const historySummary = history
|
||||
.slice(0, 20)
|
||||
.map((e) => `${new Date(e.createdAt).toISOString()} ${e.kind}`)
|
||||
.join("\n");
|
||||
|
||||
let sessionEventsSummary = "";
|
||||
if (task.activeSandboxId && task.activeSessionId) {
|
||||
const events = await client
|
||||
.listSandboxSessionEvents(workspaceId, task.providerId, task.activeSandboxId, {
|
||||
sessionId: task.activeSessionId,
|
||||
limit: 50,
|
||||
})
|
||||
.then((r) => r.items)
|
||||
.catch(() => []);
|
||||
sessionEventsSummary = events
|
||||
.slice(-12)
|
||||
.map((e) => `${new Date(e.createdAt).toISOString()} ${e.sender}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
"=== task ===",
|
||||
JSON.stringify(
|
||||
{
|
||||
status: task.status,
|
||||
statusMessage: task.statusMessage,
|
||||
title: task.title,
|
||||
branchName: task.branchName,
|
||||
activeSandboxId: task.activeSandboxId,
|
||||
activeSessionId: task.activeSessionId,
|
||||
prUrl: task.prUrl,
|
||||
prSubmitted: task.prSubmitted,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
"=== history (most recent first) ===",
|
||||
historySummary || "(none)",
|
||||
"=== session events (tail) ===",
|
||||
sessionEventsSummary || "(none)",
|
||||
].join("\n");
|
||||
} catch (err) {
|
||||
return `debug dump failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function githubApi(token: string, path: string, init?: RequestInit): Promise<Response> {
|
||||
const url = `https://api.github.com/${path.replace(/^\/+/, "")}`;
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
|
||||
it.skipIf(!RUN_E2E)(
|
||||
"creates a task, waits for agent to implement, and opens a PR",
|
||||
{ timeout: 15 * 60_000 },
|
||||
async () => {
|
||||
const endpoint =
|
||||
process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet";
|
||||
const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
|
||||
const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO");
|
||||
const githubToken = requiredEnv("GITHUB_TOKEN");
|
||||
|
||||
const { fullName } = parseGithubRepo(repoRemote);
|
||||
const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const expectedFile = `e2e/${runId}.txt`;
|
||||
|
||||
const client = createBackendClient({
|
||||
endpoint,
|
||||
defaultWorkspaceId: workspaceId,
|
||||
});
|
||||
|
||||
const repo = await client.addRepo(workspaceId, repoRemote);
|
||||
|
||||
const created = await client.createTask({
|
||||
workspaceId,
|
||||
repoId: repo.repoId,
|
||||
task: [
|
||||
"E2E test task:",
|
||||
`1. Create a new file at ${expectedFile} containing the single line: ${runId}`,
|
||||
"2. git add the file",
|
||||
`3. git commit -m \"test(e2e): ${runId}\"`,
|
||||
"4. git push the branch to origin",
|
||||
"5. Stop when done (agent should go idle).",
|
||||
].join("\n"),
|
||||
providerId: "daytona",
|
||||
explicitTitle: `test(e2e): ${runId}`,
|
||||
explicitBranchName: `e2e/${runId}`,
|
||||
});
|
||||
|
||||
let prNumber: number | null = null;
|
||||
let branchName: string | null = null;
|
||||
let sandboxId: string | null = null;
|
||||
let sessionId: string | null = null;
|
||||
let lastStatus: string | null = null;
|
||||
|
||||
try {
|
||||
const namedAndProvisioned = await poll<TaskRecord>(
|
||||
"task naming + sandbox provisioning",
|
||||
// Cold Daytona snapshot/image preparation can exceed 5 minutes on first run.
|
||||
8 * 60_000,
|
||||
1_000,
|
||||
async () => client.getTask(workspaceId, created.taskId),
|
||||
(h) => Boolean(h.title && h.branchName && h.activeSandboxId),
|
||||
(h) => {
|
||||
if (h.status !== lastStatus) {
|
||||
lastStatus = h.status;
|
||||
}
|
||||
if (h.status === "error") {
|
||||
throw new Error("task entered error state during provisioning");
|
||||
}
|
||||
}
|
||||
).catch(async (err) => {
|
||||
const dump = await debugDump(client, workspaceId, created.taskId);
|
||||
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
|
||||
});
|
||||
|
||||
branchName = namedAndProvisioned.branchName!;
|
||||
sandboxId = namedAndProvisioned.activeSandboxId!;
|
||||
|
||||
const withSession = await poll<TaskRecord>(
|
||||
"task to create active session",
|
||||
3 * 60_000,
|
||||
1_500,
|
||||
async () => client.getTask(workspaceId, created.taskId),
|
||||
(h) => Boolean(h.activeSessionId),
|
||||
(h) => {
|
||||
if (h.status === "error") {
|
||||
throw new Error("task entered error state while waiting for active session");
|
||||
}
|
||||
}
|
||||
).catch(async (err) => {
|
||||
const dump = await debugDump(client, workspaceId, created.taskId);
|
||||
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
|
||||
});
|
||||
|
||||
sessionId = withSession.activeSessionId!;
|
||||
|
||||
await poll<{ id: string }[]>(
|
||||
"session transcript bootstrap events",
|
||||
2 * 60_000,
|
||||
2_000,
|
||||
async () =>
|
||||
(
|
||||
await client.listSandboxSessionEvents(workspaceId, withSession.providerId, sandboxId!, {
|
||||
sessionId: sessionId!,
|
||||
limit: 40,
|
||||
})
|
||||
).items,
|
||||
(events) => events.length > 0
|
||||
).catch(async (err) => {
|
||||
const dump = await debugDump(client, workspaceId, created.taskId);
|
||||
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
|
||||
});
|
||||
|
||||
await poll<TaskRecord>(
|
||||
"task to reach idle state",
|
||||
8 * 60_000,
|
||||
2_000,
|
||||
async () => client.getTask(workspaceId, created.taskId),
|
||||
(h) => h.status === "idle",
|
||||
(h) => {
|
||||
if (h.status === "error") {
|
||||
throw new Error("task entered error state while waiting for idle");
|
||||
}
|
||||
}
|
||||
).catch(async (err) => {
|
||||
const dump = await debugDump(client, workspaceId, created.taskId);
|
||||
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
|
||||
});
|
||||
|
||||
const prCreatedEvent = await poll<HistoryEvent[]>(
|
||||
"PR creation history event",
|
||||
3 * 60_000,
|
||||
2_000,
|
||||
async () => client.listHistory({ workspaceId, taskId: created.taskId, limit: 200 }),
|
||||
(events) => events.some((e) => e.kind === "task.pr_created")
|
||||
)
|
||||
.catch(async (err) => {
|
||||
const dump = await debugDump(client, workspaceId, created.taskId);
|
||||
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
|
||||
})
|
||||
.then((events) => events.find((e) => e.kind === "task.pr_created")!);
|
||||
|
||||
const payload = parseHistoryPayload(prCreatedEvent);
|
||||
prNumber = Number(payload.prNumber);
|
||||
const prUrl = String(payload.prUrl ?? "");
|
||||
|
||||
expect(prNumber).toBeGreaterThan(0);
|
||||
expect(prUrl).toContain("/pull/");
|
||||
|
||||
const prFilesRes = await githubApi(
|
||||
githubToken,
|
||||
`repos/${fullName}/pulls/${prNumber}/files?per_page=100`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
if (!prFilesRes.ok) {
|
||||
const body = await prFilesRes.text();
|
||||
throw new Error(`GitHub PR files request failed: ${prFilesRes.status} ${body}`);
|
||||
}
|
||||
const prFiles = (await prFilesRes.json()) as Array<{ filename: string }>;
|
||||
expect(prFiles.some((f) => f.filename === expectedFile)).toBe(true);
|
||||
|
||||
// Close the task and assert the sandbox is released (stopped).
|
||||
await client.runAction(workspaceId, created.taskId, "archive");
|
||||
|
||||
await poll<TaskRecord>(
|
||||
"task to become archived (session released)",
|
||||
60_000,
|
||||
1_000,
|
||||
async () => client.getTask(workspaceId, created.taskId),
|
||||
(h) => h.status === "archived" && h.activeSessionId === null
|
||||
).catch(async (err) => {
|
||||
const dump = await debugDump(client, workspaceId, created.taskId);
|
||||
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
|
||||
});
|
||||
|
||||
if (sandboxId) {
|
||||
await poll<{ providerId: string; sandboxId: string; state: string; at: number }>(
|
||||
"daytona sandbox to stop",
|
||||
2 * 60_000,
|
||||
2_000,
|
||||
async () => client.sandboxProviderState(workspaceId, "daytona", sandboxId!),
|
||||
(s) => {
|
||||
const st = String(s.state).toLowerCase();
|
||||
return st.includes("stopped") || st.includes("suspended") || st.includes("paused");
|
||||
}
|
||||
).catch(async (err) => {
|
||||
const dump = await debugDump(client, workspaceId, created.taskId);
|
||||
const state = await client
|
||||
.sandboxProviderState(workspaceId, "daytona", sandboxId!)
|
||||
.catch(() => null);
|
||||
throw new Error(
|
||||
`${err instanceof Error ? err.message : String(err)}\n` +
|
||||
`sandbox state: ${state ? state.state : "unknown"}\n` +
|
||||
`${dump}`
|
||||
);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (prNumber && Number.isFinite(prNumber)) {
|
||||
await githubApi(githubToken, `repos/${fullName}/pulls/${prNumber}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ state: "closed" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (branchName) {
|
||||
await githubApi(
|
||||
githubToken,
|
||||
`repos/${fullName}/git/refs/heads/${encodeURIComponent(branchName)}`,
|
||||
{ method: "DELETE" }
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
393
foundry/packages/client/test/e2e/workbench-e2e.test.ts
Normal file
393
foundry/packages/client/test/e2e/workbench-e2e.test.ts
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
TaskRecord,
|
||||
TaskWorkbenchSnapshot,
|
||||
WorkbenchAgentTab,
|
||||
WorkbenchTask,
|
||||
WorkbenchModelId,
|
||||
WorkbenchTranscriptEvent,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import { createBackendClient } from "../../src/backend-client.js";
|
||||
|
||||
const RUN_WORKBENCH_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_E2E === "1";
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Missing required env var: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredRepoRemote(): string {
|
||||
return process.env.HF_E2E_REPO_REMOTE?.trim() || requiredEnv("HF_E2E_GITHUB_REPO");
|
||||
}
|
||||
|
||||
function workbenchModelEnv(name: string, fallback: WorkbenchModelId): WorkbenchModelId {
|
||||
const value = process.env[name]?.trim();
|
||||
switch (value) {
|
||||
case "claude-sonnet-4":
|
||||
case "claude-opus-4":
|
||||
case "gpt-4o":
|
||||
case "o3":
|
||||
return value;
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function backendPortFromEndpoint(endpoint: string): string {
|
||||
const url = new URL(endpoint);
|
||||
if (url.port) {
|
||||
return url.port;
|
||||
}
|
||||
return url.protocol === "https:" ? "443" : "80";
|
||||
}
|
||||
|
||||
async function resolveBackendContainerName(endpoint: string): Promise<string | null> {
|
||||
const explicit = process.env.HF_E2E_BACKEND_CONTAINER?.trim();
|
||||
if (explicit) {
|
||||
if (explicit.toLowerCase() === "host") {
|
||||
return null;
|
||||
}
|
||||
return explicit;
|
||||
}
|
||||
|
||||
const { stdout } = await execFileAsync("docker", [
|
||||
"ps",
|
||||
"--filter",
|
||||
`publish=${backendPortFromEndpoint(endpoint)}`,
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
]);
|
||||
const containerName = stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
|
||||
return containerName ?? null;
|
||||
}
|
||||
|
||||
function sandboxRepoPath(record: TaskRecord): string {
|
||||
const activeSandbox =
|
||||
record.sandboxes.find((sandbox) => sandbox.sandboxId === record.activeSandboxId) ??
|
||||
record.sandboxes.find((sandbox) => typeof sandbox.cwd === "string" && sandbox.cwd.length > 0);
|
||||
const cwd = activeSandbox?.cwd?.trim();
|
||||
if (!cwd) {
|
||||
throw new Error(`No sandbox cwd is available for task ${record.taskId}`);
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
|
||||
async function seedSandboxFile(endpoint: string, record: TaskRecord, filePath: string, content: string): Promise<void> {
|
||||
const repoPath = sandboxRepoPath(record);
|
||||
const containerName = await resolveBackendContainerName(endpoint);
|
||||
if (!containerName) {
|
||||
const directory =
|
||||
filePath.includes("/") ? `${repoPath}/${filePath.slice(0, filePath.lastIndexOf("/"))}` : repoPath;
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(`${repoPath}/${filePath}`, `${content}\n`, "utf8");
|
||||
return;
|
||||
}
|
||||
|
||||
const script = [
|
||||
`cd ${JSON.stringify(repoPath)}`,
|
||||
`mkdir -p ${JSON.stringify(filePath.includes("/") ? filePath.slice(0, filePath.lastIndexOf("/")) : ".")}`,
|
||||
`printf '%s\\n' ${JSON.stringify(content)} > ${JSON.stringify(filePath)}`,
|
||||
].join(" && ");
|
||||
await execFileAsync("docker", ["exec", containerName, "bash", "-lc", script]);
|
||||
}
|
||||
|
||||
async function poll<T>(
|
||||
label: string,
|
||||
timeoutMs: number,
|
||||
intervalMs: number,
|
||||
fn: () => Promise<T>,
|
||||
isDone: (value: T) => boolean,
|
||||
): Promise<T> {
|
||||
const startedAt = Date.now();
|
||||
let lastValue: T;
|
||||
|
||||
for (;;) {
|
||||
lastValue = await fn();
|
||||
if (isDone(lastValue)) {
|
||||
return lastValue;
|
||||
}
|
||||
if (Date.now() - startedAt > timeoutMs) {
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask {
|
||||
const task = snapshot.tasks.find((candidate) => candidate.id === taskId);
|
||||
if (!task) {
|
||||
throw new Error(`task ${taskId} missing from snapshot`);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
function findTab(task: WorkbenchTask, tabId: string): WorkbenchAgentTab {
|
||||
const tab = task.tabs.find((candidate) => candidate.id === tabId);
|
||||
if (!tab) {
|
||||
throw new Error(`tab ${tabId} missing from task ${task.id}`);
|
||||
}
|
||||
return tab;
|
||||
}
|
||||
|
||||
function extractEventText(event: WorkbenchTranscriptEvent): string {
|
||||
const payload = event.payload;
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return String(payload ?? "");
|
||||
}
|
||||
|
||||
const envelope = payload as {
|
||||
method?: unknown;
|
||||
params?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
const params = envelope.params;
|
||||
if (params && typeof params === "object") {
|
||||
const update = (params as { update?: unknown }).update;
|
||||
if (update && typeof update === "object") {
|
||||
const content = (update as { content?: unknown }).content;
|
||||
if (content && typeof content === "object") {
|
||||
const chunkText = (content as { text?: unknown }).text;
|
||||
if (typeof chunkText === "string") {
|
||||
return chunkText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = (params as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
return text.trim();
|
||||
}
|
||||
const prompt = (params as { prompt?: Array<{ text?: unknown }> }).prompt;
|
||||
if (Array.isArray(prompt)) {
|
||||
const value = prompt
|
||||
.map((item) => (typeof item?.text === "string" ? item.text.trim() : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = envelope.result;
|
||||
if (result && typeof result === "object") {
|
||||
const text = (result as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (envelope.error) {
|
||||
return JSON.stringify(envelope.error);
|
||||
}
|
||||
|
||||
if (typeof envelope.method === "string") {
|
||||
return envelope.method;
|
||||
}
|
||||
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function transcriptIncludesAgentText(
|
||||
transcript: WorkbenchTranscriptEvent[],
|
||||
expectedText: string,
|
||||
): boolean {
|
||||
return transcript
|
||||
.filter((event) => event.sender === "agent")
|
||||
.map((event) => extractEventText(event))
|
||||
.join("")
|
||||
.includes(expectedText);
|
||||
}
|
||||
|
||||
describe("e2e(client): workbench flows", () => {
|
||||
it.skipIf(!RUN_WORKBENCH_E2E)(
|
||||
"creates a task, adds sessions, exchanges messages, and manages workbench state",
|
||||
{ timeout: 20 * 60_000 },
|
||||
async () => {
|
||||
const endpoint =
|
||||
process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet";
|
||||
const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
|
||||
const repoRemote = requiredRepoRemote();
|
||||
const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o");
|
||||
const runId = `wb-${Date.now().toString(36)}`;
|
||||
const expectedFile = `${runId}.txt`;
|
||||
const expectedInitialReply = `WORKBENCH_READY_${runId}`;
|
||||
const expectedReply = `WORKBENCH_ACK_${runId}`;
|
||||
|
||||
const client = createBackendClient({
|
||||
endpoint,
|
||||
defaultWorkspaceId: workspaceId,
|
||||
});
|
||||
|
||||
const repo = await client.addRepo(workspaceId, repoRemote);
|
||||
const created = await client.createWorkbenchTask(workspaceId, {
|
||||
repoId: repo.repoId,
|
||||
title: `Workbench E2E ${runId}`,
|
||||
branch: `e2e/${runId}`,
|
||||
model,
|
||||
task: `Reply with exactly: ${expectedInitialReply}`,
|
||||
});
|
||||
|
||||
const provisioned = await poll(
|
||||
"task provisioning",
|
||||
12 * 60_000,
|
||||
2_000,
|
||||
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
|
||||
(task) => task.branch === `e2e/${runId}` && task.tabs.length > 0,
|
||||
);
|
||||
|
||||
const primaryTab = provisioned.tabs[0]!;
|
||||
|
||||
const initialCompleted = await poll(
|
||||
"initial agent response",
|
||||
12 * 60_000,
|
||||
2_000,
|
||||
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
|
||||
(task) => {
|
||||
const tab = findTab(task, primaryTab.id);
|
||||
return (
|
||||
task.status === "idle" &&
|
||||
tab.status === "idle" &&
|
||||
transcriptIncludesAgentText(tab.transcript, expectedInitialReply)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
expect(findTab(initialCompleted, primaryTab.id).sessionId).toBeTruthy();
|
||||
expect(transcriptIncludesAgentText(findTab(initialCompleted, primaryTab.id).transcript, expectedInitialReply)).toBe(true);
|
||||
|
||||
const detail = await client.getTask(workspaceId, created.taskId);
|
||||
await seedSandboxFile(endpoint, detail, expectedFile, runId);
|
||||
|
||||
const fileSeeded = await poll(
|
||||
"seeded sandbox file reflected in workbench",
|
||||
30_000,
|
||||
1_000,
|
||||
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
|
||||
(task) => task.fileChanges.some((file) => file.path === expectedFile),
|
||||
);
|
||||
expect(fileSeeded.fileChanges.some((file) => file.path === expectedFile)).toBe(true);
|
||||
|
||||
await client.renameWorkbenchTask(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
value: `Workbench E2E ${runId} Renamed`,
|
||||
});
|
||||
await client.renameWorkbenchSession(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
tabId: primaryTab.id,
|
||||
title: "Primary Session",
|
||||
});
|
||||
|
||||
const secondTab = await client.createWorkbenchSession(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
model,
|
||||
});
|
||||
|
||||
await client.renameWorkbenchSession(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
tabId: secondTab.tabId,
|
||||
title: "Follow-up Session",
|
||||
});
|
||||
|
||||
await client.updateWorkbenchDraft(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
tabId: secondTab.tabId,
|
||||
text: `Reply with exactly: ${expectedReply}`,
|
||||
attachments: [
|
||||
{
|
||||
id: `${expectedFile}:1`,
|
||||
filePath: expectedFile,
|
||||
lineNumber: 1,
|
||||
lineContent: runId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const drafted = findTask(await client.getWorkbench(workspaceId), created.taskId);
|
||||
expect(findTab(drafted, secondTab.tabId).draft.text).toContain(expectedReply);
|
||||
expect(findTab(drafted, secondTab.tabId).draft.attachments).toHaveLength(1);
|
||||
|
||||
await client.sendWorkbenchMessage(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
tabId: secondTab.tabId,
|
||||
text: `Reply with exactly: ${expectedReply}`,
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
const withSecondReply = await poll(
|
||||
"follow-up session response",
|
||||
10 * 60_000,
|
||||
2_000,
|
||||
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
|
||||
(task) => {
|
||||
const tab = findTab(task, secondTab.tabId);
|
||||
return (
|
||||
tab.status === "idle" &&
|
||||
transcriptIncludesAgentText(tab.transcript, expectedReply)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const secondTranscript = findTab(withSecondReply, secondTab.tabId).transcript;
|
||||
expect(transcriptIncludesAgentText(secondTranscript, expectedReply)).toBe(true);
|
||||
|
||||
await client.setWorkbenchSessionUnread(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
tabId: secondTab.tabId,
|
||||
unread: false,
|
||||
});
|
||||
await client.markWorkbenchUnread(workspaceId, { taskId: created.taskId });
|
||||
|
||||
const unreadSnapshot = findTask(await client.getWorkbench(workspaceId), created.taskId);
|
||||
expect(unreadSnapshot.tabs.some((tab) => tab.unread)).toBe(true);
|
||||
|
||||
await client.closeWorkbenchSession(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
tabId: secondTab.tabId,
|
||||
});
|
||||
|
||||
const closedSnapshot = await poll(
|
||||
"secondary session closed",
|
||||
30_000,
|
||||
1_000,
|
||||
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
|
||||
(task) => !task.tabs.some((tab) => tab.id === secondTab.tabId),
|
||||
);
|
||||
expect(closedSnapshot.tabs).toHaveLength(1);
|
||||
|
||||
await client.revertWorkbenchFile(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
path: expectedFile,
|
||||
});
|
||||
|
||||
const revertedSnapshot = await poll(
|
||||
"file revert reflected in workbench",
|
||||
30_000,
|
||||
1_000,
|
||||
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
|
||||
(task) => !task.fileChanges.some((file) => file.path === expectedFile),
|
||||
);
|
||||
|
||||
expect(revertedSnapshot.fileChanges.some((file) => file.path === expectedFile)).toBe(false);
|
||||
expect(revertedSnapshot.title).toBe(`Workbench E2E ${runId} Renamed`);
|
||||
expect(findTab(revertedSnapshot, primaryTab.id).sessionName).toBe("Primary Session");
|
||||
},
|
||||
);
|
||||
});
|
||||
335
foundry/packages/client/test/e2e/workbench-load-e2e.test.ts
Normal file
335
foundry/packages/client/test/e2e/workbench-load-e2e.test.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
TaskWorkbenchSnapshot,
|
||||
WorkbenchAgentTab,
|
||||
WorkbenchTask,
|
||||
WorkbenchModelId,
|
||||
WorkbenchTranscriptEvent,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import { createBackendClient } from "../../src/backend-client.js";
|
||||
|
||||
const RUN_WORKBENCH_LOAD_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E === "1";
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Missing required env var: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredRepoRemote(): string {
|
||||
return process.env.HF_E2E_REPO_REMOTE?.trim() || requiredEnv("HF_E2E_GITHUB_REPO");
|
||||
}
|
||||
|
||||
function workbenchModelEnv(name: string, fallback: WorkbenchModelId): WorkbenchModelId {
|
||||
const value = process.env[name]?.trim();
|
||||
switch (value) {
|
||||
case "claude-sonnet-4":
|
||||
case "claude-opus-4":
|
||||
case "gpt-4o":
|
||||
case "o3":
|
||||
return value;
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function intEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
const value = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function poll<T>(
|
||||
label: string,
|
||||
timeoutMs: number,
|
||||
intervalMs: number,
|
||||
fn: () => Promise<T>,
|
||||
isDone: (value: T) => boolean,
|
||||
): Promise<T> {
|
||||
const startedAt = Date.now();
|
||||
let lastValue: T;
|
||||
|
||||
for (;;) {
|
||||
lastValue = await fn();
|
||||
if (isDone(lastValue)) {
|
||||
return lastValue;
|
||||
}
|
||||
if (Date.now() - startedAt > timeoutMs) {
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask {
|
||||
const task = snapshot.tasks.find((candidate) => candidate.id === taskId);
|
||||
if (!task) {
|
||||
throw new Error(`task ${taskId} missing from snapshot`);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
function findTab(task: WorkbenchTask, tabId: string): WorkbenchAgentTab {
|
||||
const tab = task.tabs.find((candidate) => candidate.id === tabId);
|
||||
if (!tab) {
|
||||
throw new Error(`tab ${tabId} missing from task ${task.id}`);
|
||||
}
|
||||
return tab;
|
||||
}
|
||||
|
||||
function extractEventText(event: WorkbenchTranscriptEvent): string {
|
||||
const payload = event.payload;
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return String(payload ?? "");
|
||||
}
|
||||
|
||||
const envelope = payload as {
|
||||
method?: unknown;
|
||||
params?: unknown;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
const params = envelope.params;
|
||||
if (params && typeof params === "object") {
|
||||
const update = (params as { update?: unknown }).update;
|
||||
if (update && typeof update === "object") {
|
||||
const content = (update as { content?: unknown }).content;
|
||||
if (content && typeof content === "object") {
|
||||
const chunkText = (content as { text?: unknown }).text;
|
||||
if (typeof chunkText === "string") {
|
||||
return chunkText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = (params as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
const prompt = (params as { prompt?: Array<{ text?: unknown }> }).prompt;
|
||||
if (Array.isArray(prompt)) {
|
||||
return prompt
|
||||
.map((item) => (typeof item?.text === "string" ? item.text.trim() : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
const result = envelope.result;
|
||||
if (result && typeof result === "object") {
|
||||
const text = (result as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return typeof envelope.method === "string" ? envelope.method : JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function transcriptIncludesAgentText(transcript: WorkbenchTranscriptEvent[], expectedText: string): boolean {
|
||||
return transcript
|
||||
.filter((event) => event.sender === "agent")
|
||||
.map((event) => extractEventText(event))
|
||||
.join("")
|
||||
.includes(expectedText);
|
||||
}
|
||||
|
||||
function average(values: number[]): number {
|
||||
return values.reduce((sum, value) => sum + value, 0) / Math.max(values.length, 1);
|
||||
}
|
||||
|
||||
async function measureWorkbenchSnapshot(
|
||||
client: ReturnType<typeof createBackendClient>,
|
||||
workspaceId: string,
|
||||
iterations: number,
|
||||
): Promise<{
|
||||
avgMs: number;
|
||||
maxMs: number;
|
||||
payloadBytes: number;
|
||||
taskCount: number;
|
||||
tabCount: number;
|
||||
transcriptEventCount: number;
|
||||
}> {
|
||||
const durations: number[] = [];
|
||||
let snapshot: TaskWorkbenchSnapshot | null = null;
|
||||
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
const startedAt = performance.now();
|
||||
snapshot = await client.getWorkbench(workspaceId);
|
||||
durations.push(performance.now() - startedAt);
|
||||
}
|
||||
|
||||
const finalSnapshot = snapshot ?? {
|
||||
workspaceId,
|
||||
repos: [],
|
||||
projects: [],
|
||||
tasks: [],
|
||||
};
|
||||
const payloadBytes = Buffer.byteLength(JSON.stringify(finalSnapshot), "utf8");
|
||||
const tabCount = finalSnapshot.tasks.reduce((sum, task) => sum + task.tabs.length, 0);
|
||||
const transcriptEventCount = finalSnapshot.tasks.reduce(
|
||||
(sum, task) =>
|
||||
sum + task.tabs.reduce((tabSum, tab) => tabSum + tab.transcript.length, 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
avgMs: Math.round(average(durations)),
|
||||
maxMs: Math.round(Math.max(...durations, 0)),
|
||||
payloadBytes,
|
||||
taskCount: finalSnapshot.tasks.length,
|
||||
tabCount,
|
||||
transcriptEventCount,
|
||||
};
|
||||
}
|
||||
|
||||
describe("e2e(client): workbench load", () => {
|
||||
it.skipIf(!RUN_WORKBENCH_LOAD_E2E)(
|
||||
"runs a simple sequential load profile against the real backend",
|
||||
{ timeout: 30 * 60_000 },
|
||||
async () => {
|
||||
const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet";
|
||||
const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
|
||||
const repoRemote = requiredRepoRemote();
|
||||
const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o");
|
||||
const taskCount = intEnv("HF_LOAD_HANDOFF_COUNT", 3);
|
||||
const extraSessionCount = intEnv("HF_LOAD_EXTRA_SESSION_COUNT", 2);
|
||||
const pollIntervalMs = intEnv("HF_LOAD_POLL_INTERVAL_MS", 2_000);
|
||||
|
||||
const client = createBackendClient({
|
||||
endpoint,
|
||||
defaultWorkspaceId: workspaceId,
|
||||
});
|
||||
|
||||
const repo = await client.addRepo(workspaceId, repoRemote);
|
||||
const createTaskLatencies: number[] = [];
|
||||
const provisionLatencies: number[] = [];
|
||||
const createSessionLatencies: number[] = [];
|
||||
const messageRoundTripLatencies: number[] = [];
|
||||
const snapshotSeries: Array<{
|
||||
taskCount: number;
|
||||
avgMs: number;
|
||||
maxMs: number;
|
||||
payloadBytes: number;
|
||||
tabCount: number;
|
||||
transcriptEventCount: number;
|
||||
}> = [];
|
||||
|
||||
snapshotSeries.push(await measureWorkbenchSnapshot(client, workspaceId, 2));
|
||||
|
||||
for (let taskIndex = 0; taskIndex < taskCount; taskIndex += 1) {
|
||||
const runId = `load-${taskIndex}-${Date.now().toString(36)}`;
|
||||
const initialReply = `LOAD_INIT_${runId}`;
|
||||
|
||||
const createStartedAt = performance.now();
|
||||
const created = await client.createWorkbenchTask(workspaceId, {
|
||||
repoId: repo.repoId,
|
||||
title: `Workbench Load ${runId}`,
|
||||
branch: `load/${runId}`,
|
||||
model,
|
||||
task: `Reply with exactly: ${initialReply}`,
|
||||
});
|
||||
createTaskLatencies.push(performance.now() - createStartedAt);
|
||||
|
||||
const provisionStartedAt = performance.now();
|
||||
const provisioned = await poll(
|
||||
`task ${runId} provisioning`,
|
||||
12 * 60_000,
|
||||
pollIntervalMs,
|
||||
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
|
||||
(task) => {
|
||||
const tab = task.tabs[0];
|
||||
return Boolean(
|
||||
tab &&
|
||||
task.status === "idle" &&
|
||||
tab.status === "idle" &&
|
||||
transcriptIncludesAgentText(tab.transcript, initialReply),
|
||||
);
|
||||
},
|
||||
);
|
||||
provisionLatencies.push(performance.now() - provisionStartedAt);
|
||||
|
||||
expect(provisioned.tabs.length).toBeGreaterThan(0);
|
||||
const primaryTab = provisioned.tabs[0]!;
|
||||
expect(transcriptIncludesAgentText(primaryTab.transcript, initialReply)).toBe(true);
|
||||
|
||||
for (let sessionIndex = 0; sessionIndex < extraSessionCount; sessionIndex += 1) {
|
||||
const expectedReply = `LOAD_REPLY_${runId}_${sessionIndex}`;
|
||||
const createSessionStartedAt = performance.now();
|
||||
const createdSession = await client.createWorkbenchSession(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
model,
|
||||
});
|
||||
createSessionLatencies.push(performance.now() - createSessionStartedAt);
|
||||
|
||||
await client.sendWorkbenchMessage(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
tabId: createdSession.tabId,
|
||||
text: `Run pwd in the repo, then reply with exactly: ${expectedReply}`,
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
const messageStartedAt = performance.now();
|
||||
const withReply = await poll(
|
||||
`task ${runId} session ${sessionIndex} reply`,
|
||||
10 * 60_000,
|
||||
pollIntervalMs,
|
||||
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
|
||||
(task) => {
|
||||
const tab = findTab(task, createdSession.tabId);
|
||||
return tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedReply);
|
||||
},
|
||||
);
|
||||
messageRoundTripLatencies.push(performance.now() - messageStartedAt);
|
||||
|
||||
expect(transcriptIncludesAgentText(findTab(withReply, createdSession.tabId).transcript, expectedReply)).toBe(true);
|
||||
}
|
||||
|
||||
const snapshotMetrics = await measureWorkbenchSnapshot(client, workspaceId, 3);
|
||||
snapshotSeries.push(snapshotMetrics);
|
||||
console.info(
|
||||
"[workbench-load-snapshot]",
|
||||
JSON.stringify({
|
||||
taskIndex: taskIndex + 1,
|
||||
...snapshotMetrics,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const firstSnapshot = snapshotSeries[0]!;
|
||||
const lastSnapshot = snapshotSeries[snapshotSeries.length - 1]!;
|
||||
const summary = {
|
||||
taskCount,
|
||||
extraSessionCount,
|
||||
createTaskAvgMs: Math.round(average(createTaskLatencies)),
|
||||
provisionAvgMs: Math.round(average(provisionLatencies)),
|
||||
createSessionAvgMs: Math.round(average(createSessionLatencies)),
|
||||
messageRoundTripAvgMs: Math.round(average(messageRoundTripLatencies)),
|
||||
snapshotReadBaselineAvgMs: firstSnapshot.avgMs,
|
||||
snapshotReadFinalAvgMs: lastSnapshot.avgMs,
|
||||
snapshotReadFinalMaxMs: lastSnapshot.maxMs,
|
||||
snapshotPayloadBaselineBytes: firstSnapshot.payloadBytes,
|
||||
snapshotPayloadFinalBytes: lastSnapshot.payloadBytes,
|
||||
snapshotTabFinalCount: lastSnapshot.tabCount,
|
||||
snapshotTranscriptFinalCount: lastSnapshot.transcriptEventCount,
|
||||
};
|
||||
|
||||
console.info("[workbench-load-summary]", JSON.stringify(summary));
|
||||
|
||||
expect(createTaskLatencies.length).toBe(taskCount);
|
||||
expect(provisionLatencies.length).toBe(taskCount);
|
||||
expect(createSessionLatencies.length).toBe(taskCount * extraSessionCount);
|
||||
expect(messageRoundTripLatencies.length).toBe(taskCount * extraSessionCount);
|
||||
},
|
||||
);
|
||||
});
|
||||
31
foundry/packages/client/test/keys.test.ts
Normal file
31
foundry/packages/client/test/keys.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
taskKey,
|
||||
taskStatusSyncKey,
|
||||
historyKey,
|
||||
repoBranchSyncKey,
|
||||
repoKey,
|
||||
repoPrSyncKey,
|
||||
sandboxInstanceKey,
|
||||
workspaceKey
|
||||
} from "../src/keys.js";
|
||||
|
||||
describe("actor keys", () => {
|
||||
it("prefixes every key with workspace namespace", () => {
|
||||
const keys = [
|
||||
workspaceKey("default"),
|
||||
repoKey("default", "repo"),
|
||||
taskKey("default", "task"),
|
||||
sandboxInstanceKey("default", "daytona", "sbx"),
|
||||
historyKey("default", "repo"),
|
||||
repoPrSyncKey("default", "repo"),
|
||||
repoBranchSyncKey("default", "repo"),
|
||||
taskStatusSyncKey("default", "repo", "task", "sandbox-1", "session-1")
|
||||
];
|
||||
|
||||
for (const key of keys) {
|
||||
expect(key[0]).toBe("ws");
|
||||
expect(key[1]).toBe("default");
|
||||
}
|
||||
});
|
||||
});
|
||||
92
foundry/packages/client/test/view-model.test.ts
Normal file
92
foundry/packages/client/test/view-model.test.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskRecord } from "@sandbox-agent/foundry-shared";
|
||||
import {
|
||||
filterTasks,
|
||||
formatRelativeAge,
|
||||
fuzzyMatch,
|
||||
summarizeTasks
|
||||
} from "../src/view-model.js";
|
||||
|
||||
const sample: TaskRecord = {
|
||||
workspaceId: "default",
|
||||
repoId: "repo-a",
|
||||
repoRemote: "https://example.com/repo-a.git",
|
||||
taskId: "task-1",
|
||||
branchName: "feature/test",
|
||||
title: "Test Title",
|
||||
task: "Do test",
|
||||
providerId: "daytona",
|
||||
status: "running",
|
||||
statusMessage: null,
|
||||
activeSandboxId: "sandbox-1",
|
||||
activeSessionId: "session-1",
|
||||
sandboxes: [
|
||||
{
|
||||
sandboxId: "sandbox-1",
|
||||
providerId: "daytona",
|
||||
sandboxActorId: null,
|
||||
switchTarget: "daytona://sandbox-1",
|
||||
cwd: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
agentType: null,
|
||||
prSubmitted: false,
|
||||
diffStat: null,
|
||||
prUrl: null,
|
||||
prAuthor: null,
|
||||
ciStatus: null,
|
||||
reviewStatus: null,
|
||||
reviewer: null,
|
||||
conflictsWithMain: null,
|
||||
hasUnpushed: null,
|
||||
parentBranch: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
};
|
||||
|
||||
describe("search helpers", () => {
|
||||
it("supports ordered fuzzy matching", () => {
|
||||
expect(fuzzyMatch("feature/test-branch", "ftb")).toBe(true);
|
||||
expect(fuzzyMatch("feature/test-branch", "fbt")).toBe(false);
|
||||
});
|
||||
|
||||
it("filters rows across branch and title", () => {
|
||||
const rows: TaskRecord[] = [
|
||||
sample,
|
||||
{
|
||||
...sample,
|
||||
taskId: "task-2",
|
||||
branchName: "docs/update-intro",
|
||||
title: "Docs Intro Refresh",
|
||||
status: "idle"
|
||||
}
|
||||
];
|
||||
expect(filterTasks(rows, "doc")).toHaveLength(1);
|
||||
expect(filterTasks(rows, "h2")).toHaveLength(1);
|
||||
expect(filterTasks(rows, "test")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summary helpers", () => {
|
||||
it("formats relative age", () => {
|
||||
expect(formatRelativeAge(9_000, 10_000)).toBe("1s");
|
||||
expect(formatRelativeAge(0, 120_000)).toBe("2m");
|
||||
});
|
||||
|
||||
it("summarizes by status and provider", () => {
|
||||
const rows: TaskRecord[] = [
|
||||
sample,
|
||||
{ ...sample, taskId: "task-2", status: "idle", providerId: "daytona" },
|
||||
{ ...sample, taskId: "task-3", status: "error", providerId: "daytona" }
|
||||
];
|
||||
|
||||
const summary = summarizeTasks(rows);
|
||||
expect(summary.total).toBe(3);
|
||||
expect(summary.byStatus.running).toBe(1);
|
||||
expect(summary.byStatus.idle).toBe(1);
|
||||
expect(summary.byStatus.error).toBe(1);
|
||||
expect(summary.byProvider.daytona).toBe(3);
|
||||
});
|
||||
});
|
||||
128
foundry/packages/client/test/workbench-client.test.ts
Normal file
128
foundry/packages/client/test/workbench-client.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { BackendClient } from "../src/backend-client.js";
|
||||
import { createTaskWorkbenchClient } from "../src/workbench-client.js";
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe("createTaskWorkbenchClient", () => {
|
||||
it("scopes mock clients by workspace", async () => {
|
||||
const alpha = createTaskWorkbenchClient({
|
||||
mode: "mock",
|
||||
workspaceId: "mock-alpha",
|
||||
});
|
||||
const beta = createTaskWorkbenchClient({
|
||||
mode: "mock",
|
||||
workspaceId: "mock-beta",
|
||||
});
|
||||
|
||||
const alphaInitial = alpha.getSnapshot();
|
||||
const betaInitial = beta.getSnapshot();
|
||||
expect(alphaInitial.workspaceId).toBe("mock-alpha");
|
||||
expect(betaInitial.workspaceId).toBe("mock-beta");
|
||||
|
||||
await alpha.createTask({
|
||||
repoId: alphaInitial.repos[0]!.id,
|
||||
task: "Ship alpha-only change",
|
||||
title: "Alpha only",
|
||||
});
|
||||
|
||||
expect(alpha.getSnapshot().tasks).toHaveLength(alphaInitial.tasks.length + 1);
|
||||
expect(beta.getSnapshot().tasks).toHaveLength(betaInitial.tasks.length);
|
||||
});
|
||||
|
||||
it("uses the initial task to bootstrap a new mock task session", async () => {
|
||||
const client = createTaskWorkbenchClient({
|
||||
mode: "mock",
|
||||
workspaceId: "mock-onboarding",
|
||||
});
|
||||
const snapshot = client.getSnapshot();
|
||||
|
||||
const created = await client.createTask({
|
||||
repoId: snapshot.repos[0]!.id,
|
||||
task: "Reply with exactly: MOCK_WORKBENCH_READY",
|
||||
title: "Mock onboarding",
|
||||
branch: "feat/mock-onboarding",
|
||||
model: "gpt-4o",
|
||||
});
|
||||
|
||||
const runningTask = client.getSnapshot().tasks.find((task) => task.id === created.taskId);
|
||||
expect(runningTask).toEqual(
|
||||
expect.objectContaining({
|
||||
title: "Mock onboarding",
|
||||
branch: "feat/mock-onboarding",
|
||||
status: "running",
|
||||
}),
|
||||
);
|
||||
expect(runningTask?.tabs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: created.tabId,
|
||||
created: true,
|
||||
status: "running",
|
||||
}),
|
||||
);
|
||||
expect(runningTask?.tabs[0]?.transcript).toEqual([
|
||||
expect.objectContaining({
|
||||
sender: "client",
|
||||
payload: expect.objectContaining({
|
||||
method: "session/prompt",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
await sleep(2_700);
|
||||
|
||||
const completedTask = client.getSnapshot().tasks.find((task) => task.id === created.taskId);
|
||||
expect(completedTask?.status).toBe("idle");
|
||||
expect(completedTask?.tabs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "idle",
|
||||
unread: true,
|
||||
}),
|
||||
);
|
||||
expect(completedTask?.tabs[0]?.transcript).toEqual([
|
||||
expect.objectContaining({ sender: "client" }),
|
||||
expect.objectContaining({ sender: "agent" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes remote push actions through the backend boundary", async () => {
|
||||
const actions: Array<{ workspaceId: string; taskId: string; action: string }> = [];
|
||||
let snapshotReads = 0;
|
||||
const backend = {
|
||||
async runAction(workspaceId: string, taskId: string, action: string): Promise<void> {
|
||||
actions.push({ workspaceId, taskId, action });
|
||||
},
|
||||
async getWorkbench(workspaceId: string) {
|
||||
snapshotReads += 1;
|
||||
return {
|
||||
workspaceId,
|
||||
repos: [],
|
||||
projects: [],
|
||||
tasks: [],
|
||||
};
|
||||
},
|
||||
subscribeWorkbench(): () => void {
|
||||
return () => {};
|
||||
},
|
||||
} as unknown as BackendClient;
|
||||
|
||||
const client = createTaskWorkbenchClient({
|
||||
mode: "remote",
|
||||
backend,
|
||||
workspaceId: "remote-ws",
|
||||
});
|
||||
|
||||
await client.pushTask({ taskId: "task-123" });
|
||||
|
||||
expect(actions).toEqual([
|
||||
{
|
||||
workspaceId: "remote-ws",
|
||||
taskId: "task-123",
|
||||
action: "push",
|
||||
},
|
||||
]);
|
||||
expect(snapshotReads).toBe(1);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue