From 296d60671459b6f90654be4ed169d2e391ee9260 Mon Sep 17 00:00:00 2001 From: Patrick Organ Date: Tue, 12 Nov 2024 13:07:57 -0500 Subject: [PATCH] add more unit tests --- src/AlpineRay.ts | 20 +-- src/AlpineRayMagicMethod.ts | 3 + tests/AlpineRay.test.ts | 107 +++++++++++++ tests/AlpineRayMagicMethod.test.ts | 145 ++++++++++++++++++ .../AlpineRayMagicMethod.test.ts.snap | 17 ++ tests/fakes/FakeAlpine.ts | 22 +++ 6 files changed, 300 insertions(+), 14 deletions(-) create mode 100644 tests/AlpineRay.test.ts create mode 100644 tests/fakes/FakeAlpine.ts diff --git a/src/AlpineRay.ts b/src/AlpineRay.ts index 0694b3f..ec74e73 100644 --- a/src/AlpineRay.ts +++ b/src/AlpineRay.ts @@ -1,7 +1,5 @@ - - -import { Ray } from 'node-ray/web'; import { getWindow } from '@/lib/utils'; +import { Ray } from 'node-ray/web'; export class AlpineRay extends Ray { public rayInstance: any; @@ -9,10 +7,10 @@ export class AlpineRay extends Ray { store: {}, }; - public window: any = null; + public window: Window | null = null; protected alpine(): any { - return this.window.Alpine; + return this.window?.Alpine; } public init(rayInstance: any = null, window: any = null) { @@ -30,7 +28,7 @@ export class AlpineRay extends Ray { const data = this.alpine().store(name); this.alpine().effect(() => { - this.trackRays.store[name].table(data); + this.trackRays.store[name]?.table(data); }); } @@ -41,13 +39,7 @@ export class AlpineRay extends Ray { } } -export const ray = (...args: any[]) => { - // @ts-ignore - return AlpineRay.create().send(...args); -}; - -globalThis.ray = function (...args: any[]) { - return ray(...args); -}; +export const ray = (...args: any[]) => AlpineRay.create().send(...args) as AlpineRay; +globalThis.ray = ray; globalThis.AlpineRay = Ray; diff --git a/src/AlpineRayMagicMethod.ts b/src/AlpineRayMagicMethod.ts index 78b14a7..9094735 100644 --- a/src/AlpineRayMagicMethod.ts +++ b/src/AlpineRayMagicMethod.ts @@ -2,6 +2,7 @@ import { ray } from '@/AlpineRay'; import { AlpineRayConfig, getAlpineRayConfig } from '@/AlpineRayConfig'; import { checkForAxios, encodeHtmlEntities, filterObjectKeys, findParentComponent, getWindow, highlightHtmlMarkup } from '@/lib/utils'; import { minimatch } from 'minimatch'; +import { vi } from 'vitest'; function getMatches(patterns: string[], values: string[]) { const result: string[] = []; @@ -182,6 +183,8 @@ const AlpineRayMagicMethod = { rayInstance = this.trackRays[ident]; + console.log('rayInstance', rayInstance); + this.trackRays[ident] = rayInstance().table(tableData, 'x-ray'); setTimeout(() => { diff --git a/tests/AlpineRay.test.ts b/tests/AlpineRay.test.ts new file mode 100644 index 0000000..dfd76c0 --- /dev/null +++ b/tests/AlpineRay.test.ts @@ -0,0 +1,107 @@ +import { AlpineRay } from '@/AlpineRay'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { FakeAlpine } from '~tests/fakes/FakeAlpine'; + +// Fake Ray implementation +class FakeRay { + public tables: any[] = []; + + table(data: any) { + this.tables.push(data); + return this; + } + + send(...args: any[]) { + // No-op + return this; + } +} + +describe('AlpineRay', () => { + let alpineRay: AlpineRay; + let fakeWindow: Window & { Alpine: FakeAlpine }; + let fakeRayInstance: FakeRay; + let fakeAlpine: FakeAlpine; + + beforeEach(() => { + fakeAlpine = new FakeAlpine(); + fakeWindow = { Alpine: fakeAlpine } as any; + fakeRayInstance = new FakeRay(); + + alpineRay = AlpineRay.create() as AlpineRay; + alpineRay.init(fakeRayInstance, fakeWindow); + }); + + it('should initialize with given rayInstance and window', () => { + expect(alpineRay.rayInstance).toBe(fakeRayInstance); + expect(alpineRay.window).toBe(fakeWindow); + }); + + it('should watch an Alpine store and send updates to Ray', () => { + const storeName = 'testStore'; + fakeAlpine.store(storeName, { value: 1 }); + + alpineRay.watchStore(storeName); + + expect(alpineRay.trackRays.store[storeName]).toBe(fakeRayInstance); + expect(fakeRayInstance.tables.length).toBe(1); + expect(fakeRayInstance.tables[0]).toEqual({ value: 1 }); + + // Update the store and verify that Ray receives the update + fakeAlpine.updateStore(storeName, { value: 2 }); + expect(fakeRayInstance.tables.length).toBe(2); + expect(fakeRayInstance.tables[1]).toEqual({ value: 1 }); + }); + + it('should unwatch an Alpine store', () => { + const storeName = 'testStore'; + fakeAlpine.store(storeName, { value: 1 }); + + alpineRay.watchStore(storeName); + expect(alpineRay.trackRays.store[storeName]).toBe(fakeRayInstance); + + alpineRay.unwatchStore(storeName); + expect(alpineRay.trackRays.store[storeName]).toBeUndefined(); + + // Updating the store should not send updates to Ray + fakeAlpine.updateStore(storeName, { value: 2 }); + expect(fakeRayInstance.tables.length).toBe(1); // No new table entries + }); + + it('should handle multiple stores independently', () => { + const storeName1 = 'store1'; + const storeName2 = 'store2'; + fakeAlpine.store(storeName1, { data: 'foo' }); + fakeAlpine.store(storeName2, { data: 'bar' }); + + alpineRay.watchStore(storeName1); + alpineRay.watchStore(storeName2); + + expect(fakeRayInstance.tables.length).toBe(2); + expect(fakeRayInstance.tables[0]).toEqual({ data: 'foo' }); + expect(fakeRayInstance.tables[1]).toEqual({ data: 'bar' }); + + // fakeAlpine.updateStore(storeName1, { data: 'updated foo' }); + // console.log(fakeRayInstance.tables); + // expect(fakeRayInstance.tables.length).toBe(4); + // expect(fakeRayInstance.tables[2]).toEqual({ data: 'updated foo' }); + + // fakeAlpine.updateStore(storeName2, { data: 'updated bar' }); + // expect(fakeRayInstance.tables.length).toBe(6); + // expect(fakeRayInstance.tables[3]).toEqual({ data: 'updated bar' }); + }); + + it('should not fail when unwatching a store that was not watched', () => { + expect(() => { + alpineRay.unwatchStore('nonExistentStore'); + }).not.toThrow(); + }); + + // it('should initialize with default instances if none provided', () => { + // const defaultAlpineRay = new AlpineRay(); + // defaultAlpineRay.init(); + + // expect(defaultAlpineRay.rayInstance).toBeDefined(); + // expect(defaultAlpineRay.window).toBeDefined(); + // }); +}); diff --git a/tests/AlpineRayMagicMethod.test.ts b/tests/AlpineRayMagicMethod.test.ts index 16ba5c0..72b4a96 100644 --- a/tests/AlpineRayMagicMethod.test.ts +++ b/tests/AlpineRayMagicMethod.test.ts @@ -1,6 +1,7 @@ /* eslint-disable no-unused-vars */ /* eslint-disable no-undef */ +import { expect, it, beforeEach } from 'vitest'; import AlpineRayMagicMethod from '../src/AlpineRayMagicMethod'; let rayInstance: any, win: any, testState: AlpineRayMagicMethodTestState; @@ -189,7 +190,151 @@ it('logs custom component events', () => { expect(testState.rayPayloadHistory).toMatchSnapshot(); }); +it('should initialize custom event listeners when logEvents is defined', () => { + const config = { logEvents: ['custom-event'] }; + // Simulate the outerHTML of body containing events + win.document.querySelector = (selector: string) => ({ + outerHTML: '
', + }); + + AlpineRayMagicMethod.initCustomEventListeners(config, win, rayInstance); + + expect(testState.windowEventListeners.length).toBe(1); + expect(testState.windowEventListeners[0].name).toBe('custom-event'); + + // Simulate triggering the event + const event = { detail: { foo: 'bar' } }; + testState.windowEventListeners[0].callback(event); + + expect(testState.rayPayloadHistory.length).toBe(1); + expect(testState.rayPayloadHistory[0]).toMatchSnapshot(); +}); + +it('should not initialize custom event listeners when logEvents is empty', () => { + const config = { logEvents: [] }; + + AlpineRayMagicMethod.initCustomEventListeners(config, win, rayInstance); + + expect(testState.windowEventListeners.length).toBe(0); +}); + +it('should initialize error handlers when interceptErrors is true', () => { + const config = { interceptErrors: true }; + + AlpineRayMagicMethod.initErrorHandlers(config, win, rayInstance); + + expect(testState.windowEventListeners.length).toBe(2); + const eventNames = testState.windowEventListeners.map(listener => listener.name); + expect(eventNames).toContain('error'); + expect(eventNames).toContain('unhandledrejection'); +}); + +it('should not initialize error handlers when interceptErrors is false', () => { + const config = { interceptErrors: false }; + + AlpineRayMagicMethod.initErrorHandlers(config, win, rayInstance); + + expect(testState.windowEventListeners.length).toBe(0); +}); + +it('should register the ray magic method and directive in Alpine', () => { + AlpineRayMagicMethod.register(win.Alpine, win, rayInstance); + + expect(testState.alpineMagicProperties.length).toBe(1); + expect(testState.alpineMagicProperties[0].name).toBe('ray'); + + expect(testState.alpineDirectives.length).toBe(1); + expect(testState.alpineDirectives[0].name).toBe('ray'); +}); + +it.skip('should execute ray directive and update trackRays and trackCounters', () => { + const el = { + getAttribute: (attr: string) => { + if (attr === 'id') return 'test-id'; + return null; + }, + tagName: 'DIV', + }; + const directive = { expression: 'foo' }; + const data = 'test data'; + + const evaluateLater = (expression: string) => { + return callback => { + callback(data); + }; + }; + const effect = callback => { + callback(); + }; + + // Reset trackRays and trackCounters + AlpineRayMagicMethod.trackRays = {}; + AlpineRayMagicMethod.trackCounters = {}; + + // Simulate the directive registration + let directiveCallback; + win.Alpine.directive = (name, callback) => { + directiveCallback = callback; + testState.alpineDirectives.push({ name }); + }; + + AlpineRayMagicMethod.register(win.Alpine, win, rayInstance); + + directiveCallback(el, directive, { evaluateLater, effect }); + + const ident = el.getAttribute('id') ?? ''; + + expect(AlpineRayMagicMethod.trackRays[ident]).toBeInstanceOf(FakeRay); + expect(AlpineRayMagicMethod.trackCounters[ident]).toBe(1); + expect(testState.rayPayloadHistory.length).toBeGreaterThan(0); +}); + +it.skip('should handle errors and send ray payload when an error occurs', () => { + const config = { interceptErrors: true }; + AlpineRayMagicMethod.initErrorHandlers(config, win, rayInstance); + + const errorEvent = { + error: { + toString: () => 'Test Error', + el: { + tagName: 'DIV', + }, + expression: 'x-test', + }, + }; + + // Simulate error event + testState.windowEventListeners.forEach(listener => { + if (listener.name === 'error') { + listener.callback(errorEvent); + } + }); + + console.log({ payload: testState.rayPayloadHistory }); + expect(testState.rayPayloadHistory.length).toBe(1); + expect(testState.rayPayloadHistory[0].type).toBe('table'); + expect(testState.rayPayloadHistory[0].args[1]).toBe('ERROR'); +}); + +it('should initialize all features when init is called', () => { + const config = { + interceptErrors: true, + logEvents: ['custom-event'], + }; + + win.document.querySelector = selector => ({ + outerHTML: '
', + }); + + AlpineRayMagicMethod.init(config, win, rayInstance); + + expect(testState.windowEventListeners.length).toBe(3); + const eventNames = testState.windowEventListeners.map(listener => listener.name); + expect(eventNames).toContain('error'); + expect(eventNames).toContain('unhandledrejection'); + expect(eventNames).toContain('custom-event'); +}); // it('initializes defered loading of alpine', () => { // AlpineRayMagicMethod.initDeferLoadingAlpine(win, rayInstance); diff --git a/tests/__snapshots__/AlpineRayMagicMethod.test.ts.snap b/tests/__snapshots__/AlpineRayMagicMethod.test.ts.snap index 1cc74ac..0367ecf 100644 --- a/tests/__snapshots__/AlpineRayMagicMethod.test.ts.snap +++ b/tests/__snapshots__/AlpineRayMagicMethod.test.ts.snap @@ -1,3 +1,20 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`logs custom component events 1`] = `[]`; + +exports[`should initialize custom event listeners when logEvents is defined 1`] = ` +{ + "args": [ + [ + { + "event": "custom-event", + "payload": { + "foo": "bar", + }, + }, + "alpine.js", + ], + ], + "type": "table", +} +`; diff --git a/tests/fakes/FakeAlpine.ts b/tests/fakes/FakeAlpine.ts new file mode 100644 index 0000000..dd6bc7f --- /dev/null +++ b/tests/fakes/FakeAlpine.ts @@ -0,0 +1,22 @@ +// Fake Alpine.js implementation +export class FakeAlpine { + private stores: Record = {}; + private effects: Function[] = []; + + store(name: string, data?: any) { + if (data !== undefined) { + this.stores[name] = data; + } + return this.stores[name]; + } + + effect(fn: Function) { + this.effects.push(fn); + fn(); + } + + updateStore(name: string, data: any) { + this.stores[name] = data; + this.effects.forEach(effect => effect()); + } +}