20308a2d16
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
203 lines
8.3 KiB
TypeScript
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();
|
|
});
|
|
});
|