From 59a3565cfecb9fa57cc9a08d84bbcc6a8015829f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 15:02:28 +0000 Subject: [PATCH 1/7] Add unit tests for all non-React code paths Covers deepEqual, polyfillRequestAnimationFrame, validateConfigId, the default client singleton, and an end-to-end expansion of the initialize() client tests (URL parsing, config/elements/style APIs, event handlers, promise resolution and rejection, unsubscribers, and destroy behavior). React-dependent modules are intentionally left untested since react is only an optional peer dep and is not installed in the test environment. --- src/__tests__/client.test.ts | 42 ++ src/__tests__/error.test.ts | 63 ++ src/client/__tests__/initialize.test.ts | 663 +++++++++++++++++- src/utils/__tests__/deepEqual.test.ts | 91 +++ .../polyfillRequestAnimationFrame.test.ts | 56 ++ 5 files changed, 898 insertions(+), 17 deletions(-) create mode 100644 src/__tests__/client.test.ts create mode 100644 src/__tests__/error.test.ts create mode 100644 src/utils/__tests__/deepEqual.test.ts create mode 100644 src/utils/__tests__/polyfillRequestAnimationFrame.test.ts diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts new file mode 100644 index 0000000..dcf1d29 --- /dev/null +++ b/src/__tests__/client.test.ts @@ -0,0 +1,42 @@ +import { client } from '../client'; + +describe('client', () => { + it('exports a singleton plugin instance', () => { + expect(client).toBeDefined(); + expect(client.config).toBeDefined(); + expect(client.elements).toBeDefined(); + expect(client.style).toBeDefined(); + expect(typeof client.destroy).toBe('function'); + }); + + it('exposes the documented config methods', () => { + expect(typeof client.config.get).toBe('function'); + expect(typeof client.config.set).toBe('function'); + expect(typeof client.config.subscribe).toBe('function'); + expect(typeof client.config.getVariable).toBe('function'); + expect(typeof client.config.setVariable).toBe('function'); + expect(typeof client.config.subscribeToWorkbookVariable).toBe('function'); + expect(typeof client.config.getInteraction).toBe('function'); + expect(typeof client.config.setInteraction).toBe('function'); + expect(typeof client.config.subscribeToWorkbookInteraction).toBe('function'); + expect(typeof client.config.triggerAction).toBe('function'); + expect(typeof client.config.registerEffect).toBe('function'); + expect(typeof client.config.configureEditorPanel).toBe('function'); + expect(typeof client.config.setLoadingState).toBe('function'); + expect(typeof client.config.getUrlParameter).toBe('function'); + expect(typeof client.config.setUrlParameter).toBe('function'); + expect(typeof client.config.subscribeToUrlParameter).toBe('function'); + }); + + it('exposes the documented elements methods', () => { + expect(typeof client.elements.getElementColumns).toBe('function'); + expect(typeof client.elements.subscribeToElementColumns).toBe('function'); + expect(typeof client.elements.subscribeToElementData).toBe('function'); + expect(typeof client.elements.fetchMoreElementData).toBe('function'); + }); + + it('exposes the documented style methods', () => { + expect(typeof client.style.subscribe).toBe('function'); + expect(typeof client.style.get).toBe('function'); + }); +}); diff --git a/src/__tests__/error.test.ts b/src/__tests__/error.test.ts new file mode 100644 index 0000000..587e334 --- /dev/null +++ b/src/__tests__/error.test.ts @@ -0,0 +1,63 @@ +import { validateConfigId } from '../error'; + +describe('validateConfigId', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('logs a warning when configId is undefined', () => { + validateConfigId(undefined as unknown as string, 'variable'); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith('Invalid config variable: undefined'); + }); + + it('does not warn for a defined configId', () => { + validateConfigId('id', 'variable'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('does not warn for an empty string configId', () => { + validateConfigId('', 'variable'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('does not warn for null configId (only undefined triggers a warning)', () => { + validateConfigId(null as unknown as string, 'variable'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('includes the expectedConfigType in the warning message', () => { + validateConfigId(undefined as unknown as string, 'element'); + expect(warnSpy).toHaveBeenCalledWith('Invalid config element: undefined'); + + warnSpy.mockClear(); + validateConfigId(undefined as unknown as string, 'url-parameter'); + expect(warnSpy).toHaveBeenCalledWith( + 'Invalid config url-parameter: undefined', + ); + + warnSpy.mockClear(); + validateConfigId(undefined as unknown as string, 'action-trigger'); + expect(warnSpy).toHaveBeenCalledWith( + 'Invalid config action-trigger: undefined', + ); + + warnSpy.mockClear(); + validateConfigId(undefined as unknown as string, 'action-effect'); + expect(warnSpy).toHaveBeenCalledWith( + 'Invalid config action-effect: undefined', + ); + + warnSpy.mockClear(); + validateConfigId(undefined as unknown as string, 'interaction'); + expect(warnSpy).toHaveBeenCalledWith( + 'Invalid config interaction: undefined', + ); + }); +}); diff --git a/src/client/__tests__/initialize.test.ts b/src/client/__tests__/initialize.test.ts index 745d33c..4d80fd1 100644 --- a/src/client/__tests__/initialize.test.ts +++ b/src/client/__tests__/initialize.test.ts @@ -1,30 +1,659 @@ +import { PluginInstance } from '../../types'; import { initialize } from '../initialize'; +function sendWindowMessage(data: { + type: string; + result?: unknown; + error?: unknown; +}) { + window.dispatchEvent(new MessageEvent('message', { data })); +} + +type PostMessageSpy = ReturnType; + +function postMessages(spy: PostMessageSpy) { + return spy.mock.calls.map( + call => ({ data: call[0] as any, origin: call[1] as string }), + ); +} + +function findPostMessage(spy: PostMessageSpy, type: string) { + return postMessages(spy).find(c => c.data.type === type); +} + describe('initialize', () => { - let originalAddEventListener: any; - let originalRemoveEventListener: any; + let postMessageSpy: PostMessageSpy; + let originalUrl: string; - beforeAll(() => { - originalAddEventListener = window.addEventListener; - originalRemoveEventListener = window.removeEventListener; - window.addEventListener = vi.fn(); - window.removeEventListener = vi.fn(); + beforeEach(() => { + originalUrl = window.location.href; + postMessageSpy = vi + .spyOn(window.parent, 'postMessage') + .mockImplementation(() => {}); }); - beforeEach(() => { - vi.resetAllMocks(); + afterEach(() => { + postMessageSpy.mockRestore(); + window.history.replaceState({}, '', originalUrl); }); - it('should initialize and be destroyable', () => { - const client = initialize(); - expect(window.addEventListener).toHaveBeenCalled(); + describe('lifecycle', () => { + it('returns a client with the expected shape', () => { + const client = initialize(); + expect(client.config).toBeDefined(); + expect(client.elements).toBeDefined(); + expect(client.style).toBeDefined(); + expect(typeof client.destroy).toBe('function'); + client.destroy(); + }); + + it('attaches a message listener and removes it on destroy', () => { + const addSpy = vi.spyOn(window, 'addEventListener'); + const removeSpy = vi.spyOn(window, 'removeEventListener'); + + const client = initialize(); + const messageAdd = addSpy.mock.calls.find(c => c[0] === 'message'); + expect(messageAdd).toBeDefined(); + + client.destroy(); + const messageRemove = removeSpy.mock.calls.find(c => c[0] === 'message'); + expect(messageRemove).toBeDefined(); + // The same listener reference is used for add and remove + expect(messageRemove?.[1]).toBe(messageAdd?.[1]); + + addSpy.mockRestore(); + removeSpy.mockRestore(); + }); + + it('sends an initial wb:plugin:init message including the SDK version', () => { + const client = initialize(); + const init = findPostMessage(postMessageSpy, 'wb:plugin:init'); + expect(init).toBeDefined(); + expect(Array.isArray(init?.data.args)).toBe(true); + expect(typeof init?.data.args[0]).toBe('string'); + client.destroy(); + }); - client.destroy(); - expect(window.removeEventListener).toHaveBeenCalled(); + it('sends a focus event on window click', () => { + const client = initialize(); + postMessageSpy.mockClear(); + window.dispatchEvent(new MouseEvent('click')); + const focus = findPostMessage(postMessageSpy, 'wb:plugin:focus'); + expect(focus).toBeDefined(); + client.destroy(); + }); }); - afterAll(() => { - window.addEventListener = originalAddEventListener; - window.removeEventListener = originalRemoveEventListener; + describe('URL param parsing', () => { + it('parses JSON-encoded URL params into the plugin config', () => { + window.history.replaceState({}, '', '/?id=%22abc%22'); + const client = initialize(); + const init = findPostMessage(postMessageSpy, 'wb:plugin:init'); + expect(init?.data.elementId).toBe('abc'); + client.destroy(); + }); + + it('uses wbOrigin from URL params for postMessage', () => { + const origin = 'https://sigma.example'; + window.history.replaceState( + {}, + '', + '/?wbOrigin=' + encodeURIComponent(JSON.stringify(origin)), + ); + const client = initialize(); + const init = findPostMessage(postMessageSpy, 'wb:plugin:init'); + expect(init?.origin).toBe(origin); + client.destroy(); + }); + + it('falls back to "*" when wbOrigin is not provided', () => { + window.history.replaceState({}, '', '/'); + const client = initialize(); + const init = findPostMessage(postMessageSpy, 'wb:plugin:init'); + expect(init?.origin).toBe('*'); + client.destroy(); + }); + + it('logs an error for malformed JSON params', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + window.history.replaceState({}, '', '/?bad=notJson'); + const client = initialize(); + expect(errorSpy).toHaveBeenCalled(); + expect(errorSpy.mock.calls[0][0]).toContain( + 'Failed to parse URL param bad', + ); + errorSpy.mockRestore(); + client.destroy(); + }); + + it('silently ignores invalid frameId and sessionId in the vitest browser', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + window.history.replaceState( + {}, + '', + '/?frameId=notJson&sessionId=alsoNotJson', + ); + const client = initialize(); + expect(errorSpy).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + client.destroy(); + }); + }); + + describe('init response', () => { + it('updates pluginConfig and emits config when init resolves', async () => { + const client = initialize(); + const configListener = vi.fn(); + client.config.subscribe(configListener); + + sendWindowMessage({ + type: 'wb:plugin:init', + result: { sigmaEnv: 'author', config: { foo: 'bar' } }, + error: null, + }); + await Promise.resolve(); + + expect(client.sigmaEnv).toBe('author'); + expect(client.config.get()).toEqual({ foo: 'bar' }); + expect(configListener).toHaveBeenCalledWith({ foo: 'bar' }); + client.destroy(); + }); + + it('exposes isScreenshot from the init response', async () => { + const client = initialize(); + sendWindowMessage({ + type: 'wb:plugin:init', + result: { screenshot: true }, + error: null, + }); + await Promise.resolve(); + expect((client as unknown as { isScreenshot: boolean }).isScreenshot).toBe( + true, + ); + client.destroy(); + }); + }); + + describe('config API', () => { + let client: PluginInstance; + + beforeEach(async () => { + client = initialize(); + sendWindowMessage({ + type: 'wb:plugin:init', + result: { config: { initial: 'x' } }, + error: null, + }); + await Promise.resolve(); + postMessageSpy.mockClear(); + }); + + afterEach(() => { + client.destroy(); + }); + + it('get returns the current config', () => { + expect(client.config.get()).toEqual({ initial: 'x' }); + }); + + it('getKey returns a value from the config', () => { + expect( + (client.config as unknown as { getKey: (k: string) => unknown }).getKey( + 'initial', + ), + ).toBe('x'); + }); + + it('set posts wb:plugin:config:update with the partial config', () => { + client.config.set({ a: 1 } as Record); + const msg = findPostMessage(postMessageSpy, 'wb:plugin:config:update'); + expect(msg?.data.args).toEqual([{ a: 1 }]); + }); + + it('setKey posts wb:plugin:config:update with a single key/value', () => { + ( + client.config as unknown as { + setKey: (k: string, v: unknown) => void; + } + ).setKey('newKey', 'newVal'); + const msg = findPostMessage(postMessageSpy, 'wb:plugin:config:update'); + expect(msg?.data.args).toEqual([{ newKey: 'newVal' }]); + }); + + it('subscribe receives updates from wb:plugin:config:update messages', () => { + const listener = vi.fn(); + client.config.subscribe(listener); + sendWindowMessage({ + type: 'wb:plugin:config:update', + result: { config: { b: 2 } }, + error: null, + }); + expect(listener).toHaveBeenCalledWith({ b: 2 }); + }); + + it('subscribe falls back to an empty object when config is missing', () => { + const listener = vi.fn(); + client.config.subscribe(listener); + sendWindowMessage({ + type: 'wb:plugin:config:update', + result: {}, + error: null, + }); + expect(listener).toHaveBeenCalledWith({}); + }); + + it('subscribe returns a working unsubscriber', () => { + const listener = vi.fn(); + const unsub = client.config.subscribe(listener); + unsub(); + sendWindowMessage({ + type: 'wb:plugin:config:update', + result: { config: { c: 3 } }, + error: null, + }); + expect(listener).not.toHaveBeenCalled(); + }); + + it('getVariable returns the subscribed variable for the given id', () => { + sendWindowMessage({ + type: 'wb:plugin:variable:update', + result: { v1: { name: 'v1', defaultValue: { type: 'text', value: 'hi' } } }, + error: null, + }); + expect(client.config.getVariable('v1')).toEqual({ + name: 'v1', + defaultValue: { type: 'text', value: 'hi' }, + }); + }); + + it('setVariable posts wb:plugin:variable:set with id and values', () => { + client.config.setVariable('v1', 'a', 'b'); + const msg = findPostMessage(postMessageSpy, 'wb:plugin:variable:set'); + expect(msg?.data.args).toEqual(['v1', 'a', 'b']); + }); + + it('subscribeToWorkbookVariable invokes the callback on updates', () => { + const cb = vi.fn(); + client.config.subscribeToWorkbookVariable('v1', cb); + sendWindowMessage({ + type: 'wb:plugin:variable:update', + result: { v1: { name: 'v1', defaultValue: { type: 't', value: 1 } } }, + error: null, + }); + expect(cb).toHaveBeenCalledWith({ + name: 'v1', + defaultValue: { type: 't', value: 1 }, + }); + }); + + it('subscribeToWorkbookVariable returns a working unsubscriber', () => { + const cb = vi.fn(); + const unsub = client.config.subscribeToWorkbookVariable('v1', cb); + unsub(); + sendWindowMessage({ + type: 'wb:plugin:variable:update', + result: { v1: { name: 'v1', defaultValue: { type: 't', value: 2 } } }, + error: null, + }); + expect(cb).not.toHaveBeenCalled(); + }); + + it('getInteraction returns the subscribed interaction selection', () => { + sendWindowMessage({ + type: 'wb:plugin:selection:update', + result: { i1: [{ col: { type: 't', val: 1 } }] }, + error: null, + }); + expect(client.config.getInteraction('i1')).toEqual([ + { col: { type: 't', val: 1 } }, + ]); + }); + + it('setInteraction posts wb:plugin:selection:set with id, element, and selection', () => { + client.config.setInteraction('cfg', 'el', [{ col: { type: 't' } }]); + const msg = findPostMessage(postMessageSpy, 'wb:plugin:selection:set'); + expect(msg?.data.args).toEqual(['cfg', 'el', [{ col: { type: 't' } }]]); + }); + + it('subscribeToWorkbookInteraction invokes the callback on updates', () => { + const cb = vi.fn(); + client.config.subscribeToWorkbookInteraction('i1', cb); + sendWindowMessage({ + type: 'wb:plugin:selection:update', + result: { i1: [{ a: { type: 't' } }] }, + error: null, + }); + expect(cb).toHaveBeenCalledWith([{ a: { type: 't' } }]); + }); + + it('subscribeToWorkbookInteraction returns a working unsubscriber', () => { + const cb = vi.fn(); + const unsub = client.config.subscribeToWorkbookInteraction('i1', cb); + unsub(); + sendWindowMessage({ + type: 'wb:plugin:selection:update', + result: { i1: [{ a: { type: 't' } }] }, + error: null, + }); + expect(cb).not.toHaveBeenCalled(); + }); + + it('triggerAction posts wb:plugin:action-trigger:invoke', () => { + client.config.triggerAction('cfg'); + const msg = findPostMessage( + postMessageSpy, + 'wb:plugin:action-trigger:invoke', + ); + expect(msg?.data.args).toEqual(['cfg']); + }); + + it('registerEffect stores an effect that is called on invoke', () => { + const fn = vi.fn(); + client.config.registerEffect('e1', fn); + sendWindowMessage({ + type: 'wb:plugin:action-effect:invoke', + result: 'e1', + error: null, + }); + expect(fn).toHaveBeenCalled(); + }); + + it('registerEffect returns an unregister function that detaches the effect', () => { + const fn = vi.fn(); + const unreg = client.config.registerEffect('e1', fn); + unreg(); + expect(() => { + sendWindowMessage({ + type: 'wb:plugin:action-effect:invoke', + result: 'e1', + error: null, + }); + }).toThrow(/Unknown action effect with name: e1/); + expect(fn).not.toHaveBeenCalled(); + }); + + it('throws when an unknown action effect is invoked', () => { + expect(() => { + sendWindowMessage({ + type: 'wb:plugin:action-effect:invoke', + result: 'unknown', + error: null, + }); + }).toThrow(/Unknown action effect with name: unknown/); + }); + + it('configureEditorPanel posts wb:plugin:config:inspector with options', () => { + const options = [{ type: 'group', name: 'g' } as const]; + client.config.configureEditorPanel(options as any); + const msg = findPostMessage(postMessageSpy, 'wb:plugin:config:inspector'); + expect(msg?.data.args).toEqual([options]); + }); + + it('setLoadingState posts wb:plugin:config:loading-state', () => { + client.config.setLoadingState(true); + const msg = findPostMessage( + postMessageSpy, + 'wb:plugin:config:loading-state', + ); + expect(msg?.data.args).toEqual([true]); + }); + + it('getUrlParameter returns the subscribed url parameter', () => { + sendWindowMessage({ + type: 'wb:plugin:url-parameter:update', + result: { u1: { value: 'val' } }, + error: null, + }); + expect(client.config.getUrlParameter('u1')).toEqual({ value: 'val' }); + }); + + it('setUrlParameter posts wb:plugin:url-parameter:set', () => { + client.config.setUrlParameter('u1', 'newVal'); + const msg = findPostMessage( + postMessageSpy, + 'wb:plugin:url-parameter:set', + ); + expect(msg?.data.args).toEqual(['u1', 'newVal']); + }); + + it('subscribeToUrlParameter invokes the callback with the initial value', () => { + const cb = vi.fn(); + client.config.subscribeToUrlParameter('u1', cb); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith(undefined); + }); + + it('subscribeToUrlParameter invokes the callback on updates', () => { + const cb = vi.fn(); + client.config.subscribeToUrlParameter('u1', cb); + cb.mockClear(); + sendWindowMessage({ + type: 'wb:plugin:url-parameter:update', + result: { u1: { value: 'V' } }, + error: null, + }); + expect(cb).toHaveBeenCalledWith({ value: 'V' }); + }); + + it('subscribeToUrlParameter returns a working unsubscriber', () => { + const cb = vi.fn(); + const unsub = client.config.subscribeToUrlParameter('u1', cb); + unsub(); + cb.mockClear(); + sendWindowMessage({ + type: 'wb:plugin:url-parameter:update', + result: { u1: { value: 'X' } }, + error: null, + }); + expect(cb).not.toHaveBeenCalled(); + }); + + it('warns through validateConfigId when configId is undefined', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + client.config.getVariable(undefined as unknown as string); + expect(warnSpy).toHaveBeenCalledWith('Invalid config variable: undefined'); + warnSpy.mockRestore(); + }); + }); + + describe('elements API', () => { + let client: PluginInstance; + + beforeEach(async () => { + client = initialize(); + sendWindowMessage({ type: 'wb:plugin:init', result: {}, error: null }); + await Promise.resolve(); + postMessageSpy.mockClear(); + }); + + afterEach(() => { + client.destroy(); + }); + + it('getElementColumns posts wb:plugin:element:columns:get and returns a Promise', () => { + const p = client.elements.getElementColumns('el1'); + expect(p).toBeInstanceOf(Promise); + const msg = findPostMessage( + postMessageSpy, + 'wb:plugin:element:columns:get', + ); + expect(msg?.data.args).toEqual(['el1']); + }); + + it('getElementColumns resolves with the response data', async () => { + const cols = { c1: { id: 'c1', name: 'C', columnType: 'text' } }; + const p = client.elements.getElementColumns('el1'); + sendWindowMessage({ + type: 'wb:plugin:element:columns:get', + result: cols, + error: null, + }); + await expect(p).resolves.toEqual(cols); + }); + + it('getElementColumns rejects when the response carries an error', async () => { + const p = client.elements.getElementColumns('el1'); + sendWindowMessage({ + type: 'wb:plugin:element:columns:get', + result: null, + error: 'boom', + }); + await expect(p).rejects.toBe('boom'); + }); + + it('subscribeToElementColumns subscribes, dispatches data, and unsubscribes', () => { + const cb = vi.fn(); + const unsub = client.elements.subscribeToElementColumns('el1', cb); + + const sub = findPostMessage( + postMessageSpy, + 'wb:plugin:element:subscribe:columns', + ); + expect(sub?.data.args).toEqual(['el1']); + + const cols = { c1: { id: 'c1', name: 'X', columnType: 'number' } }; + sendWindowMessage({ + type: 'wb:plugin:element:el1:columns', + result: cols, + error: null, + }); + expect(cb).toHaveBeenCalledWith(cols); + + postMessageSpy.mockClear(); + cb.mockClear(); + unsub(); + const unsubMsg = findPostMessage( + postMessageSpy, + 'wb:plugin:element:unsubscribe:columns', + ); + expect(unsubMsg?.data.args).toEqual(['el1']); + + sendWindowMessage({ + type: 'wb:plugin:element:el1:columns', + result: cols, + error: null, + }); + expect(cb).not.toHaveBeenCalled(); + }); + + it('subscribeToElementData subscribes, dispatches data, and unsubscribes', () => { + const cb = vi.fn(); + const unsub = client.elements.subscribeToElementData('el1', cb); + + const sub = findPostMessage( + postMessageSpy, + 'wb:plugin:element:subscribe:data', + ); + expect(sub?.data.args).toEqual(['el1']); + + const data = { c1: [1, 2, 3] }; + sendWindowMessage({ + type: 'wb:plugin:element:el1:data', + result: data, + error: null, + }); + expect(cb).toHaveBeenCalledWith(data); + + postMessageSpy.mockClear(); + cb.mockClear(); + unsub(); + const unsubMsg = findPostMessage( + postMessageSpy, + 'wb:plugin:element:unsubscribe:data', + ); + expect(unsubMsg?.data.args).toEqual(['el1']); + + sendWindowMessage({ + type: 'wb:plugin:element:el1:data', + result: data, + error: null, + }); + expect(cb).not.toHaveBeenCalled(); + }); + + it('fetchMoreElementData posts wb:plugin:element:fetch-more', () => { + client.elements.fetchMoreElementData('el1'); + const msg = findPostMessage( + postMessageSpy, + 'wb:plugin:element:fetch-more', + ); + expect(msg?.data.args).toEqual(['el1']); + }); + + it('warns through validateConfigId for an undefined element id', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + client.elements.fetchMoreElementData(undefined as unknown as string); + expect(warnSpy).toHaveBeenCalledWith('Invalid config element: undefined'); + warnSpy.mockRestore(); + }); + }); + + describe('style API', () => { + let client: PluginInstance; + + beforeEach(async () => { + client = initialize(); + sendWindowMessage({ type: 'wb:plugin:init', result: {}, error: null }); + await Promise.resolve(); + postMessageSpy.mockClear(); + }); + + afterEach(() => { + client.destroy(); + }); + + it('subscribe receives style updates', () => { + const cb = vi.fn(); + client.style.subscribe(cb); + sendWindowMessage({ + type: 'wb:plugin:style:update', + result: { backgroundColor: '#fff' }, + error: null, + }); + expect(cb).toHaveBeenCalledWith({ backgroundColor: '#fff' }); + }); + + it('subscribe returns a working unsubscriber', () => { + const cb = vi.fn(); + const unsub = client.style.subscribe(cb); + unsub(); + sendWindowMessage({ + type: 'wb:plugin:style:update', + result: { backgroundColor: '#000' }, + error: null, + }); + expect(cb).not.toHaveBeenCalled(); + }); + + it('get sends wb:plugin:style:get and resolves with the style', async () => { + const p = client.style.get(); + const msg = findPostMessage(postMessageSpy, 'wb:plugin:style:get'); + expect(msg).toBeDefined(); + sendWindowMessage({ + type: 'wb:plugin:style:get', + result: { backgroundColor: '#abc' }, + error: null, + }); + await expect(p).resolves.toEqual({ backgroundColor: '#abc' }); + }); + }); + + describe('destroy', () => { + it('clears listeners so further messages do not trigger callbacks', async () => { + const client = initialize(); + sendWindowMessage({ type: 'wb:plugin:init', result: {}, error: null }); + await Promise.resolve(); + + const cb = vi.fn(); + client.config.subscribe(cb); + client.destroy(); + + sendWindowMessage({ + type: 'wb:plugin:config:update', + result: { config: { x: 1 } }, + error: null, + }); + expect(cb).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/utils/__tests__/deepEqual.test.ts b/src/utils/__tests__/deepEqual.test.ts new file mode 100644 index 0000000..a57783c --- /dev/null +++ b/src/utils/__tests__/deepEqual.test.ts @@ -0,0 +1,91 @@ +import { deepEqual } from '../deepEqual'; + +describe('deepEqual', () => { + describe('primitive comparisons', () => { + it('returns true for strictly equal primitives', () => { + expect(deepEqual(1, 1)).toBe(true); + expect(deepEqual('a', 'a')).toBe(true); + expect(deepEqual(true, true)).toBe(true); + expect(deepEqual(false, false)).toBe(true); + expect(deepEqual(null, null)).toBe(true); + expect(deepEqual(undefined, undefined)).toBe(true); + }); + + it('returns true for the same reference', () => { + const sym = Symbol('x'); + expect(deepEqual(sym, sym)).toBe(true); + + const obj = { a: 1 }; + expect(deepEqual(obj, obj)).toBe(true); + }); + + it('returns falsy for non-equal primitives', () => { + expect(deepEqual(1, 2)).toBeFalsy(); + expect(deepEqual('a', 'b')).toBeFalsy(); + expect(deepEqual(true, false)).toBeFalsy(); + expect(deepEqual(null, undefined)).toBeFalsy(); + expect(deepEqual(0, '0')).toBeFalsy(); + }); + }); + + describe('object comparisons', () => { + it('returns true for two empty objects', () => { + expect(deepEqual({}, {})).toBe(true); + }); + + it('returns true for shallowly equal objects', () => { + expect(deepEqual({ a: 1, b: 'x' }, { a: 1, b: 'x' })).toBe(true); + }); + + it('returns false for objects with different key counts', () => { + expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false); + expect(deepEqual({ a: 1, b: 2 }, { a: 1 })).toBe(false); + }); + + it('returns false for objects with the same keys but different values', () => { + expect(deepEqual({ a: 1 }, { a: 2 })).toBe(false); + }); + + it('returns true for deeply nested equal objects', () => { + expect( + deepEqual( + { a: { b: { c: [1, 2, 3] } } }, + { a: { b: { c: [1, 2, 3] } } }, + ), + ).toBe(true); + }); + + it('returns false for deeply nested objects that differ', () => { + expect( + deepEqual( + { a: { b: { c: [1, 2, 3] } } }, + { a: { b: { c: [1, 2, 4] } } }, + ), + ).toBe(false); + }); + + it('compares arrays as objects', () => { + expect(deepEqual([1, 2, 3], [1, 2, 3])).toBe(true); + expect(deepEqual([1, 2], [1, 2, 3])).toBe(false); + expect(deepEqual([1, 2, 3], [3, 2, 1])).toBe(false); + }); + }); + + describe('mixed type comparisons', () => { + it('returns falsy when comparing an object against null', () => { + expect(deepEqual({}, null)).toBeFalsy(); + expect(deepEqual(null, {})).toBeFalsy(); + }); + + it('returns falsy when comparing an object against a primitive', () => { + expect(deepEqual({}, 1)).toBeFalsy(); + expect(deepEqual(1, {})).toBeFalsy(); + expect(deepEqual([], 'a')).toBeFalsy(); + }); + + it('returns falsy when comparing an object against undefined', () => { + expect(deepEqual({}, undefined)).toBeFalsy(); + expect(deepEqual(undefined, {})).toBeFalsy(); + }); + }); +}); diff --git a/src/utils/__tests__/polyfillRequestAnimationFrame.test.ts b/src/utils/__tests__/polyfillRequestAnimationFrame.test.ts new file mode 100644 index 0000000..9788afd --- /dev/null +++ b/src/utils/__tests__/polyfillRequestAnimationFrame.test.ts @@ -0,0 +1,56 @@ +import { polyfillRequestAnimationFrame } from '../polyfillRequestAnimationFrame'; + +describe('polyfillRequestAnimationFrame', () => { + it('replaces requestAnimationFrame with a setTimeout-based polyfill', () => { + const setTimeoutSpy = vi.fn(() => 42 as unknown as ReturnType); + const clearTimeoutSpy = vi.fn(); + const fakeWindow = { + requestAnimationFrame: () => 0, + cancelAnimationFrame: () => {}, + setTimeout: setTimeoutSpy, + clearTimeout: clearTimeoutSpy, + } as unknown as Window; + + polyfillRequestAnimationFrame(fakeWindow); + + const cb = vi.fn(); + const handle = fakeWindow.requestAnimationFrame(cb); + expect(setTimeoutSpy).toHaveBeenCalledTimes(1); + expect(setTimeoutSpy).toHaveBeenCalledWith(cb, 1000 / 60); + expect(handle).toBe(42); + }); + + it('replaces cancelAnimationFrame to delegate to clearTimeout', () => { + const setTimeoutSpy = vi.fn(); + const clearTimeoutSpy = vi.fn(); + const fakeWindow = { + requestAnimationFrame: () => 0, + cancelAnimationFrame: () => {}, + setTimeout: setTimeoutSpy, + clearTimeout: clearTimeoutSpy, + } as unknown as Window; + + polyfillRequestAnimationFrame(fakeWindow); + + fakeWindow.cancelAnimationFrame(99); + expect(clearTimeoutSpy).toHaveBeenCalledWith(99); + }); + + it('is a no-op when requestAnimationFrame is not present on the window', () => { + const fakeWindow = { + setTimeout: vi.fn(), + clearTimeout: vi.fn(), + } as unknown as Window; + + polyfillRequestAnimationFrame(fakeWindow); + + expect((fakeWindow as any).requestAnimationFrame).toBeUndefined(); + expect((fakeWindow as any).cancelAnimationFrame).toBeUndefined(); + }); + + it('polyfills the real window without throwing', () => { + expect(() => polyfillRequestAnimationFrame(window)).not.toThrow(); + expect(typeof window.requestAnimationFrame).toBe('function'); + expect(typeof window.cancelAnimationFrame).toBe('function'); + }); +}); From 17da8912c28b619fb451a65a9d936e7c716a2c7d Mon Sep 17 00:00:00 2001 From: Pearce Ropion Date: Mon, 11 May 2026 09:17:32 -0700 Subject: [PATCH 2/7] Fix tests --- package.json | 7 +- src/client/__tests__/initialize.test.ts | 235 +++++++---- src/client/initialize.ts | 2 +- src/react/__tests__/hooks.test.tsx | 517 ++++++++++++++++++++++++ vitest.config.ts | 3 + yarn.lock | 193 ++++++++- 6 files changed, 856 insertions(+), 101 deletions(-) create mode 100644 src/react/__tests__/hooks.test.tsx diff --git a/package.json b/package.json index 2e46a28..b382ba9 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,11 @@ }, "devDependencies": { "@arethetypeswrong/core": "^0.18.2", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.2", "@types/node": "^24.0.0", - "@types/react": "^19.0.0", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", "@vitest/browser-playwright": "^4.1.5", "lint-staged": "^16.4.0", "oxfmt": "^0.38.0", @@ -44,6 +47,8 @@ "oxlint-tsgolint": "^0.16.0", "playwright": "^1.49.0", "publint": "^0.3.18", + "react": "19.2.5", + "react-dom": "19.2.5", "tsdown": "^0.21.10", "typescript": "^6.0.2", "unplugin-unused": "^0.5.7", diff --git a/src/client/__tests__/initialize.test.ts b/src/client/__tests__/initialize.test.ts index 4d80fd1..613c5fe 100644 --- a/src/client/__tests__/initialize.test.ts +++ b/src/client/__tests__/initialize.test.ts @@ -1,6 +1,22 @@ +import type { MockInstance } from 'vitest'; + import { PluginInstance } from '../../types'; import { initialize } from '../initialize'; +interface PluginMessage { + type: string; + args?: unknown[]; + elementId?: string; +} + +type PostMessageFn = (message: PluginMessage, targetOrigin: string) => void; + +// `window.postMessage` has multiple overloads in lib.dom, which makes the +// inferred `MockInstance` lose its `calls` arg types. We narrow to the exact +// shape `initialize.ts` always passes (`{ type, args, elementId }`, targetOrigin) +// so `spy.mock.calls` is properly typed at use sites. +type PostMessageSpy = MockInstance; + function sendWindowMessage(data: { type: string; result?: unknown; @@ -9,16 +25,37 @@ function sendWindowMessage(data: { window.dispatchEvent(new MessageEvent('message', { data })); } -type PostMessageSpy = ReturnType; - function postMessages(spy: PostMessageSpy) { - return spy.mock.calls.map( - call => ({ data: call[0] as any, origin: call[1] as string }), - ); + return spy.mock.calls.map(call => ({ data: call[0], origin: call[1] })); } function findPostMessage(spy: PostMessageSpy, type: string) { - return postMessages(spy).find(c => c.data.type === type); + return postMessages(spy).find(message => message.data.type === type); +} + +// Initializes a client while capturing the source's `message` listener so +// tests can invoke it directly. Direct invocation lets thrown errors propagate +// synchronously to `expect().toThrow` instead of bubbling out as uncaught +// errors (which Vite + Vitest each log to the console). +function initializeAndCaptureMessageListener() { + let messageListener: ((event: unknown) => void) | undefined; + const original = window.addEventListener.bind(window); + const spy = vi.spyOn(window, 'addEventListener').mockImplementation((( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ) => { + if (type === 'message') { + messageListener = listener as (event: unknown) => void; + } + return original(type, listener, options); + }) as typeof window.addEventListener); + const client = initialize(); + spy.mockRestore(); + if (!messageListener) { + throw new Error('Failed to capture message listener'); + } + return { client, messageListener }; } describe('initialize', () => { @@ -52,11 +89,13 @@ describe('initialize', () => { const removeSpy = vi.spyOn(window, 'removeEventListener'); const client = initialize(); - const messageAdd = addSpy.mock.calls.find(c => c[0] === 'message'); + const messageAdd = addSpy.mock.calls.find(call => call[0] === 'message'); expect(messageAdd).toBeDefined(); client.destroy(); - const messageRemove = removeSpy.mock.calls.find(c => c[0] === 'message'); + const messageRemove = removeSpy.mock.calls.find( + call => call[0] === 'message', + ); expect(messageRemove).toBeDefined(); // The same listener reference is used for add and remove expect(messageRemove?.[1]).toBe(messageAdd?.[1]); @@ -70,7 +109,7 @@ describe('initialize', () => { const init = findPostMessage(postMessageSpy, 'wb:plugin:init'); expect(init).toBeDefined(); expect(Array.isArray(init?.data.args)).toBe(true); - expect(typeof init?.data.args[0]).toBe('string'); + expect(typeof init?.data.args?.[0]).toBe('string'); client.destroy(); }); @@ -126,12 +165,12 @@ describe('initialize', () => { client.destroy(); }); - it('silently ignores invalid frameId and sessionId in the vitest browser', () => { + it('silently ignores invalid iframeId and sessionId in the vitest browser', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); window.history.replaceState( {}, '', - '/?frameId=notJson&sessionId=alsoNotJson', + '/?iframeId=notJson&sessionId=alsoNotJson', ); const client = initialize(); expect(errorSpy).not.toHaveBeenCalled(); @@ -167,9 +206,9 @@ describe('initialize', () => { error: null, }); await Promise.resolve(); - expect((client as unknown as { isScreenshot: boolean }).isScreenshot).toBe( - true, - ); + expect( + (client as unknown as { isScreenshot: boolean }).isScreenshot, + ).toBe(true); client.destroy(); }); }); @@ -198,9 +237,9 @@ describe('initialize', () => { it('getKey returns a value from the config', () => { expect( - (client.config as unknown as { getKey: (k: string) => unknown }).getKey( - 'initial', - ), + ( + client.config as unknown as { getKey: (key: string) => unknown } + ).getKey('initial'), ).toBe('x'); }); @@ -213,7 +252,7 @@ describe('initialize', () => { it('setKey posts wb:plugin:config:update with a single key/value', () => { ( client.config as unknown as { - setKey: (k: string, v: unknown) => void; + setKey: (key: string, value: unknown) => void; } ).setKey('newKey', 'newVal'); const msg = findPostMessage(postMessageSpy, 'wb:plugin:config:update'); @@ -236,7 +275,7 @@ describe('initialize', () => { client.config.subscribe(listener); sendWindowMessage({ type: 'wb:plugin:config:update', - result: {}, + result: { config: null }, error: null, }); expect(listener).toHaveBeenCalledWith({}); @@ -257,7 +296,9 @@ describe('initialize', () => { it('getVariable returns the subscribed variable for the given id', () => { sendWindowMessage({ type: 'wb:plugin:variable:update', - result: { v1: { name: 'v1', defaultValue: { type: 'text', value: 'hi' } } }, + result: { + v1: { name: 'v1', defaultValue: { type: 'text', value: 'hi' } }, + }, error: null, }); expect(client.config.getVariable('v1')).toEqual({ @@ -273,29 +314,29 @@ describe('initialize', () => { }); it('subscribeToWorkbookVariable invokes the callback on updates', () => { - const cb = vi.fn(); - client.config.subscribeToWorkbookVariable('v1', cb); + const callback = vi.fn(); + client.config.subscribeToWorkbookVariable('v1', callback); sendWindowMessage({ type: 'wb:plugin:variable:update', result: { v1: { name: 'v1', defaultValue: { type: 't', value: 1 } } }, error: null, }); - expect(cb).toHaveBeenCalledWith({ + expect(callback).toHaveBeenCalledWith({ name: 'v1', defaultValue: { type: 't', value: 1 }, }); }); it('subscribeToWorkbookVariable returns a working unsubscriber', () => { - const cb = vi.fn(); - const unsub = client.config.subscribeToWorkbookVariable('v1', cb); + const callback = vi.fn(); + const unsub = client.config.subscribeToWorkbookVariable('v1', callback); unsub(); sendWindowMessage({ type: 'wb:plugin:variable:update', result: { v1: { name: 'v1', defaultValue: { type: 't', value: 2 } } }, error: null, }); - expect(cb).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); }); it('getInteraction returns the subscribed interaction selection', () => { @@ -316,26 +357,29 @@ describe('initialize', () => { }); it('subscribeToWorkbookInteraction invokes the callback on updates', () => { - const cb = vi.fn(); - client.config.subscribeToWorkbookInteraction('i1', cb); + const callback = vi.fn(); + client.config.subscribeToWorkbookInteraction('i1', callback); sendWindowMessage({ type: 'wb:plugin:selection:update', result: { i1: [{ a: { type: 't' } }] }, error: null, }); - expect(cb).toHaveBeenCalledWith([{ a: { type: 't' } }]); + expect(callback).toHaveBeenCalledWith([{ a: { type: 't' } }]); }); it('subscribeToWorkbookInteraction returns a working unsubscriber', () => { - const cb = vi.fn(); - const unsub = client.config.subscribeToWorkbookInteraction('i1', cb); + const callback = vi.fn(); + const unsub = client.config.subscribeToWorkbookInteraction( + 'i1', + callback, + ); unsub(); sendWindowMessage({ type: 'wb:plugin:selection:update', result: { i1: [{ a: { type: 't' } }] }, error: null, }); - expect(cb).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); }); it('triggerAction posts wb:plugin:action-trigger:invoke', () => { @@ -359,25 +403,38 @@ describe('initialize', () => { }); it('registerEffect returns an unregister function that detaches the effect', () => { + // Use a fresh client whose message listener we can call directly: the + // throw needs to propagate synchronously into `expect().toThrow` rather + // than escape as an uncaught error via `window.dispatchEvent`. + client.destroy(); + const captured = initializeAndCaptureMessageListener(); + client = captured.client; const fn = vi.fn(); const unreg = client.config.registerEffect('e1', fn); unreg(); expect(() => { - sendWindowMessage({ - type: 'wb:plugin:action-effect:invoke', - result: 'e1', - error: null, + captured.messageListener({ + data: { + type: 'wb:plugin:action-effect:invoke', + result: 'e1', + error: null, + }, }); }).toThrow(/Unknown action effect with name: e1/); expect(fn).not.toHaveBeenCalled(); }); it('throws when an unknown action effect is invoked', () => { + client.destroy(); + const captured = initializeAndCaptureMessageListener(); + client = captured.client; expect(() => { - sendWindowMessage({ - type: 'wb:plugin:action-effect:invoke', - result: 'unknown', - error: null, + captured.messageListener({ + data: { + type: 'wb:plugin:action-effect:invoke', + result: 'unknown', + error: null, + }, }); }).toThrow(/Unknown action effect with name: unknown/); }); @@ -417,41 +474,43 @@ describe('initialize', () => { }); it('subscribeToUrlParameter invokes the callback with the initial value', () => { - const cb = vi.fn(); - client.config.subscribeToUrlParameter('u1', cb); - expect(cb).toHaveBeenCalledTimes(1); - expect(cb).toHaveBeenCalledWith(undefined); + const callback = vi.fn(); + client.config.subscribeToUrlParameter('u1', callback); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(undefined); }); it('subscribeToUrlParameter invokes the callback on updates', () => { - const cb = vi.fn(); - client.config.subscribeToUrlParameter('u1', cb); - cb.mockClear(); + const callback = vi.fn(); + client.config.subscribeToUrlParameter('u1', callback); + callback.mockClear(); sendWindowMessage({ type: 'wb:plugin:url-parameter:update', result: { u1: { value: 'V' } }, error: null, }); - expect(cb).toHaveBeenCalledWith({ value: 'V' }); + expect(callback).toHaveBeenCalledWith({ value: 'V' }); }); it('subscribeToUrlParameter returns a working unsubscriber', () => { - const cb = vi.fn(); - const unsub = client.config.subscribeToUrlParameter('u1', cb); + const callback = vi.fn(); + const unsub = client.config.subscribeToUrlParameter('u1', callback); unsub(); - cb.mockClear(); + callback.mockClear(); sendWindowMessage({ type: 'wb:plugin:url-parameter:update', result: { u1: { value: 'X' } }, error: null, }); - expect(cb).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); }); it('warns through validateConfigId when configId is undefined', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); client.config.getVariable(undefined as unknown as string); - expect(warnSpy).toHaveBeenCalledWith('Invalid config variable: undefined'); + expect(warnSpy).toHaveBeenCalledWith( + 'Invalid config variable: undefined', + ); warnSpy.mockRestore(); }); }); @@ -471,8 +530,8 @@ describe('initialize', () => { }); it('getElementColumns posts wb:plugin:element:columns:get and returns a Promise', () => { - const p = client.elements.getElementColumns('el1'); - expect(p).toBeInstanceOf(Promise); + const promise = client.elements.getElementColumns('el1'); + expect(promise).toBeInstanceOf(Promise); const msg = findPostMessage( postMessageSpy, 'wb:plugin:element:columns:get', @@ -481,29 +540,29 @@ describe('initialize', () => { }); it('getElementColumns resolves with the response data', async () => { - const cols = { c1: { id: 'c1', name: 'C', columnType: 'text' } }; - const p = client.elements.getElementColumns('el1'); + const columns = { c1: { id: 'c1', name: 'C', columnType: 'text' } }; + const promise = client.elements.getElementColumns('el1'); sendWindowMessage({ type: 'wb:plugin:element:columns:get', - result: cols, + result: columns, error: null, }); - await expect(p).resolves.toEqual(cols); + await expect(promise).resolves.toEqual(columns); }); it('getElementColumns rejects when the response carries an error', async () => { - const p = client.elements.getElementColumns('el1'); + const promise = client.elements.getElementColumns('el1'); sendWindowMessage({ type: 'wb:plugin:element:columns:get', result: null, error: 'boom', }); - await expect(p).rejects.toBe('boom'); + await expect(promise).rejects.toBe('boom'); }); it('subscribeToElementColumns subscribes, dispatches data, and unsubscribes', () => { - const cb = vi.fn(); - const unsub = client.elements.subscribeToElementColumns('el1', cb); + const callback = vi.fn(); + const unsub = client.elements.subscribeToElementColumns('el1', callback); const sub = findPostMessage( postMessageSpy, @@ -511,16 +570,16 @@ describe('initialize', () => { ); expect(sub?.data.args).toEqual(['el1']); - const cols = { c1: { id: 'c1', name: 'X', columnType: 'number' } }; + const columns = { c1: { id: 'c1', name: 'X', columnType: 'number' } }; sendWindowMessage({ type: 'wb:plugin:element:el1:columns', - result: cols, + result: columns, error: null, }); - expect(cb).toHaveBeenCalledWith(cols); + expect(callback).toHaveBeenCalledWith(columns, null); postMessageSpy.mockClear(); - cb.mockClear(); + callback.mockClear(); unsub(); const unsubMsg = findPostMessage( postMessageSpy, @@ -530,15 +589,15 @@ describe('initialize', () => { sendWindowMessage({ type: 'wb:plugin:element:el1:columns', - result: cols, + result: columns, error: null, }); - expect(cb).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); }); it('subscribeToElementData subscribes, dispatches data, and unsubscribes', () => { - const cb = vi.fn(); - const unsub = client.elements.subscribeToElementData('el1', cb); + const callback = vi.fn(); + const unsub = client.elements.subscribeToElementData('el1', callback); const sub = findPostMessage( postMessageSpy, @@ -552,10 +611,10 @@ describe('initialize', () => { result: data, error: null, }); - expect(cb).toHaveBeenCalledWith(data); + expect(callback).toHaveBeenCalledWith(data, null); postMessageSpy.mockClear(); - cb.mockClear(); + callback.mockClear(); unsub(); const unsubMsg = findPostMessage( postMessageSpy, @@ -568,7 +627,7 @@ describe('initialize', () => { result: data, error: null, }); - expect(cb).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); }); it('fetchMoreElementData posts wb:plugin:element:fetch-more', () => { @@ -603,38 +662,38 @@ describe('initialize', () => { }); it('subscribe receives style updates', () => { - const cb = vi.fn(); - client.style.subscribe(cb); + const callback = vi.fn(); + client.style.subscribe(callback); sendWindowMessage({ type: 'wb:plugin:style:update', - result: { backgroundColor: '#fff' }, + result: { backgroundColor: '#FFFFFF' }, error: null, }); - expect(cb).toHaveBeenCalledWith({ backgroundColor: '#fff' }); + expect(callback).toHaveBeenCalledWith({ backgroundColor: '#FFFFFF' }, null); }); it('subscribe returns a working unsubscriber', () => { - const cb = vi.fn(); - const unsub = client.style.subscribe(cb); + const callback = vi.fn(); + const unsub = client.style.subscribe(callback); unsub(); sendWindowMessage({ type: 'wb:plugin:style:update', - result: { backgroundColor: '#000' }, + result: { backgroundColor: '#000000' }, error: null, }); - expect(cb).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); }); it('get sends wb:plugin:style:get and resolves with the style', async () => { - const p = client.style.get(); + const promise = client.style.get(); const msg = findPostMessage(postMessageSpy, 'wb:plugin:style:get'); expect(msg).toBeDefined(); sendWindowMessage({ type: 'wb:plugin:style:get', - result: { backgroundColor: '#abc' }, + result: { backgroundColor: '#AABBCC' }, error: null, }); - await expect(p).resolves.toEqual({ backgroundColor: '#abc' }); + await expect(promise).resolves.toEqual({ backgroundColor: '#AABBCC' }); }); }); @@ -644,8 +703,8 @@ describe('initialize', () => { sendWindowMessage({ type: 'wb:plugin:init', result: {}, error: null }); await Promise.resolve(); - const cb = vi.fn(); - client.config.subscribe(cb); + const callback = vi.fn(); + client.config.subscribe(callback); client.destroy(); sendWindowMessage({ @@ -653,7 +712,7 @@ describe('initialize', () => { result: { config: { x: 1 } }, error: null, }); - expect(cb).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); }); }); }); diff --git a/src/client/initialize.ts b/src/client/initialize.ts index b84e478..8a93187 100644 --- a/src/client/initialize.ts +++ b/src/client/initialize.ts @@ -28,7 +28,7 @@ export function initialize(): PluginInstance { try { pluginConfig[key] = JSON.parse(value); } catch (_err: unknown) { - if (__VITEST_BROWSER__ && (key === 'frameId' || key === 'sessionId')) { + if (__VITEST_BROWSER__ && (key === 'iframeId' || key === 'sessionId')) { // noop: vitest browser injects these into the test iframe URL } else { console.error( diff --git a/src/react/__tests__/hooks.test.tsx b/src/react/__tests__/hooks.test.tsx new file mode 100644 index 0000000..3518cd8 --- /dev/null +++ b/src/react/__tests__/hooks.test.tsx @@ -0,0 +1,517 @@ +import { act, renderHook } from '@testing-library/react'; +import * as React from 'react'; + +import { PluginInstance } from '../../types'; +import { SigmaClientProvider } from '../Provider'; +import { + useActionEffect, + useActionTrigger, + useConfig, + useEditorPanelConfig, + useElementColumns, + useElementData, + useInteraction, + useLoadingState, + usePaginatedElementData, + usePlugin, + usePluginStyle, + useUrlParameter, + useVariable, +} from '../hooks'; + +type Subscriber = (value: T) => void; + +interface MockSubscription { + fn: ReturnType; + unsubscribe: ReturnType; + emit: (value: T) => void; +} + +function createSubscription(): MockSubscription { + let callback: Subscriber | null = null; + const unsubscribe = vi.fn(); + const fn = vi.fn((...args: unknown[]) => { + callback = args[args.length - 1] as Subscriber; + return unsubscribe; + }); + return { + fn, + unsubscribe, + emit: (value: T) => callback?.(value), + }; +} + +function createMockClient() { + const subs = { + elementColumns: createSubscription(), + elementData: createSubscription(), + variable: createSubscription(), + urlParameter: createSubscription(), + interaction: createSubscription(), + config: createSubscription(), + style: createSubscription(), + }; + + const styleResolvers: Array<(value: unknown) => void> = []; + const stylePromises: Array> = []; + + const client = { + sigmaEnv: 'author' as const, + config: { + get: vi.fn(() => ({})), + getKey: vi.fn(), + set: vi.fn(), + setKey: vi.fn(), + subscribe: subs.config.fn, + configureEditorPanel: vi.fn(), + setLoadingState: vi.fn(), + getVariable: vi.fn(), + setVariable: vi.fn(), + subscribeToWorkbookVariable: subs.variable.fn, + getUrlParameter: vi.fn(), + setUrlParameter: vi.fn(), + subscribeToUrlParameter: subs.urlParameter.fn, + getInteraction: vi.fn(), + setInteraction: vi.fn(), + subscribeToWorkbookInteraction: subs.interaction.fn, + triggerAction: vi.fn(), + registerEffect: vi.fn((_id: string, _effect: () => void) => vi.fn()), + }, + elements: { + getElementColumns: vi.fn(), + subscribeToElementColumns: subs.elementColumns.fn, + subscribeToElementData: subs.elementData.fn, + fetchMoreElementData: vi.fn(), + }, + style: { + subscribe: subs.style.fn, + get: vi.fn(() => { + const promises = new Promise(resolve => { + styleResolvers.push(resolve); + }); + stylePromises.push(promises); + return promises; + }), + }, + destroy: vi.fn(), + } as unknown as PluginInstance; + + return { + client, + subs, + resolveStyleGet: (value: unknown) => { + const resolver = styleResolvers.shift(); + resolver?.(value); + return stylePromises.shift(); + }, + }; +} + +function withProvider(client: PluginInstance) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + {children} + ); + }; +} + +describe('react/hooks', () => { + describe('usePlugin', () => { + it('returns the client from context', () => { + const { client } = createMockClient(); + const { result } = renderHook(() => usePlugin(), { + wrapper: withProvider(client), + }); + expect(result.current).toBe(client); + }); + }); + + describe('useEditorPanelConfig', () => { + it('calls configureEditorPanel on mount with provided options', () => { + const { client } = createMockClient(); + const options = [{ type: 'group', name: 'g' } as any]; + renderHook(() => useEditorPanelConfig(options), { + wrapper: withProvider(client), + }); + expect(client.config.configureEditorPanel).toHaveBeenCalledWith(options); + }); + + it('does not re-call when options are deeply equal across renders', () => { + const { client } = createMockClient(); + const { rerender } = renderHook( + ({ opts }: { opts: any[] }) => useEditorPanelConfig(opts), + { + wrapper: withProvider(client), + initialProps: { opts: [{ type: 'group', name: 'g' }] as any[] }, + }, + ); + expect(client.config.configureEditorPanel).toHaveBeenCalledTimes(1); + rerender({ opts: [{ type: 'group', name: 'g' }] }); + expect(client.config.configureEditorPanel).toHaveBeenCalledTimes(1); + }); + + it('re-calls when options change', () => { + const { client } = createMockClient(); + const { rerender } = renderHook( + ({ opts }: { opts: any[] }) => useEditorPanelConfig(opts), + { + wrapper: withProvider(client), + initialProps: { opts: [{ type: 'group', name: 'a' }] as any[] }, + }, + ); + rerender({ opts: [{ type: 'group', name: 'b' }] }); + expect(client.config.configureEditorPanel).toHaveBeenCalledTimes(2); + expect(client.config.configureEditorPanel).toHaveBeenLastCalledWith([ + { type: 'group', name: 'b' }, + ]); + }); + + it('skips when nextOptions is null', () => { + const { client } = createMockClient(); + renderHook(() => useEditorPanelConfig(null as any), { + wrapper: withProvider(client), + }); + expect(client.config.configureEditorPanel).not.toHaveBeenCalled(); + }); + }); + + describe('useLoadingState', () => { + it('sets the initial loading state and returns it', () => { + const { client } = createMockClient(); + const { result } = renderHook(() => useLoadingState(true), { + wrapper: withProvider(client), + }); + expect(client.config.setLoadingState).toHaveBeenCalledWith(true); + expect(result.current[0]).toBe(true); + }); + + it('setter updates state and calls setLoadingState when value changes', () => { + const { client } = createMockClient(); + const { result } = renderHook(() => useLoadingState(true), { + wrapper: withProvider(client), + }); + (client.config.setLoadingState as any).mockClear(); + + act(() => { + result.current[1](false); + }); + + expect(result.current[0]).toBe(false); + expect(client.config.setLoadingState).toHaveBeenCalledWith(false); + }); + + it('setter is a no-op when nextState equals current state', () => { + const { client } = createMockClient(); + const { result } = renderHook(() => useLoadingState(true), { + wrapper: withProvider(client), + }); + (client.config.setLoadingState as any).mockClear(); + + act(() => { + result.current[1](true); + }); + + expect(client.config.setLoadingState).not.toHaveBeenCalled(); + }); + }); + + describe('useElementColumns', () => { + it('subscribes and returns the latest columns', () => { + const { client, subs } = createMockClient(); + const { result } = renderHook(() => useElementColumns('el1'), { + wrapper: withProvider(client), + }); + expect(client.elements.subscribeToElementColumns).toHaveBeenCalledWith( + 'el1', + expect.any(Function), + ); + expect(result.current).toEqual({}); + + const cols = { c1: { id: 'c1', name: 'C', columnType: 'text' } }; + act(() => subs.elementColumns.emit(cols)); + expect(result.current).toEqual(cols); + }); + + it('does not subscribe when configId is falsy', () => { + const { client } = createMockClient(); + renderHook(() => useElementColumns(''), { + wrapper: withProvider(client), + }); + expect(client.elements.subscribeToElementColumns).not.toHaveBeenCalled(); + }); + + it('unsubscribes on unmount', () => { + const { client, subs } = createMockClient(); + const { unmount } = renderHook(() => useElementColumns('el1'), { + wrapper: withProvider(client), + }); + unmount(); + expect(subs.elementColumns.unsubscribe).toHaveBeenCalled(); + }); + }); + + describe('useElementData', () => { + it('subscribes and returns the latest data', () => { + const { client, subs } = createMockClient(); + const { result } = renderHook(() => useElementData('el1'), { + wrapper: withProvider(client), + }); + expect(client.elements.subscribeToElementData).toHaveBeenCalledWith( + 'el1', + expect.any(Function), + ); + + const data = { c1: [1, 2, 3] }; + act(() => subs.elementData.emit(data)); + expect(result.current).toEqual(data); + }); + + it('does not subscribe when configId is falsy', () => { + const { client } = createMockClient(); + renderHook(() => useElementData(undefined as any), { + wrapper: withProvider(client), + }); + expect(client.elements.subscribeToElementData).not.toHaveBeenCalled(); + }); + }); + + describe('usePaginatedElementData', () => { + it('subscribes to data and returns it with a loadMore callback', () => { + const { client, subs } = createMockClient(); + const { result } = renderHook(() => usePaginatedElementData('el1'), { + wrapper: withProvider(client), + }); + const data = { c1: [1, 2] }; + act(() => subs.elementData.emit(data)); + expect(result.current[0]).toEqual(data); + + act(() => result.current[1]()); + expect(client.elements.fetchMoreElementData).toHaveBeenCalledWith('el1'); + }); + + it('loadMore is a no-op when configId is falsy', () => { + const { client } = createMockClient(); + const { result } = renderHook(() => usePaginatedElementData(''), { + wrapper: withProvider(client), + }); + act(() => result.current[1]()); + expect(client.elements.fetchMoreElementData).not.toHaveBeenCalled(); + }); + }); + + describe('useConfig', () => { + it('returns the full config when no key is provided', () => { + const { client, subs } = createMockClient(); + (client.config.get as any).mockReturnValue({ a: 1 }); + const { result } = renderHook(() => useConfig(), { + wrapper: withProvider(client), + }); + expect(result.current).toEqual({ a: 1 }); + + act(() => subs.config.emit({ a: 2 })); + expect(result.current).toEqual({ a: 2 }); + }); + + it('returns the keyed value when a key is provided', () => { + const { client, subs } = createMockClient(); + (client.config.getKey as any).mockImplementation( + (key: string) => (({ foo: 'bar' }) as any)[key], + ); + const { result } = renderHook(() => useConfig('foo'), { + wrapper: withProvider(client), + }); + expect(client.config.getKey).toHaveBeenCalledWith('foo'); + expect(result.current).toBe('bar'); + + act(() => subs.config.emit({ foo: 'baz' })); + expect(result.current).toBe('baz'); + }); + }); + + describe('useVariable', () => { + it('returns the initial variable from getVariable', () => { + const { client } = createMockClient(); + const variable = { + name: 'v1', + defaultValue: { type: 'text', value: 'hi' }, + }; + (client.config.getVariable as any).mockReturnValue(variable); + const { result } = renderHook(() => useVariable('v1'), { + wrapper: withProvider(client), + }); + expect(client.config.getVariable).toHaveBeenCalledWith('v1'); + expect(result.current[0]).toEqual(variable); + }); + + it('updates when the subscription emits', () => { + const { client, subs } = createMockClient(); + const { result } = renderHook(() => useVariable('v1'), { + wrapper: withProvider(client), + }); + const next = { name: 'v1', defaultValue: { type: 'text', value: 'b' } }; + act(() => subs.variable.emit(next)); + expect(result.current[0]).toEqual(next); + }); + + it('setter calls setVariable with id and values', () => { + const { client } = createMockClient(); + const { result } = renderHook(() => useVariable('v1'), { + wrapper: withProvider(client), + }); + act(() => { + result.current[1]('a', 'b'); + }); + expect(client.config.setVariable).toHaveBeenCalledWith('v1', 'a', 'b'); + }); + }); + + describe('useUrlParameter', () => { + it('returns the initial url parameter from getUrlParameter', () => { + const { client } = createMockClient(); + (client.config.getUrlParameter as any).mockReturnValue({ value: 'x' }); + const { result } = renderHook(() => useUrlParameter('u1'), { + wrapper: withProvider(client), + }); + expect(client.config.getUrlParameter).toHaveBeenCalledWith('u1'); + expect(result.current[0]).toEqual({ value: 'x' }); + }); + + it('updates when the subscription emits', () => { + const { client, subs } = createMockClient(); + const { result } = renderHook(() => useUrlParameter('u1'), { + wrapper: withProvider(client), + }); + act(() => subs.urlParameter.emit({ value: 'y' })); + expect(result.current[0]).toEqual({ value: 'y' }); + }); + + it('setter calls setUrlParameter', () => { + const { client } = createMockClient(); + const { result } = renderHook(() => useUrlParameter('u1'), { + wrapper: withProvider(client), + }); + act(() => result.current[1]('newVal')); + expect(client.config.setUrlParameter).toHaveBeenCalledWith( + 'u1', + 'newVal', + ); + }); + }); + + describe('useInteraction', () => { + it('updates state from the subscription', () => { + const { client, subs } = createMockClient(); + const { result } = renderHook(() => useInteraction('i1', 'el1'), { + wrapper: withProvider(client), + }); + expect(client.config.subscribeToWorkbookInteraction).toHaveBeenCalledWith( + 'i1', + expect.any(Function), + ); + + const selection = [{ col: { type: 'text', val: 1 } }]; + act(() => subs.interaction.emit(selection)); + expect(result.current[0]).toEqual(selection); + }); + + it('setter calls setInteraction with id, elementId, and value', () => { + const { client } = createMockClient(); + const { result } = renderHook(() => useInteraction('i1', 'el1'), { + wrapper: withProvider(client), + }); + const selection = [{ col: { type: 'text' } }]; + act(() => { + (result.current[1] as (value: typeof selection) => void)(selection); + }); + expect(client.config.setInteraction).toHaveBeenCalledWith( + 'i1', + 'el1', + selection, + ); + }); + }); + + describe('useActionTrigger', () => { + it('returns a callback that triggers the action', () => { + const { client } = createMockClient(); + const { result } = renderHook(() => useActionTrigger('a1'), { + wrapper: withProvider(client), + }); + act(() => result.current()); + expect(client.config.triggerAction).toHaveBeenCalledWith('a1'); + }); + }); + + describe('useActionEffect', () => { + it('registers an effect for the given configId', () => { + const { client } = createMockClient(); + const effect = vi.fn(); + renderHook(() => useActionEffect('e1', effect), { + wrapper: withProvider(client), + }); + expect(client.config.registerEffect).toHaveBeenCalledWith( + 'e1', + expect.any(Function), + ); + }); + + it('re-registers with the latest effect when effect changes', () => { + const { client } = createMockClient(); + const first = vi.fn(); + const second = vi.fn(); + const { rerender } = renderHook( + ({ fx }: { fx: () => void }) => useActionEffect('e1', fx), + { + wrapper: withProvider(client), + initialProps: { fx: first }, + }, + ); + expect(client.config.registerEffect).toHaveBeenCalledTimes(1); + + rerender({ fx: second }); + expect(client.config.registerEffect).toHaveBeenCalledTimes(2); + + const calls = (client.config.registerEffect as any).mock.calls; + const lastRegistered = calls[calls.length - 1][1] as () => void; + lastRegistered(); + expect(second).toHaveBeenCalled(); + expect(first).not.toHaveBeenCalled(); + }); + + it('unregisters the effect on unmount', () => { + const { client } = createMockClient(); + const unregister = vi.fn(); + (client.config.registerEffect as any).mockReturnValue(unregister); + const { unmount } = renderHook(() => useActionEffect('e1', vi.fn()), { + wrapper: withProvider(client), + }); + unmount(); + expect(unregister).toHaveBeenCalled(); + }); + }); + + describe('usePluginStyle', () => { + it('returns undefined initially and updates from style.get()', async () => { + const { client, resolveStyleGet } = createMockClient(); + const { result } = renderHook(() => usePluginStyle(), { + wrapper: withProvider(client), + }); + expect(result.current).toBeUndefined(); + expect(client.style.get).toHaveBeenCalled(); + + await act(async () => { + await resolveStyleGet({ backgroundColor: '#FFFFFF' }); + }); + expect(result.current).toEqual({ backgroundColor: '#FFFFFF' }); + }); + + it('updates when style.subscribe emits', () => { + const { client, subs } = createMockClient(); + const { result } = renderHook(() => usePluginStyle(), { + wrapper: withProvider(client), + }); + act(() => subs.style.emit({ backgroundColor: '#000000' })); + expect(result.current).toEqual({ backgroundColor: '#000000' }); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index d090783..25fc14c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,9 @@ export default defineConfig({ __VERSION__: JSON.stringify(packageJson.version), __VITEST_BROWSER__: true.toString(), }, + oxc: { + jsx: { runtime: 'automatic' }, + }, test: { globals: true, include: ['src/**/*.test.{js,jsx,ts,tsx}'], diff --git a/yarn.lock b/yarn.lock index 4b38e28..8c1f864 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28,6 +28,17 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.10.4": + version: 7.29.0 + resolution: "@babel/code-frame@npm:7.29.0" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.28.5" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.1.1" + checksum: 10c0/d34cc504e7765dfb576a663d97067afb614525806b5cad1a5cc1a7183b916fec8ff57fa233585e3926fd5a9e6b31aae6df91aa81ae9775fb7a28f658d3346f0d + languageName: node + linkType: hard + "@babel/generator@npm:8.0.0-rc.3": version: 8.0.0-rc.3 resolution: "@babel/generator@npm:8.0.0-rc.3" @@ -56,6 +67,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/helper-validator-identifier@npm:7.28.5" + checksum: 10c0/42aaebed91f739a41f3d80b72752d1f95fd7c72394e8e4bd7cdd88817e0774d80a432451bcba17c2c642c257c483bf1d409dd4548883429ea9493a3bc4ab0847 + languageName: node + linkType: hard + "@babel/helper-validator-identifier@npm:^8.0.0-rc.3, @babel/helper-validator-identifier@npm:^8.0.0-rc.4": version: 8.0.0-rc.4 resolution: "@babel/helper-validator-identifier@npm:8.0.0-rc.4" @@ -85,6 +103,13 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.12.5": + version: 7.29.2 + resolution: "@babel/runtime@npm:7.29.2" + checksum: 10c0/30b80a0140d16467792e1bbeb06f655b0dab70407da38dfac7fedae9c859f9ae9d846ef14ad77bd3814c064295fe9b1bc551f1541ea14646ae9f22b71a8bc17a + languageName: node + linkType: hard + "@babel/types@npm:8.0.0-rc.3": version: 8.0.0-rc.3 resolution: "@babel/types@npm:8.0.0-rc.3" @@ -714,8 +739,11 @@ __metadata: resolution: "@sigmacomputing/plugin@workspace:." dependencies: "@arethetypeswrong/core": "npm:^0.18.2" + "@testing-library/dom": "npm:10.4.1" + "@testing-library/react": "npm:16.3.2" "@types/node": "npm:^24.0.0" - "@types/react": "npm:^19.0.0" + "@types/react": "npm:19.2.14" + "@types/react-dom": "npm:19.2.3" "@vitest/browser-playwright": "npm:^4.1.5" lint-staged: "npm:^16.4.0" oxfmt: "npm:^0.38.0" @@ -723,6 +751,8 @@ __metadata: oxlint-tsgolint: "npm:^0.16.0" playwright: "npm:^1.49.0" publint: "npm:^0.3.18" + react: "npm:19.2.5" + react-dom: "npm:19.2.5" tsdown: "npm:^0.21.10" typescript: "npm:^6.0.2" unplugin-unused: "npm:^0.5.7" @@ -742,6 +772,42 @@ __metadata: languageName: node linkType: hard +"@testing-library/dom@npm:10.4.1": + version: 10.4.1 + resolution: "@testing-library/dom@npm:10.4.1" + dependencies: + "@babel/code-frame": "npm:^7.10.4" + "@babel/runtime": "npm:^7.12.5" + "@types/aria-query": "npm:^5.0.1" + aria-query: "npm:5.3.0" + dom-accessibility-api: "npm:^0.5.9" + lz-string: "npm:^1.5.0" + picocolors: "npm:1.1.1" + pretty-format: "npm:^27.0.2" + checksum: 10c0/19ce048012d395ad0468b0dbcc4d0911f6f9e39464d7a8464a587b29707eed5482000dad728f5acc4ed314d2f4d54f34982999a114d2404f36d048278db815b1 + languageName: node + linkType: hard + +"@testing-library/react@npm:16.3.2": + version: 16.3.2 + resolution: "@testing-library/react@npm:16.3.2" + dependencies: + "@babel/runtime": "npm:^7.12.5" + peerDependencies: + "@testing-library/dom": ^10.0.0 + "@types/react": ^18.0.0 || ^19.0.0 + "@types/react-dom": ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/f9c7f0915e1b5f7b750e6c7d8b51f091b8ae7ea99bacb761d7b8505ba25de9cfcb749a0f779f1650fb268b499dd79165dc7e1ee0b8b4cb63430d3ddc81ffe044 + languageName: node + linkType: hard + "@tybys/wasm-util@npm:^0.10.1": version: 0.10.1 resolution: "@tybys/wasm-util@npm:0.10.1" @@ -751,6 +817,13 @@ __metadata: languageName: node linkType: hard +"@types/aria-query@npm:^5.0.1": + version: 5.0.4 + resolution: "@types/aria-query@npm:5.0.4" + checksum: 10c0/dc667bc6a3acc7bba2bccf8c23d56cb1f2f4defaa704cfef595437107efaa972d3b3db9ec1d66bc2711bfc35086821edd32c302bffab36f2e79b97f312069f08 + languageName: node + linkType: hard + "@types/chai@npm:^5.2.2": version: 5.2.3 resolution: "@types/chai@npm:5.2.3" @@ -791,12 +864,21 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:^19.0.0": - version: 19.0.3 - resolution: "@types/react@npm:19.0.3" +"@types/react-dom@npm:19.2.3": + version: 19.2.3 + resolution: "@types/react-dom@npm:19.2.3" + peerDependencies: + "@types/react": ^19.2.0 + checksum: 10c0/b486ebe0f4e2fb35e2e108df1d8fc0927ca5d6002d5771e8a739de11239fe62d0e207c50886185253c99eb9dedfeeb956ea7429e5ba17f6693c7acb4c02f8cd1 + languageName: node + linkType: hard + +"@types/react@npm:19.2.14": + version: 19.2.14 + resolution: "@types/react@npm:19.2.14" dependencies: - csstype: "npm:^3.0.2" - checksum: 10c0/90129c45f2f09154d9409964964d0ccbac7f04d5f7fcf73fc146d33887931fbfdfd1e2947514298f94f986cc264aff8ba3201e9a4ea207d3308f20a06d47c805 + csstype: "npm:^3.2.2" + checksum: 10c0/7d25bf41b57719452d86d2ac0570b659210402707313a36ee612666bf11275a1c69824f8c3ee1fdca077ccfe15452f6da8f1224529b917050eb2d861e52b59b7 languageName: node linkType: hard @@ -982,6 +1064,13 @@ __metadata: languageName: node linkType: hard +"ansi-styles@npm:^5.0.0": + version: 5.2.0 + resolution: "ansi-styles@npm:5.2.0" + checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df + languageName: node + linkType: hard + "ansi-styles@npm:^6.1.0": version: 6.2.1 resolution: "ansi-styles@npm:6.2.1" @@ -1003,6 +1092,15 @@ __metadata: languageName: node linkType: hard +"aria-query@npm:5.3.0": + version: 5.3.0 + resolution: "aria-query@npm:5.3.0" + dependencies: + dequal: "npm:^2.0.3" + checksum: 10c0/2bff0d4eba5852a9dd578ecf47eaef0e82cc52569b48469b0aac2db5145db0b17b7a58d9e01237706d1e14b7a1b0ac9b78e9c97027ad97679dd8f91b85da1469 + languageName: node + linkType: hard + "assertion-error@npm:^2.0.1": version: 2.0.1 resolution: "assertion-error@npm:2.0.1" @@ -1166,10 +1264,10 @@ __metadata: languageName: node linkType: hard -"csstype@npm:^3.0.2": - version: 3.1.0 - resolution: "csstype@npm:3.1.0" - checksum: 10c0/4edcf1eb8b8e83e8b1dc557d9f61e720012e6d2453f4c05fa4221dacf604c4d7552383f0cead9adea4b3f23e3da3aa25cc4fb05823b51fb1cbad43e1d8bb45ff +"csstype@npm:^3.2.2": + version: 3.2.3 + resolution: "csstype@npm:3.2.3" + checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce languageName: node linkType: hard @@ -1192,6 +1290,13 @@ __metadata: languageName: node linkType: hard +"dequal@npm:^2.0.3": + version: 2.0.3 + resolution: "dequal@npm:2.0.3" + checksum: 10c0/f98860cdf58b64991ae10205137c0e97d384c3a4edc7f807603887b7c4b850af1224a33d88012009f150861cbee4fa2d322c4cc04b9313bee312e47f6ecaa888 + languageName: node + linkType: hard + "detect-libc@npm:^2.0.3": version: 2.1.2 resolution: "detect-libc@npm:2.1.2" @@ -1199,6 +1304,13 @@ __metadata: languageName: node linkType: hard +"dom-accessibility-api@npm:^0.5.9": + version: 0.5.16 + resolution: "dom-accessibility-api@npm:0.5.16" + checksum: 10c0/b2c2eda4fae568977cdac27a9f0c001edf4f95a6a6191dfa611e3721db2478d1badc01db5bb4fa8a848aeee13e442a6c2a4386d65ec65a1436f24715a2f8d053 + languageName: node + linkType: hard + "dts-resolver@npm:^2.1.3": version: 2.1.3 resolution: "dts-resolver@npm:2.1.3" @@ -1575,6 +1687,13 @@ __metadata: languageName: node linkType: hard +"js-tokens@npm:^4.0.0": + version: 4.0.0 + resolution: "js-tokens@npm:4.0.0" + checksum: 10c0/e248708d377aa058eacf2037b07ded847790e6de892bbad3dac0abba2e759cb9f121b00099a65195616badcb6eca8d14d975cb3e89eb1cfda644756402c8aeed + languageName: node + linkType: hard + "jsbn@npm:1.1.0": version: 1.1.0 resolution: "jsbn@npm:1.1.0" @@ -1768,6 +1887,15 @@ __metadata: languageName: node linkType: hard +"lz-string@npm:^1.5.0": + version: 1.5.0 + resolution: "lz-string@npm:1.5.0" + bin: + lz-string: bin/bin.js + checksum: 10c0/36128e4de34791838abe979b19927c26e67201ca5acf00880377af7d765b38d1c60847e01c5ec61b1a260c48029084ab3893a3925fd6e48a04011364b089991b + languageName: node + linkType: hard + "magic-string@npm:^0.30.21": version: 0.30.21 resolution: "magic-string@npm:0.30.21" @@ -2208,7 +2336,7 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:^1.1.1": +"picocolors@npm:1.1.1, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 @@ -2264,6 +2392,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^27.0.2": + version: 27.5.1 + resolution: "pretty-format@npm:27.5.1" + dependencies: + ansi-regex: "npm:^5.0.1" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^17.0.1" + checksum: 10c0/0cbda1031aa30c659e10921fa94e0dd3f903ecbbbe7184a729ad66f2b6e7f17891e8c7d7654c458fa4ccb1a411ffb695b4f17bbcd3fe075fabe181027c4040ed + languageName: node + linkType: hard + "proc-log@npm:^4.1.0, proc-log@npm:^4.2.0": version: 4.2.0 resolution: "proc-log@npm:4.2.0" @@ -2302,6 +2441,31 @@ __metadata: languageName: node linkType: hard +"react-dom@npm:19.2.5": + version: 19.2.5 + resolution: "react-dom@npm:19.2.5" + dependencies: + scheduler: "npm:^0.27.0" + peerDependencies: + react: ^19.2.5 + checksum: 10c0/8067606e9f58e4c2e8cb5f09570217dbc71c4843ebcaa20ae2085912d3e3a351f17d8f7c1713313cdda7f272840c8c34ff6c860fcb840862071bceea218e0c63 + languageName: node + linkType: hard + +"react-is@npm:^17.0.1": + version: 17.0.2 + resolution: "react-is@npm:17.0.2" + checksum: 10c0/2bdb6b93fbb1820b024b496042cce405c57e2f85e777c9aabd55f9b26d145408f9f74f5934676ffdc46f3dcff656d78413a6e43968e7b3f92eea35b3052e9053 + languageName: node + linkType: hard + +"react@npm:19.2.5": + version: 19.2.5 + resolution: "react@npm:19.2.5" + checksum: 10c0/4b5f231dbef92886f602533c9ce3bde04d99f0e71dfb5d794c43e02726efaad0421c08688f75fc98a6d6e1dc017372e1af7abbfecdc86a79968f461675931a7a + languageName: node + linkType: hard + "resolve-pkg-maps@npm:^1.0.0": version: 1.0.0 resolution: "resolve-pkg-maps@npm:1.0.0" @@ -2440,6 +2604,13 @@ __metadata: languageName: node linkType: hard +"scheduler@npm:^0.27.0": + version: 0.27.0 + resolution: "scheduler@npm:0.27.0" + checksum: 10c0/4f03048cb05a3c8fddc45813052251eca00688f413a3cee236d984a161da28db28ba71bd11e7a3dd02f7af84ab28d39fb311431d3b3772fed557945beb00c452 + languageName: node + linkType: hard + "semver@npm:^7.3.5": version: 7.6.3 resolution: "semver@npm:7.6.3" From 2557d3cfe66c9c5b97b86161f368fa7b2744a9b9 Mon Sep 17 00:00:00 2001 From: Pearce Ropion Date: Mon, 11 May 2026 09:19:20 -0700 Subject: [PATCH 3/7] Fix format --- src/__tests__/client.test.ts | 4 +++- src/client/__tests__/initialize.test.ts | 5 ++++- src/utils/__tests__/polyfillRequestAnimationFrame.test.ts | 4 +++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index dcf1d29..2caa7be 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -18,7 +18,9 @@ describe('client', () => { expect(typeof client.config.subscribeToWorkbookVariable).toBe('function'); expect(typeof client.config.getInteraction).toBe('function'); expect(typeof client.config.setInteraction).toBe('function'); - expect(typeof client.config.subscribeToWorkbookInteraction).toBe('function'); + expect(typeof client.config.subscribeToWorkbookInteraction).toBe( + 'function', + ); expect(typeof client.config.triggerAction).toBe('function'); expect(typeof client.config.registerEffect).toBe('function'); expect(typeof client.config.configureEditorPanel).toBe('function'); diff --git a/src/client/__tests__/initialize.test.ts b/src/client/__tests__/initialize.test.ts index 613c5fe..3652fb6 100644 --- a/src/client/__tests__/initialize.test.ts +++ b/src/client/__tests__/initialize.test.ts @@ -669,7 +669,10 @@ describe('initialize', () => { result: { backgroundColor: '#FFFFFF' }, error: null, }); - expect(callback).toHaveBeenCalledWith({ backgroundColor: '#FFFFFF' }, null); + expect(callback).toHaveBeenCalledWith( + { backgroundColor: '#FFFFFF' }, + null, + ); }); it('subscribe returns a working unsubscriber', () => { diff --git a/src/utils/__tests__/polyfillRequestAnimationFrame.test.ts b/src/utils/__tests__/polyfillRequestAnimationFrame.test.ts index 9788afd..bc7ddcf 100644 --- a/src/utils/__tests__/polyfillRequestAnimationFrame.test.ts +++ b/src/utils/__tests__/polyfillRequestAnimationFrame.test.ts @@ -2,7 +2,9 @@ import { polyfillRequestAnimationFrame } from '../polyfillRequestAnimationFrame' describe('polyfillRequestAnimationFrame', () => { it('replaces requestAnimationFrame with a setTimeout-based polyfill', () => { - const setTimeoutSpy = vi.fn(() => 42 as unknown as ReturnType); + const setTimeoutSpy = vi.fn( + () => 42 as unknown as ReturnType, + ); const clearTimeoutSpy = vi.fn(); const fakeWindow = { requestAnimationFrame: () => 0, From 2e8fca8bd68751fccc2c1652dc24106e028252cb Mon Sep 17 00:00:00 2001 From: Pearce Ropion Date: Mon, 11 May 2026 09:22:52 -0700 Subject: [PATCH 4/7] Optimize deps --- vitest.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index 25fc14c..a3de5fd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,9 @@ export default defineConfig({ oxc: { jsx: { runtime: 'automatic' }, }, + optimizeDeps: { + include: ['react/jsx-dev-runtime'], + }, test: { globals: true, include: ['src/**/*.test.{js,jsx,ts,tsx}'], From f7de85ed39c474b2e9b72e581dab93096a605c65 Mon Sep 17 00:00:00 2001 From: Pearce Ropion Date: Thu, 14 May 2026 08:38:12 -0700 Subject: [PATCH 5/7] Actually review the tests --- src/__tests__/client.test.ts | 33 -- src/client/initialize.ts | 2 +- src/react/__tests__/hooks.test.tsx | 307 +++++++++--------- src/{ => utils}/__tests__/error.test.ts | 32 +- .../polyfillRequestAnimationFrame.test.ts | 6 +- src/{ => utils}/error.ts | 2 +- src/utils/polyfillRequestAnimationFrame.ts | 13 +- 7 files changed, 163 insertions(+), 232 deletions(-) rename src/{ => utils}/__tests__/error.test.ts (61%) rename src/{ => utils}/error.ts (80%) diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 2caa7be..090faa4 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -8,37 +8,4 @@ describe('client', () => { expect(client.style).toBeDefined(); expect(typeof client.destroy).toBe('function'); }); - - it('exposes the documented config methods', () => { - expect(typeof client.config.get).toBe('function'); - expect(typeof client.config.set).toBe('function'); - expect(typeof client.config.subscribe).toBe('function'); - expect(typeof client.config.getVariable).toBe('function'); - expect(typeof client.config.setVariable).toBe('function'); - expect(typeof client.config.subscribeToWorkbookVariable).toBe('function'); - expect(typeof client.config.getInteraction).toBe('function'); - expect(typeof client.config.setInteraction).toBe('function'); - expect(typeof client.config.subscribeToWorkbookInteraction).toBe( - 'function', - ); - expect(typeof client.config.triggerAction).toBe('function'); - expect(typeof client.config.registerEffect).toBe('function'); - expect(typeof client.config.configureEditorPanel).toBe('function'); - expect(typeof client.config.setLoadingState).toBe('function'); - expect(typeof client.config.getUrlParameter).toBe('function'); - expect(typeof client.config.setUrlParameter).toBe('function'); - expect(typeof client.config.subscribeToUrlParameter).toBe('function'); - }); - - it('exposes the documented elements methods', () => { - expect(typeof client.elements.getElementColumns).toBe('function'); - expect(typeof client.elements.subscribeToElementColumns).toBe('function'); - expect(typeof client.elements.subscribeToElementData).toBe('function'); - expect(typeof client.elements.fetchMoreElementData).toBe('function'); - }); - - it('exposes the documented style methods', () => { - expect(typeof client.style.subscribe).toBe('function'); - expect(typeof client.style.get).toBe('function'); - }); }); diff --git a/src/client/initialize.ts b/src/client/initialize.ts index 8a93187..8f9c9fe 100644 --- a/src/client/initialize.ts +++ b/src/client/initialize.ts @@ -1,4 +1,3 @@ -import { validateConfigId } from '../error'; import { PluginConfig, PluginInstance, @@ -8,6 +7,7 @@ import { WorkbookSelection, WorkbookVariable, } from '../types'; +import { validateConfigId } from '../utils/error'; export function initialize(): PluginInstance { const pluginConfig: Partial> = { diff --git a/src/react/__tests__/hooks.test.tsx b/src/react/__tests__/hooks.test.tsx index 3518cd8..9e47021 100644 --- a/src/react/__tests__/hooks.test.tsx +++ b/src/react/__tests__/hooks.test.tsx @@ -1,6 +1,7 @@ import { act, renderHook } from '@testing-library/react'; import * as React from 'react'; +import { initialize } from '../../client/initialize'; import { PluginInstance } from '../../types'; import { SigmaClientProvider } from '../Provider'; import { @@ -21,90 +22,42 @@ import { type Subscriber = (value: T) => void; -interface MockSubscription { - fn: ReturnType; +interface SubscriptionStub { unsubscribe: ReturnType; emit: (value: T) => void; } -function createSubscription(): MockSubscription { +// Replaces a subscribe-style method on the real client with a stub that +// captures the callback (so tests can synchronously emit values to the hook) +// and returns a vi.fn() unsubscriber that tests can assert against. +function stubSubscription( + target: object, + method: string, +): SubscriptionStub { let callback: Subscriber | null = null; const unsubscribe = vi.fn(); - const fn = vi.fn((...args: unknown[]) => { - callback = args[args.length - 1] as Subscriber; - return unsubscribe; - }); + vi.spyOn(target as any, method as any).mockImplementation( + ((...args: unknown[]) => { + callback = args[args.length - 1] as Subscriber; + return unsubscribe; + }) as never, + ); return { - fn, unsubscribe, emit: (value: T) => callback?.(value), }; } -function createMockClient() { - const subs = { - elementColumns: createSubscription(), - elementData: createSubscription(), - variable: createSubscription(), - urlParameter: createSubscription(), - interaction: createSubscription(), - config: createSubscription(), - style: createSubscription(), - }; - - const styleResolvers: Array<(value: unknown) => void> = []; - const stylePromises: Array> = []; - - const client = { - sigmaEnv: 'author' as const, - config: { - get: vi.fn(() => ({})), - getKey: vi.fn(), - set: vi.fn(), - setKey: vi.fn(), - subscribe: subs.config.fn, - configureEditorPanel: vi.fn(), - setLoadingState: vi.fn(), - getVariable: vi.fn(), - setVariable: vi.fn(), - subscribeToWorkbookVariable: subs.variable.fn, - getUrlParameter: vi.fn(), - setUrlParameter: vi.fn(), - subscribeToUrlParameter: subs.urlParameter.fn, - getInteraction: vi.fn(), - setInteraction: vi.fn(), - subscribeToWorkbookInteraction: subs.interaction.fn, - triggerAction: vi.fn(), - registerEffect: vi.fn((_id: string, _effect: () => void) => vi.fn()), - }, - elements: { - getElementColumns: vi.fn(), - subscribeToElementColumns: subs.elementColumns.fn, - subscribeToElementData: subs.elementData.fn, - fetchMoreElementData: vi.fn(), - }, - style: { - subscribe: subs.style.fn, - get: vi.fn(() => { - const promises = new Promise(resolve => { - styleResolvers.push(resolve); - }); - stylePromises.push(promises); - return promises; - }), - }, - destroy: vi.fn(), - } as unknown as PluginInstance; - - return { - client, - subs, - resolveStyleGet: (value: unknown) => { - const resolver = styleResolvers.shift(); - resolver?.(value); - return stylePromises.shift(); - }, - }; +// Stubs client.style.get to return a promise the test controls — the real +// implementation would resolve only after a wb:plugin:style:get postMessage +// round-trip we don't simulate here. +function stubStyleGet(client: PluginInstance) { + let resolve!: (value: unknown) => void; + const promise = new Promise(r => { + resolve = r; + }); + vi.spyOn(client.style, 'get').mockReturnValue(promise as never); + return { resolve }; } function withProvider(client: PluginInstance) { @@ -116,9 +69,22 @@ function withProvider(client: PluginInstance) { } describe('react/hooks', () => { + let client: PluginInstance; + + beforeEach(() => { + // Prevent the real client from posting messages to window.parent during + // initialize() and from any spied-through method that calls execPromise. + vi.spyOn(window.parent, 'postMessage').mockImplementation(() => {}); + client = initialize(); + }); + + afterEach(() => { + client.destroy(); + vi.restoreAllMocks(); + }); + describe('usePlugin', () => { it('returns the client from context', () => { - const { client } = createMockClient(); const { result } = renderHook(() => usePlugin(), { wrapper: withProvider(client), }); @@ -128,16 +94,16 @@ describe('react/hooks', () => { describe('useEditorPanelConfig', () => { it('calls configureEditorPanel on mount with provided options', () => { - const { client } = createMockClient(); + const spy = vi.spyOn(client.config, 'configureEditorPanel'); const options = [{ type: 'group', name: 'g' } as any]; renderHook(() => useEditorPanelConfig(options), { wrapper: withProvider(client), }); - expect(client.config.configureEditorPanel).toHaveBeenCalledWith(options); + expect(spy).toHaveBeenCalledWith(options); }); it('does not re-call when options are deeply equal across renders', () => { - const { client } = createMockClient(); + const spy = vi.spyOn(client.config, 'configureEditorPanel'); const { rerender } = renderHook( ({ opts }: { opts: any[] }) => useEditorPanelConfig(opts), { @@ -145,13 +111,13 @@ describe('react/hooks', () => { initialProps: { opts: [{ type: 'group', name: 'g' }] as any[] }, }, ); - expect(client.config.configureEditorPanel).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(1); rerender({ opts: [{ type: 'group', name: 'g' }] }); - expect(client.config.configureEditorPanel).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(1); }); it('re-calls when options change', () => { - const { client } = createMockClient(); + const spy = vi.spyOn(client.config, 'configureEditorPanel'); const { rerender } = renderHook( ({ opts }: { opts: any[] }) => useEditorPanelConfig(opts), { @@ -160,64 +126,65 @@ describe('react/hooks', () => { }, ); rerender({ opts: [{ type: 'group', name: 'b' }] }); - expect(client.config.configureEditorPanel).toHaveBeenCalledTimes(2); - expect(client.config.configureEditorPanel).toHaveBeenLastCalledWith([ - { type: 'group', name: 'b' }, - ]); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenLastCalledWith([{ type: 'group', name: 'b' }]); }); it('skips when nextOptions is null', () => { - const { client } = createMockClient(); + const spy = vi.spyOn(client.config, 'configureEditorPanel'); renderHook(() => useEditorPanelConfig(null as any), { wrapper: withProvider(client), }); - expect(client.config.configureEditorPanel).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); }); }); describe('useLoadingState', () => { it('sets the initial loading state and returns it', () => { - const { client } = createMockClient(); + const spy = vi.spyOn(client.config, 'setLoadingState'); const { result } = renderHook(() => useLoadingState(true), { wrapper: withProvider(client), }); - expect(client.config.setLoadingState).toHaveBeenCalledWith(true); + expect(spy).toHaveBeenCalledWith(true); expect(result.current[0]).toBe(true); }); it('setter updates state and calls setLoadingState when value changes', () => { - const { client } = createMockClient(); + const spy = vi.spyOn(client.config, 'setLoadingState'); const { result } = renderHook(() => useLoadingState(true), { wrapper: withProvider(client), }); - (client.config.setLoadingState as any).mockClear(); + spy.mockClear(); act(() => { result.current[1](false); }); expect(result.current[0]).toBe(false); - expect(client.config.setLoadingState).toHaveBeenCalledWith(false); + expect(spy).toHaveBeenCalledWith(false); }); it('setter is a no-op when nextState equals current state', () => { - const { client } = createMockClient(); + const spy = vi.spyOn(client.config, 'setLoadingState'); const { result } = renderHook(() => useLoadingState(true), { wrapper: withProvider(client), }); - (client.config.setLoadingState as any).mockClear(); + spy.mockClear(); act(() => { result.current[1](true); }); - expect(client.config.setLoadingState).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); }); }); describe('useElementColumns', () => { it('subscribes and returns the latest columns', () => { - const { client, subs } = createMockClient(); + const sub = stubSubscription( + client.elements, + 'subscribeToElementColumns', + ); const { result } = renderHook(() => useElementColumns('el1'), { wrapper: withProvider(client), }); @@ -228,31 +195,37 @@ describe('react/hooks', () => { expect(result.current).toEqual({}); const cols = { c1: { id: 'c1', name: 'C', columnType: 'text' } }; - act(() => subs.elementColumns.emit(cols)); + act(() => sub.emit(cols)); expect(result.current).toEqual(cols); }); it('does not subscribe when configId is falsy', () => { - const { client } = createMockClient(); + const spy = vi.spyOn(client.elements, 'subscribeToElementColumns'); renderHook(() => useElementColumns(''), { wrapper: withProvider(client), }); - expect(client.elements.subscribeToElementColumns).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); }); it('unsubscribes on unmount', () => { - const { client, subs } = createMockClient(); + const sub = stubSubscription( + client.elements, + 'subscribeToElementColumns', + ); const { unmount } = renderHook(() => useElementColumns('el1'), { wrapper: withProvider(client), }); unmount(); - expect(subs.elementColumns.unsubscribe).toHaveBeenCalled(); + expect(sub.unsubscribe).toHaveBeenCalled(); }); }); describe('useElementData', () => { it('subscribes and returns the latest data', () => { - const { client, subs } = createMockClient(); + const sub = stubSubscription( + client.elements, + 'subscribeToElementData', + ); const { result } = renderHook(() => useElementData('el1'), { wrapper: withProvider(client), }); @@ -262,145 +235,165 @@ describe('react/hooks', () => { ); const data = { c1: [1, 2, 3] }; - act(() => subs.elementData.emit(data)); + act(() => sub.emit(data)); expect(result.current).toEqual(data); }); it('does not subscribe when configId is falsy', () => { - const { client } = createMockClient(); + const spy = vi.spyOn(client.elements, 'subscribeToElementData'); renderHook(() => useElementData(undefined as any), { wrapper: withProvider(client), }); - expect(client.elements.subscribeToElementData).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); }); }); describe('usePaginatedElementData', () => { it('subscribes to data and returns it with a loadMore callback', () => { - const { client, subs } = createMockClient(); + const sub = stubSubscription( + client.elements, + 'subscribeToElementData', + ); + const fetchSpy = vi.spyOn(client.elements, 'fetchMoreElementData'); const { result } = renderHook(() => usePaginatedElementData('el1'), { wrapper: withProvider(client), }); const data = { c1: [1, 2] }; - act(() => subs.elementData.emit(data)); + act(() => sub.emit(data)); expect(result.current[0]).toEqual(data); act(() => result.current[1]()); - expect(client.elements.fetchMoreElementData).toHaveBeenCalledWith('el1'); + expect(fetchSpy).toHaveBeenCalledWith('el1'); }); it('loadMore is a no-op when configId is falsy', () => { - const { client } = createMockClient(); + const fetchSpy = vi.spyOn(client.elements, 'fetchMoreElementData'); const { result } = renderHook(() => usePaginatedElementData(''), { wrapper: withProvider(client), }); act(() => result.current[1]()); - expect(client.elements.fetchMoreElementData).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); }); }); describe('useConfig', () => { it('returns the full config when no key is provided', () => { - const { client, subs } = createMockClient(); - (client.config.get as any).mockReturnValue({ a: 1 }); + vi.spyOn(client.config, 'get').mockReturnValue({ a: 1 }); + const sub = stubSubscription(client.config, 'subscribe'); const { result } = renderHook(() => useConfig(), { wrapper: withProvider(client), }); expect(result.current).toEqual({ a: 1 }); - act(() => subs.config.emit({ a: 2 })); + act(() => sub.emit({ a: 2 })); expect(result.current).toEqual({ a: 2 }); }); it('returns the keyed value when a key is provided', () => { - const { client, subs } = createMockClient(); - (client.config.getKey as any).mockImplementation( - (key: string) => (({ foo: 'bar' }) as any)[key], - ); + const getKeySpy = vi + .spyOn(client.config, 'getKey') + .mockImplementation( + key => + (({ foo: 'bar' }) as Record)[ + key as string + ] as never, + ); + const sub = stubSubscription(client.config, 'subscribe'); const { result } = renderHook(() => useConfig('foo'), { wrapper: withProvider(client), }); - expect(client.config.getKey).toHaveBeenCalledWith('foo'); + expect(getKeySpy).toHaveBeenCalledWith('foo'); expect(result.current).toBe('bar'); - act(() => subs.config.emit({ foo: 'baz' })); + act(() => sub.emit({ foo: 'baz' })); expect(result.current).toBe('baz'); }); }); describe('useVariable', () => { it('returns the initial variable from getVariable', () => { - const { client } = createMockClient(); const variable = { name: 'v1', defaultValue: { type: 'text', value: 'hi' }, }; - (client.config.getVariable as any).mockReturnValue(variable); + const getSpy = vi + .spyOn(client.config, 'getVariable') + .mockReturnValue(variable as any); const { result } = renderHook(() => useVariable('v1'), { wrapper: withProvider(client), }); - expect(client.config.getVariable).toHaveBeenCalledWith('v1'); + expect(getSpy).toHaveBeenCalledWith('v1'); expect(result.current[0]).toEqual(variable); }); it('updates when the subscription emits', () => { - const { client, subs } = createMockClient(); + const sub = stubSubscription( + client.config, + 'subscribeToWorkbookVariable', + ); const { result } = renderHook(() => useVariable('v1'), { wrapper: withProvider(client), }); const next = { name: 'v1', defaultValue: { type: 'text', value: 'b' } }; - act(() => subs.variable.emit(next)); + act(() => sub.emit(next)); expect(result.current[0]).toEqual(next); }); it('setter calls setVariable with id and values', () => { - const { client } = createMockClient(); + const setSpy = vi.spyOn(client.config, 'setVariable'); const { result } = renderHook(() => useVariable('v1'), { wrapper: withProvider(client), }); act(() => { result.current[1]('a', 'b'); }); - expect(client.config.setVariable).toHaveBeenCalledWith('v1', 'a', 'b'); + expect(setSpy).toHaveBeenCalledWith('v1', 'a', 'b'); }); }); describe('useUrlParameter', () => { it('returns the initial url parameter from getUrlParameter', () => { - const { client } = createMockClient(); - (client.config.getUrlParameter as any).mockReturnValue({ value: 'x' }); + const getSpy = vi + .spyOn(client.config, 'getUrlParameter') + .mockReturnValue({ value: 'x' } as any); + // Stub subscribe so its real impl does not immediately emit + // the (empty) cached parameter and clobber the initial value. + stubSubscription(client.config, 'subscribeToUrlParameter'); const { result } = renderHook(() => useUrlParameter('u1'), { wrapper: withProvider(client), }); - expect(client.config.getUrlParameter).toHaveBeenCalledWith('u1'); + expect(getSpy).toHaveBeenCalledWith('u1'); expect(result.current[0]).toEqual({ value: 'x' }); }); it('updates when the subscription emits', () => { - const { client, subs } = createMockClient(); + const sub = stubSubscription( + client.config, + 'subscribeToUrlParameter', + ); const { result } = renderHook(() => useUrlParameter('u1'), { wrapper: withProvider(client), }); - act(() => subs.urlParameter.emit({ value: 'y' })); + act(() => sub.emit({ value: 'y' })); expect(result.current[0]).toEqual({ value: 'y' }); }); it('setter calls setUrlParameter', () => { - const { client } = createMockClient(); + const setSpy = vi.spyOn(client.config, 'setUrlParameter'); const { result } = renderHook(() => useUrlParameter('u1'), { wrapper: withProvider(client), }); act(() => result.current[1]('newVal')); - expect(client.config.setUrlParameter).toHaveBeenCalledWith( - 'u1', - 'newVal', - ); + expect(setSpy).toHaveBeenCalledWith('u1', 'newVal'); }); }); describe('useInteraction', () => { it('updates state from the subscription', () => { - const { client, subs } = createMockClient(); + const sub = stubSubscription( + client.config, + 'subscribeToWorkbookInteraction', + ); const { result } = renderHook(() => useInteraction('i1', 'el1'), { wrapper: withProvider(client), }); @@ -410,12 +403,12 @@ describe('react/hooks', () => { ); const selection = [{ col: { type: 'text', val: 1 } }]; - act(() => subs.interaction.emit(selection)); + act(() => sub.emit(selection)); expect(result.current[0]).toEqual(selection); }); it('setter calls setInteraction with id, elementId, and value', () => { - const { client } = createMockClient(); + const setSpy = vi.spyOn(client.config, 'setInteraction'); const { result } = renderHook(() => useInteraction('i1', 'el1'), { wrapper: withProvider(client), }); @@ -423,40 +416,33 @@ describe('react/hooks', () => { act(() => { (result.current[1] as (value: typeof selection) => void)(selection); }); - expect(client.config.setInteraction).toHaveBeenCalledWith( - 'i1', - 'el1', - selection, - ); + expect(setSpy).toHaveBeenCalledWith('i1', 'el1', selection); }); }); describe('useActionTrigger', () => { it('returns a callback that triggers the action', () => { - const { client } = createMockClient(); + const spy = vi.spyOn(client.config, 'triggerAction'); const { result } = renderHook(() => useActionTrigger('a1'), { wrapper: withProvider(client), }); act(() => result.current()); - expect(client.config.triggerAction).toHaveBeenCalledWith('a1'); + expect(spy).toHaveBeenCalledWith('a1'); }); }); describe('useActionEffect', () => { it('registers an effect for the given configId', () => { - const { client } = createMockClient(); + const spy = vi.spyOn(client.config, 'registerEffect'); const effect = vi.fn(); renderHook(() => useActionEffect('e1', effect), { wrapper: withProvider(client), }); - expect(client.config.registerEffect).toHaveBeenCalledWith( - 'e1', - expect.any(Function), - ); + expect(spy).toHaveBeenCalledWith('e1', expect.any(Function)); }); it('re-registers with the latest effect when effect changes', () => { - const { client } = createMockClient(); + const spy = vi.spyOn(client.config, 'registerEffect'); const first = vi.fn(); const second = vi.fn(); const { rerender } = renderHook( @@ -466,22 +452,20 @@ describe('react/hooks', () => { initialProps: { fx: first }, }, ); - expect(client.config.registerEffect).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(1); rerender({ fx: second }); - expect(client.config.registerEffect).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledTimes(2); - const calls = (client.config.registerEffect as any).mock.calls; - const lastRegistered = calls[calls.length - 1][1] as () => void; + const lastRegistered = spy.mock.calls[spy.mock.calls.length - 1][1]; lastRegistered(); expect(second).toHaveBeenCalled(); expect(first).not.toHaveBeenCalled(); }); it('unregisters the effect on unmount', () => { - const { client } = createMockClient(); const unregister = vi.fn(); - (client.config.registerEffect as any).mockReturnValue(unregister); + vi.spyOn(client.config, 'registerEffect').mockReturnValue(unregister); const { unmount } = renderHook(() => useActionEffect('e1', vi.fn()), { wrapper: withProvider(client), }); @@ -492,7 +476,8 @@ describe('react/hooks', () => { describe('usePluginStyle', () => { it('returns undefined initially and updates from style.get()', async () => { - const { client, resolveStyleGet } = createMockClient(); + const { resolve } = stubStyleGet(client); + stubSubscription(client.style, 'subscribe'); const { result } = renderHook(() => usePluginStyle(), { wrapper: withProvider(client), }); @@ -500,17 +485,19 @@ describe('react/hooks', () => { expect(client.style.get).toHaveBeenCalled(); await act(async () => { - await resolveStyleGet({ backgroundColor: '#FFFFFF' }); + resolve({ backgroundColor: '#FFFFFF' }); + await Promise.resolve(); }); expect(result.current).toEqual({ backgroundColor: '#FFFFFF' }); }); it('updates when style.subscribe emits', () => { - const { client, subs } = createMockClient(); + stubStyleGet(client); + const sub = stubSubscription(client.style, 'subscribe'); const { result } = renderHook(() => usePluginStyle(), { wrapper: withProvider(client), }); - act(() => subs.style.emit({ backgroundColor: '#000000' })); + act(() => sub.emit({ backgroundColor: '#000000' })); expect(result.current).toEqual({ backgroundColor: '#000000' }); }); }); diff --git a/src/__tests__/error.test.ts b/src/utils/__tests__/error.test.ts similarity index 61% rename from src/__tests__/error.test.ts rename to src/utils/__tests__/error.test.ts index 587e334..ee27a6d 100644 --- a/src/__tests__/error.test.ts +++ b/src/utils/__tests__/error.test.ts @@ -22,42 +22,18 @@ describe('validateConfigId', () => { expect(warnSpy).not.toHaveBeenCalled(); }); - it('does not warn for an empty string configId', () => { - validateConfigId('', 'variable'); + it('does not warn for null configId (only undefined triggers a warning)', () => { + validateConfigId(null as unknown as string, 'variable'); expect(warnSpy).not.toHaveBeenCalled(); }); - it('does not warn for null configId (only undefined triggers a warning)', () => { - validateConfigId(null as unknown as string, 'variable'); + it('does not warn for an empty string configId', () => { + validateConfigId('', 'variable'); expect(warnSpy).not.toHaveBeenCalled(); }); it('includes the expectedConfigType in the warning message', () => { validateConfigId(undefined as unknown as string, 'element'); expect(warnSpy).toHaveBeenCalledWith('Invalid config element: undefined'); - - warnSpy.mockClear(); - validateConfigId(undefined as unknown as string, 'url-parameter'); - expect(warnSpy).toHaveBeenCalledWith( - 'Invalid config url-parameter: undefined', - ); - - warnSpy.mockClear(); - validateConfigId(undefined as unknown as string, 'action-trigger'); - expect(warnSpy).toHaveBeenCalledWith( - 'Invalid config action-trigger: undefined', - ); - - warnSpy.mockClear(); - validateConfigId(undefined as unknown as string, 'action-effect'); - expect(warnSpy).toHaveBeenCalledWith( - 'Invalid config action-effect: undefined', - ); - - warnSpy.mockClear(); - validateConfigId(undefined as unknown as string, 'interaction'); - expect(warnSpy).toHaveBeenCalledWith( - 'Invalid config interaction: undefined', - ); }); }); diff --git a/src/utils/__tests__/polyfillRequestAnimationFrame.test.ts b/src/utils/__tests__/polyfillRequestAnimationFrame.test.ts index bc7ddcf..607db1e 100644 --- a/src/utils/__tests__/polyfillRequestAnimationFrame.test.ts +++ b/src/utils/__tests__/polyfillRequestAnimationFrame.test.ts @@ -15,10 +15,10 @@ describe('polyfillRequestAnimationFrame', () => { polyfillRequestAnimationFrame(fakeWindow); - const cb = vi.fn(); - const handle = fakeWindow.requestAnimationFrame(cb); + const callback = vi.fn(); + const handle = fakeWindow.requestAnimationFrame(callback); expect(setTimeoutSpy).toHaveBeenCalledTimes(1); - expect(setTimeoutSpy).toHaveBeenCalledWith(cb, 1000 / 60); + expect(setTimeoutSpy).toHaveBeenCalledWith(callback, 1000 / 60); expect(handle).toBe(42); }); diff --git a/src/error.ts b/src/utils/error.ts similarity index 80% rename from src/error.ts rename to src/utils/error.ts index e737dc6..ff3e004 100644 --- a/src/error.ts +++ b/src/utils/error.ts @@ -1,4 +1,4 @@ -import { CustomPluginConfigOptions } from './types'; +import { CustomPluginConfigOptions } from '../types'; export function validateConfigId( configId: string, diff --git a/src/utils/polyfillRequestAnimationFrame.ts b/src/utils/polyfillRequestAnimationFrame.ts index 6966832..b2a2463 100644 --- a/src/utils/polyfillRequestAnimationFrame.ts +++ b/src/utils/polyfillRequestAnimationFrame.ts @@ -1,12 +1,13 @@ +const FPS = 1000 / 60; + /** * requestAnimationFrame() calls are paused in most browsers when running in background tabs or hidden