diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ff0665b --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +WP_USERNAME=admin +WP_PASSWORD=password +WP_BASE_URL=http://localhost:8888 diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index fbaee05..4b8cd5c 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -29,6 +29,16 @@ jobs: with: persist-credentials: false + # .env is gitignored (local-only overrides); CI never has one. Without + # it, @wordpress/e2e-test-utils-playwright falls back to its own + # internal default of localhost:8889 -- the wp-env "tests" environment, + # not the "development" one this workflow starts -- for REST root + # discovery specifically, causing every REST call to 403 against the + # wrong WordPress instance. .env.example is the single committed source + # of truth for these values; copy it so CI loads the same defaults as + # local dev instead of duplicating them here. + - run: cp .env.example .env + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 @@ -48,13 +58,22 @@ jobs: run: composer install --no-interaction - name: Cache Playwright browsers + id: cache-playwright uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - + restore-keys: | + playwright-${{ runner.os }}- + + # --with-deps runs apt-get to install OS-level libraries every time, + # even when the browser binary itself is already cached. Those + # libraries come from the runner image, not the browser version, so + # they don't need reinstalling on a cache hit -- skipping apt-get here + # avoids depending on the package mirror being fast (or up) on every + # single run. - name: Install Playwright browsers - run: npx playwright install --with-deps chromium + run: npx playwright install ${{ steps.cache-playwright.outputs.cache-hit == 'true' && '' || '--with-deps' }} chromium # Restores the most recent cached checkout as a starting point, then # build-gutenberg.sh fetches and fast-forwards it to the latest trunk diff --git a/playwright.config.ts b/playwright.config.ts index 819c15b..b00ef54 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -29,7 +29,13 @@ export default defineConfig({ ], webServer: { command: 'npm run env:start', - url: baseURL, + // rest_api_init only fires for requests actually routed through + // /wp-json/, unlike a plain homepage request. Pointing the readiness + // check here (rather than at baseURL) makes Playwright's own polling + // pay for REST route registration once, up front, instead of the + // first test that calls RequestUtils.rest() paying it inside its own + // 30s test timeout. + url: `${baseURL}/wp-json/`, // playwright.yml starts wp-env as its own explicit step before running // tests, in both CI and local dev. Playwright must never try to start // a second instance itself, or it collides on the same port. diff --git a/tests/e2e/gutenberg-integration.spec.ts b/tests/e2e/gutenberg-integration.spec.ts index 949a901..9430ba2 100644 --- a/tests/e2e/gutenberg-integration.spec.ts +++ b/tests/e2e/gutenberg-integration.spec.ts @@ -66,7 +66,7 @@ test.describe('Gutenberg Integration', () => { ); }); - test('storage provider handles room creation', async ({ page, admin }) => { + test('storage provider handles room creation', async ({ page, admin, editor: editorUtils }) => { // Create a post and open it in the editor const postId = await admin.createNewPost({ title: 'Storage Test Post', @@ -75,26 +75,23 @@ test.describe('Gutenberg Integration', () => { await page.waitForSelector('.edit-post-layout', { timeout: 10000 }); - // Make an edit to trigger storage writes - const editor = page.locator('[contenteditable="true"]').first(); - if (await editor.isVisible()) { - await editor.click(); - await editor.pressSequentially(' Updated content.', { delay: 50 }); - await page.waitForTimeout(1000); // Wait for debounced save - } - - // Save the post - await page.keyboard.press('Meta+S'); - await page.waitForTimeout(1000); - - // Check if collaboration updates were stored - // We can't directly query the table from the browser, but we can check logs - // or verify the post saved successfully - const saveButton = page.locator('button:has-text("Save draft"), button:has-text("Update")').first(); - - // If post saved, storage is working - const isSaved = await page.locator('.components-snackbar__content').textContent().catch(() => ''); - // Success indicators vary, but lack of errors is a good sign - expect(isSaved).not.toContain('error'); + // Make an edit to trigger storage writes. The block canvas renders + // inside an iframe for style isolation, so page.locator() alone can + // never match its contenteditable regions -- frameLocator() is + // required to reach inside it. + const editor = page + .frameLocator('iframe[name="editor-canvas"]') + .locator('[contenteditable="true"]') + .first(); + await editor.waitFor({ state: 'visible', timeout: 10000 }); + await editor.click(); + await editor.pressSequentially(' Updated content.', { delay: 50 }); + await page.waitForTimeout(1000); // Wait for debounced save + + // Save the post. editor.saveDraft() clicks the actual "Save draft" + // button and waits for the "Draft saved" notice, rather than a + // keyboard shortcut whose modifier key (Meta vs Ctrl) is OS-dependent + // and doesn't work in this Linux CI environment. + await editorUtils.saveDraft(); }); }); diff --git a/tests/e2e/utils/collaborative.ts b/tests/e2e/utils/collaborative.ts index 21b1c01..a361fc7 100644 --- a/tests/e2e/utils/collaborative.ts +++ b/tests/e2e/utils/collaborative.ts @@ -3,6 +3,7 @@ * * Utilities for testing multi-user collaborative scenarios in WordPress. */ +import { execFileSync } from 'child_process'; import type { Browser, BrowserContext, Page } from '@playwright/test'; import { Admin, Editor, PageUtils, RequestUtils } from '@wordpress/e2e-test-utils-playwright'; @@ -33,53 +34,56 @@ export interface PresenceEntry { date_gmt: string; } -const ADMIN_USERNAME = process.env.WP_USERNAME || 'admin'; -const ADMIN_PASSWORD = process.env.WP_PASSWORD || 'password'; const BASE_URL = process.env.WP_BASE_URL || 'http://localhost:8888'; const COLLABORATOR_PASSWORD = 'sync-storage-e2e-collaborator!1'; +/** + * Run a `wp` command in the wp-env `cli` container and return its stdout. + * + * @param args Arguments to pass to `wp`. + */ +function wpCli(args: string[]): string { + return execFileSync('npx', ['wp-env', 'run', 'cli', 'wp', ...args], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }); +} + /** * Create (or reuse, from a previous run) a distinct editor user for a collaborative session. * - * @param admin Admin-authenticated RequestUtils, used to provision the collaborator. + * Provisioned via WP-CLI rather than the REST API: it runs directly in the + * cli container in about a second, instead of paying for a full + * login + REST-root-discovery bootstrap against a cold PHP worker for every + * collaborator. + * * @param username Username for the collaborator. */ -async function ensureCollaborator( - admin: RequestUtils, - username: string -): Promise<{ id: number }> { +function ensureCollaborator(username: string): { id: number } { + let id: number; try { - return await admin.createUser({ - username, - email: `${username}@example.test`, - password: COLLABORATOR_PASSWORD, - roles: ['editor'], - }); - } catch (error) { - if ((error as { code?: string })?.code !== 'existing_user_login') { - throw error; - } - } - - const matches: Array<{ id: number; slug: string }> = await admin.rest({ - path: `/wp/v2/users?search=${encodeURIComponent(username)}&context=edit`, - }); - const existing = matches.find((user) => user.slug === username); - if (!existing) { - throw new Error( - `Collaborator "${username}" already exists but could not be found via search.` + id = parseInt(wpCli(['user', 'get', username, '--field=ID']).trim(), 10); + } catch { + id = parseInt( + wpCli([ + 'user', + 'create', + username, + `${username}@example.test`, + '--role=editor', + `--user_pass=${COLLABORATOR_PASSWORD}`, + '--porcelain', + ]).trim(), + 10 ); + return { id }; } // Reset the password so this run's login credentials are known, regardless // of what a previous run left behind. - await admin.rest({ - method: 'POST', - path: `/wp/v2/users/${existing.id}`, - data: { password: COLLABORATOR_PASSWORD }, - }); + wpCli(['user', 'update', String(id), `--user_pass=${COLLABORATOR_PASSWORD}`]); - return { id: existing.id }; + return { id }; } /** @@ -97,16 +101,11 @@ export async function createCollaborativeSessions( browser: Browser, count: number = 2 ): Promise { - const admin = await RequestUtils.setup({ - user: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD }, - baseURL: BASE_URL, - }); - const sessions: CollaborativeSession[] = []; for (let i = 0; i < count; i++) { const userName = `sync-storage-editor-${i + 1}`; - const { id: userId } = await ensureCollaborator(admin, userName); + const { id: userId } = ensureCollaborator(userName); const context = await browser.newContext(); const requestUtils = new RequestUtils(context.request, {