Files
Magent/scripts/review_settings_ui.cjs
T
Assclaw 4d67567d4c
Magent CI/CD / verify (push) Successful in 10m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m58s
Simplify navigation and modernize profile and sign-in
2026-09-06 18:24:18 +12:00

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) })