diff --git a/test/specs/account.spec.ts b/test/specs/account.spec.ts index 02392e0..78030c5 100644 --- a/test/specs/account.spec.ts +++ b/test/specs/account.spec.ts @@ -12,23 +12,41 @@ import path from 'path'; const REALM = 'test-apiary'; const ACCOUNT_URL = `/realms/${REALM}/account/`; -// Unlike login.spec.ts, this suite doesn't run across all 6 viewport -// projects: "Personal info"'s own tests already open explicit-viewport -// contexts (desktop 1440x900 / mobile 390x844) regardless of which project -// runs them, so the project matrix would only re-run the exact same two -// sizes six times over. Every other describe block here uses the default -// page/viewport and was written and verified against desktop-1440 alone -- -// running them under narrower projects surfaces real, separately-tracked -// gaps (#113: nav collapses behind a hamburger these tests don't drive, and -// a genuine button-name a11y violation at mobile-390/iphone-393) rather -// than a theme regression, but at 30s per timeout that also blew this -// workflow's regression job past its 15-minute budget (confirmed live: -// CI's own run history shows the job cancelled at 15m17s once this file's -// tests entered the matrix). Scoping to desktop-1440 fixes both at once; -// #113 tracks doing the narrow-viewport coverage properly and on purpose. -test.beforeEach(({}, testInfo) => { - test.skip(testInfo.project.name !== 'desktop-1440', 'Account console suite runs once against desktop-1440 -- see comment above (#113).'); -}); +// #113: below ~820px (tablet-820, mobile-390, iphone-393) the account +// console's sidebar nav collapses behind a hamburger toggle in the +// masthead. Every describe block below that navigates via the sidebar +// (anything that isn't "Personal info", which manages its own explicit +// viewport contexts) drives openSidebarNav() first so it still works when +// the project's own viewport is one of the narrow ones -- previously these +// tests only ever ran under desktop-1440 (where the sidebar needs no +// toggle), and the whole suite was skipped outright everywhere else to +// avoid a 30s-per-test timeout waiting for nav text that was present in the +// DOM but hidden off-canvas (confirmed live: that timeout blew CI's +// 15-minute regression-job budget once this file entered the project +// matrix). Fixing the navigation removes that timeout, so the file now +// runs across the full matrix like login.spec.ts does. +async function openSidebarNav(page: Page) { + const nav = page.locator('.pf-v5-c-page__sidebar'); + if (await nav.evaluate((el) => el.classList.contains('pf-m-expanded'))) return; + const toggle = page.locator('.pf-v5-c-masthead .pf-v5-c-button').first(); + if (!(await toggle.isVisible())) return; // wide viewport -- no hamburger, sidebar is already visible + await toggle.click(); + await expect(nav).toHaveClass(/pf-m-expanded/); +} + +// Selecting a nav item does *not* auto-close the drawer here (confirmed +// live) -- it only closes on an outside click, which most of these tests +// never make. Screenshot-taking tests need it closed explicitly first, or +// the drawer sits on top of the exact content the screenshot exists to +// verify (found live: account-applications.png's very first narrow-viewport +// baseline was almost entirely the open drawer, not the Applications list). +async function closeSidebarNavIfOpen(page: Page) { + const nav = page.locator('.pf-v5-c-page__sidebar'); + if (!(await nav.evaluate((el) => el.classList.contains('pf-m-expanded')))) return; + const toggle = page.locator('.pf-v5-c-masthead .pf-v5-c-button').first(); + await toggle.click(); + await expect(nav).not.toHaveClass(/pf-m-expanded/); +} async function login(page: Page, baseURL: string, username: string) { await page.goto(baseURL + ACCOUNT_URL); @@ -72,94 +90,110 @@ function trackPageHealth(page: Page, baseURL: string) { } 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)'); - - // #91: the real APIARY brand mark (theme.properties' `logo=`), - // not Keycloak's own default logo.svg. Header.tsx only exposes one - // logo path, so light/dark is handled inside the SVG itself (see - // img/apiary-mark.svg's own comment) -- assert the browser actually - // decoded it (naturalWidth/Height), not just that an tag with - // some src exists, since a malformed inline SVG renders as a - // "successful" zero-size broken image with no console error at all - // (found live building this: an XML comment containing a literal - // double hyphen silently broke the whole file this way). - const brand = page.locator('.pf-v5-c-masthead img').first(); - await expect(brand).toHaveAttribute('src', /apiary-mark\.svg$/); - const brandSize = await brand.evaluate((el: HTMLImageElement) => ({ w: el.naturalWidth, h: el.naturalHeight })); - expect(brandSize, 'brand mark must actually decode, not just have a src').toEqual({ w: 64, h: 64 }); - - 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(); - }); + // These screenshot-based tests each open their own explicit-viewport + // browser context (desktop 1440x900 / mobile 390x844) regardless of + // which project runs them, so running them under all 6 projects would + // just re-run the exact same two sizes six times over -- scoped to + // desktop-1440 only for that reason (a real redundancy concern, distinct + // from the hamburger-nav gap the rest of this file had -- #113). The + // WCAG check just below this block deliberately stays *outside* this + // skip: it uses the project's own default viewport, and #113's own + // button-name a11y violation only reproduces at mobile-390/iphone-393 -- + // skipping it there would skip the one check that actually catches it. + test.describe('screenshots', () => { + test.beforeEach(({}, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-1440', 'Each test here already covers every viewport it needs via explicit browser contexts -- see comment above.'); + }); + + 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)'); + + // #91: the real APIARY brand mark (theme.properties' `logo=`), + // not Keycloak's own default logo.svg. Header.tsx only exposes one + // logo path, so light/dark is handled inside the SVG itself (see + // img/apiary-mark.svg's own comment) -- assert the browser actually + // decoded it (naturalWidth/Height), not just that an tag with + // some src exists, since a malformed inline SVG renders as a + // "successful" zero-size broken image with no console error at all + // (found live building this: an XML comment containing a literal + // double hyphen silently broke the whole file this way). + const brand = page.locator('.pf-v5-c-masthead img').first(); + await expect(brand).toHaveAttribute('src', /apiary-mark\.svg$/); + const brandSize = await brand.evaluate((el: HTMLImageElement) => ({ w: el.naturalWidth, h: el.naturalHeight })); + expect(brandSize, 'brand mark must actually decode, not just have a src').toEqual({ w: 64, h: 64 }); + + 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(); - // #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'); - 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'); + }); - // 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'); + }); }); - // 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 }) => { + test('has no automatically detectable WCAG violations', 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'); + const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze(); + expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]); }); }); @@ -167,6 +201,7 @@ 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 openSidebarNav(page); 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'); @@ -179,14 +214,27 @@ test.describe('Account security (#91)', () => { // longer than the default 5s auto-retry (and longer than // `networkidle`, which resolved before this settled) before the // "Two-factor authentication" section appears at all. - await expect(page.getByRole('button', { name: /Set up Authenticator application/i })).toBeVisible({ timeout: 15_000 }); + const otpCreate = page.locator('[data-testid="otp/create"]'); + await otpCreate.waitFor({ state: 'attached', timeout: 15_000 }); + // #113: below PatternFly's `lg` breakpoint this action collapses from + // the inline link-button above into this row's own "more actions" + // kebab (the same collapse pattern the masthead's own + // options-kebab-toggle uses) -- both elements are always in the DOM, + // CSS just toggles which one is visible, so open the kebab first when + // the direct button isn't. + if (!(await otpCreate.isVisible())) { + await otpCreate.locator('xpath=preceding-sibling::button[1]').click(); + } + await expect(page.getByText('Set up Authenticator application').first()).toBeVisible(); health.assertHealthy(); + await closeSidebarNavIfOpen(page); await expect(page).toHaveScreenshot('account-signing-in.png'); }); test('Device activity lists the current session and offers sign-out', async ({ page, baseURL }) => { const health = trackPageHealth(page, baseURL!); await login(page, baseURL!, 'test-user-consent'); + await openSidebarNav(page); await page.getByText('Account security', { exact: true }).click(); await page.getByText('Device activity', { exact: true }).click(); await expect(page.getByTestId('page-heading')).toHaveText('Device activity'); @@ -204,6 +252,7 @@ test.describe('Applications (#91)', () => { test('lists the Account Console client itself', async ({ page, baseURL }) => { const health = trackPageHealth(page, baseURL!); await login(page, baseURL!, 'test-user-consent'); + await openSidebarNav(page); await page.getByText('Applications', { exact: true }).click(); await expect(page.getByTestId('page-heading')).toHaveText('Application'); // Despite the visual table styling (account.css's own `.pf-v5-c-table` @@ -214,6 +263,7 @@ test.describe('Applications (#91)', () => { // reliable here. await expect(page.getByText('Account Console', { exact: true })).toBeVisible({ timeout: 15_000 }); health.assertHealthy(); + await closeSidebarNavIfOpen(page); await expect(page).toHaveScreenshot('account-applications.png'); }); }); @@ -221,7 +271,13 @@ test.describe('Applications (#91)', () => { test.describe('Masthead user menu (#91)', () => { test('opens, is legible, and offers sign out', async ({ page, baseURL }) => { await login(page, baseURL!, 'test-user-consent'); - const toggle = page.locator('.pf-v5-c-masthead .pf-v5-c-menu-toggle').first(); + // #113: below PatternFly's `lg` breakpoint the direct user-menu toggle + // (options-toggle) is hidden and its actions (Sign out included) move + // into the masthead's own "more options" kebab (options-kebab-toggle) + // instead -- both are always in the DOM, CSS just toggles which one is + // visible. + const directToggle = page.locator('[data-testid="options-toggle"]'); + const toggle = (await directToggle.isVisible()) ? directToggle : page.locator('[data-testid="options-kebab-toggle"]'); await toggle.click(); const signOut = page.getByRole('menuitem', { name: 'Sign out' }); await expect(signOut).toBeVisible(); @@ -324,11 +380,14 @@ test.describe('Account theme DOM-hook compatibility (#91, #101 analogue)', () => await page.getByRole('button', { name: 'Save' }).click(); await visit(); + await openSidebarNav(page); await page.getByText('Account security', { exact: true }).click(); await page.getByText('Signing in', { exact: true }).click(); await visit(); + await openSidebarNav(page); await page.getByText('Device activity', { exact: true }).click(); await visit(); + await openSidebarNav(page); await page.getByText('Applications', { exact: true }).click(); await visit(); diff --git a/test/specs/account.spec.ts-snapshots/account-applications-iphone-393-linux.png b/test/specs/account.spec.ts-snapshots/account-applications-iphone-393-linux.png new file mode 100644 index 0000000..1d64a7a Binary files /dev/null and b/test/specs/account.spec.ts-snapshots/account-applications-iphone-393-linux.png differ diff --git a/test/specs/account.spec.ts-snapshots/account-applications-mobile-390-linux.png b/test/specs/account.spec.ts-snapshots/account-applications-mobile-390-linux.png new file mode 100644 index 0000000..96a25a1 Binary files /dev/null and b/test/specs/account.spec.ts-snapshots/account-applications-mobile-390-linux.png differ diff --git a/test/specs/account.spec.ts-snapshots/account-applications-tablet-820-linux.png b/test/specs/account.spec.ts-snapshots/account-applications-tablet-820-linux.png new file mode 100644 index 0000000..5360ae6 Binary files /dev/null and b/test/specs/account.spec.ts-snapshots/account-applications-tablet-820-linux.png differ diff --git a/test/specs/account.spec.ts-snapshots/account-applications-uhd-3840-linux.png b/test/specs/account.spec.ts-snapshots/account-applications-uhd-3840-linux.png new file mode 100644 index 0000000..5e7a0d2 Binary files /dev/null and b/test/specs/account.spec.ts-snapshots/account-applications-uhd-3840-linux.png differ diff --git a/test/specs/account.spec.ts-snapshots/account-applications-uhq-1920-linux.png b/test/specs/account.spec.ts-snapshots/account-applications-uhq-1920-linux.png new file mode 100644 index 0000000..b0e5ace Binary files /dev/null and b/test/specs/account.spec.ts-snapshots/account-applications-uhq-1920-linux.png differ diff --git a/test/specs/account.spec.ts-snapshots/account-signing-in-iphone-393-linux.png b/test/specs/account.spec.ts-snapshots/account-signing-in-iphone-393-linux.png new file mode 100644 index 0000000..be35df7 Binary files /dev/null and b/test/specs/account.spec.ts-snapshots/account-signing-in-iphone-393-linux.png differ diff --git a/test/specs/account.spec.ts-snapshots/account-signing-in-mobile-390-linux.png b/test/specs/account.spec.ts-snapshots/account-signing-in-mobile-390-linux.png new file mode 100644 index 0000000..0e672d7 Binary files /dev/null and b/test/specs/account.spec.ts-snapshots/account-signing-in-mobile-390-linux.png differ diff --git a/test/specs/account.spec.ts-snapshots/account-signing-in-tablet-820-linux.png b/test/specs/account.spec.ts-snapshots/account-signing-in-tablet-820-linux.png new file mode 100644 index 0000000..54a0451 Binary files /dev/null and b/test/specs/account.spec.ts-snapshots/account-signing-in-tablet-820-linux.png differ diff --git a/test/specs/account.spec.ts-snapshots/account-signing-in-uhd-3840-linux.png b/test/specs/account.spec.ts-snapshots/account-signing-in-uhd-3840-linux.png new file mode 100644 index 0000000..42be41e Binary files /dev/null and b/test/specs/account.spec.ts-snapshots/account-signing-in-uhd-3840-linux.png differ diff --git a/test/specs/account.spec.ts-snapshots/account-signing-in-uhq-1920-linux.png b/test/specs/account.spec.ts-snapshots/account-signing-in-uhq-1920-linux.png new file mode 100644 index 0000000..568dd57 Binary files /dev/null and b/test/specs/account.spec.ts-snapshots/account-signing-in-uhq-1920-linux.png differ diff --git a/test/specs/login.spec.ts b/test/specs/login.spec.ts index 99d4cc0..5ed464b 100644 --- a/test/specs/login.spec.ts +++ b/test/specs/login.spec.ts @@ -282,6 +282,107 @@ test.describe('login page shell (#104 geometry, #98 branding)', () => { }); }); +// #114 (#91's own acceptance criteria: "hover, focus, disabled, loading" +// state coverage): none of these four had explicit, intentional coverage +// before this block. Focus is exercised incidentally elsewhere by +// keyboard-interaction tests, but never asserted as a real visual-state +// change the way hover/disabled/loading are here. +test.describe('#114 hover/focus/disabled/loading state coverage', () => { + test('primary submit button changes background on hover and keyboard focus', async ({ page, baseURL }) => { + await page.goto(authUrl(baseURL!)); + await page.waitForSelector('#username'); + await page.locator('#username').fill('test-user-consent'); + await page.locator('#username').press('Enter'); + await page.waitForSelector('#password'); + + const submitBtn = page.locator('input[type="submit"], button[type="submit"]'); + const restBg = await submitBtn.evaluate((el) => getComputedStyle(el).backgroundColor); + + // .pf-v5-c-button transitions background-color (login.css's own + // `transition:` rule) -- toHaveCSS polls/retries until the transition + // settles instead of racing a single evaluate() read against it + // (confirmed live: a bare evaluate() immediately after .hover() caught + // the pre-transition color often enough to be a real flake, not a + // one-off). + await submitBtn.hover(); + await expect(submitBtn, 'primary button background must change on hover (login.css .pf-m-primary:hover)').not.toHaveCSS('background-color', restBg); + await page.mouse.move(0, 0); // leave hover before checking focus in isolation + + // A plain .focus() call does trigger :focus-visible on this button + // (confirmed live), so real keyboard-Tab choreography isn't needed to + // exercise the same rule hover does (.pf-m-primary:focus-visible). + await submitBtn.focus(); + await expect(submitBtn, 'primary button background must change on keyboard focus (login.css .pf-m-primary:focus-visible)').not.toHaveCSS('background-color', restBg); + }); + + test('secondary ("No") button on the consent page changes on hover and focus', async ({ page, baseURL }) => { + // Same real trigger the consent describe block's own test already + // uses: kcButtonSecondaryClass reaches this exact button, and its + // visible border/background live on PatternFly's ::after-pseudo-element + // custom-property indirection, not its own border/background/color + // (login.css's own comment on .pf-m-secondary) -- a plain-property + // override is a no-op there, so hover/focus need the same + // computed-style check to mean anything. + await page.goto(authUrl(baseURL!, 'theme-test-consent-client')); + await page.locator('#username').fill('test-user-consent'); + await page.locator('#username').press('Enter'); + await page.locator('#password').fill('test-password-only'); + await submit(page); + + const noButton = page.getByRole('button', { name: 'No' }); + await expect(noButton).toBeVisible(); + const restBg = await noButton.evaluate((el) => getComputedStyle(el).backgroundColor); + + await noButton.hover(); + await expect(noButton, 'secondary button background must change on hover (login.css .pf-m-secondary hover custom properties)').not.toHaveCSS('background-color', restBg); + await page.mouse.move(0, 0); + + await noButton.focus(); + await expect(noButton, 'secondary button background must change on keyboard focus (login.css .pf-m-secondary focus custom properties)').not.toHaveCSS('background-color', restBg); + }); + + test('submit button is disabled while the credential-step submission is in flight', async ({ page, baseURL }) => { + await stubLandingPage(page); + await page.goto(authUrl(baseURL!)); + await page.waitForSelector('#username'); + await page.locator('#username').fill('test-user-consent'); + await page.locator('#username').press('Enter'); + await page.locator('#password').fill('test-password-only'); + + // Keycloak's own inline onsubmit="login.disabled = true" (the form's + // built-in anti-double-submit guard -- see xore-auth.js's own comment + // on this exact attribute) is what disables this button. Delay the + // real POST (credential-step submission goes to login-actions/authenticate, + // not the openid-connect/auth URL the page itself loaded from) so + // there's an observable window to catch it disabled before the page + // navigates away. + let release: () => void = () => {}; + const gate = new Promise((resolve) => { release = resolve; }); + await page.route('**/login-actions/authenticate**', async (route) => { + if (route.request().method() !== 'POST') return route.continue(); + await gate; + await route.continue(); + }); + + // Playwright's own locator actions (click/evaluate alike) wait for any + // in-flight navigation to settle before they'll even run -- exactly the + // state this test needs to inspect mid-flight, so they'd deadlock + // against the held POST above. Click and read `disabled` in one plain + // page.evaluate() instead: btn.click() dispatches the submit event (and + // therefore Keycloak's synchronous onsubmit handler) synchronously, so + // `disabled` already reflects it by the time click() returns, no + // separate Playwright-tracked round trip involved. + const disabledRightAfterClick = await page.evaluate(() => { + const btn = document.querySelector('#kc-login')!; + btn.click(); + return btn.disabled; + }); + expect(disabledRightAfterClick, 'submit button must be disabled synchronously on submit, before the response even comes back').toBe(true); + release(); + await page.waitForLoadState('networkidle'); + }); +}); + test.describe('#103 staged identity/credential interaction', () => { test('Enter key advances from identity to credential step and back via change', async ({ page, baseURL }) => { await page.goto(authUrl(baseURL!)); @@ -489,7 +590,25 @@ test.describe('#106 WebAuthn/passkey required action (webauthn-register.ftl)', ( await health.assertHealthy(); await expect(page).toHaveScreenshot('webauthn-register.png'); - await clickWebAuthnButton(page, '#registerWebAuthn'); + // #114: xore-auth.js's setupPasskeyBusyState adds aria-busy="true" and + // the .kc-busy class (login.css: opacity .6, pointer-events: none) + // synchronously in its own click listener, alongside (never racing) + // Keycloak's own { once: true } listener that starts the real + // navigator.credentials.create() ceremony -- click and read the + // resulting state in one plain page.evaluate() so this is observed + // deterministically regardless of how fast the virtual authenticator + // responds, not via a separate Playwright-tracked round trip that could + // just as easily run after the ceremony (and the busy state with it) + // has already resolved. + const beforeUrl = page.url(); + const busyRightAfterClick = await page.evaluate(() => { + const btn = document.getElementById('registerWebAuthn') as HTMLButtonElement; + btn.click(); + return { ariaBusy: btn.getAttribute('aria-busy'), busyClass: btn.classList.contains('kc-busy') }; + }); + expect(busyRightAfterClick, 'registerWebAuthn must go visibly busy the instant the ceremony starts').toEqual({ ariaBusy: 'true', busyClass: true }); + await page.waitForURL((url) => url.toString() !== beforeUrl, { timeout: 10_000 }).catch(() => {}); + await page.waitForLoadState('networkidle'); // A real, successful ceremony lands back at the client redirect with an // auth code -- not stuck on the required-action page or an error state. expect(page.url()).toContain('/theme-test-landing'); diff --git a/themes/apiary/account/resources/js/xore-account.js b/themes/apiary/account/resources/js/xore-account.js new file mode 100644 index 0000000..07ec682 --- /dev/null +++ b/themes/apiary/account/resources/js/xore-account.js @@ -0,0 +1,53 @@ +/* APIARY Keycloak account console theme -- accessibility patch (#113). + * + * Progressive enhancement only, same contract as the login theme's own + * xore-auth.js: if this fails to load or throws, the inherited keycloak.v3 + * Account Console still works exactly as Keycloak ships it. + * + * keycloak.v3 renders icon-only "more actions" kebab buttons + * (`pf-v5-c-menu-toggle pf-m-plain`, e.g. the masthead's own + * data-testid="options-kebab-toggle") with no aria-label, no inner text, + * and no aria-labelledby -- a critical WCAG button-name violation + * (axe-core, confirmed live at mobile-390/iphone-393 widths, where the + * masthead's own user-menu options collapse into exactly this kebab and it + * becomes the only way to reach them). The same unlabeled-kebab pattern + * also shows up per-credential-row (e.g. Signing in's OTP row, #113) below + * PatternFly's `lg` breakpoint -- targeting the class rather than one + * specific data-testid catches every instance instead of only the one + * axe happened to have visited. This is compiled upstream PatternFly/React + * markup this repo doesn't own the source of, so it can't be fixed in a + * template -- only patched after render. + * + * Loaded via theme.properties' `scripts=js/xore-account.js`. The Account + * Console is a client-rendered SPA: the target button doesn't exist at + * script-execution time, and gets destroyed/recreated by React on every + * resize-driven toolbar re-layout and on every client-side route change -- + * a one-shot DOMContentLoaded label would miss all of that, so this + * watches for it continuously instead. + */ +(function () { + "use strict"; + + var KEBAB_SELECTOR = "button.pf-v5-c-menu-toggle.pf-m-plain"; + var LABEL = "More options"; + + function labelKebabs() { + var kebabs = document.querySelectorAll(KEBAB_SELECTOR); + for (var i = 0; i < kebabs.length; i++) { + if (!kebabs[i].hasAttribute("aria-label")) { + kebabs[i].setAttribute("aria-label", LABEL); + } + } + } + + function start() { + labelKebabs(); + new MutationObserver(labelKebabs).observe(document.body, { childList: true, subtree: true }); + } + + if (document.body) { + start(); + } else { + document.addEventListener("DOMContentLoaded", start); + } +})(); diff --git a/themes/apiary/account/theme.properties b/themes/apiary/account/theme.properties index 1a56e0d..6214449 100644 --- a/themes/apiary/account/theme.properties +++ b/themes/apiary/account/theme.properties @@ -1,4 +1,5 @@ parent=keycloak.v3 styles=css/xore-theme.css css/account.css +scripts=js/xore-account.js meta=viewport==width=device-width,initial-scale=1 logo=img/apiary-mark.svg