From 93f156d696aa8cbc144b2fc8b458d356868e824e Mon Sep 17 00:00:00 2001 From: Chijioke Joseph Date: Sat, 17 Oct 2026 19:13:39 +0100 Subject: [PATCH] test(sdk): cover browser DOM integrations --- packages/sdk/package.json | 1 + packages/sdk/src/client.stream.dom.test.ts | 45 +++++++++++++++++++ packages/sdk/src/offline-sync.dom.test.ts | 28 ++++++++++++ .../src/widget/checkout-widget.dom.test.ts | 28 ++++++++++++ packages/sdk/src/widget/checkout-widget.ts | 7 ++- packages/sdk/vitest.config.ts | 2 + pnpm-lock.yaml | 3 ++ 7 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 packages/sdk/src/client.stream.dom.test.ts create mode 100644 packages/sdk/src/offline-sync.dom.test.ts create mode 100644 packages/sdk/src/widget/checkout-widget.dom.test.ts diff --git a/packages/sdk/package.json b/packages/sdk/package.json index bae6f9da..2dfa5da5 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -73,6 +73,7 @@ "@types/express": "^5.0.6", "@types/react": "^18.3.3", "@types/supertest": "^7.2.1", + "jsdom": "^30.1.1", "openapi-typescript": "^7.13.0", "supertest": "^7.2.2", "tsup": "^8.0.0", diff --git a/packages/sdk/src/client.stream.dom.test.ts b/packages/sdk/src/client.stream.dom.test.ts new file mode 100644 index 00000000..09ea5d70 --- /dev/null +++ b/packages/sdk/src/client.stream.dom.test.ts @@ -0,0 +1,45 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AccensaClient } from './client'; +import type { SyncEvent } from './types/sync-event'; + +class MockEventSource extends EventTarget { + static instances: MockEventSource[] = []; + readonly close = vi.fn(); + + constructor(readonly url: string) { + super(); + MockEventSource.instances.push(this); + } +} + +describe('AccensaClient sync event stream', () => { + afterEach(() => { + vi.unstubAllGlobals(); + MockEventSource.instances = []; + }); + + it('delivers sync events and closes the stream when unsubscribed', () => { + vi.stubGlobal('EventSource', MockEventSource); + const client = new AccensaClient({ indexerUrl: 'https://indexer.example' }); + const onSync = vi.fn(); + const unsubscribe = client.subscribeSync({ onSync }); + const source = MockEventSource.instances[0]; + const payload: SyncEvent = { + merchant: 'G' + 'A'.repeat(55), + syncedTo: 42, + inserted: 2, + scanned: 3, + pages: 1, + drained: true, + occurredAt: '2026-10-17T00:00:00.000Z', + }; + + source.dispatchEvent(new MessageEvent('sync', { data: JSON.stringify(payload) })); + + expect(source.url).toBe('https://indexer.example/api/sync/stream'); + expect(onSync).toHaveBeenCalledWith(payload); + unsubscribe(); + expect(source.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/sdk/src/offline-sync.dom.test.ts b/packages/sdk/src/offline-sync.dom.test.ts new file mode 100644 index 00000000..78aa77eb --- /dev/null +++ b/packages/sdk/src/offline-sync.dom.test.ts @@ -0,0 +1,28 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it } from 'vitest'; +import { OfflineSyncEngine } from './offline-sync'; + +describe('OfflineSyncEngine in a browser DOM', () => { + afterEach(() => { + localStorage.clear(); + window.dispatchEvent(new Event('online')); + }); + + it('persists queued work offline and flushes it when connectivity returns', async () => { + Object.defineProperty(navigator, 'onLine', { configurable: true, value: true }); + const engine = new OfflineSyncEngine({ storageKey: 'offline-dom-test', syncIntervalMs: 0 }); + await engine.init(); + + window.dispatchEvent(new Event('offline')); + engine.queueUpdate('orders', 'order-1', { status: 'ready' }); + + expect(engine.getPendingCount()).toBe(1); + expect(JSON.parse(localStorage.getItem('offline-dom-test')!).pendingOps).toHaveLength(1); + + window.dispatchEvent(new Event('online')); + await Promise.resolve(); + + expect(engine.getPendingCount()).toBe(0); + expect(JSON.parse(localStorage.getItem('offline-dom-test')!).pendingOps).toHaveLength(0); + }); +}); diff --git a/packages/sdk/src/widget/checkout-widget.dom.test.ts b/packages/sdk/src/widget/checkout-widget.dom.test.ts new file mode 100644 index 00000000..37f9e133 --- /dev/null +++ b/packages/sdk/src/widget/checkout-widget.dom.test.ts @@ -0,0 +1,28 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AccensaCheckoutWidget } from './checkout-widget'; + +describe('AccensaCheckoutWidget DOM lifecycle', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.replaceChildren(); + }); + + it('renders when connected and removes its message listener on disconnect', () => { + expect(customElements.get('accensa-checkout')).toBe(AccensaCheckoutWidget); + const add = vi.spyOn(window, 'addEventListener'); + const remove = vi.spyOn(window, 'removeEventListener'); + const widget = document.createElement('accensa-checkout') as AccensaCheckoutWidget; + widget.setAttribute('amount', '12.50'); + document.body.append(widget); + + expect(widget.shadowRoot?.textContent).toContain('12.50'); + const registered = add.mock.calls.find(([type]) => type === 'message')?.[1]; + expect(registered).toBeTypeOf('function'); + + widget.remove(); + + const removed = remove.mock.calls.find(([type]) => type === 'message')?.[1]; + expect(removed).toBe(registered); + }); +}); diff --git a/packages/sdk/src/widget/checkout-widget.ts b/packages/sdk/src/widget/checkout-widget.ts index 396f5a5a..8ae0d219 100644 --- a/packages/sdk/src/widget/checkout-widget.ts +++ b/packages/sdk/src/widget/checkout-widget.ts @@ -67,7 +67,7 @@ class AccensaCheckoutWidget extends HTMLElementBase { } disconnectedCallback() { - window.removeEventListener('message', this.handleMessage); + window.removeEventListener('message', this.boundHandleMessage); } attributeChangedCallback(name: string, oldValue: string, newValue: string) { @@ -88,7 +88,7 @@ class AccensaCheckoutWidget extends HTMLElementBase { } private setupMessageListener() { - window.addEventListener('message', this.handleMessage.bind(this)); + window.addEventListener('message', this.boundHandleMessage); } private handleMessage = (event: MessageEvent) => { @@ -119,6 +119,9 @@ class AccensaCheckoutWidget extends HTMLElementBase { } }; + // Retain the same bound callback so disconnection can remove the listener. + private boundHandleMessage = this.handleMessage.bind(this); + private postMessage(message: WidgetMessage, targetOrigin: string = MESSAGE_ORIGIN) { if (this.messageId > 10000) { this.messageId = 0; // Prevent overflow diff --git a/packages/sdk/vitest.config.ts b/packages/sdk/vitest.config.ts index 723ae7ef..ba8c84cf 100644 --- a/packages/sdk/vitest.config.ts +++ b/packages/sdk/vitest.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + // DOM tests opt in with a per-file @vitest-environment jsdom docblock so + // the SDK's existing Node-only tests keep their current runtime. /** * `tsc` now runs alongside the suite, which starves the event loop enough * on a loaded machine to push the timer-driven tests (the abort and retry diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8088f1b6..fb4edec3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -284,6 +284,9 @@ importers: '@types/supertest': specifier: ^7.2.1 version: 7.2.1 + jsdom: + specifier: ^30.1.1 + version: 30.1.1(@noble/hashes@2.2.0) openapi-typescript: specifier: ^7.13.0 version: 7.13.0(typescript@5.9.3)