Skip to content

Commit 06062f5

Browse files
trangdoan982claude
andcommitted
Refactor e2e tests: extract helpers, add step screenshots, split specs
Extract inline Obsidian interaction logic into reusable helpers (commands, vault, modal, screenshots) and split smoke.spec.ts into focused spec files for plugin-load and node-creation. Add JSON reporter to playwright config. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1cb7a28 commit 06062f5

9 files changed

Lines changed: 349 additions & 90 deletions

File tree

apps/obsidian/.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
e2e/test-vault/
1+
e2e/test-vault*/
22
e2e/test-results/
33
e2e/html-report/
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { Page } from "@playwright/test";
2+
3+
/**
4+
* Execute a command via Obsidian's internal command API (fast, reliable).
5+
* Use this for most test scenarios.
6+
*/
7+
export const executeCommand = async (
8+
page: Page,
9+
commandId: string,
10+
): Promise<void> => {
11+
await page.evaluate((id) => {
12+
// @ts-expect-error - Obsidian's global `app` is available at runtime
13+
app.commands.executeCommandById(`@discourse-graph/obsidian:${id}`);
14+
}, commandId);
15+
await page.waitForTimeout(500);
16+
};
17+
18+
/**
19+
* Execute a command via the command palette UI.
20+
* Use this when testing the palette interaction itself.
21+
*/
22+
export const executeCommandViaPalette = async (
23+
page: Page,
24+
commandLabel: string,
25+
): Promise<void> => {
26+
await page.keyboard.press("Meta+p");
27+
await page.waitForTimeout(500);
28+
29+
await page.keyboard.type(commandLabel, { delay: 30 });
30+
await page.waitForTimeout(500);
31+
32+
await page.keyboard.press("Enter");
33+
await page.waitForTimeout(1_000);
34+
};
35+
36+
/**
37+
* Ensure an active editor exists by creating and opening a scratch file.
38+
* Required before running editorCallback commands like "Create discourse node".
39+
*/
40+
export const ensureActiveEditor = async (page: Page): Promise<void> => {
41+
await page.evaluate(async () => {
42+
// @ts-expect-error - Obsidian's global `app` is available at runtime
43+
const vault = app.vault;
44+
const fileName = `scratch-e2e-${Date.now()}.md`;
45+
const file = await vault.create(fileName, "");
46+
// @ts-expect-error - Obsidian's global `app` is available at runtime
47+
await app.workspace.openLinkText(file.path, "", false);
48+
});
49+
await page.waitForTimeout(1_000);
50+
};

apps/obsidian/e2e/helpers/modal.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { Page } from "@playwright/test";
2+
3+
/**
4+
* Wait for the ModifyNodeModal to appear.
5+
*/
6+
export const waitForModal = async (page: Page): Promise<void> => {
7+
await page.waitForSelector(".modal-container", { timeout: 5_000 });
8+
};
9+
10+
/**
11+
* Select a node type from the dropdown in the modal.
12+
*/
13+
export const selectNodeType = async (
14+
page: Page,
15+
label: string,
16+
): Promise<void> => {
17+
const nodeTypeSelect = page.locator(".modal-container select").first();
18+
await nodeTypeSelect.selectOption({ label });
19+
await page.waitForTimeout(300);
20+
};
21+
22+
/**
23+
* Fill the content/title input field in the modal.
24+
*/
25+
export const fillNodeContent = async (
26+
page: Page,
27+
content: string,
28+
): Promise<void> => {
29+
const contentInput = page
30+
.locator(".modal-container input[type='text']")
31+
.first();
32+
await contentInput.click();
33+
await contentInput.fill(content);
34+
await page.waitForTimeout(300);
35+
};
36+
37+
/**
38+
* Click the Confirm button (mod-cta) in the modal.
39+
*/
40+
export const confirmModal = async (page: Page): Promise<void> => {
41+
// Use force: true to bypass any suggestion/autocomplete overlay that may cover the button
42+
await page.locator(".modal-container button.mod-cta").click({ force: true });
43+
await page.waitForTimeout(2_000);
44+
};
45+
46+
/**
47+
* Click the Cancel button in the modal.
48+
*/
49+
export const cancelModal = async (page: Page): Promise<void> => {
50+
await page.locator(".modal-container button:not(.mod-cta)").click();
51+
await page.waitForTimeout(500);
52+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import path from "path";
2+
import fs from "fs";
3+
import type { Page } from "@playwright/test";
4+
5+
const TEST_RESULTS_DIR = path.join(__dirname, "..", "test-results");
6+
7+
/**
8+
* Capture a step-based screenshot organized by test name.
9+
* Saves to: test-results/<testName>/<stepName>.png
10+
*/
11+
export const captureStep = async (
12+
page: Page,
13+
testName: string,
14+
stepName: string,
15+
): Promise<string> => {
16+
const dir = path.join(TEST_RESULTS_DIR, testName);
17+
fs.mkdirSync(dir, { recursive: true });
18+
19+
const filePath = path.join(dir, `${stepName}.png`);
20+
await page.screenshot({ path: filePath });
21+
return filePath;
22+
};

apps/obsidian/e2e/helpers/vault.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { Page } from "@playwright/test";
2+
3+
/**
4+
* Find markdown files whose basename starts with the given prefix.
5+
* Returns an array of basenames.
6+
*/
7+
export const findFilesByPrefix = async (
8+
page: Page,
9+
prefix: string,
10+
): Promise<string[]> => {
11+
return page.evaluate((pfx) => {
12+
// @ts-expect-error - Obsidian's global `app` is available at runtime
13+
const files = app?.vault?.getMarkdownFiles() || [];
14+
return files
15+
.filter((f: { basename: string }) => f.basename.startsWith(pfx))
16+
.map((f: { basename: string }) => f.basename);
17+
}, prefix);
18+
};
19+
20+
/**
21+
* Read the text content of a markdown file by its basename.
22+
* Returns null if the file is not found.
23+
*/
24+
export const readFileContent = async (
25+
page: Page,
26+
basename: string,
27+
): Promise<string | null> => {
28+
return page.evaluate(async (name) => {
29+
// @ts-expect-error - Obsidian's global `app` is available at runtime
30+
const files = app?.vault?.getMarkdownFiles() || [];
31+
const file = files.find((f: { basename: string }) => f.basename === name);
32+
if (!file) return null;
33+
// @ts-expect-error - Obsidian's global `app` is available at runtime
34+
return app.vault.read(file);
35+
}, basename);
36+
};
37+
38+
/**
39+
* Check whether a plugin is loaded in Obsidian's plugin registry.
40+
*/
41+
export const isPluginLoaded = async (
42+
page: Page,
43+
pluginId: string,
44+
): Promise<boolean> => {
45+
return page.evaluate((id) => {
46+
// @ts-expect-error - Obsidian's global `app` is available at runtime
47+
const plugins = app?.plugins?.plugins;
48+
return plugins ? id in plugins : false;
49+
}, pluginId);
50+
};
51+
52+
/**
53+
* Get all markdown file basenames in the vault.
54+
*/
55+
export const getMarkdownFiles = async (page: Page): Promise<string[]> => {
56+
return page.evaluate(() => {
57+
// @ts-expect-error - Obsidian's global `app` is available at runtime
58+
const files = app?.vault?.getMarkdownFiles() || [];
59+
return files.map((f: { basename: string }) => f.basename);
60+
});
61+
};

apps/obsidian/e2e/playwright.config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ export default defineConfig({
88
},
99
retries: 0,
1010
workers: 1,
11-
reporter: [["list"], ["html", { outputFolder: "html-report" }]],
11+
reporter: [
12+
["list"],
13+
["html", { outputFolder: "html-report" }],
14+
["json", { outputFile: "test-results/report.json" }],
15+
],
1216
use: {
1317
screenshot: "only-on-failure",
1418
trace: "retain-on-failure",
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { test, expect, type Browser, type Page } from "@playwright/test";
2+
import path from "path";
3+
import type { ChildProcess } from "child_process";
4+
import {
5+
createTestVault,
6+
cleanTestVault,
7+
launchObsidian,
8+
} from "../helpers/obsidian-setup";
9+
import {
10+
ensureActiveEditor,
11+
executeCommandViaPalette,
12+
} from "../helpers/commands";
13+
import { findFilesByPrefix, readFileContent } from "../helpers/vault";
14+
import {
15+
waitForModal,
16+
selectNodeType,
17+
fillNodeContent,
18+
confirmModal,
19+
} from "../helpers/modal";
20+
import { captureStep } from "../helpers/screenshots";
21+
22+
const VAULT_PATH = path.join(__dirname, "..", "test-vault-node-creation");
23+
24+
let browser: Browser;
25+
let page: Page;
26+
let obsidianProcess: ChildProcess;
27+
28+
test.beforeAll(async () => {
29+
createTestVault(VAULT_PATH);
30+
const launched = await launchObsidian(VAULT_PATH);
31+
browser = launched.browser;
32+
page = launched.page;
33+
obsidianProcess = launched.obsidianProcess;
34+
35+
// Wait for plugins to initialize
36+
await page.waitForTimeout(5_000);
37+
});
38+
39+
test.afterAll(async () => {
40+
if (browser) {
41+
await browser.close();
42+
}
43+
if (obsidianProcess) {
44+
obsidianProcess.kill();
45+
}
46+
cleanTestVault(VAULT_PATH);
47+
});
48+
49+
test("Create a Question node via command palette", async () => {
50+
await ensureActiveEditor(page);
51+
52+
await executeCommandViaPalette(
53+
page,
54+
"Discourse Graph: Create discourse node",
55+
);
56+
57+
await waitForModal(page);
58+
await captureStep(page, "node-creation", "01-modal-open");
59+
60+
await selectNodeType(page, "Question");
61+
await fillNodeContent(page, `What is discourse graph testing ${Date.now()}`);
62+
await captureStep(page, "node-creation", "02-modal-filled");
63+
64+
await confirmModal(page);
65+
await captureStep(page, "node-creation", "03-node-created");
66+
67+
// Verify file was created with correct prefix
68+
const files = await findFilesByPrefix(page, "QUE -");
69+
expect(files.length).toBeGreaterThan(0);
70+
71+
// Verify frontmatter contains nodeTypeId
72+
const content = await readFileContent(page, files[0]!);
73+
if (content) {
74+
expect(content).toContain("nodeTypeId");
75+
}
76+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { test, expect, type Browser, type Page } from "@playwright/test";
2+
import path from "path";
3+
import type { ChildProcess } from "child_process";
4+
import {
5+
createTestVault,
6+
cleanTestVault,
7+
launchObsidian,
8+
} from "../helpers/obsidian-setup";
9+
import { isPluginLoaded } from "../helpers/vault";
10+
import { captureStep } from "../helpers/screenshots";
11+
12+
const VAULT_PATH = path.join(__dirname, "..", "test-vault-plugin-load");
13+
const PLUGIN_ID = "@discourse-graph/obsidian";
14+
15+
let browser: Browser;
16+
let page: Page;
17+
let obsidianProcess: ChildProcess;
18+
19+
test.beforeAll(async () => {
20+
createTestVault(VAULT_PATH);
21+
const launched = await launchObsidian(VAULT_PATH);
22+
browser = launched.browser;
23+
page = launched.page;
24+
obsidianProcess = launched.obsidianProcess;
25+
});
26+
27+
test.afterAll(async () => {
28+
if (browser) {
29+
await browser.close();
30+
}
31+
if (obsidianProcess) {
32+
obsidianProcess.kill();
33+
}
34+
cleanTestVault(VAULT_PATH);
35+
});
36+
37+
test("Plugin loads in Obsidian", async () => {
38+
await page.waitForTimeout(5_000);
39+
40+
const pluginLoaded = await isPluginLoaded(page, PLUGIN_ID);
41+
42+
await captureStep(page, "plugin-load", "01-plugin-loaded");
43+
44+
expect(pluginLoaded).toBe(true);
45+
});

0 commit comments

Comments
 (0)