241 lines
14 KiB
JavaScript
241 lines
14 KiB
JavaScript
// UI contract checks. Every API request is mocked: no accounts, passwords,
|
|
// emails or live media are modified. Requires Node and Playwright.
|
|
const assert = require('node:assert/strict')
|
|
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
|
|
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101'
|
|
const output = process.env.REVIEW_DIR
|
|
const site = { login: { message: '', showJellyfinLogin: true, showLocalLogin: true, showForgotPassword: true, showSignupLink: true }, navigation: { showRequests: true }, banner: { enabled: true, message: 'Beta environment', tone: 'warning', backgroundColor: null, borderColor: null } }
|
|
|
|
;(async () => {
|
|
const browser = await chromium.launch({ headless: true })
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
|
let options = structuredClone(site)
|
|
let loginStatus = 401
|
|
let profileStatus = 200
|
|
let emailStatus = 200
|
|
let passwordStatus = 200
|
|
let provider = 'jellyfin'
|
|
let supported = true
|
|
let savedEmail = 'member@example.com'
|
|
let adminSettings = [
|
|
['site_banner_enabled', 'true'],
|
|
['site_banner_tone', 'warning'],
|
|
['site_banner_background_color', '#24172f'],
|
|
['site_banner_border_color', '#d946ef'],
|
|
['site_banner_message', 'Maintenance tonight'],
|
|
['site_login_message', 'Sign-in help is available from the media team.'],
|
|
['site_login_show_jellyfin_login', 'true'],
|
|
['site_login_show_local_login', 'true'],
|
|
['site_login_show_forgot_password', 'true'],
|
|
['site_login_show_signup_link', 'true'],
|
|
].map(([key, value]) => ({ key, value, isSet: true, source: 'db', sensitive: false }))
|
|
const calls = []
|
|
const errors = []
|
|
await context.route('**/api/**', async (route) => {
|
|
const request = route.request()
|
|
const path = new URL(request.url()).pathname
|
|
const method = request.method()
|
|
const reply = (json, status = 200) => route.fulfill({ status, json })
|
|
if (path === '/api/site/public' || path === '/api/site/info') return reply(options)
|
|
if (path === '/api/auth/me') return reply({ username: 'Grizzlyflix member', role: 'admin' })
|
|
if (path === '/api/admin/settings') {
|
|
if (method === 'PUT') {
|
|
const body = request.postDataJSON()
|
|
calls.push({ path, method, body: request.postData() })
|
|
adminSettings = adminSettings.map((setting) => Object.hasOwn(body, setting.key)
|
|
? { ...setting, value: body[setting.key], isSet: Boolean(body[setting.key]) }
|
|
: setting)
|
|
return reply({ status: 'ok', updated: Object.keys(body).length })
|
|
}
|
|
return reply({ settings: adminSettings })
|
|
}
|
|
if (path === '/api/auth/profile') return reply(profileStatus === 200 ? {
|
|
user: { username: 'Grizzlyflix member', role: 'user', email: savedEmail, auth_provider: provider, password_provider: provider, password_change_supported: supported },
|
|
stats: { total: 12, ready: 9, in_progress: 3 },
|
|
activity: { recent: Array.from({ length: 7 }, (_, i) => ({ ip: '192.0.2.' + (i + 1), user_agent: 'Mozilla/5.0 (Windows NT 10.0) Chrome/140.0', first_seen_at: '2026-09-01T00:00:00Z', last_seen_at: '2026-09-06T01:00:00Z' })) },
|
|
} : { detail: 'Service unavailable' }, profileStatus)
|
|
if (method !== 'GET') calls.push({ path, method, body: request.postData() })
|
|
if (path === '/api/auth/profile/email') {
|
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
if (emailStatus !== 200) return reply({ detail: 'This email is already in use.' }, emailStatus)
|
|
savedEmail = request.postDataJSON().email
|
|
return reply({ email: savedEmail })
|
|
}
|
|
if (path === '/api/auth/password') {
|
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
return reply(passwordStatus === 200 ? { provider } : { detail: 'Current password is incorrect' }, passwordStatus)
|
|
}
|
|
if (path.endsWith('/login')) {
|
|
if (loginStatus === 0) return route.abort()
|
|
return reply({ authenticated: loginStatus === 200 }, loginStatus)
|
|
}
|
|
if (path.includes('/events/stream')) return route.fulfill({ status: 200, contentType: 'text/event-stream', body: ': fixture\n\n' })
|
|
if (path.includes('/branding/')) return route.fulfill({ status: 404 })
|
|
return reply({ requests: [], items: [], services: [], results: [] })
|
|
})
|
|
const page = await context.newPage()
|
|
page.on('pageerror', (error) => errors.push(error.message))
|
|
const screenshot = async (name) => { if (output) await page.screenshot({ path: output + '/' + name + '.png', fullPage: true, animations: 'disabled' }) }
|
|
const openLogin = async () => { await page.goto(base + '/login'); await page.getByRole('heading', { name: 'Welcome back.' }).waitFor(); await page.waitForTimeout(250) }
|
|
const login = async () => { await page.getByLabel('Username', { exact: true }).fill('member'); await page.getByLabel('Password', { exact: true }).fill('test-password'); await page.getByRole('button', { name: 'Sign in', exact: true }).click() }
|
|
await openLogin()
|
|
assert.equal(await page.locator('.header,.workspace-sidebar,.admin-sidebar').count(), 0)
|
|
await screenshot('account-login-desktop')
|
|
await login()
|
|
await page.getByRole('alert').filter({ hasText: 'Check your username' }).waitFor()
|
|
assert.equal(calls.at(-1).path, '/api/auth/jellyfin/login')
|
|
await page.getByRole('button', { name: 'Magent', exact: true }).click()
|
|
loginStatus = 429
|
|
await login()
|
|
await page.getByRole('alert').filter({ hasText: 'Too many attempts' }).waitFor()
|
|
assert.equal(calls.at(-1).path, '/api/auth/login')
|
|
loginStatus = 503
|
|
await login()
|
|
await page.getByRole('alert').filter({ hasText: 'temporarily unavailable' }).waitFor()
|
|
loginStatus = 0
|
|
await login()
|
|
await page.getByRole('alert').filter({ hasText: 'Check your connection' }).waitFor()
|
|
await page.getByRole('button', { name: 'Show password' }).click()
|
|
assert.equal(await page.getByLabel('Password', { exact: true }).getAttribute('type'), 'text')
|
|
await page.getByRole('button', { name: 'Hide password' }).click()
|
|
|
|
options.login.showJellyfinLogin = false
|
|
await openLogin()
|
|
assert.equal(await page.getByRole('group', { name: 'Sign-in account' }).count(), 0)
|
|
loginStatus = 401
|
|
await login()
|
|
await page.getByRole('alert').waitFor()
|
|
assert.equal(calls.at(-1).path, '/api/auth/login')
|
|
options.login.showLocalLogin = false
|
|
options.login.showForgotPassword = false
|
|
options.login.showSignupLink = false
|
|
options.login.message = 'Sign-in help is available from the media team.'
|
|
options.banner.message = 'Maintenance tonight'
|
|
options.banner.backgroundColor = '#24172f'
|
|
options.banner.borderColor = '#d946ef'
|
|
await openLogin()
|
|
assert.equal(await page.getByRole('button', { name: 'Sign in', exact: true }).count(), 0)
|
|
assert.equal(await page.getByRole('link', { name: 'Forgot password?' }).count(), 0)
|
|
assert.equal(await page.getByRole('link', { name: /Create an account/ }).count(), 0)
|
|
assert.equal(await page.getByText('Maintenance tonight').count(), 0)
|
|
assert(await page.getByText('Sign-in help is available from the media team.').isVisible())
|
|
options = structuredClone(site)
|
|
options.login.showLocalLogin = false
|
|
await openLogin()
|
|
loginStatus = 200
|
|
await login()
|
|
await page.waitForURL(base + '/welcome')
|
|
assert((await context.cookies()).some((cookie) => cookie.name === 'magent_logged_in' && cookie.value === '1'))
|
|
console.log('PASS: both sign-in providers, disabled methods, error states, password visibility, redirect, login-only message and hidden site banner')
|
|
|
|
options.banner = { enabled: true, message: 'Custom site banner', tone: 'warning', backgroundColor: '#24172f', borderColor: '#d946ef' }
|
|
await page.goto(base + '/admin/site')
|
|
const signedInBannerStyle = await page.getByText('Custom site banner', { exact: true }).evaluate((element) => ({
|
|
background: getComputedStyle(element).backgroundColor,
|
|
border: getComputedStyle(element).borderTopColor,
|
|
}))
|
|
assert.deepEqual(signedInBannerStyle, { background: 'rgb(36, 23, 47)', border: 'rgb(217, 70, 239)' })
|
|
const bannerRegion = page.locator('#config-site-banner')
|
|
const backgroundHex = bannerRegion.getByLabel('Banner background colour', { exact: true })
|
|
await backgroundHex.waitFor()
|
|
await backgroundHex.fill('red')
|
|
assert.equal(await backgroundHex.evaluate((element) => element.checkValidity()), false)
|
|
await bannerRegion.getByLabel('Choose banner background colour', { exact: true }).fill('#112233')
|
|
assert.equal(await backgroundHex.inputValue(), '#112233')
|
|
await bannerRegion.getByRole('button', { name: 'Save changes', exact: true }).click()
|
|
await bannerRegion.getByText('Settings saved.').waitFor()
|
|
const bannerSave = calls.filter((call) => call.path === '/api/admin/settings').at(-1)
|
|
assert.equal(JSON.parse(bannerSave.body).site_banner_background_color, '#112233')
|
|
|
|
const loginRegion = page.locator('#config-site-login')
|
|
await loginRegion.getByLabel('Logged-out login page message', { exact: true }).fill('Welcome. Contact support if you cannot sign in.')
|
|
await loginRegion.getByRole('button', { name: 'Save changes', exact: true }).click()
|
|
await loginRegion.getByText('Settings saved.').waitFor()
|
|
const loginSave = calls.filter((call) => call.path === '/api/admin/settings').at(-1)
|
|
assert.equal(JSON.parse(loginSave.body).site_login_message, 'Welcome. Contact support if you cannot sign in.')
|
|
assert(!Object.hasOwn(JSON.parse(loginSave.body), 'site_banner_message'))
|
|
console.log('PASS: Site & sign-in colour picker, native hex validation, login message and region-only saves')
|
|
|
|
await page.goto(base + '/profile')
|
|
const email = page.getByLabel('Email address', { exact: true })
|
|
await email.waitFor()
|
|
assert.equal(await page.locator('.workspace-sidebar,.admin-sidebar').count(), 0)
|
|
assert(await page.getByRole('button', { name: 'Save email' }).isDisabled())
|
|
await screenshot('account-profile-desktop')
|
|
await email.fill('changed@example.com')
|
|
await page.getByRole('button', { name: 'Discard' }).click()
|
|
assert.equal(await email.inputValue(), savedEmail)
|
|
await email.fill('not-an-email')
|
|
assert.equal(await email.evaluate((el) => el.checkValidity()), false)
|
|
await email.fill('changed@example.com')
|
|
await page.getByRole('button', { name: 'Save email' }).click()
|
|
await page.getByRole('status').filter({ hasText: 'Email saved.' }).waitFor()
|
|
assert.equal(JSON.parse(calls.at(-1).body).email, 'changed@example.com')
|
|
await email.fill('')
|
|
assert(await page.getByText('Saving without an email stops account and issue emails.').isVisible())
|
|
await page.getByRole('button', { name: 'Save email' }).click()
|
|
await page.getByRole('status').filter({ hasText: 'Email removed.' }).waitFor()
|
|
assert.equal(JSON.parse(calls.at(-1).body).email, null)
|
|
emailStatus = 409
|
|
await email.fill('duplicate@example.com')
|
|
await page.getByRole('button', { name: 'Save email' }).click()
|
|
await page.getByRole('alert').filter({ hasText: 'already in use' }).waitFor()
|
|
await page.getByRole('tab', { name: 'Account', exact: true }).focus()
|
|
await page.keyboard.press('ArrowRight')
|
|
assert.equal(await page.getByRole('tab', { name: 'Security', exact: true }).getAttribute('aria-selected'), 'true')
|
|
await screenshot('account-security-desktop')
|
|
await page.getByLabel('Current password', { exact: true }).fill('current-password')
|
|
await page.getByLabel('New password', { exact: true }).fill('new-password')
|
|
await page.getByLabel('Confirm new password', { exact: true }).fill('mismatch-password')
|
|
const previousCalls = calls.length
|
|
await page.getByRole('button', { name: 'Update password' }).click()
|
|
await page.getByRole('alert').filter({ hasText: 'do not match' }).waitFor()
|
|
assert.equal(calls.length, previousCalls)
|
|
await page.getByLabel('Confirm new password', { exact: true }).fill('new-password')
|
|
passwordStatus = 401
|
|
await page.getByRole('button', { name: 'Update password' }).click()
|
|
await page.getByRole('alert').filter({ hasText: 'Current password is incorrect' }).waitFor()
|
|
assert.equal(new URL(page.url()).pathname, '/profile')
|
|
passwordStatus = 200
|
|
await page.getByRole('button', { name: 'Update password' }).click()
|
|
await page.getByRole('status').filter({ hasText: 'Password updated for Grizzlyflix' }).waitFor()
|
|
assert.equal(await page.getByLabel('Current password', { exact: true }).inputValue(), '')
|
|
await page.getByRole('tab', { name: 'Activity', exact: true }).click()
|
|
assert.equal(await page.locator('.account-access-list > li').count(), 5)
|
|
assert.equal(await page.getByText('192.0.2.1', { exact: true }).isVisible(), false)
|
|
await page.getByRole('button', { name: 'Show all activity' }).click()
|
|
assert.equal(await page.locator('.account-access-list > li').count(), 7)
|
|
await screenshot('account-activity-desktop')
|
|
provider = 'local'
|
|
await page.goto(base + '/profile?tab=security')
|
|
await page.getByText('Keep your Magent account secure.').waitFor()
|
|
supported = false
|
|
await page.reload()
|
|
await page.getByText('Password changes are managed by your sign-in provider.', { exact: false }).waitFor()
|
|
assert.equal(await page.getByRole('button', { name: 'Update password' }).count(), 0)
|
|
profileStatus = 503
|
|
await page.reload()
|
|
await page.getByRole('button', { name: 'Try again' }).waitFor()
|
|
assert.equal(new URL(page.url()).pathname, '/profile')
|
|
profileStatus = 200
|
|
supported = true
|
|
await page.getByRole('button', { name: 'Try again' }).click()
|
|
await page.getByRole('tab', { name: 'Account', exact: true }).waitFor()
|
|
console.log('PASS: email save/removal/discard/validation, password validation/providers, keyboard tabs, activity and load recovery')
|
|
|
|
options = structuredClone(site)
|
|
for (const width of [320, 390, 768, 1024, 1440]) {
|
|
await page.setViewportSize({ width, height: 900 })
|
|
for (const path of ['/login', '/profile', '/profile?tab=security', '/profile?tab=activity']) {
|
|
await page.goto(base + path)
|
|
await page.waitForTimeout(300)
|
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth), false, path + ' overflows at ' + width)
|
|
if (width === 390 && (path === '/profile' || path === '/login')) await screenshot('account-mobile-' + path.slice(1))
|
|
}
|
|
}
|
|
assert.deepEqual(errors, [])
|
|
console.log('PASS: responsive layout at 320, 390, 768, 1024 and 1440px; no JavaScript errors')
|
|
await context.unrouteAll({ behavior: 'ignoreErrors' })
|
|
await browser.close()
|
|
})().catch((error) => { console.error(error.message); process.exit(1) })
|