mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-19 19:16:01 +00:00
fix: keep packaged dialogs inside mobile safe areas
This commit is contained in:
Generated
+8
-8
@@ -23,7 +23,7 @@
|
|||||||
"@smithy/types": "^4.14.3",
|
"@smithy/types": "^4.14.3",
|
||||||
"@smithy/util-retry": "^4.4.5",
|
"@smithy/util-retry": "^4.4.5",
|
||||||
"@trystero-p2p/nostr": "^0.24.0",
|
"@trystero-p2p/nostr": "^0.24.0",
|
||||||
"@vrtmrz/livesync-commonlib": "file:../livesync-commonlib-package-boundary/artifacts/vrtmrz-livesync-commonlib-0.1.0-package-proof.8.tgz",
|
"@vrtmrz/livesync-commonlib": "0.1.0-rc.0",
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
"@vitest/browser": "^4.1.8",
|
"@vitest/browser": "^4.1.8",
|
||||||
"@vitest/browser-playwright": "^4.1.8",
|
"@vitest/browser-playwright": "^4.1.8",
|
||||||
"@vitest/coverage-v8": "^4.1.8",
|
"@vitest/coverage-v8": "^4.1.8",
|
||||||
"@vrtmrz/obsidian-test-session": "0.1.0",
|
"@vrtmrz/obsidian-test-session": "0.2.0",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"esbuild": "0.28.1",
|
"esbuild": "0.28.1",
|
||||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||||
@@ -4948,9 +4948,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||||
"version": "0.1.0-package-proof.8",
|
"version": "0.1.0-rc.0",
|
||||||
"resolved": "file:../livesync-commonlib-package-boundary/artifacts/vrtmrz-livesync-commonlib-0.1.0-package-proof.8.tgz",
|
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0-rc.0.tgz",
|
||||||
"integrity": "sha512-nzoyrszHxY9fLmYm5hsF14ZP3mjkKDcPngE8fJY9mWTFHQPtn0k7owlGQoP/pdqcVW2RowQXbWxkMwsBEn23bw==",
|
"integrity": "sha512-Aa+xC7bG78M7H8leIMmZdTRzA0Xh+ZgmiZJv0pXjy5OdZTPDu1H9M0eRcs5xL2pNBiOLKs0CKiEBql7JgunnQg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.808.0",
|
"@aws-sdk/client-s3": "^3.808.0",
|
||||||
@@ -4995,9 +4995,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vrtmrz/obsidian-test-session": {
|
"node_modules/@vrtmrz/obsidian-test-session": {
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.0.tgz",
|
||||||
"integrity": "sha512-asBOIRTc3xK5GF5ds5mkxN6vsO4RE8o7puvVjoJiGYSlxoFa9jzr8FwAO13CyoOriHF05pOZfTB+eQmL1aNb/A==",
|
"integrity": "sha512-Mnw1wide/KddJHfq6ulpwqdPLV8rIywsY013m9nQvn2GFm7XnBAqZjZpVEolMJH/mqbLlWvU3PhL1UWJLZsIJA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+3
-2
@@ -36,6 +36,7 @@
|
|||||||
"test:e2e:obsidian:cli-help": "tsx test/e2e-obsidian/scripts/cli-help.ts",
|
"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:debug-ui": "tsx test/e2e-obsidian/scripts/debug-ui.ts",
|
||||||
"test:e2e:obsidian:smoke": "tsx test/e2e-obsidian/scripts/smoke.ts",
|
"test:e2e:obsidian:smoke": "tsx test/e2e-obsidian/scripts/smoke.ts",
|
||||||
|
"test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts",
|
||||||
"test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts",
|
"test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts",
|
||||||
"test:e2e:obsidian:couchdb-upload": "tsx test/e2e-obsidian/scripts/couchdb-upload.ts",
|
"test:e2e:obsidian:couchdb-upload": "tsx test/e2e-obsidian/scripts/couchdb-upload.ts",
|
||||||
"test:e2e:obsidian:cli-to-obsidian-sync": "tsx test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts",
|
"test:e2e:obsidian:cli-to-obsidian-sync": "tsx test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts",
|
||||||
@@ -101,7 +102,7 @@
|
|||||||
"@vitest/browser": "^4.1.8",
|
"@vitest/browser": "^4.1.8",
|
||||||
"@vitest/browser-playwright": "^4.1.8",
|
"@vitest/browser-playwright": "^4.1.8",
|
||||||
"@vitest/coverage-v8": "^4.1.8",
|
"@vitest/coverage-v8": "^4.1.8",
|
||||||
"@vrtmrz/obsidian-test-session": "0.1.0",
|
"@vrtmrz/obsidian-test-session": "0.2.0",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"esbuild": "0.28.1",
|
"esbuild": "0.28.1",
|
||||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||||
@@ -151,7 +152,7 @@
|
|||||||
"@smithy/types": "^4.14.3",
|
"@smithy/types": "^4.14.3",
|
||||||
"@smithy/util-retry": "^4.4.5",
|
"@smithy/util-retry": "^4.4.5",
|
||||||
"@trystero-p2p/nostr": "^0.24.0",
|
"@trystero-p2p/nostr": "^0.24.0",
|
||||||
"@vrtmrz/livesync-commonlib": "file:../livesync-commonlib-package-boundary/artifacts/vrtmrz-livesync-commonlib-0.1.0-package-proof.8.tgz",
|
"@vrtmrz/livesync-commonlib": "0.1.0-rc.0",
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
|
|||||||
@@ -42,6 +42,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
:global(body.is-mobile .livesync-svelte-dialog-container) {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
||||||
|
padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
|
||||||
|
padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
||||||
|
padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.is-mobile .livesync-svelte-dialog-container .modal) {
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.dialog-host {
|
.dialog-host {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export class SvelteDialogObsidian<
|
|||||||
super(context.app);
|
super(context.app);
|
||||||
this.initDialog(context, dependents, component, initialData);
|
this.initDialog(context, dependents, component, initialData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override onOpen(): void {
|
||||||
|
super.onOpen();
|
||||||
|
this.contentEl.closest(".modal-container")?.classList.add("livesync-svelte-dialog-container");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ObsidianSvelteDialogManager<T extends ObsidianServiceContext> extends SvelteDialogManagerBase<T> {
|
export class ObsidianSvelteDialogManager<T extends ObsidianServiceContext> extends SvelteDialogManagerBase<T> {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ npm run test:e2e:obsidian:install-appimage
|
|||||||
npm run test:e2e:obsidian:discover
|
npm run test:e2e:obsidian:discover
|
||||||
npm run test:e2e:obsidian:cli-help -- vaults verbose
|
npm run test:e2e:obsidian:cli-help -- vaults verbose
|
||||||
npm run test:e2e:obsidian:smoke
|
npm run test:e2e:obsidian:smoke
|
||||||
|
npm run test:e2e:obsidian:dialog-mounts
|
||||||
npm run test:e2e:obsidian:vault-reflection
|
npm run test:e2e:obsidian:vault-reflection
|
||||||
npm run test:e2e:obsidian:couchdb-upload
|
npm run test:e2e:obsidian:couchdb-upload
|
||||||
npm run test:e2e:obsidian:cli-to-obsidian-sync
|
npm run test:e2e:obsidian:cli-to-obsidian-sync
|
||||||
@@ -72,13 +73,17 @@ 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: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:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, 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: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.
|
||||||
|
|
||||||
|
The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixel viewport, and explicit iPhone-style safe-area insets of 47 pixels at the top and 34 pixels at the bottom. The public `@vrtmrz/obsidian-test-session` layout assertions require each modal to remain within the viewport and safe area without horizontal overflow. They also require the Obsidian Close control to remain within the safe area and provide at least a 44 by 44 CSS-pixel touch target. The runner clicks that control to verify actionability, then completes the explicit cancellation path. These simulated checks cover deterministic layout and interaction boundaries; they do not claim to reproduce a native operating-system overlay.
|
||||||
|
|
||||||
|
`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, 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, configures Self-hosted LiveSync through `obsidian-cli eval`, 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.
|
`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, configures Self-hosted LiveSync through `obsidian-cli eval`, 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.
|
||||||
|
|
||||||
The same workflow checks the two remote-activity status boundaries. It first holds a real CouchDB request at the selected fetch implementation and confirms that `🌐N` is visible while `📲` is absent. It then holds the real one-shot replication immediately before its replicator call, confirms that `📲` is visible while no physical request is active, releases it, and requires the finite and bounded activity counts to return to zero, the request and response counts to balance, and both indicators to disappear. Finally, it creates a remote-only chunk, holds the real on-demand fetch immediately before its remote call, makes the same logical active and idle assertions, and verifies that the fetched chunk is written into the local database. These gates make the active states deterministic without replacing the remote request or operation.
|
The same workflow checks the two remote-activity status boundaries. It first holds a real CouchDB request at the selected fetch implementation and confirms that `🌐N` is visible while `📲` is absent. It then holds the real one-shot replication immediately before its replicator call, confirms that `📲` is visible while no physical request is active, releases it, and requires the finite and bounded activity counts to return to zero, the request and response counts to balance, and both indicators to disappear. Finally, it creates a remote-only chunk, holds the real on-demand fetch immediately before its remote call, makes the same logical active and idle assertions, and verifies that the fetched chunk is written into the local database. These gates make the active states deterministic without replacing the remote request or operation.
|
||||||
|
|
||||||
If this status workflow fails while Obsidian is running, it writes a full-page screenshot and a JSON snapshot of the status text and counters under `/tmp/obsidian-livesync-e2e`. Set `E2E_OBSIDIAN_DIAGNOSTICS_DIR` to use another directory.
|
If this status workflow fails while Obsidian is running, it writes a full-page screenshot and a JSON snapshot of the status text and counters under `/tmp/obsidian-livesync-e2e`. The dialogue-mount workflow leaves desktop and mobile screenshots for both representative Svelte routes, and the Hidden File Sync workflow captures the successfully displayed JSON Resolve dialogue before selecting an option. The suite therefore records representative evidence without capturing every interaction. Set `E2E_OBSIDIAN_DIAGNOSTICS_DIR` to use another directory.
|
||||||
|
|
||||||
`test:e2e:obsidian:cli-to-obsidian-sync` is the cross-runtime compatibility check for the official LiveSync CLI and the real Obsidian plug-in. Build the plug-in first, and build the local CLI too when no external CLI command is selected. The script uses E2EE, Path Obfuscation, and the current preferred chunk settings to create and synchronise a note through the CLI, starts real Obsidian with an isolated Vault and profile, synchronises the same CouchDB database, and verifies that the plug-in materialises identical note content. This covers the boundary that CLI-only and plug-in-only round trips do not exercise.
|
`test:e2e:obsidian:cli-to-obsidian-sync` is the cross-runtime compatibility check for the official LiveSync CLI and the real Obsidian plug-in. Build the plug-in first, and build the local CLI too when no external CLI command is selected. The script uses E2EE, Path Obfuscation, and the current preferred chunk settings to create and synchronise a note through the CLI, starts real Obsidian with an isolated Vault and profile, synchronises the same CouchDB database, and verifies that the plug-in materialises identical note content. This covers the boundary that CLI-only and plug-in-only round trips do not exercise.
|
||||||
|
|
||||||
@@ -133,6 +138,7 @@ Useful environment variables:
|
|||||||
- `E2E_OBSIDIAN_FORCE_DOWNLOAD=true`: re-download the AppImage even when it exists.
|
- `E2E_OBSIDIAN_FORCE_DOWNLOAD=true`: re-download the AppImage even when it exists.
|
||||||
- `E2E_OBSIDIAN_SKIP_EXTRACT=true`: download the AppImage without extracting it.
|
- `E2E_OBSIDIAN_SKIP_EXTRACT=true`: download the AppImage without extracting it.
|
||||||
- `E2E_OBSIDIAN_SMOKE_TIMEOUT_MS`: smoke timeout in milliseconds.
|
- `E2E_OBSIDIAN_SMOKE_TIMEOUT_MS`: smoke timeout in milliseconds.
|
||||||
|
- `E2E_OBSIDIAN_DIALOG_TIMEOUT_MS`: timeout for a representative Svelte dialogue to mount, expose its principal controls, and close; default is 10 seconds.
|
||||||
- `E2E_OBSIDIAN_READY_TIMEOUT_MS`: plug-in readiness timeout in milliseconds.
|
- `E2E_OBSIDIAN_READY_TIMEOUT_MS`: plug-in readiness timeout in milliseconds.
|
||||||
- `E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS`: timeout for waiting until the vault-side Obsidian CLI exposes the plug-in catalogue.
|
- `E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS`: timeout for waiting until the vault-side Obsidian CLI exposes the plug-in catalogue.
|
||||||
- `E2E_OBSIDIAN_CLI_TIMEOUT_MS`: timeout for each `obsidian-cli` invocation.
|
- `E2E_OBSIDIAN_CLI_TIMEOUT_MS`: timeout for each `obsidian-cli` invocation.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type StartObsidianLiveSyncSessionOptions = {
|
|||||||
cliBinary: string;
|
cliBinary: string;
|
||||||
vault: TemporaryVault;
|
vault: TemporaryVault;
|
||||||
startupGraceMs?: number;
|
startupGraceMs?: number;
|
||||||
|
pluginData?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function startObsidianLiveSyncSession(
|
export async function startObsidianLiveSyncSession(
|
||||||
@@ -20,5 +21,6 @@ export async function startObsidianLiveSyncSession(
|
|||||||
pluginId: "obsidian-livesync",
|
pluginId: "obsidian-livesync",
|
||||||
artifactRoot: process.cwd(),
|
artifactRoot: process.cwd(),
|
||||||
startupGraceMs: options.startupGraceMs,
|
startupGraceMs: options.startupGraceMs,
|
||||||
|
pluginData: options.pluginData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { mkdir } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
import { withObsidianPage } from "@vrtmrz/obsidian-test-session";
|
import { withObsidianPage } from "@vrtmrz/obsidian-test-session";
|
||||||
|
import type { Page } from "playwright";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
obsidianRemoteDebuggingPort,
|
obsidianRemoteDebuggingPort,
|
||||||
@@ -7,6 +10,41 @@ export {
|
|||||||
withObsidianPage,
|
withObsidianPage,
|
||||||
} from "@vrtmrz/obsidian-test-session";
|
} from "@vrtmrz/obsidian-test-session";
|
||||||
|
|
||||||
|
export async function captureObsidianDialogue(
|
||||||
|
port: number,
|
||||||
|
filename: string,
|
||||||
|
assertReady: (page: Page) => Promise<void>
|
||||||
|
): Promise<string> {
|
||||||
|
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
|
||||||
|
const screenshotPath = join(outputDirectory, filename);
|
||||||
|
await mkdir(dirname(screenshotPath), { recursive: true });
|
||||||
|
|
||||||
|
await withObsidianPage(port, async (page) => {
|
||||||
|
try {
|
||||||
|
await assertReady(page);
|
||||||
|
} catch (error) {
|
||||||
|
const failurePath = screenshotPath.replace(/\.png$/u, ".failure.png");
|
||||||
|
await page.screenshot({ path: failurePath, fullPage: true });
|
||||||
|
console.error(`Dialogue failure screenshot: ${failurePath}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return screenshotPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureJsonResolveDialogue(port: number): Promise<string> {
|
||||||
|
return await captureObsidianDialogue(port, "hidden-file-json-resolve-dialogue.png", async (page) => {
|
||||||
|
const optionAB = page.locator('label:has(input[name="disp"][value="AB"])');
|
||||||
|
const optionBA = page.locator('label:has(input[name="disp"][value="BA"])');
|
||||||
|
const applyButton = page.getByRole("button", { name: "Apply" });
|
||||||
|
await optionAB.waitFor({ state: "visible", timeout: 10000 });
|
||||||
|
await optionBA.waitFor({ state: "visible", timeout: 10000 });
|
||||||
|
await applyButton.waitFor({ state: "visible", timeout: 10000 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function clickJsonResolveOption(port: number, mode: "AB" | "BA"): Promise<void> {
|
export async function clickJsonResolveOption(port: number, mode: "AB" | "BA"): Promise<void> {
|
||||||
await withObsidianPage(port, async (page) => {
|
await withObsidianPage(port, async (page) => {
|
||||||
const option = page.locator(`label:has(input[name="disp"][value="${mode}"])`);
|
const option = page.locator(`label:has(input[name="disp"][value="${mode}"])`);
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
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 { 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";
|
||||||
|
|
||||||
|
type DialogueRunState = {
|
||||||
|
done: boolean;
|
||||||
|
error?: string;
|
||||||
|
kind: string;
|
||||||
|
result?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SetupManagerHandle = {
|
||||||
|
constructor: { name: string };
|
||||||
|
onSelectServer?: (settings: unknown, remoteType: string) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LiveSyncTestPlugin = {
|
||||||
|
core: {
|
||||||
|
modules: SetupManagerHandle[];
|
||||||
|
settings: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||||
|
if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded");
|
||||||
|
const manager = plugin.core.modules.find((module) => module.constructor.name === "SetupManager");
|
||||||
|
if (typeof manager?.onSelectServer !== "function") throw new Error("Could not find SetupManager");
|
||||||
|
const state: DialogueRunState = { kind: "remote-selection", done: false };
|
||||||
|
(globalThis as unknown as Record<string, DialogueRunState>)[stateKey] = state;
|
||||||
|
void manager.onSelectServer(plugin.core.settings, "unknown").then(
|
||||||
|
(result) => {
|
||||||
|
state.result = result;
|
||||||
|
state.done = true;
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
state.error = error instanceof Error ? error.message : String(error);
|
||||||
|
state.done = true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, dialogRunStateKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSetupUriDialogue(): 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-opensetupuri"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!opened) {
|
||||||
|
throw new Error("The Setup URI command was not registered or could not be executed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertDialogueRunCompleted(): Promise<void> {
|
||||||
|
const state = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||||
|
await page.waitForFunction(
|
||||||
|
(stateKey) =>
|
||||||
|
(globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey]?.done === true,
|
||||||
|
dialogRunStateKey,
|
||||||
|
{ timeout: uiTimeoutMs }
|
||||||
|
);
|
||||||
|
return await page.evaluate(
|
||||||
|
(stateKey) => (globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey],
|
||||||
|
dialogRunStateKey
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!state) {
|
||||||
|
throw new Error("The remote selection dialogue did not record its completion state.");
|
||||||
|
}
|
||||||
|
if (state.error) {
|
||||||
|
throw new Error(`The remote selection dialogue failed: ${state.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise<string> {
|
||||||
|
await openRemoteSelectionDialogue();
|
||||||
|
const screenshotPath = await captureObsidianDialogue(
|
||||||
|
obsidianRemoteDebuggingPort(),
|
||||||
|
`setup-remote-selection-dialogue${mode === "mobile" ? "-mobile" : ""}.png`,
|
||||||
|
async (page) => {
|
||||||
|
const modal = page.locator(".modal-container").filter({
|
||||||
|
has: page.locator(".modal-title").filter({ hasText: "Enter Server Information" }),
|
||||||
|
});
|
||||||
|
const dialogue = modal.locator(".dialog-host");
|
||||||
|
await modal.waitFor({
|
||||||
|
state: "visible",
|
||||||
|
timeout: uiTimeoutMs,
|
||||||
|
});
|
||||||
|
for (const label of ["CouchDB", "S3/MinIO/R2 Object Storage", "Peer-to-Peer only"]) {
|
||||||
|
await dialogue.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||||
|
}
|
||||||
|
await modal
|
||||||
|
.getByRole("button", { name: "No, please take me back" })
|
||||||
|
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||||
|
if (mode === "mobile") {
|
||||||
|
await assertMobileDialogueLayout(page, modal, "remote selection dialogue");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||||
|
const modal = page.locator(".modal-container").filter({
|
||||||
|
has: page.locator(".modal-title").filter({ hasText: "Enter Server Information" }),
|
||||||
|
});
|
||||||
|
if (mode === "mobile") {
|
||||||
|
const previousDialogue = await modal.locator(".modal").last().elementHandle();
|
||||||
|
if (previousDialogue === null) {
|
||||||
|
throw new Error("The remote selection dialogue did not expose a close control.");
|
||||||
|
}
|
||||||
|
await modal.locator(".modal-close-button").click({ timeout: uiTimeoutMs });
|
||||||
|
await page.waitForFunction((element) => !element.isConnected, previousDialogue, { timeout: uiTimeoutMs });
|
||||||
|
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||||
|
}
|
||||||
|
await modal.getByRole("button", { name: "No, please take me back" }).click({ timeout: uiTimeoutMs });
|
||||||
|
});
|
||||||
|
await assertDialogueRunCompleted();
|
||||||
|
return screenshotPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifySetupUriDialogue(mode: DialogueMode): Promise<string> {
|
||||||
|
await openSetupUriDialogue();
|
||||||
|
const screenshotPath = await captureObsidianDialogue(
|
||||||
|
obsidianRemoteDebuggingPort(),
|
||||||
|
`setup-uri-dialogue${mode === "mobile" ? "-mobile" : ""}.png`,
|
||||||
|
async (page) => {
|
||||||
|
const modal = page.locator(".modal-container").filter({
|
||||||
|
has: page.locator(".modal-title").filter({ hasText: "Enter Setup URI" }),
|
||||||
|
});
|
||||||
|
await modal.waitFor({
|
||||||
|
state: "visible",
|
||||||
|
timeout: uiTimeoutMs,
|
||||||
|
});
|
||||||
|
await modal
|
||||||
|
.locator('input[placeholder^="obsidian://setuplivesync"]')
|
||||||
|
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||||
|
await modal.locator('input[name="password"]').waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||||
|
await modal
|
||||||
|
.getByRole("button", { name: "Test Settings and Continue" })
|
||||||
|
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||||
|
await modal.getByRole("button", { name: "Cancel" }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||||
|
if (mode === "mobile") {
|
||||||
|
await assertMobileDialogueLayout(page, modal, "Setup URI dialogue");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||||
|
const modal = page.locator(".modal-container").filter({
|
||||||
|
has: page.locator(".modal-title").filter({ hasText: "Enter Setup URI" }),
|
||||||
|
});
|
||||||
|
await modal.getByRole("button", { name: "Cancel" }).click({ timeout: uiTimeoutMs });
|
||||||
|
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||||
|
});
|
||||||
|
return screenshotPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
session = await startObsidianLiveSyncSession({
|
||||||
|
binary,
|
||||||
|
cliBinary: cli.binary,
|
||||||
|
vault,
|
||||||
|
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||||
|
pluginData: {
|
||||||
|
doctorProcessedVersion: "0.25.27",
|
||||||
|
isConfigured: true,
|
||||||
|
lastReadUpdates: Number.MAX_SAFE_INTEGER,
|
||||||
|
liveSync: false,
|
||||||
|
notifyThresholdOfRemoteStorageSize: 0,
|
||||||
|
syncOnStart: false,
|
||||||
|
syncOnSave: false,
|
||||||
|
syncOnEditorSave: false,
|
||||||
|
syncOnFileOpen: false,
|
||||||
|
syncAfterMerge: false,
|
||||||
|
periodicReplication: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||||
|
|
||||||
|
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);
|
||||||
|
try {
|
||||||
|
const mobileRemoteScreenshot = await verifyRemoteSelectionDialogue("mobile");
|
||||||
|
console.log(
|
||||||
|
`Mobile remote selection dialogue passed viewport, safe-area, touch-target, and close-control checks. Screenshot: ${mobileRemoteScreenshot}`
|
||||||
|
);
|
||||||
|
const mobileSetupUriScreenshot = await verifySetupUriDialogue("mobile");
|
||||||
|
console.log(
|
||||||
|
`Mobile Setup URI dialogue passed viewport, safe-area, and touch-target checks. Screenshot: ${mobileSetupUriScreenshot}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await setMobileDialogueTestMode(false);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (session) {
|
||||||
|
await session.app.stop();
|
||||||
|
}
|
||||||
|
await vault.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
console.error(error instanceof Error ? error.stack : error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
type LocalDatabaseEntry,
|
type LocalDatabaseEntry,
|
||||||
} from "../runner/liveSyncWorkflow.ts";
|
} from "../runner/liveSyncWorkflow.ts";
|
||||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||||
import { clickJsonResolveOption, obsidianRemoteDebuggingPort } from "../runner/ui.ts";
|
import { captureJsonResolveDialogue, clickJsonResolveOption, obsidianRemoteDebuggingPort } from "../runner/ui.ts";
|
||||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||||
|
|
||||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
|
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
|
||||||
@@ -431,6 +431,7 @@ async function runJsonManualConflictResolution(context: RunnerContext, vault: Te
|
|||||||
const session = await startConfiguredSession(context, vault);
|
const session = await startConfiguredSession(context, vault);
|
||||||
await createHiddenJsonConflict(context, session, vault, manualMergeJsonPath, base, left, right);
|
await createHiddenJsonConflict(context, session, vault, manualMergeJsonPath, base, left, right);
|
||||||
await openHiddenJsonResolveModal(context.cliBinary, session.cliEnv, manualMergeJsonPath);
|
await openHiddenJsonResolveModal(context.cliBinary, session.cliEnv, manualMergeJsonPath);
|
||||||
|
const screenshotPath = await captureJsonResolveDialogue(obsidianRemoteDebuggingPort());
|
||||||
await clickJsonResolveOption(obsidianRemoteDebuggingPort(), "AB");
|
await clickJsonResolveOption(obsidianRemoteDebuggingPort(), "AB");
|
||||||
|
|
||||||
const merged = await waitForPathContent(vault.path, manualMergeJsonPath, (content) =>
|
const merged = await waitForPathContent(vault.path, manualMergeJsonPath, (content) =>
|
||||||
@@ -442,7 +443,7 @@ async function runJsonManualConflictResolution(context: RunnerContext, vault: Te
|
|||||||
assertEqual(parsed.shared, "right", "Manual JSON conflict resolution did not apply the selected merged result.");
|
assertEqual(parsed.shared, "right", "Manual JSON conflict resolution did not apply the selected merged result.");
|
||||||
assertEqual(parsed.fromA, true, "Manual JSON conflict resolution lost the first-side value.");
|
assertEqual(parsed.fromA, true, "Manual JSON conflict resolution lost the first-side value.");
|
||||||
assertEqual(parsed.fromB, true, "Manual JSON conflict resolution lost the second-side value.");
|
assertEqual(parsed.fromB, true, "Manual JSON conflict resolution lost the second-side value.");
|
||||||
console.log("Hidden JSON conflict modal applied the selected merged result.");
|
console.log(`Hidden JSON conflict modal applied the selected merged result. Screenshot: ${screenshotPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runTargetMismatch(
|
async function runTargetMismatch(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const testSteps: Step[] = [
|
|||||||
: []),
|
: []),
|
||||||
{ name: "discover", args: ["run", "test:e2e:obsidian:discover"] },
|
{ name: "discover", args: ["run", "test:e2e:obsidian:discover"] },
|
||||||
{ name: "smoke", args: ["run", "test:e2e:obsidian:smoke"] },
|
{ name: "smoke", args: ["run", "test:e2e:obsidian:smoke"] },
|
||||||
|
{ name: "Svelte dialogue mounts", args: ["run", "test:e2e:obsidian:dialog-mounts"] },
|
||||||
{ name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] },
|
{ name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] },
|
||||||
{ name: "CouchDB upload", args: ["run", "test:e2e:obsidian:couchdb-upload"] },
|
{ name: "CouchDB upload", args: ["run", "test:e2e:obsidian:couchdb-upload"] },
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user