Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 81 additions & 33 deletions edge-apps/grafana/e2e/screenshots.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test } from '@playwright/test'
import { test, type Browser, type Route } from '@playwright/test'
import {
createMockScreenlyForScreenshots,
getScreenshotsDir,
Expand All @@ -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<void>,
) {
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.',
}),
)
})
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
205 changes: 146 additions & 59 deletions edge-apps/grafana/src/main.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<typeof mock>).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)
})
})
10 changes: 1 addition & 9 deletions edge-apps/grafana/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading