Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/obsidian-test-session/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
- a local Obsidian executable and the matching `obsidian-cli`; and
- a local test environment that can launch Electron and connect to its remote-debugging port.

Executable discovery is implemented for Linux, macOS, and Windows. This project exercises complete real-Obsidian sessions on Linux and macOS; Windows discovery exists but the end-to-end workflow remains unverified. Automated AppImage download and optional `xvfb-run` wrapping are Linux-specific.
Executable discovery is implemented for Linux, macOS, and Windows. On Windows it checks the official per-user installation below `LOCALAPPDATA`, the all-user installation directories, and then `PATH`, including the `Obsidian.com` terminal redirector. The reviewed complete real-Obsidian session matrix covers Linux and macOS. Windows executable discovery has independent Windows coverage. Automated AppImage download and optional `xvfb-run` wrapping are Linux-specific.

Managed Linux sessions default to the reviewed Obsidian 1.13.6 AppImage. The package keeps an immutable catalogue of reviewed asset names and SHA-256 digests, installs each version and architecture separately, and never downloads during executable discovery or session start-up. An exact version outside that catalogue is rejected unless the caller deliberately enables an unverified regression probe. A passing unverified probe does not establish supported-version status.

Expand Down
58 changes: 57 additions & 1 deletion packages/obsidian-test-session/src/environment.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { platform } from "node:process";
import { resolve } from "node:path";
import { join, resolve } from "node:path";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest";
import {
DEFAULT_VALIDATED_OBSIDIAN_VERSION,
Expand All @@ -25,6 +27,60 @@ describe("Obsidian executable discovery", () => {
expect(result.checked).toContain("/usr/local/bin/obsidian");
});

it.runIf(platform === "win32")(
"finds the official per-user Windows executable and terminal redirector outside PATH",
async () => {
const localAppData = await mkdtemp(join(tmpdir(), "obsidian-discovery-"));
const installDirectory = join(localAppData, "Programs", "Obsidian");
await mkdir(installDirectory, { recursive: true });
await Promise.all([
writeFile(join(installDirectory, "Obsidian.exe"), "fixture"),
writeFile(join(installDirectory, "Obsidian.com"), "fixture"),
]);
try {
const env = { LOCALAPPDATA: localAppData, Path: "" };

expect(discoverObsidianBinary(env).binary).toBe(
join(installDirectory, "Obsidian.exe"),
);
expect(discoverObsidianCli(env).binary).toBe(
join(installDirectory, "Obsidian.com"),
);
} finally {
await rm(localAppData, { recursive: true, force: true });
}
},
);

it.runIf(platform === "win32")(
"checks every standard Windows location before PATH candidates",
() => {
const localAppData = join(tmpdir(), "missing-obsidian-local-app-data");
const programFiles = "C:\\Missing Program Files";
const programFilesX86 = "C:\\Missing Program Files (x86)";
const pathEntry = "C:\\Missing Path Entry";
const result = discoverObsidianCli({
LOCALAPPDATA: localAppData,
ProgramFiles: programFiles,
"ProgramFiles(x86)": programFilesX86,
Path: pathEntry,
});

expect(result.binary).toBeUndefined();
expect(result.checked).toEqual(
[
join(localAppData, "Programs", "Obsidian"),
join(programFiles, "Obsidian"),
join(programFilesX86, "Obsidian"),
pathEntry,
].flatMap((directory) => [
join(directory, "Obsidian.com"),
join(directory, "obsidian-cli.exe"),
]),
);
},
);

it.runIf(platform === "linux")(
"selects the versioned managed AppImage by default",
() => {
Expand Down
60 changes: 49 additions & 11 deletions packages/obsidian-test-session/src/environment.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { accessSync, constants, existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { delimiter, join, resolve } from "node:path";
import { platform } from "node:process";
import {
allowUnverifiedObsidianVersion,
Expand Down Expand Up @@ -42,10 +42,6 @@ const defaultCandidatesByPlatform: Partial<
"/opt/obsidian/obsidian",
"/app/bin/obsidian",
],
win32: [
"C:\\Program Files\\Obsidian\\Obsidian.exe",
"C:\\Program Files (x86)\\Obsidian\\Obsidian.exe",
],
};

const defaultCliCandidatesByPlatform: Partial<
Expand All @@ -63,12 +59,54 @@ const defaultCliCandidatesByPlatform: Partial<
"/opt/Obsidian/obsidian-cli",
"/opt/obsidian/obsidian-cli",
],
win32: [
"C:\\Program Files\\Obsidian\\obsidian-cli.exe",
"C:\\Program Files (x86)\\Obsidian\\obsidian-cli.exe",
],
};

function windowsInstallRoots(env: NodeJS.ProcessEnv): string[] {
const programFiles64 = env.ProgramW6432?.trim();
const programFilesX86 = env["ProgramFiles(x86)"]?.trim();
const roots = [
env.LOCALAPPDATA?.trim()
? join(env.LOCALAPPDATA, "Programs", "Obsidian")
: undefined,
programFiles64 ? join(programFiles64, "Obsidian") : undefined,
env.ProgramFiles?.trim()
? join(env.ProgramFiles, "Obsidian")
: "C:\\Program Files\\Obsidian",
programFilesX86
? join(programFilesX86, "Obsidian")
: "C:\\Program Files (x86)\\Obsidian",
];
return [...new Set(roots.filter((root): root is string => Boolean(root)))];
}

function pathDirectories(env: NodeJS.ProcessEnv): string[] {
return (env.Path ?? env.PATH ?? "")
.split(delimiter)
.map((entry) => entry.trim().replace(/^"|"$/gu, ""))
.filter(Boolean);
}

function defaultBinaryCandidates(env: NodeJS.ProcessEnv): string[] {
if (platform !== "win32")
return [...(defaultCandidatesByPlatform[platform] ?? [])];
return [
...windowsInstallRoots(env).map((directory) =>
join(directory, "Obsidian.exe"),
),
...pathDirectories(env).map((directory) => join(directory, "Obsidian.exe")),
];
}

function defaultCliCandidates(env: NodeJS.ProcessEnv): string[] {
if (platform !== "win32")
return [...(defaultCliCandidatesByPlatform[platform] ?? [])];
const roots = [...windowsInstallRoots(env), ...pathDirectories(env)];
return roots.flatMap((directory) => [
join(directory, "Obsidian.com"),
join(directory, "obsidian-cli.exe"),
]);
}

interface ManagedObsidianTarget extends ObsidianVersionSelection {
installDirectory: string;
}
Expand Down Expand Up @@ -169,7 +207,7 @@ export function discoverObsidianBinary(
const managedTarget = managedObsidianTarget(env);
if (managedTarget !== undefined)
return discover(undefined, managedBinaryCandidates(managedTarget));
return discover(undefined, defaultCandidatesByPlatform[platform] ?? []);
return discover(undefined, defaultBinaryCandidates(env));
}

/**
Expand Down Expand Up @@ -220,7 +258,7 @@ export function discoverObsidianCli(
const managedTarget = managedObsidianTarget(env);
if (managedTarget !== undefined)
return discover(undefined, managedCliCandidates(managedTarget));
return discover(undefined, defaultCliCandidatesByPlatform[platform] ?? []);
return discover(undefined, defaultCliCandidates(env));
}

/**
Expand Down
78 changes: 78 additions & 0 deletions packages/obsidian-test-session/src/environment.windows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { win32 } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";

const files = vi.hoisted(() => new Set<string>());

vi.mock("node:process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:process")>()),
platform: "win32",
}));

vi.mock("node:path", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:path")>();
return {
...actual,
join: actual.win32.join,
resolve: actual.win32.resolve,
delimiter: actual.win32.delimiter,
};
});

vi.mock("node:fs", async (importOriginal) => ({
...(await importOriginal<typeof import("node:fs")>()),
existsSync: (path: string) => files.has(path.toLowerCase()),
}));

import { discoverObsidianBinary, discoverObsidianCli } from "./environment.js";

const nativeDirectory = "C:\\Program Files\\Obsidian";
const env = {
LOCALAPPDATA: "C:\\Users\\test\\AppData\\Local",
ProgramFiles: "C:\\Program Files (x86)",
"ProgramFiles(x86)": "C:\\Program Files (x86)",
ProgramW6432: "C:\\Program Files",
Path: "",
};

beforeEach(() => files.clear());

function addFile(directory: string, filename: string): string {
const path = win32.join(directory, filename);
files.add(path.toLowerCase());
return path;
}

describe("Windows executable discovery", () => {
it("finds native all-user executables from a 64-bit environment", () => {
const binary = addFile(nativeDirectory, "Obsidian.exe");
const cli = addFile(nativeDirectory, "obsidian-cli.exe");
const nativeEnv = { ...env, ProgramFiles: env.ProgramW6432 };

expect(discoverObsidianBinary(nativeEnv).binary).toBe(binary);
expect(discoverObsidianCli(nativeEnv).binary).toBe(cli);
});

it.each([
["Obsidian.exe", discoverObsidianBinary],
["Obsidian.com", discoverObsidianCli],
["obsidian-cli.exe", discoverObsidianCli],
] as const)(
"finds native all-user %s from a WOW64 environment without PATH registration",
(filename, discover) => {
const path = addFile(nativeDirectory, filename);

expect(discover(env).binary).toBe(path);
},
);

it("prefers per-user executables over native all-user executables", () => {
const perUserDirectory = win32.join(env.LOCALAPPDATA, "Programs", "Obsidian");
const binary = addFile(perUserDirectory, "Obsidian.exe");
const cli = addFile(perUserDirectory, "Obsidian.com");
addFile(nativeDirectory, "Obsidian.exe");
addFile(nativeDirectory, "Obsidian.com");

expect(discoverObsidianBinary(env).binary).toBe(binary);
expect(discoverObsidianCli(env).binary).toBe(cli);
});
});
15 changes: 12 additions & 3 deletions packages/obsidian-test-session/src/vault.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { join, relative, sep } from "node:path";
import { describe, expect, it } from "vitest";
import { createTemporaryVault } from "./vault.js";

describe("createTemporaryVault", () => {
function isBelow(parentPath: string, childPath: string): boolean {
const pathFromParent = relative(parentPath, childPath);
return (
pathFromParent.length > 0 &&
pathFromParent !== ".." &&
!pathFromParent.startsWith(`..${sep}`)
);
}

it("creates isolated profile state and removes it on disposal", async () => {
const previousKeep = process.env.E2E_OBSIDIAN_KEEP_VAULT;
delete process.env.E2E_OBSIDIAN_KEEP_VAULT;
Expand Down Expand Up @@ -55,8 +64,8 @@ describe("createTemporaryVault", () => {
};
const vault = await createTemporaryVault(options);
try {
expect(vault.path.startsWith(`${temporaryRoot}/`)).toBe(true);
expect(vault.statePath.startsWith(`${temporaryRoot}/`)).toBe(true);
expect(isBelow(temporaryRoot, vault.path)).toBe(true);
expect(isBelow(temporaryRoot, vault.statePath)).toBe(true);
} finally {
await vault.dispose();
await rm(temporaryRoot, { recursive: true, force: true });
Expand Down
1 change: 1 addition & 0 deletions packages/obsidian-test-session/updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
### Fixes

- Selected the active Vault renderer when Obsidian exposes a Settings pop-out first, and recognised the current mobile Modal header control when checking safe-area and touch-target behaviour.
- Discovered official per-user and all-user Windows installations beyond `PATH`, including the `Obsidian.com` terminal redirector.

### Documentation

Expand Down
Loading