mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 13:02:58 +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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user