mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 13:02:58 +00:00
feat: restore opt-in first-run onboarding
This commit is contained in:
@@ -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