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: diff --git a/config/playwright/playwright.config.ts b/config/playwright/playwright.config.ts index 68b6ac87d..995db3edd 100644 --- a/config/playwright/playwright.config.ts +++ b/config/playwright/playwright.config.ts @@ -14,6 +14,8 @@ 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 @@ -66,7 +68,7 @@ export default defineConfig({ storageState }, dependencies: ['setup'], - testIgnore: /.*\.setup\.ts/ + testIgnore: [/.*\.setup\.ts/, /.*\.teardown\.ts/, rtlSpecs] }, { @@ -76,7 +78,31 @@ export default defineConfig({ storageState }, dependencies: ['setup', 'flat-files-setup'], - testIgnore: /.*\.setup\.ts/ + testIgnore: [/.*\.setup\.ts/, /.*\.teardown\.ts/, rtlSpecs] + }, + + // 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'] }, + dependencies: ['setup'] + }, + { + name: 'rtl-teardown', + testMatch: /rtl\.teardown\.ts/ + }, + { + name: 'chromium-rtl', + testMatch: rtlSpecs, + use: { + ...devices['Desktop Chrome'], + storageState: rtlStorageState + }, + dependencies: ['rtl-setup'], + teardown: 'rtl-teardown' } ], 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) }) 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..af2a9d3c0 --- /dev/null +++ b/tests/e2e/rtl.setup.ts @@ -0,0 +1,49 @@ +import { join } from 'path' +import { expect, test as setup } from '@playwright/test' +import { wpCli } from './helpers/wpCli' + +// 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 + +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 { + await wpCli(['language', 'core', 'install', RTL_LOCALE]) + } catch (error) { + console.warn(`Could not install the ${RTL_LOCALE} language pack; RTL specs will skip.`, error) + } + + // `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() + + const dir = await page.evaluate(() => document.documentElement.getAttribute('dir')) + 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 new file mode 100644 index 000000000..9b6fc00c2 --- /dev/null +++ b/tests/e2e/rtl.teardown.ts @@ -0,0 +1,11 @@ +import { test as teardown } from '@playwright/test' +import { wpCli } from './helpers/wpCli' + +// 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', 'delete', 'rtl-admin', '--yes']) + } catch { + // Already gone, or never created because the setup failed early. + } +})