240 lines
11 KiB
TypeScript
240 lines
11 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
const REQUIRED_DURATION_MS = 30 * 60 * 1_000;
|
|
const configuredDurationMs = Number.parseInt(process.env.EDITING_SOAK_DURATION_MS ?? String(REQUIRED_DURATION_MS), 10);
|
|
const allowShort = process.env.EDITING_SOAK_ALLOW_SHORT === "1";
|
|
const reportPath = process.env.EDITING_SOAK_REPORT ? path.resolve(process.cwd(), process.env.EDITING_SOAK_REPORT) : null;
|
|
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
|
|
|
interface StoredProjectSample {
|
|
revision: number;
|
|
sha256: string;
|
|
bytes: number;
|
|
snapshotCount: number;
|
|
}
|
|
|
|
interface ResourceSample {
|
|
cycle: number;
|
|
label: string;
|
|
elapsedMs: number;
|
|
jsHeapBytes: number;
|
|
storageUsageBytes: number;
|
|
storageQuotaBytes: number;
|
|
}
|
|
|
|
async function readStoredProject(page: Page): Promise<StoredProjectSample> {
|
|
return page.evaluate(async () => {
|
|
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
|
const storage = new StorageClient();
|
|
try {
|
|
const [project, snapshots] = await Promise.all([
|
|
storage.readProject("basic_scene"),
|
|
storage.listSnapshots("basic_scene"),
|
|
]);
|
|
return { revision: project.revision, sha256: project.sha256, bytes: project.bytes, snapshotCount: snapshots.snapshots.length };
|
|
}
|
|
finally {
|
|
storage.terminate();
|
|
}
|
|
});
|
|
}
|
|
|
|
test("M7-18 keeps high-frequency retained snapshots within a bounded origin budget", async ({ page }) => {
|
|
await page.goto("/");
|
|
const result = await page.evaluate(async () => {
|
|
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
|
const storage = new StorageClient();
|
|
const projectId = `snapshot-soak-${Date.now()}`;
|
|
const payload = new Uint8Array(256 * 1024);
|
|
payload.fill(0x5a);
|
|
const baseline = (await navigator.storage.estimate()).usage ?? 0;
|
|
for (let revision = 1; revision <= 128; revision++) {
|
|
payload[0] = revision & 0xff;
|
|
await storage.saveSnapshot(projectId, revision, payload.slice().buffer, 5, 2 * 1024 * 1024);
|
|
}
|
|
const snapshots = await storage.listSnapshots(projectId);
|
|
const latest = await storage.readSnapshot(projectId, 128);
|
|
const final = (await navigator.storage.estimate()).usage ?? 0;
|
|
storage.terminate();
|
|
return {
|
|
growthBytes: final - baseline,
|
|
revisions: snapshots.snapshots.map((snapshot) => snapshot.revision),
|
|
latestByte: new Uint8Array(latest.buffer)[0],
|
|
};
|
|
});
|
|
expect(result.revisions).toEqual([128, 127, 126, 125, 124]);
|
|
expect(result.latestByte).toBe(128);
|
|
expect(result.growthBytes).toBeLessThanOrEqual(4 * 1024 * 1024);
|
|
});
|
|
|
|
test("M7-18 sustains editing, autosave and OPFS reopen for 30 minutes", async ({ page }, testInfo) => {
|
|
if (!Number.isSafeInteger(configuredDurationMs) || configuredDurationMs <= 0) throw new Error("EDITING_SOAK_DURATION_INVALID");
|
|
if (!allowShort && configuredDurationMs < REQUIRED_DURATION_MS) throw new Error("EDITING_SOAK_DURATION_BELOW_30_MINUTES");
|
|
test.setTimeout(configuredDurationMs + 5 * 60 * 1_000);
|
|
|
|
const pageErrors: string[] = [];
|
|
const resourceSamples: ResourceSample[] = [];
|
|
const revisionSamples: number[] = [];
|
|
page.on("pageerror", (error) => pageErrors.push(error.message));
|
|
let downloadCount = 0;
|
|
page.on("download", () => { downloadCount += 1; });
|
|
|
|
const startedAt = new Date().toISOString();
|
|
let soakStarted = 0;
|
|
let actualDurationMs = 0;
|
|
let cycles = 0;
|
|
let autosaves = 0;
|
|
let reopens = 0;
|
|
let initialRevision = 0;
|
|
let finalStored: StoredProjectSample | null = null;
|
|
let failure: string | null = null;
|
|
|
|
const app = page.locator("main.blender-app");
|
|
const sampleResources = async (label: string): Promise<void> => {
|
|
const cdp = await page.context().newCDPSession(page);
|
|
let jsHeapBytes = 0;
|
|
try {
|
|
await cdp.send("HeapProfiler.collectGarbage");
|
|
await cdp.send("Performance.enable");
|
|
const metrics = await cdp.send("Performance.getMetrics") as { metrics: Array<{ name: string; value: number }> };
|
|
jsHeapBytes = metrics.metrics.find((metric) => metric.name === "JSHeapUsedSize")?.value ?? 0;
|
|
}
|
|
finally {
|
|
await cdp.detach();
|
|
}
|
|
const storage = await page.evaluate(async () => {
|
|
const estimate = await navigator.storage.estimate();
|
|
return { usage: estimate.usage ?? 0, quota: estimate.quota ?? 0 };
|
|
});
|
|
resourceSamples.push({ cycle: cycles, label, elapsedMs: soakStarted ? Date.now() - soakStarted : 0, jsHeapBytes, storageUsageBytes: storage.usage, storageQuotaBytes: storage.quota });
|
|
};
|
|
|
|
const reopen = async (expected: StoredProjectSample): Promise<void> => {
|
|
await page.reload();
|
|
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
|
const recent = page.getByTestId("recent-projects");
|
|
await expect(recent.locator('option[value="basic_scene"]')).toHaveCount(1, { timeout: 20_000 });
|
|
await recent.selectOption("basic_scene");
|
|
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3", { timeout: 30_000 });
|
|
await expect(app).toHaveAttribute("data-project-id", "basic_scene");
|
|
await expect(app).toHaveAttribute("data-current-main-revision", String(expected.revision));
|
|
await expect(app).toHaveAttribute("data-committed-main-revision", String(expected.revision));
|
|
await expect(app).toHaveAttribute("data-save-committed-hash", expected.sha256);
|
|
await expect(app).toHaveAttribute("data-dirty", "false");
|
|
await expect(app).toHaveAttribute("data-diagnostic-count", "0");
|
|
await expect(app).not.toHaveAttribute("data-worker-fault-code", /.+/);
|
|
reopens += 1;
|
|
};
|
|
|
|
try {
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
|
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
|
|
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3", { timeout: 30_000 });
|
|
const firstDownload = page.waitForEvent("download");
|
|
await page.getByRole("button", { name: "保存项目" }).click();
|
|
await firstDownload;
|
|
await expect(app).toHaveAttribute("data-dirty", "false");
|
|
finalStored = await readStoredProject(page);
|
|
initialRevision = finalStored.revision;
|
|
revisionSamples.push(initialRevision);
|
|
await sampleResources("baseline");
|
|
|
|
soakStarted = Date.now();
|
|
const reopenEveryCycles = allowShort ? 3 : 12;
|
|
const cyclePauseMs = allowShort ? 100 : 2_000;
|
|
while (Date.now() - soakStarted < configuredDurationMs) {
|
|
await page.getByRole("button", { name: "添加立方体" }).click();
|
|
await expect(page.getByTestId("scene-stats")).toContainText("Objects 4");
|
|
await expect(app).toHaveAttribute("data-dirty", "true");
|
|
await page.getByRole("button", { name: "删除对象" }).click();
|
|
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3");
|
|
await expect(app).toHaveAttribute("data-dirty", "false", { timeout: 15_000 });
|
|
await expect(app).toHaveAttribute("data-save-transaction-status", "SUCCEEDED");
|
|
const currentRevision = Number(await app.getAttribute("data-current-main-revision"));
|
|
const committedRevision = Number(await app.getAttribute("data-committed-main-revision"));
|
|
expect(currentRevision).toBe(committedRevision);
|
|
expect(currentRevision).toBeGreaterThan(revisionSamples.at(-1) ?? 0);
|
|
revisionSamples.push(currentRevision);
|
|
finalStored = await readStoredProject(page);
|
|
expect(finalStored.revision).toBe(currentRevision);
|
|
expect(finalStored.sha256).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(finalStored.snapshotCount).toBeLessThanOrEqual(5);
|
|
await expect(app).toHaveAttribute("data-save-committed-hash", finalStored.sha256);
|
|
await expect(app).toHaveAttribute("data-diagnostic-count", "0");
|
|
cycles += 1;
|
|
autosaves += 1;
|
|
|
|
if (cycles % reopenEveryCycles === 0) {
|
|
await reopen(finalStored);
|
|
await sampleResources(`reopen-${reopens}`);
|
|
console.log(`M7-18 soak progress elapsedMs=${Date.now() - soakStarted} cycles=${cycles} reopens=${reopens} revision=${finalStored.revision}`);
|
|
}
|
|
if (cyclePauseMs > 0) await page.waitForTimeout(cyclePauseMs);
|
|
}
|
|
actualDurationMs = Date.now() - soakStarted;
|
|
finalStored = await readStoredProject(page);
|
|
await reopen(finalStored);
|
|
await sampleResources("final");
|
|
|
|
const baseline = resourceSamples[0];
|
|
const final = resourceSamples.at(-1)!;
|
|
const heapGrowthBytes = final.jsHeapBytes - baseline.jsHeapBytes;
|
|
const storageGrowthBytes = final.storageUsageBytes - baseline.storageUsageBytes;
|
|
expect(actualDurationMs).toBeGreaterThanOrEqual(configuredDurationMs);
|
|
expect(cycles).toBeGreaterThan(allowShort ? 0 : 100);
|
|
expect(reopens).toBeGreaterThanOrEqual(allowShort ? 1 : 20);
|
|
expect(autosaves).toBe(cycles);
|
|
expect(finalStored.revision).toBeGreaterThanOrEqual(initialRevision + cycles * 2);
|
|
expect(finalStored.snapshotCount).toBeLessThanOrEqual(5);
|
|
expect(downloadCount).toBe(1);
|
|
expect(pageErrors).toEqual([]);
|
|
expect(heapGrowthBytes).toBeLessThanOrEqual(64 * 1024 * 1024);
|
|
expect(storageGrowthBytes).toBeLessThanOrEqual(16 * 1024 * 1024);
|
|
}
|
|
catch (error) {
|
|
failure = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
throw error;
|
|
}
|
|
finally {
|
|
if (soakStarted && actualDurationMs === 0) actualDurationMs = Date.now() - soakStarted;
|
|
const baseline = resourceSamples[0];
|
|
const final = resourceSamples.at(-1);
|
|
const report = {
|
|
schemaVersion: 1,
|
|
status: failure ? "FAILED" : "READY",
|
|
profile: allowShort ? "DEBUG" : "FORMAL",
|
|
requiredDurationMs: REQUIRED_DURATION_MS,
|
|
configuredDurationMs,
|
|
actualDurationMs,
|
|
startedAt,
|
|
finishedAt: new Date().toISOString(),
|
|
cycles,
|
|
autosaves,
|
|
reopens,
|
|
initialRevision,
|
|
finalRevision: finalStored?.revision ?? 0,
|
|
finalSha256: finalStored?.sha256 ?? null,
|
|
finalBytes: finalStored?.bytes ?? 0,
|
|
finalSnapshotCount: finalStored?.snapshotCount ?? 0,
|
|
downloadCount,
|
|
pageErrors,
|
|
failure,
|
|
limits: { maxHeapGrowthBytes: 64 * 1024 * 1024, maxStorageGrowthBytes: 16 * 1024 * 1024, maxSnapshots: 5 },
|
|
observed: {
|
|
heapGrowthBytes: baseline && final ? final.jsHeapBytes - baseline.jsHeapBytes : null,
|
|
storageGrowthBytes: baseline && final ? final.storageUsageBytes - baseline.storageUsageBytes : null,
|
|
},
|
|
resourceSamples,
|
|
};
|
|
const body = `${JSON.stringify(report, null, 2)}\n`;
|
|
await testInfo.attach("editing-soak-report", { body, contentType: "application/json" });
|
|
if (reportPath) {
|
|
await fs.mkdir(path.dirname(reportPath), { recursive: true });
|
|
await fs.writeFile(reportPath, body);
|
|
}
|
|
}
|
|
});
|