Simplify navigation and modernize profile and sign-in
Magent CI/CD / verify (push) Successful in 10m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m58s

This commit is contained in:
2026-09-06 18:24:18 +12:00
parent b5e4c57e93
commit 4d67567d4c
15 changed files with 620 additions and 755 deletions
+17 -5
View File
@@ -47,18 +47,30 @@ echo "Running remote beta smoke checks"
ssh ${ssh_opts} "${remote}" "
set -e
python3 - <<'PY'
from urllib import request
import time
from urllib import error, request
checks = [
('http://127.0.0.1:8100/health', 200),
('http://${beta_frontend_bind}:3100/login', 200),
]
# Compose returns before the app is ready. Allow the new processes to start
# instead of reporting a failed deployment on the first connection reset.
deadline = time.monotonic() + 90
for url, expected in checks:
with request.urlopen(url, timeout=20) as response:
if response.status != expected:
raise SystemExit(f'{url} returned {response.status}, expected {expected}')
print(url, response.status)
while True:
try:
with request.urlopen(url, timeout=5) as response:
if response.status != expected:
raise OSError(f'HTTP {response.status}, expected {expected}')
print(url, response.status)
break
except (error.URLError, OSError, TimeoutError) as exc:
if time.monotonic() >= deadline:
raise SystemExit(f'Beta did not become ready: {url}: {exc}') from exc
print(f'Waiting for beta to start: {url}', flush=True)
time.sleep(2)
PY
"
+185
View File
@@ -0,0 +1,185 @@
// 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) })
+1 -1
View File
@@ -8,7 +8,7 @@ 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(fs.readFileSync(reviewDir + '/session.json', 'utf8'))
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'] })