154 lines
7.3 KiB
JavaScript
154 lines
7.3 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import { writeFile } from "node:fs/promises";
|
|
import { MemoryStorage, WorkspaceJournal } from "./journal.mjs";
|
|
import { createCheckpoint } from "./recovery.mjs";
|
|
import { ReplayTimeline, canonicalJson, fnv1a64 } from "../worker/replay.mjs";
|
|
import {
|
|
applyRollbackPackage,
|
|
createOfflinePackage,
|
|
createRollbackPackage,
|
|
createWorkspacePackage,
|
|
migrateWorkspacePackage,
|
|
verifyOfflinePackage,
|
|
verifyWorkspacePackage,
|
|
} from "./migration.mjs";
|
|
|
|
const contextId = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
|
|
|
|
function timelineAt(events) {
|
|
const timeline = new ReplayTimeline();
|
|
timeline.appendEvent(1, 0, "SESSION_READY", {});
|
|
timeline.checkpoint(1, 0, { tick: 0, position: 0 });
|
|
if (events) {
|
|
timeline.appendEvent(2, 4, "SIM_STEP", { cycles: 4 });
|
|
timeline.checkpoint(2, 4, { tick: 4, position: 4 }, timeline.snapshots.latest().snapshot_id);
|
|
}
|
|
return timeline;
|
|
}
|
|
|
|
const storage = new MemoryStorage();
|
|
const journal = new WorkspaceJournal(storage, { projectId: "demo" });
|
|
journal.prepare("op-1", [{ path: "config/main.ini", data: "[EMC]\n" }]);
|
|
journal.commit("op-1");
|
|
const journalRecords = journal.records();
|
|
journal.prepare("op-2", [{ path: "programs/main.ngc", data: "G0 X1\n" }]);
|
|
journal.commit("op-2");
|
|
const currentJournalRecords = journal.records();
|
|
|
|
const committedTimeline = timelineAt(false);
|
|
const committedCheckpoint = createCheckpoint({
|
|
contextId,
|
|
timeline: committedTimeline,
|
|
state: { tick: 0, position: 0 },
|
|
workspaceHash: fnv1a64(journalRecords),
|
|
});
|
|
const currentTimeline = timelineAt(true);
|
|
const currentCheckpoint = createCheckpoint({
|
|
contextId,
|
|
timeline: currentTimeline,
|
|
state: { tick: 4, position: 4 },
|
|
workspaceHash: fnv1a64(currentJournalRecords),
|
|
});
|
|
|
|
const committed = createWorkspacePackage({ contextId, timeline: committedTimeline, checkpoint: committedCheckpoint, journal: journalRecords, committedSeq: 1 });
|
|
const current = createWorkspacePackage({ contextId, timeline: currentTimeline, checkpoint: currentCheckpoint, journal: currentJournalRecords, committedSeq: 2 });
|
|
assert.equal(verifyWorkspacePackage(committed, { contextId }).package_hash, committed.package_hash);
|
|
assert.equal(verifyWorkspacePackage(current, { contextId }).package_hash, current.package_hash);
|
|
assert.deepEqual(createWorkspacePackage({ contextId, timeline: currentTimeline, checkpoint: currentCheckpoint, journal: currentJournalRecords, committedSeq: 2 }), current);
|
|
|
|
const migrated = migrateWorkspacePackage(current, 2);
|
|
assert.equal(migrated.format_version, 2);
|
|
assert.equal(migrated.migration.lossless, true);
|
|
assert.equal(verifyWorkspacePackage(migrated, { contextId, expectedVersion: 2 }).format_version, 2);
|
|
const roundTrip = migrateWorkspacePackage(migrated, 1);
|
|
assert.deepEqual(roundTrip, current);
|
|
assert.throws(() => migrateWorkspacePackage(current, 3), /package_version_unsupported/);
|
|
assert.throws(() => verifyWorkspacePackage(current, { contextId: "other-context" }), /package_context_mismatch/);
|
|
const tampered = structuredClone(migrated);
|
|
tampered.timeline.events.events[1].payload.cycles = 99;
|
|
assert.throws(() => verifyWorkspacePackage(tampered), /(?:package_(?:content_)?hash_mismatch|event_payload_hash_mismatch)/);
|
|
|
|
const rollback = createRollbackPackage({ current, committed });
|
|
assert.equal(rollback.nearest_committed_seq, 1);
|
|
assert.throws(() => applyRollbackPackage(rollback, { currentPackageHash: current.package_hash, failureInjection: "before_commit" }), /rollback_injected_failure/);
|
|
const rolledBack = applyRollbackPackage(rollback, { currentPackageHash: current.package_hash });
|
|
assert.deepEqual(rolledBack, committed);
|
|
assert.equal(current.package_hash, current.package_hash);
|
|
const badRollback = structuredClone(rollback);
|
|
badRollback.target_package_hash = "fnv1a64:0000000000000000";
|
|
assert.throws(() => applyRollbackPackage(badRollback, { currentPackageHash: current.package_hash }), /rollback_hash_mismatch/);
|
|
|
|
const offline = createOfflinePackage({
|
|
workspacePackage: migrated,
|
|
resources: [
|
|
{ path: "ui/index.html", data: "<main>LinuxCNC</main>" },
|
|
{ path: "wasm/sim.wasm", data: "wasm-bytes" },
|
|
],
|
|
});
|
|
assert.deepEqual(verifyOfflinePackage(offline, { contextId, workspacePackage: migrated, available: [{ path: "ui/index.html", data: "<main>LinuxCNC</main>" }, { path: "wasm/sim.wasm", data: "wasm-bytes" }] }), { status: "ready", missing: [], corrupted: [], offline: true });
|
|
assert.throws(() => verifyOfflinePackage(offline, { contextId, available: ["ui/index.html"], online: false }), /offline_resource_invalid/);
|
|
assert.deepEqual(verifyOfflinePackage(offline, { contextId, available: ["ui/index.html"], online: true }), { status: "network_required", missing: ["wasm/sim.wasm"], corrupted: [], offline: false });
|
|
|
|
const report = {
|
|
schema: "workspace-migration-report.v1",
|
|
context_id: contextId,
|
|
status: "passed",
|
|
implementation_status: "verified",
|
|
format: {
|
|
schema: "workspace-package.v1",
|
|
supported_versions: [1, 2],
|
|
payload_schemas_unchanged: ["event-log.v1", "snapshot.v1", "simulation-checkpoint.v1", "workspace-journal.v1"],
|
|
old_package_immutable: true,
|
|
},
|
|
migration: {
|
|
status: "passed",
|
|
source_version: current.format_version,
|
|
target_version: migrated.format_version,
|
|
source_hash: current.package_hash,
|
|
migrated_hash: migrated.package_hash,
|
|
round_trip_hash: roundTrip.package_hash,
|
|
round_trip_equal: roundTrip.package_hash === current.package_hash,
|
|
deterministic: migrated.package_hash === migrateWorkspacePackage(current, 2).package_hash,
|
|
},
|
|
rollback: {
|
|
status: "passed",
|
|
schema: rollback.schema,
|
|
current_hash: current.package_hash,
|
|
target_hash: committed.package_hash,
|
|
nearest_committed_seq: rollback.nearest_committed_seq,
|
|
rollback_hash: rollback.rollback_hash,
|
|
failure_injection: "before_commit",
|
|
failure_result: "rollback_injected_failure",
|
|
recovery_status: "recovered",
|
|
recovery_hash: rolledBack.package_hash,
|
|
},
|
|
offline: {
|
|
status: "passed",
|
|
schema: offline.schema,
|
|
bundle_hash: offline.bundle_hash,
|
|
required_paths: offline.required_paths,
|
|
offline_start: "ready",
|
|
missing_resource_result: "offline_resource_invalid",
|
|
online_degraded_result: "network_required",
|
|
package_path: "coverage/offline-cache.package.json",
|
|
},
|
|
rollback_package_path: "coverage/workspace-rollback.package.json",
|
|
recovery_endpoint: {
|
|
status: "verified",
|
|
endpoint: "nearest_committed_package",
|
|
state_hash: committedCheckpoint.state_hash,
|
|
no_silent_downgrade: true,
|
|
},
|
|
oracle: {
|
|
status: "blocked",
|
|
reason: "official migration and browser offline oracle is absent from the locked source",
|
|
blocked_samples: ["native/WASM package migration", "browser Service Worker offline startup", "OPFS rollback after crash"],
|
|
},
|
|
};
|
|
report.report_sha256 = createHash("sha256").update(canonicalJson(report), "utf8").digest("hex");
|
|
await writeFile(new URL("../../coverage/workspace-rollback.package.json", import.meta.url), `${JSON.stringify(rollback, null, 2)}\n`);
|
|
await writeFile(new URL("../../coverage/offline-cache.package.json", import.meta.url), `${JSON.stringify(offline, null, 2)}\n`);
|
|
await writeFile(new URL("../../coverage/workspace-migration.report.json", import.meta.url), `${JSON.stringify(report, null, 2)}\n`);
|
|
console.log(`migration=passed source=${current.package_hash} target=${migrated.package_hash} rollback=${rollback.rollback_hash}`);
|