256 lines
11 KiB
TypeScript
256 lines
11 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { test, expect } from "../../fixtures/test-base";
|
|
import { seedManagedGoCache } from "../../helpers/storage-maintenance";
|
|
|
|
function seedOrphanWorkspace(tmpDir: string): { root: string; artifact: string } {
|
|
const root = path.join(tmpDir, ".kandev", "tasks", "e2e-storage-orphan_abc");
|
|
const artifact = path.join(root, "repo", "node_modules", "fixture", "index.js");
|
|
fs.mkdirSync(path.dirname(artifact), { recursive: true });
|
|
fs.writeFileSync(artifact, "orphan-node-modules-fixture");
|
|
fs.writeFileSync(
|
|
path.join(root, ".kandev-workspace.json"),
|
|
JSON.stringify({
|
|
task_id: "e2e-storage-orphan",
|
|
workspace_id: "e2e-orphan-workspace",
|
|
task_dir_name: "e2e-storage-orphan_abc",
|
|
layout_version: 1,
|
|
created_at: "2026-06-01T00:00:00Z",
|
|
}),
|
|
);
|
|
const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
|
|
fs.utimesSync(root, old, old);
|
|
return { root, artifact };
|
|
}
|
|
|
|
test.describe("System storage maintenance", () => {
|
|
test("cleans a disabled managed Go cache only through its explicit action", async ({
|
|
testPage,
|
|
backend,
|
|
}) => {
|
|
const cache = seedManagedGoCache(backend.tmpDir);
|
|
expect(fs.statSync(cache.artifact).size).toBeGreaterThan(15 * 1024 * 1024 * 1024);
|
|
const overviewResponse = testPage.waitForResponse(
|
|
(response) => new URL(response.url()).pathname === "/api/v1/system/storage",
|
|
);
|
|
await testPage.goto("/settings/system/storage");
|
|
const overview = await (await overviewResponse).json();
|
|
expect(overview.summary.go_cache).toMatchObject({ owned: true });
|
|
expect(overview.summary.go_cache.size_bytes).toBeGreaterThan(15 * 1024 * 1024 * 1024);
|
|
await testPage.getByTestId("storage-resource-go-cache-trigger").click();
|
|
const cleanButton = testPage.getByTestId("storage-go-cache-clean");
|
|
await expect(cleanButton).toBeEnabled();
|
|
|
|
const globalRequest = testPage.waitForRequest(
|
|
(request) =>
|
|
request.method() === "POST" &&
|
|
new URL(request.url()).pathname === "/api/v1/system/storage/run",
|
|
);
|
|
await testPage.getByTestId("storage-run-now").click();
|
|
expect((await globalRequest).postDataJSON()).toEqual({});
|
|
await expect(testPage.getByTestId("storage-run-now")).toHaveAttribute(
|
|
"data-job-state",
|
|
"succeeded",
|
|
);
|
|
expect(fs.existsSync(cache.artifact)).toBe(true);
|
|
|
|
const explicitRequest = testPage.waitForRequest(
|
|
(request) =>
|
|
request.method() === "POST" &&
|
|
new URL(request.url()).pathname === "/api/v1/system/storage/run",
|
|
);
|
|
await cleanButton.click();
|
|
expect((await explicitRequest).postDataJSON()).toEqual({ resources: ["go_cache"] });
|
|
await expect.poll(() => fs.existsSync(cache.artifact)).toBe(false);
|
|
});
|
|
|
|
test("reuses the cached snapshot until Analyze refreshes it", async ({ testPage }) => {
|
|
await testPage.goto("/settings/system/storage");
|
|
const analyzedTime = testPage.locator("time[datetime]").filter({ hasText: "Last analyzed" });
|
|
await expect(analyzedTime).toHaveText(/^Last analyzed .+/);
|
|
const initialAnalyzedAt = await analyzedTime.getAttribute("datetime");
|
|
expect(initialAnalyzedAt).toBeTruthy();
|
|
|
|
await testPage.reload();
|
|
await expect(analyzedTime).toHaveAttribute("datetime", initialAnalyzedAt!);
|
|
|
|
const scheduling = testPage.getByTestId("storage-scheduling-enabled");
|
|
const initialSchedulingState = await scheduling.getAttribute("data-state");
|
|
try {
|
|
await scheduling.click();
|
|
await testPage.getByRole("button", { name: "Save changes" }).click();
|
|
await expect(testPage.getByText("Storage policy saved")).toBeVisible();
|
|
await expect(analyzedTime).toHaveAttribute("datetime", initialAnalyzedAt!);
|
|
|
|
await testPage.getByTestId("storage-analyze").click();
|
|
await expect(testPage.getByTestId("storage-analyze")).toHaveAttribute(
|
|
"data-job-state",
|
|
"succeeded",
|
|
);
|
|
await expect.poll(() => analyzedTime.getAttribute("datetime")).not.toBe(initialAnalyzedAt);
|
|
} finally {
|
|
if ((await scheduling.getAttribute("data-state")) !== initialSchedulingState) {
|
|
await scheduling.click();
|
|
await testPage.getByRole("button", { name: "Save changes" }).click();
|
|
await expect(testPage.getByText("Storage policy saved")).toBeVisible();
|
|
}
|
|
}
|
|
});
|
|
|
|
test("persists policy and analyzes, quarantines, and restores an orphan workspace", async ({
|
|
testPage,
|
|
backend,
|
|
}) => {
|
|
const orphan = seedOrphanWorkspace(backend.tmpDir);
|
|
await testPage.goto("/settings/system/storage");
|
|
const overviewBox = await testPage.getByTestId("storage-overview-card").boundingBox();
|
|
const policyBox = await testPage.getByTestId("storage-policy-card").boundingBox();
|
|
expect(overviewBox).not.toBeNull();
|
|
expect(policyBox).not.toBeNull();
|
|
expect(policyBox!.y).toBeGreaterThanOrEqual(overviewBox!.y + overviewBox!.height);
|
|
const scheduling = testPage.getByTestId("storage-scheduling-enabled");
|
|
await expect(scheduling).toHaveAttribute("data-state", "unchecked");
|
|
await expect(testPage.getByTestId("storage-check-interval")).toHaveValue("24");
|
|
await expect(testPage.getByTestId("storage-check-interval")).toBeDisabled();
|
|
await expect(testPage.getByTestId("storage-idle-period")).toBeDisabled();
|
|
|
|
await scheduling.click();
|
|
await expect(testPage.getByTestId("storage-check-interval")).toBeEnabled();
|
|
await expect(testPage.getByTestId("storage-idle-period")).toBeEnabled();
|
|
await testPage.getByTestId("storage-idle-period").fill("11");
|
|
await expect(testPage.getByTestId("storage-idle-period")).toHaveAttribute(
|
|
"data-settings-dirty",
|
|
"true",
|
|
);
|
|
await expect(testPage.getByTestId("storage-policy-section-schedule")).toHaveAttribute(
|
|
"data-settings-dirty",
|
|
"true",
|
|
);
|
|
await testPage.getByRole("button", { name: "Save changes" }).click();
|
|
await expect(testPage.getByText("Storage policy saved")).toBeVisible();
|
|
await testPage.reload();
|
|
await expect(scheduling).toHaveAttribute("data-state", "checked");
|
|
await expect(testPage.getByTestId("storage-idle-period")).toHaveValue("11");
|
|
|
|
// Stop the newly enabled scheduler before exercising a deterministic manual run.
|
|
await scheduling.click();
|
|
await testPage.getByRole("button", { name: "Save changes" }).click();
|
|
await expect(scheduling).toHaveAttribute("data-state", "unchecked");
|
|
|
|
await testPage.getByTestId("storage-analyze").click();
|
|
await expect(testPage.getByTestId("storage-analyze")).toHaveAttribute(
|
|
"data-job-state",
|
|
"succeeded",
|
|
);
|
|
await testPage.getByTestId("storage-resource-workspaces-trigger").click();
|
|
await expect(testPage.getByTestId("storage-resource-workspaces-trigger")).toContainText(
|
|
"Task workspaces<0.01 GB",
|
|
);
|
|
expect(fs.existsSync(orphan.artifact)).toBe(true);
|
|
|
|
await backend.restart();
|
|
await testPage.reload();
|
|
await expect(testPage.getByTestId("storage-idle-period")).toHaveValue("11");
|
|
|
|
await testPage.getByTestId("storage-run-now").click();
|
|
await expect(testPage.getByTestId("storage-run-now")).toHaveAttribute(
|
|
"data-job-state",
|
|
"succeeded",
|
|
);
|
|
const quarantineCard = testPage.getByTestId("storage-quarantine-card");
|
|
const entry = quarantineCard
|
|
.locator('[data-testid^="storage-quarantine-"]')
|
|
.filter({ hasText: orphan.root })
|
|
.last();
|
|
await expect(entry).toBeVisible();
|
|
expect(fs.existsSync(orphan.root)).toBe(false);
|
|
|
|
await entry.getByRole("button", { name: "Restore" }).click();
|
|
await expect.poll(() => fs.existsSync(orphan.artifact)).toBe(true);
|
|
await expect(quarantineCard.getByText(orphan.root)).toHaveCount(0);
|
|
});
|
|
|
|
test("shows busy feedback instead of forcing maintenance over an active task", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
prCapture,
|
|
}) => {
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Storage activity gate",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
if (!task.session_id) throw new Error("createTaskWithAgent did not return session_id");
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const { sessions } = await apiClient.listTaskSessions(task.id);
|
|
return sessions.find((session) => session.id === task.session_id)?.state ?? "";
|
|
},
|
|
{ timeout: 20_000, message: "Waiting for initial task turn to finish" },
|
|
)
|
|
.toBe("WAITING_FOR_INPUT");
|
|
await apiClient.addUserMessage(
|
|
task.id,
|
|
task.session_id,
|
|
'e2e:delay(15000)\ne2e:message("activity finished")',
|
|
);
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const { sessions } = await apiClient.listTaskSessions(task.id);
|
|
return sessions.find((session) => session.id === task.session_id)?.state ?? "";
|
|
},
|
|
{ timeout: 20_000, message: "Waiting for active task storage gate" },
|
|
)
|
|
.toBe("RUNNING");
|
|
|
|
await testPage.goto("/settings/system/storage");
|
|
const responsePromise = testPage.waitForResponse(
|
|
(response) =>
|
|
response.request().method() === "POST" &&
|
|
new URL(response.url()).pathname === "/api/v1/system/storage/run",
|
|
);
|
|
await testPage.getByTestId("storage-run-now").click();
|
|
const response = await responsePromise;
|
|
const responseBody = await response.json();
|
|
expect({ status: response.status(), body: responseBody }).toMatchObject({
|
|
status: 409,
|
|
body: { busy_resources: expect.any(Array) },
|
|
});
|
|
expect(responseBody.force_available).toBe(true);
|
|
expect(responseBody.busy_resources[0]).toMatchObject({
|
|
kind: expect.any(String),
|
|
label: expect.any(String),
|
|
});
|
|
await expect(testPage.getByTestId("storage-busy")).toContainText(
|
|
responseBody.busy_resources[0].label,
|
|
);
|
|
await expect(testPage.getByTestId("storage-busy")).toContainText(/may disrupt/i);
|
|
await prCapture.screenshot("busy-feedback", {
|
|
caption: "Desktop storage cleanup explains the active work and offers Run anyway",
|
|
fullPage: true,
|
|
});
|
|
|
|
const forceRequest = testPage.waitForRequest(
|
|
(request) =>
|
|
request.method() === "POST" &&
|
|
new URL(request.url()).pathname === "/api/v1/system/storage/run",
|
|
);
|
|
await testPage.getByTestId("storage-run-anyway").click();
|
|
expect((await forceRequest).postDataJSON()).toEqual({ force: true });
|
|
await expect(testPage.getByTestId("storage-run-now")).toHaveAttribute(
|
|
"data-job-state",
|
|
"succeeded",
|
|
{ timeout: 20_000 },
|
|
);
|
|
});
|
|
});
|