mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-06-26 16:13:57 +00:00
(test): the E2E test on the real-Obsidian
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
# Real Obsidian E2E Runner
|
||||
|
||||
This directory contains the experimental real Obsidian end-to-end runner.
|
||||
|
||||
The current smoke runner verifies only the launch path:
|
||||
|
||||
1. create a temporary vault,
|
||||
2. install the built Self-hosted LiveSync plug-in artifacts,
|
||||
3. launch real Obsidian,
|
||||
4. open the temporary vault through `obsidian-cli`,
|
||||
5. enable Obsidian community plug-ins for the temporary app profile,
|
||||
6. reload Self-hosted LiveSync through `obsidian-cli`,
|
||||
7. verify through `obsidian-cli eval` that the plug-in is loaded,
|
||||
8. terminate Obsidian and remove the temporary vault.
|
||||
|
||||
The runner does not require Self-hosted LiveSync to expose an E2E-only bridge. Readiness is checked from outside the plug-in through Obsidian's own CLI.
|
||||
|
||||
Obsidian 1.12 stores the global community plug-in switch outside `.obsidian/community-plugins.json`. The smoke runner enables it through `app.plugins.setEnable(true)` after the vault window is available.
|
||||
|
||||
## Local Setup
|
||||
|
||||
Set `OBSIDIAN_BINARY` when Obsidian is not installed in a standard location.
|
||||
|
||||
For an AppImage on Linux without FUSE, use the helper script:
|
||||
|
||||
```bash
|
||||
npm run test:e2e:obsidian:install-appimage
|
||||
```
|
||||
|
||||
The script downloads Obsidian `1.12.7` for the current architecture, stores it in `_testdata/obsidian`, and extracts it to `_testdata/obsidian/squashfs-root`. The runner checks `_testdata/obsidian/squashfs-root/obsidian` before the AppImage path.
|
||||
|
||||
Do not download the AppImage on every CI run. Prefer one of these approaches:
|
||||
|
||||
- set `OBSIDIAN_BINARY` to a pre-installed Obsidian executable,
|
||||
- restore `_testdata/obsidian/squashfs-root` from a CI cache, or
|
||||
- run `test:e2e:obsidian:install-appimage` only in a manually triggered preparation job.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
Useful environment variables:
|
||||
|
||||
- `OBSIDIAN_BINARY`: explicit Obsidian executable path.
|
||||
- `E2E_OBSIDIAN_VERSION`: Obsidian AppImage version for `test:e2e:obsidian:install-appimage`; default is `1.12.7`.
|
||||
- `E2E_OBSIDIAN_APPIMAGE_ARCH`: AppImage architecture override, such as `arm64` or `x86_64`.
|
||||
- `E2E_OBSIDIAN_APPIMAGE_URL`: explicit AppImage URL override.
|
||||
- `E2E_OBSIDIAN_DOWNLOAD_DIR`: AppImage download and extraction directory; default is `_testdata/obsidian`.
|
||||
- `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_SMOKE_TIMEOUT_MS`: smoke 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_TIMEOUT_MS`: timeout for each `obsidian-cli` invocation.
|
||||
- `E2E_OBSIDIAN_STARTUP_GRACE_MS`: early process-exit detection window in milliseconds.
|
||||
- `E2E_OBSIDIAN_KEEP_VAULT=true`: keep the temporary vault for inspection.
|
||||
- `E2E_OBSIDIAN_USE_XVFB=false`: disable automatic `xvfb-run` on headless Linux.
|
||||
- `E2E_OBSIDIAN_ARGS`: override the default Obsidian launch arguments.
|
||||
|
||||
On headless Linux, the runner automatically uses `/usr/bin/xvfb-run` when no `DISPLAY` or `WAYLAND_DISPLAY` is present.
|
||||
@@ -0,0 +1,62 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export type ObsidianCliResult = {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
export async function runObsidianCli(
|
||||
cliBinary: string,
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
timeoutMs = Number(process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ?? 10000)
|
||||
): Promise<ObsidianCliResult> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(cliBinary, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error(`Obsidian CLI timed out: ${cliBinary} ${args.join(" ")}`));
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ code, signal, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function openVaultWithObsidianCli(
|
||||
cliBinary: string,
|
||||
vaultPath: string,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): Promise<void> {
|
||||
const result = await runObsidianCli(cliBinary, [`obsidian://open?path=${encodeURIComponent(vaultPath)}`], env);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`Failed to open Obsidian vault through CLI. code=${result.code}, signal=${result.signal}`,
|
||||
result.stdout ? `stdout:\n${result.stdout}` : undefined,
|
||||
result.stderr ? `stderr:\n${result.stderr}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { accessSync, constants, existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { platform } from "node:process";
|
||||
|
||||
export type ObsidianDiscoveryResult = {
|
||||
binary?: string;
|
||||
source?: string;
|
||||
checked: string[];
|
||||
};
|
||||
|
||||
const defaultCandidatesByPlatform: Record<NodeJS.Platform, string[]> = {
|
||||
aix: [],
|
||||
android: [],
|
||||
darwin: [
|
||||
"/Applications/Obsidian.app/Contents/MacOS/Obsidian",
|
||||
"/Applications/Obsidian.app/Contents/MacOS/obsidian",
|
||||
],
|
||||
freebsd: [],
|
||||
haiku: [],
|
||||
linux: [
|
||||
"_testdata/obsidian/squashfs-root/obsidian",
|
||||
"_testdata/obsidian/squashfs-root/AppRun",
|
||||
"_testdata/obsidian/Obsidian-1.12.7-arm64.AppImage",
|
||||
"_testdata/obsidian/Obsidian-1.12.7-x86_64.AppImage",
|
||||
"/usr/bin/obsidian",
|
||||
"/usr/local/bin/obsidian",
|
||||
"/snap/bin/obsidian",
|
||||
"/opt/Obsidian/obsidian",
|
||||
"/opt/obsidian/obsidian",
|
||||
"/app/bin/obsidian",
|
||||
],
|
||||
openbsd: [],
|
||||
sunos: [],
|
||||
win32: ["C:\\Program Files\\Obsidian\\Obsidian.exe", "C:\\Program Files (x86)\\Obsidian\\Obsidian.exe"],
|
||||
cygwin: [],
|
||||
netbsd: [],
|
||||
};
|
||||
|
||||
const defaultCliCandidatesByPlatform: Record<NodeJS.Platform, string[]> = {
|
||||
aix: [],
|
||||
android: [],
|
||||
darwin: [
|
||||
"/Applications/Obsidian.app/Contents/MacOS/obsidian-cli",
|
||||
"/Applications/Obsidian.app/Contents/Resources/obsidian-cli",
|
||||
],
|
||||
freebsd: [],
|
||||
haiku: [],
|
||||
linux: [
|
||||
"_testdata/obsidian/squashfs-root/obsidian-cli",
|
||||
"/usr/bin/obsidian-cli",
|
||||
"/usr/local/bin/obsidian-cli",
|
||||
"/snap/bin/obsidian-cli",
|
||||
"/opt/Obsidian/obsidian-cli",
|
||||
"/opt/obsidian/obsidian-cli",
|
||||
],
|
||||
openbsd: [],
|
||||
sunos: [],
|
||||
win32: ["C:\\Program Files\\Obsidian\\obsidian-cli.exe", "C:\\Program Files (x86)\\Obsidian\\obsidian-cli.exe"],
|
||||
cygwin: [],
|
||||
netbsd: [],
|
||||
};
|
||||
|
||||
function isUsableFile(path: string): boolean {
|
||||
const resolvedPath = resolve(path);
|
||||
if (!existsSync(resolvedPath)) {
|
||||
return false;
|
||||
}
|
||||
if (platform === "win32") {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
accessSync(resolvedPath, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function discoverObsidianBinary(env: NodeJS.ProcessEnv = process.env): ObsidianDiscoveryResult {
|
||||
const checked: string[] = [];
|
||||
const envBinary = env.OBSIDIAN_BINARY?.trim();
|
||||
if (envBinary) {
|
||||
checked.push(envBinary);
|
||||
if (isUsableFile(envBinary)) {
|
||||
return {
|
||||
binary: resolve(envBinary),
|
||||
source: "OBSIDIAN_BINARY",
|
||||
checked,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = defaultCandidatesByPlatform[platform] ?? [];
|
||||
for (const candidate of candidates) {
|
||||
checked.push(candidate);
|
||||
if (isUsableFile(candidate)) {
|
||||
return {
|
||||
binary: resolve(candidate),
|
||||
source: "default-path",
|
||||
checked,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { checked };
|
||||
}
|
||||
|
||||
export function requireObsidianBinary(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const result = discoverObsidianBinary(env);
|
||||
if (!result.binary) {
|
||||
throw new Error(
|
||||
[
|
||||
"Could not find an Obsidian executable.",
|
||||
"Set OBSIDIAN_BINARY to the installed Obsidian executable path.",
|
||||
`Checked paths: ${result.checked.length > 0 ? result.checked.join(", ") : "(none)"}`,
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
return result.binary;
|
||||
}
|
||||
|
||||
export function discoverObsidianCli(env: NodeJS.ProcessEnv = process.env): ObsidianDiscoveryResult {
|
||||
const checked: string[] = [];
|
||||
const envBinary = env.OBSIDIAN_CLI?.trim();
|
||||
if (envBinary) {
|
||||
checked.push(envBinary);
|
||||
if (isUsableFile(envBinary)) {
|
||||
return {
|
||||
binary: resolve(envBinary),
|
||||
source: "OBSIDIAN_CLI",
|
||||
checked,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = defaultCliCandidatesByPlatform[platform] ?? [];
|
||||
for (const candidate of candidates) {
|
||||
checked.push(candidate);
|
||||
if (isUsableFile(candidate)) {
|
||||
return {
|
||||
binary: resolve(candidate),
|
||||
source: "default-path",
|
||||
checked,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { checked };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { platform } from "node:process";
|
||||
|
||||
export type ObsidianProcess = {
|
||||
process: ChildProcess;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type LaunchObsidianOptions = {
|
||||
binary: string;
|
||||
vaultPath: string;
|
||||
homePath?: string;
|
||||
xdgConfigPath?: string;
|
||||
userDataPath?: string;
|
||||
startupGraceMs?: number;
|
||||
};
|
||||
|
||||
function splitArgs(args: string): string[] {
|
||||
return args.split(" ").filter((arg) => arg.length > 0);
|
||||
}
|
||||
|
||||
function launchArgs(options: LaunchObsidianOptions): string[] {
|
||||
const explicitArgs = process.env.E2E_OBSIDIAN_ARGS;
|
||||
if (explicitArgs) {
|
||||
return splitArgs(explicitArgs);
|
||||
}
|
||||
return [
|
||||
"--no-sandbox",
|
||||
"--disable-gpu",
|
||||
"--disable-software-rasterizer",
|
||||
...(process.env.E2E_OBSIDIAN_USE_USER_DATA_DIR === "true" && options.userDataPath
|
||||
? [`--user-data-dir=${options.userDataPath}`]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
function shouldUseXvfb(): boolean {
|
||||
if (process.env.E2E_OBSIDIAN_USE_XVFB === "false") {
|
||||
return false;
|
||||
}
|
||||
if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) {
|
||||
return false;
|
||||
}
|
||||
return platform === "linux" && existsSync("/usr/bin/xvfb-run");
|
||||
}
|
||||
|
||||
export async function launchObsidian(options: LaunchObsidianOptions): Promise<ObsidianProcess> {
|
||||
const startupGraceMs = options.startupGraceMs ?? 1000;
|
||||
const args = launchArgs(options);
|
||||
const useXvfb = shouldUseXvfb();
|
||||
const command = useXvfb ? "/usr/bin/xvfb-run" : options.binary;
|
||||
const commandArgs = useXvfb ? ["-a", options.binary, ...args] : args;
|
||||
const child = spawn(command, commandArgs, {
|
||||
cwd: dirname(options.binary),
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
...(options.homePath ? { HOME: options.homePath } : {}),
|
||||
...(options.xdgConfigPath ? { XDG_CONFIG_HOME: options.xdgConfigPath } : {}),
|
||||
OBSIDIAN_DISABLE_GPU: process.env.OBSIDIAN_DISABLE_GPU ?? "1",
|
||||
},
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
let stdout = "";
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
const exitPromise = once(child, "exit").then(([code, signal]) => ({ code, signal }));
|
||||
const timer = new Promise<"timeout">((resolve) => {
|
||||
setTimeout(() => resolve("timeout"), startupGraceMs);
|
||||
});
|
||||
const firstResult = await Promise.race([exitPromise, timer]);
|
||||
if (firstResult !== "timeout") {
|
||||
throw new Error(
|
||||
[
|
||||
`Obsidian exited before the smoke timeout. code=${firstResult.code}, signal=${firstResult.signal}`,
|
||||
stdout ? `stdout:\n${stdout}` : undefined,
|
||||
stderr ? `stderr:\n${stderr}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
process: child,
|
||||
stop: async () => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
if (child.pid) {
|
||||
process.kill(-child.pid, "SIGTERM");
|
||||
} else {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
const stopTimer = new Promise<"timeout">((resolve) => {
|
||||
setTimeout(() => resolve("timeout"), 5000);
|
||||
});
|
||||
const stopResult = await Promise.race([exitPromise, stopTimer]);
|
||||
if (stopResult === "timeout") {
|
||||
if (child.pid) {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
} else {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
await exitPromise;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { copyFile, mkdir, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export type PluginInstallResult = {
|
||||
pluginDir: string;
|
||||
copied: string[];
|
||||
};
|
||||
|
||||
const pluginId = "obsidian-livesync";
|
||||
|
||||
export async function installBuiltPlugin(vaultPath: string, rootDir = process.cwd()): Promise<PluginInstallResult> {
|
||||
const pluginDir = join(vaultPath, ".obsidian", "plugins", pluginId);
|
||||
const copied: string[] = [];
|
||||
await mkdir(pluginDir, { recursive: true });
|
||||
|
||||
const requiredArtifacts = ["main.js", "manifest.json"];
|
||||
for (const artifact of requiredArtifacts) {
|
||||
const source = resolve(rootDir, artifact);
|
||||
if (!existsSync(source)) {
|
||||
throw new Error(`Required plug-in artifact is missing: ${source}`);
|
||||
}
|
||||
await copyFile(source, join(pluginDir, artifact));
|
||||
copied.push(artifact);
|
||||
}
|
||||
|
||||
const optionalArtifacts = ["styles.css"];
|
||||
for (const artifact of optionalArtifacts) {
|
||||
const source = resolve(rootDir, artifact);
|
||||
if (!existsSync(source)) {
|
||||
continue;
|
||||
}
|
||||
await copyFile(source, join(pluginDir, artifact));
|
||||
copied.push(artifact);
|
||||
}
|
||||
|
||||
await writeFile(join(vaultPath, ".obsidian", "community-plugins.json"), JSON.stringify([pluginId], null, 4));
|
||||
return { pluginDir, copied };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { runObsidianCli } from "./cli.ts";
|
||||
|
||||
export type PluginReadiness = {
|
||||
status: "ready";
|
||||
pluginId: string;
|
||||
pluginVersion: string;
|
||||
vaultName: string;
|
||||
};
|
||||
|
||||
function parseEvalJson(stdout: string): unknown {
|
||||
const marker = "=> ";
|
||||
const markerIndex = stdout.indexOf(marker);
|
||||
const text = markerIndex >= 0 ? stdout.slice(markerIndex + marker.length) : stdout;
|
||||
return JSON.parse(text.trim());
|
||||
}
|
||||
|
||||
export async function waitForPluginReady(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
timeoutMs = Number(process.env.E2E_OBSIDIAN_READY_TIMEOUT_MS ?? 20000)
|
||||
): Promise<PluginReadiness> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastOutput = "";
|
||||
while (Date.now() < deadline) {
|
||||
const result = await runObsidianCli(
|
||||
cliBinary,
|
||||
[
|
||||
"eval",
|
||||
[
|
||||
"code=(async()=>JSON.stringify({",
|
||||
"status:!!app.plugins.plugins['obsidian-livesync']?'ready':'pending',",
|
||||
"pluginId:'obsidian-livesync',",
|
||||
"pluginVersion:app.plugins.manifests['obsidian-livesync']?.version,",
|
||||
"vaultName:app.vault.getName()",
|
||||
"}))()",
|
||||
].join(""),
|
||||
],
|
||||
env
|
||||
);
|
||||
lastOutput = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
||||
try {
|
||||
const readiness = parseEvalJson(result.stdout) as PluginReadiness;
|
||||
if (readiness.status === "ready") {
|
||||
return readiness;
|
||||
}
|
||||
} catch {
|
||||
// Keep polling until Obsidian exposes the vault-side CLI and plug-in state.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`Timed out waiting for Self-hosted LiveSync readiness through Obsidian CLI.\n${lastOutput}`);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
export type TemporaryVault = {
|
||||
path: string;
|
||||
name: string;
|
||||
homePath: string;
|
||||
xdgConfigPath: string;
|
||||
userDataPath: string;
|
||||
dispose: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function createTemporaryVault(prefix = "obsidian-livesync-e2e-"): Promise<TemporaryVault> {
|
||||
const vaultPath = await mkdtemp(join(tmpdir(), prefix));
|
||||
const name = vaultPath.split(/[\\/]/).pop() ?? "obsidian-livesync-e2e";
|
||||
await mkdir(join(vaultPath, ".obsidian"), { recursive: true });
|
||||
const homePath = join(vaultPath, ".obsidian", "e2e-home");
|
||||
const xdgConfigPath = join(vaultPath, ".obsidian", "e2e-xdg-config");
|
||||
const userDataPath = join(vaultPath, ".obsidian", "e2e-user-data");
|
||||
await mkdir(homePath, { recursive: true });
|
||||
await mkdir(xdgConfigPath, { recursive: true });
|
||||
await mkdir(userDataPath, { recursive: true });
|
||||
await writeFile(
|
||||
join(vaultPath, ".obsidian", "app.json"),
|
||||
JSON.stringify({ legacyEditor: false, safeMode: false }, null, 4)
|
||||
);
|
||||
await writeObsidianVaultRegistry(vaultPath, name, homePath, xdgConfigPath, userDataPath);
|
||||
|
||||
return {
|
||||
path: vaultPath,
|
||||
name,
|
||||
homePath,
|
||||
xdgConfigPath,
|
||||
userDataPath,
|
||||
dispose: async () => {
|
||||
if (process.env.E2E_OBSIDIAN_KEEP_VAULT === "true") {
|
||||
console.log(`Keeping temporary vault: ${vaultPath}`);
|
||||
return;
|
||||
}
|
||||
await rm(vaultPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeObsidianVaultRegistry(
|
||||
vaultPath: string,
|
||||
vaultName: string,
|
||||
homePath: string,
|
||||
xdgConfigPath: string,
|
||||
userDataPath: string
|
||||
): Promise<void> {
|
||||
const vaultId = `livesync-e2e-${Date.now()}`;
|
||||
const registry = {
|
||||
cli: true,
|
||||
vaults: {
|
||||
[vaultId]: {
|
||||
path: vaultPath,
|
||||
ts: Date.now(),
|
||||
open: true,
|
||||
name: vaultName,
|
||||
},
|
||||
},
|
||||
};
|
||||
const registryText = JSON.stringify(registry, null, 4);
|
||||
for (const configRoot of [join(homePath, ".config"), xdgConfigPath]) {
|
||||
const obsidianConfigDir = join(configRoot, "obsidian");
|
||||
await mkdir(obsidianConfigDir, { recursive: true });
|
||||
await writeFile(join(obsidianConfigDir, "obsidian.json"), registryText);
|
||||
}
|
||||
await writeFile(join(userDataPath, "obsidian.json"), registryText);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { launchObsidian } from "../runner/launch.ts";
|
||||
import { runObsidianCli } from "../runner/cli.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
import { installBuiltPlugin } from "../runner/pluginInstaller.ts";
|
||||
|
||||
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 app;
|
||||
try {
|
||||
await installBuiltPlugin(vault.path);
|
||||
app = await launchObsidian({
|
||||
binary,
|
||||
vaultPath: vault.path,
|
||||
homePath: vault.homePath,
|
||||
xdgConfigPath: vault.xdgConfigPath,
|
||||
userDataPath: vault.userDataPath,
|
||||
});
|
||||
const cliEnv = {
|
||||
...process.env,
|
||||
HOME: vault.homePath,
|
||||
XDG_CONFIG_HOME: vault.xdgConfigPath,
|
||||
};
|
||||
await runObsidianCli(cli.binary, [`obsidian://open?path=${encodeURIComponent(vault.path)}`], cliEnv);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
if (process.env.E2E_OBSIDIAN_RELOAD_PLUGIN === "true") {
|
||||
await runObsidianCli(cli.binary, ["eval", "code=(async()=>app.plugins.setEnable(true))()"], cliEnv);
|
||||
await runObsidianCli(cli.binary, ["plugin:reload", "id=obsidian-livesync"], cliEnv);
|
||||
}
|
||||
const cliArgs = process.argv.slice(2);
|
||||
const result = await runObsidianCli(cli.binary, cliArgs.length > 0 ? cliArgs : ["--help"], cliEnv);
|
||||
console.log(result.stdout);
|
||||
console.error(result.stderr);
|
||||
process.exitCode = result.code ?? 1;
|
||||
} finally {
|
||||
if (app) {
|
||||
await app.stop();
|
||||
}
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { discoverObsidianBinary } from "../runner/environment.ts";
|
||||
|
||||
const result = discoverObsidianBinary();
|
||||
if (result.binary) {
|
||||
console.log(`Obsidian executable: ${result.binary}`);
|
||||
console.log(`Source: ${result.source}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error("Obsidian executable was not found.");
|
||||
console.error("Set OBSIDIAN_BINARY to the installed Obsidian executable path.");
|
||||
console.error(`Checked paths: ${result.checked.length > 0 ? result.checked.join(", ") : "(none)"}`);
|
||||
process.exit(1);
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createWriteStream, existsSync } from "node:fs";
|
||||
import { chmod, mkdir } from "node:fs/promises";
|
||||
import { get } from "node:https";
|
||||
import { arch } from "node:process";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const defaultVersion = "1.12.7";
|
||||
|
||||
function appImageArch(): string {
|
||||
const requestedArch = process.env.E2E_OBSIDIAN_APPIMAGE_ARCH?.trim();
|
||||
if (requestedArch) {
|
||||
return requestedArch;
|
||||
}
|
||||
if (arch === "arm64") {
|
||||
return "arm64";
|
||||
}
|
||||
if (arch === "x64") {
|
||||
return "x86_64";
|
||||
}
|
||||
throw new Error(`Unsupported architecture for Obsidian AppImage: ${arch}`);
|
||||
}
|
||||
|
||||
function appImageUrl(version: string, imageArch: string): string {
|
||||
return `https://github.com/obsidianmd/obsidian-releases/releases/download/v${version}/Obsidian-${version}-${imageArch}.AppImage`;
|
||||
}
|
||||
|
||||
function download(url: string, destination: string, redirectsLeft = 5): Promise<void> {
|
||||
return new Promise((resolveDownload, reject) => {
|
||||
const request = get(url, (response) => {
|
||||
const statusCode = response.statusCode ?? 0;
|
||||
const location = response.headers.location;
|
||||
if (statusCode >= 300 && statusCode < 400 && location) {
|
||||
response.resume();
|
||||
if (redirectsLeft <= 0) {
|
||||
reject(new Error(`Too many redirects while downloading ${url}`));
|
||||
return;
|
||||
}
|
||||
download(new URL(location, url).toString(), destination, redirectsLeft - 1)
|
||||
.then(resolveDownload)
|
||||
.catch(reject);
|
||||
return;
|
||||
}
|
||||
if (statusCode !== 200) {
|
||||
response.resume();
|
||||
reject(new Error(`Failed to download ${url}: HTTP ${statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const file = createWriteStream(destination, { mode: 0o755 });
|
||||
response.pipe(file);
|
||||
file.on("finish", () => {
|
||||
file.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolveDownload();
|
||||
}
|
||||
});
|
||||
});
|
||||
file.on("error", reject);
|
||||
});
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function extractAppImage(appImagePath: string, cwd: string): Promise<void> {
|
||||
return new Promise((resolveExtract, reject) => {
|
||||
const child = spawn(appImagePath, ["--appimage-extract"], {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolveExtract();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`AppImage extraction failed. code=${code}, signal=${signal}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const version = process.env.E2E_OBSIDIAN_VERSION?.trim() || defaultVersion;
|
||||
const imageArch = appImageArch();
|
||||
const targetDir = resolve(process.env.E2E_OBSIDIAN_DOWNLOAD_DIR?.trim() || "_testdata/obsidian");
|
||||
const url = process.env.E2E_OBSIDIAN_APPIMAGE_URL?.trim() || appImageUrl(version, imageArch);
|
||||
const appImagePath = join(targetDir, basename(new URL(url).pathname));
|
||||
const extractedBinary = join(targetDir, "squashfs-root", "obsidian");
|
||||
const forceDownload = process.env.E2E_OBSIDIAN_FORCE_DOWNLOAD === "true";
|
||||
const skipExtract = process.env.E2E_OBSIDIAN_SKIP_EXTRACT === "true";
|
||||
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
|
||||
if (!existsSync(appImagePath) || forceDownload) {
|
||||
console.log(`Downloading Obsidian AppImage: ${url}`);
|
||||
console.log(`Destination: ${appImagePath}`);
|
||||
await download(url, appImagePath);
|
||||
await chmod(appImagePath, 0o755);
|
||||
} else {
|
||||
console.log(`Using existing Obsidian AppImage: ${appImagePath}`);
|
||||
}
|
||||
|
||||
if (!skipExtract) {
|
||||
if (existsSync(extractedBinary)) {
|
||||
console.log(`Using existing extracted Obsidian binary: ${extractedBinary}`);
|
||||
} else {
|
||||
console.log(`Extracting Obsidian AppImage in ${targetDir}`);
|
||||
await extractAppImage(appImagePath, targetDir);
|
||||
console.log(`Extracted Obsidian binary: ${extractedBinary}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Set OBSIDIAN_BINARY=${extractedBinary} to use the extracted binary explicitly.`);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { launchObsidian } from "../runner/launch.ts";
|
||||
import { installBuiltPlugin } from "../runner/pluginInstaller.ts";
|
||||
import { waitForPluginReady } from "../runner/readiness.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
import { openVaultWithObsidianCli, runObsidianCli } from "../runner/cli.ts";
|
||||
|
||||
async function waitForPluginCatalogue(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS ?? 15000);
|
||||
let lastOutput = "";
|
||||
while (Date.now() < deadline) {
|
||||
const result = await runObsidianCli(cliBinary, ["plugins", "filter=community"], env);
|
||||
lastOutput = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
||||
if (result.stdout.includes("obsidian-livesync")) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`Timed out waiting for Obsidian plug-in catalogue through CLI.\n${lastOutput}`);
|
||||
}
|
||||
|
||||
async function enableCommunityPlugins(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
const result = await runObsidianCli(cliBinary, ["eval", "code=(async()=>app.plugins.setEnable(true))()"], env);
|
||||
if (result.code !== 0 || result.stdout.includes("Error:")) {
|
||||
throw new Error(
|
||||
[
|
||||
`Failed to enable Obsidian community plug-ins through CLI. code=${result.code}, signal=${result.signal}`,
|
||||
result.stdout ? `stdout:\n${result.stdout}` : undefined,
|
||||
result.stderr ? `stderr:\n${result.stderr}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 app;
|
||||
try {
|
||||
const install = await installBuiltPlugin(vault.path);
|
||||
console.log(`Using Obsidian executable: ${binary}`);
|
||||
console.log(`Temporary vault: ${vault.path}`);
|
||||
console.log(`Installed plug-in artifacts: ${install.copied.join(", ")}`);
|
||||
|
||||
app = await launchObsidian({
|
||||
binary,
|
||||
vaultPath: vault.path,
|
||||
homePath: vault.homePath,
|
||||
xdgConfigPath: vault.xdgConfigPath,
|
||||
userDataPath: vault.userDataPath,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
});
|
||||
const cliEnv = {
|
||||
...process.env,
|
||||
HOME: vault.homePath,
|
||||
XDG_CONFIG_HOME: vault.xdgConfigPath,
|
||||
};
|
||||
await openVaultWithObsidianCli(cli.binary, vault.path, cliEnv);
|
||||
await waitForPluginCatalogue(cli.binary, cliEnv);
|
||||
await enableCommunityPlugins(cli.binary, cliEnv);
|
||||
const reload = await runObsidianCli(cli.binary, ["plugin:reload", "id=obsidian-livesync"], cliEnv);
|
||||
if (reload.code !== 0 || !reload.stdout.includes("Reloaded: obsidian-livesync")) {
|
||||
throw new Error(
|
||||
[
|
||||
`Failed to reload Self-hosted LiveSync through Obsidian CLI. code=${reload.code}, signal=${reload.signal}`,
|
||||
reload.stdout ? `stdout:\n${reload.stdout}` : undefined,
|
||||
reload.stderr ? `stderr:\n${reload.stderr}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
const readiness = await waitForPluginReady(cli.binary, cliEnv);
|
||||
console.log(
|
||||
`Obsidian plug-in ready: ${readiness.pluginId}@${readiness.pluginVersion} in ${readiness.vaultName}`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, Number(process.env.E2E_OBSIDIAN_SMOKE_TIMEOUT_MS ?? 1000)));
|
||||
console.log("Obsidian stayed alive after the plug-in readiness check.");
|
||||
} finally {
|
||||
if (app) {
|
||||
await 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