Files
kandev/apps/web/e2e/tests/task/runner-switch.spec.ts
T
2026-09-13 15:25:16 +01:00

203 lines
8.3 KiB
TypeScript

import type { Page } from "@playwright/test";
import { expect, test } from "../../fixtures/test-base";
import { watchWs } from "../../helpers/causal-waits";
import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
import type { ApiClient } from "../../helpers/api-client";
// REQ-TASKS-RUNNER-SWITCH-004: the executor-profile picker in the task edit
// dialog, gated by the server-projected runner_editable verdict rather than
// task state. Exercises the regular task-create/edit dialog, so run with the
// office feature disabled (see useRegularMode).
useRegularMode();
type ExecutorProfile = { id: string; name: string };
type ExecutorSummary = { id: string; type: string; profiles?: ExecutorProfile[] };
async function twoExecutorProfiles(
apiClient: ApiClient,
): Promise<{ first: ExecutorProfile; second: ExecutorProfile }> {
const { executors } = await apiClient.listExecutors();
const worktreeExecutor = executors.find(
(executor: ExecutorSummary) => executor.type === "worktree",
);
const localExecutor = executors.find((executor: ExecutorSummary) =>
["local", "local_pc"].includes(executor.type),
);
const first = worktreeExecutor?.profiles?.[0];
const second = localExecutor?.profiles?.[0];
expect(first, "a worktree executor profile is required by the fixture").toBeDefined();
expect(second, "a local executor profile is required by the fixture").toBeDefined();
return { first: first!, second: second! };
}
async function openEditDialog(page: Page, kanban: KanbanPage, taskId: string) {
await kanban.openTaskActionsMenu(taskId);
await page.getByRole("menuitem", { name: "Edit", exact: true }).click();
const dialog = page.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
return dialog;
}
// A not-started task's edit dialog also auto-picks a default agent, which
// flips the footer to the split "Start task" button (showStartTask in
// task-create-dialog-footer.tsx) instead of a plain "Update task" button.
// The plain button only appears once an agent has been explicitly cleared.
// Saving without launching a session means opening the split button's
// dropdown and choosing its "Update task" alt action either way.
async function saveEditWithoutStartingAgent(
page: Page,
dialog: import("@playwright/test").Locator,
) {
const chevron = dialog.getByTestId("submit-start-agent-chevron");
if (await chevron.isVisible()) {
await chevron.click();
await page.getByTestId("submit-create-without-agent").click();
return;
}
await dialog.getByRole("button", { name: "Update task", exact: true }).click();
}
test.describe("Runner switch before materialization", () => {
test("switching the executor profile on an eligible task updates the stored runner", async ({
testPage,
apiClient,
seedData,
}) => {
const { first, second } = await twoExecutorProfiles(apiClient);
const task = await apiClient.createTask(seedData.workspaceId, "Runner switch eligible task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
// `executor_profile_id` on the create body is only persisted to metadata
// when an agent_profile_id is also set (task_http_handlers.go), so a
// plain task needs the stored profile written directly to give the
// dialog a genuine "stored choice" to seed from, distinct from the
// "resolved default" case covered below.
await apiClient.updateTaskMetadata(task.id, { executor_profile_id: first.id });
const ws = watchWs(testPage);
const kanban = new KanbanPage(testPage);
await kanban.goto();
const dialog = await openEditDialog(testPage, kanban, task.id);
const executorSelector = dialog.getByTestId("executor-profile-selector");
await expect(executorSelector).toBeVisible();
await expect(dialog.getByTestId("runner-ineligible-note")).toHaveCount(0);
await expect(executorSelector).toContainText(first.name);
await executorSelector.click();
await testPage.getByRole("option", { name: new RegExp(second.name) }).click();
const switched = ws.waitForResponse("task.runner");
await saveEditWithoutStartingAgent(testPage, dialog);
await switched;
await expect(dialog).toHaveCount(0);
const updated = await apiClient.getTask(task.id);
expect(updated.metadata?.executor_profile_id).toBe(second.id);
});
test("a task with an existing session presents the reason instead of the picker", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Runner switch ineligible task",
seedData.agentProfileId,
{
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await expect
.poll(async () => (await apiClient.getTask(task.id)).primary_session_id ?? null)
.not.toBeNull();
const kanban = new KanbanPage(testPage);
await kanban.goto();
const dialog = await openEditDialog(testPage, kanban, task.id);
const note = dialog.getByTestId("runner-ineligible-note");
await expect(note).toBeVisible();
await expect(note).toContainText("already has a session");
await expect(dialog.getByTestId("executor-profile-selector")).toHaveCount(0);
});
test("a switch rejected after the dialog opened leaves the dialog open with no partial save", async ({
testPage,
apiClient,
seedData,
}) => {
const { first, second } = await twoExecutorProfiles(apiClient);
const task = await apiClient.createTask(seedData.workspaceId, "Runner switch race task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
await apiClient.updateTaskMetadata(task.id, { executor_profile_id: first.id });
const kanban = new KanbanPage(testPage);
await kanban.goto();
const dialog = await openEditDialog(testPage, kanban, task.id);
const executorSelector = dialog.getByTestId("executor-profile-selector");
await executorSelector.click();
await testPage.getByRole("option", { name: new RegExp(second.name) }).click();
// Simulate a concurrent change that makes the task ineligible between the
// dialog opening (with a stale eligible snapshot) and Save: the server
// re-evaluates the gate inside the switch transaction and must reject.
await apiClient.archiveTask(task.id);
await saveEditWithoutStartingAgent(testPage, dialog);
const toast = testPage.getByTestId("toast-message");
await expect(toast).toBeVisible();
await expect(toast).toContainText("archived");
await expect(dialog).toBeVisible();
const afterRejection = await apiClient.getTask(task.id);
expect(afterRejection.metadata?.executor_profile_id).toBe(first.id);
});
test("saving without changing a seeded default profile issues no switch", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTask(seedData.workspaceId, "Runner switch untouched task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
expect((await apiClient.getTask(task.id)).metadata?.executor_profile_id).toBeUndefined();
const ws = watchWs(testPage);
const kanban = new KanbanPage(testPage);
await kanban.goto();
const dialog = await openEditDialog(testPage, kanban, task.id);
// The picker seeds a resolved default (AC-TASKS-RUNNER-SWITCH-004.5a);
// leaving it untouched must not pin that default as an explicit switch.
await expect(dialog.getByTestId("executor-profile-selector")).toBeVisible();
await dialog.getByTestId("task-title-input").fill("Runner switch untouched task (renamed)");
const rejectedRunnerCall = ws
.waitForResponse("task.runner", { timeout: 3_000 })
.then(() => "sent" as const)
.catch(() => "not-sent" as const);
await saveEditWithoutStartingAgent(testPage, dialog);
await expect(dialog).toHaveCount(0);
expect(await rejectedRunnerCall).toBe("not-sent");
const updated = await apiClient.getTask(task.id);
expect(updated.title).toBe("Runner switch untouched task (renamed)");
expect(updated.metadata?.executor_profile_id).toBeUndefined();
});
});