mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-13 00:03:11 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50247a80ea | |||
| 42a333a571 | |||
| a6a5f7af53 | |||
| 4df87cc96a | |||
| d16e66c67a | |||
| 57e26f1079 | |||
| dbb8d2be22 | |||
| a60932d9e4 | |||
| fd3e8416b7 | |||
| b9a27ffef9 | |||
| af72cac4e3 | |||
| 7d26f0ae35 | |||
| 008a5ace06 | |||
| b1955703b9 | |||
| 39065e15e3 | |||
| 7b480d4c1d | |||
| 33cd6a3b51 | |||
| 870ac93cac | |||
| 2fcdbbc4da | |||
| 18e8b239bf | |||
| ef655a2976 | |||
| 78eb6ec3bc | |||
| c0780d19ba | |||
| 5b61ea5009 |
@@ -0,0 +1,67 @@
|
|||||||
|
name: Deploy GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'aggregator.html'
|
||||||
|
- '.github/workflows/deploy-pages.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'aggregator.html'
|
||||||
|
- '.github/workflows/deploy-pages.yml'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Validate and package Pages site
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Validate aggregator
|
||||||
|
run: |
|
||||||
|
test -s aggregator.html
|
||||||
|
grep -Fq '<!DOCTYPE html>' aggregator.html
|
||||||
|
grep -Fq 'obsidian://setuplivesync?settingsQR=' aggregator.html
|
||||||
|
sed -n '/<script>/,/<\/script>/p' aggregator.html | sed '1d;$d' > aggregator.js
|
||||||
|
node --check aggregator.js
|
||||||
|
rm aggregator.js
|
||||||
|
|
||||||
|
- name: Prepare Pages site
|
||||||
|
run: |
|
||||||
|
mkdir -p _site
|
||||||
|
cp aggregator.html _site/aggregator.html
|
||||||
|
touch _site/.nojekyll
|
||||||
|
|
||||||
|
- name: Configure GitHub Pages
|
||||||
|
uses: actions/configure-pages@v5
|
||||||
|
|
||||||
|
- name: Upload GitHub Pages artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: _site
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy GitHub Pages
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- name: Deploy GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
@@ -47,6 +47,12 @@ npm test # Run Harness based vitest tests (requires Docker services)
|
|||||||
|
|
||||||
Use CLI E2E tests or real Obsidian E2E scripts instead of `npm test` when the behaviour can be verified outside the browser harness.
|
Use CLI E2E tests or real Obsidian E2E scripts instead of `npm test` when the behaviour can be verified outside the browser harness.
|
||||||
|
|
||||||
|
### Unreleased change notes
|
||||||
|
|
||||||
|
Keep changes that may belong in a future release under `## Unreleased` at the top of `updates.md` when they do not justify an immediate release. Do not add a date to this virtual version. Move relevant entries under the real version and ordinal release date when preparing that release, then leave an empty `## Unreleased` section for subsequent work.
|
||||||
|
|
||||||
|
Use this section for durable release-note candidates, including compatibility-relevant internal maintenance, rather than tasks, local diagnostics, or implementation journals. Categorise user-visible behaviour separately from internal changes and testing.
|
||||||
|
|
||||||
### Auto-copy to test vaults
|
### Auto-copy to test vaults
|
||||||
|
|
||||||
To facilitate development and testing, the build process can automatically copy the built plugin to specified test vault
|
To facilitate development and testing, the build process can automatically copy the built plugin to specified test vault
|
||||||
|
|||||||
Generated
+16
@@ -58,6 +58,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",
|
||||||
"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",
|
||||||
@@ -4180,6 +4181,7 @@
|
|||||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
@@ -4944,6 +4946,20 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vrtmrz/obsidian-test-session": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-asBOIRTc3xK5GF5ds5mkxN6vsO4RE8o7puvVjoJiGYSlxoFa9jzr8FwAO13CyoOriHF05pOZfTB+eQmL1aNb/A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": ">=20",
|
||||||
|
"playwright": ">=1.50.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@wdio/config": {
|
"node_modules/@wdio/config": {
|
||||||
"version": "9.27.0",
|
"version": "9.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.27.0.tgz",
|
||||||
|
|||||||
@@ -105,6 +105,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",
|
||||||
"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",
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
|
||||||
|
|
||||||
|
/** One platform-neutral storage adapter contract case. */
|
||||||
|
export interface StorageAdapterContractCase {
|
||||||
|
readonly name: string;
|
||||||
|
run(adapter: IStorageAdapter): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEqual(actual: unknown, expected: unknown, message: string): void {
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(`${message}\nactual=${JSON.stringify(actual)}\nexpected=${JSON.stringify(expected)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertRejects(operation: () => Promise<unknown>, message: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Passing baseline shared by Node, FSAPI, and future storage adapters. */
|
||||||
|
export const storageAdapterContractCases: readonly StorageAdapterContractCase[] = [
|
||||||
|
{
|
||||||
|
name: "reports missing paths consistently",
|
||||||
|
async run(adapter) {
|
||||||
|
assertEqual(await adapter.exists("missing.txt"), false, "missing path should not exist");
|
||||||
|
assertEqual(await adapter.stat("missing.txt"), null, "missing stat should be null");
|
||||||
|
assertEqual(await adapter.trystat("missing.txt"), null, "missing trystat should be null");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "creates parent directories for nested text writes",
|
||||||
|
async run(adapter) {
|
||||||
|
await adapter.write("notes/nested/note.md", "hello");
|
||||||
|
assertEqual(await adapter.read("notes/nested/note.md"), "hello", "text should round-trip");
|
||||||
|
assert(await adapter.exists("notes/nested/note.md"), "written text path should exist");
|
||||||
|
assertEqual((await adapter.stat("notes/nested/note.md"))?.type, "file", "written path should be a file");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "round-trips exact binary bytes",
|
||||||
|
async run(adapter) {
|
||||||
|
const expected = Uint8Array.from([0x00, 0x7f, 0x80, 0xff, 0x42]);
|
||||||
|
await adapter.writeBinary("binary/blob.bin", expected.buffer.slice(0));
|
||||||
|
const result = await adapter.readBinary("binary/blob.bin");
|
||||||
|
assertEqual([...new Uint8Array(result)], [...expected], "binary data should round-trip exactly");
|
||||||
|
assertEqual(result.byteLength, expected.byteLength, "binary result should have the exact visible length");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "creates and extends text through append",
|
||||||
|
async run(adapter) {
|
||||||
|
await adapter.append("logs/events.log", "first");
|
||||||
|
await adapter.append("logs/events.log", ":second");
|
||||||
|
assertEqual(await adapter.read("logs/events.log"), "first:second", "append should create then extend text");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lists direct files and folders",
|
||||||
|
async run(adapter) {
|
||||||
|
await adapter.mkdir("listing/folder");
|
||||||
|
await adapter.write("listing/file.txt", "content");
|
||||||
|
const listed = await adapter.list("listing");
|
||||||
|
assertEqual([...listed.files].sort(), ["listing/file.txt"], "list should contain the direct file");
|
||||||
|
assertEqual([...listed.folders].sort(), ["listing/folder"], "list should contain the direct folder");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "removes files and directory trees",
|
||||||
|
async run(adapter) {
|
||||||
|
await adapter.write("remove/file.txt", "content");
|
||||||
|
await adapter.write("remove/folder/nested.txt", "content");
|
||||||
|
await adapter.remove("remove/file.txt");
|
||||||
|
assertEqual(await adapter.exists("remove/file.txt"), false, "file should be removed");
|
||||||
|
await adapter.remove("remove/folder");
|
||||||
|
assertEqual(await adapter.exists("remove/folder"), false, "directory tree should be removed");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "keeps operations inside the configured root",
|
||||||
|
async run(adapter) {
|
||||||
|
await assertRejects(() => adapter.exists("../outside"), "parent traversal should be rejected");
|
||||||
|
await assertRejects(() => adapter.write("nested/../outside", "content"), "nested traversal should be rejected");
|
||||||
|
await assertRejects(() => adapter.read("/absolute"), "absolute paths should be rejected");
|
||||||
|
await assertRejects(() => adapter.read("C:\\absolute"), "drive-qualified paths should be rejected");
|
||||||
|
await assertRejects(() => adapter.read("nested\\outside"), "backslash-separated paths should be rejected");
|
||||||
|
await assertRejects(() => adapter.remove(""), "removing the configured root should be rejected");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uses the empty path only for root-safe operations",
|
||||||
|
async run(adapter) {
|
||||||
|
await adapter.mkdir("");
|
||||||
|
assertEqual(await adapter.exists(""), true, "the configured root should exist");
|
||||||
|
assertEqual((await adapter.stat(""))?.type, "folder", "the configured root should be a folder");
|
||||||
|
assertEqual(await adapter.list(""), { files: [], folders: [] }, "the configured root should be listable");
|
||||||
|
await assertRejects(() => adapter.write("", "content"), "writing over the configured root should be rejected");
|
||||||
|
await assertRejects(() => adapter.append("", "content"), "appending to the configured root should be rejected");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -2,20 +2,22 @@ import type { UXDataWriteOptions } from "@lib/common/types";
|
|||||||
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
|
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
|
||||||
import type { NodeStat } from "./NodeTypes";
|
import type { NodeStat } from "./NodeTypes";
|
||||||
import { fsPromises as fs, path } from "@/apps/cli/node-compat";
|
import { fsPromises as fs, path } from "@/apps/cli/node-compat";
|
||||||
|
import { validateStoragePath } from "@/apps/storagePath";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage adapter implementation for Node.js
|
* Storage adapter implementation for Node.js
|
||||||
*/
|
*/
|
||||||
export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
||||||
constructor(private basePath: string) {}
|
constructor(private readonly basePath: string) {}
|
||||||
|
|
||||||
private resolvePath(p: string): string {
|
private resolvePath(p: string, allowRoot: boolean = true): string {
|
||||||
return path.join(this.basePath, p);
|
return path.join(this.basePath, validateStoragePath(p, allowRoot));
|
||||||
}
|
}
|
||||||
|
|
||||||
async exists(p: string): Promise<boolean> {
|
async exists(p: string): Promise<boolean> {
|
||||||
|
const fullPath = this.resolvePath(p);
|
||||||
try {
|
try {
|
||||||
await fs.access(this.resolvePath(p));
|
await fs.access(fullPath);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -23,8 +25,9 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async trystat(p: string): Promise<NodeStat | null> {
|
async trystat(p: string): Promise<NodeStat | null> {
|
||||||
|
const fullPath = this.resolvePath(p);
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(this.resolvePath(p));
|
const stat = await fs.stat(fullPath);
|
||||||
return {
|
return {
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
mtime: Math.floor(stat.mtimeMs),
|
mtime: Math.floor(stat.mtimeMs),
|
||||||
@@ -45,7 +48,7 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(p: string): Promise<void> {
|
async remove(p: string): Promise<void> {
|
||||||
const fullPath = this.resolvePath(p);
|
const fullPath = this.resolvePath(p, false);
|
||||||
const stat = await fs.stat(fullPath);
|
const stat = await fs.stat(fullPath);
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
await fs.rm(fullPath, { recursive: true, force: true });
|
await fs.rm(fullPath, { recursive: true, force: true });
|
||||||
@@ -55,17 +58,17 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async read(p: string): Promise<string> {
|
async read(p: string): Promise<string> {
|
||||||
return await fs.readFile(this.resolvePath(p), "utf-8");
|
return await fs.readFile(this.resolvePath(p, false), "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
async readBinary(p: string): Promise<ArrayBuffer> {
|
async readBinary(p: string): Promise<ArrayBuffer> {
|
||||||
const buffer = await fs.readFile(this.resolvePath(p));
|
const buffer = await fs.readFile(this.resolvePath(p, false));
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- required in environments where Buffer.buffer is ArrayBufferLike
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- required in environments where Buffer.buffer is ArrayBufferLike
|
||||||
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
|
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||||
const fullPath = this.resolvePath(p);
|
const fullPath = this.resolvePath(p, false);
|
||||||
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
||||||
await fs.writeFile(fullPath, data, "utf-8");
|
await fs.writeFile(fullPath, data, "utf-8");
|
||||||
|
|
||||||
@@ -77,7 +80,7 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||||
const fullPath = this.resolvePath(p);
|
const fullPath = this.resolvePath(p, false);
|
||||||
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
||||||
await fs.writeFile(fullPath, new Uint8Array(data));
|
await fs.writeFile(fullPath, new Uint8Array(data));
|
||||||
|
|
||||||
@@ -89,7 +92,7 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async append(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
async append(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||||
const fullPath = this.resolvePath(p);
|
const fullPath = this.resolvePath(p, false);
|
||||||
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
||||||
await fs.appendFile(fullPath, data, "utf-8");
|
await fs.appendFile(fullPath, data, "utf-8");
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import * as fs from "node:fs/promises";
|
|||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { storageAdapterContractCases } from "@/apps/_test/storageAdapterContract";
|
||||||
import { NodeStorageAdapter } from "./NodeStorageAdapter";
|
import { NodeStorageAdapter } from "./NodeStorageAdapter";
|
||||||
|
|
||||||
describe("NodeStorageAdapter binary I/O", () => {
|
describe("NodeStorageAdapter", () => {
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
async function createAdapter() {
|
async function createAdapter() {
|
||||||
@@ -17,6 +18,12 @@ describe("NodeStorageAdapter binary I/O", () => {
|
|||||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const contractCase of storageAdapterContractCases) {
|
||||||
|
it(contractCase.name, async () => {
|
||||||
|
await contractCase.run(await createAdapter());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it("writes and reads binary data without corruption", async () => {
|
it("writes and reads binary data without corruption", async () => {
|
||||||
const adapter = await createAdapter();
|
const adapter = await createAdapter();
|
||||||
const expected = Uint8Array.from([0x00, 0x7f, 0x80, 0xff, 0x42]);
|
const expected = Uint8Array.from([0x00, 0x7f, 0x80, 0xff, 0x42]);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Validate the platform-neutral path vocabulary used by rooted storage adapters.
|
||||||
|
*
|
||||||
|
* Paths are slash-separated and relative to the root bound to the adapter. Root
|
||||||
|
* selection and authorisation happen before the adapter is constructed.
|
||||||
|
*/
|
||||||
|
export function validateStoragePath(storagePath: string, allowRoot: boolean = true): string {
|
||||||
|
if (storagePath === "") {
|
||||||
|
if (allowRoot) return storagePath;
|
||||||
|
throw new Error("The storage root is not a valid entry path");
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveSync normally rejects ':' in portable filenames before paths reach storage. A leading letter and colon
|
||||||
|
// therefore represents drive-qualified input or a leaked non-storage namespace, not a supported physical path.
|
||||||
|
if (storagePath.startsWith("/") || storagePath.startsWith("\\") || /^[A-Za-z]:/.test(storagePath)) {
|
||||||
|
throw new Error(`Storage paths must be relative to the configured root: ${storagePath}`);
|
||||||
|
}
|
||||||
|
if (storagePath.includes("\\")) {
|
||||||
|
throw new Error(`Storage paths must use forward slashes: ${storagePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = storagePath.split("/");
|
||||||
|
if (segments.some((segment) => segment === "." || segment === "..")) {
|
||||||
|
throw new Error(`Storage paths must not contain traversal segments: ${storagePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return storagePath;
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { UXDataWriteOptions } from "@lib/common/types";
|
import type { UXDataWriteOptions } from "@lib/common/types";
|
||||||
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
|
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
|
||||||
import type { FSAPIStat } from "./FSAPITypes";
|
import type { FSAPIStat } from "./FSAPITypes";
|
||||||
|
import { validateStoragePath } from "@/apps/storagePath";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage adapter implementation for FileSystem API
|
* Storage adapter implementation for FileSystem API
|
||||||
*/
|
*/
|
||||||
export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
||||||
constructor(private rootHandle: FileSystemDirectoryHandle) {}
|
constructor(private readonly rootHandle: FileSystemDirectoryHandle) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a path to directory and file handles
|
* Resolve a path to directory and file handles
|
||||||
@@ -15,11 +16,9 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
dirHandle: FileSystemDirectoryHandle;
|
dirHandle: FileSystemDirectoryHandle;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
|
validateStoragePath(p, false);
|
||||||
try {
|
try {
|
||||||
const parts = p.split("/").filter((part) => part !== "");
|
const parts = p.split("/").filter((part) => part !== "");
|
||||||
if (parts.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentHandle = this.rootHandle;
|
let currentHandle = this.rootHandle;
|
||||||
const fileName = parts[parts.length - 1];
|
const fileName = parts[parts.length - 1];
|
||||||
@@ -39,6 +38,8 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
* Get file handle for a given path
|
* Get file handle for a given path
|
||||||
*/
|
*/
|
||||||
private async getFileHandle(p: string): Promise<FileSystemFileHandle | null> {
|
private async getFileHandle(p: string): Promise<FileSystemFileHandle | null> {
|
||||||
|
validateStoragePath(p);
|
||||||
|
if (p === "") return null;
|
||||||
const resolved = await this.resolvePath(p);
|
const resolved = await this.resolvePath(p);
|
||||||
if (!resolved) return null;
|
if (!resolved) return null;
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
* Get directory handle for a given path
|
* Get directory handle for a given path
|
||||||
*/
|
*/
|
||||||
private async getDirectoryHandle(p: string): Promise<FileSystemDirectoryHandle | null> {
|
private async getDirectoryHandle(p: string): Promise<FileSystemDirectoryHandle | null> {
|
||||||
|
validateStoragePath(p);
|
||||||
try {
|
try {
|
||||||
const parts = p.split("/").filter((part) => part !== "");
|
const parts = p.split("/").filter((part) => part !== "");
|
||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
@@ -70,6 +72,20 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a writable file path after creating its parent directories. */
|
||||||
|
private async resolveWritablePath(p: string): Promise<{
|
||||||
|
dirHandle: FileSystemDirectoryHandle;
|
||||||
|
fileName: string;
|
||||||
|
} | null> {
|
||||||
|
validateStoragePath(p, false);
|
||||||
|
const parts = p.split("/").filter((part) => part !== "");
|
||||||
|
const fileName = parts.pop()!;
|
||||||
|
const parentPath = parts.join("/");
|
||||||
|
await this.mkdir(parentPath);
|
||||||
|
const dirHandle = await this.getDirectoryHandle(parentPath);
|
||||||
|
return dirHandle ? { dirHandle, fileName } : null;
|
||||||
|
}
|
||||||
|
|
||||||
async exists(p: string): Promise<boolean> {
|
async exists(p: string): Promise<boolean> {
|
||||||
const fileHandle = await this.getFileHandle(p);
|
const fileHandle = await this.getFileHandle(p);
|
||||||
if (fileHandle) return true;
|
if (fileHandle) return true;
|
||||||
@@ -110,6 +126,7 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async mkdir(p: string): Promise<void> {
|
async mkdir(p: string): Promise<void> {
|
||||||
|
validateStoragePath(p);
|
||||||
const parts = p.split("/").filter((part) => part !== "");
|
const parts = p.split("/").filter((part) => part !== "");
|
||||||
let currentHandle = this.rootHandle;
|
let currentHandle = this.rootHandle;
|
||||||
|
|
||||||
@@ -146,14 +163,11 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||||
const resolved = await this.resolvePath(p);
|
const resolved = await this.resolveWritablePath(p);
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
throw new Error(`Invalid path: ${p}`);
|
throw new Error(`Invalid path: ${p}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure parent directory exists
|
|
||||||
await this.mkdir(p.split("/").slice(0, -1).join("/"));
|
|
||||||
|
|
||||||
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
|
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
|
||||||
const writable = await fileHandle.createWritable();
|
const writable = await fileHandle.createWritable();
|
||||||
await writable.write(data);
|
await writable.write(data);
|
||||||
@@ -161,14 +175,11 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||||
const resolved = await this.resolvePath(p);
|
const resolved = await this.resolveWritablePath(p);
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
throw new Error(`Invalid path: ${p}`);
|
throw new Error(`Invalid path: ${p}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure parent directory exists
|
|
||||||
await this.mkdir(p.split("/").slice(0, -1).join("/"));
|
|
||||||
|
|
||||||
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
|
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
|
||||||
const writable = await fileHandle.createWritable();
|
const writable = await fileHandle.createWritable();
|
||||||
await writable.write(data);
|
await writable.write(data);
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it } from "vitest";
|
||||||
|
import { storageAdapterContractCases } from "@/apps/_test/storageAdapterContract";
|
||||||
|
import { FSAPIStorageAdapter } from "./FSAPIStorageAdapter";
|
||||||
|
|
||||||
|
class MemoryFileHandle {
|
||||||
|
readonly kind = "file";
|
||||||
|
private data = new Uint8Array();
|
||||||
|
|
||||||
|
constructor(readonly name: string) {}
|
||||||
|
|
||||||
|
async getFile(): Promise<File> {
|
||||||
|
return new File([this.data], this.name, { lastModified: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWritable(): Promise<FileSystemWritableFileStream> {
|
||||||
|
const handle = this;
|
||||||
|
return {
|
||||||
|
async write(data: FileSystemWriteChunkType) {
|
||||||
|
if (typeof data === "string") {
|
||||||
|
handle.data = new TextEncoder().encode(data);
|
||||||
|
} else if (data instanceof ArrayBuffer) {
|
||||||
|
handle.data = new Uint8Array(data.slice(0));
|
||||||
|
} else if (ArrayBuffer.isView(data)) {
|
||||||
|
handle.data = new Uint8Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength));
|
||||||
|
} else {
|
||||||
|
throw new TypeError("Unsupported in-memory write type");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async close() {},
|
||||||
|
} as FileSystemWritableFileStream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryDirectoryHandle {
|
||||||
|
readonly kind = "directory";
|
||||||
|
private readonly children = new Map<string, MemoryDirectoryHandle | MemoryFileHandle>();
|
||||||
|
|
||||||
|
constructor(readonly name: string) {}
|
||||||
|
|
||||||
|
async getDirectoryHandle(name: string, options?: FileSystemGetDirectoryOptions): Promise<FileSystemDirectoryHandle> {
|
||||||
|
const existing = this.children.get(name);
|
||||||
|
if (existing instanceof MemoryDirectoryHandle) return existing as unknown as FileSystemDirectoryHandle;
|
||||||
|
if (existing !== undefined || !options?.create) throw new DOMException("Directory not found", "NotFoundError");
|
||||||
|
const directory = new MemoryDirectoryHandle(name);
|
||||||
|
this.children.set(name, directory);
|
||||||
|
return directory as unknown as FileSystemDirectoryHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle> {
|
||||||
|
const existing = this.children.get(name);
|
||||||
|
if (existing instanceof MemoryFileHandle) return existing as unknown as FileSystemFileHandle;
|
||||||
|
if (existing !== undefined || !options?.create) throw new DOMException("File not found", "NotFoundError");
|
||||||
|
const file = new MemoryFileHandle(name);
|
||||||
|
this.children.set(name, file);
|
||||||
|
return file as unknown as FileSystemFileHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<void> {
|
||||||
|
const existing = this.children.get(name);
|
||||||
|
if (existing === undefined) throw new DOMException("Entry not found", "NotFoundError");
|
||||||
|
if (existing instanceof MemoryDirectoryHandle && !options?.recursive && existing.children.size > 0) {
|
||||||
|
throw new DOMException("Directory is not empty", "InvalidModificationError");
|
||||||
|
}
|
||||||
|
this.children.delete(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
|
||||||
|
for (const [name, entry] of this.children) {
|
||||||
|
yield [name, entry as unknown as FileSystemHandle];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FSAPIStorageAdapter", () => {
|
||||||
|
for (const contractCase of storageAdapterContractCases) {
|
||||||
|
it(contractCase.name, async () => {
|
||||||
|
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||||
|
await contractCase.run(new FSAPIStorageAdapter(root));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
+1
-1
Submodule src/lib updated: a0efb7274e...1cb156d463
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
This directory contains the experimental real Obsidian end-to-end runner.
|
This directory contains the experimental real Obsidian end-to-end runner.
|
||||||
|
|
||||||
|
The generic application discovery, isolated-vault, plug-in installation, process lifecycle, CLI, CDP, and readiness implementation comes from `@vrtmrz/obsidian-test-session`. The small modules under `runner/` preserve LiveSync's existing imports and supply its plug-in ID and artefact location. LiveSync-specific fixtures, services, settings, workflows, and assertions remain in this repository.
|
||||||
|
|
||||||
The current smoke runner verifies only the launch path:
|
The current smoke runner verifies only the launch path:
|
||||||
|
|
||||||
1. create a temporary vault,
|
1. create a temporary vault,
|
||||||
@@ -18,7 +20,7 @@ The runner does not require Self-hosted LiveSync to expose an E2E-only bridge. R
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence.
|
Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence. Add generic Obsidian bootstrap improvements to Fancy Kit; keep LiveSync behaviour and scenario helpers here.
|
||||||
|
|
||||||
Each test vault uses an isolated Obsidian profile. The runner creates temporary directories for `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, and Electron `--user-data-dir`, writes the vault registry into those directories, pre-seeds the temporary Chromium local storage so community plug-ins are trusted for that generated vault ID, and passes the same environment to `obsidian-cli`. This is intended to keep real Obsidian E2E runs separate from a developer's daily Obsidian profile and vault registry.
|
Each test vault uses an isolated Obsidian profile. The runner creates temporary directories for `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, and Electron `--user-data-dir`, writes the vault registry into those directories, pre-seeds the temporary Chromium local storage so community plug-ins are trusted for that generated vault ID, and passes the same environment to `obsidian-cli`. This is intended to keep real Obsidian E2E runs separate from a developer's daily Obsidian profile and vault registry.
|
||||||
|
|
||||||
|
|||||||
@@ -1,103 +1,6 @@
|
|||||||
import { spawn } from "node:child_process";
|
export {
|
||||||
|
evalObsidianJson,
|
||||||
export type ObsidianCliResult = {
|
openVaultWithObsidianCli,
|
||||||
code: number | null;
|
runObsidianCli,
|
||||||
signal: NodeJS.Signals | null;
|
type ObsidianCliResult,
|
||||||
stdout: string;
|
} from "@vrtmrz/obsidian-test-session";
|
||||||
stderr: 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 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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function evalObsidianJson<T>(
|
|
||||||
cliBinary: string,
|
|
||||||
code: string,
|
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
|
||||||
timeoutMs?: number
|
|
||||||
): Promise<T> {
|
|
||||||
const result = await runObsidianCli(cliBinary, ["eval", `code=${code}`], env, timeoutMs);
|
|
||||||
if (result.code !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
[
|
|
||||||
`Failed to evaluate Obsidian JavaScript 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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return parseEvalJson(result.stdout) as T;
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(
|
|
||||||
[
|
|
||||||
`Failed to parse Obsidian CLI eval JSON. code=${result.code}, signal=${result.signal}`,
|
|
||||||
error instanceof Error ? `parse error: ${error.message}` : undefined,
|
|
||||||
result.stdout ? `stdout:\n${result.stdout}` : undefined,
|
|
||||||
result.stderr ? `stderr:\n${result.stderr}` : undefined,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,149 +1,7 @@
|
|||||||
import { accessSync, constants, existsSync } from "node:fs";
|
export {
|
||||||
import { resolve } from "node:path";
|
discoverObsidianBinary,
|
||||||
import { platform } from "node:process";
|
discoverObsidianCli,
|
||||||
|
requireObsidianBinary,
|
||||||
export type ObsidianDiscoveryResult = {
|
requireObsidianCli,
|
||||||
binary?: string;
|
type ObsidianDiscoveryResult,
|
||||||
source?: string;
|
} from "@vrtmrz/obsidian-test-session";
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,196 +1,26 @@
|
|||||||
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
import {
|
||||||
import { once } from "node:events";
|
cleanupStaleObsidianE2EProcesses as cleanupStaleProcesses,
|
||||||
import { existsSync } from "node:fs";
|
launchObsidian as launchObsidianSession,
|
||||||
import { dirname } from "node:path";
|
type LaunchObsidianOptions,
|
||||||
import { platform } from "node:process";
|
type ObsidianProcess,
|
||||||
import { promisify } from "node:util";
|
type ObsidianProcessOutput,
|
||||||
|
} from "@vrtmrz/obsidian-test-session";
|
||||||
|
|
||||||
export type ObsidianProcess = {
|
export type { LaunchObsidianOptions, ObsidianProcess, ObsidianProcessOutput };
|
||||||
process: ChildProcess;
|
|
||||||
output: () => { stdout: string; stderr: string };
|
|
||||||
stop: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LaunchObsidianOptions = {
|
const STALE_PROCESS_PATTERN = "obsidian-livesync-e2e-state";
|
||||||
binary: string;
|
|
||||||
vaultPath: string;
|
|
||||||
homePath?: string;
|
|
||||||
xdgConfigPath?: string;
|
|
||||||
xdgCachePath?: string;
|
|
||||||
xdgDataPath?: string;
|
|
||||||
userDataPath?: string;
|
|
||||||
startupGraceMs?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
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 !== "false" && options.userDataPath
|
|
||||||
? [`--user-data-dir=${options.userDataPath}`]
|
|
||||||
: []),
|
|
||||||
...(process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT
|
|
||||||
? [`--remote-debugging-port=${process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT}`]
|
|
||||||
: []),
|
|
||||||
`obsidian://open?path=${encodeURIComponent(options.vaultPath)}`,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listChildPids(pid: number): Promise<number[]> {
|
|
||||||
if (platform === "win32") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const { stdout } = await execFileAsync("ps", ["-o", "pid=", "--ppid", String(pid)]).catch(() => ({
|
|
||||||
stdout: "",
|
|
||||||
}));
|
|
||||||
const directChildren = stdout
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => Number(line.trim()))
|
|
||||||
.filter((childPid) => Number.isInteger(childPid) && childPid > 0);
|
|
||||||
const descendants = await Promise.all(directChildren.map((childPid) => listChildPids(childPid)));
|
|
||||||
return [...directChildren, ...descendants.flat()];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function killPids(pids: number[], signal: NodeJS.Signals): Promise<void> {
|
|
||||||
for (const pid of pids) {
|
|
||||||
if (pid === process.pid) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
process.kill(pid, signal);
|
|
||||||
} catch {
|
|
||||||
// The process may have exited between discovery and signalling.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForExit(exitPromise: Promise<unknown>, timeoutMs: number): Promise<"exited" | "timeout"> {
|
|
||||||
const stopTimer = new Promise<"timeout">((resolve) => {
|
|
||||||
setTimeout(() => resolve("timeout"), timeoutMs);
|
|
||||||
});
|
|
||||||
const stopResult = await Promise.race([exitPromise.then(() => "exited" as const), stopTimer]);
|
|
||||||
return stopResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cleanupStaleObsidianE2EProcesses(): Promise<void> {
|
export async function cleanupStaleObsidianE2EProcesses(): Promise<void> {
|
||||||
if (process.env.E2E_OBSIDIAN_CLEANUP_STALE_PROCESSES === "false" || platform === "win32") {
|
await cleanupStaleProcesses(STALE_PROCESS_PATTERN);
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { stdout } = await execFileAsync("pgrep", ["-f", "obsidian-livesync-e2e-state"]).catch(() => ({
|
|
||||||
stdout: "",
|
|
||||||
}));
|
|
||||||
const pids = stdout
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => Number(line.trim()))
|
|
||||||
.filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
|
|
||||||
if (pids.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await killPids(pids, "SIGTERM");
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
await killPids(pids, "SIGKILL");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function launchObsidian(options: LaunchObsidianOptions): Promise<ObsidianProcess> {
|
export async function launchObsidian(options: LaunchObsidianOptions): Promise<ObsidianProcess> {
|
||||||
await cleanupStaleObsidianE2EProcesses();
|
const configuredPort =
|
||||||
const startupGraceMs = options.startupGraceMs ?? 1000;
|
options.env?.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT ?? process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT;
|
||||||
const args = launchArgs(options);
|
return await launchObsidianSession({
|
||||||
const useXvfb = shouldUseXvfb();
|
...options,
|
||||||
const command = useXvfb ? "/usr/bin/xvfb-run" : options.binary;
|
remoteDebuggingPort:
|
||||||
const commandArgs = useXvfb ? ["-a", options.binary, ...args] : args;
|
options.remoteDebuggingPort ?? (configuredPort === undefined ? undefined : Number(configuredPort)),
|
||||||
const child = spawn(command, commandArgs, {
|
staleProcessPattern: options.staleProcessPattern ?? STALE_PROCESS_PATTERN,
|
||||||
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 } : {}),
|
|
||||||
...(options.xdgCachePath ? { XDG_CACHE_HOME: options.xdgCachePath } : {}),
|
|
||||||
...(options.xdgDataPath ? { XDG_DATA_HOME: options.xdgDataPath } : {}),
|
|
||||||
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,
|
|
||||||
output: () => ({ stdout, stderr }),
|
|
||||||
stop: async () => {
|
|
||||||
if (child.exitCode !== null || child.signalCode !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const descendantPids = child.pid ? await listChildPids(child.pid) : [];
|
|
||||||
if (child.pid) {
|
|
||||||
try {
|
|
||||||
process.kill(-child.pid, "SIGTERM");
|
|
||||||
} catch {
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
}
|
|
||||||
await killPids(descendantPids.reverse(), "SIGTERM");
|
|
||||||
const stopResult = await waitForExit(exitPromise, 5000);
|
|
||||||
if (stopResult === "timeout") {
|
|
||||||
if (child.pid) {
|
|
||||||
try {
|
|
||||||
process.kill(-child.pid, "SIGKILL");
|
|
||||||
} catch {
|
|
||||||
child.kill("SIGKILL");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
child.kill("SIGKILL");
|
|
||||||
}
|
|
||||||
await killPids(descendantPids, "SIGKILL");
|
|
||||||
await exitPromise;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,13 @@
|
|||||||
import { copyFile, mkdir, writeFile } from "node:fs/promises";
|
import {
|
||||||
import { existsSync } from "node:fs";
|
installBuiltPlugin as installGenericBuiltPlugin,
|
||||||
import { join, resolve } from "node:path";
|
type PluginInstallResult,
|
||||||
|
} from "@vrtmrz/obsidian-test-session";
|
||||||
|
|
||||||
export type PluginInstallResult = {
|
export type { PluginInstallResult };
|
||||||
pluginDir: string;
|
|
||||||
copied: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const pluginId = "obsidian-livesync";
|
|
||||||
|
|
||||||
export async function installBuiltPlugin(vaultPath: string, rootDir = process.cwd()): Promise<PluginInstallResult> {
|
export async function installBuiltPlugin(vaultPath: string, rootDir = process.cwd()): Promise<PluginInstallResult> {
|
||||||
const pluginDir = join(vaultPath, ".obsidian", "plugins", pluginId);
|
return await installGenericBuiltPlugin(vaultPath, {
|
||||||
const copied: string[] = [];
|
pluginId: "obsidian-livesync",
|
||||||
await mkdir(pluginDir, { recursive: true });
|
artifactRoot: rootDir,
|
||||||
|
});
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1 @@
|
|||||||
import { evalObsidianJson } from "./cli.ts";
|
export { waitForPluginReady, type PluginReadiness } from "@vrtmrz/obsidian-test-session";
|
||||||
|
|
||||||
export type PluginReadiness = {
|
|
||||||
status: "ready";
|
|
||||||
pluginId: string;
|
|
||||||
pluginVersion: string;
|
|
||||||
vaultName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
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) {
|
|
||||||
try {
|
|
||||||
const readiness = await evalObsidianJson<PluginReadiness>(
|
|
||||||
cliBinary,
|
|
||||||
[
|
|
||||||
"(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
|
|
||||||
);
|
|
||||||
if (readiness.status === "ready") {
|
|
||||||
return readiness;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
lastOutput = error instanceof Error ? error.message : String(error);
|
|
||||||
// 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}`);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
import { evalObsidianJson, openVaultWithObsidianCli, runObsidianCli } from "./cli.ts";
|
import { startObsidianPluginSession, type ObsidianPluginSession } from "@vrtmrz/obsidian-test-session";
|
||||||
import { launchObsidian, type ObsidianProcess } from "./launch.ts";
|
|
||||||
import { installBuiltPlugin, type PluginInstallResult } from "./pluginInstaller.ts";
|
|
||||||
import { waitForPluginReady, type PluginReadiness } from "./readiness.ts";
|
|
||||||
import type { TemporaryVault } from "./vault.ts";
|
import type { TemporaryVault } from "./vault.ts";
|
||||||
import { obsidianRemoteDebuggingPort, preseedTrustedVaultState, trustVaultIfPrompted } from "./ui.ts";
|
|
||||||
|
|
||||||
export type ObsidianLiveSyncSession = {
|
export type ObsidianLiveSyncSession = ObsidianPluginSession;
|
||||||
app: ObsidianProcess;
|
|
||||||
cliEnv: NodeJS.ProcessEnv;
|
|
||||||
install: PluginInstallResult;
|
|
||||||
readiness: PluginReadiness;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StartObsidianLiveSyncSessionOptions = {
|
export type StartObsidianLiveSyncSessionOptions = {
|
||||||
binary: string;
|
binary: string;
|
||||||
@@ -19,101 +10,15 @@ export type StartObsidianLiveSyncSessionOptions = {
|
|||||||
startupGraceMs?: number;
|
startupGraceMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function waitForPluginCatalogue(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
|
||||||
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS ?? 60000);
|
|
||||||
let lastOutput = "";
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
try {
|
|
||||||
const result = await evalObsidianJson<{ hasLiveSync: boolean }>(
|
|
||||||
cliBinary,
|
|
||||||
["JSON.stringify({", "hasLiveSync:!!app.plugins?.manifests?.['obsidian-livesync']", "})"].join(""),
|
|
||||||
env
|
|
||||||
);
|
|
||||||
lastOutput = JSON.stringify(result);
|
|
||||||
if (result.hasLiveSync) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
lastOutput = error instanceof Error ? error.message : String(error);
|
|
||||||
}
|
|
||||||
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 reloadLiveSyncPlugin(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
|
||||||
const reload = await runObsidianCli(cliBinary, ["plugin:reload", "id=obsidian-livesync"], env);
|
|
||||||
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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startObsidianLiveSyncSession(
|
export async function startObsidianLiveSyncSession(
|
||||||
options: StartObsidianLiveSyncSessionOptions
|
options: StartObsidianLiveSyncSessionOptions
|
||||||
): Promise<ObsidianLiveSyncSession> {
|
): Promise<ObsidianLiveSyncSession> {
|
||||||
const install = await installBuiltPlugin(options.vault.path);
|
return await startObsidianPluginSession({
|
||||||
const remoteDebuggingPort = obsidianRemoteDebuggingPort();
|
|
||||||
const app = await launchObsidian({
|
|
||||||
binary: options.binary,
|
binary: options.binary,
|
||||||
vaultPath: options.vault.path,
|
cliBinary: options.cliBinary,
|
||||||
homePath: options.vault.homePath,
|
vault: options.vault,
|
||||||
xdgConfigPath: options.vault.xdgConfigPath,
|
pluginId: "obsidian-livesync",
|
||||||
xdgCachePath: options.vault.xdgCachePath,
|
artifactRoot: process.cwd(),
|
||||||
xdgDataPath: options.vault.xdgDataPath,
|
|
||||||
userDataPath: options.vault.userDataPath,
|
|
||||||
startupGraceMs: options.startupGraceMs,
|
startupGraceMs: options.startupGraceMs,
|
||||||
});
|
});
|
||||||
const cliEnv = {
|
|
||||||
...process.env,
|
|
||||||
HOME: options.vault.homePath,
|
|
||||||
XDG_CONFIG_HOME: options.vault.xdgConfigPath,
|
|
||||||
XDG_CACHE_HOME: options.vault.xdgCachePath,
|
|
||||||
XDG_DATA_HOME: options.vault.xdgDataPath,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await preseedTrustedVaultState(remoteDebuggingPort, options.vault.id);
|
|
||||||
await openVaultWithObsidianCli(options.cliBinary, options.vault.path, cliEnv);
|
|
||||||
await trustVaultIfPrompted(remoteDebuggingPort);
|
|
||||||
await waitForPluginCatalogue(options.cliBinary, cliEnv);
|
|
||||||
await enableCommunityPlugins(options.cliBinary, cliEnv);
|
|
||||||
await reloadLiveSyncPlugin(options.cliBinary, cliEnv);
|
|
||||||
const readiness = await waitForPluginReady(options.cliBinary, cliEnv);
|
|
||||||
return { app, cliEnv, install, readiness };
|
|
||||||
} catch (error) {
|
|
||||||
const output = app.output();
|
|
||||||
await app.stop();
|
|
||||||
throw new Error(
|
|
||||||
[
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
output.stdout ? `Obsidian stdout:\n${output.stdout}` : undefined,
|
|
||||||
output.stderr ? `Obsidian stderr:\n${output.stderr}` : undefined,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +1,11 @@
|
|||||||
import { chromium, type Page } from "playwright";
|
import { withObsidianPage } from "@vrtmrz/obsidian-test-session";
|
||||||
|
|
||||||
export function obsidianRemoteDebuggingPort(): number {
|
export {
|
||||||
const port = Number(process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT ?? 9222);
|
obsidianRemoteDebuggingPort,
|
||||||
process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT = String(port);
|
preseedTrustedVaultState,
|
||||||
return port;
|
trustVaultIfPrompted,
|
||||||
}
|
withObsidianPage,
|
||||||
|
} from "@vrtmrz/obsidian-test-session";
|
||||||
async function waitForCdp(port: number): Promise<void> {
|
|
||||||
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CDP_TIMEOUT_MS ?? 30000);
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`http://127.0.0.1:${port}/json/version`);
|
|
||||||
if (response.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Keep polling until Obsidian exposes the debugging endpoint.
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
}
|
|
||||||
throw new Error(`Timed out waiting for Obsidian DevTools endpoint on port ${port}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function withObsidianPage<T>(port: number, operation: (page: Page) => Promise<T>): Promise<T> {
|
|
||||||
await waitForCdp(port);
|
|
||||||
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
|
|
||||||
try {
|
|
||||||
const context = browser.contexts()[0];
|
|
||||||
const page = context.pages()[0] ?? (await context.waitForEvent("page", { timeout: 10000 }));
|
|
||||||
return await operation(page);
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function preseedTrustedVaultState(port: number, vaultId: string): Promise<void> {
|
|
||||||
await withObsidianPage(port, async (page) => {
|
|
||||||
await page.evaluate((id) => {
|
|
||||||
localStorage.setItem(`enable-plugin-${id}`, "true");
|
|
||||||
}, vaultId);
|
|
||||||
await page.reload({ waitUntil: "domcontentloaded", timeout: 10000 }).catch(() => undefined);
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function trustVaultIfPrompted(port: number): Promise<void> {
|
|
||||||
await withObsidianPage(port, async (page) => {
|
|
||||||
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_TRUST_PROMPT_TIMEOUT_MS ?? 30000);
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const yesButton = page.getByRole("button", { name: "Yes" });
|
|
||||||
if (await yesButton.isVisible({ timeout: 1000 }).catch(() => false)) {
|
|
||||||
await yesButton.click();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trustButton = page.getByText("Trust author and enable plugins");
|
|
||||||
if (await trustButton.isVisible({ timeout: 1000 }).catch(() => false)) {
|
|
||||||
await trustButton.click();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = page.locator(".workspace");
|
|
||||||
if (await workspace.isVisible({ timeout: 1000 }).catch(() => false)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
||||||
|
|||||||
@@ -1,94 +1,14 @@
|
|||||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
import {
|
||||||
import { join } from "node:path";
|
createTemporaryVault as createGenericTemporaryVault,
|
||||||
import { tmpdir } from "node:os";
|
type TemporaryVault,
|
||||||
|
} from "@vrtmrz/obsidian-test-session";
|
||||||
|
|
||||||
export type TemporaryVault = {
|
export type { TemporaryVault };
|
||||||
path: string;
|
|
||||||
name: string;
|
|
||||||
id: string;
|
|
||||||
homePath: string;
|
|
||||||
xdgConfigPath: string;
|
|
||||||
xdgCachePath: string;
|
|
||||||
xdgDataPath: string;
|
|
||||||
userDataPath: string;
|
|
||||||
dispose: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function createTemporaryVault(prefix = "obsidian-livesync-e2e-"): Promise<TemporaryVault> {
|
export async function createTemporaryVault(prefix = "obsidian-livesync-e2e-"): Promise<TemporaryVault> {
|
||||||
const vaultPath = await mkdtemp(join(tmpdir(), prefix));
|
return await createGenericTemporaryVault({
|
||||||
const statePath = await mkdtemp(join(tmpdir(), `${prefix}state-`));
|
prefix,
|
||||||
const name = vaultPath.split(/[\\/]/).pop() ?? "obsidian-livesync-e2e";
|
pluginIds: ["obsidian-livesync"],
|
||||||
await mkdir(join(vaultPath, ".obsidian"), { recursive: true });
|
idPrefix: "livesync-e2e",
|
||||||
const homePath = join(statePath, "home");
|
});
|
||||||
const xdgConfigPath = join(statePath, "xdg-config");
|
|
||||||
const xdgCachePath = join(statePath, "xdg-cache");
|
|
||||||
const xdgDataPath = join(statePath, "xdg-data");
|
|
||||||
const userDataPath = join(statePath, "user-data");
|
|
||||||
const id = `livesync-e2e-${Date.now()}`;
|
|
||||||
await mkdir(homePath, { recursive: true });
|
|
||||||
await mkdir(xdgConfigPath, { recursive: true });
|
|
||||||
await mkdir(xdgCachePath, { recursive: true });
|
|
||||||
await mkdir(xdgDataPath, { recursive: true });
|
|
||||||
await mkdir(userDataPath, { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
join(vaultPath, ".obsidian", "app.json"),
|
|
||||||
JSON.stringify({ legacyEditor: false, safeMode: false }, null, 4)
|
|
||||||
);
|
|
||||||
await writeFile(
|
|
||||||
join(vaultPath, ".obsidian", "community-plugins.json"),
|
|
||||||
JSON.stringify(["obsidian-livesync"], null, 4)
|
|
||||||
);
|
|
||||||
await writeObsidianVaultRegistry(id, vaultPath, name, homePath, xdgConfigPath, userDataPath);
|
|
||||||
|
|
||||||
return {
|
|
||||||
path: vaultPath,
|
|
||||||
name,
|
|
||||||
id,
|
|
||||||
homePath,
|
|
||||||
xdgConfigPath,
|
|
||||||
xdgCachePath,
|
|
||||||
xdgDataPath,
|
|
||||||
userDataPath,
|
|
||||||
dispose: async () => {
|
|
||||||
if (process.env.E2E_OBSIDIAN_KEEP_VAULT === "true") {
|
|
||||||
console.log(`Keeping temporary vault: ${vaultPath}`);
|
|
||||||
console.log(`Keeping temporary Obsidian state: ${statePath}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await Promise.all([
|
|
||||||
rm(vaultPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }),
|
|
||||||
rm(statePath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }),
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeObsidianVaultRegistry(
|
|
||||||
vaultId: string,
|
|
||||||
vaultPath: string,
|
|
||||||
vaultName: string,
|
|
||||||
homePath: string,
|
|
||||||
xdgConfigPath: string,
|
|
||||||
userDataPath: string
|
|
||||||
): Promise<void> {
|
|
||||||
const vaultRecord = {
|
|
||||||
path: vaultPath,
|
|
||||||
ts: Date.now(),
|
|
||||||
open: true,
|
|
||||||
name: vaultName,
|
|
||||||
};
|
|
||||||
const registry = {
|
|
||||||
cli: true,
|
|
||||||
vaults: {
|
|
||||||
[vaultId]: vaultRecord,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
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);
|
|
||||||
await writeFile(join(userDataPath, `${vaultId}.json`), JSON.stringify(vaultRecord, null, 4));
|
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -3,6 +3,22 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
|
|||||||
|
|
||||||
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
|
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Improved (CLI and Webapp)
|
||||||
|
|
||||||
|
- Rooted storage adapters now reject absolute, drive-qualified, backslash-separated, and traversal paths. They also prevent file writes, appends, and removal from targeting the configured root itself.
|
||||||
|
- File System Access API storage can now create files below previously missing parent directories, matching the existing Node behaviour.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Added shared Node and File System Access API storage contract coverage for metadata, text and binary operations, append, listing, removal, path containment, and empty-root handling.
|
||||||
|
- Added a Compose-based P2P end-to-end smoke test and repeatable network benchmark cases for local performance investigations.
|
||||||
|
|
||||||
|
### Miscellaneous
|
||||||
|
|
||||||
|
- Split the internal storage adapter contract into focused capability views without changing existing runtime behaviour.
|
||||||
|
|
||||||
## 0.25.80
|
## 0.25.80
|
||||||
|
|
||||||
7th July, 2026
|
7th July, 2026
|
||||||
|
|||||||
Reference in New Issue
Block a user