obsidian-testing-framework 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # the Obsidian Testing Framework
2
+
3
+ ## what is this?
4
+
5
+ this is a library that (finally!) lets you end-to-end test your obsidian plugins.
6
+ it uses [playwright](https://playwright.dev/docs/intro) to interact with obsidian,
7
+ which is an Electron app under the hood.
8
+
9
+ ## great! how do i use it?
10
+
11
+ ### basic usage
12
+ ```ts
13
+ import {test} from "obsidian-testing-library";
14
+ test('obsidian app url', async ({ page }) => {
15
+ console.log(page.url());
16
+ expect(/obsidian\.md/i.test(page.url())).toBeTruthy()
17
+ });
18
+ ```
19
+ ### usage with the `app` instance
20
+ ```ts
21
+ test("idk", async({page}) => {
22
+ console.log("idk")
23
+ let tfile = await doWithApp(page, async (app) => {
24
+ return app.metadataCache.getFirstLinkpathDest("Welcome", "/");
25
+ });
26
+ expect(tfile.basename).toEqual("Welcome")
27
+ })
28
+ ```
29
+
30
+ ### other utilities
31
+
32
+ see `src/util.ts` for the currently included utilities and their documentation.
@@ -0,0 +1,9 @@
1
+ import { ElectronApplication, JSHandle, Page } from "playwright";
2
+ import { ObsidianTestingConfig } from "./index.js";
3
+ import { App } from "obsidian";
4
+ export interface ObsidianTestFixtures {
5
+ electronApp: ElectronApplication;
6
+ page: Page;
7
+ obsidian: ObsidianTestingConfig;
8
+ appHandle: JSHandle<App>;
9
+ }
@@ -0,0 +1 @@
1
+ export {};
package/lib/index.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { ObsidianTestFixtures } from "./fixtures.js";
2
+ export interface ObsidianTestingConfig {
3
+ vault?: string;
4
+ }
5
+ export declare function getExe(): string;
6
+ export declare const test: import("playwright/test").TestType<import("playwright/test").PlaywrightTestArgs & import("playwright/test").PlaywrightTestOptions & ObsidianTestFixtures, import("playwright/test").PlaywrightWorkerArgs & import("playwright/test").PlaywrightWorkerOptions>;
package/lib/index.js ADDED
@@ -0,0 +1,120 @@
1
+ import { test as base } from "@playwright/test";
2
+ import { _electron as electron } from "playwright";
3
+ import path from "path";
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
5
+ import { pageUtils, waitForIndexingComplete } from "./util.js";
6
+ import { randomBytes } from "crypto";
7
+ export function getExe() {
8
+ checkToy();
9
+ if (process.platform == "win32") {
10
+ return path.join(process.env.LOCALAPPDATA, "Obsidian", "Resources", "app.asar");
11
+ }
12
+ const possibleDirs = [
13
+ "/opt/Obsidian",
14
+ "/usr/lib/Obsidian",
15
+ "/opt/obsidian",
16
+ "/usr/lib/obsidian",
17
+ "/var/lib/flatpak/app/md.obsidian.Obsidian/current/active/files",
18
+ "/snap/obsidian/current",
19
+ ];
20
+ for (let i = 0; i < possibleDirs.length; i++) {
21
+ if (existsSync(possibleDirs[i])) {
22
+ // console.log(execSync(`ls -l ${possibleDirs[i]}`).toString());
23
+ return path.join(possibleDirs[i], "resources", "app.asar");
24
+ }
25
+ }
26
+ return "";
27
+ }
28
+ function checkToy() {
29
+ if (process.platform == "darwin") {
30
+ throw new Error("use a non-toy operating system, dumbass");
31
+ }
32
+ }
33
+ function generateVaultConfig(vault) {
34
+ const vaultHash = randomBytes(8).toString("hex").toLocaleLowerCase();
35
+ let configLocation;
36
+ console.log("vault is", vault, existsSync(vault));
37
+ checkToy();
38
+ if (process.platform == "win32") {
39
+ configLocation = path.join(`${process.env.APPDATA}`, "Obsidian");
40
+ }
41
+ else {
42
+ configLocation = path.join(`${process.env.XDG_CONFIG_HOME}`, "obsidian");
43
+ try {
44
+ mkdirSync(configLocation, { recursive: true });
45
+ }
46
+ catch (e) { }
47
+ }
48
+ const obsidianConfigFile = path.join(configLocation, "obsidian.json");
49
+ if (!existsSync(obsidianConfigFile)) {
50
+ writeFileSync(obsidianConfigFile, JSON.stringify({ vaults: {} }));
51
+ }
52
+ const json = JSON.parse(readFileSync(obsidianConfigFile).toString());
53
+ if (!Object.values(json.vaults).some((a) => a.path === vault)) {
54
+ json.vaults[vaultHash] = {
55
+ path: vault,
56
+ ts: Date.now(),
57
+ };
58
+ writeFileSync(obsidianConfigFile, JSON.stringify(json));
59
+ writeFileSync(path.join(configLocation, `${vaultHash}.json`), "{}");
60
+ return vaultHash;
61
+ }
62
+ else {
63
+ return Object.entries(json.vaults).find(a => a[1].path === vault)[0];
64
+ }
65
+ }
66
+ const obsidianTestFixtures = {
67
+ electronApp: [
68
+ async ({ obsidian: { vault } }, run) => {
69
+ process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = "true";
70
+ console.log("asar located at:", getExe());
71
+ let uriArg = "";
72
+ if (vault) {
73
+ let id = generateVaultConfig(vault);
74
+ if (!!id) {
75
+ uriArg = `obsidian://open?vault=${encodeURIComponent(id)}`;
76
+ }
77
+ }
78
+ const electronApp = await electron.launch({
79
+ timeout: 60000,
80
+ args: [getExe(), uriArg].filter((a) => !!a),
81
+ });
82
+ electronApp.on("console", async (msg) => {
83
+ console.log(...(await Promise.all(msg.args().map((a) => a.jsonValue()))));
84
+ });
85
+ await electronApp.waitForEvent("window");
86
+ await run(electronApp);
87
+ await electronApp.close();
88
+ },
89
+ { timeout: 60000 },
90
+ ],
91
+ page: [
92
+ async ({ electronApp }, run) => {
93
+ const windows = electronApp.windows();
94
+ // console.log("windows", windows);
95
+ let page = windows[windows.length - 1];
96
+ await page.waitForLoadState("domcontentloaded");
97
+ try {
98
+ await waitForIndexingComplete(page);
99
+ }
100
+ catch (e) {
101
+ console.warn("timed out waiting for metadata cache. continuing...");
102
+ }
103
+ for (let fn of Object.entries(pageUtils)) {
104
+ await page.exposeFunction(fn[0], fn[1]);
105
+ }
106
+ page.on("pageerror", exc => {
107
+ console.error("EXCEPTION");
108
+ console.error(exc);
109
+ });
110
+ page.on("console", async (msg) => {
111
+ console.log(...(await Promise.all(msg.args().map((a) => a.jsonValue()))));
112
+ });
113
+ await run(page);
114
+ },
115
+ { timeout: 60000 },
116
+ ],
117
+ obsidian: [{}, { option: true }],
118
+ };
119
+ // @ts-ignore some error about a string type now having `undefined` as part of it's union
120
+ export const test = base.extend(obsidianTestFixtures);
package/lib/util.d.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { App, TFile } from "obsidian";
2
+ import { Page } from "playwright";
3
+ /**
4
+ * asserts that the contents of the file at `path` is equal to `expectedContent`.
5
+ *
6
+ * @export
7
+ * @param {Page} page - a Playwright page
8
+ * @param {string} path - the file to check
9
+ * @param {string} expectedContent - the expected content
10
+ * @param {boolean} [cached=true] - whether to use `app.vault.cachedRead`
11
+ */
12
+ export declare function assertFileEquals(page: Page, path: string, expectedContent: string, cached?: boolean): Promise<void>;
13
+ /**
14
+ * asserts that the line at `lineNumber` in the file at `path` is equal
15
+ * to `expectedContent`.
16
+ *
17
+ * @export
18
+ * @param {Page} page - a Playwright page
19
+ * @param {string} path - the file to check
20
+ * @param {number} lineNumber - the line in the file to check (0-based)
21
+ * @param {string} expectedContent - the expected content
22
+ * @param {boolean} [cached=true] - whether to use `app.vault.cachedRead`
23
+ */
24
+ export declare function assertLineEquals(page: Page, path: string, lineNumber: number, expectedContent: string, cached?: boolean): Promise<void>;
25
+ /**
26
+ * asserts that the lines in the specified range are equal to the expected
27
+ * content.
28
+ *
29
+ * @export
30
+ * @param {Page} page - a Playwright page
31
+ * @param {string} path - the file to check
32
+ * @param {number} start - the start of the desired line range (0-based)
33
+ * @param {number} end - the end of the desired line range (1-based)
34
+ * @param {string} expected - the expected content
35
+ * @param {boolean} [cached=true] - whether to use `app.vault.cachedRead`
36
+ */
37
+ export declare function assertLinesEqual(page: Page, path: string, start: number, end: number, expected: string, cached?: boolean): Promise<void>;
38
+ /**
39
+ * asserts all lines in the given range match a regex.
40
+ *
41
+ * @export
42
+ * @param {Page} page - a Playwright page
43
+ * @param {string} path - the file to check
44
+ * @param {number} start - the start of the desired line range (0-based)
45
+ * @param {number} end - the end of the desired line range (1-based)
46
+ * @param {RegExp} regex - the regex to test against
47
+ * @param {boolean} [cached=true] - whether to use `app.vault.cachedRead`
48
+ */
49
+ export declare function assertLinesMatch(page: Page, path: string, start: number, end: number, regex: RegExp, cached?: boolean): Promise<void>;
50
+ /**
51
+ * reads the file at `path` and returns its contents
52
+ *
53
+ * @export
54
+ * @param {Page} page - a Playwright page
55
+ * @param {string} path the file to read
56
+ * @param {boolean} [cached=true] - whether to use `app.vault.cachedRead`
57
+ * @return {Promise<string>} the file's contents
58
+ */
59
+ export declare function readFile(page: Page, path: string, cached?: boolean): Promise<string>;
60
+ /**
61
+ * do something with the global `App` instance,
62
+ * and return the result of that invocation
63
+ *
64
+ * @export
65
+ * @typeParam - T the return type of the callback
66
+ * @typeParam - A the additional argument(s) to pass
67
+ * @param {Page} page - a Playwright page
68
+ * @param {((a: App, args?: A) => T | Promise<T>)} callback - the function to execute
69
+ * @param {A} [args] - optional arguments to pass to the callback
70
+ * @return {Promise<T>} a promise containing `callback`'s return value (if any)
71
+ */
72
+ export declare function doWithApp<T = unknown, A = any>(page: Page, callback: (a: App, args?: A) => T | Promise<T>, args?: A): Promise<T>;
73
+ /**
74
+ * @internal
75
+ */
76
+ export declare function waitForIndexingComplete(page: Page): Promise<unknown>;
77
+ /**
78
+ * @internal
79
+ */
80
+ export declare const pageUtils: {
81
+ getFile: (file: string) => TFile;
82
+ };
package/lib/util.js ADDED
@@ -0,0 +1,135 @@
1
+ import { expect } from "@playwright/test";
2
+ /**
3
+ * asserts that the contents of the file at `path` is equal to `expectedContent`.
4
+ *
5
+ * @export
6
+ * @param {Page} page - a Playwright page
7
+ * @param {string} path - the file to check
8
+ * @param {string} expectedContent - the expected content
9
+ * @param {boolean} [cached=true] - whether to use `app.vault.cachedRead`
10
+ */
11
+ export async function assertFileEquals(page, path, expectedContent, cached = true) {
12
+ const fileContent = await readFile(page, path, cached);
13
+ expect(fileContent).toEqual(normalizeEOL(expectedContent));
14
+ }
15
+ /**
16
+ * asserts that the line at `lineNumber` in the file at `path` is equal
17
+ * to `expectedContent`.
18
+ *
19
+ * @export
20
+ * @param {Page} page - a Playwright page
21
+ * @param {string} path - the file to check
22
+ * @param {number} lineNumber - the line in the file to check (0-based)
23
+ * @param {string} expectedContent - the expected content
24
+ * @param {boolean} [cached=true] - whether to use `app.vault.cachedRead`
25
+ */
26
+ export async function assertLineEquals(page, path, lineNumber, expectedContent, cached = true) {
27
+ const fileContent = await readFile(page, path, cached);
28
+ expect(fileContent.split("\n")[lineNumber]).toEqual(normalizeEOL(expectedContent));
29
+ }
30
+ /**
31
+ * asserts that the lines in the specified range are equal to the expected
32
+ * content.
33
+ *
34
+ * @export
35
+ * @param {Page} page - a Playwright page
36
+ * @param {string} path - the file to check
37
+ * @param {number} start - the start of the desired line range (0-based)
38
+ * @param {number} end - the end of the desired line range (1-based)
39
+ * @param {string} expected - the expected content
40
+ * @param {boolean} [cached=true] - whether to use `app.vault.cachedRead`
41
+ */
42
+ export async function assertLinesEqual(page, path, start, end, expected, cached = true) {
43
+ const fileContent = await readFile(page, path, cached);
44
+ const lines = fileContent.split("\n").slice(start, end);
45
+ const expectedLines = normalizeEOL(expected).split("\n");
46
+ expect(lines.every((l, i) => l == expectedLines[i])).toEqual(true);
47
+ }
48
+ const getFile = (file) => {
49
+ let f = window.app.vault.getFileByPath(file);
50
+ if (!f) {
51
+ throw new Error("File does not exist in vault.");
52
+ }
53
+ return f;
54
+ };
55
+ /**
56
+ * asserts all lines in the given range match a regex.
57
+ *
58
+ * @export
59
+ * @param {Page} page - a Playwright page
60
+ * @param {string} path - the file to check
61
+ * @param {number} start - the start of the desired line range (0-based)
62
+ * @param {number} end - the end of the desired line range (1-based)
63
+ * @param {RegExp} regex - the regex to test against
64
+ * @param {boolean} [cached=true] - whether to use `app.vault.cachedRead`
65
+ */
66
+ export async function assertLinesMatch(page, path, start, end, regex, cached = true) {
67
+ const fileContent = await readFile(page, path, cached);
68
+ const lines = fileContent.split("\n").slice(start, end);
69
+ expect(lines.every(l => regex.test(l))).toEqual(true);
70
+ }
71
+ /**
72
+ * reads the file at `path` and returns its contents
73
+ *
74
+ * @export
75
+ * @param {Page} page - a Playwright page
76
+ * @param {string} path the file to read
77
+ * @param {boolean} [cached=true] - whether to use `app.vault.cachedRead`
78
+ * @return {Promise<string>} the file's contents
79
+ */
80
+ export async function readFile(page, path, cached = true) {
81
+ const fn = getFile.toString();
82
+ return normalizeEOL(await doWithApp(page, async (app, args) => {
83
+ const gf = eval(`(${args.getFile})`);
84
+ const file = gf(args.path);
85
+ return await (args.cached ? app.vault.cachedRead(file) : app.vault.read(file));
86
+ }, { path, cached, getFile: fn }));
87
+ }
88
+ /**
89
+ * do something with the global `App` instance,
90
+ * and return the result of that invocation
91
+ *
92
+ * @export
93
+ * @typeParam - T the return type of the callback
94
+ * @typeParam - A the additional argument(s) to pass
95
+ * @param {Page} page - a Playwright page
96
+ * @param {((a: App, args?: A) => T | Promise<T>)} callback - the function to execute
97
+ * @param {A} [args] - optional arguments to pass to the callback
98
+ * @return {Promise<T>} a promise containing `callback`'s return value (if any)
99
+ */
100
+ export async function doWithApp(page, callback, args) {
101
+ const cbStr = callback.toString();
102
+ return await page.evaluate(async ({ __callback: cb, args }) => {
103
+ const func = new Function("args", `return ((${cb}))(window.app, args)`);
104
+ return await func(args);
105
+ }, { __callback: cbStr, args });
106
+ }
107
+ /**
108
+ * @internal
109
+ */
110
+ export function waitForIndexingComplete(page) {
111
+ return page.evaluateHandle("window.app").then((appHandle) => {
112
+ return appHandle.evaluate((app) => {
113
+ return new Promise((res2, rej2) => {
114
+ let resolved = false;
115
+ app.metadataCache.on("resolved", () => {
116
+ res2(null);
117
+ resolved = true;
118
+ });
119
+ setTimeout(() => !resolved && rej2("timeout"), 10000);
120
+ });
121
+ });
122
+ });
123
+ }
124
+ /**
125
+ * @internal
126
+ */
127
+ function normalizeEOL(str) {
128
+ return str.split(/\r\n|\r|\n/).join("\n");
129
+ }
130
+ /**
131
+ * @internal
132
+ */
133
+ export const pageUtils = {
134
+ getFile,
135
+ };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "obsidian-testing-framework",
3
+ "packageManager": "yarn@4.5.1",
4
+ "dependencies": {
5
+ "@codemirror/language": "https://github.com/lishid/cm-language",
6
+ "@codemirror/state": "^6.0.1",
7
+ "@codemirror/view": "^6.0.1",
8
+ "@playwright/test": "^1.48.1",
9
+ "asar": "^3.2.0",
10
+ "electron": "^33.0.2",
11
+ "obsidian": "latest",
12
+ "playwright": "^1.48.1",
13
+ "tmp": "^0.2.3",
14
+ "typescript": "^5.6.3",
15
+ "xvfb-maybe": "^0.2.1"
16
+ },
17
+ "files": [
18
+ "./lib",
19
+ "../../README.md"
20
+ ],
21
+ "readme": "",
22
+ "version": "0.0.4",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./lib/index.d.ts",
26
+ "default": "./lib/index.js"
27
+ },
28
+ "./utils": {
29
+ "types": "./lib/util.d.ts",
30
+ "default": "./lib/util.js"
31
+ },
32
+ "./fixture": {
33
+ "types": "./lib/fixtures.d.ts",
34
+ "default": "./lib/fixtures.js"
35
+ }
36
+ },
37
+ "main": "./lib/index.js",
38
+ "typings": "./lib/index.d.ts",
39
+ "scripts": {
40
+ "build": "tsc",
41
+ "lint": "tslint -c tslint.json src/**/*.ts",
42
+ "prepublishOnly": "rm README.md && cp ../../README.md . && rimraf lib && npm run build"
43
+ },
44
+ "devDependencies": {
45
+ "@types/tmp": "^0",
46
+ "rimraf": "^6.0.1",
47
+ "vitest": "^2.1.3"
48
+ },
49
+ "type": "module"
50
+ }