mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-26 20:47:07 +00:00
(test): the E2E test on the real-Obsidian
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user