From 21985aca1e4c3a67583a4f22a5e1d10d51002703 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Thu, 3 Sep 2026 23:03:23 +0100 Subject: [PATCH 1/4] test: check the admin screens under a right-to-left locale --- config/playwright/playwright.config.ts | 32 ++++++++++++- tests/e2e/rtl-layout.spec.ts | 62 ++++++++++++++++++++++++++ tests/e2e/rtl.setup.ts | 25 +++++++++++ tests/e2e/rtl.teardown.ts | 12 +++++ 4 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/rtl-layout.spec.ts create mode 100644 tests/e2e/rtl.setup.ts create mode 100644 tests/e2e/rtl.teardown.ts diff --git a/config/playwright/playwright.config.ts b/config/playwright/playwright.config.ts index 68b6ac87d..89c9317f7 100644 --- a/config/playwright/playwright.config.ts +++ b/config/playwright/playwright.config.ts @@ -14,6 +14,7 @@ const MILLISECONDS_IN_SECOND = 1000 const baseTestsDir = join(__dirname, '..', '..', 'tests') const storageState = join(baseTestsDir, 'e2e/.auth/user.json') +const rtlSpecs = /rtl-layout\.spec\.ts/ /** * @see https://playwright.dev/docs/test-configuration @@ -66,7 +67,7 @@ export default defineConfig({ storageState }, dependencies: ['setup'], - testIgnore: /.*\.setup\.ts/ + testIgnore: [/.*\.setup\.ts/, /.*\.teardown\.ts/, rtlSpecs] }, { @@ -76,7 +77,34 @@ export default defineConfig({ storageState }, dependencies: ['setup', 'flat-files-setup'], - testIgnore: /.*\.setup\.ts/ + testIgnore: [/.*\.setup\.ts/, /.*\.teardown\.ts/, rtlSpecs] + }, + + // The RTL specs run with the test user on a right-to-left locale. The + // setup installs the language pack and switches the user; the teardown + // switches back, so the other projects never see the site mirrored. + { + name: 'rtl-setup', + testMatch: /rtl\.setup\.ts/, + use: { + ...devices['Desktop Chrome'], + storageState + }, + dependencies: ['setup'] + }, + { + name: 'rtl-teardown', + testMatch: /rtl\.teardown\.ts/ + }, + { + name: 'chromium-rtl', + testMatch: rtlSpecs, + use: { + ...devices['Desktop Chrome'], + storageState + }, + dependencies: ['setup', 'rtl-setup'], + teardown: 'rtl-teardown' } ], diff --git a/tests/e2e/rtl-layout.spec.ts b/tests/e2e/rtl-layout.spec.ts new file mode 100644 index 000000000..8baa61a8d --- /dev/null +++ b/tests/e2e/rtl-layout.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from '@playwright/test' + +// The admin screens under a right-to-left locale. Mirroring is driven by the +// direction multiplier and logical properties; what can still go wrong is a +// control pushed past the page edge or a row that no longer fits, so every +// screen is checked for those rather than for how it looks. +const SCREENS: [string, string, string][] = [ + ['settings, editing tab', '/wp-admin/admin.php?page=snippets-settings§ion=editing', '#settings-sections-tabs'], + ['settings, advanced tab', '/wp-admin/admin.php?page=snippets-settings§ion=advanced', '#settings-sections-tabs'], + ['snippets list', '/wp-admin/admin.php?page=snippets', '.snippet-view-toggle'], + ['new snippet', '/wp-admin/admin.php?page=add-snippet', '.CodeMirror'], + ['import', '/wp-admin/admin.php?page=import-code-snippets', '#wpbody-content'] +] + +interface LayoutReport { + dir: string + bodyRtl: boolean + multiplier: string + pageOverflow: number + offscreen: string[] +} + +const inspect = (): LayoutReport => { + const viewportWidth = document.documentElement.clientWidth + const selector = ['a', 'button', 'input', 'select'].map(tag => `#wpbody-content ${tag}`).join(', ') + const controls = Array.from(document.querySelectorAll(selector)) + + const offscreen = controls + .filter(element => null !== element.offsetParent) + .map(element => ({ element, rect: element.getBoundingClientRect() })) + .filter(({ rect }) => 0 < rect.width && (0 > rect.left || rect.right > viewportWidth + 1)) + .map(({ element, rect }) => { + const name = `${element.tagName.toLowerCase()}.${element.className.split(' ')[0]}` + return `${name} left=${Math.round(rect.left)} right=${Math.round(rect.right)}` + }) + + return { + dir: document.documentElement.getAttribute('dir') ?? 'ltr', + bodyRtl: document.body.classList.contains('rtl'), + multiplier: getComputedStyle(document.documentElement).getPropertyValue('--cs-direction-multiplier').trim(), + pageOverflow: document.documentElement.scrollWidth - viewportWidth, + offscreen + } +} + +test.describe('Right-to-left layout', () => { + for (const [name, url, ready] of SCREENS) { + test(`${name} mirrors without spilling off the page`, async ({ page }) => { + await page.setViewportSize({ width: 1360, height: 900 }) + await page.goto(url) + await page.waitForSelector(ready) + + const report = await page.evaluate(inspect) + test.skip('rtl' !== report.dir, 'The RTL locale is not available on this site, so there is nothing to check.') + + expect(report.bodyRtl, 'WordPress marks the body as RTL').toBe(true) + expect(report.multiplier, 'the direction multiplier is flipped').toBe('-1') + expect(report.pageOverflow, 'the page does not scroll sideways').toBeLessThanOrEqual(0) + expect(report.offscreen, 'no control sits outside the viewport').toEqual([]) + }) + } +}) diff --git a/tests/e2e/rtl.setup.ts b/tests/e2e/rtl.setup.ts new file mode 100644 index 000000000..129562edf --- /dev/null +++ b/tests/e2e/rtl.setup.ts @@ -0,0 +1,25 @@ +import { test as setup } from '@playwright/test' +import { wpCli } from './helpers/wpCli' + +// Put the test user on a right-to-left locale. The language pack is fetched +// from wordpress.org when missing; if that is impossible (offline), the RTL +// specs notice the page is still left-to-right and skip themselves. +const RTL_LOCALE = 'he_IL' +const SETUP_TIMEOUT_MS = 180000 + +setup('switch the test user to a right-to-left locale', async ({ page }) => { + setup.setTimeout(SETUP_TIMEOUT_MS) + + try { + await wpCli(['language', 'core', 'install', RTL_LOCALE]) + } catch (error) { + console.warn(`Could not install the ${RTL_LOCALE} language pack; RTL specs will skip.`, error) + } + + await wpCli(['user', 'update', 'admin', `--locale=${RTL_LOCALE}`]) + + await page.goto('/wp-admin/') + await page.waitForSelector('#wpbody-content') + const dir = await page.evaluate(() => document.documentElement.getAttribute('dir')) + console.log(`RTL setup: admin renders with dir="${dir ?? 'ltr'}"`) +}) diff --git a/tests/e2e/rtl.teardown.ts b/tests/e2e/rtl.teardown.ts new file mode 100644 index 000000000..6336cdbc5 --- /dev/null +++ b/tests/e2e/rtl.teardown.ts @@ -0,0 +1,12 @@ +import { test as teardown } from '@playwright/test' +import { wpCli } from './helpers/wpCli' + +// Put the test user back on the site's own locale, whatever the RTL specs did. +teardown('restore the test user locale', async () => { + try { + await wpCli(['user', 'update', 'admin', '--locale=']) + await wpCli(['user', 'meta', 'delete', 'admin', 'locale']) + } catch { + // The meta may already be gone; nothing else to restore. + } +}) From b97947b4ef6925b1d9f9641f1245a05543ef1bca Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Thu, 3 Sep 2026 23:07:12 +0100 Subject: [PATCH 2/4] test: cover the toolbar boundary widths and the editor input labels --- src/css/common/list-table/_responsive.scss | 9 +++++---- tests/e2e/editor-labels.spec.ts | 20 ++++++++++++++++++++ tests/e2e/list-toolbar-fit.spec.ts | 8 ++++---- 3 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/editor-labels.spec.ts diff --git a/src/css/common/list-table/_responsive.scss b/src/css/common/list-table/_responsive.scss index 64ca500e1..8f41e2fae 100644 --- a/src/css/common/list-table/_responsive.scss +++ b/src/css/common/list-table/_responsive.scss @@ -1,9 +1,10 @@ @use '../theme'; -// Toolbar squeeze: above the mobile breakpoint the row never wraps — -// components shrink gracefully instead (flex-wrap would otherwise win -// before flex-shrink ever engages). Wrapping remains the small-screen -// fallback only. +// Toolbar squeeze: above the tablet collapse (see below) the row never wraps; +// components shrink instead (flex-wrap would otherwise win before flex-shrink +// ever engages). Between 782px and 1400px the pagination and view toggle, which +// cannot shrink, force the row onto two lines; below 782px wrapping is the +// small-screen fallback. .snippets-list-view .tablenav { flex-wrap: wrap; diff --git a/tests/e2e/editor-labels.spec.ts b/tests/e2e/editor-labels.spec.ts new file mode 100644 index 000000000..6ab39ea27 --- /dev/null +++ b/tests/e2e/editor-labels.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from '@playwright/test' + +// CodeMirror replaces the labelled textarea with an input of its own, so the +// name a screen reader hears comes from the attribute the plugin sets on that +// input after the editor starts. One check per editor the plugin creates. +test.describe('Code editor labels', () => { + test('the snippet editor input is named', async ({ page }) => { + await page.goto('/wp-admin/admin.php?page=add-snippet') + await page.waitForSelector('.CodeMirror') + + await expect(page.locator('.snippet-editor .CodeMirror textarea')).toHaveAttribute('aria-label', 'Snippet code') + }) + + test('the settings preview input is named', async ({ page }) => { + await page.goto('/wp-admin/admin.php?page=snippets-settings§ion=editing') + await page.waitForSelector('.CodeMirror') + + await expect(page.locator('.CodeMirror textarea').first()).toHaveAttribute('aria-label', 'Code editor preview') + }) +}) diff --git a/tests/e2e/list-toolbar-fit.spec.ts b/tests/e2e/list-toolbar-fit.spec.ts index 17a357caf..37baa34fb 100644 --- a/tests/e2e/list-toolbar-fit.spec.ts +++ b/tests/e2e/list-toolbar-fit.spec.ts @@ -4,8 +4,10 @@ const SNIPPETS_URL = '/wp-admin/admin.php?page=snippets' // The toolbar's end group (pagination plus the view toggle) cannot shrink, so at // widths where the row does not fit it must wrap rather than spill off the page. +// The row collapses onto two lines up to 1400px, so both sides of that boundary +// are covered; the right-to-left project checks the same screen mirrored. test.describe('Snippets toolbar fit', () => { - for (const width of [1280, 1360, 1600]) { + for (const width of [1280, 1360, 1400, 1401, 1600]) { test(`nothing spills out of the toolbar at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }) await page.goto(SNIPPETS_URL) @@ -15,18 +17,16 @@ test.describe('Snippets toolbar fit', () => { const nav = document.querySelector('.snippets-list-view .tablenav.top') const toggle = document.querySelector('.snippet-view-toggle') if (!nav || !toggle) { - return { missing: true } + throw new Error('The snippets toolbar or its view toggle did not render.') } const n = nav.getBoundingClientRect() const t = toggle.getBoundingClientRect() return { - missing: false, pageOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, toggleInsideNav: t.left >= n.left - 1 && t.right <= n.right + 1 } }) - expect(fit.missing).toBe(false) expect(fit.pageOverflow).toBeLessThanOrEqual(0) expect(fit.toggleInsideNav).toBe(true) }) From 048dd1e7cce114345d789ee10f0899a344462b35 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Thu, 3 Sep 2026 23:07:23 +0100 Subject: [PATCH 3/4] ci: run the right-to-left Playwright project with the default suite --- .github/workflows/playwright-test.yml | 3 ++- .github/workflows/playwright.yml | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/playwright-test.yml b/.github/workflows/playwright-test.yml index debf07092..8b07fbe6a 100644 --- a/.github/workflows/playwright-test.yml +++ b/.github/workflows/playwright-test.yml @@ -245,7 +245,8 @@ jobs: mkdir -p test-results/ci suffix="${{ inputs.project-name }}${{ inputs.multisite && '-multisite' || '' }}" : > "test-results/ci/playwright-${suffix}.log" - npm run test:playwright -- --project=${{ inputs.project-name }} 2>&1 | tee -a "test-results/ci/playwright-${suffix}.log" + # shellcheck disable=SC2086 -- project-name may hold several names. + npm run test:playwright -- --project ${{ inputs.project-name }} 2>&1 | tee -a "test-results/ci/playwright-${suffix}.log" - name: Normalize Playwright report filenames if: always() diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index aac57a8ce..1ea1c46a4 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -20,7 +20,9 @@ jobs: uses: ./.github/workflows/playwright-test.yml with: test-mode: 'default' - project-name: 'chromium-db-snippets' + # The right-to-left project runs after the default one on the same site; + # its own setup switches the test user's locale and its teardown restores it. + project-name: 'chromium-db-snippets chromium-rtl' multisite: ${{ inputs.multisite }} playwright-file-based-execution: From 98a089e1a668789bdb0d2a106453588c2b358bc8 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Thu, 3 Sep 2026 23:24:05 +0100 Subject: [PATCH 4/4] test: give the right-to-left specs a user and session of their own --- config/playwright/playwright.config.ts | 16 +++++----- tests/e2e/rtl.setup.ts | 42 ++++++++++++++++++++------ tests/e2e/rtl.teardown.ts | 9 +++--- 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/config/playwright/playwright.config.ts b/config/playwright/playwright.config.ts index 89c9317f7..995db3edd 100644 --- a/config/playwright/playwright.config.ts +++ b/config/playwright/playwright.config.ts @@ -15,6 +15,7 @@ const MILLISECONDS_IN_SECOND = 1000 const baseTestsDir = join(__dirname, '..', '..', 'tests') const storageState = join(baseTestsDir, 'e2e/.auth/user.json') const rtlSpecs = /rtl-layout\.spec\.ts/ +const rtlStorageState = join(baseTestsDir, 'e2e/.auth/rtl-user.json') /** * @see https://playwright.dev/docs/test-configuration @@ -80,16 +81,13 @@ export default defineConfig({ testIgnore: [/.*\.setup\.ts/, /.*\.teardown\.ts/, rtlSpecs] }, - // The RTL specs run with the test user on a right-to-left locale. The - // setup installs the language pack and switches the user; the teardown - // switches back, so the other projects never see the site mirrored. + // The RTL specs sign in as a user of their own on a right-to-left locale + // and keep that session apart, so the other projects never see the site + // mirrored whatever order Playwright runs them in. { name: 'rtl-setup', testMatch: /rtl\.setup\.ts/, - use: { - ...devices['Desktop Chrome'], - storageState - }, + use: { ...devices['Desktop Chrome'] }, dependencies: ['setup'] }, { @@ -101,9 +99,9 @@ export default defineConfig({ testMatch: rtlSpecs, use: { ...devices['Desktop Chrome'], - storageState + storageState: rtlStorageState }, - dependencies: ['setup', 'rtl-setup'], + dependencies: ['rtl-setup'], teardown: 'rtl-teardown' } ], diff --git a/tests/e2e/rtl.setup.ts b/tests/e2e/rtl.setup.ts index 129562edf..af2a9d3c0 100644 --- a/tests/e2e/rtl.setup.ts +++ b/tests/e2e/rtl.setup.ts @@ -1,13 +1,19 @@ -import { test as setup } from '@playwright/test' +import { join } from 'path' +import { expect, test as setup } from '@playwright/test' import { wpCli } from './helpers/wpCli' -// Put the test user on a right-to-left locale. The language pack is fetched -// from wordpress.org when missing; if that is impossible (offline), the RTL -// specs notice the page is still left-to-right and skip themselves. +// The RTL specs sign in as a user of their own whose locale is right-to-left, +// so the rest of the suite, which signs in as the usual admin, never sees the +// site mirrored, whatever order the projects run in. The language pack is +// fetched from wordpress.org when missing; if that is impossible (offline), +// the specs notice the page is still left-to-right and skip themselves. const RTL_LOCALE = 'he_IL' +const RTL_USER = 'rtl-admin' const SETUP_TIMEOUT_MS = 180000 -setup('switch the test user to a right-to-left locale', async ({ page }) => { +export const rtlAuthFile = join(__dirname, '.auth/rtl-user.json') + +setup('sign in as a right-to-left user', async ({ page }) => { setup.setTimeout(SETUP_TIMEOUT_MS) try { @@ -16,10 +22,28 @@ setup('switch the test user to a right-to-left locale', async ({ page }) => { console.warn(`Could not install the ${RTL_LOCALE} language pack; RTL specs will skip.`, error) } - await wpCli(['user', 'update', 'admin', `--locale=${RTL_LOCALE}`]) + // `user create` takes no locale flag, so the locale is set by a second command. + try { + await wpCli(['user', 'get', RTL_USER, '--field=ID']) + } catch { + await wpCli(['user', 'create', RTL_USER, `${RTL_USER}@example.org`, '--role=administrator']) + } + + await wpCli(['user', 'update', RTL_USER, '--user_pass=password', `--locale=${RTL_LOCALE}`]) + + await page.goto('/wp-login.php') + await page.waitForSelector('#user_login') + await page.fill('#user_login', RTL_USER) + await page.fill('#user_pass', 'password') + await Promise.all([ + page.waitForLoadState('domcontentloaded'), + page.click('#wp-submit') + ]) + await page.waitForSelector('#wpbody-content, #adminmenu', { timeout: 60000 }) + await expect(page.locator('#adminmenu')).toBeVisible() - await page.goto('/wp-admin/') - await page.waitForSelector('#wpbody-content') const dir = await page.evaluate(() => document.documentElement.getAttribute('dir')) - console.log(`RTL setup: admin renders with dir="${dir ?? 'ltr'}"`) + console.log(`RTL setup: ${RTL_USER} renders the admin with dir="${dir ?? 'ltr'}"`) + + await page.context().storageState({ path: rtlAuthFile }) }) diff --git a/tests/e2e/rtl.teardown.ts b/tests/e2e/rtl.teardown.ts index 6336cdbc5..9b6fc00c2 100644 --- a/tests/e2e/rtl.teardown.ts +++ b/tests/e2e/rtl.teardown.ts @@ -1,12 +1,11 @@ import { test as teardown } from '@playwright/test' import { wpCli } from './helpers/wpCli' -// Put the test user back on the site's own locale, whatever the RTL specs did. -teardown('restore the test user locale', async () => { +// Remove the right-to-left user the setup created, so the site is left as found. +teardown('remove the right-to-left user', async () => { try { - await wpCli(['user', 'update', 'admin', '--locale=']) - await wpCli(['user', 'meta', 'delete', 'admin', 'locale']) + await wpCli(['user', 'delete', 'rtl-admin', '--yes']) } catch { - // The meta may already be gone; nothing else to restore. + // Already gone, or never created because the setup failed early. } })