diff --git a/edge-apps/grafana/e2e/screenshots.spec.ts b/edge-apps/grafana/e2e/screenshots.spec.ts index 079aca530..eec5f3df2 100644 --- a/edge-apps/grafana/e2e/screenshots.spec.ts +++ b/edge-apps/grafana/e2e/screenshots.spec.ts @@ -1,4 +1,4 @@ -import { test } from '@playwright/test' +import { test, type Browser, type Route } from '@playwright/test' import { createMockScreenlyForScreenshots, getScreenshotsDir, @@ -22,49 +22,97 @@ const { screenlyJsContent } = createMockScreenlyForScreenshots( }, ) +const { screenlyJsContent: screenlyJsContentWithErrors } = + createMockScreenlyForScreenshots( + {}, + { + dashboard_id: DASHBOARD_ID, + refresh_interval: '3600', + display_errors: 'true', + screenly_oauth_tokens_url: 'http://127.0.0.1:8080/oauth/', + }, + ) + const dashboardImage = fs.readFileSync( path.resolve('e2e/fixtures/sample-grafana-dashboard.png'), ) -for (const { width, height } of RESOLUTIONS) { - test(`screenshot ${width}x${height}`, async ({ browser }) => { - const screenshotsDir = getScreenshotsDir() +const DISPLAY_ERRORS_RESOLUTIONS = [ + { width: 1920, height: 1080 }, + { width: 1080, height: 1920 }, +] - const context = await browser.newContext({ viewport: { width, height } }) - const page = await context.newPage() +async function runScreenshotTest( + browser: Browser, + width: number, + height: number, + screenlyContent: string, + filename: string, + mockRenderRoute: (route: Route) => Promise, +) { + const screenshotsDir = getScreenshotsDir() + const context = await browser.newContext({ viewport: { width, height } }) + const page = await context.newPage() - await setupClockMock(page) - await setupScreenlyJsMock(page, screenlyJsContent) + await setupClockMock(page) + await setupScreenlyJsMock(page, screenlyContent) - // Mock OAuth token endpoint - await page.route('**/oauth/access_token/', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - token: 'mock-service-access-token', - metadata: { domain: GRAFANA_DOMAIN }, - }), - }) + await page.route('**/oauth/access_token/', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + token: 'mock-service-access-token', + metadata: { domain: GRAFANA_DOMAIN }, + }), }) + }) - // Mock Grafana render endpoint - await page.route('**/render/d/**', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'image/png', - body: dashboardImage, - }) - }) + await page.route('**/render/d/**', mockRenderRoute) - await page.goto('/') - await page.waitForLoadState('networkidle') + await page.goto('/') + await page.waitForLoadState('networkidle') - await page.screenshot({ - path: path.join(screenshotsDir, `${width}x${height}.png`), - fullPage: false, - }) + await page.screenshot({ + path: path.join(screenshotsDir, filename), + fullPage: false, + }) - await context.close() + await context.close() +} + +for (const { width, height } of RESOLUTIONS) { + test(`screenshot ${width}x${height}`, async ({ browser }) => { + await runScreenshotTest( + browser, + width, + height, + screenlyJsContent, + `${width}x${height}.png`, + async (route) => + route.fulfill({ + status: 200, + contentType: 'image/png', + body: dashboardImage, + }), + ) + }) +} + +for (const { width, height } of DISPLAY_ERRORS_RESOLUTIONS) { + test(`screenshot ${width}x${height} display-errors`, async ({ browser }) => { + await runScreenshotTest( + browser, + width, + height, + screenlyJsContentWithErrors, + `${width}x${height}-display-errors.png`, + async (route) => + route.fulfill({ + status: 403, + contentType: 'text/plain', + body: 'Access to this Grafana dashboard is forbidden.', + }), + ) }) } diff --git a/edge-apps/grafana/screenshots/1080x1920-display-errors.webp b/edge-apps/grafana/screenshots/1080x1920-display-errors.webp new file mode 100644 index 000000000..98e830174 Binary files /dev/null and b/edge-apps/grafana/screenshots/1080x1920-display-errors.webp differ diff --git a/edge-apps/grafana/screenshots/1920x1080-display-errors.webp b/edge-apps/grafana/screenshots/1920x1080-display-errors.webp new file mode 100644 index 000000000..151075066 Binary files /dev/null and b/edge-apps/grafana/screenshots/1920x1080-display-errors.webp differ diff --git a/edge-apps/grafana/src/main.test.ts b/edge-apps/grafana/src/main.test.ts index f489e0269..4f3b2bcb4 100644 --- a/edge-apps/grafana/src/main.test.ts +++ b/edge-apps/grafana/src/main.test.ts @@ -1,5 +1,5 @@ -import { describe, test, expect, beforeEach, afterEach } from 'bun:test' -import { getRenderUrl } from './render' +import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test' +import { getRenderUrl, fetchAndRenderDashboard } from './render' import type { ScreenlyObject } from '@screenly/edge-apps' // Mock screenly object @@ -22,79 +22,166 @@ Object.assign(globalThis.window, { innerHeight: 567, }) -describe('Grafana App', () => { - describe('getRenderUrl', () => { - let originalScreenWidth: number - let originalScreenHeight: number +describe('getRenderUrl', () => { + let originalScreenWidth: number + let originalScreenHeight: number - beforeEach(() => { - originalScreenWidth = globalThis.window.innerWidth - originalScreenHeight = globalThis.window.innerHeight - }) + beforeEach(() => { + originalScreenWidth = globalThis.window.innerWidth + originalScreenHeight = globalThis.window.innerHeight + }) + + afterEach(() => { + globalThis.window.innerWidth = originalScreenWidth + globalThis.window.innerHeight = originalScreenHeight + }) + + test('should construct URL with correct parameters', () => { + globalThis.window.innerWidth = 1234 + globalThis.window.innerHeight = 567 + + const url = getRenderUrl('https://grafana.example.com', 'abc123') + + expect(url).toContain( + 'https://cors-proxy.example.com/https://grafana.example.com/render/d/abc123', + ) + expect(url).toContain('width=1234') + expect(url).toContain('height=567') + expect(url).toContain('kiosk=true') + }) + + test('should use dynamic window dimensions', () => { + globalThis.window.innerWidth = 3840 + globalThis.window.innerHeight = 2160 - afterEach(() => { - globalThis.window.innerWidth = originalScreenWidth - globalThis.window.innerHeight = originalScreenHeight - }) + const url = getRenderUrl('grafana.example.com', 'xyz789') - test('should construct URL with correct parameters', () => { - globalThis.window.innerWidth = 1234 - globalThis.window.innerHeight = 567 + expect(url).toContain('width=3840') + expect(url).toContain('height=2160') + }) + + test('should include all required query parameters', () => { + const url = getRenderUrl('my-grafana.net', 'dash1') - const url = getRenderUrl('https://grafana.example.com', 'abc123') + const params = new URLSearchParams(url.split('?')[1]) + expect(params.has('width')).toBe(true) + expect(params.has('height')).toBe(true) + expect(params.get('kiosk')).toBe('true') + }) - expect(url).toContain( - 'https://cors-proxy.example.com/https://grafana.example.com/render/d/abc123', - ) - expect(url).toContain('width=1234') - expect(url).toContain('height=567') - expect(url).toContain('kiosk=true') - }) + test('should include CORS proxy URL', () => { + const url = getRenderUrl('my-grafana.net', 'dash1') - test('should use dynamic window dimensions', () => { - globalThis.window.innerWidth = 3840 - globalThis.window.innerHeight = 2160 + expect(url).toContain('https://cors-proxy.example.com') + }) - const url = getRenderUrl('grafana.example.com', 'xyz789') + test('should include domain in render path', () => { + const url = getRenderUrl('custom.grafana.net', 'dash-id') - expect(url).toContain('width=3840') - expect(url).toContain('height=2160') - }) + expect(url).toContain('custom.grafana.net') + expect(url).toContain('dash-id') + }) +}) - test('should include all required query parameters', () => { - const url = getRenderUrl('my-grafana.net', 'dash1') +describe('fetchAndRenderDashboard', () => { + const RENDER_URL = 'https://example.com/render' + const TOKEN = 'token123' + + const imgElement = { + setAttribute: mock(() => {}), + src: '', + } as unknown as HTMLImageElement + + let originalFetch: typeof fetch + let originalCreateObjectURL: typeof URL.createObjectURL + let originalRevokeObjectURL: typeof URL.revokeObjectURL + + function mockSuccessfulFetch(objectUrl = 'blob:fake-url') { + globalThis.fetch = mock(async () => ({ + ok: true, + blob: async () => new Blob(['data'], { type: 'image/png' }), + })) as unknown as typeof fetch + globalThis.URL.createObjectURL = mock(() => objectUrl) + } + + beforeEach(() => { + originalFetch = globalThis.fetch + originalCreateObjectURL = globalThis.URL.createObjectURL + originalRevokeObjectURL = globalThis.URL.revokeObjectURL + ;(imgElement.setAttribute as ReturnType).mockClear() + ;(imgElement as { src: string }).src = '' + }) - const params = new URLSearchParams(url.split('?')[1]) - expect(params.has('width')).toBe(true) - expect(params.has('height')).toBe(true) - expect(params.get('kiosk')).toBe('true') - }) + afterEach(() => { + globalThis.fetch = originalFetch + globalThis.URL.createObjectURL = originalCreateObjectURL + globalThis.URL.revokeObjectURL = originalRevokeObjectURL + }) - test('should include CORS proxy URL', () => { - const url = getRenderUrl('my-grafana.net', 'dash1') + function mockFailedFetch(status: number, statusText: string, body = '') { + globalThis.fetch = mock(async () => ({ + ok: false, + status, + statusText, + text: async () => body, + })) as unknown as typeof fetch + } - expect(url).toContain('https://cors-proxy.example.com') - }) + test('should render the image when fetch succeeds', async () => { + mockSuccessfulFetch() - test('should include domain in render path', () => { - const url = getRenderUrl('custom.grafana.net', 'dash-id') + await fetchAndRenderDashboard(RENDER_URL, TOKEN, imgElement) - expect(url).toContain('custom.grafana.net') - expect(url).toContain('dash-id') - }) + expect(imgElement.setAttribute).toHaveBeenCalledWith('src', 'blob:fake-url') }) - describe('Configuration validation', () => { - test('refresh interval should be numeric and positive', () => { - const refreshInterval = 60 - expect(typeof refreshInterval).toBe('number') - expect(refreshInterval).toBeGreaterThan(0) - }) + test('should revoke the previous blob URL before setting a new one', async () => { + mockSuccessfulFetch('blob:new-url') + globalThis.URL.revokeObjectURL = mock(() => {}) + ;(imgElement as { src: string }).src = 'blob:old-url' + + await fetchAndRenderDashboard(RENDER_URL, TOKEN, imgElement) + + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:old-url') + }) + + test('should throw with HTTP status when response is not ok', async () => { + mockFailedFetch(401, 'Unauthorized') + + expect( + fetchAndRenderDashboard(RENDER_URL, 'bad-token', imgElement), + ).rejects.toThrow('HTTP 401 Unauthorized') + }) + + test('should include response body in the error when available', async () => { + mockFailedFetch(403, 'Forbidden', 'Access denied for this user') + + expect( + fetchAndRenderDashboard(RENDER_URL, 'bad-token', imgElement), + ).rejects.toThrow('HTTP 403 Forbidden - Access denied for this user') + }) + + test('should throw on network error', async () => { + globalThis.fetch = mock(() => + Promise.reject(new Error('Network request failed')), + ) as unknown as typeof fetch + + expect( + fetchAndRenderDashboard(RENDER_URL, TOKEN, imgElement), + ).rejects.toThrow('Network request failed') + }) +}) + +describe('Configuration validation', () => { + test('refresh interval should be numeric and positive', () => { + const refreshInterval = 60 + expect(typeof refreshInterval).toBe('number') + expect(refreshInterval).toBeGreaterThan(0) + }) - test('service access token should be a string', () => { - const serviceAccessToken = 'glsa_xxxxxxxxxxxx' - expect(typeof serviceAccessToken).toBe('string') - expect(serviceAccessToken.length).toBeGreaterThan(0) - }) + test('service access token should be a string', () => { + const serviceAccessToken = 'glsa_xxxxxxxxxxxx' + expect(typeof serviceAccessToken).toBe('string') + expect(serviceAccessToken.length).toBeGreaterThan(0) }) }) diff --git a/edge-apps/grafana/src/main.ts b/edge-apps/grafana/src/main.ts index bc1aa6aef..1bb4922b9 100644 --- a/edge-apps/grafana/src/main.ts +++ b/edge-apps/grafana/src/main.ts @@ -38,15 +38,7 @@ window.onload = async function () { const imgElement = document.querySelector('#content img') as HTMLImageElement // Fetch dashboard immediately - const success = await fetchAndRenderDashboard( - imageUrl, - serviceAccessToken, - imgElement, - ) - - if (!success) { - throw new Error('Failed to load the Grafana dashboard image.') - } + await fetchAndRenderDashboard(imageUrl, serviceAccessToken, imgElement) // Set up interval to refresh the dashboard setInterval(async () => { diff --git a/edge-apps/grafana/src/render.ts b/edge-apps/grafana/src/render.ts index f62b1e203..0a6b3cb62 100644 --- a/edge-apps/grafana/src/render.ts +++ b/edge-apps/grafana/src/render.ts @@ -15,31 +15,29 @@ export async function fetchAndRenderDashboard( imageUrl: string, serviceAccessToken: string, imgElement: HTMLImageElement, -): Promise { - try { - const response = await fetch(imageUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${serviceAccessToken}`, - 'Content-Type': 'image/png', - }, - }) +): Promise { + const response = await fetch(imageUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${serviceAccessToken}`, + 'Content-Type': 'image/png', + }, + }) - if (!response.ok) { - console.error( - `Failed to fetch dashboard image from ${imageUrl}: ${response.status} ${response.statusText}`, - ) - return false - } + if (!response.ok) { + const body = (await response.text()).trim() + const detail = body ? ` - ${body}` : '' + throw new Error( + `Failed to load the Grafana dashboard image: HTTP ${response.status} ${response.statusText}${detail}`, + ) + } - const blob = await response.blob() - const objectUrl = URL.createObjectURL(blob) + const blob = await response.blob() + const objectUrl = URL.createObjectURL(blob) - // Render Grafana dashboard as an image - imgElement.setAttribute('src', objectUrl) - return true - } catch (error) { - console.error('Error fetching dashboard image:', error) - return false + if (imgElement.src.startsWith('blob:')) { + URL.revokeObjectURL(imgElement.src) } + + imgElement.setAttribute('src', objectUrl) }