Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488// src/plugin/__tests__/component.test.tsimport { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'import { Component } from '../component'import { Events } from '../events'
describe('Component', () => { it('continues all unload work after a child and cleanup throw, then reports errors', () => { const childCleanup = vi.fn() const finalCleanup = vi.fn() const onunload = vi.fn() class Child extends Component { onunload() { throw new Error('child failed') } } class Parent extends Component { onunload() { onunload() } } const parent = new Parent() parent.addChild(new Child()) parent.addChild(new class extends Component { onunload() { childCleanup() } }()) parent.load() parent.register(() => { throw new Error('cleanup failed') }) parent.register(finalCleanup) expect(() => parent.unload()).toThrow('Cleanup failed') expect(childCleanup).toHaveBeenCalledOnce() expect(finalCleanup).toHaveBeenCalledOnce() expect(onunload).toHaveBeenCalledOnce() }) describe('lifecycle', () => { it('calls onload when load() is called', () => { const onloadSpy = vi.fn() class TestComponent extends Component { onload() { onloadSpy() } } const comp = new TestComponent() comp.load() expect(onloadSpy).toHaveBeenCalled() })
it('calls onunload when unload() is called', () => { const onunloadSpy = vi.fn() class TestComponent extends Component { onunload() { onunloadSpy() } } const comp = new TestComponent() comp.load() comp.unload() expect(onunloadSpy).toHaveBeenCalled() })
it('does not call onunload if not loaded', () => { const onunloadSpy = vi.fn() class TestComponent extends Component { onunload() { onunloadSpy() } } const comp = new TestComponent() comp.unload() expect(onunloadSpy).not.toHaveBeenCalled() })
it('does not call onload twice if already loaded', () => { const onloadSpy = vi.fn() class TestComponent extends Component { onload() { onloadSpy() } } const comp = new TestComponent() comp.load() comp.load() expect(onloadSpy).toHaveBeenCalledTimes(1) })
it('catches rejected promise from async onload()', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) class AsyncFailComponent extends Component { onload(): Promise<void> { return Promise.reject(new Error('async onload failed')) } } const comp = new AsyncFailComponent() const result = comp.load() expect(result).toBeInstanceOf(Promise) // The returned promise should reject await expect(result).rejects.toThrow('async onload failed') // The catch fork should have logged expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('onload'), expect.any(Error) ) consoleSpy.mockRestore() })
it('returns a promise when onload is async', () => { class AsyncComponent extends Component { async onload(): Promise<void> { await new Promise(r => setTimeout(r, 10)) } } const comp = new AsyncComponent() const result = comp.load() expect(result).toBeInstanceOf(Promise) })
it('returns void when onload is synchronous', () => { class SyncComponent extends Component { onload(): void { // sync } } const comp = new SyncComponent() const result = comp.load() expect(result).toBeUndefined() }) })
describe('child components', () => { it('loads child when parent is already loaded', () => { const childOnload = vi.fn() class Child extends Component { onload() { childOnload() } } const parent = new Component() parent.load() const child = parent.addChild(new Child()) expect(childOnload).toHaveBeenCalled() expect(child).toBeInstanceOf(Child) })
it('unloads children when parent unloads', () => { const childOnunload = vi.fn() class Child extends Component { onunload() { childOnunload() } } const parent = new Component() parent.load() parent.addChild(new Child()) parent.unload() expect(childOnunload).toHaveBeenCalled() })
it('removeChild unloads the child', () => { const childOnunload = vi.fn() class Child extends Component { onunload() { childOnunload() } } const parent = new Component() parent.load() const child = parent.addChild(new Child()) parent.removeChild(child) expect(childOnunload).toHaveBeenCalled() }) })
describe('register', () => { it('calls registered callbacks on unload', () => { const cleanup = vi.fn() const comp = new Component() comp.load() comp.register(cleanup) comp.unload() expect(cleanup).toHaveBeenCalled() }) })
it.each(['child', 'callback'])('drains every cleanup category after a throwing %s', failure => { vi.useFakeTimers() try { const onUnload = vi.fn() class Parent extends Component { onunload() { onUnload() } } class BrokenChild extends Component { onunload() { throw new Error('cleanup failed') } } const parent = new Parent() parent.load() if (failure === 'child') parent.addChild(new BrokenChild()) const childCleanup = vi.fn() class Child extends Component { onunload() { childCleanup() } } parent.addChild(new Child()) if (failure === 'callback') parent.register(() => { throw new Error('cleanup failed') }) const callbackCleanup = vi.fn() parent.register(callbackCleanup) const events = new Events() const event = vi.fn() parent.registerEvent(events.on('ping', event)) const dom = document.createElement('button') const click = vi.fn() parent.registerDomEvent(dom, 'click', click) const interval = vi.fn() parent.registerInterval(window.setInterval(interval, 10))
expect(() => parent.unload()).toThrow('cleanup failed') events.trigger('ping') dom.click() vi.advanceTimersByTime(30) expect(childCleanup).toHaveBeenCalledTimes(1) expect(callbackCleanup).toHaveBeenCalledTimes(1) expect(onUnload).toHaveBeenCalledTimes(1) expect(event).not.toHaveBeenCalled() expect(click).not.toHaveBeenCalled() expect(interval).not.toHaveBeenCalled() parent.unload() expect(callbackCleanup).toHaveBeenCalledTimes(1) expect(onUnload).toHaveBeenCalledTimes(1) } finally { vi.useRealTimers() } })
it.each([false, true])('runs cleanup registered during teardown exactly once (throws=%s)', throws => { const component = new Component() const calls: string[] = [] component.load() component.register(() => { calls.push('outer') component.register(() => { calls.push('nested') if (throws) throw new Error('nested cleanup failed') }) }) component.register(() => { calls.push('sibling') }) if (throws) expect(() => component.unload()).toThrow('nested cleanup failed') else component.unload() expect(calls).toEqual(['outer', 'nested', 'sibling']) component.unload() expect(calls).toEqual(['outer', 'nested', 'sibling']) })
describe('registerEvent', () => { it('removes event listener on unload', () => { const events = new Events() const callback = vi.fn() const comp = new Component() comp.load() const ref = events.on('test', callback) comp.registerEvent(ref) comp.unload() events.trigger('test') expect(callback).not.toHaveBeenCalled() }) })
describe('lifecycle guards', () => { it('addChild no-ops after unload and unloads the child', () => { const parent = new Component() parent.load() parent.unload()
const childOnunload = vi.fn() class Child extends Component { onunload() { childOnunload() } } const child = new Child() child.load() const returned = parent.addChild(child) expect(returned).toBe(child) // Child should have been unloaded since parent is dead expect(childOnunload).toHaveBeenCalled() })
it('addChild stores child before load() without loading it', () => { const childOnload = vi.fn() class Child extends Component { onload() { childOnload() } } const parent = new Component() // Parent not yet loaded — child should be stored but not loaded const child = parent.addChild(new Child()) expect(child).toBeInstanceOf(Child) expect(childOnload).not.toHaveBeenCalled() })
it('addChild no-ops after unload with unloaded child (no crash)', () => { const parent = new Component() parent.load() parent.unload()
const child = new Component() // child is NOT loaded — unload() on it is a no-op const returned = parent.addChild(child) expect(returned).toBe(child) })
it('registerEvent stores ref before load() for cleanup on unload', () => { const events = new Events() const callback = vi.fn() const comp = new Component() // Not yet loaded — ref should be stored, not detached const ref = events.on('test', callback) comp.registerEvent(ref) // Event should still fire since ref was stored, not detached events.trigger('test') expect(callback).toHaveBeenCalled() // When we load then unload, the stored ref should be cleaned up callback.mockClear() comp.load() comp.unload() events.trigger('test') expect(callback).not.toHaveBeenCalled() })
it('registerInterval stores interval before load() for cleanup on unload', () => { vi.useFakeTimers() const callback = vi.fn() const comp = new Component() // Not yet loaded — interval should be stored, not cleared const id = window.setInterval(callback, 100) comp.registerInterval(id) vi.advanceTimersByTime(300) expect(callback).toHaveBeenCalledTimes(3) // When we load then unload, the stored interval should be cleared callback.mockClear() comp.load() comp.unload() vi.advanceTimersByTime(500) expect(callback).not.toHaveBeenCalled() vi.useRealTimers() })
it('registerEvent detaches the ref immediately after unload', () => { const events = new Events() const callback = vi.fn() const comp = new Component() comp.load() comp.unload()
const ref = events.on('test', callback) comp.registerEvent(ref) events.trigger('test') expect(callback).not.toHaveBeenCalled() })
it('registerInterval clears the interval immediately after unload', () => { vi.useFakeTimers() const callback = vi.fn() const comp = new Component() comp.load() comp.unload()
const id = window.setInterval(callback, 100) comp.registerInterval(id) vi.advanceTimersByTime(500) expect(callback).not.toHaveBeenCalled() vi.useRealTimers() })
it('registration methods work normally during onload', () => { const events = new Events() const callback = vi.fn() class TestComp extends Component { onload() { this.registerEvent(events.on('test', callback)) } } const comp = new TestComp() comp.load() events.trigger('test') expect(callback).toHaveBeenCalled() })
it('guards work with async onload after unload (StrictMode simulation)', async () => { vi.useFakeTimers() const events = new Events() const callback = vi.fn() class AsyncComp extends Component { async onload(): Promise<void> { await new Promise(r => setTimeout(r, 10)) // This runs after unload — should be guarded this.registerEvent(events.on('test', callback)) } } const comp = new AsyncComp() comp.load() comp.unload() // Unload before async onload completes // Advance timers to let async onload complete await vi.advanceTimersByTimeAsync(50) events.trigger('test') expect(callback).not.toHaveBeenCalled() vi.useRealTimers() }) })
describe('registerInterval', () => { beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers() })
it('clears interval on unload', () => { const callback = vi.fn() const comp = new Component() comp.load() const id = window.setInterval(callback, 100) comp.registerInterval(id) comp.unload() vi.advanceTimersByTime(500) expect(callback).not.toHaveBeenCalled() }) })
describe('registerDomEvent', () => { it('adds DOM event listener to HTMLElement', () => { const callback = vi.fn() const comp = new Component() const el = document.createElement('div') comp.load() comp.registerDomEvent(el, 'click', callback) el.dispatchEvent(new MouseEvent('click')) expect(callback).toHaveBeenCalledTimes(1) })
it('removes DOM event listener on unload', () => { const callback = vi.fn() const comp = new Component() const el = document.createElement('div') comp.load() comp.registerDomEvent(el, 'click', callback) comp.unload() el.dispatchEvent(new MouseEvent('click')) expect(callback).not.toHaveBeenCalled() })
it('adds DOM event listener to Window', () => { const callback = vi.fn() const comp = new Component() comp.load() comp.registerDomEvent(window, 'resize', callback) window.dispatchEvent(new Event('resize')) expect(callback).toHaveBeenCalledTimes(1) comp.unload() })
it('removes Window event listener on unload', () => { const callback = vi.fn() const comp = new Component() comp.load() comp.registerDomEvent(window, 'resize', callback) comp.unload() window.dispatchEvent(new Event('resize')) expect(callback).not.toHaveBeenCalled() })
it('adds DOM event listener to Document', () => { const callback = vi.fn() const comp = new Component() comp.load() comp.registerDomEvent(document, 'keydown', callback) document.dispatchEvent(new KeyboardEvent('keydown')) expect(callback).toHaveBeenCalledTimes(1) comp.unload() })
it('removes Document event listener on unload', () => { const callback = vi.fn() const comp = new Component() comp.load() comp.registerDomEvent(document, 'keydown', callback) comp.unload() document.dispatchEvent(new KeyboardEvent('keydown')) expect(callback).not.toHaveBeenCalled() })
it('does not add listener on dead component', () => { const callback = vi.fn() const comp = new Component() const el = document.createElement('div') comp.load() comp.unload() // Now dead comp.registerDomEvent(el, 'click', callback) el.dispatchEvent(new MouseEvent('click')) expect(callback).not.toHaveBeenCalled() })
it('stores listener before load() for cleanup on unload', () => { const callback = vi.fn() const comp = new Component() const el = document.createElement('div') // Not yet loaded — listener should be added and stored comp.registerDomEvent(el, 'click', callback) el.dispatchEvent(new MouseEvent('click')) expect(callback).toHaveBeenCalledTimes(1) // When we load then unload, the stored listener should be removed callback.mockClear() comp.load() comp.unload() el.dispatchEvent(new MouseEvent('click')) expect(callback).not.toHaveBeenCalled() }) })})