diff --git a/.github/workflows/edge-app-checks.yml b/.github/workflows/edge-app-checks.yml index 024fb997f..c43934579 100644 --- a/.github/workflows/edge-app-checks.yml +++ b/.github/workflows/edge-app-checks.yml @@ -157,9 +157,57 @@ jobs: working-directory: edge-apps/${{ matrix.app }} run: bun run format:check + - name: Clean coverage directory + working-directory: edge-apps/${{ matrix.app }} + run: rm -rf coverage + - name: Run unit tests + id: run-tests working-directory: edge-apps/${{ matrix.app }} - run: bun run test:unit + run: bun run test:unit 2>&1 | tee test-output.txt + + - name: Generate coverage summary + if: always() + working-directory: edge-apps/${{ matrix.app }} + run: | + if [ -f "test-output.txt" ]; then + { + echo "## Code Coverage Report for ${{ matrix.app }}" + echo "" + echo "| File | % Funcs | % Lines |" + echo "|:-----|--------:|--------:|" + + # Parse bun test output for coverage table + awk ' + BEGIN { in_table = 0 } + /^-+\|/ { + in_table = 1 + next + } + in_table && /^All files/ { + split($0, parts, "|") + gsub(/^[[:space:]]+|[[:space:]]+$/, "", parts[2]) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", parts[3]) + printf "| **All files** | **%s%%** | **%s%%** |\n", parts[2], parts[3] + next + } + in_table && /^[[:space:]]*src\// { + split($0, parts, "|") + gsub(/^[[:space:]]+|[[:space:]]+$/, "", parts[1]) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", parts[2]) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", parts[3]) + printf "| %s | %s%% | %s%% |\n", parts[1], parts[2], parts[3] + } + /^-+\|-+/ && in_table { + in_table = 0 + } + ' test-output.txt + + echo "" + } >> "$GITHUB_STEP_SUMMARY" + else + echo "No test output found for ${{ matrix.app }}" >> "$GITHUB_STEP_SUMMARY" + fi - name: Build application working-directory: edge-apps/${{ matrix.app }} diff --git a/edge-apps/edge-apps-library/bunfig.toml b/edge-apps/edge-apps-library/bunfig.toml index 38b6d6fe7..3ff4ebccf 100644 --- a/edge-apps/edge-apps-library/bunfig.toml +++ b/edge-apps/edge-apps-library/bunfig.toml @@ -1,3 +1,6 @@ [test] preload = ["./src/test/setup.ts"] +coverage = true +coverageThreshold = 0.9 +coverageReporter = ["text", "lcov"] diff --git a/edge-apps/edge-apps-library/src/test/mock.test.ts b/edge-apps/edge-apps-library/src/test/mock.test.ts index 53e44ce09..68505c152 100644 --- a/edge-apps/edge-apps-library/src/test/mock.test.ts +++ b/edge-apps/edge-apps-library/src/test/mock.test.ts @@ -9,6 +9,19 @@ import { const global = globalThis as Record +/** + * Helper function to check property existence and optionally verify its value. + * Note: Cannot test for properties explicitly set to undefined - only checks + * property existence when no value is provided. + */ +function expectProperty(obj: unknown, key: string, value?: unknown) { + if (value !== undefined) { + expect(obj).toHaveProperty(key, value) + } else { + expect(obj).toHaveProperty(key) + } +} + // eslint-disable-next-line max-lines-per-function describe('mock utilities', () => { afterEach(() => { @@ -17,22 +30,36 @@ describe('mock utilities', () => { describe('mockMetadata', () => { test('should have default metadata values', () => { - expect(mockMetadata).toHaveProperty('coordinates') - expect(mockMetadata).toHaveProperty('hostname', 'test-hostname') - expect(mockMetadata).toHaveProperty('location', 'Test Location') - expect(mockMetadata).toHaveProperty('hardware', 'test-hardware') - expect(mockMetadata).toHaveProperty('screenly_version', '1.0.0-test') - expect(mockMetadata).toHaveProperty('screen_name', 'Test Screen') - expect(mockMetadata).toHaveProperty('tags') + const expectedProperties = [ + { key: 'coordinates' }, + { key: 'hostname', value: 'test-hostname' }, + { key: 'location', value: 'Test Location' }, + { key: 'hardware', value: 'test-hardware' }, + { key: 'screenly_version', value: '1.0.0-test' }, + { key: 'screen_name', value: 'Test Screen' }, + { key: 'tags' }, + ] + + expectedProperties.forEach(({ key, value }) => { + expectProperty(mockMetadata, key, value) + }) + expect(Array.isArray(mockMetadata.tags)).toBe(true) }) }) describe('mockSettings', () => { test('should have default settings values', () => { - expect(mockSettings).toHaveProperty('screenly_color_accent', '#972EFF') - expect(mockSettings).toHaveProperty('screenly_color_light', '#ADAFBE') - expect(mockSettings).toHaveProperty('screenly_color_dark', '#454BD2') + const expectedProperties = [ + { key: 'screenly_color_accent', value: '#972EFF' }, + { key: 'screenly_color_light', value: '#ADAFBE' }, + { key: 'screenly_color_dark', value: '#454BD2' }, + ] + + expectedProperties.forEach(({ key, value }) => { + expectProperty(mockSettings, key, value) + }) + expect(mockSettings).not.toHaveProperty('theme') }) }) @@ -41,36 +68,44 @@ describe('mock utilities', () => { test('should create mock with default values', () => { const mock = createMockScreenly() - expect(mock).toHaveProperty('signalReadyForRendering') - expect(mock).toHaveProperty('metadata') - expect(mock).toHaveProperty('settings') - expect(mock).toHaveProperty('cors_proxy_url', 'http://localhost:8080') + const expectedProperties = [ + 'signalReadyForRendering', + 'metadata', + 'settings', + ] + expectedProperties.forEach((key) => expectProperty(mock, key)) + + expectProperty(mock, 'cors_proxy_url', 'http://localhost:8080') expect(typeof mock.signalReadyForRendering).toBe('function') }) + test('should have a callable signalReadyForRendering function', () => { + const mock = createMockScreenly() + expect(() => mock.signalReadyForRendering()).not.toThrow() + }) + test('should merge custom metadata', () => { - const mock = createMockScreenly({ + const customMetadata = { hostname: 'custom-hostname', location: 'Custom Location', - }) + } + const mock = createMockScreenly(customMetadata) expect(mock.metadata.hostname).toBe('custom-hostname') expect(mock.metadata.location).toBe('Custom Location') - expect(mock.metadata.hardware).toBe('test-hardware') // default value preserved + expect(mock.metadata.hardware).toBe('test-hardware') }) test('should merge custom settings', () => { - const mock = createMockScreenly( - {}, - { - theme: 'dark', - custom_setting: 'custom_value', - }, - ) + const customSettings = { + theme: 'dark', + custom_setting: 'custom_value', + } + const mock = createMockScreenly({}, customSettings) expect(mock.settings.theme).toBe('dark') expect(mock.settings.custom_setting).toBe('custom_value') - expect(mock.settings.screenly_color_accent).toBe('#972EFF') // default value preserved + expect(mock.settings.screenly_color_accent).toBe('#972EFF') }) }) @@ -79,21 +114,20 @@ describe('mock utilities', () => { setupScreenlyMock() expect(global.screenly).toBeDefined() - expect(global.screenly).toHaveProperty('metadata') - expect(global.screenly).toHaveProperty('settings') + expectProperty(global.screenly, 'metadata') + expectProperty(global.screenly, 'settings') }) test('should setup with custom values', () => { setupScreenlyMock({ hostname: 'custom-host' }, { theme: 'dark' }) const screenly = global.screenly as Record - expect(screenly.metadata).toHaveProperty('hostname', 'custom-host') - expect(screenly.settings).toHaveProperty('theme', 'dark') + expectProperty(screenly.metadata, 'hostname', 'custom-host') + expectProperty(screenly.settings, 'theme', 'dark') }) test('should return the mock object', () => { const mock = setupScreenlyMock() - expect(mock).toBe(global.screenly) }) }) diff --git a/edge-apps/edge-apps-library/src/test/mock.ts b/edge-apps/edge-apps-library/src/test/mock.ts index b91794387..bfebb5184 100644 --- a/edge-apps/edge-apps-library/src/test/mock.ts +++ b/edge-apps/edge-apps-library/src/test/mock.ts @@ -36,12 +36,13 @@ export const mockSettings: ScreenlySettings = { export function createMockScreenly( metadata: Partial = {}, settings: Partial = {}, + corsProxyUrl = 'http://localhost:8080', ): ScreenlyObject { return { signalReadyForRendering: () => {}, metadata: { ...mockMetadata, ...metadata }, settings: { ...mockSettings, ...settings }, - cors_proxy_url: 'http://localhost:8080', + cors_proxy_url: corsProxyUrl, } } @@ -52,8 +53,9 @@ export function createMockScreenly( export function setupScreenlyMock( metadata: Partial = {}, settings: Partial = {}, + corsProxyUrl?: string, ): ScreenlyObject { - const mock = createMockScreenly(metadata, settings) + const mock = createMockScreenly(metadata, settings, corsProxyUrl) global.screenly = mock return mock } diff --git a/edge-apps/edge-apps-library/src/test/setup.ts b/edge-apps/edge-apps-library/src/test/setup.ts index 18c65f9d0..8d2cdcdbb 100644 --- a/edge-apps/edge-apps-library/src/test/setup.ts +++ b/edge-apps/edge-apps-library/src/test/setup.ts @@ -12,3 +12,4 @@ global.document = dom.window.document global.window = dom.window as unknown as Window & typeof globalThis global.navigator = dom.window.navigator global.Node = dom.window.Node +global.FileReader = dom.window.FileReader diff --git a/edge-apps/edge-apps-library/src/utils/theme.test.ts b/edge-apps/edge-apps-library/src/utils/theme.test.ts index 53b807f3e..6ac26eefd 100644 --- a/edge-apps/edge-apps-library/src/utils/theme.test.ts +++ b/edge-apps/edge-apps-library/src/utils/theme.test.ts @@ -1,14 +1,47 @@ -import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test' import { getPrimaryColor, getSecondaryColor, getThemeColors, applyThemeColors, setupTheme, + fetchLogoImage, + setupBrandingLogo, + setupBranding, DEFAULT_THEME_COLORS, } from './theme' import { setupScreenlyMock, resetScreenlyMock } from '../test/mock' +// Helper constants +const PNG_MAGIC_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]) +const JPEG_MAGIC_BYTES = new Uint8Array([0xff, 0xd8, 0xff]) +const DEFAULT_SECONDARY = '#adafbe' +const PROXY_URL = 'https://proxy.example.com' +const TEST_LOGO_URL = 'https://example.com/logo.png' + +// Helper functions +function createMockFetch(ok: boolean, bytes?: Uint8Array, status = 200) { + return mock(() => + Promise.resolve({ + ok, + status, + blob: bytes ? () => Promise.resolve(new Blob([bytes])) : undefined, + } as Response), + ) +} + +function getCSSProperty(name: string): string { + return document.documentElement.style.getPropertyValue(name) +} + +function setupMockWithLogo(theme: 'light' | 'dark', logoUrl: string) { + const logoKey = + theme === 'light' ? 'screenly_logo_light' : 'screenly_logo_dark' + setupScreenlyMock({}, { theme, [logoKey]: logoUrl }, PROXY_URL) +} + // eslint-disable-next-line max-lines-per-function describe('theme utilities', () => { beforeEach(() => { @@ -21,50 +54,47 @@ describe('theme utilities', () => { describe('getPrimaryColor', () => { test('should return default color when no accent color provided', () => { - const color = getPrimaryColor() - expect(color).toBe(DEFAULT_THEME_COLORS.primary) + expect(getPrimaryColor(undefined)).toBe(DEFAULT_THEME_COLORS.primary) }) test('should return default color when accent color is white', () => { - const color = getPrimaryColor('#ffffff') - expect(color).toBe(DEFAULT_THEME_COLORS.primary) + expect(getPrimaryColor('#ffffff')).toBe(DEFAULT_THEME_COLORS.primary) }) test('should return default color when accent color is white (uppercase)', () => { - const color = getPrimaryColor('#FFFFFF') - expect(color).toBe(DEFAULT_THEME_COLORS.primary) + expect(getPrimaryColor('#FFFFFF')).toBe(DEFAULT_THEME_COLORS.primary) }) test('should return provided accent color', () => { - const color = getPrimaryColor('#FF0000') - expect(color).toBe('#FF0000') + expect(getPrimaryColor('#FF0000')).toBe('#FF0000') }) }) describe('getSecondaryColor', () => { test('should return default secondary when theme is undefined', () => { - const color = getSecondaryColor(undefined) - expect(color).toBe(DEFAULT_THEME_COLORS.secondary) + expect(getSecondaryColor(undefined, undefined, undefined)).toBe( + DEFAULT_THEME_COLORS.secondary, + ) }) test('should return light color when theme is light', () => { - const color = getSecondaryColor('light', '#123456') - expect(color).toBe('#123456') + expect(getSecondaryColor('light', '#123456', undefined)).toBe('#123456') }) test('should return dark color when theme is dark', () => { - const color = getSecondaryColor('dark', undefined, '#654321') - expect(color).toBe('#654321') + expect(getSecondaryColor('dark', undefined, '#654321')).toBe('#654321') }) - test('should return default when light color is white', () => { - const color = getSecondaryColor('light', '#ffffff') - expect(color).toBe('#adafbe') + test('should return default secondary when light color is white', () => { + expect(getSecondaryColor('light', '#ffffff', undefined)).toBe( + DEFAULT_SECONDARY, + ) }) - test('should return default when dark color is white', () => { - const color = getSecondaryColor('dark', undefined, '#FFFFFF') - expect(color).toBe('#adafbe') + test('should return default secondary when dark color is white', () => { + expect(getSecondaryColor('dark', undefined, '#FFFFFF')).toBe( + DEFAULT_SECONDARY, + ) }) }) @@ -106,26 +136,10 @@ describe('theme utilities', () => { applyThemeColors(colors) - expect( - document.documentElement.style.getPropertyValue( - '--theme-color-primary', - ), - ).toBe('#FF0000') - expect( - document.documentElement.style.getPropertyValue( - '--theme-color-secondary', - ), - ).toBe('#00FF00') - expect( - document.documentElement.style.getPropertyValue( - '--theme-color-tertiary', - ), - ).toBe('#0000FF') - expect( - document.documentElement.style.getPropertyValue( - '--theme-color-background', - ), - ).toBe('#FFFFFF') + expect(getCSSProperty('--theme-color-primary')).toBe('#FF0000') + expect(getCSSProperty('--theme-color-secondary')).toBe('#00FF00') + expect(getCSSProperty('--theme-color-tertiary')).toBe('#0000FF') + expect(getCSSProperty('--theme-color-background')).toBe('#FFFFFF') }) }) @@ -144,11 +158,160 @@ describe('theme utilities', () => { expect(colors.primary).toBe('#FF0000') expect(colors.secondary).toBe('#00FF00') - expect( - document.documentElement.style.getPropertyValue( - '--theme-color-primary', - ), - ).toBe('#FF0000') + expect(getCSSProperty('--theme-color-primary')).toBe('#FF0000') + }) + }) + + describe('fetchLogoImage', () => { + test('should fetch and process SVG image', async () => { + const svgContent = '' + + global.fetch = mock(() => + Promise.resolve({ + ok: true, + blob: async () => { + // Use jsdom's Blob constructor for proper FileReader compatibility. + // Native Blob won't work with jsdom's FileReader implementation. + const JSDOMWindow = global.window as typeof globalThis & { + Blob: typeof Blob + } + const blob = new JSDOMWindow.Blob([svgContent], { + type: 'text/plain', + }) + // Manually add arrayBuffer method - jsdom's Blob doesn't implement + // this method natively, but our code requires it for SVG processing. + // This polyfill converts the SVG content to an ArrayBuffer. + blob.arrayBuffer = async () => { + const encoder = new TextEncoder() + return encoder.encode(svgContent).buffer + } + return blob + }, + } as Response), + ) + + const result = await fetchLogoImage('https://example.com/logo.svg') + expect(result.startsWith('data:image/svg+xml;base64,')).toBe(true) + // Verify the base64 content can be decoded back to the original + const base64Part = result.split(',')[1] + const decoded = atob(base64Part) + expect(decoded).toBe(svgContent) + }) + + const imageTests = [ + { + bytes: PNG_MAGIC_BYTES, + type: 'PNG', + url: 'https://example.com/logo.png', + }, + { + bytes: JPEG_MAGIC_BYTES, + type: 'JPEG', + url: 'https://example.com/logo.jpg', + }, + ] + + imageTests.forEach(({ bytes, type, url }) => { + test(`should return URL for ${type} image`, async () => { + global.fetch = createMockFetch(true, bytes) + expect(await fetchLogoImage(url)).toBe(url) + }) + }) + + test('should throw error for failed fetch', async () => { + global.fetch = createMockFetch(false, undefined, 404) + await expect( + fetchLogoImage('https://example.com/not-found.png'), + ).rejects.toThrow(/Failed to fetch image/) + }) + + test('should throw error for unknown image type', async () => { + global.fetch = createMockFetch( + true, + new Uint8Array([0x00, 0x01, 0x02, 0x03]), + ) + await expect( + fetchLogoImage('https://example.com/unknown.bin'), + ).rejects.toThrow('Unknown image type') + }) + }) + + describe('setupBrandingLogo', () => { + test('should return empty string when no logo is configured', async () => { + setupScreenlyMock({}, { theme: 'light' }) + expect(await setupBrandingLogo()).toBe('') + }) + + const logoTests = [ + { theme: 'light' as const, url: 'https://example.com/light-logo.png' }, + { theme: 'dark' as const, url: 'https://example.com/dark-logo.png' }, + ] + + logoTests.forEach(({ theme, url }) => { + test(`should fetch ${theme} logo for ${theme} theme`, async () => { + setupMockWithLogo(theme, url) + global.fetch = createMockFetch(true, PNG_MAGIC_BYTES) + expect(await setupBrandingLogo()).toContain('example.com') + }) + }) + + test('should fallback to direct URL when CORS proxy fails', async () => { + setupMockWithLogo('light', TEST_LOGO_URL) + + let fetchCount = 0 + global.fetch = mock(() => { + fetchCount++ + return Promise.resolve( + fetchCount === 1 + ? ({ ok: false, status: 500 } as Response) + : ({ + ok: true, + blob: () => Promise.resolve(new Blob([PNG_MAGIC_BYTES])), + } as Response), + ) + }) + + expect(await setupBrandingLogo()).toBe(TEST_LOGO_URL) + }) + + test('should return empty string when all fetch attempts fail', async () => { + setupMockWithLogo('light', TEST_LOGO_URL) + global.fetch = createMockFetch(false, undefined, 404) + expect(await setupBrandingLogo()).toBe('') + }) + }) + + describe('setupBranding', () => { + const brandingSettings = { + screenly_color_accent: '#FF0000', + theme: 'light' as const, + screenly_color_light: '#00FF00', + screenly_logo_light: TEST_LOGO_URL, + } + + test('should setup complete branding with colors and logo', async () => { + setupScreenlyMock({}, brandingSettings, PROXY_URL) + global.fetch = createMockFetch(true, PNG_MAGIC_BYTES) + + const branding = await setupBranding() + + expect(branding.colors.primary).toBe('#FF0000') + expect(branding.colors.secondary).toBe('#00FF00') + expect(branding.logoUrl).toContain('example.com') + }) + + test('should setup branding without logo when fetch fails', async () => { + setupScreenlyMock({}, brandingSettings, PROXY_URL) + global.fetch = createMockFetch(false, undefined, 404) + + // Verify setupBrandingLogo returns empty string when fetch fails + const logoUrl = await setupBrandingLogo() + expect(logoUrl).toBe('') + + // Verify setupBranding converts empty string to undefined + const branding = await setupBranding() + expect(branding.colors.primary).toBe('#FF0000') + expect(branding.logoUrl).toBeUndefined() }) }) })