mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-01 15:02:31 +00:00
test: recover review harness controller contract
This commit is contained in:
@@ -0,0 +1,289 @@
|
|||||||
|
import type { ObsidianLiveSyncSettings, SettingsMigrationState } from "@vrtmrz/livesync-commonlib/settings";
|
||||||
|
import type { CompatibilityPause } from "@/common/databaseCompatibility.ts";
|
||||||
|
import type {
|
||||||
|
ReviewHarnessScenarioResult,
|
||||||
|
ReviewHarnessScenarioStatus,
|
||||||
|
} from "./reviewHarnessTypes";
|
||||||
|
|
||||||
|
export type { ReviewHarnessScenarioResult, ReviewHarnessScenarioStatus } from "./reviewHarnessTypes";
|
||||||
|
|
||||||
|
export const REVIEW_HARNESS_SCENARIOS = [
|
||||||
|
{
|
||||||
|
id: "settings-lifecycle",
|
||||||
|
title: "Settings lifecycle",
|
||||||
|
description:
|
||||||
|
"Checks whether loaded settings retain the seven synchronisation choices and apply new-Vault recommendations only to a genuinely new Vault.",
|
||||||
|
mode: "automatic",
|
||||||
|
access: "read-only",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "compatibility-review",
|
||||||
|
title: "Compatibility review boundary",
|
||||||
|
description:
|
||||||
|
"Checks the current device-local compatibility pause, then guides the reviewer through its explicit review and restart boundary.",
|
||||||
|
mode: "guided",
|
||||||
|
access: "device-local-state",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p2p-composition",
|
||||||
|
title: "P2P composition",
|
||||||
|
description:
|
||||||
|
"Checks that the Obsidian host and P2P interface still resolve the current Commonlib replicator.",
|
||||||
|
mode: "automatic",
|
||||||
|
access: "read-only",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "vault-round-trip",
|
||||||
|
title: "Vault fixture round trip",
|
||||||
|
description:
|
||||||
|
"Creates, reads, modifies, renames, and removes a fixed owned fixture tree after explicit confirmation.",
|
||||||
|
mode: "automatic",
|
||||||
|
access: "dedicated-vault-fixtures",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const REVIEW_HARNESS_SCENARIO_IDS = REVIEW_HARNESS_SCENARIOS.map(({ id }) => id);
|
||||||
|
|
||||||
|
export type ReviewHarnessScenario = (typeof REVIEW_HARNESS_SCENARIOS)[number];
|
||||||
|
export type ReviewHarnessScenarioId = ReviewHarnessScenario["id"];
|
||||||
|
export type ReviewHarnessScenarioMode = ReviewHarnessScenario["mode"];
|
||||||
|
export type ReviewHarnessScenarioAccess = ReviewHarnessScenario["access"];
|
||||||
|
|
||||||
|
export const REVIEW_HARNESS_STATE_KEY = "review-harness-v1";
|
||||||
|
|
||||||
|
export interface PendingReviewRun {
|
||||||
|
readonly formatVersion: 1;
|
||||||
|
readonly requestId: string;
|
||||||
|
readonly scenarioId: "compatibility-review";
|
||||||
|
readonly stage: "awaiting-restart";
|
||||||
|
readonly requestedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParsedPendingReviewRun = { pendingRun?: PendingReviewRun; error?: string };
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePendingReviewRun(serialised: string | null | undefined): ParsedPendingReviewRun {
|
||||||
|
if (!serialised) return {};
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(serialised) as unknown;
|
||||||
|
} catch {
|
||||||
|
return { error: "Review Harness continuation is not valid JSON" };
|
||||||
|
}
|
||||||
|
if (!isRecord(value)) return { error: "Review Harness continuation must be an object" };
|
||||||
|
if (value.formatVersion !== 1) {
|
||||||
|
return { error: `Unsupported Review Harness continuation version: ${String(value.formatVersion)}` };
|
||||||
|
}
|
||||||
|
if (value.scenarioId !== "compatibility-review") {
|
||||||
|
return { error: `Unknown Review Harness scenario: ${String(value.scenarioId)}` };
|
||||||
|
}
|
||||||
|
if (value.stage !== "awaiting-restart") {
|
||||||
|
return { error: `Unknown Review Harness continuation stage: ${String(value.stage)}` };
|
||||||
|
}
|
||||||
|
if (typeof value.requestedAt !== "string") {
|
||||||
|
return { error: "Review Harness request time must be an ISO date" };
|
||||||
|
}
|
||||||
|
const requestedAtMs = Date.parse(value.requestedAt);
|
||||||
|
if (Number.isNaN(requestedAtMs) || new Date(requestedAtMs).toISOString() !== value.requestedAt) {
|
||||||
|
return { error: "Review Harness request time must be an ISO date" };
|
||||||
|
}
|
||||||
|
if (value.requestId !== `compatibility-review-${value.requestedAt}`) {
|
||||||
|
return { error: "Review Harness request ID does not match the fixed continuation format" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pendingRun: {
|
||||||
|
formatVersion: 1,
|
||||||
|
requestId: value.requestId,
|
||||||
|
scenarioId: value.scenarioId,
|
||||||
|
stage: value.stage,
|
||||||
|
requestedAt: value.requestedAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESERVED_SYNC_SETTING_KEYS = [
|
||||||
|
"liveSync",
|
||||||
|
"syncOnSave",
|
||||||
|
"syncOnEditorSave",
|
||||||
|
"syncOnStart",
|
||||||
|
"syncOnFileOpen",
|
||||||
|
"syncAfterMerge",
|
||||||
|
"periodicReplication",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const NEW_VAULT_RECOMMENDATION_KEYS = [
|
||||||
|
"syncMaxSizeInMB",
|
||||||
|
"chunkSplitterVersion",
|
||||||
|
"doNotUseFixedRevisionForChunks",
|
||||||
|
"usePluginSyncV2",
|
||||||
|
"handleFilenameCaseSensitive",
|
||||||
|
"E2EEAlgorithm",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type LifecycleSettingKey = (typeof PRESERVED_SYNC_SETTING_KEYS)[number] | (typeof NEW_VAULT_RECOMMENDATION_KEYS)[number];
|
||||||
|
type SettingsForLifecycleInspection = Partial<Pick<ObsidianLiveSyncSettings, LifecycleSettingKey>>;
|
||||||
|
|
||||||
|
export function inspectSettingsLifecycle(input: {
|
||||||
|
readonly migration: SettingsMigrationState | undefined;
|
||||||
|
readonly settings: SettingsForLifecycleInspection;
|
||||||
|
readonly newVaultSettings: SettingsForLifecycleInspection;
|
||||||
|
}): ReviewHarnessScenarioResult {
|
||||||
|
if (!input.migration) {
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
detail: "The settings service did not expose migration evidence.",
|
||||||
|
observations: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidSyncSettings = PRESERVED_SYNC_SETTING_KEYS.filter(
|
||||||
|
(key) => typeof input.settings[key] !== "boolean"
|
||||||
|
);
|
||||||
|
if (invalidSyncSettings.length > 0) {
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
detail: `Synchronisation choices are missing or invalid: ${invalidSyncSettings.join(", ")}`,
|
||||||
|
observations: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const observations = PRESERVED_SYNC_SETTING_KEYS.map((key) => `${key}=${String(input.settings[key])}`);
|
||||||
|
if (!input.migration.isNewVault) {
|
||||||
|
return {
|
||||||
|
status: "passed",
|
||||||
|
detail: `Loaded an existing Vault from settings schema ${input.migration.sourceVersion} to ${input.migration.targetVersion}.`,
|
||||||
|
observations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mismatchedRecommendations = NEW_VAULT_RECOMMENDATION_KEYS.filter(
|
||||||
|
(key) => input.settings[key] !== input.newVaultSettings[key]
|
||||||
|
);
|
||||||
|
if (mismatchedRecommendations.length > 0) {
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
detail: `The new Vault recommendations differ for: ${mismatchedRecommendations.join(", ")}`,
|
||||||
|
observations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "passed",
|
||||||
|
detail: "Loaded the current new Vault recommendations without enabling a remote connection.",
|
||||||
|
observations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inspectCompatibilityReview(input: {
|
||||||
|
readonly migration: SettingsMigrationState | undefined;
|
||||||
|
readonly reviewInitialised: boolean;
|
||||||
|
readonly pendingPause: CompatibilityPause | undefined;
|
||||||
|
}): ReviewHarnessScenarioResult {
|
||||||
|
if (input.migration?.requiresSyncReview && !input.reviewInitialised) {
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
detail: "The settings migration requires review, but the compatibility review was not initialised.",
|
||||||
|
observations: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const observations = [
|
||||||
|
`migrationReviewRequired=${String(input.migration?.requiresSyncReview ?? false)}`,
|
||||||
|
`compatibilityReviewInitialised=${String(input.reviewInitialised)}`,
|
||||||
|
`compatibilityPausePending=${String(input.pendingPause !== undefined)}`,
|
||||||
|
];
|
||||||
|
if (input.pendingPause) {
|
||||||
|
observations.push(`reasonSources=${input.pendingPause.reasons.map(({ source }) => source).join(",")}`);
|
||||||
|
observations.push(`reasonCount=${input.pendingPause.reasons.length}`);
|
||||||
|
observations.push(`resumable=${String(input.pendingPause.resumable)}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "passed",
|
||||||
|
detail: input.pendingPause
|
||||||
|
? "A device-local compatibility review is pending."
|
||||||
|
: "No compatibility review is pending on this device.",
|
||||||
|
observations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewHarnessReportScenario {
|
||||||
|
readonly id: ReviewHarnessScenarioId;
|
||||||
|
readonly title: string;
|
||||||
|
readonly mode: ReviewHarnessScenarioMode;
|
||||||
|
readonly status: ReviewHarnessScenarioStatus;
|
||||||
|
readonly detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewHarnessReportInput {
|
||||||
|
readonly generatedAt: string;
|
||||||
|
readonly environment: {
|
||||||
|
readonly pluginVersion: string;
|
||||||
|
readonly obsidianVersion: string;
|
||||||
|
readonly platform: string;
|
||||||
|
readonly userAgent: string;
|
||||||
|
readonly viewport: string;
|
||||||
|
};
|
||||||
|
readonly scenarios: readonly ReviewHarnessReportScenario[];
|
||||||
|
readonly transcript: readonly {
|
||||||
|
readonly at: string;
|
||||||
|
readonly event: string;
|
||||||
|
readonly detail?: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableCell(value: string): string {
|
||||||
|
return value.replace(/\|/gu, "\\|").replace(/\r?\n/gu, "<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function table(headers: readonly string[], rows: readonly (readonly string[])[]): string {
|
||||||
|
const header = `| ${headers.map(tableCell).join(" | ")} |`;
|
||||||
|
const separator = `| ${headers.map(() => "---").join(" | ")} |`;
|
||||||
|
const body = rows.map((row) => `| ${row.map(tableCell).join(" | ")} |`);
|
||||||
|
return [header, separator, ...body].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatReviewHarnessReport(input: ReviewHarnessReportInput): string {
|
||||||
|
const environment = table(
|
||||||
|
["Field", "Value"],
|
||||||
|
[
|
||||||
|
["Plug-in", input.environment.pluginVersion],
|
||||||
|
["Obsidian", input.environment.obsidianVersion],
|
||||||
|
["Platform", input.environment.platform],
|
||||||
|
["User agent", input.environment.userAgent],
|
||||||
|
["Viewport", input.environment.viewport],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const scenarios = table(
|
||||||
|
["Scenario", "Mode", "Status", "Detail"],
|
||||||
|
input.scenarios.map(({ id, title, mode, status, detail }) => [
|
||||||
|
`${title} (${id})`,
|
||||||
|
mode,
|
||||||
|
status,
|
||||||
|
detail,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
return `## Self-hosted LiveSync Review Harness report
|
||||||
|
|
||||||
|
Generated at \`${tableCell(input.generatedAt)}\`.
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
|
||||||
|
${environment}
|
||||||
|
|
||||||
|
### Scenarios
|
||||||
|
|
||||||
|
${scenarios}
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Event transcript</summary>
|
||||||
|
|
||||||
|
\`\`\`json
|
||||||
|
${JSON.stringify(input.transcript, null, 2)}
|
||||||
|
\`\`\`
|
||||||
|
</details>
|
||||||
|
|
||||||
|
This report was copied locally and was not transmitted by Self-hosted LiveSync. It intentionally omits Vault identifiers, paths, file names, file contents, remote configuration, and secrets. Review the environment information before posting because a user agent and viewport may identify the device or operating system.
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { NEW_VAULT_SETTINGS, type SettingsMigrationState } from "@vrtmrz/livesync-commonlib/settings";
|
||||||
|
import type { CompatibilityPause } from "@/common/databaseCompatibility.ts";
|
||||||
|
import {
|
||||||
|
REVIEW_HARNESS_SCENARIO_IDS,
|
||||||
|
formatReviewHarnessReport,
|
||||||
|
inspectCompatibilityReview,
|
||||||
|
inspectSettingsLifecycle,
|
||||||
|
parsePendingReviewRun,
|
||||||
|
} from "./reviewHarnessContract";
|
||||||
|
|
||||||
|
const preservedSyncSettings = {
|
||||||
|
liveSync: true,
|
||||||
|
syncOnSave: false,
|
||||||
|
syncOnEditorSave: true,
|
||||||
|
syncOnStart: false,
|
||||||
|
syncOnFileOpen: true,
|
||||||
|
syncAfterMerge: false,
|
||||||
|
periodicReplication: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function migration(overrides: Partial<SettingsMigrationState> = {}): SettingsMigrationState {
|
||||||
|
return {
|
||||||
|
sourceVersion: 9,
|
||||||
|
targetVersion: 10,
|
||||||
|
isNewVault: false,
|
||||||
|
isFromFutureSchema: false,
|
||||||
|
changed: true,
|
||||||
|
requiresSyncReview: true,
|
||||||
|
reviewReasons: [
|
||||||
|
{
|
||||||
|
code: "legacy-update-review-pending",
|
||||||
|
fromVersion: 9,
|
||||||
|
toVersion: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function compatibilityPause(): CompatibilityPause {
|
||||||
|
return {
|
||||||
|
resumable: true,
|
||||||
|
reasons: [
|
||||||
|
{
|
||||||
|
source: "settings-schema",
|
||||||
|
sourceVersion: 9,
|
||||||
|
currentVersion: 10,
|
||||||
|
isFromFutureSchema: false,
|
||||||
|
resumable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Review Harness contract", () => {
|
||||||
|
it("accepts only a fixed one-shot restart continuation", () => {
|
||||||
|
const pendingRun = {
|
||||||
|
formatVersion: 1,
|
||||||
|
requestId: "compatibility-review-2026-07-18T12:00:00.000Z",
|
||||||
|
scenarioId: "compatibility-review",
|
||||||
|
stage: "awaiting-restart",
|
||||||
|
requestedAt: "2026-07-18T12:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsePendingReviewRun(JSON.stringify(pendingRun))).toEqual({ pendingRun });
|
||||||
|
expect(
|
||||||
|
parsePendingReviewRun(
|
||||||
|
JSON.stringify({ ...pendingRun, scenarioId: "arbitrary-command", command: "delete-vault" })
|
||||||
|
)
|
||||||
|
).toEqual({ error: "Unknown Review Harness scenario: arbitrary-command" });
|
||||||
|
expect(parsePendingReviewRun("not-json")).toEqual({ error: "Review Harness continuation is not valid JSON" });
|
||||||
|
expect(REVIEW_HARNESS_SCENARIO_IDS).toEqual([
|
||||||
|
"settings-lifecycle",
|
||||||
|
"compatibility-review",
|
||||||
|
"p2p-composition",
|
||||||
|
"vault-round-trip",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks that an existing Vault keeps the seven synchronisation choices typed and intact", () => {
|
||||||
|
const result = inspectSettingsLifecycle({
|
||||||
|
migration: migration(),
|
||||||
|
settings: preservedSyncSettings,
|
||||||
|
newVaultSettings: NEW_VAULT_SETTINGS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("passed");
|
||||||
|
expect(result.detail).toContain("existing Vault");
|
||||||
|
expect(result.observations).toContain("liveSync=true");
|
||||||
|
expect(result.observations).toContain("periodicReplication=true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks recommended values only for a genuinely new Vault", () => {
|
||||||
|
const result = inspectSettingsLifecycle({
|
||||||
|
migration: migration({
|
||||||
|
sourceVersion: 10,
|
||||||
|
targetVersion: 10,
|
||||||
|
isNewVault: true,
|
||||||
|
changed: false,
|
||||||
|
requiresSyncReview: false,
|
||||||
|
reviewReasons: [],
|
||||||
|
}),
|
||||||
|
settings: {
|
||||||
|
...preservedSyncSettings,
|
||||||
|
syncMaxSizeInMB: NEW_VAULT_SETTINGS.syncMaxSizeInMB,
|
||||||
|
chunkSplitterVersion: NEW_VAULT_SETTINGS.chunkSplitterVersion,
|
||||||
|
doNotUseFixedRevisionForChunks: NEW_VAULT_SETTINGS.doNotUseFixedRevisionForChunks,
|
||||||
|
usePluginSyncV2: NEW_VAULT_SETTINGS.usePluginSyncV2,
|
||||||
|
handleFilenameCaseSensitive: NEW_VAULT_SETTINGS.handleFilenameCaseSensitive,
|
||||||
|
E2EEAlgorithm: NEW_VAULT_SETTINGS.E2EEAlgorithm,
|
||||||
|
},
|
||||||
|
newVaultSettings: NEW_VAULT_SETTINGS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("passed");
|
||||||
|
expect(result.detail).toContain("new Vault recommendations");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the compatibility review controller to initialise when settings migration requires review", () => {
|
||||||
|
expect(
|
||||||
|
inspectSettingsLifecycle({
|
||||||
|
migration: undefined,
|
||||||
|
settings: preservedSyncSettings,
|
||||||
|
newVaultSettings: NEW_VAULT_SETTINGS,
|
||||||
|
}).status
|
||||||
|
).toBe("failed");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
inspectCompatibilityReview({
|
||||||
|
migration: migration(),
|
||||||
|
reviewInitialised: false,
|
||||||
|
pendingPause: undefined,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
status: "failed",
|
||||||
|
detail: "The settings migration requires review, but the compatibility review was not initialised.",
|
||||||
|
observations: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes a completed compatibility review from an uninitialised controller", () => {
|
||||||
|
const result = inspectCompatibilityReview({
|
||||||
|
migration: migration(),
|
||||||
|
reviewInitialised: true,
|
||||||
|
pendingPause: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: "passed",
|
||||||
|
detail: "No compatibility review is pending on this device.",
|
||||||
|
});
|
||||||
|
expect(result.observations).toContain("compatibilityReviewInitialised=true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports only bounded compatibility evidence", () => {
|
||||||
|
const pendingPause: CompatibilityPause = {
|
||||||
|
resumable: true,
|
||||||
|
reasons: [
|
||||||
|
...compatibilityPause().reasons,
|
||||||
|
{
|
||||||
|
source: "legacy-review",
|
||||||
|
message: "/Users/reviewer/private-vault/secret-note.md",
|
||||||
|
resumable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = inspectCompatibilityReview({
|
||||||
|
migration: migration(),
|
||||||
|
reviewInitialised: true,
|
||||||
|
pendingPause,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("passed");
|
||||||
|
expect(result.observations).toContain("reasonSources=settings-schema,legacy-review");
|
||||||
|
expect(result.observations).toContain("resumable=true");
|
||||||
|
expect(result.observations.join("\n")).not.toContain("private-vault");
|
||||||
|
expect(result.observations.join("\n")).not.toContain("secret-note.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats a review report without accepting Vault or credential fields", () => {
|
||||||
|
const report = formatReviewHarnessReport({
|
||||||
|
generatedAt: "2026-07-18T12:30:00.000Z",
|
||||||
|
environment: {
|
||||||
|
pluginVersion: "1.0.0-beta.1",
|
||||||
|
obsidianVersion: "1.13.1",
|
||||||
|
platform: "ios",
|
||||||
|
userAgent: "Obsidian|mobile\nreview",
|
||||||
|
viewport: "390x844",
|
||||||
|
},
|
||||||
|
scenarios: [
|
||||||
|
{
|
||||||
|
id: "compatibility-review",
|
||||||
|
title: "Compatibility review boundary",
|
||||||
|
mode: "guided",
|
||||||
|
status: "passed",
|
||||||
|
detail: "Compatibility pause confirmed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
transcript: [
|
||||||
|
{
|
||||||
|
at: "2026-07-18T12:29:00.000Z",
|
||||||
|
event: "compatibility-review-completed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(report).toContain("## Self-hosted LiveSync Review Harness report");
|
||||||
|
expect(report).toContain("Obsidian\\|mobile<br>review");
|
||||||
|
expect(report).toContain("Compatibility review boundary (compatibility-review)");
|
||||||
|
expect(report).toContain("was not transmitted");
|
||||||
|
expect(report).not.toContain("Vault name");
|
||||||
|
expect(report).not.toContain("credentials");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import type { ObsidianLiveSyncSettings, SettingsMigrationState } from "@vrtmrz/livesync-commonlib/settings";
|
||||||
|
import type { CompatibilityPause } from "@/common/databaseCompatibility.ts";
|
||||||
|
import {
|
||||||
|
REVIEW_HARNESS_SCENARIOS,
|
||||||
|
REVIEW_HARNESS_STATE_KEY,
|
||||||
|
formatReviewHarnessReport,
|
||||||
|
inspectCompatibilityReview,
|
||||||
|
inspectSettingsLifecycle,
|
||||||
|
parsePendingReviewRun,
|
||||||
|
type ReviewHarnessReportInput,
|
||||||
|
type ReviewHarnessScenarioId,
|
||||||
|
type ReviewHarnessScenarioResult,
|
||||||
|
} from "./reviewHarnessContract";
|
||||||
|
|
||||||
|
export interface ReviewHarnessRuntime {
|
||||||
|
now(): Date;
|
||||||
|
getSettings(): Partial<ObsidianLiveSyncSettings>;
|
||||||
|
getNewVaultSettings(): Partial<ObsidianLiveSyncSettings>;
|
||||||
|
getSettingsMigrationState(): SettingsMigrationState | undefined;
|
||||||
|
isCompatibilityReviewInitialised(): boolean;
|
||||||
|
getCompatibilityPause(): CompatibilityPause | undefined;
|
||||||
|
openCompatibilityReview(): Promise<void>;
|
||||||
|
getP2PComposition(): {
|
||||||
|
readonly first: unknown;
|
||||||
|
readonly second: unknown;
|
||||||
|
readonly expectedServices: unknown;
|
||||||
|
};
|
||||||
|
runVaultRoundTrip(): Promise<ReviewHarnessScenarioResult>;
|
||||||
|
readContinuation(): string | null;
|
||||||
|
writeContinuation(value: string): void;
|
||||||
|
deleteContinuation(): void;
|
||||||
|
restart(): void;
|
||||||
|
reportError(error: unknown): void;
|
||||||
|
copyText(value: string): Promise<void>;
|
||||||
|
getEnvironment(): ReviewHarnessReportInput["environment"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewHarnessTranscriptEntry {
|
||||||
|
readonly at: string;
|
||||||
|
readonly event: string;
|
||||||
|
readonly detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewHarnessSnapshot {
|
||||||
|
readonly results: Record<ReviewHarnessScenarioId, ReviewHarnessScenarioResult>;
|
||||||
|
readonly running: boolean;
|
||||||
|
readonly current: ReviewHarnessScenarioId | null;
|
||||||
|
readonly resumedRequestId: string | null;
|
||||||
|
readonly continuationError: string | null;
|
||||||
|
readonly transcript: readonly ReviewHarnessTranscriptEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const idleResult = (): ReviewHarnessScenarioResult => ({
|
||||||
|
status: "idle",
|
||||||
|
detail: "Not run",
|
||||||
|
observations: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
function initialResults(): Record<ReviewHarnessScenarioId, ReviewHarnessScenarioResult> {
|
||||||
|
return Object.fromEntries(REVIEW_HARNESS_SCENARIOS.map(({ id }) => [id, idleResult()])) as Record<
|
||||||
|
ReviewHarnessScenarioId,
|
||||||
|
ReviewHarnessScenarioResult
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectP2PComposition(input: ReturnType<ReviewHarnessRuntime["getP2PComposition"]>): ReviewHarnessScenarioResult {
|
||||||
|
if (input.first !== input.second) {
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
detail: "Two consecutive reads resolved different P2P replicators without a lifecycle transition.",
|
||||||
|
observations: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof input.first !== "object" || input.first === null) {
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
detail: "The P2P composition did not expose a current replicator.",
|
||||||
|
observations: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const env = "env" in input.first ? input.first.env : undefined;
|
||||||
|
const services = typeof env === "object" && env !== null && "services" in env ? env.services : undefined;
|
||||||
|
if (services !== input.expectedServices) {
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
detail: "The current P2P replicator is not bound to the active Obsidian services.",
|
||||||
|
observations: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "passed",
|
||||||
|
detail: "The live P2P result resolves the current replicator and active Obsidian services.",
|
||||||
|
observations: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReviewHarnessController {
|
||||||
|
private readonly results = initialResults();
|
||||||
|
private readonly transcript: ReviewHarnessTranscriptEntry[] = [];
|
||||||
|
private readonly listeners = new Set<() => void>();
|
||||||
|
private running = false;
|
||||||
|
private current: ReviewHarnessScenarioId | null = null;
|
||||||
|
private resumedRequestId: string | null = null;
|
||||||
|
private continuationError: string | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly runtime: ReviewHarnessRuntime) {}
|
||||||
|
|
||||||
|
subscribe(listener: () => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
for (const listener of this.listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record(event: string, detail?: string): void {
|
||||||
|
this.transcript.push({
|
||||||
|
at: this.runtime.now().toISOString(),
|
||||||
|
event,
|
||||||
|
...(detail ? { detail } : {}),
|
||||||
|
});
|
||||||
|
while (this.transcript.length > 100) this.transcript.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(): ReviewHarnessSnapshot {
|
||||||
|
return {
|
||||||
|
results: Object.fromEntries(
|
||||||
|
REVIEW_HARNESS_SCENARIOS.map(({ id }) => [id, { ...this.results[id] }])
|
||||||
|
) as Record<ReviewHarnessScenarioId, ReviewHarnessScenarioResult>,
|
||||||
|
running: this.running,
|
||||||
|
current: this.current,
|
||||||
|
resumedRequestId: this.resumedRequestId,
|
||||||
|
continuationError: this.continuationError,
|
||||||
|
transcript: [...this.transcript],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
consumeContinuation(): void {
|
||||||
|
const serialised = this.runtime.readContinuation();
|
||||||
|
if (!serialised) return;
|
||||||
|
this.runtime.deleteContinuation();
|
||||||
|
const parsed = parsePendingReviewRun(serialised);
|
||||||
|
if (!parsed.pendingRun) {
|
||||||
|
this.continuationError = parsed.error ?? "The Review Harness continuation was invalid.";
|
||||||
|
this.results["compatibility-review"] = {
|
||||||
|
status: "failed",
|
||||||
|
detail: "The stored continuation was invalid and was removed.",
|
||||||
|
observations: [],
|
||||||
|
};
|
||||||
|
this.record("continuation-rejected");
|
||||||
|
this.notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.resumedRequestId = parsed.pendingRun.requestId;
|
||||||
|
this.results["compatibility-review"] = {
|
||||||
|
status: "waiting-for-user",
|
||||||
|
detail: "Obsidian returned after the requested restart. Open the compatibility review to continue.",
|
||||||
|
observations: [`requestedAt=${parsed.pendingRun.requestedAt}`],
|
||||||
|
};
|
||||||
|
this.record("continuation-consumed", parsed.pendingRun.requestId);
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
async runAutomaticScenarios(): Promise<void> {
|
||||||
|
for (const id of ["settings-lifecycle", "p2p-composition"] as const) {
|
||||||
|
await this.runScenario(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runAllScenarios(): Promise<void> {
|
||||||
|
for (const { id } of REVIEW_HARNESS_SCENARIOS) await this.runScenario(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private inspectCompatibilityReview(): ReviewHarnessScenarioResult {
|
||||||
|
return inspectCompatibilityReview({
|
||||||
|
migration: this.runtime.getSettingsMigrationState(),
|
||||||
|
reviewInitialised: this.runtime.isCompatibilityReviewInitialised(),
|
||||||
|
pendingPause: this.runtime.getCompatibilityPause(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async runScenario(id: ReviewHarnessScenarioId): Promise<void> {
|
||||||
|
if (this.running) return;
|
||||||
|
this.running = true;
|
||||||
|
this.current = id;
|
||||||
|
this.results[id] = { status: "running", detail: "Running", observations: [] };
|
||||||
|
this.record("scenario-started", id);
|
||||||
|
this.notify();
|
||||||
|
try {
|
||||||
|
let result: ReviewHarnessScenarioResult;
|
||||||
|
if (id === "settings-lifecycle") {
|
||||||
|
result = inspectSettingsLifecycle({
|
||||||
|
migration: this.runtime.getSettingsMigrationState(),
|
||||||
|
settings: this.runtime.getSettings(),
|
||||||
|
newVaultSettings: this.runtime.getNewVaultSettings(),
|
||||||
|
});
|
||||||
|
} else if (id === "p2p-composition") {
|
||||||
|
result = inspectP2PComposition(this.runtime.getP2PComposition());
|
||||||
|
} else if (id === "vault-round-trip") {
|
||||||
|
result = await this.runtime.runVaultRoundTrip();
|
||||||
|
} else {
|
||||||
|
const inspection = this.inspectCompatibilityReview();
|
||||||
|
result =
|
||||||
|
inspection.status === "failed" || !this.runtime.getCompatibilityPause()
|
||||||
|
? inspection
|
||||||
|
: {
|
||||||
|
status: "waiting-for-user",
|
||||||
|
detail: "Open the device-local compatibility review and complete its explicit action.",
|
||||||
|
observations: inspection.observations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.results[id] = result;
|
||||||
|
this.record("scenario-updated", `${id}:${result.status}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.setUnexpectedFailure(id, error);
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
this.current = null;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async openCompatibilityReview(): Promise<void> {
|
||||||
|
if (this.running) return;
|
||||||
|
this.running = true;
|
||||||
|
this.current = "compatibility-review";
|
||||||
|
this.notify();
|
||||||
|
try {
|
||||||
|
await this.runtime.openCompatibilityReview();
|
||||||
|
const inspection = this.inspectCompatibilityReview();
|
||||||
|
this.results["compatibility-review"] =
|
||||||
|
inspection.status === "passed" && !this.runtime.getCompatibilityPause()
|
||||||
|
? {
|
||||||
|
status: "passed",
|
||||||
|
detail: "The device-local compatibility pause was reviewed and cleared.",
|
||||||
|
observations: inspection.observations,
|
||||||
|
}
|
||||||
|
: inspection.status === "failed"
|
||||||
|
? inspection
|
||||||
|
: {
|
||||||
|
status: "waiting-for-user",
|
||||||
|
detail: "The device-local compatibility review remains pending.",
|
||||||
|
observations: inspection.observations,
|
||||||
|
};
|
||||||
|
this.record(
|
||||||
|
"compatibility-review-updated",
|
||||||
|
this.results["compatibility-review"].status
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.setUnexpectedFailure("compatibility-review", error);
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
this.current = null;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setUnexpectedFailure(id: ReviewHarnessScenarioId, error: unknown): void {
|
||||||
|
this.runtime.reportError(error);
|
||||||
|
this.results[id] = {
|
||||||
|
status: "failed",
|
||||||
|
detail: "The scenario failed unexpectedly. Review the in-app logs for diagnostic details.",
|
||||||
|
observations: [],
|
||||||
|
};
|
||||||
|
this.record("scenario-failed", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareCompatibilityReviewRestart(): void {
|
||||||
|
const requestedAt = this.runtime.now().toISOString();
|
||||||
|
const pending = {
|
||||||
|
formatVersion: 1,
|
||||||
|
requestId: `compatibility-review-${requestedAt}`,
|
||||||
|
scenarioId: "compatibility-review",
|
||||||
|
stage: "awaiting-restart",
|
||||||
|
requestedAt,
|
||||||
|
} as const;
|
||||||
|
this.runtime.writeContinuation(JSON.stringify(pending));
|
||||||
|
this.results["compatibility-review"] = {
|
||||||
|
status: "waiting-for-user",
|
||||||
|
detail: "Restart requested. The one-shot continuation will be removed before the review resumes.",
|
||||||
|
observations: [],
|
||||||
|
};
|
||||||
|
this.record("restart-requested", pending.requestId);
|
||||||
|
this.notify();
|
||||||
|
this.runtime.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
createReport(): string {
|
||||||
|
return formatReviewHarnessReport({
|
||||||
|
generatedAt: this.runtime.now().toISOString(),
|
||||||
|
environment: this.runtime.getEnvironment(),
|
||||||
|
scenarios: REVIEW_HARNESS_SCENARIOS.map(({ id, title, mode }) => ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
mode,
|
||||||
|
status: this.results[id].status,
|
||||||
|
detail: this.results[id].detail,
|
||||||
|
})),
|
||||||
|
transcript: this.transcript,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyReport(): Promise<void> {
|
||||||
|
await this.runtime.copyText(this.createReport());
|
||||||
|
this.record("report-copied");
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { REVIEW_HARNESS_STATE_KEY };
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { NEW_VAULT_SETTINGS, type SettingsMigrationState } from "@vrtmrz/livesync-commonlib/settings";
|
||||||
|
import type { CompatibilityPause } from "@/common/databaseCompatibility.ts";
|
||||||
|
import { ReviewHarnessController, type ReviewHarnessRuntime } from "./reviewHarnessController";
|
||||||
|
|
||||||
|
function migration(): SettingsMigrationState {
|
||||||
|
return {
|
||||||
|
sourceVersion: 9,
|
||||||
|
targetVersion: 10,
|
||||||
|
isNewVault: false,
|
||||||
|
isFromFutureSchema: false,
|
||||||
|
changed: true,
|
||||||
|
requiresSyncReview: true,
|
||||||
|
reviewReasons: [
|
||||||
|
{
|
||||||
|
code: "legacy-update-review-pending",
|
||||||
|
fromVersion: 9,
|
||||||
|
toVersion: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function compatibilityPause(): CompatibilityPause {
|
||||||
|
return {
|
||||||
|
resumable: true,
|
||||||
|
reasons: [
|
||||||
|
{
|
||||||
|
source: "settings-schema",
|
||||||
|
sourceVersion: 9,
|
||||||
|
currentVersion: 10,
|
||||||
|
isFromFutureSchema: false,
|
||||||
|
resumable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRuntime(): ReviewHarnessRuntime & {
|
||||||
|
compatibilityReviewInitialised: boolean;
|
||||||
|
compatibilityPause: CompatibilityPause | undefined;
|
||||||
|
continuation: string | null;
|
||||||
|
events: string[];
|
||||||
|
} {
|
||||||
|
const services = {};
|
||||||
|
const replicator = { env: { services } };
|
||||||
|
const runtime: ReviewHarnessRuntime & {
|
||||||
|
compatibilityReviewInitialised: boolean;
|
||||||
|
compatibilityPause: CompatibilityPause | undefined;
|
||||||
|
continuation: string | null;
|
||||||
|
events: string[];
|
||||||
|
} = {
|
||||||
|
compatibilityReviewInitialised: true,
|
||||||
|
compatibilityPause: compatibilityPause(),
|
||||||
|
continuation: null,
|
||||||
|
events: [],
|
||||||
|
now: () => new Date("2026-07-18T12:00:00.000Z"),
|
||||||
|
getSettings: () => ({
|
||||||
|
liveSync: true,
|
||||||
|
syncOnSave: false,
|
||||||
|
syncOnEditorSave: true,
|
||||||
|
syncOnStart: false,
|
||||||
|
syncOnFileOpen: true,
|
||||||
|
syncAfterMerge: false,
|
||||||
|
periodicReplication: true,
|
||||||
|
}),
|
||||||
|
getNewVaultSettings: () => NEW_VAULT_SETTINGS,
|
||||||
|
getSettingsMigrationState: () => migration(),
|
||||||
|
isCompatibilityReviewInitialised() {
|
||||||
|
return this.compatibilityReviewInitialised;
|
||||||
|
},
|
||||||
|
getCompatibilityPause() {
|
||||||
|
return this.compatibilityPause;
|
||||||
|
},
|
||||||
|
openCompatibilityReview: vi.fn(async () => {
|
||||||
|
runtime.events.push("open-compatibility-review");
|
||||||
|
runtime.compatibilityPause = undefined;
|
||||||
|
}),
|
||||||
|
getP2PComposition: () => ({ first: replicator, second: replicator, expectedServices: services }),
|
||||||
|
runVaultRoundTrip: vi.fn(async () => ({
|
||||||
|
status: "passed" as const,
|
||||||
|
detail: "The owned fixture tree was exercised and removed.",
|
||||||
|
observations: [],
|
||||||
|
})),
|
||||||
|
readContinuation() {
|
||||||
|
return this.continuation;
|
||||||
|
},
|
||||||
|
writeContinuation(value) {
|
||||||
|
this.events.push("write");
|
||||||
|
this.continuation = value;
|
||||||
|
},
|
||||||
|
deleteContinuation() {
|
||||||
|
this.events.push("delete");
|
||||||
|
this.continuation = null;
|
||||||
|
},
|
||||||
|
restart: vi.fn(),
|
||||||
|
reportError: vi.fn(),
|
||||||
|
copyText: vi.fn(async () => undefined),
|
||||||
|
getEnvironment: () => ({
|
||||||
|
pluginVersion: "1.0.0-beta.1",
|
||||||
|
obsidianVersion: "1.13.1",
|
||||||
|
platform: "desktop",
|
||||||
|
userAgent: "test",
|
||||||
|
viewport: "1280x720",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
runtime.restart = vi.fn(() => runtime.events.push("restart"));
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ReviewHarnessController", () => {
|
||||||
|
it("runs the automatic settings and P2P composition checks", async () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
const controller = new ReviewHarnessController(runtime);
|
||||||
|
|
||||||
|
await controller.runAutomaticScenarios();
|
||||||
|
|
||||||
|
expect(controller.snapshot().results["settings-lifecycle"].status).toBe("passed");
|
||||||
|
expect(controller.snapshot().results["p2p-composition"].status).toBe("passed");
|
||||||
|
expect(controller.snapshot().results["vault-round-trip"].status).toBe("idle");
|
||||||
|
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the Vault fixture scenario only when it is selected explicitly", async () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
const controller = new ReviewHarnessController(runtime);
|
||||||
|
|
||||||
|
await controller.runAutomaticScenarios();
|
||||||
|
expect(runtime.runVaultRoundTrip).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await controller.runScenario("vault-round-trip");
|
||||||
|
expect(runtime.runVaultRoundTrip).toHaveBeenCalledOnce();
|
||||||
|
expect(controller.snapshot().results["vault-round-trip"].status).toBe("passed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not copy unexpected runtime error details into the report", async () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
runtime.runVaultRoundTrip = vi.fn(async () => {
|
||||||
|
throw new Error("Failed below /Users/reviewer/private-vault/secret-note.md");
|
||||||
|
});
|
||||||
|
const controller = new ReviewHarnessController(runtime);
|
||||||
|
|
||||||
|
await controller.runScenario("vault-round-trip");
|
||||||
|
|
||||||
|
const result = controller.snapshot().results["vault-round-trip"];
|
||||||
|
const report = controller.createReport();
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: "failed",
|
||||||
|
detail: "The scenario failed unexpectedly. Review the in-app logs for diagnostic details.",
|
||||||
|
});
|
||||||
|
expect(report).not.toContain("private-vault");
|
||||||
|
expect(report).not.toContain("secret-note.md");
|
||||||
|
expect(runtime.reportError).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a one-shot continuation before exposing the resumed guided step", () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
runtime.continuation = JSON.stringify({
|
||||||
|
formatVersion: 1,
|
||||||
|
requestId: "compatibility-review-2026-07-18T11:59:00.000Z",
|
||||||
|
scenarioId: "compatibility-review",
|
||||||
|
stage: "awaiting-restart",
|
||||||
|
requestedAt: "2026-07-18T11:59:00.000Z",
|
||||||
|
});
|
||||||
|
const controller = new ReviewHarnessController(runtime);
|
||||||
|
|
||||||
|
controller.consumeContinuation();
|
||||||
|
|
||||||
|
expect(runtime.events).toEqual(["delete"]);
|
||||||
|
expect(controller.snapshot().results["compatibility-review"]).toMatchObject({
|
||||||
|
status: "waiting-for-user",
|
||||||
|
});
|
||||||
|
expect(controller.snapshot().resumedRequestId).toBe(
|
||||||
|
"compatibility-review-2026-07-18T11:59:00.000Z"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not copy rejected continuation values into the report", () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
runtime.continuation = JSON.stringify({
|
||||||
|
formatVersion: 1,
|
||||||
|
requestId: "review-1",
|
||||||
|
scenarioId: "/Users/reviewer/private-vault/secret-note.md",
|
||||||
|
stage: "awaiting-restart",
|
||||||
|
requestedAt: "2026-07-18T11:59:00.000Z",
|
||||||
|
});
|
||||||
|
const controller = new ReviewHarnessController(runtime);
|
||||||
|
|
||||||
|
controller.consumeContinuation();
|
||||||
|
|
||||||
|
expect(controller.snapshot().continuationError).toContain("private-vault");
|
||||||
|
expect(controller.snapshot().results["compatibility-review"].detail).toBe(
|
||||||
|
"The stored continuation was invalid and was removed."
|
||||||
|
);
|
||||||
|
expect(controller.createReport()).not.toContain("private-vault");
|
||||||
|
expect(controller.createReport()).not.toContain("secret-note.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not accept an injected request ID which could enter the report transcript", () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
runtime.continuation = JSON.stringify({
|
||||||
|
formatVersion: 1,
|
||||||
|
requestId: "/Users/reviewer/private-vault/secret-note.md",
|
||||||
|
scenarioId: "compatibility-review",
|
||||||
|
stage: "awaiting-restart",
|
||||||
|
requestedAt: "2026-07-18T11:59:00.000Z",
|
||||||
|
});
|
||||||
|
const controller = new ReviewHarnessController(runtime);
|
||||||
|
|
||||||
|
controller.consumeContinuation();
|
||||||
|
|
||||||
|
expect(controller.snapshot().results["compatibility-review"].status).toBe("failed");
|
||||||
|
expect(controller.createReport()).not.toContain("private-vault");
|
||||||
|
expect(controller.createReport()).not.toContain("secret-note.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists the fixed continuation before requesting restart", () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
const controller = new ReviewHarnessController(runtime);
|
||||||
|
|
||||||
|
controller.prepareCompatibilityReviewRestart();
|
||||||
|
|
||||||
|
expect(runtime.events).toEqual(["write", "restart"]);
|
||||||
|
expect(JSON.parse(runtime.continuation!)).toMatchObject({
|
||||||
|
formatVersion: 1,
|
||||||
|
scenarioId: "compatibility-review",
|
||||||
|
stage: "awaiting-restart",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes only after the actual compatibility review clears its pause", async () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
const controller = new ReviewHarnessController(runtime);
|
||||||
|
await controller.runScenario("compatibility-review");
|
||||||
|
|
||||||
|
await controller.openCompatibilityReview();
|
||||||
|
|
||||||
|
expect(runtime.openCompatibilityReview).toHaveBeenCalledOnce();
|
||||||
|
expect(controller.snapshot().results["compatibility-review"]).toMatchObject({
|
||||||
|
status: "passed",
|
||||||
|
detail: "The device-local compatibility pause was reviewed and cleared.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remains waiting when the actual compatibility review stays paused", async () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
runtime.openCompatibilityReview = vi.fn(async () => undefined);
|
||||||
|
const controller = new ReviewHarnessController(runtime);
|
||||||
|
await controller.runScenario("compatibility-review");
|
||||||
|
|
||||||
|
await controller.openCompatibilityReview();
|
||||||
|
|
||||||
|
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies a Markdown report after compatibility evidence is recorded", async () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
const controller = new ReviewHarnessController(runtime);
|
||||||
|
await controller.runScenario("compatibility-review");
|
||||||
|
await controller.openCompatibilityReview();
|
||||||
|
|
||||||
|
await controller.copyReport();
|
||||||
|
|
||||||
|
expect(runtime.copyText).toHaveBeenCalledOnce();
|
||||||
|
expect(vi.mocked(runtime.copyText).mock.calls[0][0]).toContain("Compatibility review boundary");
|
||||||
|
expect(controller.snapshot().results["compatibility-review"].status).toBe("passed");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,6 +27,7 @@ export interface CompatibilityReviewUi {
|
|||||||
export class CompatibilityReviewController {
|
export class CompatibilityReviewController {
|
||||||
private pause: CompatibilityPause | undefined;
|
private pause: CompatibilityPause | undefined;
|
||||||
private activeReview: Promise<void> | undefined;
|
private activeReview: Promise<void> | undefined;
|
||||||
|
private _initialised = false;
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -39,6 +40,10 @@ export class CompatibilityReviewController {
|
|||||||
return this.pause;
|
return this.pause;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get initialised(): boolean {
|
||||||
|
return this._initialised;
|
||||||
|
}
|
||||||
|
|
||||||
private readAcknowledgedVersion(): string | null {
|
private readAcknowledgedVersion(): string | null {
|
||||||
const setting = this.core.services.setting;
|
const setting = this.core.services.setting;
|
||||||
const currentMarker = setting.getSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY);
|
const currentMarker = setting.getSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY);
|
||||||
@@ -68,14 +73,19 @@ export class CompatibilityReviewController {
|
|||||||
this.pause = evaluation.pause;
|
this.pause = evaluation.pause;
|
||||||
if (evaluation.initialiseAcknowledgedVersion) {
|
if (evaluation.initialiseAcknowledgedVersion) {
|
||||||
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||||
|
this._initialised = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!this.pause) {
|
||||||
|
this._initialised = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (!this.pause) return true;
|
|
||||||
|
|
||||||
if (settings.versionUpFlash === "") {
|
if (settings.versionUpFlash === "") {
|
||||||
settings.versionUpFlash = COMPATIBILITY_PAUSE_SETTING_MESSAGE;
|
settings.versionUpFlash = COMPATIBILITY_PAUSE_SETTING_MESSAGE;
|
||||||
await setting.saveSettingData();
|
await setting.saveSettingData();
|
||||||
}
|
}
|
||||||
|
this._initialised = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,8 +76,11 @@ describe("compatibility review controller", () => {
|
|||||||
it("initialises the acknowledged version for a new Vault without showing a pause", async () => {
|
it("initialises the acknowledged version for a new Vault without showing a pause", async () => {
|
||||||
const fixture = createFixture({ marker: null, migration: { isNewVault: true } });
|
const fixture = createFixture({ marker: null, migration: { isNewVault: true } });
|
||||||
|
|
||||||
|
expect(fixture.controller.initialised).toBe(false);
|
||||||
|
|
||||||
await expect(fixture.controller.initialise()).resolves.toBe(true);
|
await expect(fixture.controller.initialise()).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(fixture.controller.initialised).toBe(true);
|
||||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
|
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
|
||||||
expect(fixture.controller.pendingPause).toBeUndefined();
|
expect(fixture.controller.pendingPause).toBeUndefined();
|
||||||
expect(fixture.saveSettingData).not.toHaveBeenCalled();
|
expect(fixture.saveSettingData).not.toHaveBeenCalled();
|
||||||
|
|||||||
Reference in New Issue
Block a user