Prepare the 1.0 compatibility review flow

This commit is contained in:
vorotamoroz
2026-07-19 04:34:08 +00:00
parent 52138bf7a5
commit ed3f81e9f9
42 changed files with 5524 additions and 4119 deletions
+164 -68
View File
@@ -1,20 +1,12 @@
import {
assertLocatorHasMinimumTouchTarget,
assertLocatorWithinSafeArea,
assertLocatorWithinViewport,
assertNoHorizontalOverflow,
} from "@vrtmrz/obsidian-test-session";
import type { Locator, Page } from "playwright";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { assertMobileDialogueLayout, assertMobileNoticeLayout, 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 dialogRunStateKey = "__livesyncE2EDialogMount";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_DIALOG_TIMEOUT_MS ?? 10000);
const mobileViewport = { width: 390, height: 844 } as const;
const iPhoneSafeArea = { top: 47, right: 0, bottom: 34, left: 0 } as const;
type DialogueMode = "desktop" | "mobile";
@@ -39,66 +31,11 @@ type LiveSyncTestPlugin = {
type ObsidianTestApp = {
commands?: { executeCommandById(commandId: string): boolean };
emulateMobile?: (mobile: boolean) => void;
plugins?: { plugins: Record<string, LiveSyncTestPlugin | undefined> };
};
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
async function setMobileDialogueTestMode(enabled: boolean): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
if (enabled) {
await page.setViewportSize(mobileViewport);
}
await page.evaluate((nextEnabled) => {
const obsidianApp = (globalThis as ObsidianTestGlobal).app;
if (typeof obsidianApp?.emulateMobile !== "function") {
throw new Error("app.emulateMobile is unavailable");
}
obsidianApp.emulateMobile(nextEnabled);
}, enabled);
await page.waitForFunction(
(nextEnabled) => {
const obsidianApp = (globalThis as ObsidianTestGlobal).app;
return (
document.body.classList.contains("is-mobile") === nextEnabled &&
obsidianApp?.plugins?.plugins["obsidian-livesync"] !== undefined
);
},
enabled,
{ timeout: uiTimeoutMs }
);
await page.evaluate(
(safeArea) => {
for (const edge of ["top", "right", "bottom", "left"] as const) {
const property = `--safe-area-inset-${edge}`;
if (safeArea === null) document.body.style.removeProperty(property);
else document.body.style.setProperty(property, `${safeArea[edge]}px`);
}
},
enabled ? iPhoneSafeArea : null
);
});
}
async function assertMobileDialogueLayout(page: Page, container: Locator, label: string): Promise<void> {
const dialogue = container.locator(".modal").last();
const closeButton = dialogue.locator(".modal-close-button");
await assertLocatorWithinViewport(page, dialogue, { label });
await assertNoHorizontalOverflow(page, dialogue, { label });
await assertLocatorWithinSafeArea(page, dialogue, {
label,
safeAreaInsets: iPhoneSafeArea,
});
await assertLocatorWithinSafeArea(page, closeButton, {
label: `${label} close button`,
safeAreaInsets: iPhoneSafeArea,
});
await assertLocatorHasMinimumTouchTarget(page, closeButton, {
label: `${label} close button`,
});
}
async function openRemoteSelectionDialogue(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.evaluate((stateKey) => {
@@ -155,6 +92,96 @@ async function assertDialogueRunCompleted(): Promise<void> {
}
}
async function verifyRemoteSizeNoticeAndDialogue(): Promise<{
compatibilityReview: string;
notice: string;
dialogue: string;
}> {
const compatibilityReviewScreenshot = await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"compatibility-review-dialogue.png",
async (page) => {
const compatibilityReview = page.locator(".modal-container").filter({
has: page
.locator(".modal-title")
.filter({ hasText: "Synchronisation paused for compatibility review" }),
});
await compatibilityReview.waitFor({ state: "visible", timeout: uiTimeoutMs });
const actions = compatibilityReview.locator(".vpk-action-dialog__actions--vertical");
await actions.waitFor({ state: "visible", timeout: uiTimeoutMs });
const flexDirection = await actions.evaluate((element) => getComputedStyle(element).flexDirection);
if (flexDirection !== "column") {
throw new Error(`Expected vertically stacked compatibility actions, received ${flexDirection}.`);
}
}
);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const compatibilityReview = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Synchronisation paused for compatibility review" }),
});
await compatibilityReview
.getByRole("button", { name: "Keep synchronisation paused" })
.click({ timeout: uiTimeoutMs });
await compatibilityReview.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const noticeScreenshot = await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"remote-size-startup-notice.png",
async (page) => {
const notice = page.locator(".notice").filter({
hasText: "Remote storage size notifications are not configured.",
});
await notice.waitFor({ state: "visible", timeout: uiTimeoutMs });
await notice.getByRole("link", { name: "Review options" }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const notice = page.locator(".notice").filter({
hasText: "Remote storage size notifications are not configured.",
});
await notice.getByRole("link", { name: "Review options" }).click({ timeout: uiTimeoutMs });
await notice.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const dialogueScreenshot = await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"remote-size-review-dialogue.png",
async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
for (const action of [
"No, never warn please",
"800MB (Cloudant, fly.io)",
"2GB (Standard)",
"Ask me later",
]) {
await modal.getByRole("button", { name: action }).waitFor({ state: "visible", timeout: uiTimeoutMs });
}
}
);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }),
});
await modal.getByRole("button", { name: "Ask me later" }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
return {
compatibilityReview: compatibilityReviewScreenshot,
notice: noticeScreenshot,
dialogue: dialogueScreenshot,
};
}
async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise<string> {
await openRemoteSelectionDialogue();
const screenshotPath = await captureObsidianDialogue(
@@ -235,6 +262,67 @@ async function verifySetupUriDialogue(mode: DialogueMode): Promise<string> {
return screenshotPath;
}
async function verifyMobileStartupReviews(): Promise<{ compatibilityReview: string; remoteSizeReview: string }> {
const compatibilityReviewScreenshot = await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"compatibility-review-dialogue-mobile.png",
async (page) => {
const modal = page.locator(".modal-container").filter({
has: page
.locator(".modal-title")
.filter({ hasText: "Synchronisation paused for compatibility review" }),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.locator(".vpk-action-dialog__actions--vertical").waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await assertMobileDialogueLayout(page, modal, "compatibility review dialogue");
}
);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Synchronisation paused for compatibility review" }),
});
await modal.getByRole("button", { name: "Keep synchronisation paused" }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
const compatibilityReminder = page.locator(".livesync-compatibility-review-notice");
await compatibilityReminder.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertMobileNoticeLayout(page, compatibilityReminder, "compatibility review reminder");
const notice = page.locator(".notice").filter({
hasText: "Remote storage size notifications are not configured.",
});
await notice.getByRole("link", { name: "Review options" }).click({ timeout: uiTimeoutMs });
await notice.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const remoteSizeReviewScreenshot = await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"remote-size-review-dialogue-mobile.png",
async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertMobileDialogueLayout(page, modal, "remote size review dialogue");
}
);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }),
});
await modal.getByRole("button", { name: "Ask me later" }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
return {
compatibilityReview: compatibilityReviewScreenshot,
remoteSizeReview: remoteSizeReviewScreenshot,
};
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
@@ -252,9 +340,8 @@ async function main(): Promise<void> {
pluginData: {
doctorProcessedVersion: "0.25.27",
isConfigured: true,
lastReadUpdates: Number.MAX_SAFE_INTEGER,
liveSync: false,
notifyThresholdOfRemoteStorageSize: 0,
notifyThresholdOfRemoteStorageSize: -1,
syncOnStart: false,
syncOnSave: false,
syncOnEditorSave: false,
@@ -265,13 +352,22 @@ async function main(): Promise<void> {
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
const remoteSizeScreenshots = await verifyRemoteSizeNoticeAndDialogue();
console.log(
`Compatibility review actions were stacked vertically, and the remote-size startup notice opened an untimed review dialogue successfully. Screenshots: ${remoteSizeScreenshots.compatibilityReview}, ${remoteSizeScreenshots.notice}, ${remoteSizeScreenshots.dialogue}`
);
const remoteScreenshot = await verifyRemoteSelectionDialogue("desktop");
console.log(`Remote selection dialogue mounted and closed successfully. Screenshot: ${remoteScreenshot}`);
const setupUriScreenshot = await verifySetupUriDialogue("desktop");
console.log(`Setup URI dialogue mounted and closed successfully. Screenshot: ${setupUriScreenshot}`);
await setMobileDialogueTestMode(true);
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs);
try {
const mobileStartupScreenshots = await verifyMobileStartupReviews();
console.log(
`Mobile compatibility and remote-size reviews passed viewport, safe-area, touch-target, and vertical-action checks. Screenshots: ${mobileStartupScreenshots.compatibilityReview}, ${mobileStartupScreenshots.remoteSizeReview}`
);
const mobileRemoteScreenshot = await verifyRemoteSelectionDialogue("mobile");
console.log(
`Mobile remote selection dialogue passed viewport, safe-area, touch-target, and close-control checks. Screenshot: ${mobileRemoteScreenshot}`
@@ -281,7 +377,7 @@ async function main(): Promise<void> {
`Mobile Setup URI dialogue passed viewport, safe-area, and touch-target checks. Screenshot: ${mobileSetupUriScreenshot}`
);
} finally {
await setMobileDialogueTestMode(false);
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs);
}
} finally {
if (session) {
+162 -4
View File
@@ -1,22 +1,171 @@
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { assertMobileDialogueLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS ?? 10000);
const compatibilityReviewMessage = "Review the internal database compatibility change before synchronisation resumes.";
type ObsidianSettingsController = {
open(): void;
openTabById(tabId: string): void;
};
type LiveSyncTestPlugin = {
core: {
services: {
setting: {
currentSettings(): { versionUpFlash: string };
getSmallConfig(key: string): string | null;
};
};
};
};
type ObsidianTestApp = {
setting?: ObsidianSettingsController;
plugins?: { plugins: Record<string, LiveSyncTestPlugin | undefined> };
};
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
async function verifyCompatibilityReview(): Promise<void> {
const port = obsidianRemoteDebuggingPort();
const summaryScreenshot = await captureObsidianDialogue(port, "compatibility-review-summary.png", async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByText("Your automatic synchronisation preferences have not been changed.", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByRole("button", { name: "Review compatibility details" })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByRole("button", {
name: "Resume synchronisation",
})
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByRole("button", { name: "Keep synchronisation paused" })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
await withObsidianPage(port, async (page) => {
const markerBeforeAcknowledgement = await page.evaluate(() => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable");
return plugin.core.services.setting.getSmallConfig("database-compatibility-version");
});
if (markerBeforeAcknowledgement !== null && markerBeforeAcknowledgement !== "") {
throw new Error(
`The database version was marked as acknowledged before review: ${markerBeforeAcknowledgement}`
);
}
});
await setObsidianMobileTestMode(port, true, uiTimeoutMs);
const mobileSummaryScreenshot = await captureObsidianDialogue(
port,
"compatibility-review-summary-mobile.png",
async (page) => {
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertMobileDialogueLayout(page, summary, "compatibility review summary");
}
);
await withObsidianPage(port, async (page) => {
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary.getByRole("button", { name: "Review compatibility details" }).click();
});
const detailsScreenshot = await captureObsidianDialogue(
port,
"compatibility-review-details-mobile.png",
async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.getByText("Why synchronisation is paused", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await modal.getByText("Remote replication is blocked before work begins.", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await modal
.getByRole("button", { name: "Back to compatibility review" })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
if ((await modal.getByRole("button", { name: "Keep synchronisation paused" }).count()) !== 0) {
throw new Error("The explanatory details dialogue must not make the pause decision.");
}
await assertMobileDialogueLayout(page, modal, "compatibility review details");
}
);
await withObsidianPage(port, async (page) => {
const details = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }),
});
await details.getByRole("button", { name: "Back to compatibility review" }).click();
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
await setObsidianMobileTestMode(port, false, uiTimeoutMs);
await withObsidianPage(port, async (page) => {
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary
.getByRole("button", {
name: "Resume synchronisation",
})
.click();
await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs });
await page.waitForFunction(
(expectedVersion) => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (plugin === undefined) return false;
const setting = plugin.core.services.setting;
return (
setting.getSmallConfig("database-compatibility-version") === expectedVersion &&
setting.currentSettings().versionUpFlash === ""
);
},
`${VER}`,
{ timeout: uiTimeoutMs }
);
});
console.log(
`Compatibility review screenshots: ${summaryScreenshot}, ${mobileSummaryScreenshot}, ${detailsScreenshot}`
);
}
async function verifyDeletionSettings(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.evaluate(() => {
@@ -28,8 +177,16 @@ async function verifyDeletionSettings(): Promise<void> {
const liveSyncSettings = page.locator(".sls-setting");
await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs });
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Sync Settings"]').click();
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Change Log"]').click();
const removedAcknowledgements = liveSyncSettings.getByRole("button", {
name: /I got it and updated|OK, I have read everything/u,
});
if ((await removedAcknowledgements.count()) !== 0) {
throw new Error("The Change Log still contains a compatibility or release-note acknowledgement control.");
}
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Sync Settings"]').click();
const deletionPanel = liveSyncSettings
.locator("h4.sls-setting-panel-title")
.filter({ hasText: "Deletion Propagation" })
@@ -64,8 +221,8 @@ async function main(): Promise<void> {
pluginData: {
doctorProcessedVersion: "0.25.27",
isConfigured: true,
lastReadUpdates: Number.MAX_SAFE_INTEGER,
liveSync: false,
versionUpFlash: compatibilityReviewMessage,
notifyThresholdOfRemoteStorageSize: 0,
syncOnStart: false,
syncOnSave: false,
@@ -77,8 +234,9 @@ async function main(): Promise<void> {
},
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await verifyCompatibilityReview();
await verifyDeletionSettings();
console.log("Deletion settings expose only effective user controls.");
console.log("Compatibility review and deletion settings expose only effective user controls.");
} finally {
if (session) {
await session.app.stop();