165 lines
9.8 KiB
JavaScript
165 lines
9.8 KiB
JavaScript
// 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(process.env.REVIEW_SESSION || 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) })
|