142 lines
11 KiB
JavaScript
142 lines
11 KiB
JavaScript
// Fixture-only browser review. Every API request is intercepted.
|
||
const assert = require('node:assert/strict')
|
||
const fs = require('node:fs')
|
||
const path = require('node:path')
|
||
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
|
||
const base = process.env.REVIEW_BASE || 'http://localhost:3114'
|
||
const output = process.env.REVIEW_DIR
|
||
|
||
;(async () => {
|
||
const browser = await chromium.launch({ headless: true })
|
||
try {
|
||
const context = await browser.newContext({ acceptDownloads: true })
|
||
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }])
|
||
const calls = []
|
||
let mode = 'ready'
|
||
let role = 'admin'
|
||
let failDownload = false
|
||
const months = Array.from({ length: 24 }, (_, index) => new Date(Date.UTC(2026, 8 - index, 1)).toISOString().slice(0, 7))
|
||
const delta = (current, previous) => ({ current, previous, difference: current - previous, percent: previous ? (current - previous) / previous * 100 : current ? null : 0 })
|
||
const fixture = (month = '2026-08') => {
|
||
const index = months.indexOf(month)
|
||
const next = new Date(`${month}-01T00:00:00Z`)
|
||
next.setUTCMonth(next.getUTCMonth() + 1)
|
||
const days = month === '2026-09' ? 7 : new Date(next.getTime() - 1).getUTCDate()
|
||
const summary = { minutes: 1500, movies: 8, episodes: 23, plays: 35, active_days: 20, longest_streak: 6, current_streak: 0 }
|
||
return {
|
||
state: 'ready', month, available_months: months, timezone: 'UTC', is_admin: role === 'admin',
|
||
is_partial: month === '2026-09', comparison_capped: false,
|
||
period_start: `${month}-01T00:00:00Z`, period_end: month === '2026-09' ? '2026-09-07T12:00:00Z' : next.toISOString(),
|
||
comparison_month: months[index + 1] || '2024-09', comparison_start: `${months[index + 1] || '2024-09'}-01T00:00:00Z`, comparison_end: `${month}-01T00:00:00Z`,
|
||
updated_at: '2026-09-07T12:00:00Z', summary,
|
||
previous_summary: { ...summary, minutes: 1000 },
|
||
changes: { minutes: delta(1500, 1000), movies: delta(8, 10), episodes: delta(23, 0), requests: delta(3, 3), plays: delta(35, 25), active_days: delta(20, 15), longest_streak: delta(6, 4) },
|
||
daily: Array.from({ length: days }, (_, day) => ({ date: `${month}-${String(day + 1).padStart(2, '0')}`, minutes: day % 4 ? 40 + day * 2 : 0 })),
|
||
top_titles: [{ title: 'Severance', type: 'series', minutes: 460, plays: 10 }, { title: 'Arrival', type: 'movie', minutes: 116, plays: 1 }],
|
||
clients: [{ name: 'Jellyfin Web', minutes: 1200 }, { name: 'Jellyfin for Android TV', minutes: 300 }],
|
||
methods: [{ name: 'Direct play', minutes: 1300 }, { name: 'Transcode', minutes: 200 }],
|
||
transcoding: { video_minutes: 100, audio_minutes: 61, hardware_video_minutes: 80, software_video_minutes: 20, unknown_hardware_minutes: 0, unknown_video_minutes: 0, unknown_audio_minutes: 0, hardware: [{ name: 'NVIDIA NVENC', minutes: 80 }], audio_codecs: [{ name: 'AAC', minutes: 61 }], gpu_busy_minutes: null },
|
||
recent: [{ id: 'play', title: 'Arrival', series: '', type: 'movie', minutes: 116, played_at: `${month}-05T10:00:00Z`, client: 'Jellyfin Web', method: 'Direct play', artwork_url: '/insights/artwork/fixture?token=fixture' }],
|
||
requests: { total: 3, movies: 2, tv: 1, pending: 1, approved: 2, declined: 0, recent: [{ request_id: 12, title: 'Dune: Part Two', media_type: 'movie', status: 2 }] },
|
||
}
|
||
}
|
||
await context.route('**/api/**', async (route) => {
|
||
const request = route.request()
|
||
const url = new URL(request.url())
|
||
calls.push({ method: request.method(), path: url.pathname, month: url.searchParams.get('month') })
|
||
if (url.pathname === '/api/auth/me') return route.fulfill({ json: { username: 'Fixture viewer', role } })
|
||
if (url.pathname.startsWith('/api/insights/artwork/')) return route.fulfill({ contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 180"><rect width="120" height="180" fill="#243946"/><path d="M0 150L65 35L120 150" fill="#c8bdaa"/></svg>' })
|
||
if (url.pathname === '/api/insights/reports/monthly.csv') {
|
||
if (failDownload) return route.fulfill({ status: 502, json: { detail: 'Unavailable' } })
|
||
return route.fulfill({ contentType: 'text/csv; charset=utf-8', body: `\ufeffMonth,Minutes\r\n${url.searchParams.get('month')},1500\r\n` })
|
||
}
|
||
if (url.pathname === '/api/insights/reports/monthly' || url.pathname === '/api/insights') {
|
||
if (mode === 'unauthorized') return route.fulfill({ status: 401, json: { detail: 'Sign in' } })
|
||
if (mode === 'unavailable') return route.fulfill({ status: 502, json: { detail: 'Your monthly report is temporarily unavailable. Please try again shortly.' } })
|
||
if (mode === 'limit') return route.fulfill({ status: 422, json: { detail: "This report exceeds Jellystat's history limit. No partial report has been generated." } })
|
||
const data = { ...fixture(url.searchParams.get('month') || '2026-08'), days: 30 }
|
||
if (mode === 'empty') {
|
||
for (const key of Object.keys(data.summary)) data.summary[key] = 0
|
||
for (const key of Object.keys(data.changes)) data.changes[key] = delta(0, 0)
|
||
data.requests = { total: 0, movies: 0, tv: 0, pending: 0, approved: 0, declined: 0, recent: [] }
|
||
data.daily = data.daily.map((day) => ({ ...day, minutes: 0 }))
|
||
data.top_titles = data.recent = data.methods = data.clients = []
|
||
} else if (mode !== 'ready') { data.state = mode; data.summary = null }
|
||
return route.fulfill({ json: data })
|
||
}
|
||
if (url.pathname.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' })
|
||
return route.fulfill({ json: {} })
|
||
})
|
||
const page = await context.newPage()
|
||
const errors = []
|
||
page.on('pageerror', (error) => errors.push(error.message))
|
||
for (const width of [1440, 980, 390, 320]) {
|
||
await page.setViewportSize({ width, height: 1000 })
|
||
await page.goto(base + '/insights/reports')
|
||
await page.getByRole('heading', { name: 'August 2026', exact: true }).waitFor()
|
||
assert.equal(await page.getByLabel('Report month').inputValue(), '2026-08')
|
||
assert.equal(await page.getByLabel('Report month').locator('option').count(), 24)
|
||
assert.equal(await page.locator('.stats-view-tabs a[aria-current=page]').innerText(), 'Monthly reports')
|
||
assert.equal(await page.locator('.header-actions a.is-active').innerText(), 'My Stats')
|
||
assert.match(await page.locator('.stats-metrics').innerText(), /\+500 min \(\+50%\)/)
|
||
assert.match(await page.locator('.stats-metrics').innerText(), /−2 \(−20%\)/)
|
||
assert.match(await page.locator('.stats-metrics').innerText(), /No activity recorded in the comparison period/)
|
||
assert.equal(await page.getByRole('link', { name: 'Dune: Part Two' }).getAttribute('href'), '/requests/12')
|
||
await page.locator('.stats-history').scrollIntoViewIfNeeded()
|
||
await page.waitForFunction(() => document.querySelector('.stats-media-icon img')?.naturalWidth > 0)
|
||
await page.evaluate(() => window.scrollTo(0, 0))
|
||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `Overflow at ${width}px`)
|
||
if (width <= 980) assert.equal(await page.locator('.workspace-mobile-nav a.is-active').innerText(), 'Stats')
|
||
if (output) {
|
||
fs.mkdirSync(output, { recursive: true })
|
||
await page.screenshot({ path: path.join(output, `monthly-report-${width}.png`), fullPage: true })
|
||
}
|
||
}
|
||
await page.getByRole('button', { name: 'Previous month', exact: true }).click()
|
||
await page.getByRole('heading', { name: 'July 2026', exact: true }).waitFor()
|
||
await page.getByRole('button', { name: 'Next month', exact: true }).click()
|
||
await page.getByRole('heading', { name: 'August 2026', exact: true }).waitFor()
|
||
await page.getByLabel('Report month').selectOption('2026-09')
|
||
await page.getByRole('heading', { name: 'September 2026', exact: true }).waitFor()
|
||
await page.getByText('Compared with the same elapsed time in August 2026.', { exact: true }).waitFor()
|
||
assert(await page.getByRole('button', { name: 'Next month', exact: true }).isDisabled())
|
||
const downloaded = page.waitForEvent('download')
|
||
await page.getByRole('button', { name: 'Download CSV', exact: true }).click()
|
||
const download = await downloaded
|
||
assert.equal(download.suggestedFilename(), 'magent-monthly-report-2026-09.csv')
|
||
assert.match(fs.readFileSync(await download.path(), 'utf8'), /2026-09,1500/)
|
||
failDownload = true
|
||
await page.getByRole('button', { name: 'Download CSV', exact: true }).click()
|
||
await page.getByRole('alert').filter({ hasText: 'could not be downloaded' }).waitFor()
|
||
failDownload = false
|
||
await page.getByLabel('Report month').selectOption(months.at(-1))
|
||
await page.getByRole('heading', { name: 'October 2024', exact: true }).waitFor()
|
||
assert(await page.getByRole('button', { name: 'Previous month', exact: true }).isDisabled())
|
||
for (const [state, text] of [['empty', 'No viewing history was recorded for this month.'], ['unlinked', 'Link your viewing account'], ['not_configured', 'Your monthly story starts here'], ['unavailable', 'Report couldn’t load'], ['limit', 'No partial report has been generated.']]) {
|
||
mode = state
|
||
await page.reload()
|
||
await page.getByText(text, { exact: false }).waitFor()
|
||
if (state !== 'empty') assert(await page.getByRole('button', { name: 'Download CSV', exact: true }).isDisabled())
|
||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth))
|
||
}
|
||
mode = 'not_configured'
|
||
role = 'user'
|
||
await page.reload()
|
||
await page.getByText('Monthly reports will appear once your administrator connects Jellystat.').waitFor()
|
||
assert.equal(await page.getByRole('link', { name: 'Connect Jellystat' }).count(), 0)
|
||
mode = 'unauthorized'
|
||
await page.reload()
|
||
await page.waitForURL('**/login?next=%2Finsights%2Freports')
|
||
mode = 'ready'
|
||
await page.goto(base + '/insights/reports')
|
||
await page.getByRole('heading', { name: 'August 2026', exact: true }).waitFor()
|
||
await page.getByRole('navigation', { name: 'My Stats views' }).getByRole('link', { name: 'Overview' }).click()
|
||
await page.waitForURL('**/insights')
|
||
await page.getByRole('heading', { name: 'Your viewing rhythm' }).waitFor()
|
||
assert.equal(await page.locator('.stats-view-tabs a[aria-current=page]').innerText(), 'Overview')
|
||
assert(calls.filter((call) => call.path.startsWith('/api/insights/reports')).every((call) => call.method === 'GET'))
|
||
assert.deepEqual(errors, [])
|
||
console.log('Monthly report browser checks passed: desktop/mobile, comparison labels, periods, navigation, artwork, CSV download/error, empty/setup/link/error/auth states.')
|
||
} finally { await browser.close() }
|
||
})().catch((error) => { console.error(error); process.exit(1) })
|