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