Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
WP_USERNAME=admin
WP_PASSWORD=password
WP_BASE_URL=http://localhost:8888
23 changes: 21 additions & 2 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 19 additions & 22 deletions tests/e2e/gutenberg-integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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();
});
});
75 changes: 37 additions & 38 deletions tests/e2e/utils/collaborative.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 };
}

/**
Expand All @@ -97,16 +101,11 @@ export async function createCollaborativeSessions(
browser: Browser,
count: number = 2
): Promise<CollaborativeSession[]> {
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, {
Expand Down
Loading