Advance M7 workflows and release operations
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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