tests/e2e/control-composer.spec.ts
Rama main · réplica de lectura del repositorio original.
import { expect, test } from "@playwright/test";
import { enableScopedE2EAuth, scopedPath } from "./helpers/auth";
import {
fulfillJson,
mockBaseChrome,
mockControlSessionBootstrap,
modelId,
} from "./helpers/automation-control-plane-fixtures";
const shortModel = modelId.split("/").pop()!;
test("control composers expose permissions, model, and MCP controls without a spend cap", async ({
page,
}) => {
await enableScopedE2EAuth(page);
await mockBaseChrome(page);
// Registered after mockBaseChrome so it wins: the composer's model chip
// must follow the account default from /api/models.
await page.route("**/api/models", (route) =>
fulfillJson(route, {
models: [
{ id: modelId, context_length: 128000 },
{ id: "anthropic/claude-sonnet-5", context_length: 200000 },
],
catalog: [
{ id: modelId, context_length: 128000, is_enabled: true },
{
id: "anthropic/claude-sonnet-5",
context_length: 200000,
is_enabled: true,
},
],
default_model: modelId,
})
);
await page.route("**/api/connections", (route) =>
fulfillJson(route, { connections: [] })
);
// One connected repo: the composer must default the session's project to it.
await page.route("**/api/repos**", (route) =>
fulfillJson(route, [
{
id: "repo-1",
full_name: "acme/widgets",
owner: "acme",
name: "widgets",
default_branch: "main",
},
{
id: "repo-2",
full_name: "acme/roadmap",
owner: "acme",
name: "roadmap",
default_branch: "main",
},
])
);
const sessionCreates: Array<{
title?: string;
project?: string | null;
repo_id?: string | null;
model_id?: string | null;
}> = [];
const sessionUpdates: Array<Record<string, unknown>> = [];
let sessionRecord: Record<string, unknown> | null = null;
await page.route("**/api/control/sessions**", (route) => {
const request = route.request();
if (request.method() === "PUT" && sessionRecord) {
const body = request.postDataJSON() as Record<string, unknown>;
sessionUpdates.push(body);
sessionRecord = {
...sessionRecord,
...body,
updated_at: new Date().toISOString(),
};
return fulfillJson(route, { ok: true, session: sessionRecord });
}
if (request.method() === "GET")
return fulfillJson(
route,
new URL(request.url()).searchParams.has("id")
? sessionRecord
: sessionRecord
? [sessionRecord]
: []
);
if (request.method() !== "POST") return route.fallback();
const body = request.postDataJSON() as {
title?: string;
project?: string | null;
repo_id?: string | null;
model_id?: string | null;
};
sessionCreates.push(body);
sessionRecord = {
id: "sess-e2e-1",
title: body.title ?? "Session",
project: body.project ?? null,
repo_id: body.repo_id ?? null,
model_id: body.model_id ?? null,
pinned: false,
archived: false,
created_at: "2026-08-11T00:00:00.000Z",
updated_at: "2026-08-11T00:00:00.000Z",
messages: [],
};
return fulfillJson(route, sessionRecord);
});
const chatRequests: Array<{
messages?: Array<{
role?: string;
parts?: Array<{
type: string;
text?: string;
filename?: string;
mediaType?: string;
url?: string;
}>;
}>;
model?: string;
mode?: string;
permissions?: string;
scope?: string;
target?: string;
conversationId?: string | null;
missionId?: string | null;
missionTitle?: string | null;
repoId?: string | null;
repoFullName?: string | null;
repoOwner?: string | null;
repoName?: string | null;
repoBranch?: string | null;
repoBaseBranch?: string | null;
sandboxId?: string | null;
}> = [];
// A minimal but real UI message stream: text reply plus one tool call, so
// the timeline's MOGPLEX and TOOL rendering is exercised end to end.
const streamChunks = [
{ type: "start" },
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: "I can plan, delegate, and ship." },
{ type: "text-end", id: "t1" },
{
type: "tool-input-available",
toolCallId: "call-1",
toolName: "list_worktrees",
input: { missionId: "MSN-1" },
},
{ type: "finish" },
];
const streamBody =
streamChunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") +
"data: [DONE]\n\n";
await page.route("**/api/control/chat", (route) => {
chatRequests.push(
route.request().postDataJSON() as (typeof chatRequests)[number]
);
return route.fulfill({
status: 200,
headers: {
"content-type": "text/event-stream",
"x-vercel-ai-ui-message-stream": "v1",
},
body: streamBody,
});
});
await page.goto(scopedPath("control"));
await page.waitForLoadState("networkidle");
// New-mission composer: permissions defaults to Skip Permissions (amber
// warning), cycles to Approve Edits (blue), and no dollar spend-cap chip
// exists anywhere.
await expect(page.getByText("Describe the outcome")).toBeVisible();
// The session's project defaults to the connected repo.
const projectPicker = page.getByLabel("Project", { exact: true });
await expect(projectPicker).toBeVisible();
await expect(projectPicker).toHaveAttribute("role", "combobox");
await expect(projectPicker).toContainText("acme/widgets");
await projectPicker.click();
const projectSearch = page.getByLabel("Search projects", { exact: true });
await expect(projectSearch).toBeFocused();
await projectSearch.pressSequentially("roadmap");
await expect(projectSearch).toHaveValue("roadmap");
await expect(
page.getByRole("option", { name: "acme/roadmap" })
).toBeVisible();
await expect(page.getByRole("option", { name: "acme/widgets" })).toBeHidden();
await projectSearch.press("Enter");
await expect(projectPicker).toContainText("acme/roadmap");
await projectPicker.click();
await projectSearch.press("ArrowUp");
const roadmapOption = page.getByRole("option", { name: "acme/roadmap" });
await expect(roadmapOption).toBeFocused();
await roadmapOption.press("Space");
await expect(projectPicker).toContainText("acme/roadmap");
const permissionsChip = page.getByRole("button", {
name: "Skip Permissions",
});
await expect(permissionsChip).toBeVisible();
await expect(permissionsChip).toHaveClass(/accent-amber/);
await expect(page.getByText(/\$\d+ cap/)).toHaveCount(0);
await permissionsChip.click();
const approveEditsChip = page.getByRole("button", { name: "Approve Edits" });
await expect(approveEditsChip).toBeVisible();
await expect(approveEditsChip).toHaveClass(/accent-blue/);
await approveEditsChip.click();
await expect(
page.getByRole("button", { name: "Skip Permissions" })
).toBeVisible();
const initialModelChip = page.getByRole("button", {
name: shortModel,
exact: true,
});
await initialModelChip.click();
await page.getByRole("button", { name: "anthropic/claude-sonnet-5" }).click();
// No manual plan-mode gate: the composer sends straight to the agent.
await expect(page.getByRole("button", { name: "Plan mode" })).toHaveCount(0);
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: "control-plan.txt",
mimeType: "text/plain",
buffer: Buffer.from("Prefer the smallest safe release plan."),
});
await expect(page.getByText("control-plan.txt")).toBeVisible();
await page
.getByPlaceholder("Ask anything or run a command...")
.fill("Ship the new onboarding flow");
await page.getByRole("button", { name: "Start mission" }).click();
// Streamed replies and tool status render in the conversation.
const conversation = page.getByRole("log", { name: "Conversation" });
await expect(
conversation.getByText("I can plan, delegate, and ship.")
).toBeVisible();
await expect(conversation.getByText("Checking workers")).toBeVisible();
await conversation.locator("summary").click();
await expect(
conversation.locator("pre").filter({ hasText: /list_worktrees\(/ })
).toBeVisible();
await expect(page.getByText(/Mogplex is planning/)).toHaveCount(0);
await expect(page.getByText(/Budget: \$/)).toHaveCount(0);
expect(chatRequests[0]).toMatchObject({
model: "anthropic/claude-sonnet-5",
mode: "run",
permissions: "Skip Permissions",
scope: "IMPLEMENT",
target: "mission",
conversationId: "sess-e2e-1",
repoId: "repo-2",
repoFullName: "acme/roadmap",
repoOwner: "acme",
repoName: "roadmap",
repoBranch: "main",
repoBaseBranch: "main",
});
await expect(page).toHaveURL(/\/control\?mission=sess-e2e-1$/);
await expect(
conversation.getByText("I can plan, delegate, and ship.")
).toBeVisible();
// The new session is tied to the repo's project.
expect(sessionCreates).toHaveLength(1);
expect(sessionCreates[0]).toMatchObject({
project: "acme/roadmap",
repo_id: "repo-2",
model_id: "anthropic/claude-sonnet-5",
});
expect(chatRequests[0]?.messages?.at(-1)?.parts).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "text",
text: "Ship the new onboarding flow",
}),
expect.objectContaining({
type: "file",
filename: "control-plan.txt",
mediaType: "text/plain",
}),
])
);
const initialFilePart = chatRequests[0]?.messages
?.at(-1)
?.parts?.find((part) => part.type === "file");
expect(initialFilePart?.url).toContain("data:text/plain");
// Conversation controls retain the persisted model.
const composerPermissions = page.getByRole("button", {
name: "Skip Permissions",
});
await expect(composerPermissions).toBeVisible();
const modelChip = page.getByRole("button", {
name: "claude-sonnet-5",
exact: true,
});
await expect(modelChip).toBeVisible();
const mcpButton = page.getByRole("button", { name: "Tools: 0" });
await expect(mcpButton).toBeVisible();
await mcpButton.click();
await expect(page.getByRole("dialog", { name: "MCP servers" })).toBeVisible();
await page.keyboard.press("Escape");
// Switching models routes the chosen id through to the chat request body.
await modelChip.click();
await page.getByRole("button", { name: modelId }).click();
await expect
.poll(() => sessionUpdates.some((update) => update.model_id === modelId))
.toBe(true);
await expect(page.getByRole("button", { name: "Plan mode" })).toHaveCount(0);
await page
.getByPlaceholder("Ask for follow-up changes or attach images")
.fill("Summarize progress");
await page.getByRole("button", { name: "Send" }).click();
await expect
.poll(() => chatRequests.at(-1)?.model, { timeout: 10_000 })
.toBe(modelId);
expect(chatRequests.at(-1)).toMatchObject({
mode: "run",
permissions: "Skip Permissions",
scope: "IMPLEMENT",
target: "mission",
});
await expect(
page.getByRole("button", { name: "Attach file" }).last()
).toBeEnabled();
await page.getByTestId("control-composer-dropzone").evaluate((element) => {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(
new File(["image bytes"], "dropped-screenshot.png", {
type: "image/png",
})
);
element.dispatchEvent(
new DragEvent("drop", { bubbles: true, dataTransfer })
);
});
await expect(page.getByText("dropped-screenshot.png")).toBeVisible();
await page.getByRole("button", { name: "Send" }).click();
await expect.poll(() => chatRequests.length, { timeout: 10_000 }).toBe(3);
const attachmentOnlyRequest = chatRequests.at(-1);
expect(attachmentOnlyRequest).toMatchObject({
mode: "run",
scope: "IMPLEMENT",
target: "mission",
});
expect(attachmentOnlyRequest?.messages?.at(-1)?.parts).toEqual([
expect.objectContaining({
type: "file",
filename: "dropped-screenshot.png",
mediaType: "image/png",
}),
]);
});
test("control chat surfaces request failures instead of swallowing them", async ({
page,
}) => {
await enableScopedE2EAuth(page);
await mockBaseChrome(page);
await mockControlSessionBootstrap(page);
await page.route("**/api/connections", (route) =>
fulfillJson(route, { connections: [] })
);
await page.route("**/api/repos**", (route) =>
fulfillJson(route, [
{
id: "repo-1",
full_name: "acme/widgets",
owner: "acme",
name: "widgets",
default_branch: "main",
},
])
);
await page.route("**/api/control/chat", (route) =>
route.fulfill({ status: 500, body: "orchestrator unavailable" })
);
await page.goto(scopedPath("control"));
await page.waitForLoadState("networkidle");
await page
.getByPlaceholder("Ask anything or run a command...")
.fill("Ship something");
await page.getByRole("button", { name: "Start mission" }).click();
// The failed first send must produce a visible error, not silence.
await expect(
page.locator(".text-accent-amber").filter({ hasText: /./ }).first()
).toBeVisible();
});
test("control timeline renders agent markdown as formatted HTML", async ({
page,
}) => {
await enableScopedE2EAuth(page);
await mockBaseChrome(page);
await mockControlSessionBootstrap(page);
await page.route("**/api/connections", (route) =>
fulfillJson(route, { connections: [] })
);
// The agent's reply is markdown: a heading plus a GFM table. The timeline
// must render it as HTML, not literal pipes and dashes.
const agentMarkdown = [
"## Tool overview",
"",
"| Tool | Purpose |",
"| --- | --- |",
"| read_file | Read a file from the repo |",
"| write_file | Write content to a file |",
].join("\n");
const streamChunks = [
{ type: "start" },
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: agentMarkdown },
{ type: "text-end", id: "t1" },
{ type: "finish" },
];
const streamBody =
streamChunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") +
"data: [DONE]\n\n";
await page.route("**/api/control/chat", (route) =>
route.fulfill({
status: 200,
headers: {
"content-type": "text/event-stream",
"x-vercel-ai-ui-message-stream": "v1",
},
body: streamBody,
})
);
await page.goto(scopedPath("control"));
await page.waitForLoadState("networkidle");
await page
.getByPlaceholder("Ask anything or run a command...")
.fill("List your tools");
await page.getByRole("button", { name: "Start mission" }).click();
await expect(
page.getByRole("heading", { name: "Tool overview" }).first()
).toBeVisible();
await expect(
page.getByRole("cell", { name: "Read a file from the repo" }).first()
).toBeVisible();
await expect(
page.getByRole("complementary", { name: "Artifacts" })
).toHaveCount(0);
await expect(
page.getByRole("log", { name: "Conversation" }).getByText("| --- |")
).toHaveCount(0);
});
test("control timeline does not expose raw infrastructure details", async ({
page,
}) => {
await enableScopedE2EAuth(page);
await mockBaseChrome(page);
await mockControlSessionBootstrap(page);
await page.route("**/api/connections", (route) =>
fulfillJson(route, { connections: [] })
);
const rawAgentText =
"The Vercel Sandbox failed at /Users/runner/acme/widgets/.worktrees/sbx_abcdef/src/app.ts for deployment dpl_123456.";
const streamChunks = [
{ type: "start" },
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: rawAgentText },
{ type: "text-end", id: "t1" },
{ type: "finish" },
];
const streamBody =
streamChunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") +
"data: [DONE]\n\n";
await page.route("**/api/control/chat", (route) =>
route.fulfill({
status: 200,
headers: {
"content-type": "text/event-stream",
"x-vercel-ai-ui-message-stream": "v1",
},
body: streamBody,
})
);
await page.goto(scopedPath("control"));
await page.waitForLoadState("networkidle");
await page
.getByPlaceholder("Ask anything or run a command...")
.fill("Ship the fix");
await page.getByRole("button", { name: "Start mission" }).click();
const conversation = page.getByRole("log", { name: "Conversation" });
await expect(conversation).toContainText("development environment failed");
await expect(conversation).toContainText("src/app.ts");
await expect(conversation).not.toContainText("Vercel Sandbox");
await expect(conversation).not.toContainText("/Users/runner");
await expect(conversation).not.toContainText("dpl_123456");
});