186 lines
11 KiB
JavaScript
186 lines
11 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: { showJellyfinLogin: true, showLocalLogin: true, showForgotPassword: true, showSignupLink: true }, navigation: { showRequests: true }, banner: { enabled: true, message: 'Beta environment', tone: 'warning' } }
|
|
|
|
;(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'
|
|
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: 'user' })
|
|
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.banner.message = 'Maintenance tonight'
|
|
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(await page.getByText('Maintenance tonight').isVisible())
|
|
options = structuredClone(site)
|
|
options.login.showLocalLogin = false
|
|
await openLogin()
|
|
loginStatus = 200
|
|
await login()
|
|
await page.waitForURL(base + '/')
|
|
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 and notices')
|
|
|
|
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) })
|