diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..bd9322d --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,36 @@ +name: E2E + +on: + push: + branches: [main] + pull_request: + +env: + CI: 1 + +defaults: + run: + shell: bash + +jobs: + e2e: + name: End to end tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build plugin + run: npm run build + + # Obsidian is an Electron app, so it needs a display to run against. + - name: Run e2e tests + run: xvfb-run --auto-servernum npm run e2e diff --git a/test/specs/edit-round-trip.e2e.ts b/test/specs/edit-round-trip.e2e.ts new file mode 100644 index 0000000..46a2fb4 --- /dev/null +++ b/test/specs/edit-round-trip.e2e.ts @@ -0,0 +1,83 @@ +import { browser, expect } from '@wdio/globals' + +/** + * Creating and persisting is the part of the plugin that opening a file doesn't touch. This draws a + * shape, waits for the debounced write, and reopens the drawing from scratch, so a break anywhere + * between the editor and the vault shows up. + * + * It compares file contents rather than parsing them, so it holds whether the drawing is stored as + * markdown or as `.tldr`. + */ +describe('Editing a drawing', () => { + before(async () => { + await browser.reloadObsidian({ + plugins: ['tldraw'], + }) + }) + + it('writes an edit to disk and reads it back', async () => { + const result = await browser.executeObsidian(async ({ app }) => { + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + const plugin = (app as unknown as { plugins: { plugins: Record } }).plugins + .plugins.tldraw + + // Otherwise creating a drawing opens the destination picker and blocks the test. + plugin.settings.fileDestinations.confirmDestination = false + + const waitForEditor = async () => { + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + if (plugin.currTldrawEditor) return plugin.currTldrawEditor + await sleep(100) + } + throw new Error('The tldraw editor never mounted.') + } + + const file = await plugin.createUntitledTldrFile({}) + const leaf = await plugin.openTldrFile(file, 'new-tab') + + const editor = await waitForEditor() + const shapesBefore = editor.getCurrentPageShapes().length + const contentsBefore = await app.vault.read(file) + + editor.createShape({ + type: 'geo', + x: 100, + y: 100, + props: { geo: 'rectangle', w: 120, h: 80 }, + }) + const shapesAfterEdit = editor.getCurrentPageShapes().length + + // The plugin debounces writes (saveFileDelay, 0.5s by default), so poll the file rather + // than assuming a fixed delay. + const saveDeadline = Date.now() + 15_000 + let contentsAfter = contentsBefore + while (Date.now() < saveDeadline) { + contentsAfter = await app.vault.read(file) + if (contentsAfter !== contentsBefore) break + await sleep(200) + } + + // Reopen from scratch so the count comes back through a fresh load, not the live store. + leaf.detach() + plugin.currTldrawEditor = undefined + await sleep(500) + await plugin.openTldrFile(file, 'new-tab') + const reloaded = await waitForEditor() + const shapesAfterReopen = reloaded.getCurrentPageShapes().length + + await app.vault.delete(file) + + return { + shapesBefore, + shapesAfterEdit, + shapesAfterReopen, + wroteToDisk: contentsAfter !== contentsBefore, + } + }) + + expect(result.shapesAfterEdit).toBe(result.shapesBefore + 1) + expect(result.wroteToDisk).toBe(true) + expect(result.shapesAfterReopen).toBe(result.shapesAfterEdit) + }) +}) diff --git a/test/specs/markdown-embed.e2e.ts b/test/specs/markdown-embed.e2e.ts new file mode 100644 index 0000000..253a97e --- /dev/null +++ b/test/specs/markdown-embed.e2e.ts @@ -0,0 +1,75 @@ +import { browser, expect } from '@wdio/globals' + +/** + * Embedding a drawing in a note goes through a different path from opening one: the embed registry + * and a `TldrawImage` rather than a full editor. It's also the path the `TldrawImage` patch in + * `patches/` exists for, so it's worth holding still. + */ +describe('Embedding a drawing in a note', () => { + before(async () => { + await browser.reloadObsidian({ + plugins: ['tldraw'], + }) + }) + + it('renders the drawing in reading view', async () => { + const result = await browser.executeObsidian(async ({ app, obsidian }) => { + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + const plugin = (app as unknown as { plugins: { plugins: Record } }).plugins + .plugins.tldraw + + // Otherwise creating a drawing opens the destination picker and blocks the test. + plugin.settings.fileDestinations.confirmDestination = false + + // Embeds are registered for `.tldr`, so this one isn't stored in markdown. + const drawing = await plugin.createUntitledTldrFile({ inMarkdown: false }) + + // Give it a shape, so the embed has something to draw. + const editorLeaf = await plugin.openTldrFile(drawing, 'new-tab') + const editorDeadline = Date.now() + 15_000 + while (Date.now() < editorDeadline && !plugin.currTldrawEditor) await sleep(100) + if (!plugin.currTldrawEditor) throw new Error('The tldraw editor never mounted.') + plugin.currTldrawEditor.createShape({ + type: 'geo', + x: 100, + y: 100, + props: { geo: 'rectangle', w: 120, h: 80 }, + }) + await sleep(2000) + editorLeaf.detach() + plugin.currTldrawEditor = undefined + + const hostPath = 'embed-host.md' + const existing = app.vault.getAbstractFileByPath(hostPath) + if (existing instanceof obsidian.TFile) await app.vault.delete(existing) + const host = await app.vault.create(hostPath, `![[${drawing.name}]]`) + + const leaf = app.workspace.getLeaf('tab') + await leaf.setViewState({ + type: 'markdown', + state: { file: host.path, mode: 'preview' }, + }) + + const deadline = Date.now() + 20_000 + let embed: HTMLElement | null = null + let image: HTMLElement | null = null + while (Date.now() < deadline) { + embed = leaf.view.containerEl.querySelector('.ptl-markdown-embed') + // The embed renders lazily, so make sure it's actually on screen. + embed?.scrollIntoView() + image = leaf.view.containerEl.querySelector('.ptl-tldraw-image img[src]') + if (embed && image) break + await sleep(200) + } + + const result = { embedded: !!embed, rendered: !!image } + + leaf.detach() + await app.vault.delete(host) + await app.vault.delete(drawing) + return result + }) + + expect(result).toEqual({ embedded: true, rendered: true }) + }) +}) diff --git a/test/specs/theme-background.e2e.ts b/test/specs/theme-background.e2e.ts new file mode 100644 index 0000000..5e214a5 --- /dev/null +++ b/test/specs/theme-background.e2e.ts @@ -0,0 +1,131 @@ +import { browser, expect } from '@wdio/globals' + +/** + * The canvas background is meant to match Obsidian's own, so a drawing doesn't sit in a differently + * coloured rectangle. + * + * These specs create their own drawing rather than opening a committed fixture, so they don't + * depend on the schema version of any file on disk and stay valid across tldraw upgrades. + */ +describe('Canvas background', () => { + before(async () => { + await browser.reloadObsidian({ + plugins: ['tldraw'], + }) + }) + + it('matches the Obsidian theme background', async () => { + const result = await browser.executeObsidian(async ({ app }) => { + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + const plugin = (app as unknown as { plugins: { plugins: Record } }).plugins + .plugins.tldraw + + // Otherwise creating a drawing opens the destination picker and blocks the test. + plugin.settings.fileDestinations.confirmDestination = false + + const file = await plugin.createUntitledTldrFile({}) + const leaf = await plugin.openTldrFile(file, 'new-tab') + + const deadline = Date.now() + 15_000 + let background: HTMLElement | null = null + while (Date.now() < deadline) { + background = leaf.view.containerEl.querySelector('.tl-background') + if (background) break + await sleep(100) + } + if (!background) throw new Error('The tldraw canvas never rendered.') + + // Obsidian's variable and tldraw's computed colour are written in different notations, so + // both are resolved through the browser before comparing. + const resolve = (color: string) => { + const probe = document.createElement('div') + probe.style.color = color + document.body.appendChild(probe) + const resolved = getComputedStyle(probe).color + probe.remove() + return resolved + } + + const result = { + canvas: resolve(getComputedStyle(background).backgroundColor), + obsidian: resolve( + getComputedStyle(document.body).getPropertyValue('--background-primary').trim() + ), + } + + leaf.detach() + await app.vault.delete(file) + return result + }) + + expect(result.canvas).toBe(result.obsidian) + }) + + it('follows the Obsidian theme when it changes', async () => { + const result = await browser.executeObsidian(async ({ app }) => { + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + const plugin = (app as unknown as { plugins: { plugins: Record } }).plugins + .plugins.tldraw + plugin.settings.fileDestinations.confirmDestination = false + + const file = await plugin.createUntitledTldrFile({}) + const leaf = await plugin.openTldrFile(file, 'new-tab') + + const deadline = Date.now() + 15_000 + let background: HTMLElement | null = null + while (Date.now() < deadline) { + background = leaf.view.containerEl.querySelector('.tl-background') + if (background) break + await sleep(100) + } + if (!background) throw new Error('The tldraw canvas never rendered.') + + const resolve = (color: string) => { + const probe = document.createElement('div') + probe.style.color = color + document.body.appendChild(probe) + const resolved = getComputedStyle(probe).color + probe.remove() + return resolved + } + const read = () => ({ + canvas: resolve(getComputedStyle(background!).backgroundColor), + obsidian: resolve( + getComputedStyle(document.body).getPropertyValue('--background-primary').trim() + ), + }) + + const before = read() + + // `changeTheme` isn't in Obsidian's public typings, so fall back to writing the config and + // firing the event the plugin actually listens for. + const internals = app as unknown as { + changeTheme?(theme: string): void + vault: { getConfig?(key: string): unknown; setConfig?(key: string, value: unknown): void } + } + const next = internals.vault.getConfig?.('theme') === 'obsidian' ? 'moonstone' : 'obsidian' + if (typeof internals.changeTheme === 'function') { + internals.changeTheme(next) + } else { + internals.vault.setConfig?.('theme', next) + app.workspace.trigger('css-change') + } + + const settle = Date.now() + 10_000 + while (Date.now() < settle) { + const now = read() + if (now.obsidian !== before.obsidian && now.canvas === now.obsidian) break + await sleep(100) + } + + const after = read() + leaf.detach() + await app.vault.delete(file) + return { before, after } + }) + + // Guards against the assertion below passing trivially because the theme never actually moved. + expect(result.after.obsidian).not.toBe(result.before.obsidian) + expect(result.after.canvas).toBe(result.after.obsidian) + }) +}) diff --git a/test/specs/ui-renders.e2e.ts b/test/specs/ui-renders.e2e.ts new file mode 100644 index 0000000..eb56b06 --- /dev/null +++ b/test/specs/ui-renders.e2e.ts @@ -0,0 +1,46 @@ +import { $, browser, expect } from '@wdio/globals' + +/** + * The plugin replaces a good deal of tldraw's UI — its own main menu, zoom menu, quick actions and + * keyboard shortcuts dialog — and restyles tldraw internals by class name. An SDK upgrade can + * rename or restructure any of that without breaking a single type, so this checks the pieces are + * actually on screen. + */ +describe('Plugin UI', () => { + before(async () => { + await browser.reloadObsidian({ + plugins: ['tldraw'], + }) + + await browser.executeObsidian(async ({ app }) => { + const plugin = (app as unknown as { plugins: { plugins: Record } }).plugins + .plugins.tldraw + // Otherwise creating a drawing opens the destination picker and blocks the test. + plugin.settings.fileDestinations.confirmDestination = false + const file = await plugin.createUntitledTldrFile({}) + await plugin.openTldrFile(file, 'new-tab') + }) + }) + + it('renders the canvas chrome', async () => { + await expect($('.tldraw-view-root')).toBeExisting() + await expect($('.tlui-toolbar')).toBeExisting() + await expect($('.tlui-style-panel')).toBeExisting() + }) + + it('renders the plugin main menu', async () => { + // A real click rather than a synthetic one: the menu opens on pointer events, which an + // in-page element.click() doesn't produce. + await $('.tlui-menu-zone button').click() + + const menu = $('.tlui-menu') + await menu.waitForExist({ timeout: 5000 }) + + // The plugin swaps in its own main menu with these submenus. They're what disappears if its + // component overrides stop applying. + const text = (await menu.getText()).toLowerCase() + expect(text).toContain('file') + expect(text).toContain('edit') + expect(text).toContain('view') + }) +}) diff --git a/test/specs/view-mode.e2e.ts b/test/specs/view-mode.e2e.ts new file mode 100644 index 0000000..60aac8f --- /dev/null +++ b/test/specs/view-mode.e2e.ts @@ -0,0 +1,72 @@ +import { browser, expect } from '@wdio/globals' + +/** + * A drawing stored in markdown can be shown either as a canvas or as the note it really is, and the + * plugin swaps the leaf's view between the two. That swap is the plugin's own machinery rather than + * anything tldraw provides, so it's worth covering directly. + */ +describe('View mode', () => { + before(async () => { + await browser.reloadObsidian({ + plugins: ['tldraw'], + }) + }) + + it('switches a drawing between the canvas and its markdown', async () => { + const result = await browser.executeObsidian(async ({ app }) => { + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + const plugin = (app as unknown as { plugins: { plugins: Record } }).plugins + .plugins.tldraw + + // Otherwise creating a drawing opens the destination picker and blocks the test. + plugin.settings.fileDestinations.confirmDestination = false + + const file = await plugin.createUntitledTldrFile({}) + const leaf = await plugin.openTldrFile(file, 'new-tab') + + const waitFor = async (predicate: () => boolean) => { + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + if (predicate()) return true + await sleep(100) + } + return false + } + + const canvasFirst = await waitFor( + () => !!leaf.view.containerEl.querySelector('.tldraw-view-root') + ) + const typeAsCanvas = leaf.view.getViewType() + + await plugin.updateViewMode('markdown', leaf) + const becameMarkdown = await waitFor(() => leaf.view.getViewType() === 'markdown') + // The drawing is stored in a fenced code block, so its data should be on screen as text. + const markdownText = leaf.view.containerEl.innerText ?? '' + + await plugin.updateViewMode('tldraw-view', leaf) + const backToCanvas = await waitFor( + () => !!leaf.view.containerEl.querySelector('.tldraw-view-root') + ) + + const result = { + typeAsCanvas, + canvasFirst, + becameMarkdown, + markdownShowsData: markdownText.includes('tldraw'), + backToCanvas, + } + + leaf.detach() + await app.vault.delete(file) + return result + }) + + expect(result).toEqual({ + typeAsCanvas: 'tldraw-view', + canvasFirst: true, + becameMarkdown: true, + markdownShowsData: true, + backToCanvas: true, + }) + }) +})