Advance M8-M11 parity workflows
This commit is contained in:
239
web/tests/e2e/editing-soak.spec.ts
Normal file
239
web/tests/e2e/editing-soak.spec.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user