Advance M7 workflows and release operations
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

This commit is contained in:
mes123456
2026-08-15 17:43:53 -04:00
parent 17ab961485
commit 7c16b279ae
103 changed files with 8064 additions and 429 deletions

View File

@@ -1,20 +1,54 @@
{
"schemaVersion": 1,
"schemaVersion": 2,
"protocolVersion": 1,
"engineVersion": "blender-wasm-0.1.0",
"releaseId": "blender-wasm-0.1.0-rc.1",
"engineVersion": "blender-wasm-0.1.0-rc.1",
"engine": "blender-wasm",
"memory": {
"initialPages": 256,
"maximumPages": 32768,
"shared": false
},
"wasm": [
"variants": [
{
"id": "web-engine-bootstrap",
"fileName": "web_engine.wasm",
"url": "/vendor/blender/web_engine.wasm",
"sha256": "5d87a9ea57bd7a1888f16a5f4306e1c6d3a1bdfcf832c9485fe8518aac528fd8",
"required": true
"id": "single",
"memory": {
"initialPages": 256,
"maximumPages": 32768,
"shared": false
},
"resources": {
"js": {
"fileName": "web_engine.js",
"url": "/vendor/blender/single/web_engine.js",
"sha256": "eedb8cedeb2190fbece91d58cd5dd568356caf42191517aff2085911177157ca"
},
"wasm": {
"fileName": "web_engine.wasm",
"url": "/vendor/blender/single/web_engine.wasm",
"sha256": "f079e2221b501eee7f0b8e3290c22f37210ba44ee7a392c7fa817fde16b9ee5e"
}
}
},
{
"id": "pthread",
"memory": {
"initialPages": 256,
"maximumPages": 32768,
"shared": true
},
"resources": {
"js": {
"fileName": "web_engine.js",
"url": "/vendor/blender/pthread/web_engine.js",
"sha256": "5a78f2e27969074f72902805c32453c980841c390db6c48eb9d43787d33a8837"
},
"wasm": {
"fileName": "web_engine.wasm",
"url": "/vendor/blender/pthread/web_engine.wasm",
"sha256": "a6bed7b410b15f0d5aca229d1e926bff861e3ff16f0d3d6abc1bc73bcaf5fc0b"
},
"pthreadWorker": {
"fileName": "web_engine.js",
"url": "/vendor/blender/pthread/web_engine.js",
"sha256": "5a78f2e27969074f72902805c32453c980841c390db6c48eb9d43787d33a8837"
}
}
}
]
}

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { WebEngineClient } from "../engine-client/WebEngineClient";
import { loadWebEngineManifest, verifyWasmResource } from "../../../protocol/manifest";
import type { ProgressEvent } from "../../../protocol/progress";
@@ -32,6 +33,11 @@ import {
} from "../volume/nanovdb-viewport";
import { composePaintColorPatch, composePaintWeightPatch } from "../../../protocol/paint";
import { createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema";
import { createInitialUserActionStates, reduceUserActionStates, type UserActionIdentity, type UserActionKind } from "../../../protocol/user-action-state";
import { acquireProjectAction, createProjectActionMutexState, releaseProjectAction, type ProjectActionConflict, type ProjectActionIdentity } from "../../../protocol/project-action-mutex";
import { DEFAULT_FILE_READ_YIELD_BYTES, FileByteReadError, readFileBytes, type FileReadPhase } from "../../../protocol/file-byte-reader";
import { advanceSaveTransaction, beginSaveTransaction, commitSaveTransaction, createSaveTransactionState, failSaveTransaction, type SaveTransactionState } from "../../../protocol/save-transaction";
import { acceptHistoryTransaction, acceptMainSave, acceptMainTransaction, createDirtyState, recoverDirtyState } from "../../../protocol/dirty-state";
import "./app-shell.css";
function errorMessage(error: unknown): string {
@@ -48,6 +54,26 @@ async function sha256Hex(data: ArrayBuffer): Promise<string> {
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
}
const LAST_PROJECT_STORAGE_KEY = "blender-web:last-project-id";
function loadLastProjectId(): string {
try {
return localStorage.getItem(LAST_PROJECT_STORAGE_KEY) || "untitled";
}
catch {
return "untitled";
}
}
function rememberLastProjectId(projectId: string): void {
try {
localStorage.setItem(LAST_PROJECT_STORAGE_KEY, projectId);
}
catch {
// Project recovery still works in the current session when localStorage is unavailable.
}
}
interface AreaProps {
className?: string;
editor: EditorType;
@@ -366,7 +392,7 @@ function Outliner({ snapshot, onSelect, onToggleVisibility }: {
{collection.objectIds.map((objectId) => {
const node = nodeById.get(objectId);
if (!node) return null;
return <div key={node.id} role="button" tabIndex={0} onClick={(event) => onSelect(node.id, event.shiftKey || event.ctrlKey || event.metaKey)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") onSelect(node.id, event.shiftKey || event.ctrlKey || event.metaKey); }} className={`tree-row child${node.id === snapshot?.activeObjectId ? " selected" : ""}`}><span>·</span><span className={`tree-icon ${node.type.toLowerCase()}`}></span><span>{node.name}</span><button type="button" className="tree-action" aria-label={`${node.visible ? "隐藏" : "显示"} ${node.name}`} onClick={(event) => { event.stopPropagation(); onToggleVisibility(node.id, !node.visible); }}>{node.visible ? "◉" : "○"}</button></div>;
return <div key={node.id} role="button" tabIndex={0} data-node-id={node.id} data-data-id={node.dataId ?? ""} onClick={(event) => onSelect(node.id, event.shiftKey || event.ctrlKey || event.metaKey)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") onSelect(node.id, event.shiftKey || event.ctrlKey || event.metaKey); }} className={`tree-row child${node.id === snapshot?.activeObjectId ? " selected" : ""}`}><span>·</span><span className={`tree-icon ${node.type.toLowerCase()}`}></span><span>{node.name}</span><button type="button" className="tree-action" aria-label={`${node.visible ? "隐藏" : "显示"} ${node.name}`} onClick={(event) => { event.stopPropagation(); onToggleVisibility(node.id, !node.visible); }}>{node.visible ? "◉" : "○"}</button></div>;
})}
</div>
))}
@@ -645,7 +671,7 @@ function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { s
export function App() {
const [uiState, setUIState] = useState(createDefaultWebWorkspaceState);
const [frame, setFrame] = useState(1);
const [saved, setSaved] = useState(true);
const [dirtyState, setDirtyState] = useState(() => createDirtyState());
const [engineStatus, setEngineStatus] = useState("WASM: starting");
const [wasmStatus, setWasmStatus] = useState("WASM ABI: starting");
const [storageStatus, setStorageStatus] = useState("Storage: starting");
@@ -660,15 +686,65 @@ export function App() {
const [preview, setPreview] = useState<{ snapshot: SceneSnapshotIR; geometryBuffers: MeshGeometryBuffer[]; nonMeshGeometryBuffers: NonMeshGeometryChunk[] } | null>(null);
const [lodLevels, setLodLevels] = useState<Record<string, WebEngineLODLevelResult[]> | null>(null);
const [openProgress, setOpenProgress] = useState<ProgressEvent | null>(null);
const [openReadEvidence, setOpenReadEvidence] = useState<{ actionId: string | null; phase: FileReadPhase | "IDLE"; bytesRead: number; totalBytes: number; progressEvents: number }>({ actionId: null, phase: "IDLE", bytesRead: 0, totalBytes: 0, progressEvents: 0 });
const [openCleanupEvidence, setOpenCleanupEvidence] = useState({ readerLiveReaders: 0, readerInputBytes: 0, readerStagingFiles: 0, engineActiveRequests: 0, engineInputBytes: 0, engineNativeHandles: 0, engineStagingFiles: 0, lastStage: "IDLE" });
const [saveTransaction, setSaveTransaction] = useState<SaveTransactionState>(() => createSaveTransactionState({ revision: 0, sha256: null }));
const [userActions, dispatchUserAction] = useReducer(reduceUserActionStates, createInitialUserActionStates());
const [projectActionConflict, setProjectActionConflict] = useState<ProjectActionConflict | null>(null);
const webClientRef = useRef<WebEngineClient | null>(null);
const storageClientRef = useRef<StorageClient | null>(null);
const autosaveRef = useRef<AutosaveScheduler | null>(null);
const commandCountRef = useRef(0);
const projectIdRef = useRef("untitled");
const projectIdRef = useRef(loadLastProjectId());
const projectRevisionRef = useRef(0);
const committedProjectRevisionRef = useRef(0);
const committedProjectHashRef = useRef<string | null>(null);
const userActionSequenceRef = useRef(0);
const projectActionMutexRef = useRef(createProjectActionMutexState());
const openAbortControllerRef = useRef<AbortController | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const workspace = uiState.context.workspaceId;
const saved = !dirtyState.dirty;
const workspaceLabel = useMemo(() => `${workspace} Workspace`, [workspace]);
const dispatchUI = (command: UICommand) => setUIState((state) => reduceUICommand(state, command));
const nextUserActionIdentity = <Kind extends UserActionKind>(kind: Kind): UserActionIdentity & { kind: Kind } => (
{ kind, actionId: `${kind}:${++userActionSequenceRef.current}` }
);
const startUserAction = (identity: UserActionIdentity): void => {
dispatchUserAction({ type: "START", identity });
};
const beginUserAction = (kind: UserActionKind): UserActionIdentity => {
const identity = nextUserActionIdentity(kind);
startUserAction(identity);
return identity;
};
const succeedUserAction = (identity: UserActionIdentity): void => {
dispatchUserAction({ type: "SUCCEED", identity });
};
const failUserAction = (identity: UserActionIdentity, errorCode: string): void => {
dispatchUserAction({ type: "FAIL", identity, errorCode });
};
const cancelUserAction = (identity: UserActionIdentity, errorCode: string): void => {
dispatchUserAction({ type: "CANCEL", identity, errorCode });
};
const acquireProjectActionLock = (identity: ProjectActionIdentity, reportConflict = true): boolean => {
const result = acquireProjectAction(projectActionMutexRef.current, identity);
if (!result.granted) {
if (reportConflict) {
setProjectActionConflict(result.conflict);
setEngineStatus(`Action: blocked (${result.conflict.code}: ${result.conflict.reason})`);
}
return false;
}
projectActionMutexRef.current = result.state;
setProjectActionConflict(null);
return true;
};
const releaseProjectActionLock = (identity: ProjectActionIdentity): void => {
const result = releaseProjectAction(projectActionMutexRef.current, identity);
if (result.released) projectActionMutexRef.current = result.state;
else setEngineStatus(`Action: lock failure (${result.errorCode})`);
};
const selectObject = (id: string, additive = false): void => {
setSelectedObjectIds((current) => {
if (!additive) return new Set([id]);
@@ -767,11 +843,11 @@ export function App() {
// Cache invalidation is best effort and never blocks an engine command.
}
};
const applyEditCommand = async (command: WebEngineEditCommand): Promise<void> => {
const applyEditCommand = async (command: WebEngineEditCommand): Promise<boolean> => {
const client = webClientRef.current;
if (!client || !snapshot) {
if (command.type === "setFrame") setFrame(command.frame);
return;
return false;
}
try {
if (command.type === "previewDecimateMesh") {
@@ -781,9 +857,11 @@ export function App() {
setEngineStatus(result.simplify
? `Engine: Preview ${result.simplify.originalTriangleCount} -> ${result.simplify.outputTriangleCount} triangles`
: "Engine: Preview ready");
return;
return true;
}
const result = await client.applyCommand(command);
const logicalRevision = Math.max(projectRevisionRef.current + 1, result.snapshot.revision);
projectRevisionRef.current = logicalRevision;
setPreview(null);
setLodLevels(null);
setSnapshot(result.snapshot);
@@ -794,7 +872,26 @@ export function App() {
if (command.type === "meshEdit" || command.type === "separateMeshFaces" || command.type === "joinObjects") {
setMeshSelection((current) => ({ ...current, meshId: null, indices: new Set() }));
}
setSaved(false);
if (command.type === "undo" || command.type === "redo") {
let matchesCommittedContent = false;
try {
const historyBlend = await client.saveBlend();
matchesCommittedContent = await sha256Hex(historyBlend) === committedProjectHashRef.current;
}
catch {
// A history serialization failure is conservative: the project remains dirty.
}
setDirtyState((current) => {
const accepted = acceptHistoryTransaction(current, logicalRevision, matchesCommittedContent);
return accepted.ok ? accepted.state : current;
});
}
else {
setDirtyState((current) => {
const accepted = acceptMainTransaction(current, logicalRevision);
return accepted.ok ? accepted.state : current;
});
}
void invalidateCachedLODs(projectIdRef.current, result.snapshot.revision);
setEngineStatus(result.simplify
? `Engine: Decimate ${result.simplify.originalTriangleCount} -> ${result.simplify.outputTriangleCount} triangles (r${result.snapshot.revision})`
@@ -803,12 +900,12 @@ export function App() {
const storage = storageClientRef.current;
if (storage) {
try {
const operationId = `op-${result.snapshot.revision}-${crypto.randomUUID()}`;
await storage.appendOperation(operationId, projectIdRef.current, result.snapshot.revision, command);
const operationId = `op-${logicalRevision}-${crypto.randomUUID()}`;
await storage.appendOperation(operationId, projectIdRef.current, logicalRevision, command);
commandCountRef.current += 1;
if (commandCountRef.current % 10 === 0) {
const blend = await client.saveBlend();
await storage.saveSnapshot(projectIdRef.current, result.snapshot.revision, blend.slice(0));
await storage.saveSnapshot(projectIdRef.current, logicalRevision, blend.slice(0));
}
}
catch (error) {
@@ -816,8 +913,10 @@ export function App() {
}
}
}
return true;
} catch (error) {
setEngineStatus(`Engine: command failed (${errorMessage(error)})`);
return false;
}
};
@@ -858,18 +957,30 @@ export function App() {
...(["BEVEL", "LOOP_CUT"].includes(operation) ? { segments: operation === "BEVEL" ? 2 : 1 } : {}) });
};
const importImage = async (file: File): Promise<void> => {
const identity = beginUserAction("IMPORT");
let failureCode = "IMPORT_DECODE_FAILED";
let bitmap: ImageBitmap | null = null;
try {
const bitmap = await createImageBitmap(file);
bitmap = await createImageBitmap(file);
const bytes = new Uint8Array(await file.arrayBuffer());
let binary = "";
for (let offset = 0; offset < bytes.length; offset += 0x8000) binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
void applyEditCommand({ type: "importImage", name: file.name.replace(/\.[^.]+$/, "") || "Image",
failureCode = "IMPORT_MAIN_TRANSACTION_FAILED";
const applied = await applyEditCommand({ type: "importImage", name: file.name.replace(/\.[^.]+$/, "") || "Image",
mimeType: file.type === "image/jpeg" ? "image/jpeg" : "image/png", width: bitmap.width, height: bitmap.height, base64: btoa(binary) });
bitmap.close();
if (!applied) {
failUserAction(identity, failureCode);
return;
}
succeedUserAction(identity);
}
catch (error) {
failUserAction(identity, failureCode);
setEngineStatus(`Image import failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
finally {
bitmap?.close();
}
};
const transformActive = (tool: "translate" | "rotate" | "scale", amount = 0.1, axis: 0 | 1 | 2 = tool === "rotate" ? 2 : 0, axisVector?: [number, number, number]): void => {
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
@@ -1085,7 +1196,9 @@ export function App() {
let mounted = true;
void loadWebEngineManifest()
.then(async (manifest) => {
const requiredResource = manifest.wasm.find((resource) => resource.required);
const requiredResource = "variants" in manifest
? manifest.variants.find((variant) => variant.id === "single")?.resources.wasm
: manifest.wasm.find((resource) => resource.required);
if (requiredResource) await verifyWasmResource(requiredResource);
if (mounted) setManifestStatus(`Manifest: verified r${manifest.protocolVersion}`);
})
@@ -1171,15 +1284,66 @@ export function App() {
};
const openBlendFile = async (file: File): Promise<void> => {
const identity = nextUserActionIdentity("OPEN");
if (!acquireProjectActionLock(identity)) return;
startUserAction(identity);
const controller = new AbortController();
openAbortControllerRef.current = controller;
const client = webClientRef.current;
if (!client) return;
setOpenProgress({ requestId: "ui", operation: "blend.open", phase: "started", fraction: 0, message: `打开 ${file.name}` });
let failureCode = "OPEN_FILE_READ_FAILED";
try {
const input = await file.arrayBuffer();
if (!client) {
failUserAction(identity, "OPEN_ENGINE_UNAVAILABLE");
return;
}
const input = await readFileBytes(file, {
signal: controller.signal,
yieldControl: () => new Promise((resolve) => requestAnimationFrame(() => resolve())),
onResourceState: (state) => setOpenCleanupEvidence((current) => ({
...current,
readerLiveReaders: state.liveReaders,
readerInputBytes: state.liveInputBytes,
readerStagingFiles: state.liveStagingFiles,
})),
onProgress: (item) => {
const update = (): void => {
setOpenReadEvidence((current) => ({
actionId: identity.actionId,
phase: item.phase,
bytesRead: item.bytesRead,
totalBytes: item.totalBytes,
progressEvents: current.actionId === identity.actionId ? current.progressEvents + 1 : 1,
}));
setOpenProgress({
requestId: identity.actionId,
operation: "blend.open.read",
phase: item.phase === "STARTED" ? "started" : item.phase === "COMPLETED" ? "completed" : item.phase === "CANCELLED" ? "cancelled" : "progress",
fraction: item.fraction,
bytesRead: item.bytesRead,
totalBytes: item.totalBytes,
message: `读取 ${file.name} ${item.bytesRead}/${item.totalBytes} B`,
});
};
if (item.phase !== "READING" || item.bytesRead % DEFAULT_FILE_READ_YIELD_BYTES === 0) flushSync(update);
else update();
},
});
if (controller.signal.aborted) throw new FileByteReadError("FILE_READ_CANCELLED", "cancelled before engine open");
const inputSha256 = await sha256Hex(input);
const projectId = file.name.replace(/\.blend$/i, "").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "untitled";
projectIdRef.current = projectId;
const result = await client.openBlend(input, setOpenProgress);
setVolumeProject({ projectId, sourceBlendSha256: await sha256Hex(input) });
rememberLastProjectId(projectId);
failureCode = "OPEN_ENGINE_FAILED";
const result = await client.openBlend(input, (progress) => {
setOpenCleanupEvidence((current) => ({ ...current, lastStage: progress.stage ?? current.lastStage }));
setOpenProgress({ ...progress, requestId: identity.actionId });
}, controller.signal);
projectRevisionRef.current = result.snapshot.revision;
committedProjectRevisionRef.current = 0;
committedProjectHashRef.current = inputSha256;
setSaveTransaction(createSaveTransactionState({ revision: 0, sha256: inputSha256 }));
setDirtyState(createDirtyState(result.snapshot.revision));
setVolumeProject({ projectId, sourceBlendSha256: inputSha256 });
setPreview(null);
setLodLevels(null);
setGPUTextureAssets([]);
@@ -1188,32 +1352,104 @@ export function App() {
setGeometryBuffers(result.geometryBuffers);
setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []);
setFrame(result.snapshot.frame.current);
setSaved(true);
setEngineStatus(`Engine: SceneIR r${result.snapshot.revision} (${result.snapshot.nodes.length} objects)`);
void cachePackedAssets(projectId, result.snapshot).then((assets) => {
if (projectIdRef.current === projectId) setGPUTextureAssets(assets);
});
void restoreCachedLODs(projectId, result.snapshot);
succeedUserAction(identity);
} catch (error) {
setEngineStatus(`Engine: .blend read failed${error instanceof Error ? ` (${error.message})` : ""}`);
if (controller.signal.aborted || (error instanceof FileByteReadError && error.code === "FILE_READ_CANCELLED")) {
cancelUserAction(identity, "OPEN_CANCELLED");
if (client) {
try {
const resources = await client.openResourceStatus();
setOpenCleanupEvidence((current) => ({
...current,
engineActiveRequests: resources.activeRequests,
engineInputBytes: resources.liveInputBytes,
engineNativeHandles: resources.liveNativeHandles,
engineStagingFiles: resources.liveStagingFiles,
}));
}
catch {
// The visible cancellation remains valid; Worker recovery is handled separately.
}
}
setEngineStatus("Engine: .blend open cancelled");
}
else {
failUserAction(identity, error instanceof FileByteReadError ? error.code : failureCode);
setEngineStatus(`Engine: .blend read failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
} finally {
setOpenProgress(null);
if (openAbortControllerRef.current === controller) openAbortControllerRef.current = null;
releaseProjectActionLock(identity);
}
};
const cancelOpenFileRead = (): void => {
openAbortControllerRef.current?.abort();
};
const persistProject = async (): Promise<ArrayBuffer | null> => {
const client = webClientRef.current;
if (!client || !snapshot) return null;
const data = await client.saveBlend();
const storage = storageClientRef.current;
if (storage) {
await storage.saveProject(projectIdRef.current, snapshot.revision, data.slice(0));
await storage.saveSnapshot(projectIdRef.current, snapshot.revision, data.slice(0));
await storage.pruneOperations(projectIdRef.current, snapshot.revision);
const revision = Math.max(projectRevisionRef.current, committedProjectRevisionRef.current, snapshot.revision);
const started = beginSaveTransaction(createSaveTransactionState({
revision: committedProjectRevisionRef.current,
sha256: committedProjectHashRef.current,
}), revision);
if (!started.ok) throw new Error(started.errorCode);
let transaction = started.state;
setSaveTransaction(transaction);
try {
const data = await client.saveBlend();
const sha256 = await sha256Hex(data);
const staged = advanceSaveTransaction(transaction, "OPFS_STAGE", sha256);
if (!staged.ok) throw new Error(staged.errorCode);
transaction = staged.state;
setSaveTransaction(transaction);
const storage = storageClientRef.current;
let persisted = { revision, sha256 };
if (storage) {
const result = await storage.saveProject(projectIdRef.current, revision, data.slice(0));
persisted = { revision: result.revision, sha256: result.sha256 };
}
const sceneCommitted = advanceSaveTransaction(transaction, "SCENE_COMMIT");
if (!sceneCommitted.ok) throw new Error(sceneCommitted.errorCode);
transaction = sceneCommitted.state;
const metadataCommitted = advanceSaveTransaction(transaction, "METADATA_COMMIT");
if (!metadataCommitted.ok) throw new Error(metadataCommitted.errorCode);
transaction = metadataCommitted.state;
const committed = commitSaveTransaction(transaction, persisted);
if (!committed.ok) throw new Error(committed.errorCode);
transaction = committed.state;
setSaveTransaction(transaction);
committedProjectRevisionRef.current = persisted.revision;
committedProjectHashRef.current = persisted.sha256;
setVolumeProject({ projectId: projectIdRef.current, sourceBlendSha256: persisted.sha256 });
setDirtyState((current) => {
const accepted = acceptMainSave(current, persisted.revision);
return accepted.ok ? accepted.state : current;
});
if (storage) {
try {
await storage.saveSnapshot(projectIdRef.current, revision, data.slice(0));
await storage.pruneOperations(projectIdRef.current, revision);
}
catch (error) {
setStorageStatus(`Storage: post-commit maintenance failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
}
return data;
}
catch (error) {
setSaveTransaction(failSaveTransaction(transaction, transaction.stage === "SERIALIZE" ? "SAVE_SERIALIZE_INTERRUPTED" : "SAVE_STORAGE_INTERRUPTED"));
throw error;
}
setVolumeProject({ projectId: projectIdRef.current, sourceBlendSha256: await sha256Hex(data) });
setSaved(true);
return data;
};
const recoverCachedProject = async (): Promise<void> => {
@@ -1222,6 +1458,7 @@ export function App() {
if (!client || !storage) return;
try {
const projectId = projectIdRef.current;
rememberLastProjectId(projectId);
let baseRevision = 0;
let buffer: ArrayBuffer;
try {
@@ -1237,8 +1474,9 @@ export function App() {
baseRevision = saved.revision;
buffer = saved.buffer;
}
const baseSha256 = await sha256Hex(buffer);
let opened = await client.openBlend(buffer);
setVolumeProject({ projectId, sourceBlendSha256: await sha256Hex(buffer) });
setVolumeProject({ projectId, sourceBlendSha256: baseSha256 });
const replay = await storage.listOperations(projectId, baseRevision);
for (const operation of replay.operations) {
const payload = operation.payload as WebEngineEditCommand;
@@ -1247,6 +1485,14 @@ export function App() {
}
opened = await client.applyCommand(payload);
}
committedProjectRevisionRef.current = baseRevision;
committedProjectHashRef.current = baseSha256;
setSaveTransaction(createSaveTransactionState({ revision: baseRevision, sha256: baseSha256 }));
projectRevisionRef.current = replay.operations.reduce(
(revision, operation) => Math.max(revision + 1, operation.revision),
baseRevision,
);
setDirtyState(recoverDirtyState(projectRevisionRef.current, baseRevision));
setPreview(null);
setLodLevels(null);
setGPUTextureAssets([]);
@@ -1255,7 +1501,6 @@ export function App() {
setNonMeshGeometryBuffers(opened.nonMeshGeometryBuffers ?? []);
setFrame(opened.snapshot.frame.current);
setSelectedObjectIds(new Set(opened.snapshot.activeObjectId ? [opened.snapshot.activeObjectId] : []));
setSaved(replay.operations.length === 0);
setEngineStatus(`Recovery: ${replay.operations.length} operation(s), ${replay.quarantined} quarantined`);
void cachePackedAssets(projectId, opened.snapshot).then((assets) => {
if (projectIdRef.current === projectId) setGPUTextureAssets(assets);
@@ -1267,48 +1512,115 @@ export function App() {
};
const saveBlend = async (): Promise<void> => {
const saveIdentity = nextUserActionIdentity("SAVE");
if (!acquireProjectActionLock(saveIdentity)) return;
startUserAction(saveIdentity);
let data: ArrayBuffer | null = null;
try {
const persisted = await persistProject();
if (!persisted) {
failUserAction(saveIdentity, "SAVE_PROJECT_UNAVAILABLE");
}
else {
data = persisted;
succeedUserAction(saveIdentity);
}
} catch (error) {
failUserAction(saveIdentity, "SAVE_FAILED");
setEngineStatus(`Engine: .blend save failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
finally {
releaseProjectActionLock(saveIdentity);
}
if (!data) return;
const saveAsIdentity = beginUserAction("SAVE_AS");
try {
const data = await persistProject();
if (!data) return;
const url = URL.createObjectURL(new Blob([data], { type: "application/octet-stream" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "blender-web.blend";
anchor.click();
URL.revokeObjectURL(url);
succeedUserAction(saveAsIdentity);
} catch (error) {
setEngineStatus(`Engine: .blend save failed${error instanceof Error ? ` (${error.message})` : ""}`);
failUserAction(saveAsIdentity, "SAVE_AS_DOWNLOAD_FAILED");
setEngineStatus(`Engine: .blend download failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
};
const closeProject = (): void => {
if (!snapshot) return;
const identity: ProjectActionIdentity = { kind: "CLOSE", actionId: `CLOSE:${++userActionSequenceRef.current}` };
if (!acquireProjectActionLock(identity)) return;
try {
autosaveRef.current?.cancel();
setPreview(null);
setLodLevels(null);
setGPUTextureAssets([]);
setVolumeProject(null);
setSnapshot(null);
setSelectedObjectIds(new Set());
setMeshSelection({ meshId: null, mode: "FACE", indices: new Set() });
setGeometryBuffers([]);
setNonMeshGeometryBuffers([]);
setFrame(1);
setDirtyState(createDirtyState());
projectRevisionRef.current = 0;
committedProjectRevisionRef.current = 0;
committedProjectHashRef.current = null;
setSaveTransaction(createSaveTransactionState({ revision: 0, sha256: null }));
commandCountRef.current = 0;
setOpenProgress(null);
setEngineStatus("Engine: ready, open a .blend file");
}
finally {
releaseProjectActionLock(identity);
}
};
const reportGLBExport = async (): Promise<void> => {
if (!snapshot) return;
let exportSnapshot = snapshot;
let exportGeometryBuffers = geometryBuffers;
if ((snapshot.nonMeshData?.length ?? 0) > 0 && webClientRef.current) {
try {
const evaluated = await webClientRef.current.evaluateDepsgraph();
const mapped = mapEvaluatedNonMeshForExport(snapshot, geometryBuffers, evaluated.depsgraph);
exportSnapshot = mapped.snapshot;
exportGeometryBuffers = mapped.geometryBuffers;
const identity = beginUserAction("EXPORT");
if (!snapshot) {
failUserAction(identity, "EXPORT_PROJECT_UNAVAILABLE");
setEngineStatus("GLB: blocked (no open project)");
return;
}
try {
let exportSnapshot = snapshot;
let exportGeometryBuffers = geometryBuffers;
if ((snapshot.nonMeshData?.length ?? 0) > 0 && webClientRef.current) {
try {
const evaluated = await webClientRef.current.evaluateDepsgraph();
const mapped = mapEvaluatedNonMeshForExport(snapshot, geometryBuffers, evaluated.depsgraph);
exportSnapshot = mapped.snapshot;
exportGeometryBuffers = mapped.geometryBuffers;
}
catch {
// The exporter will emit a machine-readable evaluation-required error.
}
}
catch {
// The exporter will emit a machine-readable evaluation-required error.
const result = exportGLB(exportSnapshot, exportGeometryBuffers, [], nonMeshGeometryBuffers);
const report = result.report;
const errorCount = report.warnings.filter((warning) => warning.severity === "error").length;
const warningCount = report.warnings.length - errorCount;
if (result.glb) {
const url = URL.createObjectURL(new Blob([result.glb], { type: "model/gltf-binary" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "blender-web.glb";
anchor.click();
URL.revokeObjectURL(url);
succeedUserAction(identity);
setEngineStatus(`GLB: exported (${result.glb.byteLength} bytes, ${warningCount} warnings)`);
}
else {
failUserAction(identity, "EXPORT_BLOCKED");
setEngineStatus(`GLB: blocked (${errorCount} errors, ${warningCount} warnings)`);
}
}
const result = exportGLB(exportSnapshot, exportGeometryBuffers, [], nonMeshGeometryBuffers);
const report = result.report;
const errorCount = report.warnings.filter((warning) => warning.severity === "error").length;
const warningCount = report.warnings.length - errorCount;
if (result.glb) {
const url = URL.createObjectURL(new Blob([result.glb], { type: "model/gltf-binary" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "blender-web.glb";
anchor.click();
URL.revokeObjectURL(url);
setEngineStatus(`GLB: exported (${result.glb.byteLength} bytes, ${warningCount} warnings)`);
catch (error) {
failUserAction(identity, "EXPORT_FAILED");
setEngineStatus(`GLB: export failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
else setEngineStatus(`GLB: blocked (${errorCount} errors, ${warningCount} warnings)`);
};
useEffect(() => {
@@ -1317,11 +1629,16 @@ export function App() {
return;
}
autosaveRef.current?.schedule(async () => {
const identity: ProjectActionIdentity = { kind: "SAVE", actionId: `AUTOSAVE:${++userActionSequenceRef.current}` };
if (!acquireProjectActionLock(identity, false)) return;
try {
await persistProject();
} catch (error) {
setEngineStatus(`Engine: autosave failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
finally {
releaseProjectActionLock(identity);
}
});
}, [saved, snapshot]);
@@ -1329,6 +1646,7 @@ export function App() {
const vertexCount = snapshot?.meshes.reduce((total, mesh) => total + mesh.vertexCount, 0) ?? 0;
const faceCount = snapshot?.meshes.reduce((total, mesh) => total + mesh.faceCount, 0) ?? 0;
const frameRange = snapshot?.frame ?? { current: frame, start: 1, end: 250 };
const canCancelOpenRead = userActions.OPEN.status === "RUNNING" && (openProgress?.operation === "blend.open.read" || openProgress?.cancellable === true);
const operatorCommands: OperatorCommand[] = [
...(["Layout", "Modeling", "Animation"] as WorkspaceId[]).filter((id) => id !== workspace).map((id) => ({ id: `workspace.${id}`, label: `Switch to ${id}`, keywords: "workspace", execute: () => dispatchUI({ type: "switchWorkspace", workspaceId: id }) })),
{ id: "mode.toggle", label: uiState.context.mode === "Object" ? "Enter Edit Mode" : "Exit Edit Mode", keywords: "mode tab", execute: () => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" }) },
@@ -1338,21 +1656,41 @@ export function App() {
{ id: "edit.undo", label: "Undo", keywords: "history", execute: () => { void applyEditCommand({ type: "undo" }); } },
{ id: "edit.redo", label: "Redo", keywords: "history", execute: () => { void applyEditCommand({ type: "redo" }); } },
{ id: "file.save", label: "Save Project", keywords: "file blend", execute: () => { void saveBlend(); } },
{ id: "file.close", label: "Close Project", keywords: "file", execute: closeProject },
] : []),
];
return (
<main className="blender-app" data-workspace={workspace} data-ui-revision={uiState.context.revision}>
<main className="blender-app" data-workspace={workspace} data-ui-revision={uiState.context.revision}
data-user-action-import-status={userActions.IMPORT.status} data-user-action-import-id={userActions.IMPORT.identity?.actionId} data-user-action-import-error={userActions.IMPORT.errorCode ?? undefined}
data-user-action-open-status={userActions.OPEN.status} data-user-action-open-id={userActions.OPEN.identity?.actionId} data-user-action-open-error={userActions.OPEN.errorCode ?? undefined}
data-user-action-save-status={userActions.SAVE.status} data-user-action-save-id={userActions.SAVE.identity?.actionId} data-user-action-save-error={userActions.SAVE.errorCode ?? undefined}
data-user-action-save-as-status={userActions.SAVE_AS.status} data-user-action-save-as-id={userActions.SAVE_AS.identity?.actionId} data-user-action-save-as-error={userActions.SAVE_AS.errorCode ?? undefined}
data-user-action-export-status={userActions.EXPORT.status} data-user-action-export-id={userActions.EXPORT.identity?.actionId} data-user-action-export-error={userActions.EXPORT.errorCode ?? undefined}
data-project-action-conflict={projectActionConflict?.code} data-project-action-conflict-reason={projectActionConflict?.reason}
data-project-action-conflict-requested={projectActionConflict?.requested.kind} data-project-action-conflict-owner={projectActionConflict?.owner.kind}
data-project-action-conflict-request-id={projectActionConflict?.requested.actionId} data-project-action-conflict-owner-id={projectActionConflict?.owner.actionId}
data-open-read-action-id={openReadEvidence.actionId ?? undefined} data-open-read-phase={openReadEvidence.phase}
data-open-read-bytes={openReadEvidence.bytesRead} data-open-read-total={openReadEvidence.totalBytes} data-open-read-events={openReadEvidence.progressEvents}
data-open-cleanup-reader-count={openCleanupEvidence.readerLiveReaders} data-open-cleanup-reader-bytes={openCleanupEvidence.readerInputBytes}
data-open-cleanup-reader-staging={openCleanupEvidence.readerStagingFiles} data-open-cleanup-engine-requests={openCleanupEvidence.engineActiveRequests}
data-open-cleanup-engine-bytes={openCleanupEvidence.engineInputBytes} data-open-cleanup-engine-handles={openCleanupEvidence.engineNativeHandles}
data-open-cleanup-engine-staging={openCleanupEvidence.engineStagingFiles} data-open-cleanup-stage={openCleanupEvidence.lastStage}
data-save-transaction-status={saveTransaction.status} data-save-transaction-stage={saveTransaction.stage ?? undefined}
data-save-committed-revision={saveTransaction.committed.revision} data-save-committed-hash={saveTransaction.committed.sha256 ?? undefined}
data-save-candidate-revision={saveTransaction.candidate?.revision} data-save-candidate-hash={saveTransaction.candidate?.sha256 ?? undefined}
data-save-transaction-error={saveTransaction.errorCode ?? undefined} data-dirty={dirtyState.dirty}
data-current-main-revision={dirtyState.currentMainRevision} data-committed-main-revision={dirtyState.committedMainRevision}>
<header className="topbar">
<div className="brand"><span className="brand-mark" aria-hidden="true"></span><span>Web Blender Modeler V1</span></div>
<nav className="menu-bar" aria-label="主菜单"><button type="button" onClick={() => fileInputRef.current?.click()}></button><button type="button"></button><button type="button"></button><button type="button"></button><button type="button"></button></nav>
<nav className="workspace-tabs" aria-label="工作区">
{(["Layout", "Modeling", "Animation"] as WorkspaceId[]).map((item) => <button key={item} className={item === workspace ? "workspace-tab active" : "workspace-tab"} type="button" onClick={() => dispatchUI({ type: "switchWorkspace", workspaceId: item })}>{item}</button>)}
</nav>
<div className="topbar-actions"><button type="button" aria-label="打开 .blend" onClick={() => fileInputRef.current?.click()}></button><button type="button" aria-label="恢复项目" onClick={() => void recoverCachedProject()}></button><button type="button" aria-label="保存项目" onClick={() => void saveBlend()}></button><button type="button" aria-label="导出 GLB" onClick={reportGLBExport}>GLB</button><button type="button" aria-label="撤销" onClick={() => void applyEditCommand({ type: "undo" })}></button><button type="button" aria-label="重做" onClick={() => void applyEditCommand({ type: "redo" })}></button><button type="button" aria-label="操作搜索" onClick={() => dispatchUI({ type: "toggleOperatorSearch", open: true })}>F3</button></div>
<div className="topbar-actions"><button type="button" aria-label="打开 .blend" onClick={() => fileInputRef.current?.click()}></button><button type="button" aria-label="关闭项目" disabled={!snapshot} onClick={closeProject}></button><button type="button" aria-label="恢复项目" onClick={() => void recoverCachedProject()}></button><button type="button" aria-label="保存项目" onClick={() => void saveBlend()}></button><button type="button" aria-label="导出 GLB" onClick={() => void reportGLBExport()}>GLB</button><button type="button" aria-label="撤销" onClick={() => void applyEditCommand({ type: "undo" })}></button><button type="button" aria-label="重做" onClick={() => void applyEditCommand({ type: "redo" })}></button><button type="button" aria-label="操作搜索" onClick={() => dispatchUI({ type: "toggleOperatorSearch", open: true })}>F3</button></div>
<input ref={fileInputRef} className="file-input-hidden" type="file" accept=".blend,application/octet-stream" data-testid="blend-file-input" onChange={(event) => { const file = event.target.files?.[0]; if (file) void openBlendFile(file); event.target.value = ""; }} />
</header>
<div className="workspace-toolbar"><span>{workspaceLabel}</span><button type="button" className="mode-chip" onClick={() => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" })}>{uiState.context.mode} Mode</button>{uiState.context.mode === "Edit" ? <><div className="segmented" aria-label="网格选择模式">{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => <button key={mode} type="button" className={meshSelection.mode === mode ? "active" : ""} onClick={() => setMeshSelectionMode(mode)}>{mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face"}</button>)}</div><button type="button" onClick={selectAllMeshElements}>Select All</button>{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT"] as MeshEditOperation[]).map((operation) => <button key={operation} type="button" disabled={meshSelection.indices.size === 0} onClick={() => runMeshEdit(operation)}>{operation.replace("_", " ")}</button>)}<button type="button" disabled={meshSelection.mode !== "FACE" || meshSelection.indices.size === 0 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "separateMeshFaces", objectId: snapshot.activeObjectId, faceIndices: [...meshSelection.indices], name: "Separated" })}>Separate</button></> : <><button type="button" aria-label="添加立方体" onClick={() => void applyEditCommand({ type: "createPrimitive", primitive: "CUBE", location: [0, 0, 0] })}>Add Cube</button><button type="button" aria-label="复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.25, 0.25, 0] })}>Duplicate</button><button type="button" aria-label="链接复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.5, 0.5, 0], linked: true })}>Linked Duplicate</button><button type="button" aria-label="删除对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "deleteObject", objectId: snapshot.activeObjectId })}>Delete</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => { const child = snapshot?.activeObjectId; const parent = [...selectedObjectIds].find((id) => id !== child); if (child && parent) void applyEditCommand({ type: "setParent", objectId: child, parentId: parent, keepTransform: true }); }}>Parent</button><button type="button" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "setParent", objectId: snapshot.activeObjectId, parentId: null, keepTransform: true })}>Unparent</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "joinObjects", activeObjectId: snapshot.activeObjectId, objectIds: [...selectedObjectIds] })}>Join</button><button type="button" onClick={() => void applyEditCommand({ type: "createCollection", name: `Collection ${(snapshot?.collections.length ?? 0) + 1}` })}>New Collection</button></>}<span className="toolbar-spacer" /><span>{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><button type="button" onClick={() => setSaved(false)}>{saved ? "已保存" : "未保存"}</button></div>
<div className="workspace-toolbar"><span>{workspaceLabel}</span><button type="button" className="mode-chip" onClick={() => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" })}>{uiState.context.mode} Mode</button>{uiState.context.mode === "Edit" ? <><div className="segmented" aria-label="网格选择模式">{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => <button key={mode} type="button" className={meshSelection.mode === mode ? "active" : ""} onClick={() => setMeshSelectionMode(mode)}>{mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face"}</button>)}</div><button type="button" onClick={selectAllMeshElements}>Select All</button>{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT"] as MeshEditOperation[]).map((operation) => <button key={operation} type="button" disabled={meshSelection.indices.size === 0} onClick={() => runMeshEdit(operation)}>{operation.replace("_", " ")}</button>)}<button type="button" disabled={meshSelection.mode !== "FACE" || meshSelection.indices.size === 0 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "separateMeshFaces", objectId: snapshot.activeObjectId, faceIndices: [...meshSelection.indices], name: "Separated" })}>Separate</button></> : <><button type="button" aria-label="添加立方体" onClick={() => void applyEditCommand({ type: "createPrimitive", primitive: "CUBE", location: [0, 0, 0] })}>Add Cube</button><button type="button" aria-label="复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.25, 0.25, 0] })}>Duplicate</button><button type="button" aria-label="链接复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.5, 0.5, 0], linked: true })}>Linked Duplicate</button><button type="button" aria-label="删除对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "deleteObject", objectId: snapshot.activeObjectId })}>Delete</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => { const child = snapshot?.activeObjectId; const parent = [...selectedObjectIds].find((id) => id !== child); if (child && parent) void applyEditCommand({ type: "setParent", objectId: child, parentId: parent, keepTransform: true }); }}>Parent</button><button type="button" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "setParent", objectId: snapshot.activeObjectId, parentId: null, keepTransform: true })}>Unparent</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "joinObjects", activeObjectId: snapshot.activeObjectId, objectIds: [...selectedObjectIds] })}>Join</button><button type="button" onClick={() => void applyEditCommand({ type: "createCollection", name: `Collection ${(snapshot?.collections.length ?? 0) + 1}` })}>New Collection</button></>}<span className="toolbar-spacer" /><span>{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><output data-testid="dirty-status">{saved ? "已保存" : "未保存"}</output></div>
<div className="workspace-grid">
<Area className="viewport-area" editor="3D Viewport"><ViewportPlaceholder snapshot={preview?.snapshot ?? snapshot} geometryBuffers={preview?.geometryBuffers ?? geometryBuffers} nonMeshGeometryBuffers={preview?.nonMeshGeometryBuffers ?? nonMeshGeometryBuffers} textureAssets={gpuTextureAssets} volumeProject={volumeProject} lodLevels={preview ? null : lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} onSelect={selectObject} onElementSelect={selectMeshElement} onGreasePencilPointSelect={selectGreasePencilPoint} onTransform={transformActive} /></Area>
<Area className="outliner-area" editor="Outliner"><Outliner snapshot={snapshot} onSelect={selectObject} onToggleVisibility={(id, visible) => void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /></Area>
@@ -1360,7 +1698,7 @@ export function App() {
<Area className="timeline-area" editor="Timeline"><Timeline snapshot={snapshot} frame={frame} start={frameRange.start} end={frameRange.end} onFrameChange={(value) => void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} /></Area>
</div>
{uiState.operatorSearchOpen ? <OperatorSearch commands={operatorCommands} onClose={() => dispatchUI({ type: "toggleOperatorSearch", open: false })} /> : null}
<footer className="status-bar"><span>Web Blender Modeler V1</span><span data-testid="scene-stats">Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span data-testid="open-progress">{openProgress.message ?? "Opening"}</span> : null}<span className="status-spacer" /><span>{manifestStatus}</span><span>{wasmStatus}</span><span data-testid="engine-status">{engineStatus}</span><span>{storageStatus}</span></footer>
<footer className="status-bar"><span>Web Blender Modeler V1</span><span data-testid="scene-stats">Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span className="open-progress" data-testid="open-progress" data-phase={openProgress.phase} data-stage={openProgress.stage} data-cancellable={openProgress.cancellable} data-bytes-read={openProgress.bytesRead} data-total-bytes={openProgress.totalBytes}><progress aria-label="打开文件进度" max={(openProgress.totalBytes ?? 0) > 0 ? openProgress.totalBytes : 1} value={openProgress.bytesRead ?? openProgress.fraction ?? 0} /><span>{openProgress.message ?? "Opening"}</span>{canCancelOpenRead ? <button type="button" aria-label="取消打开" title="取消打开" onClick={cancelOpenFileRead}>×</button> : null}</span> : null}<span className="status-spacer" /><span>{manifestStatus}</span><span>{wasmStatus}</span><span data-testid="engine-status">{engineStatus}</span><span>{storageStatus}</span></footer>
</main>
);
}

View File

@@ -19,6 +19,7 @@ button { color: inherit; border: 0; cursor: pointer; }
.menu-bar, .workspace-tabs, .topbar-actions { display: flex; align-items: center; gap: 2px; }
.menu-bar button, .topbar-actions button, .workspace-tab, .workspace-toolbar button { padding: 5px 8px; background: transparent; border-radius: 3px; color: #bfc2c8; }
.menu-bar button:hover, .topbar-actions button:hover, .workspace-tab:hover, .workspace-toolbar button:hover { background: #3a3c41; color: #fff; }
.topbar-actions button:disabled { color: #6f737b; background: transparent; cursor: default; }
.workspace-tabs { align-self: stretch; gap: 0; }
.workspace-tab { min-width: 82px; border-bottom: 2px solid transparent; border-radius: 0; }
.workspace-tab.active { color: #fff; background: #313338; border-bottom-color: #e37a2c; }
@@ -62,6 +63,7 @@ button { color: inherit; border: 0; cursor: pointer; }
.property-section input[type="number"] { width: 72px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="text"], .property-section label > input:not([type]) { min-width: 0; width: 130px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="checkbox"] { accent-color: #e37a2c; }.property-actions { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0; }.property-actions button, .file-button { padding: 4px 7px; color: #e6e7e9; background: #393c42; border: 1px solid #4d5158; border-radius: 2px; }.property-actions button:disabled { opacity: .45; cursor: default; }.file-button { position: relative; cursor: pointer; }.file-button input { position: absolute; width: 1px; height: 1px; opacity: 0; }.material-slots { display: grid; gap: 2px; max-height: 62px; overflow: auto; color: #b9bdc5; font-size: 11px; }.delimit-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 8px; margin: 6px 0; padding: 5px 0 3px; border: 0; border-top: 1px solid #383a40; }.delimit-options legend { padding: 0 5px 0 0; color: #7f858e; font-size: 10px; }.property-section .delimit-options label { justify-content: flex-start; min-width: 0; min-height: 20px; font-size: 10px; }
.timeline-content { display: grid; grid-template-rows: 34px 20px 16px minmax(30px, 1fr); height: 100%; padding: 6px 12px; }.timeline-controls { display: flex; align-items: center; gap: 4px; overflow-x: auto; }.timeline-controls button { min-width: 28px; height: 26px; padding: 0 6px; color: #c9ccd2; background: #35373d; border-radius: 3px; white-space: nowrap; }.timeline-controls button:hover { background: #4a4d54; }.frame-number { min-width: 45px; margin-left: 10px; padding: 5px 8px; text-align: center; color: #fff; background: #181a1d; border: 1px solid #4a4d54; border-radius: 3px; }.frame-slider { width: 100%; accent-color: #e37a2c; }.timeline-scale { display: flex; justify-content: space-between; color: #7f858f; font-size: 11px; }.dope-sheet { display: grid; grid-template-columns: 100px minmax(120px, 1fr) 90px; align-items: center; gap: 8px; border-top: 1px solid #3a3d43; }.channel-name { overflow: hidden; color: #b8bcc4; text-overflow: ellipsis; white-space: nowrap; }.key-track { position: relative; height: 20px; background: #1e2024; border: 1px solid #373a40; }.key-dot { position: absolute; top: 5px; width: 9px; height: 9px; padding: 0; transform: translateX(-50%) rotate(45deg); background: #d6a348; border: 1px solid #f2c977; }.key-dot.active { background: #e36d2d; }.dope-sheet select { min-width: 0; color: #ddd; background: #292b30; border: 1px solid #484c53; }
.status-bar { gap: 16px; min-width: 0; min-height: 24px; overflow: hidden; color: #8f949c; font-size: 11px; border: 0; }.status-bar span:not(.status-spacer) { white-space: nowrap; }
.open-progress { display: inline-flex; align-items: center; gap: 5px; min-width: 0; max-width: 360px; }.open-progress progress { width: 72px; height: 8px; accent-color: #e37a2c; }.open-progress > span { overflow: hidden; text-overflow: ellipsis; }.open-progress button { width: 20px; height: 20px; padding: 0; color: #d6d8dc; background: #3a3c41; border-radius: 2px; }
.file-input-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.operator-search { position: fixed; top: 56px; left: 50%; z-index: 10; width: min(480px, calc(100vw - 24px)); padding: 8px; transform: translateX(-50%); background: #303238; border: 1px solid #545860; border-radius: 4px; box-shadow: 0 12px 30px #0008; }
.operator-search input { width: 100%; padding: 8px 10px; color: #f4f5f6; background: #1d1f22; border: 1px solid #5a5e66; border-radius: 3px; outline: none; }

View File

@@ -8,6 +8,7 @@ import type {
WebEngineRequest,
WebEngineResponse,
WebEngineResult,
WebEngineOpenResourceStatus,
WebEngineStatus,
} from "../../../protocol/web-engine";
import type { LODGenerationRequest } from "../../../protocol/lod";
@@ -25,6 +26,7 @@ interface PendingRequest {
reject: (error: ErrorReport) => void;
onProgress?: (progress: ProgressEvent) => void;
timer: ReturnType<typeof setTimeout>;
cleanup?: () => void;
}
export interface WebEngineClientOptions {
@@ -61,11 +63,11 @@ export class WebEngineClient {
return (await this.request({ type: "init" })).status;
}
async openBlend(buffer: ArrayBuffer, onProgress?: (progress: ProgressEvent) => void): Promise<BlendOpenResult> {
async openBlend(buffer: ArrayBuffer, onProgress?: (progress: ProgressEvent) => void, signal?: AbortSignal): Promise<BlendOpenResult> {
if (buffer.byteLength === 0) {
throw this.report("INVALID_ARGUMENT", "无法打开空的 .blend 文件", true);
}
const result = await this.request({ type: "openBlend", buffer }, [buffer], onProgress);
const result = await this.request({ type: "openBlend", buffer }, [buffer], onProgress, signal);
if (!result.snapshot) throw this.report("BLEND_READ_FAILED", "WebEngine 未返回 SceneIR", true);
this.geometryBuffers = result.geometryBuffers ?? [];
this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers ?? [];
@@ -146,6 +148,12 @@ export class WebEngineClient {
return result.blend;
}
async openResourceStatus(): Promise<WebEngineOpenResourceStatus> {
const result = await this.request({ type: "openResourceStatus" });
if (!result.openResources) throw this.report("INVALID_ARGUMENT", "WebEngine open resource status missing", true);
return result.openResources;
}
terminate(): void {
this.failPending(this.report("WORKER_TERMINATED", "WebEngineWorker 已关闭", true));
this.worker?.terminate();
@@ -169,16 +177,29 @@ export class WebEngineClient {
command: WebEngineRequest["command"],
transfer: Transferable[] = [],
onProgress?: (progress: ProgressEvent) => void,
signal?: AbortSignal,
): Promise<WebEngineResult> {
const worker = this.start();
const requestId = `web-engine-${++this.requestCounter}`;
const request = { requestId, command } as WebEngineRequest;
return new Promise<WebEngineResult>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.get(requestId)?.cleanup?.();
this.pending.delete(requestId);
reject(this.report("WORKER_TERMINATED", `WebEngine 请求超时: ${command.type}`, true));
}, this.timeoutMs);
this.pending.set(requestId, { resolve, reject, onProgress, timer });
const abort = (): void => {
worker.postMessage({
requestId: `web-engine-cancel-${++this.requestCounter}`,
command: { type: "cancelOpen", targetRequestId: requestId },
} satisfies WebEngineRequest);
};
const cleanup = signal ? () => signal.removeEventListener("abort", abort) : undefined;
this.pending.set(requestId, { resolve, reject, onProgress, timer, cleanup });
if (signal) {
signal.addEventListener("abort", abort, { once: true });
if (signal.aborted) abort();
}
worker.postMessage(request, transfer);
});
}
@@ -192,6 +213,7 @@ export class WebEngineClient {
}
this.pending.delete(response.requestId);
clearTimeout(pending.timer);
pending.cleanup?.();
if (response.ok) pending.resolve(response.result);
else pending.reject(response.error);
}
@@ -199,6 +221,7 @@ export class WebEngineClient {
private failPending(error: ErrorReport): void {
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.cleanup?.();
pending.reject(error);
}
this.pending.clear();

View File

@@ -0,0 +1,139 @@
import type { WebEngineVariantV2 } from "../../../protocol/manifest";
import type {
EngineVariantResourceState,
EngineVariantSession,
} from "./engine-variant-bootstrap";
import { EngineVariantLoadError } from "./engine-variant-bootstrap";
interface EmscriptenPThreadRuntime {
unusedWorkers: Worker[];
runningWorkers: Worker[];
terminateAllThreads(): void;
}
interface BrowserVariantModule {
HEAPU8: Uint8Array;
_malloc(size: number): number;
_free(pointer: number): void;
_web_engine_create(): number;
_web_engine_destroy(handle: number): void;
_web_engine_get_live_handles(): number;
_web_engine_open_blend(handle: number, data: number, length: number): number;
PThread?: EmscriptenPThreadRuntime;
}
type BrowserVariantFactory = (options: { wasmBinary: ArrayBuffer }) => Promise<BrowserVariantModule>;
async function verifiedBytes(url: string, expectedSha256: string, controllers: Set<AbortController>): Promise<ArrayBuffer> {
const controller = new AbortController();
controllers.add(controller);
try {
const response = await fetch(url, { cache: "no-store", signal: controller.signal });
if (!response.ok) {
throw new EngineVariantLoadError(
"ENGINE_VARIANT_RESOURCE_REQUEST_FAILED",
`${url} returned ${response.status}`,
);
}
const bytes = await response.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", bytes);
const actual = [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
if (actual !== expectedSha256) {
throw new EngineVariantLoadError("ENGINE_VARIANT_RESOURCE_HASH_MISMATCH", url);
}
return bytes;
}
finally {
controllers.delete(controller);
}
}
class BrowserEngineVariantSession implements EngineVariantSession<ArrayBuffer> {
readonly variant: WebEngineVariantV2;
private readonly module: BrowserVariantModule;
private readonly pendingRequests: Set<AbortController>;
private readonly timers = new Set<ReturnType<typeof setTimeout>>();
private handle: number;
constructor(
variant: WebEngineVariantV2,
module: BrowserVariantModule,
handle: number,
pendingRequests: Set<AbortController>,
) {
this.variant = variant;
this.module = module;
this.handle = handle;
this.pendingRequests = pendingRequests;
}
async openProject(project: ArrayBuffer): Promise<void> {
if (this.handle <= 0) throw new Error("ENGINE_VARIANT_SESSION_DISPOSED");
const pointer = this.module._malloc(project.byteLength);
if (project.byteLength > 0 && pointer <= 0) throw new Error("ENGINE_VARIANT_PROJECT_ALLOCATION_FAILED");
try {
if (project.byteLength > 0) this.module.HEAPU8.set(new Uint8Array(project), pointer);
const result = this.module._web_engine_open_blend(this.handle, pointer, project.byteLength);
if (result !== 0) throw new Error(`ENGINE_VARIANT_PROJECT_OPEN_FAILED: ${result}`);
}
finally {
if (pointer > 0) this.module._free(pointer);
}
}
resourceState(): EngineVariantResourceState {
const pthread = this.module.PThread;
return {
handles: this.handle > 0 ? this.module._web_engine_get_live_handles() : 0,
workers: pthread ? pthread.unusedWorkers.length + pthread.runningWorkers.length : 0,
timers: this.timers.size,
pendingRequests: this.pendingRequests.size,
};
}
testOnlySeedTrackedResources(): void {
this.pendingRequests.add(new AbortController());
const timer = setTimeout(() => this.timers.delete(timer), 60_000);
this.timers.add(timer);
}
async dispose(): Promise<EngineVariantResourceState> {
for (const controller of this.pendingRequests) controller.abort();
this.pendingRequests.clear();
for (const timer of this.timers) clearTimeout(timer);
this.timers.clear();
if (this.handle > 0) {
this.module._web_engine_destroy(this.handle);
this.handle = 0;
}
this.module.PThread?.terminateAllThreads();
return this.resourceState();
}
}
export async function createBrowserEngineVariantSession(
variant: WebEngineVariantV2,
): Promise<EngineVariantSession<ArrayBuffer>> {
const controllers = new Set<AbortController>();
const verifiedResources = new Map<string, ArrayBuffer>();
const verify = async (resource: { url: string; sha256: string }): Promise<ArrayBuffer> => {
const existing = verifiedResources.get(resource.url);
if (existing) return existing;
const bytes = await verifiedBytes(resource.url, resource.sha256, controllers);
verifiedResources.set(resource.url, bytes);
return bytes;
};
await verify(variant.resources.js);
const wasmBinary = await verify(variant.resources.wasm);
if (variant.id === "pthread") await verify(variant.resources.pthreadWorker);
const imported = await import(/* @vite-ignore */ variant.resources.js.url) as { default: BrowserVariantFactory };
const module = await imported.default({ wasmBinary });
const handle = module._web_engine_create();
if (handle <= 0) {
module.PThread?.terminateAllThreads();
throw new Error("ENGINE_VARIANT_HANDLE_CREATION_FAILED");
}
return new BrowserEngineVariantSession(variant, module, handle, controllers);
}

View File

@@ -0,0 +1,195 @@
import {
bindWebEngineRelease,
selectWebEngineVariant,
type WasmThreadingCapabilities,
type WebEngineReleaseBinding,
type WebEngineVariantPolicy,
} from "../../../protocol/engine-variant";
import type { CapabilityGateResult } from "../../../protocol/capability-gates";
import type { WebEngineManifestV2, WebEngineVariantV2 } from "../../../protocol/manifest";
export interface EngineVariantResourceState {
handles: number;
workers: number;
timers: number;
pendingRequests: number;
}
export interface EngineVariantSession<Project = ArrayBuffer> {
readonly variant: WebEngineVariantV2;
openProject(project: Project): Promise<void>;
dispose(): Promise<EngineVariantResourceState>;
resourceState(): EngineVariantResourceState;
testOnlySeedTrackedResources?(): void;
}
export interface EngineVariantFallbackReason {
code: "PTHREAD_INITIALIZATION_FAILED";
message: string;
}
export interface EngineVariantBootstrapResult {
status: "READY";
selected: "single" | "pthread";
attempted: Array<"single" | "pthread">;
fallbackReason: EngineVariantFallbackReason | null;
pthreadGate: CapabilityGateResult;
openCount: number;
failedAttemptCleanup: EngineVariantResourceState | null;
}
export interface EngineVariantBootstrapOutcome<Project = ArrayBuffer> {
result: EngineVariantBootstrapResult;
session: EngineVariantSession<Project>;
}
export type WebEngineRefreshRequired = Extract<WebEngineReleaseBinding, { status: "REFRESH_REQUIRED" }>;
export type WebEngineReleaseBootstrapOutcome<Project = ArrayBuffer> =
| EngineVariantBootstrapOutcome<Project>
| { result: WebEngineRefreshRequired; session: null };
export interface EngineVariantBootstrapDependencies<Project = ArrayBuffer> {
initialize(variant: WebEngineVariantV2): Promise<EngineVariantSession<Project>>;
}
export interface EngineVariantBootstrapTestHooks {
failPthreadAfterInitialize?: boolean;
seedTrackedPthreadResources?: boolean;
}
export class EngineVariantLoadError extends Error {
readonly code: "ENGINE_VARIANT_RESOURCE_REQUEST_FAILED" | "ENGINE_VARIANT_RESOURCE_HASH_MISMATCH";
constructor(code: EngineVariantLoadError["code"], message: string, options?: ErrorOptions) {
super(message, options);
this.name = "EngineVariantLoadError";
this.code = code;
}
}
export class EngineVariantBootstrapError extends Error {
readonly code: "ENGINE_VARIANT_INTEGRITY_FAILED";
readonly attempted: Array<"single" | "pthread">;
constructor(attempted: Array<"single" | "pthread">, cause: EngineVariantLoadError) {
super(`${cause.code}: ${cause.message}`, { cause });
this.name = "EngineVariantBootstrapError";
this.code = "ENGINE_VARIANT_INTEGRITY_FAILED";
this.attempted = [...attempted];
}
}
function failureMessage(error: unknown): string {
return error instanceof Error && error.message ? error.message : "pthread engine initialization failed";
}
function rejectIntegrityFailure(
error: unknown,
attempted: Array<"single" | "pthread">,
): void {
if (error instanceof EngineVariantLoadError && error.code === "ENGINE_VARIANT_RESOURCE_HASH_MISMATCH") {
throw new EngineVariantBootstrapError(attempted, error);
}
}
async function bootstrapSelectedWebEngineVariant<Project = ArrayBuffer>(
manifest: WebEngineManifestV2,
policy: WebEngineVariantPolicy,
capabilities: WasmThreadingCapabilities,
dependencies: EngineVariantBootstrapDependencies<Project>,
pendingProject?: Project,
testHooks?: EngineVariantBootstrapTestHooks,
): Promise<EngineVariantBootstrapOutcome<Project>> {
const selection = selectWebEngineVariant(manifest, policy, capabilities);
if (!selection.selectedVariant) throw new Error("PTHREAD_REQUIRED_CAPABILITY_BLOCKED");
const attempted: Array<"single" | "pthread"> = [];
let failedAttemptCleanup: EngineVariantResourceState | null = null;
let fallbackReason: EngineVariantFallbackReason | null = null;
let session: EngineVariantSession<Project> | null = null;
const initialize = async (variant: WebEngineVariantV2): Promise<EngineVariantSession<Project>> => {
attempted.push(variant.id);
return dependencies.initialize(variant);
};
try {
session = await initialize(selection.selectedVariant);
if (selection.selectedVariant.id === "pthread") {
if (testHooks?.seedTrackedPthreadResources) session.testOnlySeedTrackedResources?.();
if (testHooks?.failPthreadAfterInitialize) throw new Error("TEST_PTHREAD_INITIALIZATION_FAILURE");
}
}
catch (error) {
const failedSession = session;
if (failedSession) failedAttemptCleanup = await failedSession.dispose();
rejectIntegrityFailure(error, attempted);
if (policy !== "AUTO" || selection.selectedVariant.id !== "pthread") throw error;
fallbackReason = { code: "PTHREAD_INITIALIZATION_FAILED", message: failureMessage(error) };
const single = manifest.variants.find((variant) => variant.id === "single");
if (!single) throw new Error("ENGINE_MANIFEST_VARIANTS_MISSING", { cause: error });
try {
session = await initialize(single);
}
catch (fallbackError) {
rejectIntegrityFailure(fallbackError, attempted);
throw fallbackError;
}
}
if (!session) throw new Error("ENGINE_VARIANT_INITIALIZATION_FAILED");
let openCount = 0;
if (pendingProject !== undefined) {
try {
await session.openProject(pendingProject);
openCount = 1;
}
catch (error) {
await session.dispose();
throw error;
}
}
return {
result: {
status: "READY",
selected: session.variant.id,
attempted,
fallbackReason,
pthreadGate: selection.pthreadGate,
openCount,
failedAttemptCleanup,
},
session,
};
}
export async function bootstrapWebEngineRelease<Project = ArrayBuffer>(
expectedReleaseId: string,
manifest: WebEngineManifestV2,
policy: WebEngineVariantPolicy,
capabilities: WasmThreadingCapabilities,
dependencies: EngineVariantBootstrapDependencies<Project>,
pendingProject?: Project,
testHooks?: EngineVariantBootstrapTestHooks,
): Promise<WebEngineReleaseBootstrapOutcome<Project>> {
const binding = bindWebEngineRelease(expectedReleaseId, manifest);
if (binding.status === "REFRESH_REQUIRED") {
return { result: binding, session: null };
}
return bootstrapSelectedWebEngineVariant(
binding.manifest,
policy,
capabilities,
dependencies,
pendingProject,
testHooks,
);
}
export function engineVariantStatusLabel(result: EngineVariantBootstrapResult): string {
if (result.selected === "pthread") return "Runtime: pthread";
return result.fallbackReason
? `Runtime: single (pthread fallback: ${result.fallbackReason.code})`
: "Runtime: single";
}

View File

@@ -1,10 +1,8 @@
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "../../../protocol/capability-gates";
export interface WasmThreadingCapabilities {
crossOriginIsolated: boolean;
sharedArrayBuffer: boolean;
worker: boolean;
}
import {
type WasmThreadingCapabilities,
} from "../../../protocol/engine-variant";
export { gateWasmThreadingCapability, selectWebEngineVariant } from "../../../protocol/engine-variant";
export type { WasmThreadingCapabilities } from "../../../protocol/engine-variant";
export interface BrowserCapabilities extends WasmThreadingCapabilities {
webgl2: boolean;
@@ -17,34 +15,6 @@ export interface BrowserCapabilities extends WasmThreadingCapabilities {
indexedDb: boolean;
}
export function gateWasmThreadingCapability(capabilities: WasmThreadingCapabilities): CapabilityGateResult {
const issues = [];
if (!capabilities.crossOriginIsolated) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"Cross-origin isolation is required for the pthread WASM engine",
"crossOriginIsolated",
));
}
if (!capabilities.sharedArrayBuffer) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"SharedArrayBuffer is required for the pthread WASM engine",
"sharedArrayBuffer",
));
}
if (!capabilities.worker) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"Worker is required for the pthread WASM engine",
"worker",
));
}
return issues.length > 0
? blockedGate("M6-02", "WASM_PTHREAD_ENGINE", issues)
: readyGate("M6-02", "WASM_PTHREAD_ENGINE");
}
function supportsWasmFeature(feature: "simd" | "threads"): boolean {
if (typeof WebAssembly === "undefined") return false;

View File

@@ -43,7 +43,7 @@ export class StorageClient {
return this.request({ type: "ensureProject", projectId }) as Promise<StorageProjectResult>;
}
saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: "after-stage" | "after-scene-commit" | "quota"): Promise<StorageSaveResult> {
saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: "after-stage" | "after-scene-commit" | "before-metadata-commit" | "quota"): Promise<StorageSaveResult> {
return this.request({ type: "saveProject", projectId, revision, buffer, faultAt }, [buffer]) as Promise<StorageSaveResult>;
}

View File

@@ -1,6 +1,7 @@
const PROJECT_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
export const OPFS_PROJECT_SCHEMA_VERSION = 1;
export interface OpfsProjectLayout {
projectId: string;
@@ -52,7 +53,7 @@ type OpfsStorage = StorageManager & { getDirectory?: () => Promise<FileSystemDir
export type ProjectSaveFault = "after-stage" | "after-scene-commit";
export interface ProjectBlendManifest {
schemaVersion: 1;
schemaVersion: typeof OPFS_PROJECT_SCHEMA_VERSION;
projectId: string;
revision: number;
bytes: number;
@@ -140,7 +141,7 @@ async function verifyFile(directory: FileSystemDirectoryHandle, name: string, by
}
function validateManifest(value: ProjectBlendManifest | undefined, projectId: string): ProjectBlendManifest | undefined {
if (!value || value.schemaVersion !== 1 || value.projectId !== projectId ||
if (!value || value.schemaVersion !== OPFS_PROJECT_SCHEMA_VERSION || value.projectId !== projectId ||
!Number.isInteger(value.revision) || value.revision < 0 ||
!Number.isInteger(value.bytes) || value.bytes <= 0 ||
!SHA256_PATTERN.test(value.sha256) || typeof value.committedAt !== "string") {
@@ -150,7 +151,7 @@ function validateManifest(value: ProjectBlendManifest | undefined, projectId: st
}
function validateJournal(value: ProjectBlendJournal | undefined, projectId: string): ProjectBlendJournal {
if (!value || value.schemaVersion !== 1 || value.projectId !== projectId ||
if (!value || value.schemaVersion !== OPFS_PROJECT_SCHEMA_VERSION || value.projectId !== projectId ||
!Number.isInteger(value.revision) || value.revision < 0 ||
!Number.isInteger(value.bytes) || value.bytes <= 0 ||
!SHA256_PATTERN.test(value.sha256) || typeof value.createdAt !== "string" ||
@@ -176,7 +177,7 @@ async function finalizeBlendCommit(
journal: ProjectBlendJournal,
): Promise<ProjectBlendManifest> {
const manifest: ProjectBlendManifest = {
schemaVersion: 1,
schemaVersion: OPFS_PROJECT_SCHEMA_VERSION,
projectId: journal.projectId,
revision: journal.revision,
bytes: journal.bytes,
@@ -266,7 +267,7 @@ export async function writeProjectBlend(
const stageName = `scene.blend.${crypto.randomUUID()}.stage`;
const sha256 = await sha256Hex(data);
const journal: ProjectBlendJournal = {
schemaVersion: 1,
schemaVersion: OPFS_PROJECT_SCHEMA_VERSION,
projectId,
revision,
bytes: data.byteLength,

View File

@@ -199,12 +199,12 @@ async function ensureProject(projectId: string): Promise<StorageProjectResult> {
};
}
async function saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: ProjectSaveFault | "quota"): Promise<StorageSaveResult> {
async function saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: ProjectSaveFault | "before-metadata-commit" | "quota"): Promise<StorageSaveResult> {
if (faultAt === "quota") throw new Error("QuotaExceededError: injected OPFS quota exhaustion");
const useOpfs = await useOpfsForProject(projectId);
const layout = useOpfs ? await ensureProjectLayout(projectId) : projectLayout(projectId);
const sha256 = await sha256Hex(buffer);
const existing = faultAt ? undefined : await readProjectRow(projectId);
const existing = await readProjectRow(projectId);
if (existing && existing.revision > revision) {
throw new Error(`PROJECT_REVISION_CONFLICT: committed revision ${existing.revision} is newer than ${revision}`);
}
@@ -232,8 +232,24 @@ async function saveProject(projectId: string, revision: number, buffer: ArrayBuf
};
}
if (useOpfs) {
const committed = await writeProjectBlend(projectId, revision, buffer, undefined, faultAt);
if (committed.manifest.sha256 !== sha256) throw new Error("Project commit digest mismatch");
const previousBuffer = faultAt && existing ? await readProjectBlend(projectId) : undefined;
try {
const opfsFault = faultAt === "after-stage" || faultAt === "after-scene-commit" ? faultAt : undefined;
const committed = await writeProjectBlend(projectId, revision, buffer, undefined, opfsFault);
if (committed.manifest.sha256 !== sha256) throw new Error("Project commit digest mismatch");
if (faultAt === "before-metadata-commit") {
throw new Error("PROJECT_SAVE_FAULT_INJECTED: before-metadata-commit");
}
}
catch (error) {
if (faultAt && existing && previousBuffer) {
const rollback = await writeProjectBlend(projectId, existing.revision, previousBuffer);
if (rollback.manifest.sha256 !== existing.sha256) {
throw new Error("PROJECT_SAVE_ROLLBACK_FAILED: old committed hash was not restored");
}
}
throw error;
}
}
else if (faultAt) {
throw new Error("PROJECT_SAVE_FAULT_UNAVAILABLE: IndexedDB commits are already transactional");

View File

@@ -1,6 +1,6 @@
import type { ErrorReport } from "../../../protocol/error";
import { parseSceneDelta, type SceneDelta } from "../../../protocol/scene-delta";
import type { MeshGeometryBuffer, WebEngineLODError, WebEngineLODLevelResult, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine";
import type { MeshGeometryBuffer, WebEngineLODError, WebEngineLODLevelResult, WebEngineOpenResourceStatus, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine";
import { parseSceneSnapshotIR } from "../../../protocol/scene-ir";
import { parseLODManifest, type SimplifyResult } from "../../../protocol/simplify";
import type { LODGenerationRequest } from "../../../protocol/lod";
@@ -95,6 +95,21 @@ let currentSnapshot: ReturnType<typeof parseSceneSnapshotIR> | null = null;
let currentGeometryBuffers: MeshGeometryBuffer[] = [];
let sourceBlendBuffer: ArrayBuffer | null = null;
let sourceBlendRevision = -1;
const cancelledOpenRequests = new Set<string>();
const activeOpenResources = new Map<string, { liveInputBytes: number; liveNativeHandles: number; liveStagingFiles: number }>();
function openResourceStatus(): WebEngineOpenResourceStatus {
return [...activeOpenResources.values()].reduce<WebEngineOpenResourceStatus>((status, item) => ({
activeRequests: status.activeRequests + 1,
liveInputBytes: status.liveInputBytes + item.liveInputBytes,
liveNativeHandles: status.liveNativeHandles + item.liveNativeHandles,
liveStagingFiles: status.liveStagingFiles + item.liveStagingFiles,
}), { activeRequests: 0, liveInputBytes: 0, liveNativeHandles: 0, liveStagingFiles: 0 });
}
function throwIfOpenCancelled(requestId: string): void {
if (cancelledOpenRequests.has(requestId)) throw report("OPEN_CANCELLED", "Blend open cancelled before Main commit");
}
function status(): WebEngineStatus {
return {
@@ -470,6 +485,15 @@ function nativeError(fallbackCode: ErrorReport["code"]): ErrorReport {
return report(mappedCode, message);
}
function nativeErrorFor(target: WasmModule, fallbackCode: ErrorReport["code"]): ErrorReport {
const code = target._web_engine_last_error_code() ?? 0;
const message = target._web_engine_last_error_message() !== 0
? target.UTF8ToString(target._web_engine_last_error_message())
: "WebEngine native call failed";
const mappedCode: ErrorReport["code"] = code === -4 ? "BLEND_READ_FAILED" : code === -5 ? "BLEND_WRITE_FAILED" : fallbackCode;
return report(mappedCode, message);
}
async function initialize(): Promise<WebEngineStatus> {
if (module) return status();
if (!initializationPromise) {
@@ -921,8 +945,8 @@ async function evaluateIsolatedDecimate(
}
}
function progress(requestId: string, phase: "started" | "progress" | "completed", fraction: number, message: string): void {
scope.postMessage({ kind: "progress", requestId, progress: { requestId, operation: "blend.open", phase, fraction, message } });
function progress(requestId: string, phase: "started" | "progress" | "completed", fraction: number, message: string, stage?: string, cancellable?: boolean): void {
scope.postMessage({ kind: "progress", requestId, progress: { requestId, operation: "blend.open", phase, fraction, message, stage, cancellable } });
}
scope.onmessage = async (event) => {
@@ -936,27 +960,74 @@ scope.onmessage = async (event) => {
result = baseResult();
break;
case "openBlend": {
progress(request.requestId, "started", 0, "Reading .blend");
await initialize();
const sourceCopy = request.command.buffer.slice(0);
const input = copyIntoWasm(request.command.buffer);
const resource = { liveInputBytes: request.command.buffer.byteLength, liveNativeHandles: 0, liveStagingFiles: 0 };
activeOpenResources.set(request.requestId, resource);
let candidateModule: WasmModule | null = null;
let candidateHandle = 0;
try {
if (!module || module._web_engine_open_blend(handle, input.pointer, input.length) !== 0) {
throw nativeError("BLEND_READ_FAILED");
progress(request.requestId, "started", 0, "Preparing isolated Main", "NATIVE_INITIALIZE", true);
await initialize();
throwIfOpenCancelled(request.requestId);
if (!wasmFactory || !wasmBinary) throw report("WASM_INIT_FAILED", "WebEngine isolated open module is unavailable");
candidateModule = await wasmFactory({ wasmBinary: wasmBinary.slice(0) });
throwIfOpenCancelled(request.requestId);
candidateHandle = candidateModule._web_engine_create();
if (candidateHandle <= 0) throw report("WASM_INIT_FAILED", "WebEngine isolated open handle creation failed");
resource.liveNativeHandles = 1;
const pointer = candidateModule._malloc(request.command.buffer.byteLength);
if (!pointer) throw report("WASM_OUT_OF_MEMORY", "WebEngine could not allocate isolated blend input");
try {
candidateModule.HEAPU8.set(new Uint8Array(request.command.buffer), pointer);
if (candidateModule._web_engine_open_blend(candidateHandle, pointer, request.command.buffer.byteLength) !== 0) {
throw nativeErrorFor(candidateModule, "BLEND_READ_FAILED");
}
}
finally {
candidateModule._free(pointer);
resource.liveInputBytes = 0;
}
progress(request.requestId, "progress", 0.7, "Isolated Main opened", "NATIVE_OPENED", true);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
throwIfOpenCancelled(request.requestId);
const previousModule = module;
const previousHandle = handle;
module = candidateModule;
handle = candidateHandle;
try {
progress(request.requestId, "progress", 0.8, "Building SceneIR", "COMMITTING", false);
const scene = await readSnapshotResult();
throwIfOpenCancelled(request.requestId);
const sourceCopy = request.command.buffer.slice(0);
currentSnapshot = scene.snapshot;
sourceBlendBuffer = sourceCopy;
sourceBlendRevision = scene.snapshot.revision;
result = { status: status(), ...publishFullScene(scene) };
}
catch (error) {
module = previousModule;
handle = previousHandle;
throw error;
}
previousModule?._web_engine_destroy(previousHandle);
candidateHandle = 0;
resource.liveNativeHandles = 0;
progress(request.requestId, "completed", 1, "SceneIR ready", "COMPLETED", false);
}
finally {
module?._free(input.pointer);
if (candidateModule && candidateHandle > 0) candidateModule._web_engine_destroy(candidateHandle);
activeOpenResources.delete(request.requestId);
cancelledOpenRequests.delete(request.requestId);
}
progress(request.requestId, "progress", 0.8, "Building SceneIR");
const scene = await readSnapshotResult();
currentSnapshot = scene.snapshot;
sourceBlendBuffer = sourceCopy;
sourceBlendRevision = scene.snapshot.revision;
result = { status: status(), ...publishFullScene(scene) };
progress(request.requestId, "completed", 1, "SceneIR ready");
break;
}
break;
}
case "cancelOpen":
if (activeOpenResources.has(request.command.targetRequestId)) cancelledOpenRequests.add(request.command.targetRequestId);
result = { status: status(), openResources: openResourceStatus() };
break;
case "openResourceStatus":
result = { status: status(), openResources: openResourceStatus() };
break;
case "snapshot":
await initialize();
{

View File

@@ -11,6 +11,9 @@ const deploymentContract = JSON.parse(
fs.readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../docs/web/deployment-contract.json"), "utf8"),
) as { responseHeaders: { allResponses: Record<string, string> } };
const isolationHeaders = deploymentContract.responseHeaders.allResponses;
const runtimeEnvironment = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;
const unisolatedSingleThreadTest = runtimeEnvironment?.WEB_TEST_UNISOLATED_SINGLE_THREAD === "1";
const developmentHeaders = unisolatedSingleThreadTest ? {} : isolationHeaders;
function preserveIsolationHeaders(): Plugin {
const install = (server: { middlewares: { use: (handler: (request: unknown, response: { setHeader: (name: string, value: string) => void }, next: () => void) => void) => void } }) => {
@@ -26,8 +29,31 @@ function preserveIsolationHeaders(): Plugin {
};
}
function serveEngineVariantModuleImports(): Plugin {
const vendorRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "public/vendor/blender");
const install = (server: { middlewares: { use: (handler: (request: unknown, response: { setHeader: (name: string, value: string | number) => void }, next: () => void) => void) => void } }) => {
server.middlewares.use((request, response, next) => {
const url = new URL((request as { url?: string }).url ?? "/", "http://vite.local");
const match = url.pathname.match(/^\/vendor\/blender\/(single|pthread)\/web_engine\.js$/);
if (!match || !url.searchParams.has("import")) { next(); return; }
const filePath = path.join(vendorRoot, match[1], "web_engine.js");
if (!fs.existsSync(filePath)) { next(); return; }
const stat = fs.statSync(filePath);
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
response.setHeader("Content-Length", stat.size);
for (const [name, value] of Object.entries(developmentHeaders)) response.setHeader(name, value);
fs.createReadStream(filePath).pipe(response as never);
});
};
return {
name: "serve-engine-variant-module-imports",
configureServer: install,
configurePreviewServer: install,
};
}
function localVDBFixture(): Plugin {
const resourceRoot = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.VDB_RESOURCE_ROOT ?? "/home/mes123456/resource-library/blender-web-vdb";
const resourceRoot = runtimeEnvironment?.VDB_RESOURCE_ROOT ?? path.join(runtimeEnvironment?.HOME ?? "", "resource-library/blender-web-vdb");
const files = new Map([
["/__vdb_fixture__/manifest", { path: path.join(resourceRoot, "manifests/generated-smoke.nanovdb.json"), type: "application/json" }],
["/__vdb_fixture__/report", { path: path.join(resourceRoot, "reports/generated-smoke-conversion.json"), type: "application/json" }],
@@ -70,18 +96,19 @@ function localVDBFixture(): Plugin {
export default defineConfig({
root: "app",
plugins: [preserveIsolationHeaders(), localVDBFixture(), react()],
plugins: [
serveEngineVariantModuleImports(),
...(unisolatedSingleThreadTest ? [] : [preserveIsolationHeaders()]),
localVDBFixture(),
react(),
],
server: {
port: 5173,
strictPort: false,
headers: {
...isolationHeaders,
},
headers: developmentHeaders,
},
preview: {
headers: {
...isolationHeaders,
},
headers: developmentHeaders,
},
build: {
outDir: "../dist",

View File

@@ -25,8 +25,12 @@ target_include_directories(blender_web_engine_core PUBLIC
add_executable(web_engine web_engine_smoke.cpp)
target_link_libraries(web_engine PRIVATE blender_web_engine_core)
set(WEB_ENGINE_EXPORTED_RUNTIME_METHODS "['ccall','cwrap']")
if(WEB_ENGINE_THREADS)
set(WEB_ENGINE_EXPORTED_RUNTIME_METHODS "['ccall','cwrap','PThread']")
endif()
target_link_options(web_engine PRIVATE
"-sEXPORTED_FUNCTIONS=['_malloc','_free','_web_engine_create','_web_engine_destroy','_web_engine_get_memory_stats','_web_engine_get_live_handles','_web_engine_get_allocated_bytes','_web_engine_open_blend','_web_engine_apply_command','_web_engine_get_scene_snapshot','_web_engine_save_blend','_web_engine_free_buffer','_web_engine_last_error_code','_web_engine_last_error_message']"
"-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap']"
"-sEXPORTED_RUNTIME_METHODS=${WEB_ENGINE_EXPORTED_RUNTIME_METHODS}"
)
set_target_properties(web_engine PROPERTIES OUTPUT_NAME "web_engine")

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "blender-web-editor",
"version": "0.1.0",
"version": "0.1.0-rc.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "blender-web-editor",
"version": "0.1.0",
"version": "0.1.0-rc.1",
"dependencies": {
"react": "19.2.8",
"react-dom": "19.2.8"

View File

@@ -1,7 +1,7 @@
{
"name": "blender-web-editor",
"private": true,
"version": "0.1.0",
"version": "0.1.0-rc.1",
"type": "module",
"scripts": {
"dev": "vite --config app/vite.config.ts",
@@ -10,6 +10,25 @@
"lint": "eslint app",
"check:local-deps": "bash ../tools/web/check-local-deps.sh",
"test": "node --test tests/**/*.test.mjs",
"test:engine-manifest-v2": "node --test tests/unit/engine-manifest-v2.test.mjs",
"test:engine-variant-install": "node ../tools/web/check-engine-variant-install.mjs",
"test:engine-variant-selection": "node --test tests/unit/engine-variant-selection.test.mjs",
"test:engine-variant-fallback": "node --test tests/unit/engine-variant-fallback.test.mjs",
"test:engine-upgrade-safety": "node --test --test-name-pattern=M6-08 tests/unit/engine-variant-selection.test.mjs tests/unit/engine-variant-fallback.test.mjs && playwright test --config playwright.config.ts tests/e2e/engine-upgrade-safety.spec.ts",
"test:user-actions": "node --test tests/unit/user-action-state.test.mjs tests/unit/project-action-mutex.test.mjs tests/unit/file-byte-reader.test.mjs tests/unit/save-transaction.test.mjs tests/unit/dirty-state.test.mjs && playwright test --config playwright.config.ts tests/e2e/user-action-state.spec.ts tests/e2e/project-action-mutex.spec.ts tests/e2e/file-import-progress.spec.ts tests/e2e/save-interruption.spec.ts tests/e2e/dirty-state.spec.ts",
"test:deployment-http": "node ../tools/web/check-deployment-http.mjs",
"test:deployment-runbook": "node ../tools/web/check-deployment-runbook.mjs",
"test:upgrade-runbook": "node ../tools/web/check-upgrade-runbook.mjs",
"test:rollback-runbook": "node ../tools/web/check-rollback-runbook.mjs",
"test:operations-diagnostics": "node ../tools/web/check-operations-diagnostics.mjs",
"test:operations-rehearsal": "node ../tools/web/run-operations-rehearsal.mjs",
"test:deployment-cache": "npm run build && node ../tools/web/check-deployment-cache.mjs",
"test:archive-offline": "npm run build && node ../tools/web/create-offline-release.mjs && playwright test --config playwright.archive.config.ts",
"test:binary-archive": "node ../tools/web/check-binary-archive.mjs",
"test:source-archive": "node ../tools/web/check-source-archive.mjs",
"test:single-thread-unisolated": "playwright test --config playwright.single-thread.config.ts",
"test:pthread-engine": "playwright test --config playwright.config.ts tests/e2e/pthread-engine-isolated.spec.ts",
"test:pthread-fallback": "playwright test --config playwright.config.ts tests/e2e/pthread-fallback.spec.ts",
"test:e2e": "playwright test --config playwright.config.ts",
"test:capability-gates": "playwright test --config playwright.config.ts -g \"undeclared capability protocols\"",
"test:simulation-cache": "playwright test --config playwright.config.ts -g \"Simulation caches\"",
@@ -17,6 +36,7 @@
"test:network-interruption": "playwright test --config playwright.config.ts tests/e2e/network-interruption.spec.ts",
"test:device-loss": "playwright test --config playwright.config.ts tests/e2e/device-loss.spec.ts",
"test:oom-recovery": "playwright test --config playwright.config.ts tests/e2e/oom-recovery.spec.ts",
"test:storage-quota": "playwright test --config playwright.config.ts -g \"real OPFS quota\"",
"test:texture-4k-performance": "playwright test --config playwright.config.ts tests/e2e/texture-4k-performance.spec.ts",
"test:texture-8k-performance": "playwright test --config playwright.config.ts tests/e2e/texture-8k-performance.spec.ts",
"test:physics-main-reader": "node ../tools/web/check-physics-main-reader.mjs",
@@ -88,8 +108,19 @@
"test:v1-acceptance-evidence": "node ../tools/web/check-v1-acceptance-evidence.mjs",
"test:v1-acceptance-release-binding": "node ../tools/web/check-v1-acceptance-release-binding.mjs",
"test:status-consistency": "node ../tools/web/check-status-consistency.mjs",
"test:ci-report": "node ../tools/web/check-ci-report.mjs",
"test:ci-failure-report": "node ../tools/web/check-ci-failure-report.mjs",
"ci:quick": "node ../tools/web/run-ci-lane.mjs quick",
"ci:chromium": "node ../tools/web/run-ci-lane.mjs chromium",
"ci:release": "node ../tools/web/run-ci-lane.mjs release",
"release:v1-acceptance": "node ../tools/web/run-v1-acceptance.mjs",
"release:offline": "npm run build && node ../tools/web/check-offline-reproducibility.mjs",
"release:rc-manifest": "node ../tools/web/create-rc-manifest.mjs && node ../tools/web/check-rc-manifest.mjs",
"test:rc-manifest": "node ../tools/web/check-rc-manifest.mjs",
"test:rc-release-notes": "node ../tools/web/check-rc-release-notes.mjs",
"test:rc-known-limitations": "node ../tools/web/check-rc-known-limitations.mjs",
"test:rc-release-recovery": "node ../tools/web/check-rc-release-recovery.mjs",
"test:rc-docs": "npm run test:rc-release-notes && npm run test:rc-known-limitations && npm run test:rc-release-recovery",
"diagnose:depsgraph": "node ../tools/web/diagnose-depsgraph.mjs"
},
"dependencies": {

View File

@@ -0,0 +1,42 @@
import path from "node:path";
import { execFileSync } from "node:child_process";
import { defineConfig, devices } from "@playwright/test";
function resolveChromePath(): string | undefined {
if (process.env.CHROME_PATH) return process.env.CHROME_PATH;
try { return execFileSync("which", ["google-chrome"], { encoding: "utf8" }).trim() || undefined; }
catch { return undefined; }
}
const chromePath = resolveChromePath();
const testPort = Number.parseInt(process.env.WEB_TEST_PORT ?? "5189", 10) || 5189;
const testOrigin = `http://127.0.0.1:${testPort}`;
const archive = path.resolve(process.env.M6_BINARY_ARCHIVE ?? "../release/blender-web-offline.tar.gz");
export default defineConfig({
testDir: "tests/e2e",
testMatch: "archive-offline.spec.ts",
timeout: 180_000,
expect: { timeout: 30_000 },
fullyParallel: false,
workers: 1,
reporter: [["list"]],
use: {
baseURL: testOrigin,
...devices["Desktop Chrome"],
acceptDownloads: true,
headless: true,
launchOptions: {
...(chromePath ? { executablePath: chromePath } : {}),
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
},
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
webServer: {
command: `node ../tools/web/archive-deployment-server.mjs --archive ${JSON.stringify(archive)} --port ${testPort}`,
url: testOrigin,
reuseExistingServer: false,
timeout: 60_000,
},
});

View File

@@ -1,11 +1,19 @@
import { defineConfig, devices } from "@playwright/test";
import { execFileSync } from "node:child_process";
const chromePath = process.env.CHROME_PATH ?? "/home/mes123456/.local/bin/google-chrome";
function resolveChromePath(): string | undefined {
if (process.env.CHROME_PATH) return process.env.CHROME_PATH;
try { return execFileSync("which", ["google-chrome"], { encoding: "utf8" }).trim() || undefined; }
catch { return undefined; }
}
const chromePath = resolveChromePath();
const testPort = Number.parseInt(process.env.WEB_TEST_PORT ?? "5173", 10) || 5173;
const testOrigin = `http://127.0.0.1:${testPort}`;
export default defineConfig({
testDir: "tests/e2e",
testIgnore: ["archive-offline.spec.ts", "single-thread-unisolated.spec.ts"],
timeout: 30_000,
expect: { timeout: 10_000 },
fullyParallel: false,
@@ -15,7 +23,7 @@ export default defineConfig({
...devices["Desktop Chrome"],
headless: true,
launchOptions: {
executablePath: chromePath,
...(chromePath ? { executablePath: chromePath } : {}),
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader", "--enable-unsafe-webgpu", "--enable-dawn-features=allow_unsafe_apis", "--use-webgpu-adapter=swiftshader"],
},
screenshot: "only-on-failure",

View File

@@ -1,4 +1,13 @@
import { defineConfig, devices } from "@playwright/test";
import { execFileSync } from "node:child_process";
function resolveChromePath(): string | undefined {
if (process.env.CHROME_PATH) return process.env.CHROME_PATH;
try { return execFileSync("which", ["google-chrome"], { encoding: "utf8" }).trim() || undefined; }
catch { return undefined; }
}
const chromePath = resolveChromePath();
const testPort = Number.parseInt(process.env.WEB_TEST_PORT ?? "5174", 10) || 5174;
const testOrigin = `http://127.0.0.1:${testPort}`;
@@ -11,7 +20,7 @@ export default defineConfig({
fullyParallel: false,
reporter: [["list"]],
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"], launchOptions: { executablePath: process.env.CHROME_PATH ?? "/home/mes123456/.local/bin/google-chrome", args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] } } },
{ name: "chromium", use: { ...devices["Desktop Chrome"], launchOptions: { ...(chromePath ? { executablePath: chromePath } : {}), args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] } } },
],
use: {
baseURL: testOrigin,

View File

@@ -0,0 +1,39 @@
import { defineConfig, devices } from "@playwright/test";
import { execFileSync } from "node:child_process";
function resolveChromePath(): string | undefined {
if (process.env.CHROME_PATH) return process.env.CHROME_PATH;
try { return execFileSync("which", ["google-chrome"], { encoding: "utf8" }).trim() || undefined; }
catch { return undefined; }
}
const chromePath = resolveChromePath();
const testPort = Number.parseInt(process.env.WEB_TEST_PORT ?? "5202", 10) || 5202;
const testOrigin = `http://127.0.0.1:${testPort}`;
export default defineConfig({
testDir: "tests/e2e",
testMatch: "single-thread-unisolated.spec.ts",
timeout: 45_000,
expect: { timeout: 20_000 },
fullyParallel: false,
reporter: [["list"]],
use: {
baseURL: testOrigin,
...devices["Desktop Chrome"],
headless: true,
launchOptions: {
...(chromePath ? { executablePath: chromePath } : {}),
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
},
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
webServer: {
command: `WEB_TEST_UNISOLATED_SINGLE_THREAD=1 npm run dev -- --host 127.0.0.1 --port ${testPort} --strictPort`,
cwd: import.meta.dirname,
url: testOrigin,
reuseExistingServer: false,
timeout: 30_000,
},
});

View File

@@ -0,0 +1,53 @@
export interface DirtyState {
currentMainRevision: number;
committedMainRevision: number;
dirty: boolean;
}
export type DirtyStateResult =
| { ok: true; state: DirtyState }
| { ok: false; state: DirtyState; errorCode: "DIRTY_REVISION_INVALID" | "DIRTY_REVISION_STALE" | "DIRTY_SAVE_REVISION_MISMATCH" };
function validRevision(revision: number): boolean {
return Number.isSafeInteger(revision) && revision >= 0;
}
export function createDirtyState(revision = 0): DirtyState {
if (!validRevision(revision)) throw new Error("DIRTY_REVISION_INVALID");
return { currentMainRevision: revision, committedMainRevision: revision, dirty: false };
}
export function recoverDirtyState(currentMainRevision: number, committedMainRevision: number): DirtyState {
if (!validRevision(currentMainRevision) || !validRevision(committedMainRevision) || currentMainRevision < committedMainRevision) {
throw new Error("DIRTY_REVISION_INVALID");
}
return { currentMainRevision, committedMainRevision, dirty: currentMainRevision !== committedMainRevision };
}
export function acceptMainTransaction(state: DirtyState, revision: number): DirtyStateResult {
if (!validRevision(revision)) return { ok: false, state, errorCode: "DIRTY_REVISION_INVALID" };
if (revision <= state.currentMainRevision) return { ok: false, state, errorCode: "DIRTY_REVISION_STALE" };
return {
ok: true,
state: { ...state, currentMainRevision: revision, dirty: revision !== state.committedMainRevision },
};
}
export function acceptMainSave(state: DirtyState, revision: number): DirtyStateResult {
if (!validRevision(revision)) return { ok: false, state, errorCode: "DIRTY_REVISION_INVALID" };
if (revision !== state.currentMainRevision) return { ok: false, state, errorCode: "DIRTY_SAVE_REVISION_MISMATCH" };
return { ok: true, state: { currentMainRevision: revision, committedMainRevision: revision, dirty: false } };
}
export function acceptHistoryTransaction(state: DirtyState, revision: number, matchesCommittedContent: boolean): DirtyStateResult {
if (!validRevision(revision)) return { ok: false, state, errorCode: "DIRTY_REVISION_INVALID" };
if (revision <= state.currentMainRevision) return { ok: false, state, errorCode: "DIRTY_REVISION_STALE" };
return {
ok: true,
state: {
currentMainRevision: revision,
committedMainRevision: matchesCommittedContent ? revision : state.committedMainRevision,
dirty: !matchesCommittedContent,
},
};
}

View File

@@ -0,0 +1,96 @@
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { WebEngineManifestV2, WebEngineVariantV2 } from "./manifest";
export interface WasmThreadingCapabilities {
crossOriginIsolated: boolean;
sharedArrayBuffer: boolean;
worker: boolean;
}
export type WebEngineVariantPolicy = "AUTO" | "SINGLE_REQUIRED" | "PTHREAD_REQUIRED";
export interface WebEngineVariantSelection {
policy: WebEngineVariantPolicy;
selectedVariant: WebEngineVariantV2 | null;
pthreadGate: CapabilityGateResult;
}
export type WebEngineReleaseBinding =
| { status: "READY"; releaseId: string; manifest: WebEngineManifestV2 }
| {
status: "REFRESH_REQUIRED";
expectedReleaseId: string;
actualReleaseId: string;
manifest: null;
};
export function bindWebEngineRelease(
expectedReleaseId: string,
manifest: WebEngineManifestV2,
): WebEngineReleaseBinding {
if (manifest.releaseId === expectedReleaseId) {
return { status: "READY", releaseId: manifest.releaseId, manifest };
}
return {
status: "REFRESH_REQUIRED",
expectedReleaseId,
actualReleaseId: manifest.releaseId,
manifest: null,
};
}
export function gateWasmThreadingCapability(capabilities: WasmThreadingCapabilities): CapabilityGateResult {
const issues = [];
if (!capabilities.crossOriginIsolated) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"Cross-origin isolation is required for the pthread WASM engine",
"crossOriginIsolated",
));
}
if (!capabilities.sharedArrayBuffer) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"SharedArrayBuffer is required for the pthread WASM engine",
"sharedArrayBuffer",
));
}
if (!capabilities.worker) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"Worker is required for the pthread WASM engine",
"worker",
));
}
return issues.length > 0
? blockedGate("M6-02", "WASM_PTHREAD_ENGINE", issues)
: readyGate("M6-02", "WASM_PTHREAD_ENGINE");
}
export function selectWebEngineVariant(
manifest: WebEngineManifestV2,
policy: WebEngineVariantPolicy,
capabilities: WasmThreadingCapabilities,
): WebEngineVariantSelection {
const single = manifest.variants.find((variant) => variant.id === "single");
const pthread = manifest.variants.find((variant) => variant.id === "pthread");
if (!single || !pthread) throw new Error("ENGINE_MANIFEST_VARIANTS_MISSING");
const pthreadGate = gateWasmThreadingCapability(capabilities);
if (policy === "SINGLE_REQUIRED") return { policy, selectedVariant: single, pthreadGate };
if (policy === "AUTO") {
return {
policy,
selectedVariant: pthreadGate.status === "READY" ? pthread : single,
pthreadGate,
};
}
if (policy === "PTHREAD_REQUIRED") {
return {
policy,
selectedVariant: pthreadGate.status === "READY" ? pthread : null,
pthreadGate,
};
}
throw new Error("ENGINE_VARIANT_POLICY_INVALID");
}

View File

@@ -8,6 +8,7 @@ export type ErrorCode =
| "WASM_INIT_FAILED"
| "WASM_OUT_OF_MEMORY"
| "BLEND_READ_FAILED"
| "OPEN_CANCELLED"
| "BLEND_WRITE_FAILED"
| "STORAGE_QUOTA"
| "STORAGE_TRANSACTION"

View File

@@ -0,0 +1,132 @@
export const LARGE_FILE_IMPORT_BYTES = 512 * 1024;
export const DEFAULT_FILE_READ_PROGRESS_CHUNK_BYTES = 1024 * 1024;
export const DEFAULT_FILE_READ_YIELD_BYTES = 4 * 1024 * 1024;
export type FileReadPhase = "STARTED" | "READING" | "COMPLETED" | "CANCELLED";
export interface FileReadProgress {
phase: FileReadPhase;
bytesRead: number;
totalBytes: number;
fraction: number;
}
export interface FileByteSource {
readonly size: number;
stream(): ReadableStream<Uint8Array>;
}
export interface FileByteReadOptions {
signal: AbortSignal;
onProgress?: (progress: FileReadProgress) => void;
onResourceState?: (state: FileByteReadResourceState) => void;
yieldControl?: () => Promise<void>;
progressChunkBytes?: number;
yieldEveryBytes?: number;
}
export interface FileByteReadResourceState {
liveReaders: number;
liveInputBytes: number;
liveStagingFiles: number;
}
export type FileByteReadErrorCode =
| "FILE_READ_CANCELLED"
| "FILE_READ_ALLOCATION_FAILED"
| "FILE_READ_SIZE_MISMATCH";
export class FileByteReadError extends Error {
readonly code: FileByteReadErrorCode;
constructor(code: FileByteReadErrorCode, message: string) {
super(`${code}: ${message}`);
this.name = "FileByteReadError";
this.code = code;
}
}
function progress(phase: FileReadPhase, bytesRead: number, totalBytes: number): FileReadProgress {
return {
phase,
bytesRead,
totalBytes,
fraction: totalBytes === 0 ? (phase === "COMPLETED" ? 1 : 0) : bytesRead / totalBytes,
};
}
export async function readFileBytes(source: FileByteSource, options: FileByteReadOptions): Promise<ArrayBuffer> {
const { signal, onProgress, onResourceState, yieldControl } = options;
const totalBytes = source.size;
const progressChunkBytes = options.progressChunkBytes ?? DEFAULT_FILE_READ_PROGRESS_CHUNK_BYTES;
const yieldEveryBytes = options.yieldEveryBytes ?? DEFAULT_FILE_READ_YIELD_BYTES;
if (!Number.isSafeInteger(totalBytes) || totalBytes < 0) {
throw new FileByteReadError("FILE_READ_SIZE_MISMATCH", "file size is not a non-negative safe integer");
}
if (!Number.isSafeInteger(progressChunkBytes) || progressChunkBytes <= 0 || !Number.isSafeInteger(yieldEveryBytes) || yieldEveryBytes <= 0) {
throw new FileByteReadError("FILE_READ_SIZE_MISMATCH", "progress or yield byte interval is invalid");
}
let outputBuffer: ArrayBuffer;
try {
outputBuffer = new ArrayBuffer(totalBytes);
}
catch {
throw new FileByteReadError("FILE_READ_ALLOCATION_FAILED", `could not allocate ${totalBytes} bytes`);
}
const output = new Uint8Array(outputBuffer);
const reader = source.stream().getReader();
onResourceState?.({ liveReaders: 1, liveInputBytes: totalBytes, liveStagingFiles: 0 });
let bytesRead = 0;
let nextYield = yieldEveryBytes;
let aborted = signal.aborted;
let completed = false;
const abort = (): void => {
aborted = true;
void reader.cancel("FILE_READ_CANCELLED").catch(() => undefined);
};
signal.addEventListener("abort", abort, { once: true });
onProgress?.(progress("STARTED", 0, totalBytes));
try {
while (true) {
if (aborted) throw new FileByteReadError("FILE_READ_CANCELLED", `cancelled after ${bytesRead} bytes`);
const { done, value } = await reader.read();
if (aborted) throw new FileByteReadError("FILE_READ_CANCELLED", `cancelled after ${bytesRead} bytes`);
if (done) break;
if (!value || bytesRead + value.byteLength > totalBytes) {
throw new FileByteReadError("FILE_READ_SIZE_MISMATCH", "stream exceeded the declared file size");
}
for (let offset = 0; offset < value.byteLength; offset += progressChunkBytes) {
if (aborted) throw new FileByteReadError("FILE_READ_CANCELLED", `cancelled after ${bytesRead} bytes`);
const chunk = value.subarray(offset, Math.min(value.byteLength, offset + progressChunkBytes));
output.set(chunk, bytesRead);
bytesRead += chunk.byteLength;
onProgress?.(progress("READING", bytesRead, totalBytes));
if (yieldControl && bytesRead >= nextYield && bytesRead < totalBytes) {
nextYield = bytesRead + yieldEveryBytes;
await yieldControl();
}
}
}
if (bytesRead !== totalBytes) {
throw new FileByteReadError("FILE_READ_SIZE_MISMATCH", `stream ended at ${bytesRead} of ${totalBytes} bytes`);
}
onProgress?.(progress("COMPLETED", bytesRead, totalBytes));
completed = true;
return outputBuffer;
}
catch (error) {
if (error instanceof FileByteReadError && error.code === "FILE_READ_CANCELLED") {
onProgress?.(progress("CANCELLED", bytesRead, totalBytes));
}
throw error;
}
finally {
signal.removeEventListener("abort", abort);
reader.releaseLock();
if (!completed) output.fill(0);
onResourceState?.({ liveReaders: 0, liveInputBytes: 0, liveStagingFiles: 0 });
}
}

View File

@@ -19,26 +19,256 @@ export interface WebEngineManifest {
wasm: WasmResource[];
}
export async function loadWebEngineManifest(url = "/engine-manifest.json"): Promise<WebEngineManifest> {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) throw new Error(`Engine manifest request failed: ${response.status}`);
const manifest = await response.json() as Partial<WebEngineManifest>;
if (manifest.schemaVersion !== 1 || manifest.protocolVersion !== 1) {
throw new Error("Unsupported engine manifest version");
}
if (!manifest.engine || !manifest.memory || !Array.isArray(manifest.wasm)) {
throw new Error("Invalid engine manifest shape");
}
return manifest as WebEngineManifest;
export const WEB_ENGINE_MANIFEST_V2_SCHEMA = 2 as const;
export const WEB_ENGINE_WASM_PAGE_BYTES = 65_536 as const;
export const WEB_ENGINE_MEMORY_LIMITS = {
minimumInitialPages: 256,
maximumPages: 32_768,
} as const;
export type WebEngineVariantId = "single" | "pthread";
export interface WebEngineVariantResourceV2 {
fileName: string;
url: string;
sha256: string;
}
export async function verifyWasmResource(resource: WasmResource): Promise<void> {
export interface WebEngineVariantMemoryV2<Shared extends boolean = boolean> {
initialPages: number;
maximumPages: number;
shared: Shared;
}
export interface WebEngineSingleVariantV2 {
id: "single";
memory: WebEngineVariantMemoryV2<false>;
resources: {
js: WebEngineVariantResourceV2;
wasm: WebEngineVariantResourceV2;
pthreadWorker?: never;
};
}
export interface WebEnginePthreadVariantV2 {
id: "pthread";
memory: WebEngineVariantMemoryV2<true>;
resources: {
js: WebEngineVariantResourceV2;
wasm: WebEngineVariantResourceV2;
pthreadWorker: WebEngineVariantResourceV2;
};
}
export type WebEngineVariantV2 = WebEngineSingleVariantV2 | WebEnginePthreadVariantV2;
export interface WebEngineManifestV2 {
schemaVersion: typeof WEB_ENGINE_MANIFEST_V2_SCHEMA;
protocolVersion: 1;
releaseId: string;
engineVersion: string;
engine: "blender-wasm";
variants: [WebEngineSingleVariantV2, WebEnginePthreadVariantV2];
}
export type LoadedWebEngineManifest = WebEngineManifest | WebEngineManifestV2;
export type WebEngineManifestValidationCode = "PROTOCOL_MISMATCH" | "ENGINE_MANIFEST_INVALID";
export class WebEngineManifestValidationError extends Error {
readonly code: WebEngineManifestValidationCode;
readonly path?: string;
constructor(code: WebEngineManifestValidationCode, message: string, path?: string) {
super(message);
this.name = "WebEngineManifestValidationError";
this.code = code;
this.path = path;
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const ENGINE_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/;
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function fail(code: WebEngineManifestValidationCode, message: string, path?: string): never {
throw new WebEngineManifestValidationError(code, message, path);
}
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], path: string): void {
const allowedKeys = new Set(allowed);
const unexpected = Object.keys(value).find((key) => !allowedKeys.has(key));
if (unexpected) fail("ENGINE_MANIFEST_INVALID", `${path}.${unexpected} is not allowed`, `${path}.${unexpected}`);
}
function boundedText(value: unknown, path: string, pattern: RegExp): string {
if (typeof value !== "string" || !pattern.test(value)) {
fail("ENGINE_MANIFEST_INVALID", `${path} is invalid`, path);
}
return value;
}
function parseResource(
value: unknown,
path: string,
extension: ".js" | ".wasm",
): WebEngineVariantResourceV2 {
if (!record(value)) fail("ENGINE_MANIFEST_INVALID", `${path} is required`, path);
exactKeys(value, ["fileName", "url", "sha256"], path);
const fileName = boundedText(value.fileName, `${path}.fileName`, FILE_NAME);
if (!fileName.endsWith(extension)) {
fail("ENGINE_MANIFEST_INVALID", `${path}.fileName must end with ${extension}`, `${path}.fileName`);
}
if (typeof value.url !== "string" || !value.url.startsWith("/") || /[?#\\\\]/.test(value.url)) {
fail("ENGINE_MANIFEST_INVALID", `${path}.url must be a root-relative URL`, `${path}.url`);
}
const segments = value.url.slice(1).split("/");
if (segments.length === 0 || segments.some((segment) => !FILE_NAME.test(segment) || segment === "." || segment === "..")) {
fail("ENGINE_MANIFEST_INVALID", `${path}.url contains an invalid path segment`, `${path}.url`);
}
if (segments.at(-1) !== fileName) {
fail("ENGINE_MANIFEST_INVALID", `${path}.url must end with fileName`, `${path}.url`);
}
const sha256 = boundedText(value.sha256, `${path}.sha256`, SHA256);
return { fileName, url: value.url, sha256 };
}
function parseMemory(value: unknown, id: WebEngineVariantId, path: string): WebEngineVariantMemoryV2 {
if (!record(value)) fail("ENGINE_MANIFEST_INVALID", `${path} is required`, path);
exactKeys(value, ["initialPages", "maximumPages", "shared"], path);
const { initialPages, maximumPages, shared } = value;
if (!Number.isSafeInteger(initialPages) ||
(initialPages as number) < WEB_ENGINE_MEMORY_LIMITS.minimumInitialPages ||
(initialPages as number) > WEB_ENGINE_MEMORY_LIMITS.maximumPages) {
fail("ENGINE_MANIFEST_INVALID", `${path}.initialPages is outside the supported range`, `${path}.initialPages`);
}
if (!Number.isSafeInteger(maximumPages) ||
(maximumPages as number) < (initialPages as number) ||
(maximumPages as number) > WEB_ENGINE_MEMORY_LIMITS.maximumPages) {
fail("ENGINE_MANIFEST_INVALID", `${path}.maximumPages is outside the supported range`, `${path}.maximumPages`);
}
if (shared !== (id === "pthread")) {
fail("ENGINE_MANIFEST_INVALID", `${path}.shared must be ${id === "pthread"}`, `${path}.shared`);
}
return { initialPages: initialPages as number, maximumPages: maximumPages as number, shared };
}
function parseVariant(value: unknown, index: number): WebEngineVariantV2 {
const path = `variants[${index}]`;
if (!record(value)) fail("ENGINE_MANIFEST_INVALID", `${path} is invalid`, path);
exactKeys(value, ["id", "memory", "resources"], path);
if (value.id !== "single" && value.id !== "pthread") {
fail("ENGINE_MANIFEST_INVALID", `${path}.id is invalid`, `${path}.id`);
}
const id = value.id;
if (!record(value.resources)) fail("ENGINE_MANIFEST_INVALID", `${path}.resources is required`, `${path}.resources`);
const resourcePath = `${path}.resources`;
exactKeys(value.resources, id === "pthread" ? ["js", "wasm", "pthreadWorker"] : ["js", "wasm"], resourcePath);
if (id === "pthread" && value.resources.pthreadWorker === undefined) {
fail("ENGINE_MANIFEST_INVALID", `${resourcePath}.pthreadWorker is required`, `${resourcePath}.pthreadWorker`);
}
const js = parseResource(value.resources.js, `${resourcePath}.js`, ".js");
const wasm = parseResource(value.resources.wasm, `${resourcePath}.wasm`, ".wasm");
const memory = parseMemory(value.memory, id, `${path}.memory`);
if (id === "pthread") {
const pthreadWorker = parseResource(
value.resources.pthreadWorker,
`${resourcePath}.pthreadWorker`,
".js",
);
return { id, memory: { ...memory, shared: true }, resources: { js, wasm, pthreadWorker } };
}
return { id, memory: { ...memory, shared: false }, resources: { js, wasm } };
}
export function validateWebEngineManifestV2(value: unknown): WebEngineManifestV2 {
if (!record(value) || value.schemaVersion !== WEB_ENGINE_MANIFEST_V2_SCHEMA) {
fail("PROTOCOL_MISMATCH", "Unsupported engine manifest schema", "schemaVersion");
}
exactKeys(value, ["schemaVersion", "protocolVersion", "releaseId", "engineVersion", "engine", "variants"], "manifest");
if (value.protocolVersion !== 1) {
fail("PROTOCOL_MISMATCH", "Unsupported WebEngine protocol version", "protocolVersion");
}
if (value.engine !== "blender-wasm") {
fail("ENGINE_MANIFEST_INVALID", "engine must be blender-wasm", "engine");
}
const engineVersion = boundedText(value.engineVersion, "engineVersion", ENGINE_VERSION);
const releaseId = boundedText(value.releaseId, "releaseId", ENGINE_VERSION);
if (!Array.isArray(value.variants) || value.variants.length !== 2) {
fail("ENGINE_MANIFEST_INVALID", "variants must contain exactly single and pthread", "variants");
}
const variants = value.variants.map(parseVariant);
const byId = new Map(variants.map((variant) => [variant.id, variant]));
if (byId.size !== 2 || !byId.has("single") || !byId.has("pthread")) {
fail("ENGINE_MANIFEST_INVALID", "variants must contain exactly one single and one pthread", "variants");
}
const single = byId.get("single") as WebEngineSingleVariantV2;
const pthread = byId.get("pthread") as WebEnginePthreadVariantV2;
for (const role of ["js", "wasm"] as const) {
if (single.resources[role].url === pthread.resources[role].url) {
fail(
"ENGINE_MANIFEST_INVALID",
`single and pthread ${role} resources must have distinct URLs`,
`variants.${role}`,
);
}
}
if (pthread.resources.pthreadWorker.url === single.resources.js.url) {
fail(
"ENGINE_MANIFEST_INVALID",
"pthread worker must not alias the single JS resource",
"variants.pthreadWorker",
);
}
if (pthread.resources.pthreadWorker.url === pthread.resources.js.url &&
pthread.resources.pthreadWorker.sha256 !== pthread.resources.js.sha256) {
fail(
"ENGINE_MANIFEST_INVALID",
"resources that share a URL must share the same SHA-256",
"variants.pthreadWorker.sha256",
);
}
return {
schemaVersion: WEB_ENGINE_MANIFEST_V2_SCHEMA,
protocolVersion: 1,
releaseId,
engineVersion,
engine: "blender-wasm",
variants: [single, pthread],
};
}
export async function loadWebEngineManifest(url = "/engine-manifest.json"): Promise<LoadedWebEngineManifest> {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) throw new Error(`Engine manifest request failed: ${response.status}`);
const manifest = await response.json() as unknown;
if (record(manifest) && manifest.schemaVersion === WEB_ENGINE_MANIFEST_V2_SCHEMA) {
return validateWebEngineManifestV2(manifest);
}
const legacyManifest = manifest as Partial<WebEngineManifest>;
if (legacyManifest.schemaVersion !== 1 || legacyManifest.protocolVersion !== 1) {
throw new Error("Unsupported engine manifest version");
}
if (!legacyManifest.engine || !legacyManifest.memory || !Array.isArray(legacyManifest.wasm)) {
throw new Error("Invalid engine manifest shape");
}
return legacyManifest as WebEngineManifest;
}
export async function verifyWasmResource(
resource: Pick<WasmResource, "url" | "sha256"> & Partial<Pick<WasmResource, "id">>,
): Promise<void> {
const response = await fetch(resource.url, { cache: "no-store" });
if (!response.ok) throw new Error(`WASM resource request failed: ${resource.id}`);
const resourceId = resource.id ?? resource.url;
if (!response.ok) throw new Error(`WASM resource request failed: ${resourceId}`);
const bytes = await response.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", bytes);
const actual = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
if (actual !== resource.sha256.toLowerCase()) {
throw new Error(`WASM resource hash mismatch: ${resource.id}`);
throw new Error(`WASM resource hash mismatch: ${resourceId}`);
}
}

View File

@@ -5,7 +5,11 @@ export interface ProgressEvent {
operation: string;
phase: ProgressPhase;
fraction?: number;
bytesRead?: number;
totalBytes?: number;
message?: string;
revision?: number;
errorCode?: string;
stage?: string;
cancellable?: boolean;
}

View File

@@ -0,0 +1,79 @@
export const PROJECT_ACTION_KINDS = ["OPEN", "SAVE", "CLOSE"] as const;
export type ProjectActionKind = typeof PROJECT_ACTION_KINDS[number];
export interface ProjectActionIdentity {
actionId: string;
kind: ProjectActionKind;
}
export interface ProjectActionMutexState {
owner: ProjectActionIdentity | null;
}
export type ProjectActionConflictReason =
| "REPEATED_OPEN"
| "CONCURRENT_SAVE"
| "CLOSE_DURING_SAVE"
| "PROJECT_ACTION_EXCLUSIVE";
export interface ProjectActionConflict {
code: "USER_ACTION_CONFLICT";
reason: ProjectActionConflictReason;
requested: ProjectActionIdentity;
owner: ProjectActionIdentity;
}
export type ProjectActionAcquireResult =
| { granted: true; state: ProjectActionMutexState; conflict: null }
| { granted: false; state: ProjectActionMutexState; conflict: ProjectActionConflict };
export type ProjectActionReleaseResult =
| { released: true; state: ProjectActionMutexState; errorCode: null }
| {
released: false;
state: ProjectActionMutexState;
errorCode: "PROJECT_ACTION_LOCK_NOT_HELD" | "PROJECT_ACTION_LOCK_IDENTITY_MISMATCH";
};
export const PROJECT_ACTION_CONFLICT_MATRIX: Readonly<Record<ProjectActionKind, Readonly<Record<ProjectActionKind, boolean>>>> = {
OPEN: { OPEN: true, SAVE: true, CLOSE: true },
SAVE: { OPEN: true, SAVE: true, CLOSE: true },
CLOSE: { OPEN: true, SAVE: true, CLOSE: true },
};
export function createProjectActionMutexState(): ProjectActionMutexState {
return { owner: null };
}
function conflictReason(requested: ProjectActionKind, owner: ProjectActionKind): ProjectActionConflictReason {
if (requested === "OPEN" && owner === "OPEN") return "REPEATED_OPEN";
if (requested === "SAVE" && owner === "SAVE") return "CONCURRENT_SAVE";
if (requested === "CLOSE" && owner === "SAVE") return "CLOSE_DURING_SAVE";
return "PROJECT_ACTION_EXCLUSIVE";
}
export function acquireProjectAction(state: ProjectActionMutexState, requested: ProjectActionIdentity): ProjectActionAcquireResult {
const owner = state.owner;
if (!owner) return { granted: true, state: { owner: requested }, conflict: null };
if (!PROJECT_ACTION_CONFLICT_MATRIX[requested.kind][owner.kind]) {
return { granted: true, state: { owner: requested }, conflict: null };
}
return {
granted: false,
state,
conflict: {
code: "USER_ACTION_CONFLICT",
reason: conflictReason(requested.kind, owner.kind),
requested,
owner,
},
};
}
export function releaseProjectAction(state: ProjectActionMutexState, identity: ProjectActionIdentity): ProjectActionReleaseResult {
if (!state.owner) return { released: false, state, errorCode: "PROJECT_ACTION_LOCK_NOT_HELD" };
if (state.owner.kind !== identity.kind || state.owner.actionId !== identity.actionId) {
return { released: false, state, errorCode: "PROJECT_ACTION_LOCK_IDENTITY_MISMATCH" };
}
return { released: true, state: createProjectActionMutexState(), errorCode: null };
}

View File

@@ -0,0 +1,81 @@
export const SAVE_ATTEMPT_STAGES = ["SERIALIZE", "OPFS_STAGE", "SCENE_COMMIT", "METADATA_COMMIT"] as const;
export type SaveAttemptStage = typeof SAVE_ATTEMPT_STAGES[number];
export interface SaveCommitIdentity {
revision: number;
sha256: string | null;
}
export interface SaveTransactionState {
status: "IDLE" | "RUNNING" | "SUCCEEDED" | "FAILED";
stage: SaveAttemptStage | null;
committed: SaveCommitIdentity;
candidate: SaveCommitIdentity | null;
errorCode: string | null;
}
export type SaveTransactionResult =
| { ok: true; state: SaveTransactionState }
| { ok: false; state: SaveTransactionState; errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" | "SAVE_TRANSACTION_COMMIT_MISMATCH" };
const SHA256 = /^[a-f0-9]{64}$/;
function validIdentity(identity: SaveCommitIdentity, allowNullHash: boolean): boolean {
return Number.isSafeInteger(identity.revision) && identity.revision >= 0 &&
(allowNullHash ? identity.sha256 === null || SHA256.test(identity.sha256) : typeof identity.sha256 === "string" && SHA256.test(identity.sha256));
}
export function createSaveTransactionState(committed: SaveCommitIdentity): SaveTransactionState {
if (!validIdentity(committed, true)) throw new Error("SAVE_TRANSACTION_COMMIT_INVALID");
return { status: "IDLE", stage: null, committed, candidate: null, errorCode: null };
}
export function beginSaveTransaction(state: SaveTransactionState, targetRevision: number): SaveTransactionResult {
if (state.status === "RUNNING" || !Number.isSafeInteger(targetRevision) || targetRevision < state.committed.revision) {
return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" };
}
return {
ok: true,
state: {
status: "RUNNING",
stage: "SERIALIZE",
committed: state.committed,
candidate: { revision: targetRevision, sha256: null },
errorCode: null,
},
};
}
export function advanceSaveTransaction(state: SaveTransactionState, stage: SaveAttemptStage, candidateSha256?: string): SaveTransactionResult {
if (state.status !== "RUNNING" || !state.stage || !state.candidate) {
return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" };
}
const currentIndex = SAVE_ATTEMPT_STAGES.indexOf(state.stage);
const nextIndex = SAVE_ATTEMPT_STAGES.indexOf(stage);
if (nextIndex !== currentIndex + 1 || (stage === "OPFS_STAGE" && (!candidateSha256 || !SHA256.test(candidateSha256)))) {
return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" };
}
return {
ok: true,
state: {
...state,
stage,
candidate: stage === "OPFS_STAGE" ? { ...state.candidate, sha256: candidateSha256! } : state.candidate,
},
};
}
export function failSaveTransaction(state: SaveTransactionState, errorCode: string): SaveTransactionState {
if (state.status !== "RUNNING" || !/^[A-Z][A-Z0-9_]*$/.test(errorCode)) return state;
return { ...state, status: "FAILED", errorCode };
}
export function commitSaveTransaction(state: SaveTransactionState, persisted: SaveCommitIdentity): SaveTransactionResult {
if (state.status !== "RUNNING" || state.stage !== "METADATA_COMMIT" || !state.candidate || !validIdentity(persisted, false)) {
return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" };
}
if (persisted.revision !== state.candidate.revision || persisted.sha256 !== state.candidate.sha256) {
return { ok: false, state, errorCode: "SAVE_TRANSACTION_COMMIT_MISMATCH" };
}
return { ok: true, state: { status: "SUCCEEDED", stage: "METADATA_COMMIT", committed: persisted, candidate: persisted, errorCode: null } };
}

View File

@@ -192,7 +192,7 @@ export interface StorageRequest {
| { type: "smoke" }
| { type: "info" }
| { type: "ensureProject"; projectId: string }
| { type: "saveProject"; projectId: string; revision: number; buffer: ArrayBuffer; faultAt?: "after-stage" | "after-scene-commit" | "quota" }
| { type: "saveProject"; projectId: string; revision: number; buffer: ArrayBuffer; faultAt?: "after-stage" | "after-scene-commit" | "before-metadata-commit" | "quota" }
| { type: "recoverProject"; projectId: string }
| { type: "readProject"; projectId: string }
| { type: "appendOperation"; id: string; projectId: string; revision: number; payload: unknown; inversePayload?: unknown }

View File

@@ -0,0 +1,109 @@
export const USER_ACTION_KINDS = ["IMPORT", "OPEN", "SAVE", "SAVE_AS", "EXPORT"] as const;
export type UserActionKind = typeof USER_ACTION_KINDS[number];
export const USER_ACTION_STATUSES = ["IDLE", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"] as const;
export type UserActionStatus = typeof USER_ACTION_STATUSES[number];
export interface UserActionIdentity {
actionId: string;
kind: UserActionKind;
}
export interface UserActionState {
kind: UserActionKind;
status: UserActionStatus;
identity: UserActionIdentity | null;
errorCode: string | null;
}
export type UserActionEvent =
| { type: "START"; identity: UserActionIdentity }
| { type: "SUCCEED"; identity: UserActionIdentity }
| { type: "FAIL"; identity: UserActionIdentity; errorCode: string }
| { type: "CANCEL"; identity: UserActionIdentity; errorCode?: string }
| { type: "RESET"; kind: UserActionKind };
export type UserActionTransitionErrorCode =
| "USER_ACTION_KIND_MISMATCH"
| "USER_ACTION_IDENTITY_MISMATCH"
| "USER_ACTION_IDENTITY_REUSED"
| "USER_ACTION_INVALID_ERROR_CODE"
| "USER_ACTION_INVALID_TRANSITION";
export type UserActionTransitionResult =
| { ok: true; state: UserActionState }
| { ok: false; state: UserActionState; errorCode: UserActionTransitionErrorCode };
export type UserActionStates = Record<UserActionKind, UserActionState>;
export const USER_ACTION_ALLOWED_TRANSITIONS: Readonly<Record<UserActionStatus, readonly UserActionStatus[]>> = {
IDLE: ["RUNNING"],
RUNNING: ["SUCCEEDED", "FAILED", "CANCELLED"],
SUCCEEDED: ["IDLE", "RUNNING"],
FAILED: ["IDLE", "RUNNING"],
CANCELLED: ["IDLE", "RUNNING"],
};
function idleState(kind: UserActionKind): UserActionState {
return { kind, status: "IDLE", identity: null, errorCode: null };
}
export function createInitialUserActionStates(): UserActionStates {
return Object.fromEntries(USER_ACTION_KINDS.map((kind) => [kind, idleState(kind)])) as UserActionStates;
}
function rejected(state: UserActionState, errorCode: UserActionTransitionErrorCode): UserActionTransitionResult {
return { ok: false, state, errorCode };
}
function isStableErrorCode(value: string): boolean {
return /^[A-Z][A-Z0-9_]*$/.test(value);
}
export function transitionUserAction(state: UserActionState, event: UserActionEvent): UserActionTransitionResult {
if (event.type === "RESET") {
if (event.kind !== state.kind) return rejected(state, "USER_ACTION_KIND_MISMATCH");
if (!USER_ACTION_ALLOWED_TRANSITIONS[state.status].includes("IDLE")) {
return rejected(state, "USER_ACTION_INVALID_TRANSITION");
}
return { ok: true, state: idleState(state.kind) };
}
if (event.identity.kind !== state.kind) return rejected(state, "USER_ACTION_KIND_MISMATCH");
if (!event.identity.actionId) return rejected(state, "USER_ACTION_IDENTITY_MISMATCH");
if (event.type === "START") {
if (!USER_ACTION_ALLOWED_TRANSITIONS[state.status].includes("RUNNING")) {
return rejected(state, "USER_ACTION_INVALID_TRANSITION");
}
if (state.identity?.actionId === event.identity.actionId) {
return rejected(state, "USER_ACTION_IDENTITY_REUSED");
}
return {
ok: true,
state: { kind: state.kind, status: "RUNNING", identity: event.identity, errorCode: null },
};
}
if (state.status !== "RUNNING") return rejected(state, "USER_ACTION_INVALID_TRANSITION");
if (state.identity?.actionId !== event.identity.actionId) {
return rejected(state, "USER_ACTION_IDENTITY_MISMATCH");
}
if (event.type === "SUCCEED") {
return { ok: true, state: { ...state, status: "SUCCEEDED", errorCode: null } };
}
const errorCode = event.type === "CANCEL" ? event.errorCode ?? "USER_ACTION_CANCELLED" : event.errorCode;
if (!isStableErrorCode(errorCode)) return rejected(state, "USER_ACTION_INVALID_ERROR_CODE");
return {
ok: true,
state: { ...state, status: event.type === "CANCEL" ? "CANCELLED" : "FAILED", errorCode },
};
}
export function reduceUserActionStates(states: UserActionStates, event: UserActionEvent): UserActionStates {
const kind = event.type === "RESET" ? event.kind : event.identity.kind;
const result = transitionUserAction(states[kind], event);
return result.ok ? { ...states, [kind]: result.state } : states;
}

View File

@@ -137,6 +137,8 @@ export type WebEngineEditCommand =
export type WebEngineRequest =
| { requestId: string; command: { type: "init" } }
| { requestId: string; command: { type: "openBlend"; buffer: ArrayBuffer }; }
| { requestId: string; command: { type: "cancelOpen"; targetRequestId: string } }
| { requestId: string; command: { type: "openResourceStatus" } }
| { requestId: string; command: { type: "snapshot" } }
| { requestId: string; command: { type: "applyCommand"; payload: WebEngineEditCommand } }
| { requestId: string; command: { type: "generateLOD"; payload: LODGenerationRequest } }
@@ -154,6 +156,13 @@ export interface WebEngineStatus {
allocatedBytes: number;
}
export interface WebEngineOpenResourceStatus {
activeRequests: number;
liveInputBytes: number;
liveNativeHandles: number;
liveStagingFiles: number;
}
export interface AssetRequestResult {
assetId: string;
status: "external" | "packed" | "packed-unavailable" | "missing" | "blocked";
@@ -202,6 +211,7 @@ export interface WebEngineResult {
capabilityGate?: CapabilityGateResult;
depsgraph?: DepsgraphEvaluationIR;
blend?: ArrayBuffer;
openResources?: WebEngineOpenResourceStatus;
}
export type WebEngineResponse =

View File

@@ -0,0 +1,147 @@
import { expect, test, type BrowserContext, type Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { importGLBSemantics } from "../../protocol/glb-import";
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
function isLoopbackRequest(value: string): boolean {
const url = new URL(value);
if (!["http:", "https:"].includes(url.protocol)) return true;
return url.hostname === "localhost" || url.hostname === "::1" || /^127(?:\.\d{1,3}){3}$/.test(url.hostname);
}
async function isolateExternalNetwork(context: BrowserContext, page: Page) {
const externalAttempts: string[] = [];
const requested: string[] = [];
page.on("request", (request) => requested.push(request.url()));
await context.route("**/*", async (route) => {
if (isLoopbackRequest(route.request().url())) await route.continue();
else {
externalAttempts.push(route.request().url());
await route.abort("internetdisconnected");
}
});
return { externalAttempts, requested };
}
async function waitForArchiveBoot(page: Page): Promise<void> {
await page.goto("/");
await expect(page.getByText("Web Blender Modeler V1", { exact: true }).first()).toBeVisible();
await expect(page.locator(".status-bar")).toContainText("Manifest: verified r1");
await expect(page.getByTestId("engine-status")).toContainText("Engine: ready, open a .blend file");
await expect(page.locator(".status-bar")).toContainText("Storage: IndexedDB + OPFS");
await page.evaluate(() => document.fonts.ready);
expect(await page.evaluate(() => document.fonts.status)).toBe("loaded");
}
async function objectCount(page: Page): Promise<number> {
const text = await page.getByTestId("scene-stats").innerText();
const match = text.match(/Objects (\d+)/);
if (!match) throw new Error(`scene stats omit object count: ${text}`);
return Number(match[1]);
}
async function stableIds(page: Page) {
return page.locator(".outliner-content .tree-row.child").evaluateAll((rows) => rows.map((row) => ({
objectId: (row as HTMLElement).dataset.nodeId ?? "",
dataId: (row as HTMLElement).dataset.dataId ?? "",
name: row.querySelector("span:nth-of-type(3)")?.textContent ?? "",
})).sort((left, right) => left.objectId.localeCompare(right.objectId)));
}
async function opfsCommit(page: Page, projectId: string) {
return page.evaluate(async (id) => {
const root = await navigator.storage.getDirectory();
const projects = await root.getDirectoryHandle("projects");
const project = await projects.getDirectoryHandle(id);
const manifestFile = await (await project.getFileHandle("scene.blend.meta.json")).getFile();
const manifest = JSON.parse(await manifestFile.text()) as { revision: number; bytes: number; sha256: string };
const blend = await (await project.getFileHandle("scene.blend")).getFile();
const bytes = await blend.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", bytes);
const sha256 = Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
return { ...manifest, actualBytes: bytes.byteLength, actualSha256: sha256 };
}, projectId);
}
test("M6-09 cold boots the extracted binary archive with no external network", async ({ context, page }) => {
const network = await isolateExternalNetwork(context, page);
await waitForArchiveBoot(page);
const paths = network.requested.filter((value) => value.startsWith(page.url().replace(/\/$/, ""))).map((value) => new URL(value).pathname);
expect(paths).toContain("/engine-manifest.json");
expect(paths.some((value) => value.endsWith(".wasm"))).toBe(true);
expect(paths.some((value) => value.endsWith(".css"))).toBe(true);
expect(paths.some((value) => value.includes(".worker-") && value.endsWith(".js"))).toBe(true);
expect(network.externalAttempts).toEqual([]);
});
test("M6-10 reopens and continues an OPFS project offline after Worker reconstruction", async ({ context, page }) => {
const network = await isolateExternalNetwork(context, page);
await waitForArchiveBoot(page);
const input = fs.readFileSync(basicBlend);
const projectId = `m6-offline-${Date.now()}`;
await page.getByTestId("blend-file-input").setInputFiles({
name: `${projectId}.blend`,
mimeType: "application/octet-stream",
buffer: input,
});
await expect(page.getByTestId("engine-status")).toContainText("Engine: SceneIR r");
const initialCount = await objectCount(page);
const initialIds = await stableIds(page);
await page.getByRole("button", { name: "添加立方体" }).click();
await expect.poll(() => objectCount(page)).toBe(initialCount + 1);
const editedIds = await stableIds(page);
const firstCreated = editedIds.find((entry) => !initialIds.some((initial) => initial.objectId === entry.objectId));
expect(firstCreated?.objectId).toBeTruthy();
expect(firstCreated?.dataId).toBeTruthy();
await page.getByRole("button", { name: "撤销" }).click();
await expect.poll(() => objectCount(page)).toBe(initialCount);
await page.getByRole("button", { name: "重做" }).click();
await expect.poll(() => objectCount(page)).toBe(initialCount + 1);
const firstSave = page.waitForEvent("download");
await page.getByRole("button", { name: "保存项目" }).click();
await firstSave;
const firstCommit = await opfsCommit(page, projectId);
expect(firstCommit.revision).toBeGreaterThan(0);
expect(firstCommit.bytes).toBe(firstCommit.actualBytes);
expect(firstCommit.sha256).toBe(firstCommit.actualSha256);
expect(firstCommit.sha256).toMatch(/^[a-f0-9]{64}$/);
await page.reload();
await expect(page.getByTestId("engine-status")).toContainText("Engine: ready, open a .blend file");
await page.getByRole("button", { name: "恢复项目" }).click();
await expect(page.getByTestId("engine-status")).toContainText("Recovery: 0 operation(s), 0 quarantined");
expect(await stableIds(page)).toEqual(editedIds);
await page.getByRole("button", { name: "添加立方体" }).click();
await expect.poll(() => objectCount(page)).toBe(initialCount + 2);
await page.getByRole("button", { name: "撤销" }).click();
await expect.poll(() => objectCount(page)).toBe(initialCount + 1);
await page.getByRole("button", { name: "重做" }).click();
await expect.poll(() => objectCount(page)).toBe(initialCount + 2);
const secondSave = page.waitForEvent("download");
await page.getByRole("button", { name: "保存项目" }).click();
await secondSave;
const secondCommit = await opfsCommit(page, projectId);
expect(secondCommit.revision).toBeGreaterThan(firstCommit.revision);
expect(secondCommit.sha256).toBe(secondCommit.actualSha256);
expect(secondCommit.sha256).not.toBe(firstCommit.sha256);
const glbDownload = page.waitForEvent("download");
await page.getByRole("button", { name: "导出 GLB" }).click();
const glb = await glbDownload;
const glbPath = await glb.path();
expect(glbPath).toBeTruthy();
const glbBytes = fs.readFileSync(glbPath!);
const glbBuffer = glbBytes.buffer.slice(glbBytes.byteOffset, glbBytes.byteOffset + glbBytes.byteLength);
const imported = importGLBSemantics(glbBuffer);
expect(imported.meshCount).toBeGreaterThan(0);
expect(imported.primitiveCount).toBeGreaterThan(0);
expect(imported.meshes.some((mesh) => mesh.blenderId === firstCreated?.dataId)).toBe(true);
expect(network.externalAttempts).toEqual([]);
});

View File

@@ -0,0 +1,69 @@
import { expect, test } from "@playwright/test";
import path from "node:path";
const attributeBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend");
test("M7-06 changes dirty only for accepted Main transactions and matching saves", async ({ page }) => {
await page.goto("/");
await page.getByTestId("blend-file-input").setInputFiles(attributeBlend);
await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible();
const app = page.locator(".blender-app");
await expect(app).toHaveAttribute("data-dirty", "false");
const openedRevision = await app.getAttribute("data-current-main-revision");
await expect(app).toHaveAttribute("data-committed-main-revision", openedRevision ?? "");
await page.getByRole("button", { name: "Modeling" }).click();
await expect(app).toHaveAttribute("data-dirty", "false");
await expect(app).toHaveAttribute("data-current-main-revision", openedRevision ?? "");
await page.getByRole("button", { name: "预览 Decimate" }).click();
await expect(page.getByTestId("engine-status")).toContainText("Preview");
await expect(app).toHaveAttribute("data-dirty", "false");
await expect(app).toHaveAttribute("data-current-main-revision", openedRevision ?? "");
await page.locator("label.file-button input[type=file]").setInputFiles({
name: "invalid.png",
mimeType: "image/png",
buffer: Buffer.from("not-a-png"),
});
await expect(app).toHaveAttribute("data-user-action-import-status", "FAILED");
await expect(app).toHaveAttribute("data-dirty", "false");
await expect(app).toHaveAttribute("data-current-main-revision", openedRevision ?? "");
await page.getByRole("button", { name: "添加立方体" }).click();
await expect(app).toHaveAttribute("data-dirty", "true");
await expect(page.getByTestId("dirty-status")).toHaveText("未保存");
const editedRevision = Number(await app.getAttribute("data-current-main-revision"));
expect(editedRevision).toBeGreaterThan(Number(openedRevision));
await expect(app).toHaveAttribute("data-committed-main-revision", openedRevision ?? "");
const download = page.waitForEvent("download");
await page.getByRole("button", { name: "保存项目" }).click();
await download;
await expect(app).toHaveAttribute("data-dirty", "false");
await expect(page.getByTestId("dirty-status")).toHaveText("已保存");
await expect(app).toHaveAttribute("data-committed-main-revision", String(editedRevision));
await page.getByRole("button", { name: "添加立方体" }).click();
await expect(app).toHaveAttribute("data-dirty", "true");
await page.getByRole("button", { name: "撤销" }).click();
await expect(app).toHaveAttribute("data-dirty", "false");
const undoRevision = Number(await app.getAttribute("data-current-main-revision"));
expect(undoRevision).toBeGreaterThan(editedRevision);
await page.getByRole("button", { name: "重做" }).click();
await expect(app).toHaveAttribute("data-dirty", "true");
const redoRevision = Number(await app.getAttribute("data-current-main-revision"));
expect(redoRevision).toBeGreaterThan(undoRevision);
await page.getByRole("button", { name: "撤销" }).click();
await expect(app).toHaveAttribute("data-dirty", "false");
await page.getByTestId("blend-file-input").setInputFiles({
name: "invalid.blend",
mimeType: "application/octet-stream",
buffer: Buffer.from([0x42, 0x41, 0x44]),
});
await expect(app).toHaveAttribute("data-user-action-open-status", "FAILED");
await expect(app).toHaveAttribute("data-dirty", "false");
const finalUndoRevision = await app.getAttribute("data-current-main-revision");
await expect(app).toHaveAttribute("data-committed-main-revision", finalUndoRevision ?? "");
});

View File

@@ -0,0 +1,92 @@
import { expect, test } from "@playwright/test";
test("refreshes on a release switch and rejects tampered WASM without fallback or open", async ({ page }) => {
await page.goto("/");
await expect(page.locator(".status-bar")).toContainText("Manifest: verified");
const versionGate = await page.evaluate(async () => {
const { bootstrapWebEngineRelease } = await import("/src/engine-client/engine-variant-bootstrap.ts");
const manifest = await fetch("/engine-manifest.json", { cache: "no-store" }).then((response) => response.json());
let initializationCount = 0;
const outcome = await bootstrapWebEngineRelease(
"blender-wasm-previous",
manifest,
"AUTO",
{ crossOriginIsolated: true, sharedArrayBuffer: true, worker: true },
{
initialize: async () => {
initializationCount += 1;
throw new Error("release mismatch initialized an engine");
},
},
new ArrayBuffer(8),
);
return { result: outcome.result, hasSession: outcome.session !== null, initializationCount };
});
expect(versionGate).toEqual({
result: {
status: "REFRESH_REQUIRED",
expectedReleaseId: "blender-wasm-previous",
actualReleaseId: "blender-wasm-0.1.0-rc.1",
manifest: null,
},
hasSession: false,
initializationCount: 0,
});
const variantRequests: string[] = [];
page.on("request", (request) => {
const pathname = new URL(request.url()).pathname;
if (pathname.startsWith("/vendor/blender/single/") || pathname.startsWith("/vendor/blender/pthread/")) {
variantRequests.push(pathname);
}
});
await page.route("**/vendor/blender/pthread/web_engine.wasm", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/wasm",
body: Buffer.from("tampered-pthread-wasm"),
});
});
const integrityGate = await page.evaluate(async () => {
const { detectBrowserCapabilities } = await import("/src/platform/capabilities.ts");
const { bootstrapWebEngineRelease } = await import("/src/engine-client/engine-variant-bootstrap.ts");
const { createBrowserEngineVariantSession } = await import("/src/engine-client/browser-engine-variant-session.ts");
const manifest = await fetch("/engine-manifest.json", { cache: "no-store" }).then((response) => response.json());
try {
await bootstrapWebEngineRelease(
manifest.releaseId,
manifest,
"AUTO",
detectBrowserCapabilities(),
{ initialize: createBrowserEngineVariantSession },
new ArrayBuffer(8),
);
return { rejected: false };
}
catch (error) {
const failure = error as Error & {
code?: string;
attempted?: string[];
cause?: { code?: string };
};
return {
rejected: true,
code: failure.code,
causeCode: failure.cause?.code,
attempted: failure.attempted,
};
}
});
expect(integrityGate).toEqual({
rejected: true,
code: "ENGINE_VARIANT_INTEGRITY_FAILED",
causeCode: "ENGINE_VARIANT_RESOURCE_HASH_MISMATCH",
attempted: ["pthread"],
});
expect(variantRequests.filter((url) => url.endsWith("/pthread/web_engine.wasm"))).toHaveLength(1);
expect(variantRequests.some((url) => url.includes("/single/"))).toBe(false);
});

View File

@@ -0,0 +1,111 @@
import { expect, test } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
const largeBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/image_resource_matrix.blend");
test("M7-03 records exact streamed byte progress for a large valid blend", async ({ page }) => {
test.setTimeout(60_000);
const expectedBytes = fs.statSync(largeBlend).size;
expect(expectedBytes).toBeGreaterThanOrEqual(512 * 1024);
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(largeBlend);
const app = page.locator(".blender-app");
await expect(app).toHaveAttribute("data-user-action-open-status", "SUCCEEDED", { timeout: 30_000 });
await expect(app).toHaveAttribute("data-open-read-phase", "COMPLETED");
await expect(app).toHaveAttribute("data-open-read-bytes", String(expectedBytes));
await expect(app).toHaveAttribute("data-open-read-total", String(expectedBytes));
expect(Number(await app.getAttribute("data-open-read-events"))).toBeGreaterThan(2);
expect(await app.getAttribute("data-open-read-action-id")).toBe(await app.getAttribute("data-user-action-open-id"));
});
test("M7-03 cancels a large streamed open before WebEngine and preserves the current project", async ({ page }) => {
test.setTimeout(60_000);
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.getByText("BasicCube", { exact: true })).toBeVisible();
const previousStats = await page.getByTestId("scene-stats").textContent();
const totalBytes = 40 * 1024 * 1024;
const syntheticLargeBlend = Buffer.alloc(totalBytes, 0x7f);
fs.readFileSync(basicBlend).copy(syntheticLargeBlend);
await page.getByTestId("blend-file-input").setInputFiles({
name: "cancelled-large.blend",
mimeType: "application/octet-stream",
buffer: syntheticLargeBlend,
});
const observation = await page.waitForFunction((expectedTotal) => {
const element = document.querySelector<HTMLElement>('[data-testid="open-progress"]');
const bytesRead = Number(element?.getAttribute("data-bytes-read"));
const actualTotal = Number(element?.getAttribute("data-total-bytes"));
const cancel = document.querySelector<HTMLButtonElement>('button[aria-label="取消打开"]');
if (bytesRead <= 0 || bytesRead >= expectedTotal || !cancel) return false;
cancel.click();
return { bytesRead, totalBytes: actualTotal };
}, totalBytes, { polling: "raf", timeout: 10_000 });
const observed = await observation.jsonValue() as { bytesRead: number; totalBytes: number };
expect(observed.bytesRead).toBeLessThan(totalBytes);
expect(observed.totalBytes).toBe(totalBytes);
const app = page.locator(".blender-app");
await expect(app).toHaveAttribute("data-user-action-open-status", "CANCELLED");
await expect(app).toHaveAttribute("data-user-action-open-error", "OPEN_CANCELLED");
await expect(app).toHaveAttribute("data-open-read-phase", "CANCELLED");
await expect(app).toHaveAttribute("data-open-cleanup-reader-count", "0");
await expect(app).toHaveAttribute("data-open-cleanup-reader-bytes", "0");
await expect(app).toHaveAttribute("data-open-cleanup-reader-staging", "0");
await expect(app).toHaveAttribute("data-open-cleanup-engine-requests", "0");
await expect(app).toHaveAttribute("data-open-cleanup-engine-bytes", "0");
await expect(app).toHaveAttribute("data-open-cleanup-engine-handles", "0");
await expect(app).toHaveAttribute("data-open-cleanup-engine-staging", "0");
const cancelledBytes = Number(await app.getAttribute("data-open-read-bytes"));
expect(cancelledBytes).toBeGreaterThan(0);
expect(cancelledBytes).toBeLessThan(totalBytes);
await expect(page.getByTestId("scene-stats")).toHaveText(previousStats ?? "");
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
expect(await page.evaluate(() => localStorage.getItem("blender-web:last-project-id"))).toBe("basic_scene");
});
test("M7-04 destroys isolated native open resources before reporting cancellation", async ({ page }) => {
test.setTimeout(60_000);
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.getByText("BasicCube", { exact: true })).toBeVisible();
const previousStats = await page.getByTestId("scene-stats").textContent();
await page.getByTestId("blend-file-input").setInputFiles(largeBlend);
const cancellation = await page.waitForFunction(() => {
const progress = document.querySelector<HTMLElement>('[data-testid="open-progress"]');
const stage = progress?.dataset.stage;
const cancel = document.querySelector<HTMLButtonElement>('button[aria-label="取消打开"]');
if (!cancel || (stage !== "NATIVE_INITIALIZE" && stage !== "NATIVE_OPENED")) return false;
cancel.click();
return stage;
}, undefined, { polling: "raf", timeout: 10_000 });
expect(["NATIVE_INITIALIZE", "NATIVE_OPENED"]).toContain(await cancellation.jsonValue());
const app = page.locator(".blender-app");
await expect(app).toHaveAttribute("data-user-action-open-status", "CANCELLED");
await expect(app).toHaveAttribute("data-user-action-open-error", "OPEN_CANCELLED");
await expect(app).toHaveAttribute("data-open-cleanup-reader-count", "0");
await expect(app).toHaveAttribute("data-open-cleanup-reader-bytes", "0");
await expect(app).toHaveAttribute("data-open-cleanup-reader-staging", "0");
await expect(app).toHaveAttribute("data-open-cleanup-engine-requests", "0");
await expect(app).toHaveAttribute("data-open-cleanup-engine-bytes", "0");
await expect(app).toHaveAttribute("data-open-cleanup-engine-handles", "0");
await expect(app).toHaveAttribute("data-open-cleanup-engine-staging", "0");
await expect(page.getByTestId("scene-stats")).toHaveText(previousStats ?? "");
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "添加立方体" }).click();
await expect(page.getByTestId("scene-stats")).toContainText("Objects 4");
const download = page.waitForEvent("download");
await page.getByRole("button", { name: "保存项目" }).click();
await download;
});

View File

@@ -0,0 +1,95 @@
import { expect, test, type Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
async function waitForEngine(page: Page): Promise<void> {
await page.goto("/");
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
}
test("M7-02 rejects a repeated open before it can replace the first project owner", async ({ page }) => {
await waitForEngine(page);
const bytes = Array.from(fs.readFileSync(basicBlend));
await page.evaluate(({ blendBytes }) => {
const input = document.querySelector<HTMLInputElement>("[data-testid=blend-file-input]");
if (!input) throw new Error("blend input missing");
const dispatchOpen = (name: string): void => {
const transfer = new DataTransfer();
transfer.items.add(new File([new Uint8Array(blendBytes)], name, { type: "application/octet-stream" }));
input.files = transfer.files;
input.dispatchEvent(new Event("change", { bubbles: true }));
};
dispatchOpen("first-open.blend");
dispatchOpen("second-open.blend");
}, { blendBytes: bytes });
const app = page.locator(".blender-app");
await expect(app).toHaveAttribute("data-project-action-conflict", "USER_ACTION_CONFLICT");
await expect(app).toHaveAttribute("data-project-action-conflict-reason", "REPEATED_OPEN");
await expect(app).toHaveAttribute("data-project-action-conflict-requested", "OPEN");
await expect(app).toHaveAttribute("data-project-action-conflict-owner", "OPEN");
await expect(app).toHaveAttribute("data-user-action-open-status", "SUCCEEDED");
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
expect(await page.evaluate(() => localStorage.getItem("blender-web:last-project-id"))).toBe("first-open");
});
test("M7-02 grants only one concurrent manual save and emits one download", async ({ page }) => {
await waitForEngine(page);
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
let downloadCount = 0;
page.on("download", () => { downloadCount += 1; });
await page.evaluate(() => {
const save = document.querySelector<HTMLButtonElement>('button[aria-label="保存项目"]');
if (!save) throw new Error("save button missing");
save.click();
save.click();
});
const app = page.locator(".blender-app");
await expect(app).toHaveAttribute("data-project-action-conflict", "USER_ACTION_CONFLICT");
await expect(app).toHaveAttribute("data-project-action-conflict-reason", "CONCURRENT_SAVE");
await expect(app).toHaveAttribute("data-project-action-conflict-requested", "SAVE");
await expect(app).toHaveAttribute("data-project-action-conflict-owner", "SAVE");
await expect(app).toHaveAttribute("data-user-action-save-status", "SUCCEEDED");
await expect(app).toHaveAttribute("data-user-action-save-as-status", "SUCCEEDED");
await expect.poll(() => downloadCount).toBe(1);
});
test("M7-02 blocks close until save commit completes without terminating either worker", async ({ page }) => {
await waitForEngine(page);
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
const firstDownload = page.waitForEvent("download");
await page.evaluate(() => {
const save = document.querySelector<HTMLButtonElement>('button[aria-label="保存项目"]');
const close = document.querySelector<HTMLButtonElement>('button[aria-label="关闭项目"]');
if (!save || !close) throw new Error("project action button missing");
save.click();
close.click();
});
const app = page.locator(".blender-app");
await expect(app).toHaveAttribute("data-project-action-conflict", "USER_ACTION_CONFLICT");
await expect(app).toHaveAttribute("data-project-action-conflict-reason", "CLOSE_DURING_SAVE");
await expect(app).toHaveAttribute("data-project-action-conflict-requested", "CLOSE");
await expect(app).toHaveAttribute("data-project-action-conflict-owner", "SAVE");
await firstDownload;
await expect(app).toHaveAttribute("data-user-action-save-status", "SUCCEEDED");
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "添加立方体" }).click();
await expect(page.getByTestId("scene-stats")).toContainText("Objects 4");
const secondDownload = page.waitForEvent("download");
await page.getByRole("button", { name: "保存项目" }).click();
await secondDownload;
await expect(app).not.toHaveAttribute("data-project-action-conflict");
await page.getByRole("button", { name: "关闭项目" }).click();
await expect(page.getByTestId("scene-stats")).toContainText("Objects 0");
await expect(page.getByRole("button", { name: "关闭项目" })).toBeDisabled();
});

View File

@@ -0,0 +1,78 @@
import { expect, test } from "@playwright/test";
test("loads the selected pthread engine with shared memory and its worker pool", async ({ page }) => {
const pthreadRequests: string[] = [];
page.on("request", (request) => {
const pathname = new URL(request.url()).pathname;
if (pathname.startsWith("/vendor/blender/pthread/")) pthreadRequests.push(pathname);
});
const documentResponse = await page.goto("/");
expect(documentResponse).not.toBeNull();
const headers = await documentResponse!.allHeaders();
expect(headers["cross-origin-opener-policy"]).toBe("same-origin");
expect(headers["cross-origin-embedder-policy"]).toBe("require-corp");
const result = await page.evaluate(async () => {
const { detectBrowserCapabilities, selectWebEngineVariant } = await import("/src/platform/capabilities.ts");
const manifest = await fetch("/engine-manifest.json", { cache: "no-store" }).then((response) => response.json());
const selection = selectWebEngineVariant(manifest, "PTHREAD_REQUIRED", detectBrowserCapabilities());
if (!selection.selectedVariant) {
return { selected: null, gate: selection.pthreadGate, shared: false, handle: 0, liveAfterDestroy: -1 };
}
const variant = selection.selectedVariant;
const wasmResponse = await fetch(variant.resources.wasm.url, { cache: "no-store" });
if (!wasmResponse.ok) throw new Error(`pthread WASM request failed: ${wasmResponse.status}`);
const wasmBinary = await wasmResponse.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", wasmBinary);
const actualHash = [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
if (actualHash !== variant.resources.wasm.sha256) throw new Error("pthread WASM hash mismatch");
const imported = await import(/* @vite-ignore */ variant.resources.js.url) as {
default: (options: { wasmBinary: ArrayBuffer }) => Promise<{
HEAPU8: Uint8Array;
_web_engine_create(): number;
_web_engine_destroy(handle: number): void;
_web_engine_get_live_handles(): number;
PThread: {
unusedWorkers: Worker[];
runningWorkers: Worker[];
terminateAllThreads(): void;
};
}>;
};
const module = await imported.default({ wasmBinary });
const handle = module._web_engine_create();
const shared = module.HEAPU8.buffer instanceof SharedArrayBuffer;
const liveBeforeDestroy = module._web_engine_get_live_handles();
const poolWorkersBeforeDispose = module.PThread.unusedWorkers.length + module.PThread.runningWorkers.length;
module._web_engine_destroy(handle);
module.PThread.terminateAllThreads();
return {
selected: variant.id,
gate: selection.pthreadGate,
shared,
handle,
liveBeforeDestroy,
liveAfterDestroy: module._web_engine_get_live_handles(),
poolWorkersBeforeDispose,
poolWorkersAfterDispose: module.PThread.unusedWorkers.length + module.PThread.runningWorkers.length,
};
});
expect(result).toMatchObject({
selected: "pthread",
gate: { taskId: "M6-02", capability: "WASM_PTHREAD_ENGINE", status: "READY", issues: [] },
shared: true,
liveBeforeDestroy: 1,
liveAfterDestroy: 0,
poolWorkersBeforeDispose: 1,
poolWorkersAfterDispose: 0,
});
expect(result.handle).toBeGreaterThan(0);
expect(pthreadRequests.filter((url) => url.endsWith("/web_engine.wasm"))).toHaveLength(1);
expect(pthreadRequests.filter((url) => url.endsWith("/web_engine.js")).length).toBeGreaterThanOrEqual(2);
});

View File

@@ -0,0 +1,58 @@
import { expect, test } from "@playwright/test";
test("cleans an initialized pthread attempt before one single fallback", async ({ page }) => {
const variantRequests: string[] = [];
page.on("request", (request) => {
const pathname = new URL(request.url()).pathname;
if (pathname.startsWith("/vendor/blender/single/") || pathname.startsWith("/vendor/blender/pthread/")) {
variantRequests.push(pathname);
}
});
await page.goto("/");
await expect(page.locator(".status-bar")).toContainText("Manifest: verified");
variantRequests.length = 0;
const report = await page.evaluate(async () => {
const { detectBrowserCapabilities } = await import("/src/platform/capabilities.ts");
const {
bootstrapWebEngineRelease,
engineVariantStatusLabel,
} = await import("/src/engine-client/engine-variant-bootstrap.ts");
const { createBrowserEngineVariantSession } = await import("/src/engine-client/browser-engine-variant-session.ts");
const manifest = await fetch("/engine-manifest.json", { cache: "no-store" }).then((response) => response.json());
const outcome = await bootstrapWebEngineRelease(
manifest.releaseId,
manifest,
"AUTO",
detectBrowserCapabilities(),
{ initialize: createBrowserEngineVariantSession },
undefined,
{ failPthreadAfterInitialize: true, seedTrackedPthreadResources: true },
);
if (!outcome.session) throw new Error("current release unexpectedly requires refresh");
const activeBeforeDispose = outcome.session.resourceState();
const label = engineVariantStatusLabel(outcome.result);
const activeAfterDispose = await outcome.session.dispose();
return { result: outcome.result, activeBeforeDispose, activeAfterDispose, label };
});
expect(report.result).toMatchObject({
status: "READY",
selected: "single",
attempted: ["pthread", "single"],
fallbackReason: {
code: "PTHREAD_INITIALIZATION_FAILED",
message: "TEST_PTHREAD_INITIALIZATION_FAILURE",
},
openCount: 0,
failedAttemptCleanup: { handles: 0, workers: 0, timers: 0, pendingRequests: 0 },
});
expect(report.activeBeforeDispose).toEqual({ handles: 1, workers: 0, timers: 0, pendingRequests: 0 });
expect(report.activeAfterDispose).toEqual({ handles: 0, workers: 0, timers: 0, pendingRequests: 0 });
expect(report.label).toBe("Runtime: single (pthread fallback: PTHREAD_INITIALIZATION_FAILED)");
expect(report.label).not.toMatch(/pthread\s+ready/i);
expect(variantRequests.filter((url) => url.endsWith("/pthread/web_engine.wasm"))).toHaveLength(1);
expect(variantRequests.filter((url) => url.endsWith("/single/web_engine.wasm"))).toHaveLength(1);
expect(variantRequests.filter((url) => url.endsWith("/pthread/web_engine.js")).length).toBeGreaterThanOrEqual(3);
expect(variantRequests.filter((url) => url.endsWith("/single/web_engine.js")).length).toBeGreaterThanOrEqual(2);
});

View File

@@ -0,0 +1,62 @@
import { expect, test } from "@playwright/test";
test("M7-05 restores old revision, hash and bytes after every storage save interruption", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const { StorageClient } = await import("/src/storage/StorageClient.ts");
const projectId = `m7-save-interrupt-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const oldBytes = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 7, 7, 7]);
const nextBytes = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 8, 8, 8, 8]);
const initial = new StorageClient();
const baseline = await initial.saveProject(projectId, 7, oldBytes.slice().buffer);
initial.terminate();
const observations = [];
for (const faultAt of ["after-stage", "after-scene-commit", "before-metadata-commit"] as const) {
const attempt = new StorageClient();
let error = "";
try {
await attempt.saveProject(projectId, 8, nextBytes.slice().buffer, faultAt);
}
catch (reason) {
error = reason instanceof Error ? reason.message : String(reason);
}
attempt.terminate();
const restarted = new StorageClient();
const restored = await restarted.readProject(projectId);
restarted.terminate();
observations.push({
faultAt,
error,
revision: restored.revision,
sha256: restored.sha256,
bytes: Array.from(new Uint8Array(restored.buffer)),
recovered: restored.recovered,
});
}
const completed = new StorageClient();
const committed = await completed.saveProject(projectId, 8, nextBytes.slice().buffer);
const reopened = await completed.readProject(projectId);
completed.terminate();
return {
baseline: { revision: baseline.revision, sha256: baseline.sha256, bytes: Array.from(oldBytes) },
observations,
committed: { revision: committed.revision, sha256: committed.sha256, reopenedRevision: reopened.revision, reopenedSha256: reopened.sha256 },
};
});
expect(result.observations).toHaveLength(3);
for (const item of result.observations) {
expect(item.error).toContain(`PROJECT_SAVE_FAULT_INJECTED: ${item.faultAt}`);
expect(item.revision).toBe(result.baseline.revision);
expect(item.sha256).toBe(result.baseline.sha256);
expect(item.bytes).toEqual(result.baseline.bytes);
expect(item.recovered).toBe(false);
}
expect(result.committed.revision).toBe(8);
expect(result.committed.reopenedRevision).toBe(8);
expect(result.committed.sha256).toBe(result.committed.reopenedSha256);
expect(result.committed.sha256).not.toBe(result.baseline.sha256);
});

View File

@@ -0,0 +1,110 @@
import { expect, test } from "@playwright/test";
import path from "node:path";
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
test("boots and edits with the single-thread engine without cross-origin isolation", async ({ page }) => {
const engineAssetUrls: string[] = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (/web_engine|pthread/i.test(url.pathname)) engineAssetUrls.push(url.pathname);
});
const documentResponse = await page.goto("/");
expect(documentResponse).not.toBeNull();
const headers = await documentResponse!.allHeaders();
expect(headers["cross-origin-opener-policy"]).toBeUndefined();
expect(headers["cross-origin-embedder-policy"]).toBeUndefined();
expect(headers["cross-origin-resource-policy"]).toBeUndefined();
const platform = await page.evaluate(async () => {
const {
detectBrowserCapabilities,
gateWasmThreadingCapability,
selectWebEngineVariant,
} = await import("/src/platform/capabilities.ts");
const capabilities = detectBrowserCapabilities();
const pthreadGate = gateWasmThreadingCapability(capabilities);
const manifest = await fetch("/engine-manifest.json", { cache: "no-store" }).then((response) => response.json()) as {
schemaVersion: number;
memory?: { shared: boolean };
variants?: Array<{
id: "single" | "pthread";
memory: { shared: boolean };
resources: { js: { url: string }; wasm: { url: string } };
}>;
};
const singleMemory = manifest.schemaVersion === 2
? manifest.variants?.find((variant) => variant.id === "single")?.memory
: manifest.memory;
const selection = selectWebEngineVariant(
manifest as Parameters<typeof selectWebEngineVariant>[0],
"AUTO",
capabilities,
);
if (!selection.selectedVariant) throw new Error("AUTO did not select a single-thread variant");
const selected = selection.selectedVariant;
const wasmBinary = await fetch(selected.resources.wasm.url, { cache: "no-store" })
.then((response) => {
if (!response.ok) throw new Error(`single WASM request failed: ${response.status}`);
return response.arrayBuffer();
});
const imported = await import(/* @vite-ignore */ selected.resources.js.url) as {
default: (options: { wasmBinary: ArrayBuffer }) => Promise<{
HEAPU8: Uint8Array;
_web_engine_create(): number;
_web_engine_destroy(handle: number): void;
_web_engine_get_live_handles(): number;
}>;
};
const module = await imported.default({ wasmBinary });
const variantHandle = module._web_engine_create();
const variantSharedMemory = typeof SharedArrayBuffer !== "undefined" &&
module.HEAPU8.buffer instanceof SharedArrayBuffer;
module._web_engine_destroy(variantHandle);
return {
crossOriginIsolated: capabilities.crossOriginIsolated,
sharedArrayBuffer: capabilities.sharedArrayBuffer,
worker: capabilities.worker,
pthreadGate,
autoSelectedVariant: selected.id,
variantHandle,
variantSharedMemory,
variantLiveAfterDestroy: module._web_engine_get_live_handles(),
manifestSchemaVersion: manifest.schemaVersion,
manifestSharedMemory: singleMemory?.shared,
};
});
expect(platform).toMatchObject({
crossOriginIsolated: false,
sharedArrayBuffer: false,
worker: true,
autoSelectedVariant: "single",
variantSharedMemory: false,
variantLiveAfterDestroy: 0,
manifestSchemaVersion: 2,
manifestSharedMemory: false,
pthreadGate: {
taskId: "M6-02",
capability: "WASM_PTHREAD_ENGINE",
status: "BLOCKED",
},
});
expect(platform.variantHandle).toBeGreaterThan(0);
expect(platform.pthreadGate.issues.map((issue) => issue.path)).toEqual([
"crossOriginIsolated",
"sharedArrayBuffer",
]);
await expect(page.getByTestId("engine-status")).toContainText("Engine: ready", { timeout: 30_000 });
await expect(page.locator(".status-bar")).toContainText("WASM ABI: ready");
expect(engineAssetUrls.some((url) => url.endsWith("/vendor/blender/web_engine.wasm"))).toBe(true);
expect(engineAssetUrls.some((url) => url.endsWith("/vendor/blender/single/web_engine.wasm"))).toBe(true);
expect(engineAssetUrls.some((url) => /pthread/i.test(url))).toBe(false);
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3 · Vertices 8 · Faces 6");
await page.getByRole("button", { name: "添加立方体" }).click();
await expect(page.getByTestId("scene-stats")).toContainText("Objects 4 · Vertices 16 · Faces 18");
});

View File

@@ -1511,9 +1511,9 @@ test("autosaves a dirty project without starting a download", async ({ page }) =
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "已保存" }).click();
await expect(page.getByRole("button", { name: "未保存" })).toBeVisible();
await expect(page.getByRole("button", { name: "已保存" })).toBeVisible({ timeout: 15_000 });
await page.getByRole("button", { name: "添加立方体" }).click();
await expect(page.getByTestId("dirty-status")).toHaveText("未保存");
await expect(page.getByTestId("dirty-status")).toHaveText("已保存", { timeout: 15_000 });
});
test("persists an operation log entry with an inverse payload", async ({ page }) => {

View File

@@ -0,0 +1,69 @@
import { expect, test } from "@playwright/test";
import path from "node:path";
const materialBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend");
const exportableBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
const image = path.resolve(import.meta.dirname, "../../../tests/files/web/media/sequencer-frame.png");
test("M7-01 records import, open, save, save-as and export success", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
const app = page.locator(".blender-app");
await page.getByTestId("blend-file-input").setInputFiles(exportableBlend);
await expect(app).toHaveAttribute("data-user-action-open-status", "SUCCEEDED");
await expect(app).toHaveAttribute("data-user-action-open-id", /^OPEN:\d+$/);
const glbDownload = page.waitForEvent("download");
await page.getByRole("button", { name: "导出 GLB" }).click();
expect((await glbDownload).suggestedFilename()).toBe("blender-web.glb");
await expect(app).toHaveAttribute("data-user-action-export-status", "SUCCEEDED");
await expect(app).toHaveAttribute("data-user-action-export-id", /^EXPORT:\d+$/);
await page.getByTestId("blend-file-input").setInputFiles(materialBlend);
await expect(app).toHaveAttribute("data-user-action-open-status", "SUCCEEDED");
await page.locator("label.file-button input[type=file]").setInputFiles(image);
await expect(app).toHaveAttribute("data-user-action-import-status", "SUCCEEDED");
await expect(app).toHaveAttribute("data-user-action-import-id", /^IMPORT:\d+$/);
const blendDownload = page.waitForEvent("download");
await page.getByRole("button", { name: "保存项目" }).click();
expect((await blendDownload).suggestedFilename()).toBe("blender-web.blend");
await expect(app).toHaveAttribute("data-user-action-save-status", "SUCCEEDED");
await expect(app).toHaveAttribute("data-user-action-save-as-status", "SUCCEEDED");
await expect(app).toHaveAttribute("data-user-action-save-id", /^SAVE:\d+$/);
await expect(app).toHaveAttribute("data-user-action-save-as-id", /^SAVE_AS:\d+$/);
await expect(app).toHaveAttribute("data-save-transaction-status", "SUCCEEDED");
await expect(app).toHaveAttribute("data-save-transaction-stage", "METADATA_COMMIT");
await expect(app).toHaveAttribute("data-save-committed-hash", /^[a-f0-9]{64}$/);
});
test("M7-01 exposes stable failure codes without replacing the last valid scene", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
const app = page.locator(".blender-app");
await page.getByRole("button", { name: "导出 GLB" }).click();
await expect(app).toHaveAttribute("data-user-action-export-status", "FAILED");
await expect(app).toHaveAttribute("data-user-action-export-error", "EXPORT_PROJECT_UNAVAILABLE");
await page.getByTestId("blend-file-input").setInputFiles(materialBlend);
await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible();
await page.getByTestId("blend-file-input").setInputFiles({
name: "invalid.blend",
mimeType: "application/octet-stream",
buffer: Buffer.from([0x42, 0x41, 0x44]),
});
await expect(app).toHaveAttribute("data-user-action-open-status", "FAILED");
await expect(app).toHaveAttribute("data-user-action-open-error", "OPEN_ENGINE_FAILED");
await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible();
await page.locator("label.file-button input[type=file]").setInputFiles({
name: "invalid.png",
mimeType: "image/png",
buffer: Buffer.from("not-a-png"),
});
await expect(app).toHaveAttribute("data-user-action-import-status", "FAILED");
await expect(app).toHaveAttribute("data-user-action-import-error", "IMPORT_DECODE_FAILED");
});

View File

@@ -1,10 +1,15 @@
import { expect, test } from "@playwright/test";
test("gates only the pthread WASM engine on its three platform requirements", async ({ page }) => {
await page.goto("/");
const documentResponse = await page.goto("/");
expect(documentResponse).not.toBeNull();
await expect.poll(async () => (await documentResponse!.allHeaders())["cross-origin-opener-policy"]).toBe("same-origin");
await expect.poll(async () => (await documentResponse!.allHeaders())["cross-origin-embedder-policy"]).toBe("require-corp");
await expect.poll(async () => (await documentResponse!.allHeaders())["cross-origin-resource-policy"]).toBe("same-origin");
const gates = await page.evaluate(async () => {
const { gateWasmThreadingCapability } = await import("/src/platform/capabilities.ts");
const { detectBrowserCapabilities, gateWasmThreadingCapability } = await import("/src/platform/capabilities.ts");
return {
actual: gateWasmThreadingCapability(detectBrowserCapabilities()),
ready: gateWasmThreadingCapability({ crossOriginIsolated: true, sharedArrayBuffer: true, worker: true }),
noIsolation: gateWasmThreadingCapability({ crossOriginIsolated: false, sharedArrayBuffer: true, worker: true }),
noSharedArrayBuffer: gateWasmThreadingCapability({ crossOriginIsolated: true, sharedArrayBuffer: false, worker: true }),
@@ -13,6 +18,11 @@ test("gates only the pthread WASM engine on its three platform requirements", as
};
});
expect(gates.actual).toMatchObject({
capability: "WASM_PTHREAD_ENGINE",
status: "READY",
issues: [],
});
expect(gates.ready).toEqual({
taskId: "M6-02",
capability: "WASM_PTHREAD_ENGINE",

View File

@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import ts from "typescript";
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const sourcePath = path.join(repoRoot, "web/protocol/dirty-state.ts");
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: sourcePath,
reportDiagnostics: true,
});
assert.deepEqual(transpiled.diagnostics, []);
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`;
const { acceptHistoryTransaction, acceptMainSave, acceptMainTransaction, createDirtyState, recoverDirtyState } = await import(moduleUrl);
test("M7-06 only marks dirty after an accepted Main transaction", () => {
const clean = createDirtyState(7);
const stale = acceptMainTransaction(clean, 7);
assert.deepEqual(stale, { ok: false, state: clean, errorCode: "DIRTY_REVISION_STALE" });
const edited = acceptMainTransaction(clean, 8);
assert.equal(edited.ok, true);
assert.deepEqual(edited.state, { currentMainRevision: 8, committedMainRevision: 7, dirty: true });
});
test("M7-06 failed, preview and UI-only work preserve the same dirty object", () => {
const clean = createDirtyState(3);
const failedCommandState = clean;
const previewState = failedCommandState;
const uiOnlyState = previewState;
assert.equal(failedCommandState, clean);
assert.equal(previewState, clean);
assert.equal(uiOnlyState, clean);
});
test("M7-06 clears dirty only for a save matching the accepted Main revision", () => {
const edited = recoverDirtyState(9, 7);
assert.deepEqual(acceptMainSave(edited, 8), { ok: false, state: edited, errorCode: "DIRTY_SAVE_REVISION_MISMATCH" });
const saved = acceptMainSave(edited, 9);
assert.equal(saved.ok, true);
assert.deepEqual(saved.state, { currentMainRevision: 9, committedMainRevision: 9, dirty: false });
});
test("M7-07 keeps transaction revisions monotonic while undo content toggles dirty", () => {
const saved = createDirtyState(10);
const edited = acceptMainTransaction(saved, 11);
assert.equal(edited.ok, true);
const undone = acceptHistoryTransaction(edited.state, 12, true);
assert.equal(undone.ok, true);
assert.deepEqual(undone.state, { currentMainRevision: 12, committedMainRevision: 12, dirty: false });
const redone = acceptHistoryTransaction(undone.state, 13, false);
assert.equal(redone.ok, true);
assert.deepEqual(redone.state, { currentMainRevision: 13, committedMainRevision: 12, dirty: true });
});

View File

@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import ts from "typescript";
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const protocolPath = path.join(repoRoot, "web/protocol/manifest.ts");
const protocolSource = fs.readFileSync(protocolPath, "utf8");
const transpiled = ts.transpileModule(protocolSource, {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: protocolPath,
reportDiagnostics: true,
});
assert.deepEqual(transpiled.diagnostics, []);
const protocol = await import(`data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`);
const { validateWebEngineManifestV2 } = protocol;
const goldenPath = path.join(repoRoot, "tests/golden/M6-04A/engine-manifest-v2.json");
const golden = JSON.parse(fs.readFileSync(goldenPath, "utf8"));
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/package.json"), "utf8"));
function deepFreeze(value) {
if (value && typeof value === "object") {
Object.freeze(value);
for (const item of Object.values(value)) deepFreeze(item);
}
return value;
}
function invalidCase(name, mutate, code, errorPath) {
return { name, mutate, code, errorPath };
}
test("M6-04A accepts an immutable single/pthread manifest without mutating it", () => {
const input = deepFreeze(structuredClone(golden));
const parsed = validateWebEngineManifestV2(input);
assert.deepEqual(parsed, golden);
assert.notEqual(parsed, input);
assert.notEqual(parsed.variants[0], input.variants[0]);
assert.notEqual(parsed.variants[0].resources.js, input.variants[0].resources.js);
assert.equal(parsed.variants[1].resources.pthreadWorker.url, parsed.variants[1].resources.js.url);
assert.equal(protocol.WEB_ENGINE_WASM_PAGE_BYTES, 65_536);
assert.deepEqual(protocol.WEB_ENGINE_MEMORY_LIMITS, {
minimumInitialPages: 256,
maximumPages: 32_768,
});
const reversed = validateWebEngineManifestV2({ ...golden, variants: [...golden.variants].reverse() });
assert.deepEqual(reversed.variants.map((variant) => variant.id), ["single", "pthread"]);
});
test("M6-04A rejects malformed variants, resources and memory declarations", async (t) => {
const cases = [
invalidCase("old schema", (value) => { value.schemaVersion = 1; }, "PROTOCOL_MISMATCH", "schemaVersion"),
invalidCase("unsupported protocol", (value) => { value.protocolVersion = 2; }, "PROTOCOL_MISMATCH", "protocolVersion"),
invalidCase("missing release ID", (value) => { delete value.releaseId; }, "ENGINE_MANIFEST_INVALID", "releaseId"),
invalidCase("invalid release ID", (value) => { value.releaseId = "release/id"; }, "ENGINE_MANIFEST_INVALID", "releaseId"),
invalidCase("missing pthread variant", (value) => { value.variants.pop(); }, "ENGINE_MANIFEST_INVALID", "variants"),
invalidCase("duplicate single variant", (value) => { value.variants[1].id = "single"; delete value.variants[1].resources.pthreadWorker; value.variants[1].memory.shared = false; }, "ENGINE_MANIFEST_INVALID", "variants"),
invalidCase("single shared memory", (value) => { value.variants[0].memory.shared = true; }, "ENGINE_MANIFEST_INVALID", "variants[0].memory.shared"),
invalidCase("single pthread worker", (value) => { value.variants[0].resources.pthreadWorker = structuredClone(value.variants[1].resources.pthreadWorker); }, "ENGINE_MANIFEST_INVALID", "variants[0].resources.pthreadWorker"),
invalidCase("pthread unshared memory", (value) => { value.variants[1].memory.shared = false; }, "ENGINE_MANIFEST_INVALID", "variants[1].memory.shared"),
invalidCase("missing pthread worker", (value) => { delete value.variants[1].resources.pthreadWorker; }, "ENGINE_MANIFEST_INVALID", "variants[1].resources.pthreadWorker"),
invalidCase("invalid SHA-256", (value) => { value.variants[1].resources.wasm.sha256 = "ABC"; }, "ENGINE_MANIFEST_INVALID", "variants[1].resources.wasm.sha256"),
invalidCase("remote resource URL", (value) => { value.variants[0].resources.js.url = "https://cdn.invalid/web_engine.single.js"; }, "ENGINE_MANIFEST_INVALID", "variants[0].resources.js.url"),
invalidCase("file name mismatch", (value) => { value.variants[0].resources.wasm.url = "/vendor/blender/wrong.wasm"; }, "ENGINE_MANIFEST_INVALID", "variants[0].resources.wasm.url"),
invalidCase("initial memory below floor", (value) => { value.variants[0].memory.initialPages = 255; }, "ENGINE_MANIFEST_INVALID", "variants[0].memory.initialPages"),
invalidCase("maximum memory below initial", (value) => { value.variants[1].memory.maximumPages = 255; }, "ENGINE_MANIFEST_INVALID", "variants[1].memory.maximumPages"),
invalidCase("maximum memory above ceiling", (value) => { value.variants[1].memory.maximumPages = 32_769; }, "ENGINE_MANIFEST_INVALID", "variants[1].memory.maximumPages"),
invalidCase("aliased variant WASM", (value) => { value.variants[1].resources.wasm = structuredClone(value.variants[0].resources.wasm); }, "ENGINE_MANIFEST_INVALID", "variants.wasm"),
invalidCase("worker aliases single JS", (value) => { value.variants[1].resources.pthreadWorker = structuredClone(value.variants[0].resources.js); }, "ENGINE_MANIFEST_INVALID", "variants.pthreadWorker"),
invalidCase("same worker URL with another hash", (value) => { value.variants[1].resources.pthreadWorker.sha256 = "5".repeat(64); }, "ENGINE_MANIFEST_INVALID", "variants.pthreadWorker.sha256"),
invalidCase("unknown manifest field", (value) => { value.defaultVariant = "pthread"; }, "ENGINE_MANIFEST_INVALID", "manifest.defaultVariant"),
];
for (const item of cases) {
await t.test(item.name, () => {
const input = structuredClone(golden);
item.mutate(input);
assert.throws(
() => validateWebEngineManifestV2(input),
(error) => error?.name === "WebEngineManifestValidationError" &&
error.code === item.code && error.path === item.errorPath,
);
});
}
});
test("M6-04B installs the production manifest with explicit variant paths", () => {
const production = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/app/public/engine-manifest.json"), "utf8"));
const parsed = validateWebEngineManifestV2(production);
assert.equal(parsed.releaseId, `blender-wasm-${packageJson.version}`);
assert.equal(parsed.variants[0].resources.wasm.url, "/vendor/blender/single/web_engine.wasm");
assert.equal(parsed.variants[1].resources.wasm.url, "/vendor/blender/pthread/web_engine.wasm");
assert.notEqual(parsed.variants[0].resources.wasm.sha256, parsed.variants[1].resources.wasm.sha256);
});

View File

@@ -0,0 +1,249 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import ts from "typescript";
const repoRoot = path.resolve(import.meta.dirname, "../../..");
function transpileDataUrl(filePath, replacements = new Map()) {
const result = ts.transpileModule(fs.readFileSync(filePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: filePath,
reportDiagnostics: true,
});
assert.deepEqual(result.diagnostics, []);
let source = result.outputText;
for (const [specifier, replacement] of replacements) {
source = source.replaceAll(`"${specifier}"`, JSON.stringify(replacement));
}
return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
}
const gatesUrl = transpileDataUrl(path.join(repoRoot, "web/protocol/capability-gates.ts"));
const selectorUrl = transpileDataUrl(
path.join(repoRoot, "web/protocol/engine-variant.ts"),
new Map([["./capability-gates", gatesUrl]]),
);
const bootstrapUrl = transpileDataUrl(
path.join(repoRoot, "web/app/src/engine-client/engine-variant-bootstrap.ts"),
new Map([["../../../protocol/engine-variant", selectorUrl]]),
);
const {
bootstrapWebEngineRelease,
engineVariantStatusLabel,
EngineVariantLoadError,
} = await import(bootstrapUrl);
const manifest = JSON.parse(fs.readFileSync(
path.join(repoRoot, "tests/golden/M6-04A/engine-manifest-v2.json"),
"utf8",
));
const ready = { crossOriginIsolated: true, sharedArrayBuffer: true, worker: true };
class FakeSession {
constructor(variant) {
this.variant = variant;
this.opened = [];
this.disposals = 0;
this.state = {
handles: 1,
workers: variant.id === "pthread" ? 1 : 0,
timers: 0,
pendingRequests: 0,
};
}
async openProject(project) {
this.opened.push(project);
}
resourceState() {
return { ...this.state };
}
testOnlySeedTrackedResources() {
this.state.timers = 1;
this.state.pendingRequests = 1;
}
async dispose() {
this.disposals += 1;
this.state = { handles: 0, workers: 0, timers: 0, pendingRequests: 0 };
return this.resourceState();
}
}
function dependencies() {
const sessions = [];
return {
sessions,
initialize: async (variant) => {
const session = new FakeSession(variant);
sessions.push(session);
return session;
},
};
}
test("M6-05A/B/C injects one pthread failure, cleans it and falls back once", async () => {
const deps = dependencies();
const outcome = await bootstrapWebEngineRelease(
manifest.releaseId,
manifest,
"AUTO",
ready,
deps,
undefined,
{ failPthreadAfterInitialize: true, seedTrackedPthreadResources: true },
);
assert.deepEqual(outcome.result.attempted, ["pthread", "single"]);
assert.equal(deps.sessions.length, 2);
assert.equal(deps.sessions[0].disposals, 1);
assert.deepEqual(outcome.result.failedAttemptCleanup, {
handles: 0,
workers: 0,
timers: 0,
pendingRequests: 0,
});
assert.equal(outcome.result.selected, "single");
assert.deepEqual(outcome.result.fallbackReason, {
code: "PTHREAD_INITIALIZATION_FAILED",
message: "TEST_PTHREAD_INITIALIZATION_FAILURE",
});
assert.equal(outcome.result.openCount, 0);
assert.equal(deps.sessions[0].opened.length, 0);
assert.equal(deps.sessions[1].opened.length, 0);
});
test("M6-05D opens a pending project once only after the fallback settles", async () => {
const deps = dependencies();
const project = { id: "pending-project" };
const outcome = await bootstrapWebEngineRelease(
manifest.releaseId,
manifest,
"AUTO",
ready,
deps,
project,
{ failPthreadAfterInitialize: true },
);
assert.equal(outcome.result.openCount, 1);
assert.deepEqual(deps.sessions[0].opened, []);
assert.deepEqual(deps.sessions[1].opened, [project]);
});
test("M6-05E exposes selected, attempted and fallbackReason without pthread READY UI", async () => {
const deps = dependencies();
const outcome = await bootstrapWebEngineRelease(
manifest.releaseId,
manifest,
"AUTO",
ready,
deps,
undefined,
{ failPthreadAfterInitialize: true },
);
const label = engineVariantStatusLabel(outcome.result);
assert.deepEqual(
{
selected: outcome.result.selected,
attempted: outcome.result.attempted,
fallbackReason: outcome.result.fallbackReason?.code,
},
{
selected: "single",
attempted: ["pthread", "single"],
fallbackReason: "PTHREAD_INITIALIZATION_FAILED",
},
);
assert.equal(label, "Runtime: single (pthread fallback: PTHREAD_INITIALIZATION_FAILED)");
assert.doesNotMatch(label, /pthread\s+ready/i);
});
test("M6-08E treats a manifest hash mismatch as fatal without fallback or project open", async () => {
const initialized = [];
const project = { id: "must-stay-unopened" };
await assert.rejects(
bootstrapWebEngineRelease(
manifest.releaseId,
manifest,
"AUTO",
ready,
{
initialize: async (variant) => {
initialized.push(variant.id);
if (variant.id === "pthread") {
throw new EngineVariantLoadError("ENGINE_VARIANT_RESOURCE_HASH_MISMATCH", variant.resources.wasm.url);
}
return new FakeSession(variant);
},
},
project,
),
(error) => error?.code === "ENGINE_VARIANT_INTEGRITY_FAILED" &&
error.cause?.code === "ENGINE_VARIANT_RESOURCE_HASH_MISMATCH" &&
JSON.stringify(error.attempted) === JSON.stringify(["pthread"]),
);
assert.deepEqual(initialized, ["pthread"]);
});
test("M6-08D returns refresh-required before initialization or project open", async () => {
let initializationCount = 0;
const outcome = await bootstrapWebEngineRelease(
"blender-wasm-previous",
manifest,
"AUTO",
ready,
{
initialize: async (variant) => {
initializationCount += 1;
return new FakeSession(variant);
},
},
{ id: "must-stay-unopened" },
);
assert.deepEqual(outcome, {
result: {
status: "REFRESH_REQUIRED",
expectedReleaseId: "blender-wasm-previous",
actualReleaseId: manifest.releaseId,
manifest: null,
},
session: null,
});
assert.equal(initializationCount, 0);
});
test("M6-08E normalizes a fallback variant hash mismatch and never opens the project", async () => {
const initialized = [];
const pthreadSession = new FakeSession(manifest.variants[1]);
await assert.rejects(
bootstrapWebEngineRelease(
manifest.releaseId,
manifest,
"AUTO",
ready,
{
initialize: async (variant) => {
initialized.push(variant.id);
if (variant.id === "single") {
throw new EngineVariantLoadError("ENGINE_VARIANT_RESOURCE_HASH_MISMATCH", variant.resources.wasm.url);
}
return pthreadSession;
},
},
{ id: "must-stay-unopened" },
{ failPthreadAfterInitialize: true },
),
(error) => error?.code === "ENGINE_VARIANT_INTEGRITY_FAILED" &&
error.cause?.code === "ENGINE_VARIANT_RESOURCE_HASH_MISMATCH" &&
JSON.stringify(error.attempted) === JSON.stringify(["pthread", "single"]),
);
assert.deepEqual(initialized, ["pthread", "single"]);
assert.equal(pthreadSession.disposals, 1);
assert.deepEqual(pthreadSession.opened, []);
});

View File

@@ -0,0 +1,125 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import ts from "typescript";
const repoRoot = path.resolve(import.meta.dirname, "../../..");
function transpileDataUrl(filePath, replacements = new Map()) {
const result = ts.transpileModule(fs.readFileSync(filePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: filePath,
reportDiagnostics: true,
});
assert.deepEqual(result.diagnostics, []);
let source = result.outputText;
for (const [specifier, replacement] of replacements) {
source = source.replaceAll(`"${specifier}"`, JSON.stringify(replacement));
}
return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
}
const gatesUrl = transpileDataUrl(path.join(repoRoot, "web/protocol/capability-gates.ts"));
const selectorUrl = transpileDataUrl(
path.join(repoRoot, "web/protocol/engine-variant.ts"),
new Map([["./capability-gates", gatesUrl]]),
);
const { bindWebEngineRelease, gateWasmThreadingCapability, selectWebEngineVariant } = await import(selectorUrl);
const manifest = JSON.parse(fs.readFileSync(
path.join(repoRoot, "tests/golden/M6-04A/engine-manifest-v2.json"),
"utf8",
));
const ready = { crossOriginIsolated: true, sharedArrayBuffer: true, worker: true };
function deepFreeze(value) {
if (value && typeof value === "object") {
Object.freeze(value);
for (const item of Object.values(value)) deepFreeze(item);
}
return value;
}
test("M6-04C selects from only its manifest, policy and capability inputs", () => {
const frozenManifest = deepFreeze(structuredClone(manifest));
const frozenCapabilities = deepFreeze({ ...ready });
const first = selectWebEngineVariant(frozenManifest, "SINGLE_REQUIRED", frozenCapabilities);
const second = selectWebEngineVariant(frozenManifest, "SINGLE_REQUIRED", frozenCapabilities);
assert.deepEqual(first, second);
assert.equal(first.policy, "SINGLE_REQUIRED");
assert.equal(first.selectedVariant, frozenManifest.variants[0]);
assert.equal(first.selectedVariant.id, "single");
assert.equal(first.pthreadGate.status, "READY");
assert.throws(
() => selectWebEngineVariant(frozenManifest, "INVALID", frozenCapabilities),
/ENGINE_VARIANT_POLICY_INVALID/,
);
});
test("M6-04D AUTO selects pthread only when the M6-02 gate is READY", () => {
const capabilityCases = [
[ready, "pthread", []],
[{ ...ready, crossOriginIsolated: false }, "single", ["crossOriginIsolated"]],
[{ ...ready, sharedArrayBuffer: false }, "single", ["sharedArrayBuffer"]],
[{ ...ready, worker: false }, "single", ["worker"]],
[
{ crossOriginIsolated: false, sharedArrayBuffer: false, worker: false },
"single",
["crossOriginIsolated", "sharedArrayBuffer", "worker"],
],
];
for (const [capabilities, expectedId, expectedPaths] of capabilityCases) {
const selection = selectWebEngineVariant(manifest, "AUTO", capabilities);
assert.equal(selection.selectedVariant.id, expectedId);
assert.equal(selection.pthreadGate.status, expectedId === "pthread" ? "READY" : "BLOCKED");
assert.deepEqual(selection.pthreadGate.issues.map((issue) => issue.path), expectedPaths);
}
});
test("M6-04E PTHREAD_REQUIRED exposes the M6-02 block without a requestable variant", () => {
const capabilities = { crossOriginIsolated: false, sharedArrayBuffer: false, worker: true };
const expectedGate = gateWasmThreadingCapability(capabilities);
const blocked = selectWebEngineVariant(manifest, "PTHREAD_REQUIRED", capabilities);
assert.equal(blocked.selectedVariant, null);
assert.deepEqual(blocked.pthreadGate, expectedGate);
assert.deepEqual(blocked.pthreadGate, {
taskId: "M6-02",
capability: "WASM_PTHREAD_ENGINE",
status: "BLOCKED",
issues: [
{
code: "PLATFORM_CAPABILITY_UNAVAILABLE",
message: "Cross-origin isolation is required for the pthread WASM engine",
path: "crossOriginIsolated",
recoverable: true,
},
{
code: "PLATFORM_CAPABILITY_UNAVAILABLE",
message: "SharedArrayBuffer is required for the pthread WASM engine",
path: "sharedArrayBuffer",
recoverable: true,
},
],
});
const allowed = selectWebEngineVariant(manifest, "PTHREAD_REQUIRED", ready);
assert.equal(allowed.selectedVariant.id, "pthread");
assert.equal(allowed.pthreadGate.status, "READY");
});
test("M6-08C/D binds both variants to one release and refuses mixed-version startup", () => {
const current = bindWebEngineRelease(manifest.releaseId, manifest);
assert.equal(current.status, "READY");
assert.equal(current.manifest, manifest);
assert.deepEqual(current.manifest.variants.map((variant) => variant.id), ["single", "pthread"]);
const switched = bindWebEngineRelease("blender-wasm-previous", manifest);
assert.deepEqual(switched, {
status: "REFRESH_REQUIRED",
expectedReleaseId: "blender-wasm-previous",
actualReleaseId: manifest.releaseId,
manifest: null,
});
});

View File

@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import ts from "typescript";
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const sourcePath = path.join(repoRoot, "web/protocol/file-byte-reader.ts");
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: sourcePath,
reportDiagnostics: true,
});
assert.deepEqual(transpiled.diagnostics, []);
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`;
const { FileByteReadError, readFileBytes } = await import(moduleUrl);
function chunkSource(chunks, declaredSize = chunks.reduce((total, chunk) => total + (chunk.byteLength ?? chunk.length), 0)) {
return {
size: declaredSize,
stream() {
let index = 0;
return new ReadableStream({
pull(controller) {
if (index === chunks.length) controller.close();
else controller.enqueue(Uint8Array.from(chunks[index++]));
},
});
},
};
}
test("M7-03 reports progress from exact consumed byte counts", async () => {
const observations = [];
const result = await readFileBytes(chunkSource([[1, 2], [3, 4, 5], [6]]), {
signal: new AbortController().signal,
onProgress: (item) => observations.push(item),
});
assert.deepEqual(Array.from(new Uint8Array(result)), [1, 2, 3, 4, 5, 6]);
assert.deepEqual(observations.map(({ phase, bytesRead, totalBytes, fraction }) => [phase, bytesRead, totalBytes, fraction]), [
["STARTED", 0, 6, 0],
["READING", 2, 6, 2 / 6],
["READING", 5, 6, 5 / 6],
["READING", 6, 6, 1],
["COMPLETED", 6, 6, 1],
]);
});
test("M7-03 cancels between chunks without reporting completion", async () => {
const controller = new AbortController();
const observations = [];
const resources = [];
await assert.rejects(
readFileBytes(chunkSource([[1, 2], [3, 4], [5, 6]]), {
signal: controller.signal,
onProgress: (item) => {
observations.push(item);
if (item.phase === "READING" && item.bytesRead === 2) controller.abort();
},
onResourceState: (state) => resources.push(state),
}),
(error) => error instanceof FileByteReadError && error.code === "FILE_READ_CANCELLED",
);
assert.deepEqual(observations.map((item) => [item.phase, item.bytesRead]), [
["STARTED", 0],
["READING", 2],
["CANCELLED", 2],
]);
assert.deepEqual(resources, [
{ liveReaders: 1, liveInputBytes: 6, liveStagingFiles: 0 },
{ liveReaders: 0, liveInputBytes: 0, liveStagingFiles: 0 },
]);
});
test("M7-03 rejects streams shorter or longer than their declared byte size", async () => {
await assert.rejects(
readFileBytes(chunkSource([[1, 2]], 3), { signal: new AbortController().signal }),
(error) => error instanceof FileByteReadError && error.code === "FILE_READ_SIZE_MISMATCH",
);
await assert.rejects(
readFileBytes(chunkSource([[1, 2, 3]], 2), { signal: new AbortController().signal }),
(error) => error instanceof FileByteReadError && error.code === "FILE_READ_SIZE_MISMATCH",
);
});

View File

@@ -30,6 +30,10 @@ test("required renderer and engine assets are vendored", () => {
"app/src/vendor/blender/web_engine.wasm",
"app/public/vendor/blender/web_engine.js",
"app/public/vendor/blender/web_engine.wasm",
"app/public/vendor/blender/single/web_engine.js",
"app/public/vendor/blender/single/web_engine.wasm",
"app/public/vendor/blender/pthread/web_engine.js",
"app/public/vendor/blender/pthread/web_engine.wasm",
];
for (const relativePath of requiredFiles) {
const filePath = path.join(webRoot, relativePath);
@@ -47,6 +51,12 @@ test("required renderer and engine assets are vendored", () => {
}
});
test("schema v2 engine assets carry a valid release identity", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(webRoot, "app/public/engine-manifest.json"), "utf8"));
assert.equal(manifest.schemaVersion, 2);
assert.match(manifest.releaseId, /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/);
});
test("attribute geometry fixture is reproducible input", () => {
const fixture = path.join(webRoot, "..", "tests/files/web/attribute_scene.blend");
const manifest = JSON.parse(fs.readFileSync(path.join(webRoot, "..", "tests/files/web/manifest.json"), "utf8"));

View File

@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import ts from "typescript";
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const sourcePath = path.join(repoRoot, "web/protocol/project-action-mutex.ts");
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: sourcePath,
reportDiagnostics: true,
});
assert.deepEqual(transpiled.diagnostics, []);
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`;
const {
PROJECT_ACTION_CONFLICT_MATRIX,
acquireProjectAction,
createProjectActionMutexState,
releaseProjectAction,
} = await import(moduleUrl);
const action = (kind, sequence) => ({ kind, actionId: `${kind}:${sequence}` });
test("M7-02 keeps the project action conflict matrix symmetric and exclusive", () => {
for (const requested of ["OPEN", "SAVE", "CLOSE"]) {
for (const owner of ["OPEN", "SAVE", "CLOSE"]) {
assert.equal(PROJECT_ACTION_CONFLICT_MATRIX[requested][owner], true);
assert.equal(PROJECT_ACTION_CONFLICT_MATRIX[requested][owner], PROJECT_ACTION_CONFLICT_MATRIX[owner][requested]);
}
}
});
test("M7-02 rejects repeated open with its original owner unchanged", () => {
const first = action("OPEN", 1);
const second = action("OPEN", 2);
const acquired = acquireProjectAction(createProjectActionMutexState(), first);
assert.equal(acquired.granted, true);
assert.deepEqual(acquireProjectAction(acquired.state, second), {
granted: false,
state: acquired.state,
conflict: { code: "USER_ACTION_CONFLICT", reason: "REPEATED_OPEN", requested: second, owner: first },
});
});
test("M7-02 rejects concurrent save and close-during-save with stable reasons", () => {
const first = action("SAVE", 1);
const acquired = acquireProjectAction(createProjectActionMutexState(), first);
assert.equal(acquired.granted, true);
assert.equal(acquireProjectAction(acquired.state, action("SAVE", 2)).conflict.reason, "CONCURRENT_SAVE");
assert.equal(acquireProjectAction(acquired.state, action("CLOSE", 3)).conflict.reason, "CLOSE_DURING_SAVE");
});
test("M7-02 only lets the exact owner release the project lock", () => {
const owner = action("SAVE", 1);
const acquired = acquireProjectAction(createProjectActionMutexState(), owner);
assert.equal(acquired.granted, true);
assert.deepEqual(releaseProjectAction(acquired.state, action("SAVE", 2)), {
released: false,
state: acquired.state,
errorCode: "PROJECT_ACTION_LOCK_IDENTITY_MISMATCH",
});
assert.deepEqual(releaseProjectAction(acquired.state, owner), {
released: true,
state: { owner: null },
errorCode: null,
});
});

View File

@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import ts from "typescript";
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const sourcePath = path.join(repoRoot, "web/protocol/save-transaction.ts");
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: sourcePath,
reportDiagnostics: true,
});
assert.deepEqual(transpiled.diagnostics, []);
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`;
const {
SAVE_ATTEMPT_STAGES,
advanceSaveTransaction,
beginSaveTransaction,
commitSaveTransaction,
createSaveTransactionState,
failSaveTransaction,
} = await import(moduleUrl);
const oldCommit = { revision: 7, sha256: "a".repeat(64) };
const newCommit = { revision: 8, sha256: "b".repeat(64) };
function stateAt(stage) {
let result = beginSaveTransaction(createSaveTransactionState(oldCommit), newCommit.revision);
assert.equal(result.ok, true);
for (const next of SAVE_ATTEMPT_STAGES.slice(1, SAVE_ATTEMPT_STAGES.indexOf(stage) + 1)) {
result = advanceSaveTransaction(result.state, next, next === "OPFS_STAGE" ? newCommit.sha256 : undefined);
assert.equal(result.ok, true);
}
return result.state;
}
test("M7-05 preserves the old committed identity at every failed save stage", () => {
for (const stage of SAVE_ATTEMPT_STAGES) {
const failed = failSaveTransaction(stateAt(stage), `SAVE_${stage}_INTERRUPTED`);
assert.equal(failed.status, "FAILED");
assert.equal(failed.stage, stage);
assert.deepEqual(failed.committed, oldCommit);
assert.equal(failed.candidate.revision, newCommit.revision);
}
});
test("M7-05 advances committed revision and hash only after matching metadata commit", () => {
const metadata = stateAt("METADATA_COMMIT");
assert.deepEqual(commitSaveTransaction(metadata, { ...newCommit, sha256: "c".repeat(64) }), {
ok: false,
state: metadata,
errorCode: "SAVE_TRANSACTION_COMMIT_MISMATCH",
});
const committed = commitSaveTransaction(metadata, newCommit);
assert.equal(committed.ok, true);
assert.deepEqual(committed.state.committed, newCommit);
assert.equal(committed.state.status, "SUCCEEDED");
});
test("M7-05 rejects reentrant and out-of-order save transitions", () => {
const running = beginSaveTransaction(createSaveTransactionState(oldCommit), 8);
assert.equal(running.ok, true);
assert.equal(beginSaveTransaction(running.state, 9).errorCode, "SAVE_TRANSACTION_INVALID_TRANSITION");
assert.equal(advanceSaveTransaction(running.state, "SCENE_COMMIT").errorCode, "SAVE_TRANSACTION_INVALID_TRANSITION");
});

View File

@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import ts from "typescript";
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const sourcePath = path.join(repoRoot, "web/protocol/user-action-state.ts");
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: sourcePath,
reportDiagnostics: true,
});
assert.deepEqual(transpiled.diagnostics, []);
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`;
const {
USER_ACTION_KINDS,
createInitialUserActionStates,
reduceUserActionStates,
transitionUserAction,
} = await import(moduleUrl);
function identity(kind, sequence = 1) {
return { kind, actionId: `${kind}:${sequence}` };
}
test("M7-01 initializes all five user actions at IDLE", () => {
const states = createInitialUserActionStates();
assert.deepEqual(Object.keys(states), USER_ACTION_KINDS);
for (const kind of USER_ACTION_KINDS) {
assert.deepEqual(states[kind], { kind, status: "IDLE", identity: null, errorCode: null });
}
});
test("M7-01 records deterministic RUNNING and SUCCEEDED transitions for every action", () => {
let states = createInitialUserActionStates();
for (const kind of USER_ACTION_KINDS) {
const currentIdentity = identity(kind);
states = reduceUserActionStates(states, { type: "START", identity: currentIdentity });
assert.deepEqual(states[kind], { kind, status: "RUNNING", identity: currentIdentity, errorCode: null });
states = reduceUserActionStates(states, { type: "SUCCEED", identity: currentIdentity });
assert.deepEqual(states[kind], { kind, status: "SUCCEEDED", identity: currentIdentity, errorCode: null });
}
});
test("M7-01 records stable failure and cancellation codes", () => {
const failedIdentity = identity("OPEN");
let failed = transitionUserAction(createInitialUserActionStates().OPEN, { type: "START", identity: failedIdentity });
assert.equal(failed.ok, true);
failed = transitionUserAction(failed.state, { type: "FAIL", identity: failedIdentity, errorCode: "OPEN_ENGINE_FAILED" });
assert.deepEqual(failed, {
ok: true,
state: { kind: "OPEN", status: "FAILED", identity: failedIdentity, errorCode: "OPEN_ENGINE_FAILED" },
});
const cancelledIdentity = identity("IMPORT");
let cancelled = transitionUserAction(createInitialUserActionStates().IMPORT, { type: "START", identity: cancelledIdentity });
assert.equal(cancelled.ok, true);
cancelled = transitionUserAction(cancelled.state, { type: "CANCEL", identity: cancelledIdentity });
assert.deepEqual(cancelled, {
ok: true,
state: { kind: "IMPORT", status: "CANCELLED", identity: cancelledIdentity, errorCode: "USER_ACTION_CANCELLED" },
});
});
test("M7-01 rejects wrong identities, reused identities and invalid transitions without mutation", () => {
const first = identity("SAVE");
const other = identity("SAVE", 2);
const initial = createInitialUserActionStates().SAVE;
const beforeStart = transitionUserAction(initial, { type: "SUCCEED", identity: first });
assert.deepEqual(beforeStart, { ok: false, state: initial, errorCode: "USER_ACTION_INVALID_TRANSITION" });
const running = transitionUserAction(initial, { type: "START", identity: first });
assert.equal(running.ok, true);
assert.deepEqual(transitionUserAction(running.state, { type: "FAIL", identity: other, errorCode: "SAVE_FAILED" }), {
ok: false,
state: running.state,
errorCode: "USER_ACTION_IDENTITY_MISMATCH",
});
assert.deepEqual(transitionUserAction(running.state, { type: "FAIL", identity: first, errorCode: "not-stable" }), {
ok: false,
state: running.state,
errorCode: "USER_ACTION_INVALID_ERROR_CODE",
});
const succeeded = transitionUserAction(running.state, { type: "SUCCEED", identity: first });
assert.equal(succeeded.ok, true);
assert.deepEqual(transitionUserAction(succeeded.state, { type: "START", identity: first }), {
ok: false,
state: succeeded.state,
errorCode: "USER_ACTION_IDENTITY_REUSED",
});
});