diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..69fc4ad --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1 @@ +.e2e_test_vault/ diff --git a/e2e/constants.ts b/e2e/constants.ts new file mode 100644 index 0000000..f9c85d8 --- /dev/null +++ b/e2e/constants.ts @@ -0,0 +1,2 @@ +export const TIMECODES_PLUGIN_ID = 'timecodes-plugin' +export const TEST_VAULT_DIR = '.e2e_test_vault' diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..29cf02f --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,21 @@ +{ + "name": "obsidian-timecodes-plugin-e2e-tests", + "type": "module", + "private": true, + "scripts": { + "pretest": "pnpm --dir .. run build", + "test": "wdio run wdio.conf.ts" + }, + "devDependencies": { + "@wdio/cli": "9.5.7", + "@wdio/globals": "9.5.7", + "@wdio/local-runner": "9.5.7", + "@wdio/mocha-framework": "9.5.0", + "@wdio/spec-reporter": "9.5.0", + "electron": "32.2.5", + "expect-webdriverio": "5.0.5", + "obsidian": "1.7.2", + "wdio-electron-service": "7.3.1", + "webdriverio": "9.5.7" + } +} diff --git a/e2e/specs/markdown-view_post-processor.e2e.ts b/e2e/specs/markdown-view_post-processor.e2e.ts new file mode 100644 index 0000000..950c588 --- /dev/null +++ b/e2e/specs/markdown-view_post-processor.e2e.ts @@ -0,0 +1,183 @@ +import ObsidianApp from './pageobjects/obsidian-app.page' + +describe('Markdown view post-processor', () => { + context('Link to video and timecode are within the same

aragraph in Markdown view', () => { + it('renders timecode with expected clickable link', async () => { + const noteContent = `https://youtu.be/k_ItB5btREU +22:53 - effectiveness characteristics of e2e tests` + await ObsidianApp.createNewNoteWithContent(noteContent) + + await ObsidianApp.toggleReadingView() + + const link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="22:53"]') + await expect(link).toBePresent() + const href = await link.getAttribute('href') + await expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373') + }) + }) + + context('Link to video and timecode are in different

aragraphs in Markdown view', () => { + it('renders timecode with expected clickable link', async () => { + const noteContent = `https://youtu.be/k_ItB5btREU + +22:53 - effectiveness characteristics of e2e tests` + await ObsidianApp.createNewNoteWithContent(noteContent) + + await ObsidianApp.toggleReadingView() + + const link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="22:53"]') + await expect(link).toBePresent() + const href = await link.getAttribute('href') + await expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373') + }) + }) + + context('Timecode is on the same line as video link', () => { + it('renders timecode with expected clickable link', async () => { + const noteContent = `https://youtu.be/k_ItB5btREU 22:53 - effectiveness characteristics of e2e tests` + await ObsidianApp.createNewNoteWithContent(noteContent) + + await ObsidianApp.toggleReadingView() + + const link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="22:53"]') + await expect(link).toBePresent() + const href = await link.getAttribute('href') + await expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373') + }) + }) + + context('There is a markdown link to YouTube video instead of a plain text link', () => { + it('renders timecode with expected clickable link', async () => { + const noteContent = `[Here](https://youtu.be/k_ItB5btREU) is a great talk about software testing + +22:53 - effectiveness characteristics of e2e tests` + await ObsidianApp.createNewNoteWithContent(noteContent) + + await ObsidianApp.toggleReadingView() + + const link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="22:53"]') + await expect(link).toBePresent() + const href = await link.getAttribute('href') + await expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373') + }) + }) + + context('The video is embedded with + +22:53 - effectiveness characteristics of e2e tests` + await ObsidianApp.createNewNoteWithContent(noteContent) + + await ObsidianApp.toggleReadingView() + + const link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="22:53"]') + await expect(link).toBePresent() + const href = await link.getAttribute('href') + await expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373') + }) + }) + + context('There are multiple videos', () => { + it('renders timecodes with expected clickable link', async () => { + const noteContent = `https://www.youtube.com/watch?v=Z5n9VK3sOnI Understanding Gradle classpaths + +14:27 a use case for 'api' dependency configuration + +https://youtu.be/Lipf5piizZc Another video about Gradle + +4:07 - using dependency analysis plugin + +https://www.youtube.com/watch?v=k_ItB5btREU + +22:53 - effectiveness characteristics of e2e tests` + await ObsidianApp.createNewNoteWithContent(noteContent) + + await ObsidianApp.toggleReadingView() + + let link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="14:27"]') + await expect(link).toBePresent() + let href = await link.getAttribute('href') + await expect(href).toBe('https://youtu.be/Z5n9VK3sOnI?t=867') + + link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="4:07"]') + await expect(link).toBePresent() + href = await link.getAttribute('href') + await expect(href).toBe('https://youtu.be/Lipf5piizZc?t=247') + + link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="22:53"]') + await expect(link).toBePresent() + href = await link.getAttribute('href') + await expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373') + }) + }) + + context('There are multiple videos with mixed links types', () => { + it('renders timecodes with expected clickable link', async () => { + const noteContent = `https://www.youtube.com/watch?v=Z5n9VK3sOnI Understanding Gradle classpaths + +14:27 a use case for 'api' dependency configuration + +[Another video](https://youtu.be/Lipf5piizZc) about Gradle + +4:07 - using dependency analysis plugin + + + +22:53 - effectiveness characteristics of e2e tests` + await ObsidianApp.createNewNoteWithContent(noteContent) + + await ObsidianApp.toggleReadingView() + + let link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="14:27"]') + await expect(link).toBePresent() + let href = await link.getAttribute('href') + await expect(href).toBe('https://youtu.be/Z5n9VK3sOnI?t=867') + + link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="4:07"]') + await expect(link).toBePresent() + href = await link.getAttribute('href') + await expect(href).toBe('https://youtu.be/Lipf5piizZc?t=247') + + link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="22:53"]') + await expect(link).toBePresent() + href = await link.getAttribute('href') + await expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373') + }) + }) + + context('There are multiple videos', () => { + it('preserves the content of a note AS-IS', async () => { + const noteContent = `https://www.youtube.com/watch?v=Z5n9VK3sOnI Understanding Gradle classpaths + +14:27 a use case for 'api' dependency configuration; https://youtu.be/Lipf5piizZc 4:07 - using dependency analysis plugin + +https://www.youtube.com/watch?v=k_ItB5btREU + +22:53 - effectiveness characteristics of e2e tests` + await ObsidianApp.createNewNoteWithContent(noteContent) + + await ObsidianApp.toggleReadingView() + + /** + * It's important to wait a bit here for note's content to get rendered in reading mode + */ + // eslint-disable-next-line wdio/no-pause + await browser.pause(100) + + // eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-base-to-string + const textFromAllNoteParagraphs = await $$('.markdown-preview-section p:not(.mod-ui)') + .map(p => p.getText()) + .join('\n\n') + await expect(textFromAllNoteParagraphs).toBe(noteContent) + }) + }) +}) diff --git a/e2e/specs/pageobjects/obsidian-app.page.ts b/e2e/specs/pageobjects/obsidian-app.page.ts new file mode 100644 index 0000000..148e16a --- /dev/null +++ b/e2e/specs/pageobjects/obsidian-app.page.ts @@ -0,0 +1,84 @@ +import * as fs from 'node:fs/promises' + +import { App } from 'obsidian' + +import { TEST_VAULT_DIR, TIMECODES_PLUGIN_ID } from '../../constants' + +class ObsidianApp { + async removeE2eTestVaultIfExists() { + await fs.rm(TEST_VAULT_DIR, { force: true, recursive: true }) + } + + async createAndOpenFreshVault() { + await browser.execute((testVaultDir: string) => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { ipcRenderer } = require('electron') + const shouldCreateNewVault = true + ipcRenderer.sendSync('vault-open', testVaultDir, shouldCreateNewVault) + }, TEST_VAULT_DIR) + + const targetPluginsDir = `${TEST_VAULT_DIR}/.obsidian/plugins/${TIMECODES_PLUGIN_ID}/` + await fs.mkdir(targetPluginsDir, { recursive: true }) + await fs.copyFile('../manifest.json', `${targetPluginsDir}/manifest.json`) + await fs.copyFile('../out/main.js', `${targetPluginsDir}/main.js`) + + await this.switchToMainWindow() + await $('button=Trust author and enable plugins').click() + await this.closeModal('Trust vault modal') + } + + private async switchToMainWindow() { + await browser.switchWindow('app://obsidian.md/index.html') + } + + async activateTimecodesPlugin() { + await this.activatePlugin(TIMECODES_PLUGIN_ID) + } + + private async activatePlugin(pluginId: string) { + await browser.execute((imgurPluginId: string) => { + // @ts-expect-error 'app' exists in Obsidian + declare const app: App + app.plugins.setEnable(true) + app.plugins.enablePlugin(imgurPluginId) + }, pluginId) + } + + async closeModal(modalName: string) { + console.log(`Closing '${modalName}'`) + await $('.modal-close-button').click() + } + + async createNewNoteWithContent(content: string) { + await this.doCreateNewNote(content) + } + + async createNewNote() { + await this.doCreateNewNote() + } + + private async doCreateNewNote(content?: string) { + const newNoteButton = $('aria/New note') + await newNoteButton.click() + + const noteContent = $('.workspace-leaf.mod-active .cm-contentContainer') + await noteContent.click() + if (content) { + await browser.execute((content: string) => { + // @ts-expect-error 'app' exists in Obsidian + declare const app: App + app.workspace.activeEditor!.editor!.setValue(content) + }, content) + } + } + + async toggleReadingView() { + await browser.execute(() => { + // @ts-expect-error 'app' exists in Obsidian + declare const app: App + app.commands.executeCommandById('markdown:toggle-preview') + }) + } +} + +export default new ObsidianApp() diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..5c45722 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "module": "ESNext", + "target": "es2022", + "lib": [ + "es2022", + "dom" + ], + "types": [ + "node", + "@wdio/globals/types", + "expect-webdriverio", + "@wdio/mocha-framework" + ], + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": [ + "**/*.ts", + ], +} diff --git a/e2e/types/obsidian.d.ts b/e2e/types/obsidian.d.ts new file mode 100644 index 0000000..98d36e9 --- /dev/null +++ b/e2e/types/obsidian.d.ts @@ -0,0 +1,14 @@ +import Plugin from 'obsidian' + +declare module 'obsidian' { + interface App { + plugins: { + plugins: Record + setEnable(toggle: boolean): void + enablePlugin(pluginId: string): void + } + commands: { + executeCommandById: (id: string) => boolean + } + } +} diff --git a/e2e/wdio.conf.ts b/e2e/wdio.conf.ts new file mode 100644 index 0000000..e826b60 --- /dev/null +++ b/e2e/wdio.conf.ts @@ -0,0 +1,41 @@ +/// +import ObsidianApp from './specs/pageobjects/obsidian-app.page' + +const debug = process.env.DEBUG +const ONE_DAY = 24 * 60 * 60 * 1000 + +export const config: WebdriverIO.Config = { + runner: 'local', + specs: ['./specs/*.e2e.ts'], + exclude: [], + maxInstances: 10, + capabilities: [ + { + browserName: 'electron', + browserVersion: '32.2.5', + 'wdio:electronServiceOptions': { + // custom application args + appBinaryPath: '/Applications/Obsidian.app/Contents/MacOS/Obsidian', + appArgs: [], + }, + }, + ], + logLevel: 'info', + bail: 0, + waitforTimeout: 10000, + connectionRetryTimeout: 120000, + connectionRetryCount: 3, + services: ['electron'], + framework: 'mocha', + + reporters: ['spec'], + mochaOpts: { + ui: 'bdd', + timeout: debug ? ONE_DAY : 60000, + }, + beforeSuite: async () => { + await ObsidianApp.removeE2eTestVaultIfExists() + await ObsidianApp.createAndOpenFreshVault() + await ObsidianApp.activateTimecodesPlugin() + }, +} diff --git a/eslint.config.js b/eslint.config.js index c036973..724554f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -89,6 +89,9 @@ export default tseslint.config( }, }, { - ignores: ['out/*'], + ignores: [ + 'out/*', + 'e2e/.e2e_test_vault/*', + ], }, ) diff --git a/tsconfig.json b/tsconfig.json index 2c70fb5..79b2771 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,4 +19,5 @@ "include": [ "**/*.ts" ], + "exclude": ["e2e/"], }