mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 04:52:58 +00:00
feat: restore opt-in first-run onboarding
This commit is contained in:
@@ -43,6 +43,7 @@
|
||||
"test:e2e:obsidian:cli-help": "tsx test/e2e-obsidian/scripts/cli-help.ts",
|
||||
"test:e2e:obsidian:debug-ui": "tsx test/e2e-obsidian/scripts/debug-ui.ts",
|
||||
"test:e2e:obsidian:smoke": "tsx test/e2e-obsidian/scripts/smoke.ts",
|
||||
"test:e2e:obsidian:onboarding-invitation": "tsx test/e2e-obsidian/scripts/onboarding-invitation.ts",
|
||||
"test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts",
|
||||
"test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts",
|
||||
"test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts",
|
||||
|
||||
@@ -26,6 +26,11 @@ import {
|
||||
import { countCompromisedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { SetupManager } from "@/modules/features/SetupManager.ts";
|
||||
import { showOnboardingInvitation } from "@/serviceFeatures/setupObsidian/setupManagerHandlers.ts";
|
||||
import {
|
||||
runConfiguredStartupLifecycle,
|
||||
runStartupEntryLifecycle,
|
||||
} from "@/serviceFeatures/configuredStartupLifecycle.ts";
|
||||
|
||||
type ErrorInfo = {
|
||||
path: string;
|
||||
@@ -80,9 +85,9 @@ export class ModuleMigration extends AbstractModule<LiveSyncCore> {
|
||||
}
|
||||
}
|
||||
|
||||
async initialMessage() {
|
||||
initialMessage() {
|
||||
const manager = this.core.getModule(SetupManager);
|
||||
return await manager.startOnBoarding();
|
||||
showOnboardingInvitation(this.core, manager);
|
||||
/*
|
||||
const message = $msg("moduleMigration.msgInitialSetup", {
|
||||
URI_DOC: $msg("moduleMigration.docUri"),
|
||||
@@ -344,39 +349,21 @@ export class ModuleMigration extends AbstractModule<LiveSyncCore> {
|
||||
}
|
||||
|
||||
async _everyOnFirstInitialize(): Promise<boolean> {
|
||||
if (!this.localDatabase.isReady) {
|
||||
this._log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (this.settings.isConfigured) {
|
||||
if (!(await this.hasCompromisedChunks())) {
|
||||
return false;
|
||||
}
|
||||
if (!(await this.hasIncompleteDocs())) {
|
||||
return false;
|
||||
}
|
||||
if (!(await this.migrateUsingDoctor(false))) {
|
||||
return false;
|
||||
}
|
||||
// await this.migrationCheck();
|
||||
await this.migrateDisableBulkSend();
|
||||
}
|
||||
if (!this.settings.isConfigured) {
|
||||
// if (!(await this.initialMessage()) || !(await this.askAgainForSetupURI())) {
|
||||
// this._log($msg("moduleMigration.logSetupCancelled"), LOG_LEVEL_NOTICE);
|
||||
// return false;
|
||||
// }
|
||||
if (!(await this.initialMessage())) {
|
||||
this._log($msg("moduleMigration.logSetupCancelled"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (!(await this.migrateUsingDoctor(true))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
return await runConfiguredStartupLifecycle({
|
||||
databaseReady: this.localDatabase.isReady,
|
||||
reportDatabaseNotReady: () => this._log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE),
|
||||
hasCompromisedChunks: () => this.hasCompromisedChunks(),
|
||||
hasIncompleteDocuments: () => this.hasIncompleteDocs(),
|
||||
runDoctor: () => this.migrateUsingDoctor(false),
|
||||
migrateBulkSend: () => this.migrateDisableBulkSend(),
|
||||
});
|
||||
}
|
||||
_everyOnLayoutReady(): Promise<boolean> {
|
||||
const shouldInitialiseDatabase = runStartupEntryLifecycle({
|
||||
configured: this.settings.isConfigured === true,
|
||||
inviteToOnboarding: () => this.initialMessage(),
|
||||
});
|
||||
if (!shouldInitialiseDatabase) return Promise.resolve(false);
|
||||
eventHub.onEvent(EVENT_REQUEST_RUN_DOCTOR, async (reason) => {
|
||||
await this.migrateUsingDoctor(false, reason, true);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_REQUEST_RELOAD_SETTING_TAB, EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { SettingService, type SettingServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
|
||||
import {
|
||||
EVENT_REQUEST_RELOAD_SETTING_TAB,
|
||||
EVENT_SETTING_SAVED,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import {
|
||||
SettingService,
|
||||
type SettingServiceDependencies,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
|
||||
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||
|
||||
export function normaliseObsidianSettingsData(
|
||||
data: ObsidianLiveSyncSettings | null | undefined
|
||||
): ObsidianLiveSyncSettings | undefined {
|
||||
return data ?? undefined;
|
||||
}
|
||||
|
||||
export class ObsidianSettingService<T extends ObsidianServiceContext> extends SettingService<T> {
|
||||
constructor(context: T, dependencies: SettingServiceDependencies) {
|
||||
super(context, dependencies);
|
||||
@@ -33,6 +45,6 @@ export class ObsidianSettingService<T extends ObsidianServiceContext> extends Se
|
||||
return await this.context.liveSyncPlugin.saveData(data);
|
||||
}
|
||||
protected override async loadData(): Promise<ObsidianLiveSyncSettings | undefined> {
|
||||
return await this.context.liveSyncPlugin.loadData();
|
||||
return normaliseObsidianSettingsData(await this.context.liveSyncPlugin.loadData());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { normaliseObsidianSettingsData } from "./ObsidianSettingService.ts";
|
||||
|
||||
describe("normaliseObsidianSettingsData", () => {
|
||||
it("maps Obsidian's missing data value to Commonlib's new-Vault input", () => {
|
||||
expect(normaliseObsidianSettingsData(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves stored settings", () => {
|
||||
const settings = { isConfigured: false } as ObsidianLiveSyncSettings;
|
||||
|
||||
expect(normaliseObsidianSettingsData(settings)).toBe(settings);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface ConfiguredStartupLifecycleRuntime {
|
||||
databaseReady: boolean;
|
||||
reportDatabaseNotReady(): void;
|
||||
hasCompromisedChunks(): Promise<boolean>;
|
||||
hasIncompleteDocuments(): Promise<boolean>;
|
||||
runDoctor(): Promise<boolean>;
|
||||
migrateBulkSend(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartupEntryLifecycleRuntime {
|
||||
configured: boolean;
|
||||
inviteToOnboarding(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps an unconfigured Vault outside database initialisation and all
|
||||
* configured-only start-up work while offering an explicit setup action.
|
||||
*/
|
||||
export function runStartupEntryLifecycle(runtime: StartupEntryLifecycleRuntime): boolean {
|
||||
if (runtime.configured) return true;
|
||||
runtime.inviteToOnboarding();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Separates the inert, unconfigured startup path from checks which must run
|
||||
* before an already configured device is allowed to synchronise.
|
||||
*/
|
||||
export async function runConfiguredStartupLifecycle(runtime: ConfiguredStartupLifecycleRuntime): Promise<boolean> {
|
||||
if (!runtime.databaseReady) {
|
||||
runtime.reportDatabaseNotReady();
|
||||
return false;
|
||||
}
|
||||
if (!(await runtime.hasCompromisedChunks())) return false;
|
||||
if (!(await runtime.hasIncompleteDocuments())) return false;
|
||||
if (!(await runtime.runDoctor())) return false;
|
||||
await runtime.migrateBulkSend();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
runConfiguredStartupLifecycle,
|
||||
runStartupEntryLifecycle,
|
||||
type ConfiguredStartupLifecycleRuntime,
|
||||
} from "./configuredStartupLifecycle";
|
||||
|
||||
function createRuntime(): ConfiguredStartupLifecycleRuntime & { events: string[] } {
|
||||
const events: string[] = [];
|
||||
return {
|
||||
events,
|
||||
databaseReady: true,
|
||||
reportDatabaseNotReady: vi.fn(() => events.push("database-not-ready")),
|
||||
hasCompromisedChunks: vi.fn(async () => {
|
||||
events.push("compromised-chunks");
|
||||
return true;
|
||||
}),
|
||||
hasIncompleteDocuments: vi.fn(async () => {
|
||||
events.push("incomplete-documents");
|
||||
return true;
|
||||
}),
|
||||
runDoctor: vi.fn(async () => {
|
||||
events.push("doctor");
|
||||
return true;
|
||||
}),
|
||||
migrateBulkSend: vi.fn(async () => {
|
||||
events.push("bulk-send");
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("runConfiguredStartupLifecycle", () => {
|
||||
it("runs configured checks in order before allowing initialisation", async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(true);
|
||||
|
||||
expect(runtime.events).toEqual(["compromised-chunks", "incomplete-documents", "doctor", "bulk-send"]);
|
||||
});
|
||||
|
||||
it("stops before onboarding or checks when the database is unavailable", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.databaseReady = false;
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
|
||||
expect(runtime.events).toEqual(["database-not-ready"]);
|
||||
});
|
||||
|
||||
it("stops the configured sequence at the first failed check", async () => {
|
||||
const runtime = createRuntime();
|
||||
vi.mocked(runtime.hasIncompleteDocuments).mockImplementation(async () => {
|
||||
runtime.events.push("incomplete-documents");
|
||||
return false;
|
||||
});
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
|
||||
expect(runtime.events).toEqual(["compromised-chunks", "incomplete-documents"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runStartupEntryLifecycle", () => {
|
||||
it("offers onboarding and stops before database initialisation on an unconfigured Vault", () => {
|
||||
const inviteToOnboarding = vi.fn();
|
||||
|
||||
expect(
|
||||
runStartupEntryLifecycle({
|
||||
configured: false,
|
||||
inviteToOnboarding,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(inviteToOnboarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("allows a configured Vault to continue to database initialisation", () => {
|
||||
const inviteToOnboarding = vi.fn();
|
||||
|
||||
expect(
|
||||
runStartupEntryLifecycle({
|
||||
configured: true,
|
||||
inviteToOnboarding,
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(inviteToOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,38 @@
|
||||
import { type SetupManager, UserMode } from "@/modules/features/SetupManager";
|
||||
import type { SetupFeatureHost } from "@/serviceFeatures/setupObsidian/types";
|
||||
import { EVENT_REQUEST_OPEN_P2P_SETTINGS, EVENT_REQUEST_OPEN_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import {
|
||||
EVENT_REQUEST_OPEN_P2P_SETTINGS,
|
||||
EVENT_REQUEST_OPEN_SETUP_URI,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { $msg } from "@vrtmrz/livesync-commonlib/compat/common/i18n";
|
||||
|
||||
const ONBOARDING_NOTICE_DURATION_MS = 60_000;
|
||||
|
||||
export async function openOnboarding(setupManager: SetupManager) {
|
||||
return await setupManager.startOnBoarding();
|
||||
}
|
||||
|
||||
export function showOnboardingInvitation(host: NecessaryServices<"UI", never>, setupManager: SetupManager): void {
|
||||
const message = `${$msg("Welcome to Self-hosted LiveSync")} ${$msg(
|
||||
"We will now guide you through a few questions to simplify the synchronisation setup."
|
||||
)} {HERE}`;
|
||||
host.services.UI.confirm.askInPopup(
|
||||
"initial-onboarding",
|
||||
message,
|
||||
(anchor) => {
|
||||
anchor.href = "#";
|
||||
anchor.classList.add("sls-onboarding-invitation-action");
|
||||
anchor.textContent = $msg("Ui.SetupWizard.Invitation.Start");
|
||||
anchor.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
fireAndForget(() => openOnboarding(setupManager));
|
||||
});
|
||||
},
|
||||
ONBOARDING_NOTICE_DURATION_MS
|
||||
);
|
||||
}
|
||||
|
||||
export async function openSetupURI(setupManager: SetupManager) {
|
||||
await setupManager.onUseSetupURI(UserMode.Unknown);
|
||||
@@ -17,6 +47,11 @@ export function useSetupManagerHandlersFeature(
|
||||
setupManager: SetupManager
|
||||
) {
|
||||
host.services.appLifecycle.onLoaded.addHandler(() => {
|
||||
host.services.API.addCommand({
|
||||
id: "livesync-open-onboarding",
|
||||
name: "Open onboarding wizard",
|
||||
callback: () => fireAndForget(() => openOnboarding(setupManager)),
|
||||
});
|
||||
host.services.API.addCommand({
|
||||
id: "livesync-opensetupuri",
|
||||
name: "Use the copied setup URI (Formerly Open setup URI)",
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { EVENT_REQUEST_OPEN_P2P_SETTINGS, EVENT_REQUEST_OPEN_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { openP2PSettings, openSetupURI, useSetupManagerHandlersFeature } from "./setupManagerHandlers";
|
||||
import {
|
||||
EVENT_REQUEST_OPEN_P2P_SETTINGS,
|
||||
EVENT_REQUEST_OPEN_SETUP_URI,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import {
|
||||
openOnboarding,
|
||||
openP2PSettings,
|
||||
openSetupURI,
|
||||
showOnboardingInvitation,
|
||||
useSetupManagerHandlersFeature,
|
||||
} from "./setupManagerHandlers";
|
||||
|
||||
vi.mock("@/modules/features/SetupManager", () => {
|
||||
return {
|
||||
@@ -43,6 +52,63 @@ describe("setupObsidian/setupManagerHandlers", () => {
|
||||
expect(setupManager.onP2PManualSetup).toHaveBeenCalledWith("unknown", settings, false);
|
||||
});
|
||||
|
||||
it("openOnboarding should delegate to SetupManager.startOnBoarding", async () => {
|
||||
const setupManager = {
|
||||
startOnBoarding: vi.fn(async () => await Promise.resolve(false)),
|
||||
} as any;
|
||||
|
||||
await openOnboarding(setupManager);
|
||||
|
||||
expect(setupManager.startOnBoarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("showOnboardingInvitation should wait for its fixed action link before opening onboarding", async () => {
|
||||
let configureAnchor: ((anchor: HTMLAnchorElement) => void) | undefined;
|
||||
const askInPopup = vi.fn(
|
||||
(_key: string, _text: string, callback: (anchor: HTMLAnchorElement) => void, _durationMs?: number) => {
|
||||
configureAnchor = callback;
|
||||
}
|
||||
);
|
||||
const host = {
|
||||
services: {
|
||||
UI: { confirm: { askInPopup } },
|
||||
},
|
||||
} as any;
|
||||
const setupManager = {
|
||||
startOnBoarding: vi.fn(async () => await Promise.resolve(false)),
|
||||
} as any;
|
||||
|
||||
showOnboardingInvitation(host, setupManager);
|
||||
|
||||
expect(setupManager.startOnBoarding).not.toHaveBeenCalled();
|
||||
expect(askInPopup).toHaveBeenCalledWith(
|
||||
"initial-onboarding",
|
||||
expect.stringContaining("{HERE}"),
|
||||
expect.any(Function),
|
||||
60_000
|
||||
);
|
||||
|
||||
let click: ((event: { preventDefault(): void }) => void) | undefined;
|
||||
const addClass = vi.fn();
|
||||
const anchor = {
|
||||
href: "",
|
||||
textContent: "",
|
||||
classList: { add: addClass },
|
||||
addEventListener: vi.fn((_name: string, listener: typeof click) => {
|
||||
click = listener;
|
||||
}),
|
||||
} as unknown as HTMLAnchorElement;
|
||||
configureAnchor!(anchor);
|
||||
|
||||
expect(anchor.href).toBe("#");
|
||||
expect(anchor.textContent).toBe("Start setup");
|
||||
expect(addClass).toHaveBeenCalledWith("sls-onboarding-invitation-action");
|
||||
const preventDefault = vi.fn();
|
||||
click!({ preventDefault });
|
||||
await vi.waitFor(() => expect(setupManager.startOnBoarding).toHaveBeenCalledOnce());
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("useSetupManagerHandlersFeature should register onLoaded handler that wires command and events", async () => {
|
||||
const addHandler = vi.fn();
|
||||
const addCommand = vi.fn();
|
||||
@@ -54,6 +120,11 @@ describe("setupObsidian/setupManagerHandlers", () => {
|
||||
API: {
|
||||
addCommand,
|
||||
},
|
||||
UI: {
|
||||
confirm: {
|
||||
askInPopup: vi.fn(),
|
||||
},
|
||||
},
|
||||
appLifecycle: {
|
||||
onLoaded: {
|
||||
addHandler,
|
||||
@@ -65,6 +136,7 @@ describe("setupObsidian/setupManagerHandlers", () => {
|
||||
},
|
||||
} as any;
|
||||
const setupManager = {
|
||||
startOnBoarding: vi.fn(async () => await Promise.resolve(false)),
|
||||
onUseSetupURI: vi.fn(async () => await Promise.resolve(true)),
|
||||
onP2PManualSetup: vi.fn(async () => await Promise.resolve(true)),
|
||||
} as any;
|
||||
@@ -75,6 +147,12 @@ describe("setupObsidian/setupManagerHandlers", () => {
|
||||
const loadedHandler = addHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
await loadedHandler();
|
||||
|
||||
expect(addCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "livesync-open-onboarding",
|
||||
name: "Open onboarding wizard",
|
||||
})
|
||||
);
|
||||
expect(addCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "livesync-opensetupuri",
|
||||
|
||||
@@ -339,6 +339,14 @@ body {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
|
||||
.sls-onboarding-invitation-action {
|
||||
display: inline-flex;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sls-review-harness {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
|
||||
@@ -60,6 +60,7 @@ npm run test:e2e:obsidian:install-appimage
|
||||
npm run test:e2e:obsidian:discover
|
||||
npm run test:e2e:obsidian:cli-help -- vaults verbose
|
||||
npm run test:e2e:obsidian:smoke
|
||||
npm run test:e2e:obsidian:onboarding-invitation
|
||||
npm run test:e2e:obsidian:dialog-mounts
|
||||
npm run test:e2e:obsidian:settings-ui
|
||||
npm run test:e2e:obsidian:review-harness
|
||||
@@ -80,6 +81,8 @@ npm run test:e2e:obsidian:local-suite:services
|
||||
|
||||
`test:contract:context:cli` builds the Node CLI and runs its existing Deno setup, put, read, list, information, remove, conflict-resolution, and revision workflow. `test:contract:context:obsidian` builds the plug-in and runs the real-Obsidian smoke test, including the Context inspection. These runtime scripts are local validation entry points and are not added to the default CI gate by this change.
|
||||
|
||||
`test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault and verifies that startup offers the setup wizard without opening it or scanning Vault files automatically. It checks the invitation action and introduction in mobile test mode, then uses the permanent command to reopen the wizard on the desktop. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios.
|
||||
|
||||
`test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises two representative Svelte dialogue routes: remote server selection through `SetupManager`, and Setup URI entry through the registered command. The session pre-seeds a configured, inactive plug-in state so that onboarding and migration prompts do not interfere with the dialogues under test. It requires each dialogue title and its principal controls to be visible, captures desktop and mobile screenshots, closes them through their normal user controls, and verifies that the remote-selection promise settles without an error. This covers the host, context, component, and result boundaries moved during the Commonlib package extraction without applying settings or contacting a remote service.
|
||||
|
||||
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then selects the Synchronisation Settings pane and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
|
||||
@@ -88,7 +91,7 @@ The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixe
|
||||
|
||||
`test:e2e:obsidian:review-harness` exercises only the boundaries owned by the opt-in maintainer Harness. It retains a real compatibility pause, uses the fixed Harness restart action to persist a device-local continuation and reload Obsidian, and requires the Harness to delete that state before reopening. It also runs the bounded local observations, confirms the dedicated Vault fixture root is removed, captures the copied privacy-bounded Markdown report, and checks the Harness layout and touch targets in mobile test mode. Compatibility explanation and persistence details remain owned by `settings-ui`, real P2P transfer remains owned by the Compose suite, and general Vault reflection remains owned by `vault-reflection`; the Harness test does not duplicate those workflows.
|
||||
|
||||
`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, Svelte dialogue mounting, settings UI, the Review Harness, Vault reflection, CouchDB upload, CLI-to-Obsidian synchronisation, Object Storage upload, startup scan, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB and MinIO fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run.
|
||||
`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, settings UI, the Review Harness, Vault reflection, CouchDB upload, CLI-to-Obsidian synchronisation, Object Storage upload, startup scan, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB and MinIO fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run.
|
||||
|
||||
`test:e2e:obsidian:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, starts from configured plug-in data without the device-local compatibility marker, and verifies the copied-or-restored Vault explanation in the actual compatibility dialogue. It captures the summary and details, resumes explicitly, confirms that the marker was recorded, creates a note in real Obsidian, commits the note into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the metadata document and its chunk documents.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ const testSteps: Step[] = [
|
||||
: []),
|
||||
{ name: "discover", args: ["run", "test:e2e:obsidian:discover"] },
|
||||
{ name: "smoke", args: ["run", "test:e2e:obsidian:smoke"] },
|
||||
{ name: "onboarding invitation", args: ["run", "test:e2e:obsidian:onboarding-invitation"] },
|
||||
{ name: "Svelte dialogue mounts", args: ["run", "test:e2e:obsidian:dialog-mounts"] },
|
||||
{ name: "settings UI", args: ["run", "test:e2e:obsidian:settings-ui"] },
|
||||
{ name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] },
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
assertLocatorHasMinimumTouchTarget,
|
||||
assertLocatorWithinSafeArea,
|
||||
assertNoHorizontalOverflow,
|
||||
} from "@vrtmrz/obsidian-test-session";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { assertMobileDialogueLayout, iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_ONBOARDING_TIMEOUT_MS ?? 15000);
|
||||
const markerPath = "E2E/unconfigured-startup-must-not-scan.md";
|
||||
|
||||
type UnconfiguredStartupEvidence = {
|
||||
configured: boolean;
|
||||
markerInDatabase: boolean;
|
||||
offlineScanInitialised: boolean;
|
||||
};
|
||||
|
||||
type ObsidianTestApp = {
|
||||
commands?: { executeCommandById(commandId: string): boolean };
|
||||
};
|
||||
|
||||
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
|
||||
|
||||
async function writeMarker(vaultPath: string): Promise<void> {
|
||||
const fullPath = join(vaultPath, markerPath);
|
||||
await mkdir(dirname(fullPath), { recursive: true });
|
||||
await writeFile(fullPath, "# This file must remain outside the database until setup completes.\n", "utf8");
|
||||
}
|
||||
|
||||
async function inspectUnconfiguredStartup(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv
|
||||
): Promise<UnconfiguredStartupEvidence> {
|
||||
return await evalObsidianJson<UnconfiguredStartupEvidence>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
`const markerPath=${JSON.stringify(markerPath)};`,
|
||||
"let entry=false;",
|
||||
"try{entry=await core.localDatabase.getDBEntry(markerPath,undefined,false,false);}catch{}",
|
||||
"let initialised=false;",
|
||||
"try{initialised=(await core.kvDB.get('initialized'))===true;}catch{}",
|
||||
"return JSON.stringify({",
|
||||
"configured:core.services.setting.currentSettings()?.isConfigured===true,",
|
||||
"markerInDatabase:Boolean(entry&&entry._id),",
|
||||
"offlineScanInitialised:initialised,",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
function onboardingNotice(page: Parameters<Parameters<typeof withObsidianPage>[1]>[0]) {
|
||||
return page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" });
|
||||
}
|
||||
|
||||
function onboardingDialogue(page: Parameters<Parameters<typeof withObsidianPage>[1]>[0]) {
|
||||
return page.locator(".modal-container").filter({ hasText: "Welcome to Self-hosted LiveSync" });
|
||||
}
|
||||
|
||||
async function requireInvitationWithoutDialogue(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const invitation = onboardingNotice(page);
|
||||
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await invitation.locator(".sls-onboarding-invitation-action").waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
if ((await onboardingDialogue(page).count()) !== 0) {
|
||||
throw new Error("The onboarding dialogue opened before the user selected the invitation.");
|
||||
}
|
||||
const compatibilityReview = page.locator(".modal-container").filter({
|
||||
hasText: "Synchronisation paused for compatibility review",
|
||||
});
|
||||
if ((await compatibilityReview.count()) !== 0) {
|
||||
throw new Error("A new unconfigured Vault was incorrectly treated as an existing compatibility state.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function captureDesktopInvitation(): Promise<string> {
|
||||
return await captureObsidianDialogue(
|
||||
obsidianRemoteDebuggingPort(),
|
||||
"onboarding-invitation-desktop.png",
|
||||
async (page) => {
|
||||
const invitation = onboardingNotice(page);
|
||||
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await assertNoHorizontalOverflow(page, invitation, { label: "desktop onboarding invitation" });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function captureAndSelectMobileInvitation(): Promise<string> {
|
||||
const port = obsidianRemoteDebuggingPort();
|
||||
await setObsidianMobileTestMode(port, true, uiTimeoutMs);
|
||||
const screenshot = await captureObsidianDialogue(port, "onboarding-invitation-mobile.png", async (page) => {
|
||||
const invitation = onboardingNotice(page);
|
||||
const action = invitation.locator(".sls-onboarding-invitation-action");
|
||||
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await assertLocatorWithinSafeArea(page, invitation, {
|
||||
label: "mobile onboarding invitation",
|
||||
safeAreaInsets: iPhoneSafeArea,
|
||||
});
|
||||
await assertNoHorizontalOverflow(page, invitation, { label: "mobile onboarding invitation" });
|
||||
await assertLocatorHasMinimumTouchTarget(page, action, {
|
||||
label: "mobile onboarding invitation action",
|
||||
});
|
||||
});
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await onboardingNotice(page).locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
async function captureAndCloseIntro(filename: string, mobile: boolean): Promise<string> {
|
||||
const port = obsidianRemoteDebuggingPort();
|
||||
const screenshot = await captureObsidianDialogue(port, filename, async (page) => {
|
||||
const container = onboardingDialogue(page);
|
||||
await container.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await container.getByText("I am setting this up for the first time", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await container
|
||||
.getByText("I am adding a device to an existing synchronisation setup", { exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if (mobile) await assertMobileDialogueLayout(page, container, "mobile onboarding introduction");
|
||||
});
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const container = onboardingDialogue(page);
|
||||
await container.getByRole("button", { name: "No, please take me back" }).click({ timeout: uiTimeoutMs });
|
||||
await container.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
async function openPermanentCommand(): Promise<void> {
|
||||
const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
return await page.evaluate(
|
||||
(commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true,
|
||||
"obsidian-livesync:livesync-open-onboarding"
|
||||
);
|
||||
});
|
||||
if (!opened) throw new Error("The permanent onboarding command was not registered.");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||
const vault = await createTemporaryVault();
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
try {
|
||||
await writeMarker(vault.path);
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary: cli.binary,
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
});
|
||||
|
||||
const evidence = await inspectUnconfiguredStartup(cli.binary, session.cliEnv);
|
||||
console.log(`Fresh Vault startup evidence: ${JSON.stringify(evidence)}`);
|
||||
await requireInvitationWithoutDialogue();
|
||||
if (evidence.configured || evidence.markerInDatabase || evidence.offlineScanInitialised) {
|
||||
throw new Error(`Unconfigured startup performed ordinary processing: ${JSON.stringify(evidence)}`);
|
||||
}
|
||||
|
||||
const desktopInvitation = await captureDesktopInvitation();
|
||||
await openPermanentCommand();
|
||||
const commandIntro = await captureAndCloseIntro("onboarding-intro-command-desktop.png", false);
|
||||
const mobileInvitation = await captureAndSelectMobileInvitation();
|
||||
const mobileIntro = await captureAndCloseIntro("onboarding-intro-mobile.png", true);
|
||||
|
||||
console.log(
|
||||
`Onboarding remained opt-in and kept unconfigured startup inert. Screenshots: ${[
|
||||
desktopInvitation,
|
||||
mobileInvitation,
|
||||
mobileIntro,
|
||||
commandIntro,
|
||||
].join(", ")}`
|
||||
);
|
||||
} finally {
|
||||
if (session) {
|
||||
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs).catch(() => undefined);
|
||||
await session.app.stop();
|
||||
}
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user