// Read-only visual contract review. Live writes are blocked; auth forms use fixtures. // REVIEW_SESSION is a short-lived {name, token} JSON value supplied in memory. const assert = require('node:assert/strict') const fs = require('node:fs') const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright') const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101' const liveBase = process.env.REVIEW_LIVE_BASE || base const output = process.env.REVIEW_DIR const session = JSON.parse(process.env.REVIEW_SESSION) ;(async () => { const browser = await chromium.launch({ headless: true }) const context = await browser.newContext() const errors = [] const reports = [] const blocked = [] await context.addCookies([ { name: session.name, value: session.token, url: base, httpOnly: true }, { name: 'magent_logged_in', value: '1', url: base }, ]) await context.route('**/api/**', async (route) => { const request = route.request() const url = new URL(request.url()) if (url.pathname.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(url.pathname) return route.fulfill({ status: 409, json: { detail: 'Read-only UI review' } }) } try { const response = await route.fetch({ url: liveBase + url.pathname + url.search, headers: { ...request.headers(), cookie: session.name + '=' + session.token } }) await route.fulfill({ response }) } catch { await route.abort().catch(() => {}) } }) const page = await context.newPage() page.on('pageerror', (error) => errors.push(error.message)) const inspect = async (path, width) => { await page.setViewportSize({ width, height: 1000 }) await page.goto(base + path, { waitUntil: 'domcontentloaded' }) await page.locator('main .page-heading h1').waitFor({ timeout: 30000 }) await page.waitForTimeout(600) const result = await page.evaluate(() => { const heading = document.querySelector('.page-heading') const style = getComputedStyle(heading) const main = document.querySelector('main').getBoundingClientRect() return { title: heading.querySelector('h1').textContent, headings: document.querySelectorAll('main h1').length, font: getComputedStyle(heading.querySelector('h1')).fontSize, left: main.left, right: innerWidth - main.right, overflow: document.documentElement.scrollWidth - innerWidth, radius: style.borderRadius, background: style.backgroundColor, headerWidth: heading.getBoundingClientRect().width, mainWidth: main.width } }) reports.push({ path, width, ...result }) if (output) await page.screenshot({ path: output + '/layout-' + width + path.replaceAll('/', '-') + '.png', fullPage: true, animations: 'disabled' }) if (result.overflow) console.log(await page.evaluate(() => [...document.querySelectorAll('main *')].filter((el) => el.getBoundingClientRect().right > innerWidth).slice(0, 15).map((el) => ({ tag: el.tagName, class: el.className, width: el.getBoundingClientRect().width })))) assert.equal(result.headings, 1, `${path}: one page title`) assert.equal(result.overflow, 0, `${path}: overflow at ${width}px`) assert.equal(result.left, width <= 680 ? 16 : 32, `${path}: left alignment at ${width}px`) assert.equal(result.right, result.left, `${path}: symmetric gutters`) assert.equal(result.headerWidth, result.mainWidth, `${path}: heading spans the content width`) assert.equal(result.radius, '0px', `${path}: flat page header`) assert.equal(result.background, 'rgba(0, 0, 0, 0)', `${path}: no header panel`) assert.equal(result.font, width === 1440 ? '32px' : '26px', `${path}: shared title scale`) console.log(`PASS ${width}px ${path}`) } // Resolve real records through existing navigation; do not invent or create test records. await page.goto(base) const recent = page.locator('.recent-card').first() await recent.waitFor({ timeout: 30000 }) await recent.click() await page.waitForURL('**/requests/*') const requestPath = new URL(page.url()).pathname await page.goto(base + '/users') const user = page.locator('a[href^="/users/"]').first() await user.waitFor({ timeout: 30000 }) const userPath = await user.getAttribute('href') const paths = ['/', '/new-requests', '/portal/issues', '/profile/invites', '/profile', requestPath, '/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', '/admin/system', '/admin/requests-all', '/users', userPath, '/how-it-works', '/changelog', '/feedback'] for (const width of [1440, 390]) for (const path of process.env.REVIEW_PATHS?.split(',') || paths) await inspect(path, width) // Redirects must land on the same styled workspaces. for (const path of ['/portal', '/portal/requests', '/admin/profiles', '/admin/issues']) await inspect(path, 390) for (const width of [1440, 390]) { await page.setViewportSize({ width, height: 1000 }) await page.goto(base + '/admin/invites') for (const tab of ['Invite links', 'Profiles', 'Automation', 'Delivery', 'Lineage']) { await page.getByRole('tab', { name: tab, exact: true }).click() assert.equal(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth), 0, `Invite ${tab} at ${width}px`) } await page.goto(base + requestPath) await page.locator('.request-stage').first().waitFor() const columns = await page.locator('.request-stage').evaluateAll((stages) => new Set(stages.map((el) => Math.round(el.getBoundingClientRect().left))).size) assert.equal(columns, width === 1440 ? 3 : 1, `Pipeline columns at ${width}px`) } await inspect('/layout-review-page-not-found', 390) await context.unrouteAll({ behavior: 'ignoreErrors' }) // Authentication layout and form contracts are isolated from all live services. const calls = [] await context.route('**/api/**', (route) => { const req = route.request() const path = new URL(req.url()).pathname if (req.method() !== 'GET') calls.push({ path, body: req.postDataJSON() }) const reply = (json) => route.fulfill({ status: 200, json }) if (path.endsWith('/reset/verify')) return reply({ status: 'valid', recipient_hint: 'm***@example.com', auth_provider: 'jellyfin' }) if (path.includes('/auth/invites/')) return reply({ invite: { code: 'review-only', label: 'Welcome', is_usable: true, enabled: true, description: 'Join Grizzlyflix.' } }) if (path.endsWith('/password/forgot')) return reply({ message: 'If your account is eligible, a reset link has been sent.' }) return reply({}) }) for (const width of [1440, 390, 320]) { await page.setViewportSize({ width, height: 900 }) for (const path of ['/forgot-password', '/reset-password', '/reset-password?token=review-only', '/signup', '/signup?code=review-only']) { await page.goto(base + path) await page.locator('.login-card').waitFor() assert.equal(await page.locator('.header').count(), 0) assert.equal(await page.locator('.brand-logo--login').count(), 0) assert.equal(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth), 0, `Auth overflow: ${path} ${width}`) if (output) await page.screenshot({ path: output + '/layout-auth-' + width + path.split('?')[0].replaceAll('/', '-') + (path.includes('?') ? '-valid' : '') + '.png', fullPage: true }) } } await page.goto(base + '/forgot-password') await page.getByLabel('Username or email').fill('member@example.com') await page.getByRole('button', { name: 'Send reset link', exact: true }).click() await page.getByRole('status').filter({ hasText: 'reset link has been sent' }).waitFor() assert.equal(calls.at(-1).body.identifier, 'member@example.com') await page.goto(base + '/reset-password?token=review-only') await page.getByLabel('New password', { exact: true }).fill('review-password') await page.getByLabel('Confirm new password', { exact: true }).fill('does-not-match') await page.getByRole('button', { name: 'Reset password', exact: true }).click() await page.getByRole('alert').filter({ hasText: 'Passwords do not match' }).waitFor() assert(!calls.some((call) => call.path.endsWith('/password/reset'))) assert.deepEqual(errors, []) if (output) fs.writeFileSync(output + '/layout-report.json', JSON.stringify({ reports, blocked, errors }, null, 2)) await context.unrouteAll({ behavior: 'ignoreErrors' }) await browser.close() console.log(`PASS: ${reports.length} route/viewport checks; recovery forms tested with fixtures only`) })().catch((error) => { console.error(error); process.exit(1) })