diff --git a/src/features/ReviewHarness/reviewHarnessContract.ts b/src/features/ReviewHarness/reviewHarnessContract.ts new file mode 100644 index 00000000..656a642f --- /dev/null +++ b/src/features/ReviewHarness/reviewHarnessContract.ts @@ -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 { + 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>; + +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, "
"); +} + +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} + +
+Event transcript + +\`\`\`json +${JSON.stringify(input.transcript, null, 2)} +\`\`\` +
+ +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. +`; +} diff --git a/src/features/ReviewHarness/reviewHarnessContract.unit.spec.ts b/src/features/ReviewHarness/reviewHarnessContract.unit.spec.ts new file mode 100644 index 00000000..998b538a --- /dev/null +++ b/src/features/ReviewHarness/reviewHarnessContract.unit.spec.ts @@ -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 { + 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
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"); + }); +}); diff --git a/src/features/ReviewHarness/reviewHarnessController.ts b/src/features/ReviewHarness/reviewHarnessController.ts new file mode 100644 index 00000000..9122d3d0 --- /dev/null +++ b/src/features/ReviewHarness/reviewHarnessController.ts @@ -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; + getNewVaultSettings(): Partial; + getSettingsMigrationState(): SettingsMigrationState | undefined; + isCompatibilityReviewInitialised(): boolean; + getCompatibilityPause(): CompatibilityPause | undefined; + openCompatibilityReview(): Promise; + getP2PComposition(): { + readonly first: unknown; + readonly second: unknown; + readonly expectedServices: unknown; + }; + runVaultRoundTrip(): Promise; + readContinuation(): string | null; + writeContinuation(value: string): void; + deleteContinuation(): void; + restart(): void; + reportError(error: unknown): void; + copyText(value: string): Promise; + getEnvironment(): ReviewHarnessReportInput["environment"]; +} + +export interface ReviewHarnessTranscriptEntry { + readonly at: string; + readonly event: string; + readonly detail?: string; +} + +export interface ReviewHarnessSnapshot { + readonly results: Record; + 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 { + return Object.fromEntries(REVIEW_HARNESS_SCENARIOS.map(({ id }) => [id, idleResult()])) as Record< + ReviewHarnessScenarioId, + ReviewHarnessScenarioResult + >; +} + +function inspectP2PComposition(input: ReturnType): 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, + 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 { + for (const id of ["settings-lifecycle", "p2p-composition"] as const) { + await this.runScenario(id); + } + } + + async runAllScenarios(): Promise { + 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 { + 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 { + 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 { + await this.runtime.copyText(this.createReport()); + this.record("report-copied"); + this.notify(); + } +} + +export { REVIEW_HARNESS_STATE_KEY }; diff --git a/src/features/ReviewHarness/reviewHarnessController.unit.spec.ts b/src/features/ReviewHarness/reviewHarnessController.unit.spec.ts new file mode 100644 index 00000000..8ca2faa6 --- /dev/null +++ b/src/features/ReviewHarness/reviewHarnessController.unit.spec.ts @@ -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"); + }); +}); diff --git a/src/serviceFeatures/compatibilityReview.ts b/src/serviceFeatures/compatibilityReview.ts index be95984a..66a3b67c 100644 --- a/src/serviceFeatures/compatibilityReview.ts +++ b/src/serviceFeatures/compatibilityReview.ts @@ -27,6 +27,7 @@ export interface CompatibilityReviewUi { export class CompatibilityReviewController { private pause: CompatibilityPause | undefined; private activeReview: Promise | undefined; + private _initialised = false; private disposed = false; constructor( @@ -39,6 +40,10 @@ export class CompatibilityReviewController { return this.pause; } + get initialised(): boolean { + return this._initialised; + } + private readAcknowledgedVersion(): string | null { const setting = this.core.services.setting; const currentMarker = setting.getSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY); @@ -68,14 +73,19 @@ export class CompatibilityReviewController { this.pause = evaluation.pause; if (evaluation.initialiseAcknowledgedVersion) { setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`); + this._initialised = true; + return true; + } + if (!this.pause) { + this._initialised = true; return true; } - if (!this.pause) return true; if (settings.versionUpFlash === "") { settings.versionUpFlash = COMPATIBILITY_PAUSE_SETTING_MESSAGE; await setting.saveSettingData(); } + this._initialised = true; return true; } diff --git a/src/serviceFeatures/compatibilityReview.unit.spec.ts b/src/serviceFeatures/compatibilityReview.unit.spec.ts index e5aab36f..e5868bde 100644 --- a/src/serviceFeatures/compatibilityReview.unit.spec.ts +++ b/src/serviceFeatures/compatibilityReview.unit.spec.ts @@ -76,8 +76,11 @@ describe("compatibility review controller", () => { it("initialises the acknowledged version for a new Vault without showing a pause", async () => { const fixture = createFixture({ marker: null, migration: { isNewVault: true } }); + expect(fixture.controller.initialised).toBe(false); + 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.controller.pendingPause).toBeUndefined(); expect(fixture.saveSettingData).not.toHaveBeenCalled();