Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -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
83 changes: 83 additions & 0 deletions test/specs/edit-round-trip.e2e.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> } }).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)
})
})
75 changes: 75 additions & 0 deletions test/specs/markdown-embed.e2e.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> } }).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 })
})
})
131 changes: 131 additions & 0 deletions test/specs/theme-background.e2e.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> } }).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<string, any> } }).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)
})
})
46 changes: 46 additions & 0 deletions test/specs/ui-renders.e2e.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> } }).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')
})
})
Loading
Loading