616 lines
18 KiB
TypeScript
616 lines
18 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import type { Page, Route } from "@playwright/test";
|
|
|
|
const ABOVE_DEFAULT_LIMIT_BYTES = 16 * 1024 * 1024 * 1024;
|
|
|
|
export function seedManagedGoCache(tmpDir: string): { artifact: string } {
|
|
const cacheRoot = path.join(tmpDir, ".kandev", "cache");
|
|
const cachePath = path.join(cacheRoot, "go-build");
|
|
const artifact = path.join(cachePath, "e2e-sparse-artifact");
|
|
fs.mkdirSync(cachePath, { recursive: true });
|
|
fs.writeFileSync(path.join(cachePath, ".go-build.kandev-owned"), "kandev-managed-go-cache\n");
|
|
fs.closeSync(fs.openSync(artifact, "w"));
|
|
fs.truncateSync(artifact, ABOVE_DEFAULT_LIMIT_BYTES);
|
|
return { artifact };
|
|
}
|
|
|
|
export function seedSystemTemporaryFile(
|
|
tmpDir: string,
|
|
name = "fixture.txt",
|
|
): {
|
|
root: string;
|
|
file: string;
|
|
} {
|
|
const root = path.join(tmpDir, "system-temporary");
|
|
const file = path.join(root, name);
|
|
fs.mkdirSync(root, { recursive: true });
|
|
fs.writeFileSync(file, "system-temporary-fixture");
|
|
return { root, file };
|
|
}
|
|
|
|
export async function mockPartialSystemTemporaryOverview(page: Page, root: string): Promise<void> {
|
|
await page.route("**/api/v1/system/storage", async (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
const requestHeaders = route.request().headers();
|
|
const response = await fetch(route.request().url(), {
|
|
headers: {
|
|
...(requestHeaders.accept ? { accept: requestHeaders.accept } : {}),
|
|
...(requestHeaders.cookie ? { cookie: requestHeaders.cookie } : {}),
|
|
},
|
|
});
|
|
const body = JSON.parse(await response.text()) as {
|
|
summary: Record<string, unknown> | null;
|
|
};
|
|
body.summary ??= {};
|
|
body.summary.system_temporary = {
|
|
status: "partial",
|
|
size_bytes: 24,
|
|
included_in_total: false,
|
|
reason: "deadline",
|
|
roots: [
|
|
{
|
|
requested_path: root,
|
|
path: root,
|
|
status: "partial",
|
|
size_bytes: 24,
|
|
skipped_count: 1,
|
|
warnings: [
|
|
"context deadline exceeded",
|
|
"context deadline exceeded\ncontext deadline exceeded",
|
|
"fixture entry could not be measured",
|
|
],
|
|
},
|
|
],
|
|
warnings: [
|
|
"context deadline exceeded",
|
|
"One fixture entry was skipped.",
|
|
"One fixture entry was skipped.",
|
|
],
|
|
};
|
|
await route.fulfill({
|
|
status: response.status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
});
|
|
});
|
|
}
|
|
|
|
export function storageBarsSnapshot(
|
|
options: {
|
|
workspaces?: number;
|
|
database?: number;
|
|
databaseBackups?: number;
|
|
quarantine?: number;
|
|
systemTemporary?: number;
|
|
goCache?: number;
|
|
goCacheWarning?: string;
|
|
unmanagedGoCache?: number;
|
|
temporaryArtifacts?: number;
|
|
managedContainers?: number;
|
|
imageLayers?: number;
|
|
buildCache?: number;
|
|
unusedImages?: number;
|
|
} = {},
|
|
): Record<string, unknown> {
|
|
return {
|
|
workspaces: {
|
|
total_bytes: options.workspaces ?? 8 * 1024 ** 3,
|
|
active_bytes: 0,
|
|
candidate_bytes: 0,
|
|
},
|
|
go_cache: {
|
|
path: "/data/cache/go-build",
|
|
size_bytes: options.goCache ?? 7 * 1024 ** 3,
|
|
owned: true,
|
|
unmanaged_path: "/data/home/.cache/go-build",
|
|
unmanaged_size_bytes: options.unmanagedGoCache ?? 6 * 1024 ** 3,
|
|
warning: options.goCacheWarning,
|
|
},
|
|
quarantine: { available: true, count: 1, size_bytes: options.quarantine ?? 5 * 1024 ** 3 },
|
|
temporary_artifacts: {
|
|
available: true,
|
|
total_count: 1,
|
|
total_bytes: options.temporaryArtifacts ?? 4 * 1024 ** 3,
|
|
active_count: 0,
|
|
active_bytes: 0,
|
|
protected_count: 0,
|
|
protected_bytes: 0,
|
|
stale_count: 1,
|
|
stale_bytes: options.temporaryArtifacts ?? 4 * 1024 ** 3,
|
|
skipped_count: 0,
|
|
},
|
|
system_temporary: {
|
|
status: "partial",
|
|
size_bytes: options.systemTemporary ?? 3 * 1024 ** 3,
|
|
included_in_total: false,
|
|
reason: "deadline",
|
|
roots: [],
|
|
},
|
|
docker: {
|
|
available: true,
|
|
managed_container_count: 1,
|
|
managed_container_bytes: options.managedContainers ?? 2 * 1024 ** 3,
|
|
image_layer_bytes: options.imageLayers ?? 1 * 1024 ** 3,
|
|
build_cache_bytes: options.buildCache ?? 512 * 1024 ** 2,
|
|
unused_image_bytes: options.unusedImages ?? 256 * 1024 ** 2,
|
|
},
|
|
database: {
|
|
status: "measured",
|
|
size_bytes: options.database ?? 10 * 1024 ** 3,
|
|
path: "/data/kandev.db",
|
|
included_in_total: true,
|
|
},
|
|
database_backups: {
|
|
status: "measured",
|
|
size_bytes: options.databaseBackups ?? 9 * 1024 ** 3,
|
|
path: "/data/backups",
|
|
included_in_total: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function mockStorageAnalysisBarsOverview(page: Page): Promise<{
|
|
setSnapshot: (snapshot: Record<string, unknown>) => void;
|
|
holdNextOverviewRefresh: () => {
|
|
started: Promise<void>;
|
|
release: () => void;
|
|
};
|
|
}> {
|
|
let snapshot = storageBarsSnapshot();
|
|
let heldRefresh: {
|
|
resolveStarted: () => void;
|
|
released: Promise<void>;
|
|
} | null = null;
|
|
await page.route("**/api/v1/system/storage", async (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
const requestHeaders = route.request().headers();
|
|
const response = await fetch(route.request().url(), {
|
|
headers: {
|
|
...(requestHeaders.accept ? { accept: requestHeaders.accept } : {}),
|
|
...(requestHeaders.cookie ? { cookie: requestHeaders.cookie } : {}),
|
|
},
|
|
});
|
|
const body = JSON.parse(await response.text()) as {
|
|
analyzed_at?: string | null;
|
|
analysis?: Record<string, unknown> | null;
|
|
summary?: Record<string, unknown> | null;
|
|
};
|
|
const analyzedAt = new Date().toISOString();
|
|
body.summary = snapshot;
|
|
body.analyzed_at = analyzedAt;
|
|
body.analysis = {
|
|
...(body.analysis ?? {}),
|
|
state: "ready",
|
|
completed_at: analyzedAt,
|
|
duration_ms: 100,
|
|
stale: false,
|
|
error: null,
|
|
partial_summary: null,
|
|
};
|
|
if (heldRefresh) {
|
|
const refresh = heldRefresh;
|
|
heldRefresh = null;
|
|
refresh.resolveStarted();
|
|
await refresh.released;
|
|
}
|
|
await route.fulfill({
|
|
status: response.status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
});
|
|
});
|
|
await page.route("**/api/v1/system/storage/analyze", async (route) => {
|
|
await route.fulfill({
|
|
status: 202,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ job_id: "storage-bars-analysis" }),
|
|
});
|
|
});
|
|
await page.route("**/api/v1/system/jobs/storage-bars-analysis", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
id: "storage-bars-analysis",
|
|
kind: "storage-analysis",
|
|
state: "succeeded",
|
|
started_at: new Date().toISOString(),
|
|
}),
|
|
});
|
|
});
|
|
return {
|
|
setSnapshot: (nextSnapshot) => (snapshot = nextSnapshot),
|
|
holdNextOverviewRefresh: () => {
|
|
let resolveStarted!: () => void;
|
|
let resolveRelease!: () => void;
|
|
const started = new Promise<void>((resolve) => {
|
|
resolveStarted = resolve;
|
|
});
|
|
const released = new Promise<void>((resolve) => {
|
|
resolveRelease = resolve;
|
|
});
|
|
heldRefresh = { resolveStarted, released };
|
|
return { started, release: resolveRelease };
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function mockTemporaryArtifactOverview(page: Page): Promise<void> {
|
|
await page.route("**/api/v1/system/storage", async (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
// Fetch outside Playwright's route response lifecycle. The managed E2E
|
|
// runner can dispose a route.fetch() response while a page navigation is
|
|
// still settling, which makes response.body() intermittently unavailable.
|
|
const requestHeaders = route.request().headers();
|
|
const response = await fetch(route.request().url(), {
|
|
headers: {
|
|
...(requestHeaders.accept ? { accept: requestHeaders.accept } : {}),
|
|
...(requestHeaders.cookie ? { cookie: requestHeaders.cookie } : {}),
|
|
},
|
|
});
|
|
const body = JSON.parse(await response.text()) as {
|
|
capabilities: Record<string, unknown>;
|
|
summary: Record<string, unknown> | null;
|
|
};
|
|
body.capabilities.temporary_artifacts_available = true;
|
|
body.summary ??= {};
|
|
body.summary.temporary_artifacts = {
|
|
available: true,
|
|
total_count: 3,
|
|
total_bytes: 48 * 1024 * 1024,
|
|
active_count: 1,
|
|
active_bytes: 16 * 1024 * 1024,
|
|
protected_count: 1,
|
|
protected_bytes: 16 * 1024 * 1024,
|
|
stale_count: 1,
|
|
stale_bytes: 16 * 1024 * 1024,
|
|
skipped_count: 0,
|
|
};
|
|
await route.fulfill({
|
|
status: response.status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
});
|
|
});
|
|
}
|
|
|
|
type DatabaseFootprintStatus = "measured" | "unavailable" | "not_applicable";
|
|
|
|
type DatabaseStorageBody = {
|
|
analysis?: Record<string, unknown> | null;
|
|
analyzed_at?: string | null;
|
|
summary?: Record<string, unknown> | null;
|
|
};
|
|
|
|
async function readDatabaseStorageRoute(route: Route): Promise<{
|
|
response: Response;
|
|
body: DatabaseStorageBody;
|
|
} | null> {
|
|
if (route.request().method() !== "GET") {
|
|
await route.continue();
|
|
return null;
|
|
}
|
|
const requestHeaders = route.request().headers();
|
|
const response = await fetch(route.request().url(), {
|
|
headers: {
|
|
...(requestHeaders.accept ? { accept: requestHeaders.accept } : {}),
|
|
...(requestHeaders.cookie ? { cookie: requestHeaders.cookie } : {}),
|
|
},
|
|
});
|
|
return {
|
|
response,
|
|
body: JSON.parse(await response.text()) as DatabaseStorageBody,
|
|
};
|
|
}
|
|
|
|
function databaseMeasurement(
|
|
status: DatabaseFootprintStatus,
|
|
sizeBytes: number,
|
|
resourcePath: string,
|
|
): Record<string, unknown> {
|
|
if (status === "measured") {
|
|
return {
|
|
status,
|
|
size_bytes: sizeBytes,
|
|
path: resourcePath,
|
|
included_in_total: true,
|
|
};
|
|
}
|
|
return {
|
|
status,
|
|
included_in_total: false,
|
|
reason: status === "unavailable" ? "measurement_failed" : "unsupported_driver",
|
|
};
|
|
}
|
|
|
|
function updateDatabaseStorageBody(
|
|
body: DatabaseStorageBody,
|
|
options: {
|
|
databasePath?: string;
|
|
backupPath?: string;
|
|
databaseBytes?: number;
|
|
backupBytes?: number;
|
|
databaseStatus: DatabaseFootprintStatus;
|
|
backupStatus: DatabaseFootprintStatus;
|
|
},
|
|
): void {
|
|
const databaseBytes = options.databaseBytes ?? 8 * 1024 ** 3;
|
|
const backupBytes = options.backupBytes ?? 3 * 1024 ** 3;
|
|
const analyzedAt = new Date().toISOString();
|
|
const summary = body.summary ?? {};
|
|
body.summary = {
|
|
...summary,
|
|
database: databaseMeasurement(
|
|
options.databaseStatus,
|
|
databaseBytes,
|
|
options.databasePath ?? "/var/lib/kandev/kandev.db",
|
|
),
|
|
database_backups: databaseMeasurement(
|
|
options.backupStatus,
|
|
backupBytes,
|
|
options.backupPath ?? "/var/lib/kandev/backups",
|
|
),
|
|
};
|
|
const analysis = body.analysis ?? {};
|
|
const currentProgress = (analysis.progress ?? {}) as Record<string, unknown>;
|
|
const currentSources = (currentProgress.sources ?? {}) as Record<string, unknown>;
|
|
body.analysis = {
|
|
...analysis,
|
|
state: "ready",
|
|
completed_at: analyzedAt,
|
|
duration_ms: 100,
|
|
cache_ttl_seconds: 900,
|
|
refresh_due_at: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
|
stale: false,
|
|
error: null,
|
|
progress: {
|
|
...currentProgress,
|
|
completed_sources: 8,
|
|
total_sources: 8,
|
|
sources: {
|
|
...currentSources,
|
|
database: {
|
|
state: "ready",
|
|
completed_items: 1,
|
|
bytes_scanned: databaseBytes,
|
|
},
|
|
database_backups: {
|
|
state: "ready",
|
|
completed_items: 1,
|
|
bytes_scanned: backupBytes,
|
|
},
|
|
},
|
|
},
|
|
partial_summary: null,
|
|
};
|
|
body.analyzed_at = analyzedAt;
|
|
}
|
|
|
|
export async function mockDatabaseFootprintOverview(
|
|
page: Page,
|
|
options: {
|
|
databasePath?: string;
|
|
backupPath?: string;
|
|
databaseBytes?: number;
|
|
backupBytes?: number;
|
|
databaseStatus?: DatabaseFootprintStatus;
|
|
backupStatus?: DatabaseFootprintStatus;
|
|
} = {},
|
|
): Promise<void> {
|
|
await page.route("**/api/v1/system/storage", async (route) => {
|
|
const routed = await readDatabaseStorageRoute(route);
|
|
if (!routed) return;
|
|
const { response, body } = routed;
|
|
const databaseStatus = options.databaseStatus ?? "measured";
|
|
const backupStatus = options.backupStatus ?? "measured";
|
|
updateDatabaseStorageBody(body, {
|
|
...options,
|
|
databaseStatus,
|
|
backupStatus,
|
|
});
|
|
await route.fulfill({
|
|
status: response.status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function mockProgressiveStorageOverview(page: Page): Promise<{
|
|
complete: () => void;
|
|
}> {
|
|
let completed = false;
|
|
await page.route("**/api/v1/system/storage", async (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
const requestHeaders = route.request().headers();
|
|
const response = await fetch(route.request().url(), {
|
|
headers: {
|
|
...(requestHeaders.accept ? { accept: requestHeaders.accept } : {}),
|
|
...(requestHeaders.cookie ? { cookie: requestHeaders.cookie } : {}),
|
|
},
|
|
});
|
|
const body = JSON.parse(await response.text()) as {
|
|
analysis?: unknown;
|
|
analyzed_at?: string | null;
|
|
capabilities?: Record<string, unknown>;
|
|
summary?: Record<string, unknown> | null;
|
|
};
|
|
const startedAt = new Date(Date.now() - 1200).toISOString();
|
|
const completedAt = new Date(Date.now()).toISOString();
|
|
const refreshDueAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();
|
|
const sourceProgress = {
|
|
workspaces: {
|
|
state: "ready",
|
|
completed_items: 3,
|
|
total_items: 3,
|
|
bytes_scanned: 2 * 1024 ** 3,
|
|
},
|
|
go_cache: {
|
|
state: "ready",
|
|
completed_items: 4,
|
|
total_items: 4,
|
|
bytes_scanned: 3 * 1024 ** 3,
|
|
},
|
|
quarantine: { state: "ready", completed_items: 1, total_items: 1, bytes_scanned: 1024 },
|
|
temporary_artifacts: {
|
|
state: "ready",
|
|
completed_items: 1,
|
|
total_items: 1,
|
|
bytes_scanned: 1024,
|
|
},
|
|
docker: { state: "ready", completed_items: 1, total_items: 1, bytes_scanned: 1024 },
|
|
database: {
|
|
state: "ready",
|
|
completed_items: 1,
|
|
total_items: 1,
|
|
bytes_scanned: 5 * 1024 ** 3,
|
|
},
|
|
database_backups: {
|
|
state: "ready",
|
|
completed_items: 1,
|
|
total_items: 1,
|
|
bytes_scanned: 3 * 1024 ** 3,
|
|
},
|
|
system_temporary: {
|
|
state: "ready",
|
|
completed_items: 1,
|
|
total_items: 1,
|
|
bytes_scanned: 1024,
|
|
},
|
|
};
|
|
body.analysis = completed
|
|
? {
|
|
generation: 901,
|
|
state: "ready",
|
|
started_at: startedAt,
|
|
completed_at: completedAt,
|
|
duration_ms: 1200,
|
|
cache_ttl_seconds: 900,
|
|
refresh_due_at: refreshDueAt,
|
|
stale: false,
|
|
error: null,
|
|
progress: { completed_sources: 8, total_sources: 8, sources: sourceProgress },
|
|
partial_summary: null,
|
|
}
|
|
: {
|
|
generation: 901,
|
|
state: "scanning",
|
|
started_at: startedAt,
|
|
completed_at: null,
|
|
duration_ms: null,
|
|
cache_ttl_seconds: 900,
|
|
refresh_due_at: null,
|
|
stale: false,
|
|
error: null,
|
|
progress: {
|
|
completed_sources: 1,
|
|
total_sources: 8,
|
|
sources: {
|
|
...sourceProgress,
|
|
go_cache: {
|
|
state: "scanning",
|
|
completed_items: 1,
|
|
total_items: 4,
|
|
bytes_scanned: 10,
|
|
},
|
|
quarantine: { state: "pending", completed_items: 0, bytes_scanned: 0 },
|
|
temporary_artifacts: { state: "pending", completed_items: 0, bytes_scanned: 0 },
|
|
docker: { state: "pending", completed_items: 0, bytes_scanned: 0 },
|
|
database: { state: "pending", completed_items: 0, bytes_scanned: 0 },
|
|
database_backups: { state: "pending", completed_items: 0, bytes_scanned: 0 },
|
|
system_temporary: { state: "pending", completed_items: 0, bytes_scanned: 0 },
|
|
},
|
|
},
|
|
partial_summary: {
|
|
workspaces: {
|
|
total_bytes: 2 * 1024 ** 3,
|
|
active_bytes: 1 * 1024 ** 3,
|
|
candidate_bytes: 1 * 1024 ** 3,
|
|
},
|
|
},
|
|
};
|
|
body.summary = completed
|
|
? {
|
|
workspaces: {
|
|
total_bytes: 4 * 1024 ** 3,
|
|
active_bytes: 1 * 1024 ** 3,
|
|
candidate_bytes: 2 * 1024 ** 3,
|
|
},
|
|
go_cache: {
|
|
path: "/data/cache/go-build",
|
|
size_bytes: 3 * 1024 ** 3,
|
|
owned: true,
|
|
enabled: false,
|
|
},
|
|
quarantine: { available: true, count: 1, size_bytes: 1024 },
|
|
temporary_artifacts: {
|
|
available: true,
|
|
total_count: 1,
|
|
total_bytes: 1024,
|
|
active_count: 1,
|
|
active_bytes: 1024,
|
|
protected_count: 1,
|
|
protected_bytes: 1024,
|
|
stale_count: 0,
|
|
stale_bytes: 0,
|
|
skipped_count: 0,
|
|
},
|
|
docker: {
|
|
available: false,
|
|
build_cache_bytes: 0,
|
|
image_layer_bytes: 0,
|
|
unused_image_bytes: 0,
|
|
managed_container_count: 0,
|
|
managed_container_bytes: 0,
|
|
},
|
|
database: {
|
|
status: "measured",
|
|
size_bytes: 5 * 1024 ** 3,
|
|
path: "/data/kandev.db",
|
|
included_in_total: true,
|
|
},
|
|
database_backups: {
|
|
status: "measured",
|
|
size_bytes: 3 * 1024 ** 3,
|
|
path: "/data/backups",
|
|
included_in_total: true,
|
|
},
|
|
system_temporary: {
|
|
status: "measured",
|
|
size_bytes: 1024,
|
|
included_in_total: false,
|
|
reason: "informational_overlap",
|
|
roots: [
|
|
{
|
|
requested_path: "/data/tmp",
|
|
path: "/data/tmp",
|
|
status: "measured",
|
|
size_bytes: 1024,
|
|
},
|
|
],
|
|
},
|
|
}
|
|
: null;
|
|
body.analyzed_at = completed ? completedAt : null;
|
|
await route.fulfill({
|
|
status: response.status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
});
|
|
});
|
|
return { complete: () => (completed = true) };
|
|
}
|