Files
workinf_Blender_Wasm/web/tests/e2e/editing-soak.spec.ts
mes123456 0fe8d2bb56
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
Advance M8-M11 parity workflows
2026-08-17 04:37:07 -04:00

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);
}
}
});