// Run with Node and Playwright. REVIEW_DIR/session.json holds an authorised // short-lived session as { name, token }. Never commit this file. Live requests are read-only: // all writes are blocked, with save/validation paths checked using fixtures. const reviewDir = process.env.REVIEW_DIR || '/review' const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || reviewDir + '/node_modules/playwright') const fs = require('node:fs') const assert = require('node:assert/strict') const base = process.env.REVIEW_BASE || 'https://beta.grizzlyflix.co.nz' const liveBase = process.env.REVIEW_LIVE_BASE || 'https://beta.grizzlyflix.co.nz' const prefix = process.env.REVIEW_PREFIX || 'before' const session = JSON.parse(fs.readFileSync(reviewDir + '/session.json', 'utf8')) ;(async () => { const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }) const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }) await context.addCookies([ { name: session.name, value: session.token, url: base, httpOnly: true, secure: base.startsWith('https') }, { name: 'magent_logged_in', value: '1', url: base }, ]) const errors = [] const httpErrors = [] const blocked = [] await context.route('**/api/**', async (route) => { const request = route.request() if (request.url().includes('/events/stream')) return route.fulfill({ status: 200, contentType: 'text/event-stream', body: ': review\n\n' }) if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) { blocked.push({ path: new URL(request.url()).pathname, method: request.method() }) return route.fulfill({ status: 409, json: { detail: 'Read-only browser review' } }) } if (base !== liveBase) { const remote = liveBase + new URL(request.url()).pathname + new URL(request.url()).search try { const response = await route.fetch({ url: remote, headers: { ...request.headers(), cookie: session.name + '=' + session.token } }) return await route.fulfill({ response }) } catch { // Browser cancellation must not print request headers (including cookies). await route.abort().catch(() => {}) return } } await route.continue() }) const page = await context.newPage() page.on('pageerror', (error) => errors.push(error.message)) page.on('response', (response) => { if (response.status() >= 400) httpErrors.push({ path: new URL(response.url()).pathname, status: response.status() }) }) const reports = [] const paths = process.env.REVIEW_PATHS ? process.env.REVIEW_PATHS.split(',') : prefix === 'before' ? ['/admin', '/admin/radarr', '/admin/notifications', '/admin/site'] : [ '/admin', '/admin/seerr', '/admin/jellyfin', '/admin/sonarr', '/admin/radarr', '/admin/bazarr', '/admin/prowlarr', '/admin/qbittorrent', '/admin/site', '/admin/notifications', '/admin/issue-workflow', '/admin/requests', '/admin/general', '/admin/cache', '/admin/artwork', '/admin/logs', '/admin/maintenance', '/admin/invites', '/admin/diagnostics', '/', '/new-requests', '/portal/issues', '/profile/invites', '/profile', ] for (const path of paths) { await page.goto(base + path, { waitUntil: 'domcontentloaded' }) await page.waitForTimeout(1600) if (path.startsWith('/admin/') && !['/admin/invites','/admin/diagnostics'].includes(path)) { await page.locator('.admin-card').waitFor({ timeout: 30000 }).catch(async (error) => { console.log(JSON.stringify({ failedPath: path, location: page.url(), errors, httpErrors, blocked })) throw error }) } else await page.locator('main').first().waitFor() const stats = await page.evaluate(() => ({ title: document.querySelector('main h1')?.textContent, width: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, visibleInputs: [...document.querySelectorAll('main input,main select,main textarea')].filter((el) => el.getBoundingClientRect().height > 0).length, })) reports.push({ path, ...stats }) if (['/admin','/admin/radarr','/admin/notifications','/admin/site','/admin/issue-workflow'].includes(path)) { await page.screenshot({ path: reviewDir + '/' + prefix + path.replaceAll('/','-') + '.png', fullPage: true }) } console.log(JSON.stringify({ path, ...stats })) } for (const path of ['/admin','/admin/radarr','/admin/cache','/new-requests','/portal/issues','/profile/invites']) { await page.setViewportSize({ width: 390, height: 844 }) await page.goto(base + path, { waitUntil: 'domcontentloaded' }) await page.waitForTimeout(1800) const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) reports.push({ mobile: path, overflow }) await page.screenshot({ path: reviewDir + '/' + prefix + '-mobile' + path.replaceAll('/','-') + '.png', fullPage: true }) console.log(JSON.stringify({ mobile: path, overflow })) } if (prefix !== 'before') { await page.setViewportSize({ width: 1440, height: 1000 }) await page.goto(base + '/admin/radarr') const field = page.locator('input[name=radarr_base_url]') await field.waitFor() const saved = await field.inputValue() const region = page.locator('#config-radarr-connection') assert(await region.getByRole('button', { name: 'Save changes', exact: true }).isDisabled()) await field.fill(saved + '/unsaved-review') assert(await region.getByRole('button', { name: 'Test connection', exact: true }).isDisabled()) await region.getByRole('button', { name: 'Discard', exact: true }).click() assert.equal(await field.inputValue(), saved) // Saving is intercepted: verify only this region is submitted, and // leaving an existing secret blank never overwrites that secret. let payload await page.route('**/api/admin/settings', async (route) => { if (route.request().method() !== 'PUT') return route.fallback() payload = route.request().postDataJSON() return route.fulfill({ status: 200, json: { ok: true } }) }) await field.fill(saved + '/review') await region.getByRole('button', { name: 'Save changes', exact: true }).click() await page.waitForTimeout(1000) assert(payload && payload.radarr_base_url.endsWith('/review')) assert(!('radarr_api_key' in payload)) assert(!('sonarr_base_url' in payload)) await page.goto(base + '/admin/issue-workflow') await page.locator('select[name=issue_confirmation_contact_attempts]').selectOption('0') assert(!(await page.locator('[name=issue_confirmation_interval_value]').count())) await page.locator('select[name=issue_confirmation_contact_attempts]').selectOption('3') await page.locator('input[name=issue_confirmation_interval_value]').fill('366') assert(!(await page.locator('input[name=issue_confirmation_interval_value]').evaluate((el) => el.checkValidity()))) await page.goto(base + '/admin/notifications') const discord = page.locator('#config-magent-notify-discord') await discord.getByRole('button', { name: /Discord/ }).click() const toggle = discord.getByRole('switch') await toggle.check() assert(await discord.locator('[name=magent_notify_discord_webhook_url]').isVisible()) await discord.getByRole('button', { name: 'Discard', exact: true }).click() assert(!(await discord.locator('[name=magent_notify_discord_webhook_url]').count())) await page.setViewportSize({ width: 1024, height: 768 }) await page.goto(base + '/admin/cache') await page.getByRole('button', { name: 'Load saved requests', exact: true }).waitFor() await page.screenshot({ path: reviewDir + '/tablet-cache.png', fullPage: true }) assert(await page.getByRole('button', { name: 'Load saved requests', exact: true }).isVisible()) await page.getByRole('button', { name: 'Load saved requests', exact: true }).click() await page.waitForTimeout(700) assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth), false) await page.goto(base + '/portal/issues') await page.waitForTimeout(1800) const report = page.locator('.portal-item-list > button').first() if (await report.count()) { await report.click() const modal = page.locator('.issue-detail-modal.is-open') await modal.waitFor() const top = await modal.boundingBox() const header = await page.locator('.header').boundingBox() assert(top.y >= header.y + header.height, 'Issue window must clear the header') await modal.getByRole('button', { name: 'Close', exact: true }).click() assert(!(await page.locator('.issue-detail-modal.is-open').count())) console.log('PASS: issue popup opens, clears the header and closes') } await page.goto(base + '/') await page.locator('.recent-card').first().waitFor({ timeout: 30000 }).catch(() => {}) const request = page.locator('.recent-card').first() if (await request.count()) { await request.click() await page.waitForTimeout(2500) assert(new URL(page.url()).pathname.startsWith('/requests/')) assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth), false) console.log('PASS: request detail navigation and layout') } assert(reports.every((report) => report.overflow == null || report.overflow === 0), 'Mobile overflow') console.log('PASS: dirty state, discard, region-only save, secret preservation, confirmation limits') } fs.writeFileSync(reviewDir + '/' + prefix + '-report.json', JSON.stringify({ reports, errors, blocked }, null, 2)) console.log(JSON.stringify({ errors, blocked })) await page.unrouteAll({ behavior: 'ignoreErrors' }) await context.unrouteAll({ behavior: 'ignoreErrors' }) await browser.close() if (errors.length) process.exitCode = 1 })().catch((error) => { console.error(error); process.exit(1) })