This commit is contained in:
parent
fcc18ddcc8
commit
e83946c207
57 changed files with 6257 additions and 775 deletions
627
tests/unit/readiness.test.ts
Normal file
627
tests/unit/readiness.test.ts
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/**
|
||||
* Stage 9 — Offline Ready predicate C1–C8, six-state taxonomy, boot evaluation.
|
||||
* Pure truth table (every state reachable) + boot integration against
|
||||
* fake-indexeddb using the Stage 8 staging/activation path.
|
||||
* Trace: SPIKE-05 §2–§5, IMPLEMENTATION-CONTRACT.md §20.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
// @ts-expect-error fake-indexeddb types via exports fallback
|
||||
import FDBFactory from "fake-indexeddb/lib/FDBFactory";
|
||||
// @ts-expect-error fake-indexeddb types via exports fallback
|
||||
import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const g = globalThis as unknown as Record<string, unknown>;
|
||||
g.indexedDB = new FDBFactory() as unknown;
|
||||
g.IDBKeyRange = FDBKeyRange as unknown;
|
||||
|
||||
import { buildPackage } from "../../pipeline/package.js";
|
||||
import { generateTestKeyPair } from "../../pipeline/sign.js";
|
||||
import { makeValidInput } from "../../pipeline/fixtures.js";
|
||||
import { canonicalJson } from "../../pipeline/canonical-json.js";
|
||||
import { publicKeyFromDerBase64 } from "../../src/sync/verifier/ed25519.js";
|
||||
import { verifyPackage } from "../../src/sync/verifier/package.js";
|
||||
import type { VerifiedPackage } from "../../src/sync/verifier/types.js";
|
||||
import {
|
||||
activateStagedPackage,
|
||||
restorePreviousSlot,
|
||||
stageVerifiedPackage,
|
||||
} from "../../src/sync/activation.js";
|
||||
import { openSystemDB, readSystemMeta, writeSystemMeta } from "../../src/data/system-meta/store.js";
|
||||
import { INITIAL_SYSTEM_META } from "../../src/data/system-meta/types.js";
|
||||
import {
|
||||
initializeStaging,
|
||||
markStagingComplete,
|
||||
openSlotDB,
|
||||
readSlotFileMeta,
|
||||
writeSlotFile,
|
||||
writeSlotFileWithProgress,
|
||||
writeSlotManifest,
|
||||
} from "../../src/data/slot/store.js";
|
||||
import type { FestivalManifest } from "../../src/data/festival-package/types.js";
|
||||
import { withTx } from "../../src/platform/idb/wrapper.js";
|
||||
import { DB, SLOT_FILES } from "../../src/platform/idb/names.js";
|
||||
import { APP_VERSION, SUPPORTED_SCHEMA_RANGE } from "../../src/domain/readiness/config.js";
|
||||
import {
|
||||
chipForState,
|
||||
evaluateReadiness,
|
||||
preparationLevel,
|
||||
} from "../../src/domain/readiness/logic.js";
|
||||
import type { ReadinessEvidence } from "../../src/domain/readiness/types.js";
|
||||
import { checkShellCache } from "../../src/platform/cache/shell-cache.js";
|
||||
import { defaultBootDeps, evaluateBootReadiness } from "../../src/app/readiness.js";
|
||||
import type { BootReadinessDeps } from "../../src/app/readiness.js";
|
||||
|
||||
const NOW = 1_750_000_000_000;
|
||||
|
||||
function okEvidence(patch: Partial<ReadinessEvidence> = {}): ReadinessEvidence {
|
||||
return {
|
||||
shellValid: true,
|
||||
datasetPresent: true,
|
||||
authenticity: "verified",
|
||||
integrity: "ok",
|
||||
schemaCompatible: true,
|
||||
requiredSectionsOk: true,
|
||||
requiredAssetsOk: true,
|
||||
emergencyFloorOk: true,
|
||||
missing: [],
|
||||
hadVerifiedDataset: true,
|
||||
manifestOk: true,
|
||||
incompatible: false,
|
||||
failure: null,
|
||||
presentSections: ["emergency", "schedule", "info", "map", "assets"] as readonly string[],
|
||||
stagedPending: null,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe("readiness predicate — C1–C8 truth table (SPIKE-05 §5)", () => {
|
||||
it("all true → READY, level L2, green chip", () => {
|
||||
const result = evaluateReadiness(okEvidence());
|
||||
expect(result.state).toBe("READY");
|
||||
expect(result.missing).toEqual([]);
|
||||
expect(result.level).toBe("L2");
|
||||
const chip = chipForState("READY");
|
||||
expect(chip.text).toBe("OFFLINE READY ✓");
|
||||
expect(chip.tone).toBe("ready");
|
||||
});
|
||||
|
||||
it("no dataset + never verified → NOT_READY; + previously verified → RECOVERY(missing)", () => {
|
||||
expect(
|
||||
evaluateReadiness(okEvidence({ datasetPresent: false, hadVerifiedDataset: false })).state,
|
||||
).toBe("NOT_READY");
|
||||
const recovery = evaluateReadiness(
|
||||
okEvidence({ datasetPresent: false, hadVerifiedDataset: true }),
|
||||
);
|
||||
expect(recovery.state).toBe("RECOVERY");
|
||||
expect(recovery.reason).toBe("missing");
|
||||
});
|
||||
|
||||
it("record present but files gone → RECOVERY(missing), not corrupt", () => {
|
||||
const result = evaluateReadiness(
|
||||
okEvidence({ authenticity: "mismatch", integrity: "files-missing", missing: ["schedule"] }),
|
||||
);
|
||||
expect(result.state).toBe("RECOVERY");
|
||||
expect(result.reason).toBe("missing");
|
||||
});
|
||||
|
||||
it("size tampering → RECOVERY(corrupt); record mismatch alone → RECOVERY(corrupt)", () => {
|
||||
expect(evaluateReadiness(okEvidence({ integrity: "corrupt" }))).toMatchObject({
|
||||
state: "RECOVERY",
|
||||
reason: "corrupt",
|
||||
});
|
||||
expect(evaluateReadiness(okEvidence({ authenticity: "mismatch" }))).toMatchObject({
|
||||
state: "RECOVERY",
|
||||
reason: "corrupt",
|
||||
});
|
||||
});
|
||||
|
||||
it("incompatible schema demotes to RECOVERY(incompatible), ahead of PARTIAL", () => {
|
||||
const result = evaluateReadiness(
|
||||
okEvidence({
|
||||
schemaCompatible: false,
|
||||
incompatible: true,
|
||||
requiredSectionsOk: false,
|
||||
missing: ["map"],
|
||||
}),
|
||||
);
|
||||
expect(result.state).toBe("RECOVERY");
|
||||
expect(result.reason).toBe("incompatible");
|
||||
});
|
||||
|
||||
it("files present but record missing → PARTIAL(verification), full re-verify path", () => {
|
||||
const result = evaluateReadiness(okEvidence({ authenticity: "record-missing" }));
|
||||
expect(result.state).toBe("PARTIAL");
|
||||
expect(result.missing).toContain("verification");
|
||||
});
|
||||
|
||||
it("staged manifest missing → PARTIAL(manifest)", () => {
|
||||
const result = evaluateReadiness(
|
||||
okEvidence({ manifestOk: false, requiredSectionsOk: false, missing: ["manifest"] }),
|
||||
);
|
||||
expect(result.state).toBe("PARTIAL");
|
||||
expect(result.missing).toContain("manifest");
|
||||
});
|
||||
|
||||
it("missing required sections/assets → PARTIAL(list); optional never gates", () => {
|
||||
const partial = evaluateReadiness(
|
||||
okEvidence({ requiredAssetsOk: false, missing: ["asset:map-base-overview"] }),
|
||||
);
|
||||
expect(partial.state).toBe("PARTIAL");
|
||||
expect(partial.missing).toEqual(["asset:map-base-overview"]);
|
||||
// Optional sections are not evidence at all — absence changes nothing.
|
||||
expect(evaluateReadiness(okEvidence()).state).toBe("READY");
|
||||
});
|
||||
|
||||
it("session failure → FAILED ahead of everything, even READY evidence", () => {
|
||||
const result = evaluateReadiness(okEvidence({ failure: { op: "activation" } }));
|
||||
expect(result.state).toBe("FAILED");
|
||||
expect(result.failedOp).toBe("activation");
|
||||
expect(chipForState("FAILED", "activation").tone).toBe("failed");
|
||||
});
|
||||
|
||||
it("broken floor build → FAILED(emergency-floor)", () => {
|
||||
const result = evaluateReadiness(okEvidence({ emergencyFloorOk: false }));
|
||||
expect(result.state).toBe("FAILED");
|
||||
expect(result.failedOp).toBe("emergency-floor");
|
||||
});
|
||||
|
||||
it("shell cache missing + dataset → PARTIAL(shell-cache); without dataset → NOT_READY", () => {
|
||||
expect(evaluateReadiness(okEvidence({ shellValid: false })).state).toBe("PARTIAL");
|
||||
expect(evaluateReadiness(okEvidence({ shellValid: false })).missing).toContain("shell-cache");
|
||||
expect(
|
||||
evaluateReadiness(
|
||||
okEvidence({ shellValid: false, datasetPresent: false, hadVerifiedDataset: false }),
|
||||
).state,
|
||||
).toBe("NOT_READY");
|
||||
});
|
||||
|
||||
it("interrupted staging with no active pointer → PARTIAL (resumable)", () => {
|
||||
const result = evaluateReadiness(
|
||||
okEvidence({
|
||||
datasetPresent: false,
|
||||
hadVerifiedDataset: false,
|
||||
requiredSectionsOk: false,
|
||||
requiredAssetsOk: false,
|
||||
missing: ["info", "map", "assets"],
|
||||
presentSections: ["emergency", "schedule"],
|
||||
stagedPending: { missing: ["info", "map", "assets"] },
|
||||
}),
|
||||
);
|
||||
expect(result.state).toBe("PARTIAL");
|
||||
expect(result.level).toBe("L1");
|
||||
});
|
||||
|
||||
it("preparation levels L0/L1/L2 follow staged sections, not trust", () => {
|
||||
expect(preparationLevel([])).toBe("L0");
|
||||
expect(preparationLevel(["emergency"])).toBe("L0");
|
||||
expect(preparationLevel(["emergency", "schedule"])).toBe("L1");
|
||||
expect(preparationLevel(["emergency", "schedule", "info", "map", "assets"])).toBe("L2");
|
||||
});
|
||||
|
||||
it("chip mapping covers every state", () => {
|
||||
expect(chipForState("PARTIAL", "map").tone).toBe("partial");
|
||||
expect(chipForState("NOT_READY").text).toBe("Get festival data");
|
||||
expect(chipForState("RECOVERY", "missing").text).toContain("missing");
|
||||
expect(chipForState("BASELINE_ONLY").tone).toBe("baseline");
|
||||
});
|
||||
|
||||
it("verdicts do not consult clocks", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2031-05-04T12:00:00Z"));
|
||||
expect(evaluateReadiness(okEvidence()).state).toBe("READY");
|
||||
vi.setSystemTime(new Date("1999-01-01T00:00:00Z"));
|
||||
expect(evaluateReadiness(okEvidence()).state).toBe("READY");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("APP_VERSION matches package.json; schema range covers v1", () => {
|
||||
const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { version: string };
|
||||
expect(APP_VERSION).toBe(pkg.version);
|
||||
expect(SUPPORTED_SCHEMA_RANGE).toContain(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shell cache check (C1)", () => {
|
||||
it("reports unavailable where Cache Storage is absent (Node)", async () => {
|
||||
expect(await checkShellCache()).toMatchObject({ ok: false, cacheName: null });
|
||||
});
|
||||
});
|
||||
|
||||
// ——— Boot integration ———
|
||||
|
||||
function deleteDb(name: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = (g.indexedDB as IDBFactory).deleteDatabase(name);
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
request.onerror = () => {
|
||||
reject(request.error ?? new Error("delete database failed"));
|
||||
};
|
||||
request.onblocked = () => {
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function fixture(version = 1): Promise<VerifiedPackage> {
|
||||
const keyPair = generateTestKeyPair();
|
||||
const built = buildPackage(makeValidInput({ packageVersion: version }), { signWith: keyPair });
|
||||
if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed");
|
||||
const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest));
|
||||
const files = new Map<string, Uint8Array>();
|
||||
for (const [name, file] of built.pkg.files) files.set(name, file.canonicalBytes);
|
||||
for (const asset of built.pkg.assets) files.set(asset.file, asset.bytesContent);
|
||||
const result = await verifyPackage(
|
||||
{
|
||||
manifestBytes,
|
||||
signature: built.pkg.signature,
|
||||
files: { getFile: (name) => Promise.resolve(files.get(name)) },
|
||||
emergencyFloor: built.pkg.emergencyFloor,
|
||||
},
|
||||
{
|
||||
trustedKeys: new Map([
|
||||
[keyPair.fingerprint, publicKeyFromDerBase64(keyPair.publicKeyDerBase64)],
|
||||
]),
|
||||
appVersion: "1.0.0",
|
||||
supportedSchemaRange: [1],
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw new Error(`fixture verify failed: ${result.reason}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function testDeps(patch: Partial<BootReadinessDeps> = {}): BootReadinessDeps {
|
||||
return {
|
||||
...defaultBootDeps,
|
||||
checkShell: () => Promise.resolve({ ok: true, cacheName: "lumen-shell-test" }),
|
||||
estimate: () => Promise.resolve(null),
|
||||
// Pipeline fixtures declare minAppVersion 1.0.0 / schema 1 (activation.test
|
||||
// does the same) — the dev shell version in package.json is younger.
|
||||
appVersion: "1.0.0",
|
||||
supportedSchemaRange: [1],
|
||||
now: () => NOW,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
async function activateFixture(version: number): Promise<VerifiedPackage> {
|
||||
const pkg = await fixture(version);
|
||||
const staged = await stageVerifiedPackage(pkg);
|
||||
const activated = await activateStagedPackage(pkg, staged, APP_VERSION, () => NOW);
|
||||
expect(activated.ok).toBe(true);
|
||||
return pkg;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await deleteDb(DB.SYSTEM);
|
||||
await deleteDb(DB.SLOT_A);
|
||||
await deleteDb(DB.SLOT_B);
|
||||
await deleteDb(DB.USER);
|
||||
});
|
||||
|
||||
describe("boot evaluation", () => {
|
||||
it("fresh install → NOT_READY, L0, floor present", async () => {
|
||||
const report = await evaluateBootReadiness(testDeps());
|
||||
expect(report.state).toBe("NOT_READY");
|
||||
expect(report.level).toBe("L0");
|
||||
expect(report.floorVersion).toBeTruthy();
|
||||
expect(report.canRestore).toBe(false);
|
||||
});
|
||||
|
||||
it("staged + activated fixture → READY with version detail, L2", async () => {
|
||||
const pkg = await activateFixture(1);
|
||||
const report = await evaluateBootReadiness(testDeps());
|
||||
expect(report.state).toBe("READY");
|
||||
expect(report.edition).toBe(pkg.manifest.edition);
|
||||
expect(report.packageVersion).toBe(1);
|
||||
expect(report.generatedAt).toBe(pkg.manifest.generatedAt);
|
||||
// Journal progress timestamps come from the real clock at staging time —
|
||||
// display only, never a verdict input.
|
||||
expect(report.fetchedAt).toBeGreaterThan(0);
|
||||
expect(report.sections).toHaveLength(5);
|
||||
expect(report.sections.every((s) => s.present && s.parseable)).toBe(true);
|
||||
expect(report.assets.total).toBeGreaterThan(0);
|
||||
expect(report.assets.present).toBe(report.assets.total);
|
||||
expect(report.level).toBe("L2");
|
||||
expect(report.shellCache).toBe("lumen-shell-test");
|
||||
});
|
||||
|
||||
it("second update retains rollback slot → canRestore; restore returns to v1 READY", async () => {
|
||||
await activateFixture(1);
|
||||
await activateFixture(2);
|
||||
const updated = await evaluateBootReadiness(testDeps());
|
||||
expect(updated.state).toBe("READY");
|
||||
expect(updated.packageVersion).toBe(2);
|
||||
expect(updated.canRestore).toBe(true);
|
||||
expect(await restorePreviousSlot()).toBe(true);
|
||||
const restored = await evaluateBootReadiness(testDeps());
|
||||
expect(restored.state).toBe("READY");
|
||||
expect(restored.packageVersion).toBe(1);
|
||||
});
|
||||
|
||||
it("slot loss with surviving record → RECOVERY(missing)", async () => {
|
||||
await activateFixture(1);
|
||||
await deleteDb(DB.SLOT_A);
|
||||
await deleteDb(DB.SLOT_B);
|
||||
const report = await evaluateBootReadiness(testDeps());
|
||||
expect(report.state).toBe("RECOVERY");
|
||||
expect(report.reason).toBe("missing");
|
||||
});
|
||||
|
||||
it("size tampering post-activation → RECOVERY(corrupt)", async () => {
|
||||
await activateFixture(1);
|
||||
const system = await openSystemDB();
|
||||
const meta = await readSystemMeta(system);
|
||||
system.close();
|
||||
const slot = await openSlotDB(meta.activeSlot ?? "A");
|
||||
const current = await readSlotFileMeta(slot, "schedule");
|
||||
await writeSlotFile(slot, "schedule", {
|
||||
bytes: (current?.bytes ?? 100) + 7,
|
||||
sha256: current?.sha256 ?? "0".repeat(64),
|
||||
json: { section: "schedule" },
|
||||
});
|
||||
slot.close();
|
||||
const report = await evaluateBootReadiness(testDeps());
|
||||
expect(report.state).toBe("RECOVERY");
|
||||
expect(report.reason).toBe("corrupt");
|
||||
});
|
||||
|
||||
it("staged manifest deleted → PARTIAL(manifest), re-verify path", async () => {
|
||||
await activateFixture(1);
|
||||
const system = await openSystemDB();
|
||||
const meta = await readSystemMeta(system);
|
||||
system.close();
|
||||
const slot = await openSlotDB(meta.activeSlot ?? "A");
|
||||
await withTx(slot, SLOT_FILES, "readwrite", async (tx) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = tx.objectStore(SLOT_FILES).delete("manifest");
|
||||
req.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
req.onerror = () => {
|
||||
reject(req.error ?? new Error("delete failed"));
|
||||
};
|
||||
});
|
||||
});
|
||||
slot.close();
|
||||
const report = await evaluateBootReadiness(testDeps());
|
||||
expect(report.state).toBe("PARTIAL");
|
||||
expect(report.missing).toContain("manifest");
|
||||
});
|
||||
|
||||
it("verification record wiped but files present → PARTIAL(verification)", async () => {
|
||||
await activateFixture(1);
|
||||
const system = await openSystemDB();
|
||||
const meta = await readSystemMeta(system);
|
||||
await writeSystemMeta(system, { ...meta, verification: null });
|
||||
system.close();
|
||||
const report = await evaluateBootReadiness(testDeps());
|
||||
expect(report.state).toBe("PARTIAL");
|
||||
expect(report.missing).toContain("verification");
|
||||
});
|
||||
|
||||
it("interrupted staging (emergency+schedule) with no active → PARTIAL, L1 core-ready", async () => {
|
||||
const pkg = await fixture(1);
|
||||
const slot = await openSlotDB("A");
|
||||
let journal = await initializeStaging(slot, {
|
||||
edition: pkg.manifest.edition,
|
||||
packageVersion: 1,
|
||||
manifestSha256: pkg.manifestSha256,
|
||||
startedAt: NOW,
|
||||
lastProgressAt: NOW,
|
||||
});
|
||||
for (const id of ["emergency", "schedule"] as const) {
|
||||
const entry = pkg.manifest.sections[id];
|
||||
const bytes = pkg.files.get(entry.file);
|
||||
if (!bytes) throw new Error("fixture file missing");
|
||||
journal = await writeSlotFileWithProgress(
|
||||
slot,
|
||||
id,
|
||||
{
|
||||
bytes: entry.bytes,
|
||||
sha256: entry.sha256,
|
||||
json: JSON.parse(new TextDecoder().decode(bytes)) as unknown,
|
||||
},
|
||||
journal,
|
||||
);
|
||||
}
|
||||
expect(journal.complete).toBe(false);
|
||||
slot.close();
|
||||
const report = await evaluateBootReadiness(testDeps());
|
||||
expect(report.state).toBe("PARTIAL");
|
||||
expect(report.level).toBe("L1");
|
||||
expect(report.missing).toEqual(expect.arrayContaining(["info", "map", "assets"]));
|
||||
expect(report.activationPending).toBe(false);
|
||||
});
|
||||
|
||||
it("hand-built incompatible package (schema 99) → RECOVERY(incompatible)", async () => {
|
||||
const manifest = {
|
||||
format: "lumen.package/1",
|
||||
edition: "test-edition",
|
||||
packageVersion: 9,
|
||||
schemaVersion: 99,
|
||||
generatedAt: "2026-09-01T00:00:00.000Z",
|
||||
festival: { name: "Test", timezone: "America/Chicago", startUtc: 1, endUtc: 2 },
|
||||
appCompatibility: { minAppVersion: "0.1.0", maxAppVersion: null },
|
||||
sections: {
|
||||
emergency: { file: "emergency.json", sha256: "a".repeat(64), bytes: 10 },
|
||||
schedule: { file: "schedule.json", sha256: "b".repeat(64), bytes: 10 },
|
||||
map: { file: "map.json", sha256: "c".repeat(64), bytes: 10 },
|
||||
info: { file: "info.json", sha256: "d".repeat(64), bytes: 10 },
|
||||
assets: { file: "assets.json", sha256: "e".repeat(64), bytes: 2 },
|
||||
},
|
||||
counts: { events: 0, pois: 0, assets: 0 },
|
||||
limits: { totalBytes: 52 },
|
||||
} as unknown as FestivalManifest;
|
||||
const slot = await openSlotDB("A");
|
||||
let journal = await initializeStaging(slot, {
|
||||
edition: "test-edition",
|
||||
packageVersion: 9,
|
||||
manifestSha256: "f".repeat(64),
|
||||
startedAt: NOW,
|
||||
lastProgressAt: NOW,
|
||||
});
|
||||
for (const id of ["emergency", "schedule", "map", "info"] as const) {
|
||||
journal = await writeSlotFileWithProgress(
|
||||
slot,
|
||||
id,
|
||||
{ bytes: 10, sha256: `${id}-sha`, json: { section: id } },
|
||||
journal,
|
||||
);
|
||||
}
|
||||
journal = await writeSlotFileWithProgress(
|
||||
slot,
|
||||
"assets",
|
||||
{ bytes: 2, sha256: "e".repeat(64), json: { assets: [] } },
|
||||
journal,
|
||||
);
|
||||
await writeSlotManifest(slot, manifest);
|
||||
journal = await markStagingComplete(slot);
|
||||
expect(journal.complete).toBe(true);
|
||||
slot.close();
|
||||
const system = await openSystemDB();
|
||||
await writeSystemMeta(system, {
|
||||
...INITIAL_SYSTEM_META,
|
||||
activeSlot: "A",
|
||||
activeEdition: "test-edition",
|
||||
activePackageVersion: 9,
|
||||
verification: {
|
||||
packageVersion: 9,
|
||||
edition: "test-edition",
|
||||
manifestSha256: "f".repeat(64),
|
||||
publicKeyFingerprint: "test-key",
|
||||
verifiedAt: NOW,
|
||||
appVersionAtActivation: APP_VERSION,
|
||||
},
|
||||
appVersionAtActivation: APP_VERSION,
|
||||
});
|
||||
system.close();
|
||||
const report = await evaluateBootReadiness(testDeps());
|
||||
expect(report.state).toBe("RECOVERY");
|
||||
expect(report.reason).toBe("incompatible");
|
||||
});
|
||||
|
||||
it("optional section (required:false) absent → still READY", async () => {
|
||||
const manifest = {
|
||||
format: "lumen.package/1",
|
||||
edition: "test-edition",
|
||||
packageVersion: 3,
|
||||
schemaVersion: 1,
|
||||
generatedAt: "2026-09-01T00:00:00.000Z",
|
||||
festival: { name: "Test", timezone: "America/Chicago", startUtc: 1, endUtc: 2 },
|
||||
appCompatibility: { minAppVersion: "0.1.0", maxAppVersion: null },
|
||||
sections: {
|
||||
emergency: { file: "emergency.json", sha256: "a".repeat(64), bytes: 10 },
|
||||
schedule: { file: "schedule.json", sha256: "b".repeat(64), bytes: 10 },
|
||||
map: { file: "map.json", sha256: "c".repeat(64), bytes: 10 },
|
||||
info: { file: "info.json", sha256: "d".repeat(64), bytes: 10, required: false },
|
||||
assets: { file: "assets.json", sha256: "e".repeat(64), bytes: 2 },
|
||||
},
|
||||
counts: { events: 0, pois: 0, assets: 0 },
|
||||
limits: { totalBytes: 42 },
|
||||
} as unknown as FestivalManifest;
|
||||
const slot = await openSlotDB("A");
|
||||
let journal = await initializeStaging(slot, {
|
||||
edition: "test-edition",
|
||||
packageVersion: 3,
|
||||
manifestSha256: "f".repeat(64),
|
||||
startedAt: NOW,
|
||||
lastProgressAt: NOW,
|
||||
});
|
||||
for (const id of ["emergency", "schedule", "map"] as const) {
|
||||
journal = await writeSlotFileWithProgress(
|
||||
slot,
|
||||
id,
|
||||
{ bytes: 10, sha256: `${id}-sha`, json: { section: id } },
|
||||
journal,
|
||||
);
|
||||
}
|
||||
journal = await writeSlotFileWithProgress(
|
||||
slot,
|
||||
"assets",
|
||||
{ bytes: 2, sha256: "e".repeat(64), json: { assets: [] } },
|
||||
journal,
|
||||
);
|
||||
await writeSlotManifest(slot, manifest);
|
||||
await markStagingComplete(slot);
|
||||
slot.close();
|
||||
const system = await openSystemDB();
|
||||
await writeSystemMeta(system, {
|
||||
...INITIAL_SYSTEM_META,
|
||||
activeSlot: "A",
|
||||
activeEdition: "test-edition",
|
||||
activePackageVersion: 3,
|
||||
verification: {
|
||||
packageVersion: 3,
|
||||
edition: "test-edition",
|
||||
manifestSha256: "f".repeat(64),
|
||||
publicKeyFingerprint: "test-key",
|
||||
verifiedAt: NOW,
|
||||
appVersionAtActivation: APP_VERSION,
|
||||
},
|
||||
appVersionAtActivation: APP_VERSION,
|
||||
});
|
||||
system.close();
|
||||
const report = await evaluateBootReadiness(testDeps());
|
||||
expect(report.state).toBe("READY");
|
||||
expect(report.sections.find((s) => s.id === "info")).toMatchObject({
|
||||
required: false,
|
||||
present: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("readbackPending left by a kill resolves at next boot → READY", async () => {
|
||||
await activateFixture(1);
|
||||
const system = await openSystemDB();
|
||||
const meta = await readSystemMeta(system);
|
||||
expect(meta.readbackPending).toBe(false);
|
||||
await writeSystemMeta(system, { ...meta, readbackPending: true });
|
||||
system.close();
|
||||
const report = await evaluateBootReadiness(testDeps());
|
||||
expect(report.state).toBe("READY");
|
||||
const reopened = await openSystemDB();
|
||||
expect((await readSystemMeta(reopened)).readbackPending).toBe(false);
|
||||
reopened.close();
|
||||
});
|
||||
|
||||
it("unavailable storage → BASELINE_ONLY; slot IDB error → FAILED", async () => {
|
||||
const baseline = await evaluateBootReadiness(
|
||||
testDeps({
|
||||
openSystem: () => Promise.reject(new Error("denied")),
|
||||
}),
|
||||
);
|
||||
expect(baseline.state).toBe("BASELINE_ONLY");
|
||||
expect(baseline.floorVersion).toBeTruthy();
|
||||
|
||||
await activateFixture(1);
|
||||
const failed = await evaluateBootReadiness(
|
||||
testDeps({
|
||||
openSlot: () => Promise.reject(new Error("IDB exploded")),
|
||||
}),
|
||||
);
|
||||
expect(failed.state).toBe("FAILED");
|
||||
expect(failed.failedOp).toBe("boot-check");
|
||||
});
|
||||
|
||||
it("stale staging (>7d) is not resumable → NOT_READY", async () => {
|
||||
const pkg = await fixture(1);
|
||||
const slot = await openSlotDB("A");
|
||||
const journal = await initializeStaging(slot, {
|
||||
edition: pkg.manifest.edition,
|
||||
packageVersion: 1,
|
||||
manifestSha256: pkg.manifestSha256,
|
||||
startedAt: NOW - 8 * 24 * 60 * 60 * 1000,
|
||||
lastProgressAt: NOW - 8 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(journal.complete).toBe(false);
|
||||
slot.close();
|
||||
const report = await evaluateBootReadiness(testDeps());
|
||||
expect(report.state).toBe("NOT_READY");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue