mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-01 15:02:31 +00:00
Prepare time-bound Setup URI integration for Commonlib release
Add Time-bound and Compatible generation to the Obsidian dialogue, browser peer check, and setup tools, with boundary and compatibility coverage. Show the fixed window end before sharing a Time-bound URI. This review branch depends on unpublished @vrtmrz/livesync-commonlib 0.1.32-next.0. The root npm pin and Deno lockfile remain unchanged until that candidate is available; a fresh install of this commit is not yet expected to build.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
import { DEFAULT_SETTINGS, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
P2P_CHECK_APP_ID,
|
||||
@@ -25,6 +25,7 @@ describe("P2P connection-check setup", () => {
|
||||
expect(generated.target).toBe(target);
|
||||
expect(generated.setupPassphrase).toMatch(/^[a-z2-9]{4}(?:-[a-z2-9]{4}){3}$/);
|
||||
expect(generated.setupURI).toMatch(/^obsidian:\/\/setuplivesync\?settings=/);
|
||||
expect(generated.setupURIUsableUntil).toBeGreaterThan(Date.now());
|
||||
expect(effective).toEqual(
|
||||
expect.objectContaining({
|
||||
remoteType: REMOTE_P2P,
|
||||
@@ -94,6 +95,18 @@ describe("P2P connection-check setup", () => {
|
||||
}
|
||||
);
|
||||
|
||||
it("uses the current UTC window and rejects the generated URI at its boundary", async () => {
|
||||
const clock = vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-28T12:00:00Z"));
|
||||
try {
|
||||
const generated = await generateP2PCheckSetup("desktop");
|
||||
expect(generated.setupURIUsableUntil).toBe(Date.parse("2026-10-01T00:00:00Z"));
|
||||
clock.mockReturnValue(generated.setupURIUsableUntil);
|
||||
await expect(decodeSettingsFromSetupURI(generated.setupURI, generated.setupPassphrase)).rejects.toThrow();
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates independent rooms and secrets for separate checks", async () => {
|
||||
const first = await generateP2PCheckSetup("desktop");
|
||||
const second = await generateP2PCheckSetup("desktop");
|
||||
|
||||
@@ -169,3 +169,41 @@ Deno.test({
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "WebPeer: an imported device can still be checked after its Setup URI window ends",
|
||||
sanitizeOps: false,
|
||||
sanitizeResources: false,
|
||||
async fn() {
|
||||
const server = await startStaticServer(webPeerDist);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}check.html`);
|
||||
await page.getByRole("button", { name: "Prepare desktop check", exact: true }).click();
|
||||
await page.getByAltText("Setup URI QR code for the desktop check", { exact: true }).waitFor();
|
||||
|
||||
// The target device has imported the URI; only the browser monitor is still pending.
|
||||
await page.evaluate(() => {
|
||||
const originalNow = Date.now;
|
||||
Date.now = () => originalNow() + 8 * 24 * 60 * 60 * 1_000;
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
});
|
||||
await page.getByText(/This Setup URI is outside its time window/).first().waitFor();
|
||||
|
||||
assertEquals(await page.getByLabel("Setup URI", { exact: true }).count(), 0);
|
||||
assertEquals(await page.getByAltText("Setup URI QR code for the desktop check").count(), 0);
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Start connection monitor", exact: true }).isEnabled(),
|
||||
true
|
||||
);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -178,6 +178,8 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
|
||||
|
||||
`test:e2e:obsidian:setup-uri-workflow` runs the repository's public Commonlib-backed CouchDB provisioning and Setup URI tools against the local CouchDB fixture. It configures a new, empty Vault in the first real Obsidian session through the visible onboarding wizard and uses Rebuild. After that device is working, it generates a new Setup URI through the registered command; the second real Obsidian Vault uses that URI for Fetch instead of reusing the initial Setup URI produced by the provisioning tool. The workflow verifies ordinary notes from the first device to the second and back again, independently enables Hidden File Sync on each device, and verifies a snippet. The retained Setup URI screenshots show only encrypted URIs and visually masked Setup URI passphrases; plaintext credentials are not captured. Files prefixed with `guide-` capture the relevant dialogue, settings panel, or workspace leaf without transient Notices. Public documentation copies selected images only after visual inspection; the E2E run does not overwrite repository documentation assets.
|
||||
|
||||
`E2E_OBSIDIAN_ONLY_SETUP_URI_GENERATION=true npm run test:e2e:obsidian:focused -- dialog-mounts` runs the Setup URI generation slice in an isolated real Obsidian Vault without a CouchDB service. It checks the Time-bound choice and displayed end time, the generated URI's round trip, Compatible encryption with the original passphrase, and desktop and mobile dialogue layout. Unit and protocol tests cover the time-window boundary without changing the host clock.
|
||||
|
||||
`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, ordinary rename, a case-only file name change within the same directory, deletion, and a separate encrypted round-trip with Path Obfuscation enabled. Its target-filter scenario confirms that one Vault receives and checkpoints a remote document without reflecting it, restarts with the same profile and filter, and then reflects the stored document after the filter is broadened through the settings service. Directory case changes deliberately remain outside the ordinary workflow because they require directory-aware rename handling.
|
||||
|
||||
During focused development, `E2E_OBSIDIAN_ONLY_PARENT_CASE_DELETION=true` runs an Issue #1168 check which renames `parent/test3` to `parent/Test3` through external `node:fs/promises.rename` while Vault A is open, and verifies that the note content, Metadata, and Chunk references are not logically deleted locally, remotely, or after restart. It accepts either case spelling on Vault B, so it does not provide directory rename support or exact case convergence between devices. The natural Obsidian event sequence and resulting database state are evidence for the selected build; an existing-version reproduction result must be reported separately from fixed-version safety evidence.
|
||||
|
||||
@@ -150,6 +150,12 @@ export async function generateSetupURIFromDevice(
|
||||
const prompt = modalByTitle(page, promptTitle);
|
||||
await prompt.getByRole("button", { name: "OK", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await prompt.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
const choice = modalByTitle(page, "Setup URI availability");
|
||||
await choice.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await choice.getByText("Time-bound Setup URIs can be opened until", { exact: false }).waitFor({
|
||||
state: "visible", timeout: uiTimeoutMs,
|
||||
});
|
||||
await choice.getByRole("button", { name: "Time-bound", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const resultTitle = "Your Setup URI is ready to be copied";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
|
||||
import { decryptString } from "@vrtmrz/livesync-commonlib/compat/encryption/stringEncryption";
|
||||
import { $msg } from "../../../src/common/translation.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { createE2eCouchDbPluginData, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
|
||||
@@ -53,7 +55,7 @@ type ObsidianVaultFile = {
|
||||
};
|
||||
|
||||
type ObsidianTestApp = {
|
||||
commands?: { executeCommandById(commandId: string): boolean };
|
||||
commands?: { commands?: Record<string, unknown>; executeCommandById(commandId: string): boolean };
|
||||
plugins?: { plugins: Record<string, LiveSyncTestPlugin | undefined> };
|
||||
vault?: {
|
||||
delete(file: ObsidianVaultFile, force: boolean): Promise<void>;
|
||||
@@ -656,6 +658,116 @@ async function verifySetupUriDialogue(mode: DialogueMode): Promise<string> {
|
||||
return screenshotPath;
|
||||
}
|
||||
|
||||
async function verifyGenerateSetupUriDialogue(mode: DialogueMode): Promise<string> {
|
||||
const passphrase = "dialogue-test-passphrase";
|
||||
const port = obsidianRemoteDebuggingPort();
|
||||
const modalByTitle = (page: import("playwright").Page, title: string) =>
|
||||
page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: title }),
|
||||
});
|
||||
const openAndEnterPassphrase = async () => {
|
||||
const opened = await withObsidianPage(port, async (page) => {
|
||||
await page.waitForFunction(
|
||||
(commandId) => {
|
||||
const app = (globalThis as ObsidianTestGlobal).app;
|
||||
return Boolean(
|
||||
app?.plugins?.plugins["obsidian-livesync"]?.core?.settings.isConfigured &&
|
||||
app.commands?.commands?.[commandId]
|
||||
);
|
||||
},
|
||||
"obsidian-livesync:livesync-copysetupuri",
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
return await page.evaluate(
|
||||
(commandId) =>
|
||||
(globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true,
|
||||
"obsidian-livesync:livesync-copysetupuri"
|
||||
);
|
||||
});
|
||||
if (!opened) throw new Error("The Setup URI generation command was not registered.");
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const prompt = modalByTitle(page, "Encrypt your settings");
|
||||
await prompt.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await prompt.locator('input[type="password"]').fill(passphrase);
|
||||
await prompt.getByRole("button", { name: "OK", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
};
|
||||
const selectMode = async (selected: "Time-bound" | "Compatible (no time limit)") => {
|
||||
if (selected === "Time-bound") {
|
||||
await captureObsidianDialogue(
|
||||
port,
|
||||
`setup-uri-availability${mode === "mobile" ? "-mobile" : ""}.png`,
|
||||
async (page) => {
|
||||
const choice = modalByTitle(page, "Setup URI availability");
|
||||
await choice.getByRole("button", { name: "Time-bound", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const choice = modalByTitle(page, "Setup URI availability");
|
||||
await choice.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await choice.getByText("Time-bound Setup URIs can be opened until", { exact: false }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await choice.getByRole("button", { name: "Time-bound", exact: true }).waitFor({
|
||||
state: "visible", timeout: uiTimeoutMs,
|
||||
});
|
||||
if (mode === "mobile") await assertMobileDialogueLayout(page, choice, "Setup URI availability dialogue");
|
||||
await choice.getByRole("button", { name: selected, exact: true }).click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
};
|
||||
const getResultURI = async () =>
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const result = modalByTitle(page, "Your Setup URI is ready to be copied");
|
||||
await result.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if (mode === "mobile") await assertMobileDialogueLayout(page, result, "Generated Setup URI dialogue");
|
||||
return await result.locator("textarea[readonly]").inputValue();
|
||||
});
|
||||
const closeResult = async () => {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const result = modalByTitle(page, "Your Setup URI is ready to be copied");
|
||||
await result.getByRole("button", { name: $msg("Ok"), exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await result.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
};
|
||||
|
||||
await openAndEnterPassphrase();
|
||||
await selectMode("Time-bound");
|
||||
const uri = await getResultURI();
|
||||
if (!uri.startsWith("obsidian://setuplivesync?settings=")) {
|
||||
throw new Error("The generation dialogues did not produce a Setup URI.");
|
||||
}
|
||||
const decoded = await decodeSettingsFromSetupURI(uri, passphrase);
|
||||
if (!decoded || !decoded.isConfigured) throw new Error("The generated Time-bound URI could not be opened.");
|
||||
const screenshot = await captureObsidianDialogue(
|
||||
port,
|
||||
`generated-setup-uri${mode === "mobile" ? "-mobile" : ""}.png`,
|
||||
async (page) => {
|
||||
const result = modalByTitle(page, "Your Setup URI is ready to be copied");
|
||||
await result.locator("textarea[readonly]").waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
}
|
||||
);
|
||||
await closeResult();
|
||||
|
||||
if (mode === "desktop") {
|
||||
await openAndEnterPassphrase();
|
||||
await selectMode("Compatible (no time limit)");
|
||||
const compatibleURI = await getResultURI();
|
||||
const encrypted = new URL(compatibleURI).searchParams.get("settings");
|
||||
if (!encrypted?.startsWith("%$")) throw new Error("Compatible changed the encrypted URI format.");
|
||||
const oldFormatSettings = JSON.parse(await decryptString(encrypted, passphrase)) as Record<string, unknown>;
|
||||
if (oldFormatSettings.isConfigured !== true) {
|
||||
throw new Error("Compatible did not retain the original passphrase encryption format.");
|
||||
}
|
||||
await closeResult();
|
||||
}
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
async function verifyCompatibleMismatchAutoAdjustment(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
await page.evaluate((stateKey) => {
|
||||
@@ -1228,6 +1340,19 @@ async function main(): Promise<void> {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (process.env.E2E_OBSIDIAN_ONLY_SETUP_URI_GENERATION === "true") {
|
||||
const desktopScreenshot = await verifyGenerateSetupUriDialogue("desktop");
|
||||
console.log(`Desktop Setup URI generation passed. Screenshot: ${desktopScreenshot}`);
|
||||
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs);
|
||||
try {
|
||||
const mobileScreenshot = await verifyGenerateSetupUriDialogue("mobile");
|
||||
console.log(`Mobile Setup URI generation passed. Screenshot: ${mobileScreenshot}`);
|
||||
} finally {
|
||||
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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}`
|
||||
@@ -1245,6 +1370,8 @@ async function main(): Promise<void> {
|
||||
);
|
||||
const setupUriScreenshot = await verifySetupUriDialogue("desktop");
|
||||
console.log(`Setup URI dialogue mounted and closed successfully. Screenshot: ${setupUriScreenshot}`);
|
||||
const generatedSetupUriScreenshot = await verifyGenerateSetupUriDialogue("desktop");
|
||||
console.log(`Time-bound and Compatible generation passed. Screenshot: ${generatedSetupUriScreenshot}`);
|
||||
await verifyCompatibleAlignmentSettingDefault();
|
||||
console.log("The undefined compatible-setting preference is displayed with its effective enabled default.");
|
||||
const mismatchScreenshots = await verifyConfigurationMismatchDialogues();
|
||||
@@ -1278,6 +1405,8 @@ async function main(): Promise<void> {
|
||||
console.log(
|
||||
`Mobile Setup URI dialogue passed viewport, safe-area, and touch-target checks. Screenshot: ${mobileSetupUriScreenshot}`
|
||||
);
|
||||
const mobileGeneratedSetupUriScreenshot = await verifyGenerateSetupUriDialogue("mobile");
|
||||
console.log(`Mobile Time-bound generation passed. Screenshot: ${mobileGeneratedSetupUriScreenshot}`);
|
||||
} finally {
|
||||
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user