Files
kandev/apps/web/e2e/tests/settings/repository-delete.spec.ts
T

152 lines
5.7 KiB
TypeScript

import path from "node:path";
import fs from "node:fs";
import { execSync } from "node:child_process";
import { test, expect } from "../../fixtures/test-base";
import { makeGitEnv } from "../../helpers/git-helper";
/**
* Covers the pre-deletion guard on repositories.
*
* The card calls `GET /api/v1/repositories/:id/active-session-count` when the
* user clicks Delete. When the count is zero the dialog offers the destructive
* action; when the count is positive the destructive button is hidden and the
* dialog explains the user must stop the active sessions first. This is the
* UX parity with the workflow-delete flow.
*/
test.describe("Repository deletion", () => {
async function makeRepo(opts: {
apiClient: import("../../helpers/api-client").ApiClient;
backendTmpDir: string;
workspaceId: string;
suffix: string;
}): Promise<{ id: string; name: string }> {
const repoName = `E2E Repo Delete ${opts.suffix}`;
const repoDir = path.join(opts.backendTmpDir, "repos", `e2e-repo-delete-${opts.suffix}`);
fs.mkdirSync(repoDir, { recursive: true });
const env = makeGitEnv(opts.backendTmpDir);
execSync("git init -b main", { cwd: repoDir, env });
execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env });
const repo = await opts.apiClient.createRepository(opts.workspaceId, repoDir, "main", {
name: repoName,
});
return { id: repo.id, name: repoName };
}
test("removes the card after confirm when no active sessions reference the repository", async ({
testPage,
apiClient,
backend,
seedData,
}) => {
test.setTimeout(60_000);
// testPage runs e2eReset before every test/retry, so a static suffix is
// safe and keeps test output deterministic.
const suffix = "ok";
const repo = await makeRepo({
apiClient,
backendTmpDir: backend.tmpDir,
workspaceId: seedData.workspaceId,
suffix,
});
await testPage.goto(`/settings/workspace/${seedData.workspaceId}/repositories`);
const card = testPage.locator('[data-slot="card"]', { hasText: repo.name });
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole("button", { name: "Delete", exact: true }).click();
const dialog = testPage.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 10_000 });
await expect(dialog.getByText(/This action cannot be undone/i)).toBeVisible();
await expect(dialog.getByRole("button", { name: "Delete Repository" })).toBeVisible();
await dialog.getByRole("button", { name: "Delete Repository" }).click();
await expect(card).toBeHidden({ timeout: 10_000 });
await expect(dialog).toBeHidden({ timeout: 10_000 });
});
test("blocks deletion and hides destructive button when an active session uses the repository", async ({
testPage,
apiClient,
backend,
seedData,
}) => {
test.setTimeout(90_000);
const suffix = "blocked";
const repo = await makeRepo({
apiClient,
backendTmpDir: backend.tmpDir,
workspaceId: seedData.workspaceId,
suffix,
});
// Long delay keeps the mock-agent in RUNNING for the whole test so the
// CountActiveTaskSessionsByRepository query is guaranteed to return 1.
// e2eReset on the next test tears the task down via DeleteTask, which
// shuts the agentctl instance via the cleanup goroutine.
await apiClient.createTaskWithAgent(
seedData.workspaceId,
`Repo Delete Blocker ${suffix}`,
seedData.agentProfileId,
{
description: 'e2e:delay(60000)\ne2e:message("done")',
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [repo.id],
},
);
// Wait until the count endpoint actually reports the session as active —
// there is a small window between task create, session insert, and the
// join-against-task_repositories query observing the row. Without this
// poll the click can race the SQL and the dialog opens in the
// no-active-sessions branch.
await expect
.poll(
async () => {
const res = await apiClient.rawRequest(
"GET",
`/api/v1/repositories/${repo.id}/active-session-count`,
);
if (!res.ok) return -1;
const body = (await res.json()) as { active_session_count: number };
return body.active_session_count;
},
{ timeout: 15_000, intervals: [200, 500, 1_000] },
)
.toBeGreaterThan(0);
await testPage.goto(`/settings/workspace/${seedData.workspaceId}/repositories`);
const card = testPage.locator('[data-slot="card"]', { hasText: repo.name });
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole("button", { name: "Delete", exact: true }).click();
const dialog = testPage.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 10_000 });
await expect(dialog.getByText(/active agent session/i)).toBeVisible();
// Destructive button is gated behind hasActiveSessions === false, so the
// count > 0 path must not render it.
await expect(dialog.getByRole("button", { name: "Delete Repository" })).toHaveCount(0);
// Radix DialogContent renders its own X close icon also labelled "Close";
// scope to the footer slot so the locator resolves strictly to the visible
// footer button instead of silently disambiguating with .first().
const closeButton = dialog
.locator('[data-slot="dialog-footer"]')
.getByRole("button", { name: "Close", exact: true });
await expect(closeButton).toBeVisible();
await closeButton.click();
await expect(dialog).toBeHidden({ timeout: 5_000 });
// The card stays — the repository was not deleted.
await expect(card).toBeVisible();
});
});