diff --git a/tests/js/viewer-main.test.js b/tests/js/viewer-main.test.js
new file mode 100644
index 0000000..e68469a
--- /dev/null
+++ b/tests/js/viewer-main.test.js
@@ -0,0 +1,460 @@
+/**
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ * Copyright (c) 2026 Jacob Bühler
+ */
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+// The viewer component is a version-agnostic Vue options object that NC's
+// Viewer mounts at runtime; we don't bundle Vue. Rather than spin up a Vue
+// runtime, we exercise the options object directly: computed getters and
+// methods are plain functions invoked against a controlled `this`, and the
+// render function is driven with a mock `createElement` so we can assert the
+// produced vnode tree. The component is captured by stubbing
+// `OCA.Viewer.registerHandler` before importing the module (which registers
+// on load) — no source export needed.
+
+vi.mock('../../src/lib/oc-compat.js', () => ({
+ ocGenerateUrl: (path) => path,
+ ocRequestToken: () => 'csrf-token',
+ translate: (text) => text,
+}))
+vi.mock('../../src/lib/pad-sync.js', () => ({
+ createPadSync: vi.fn(() => ({
+ configure: vi.fn(),
+ installLifecycleHandlers: vi.fn(),
+ removeLifecycleHandlers: vi.fn(),
+ start: vi.fn(),
+ stop: vi.fn(),
+ fireAndForget: vi.fn(),
+ })),
+}))
+vi.mock('../../src/lib/api-client.js', () => ({
+ apiFindOriginalPad: vi.fn(),
+ apiRecoverFromSnapshot: vi.fn(),
+}))
+vi.mock('../../src/lib/sanitize-html.js', () => ({
+ sanitizeSnapshotHtml: vi.fn((html) => `SANITIZED:${html}`),
+}))
+vi.mock('../../src/lib/pad-frame-srcdoc.js', () => ({
+ buildPadFrameSrcdoc: vi.fn((url) => `SRCDOC:${url}`),
+}))
+vi.mock('../../src/lib/urls.js', () => ({
+ parsePadPathFromDavHref: vi.fn(() => ''),
+ parsePublicShareTokenFromLocation: vi.fn(() => ''),
+}))
+
+const { apiFindOriginalPad, apiRecoverFromSnapshot } = await import('../../src/lib/api-client.js')
+const { sanitizeSnapshotHtml } = await import('../../src/lib/sanitize-html.js')
+const { parsePadPathFromDavHref, parsePublicShareTokenFromLocation } = await import('../../src/lib/urls.js')
+
+let component
+
+beforeAll(async () => {
+ window.OCA = {
+ Viewer: {
+ availableHandlers: [],
+ registerHandler: (handler) => { component = handler.component },
+ },
+ }
+ await import('../../src/viewer-main.js')
+})
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ parsePublicShareTokenFromLocation.mockReturnValue('')
+ parsePadPathFromDavHref.mockReturnValue('')
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ window.happyDOM?.setURL?.('http://localhost/')
+})
+
+// Build a non-reactive stand-in for a mounted instance: data fields seeded
+// from data(), computed exposed as live getters, methods bound to the same
+// context. Overrides seed props/data before the getters are wired.
+function makeInstance(overrides = {}) {
+ const ctx = {
+ filename: '',
+ basename: '',
+ source: '',
+ fileid: null,
+ fileId: null,
+ fileInfo: null,
+ ...component.data(),
+ $emit: vi.fn(),
+ ...overrides,
+ }
+ for (const [key, getter] of Object.entries(component.computed)) {
+ Object.defineProperty(ctx, key, { configurable: true, get: () => getter.call(ctx) })
+ }
+ for (const [key, fn] of Object.entries(component.methods)) {
+ ctx[key] = (...args) => fn.call(ctx, ...args)
+ }
+ return ctx
+}
+
+const jsonResponse = (body, ok = true, status = 200) => ({
+ ok,
+ status,
+ json: () => Promise.resolve(body),
+})
+
+// Drain queued microtasks so fire-and-forget continuations (e.g. the
+// original-pad hint lookup) settle before we assert on their results.
+const flush = async () => {
+ for (let i = 0; i < 8; i += 1) await Promise.resolve()
+}
+
+const stubFetch = (impl) => {
+ const mock = typeof impl === 'function' ? vi.fn(impl) : vi.fn().mockResolvedValue(impl)
+ vi.stubGlobal('fetch', mock)
+ return mock
+}
+
+// --- mock createElement + vnode-tree query helpers ---
+const h = (tag, data, children) => ({
+ tag,
+ data: data || {},
+ children: children == null
+ ? []
+ : (Array.isArray(children) ? children.filter((c) => c != null) : [children]),
+})
+
+const hasClass = (node, cls) =>
+ typeof node?.data?.class === 'string' && node.data.class.split(/\s+/).includes(cls)
+
+const walk = (node, visit) => {
+ if (!node || typeof node !== 'object') return
+ visit(node)
+ for (const child of node.children || []) walk(child, visit)
+}
+
+const findByClass = (root, cls) => {
+ let found = null
+ walk(root, (n) => { if (!found && hasClass(n, cls)) found = n })
+ return found
+}
+
+const findByTag = (root, tag) => {
+ const out = []
+ walk(root, (n) => { if (n.tag === tag) out.push(n) })
+ return out
+}
+
+const allText = (root) => {
+ const parts = []
+ walk(root, (n) => {
+ for (const child of n.children || []) {
+ if (typeof child === 'string') parts.push(child)
+ }
+ })
+ return parts.join(' ')
+}
+
+describe('viewer component — computed path/id derivation', () => {
+ it('derives filePath from a .pad fileInfo.path', () => {
+ const vm = makeInstance({ fileInfo: { path: '/Notes/Standup.pad' } })
+ expect(vm.filePath).toBe('/Notes/Standup.pad')
+ })
+
+ it('falls back to filename joined with the dir from the URL when fileInfo is absent', () => {
+ const vm = makeInstance({ filename: 'Plan.pad' })
+ expect(vm.filePath).toBe('/Plan.pad')
+ })
+
+ it('prefers the DAV-parsed source path when it is a .pad', () => {
+ parsePadPathFromDavHref.mockReturnValue('/From/Dav.pad')
+ const vm = makeInstance({ source: 'https://nc/remote.php/dav/files/u/From/Dav.pad' })
+ expect(vm.filePath).toBe('/From/Dav.pad')
+ })
+
+ it('returns empty filePath when nothing resolves to a .pad', () => {
+ const vm = makeInstance({ filename: 'notes.txt', basename: '' })
+ expect(vm.filePath).toBe('/notes.txt') // non-pad falls through to "/" + baseName
+ const empty = makeInstance({})
+ expect(empty.filePath).toBe('')
+ })
+
+ it('resolves a positive numeric fileId from props, preferring fileid', () => {
+ expect(makeInstance({ fileid: '42' }).resolvedFileId).toBe(42)
+ expect(makeInstance({ fileId: 7 }).resolvedFileId).toBe(7)
+ expect(makeInstance({ fileInfo: { id: 9 } }).resolvedFileId).toBe(9)
+ })
+
+ it('returns null resolvedFileId when no positive id is available', () => {
+ expect(makeInstance({ fileid: 0 }).resolvedFileId).toBeNull()
+ expect(makeInstance({}).resolvedFileId).toBeNull()
+ })
+
+ it('falls back to the file id in the Files URL when openfile=true and no prop id is set', () => {
+ window.happyDOM.setURL('http://localhost/apps/files/files/77?openfile=true')
+ expect(makeInstance({}).resolvedFileId).toBe(77)
+ })
+
+ it('ignores the Files-URL id when openfile is not set', () => {
+ window.happyDOM.setURL('http://localhost/apps/files/files/77')
+ expect(makeInstance({}).resolvedFileId).toBeNull()
+ })
+})
+
+describe('viewer component — resolveOpenUrl', () => {
+ it('happy path: sets iframeSrc, starts sync, and emits loaded', async () => {
+ stubFetch(jsonResponse({ url: 'https://pad.example/p', sync_url: 'https://sync.example', sync_interval_seconds: 60 }))
+ const vm = makeInstance({ fileid: 42, fileInfo: { path: '/x.pad' } })
+
+ await vm.resolveOpenUrl()
+
+ expect(vm.iframeSrc).toBe('https://pad.example/p')
+ expect(vm.isLoading).toBe(false)
+ expect(vm.loadError).toBe('')
+ expect(vm.$emit).toHaveBeenCalledWith('update:loaded', true)
+ expect(vm._padSync.configure).toHaveBeenCalledWith({ syncUrl: 'https://sync.example', intervalMs: 60000 })
+ expect(vm._padSync.installLifecycleHandlers).toHaveBeenCalled()
+ expect(vm._padSync.start).toHaveBeenCalled()
+ })
+
+ it('does not start the sync loop when the API returns no sync_url', async () => {
+ stubFetch(jsonResponse({ url: 'https://pad.example/p', sync_url: '' }))
+ const vm = makeInstance({ fileid: 42, fileInfo: { path: '/x.pad' } })
+
+ await vm.resolveOpenUrl()
+
+ expect(vm.iframeSrc).toBe('https://pad.example/p')
+ expect(vm._padSync.start).not.toHaveBeenCalled()
+ })
+
+ it('external pad: enters external snapshot mode with the stored snapshot', async () => {
+ stubFetch(jsonResponse({
+ url: 'https://other.server/p/abc',
+ is_external: true,
+ snapshot_text: 'hello',
+ snapshot_html: 'hello',
+ }))
+ const vm = makeInstance({ fileid: 42, fileInfo: { path: '/x.pad' } })
+
+ await vm.resolveOpenUrl()
+
+ expect(vm.snapshotMode).toBe('external')
+ expect(vm.externalOpenUrl).toBe('https://other.server/p/abc')
+ expect(vm.snapshot).toEqual({ text: 'hello', html: 'hello' })
+ expect(vm.iframeSrc).toBe('')
+ expect(vm.$emit).toHaveBeenCalledWith('update:loaded', true)
+ })
+
+ it('readonly snapshot: enters readonly mode without requiring a url', async () => {
+ stubFetch(jsonResponse({ is_readonly_snapshot: true, snapshot_text: 't', snapshot_html: 't' }))
+ const vm = makeInstance({ fileid: 42, fileInfo: { path: '/x.pad' } })
+
+ await vm.resolveOpenUrl()
+
+ expect(vm.snapshotMode).toBe('readonly')
+ expect(vm.snapshot).toEqual({ text: 't', html: 't' })
+ })
+
+ it('missing frontmatter: initializes once then re-opens', async () => {
+ const fetchMock = stubFetch()
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse({ message: 'Missing YAML frontmatter' }, false, 422))
+ .mockResolvedValueOnce(jsonResponse({ status: 'ok' }))
+ .mockResolvedValueOnce(jsonResponse({ url: 'https://pad.example/after-init', sync_url: '' }))
+ const vm = makeInstance({ fileInfo: { path: '/x.pad' } }) // resolvedFileId null -> by-path only
+
+ await vm.resolveOpenUrl()
+
+ expect(fetchMock).toHaveBeenCalledTimes(3)
+ expect(fetchMock.mock.calls[1][0]).toContain('/pads/initialize')
+ expect(vm.iframeSrc).toBe('https://pad.example/after-init')
+ expect(vm.loadError).toBe('')
+ })
+
+ it('initializes by file id (not by path) when an id is available', async () => {
+ const fetchMock = stubFetch()
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse({ message: 'Missing YAML frontmatter' }, false, 422)) // open by-id
+ .mockResolvedValueOnce(jsonResponse({ message: 'Missing YAML frontmatter' }, false, 422)) // open by-path
+ .mockResolvedValueOnce(jsonResponse({ status: 'migrated_from_legacy' })) // initialize-by-id
+ .mockResolvedValueOnce(jsonResponse({ url: 'https://pad.example/by-id', sync_url: '' })) // re-open by-id
+ const vm = makeInstance({ fileid: 42, fileInfo: { path: '/x.pad' } })
+
+ await vm.resolveOpenUrl()
+
+ expect(fetchMock.mock.calls[2][0]).toContain('/pads/initialize-by-id/42')
+ expect(vm.iframeSrc).toBe('https://pad.example/by-id')
+ })
+
+ it('missing_binding for an addressable, non-public file: offers recovery and looks up the original', async () => {
+ stubFetch(jsonResponse({ message: 'no binding', code: 'missing_binding' }, false, 404))
+ apiFindOriginalPad.mockResolvedValue({ found: true, viewer_url: 'https://nc/viewer/123', path: '/orig.pad' })
+ const vm = makeInstance({ fileid: 42, fileInfo: { path: '/copy.pad' } })
+
+ await vm.resolveOpenUrl()
+ await flush() // let the fire-and-forget original-pad hint settle
+
+ expect(vm.loadError).toBe('no binding')
+ expect(vm.canRecover).toBe(true)
+ expect(apiFindOriginalPad).toHaveBeenCalledWith(42)
+ expect(vm.originalPad).toEqual({ viewerUrl: 'https://nc/viewer/123', path: '/orig.pad' })
+ expect(vm.isCheckingOriginal).toBe(false)
+ })
+
+ it('does not offer recovery on a public share even with missing_binding', async () => {
+ parsePublicShareTokenFromLocation.mockReturnValue('share-token')
+ stubFetch(jsonResponse({ message: 'no binding', code: 'missing_binding' }, false, 404))
+ const vm = makeInstance({ fileid: 42, fileInfo: { path: '/copy.pad' } })
+
+ await vm.resolveOpenUrl()
+
+ expect(vm.canRecover).toBe(false)
+ expect(apiFindOriginalPad).not.toHaveBeenCalled()
+ })
+
+ it('aborts cleanly when a newer resolve generation supersedes this one', async () => {
+ let release
+ const gate = new Promise((resolve) => { release = resolve })
+ stubFetch(() => gate.then(() => jsonResponse({ url: 'https://stale', sync_url: '' })))
+ const vm = makeInstance({ fileid: 42, fileInfo: { path: '/x.pad' } })
+
+ const pending = vm.resolveOpenUrl()
+ vm.resolveGeneration += 1 // a newer resolve started
+ release()
+ await pending
+
+ expect(vm.iframeSrc).toBe('') // stale result discarded
+ })
+})
+
+describe('viewer component — recoverFromSnapshot', () => {
+ it('posts the recovery, clears the error, and re-resolves', async () => {
+ apiRecoverFromSnapshot.mockResolvedValue({})
+ const vm = makeInstance({ fileid: 42, canRecover: true, loadError: 'boom' })
+ vm.resolveOpenUrl = vi.fn().mockResolvedValue()
+
+ await vm.recoverFromSnapshot()
+
+ expect(apiRecoverFromSnapshot).toHaveBeenCalledWith(42)
+ expect(vm.loadError).toBe('')
+ expect(vm.canRecover).toBe(false)
+ expect(vm.resolveOpenUrl).toHaveBeenCalled()
+ expect(vm.isRecovering).toBe(false)
+ })
+
+ it('is a no-op when recovery is not available', async () => {
+ const vm = makeInstance({ fileid: 42, canRecover: false })
+ vm.resolveOpenUrl = vi.fn()
+
+ await vm.recoverFromSnapshot()
+
+ expect(apiRecoverFromSnapshot).not.toHaveBeenCalled()
+ expect(vm.resolveOpenUrl).not.toHaveBeenCalled()
+ })
+
+ it('surfaces the error and stops the spinner when recovery fails', async () => {
+ apiRecoverFromSnapshot.mockRejectedValue(new Error('recover failed'))
+ const vm = makeInstance({ fileid: 42, canRecover: true })
+ vm.resolveOpenUrl = vi.fn()
+
+ await vm.recoverFromSnapshot()
+
+ expect(vm.loadError).toBe('recover failed')
+ expect(vm.isRecovering).toBe(false)
+ })
+})
+
+describe('viewer component — teardown', () => {
+ it('flushes, stops, and unhooks the sync controller on beforeUnmount', () => {
+ const vm = makeInstance({})
+ const sync = vm.padSync() // lazily create the controller
+ component.beforeUnmount.call(vm)
+
+ expect(sync.fireAndForget).toHaveBeenCalledWith(true, true)
+ expect(sync.stop).toHaveBeenCalled()
+ expect(sync.removeLifecycleHandlers).toHaveBeenCalled()
+ })
+
+ it('does not construct a controller just to tear it down', () => {
+ const vm = makeInstance({})
+ expect(() => component.beforeDestroy.call(vm)).not.toThrow()
+ expect(vm._padSync).toBeUndefined()
+ })
+})
+
+describe('viewer component — render', () => {
+ it('renders the error card with title and message', () => {
+ const vm = makeInstance({ loadError: 'Boom' })
+ const tree = component.render.call(vm, h)
+
+ expect(findByClass(tree, 'epnc-native-status--error')).toBeTruthy()
+ expect(allText(tree)).toContain('Could not open pad')
+ expect(allText(tree)).toContain('Boom')
+ })
+
+ it('shows the "checking for the original" hint while the lookup is in flight', () => {
+ const vm = makeInstance({ loadError: 'x', canRecover: true, isCheckingOriginal: true })
+ const tree = component.render.call(vm, h)
+
+ expect(allText(tree)).toContain('Checking for the original pad...')
+ expect(findByTag(tree, 'button')).toHaveLength(0)
+ })
+
+ it('offers "Open the original" plus a create action when an original was found', () => {
+ const vm = makeInstance({
+ loadError: 'x',
+ canRecover: true,
+ originalPad: { viewerUrl: 'https://nc/viewer/9', path: '/o.pad' },
+ })
+ const tree = component.render.call(vm, h)
+
+ const link = findByTag(tree, 'a')[0]
+ expect(link.data.attrs.href).toBe('https://nc/viewer/9')
+ expect(allText(tree)).toContain('Open the original .pad file')
+ expect(findByTag(tree, 'button')).toHaveLength(1)
+ })
+
+ it('offers only a create action when no original was found', () => {
+ const vm = makeInstance({ loadError: 'x', canRecover: true, originalPad: null })
+ const tree = component.render.call(vm, h)
+
+ expect(findByTag(tree, 'a')).toHaveLength(0)
+ expect(findByTag(tree, 'button')).toHaveLength(1)
+ expect(allText(tree)).toContain('Create new pad from this file')
+ })
+
+ it('renders the external snapshot view with a sanitized body and an open-original link', () => {
+ const vm = makeInstance({
+ snapshotMode: 'external',
+ externalOpenUrl: 'https://other/p',
+ externalOpenMessage: 'msg',
+ snapshot: { text: 'plain', html: 'x' },
+ })
+ const tree = component.render.call(vm, h)
+
+ expect(allText(tree)).toContain('Pad from another server')
+ expect(findByTag(tree, 'a')[0].data.attrs.href).toBe('https://other/p')
+ expect(sanitizeSnapshotHtml).toHaveBeenCalledWith('x')
+ expect(findByClass(tree, 'epnc-native-snapshot__text--html').data.domProps.innerHTML).toBe('SANITIZED:x')
+ })
+
+ it('renders the readonly snapshot view', () => {
+ const vm = makeInstance({ snapshotMode: 'readonly', snapshot: { text: 't', html: '' } })
+ const tree = component.render.call(vm, h)
+
+ expect(allText(tree)).toContain('Read-only snapshot')
+ })
+
+ it('renders the loading placeholder while resolving', () => {
+ const vm = makeInstance({ isLoading: true })
+ const tree = component.render.call(vm, h)
+
+ expect(allText(tree)).toContain('Loading pad...')
+ })
+
+ it('renders the iframe shell with a srcdoc wrapper once a pad URL is set', () => {
+ const vm = makeInstance({ isLoading: false, iframeSrc: 'https://pad/p' })
+ const tree = component.render.call(vm, h)
+
+ const iframe = findByTag(tree, 'iframe')[0]
+ expect(iframe.data.attrs.srcdoc).toBe('SRCDOC:https://pad/p')
+ expect(iframe.data.attrs.title).toBe('Etherpad')
+ })
+})