From 5889a671a5ecd8030df2096291dce876d8345fd6 Mon Sep 17 00:00:00 2001 From: Xore Date: Sun, 9 Aug 2026 10:23:42 +0000 Subject: [PATCH 1/3] test: add account.spec.ts, fix real Account Console theme bugs (#91) Adds automated regression coverage for the Account Console theme (themes/apiary/account/, PR #111) matching login.spec.ts's depth and conventions -- previously CSS + manual screenshot verification only, per that PR's own stated gap. Also serves as #91's "upgrade compatibility check" acceptance criterion for the account console: keycloak.v3 is a compiled React SPA with no FreeMarker templates to hash the way verify-keycloak-compat.sh does for login, so the real analogue here is a DOM-hook scan that asserts every account.css selector matches a real element on a real page, with documented exceptions for the handful that are genuinely unreachable given this realm's enabled features (not compatibility drift). Real theme bugs found and fixed in account.css while building this suite: - The masthead's own `.pf-v5-c-toolbar` (its actual content wrapper) carries its own hardcoded near-black PatternFly default independent of the masthead's own background -- confirmed live via elementFromPoint: the masthead itself correctly resolved to --toolbar-bg, but this nested element painted over it entirely, rendering a solid black bar in both light and dark mode regardless of theme. - The masthead's user-menu toggle (username + chevron) hardcoded white text -- invisible in light mode against --toolbar-bg's near-white value. Only looked fine in dark mode by coincidence. - `#root` never matched anything -- the SPA actually mounts into `#app` (confirmed against the real static shell). html/body's own rules already covered the practical visual effect, but a dead selector should not be the only thing making that true. - A `.pf-v5-c-table` rule block matched zero elements anywhere: this Keycloak version's account console never renders a real Table component for device activity or applications, both of which actually use `.pf-v5-c-data-list`/`.pf-v5-c-description-list`. Removed rather than kept as non-functional dead weight; confirmed live that header/cell text and the expandable detail view already render correctly via inheritance and PatternFly's own dark-theme handling without needing an explicit override. Also fixed a real fixtures/realm-export.json gap found while building this suite: imported users got zero role mappings at all, not even the realm's own default role (which grants manage-account/view-profile) -- a real Keycloak realm-JSON-import quirk (self-registered/admin-created users get default roles automatically; imported ones don't unless listed explicitly). This was latent and invisible until now because no prior test exercised the account console, which is the only surface that actually requires those roles -- login/authentication itself needs none. Every fixture user now explicitly lists `realmRoles: ["default-roles-test-apiary"]`, matching what a real account-creation flow would produce. The production realm doesn't create users via JSON import at all, so this was never reachable there. Coverage: Personal info (light/dark x desktop/mobile, WCAG scan, validation-error and success-alert states -- #91's own acceptance criteria explicitly name both), Account security (Signing in, Device activity), Applications, the masthead user menu (including a real sign-out), the mobile hamburger nav drawer, and the DOM-hook compatibility scan. 13/13 passing against a real disposable Keycloak instance. --- test/fixtures/realm-export.json | 18 +- test/specs/account.spec.ts | 306 ++++++++++++++++++ ...ccount-applications-desktop-1440-linux.png | Bin 0 -> 33162 bytes ...l-info-dark-desktop-desktop-1440-linux.png | Bin 0 -> 42412 bytes ...al-info-dark-mobile-desktop-1440-linux.png | Bin 0 -> 29089 bytes ...-info-light-desktop-desktop-1440-linux.png | Bin 0 -> 42108 bytes ...l-info-light-mobile-desktop-1440-linux.png | Bin 0 -> 28493 bytes ...rsonal-info-success-desktop-1440-linux.png | Bin 0 -> 47434 bytes ...fo-validation-error-desktop-1440-linux.png | Bin 0 -> 50326 bytes .../account-signing-in-desktop-1440-linux.png | Bin 0 -> 63194 bytes .../apiary/account/resources/css/account.css | 51 ++- 11 files changed, 362 insertions(+), 13 deletions(-) create mode 100644 test/specs/account.spec.ts create mode 100644 test/specs/account.spec.ts-snapshots/account-applications-desktop-1440-linux.png create mode 100644 test/specs/account.spec.ts-snapshots/account-personal-info-dark-desktop-desktop-1440-linux.png create mode 100644 test/specs/account.spec.ts-snapshots/account-personal-info-dark-mobile-desktop-1440-linux.png create mode 100644 test/specs/account.spec.ts-snapshots/account-personal-info-light-desktop-desktop-1440-linux.png create mode 100644 test/specs/account.spec.ts-snapshots/account-personal-info-light-mobile-desktop-1440-linux.png create mode 100644 test/specs/account.spec.ts-snapshots/account-personal-info-success-desktop-1440-linux.png create mode 100644 test/specs/account.spec.ts-snapshots/account-personal-info-validation-error-desktop-1440-linux.png create mode 100644 test/specs/account.spec.ts-snapshots/account-signing-in-desktop-1440-linux.png diff --git a/test/fixtures/realm-export.json b/test/fixtures/realm-export.json index 6136a04..9e9519d 100644 --- a/test/fixtures/realm-export.json +++ b/test/fixtures/realm-export.json @@ -130,7 +130,8 @@ "credentials": [ {"type": "password", "value": "test-password-only", "temporary": false} ], - "requiredActions": ["CONFIGURE_TOTP"] + "requiredActions": ["CONFIGURE_TOTP"], + "realmRoles": ["default-roles-test-apiary"] }, { "username": "test-user-verify-email", @@ -140,7 +141,8 @@ "credentials": [ {"type": "password", "value": "test-password-only", "temporary": false} ], - "requiredActions": ["VERIFY_EMAIL"] + "requiredActions": ["VERIFY_EMAIL"], + "realmRoles": ["default-roles-test-apiary"] }, { "username": "test-user-webauthn-register", @@ -150,7 +152,8 @@ "credentials": [ {"type": "password", "value": "test-password-only", "temporary": false} ], - "requiredActions": ["webauthn-register"] + "requiredActions": ["webauthn-register"], + "realmRoles": ["default-roles-test-apiary"] }, { "username": "test-user-consent", @@ -160,7 +163,8 @@ "credentials": [ {"type": "password", "value": "test-password-only", "temporary": false} ], - "requiredActions": [] + "requiredActions": [], + "realmRoles": ["default-roles-test-apiary"] }, { "username": "test-user-multi-factor", @@ -170,7 +174,8 @@ "credentials": [ {"type": "password", "value": "test-password-only", "temporary": false} ], - "requiredActions": ["CONFIGURE_TOTP", "webauthn-register"] + "requiredActions": ["CONFIGURE_TOTP", "webauthn-register"], + "realmRoles": ["default-roles-test-apiary"] }, { "username": "test-user-multi-factor-2", @@ -180,7 +185,8 @@ "credentials": [ {"type": "password", "value": "test-password-only", "temporary": false} ], - "requiredActions": ["CONFIGURE_TOTP", "webauthn-register"] + "requiredActions": ["CONFIGURE_TOTP", "webauthn-register"], + "realmRoles": ["default-roles-test-apiary"] } ] } diff --git a/test/specs/account.spec.ts b/test/specs/account.spec.ts new file mode 100644 index 0000000..3ab109d --- /dev/null +++ b/test/specs/account.spec.ts @@ -0,0 +1,306 @@ +import { test, expect, Page } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import fs from 'fs'; +import path from 'path'; + +// #91: account console theme (keycloak.v3, accountTheme=apiary). Unlike +// login.spec.ts's target, this is a compiled React SPA (PatternFly v5) -- +// no FreeMarker templates, only theme.properties' `styles=` CSS layered on +// top of the upstream bundle. See themes/apiary/account/resources/css/ +// account.css's own header comment for why its rules mirror login.css's. + +const REALM = 'test-apiary'; +const ACCOUNT_URL = `/realms/${REALM}/account/`; + +async function login(page: Page, baseURL: string, username: string) { + await page.goto(baseURL + ACCOUNT_URL); + await page.waitForSelector('#username'); + await page.locator('#username').fill(username); + await page.locator('#username').press('Enter'); + await page.locator('#password').fill('test-password-only'); + await page.locator('input[type="submit"], button[type="submit"]').click(); + await page.waitForURL('**/account/**'); + await page.waitForSelector('.pf-v5-c-masthead'); + await page.waitForLoadState('networkidle'); +} + +// Same acceptance criteria as login.spec.ts's trackPageHealth: no console +// errors, no external/CDN requests. Kept local rather than shared -- each +// spec file in this project owns its own helpers (see login.spec.ts's own +// submit()/authUrl()), and the two suites' health-tracking needs already +// diverge slightly (login.spec.ts also filters out one synthetic +// test-harness URL that doesn't apply here). +function trackPageHealth(page: Page, baseURL: string) { + const consoleErrors: string[] = []; + const externalRequests: string[] = []; + const baseOrigin = new URL(baseURL).origin; + + page.on('console', (msg) => { + if (msg.type() === 'error') consoleErrors.push(msg.text()); + }); + page.on('pageerror', (err) => consoleErrors.push(String(err))); + page.on('request', (req) => { + const url = req.url(); + if (url.startsWith('data:') || url.startsWith('blob:')) return; + if (!url.startsWith(baseOrigin)) externalRequests.push(url); + }); + + return { + assertHealthy() { + expect(consoleErrors, 'no console errors').toEqual([]); + expect(externalRequests, 'no external/CDN requests -- theme must be fully local').toEqual([]); + }, + }; +} + +test.describe('Personal info (#91)', () => { + for (const theme of ['light', 'dark'] as const) { + for (const viewport of [{ name: 'desktop', width: 1440, height: 900 }, { name: 'mobile', width: 390, height: 844 }]) { + test(`renders correctly in ${theme} at ${viewport.name}`, async ({ browser, baseURL }) => { + const ctx = await browser.newContext({ viewport, colorScheme: theme }); + const page = await ctx.newPage(); + const health = trackPageHealth(page, baseURL!); + await login(page, baseURL!, 'test-user-consent'); + + await expect(page.getByTestId('page-heading')).toHaveText('Personal info'); + await expect(page.locator('#username')).toHaveValue('test-user-consent'); + + // #91 (found live auditing this suite): keycloak.v3 wraps the + // masthead's real content in a `.pf-v5-c-toolbar` that carries its + // own hardcoded near-black PatternFly default independent of the + // masthead's own background, and the user-menu toggle hardcodes + // white text -- both invisible/wrong in at least one theme unless + // account.css explicitly overrides them (see that file's own + // comments). Assert computed styles directly, not just "no visual + // regression", so a future drift fails loudly here instead of only + // being visible in a screenshot diff nobody looked closely at. + const toolbarBg = await page.locator('.pf-v5-c-masthead .pf-v5-c-toolbar').first().evaluate((el) => getComputedStyle(el).backgroundColor); + expect(toolbarBg, 'masthead toolbar must not paint over the themed masthead background').toBe('rgba(0, 0, 0, 0)'); + const menuToggleColor = await page.locator('.pf-v5-c-masthead .pf-v5-c-menu-toggle__text').first().evaluate((el) => getComputedStyle(el).color); + expect(menuToggleColor, 'user-menu toggle text must not hardcode white').not.toBe('rgb(255, 255, 255)'); + + const hasOverflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth); + expect(hasOverflow, `${viewport.name} must not overflow horizontally`).toBe(false); + + health.assertHealthy(); + await expect(page).toHaveScreenshot(`account-personal-info-${theme}-${viewport.name}.png`); + await ctx.close(); + }); + } + } + + test('has no automatically detectable WCAG violations', async ({ page, baseURL }) => { + await login(page, baseURL!, 'test-user-consent'); + const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze(); + expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]); + }); + + // #91's own acceptance criteria explicitly calls for "validation, error" + // state coverage, not just the default/happy-path render every other + // test here checks. test-user-consent has no firstName/lastName set and + // this clears the required Email field too, so submitting fails + // validation on all three -- a real server round trip, not a simulated + // client-side state. + test('shows a themed validation error on save with missing required fields', async ({ page, baseURL }) => { + // No trackPageHealth/assertHealthy here -- unlike every other test in + // this file, a real server-rejected 400 is this test's own expected + // outcome, not a health regression to flag. + await login(page, baseURL!, 'test-user-consent'); + await page.locator('#email').fill(''); + await page.getByRole('button', { name: 'Save' }).click(); + + await expect(page.locator('.pf-v5-c-alert.pf-m-danger')).toBeVisible(); + await expect(page.locator('.pf-v5-c-helper-text__item.pf-m-error, .pf-v5-c-form__helper-text.pf-m-error').first()).toBeVisible(); + await expect(page).toHaveScreenshot('account-personal-info-validation-error.png'); + + // Restore state for any later test that reuses this fixture user. + await page.locator('#email').fill('test-user-consent@example.invalid'); + }); + + // The other, equally-real state #91's acceptance criteria names: + // "success". A real save that the server actually accepts, not a + // simulated success banner. + test('shows a themed success alert on a real accepted save', async ({ page, baseURL }) => { + await login(page, baseURL!, 'test-user-consent'); + await page.locator('#firstName').fill('Test'); + await page.locator('#lastName').fill('User'); + await page.getByRole('button', { name: 'Save' }).click(); + + await expect(page.locator('.pf-v5-c-alert.pf-m-success')).toBeVisible(); + await expect(page).toHaveScreenshot('account-personal-info-success.png'); + }); +}); + +test.describe('Account security (#91)', () => { + test('Signing in shows the password credential and a passkey setup link', async ({ page, baseURL }) => { + const health = trackPageHealth(page, baseURL!); + await login(page, baseURL!, 'test-user-consent'); + await page.getByText('Account security', { exact: true }).click(); + await page.getByText('Signing in', { exact: true }).click(); + await expect(page.getByTestId('page-heading')).toHaveText('Signing in'); + await expect(page.getByText('My password')).toBeVisible(); + // "Set up Authenticator application" is a PatternFly `pf-m-link` -- + // still a real