Advance M7 workflows and release operations
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
19
web/app/public/vendor/blender/pthread/web_engine.js
vendored
Normal file
19
web/app/public/vendor/blender/pthread/web_engine.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
web/app/public/vendor/blender/pthread/web_engine.wasm
vendored
Normal file
BIN
web/app/public/vendor/blender/pthread/web_engine.wasm
vendored
Normal file
Binary file not shown.
16
web/app/public/vendor/blender/single/web_engine.js
vendored
Normal file
16
web/app/public/vendor/blender/single/web_engine.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
web/app/public/vendor/blender/single/web_engine.wasm
vendored
Normal file
BIN
web/app/public/vendor/blender/single/web_engine.wasm
vendored
Normal file
Binary file not shown.
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
|
||||
139
web/app/src/engine-client/browser-engine-variant-session.ts
Normal file
139
web/app/src/engine-client/browser-engine-variant-session.ts
Normal 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);
|
||||
}
|
||||
195
web/app/src/engine-client/engine-variant-bootstrap.ts
Normal file
195
web/app/src/engine-client/engine-variant-bootstrap.ts
Normal 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";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
4
web/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
42
web/playwright.archive.config.ts
Normal file
42
web/playwright.archive.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
39
web/playwright.single-thread.config.ts
Normal file
39
web/playwright.single-thread.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
53
web/protocol/dirty-state.ts
Normal file
53
web/protocol/dirty-state.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
96
web/protocol/engine-variant.ts
Normal file
96
web/protocol/engine-variant.ts
Normal 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");
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
132
web/protocol/file-byte-reader.ts
Normal file
132
web/protocol/file-byte-reader.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
79
web/protocol/project-action-mutex.ts
Normal file
79
web/protocol/project-action-mutex.ts
Normal 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 };
|
||||
}
|
||||
81
web/protocol/save-transaction.ts
Normal file
81
web/protocol/save-transaction.ts
Normal 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 } };
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
109
web/protocol/user-action-state.ts
Normal file
109
web/protocol/user-action-state.ts
Normal 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;
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
147
web/tests/e2e/archive-offline.spec.ts
Normal file
147
web/tests/e2e/archive-offline.spec.ts
Normal 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([]);
|
||||
});
|
||||
|
||||
69
web/tests/e2e/dirty-state.spec.ts
Normal file
69
web/tests/e2e/dirty-state.spec.ts
Normal 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 ?? "");
|
||||
});
|
||||
92
web/tests/e2e/engine-upgrade-safety.spec.ts
Normal file
92
web/tests/e2e/engine-upgrade-safety.spec.ts
Normal 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);
|
||||
});
|
||||
111
web/tests/e2e/file-import-progress.spec.ts
Normal file
111
web/tests/e2e/file-import-progress.spec.ts
Normal 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;
|
||||
});
|
||||
95
web/tests/e2e/project-action-mutex.spec.ts
Normal file
95
web/tests/e2e/project-action-mutex.spec.ts
Normal 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();
|
||||
});
|
||||
78
web/tests/e2e/pthread-engine-isolated.spec.ts
Normal file
78
web/tests/e2e/pthread-engine-isolated.spec.ts
Normal 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);
|
||||
});
|
||||
58
web/tests/e2e/pthread-fallback.spec.ts
Normal file
58
web/tests/e2e/pthread-fallback.spec.ts
Normal 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);
|
||||
});
|
||||
62
web/tests/e2e/save-interruption.spec.ts
Normal file
62
web/tests/e2e/save-interruption.spec.ts
Normal 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);
|
||||
});
|
||||
110
web/tests/e2e/single-thread-unisolated.spec.ts
Normal file
110
web/tests/e2e/single-thread-unisolated.spec.ts
Normal 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");
|
||||
});
|
||||
@@ -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 }) => {
|
||||
|
||||
69
web/tests/e2e/user-action-state.spec.ts
Normal file
69
web/tests/e2e/user-action-state.spec.ts
Normal 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");
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
55
web/tests/unit/dirty-state.test.mjs
Normal file
55
web/tests/unit/dirty-state.test.mjs
Normal 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 });
|
||||
});
|
||||
97
web/tests/unit/engine-manifest-v2.test.mjs
Normal file
97
web/tests/unit/engine-manifest-v2.test.mjs
Normal 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);
|
||||
});
|
||||
249
web/tests/unit/engine-variant-fallback.test.mjs
Normal file
249
web/tests/unit/engine-variant-fallback.test.mjs
Normal 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, []);
|
||||
});
|
||||
125
web/tests/unit/engine-variant-selection.test.mjs
Normal file
125
web/tests/unit/engine-variant-selection.test.mjs
Normal 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,
|
||||
});
|
||||
});
|
||||
84
web/tests/unit/file-byte-reader.test.mjs
Normal file
84
web/tests/unit/file-byte-reader.test.mjs
Normal 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",
|
||||
);
|
||||
});
|
||||
@@ -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"));
|
||||
|
||||
68
web/tests/unit/project-action-mutex.test.mjs
Normal file
68
web/tests/unit/project-action-mutex.test.mjs
Normal 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,
|
||||
});
|
||||
});
|
||||
66
web/tests/unit/save-transaction.test.mjs
Normal file
66
web/tests/unit/save-transaction.test.mjs
Normal 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");
|
||||
});
|
||||
93
web/tests/unit/user-action-state.test.mjs
Normal file
93
web/tests/unit/user-action-state.test.mjs
Normal 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",
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user