Files
kandev/apps/web/e2e/tests/settings/mobile-kubernetes-executor.spec.ts
T
2026-09-10 11:05:05 +01:00

419 lines
17 KiB
TypeScript

import path from "node:path";
import { devices, type Browser, type Locator, type Page } from "@playwright/test";
import { backendFixture as test, type BackendContext } from "../../fixtures/backend";
import { ApiClient } from "../../helpers/api-client";
import { acceptInvite, createInviteToken, setupAdmin } from "../../helpers/auth";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { PrAssetCapture } from "../../helpers/pr-asset-capture";
import { expect } from "@playwright/test";
const ADMIN = {
email: "mobile-k8s-admin@e2e.dev",
password: "adminpass123",
displayName: "Mobile K8s Admin",
};
const MEMBER = {
email: "mobile-k8s-member@e2e.dev",
password: "memberpass123",
displayName: "Mobile K8s Member",
};
const LONG_VALUE = "x".repeat(360);
const POD_TEMPLATE = `apiVersion: v1
kind: PodTemplate
template:
metadata:
annotations:
example.com/long-value: "${LONG_VALUE}"
spec:
containers:
- name: kandev-agent
image: example.test/kandev-agent:e2e
`;
const SHORT_POD_TEMPLATE = "apiVersion: v1\nkind: PodTemplate\n";
const TALL_POD_TEMPLATE = `${POD_TEMPLATE}${Array.from(
{ length: 24 },
(_, index) => `# autosize-line-${index + 1}`,
).join("\n")}`;
async function initPage(page: Page, backend: BackendContext) {
await page.addInitScript(
({ backendPort }: { backendPort: string }) => {
localStorage.setItem("kandev.onboarding.completed", "true");
window.__KANDEV_API_PORT = backendPort;
},
{ backendPort: String(backend.port) },
);
}
async function openMobileContext(browser: Browser, backend: BackendContext) {
const context = await browser.newContext({ ...devices["Pixel 5"], baseURL: backend.frontendUrl });
const page = await context.newPage();
await initPage(page, backend);
expect((await page.viewportSize())?.width).toBe(393);
return { context, page };
}
async function expectTouchTarget(page: Page, name: RegExp) {
const button = page.getByRole("button", { name }).first();
await expectTouchLocator(button, String(name));
return button;
}
async function expectTouchLocator(locator: Locator, label: string) {
await expect(locator).toBeVisible();
const box = await locator.boundingBox();
expect(box, `${label} must have geometry`).not.toBeNull();
expect(box!.height, `${label} must be at least 44px tall`).toBeGreaterThanOrEqual(44);
}
async function stubEmptySessions(page: Page) {
await page.route("**/api/v1/kubernetes/executors/*/sessions", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: "[]" });
});
}
async function expectLeadingProfileClusterSection(page: Page, canManage: boolean) {
const section = page.getByTestId("kubernetes-profile-cluster-section");
const workload = page.getByTestId("kubernetes-workload-card");
await expect(section).toBeVisible();
await expect(section.getByTestId("kubernetes-connection-card")).toBeVisible();
await expect(section.getByTestId("kubernetes-diagnostics-card")).toBeVisible();
await expect(section.getByTestId("kubernetes-sessions-card")).toBeVisible();
const sectionBox = await section.boundingBox();
const workloadBox = await workload.boundingBox();
expect(sectionBox).not.toBeNull();
expect(workloadBox).not.toBeNull();
expect(sectionBox!.y).toBeLessThan(workloadBox!.y);
await expect(
section.getByRole("button", {
name: canManage ? /edit cluster connection/i : /view cluster connection/i,
}),
).toHaveCount(0);
await expectTouchLocator(
section.getByTestId("kubernetes-namespace"),
"Kubernetes profile namespace field",
);
await expectTouchLocator(
section.getByTestId("kubernetes-test-button"),
"Kubernetes profile test button",
);
await expectTouchLocator(
section.getByTestId("kubernetes-sessions-card").getByRole("button", { name: /refresh/i }),
"Kubernetes sessions refresh button",
);
}
async function expectPodTemplateAutoSizes(page: Page, yaml: Locator) {
await yaml.fill(SHORT_POD_TEMPLATE);
await expect.poll(async () => (await yaml.boundingBox())?.height ?? 0).toBeLessThanOrEqual(140);
const shortHeight = (await yaml.boundingBox())!.height;
await yaml.fill(TALL_POD_TEMPLATE);
await expect
.poll(async () => (await yaml.boundingBox())?.height ?? 0)
.toBeGreaterThan(shortHeight + 100);
const tallHeight = (await yaml.boundingBox())!.height;
expect(await yaml.evaluate((element) => getComputedStyle(element).overflowY)).toBe("hidden");
expect(await yaml.evaluate((element) => element.scrollHeight <= element.clientHeight + 2)).toBe(
true,
);
expect(await yaml.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true);
await assertNoDocumentHorizontalOverflow(page, "mobile Kubernetes auto-sized YAML");
await yaml.fill(SHORT_POD_TEMPLATE);
await expect
.poll(async () => (await yaml.boundingBox())?.height ?? Number.POSITIVE_INFINITY)
.toBeLessThan(tallHeight);
}
async function seedMemberBoundary(apiClient: ApiClient) {
const executor = await apiClient.createExecutor("Mobile member Kubernetes", "k8s", {
auth_mode: "kubeconfig",
kubeconfig_path: "/tmp/mobile-member.kubeconfig",
namespace: "default",
request_timeout_seconds: "30",
});
const profile = await apiClient.createExecutorProfile(executor.id, {
name: "Mobile member profile",
config: {
platform: "linux/amd64",
main_container: "kandev-agent",
pod_template_yaml: POD_TEMPLATE,
"workspace.mode": "empty_dir",
},
prepare_script: "",
cleanup_script: "",
env_vars: [],
});
return { executor, profile };
}
test("administrator creates, tests, saves, and reloads contained Kubernetes YAML by touch", async ({
browser,
backend,
}) => {
const apiClient = new ApiClient(backend.baseUrl);
const { context, page } = await openMobileContext(browser, backend);
const name = "Mobile persistent Kubernetes";
await stubEmptySessions(page);
await page.route("**/api/v1/kubernetes/test", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
server_version: "v1.36.1",
namespace: "mobile-e2e",
steps: [
{ key: "configuration", success: true, duration_ms: 1, detail: "Configuration valid" },
{ key: "streaming", success: true, duration_ms: 4, detail: "Exec and streaming valid" },
],
warnings: [],
}),
});
});
try {
await page.goto("/settings/executors");
const card = page.getByText("Kubernetes", { exact: true }).first();
await expect(card).toBeVisible();
await card.tap();
await expect(page).toHaveURL((url) => new URL(url).pathname === "/settings/executors/new/k8s");
await page.getByTestId("kubernetes-executor-name").fill(name);
await page.getByTestId("kubernetes-namespace").fill("mobile-e2e");
await page.getByTestId("kubernetes-kubeconfig-path").fill("/tmp/mobile-e2e.kubeconfig");
await page.getByTestId("kubernetes-context").fill("mobile-context");
await page.locator("#profile-name").fill("Mobile profile");
const yaml = page.getByTestId("kubernetes-pod-template");
await yaml.fill(POD_TEMPLATE);
await page.getByTestId("kubernetes-workspace-size").fill("3Gi");
await page.getByTestId("kubernetes-storage-class").fill("standard");
const yamlBox = await yaml.boundingBox();
const cardBox = await page.getByTestId("kubernetes-workload-card").boundingBox();
expect(yamlBox).not.toBeNull();
expect(cardBox).not.toBeNull();
expect(yamlBox!.x).toBeGreaterThanOrEqual(cardBox!.x);
expect(yamlBox!.x + yamlBox!.width).toBeLessThanOrEqual(cardBox!.x + cardBox!.width + 1);
expect(await yaml.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true);
await assertNoDocumentHorizontalOverflow(page, "mobile Kubernetes create YAML");
const testButton = page.getByTestId("kubernetes-test-button");
await expectTouchLocator(testButton, "Kubernetes test button");
const diagnosticRequest = page.waitForRequest(
(request) => request.url().endsWith("/api/v1/kubernetes/test") && request.method() === "POST",
);
await testButton.tap();
const request = await diagnosticRequest;
expect(request.postDataJSON()).toMatchObject({
config: { namespace: "mobile-e2e", kubeconfig_path: "/tmp/mobile-e2e.kubeconfig" },
profile_config: { pod_template_yaml: POD_TEMPLATE, "workspace.size": "3Gi" },
});
await expect(page.getByTestId("kubernetes-test-step-streaming")).toBeVisible();
await expectTouchTarget(page, /^reset$/i);
const saveButton = await expectTouchTarget(page, /save changes/i);
await saveButton.tap();
await expect(page).toHaveURL(/\/settings\/executors\/[^/]+$/);
const profileId = decodeURIComponent(new URL(page.url()).pathname.split("/").at(-1)!);
const executor = (await apiClient.listExecutors()).executors.find((row) => row.name === name);
expect(executor).toBeTruthy();
await expect
.poll(async () => (await apiClient.getExecutorProfile(executor!.id, profileId)).name)
.toBe("Mobile profile");
await expect(page).toHaveURL(`/settings/executors/${profileId}`);
await expectTouchTarget(page, /delete profile/i);
await assertNoDocumentHorizontalOverflow(page, "mobile Kubernetes saved profile");
await page.reload();
await expect(page.getByTestId("kubernetes-executor-name")).toHaveValue(name);
await expect(page.getByTestId("kubernetes-pod-template")).toHaveValue(POD_TEMPLATE);
await expect(page.getByTestId("kubernetes-workspace-size")).toHaveValue("3Gi");
await expectLeadingProfileClusterSection(page, true);
await assertNoDocumentHorizontalOverflow(page, "mobile Kubernetes reloaded profile");
const clusterSection = page.getByTestId("kubernetes-profile-cluster-section");
await clusterSection.getByTestId("kubernetes-namespace").fill("mobile-draft-namespace");
await page.getByTestId("kubernetes-main-container").fill("mobile-draft-container");
const profileDiagnosticRequest = page.waitForRequest(
(request) => request.url().endsWith("/api/v1/kubernetes/test") && request.method() === "POST",
);
await page
.getByTestId("kubernetes-profile-cluster-section")
.getByTestId("kubernetes-test-button")
.tap();
expect((await profileDiagnosticRequest).postDataJSON()).toMatchObject({
config: {
namespace: "mobile-draft-namespace",
kubeconfig_path: "/tmp/mobile-e2e.kubeconfig",
},
profile_config: { main_container: "mobile-draft-container" },
});
await page.reload();
await expect(page.getByTestId("kubernetes-pod-template")).toHaveValue(POD_TEMPLATE);
await expectPodTemplateAutoSizes(page, page.getByTestId("kubernetes-pod-template"));
await assertNoDocumentHorizontalOverflow(page, "mobile Kubernetes profile after reload");
} finally {
const executor = (await apiClient.listExecutors()).executors.find((row) => row.name === name);
if (executor) await apiClient.deleteExecutor(executor.id).catch(() => undefined);
await context.close();
}
});
test("member boundaries stay read-only and touch-visible on mobile", async ({
browser,
backend,
}) => {
const database = path.join(backend.tmpDir, "kubernetes-member-mobile.db");
await backend.restart({ KANDEV_DATABASE_PATH: database });
const apiClient = new ApiClient(backend.baseUrl);
const seeded = await seedMemberBoundary(apiClient);
await backend.restart({ KANDEV_DATABASE_PATH: database, KANDEV_FEATURES_AUTH: "true" });
const adminContext = await browser.newContext({ baseURL: backend.frontendUrl });
const memberContext = await browser.newContext({
...devices["Pixel 5"],
baseURL: backend.frontendUrl,
});
try {
await setupAdmin(adminContext, backend.baseUrl, ADMIN);
const token = await createInviteToken(adminContext, backend.baseUrl, {
email: MEMBER.email,
role: "member",
});
await acceptInvite(memberContext, backend.baseUrl, token, MEMBER);
const page = await memberContext.newPage();
await initPage(page, backend);
await stubEmptySessions(page);
expect((await page.viewportSize())?.width).toBe(393);
await page.goto(`/settings/executors/k8s/${seeded.executor.id}`);
await expect(page).toHaveURL(`/settings/executors/${seeded.profile.id}`);
await expect(page.getByTestId("kubernetes-read-only-notice")).toBeVisible();
await expect(page.getByTestId("kubernetes-executor-name")).toBeDisabled();
const testButton = page.getByTestId("kubernetes-test-button");
await expect(testButton).toBeDisabled();
await expectTouchLocator(testButton, "Disabled Kubernetes test button");
const deleteButton = page.getByRole("button", { name: /delete profile/i });
await expect(deleteButton).toBeDisabled();
await expectTouchLocator(deleteButton, "Disabled delete executor button");
await expect(page.getByTestId("settings-floating-save")).toHaveCount(0);
await assertNoDocumentHorizontalOverflow(page, "mobile Kubernetes member profile");
const patchResponse = await memberContext.request.patch(
`${backend.baseUrl}/api/v1/executors/${seeded.executor.id}`,
{ data: { name: "Forbidden mobile rename" } },
);
expect(patchResponse.status()).toBe(403);
const testResponse = await memberContext.request.post(
`${backend.baseUrl}/api/v1/kubernetes/test`,
{
data: {
config: {
auth_mode: "kubeconfig",
kubeconfig_path: "/tmp/mobile-member.kubeconfig",
namespace: "default",
request_timeout_seconds: "30",
},
},
},
);
expect(testResponse.status()).toBe(403);
await expectLeadingProfileClusterSection(page, false);
const yaml = page.getByTestId("kubernetes-pod-template");
await expect(yaml).toBeDisabled();
const yamlBox = await yaml.boundingBox();
expect(yamlBox).not.toBeNull();
expect(yamlBox!.x + yamlBox!.width).toBeLessThanOrEqual(393);
await assertNoDocumentHorizontalOverflow(page, "mobile Kubernetes member profile");
await expect(
page
.getByTestId("kubernetes-profile-cluster-section")
.getByRole("button", { name: /view cluster connection/i }),
).toHaveCount(0);
} finally {
await adminContext.close();
await memberContext.close();
await backend.restart();
}
});
test("active session cards expose task and session identities without a cluster", async ({
browser,
backend,
}) => {
const apiClient = new ApiClient(backend.baseUrl);
const executor = await apiClient.createExecutor("Mobile session projection", "k8s", {
auth_mode: "kubeconfig",
kubeconfig_path: "/tmp/mobile-sessions.kubeconfig",
namespace: "default",
request_timeout_seconds: "30",
});
const taskId = "task-mobile-123456789";
const sessionId = "session-mobile-987654321";
const { context, page } = await openMobileContext(browser, backend);
const prCapture = new PrAssetCapture(
page,
path.join(__dirname, "mobile-kubernetes-executor.spec.ts"),
);
await page.route(`**/api/v1/kubernetes/executors/${executor.id}/sessions`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
task_id: taskId,
session_id: sessionId,
pod_name: "kandev-mobile-session",
pod_phase: "Running",
container_state: "running",
restarts: 0,
workspace_kind: "empty_dir",
created_at: "2026-08-24T10:00:00Z",
session_state: "CANCELLED",
retention_state: "retained",
main_container_requests: { cpu: "0", memory: "512Mi" },
},
]),
});
});
try {
await page.goto(`/settings/executors/k8s/${executor.id}`);
const sessions = page.getByTestId("kubernetes-mobile-session-list");
await expect(sessions).toContainText(taskId);
await expect(sessions).toContainText(sessionId);
await expect(sessions).toContainText("Retained");
await expect(sessions).toContainText("0 CPU");
await expect(sessions).toContainText("512Mi memory");
const statusSummary = sessions.getByTestId("kubernetes-session-status-summary");
await expect(statusSummary).toContainText("Cancelled");
await expect(statusSummary).toContainText("Pod: Running");
await expect(statusSummary).toContainText("Container: Running");
const statusBox = await statusSummary.boundingBox();
expect(statusBox).not.toBeNull();
expect(statusBox!.height).toBeLessThanOrEqual(80);
await expect(page.getByTestId("kubernetes-session-guidance")).toContainText(
"Stop preserves Kubernetes resources",
);
await expect(page.getByTestId("kubernetes-session-guidance")).toContainText(
"Existing claims are not deleted by Kandev",
);
const taskLink = sessions.getByTestId("kubernetes-session-task-link");
await expect(taskLink).toHaveAttribute("href", "/t/" + taskId);
await assertNoDocumentHorizontalOverflow(page, "mobile Kubernetes active sessions");
await sessions.scrollIntoViewIfNeeded();
await prCapture.screenshot("retained-kubernetes-session-mobile", {
caption: "Mobile retained Kubernetes session status",
page,
});
await taskLink.tap();
await expect(page).toHaveURL("/t/" + taskId);
} finally {
prCapture.flush();
await apiClient.deleteExecutor(executor.id).catch(() => undefined);
await context.close();
}
});