From 62cfb1aa4438f83521f217a7b840e638b55670f5 Mon Sep 17 00:00:00 2001 From: Gary Pendergast Date: Sat, 28 Mar 2026 10:49:16 +1100 Subject: [PATCH 01/10] feat: Gutenberg 22.8 V2 encoding compatibility and e2e test suite Gutenberg 22.8 (PR #76304) switched Yjs update encoding from V1 to V2, breaking collaborative editing with external clients. This adapts the sync protocol to match Gutenberg's mixed V1/V2 approach: - Step1/step2 handshake: V1 via y-protocols (Gutenberg still uses syncProtocol.readSyncMessage which hardcodes V1) - Regular updates and compactions: V2 (doc.on('updateV2'), applyUpdateV2, encodeStateAsUpdateV2) Also increases sync wait timeout from 5s to 15s to accommodate Gutenberg 22.8's slower polling intervals (4s solo), and adds a minimum WordPress version check (7.0+) during setup and connect. Adds a Playwright e2e test suite covering block insert, edit, attribute change, remove, browser-to-MCP sync, and multi-step workflows against a live wp-env Gutenberg instance. --- .gitignore | 2 + .markdownlint-cli2.jsonc | 14 +- CLAUDE.md | 3 +- package-lock.json | 64 +++ package.json | 5 + playwright.config.ts | 32 ++ src/cli/setup.ts | 9 + src/session/session-manager.ts | 25 +- src/wordpress/api-client.ts | 48 ++ src/yjs/sync-protocol.ts | 36 +- tests/e2e/block-sync.spec.ts | 625 ++++++++++++++++++++++ tests/e2e/global-setup.ts | 5 + tests/e2e/global-teardown.ts | 5 + tests/e2e/helpers/mcp.ts | 69 +++ tests/e2e/helpers/wp-env.ts | 155 ++++++ tests/integration/two-client-sync.test.ts | 14 +- tests/unit/cli/auth-server.test.ts | 2 +- tests/unit/cli/setup.test.ts | 5 +- tests/unit/server.test.ts | 10 +- tests/unit/session-manager.test.ts | 6 +- tests/unit/sync-protocol.test.ts | 6 +- tsconfig.json | 3 +- 22 files changed, 1100 insertions(+), 43 deletions(-) create mode 100644 playwright.config.ts create mode 100644 tests/e2e/block-sync.spec.ts create mode 100644 tests/e2e/global-setup.ts create mode 100644 tests/e2e/global-teardown.ts create mode 100644 tests/e2e/helpers/mcp.ts create mode 100644 tests/e2e/helpers/wp-env.ts diff --git a/.gitignore b/.gitignore index 6bbcb31..9ccd6c0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist/ coverage/ *.tsbuildinfo .gutenberg/ +test-results/ +.DS_Store \ No newline at end of file diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index ffa89f2..33b8782 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -7,7 +7,15 @@ // Allow inline HTML (tables, details/summary, etc.) "MD033": false, // Allow duplicate headings in different sections (e.g., "Architecture", "Tools" under different features) - "MD024": { "siblings_only": true } + "MD024": { + "siblings_only": true + } }, - "ignores": ["node_modules/", "dist/", "coverage/", ".gutenberg/"] -} + "ignores": [ + "node_modules/", + "dist/", + "coverage/", + ".gutenberg/", + "test-results/" + ] +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 853ed52..2f431a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,8 @@ disconnected ──connect──→ connected ──openPost/createPost──→ ### Key Design Decisions -- **V1 encoding**: All Yjs updates use `encodeStateAsUpdate`/`applyUpdate` (V1). This matches Gutenberg's encoding. The sync_step1/step2 handshake uses y-protocols standard encoding (also V1 internally). +- **Mixed V1/V2 encoding**: Gutenberg 22.8+ uses a mixed encoding approach. Sync step1/step2 use y-protocols' standard encoding (V1 internally — `syncProtocol.readSyncMessage` hardcodes `Y.encodeStateAsUpdate`/`Y.applyUpdate`). Regular updates and compactions use V2 encoding (`encodeStateAsUpdateV2`/`applyUpdateV2`, captured via `doc.on('updateV2')`). This split exists because Gutenberg switched updates/compactions to V2 (PR #76304) but still uses y-protocols for the sync handshake. Minimum compatible Gutenberg version: 22.8. +- **Minimum version check**: During `connect()` and setup, `checkMinimumVersion()` fetches `GET /wp-json/` and verifies WordPress >= 7.0. If the version is below 7.0, an error is thrown with a message suggesting Gutenberg plugin 22.8+ as an alternative. If the REST API root is unavailable or the version field is missing, the check is silently skipped (non-blocking). - **yjs pinned to 13.6.29**: Must match the version Gutenberg uses. Different versions can produce incompatible binary updates. - **Rich-text attributes**: Block attributes whose type is `rich-text` in the block schema (e.g., `core/paragraph` `content`) are stored as `Y.Text` in the Y.Doc. Other attributes are plain values. Rich-text detection is handled by `BlockTypeRegistry` in `src/yjs/block-type-registry.ts`. - **Room format**: `postType/{type}:{id}` (e.g., `postType/post:123`) diff --git a/package-lock.json b/package-lock.json index 4ac0132..1cda576 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@eslint/markdown": "^7.5.1", + "@playwright/test": "^1.58.2", "@types/node": "^22.19.15", "@vitest/coverage-v8": "^4.1.0", "@wordpress/env": "^11.2.0", @@ -2927,6 +2928,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.10", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", @@ -10813,6 +10830,53 @@ "pathe": "^2.0.1" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 01f0e39..5f906c1 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,10 @@ "dev": "tsup --watch", "start": "node dist/index.js", "test": "vitest run", + "pretest:e2e": "npm run build", + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed", + "test:e2e:install": "playwright install chromium", "test:watch": "vitest", "typecheck": "tsc --noEmit", "lint": "eslint --max-warnings 0 'src/**/*.ts' 'tests/**/*.ts' '**/*.md' && markdownlint-cli2 '**/*.md' '#node_modules'", @@ -48,6 +52,7 @@ "zod": "^3.25.76" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@eslint/js": "^10.0.1", "@eslint/markdown": "^7.5.1", "@types/node": "^22.19.15", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..b04b66d --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test'; + +const baseURL = process.env.WP_BASE_URL ?? 'http://localhost:8888'; + +export default defineConfig({ + testDir: './tests/e2e', + timeout: 120_000, + expect: { + timeout: 30_000, + }, + fullyParallel: false, + workers: 1, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', + outputDir: 'test-results/playwright', + use: { + baseURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + headless: true, + }, + globalSetup: './tests/e2e/global-setup.ts', + globalTeardown: './tests/e2e/global-teardown.ts', + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], +}); diff --git a/src/cli/setup.ts b/src/cli/setup.ts index fb3d902..581afa7 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -330,6 +330,15 @@ async function validateCredentials(deps: SetupDeps, credentials: WpCredentials): deps.exit(1); } + try { + const wpVersion = await client.checkMinimumVersion(); + deps.log(` WordPress version: ${wpVersion}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + deps.error(message); + deps.exit(1); + } + try { await client.validateSyncEndpoint(); deps.log(' ✓ Collaborative editing endpoint available'); diff --git a/src/session/session-manager.ts b/src/session/session-manager.ts index f4e5449..f26ae31 100644 --- a/src/session/session-manager.ts +++ b/src/session/session-manager.ts @@ -287,8 +287,14 @@ export class SessionManager { /** Room name for the comment sync room. */ private static readonly COMMENT_ROOM = 'root/comment'; - /** Max time (ms) to wait for sync to populate the doc before loading from REST API. Set to 0 in tests. */ - syncWaitTimeout = 5000; + /** + * Max time (ms) to wait for sync to populate the doc before loading from REST API. + * Must be long enough for the step1/step2 handshake round-trip: + * Gutenberg 22.8+ polls at 4s solo / 1s with collaborators, so the handshake + * can take up to ~8s (step1 waits for browser poll, step2 waits for MCP poll). + * Set to 0 in tests to skip sync wait. + */ + syncWaitTimeout = 15_000; // --- Throwing getters for state-dependent fields --- @@ -342,6 +348,9 @@ export class SessionManager { const user = await this.apiClient.validateConnection(); this._user = user; + // Verify WordPress version meets minimum requirements + await this.apiClient.checkMinimumVersion(); + // Validate sync endpoint is available await this.apiClient.validateSyncEndpoint(); @@ -458,7 +467,7 @@ export class SessionManager { const done = () => { if (!resolved) { resolved = true; - doc.off('update', onDocUpdate); + doc.off('updateV2', onDocUpdate); resolve(); } }; @@ -474,7 +483,7 @@ export class SessionManager { } }; - doc.on('update', onDocUpdate); + doc.on('updateV2', onDocUpdate); }); } @@ -536,7 +545,7 @@ export class SessionManager { syncClient.queueUpdate(room, syncUpdate); } }; - doc.on('update', this.updateHandler); + doc.on('updateV2', this.updateHandler); // Join the root/comment room for real-time note sync. // This room's state map acts as a change signal: when savedAt/savedBy @@ -557,7 +566,7 @@ export class SessionManager { syncClient.queueUpdate(SessionManager.COMMENT_ROOM, syncUpdate); } }; - commentDoc.on('update', this.commentUpdateHandler); + commentDoc.on('updateV2', this.commentUpdateHandler); syncClient.addRoom( SessionManager.COMMENT_ROOM, @@ -612,12 +621,12 @@ export class SessionManager { } if (this._doc && this.updateHandler) { - this._doc.off('update', this.updateHandler); + this._doc.off('updateV2', this.updateHandler); this.updateHandler = null; } if (this.commentDoc && this.commentUpdateHandler) { - this.commentDoc.off('update', this.commentUpdateHandler); + this.commentDoc.off('updateV2', this.commentUpdateHandler); this.commentUpdateHandler = null; } diff --git a/src/wordpress/api-client.ts b/src/wordpress/api-client.ts index cc7f4be..409656d 100644 --- a/src/wordpress/api-client.ts +++ b/src/wordpress/api-client.ts @@ -42,6 +42,54 @@ export class WordPressApiClient { await this.sendSyncUpdate({ rooms: [] }); } + /** + * Check that the WordPress version meets the minimum requirement for + * collaborative editing (WordPress 7.0+ or Gutenberg plugin 22.8+). + * + * Fetches the REST API root (`GET /wp-json/`) and parses the `version` + * field. If the version is below 7.0, throws an error with a clear + * message. If the version cannot be determined (e.g. the endpoint is + * unavailable or the field is missing), the check is silently skipped. + * + * @returns The WordPress version string, or `'unknown'` if it could not + * be determined. + */ + async checkMinimumVersion(): Promise { + const MINIMUM_MAJOR = 7; + const MINIMUM_MINOR = 0; + + let data: { version?: string }; + try { + data = await this.apiFetch<{ version?: string }>('/'); + } catch { + // REST API root unavailable — skip the check rather than blocking. + return 'unknown'; + } + + const version = data.version; + if (typeof version !== 'string' || version.trim() === '') { + return 'unknown'; + } + + const parts = version.split('.'); + const major = parseInt(parts[0], 10); + const minor = parseInt(parts[1] ?? '0', 10); + + if (isNaN(major) || isNaN(minor)) { + return version; + } + + if (major < MINIMUM_MAJOR || (major === MINIMUM_MAJOR && minor < MINIMUM_MINOR)) { + throw new Error( + `WordPress ${MINIMUM_MAJOR}.${MINIMUM_MINOR} or later is required for collaborative editing. ` + + `Current version: ${version}. ` + + `If you have the Gutenberg plugin installed, version 22.8 or later is required.`, + ); + } + + return version; + } + /** * Get the current authenticated user. * GET /wp/v2/users/me diff --git a/src/yjs/sync-protocol.ts b/src/yjs/sync-protocol.ts index e1701ab..84d1f70 100644 --- a/src/yjs/sync-protocol.ts +++ b/src/yjs/sync-protocol.ts @@ -1,8 +1,13 @@ /** * Yjs sync protocol helpers for the HTTP polling transport. * - * Sync steps (step1/step2) use y-protocols' standard encoding. - * Regular updates and compactions use Yjs V1 encoding (matching Gutenberg). + * Gutenberg 22.8+ uses a mixed V1/V2 encoding approach: + * - Sync step1/step2 use y-protocols' standard encoding (V1 internally). + * Gutenberg calls syncProtocol.readSyncMessage() for both creating and + * processing step2, which hardcodes Y.encodeStateAsUpdate/Y.applyUpdate (V1). + * - Regular updates and compactions use Yjs V2 encoding. + * Gutenberg captures changes via doc.on('updateV2') and applies with Y.applyUpdateV2(). + * * All binary data is base64-encoded for transport. */ import * as Y from 'yjs'; @@ -13,6 +18,7 @@ import { type SyncUpdate, SyncUpdateType } from '../wordpress/types.js'; /** * Create a sync_step1 message announcing our state vector. + * State vectors are encoding-format-agnostic (identical for V1 and V2). */ export function createSyncStep1(doc: Y.Doc): SyncUpdate { const encoder = encoding.createEncoder(); @@ -30,6 +36,10 @@ export function createSyncStep1(doc: Y.Doc): SyncUpdate { * * Reads the remote state vector from the step1 message and encodes * the missing updates as a step2 reply. + * + * Uses y-protocols' readSyncMessage which produces V1-encoded step2 data. + * This matches Gutenberg's expectation — it also uses readSyncMessage + * (and thus V1) for step2 processing. */ export function createSyncStep2(doc: Y.Doc, step1Data: Uint8Array): SyncUpdate { const decoder = decoding.createDecoder(step1Data); @@ -37,6 +47,7 @@ export function createSyncStep2(doc: Y.Doc, step1Data: Uint8Array): SyncUpdate { // readSyncMessage reads the message type byte and the state vector, // then writes the appropriate response (step2) into the encoder. + // Internally uses V1 encoding (Y.encodeStateAsUpdate), matching Gutenberg. syncProtocol.readSyncMessage(decoder, encoder, doc, 'sync'); const data = encoding.toUint8Array(encoder); @@ -51,8 +62,8 @@ export function createSyncStep2(doc: Y.Doc, step1Data: Uint8Array): SyncUpdate { * Process an incoming sync update. * * For SYNC_STEP_1: generates a SYNC_STEP_2 response. - * For SYNC_STEP_2: applies the update via y-protocols and returns null. - * For UPDATE / COMPACTION: applies the V1 update and returns null. + * For SYNC_STEP_2: applies via y-protocols (V1 internally) and returns null. + * For UPDATE / COMPACTION: applies the V2 update and returns null. * * Returns a response SyncUpdate if one is needed (e.g., step2 reply), * or null if no response is required. @@ -67,18 +78,20 @@ export function processIncomingUpdate(doc: Y.Doc, update: SyncUpdate): SyncUpdat } case SyncUpdateType.SYNC_STEP_2: { - // Apply step2 via y-protocols decoder + // Apply step2 via y-protocols (V1 internally). + // Gutenberg creates step2 using syncProtocol.readSyncMessage which + // encodes with Y.encodeStateAsUpdate (V1), so we must also use + // readSyncMessage to decode it (which calls Y.applyUpdate, V1). const decoder = decoding.createDecoder(rawData); const encoder = encoding.createEncoder(); syncProtocol.readSyncMessage(decoder, encoder, doc, 'sync'); - // step2 processing doesn't produce a response return null; } case SyncUpdateType.UPDATE: case SyncUpdateType.COMPACTION: { - // Apply V1 update directly (Gutenberg uses V1 encoding for updates) - Y.applyUpdate(doc, rawData, 'remote'); + // Apply V2 update directly (Gutenberg 22.8+ uses V2 encoding) + Y.applyUpdateV2(doc, rawData, 'remote'); return null; } @@ -88,7 +101,8 @@ export function processIncomingUpdate(doc: Y.Doc, update: SyncUpdate): SyncUpdat } /** - * Create an update message from a Y.Doc change (V1 encoded). + * Create an update message from a Y.Doc change. + * The raw bytes come from the doc's 'updateV2' event (V2 encoded). */ export function createUpdateFromChange(update: Uint8Array): SyncUpdate { return { @@ -98,10 +112,10 @@ export function createUpdateFromChange(update: Uint8Array): SyncUpdate { } /** - * Create a compaction update containing the full document state (V1 encoded). + * Create a compaction update containing the full document state (V2 encoded). */ export function createCompactionUpdate(doc: Y.Doc): SyncUpdate { - const data = Y.encodeStateAsUpdate(doc); + const data = Y.encodeStateAsUpdateV2(doc); return { type: SyncUpdateType.COMPACTION, data: uint8ArrayToBase64(data), diff --git a/tests/e2e/block-sync.spec.ts b/tests/e2e/block-sync.spec.ts new file mode 100644 index 0000000..3981e2a --- /dev/null +++ b/tests/e2e/block-sync.spec.ts @@ -0,0 +1,625 @@ +import { test, expect, type Page } from '@playwright/test'; +import { createMcpTestClient, callToolOrThrow, getToolText } from './helpers/mcp'; +import { + WP_ADMIN_PASSWORD, + WP_ADMIN_USER, + WP_BASE_URL, + createAppPassword, + createDraftPost, + deletePost, +} from './helpers/wp-env'; + +const HEADING_CONTENT = + '

Original heading

'; +const TWO_PARAGRAPHS = + '

First paragraph

Second paragraph

'; + +interface BrowserBlock { + name: string; + attributes: Record; +} + +interface EditorBlock { + name: string; + attributes: Record; + innerBlocks?: EditorBlock[]; +} + +async function loginToWordPress(page: Page): Promise { + await page.goto('/wp-login.php'); + await page.locator('#user_login').fill(WP_ADMIN_USER); + await page.locator('#user_pass').fill(WP_ADMIN_PASSWORD); + await page.locator('#wp-submit').click(); + await page.waitForURL(/\/wp-admin\/?/); +} + +async function openEditor(page: Page, postId: number): Promise { + await page.goto(`/wp-admin/post.php?post=${postId}&action=edit`); + await expect + .poll(async () => { + return page.evaluate(() => { + const wpGlobal = globalThis as typeof globalThis & { + wp?: { + data?: { + select: (store: string) => { getBlocks: () => Array }; + }; + }; + }; + const blockEditor = wpGlobal.wp?.data?.select('core/block-editor'); + return blockEditor ? blockEditor.getBlocks().length : 0; + }); + }) + .toBeGreaterThan(0); +} + +async function getBrowserBlocks(page: Page): Promise { + return page.evaluate(() => { + const wpGlobal = globalThis as typeof globalThis & { + wp?: { + data?: { + select: (store: string) => { + getBlocks: () => EditorBlock[]; + }; + }; + }; + }; + const blockEditor = wpGlobal.wp?.data?.select('core/block-editor'); + if (!blockEditor) return []; + return blockEditor.getBlocks().map((b) => ({ + name: b.name, + attributes: { ...b.attributes }, + })); + }); +} + +async function waitForQueueToDrain( + client: Awaited>['client'], +): Promise { + await expect + .poll( + async () => { + const status = await callToolOrThrow(client, 'wp_status'); + return getToolText(status); + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toContain('Queue: 0 pending updates'); +} + +test.describe('block sync', () => { + test('MCP inserts blocks visible in browser', async ({ page }) => { + test.setTimeout(120_000); + + const postId = createDraftPost('E2E block-sync insert', HEADING_CONTENT); + const appPassword = createAppPassword(`e2e-ins-${Date.now()}`); + const { client, close, stderr } = await createMcpTestClient(); + + try { + await loginToWordPress(page); + await openEditor(page, postId); + + // Verify initial content is loaded in the browser + await expect + .poll(() => getBrowserBlocks(page), { timeout: 30_000, intervals: [1000] }) + .toEqual(expect.arrayContaining([expect.objectContaining({ name: 'core/heading' })])); + + // Connect MCP and open the post + await callToolOrThrow(client, 'wp_connect', { + siteUrl: WP_BASE_URL, + username: WP_ADMIN_USER, + appPassword, + }); + await callToolOrThrow(client, 'wp_open_post', { postId }); + + // Wait for 2 collaborators + await expect + .poll( + async () => { + const status = await callToolOrThrow(client, 'wp_status'); + return getToolText(status); + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toContain('(2 collaborators)'); + + // Insert a heading and a paragraph via MCP + await callToolOrThrow(client, 'wp_insert_block', { + position: 1, + name: 'core/heading', + content: 'Inserted heading', + attributes: { level: 3 }, + }); + + await callToolOrThrow(client, 'wp_insert_block', { + position: 2, + name: 'core/paragraph', + content: 'Inserted paragraph', + }); + + await waitForQueueToDrain(client); + + // Verify the browser sees all three blocks + await expect + .poll(() => getBrowserBlocks(page), { timeout: 30_000, intervals: [1000] }) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'core/heading', + attributes: expect.objectContaining({ content: 'Original heading' }), + }), + expect.objectContaining({ + name: 'core/heading', + attributes: expect.objectContaining({ content: 'Inserted heading', level: 3 }), + }), + expect.objectContaining({ + name: 'core/paragraph', + attributes: expect.objectContaining({ content: 'Inserted paragraph' }), + }), + ]), + ); + + // Also verify block count + await expect + .poll( + async () => { + const blocks = await getBrowserBlocks(page); + return blocks.length; + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toBe(3); + } catch (error) { + const stderrOutput = stderr.join('').trim(); + throw new Error( + `${error instanceof Error ? error.message : String(error)}${stderrOutput ? `\n\nMCP stderr:\n${stderrOutput}` : ''}`, + { cause: error }, + ); + } finally { + await close(); + deletePost(postId); + } + }); + + test('MCP edits block content visible in browser', async ({ page }) => { + test.setTimeout(120_000); + + const postId = createDraftPost('E2E block-sync edit', HEADING_CONTENT); + const appPassword = createAppPassword(`e2e-edit-${Date.now()}`); + const { client, close, stderr } = await createMcpTestClient(); + + try { + await loginToWordPress(page); + await openEditor(page, postId); + + // Verify initial heading is present + await expect + .poll(() => getBrowserBlocks(page), { timeout: 30_000, intervals: [1000] }) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'core/heading', + attributes: expect.objectContaining({ content: 'Original heading' }), + }), + ]), + ); + + // Connect MCP and open the post + await callToolOrThrow(client, 'wp_connect', { + siteUrl: WP_BASE_URL, + username: WP_ADMIN_USER, + appPassword, + }); + await callToolOrThrow(client, 'wp_open_post', { postId }); + + // Wait for 2 collaborators + await expect + .poll( + async () => { + const status = await callToolOrThrow(client, 'wp_status'); + return getToolText(status); + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toContain('(2 collaborators)'); + + // Update heading content via MCP + await callToolOrThrow(client, 'wp_update_block', { + index: '0', + content: 'Updated heading from MCP', + }); + + await waitForQueueToDrain(client); + + // Verify the browser sees the updated content + await expect + .poll( + async () => { + const blocks = await getBrowserBlocks(page); + return blocks[0]?.attributes?.content; + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toBe('Updated heading from MCP'); + } catch (error) { + const stderrOutput = stderr.join('').trim(); + throw new Error( + `${error instanceof Error ? error.message : String(error)}${stderrOutput ? `\n\nMCP stderr:\n${stderrOutput}` : ''}`, + { cause: error }, + ); + } finally { + await close(); + deletePost(postId); + } + }); + + test('MCP changes block attributes visible in browser', async ({ page }) => { + test.setTimeout(120_000); + + const postId = createDraftPost('E2E block-sync attrs', HEADING_CONTENT); + const appPassword = createAppPassword(`e2e-attr-${Date.now()}`); + const { client, close, stderr } = await createMcpTestClient(); + + try { + await loginToWordPress(page); + await openEditor(page, postId); + + // Verify initial heading is level 2 + await expect + .poll( + async () => { + const blocks = await getBrowserBlocks(page); + return blocks[0]?.attributes?.level; + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toBe(2); + + // Connect MCP and open the post + await callToolOrThrow(client, 'wp_connect', { + siteUrl: WP_BASE_URL, + username: WP_ADMIN_USER, + appPassword, + }); + await callToolOrThrow(client, 'wp_open_post', { postId }); + + // Wait for 2 collaborators + await expect + .poll( + async () => { + const status = await callToolOrThrow(client, 'wp_status'); + return getToolText(status); + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toContain('(2 collaborators)'); + + // Change heading level from 2 to 4 + await callToolOrThrow(client, 'wp_update_block', { + index: '0', + attributes: { level: 4 }, + }); + + await waitForQueueToDrain(client); + + // Verify the browser sees level 4 + await expect + .poll( + async () => { + const blocks = await getBrowserBlocks(page); + return blocks[0]?.attributes?.level; + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toBe(4); + } catch (error) { + const stderrOutput = stderr.join('').trim(); + throw new Error( + `${error instanceof Error ? error.message : String(error)}${stderrOutput ? `\n\nMCP stderr:\n${stderrOutput}` : ''}`, + { cause: error }, + ); + } finally { + await close(); + deletePost(postId); + } + }); + + test('MCP removes blocks visible in browser', async ({ page }) => { + test.setTimeout(120_000); + + const postId = createDraftPost('E2E block-sync remove', TWO_PARAGRAPHS); + const appPassword = createAppPassword(`e2e-rm-${Date.now()}`); + const { client, close, stderr } = await createMcpTestClient(); + + try { + await loginToWordPress(page); + await openEditor(page, postId); + + // Verify initial two paragraphs are present + await expect + .poll( + async () => { + const blocks = await getBrowserBlocks(page); + return blocks.length; + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toBe(2); + + await expect + .poll(() => getBrowserBlocks(page), { timeout: 30_000, intervals: [1000] }) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ + attributes: expect.objectContaining({ content: 'First paragraph' }), + }), + expect.objectContaining({ + attributes: expect.objectContaining({ content: 'Second paragraph' }), + }), + ]), + ); + + // Connect MCP and open the post + await callToolOrThrow(client, 'wp_connect', { + siteUrl: WP_BASE_URL, + username: WP_ADMIN_USER, + appPassword, + }); + await callToolOrThrow(client, 'wp_open_post', { postId }); + + // Wait for 2 collaborators + await expect + .poll( + async () => { + const status = await callToolOrThrow(client, 'wp_status'); + return getToolText(status); + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toContain('(2 collaborators)'); + + // Remove the first paragraph + await callToolOrThrow(client, 'wp_remove_blocks', { + startIndex: 0, + count: 1, + }); + + await waitForQueueToDrain(client); + + // Verify the browser sees only the second paragraph + await expect + .poll( + async () => { + const blocks = await getBrowserBlocks(page); + return blocks.length; + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toBe(1); + + await expect + .poll( + async () => { + const blocks = await getBrowserBlocks(page); + return blocks[0]?.attributes?.content; + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toBe('Second paragraph'); + } catch (error) { + const stderrOutput = stderr.join('').trim(); + throw new Error( + `${error instanceof Error ? error.message : String(error)}${stderrOutput ? `\n\nMCP stderr:\n${stderrOutput}` : ''}`, + { cause: error }, + ); + } finally { + await close(); + deletePost(postId); + } + }); + + test('browser edits visible in MCP', async ({ page }) => { + test.setTimeout(120_000); + + const initialContent = + '

Browser editable paragraph

'; + const postId = createDraftPost('E2E block-sync browser-to-mcp', initialContent); + const appPassword = createAppPassword(`e2e-b2m-${Date.now()}`); + const { client, close, stderr } = await createMcpTestClient(); + + try { + await loginToWordPress(page); + await openEditor(page, postId); + + // Verify initial content in browser + await expect + .poll( + async () => { + const blocks = await getBrowserBlocks(page); + return blocks[0]?.attributes?.content; + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toBe('Browser editable paragraph'); + + // Connect MCP and open the post + await callToolOrThrow(client, 'wp_connect', { + siteUrl: WP_BASE_URL, + username: WP_ADMIN_USER, + appPassword, + }); + await callToolOrThrow(client, 'wp_open_post', { postId }); + + // Wait for 2 collaborators + await expect + .poll( + async () => { + const status = await callToolOrThrow(client, 'wp_status'); + return getToolText(status); + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toContain('(2 collaborators)'); + + // Edit the paragraph content from the browser side + const newContent = 'Content modified by the browser'; + await page.evaluate((content) => { + const wpGlobal = globalThis as typeof globalThis & { + wp?: { + data?: { + select: (store: string) => { + getBlocks: () => Array<{ clientId: string }>; + }; + dispatch: (store: string) => { + updateBlockAttributes: ( + clientId: string, + attributes: Record, + ) => void; + }; + }; + }; + }; + const blockEditor = wpGlobal.wp?.data?.select('core/block-editor'); + const blocks = blockEditor!.getBlocks(); + const blockId = blocks[0].clientId; + wpGlobal.wp!.data!.dispatch('core/block-editor').updateBlockAttributes(blockId, { + content, + }); + }, newContent); + + // Poll MCP's wp_read_post until it contains the new content + await expect + .poll( + async () => { + const post = await callToolOrThrow(client, 'wp_read_post'); + return getToolText(post); + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toContain(newContent); + } catch (error) { + const stderrOutput = stderr.join('').trim(); + throw new Error( + `${error instanceof Error ? error.message : String(error)}${stderrOutput ? `\n\nMCP stderr:\n${stderrOutput}` : ''}`, + { cause: error }, + ); + } finally { + await close(); + deletePost(postId); + } + }); + + test('multiple sequential edits in a single session', async ({ page }) => { + test.setTimeout(180_000); + + const postId = createDraftPost('E2E block-sync workflow', HEADING_CONTENT); + const appPassword = createAppPassword(`e2e-wf-${Date.now()}`); + const { client, close, stderr } = await createMcpTestClient(); + + try { + await loginToWordPress(page); + await openEditor(page, postId); + + await expect + .poll(() => getBrowserBlocks(page), { timeout: 30_000, intervals: [1000] }) + .toEqual(expect.arrayContaining([expect.objectContaining({ name: 'core/heading' })])); + + await callToolOrThrow(client, 'wp_connect', { + siteUrl: WP_BASE_URL, + username: WP_ADMIN_USER, + appPassword, + }); + await callToolOrThrow(client, 'wp_open_post', { postId }); + + await expect + .poll( + async () => { + const status = await callToolOrThrow(client, 'wp_status'); + return getToolText(status); + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toContain('(2 collaborators)'); + + // Step 1: Update heading content and level in one call + await callToolOrThrow(client, 'wp_update_block', { + index: '0', + content: 'Refined heading', + attributes: { level: 3 }, + }); + + await waitForQueueToDrain(client); + + await expect + .poll( + async () => { + const blocks = await getBrowserBlocks(page); + return { content: blocks[0]?.attributes?.content, level: blocks[0]?.attributes?.level }; + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toEqual({ content: 'Refined heading', level: 3 }); + + // Step 2: Insert a paragraph after the heading + await callToolOrThrow(client, 'wp_insert_block', { + position: 1, + name: 'core/paragraph', + content: 'A collaborative paragraph inserted mid-session.', + }); + + await waitForQueueToDrain(client); + + await expect + .poll( + async () => { + const blocks = await getBrowserBlocks(page); + return blocks.length; + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toBe(2); + + // Step 3: Insert a list with inner blocks + await callToolOrThrow(client, 'wp_insert_block', { + position: 2, + name: 'core/list', + innerBlocks: [ + { name: 'core/list-item', content: 'First item' }, + { name: 'core/list-item', content: 'Second item' }, + { name: 'core/list-item', content: 'Third item' }, + ], + }); + + await waitForQueueToDrain(client); + + // Verify final state: heading + paragraph + list with 3 items + await expect + .poll( + async () => { + const blocks = await getBrowserBlocks(page); + return blocks.length; + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toBe(3); + + await expect + .poll( + async () => { + const blocks = await getBrowserBlocks(page); + return blocks[2]?.name; + }, + { timeout: 30_000, intervals: [1000] }, + ) + .toBe('core/list'); + } catch (error) { + const stderrOutput = stderr.join('').trim(); + throw new Error( + `${error instanceof Error ? error.message : String(error)}${stderrOutput ? `\n\nMCP stderr:\n${stderrOutput}` : ''}`, + { cause: error }, + ); + } finally { + await close(); + deletePost(postId); + } + }); +}); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 0000000..3db771e --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -0,0 +1,5 @@ +import { ensureWpEnvRunning } from './helpers/wp-env'; + +export default async function globalSetup(): Promise { + await ensureWpEnvRunning(); +} diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts new file mode 100644 index 0000000..acd485c --- /dev/null +++ b/tests/e2e/global-teardown.ts @@ -0,0 +1,5 @@ +import { teardownWpEnv } from './helpers/wp-env'; + +export default async function globalTeardown(): Promise { + teardownWpEnv(); +} diff --git a/tests/e2e/helpers/mcp.ts b/tests/e2e/helpers/mcp.ts new file mode 100644 index 0000000..b20dc0e --- /dev/null +++ b/tests/e2e/helpers/mcp.ts @@ -0,0 +1,69 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); + +export interface McpTestClient { + client: Client; + close: () => Promise; + stderr: string[]; +} + +export async function createMcpTestClient(): Promise { + const transport = new StdioClientTransport({ + command: 'node', + args: ['./dist/index.js'], + cwd: REPO_ROOT, + stderr: 'pipe', + }); + const stderr: string[] = []; + + transport.stderr?.on('data', (chunk: Buffer | string) => { + stderr.push(chunk.toString()); + }); + + const client = new Client({ name: 'e2e-test-client', version: '1.0.0' }); + await client.connect(transport); + + return { + client, + stderr, + close: async () => { + await transport.close(); + }, + }; +} + +interface ToolContent { + type: string; + text?: string; +} + +type ToolResult = Awaited>; + +export async function callToolOrThrow( + client: Client, + name: string, + args: Record = {}, +): Promise { + const result = await client.callTool({ name, arguments: args }); + if ('isError' in result && result.isError) { + const content = result.content as ToolContent[]; + const text = content + .filter((item) => item.type === 'text') + .map((item) => item.text ?? '') + .join('\n'); + throw new Error(`Tool ${name} failed: ${text}`); + } + return result; +} + +export function getToolText(result: ToolResult): string { + const content = result.content as ToolContent[]; + return content + .filter((item) => item.type === 'text') + .map((item) => item.text ?? '') + .join('\n'); +} diff --git a/tests/e2e/helpers/wp-env.ts b/tests/e2e/helpers/wp-env.ts new file mode 100644 index 0000000..74b09ba --- /dev/null +++ b/tests/e2e/helpers/wp-env.ts @@ -0,0 +1,155 @@ +import { execFileSync } from 'node:child_process'; +import { writeFileSync, existsSync, readFileSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const WP_BASE_URL = process.env.WP_BASE_URL ?? 'http://localhost:8888'; +export const WP_ADMIN_USER = process.env.WP_E2E_ADMIN_USER ?? 'admin'; +export const WP_ADMIN_PASSWORD = process.env.WP_E2E_ADMIN_PASSWORD ?? 'password'; +export const WP_MCP_USER = process.env.WP_E2E_MCP_USER ?? 'claudaborative-e2e'; +export const WP_MCP_PASSWORD = process.env.WP_E2E_MCP_PASSWORD ?? 'claudaborative-e2e-pass'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const STATE_FILE = path.join(tmpdir(), 'claudaborative-editing-e2e-wp-env.json'); + +function runCommand(command: string, args: string[], inheritOutput: boolean = false): string { + return execFileSync(command, args, { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: inheritOutput ? 'inherit' : ['ignore', 'pipe', 'pipe'], + }); +} + +function runWpEnv(args: string[], inheritOutput: boolean = false): string { + return runCommand('npx', ['wp-env', ...args], inheritOutput); +} + +function tryRunWpEnv(args: string[]): string | null { + try { + return runWpEnv(args); + } catch { + return null; + } +} + +export async function waitForWordPress(timeoutMs: number = 180_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const response = await fetch(`${WP_BASE_URL}/wp-login.php`, { redirect: 'manual' }); + if (response.ok || response.status === 302) { + return; + } + lastError = new Error(`Unexpected status: ${response.status}`); + } catch (error) { + lastError = error; + } + + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + + throw new Error(`Timed out waiting for WordPress at ${WP_BASE_URL}: ${String(lastError)}`); +} + +export async function ensureWpEnvRunning(): Promise { + const alreadyRunning = await (async () => { + try { + const response = await fetch(`${WP_BASE_URL}/wp-login.php`, { redirect: 'manual' }); + return response.ok || response.status === 302; + } catch { + return false; + } + })(); + + if (!alreadyRunning) { + runWpEnv(['start'], true); + writeFileSync(STATE_FILE, JSON.stringify({ startedBySuite: true }), 'utf8'); + } + + await waitForWordPress(); +} + +export function teardownWpEnv(): void { + if (!existsSync(STATE_FILE) || process.env.CLAUDABORATIVE_E2E_REUSE_ENV === '1') { + return; + } + + const state = JSON.parse(readFileSync(STATE_FILE, 'utf8')) as { startedBySuite?: boolean }; + if (state.startedBySuite) { + runWpEnv(['stop'], true); + } + + unlinkSync(STATE_FILE); +} + +export function createAppPassword(label: string): string { + return runWpEnv([ + 'run', + 'cli', + 'wp', + 'user', + 'application-password', + 'create', + WP_ADMIN_USER, + label, + '--porcelain', + ]).trim(); +} + +export function ensureEditorUserExists(): void { + const existing = tryRunWpEnv(['run', 'cli', 'wp', 'user', 'get', WP_MCP_USER, '--field=ID']); + if (existing) { + return; + } + + runWpEnv([ + 'run', + 'cli', + 'wp', + 'user', + 'create', + WP_MCP_USER, + `${WP_MCP_USER}@example.com`, + '--role=editor', + `--user_pass=${WP_MCP_PASSWORD}`, + '--display_name=E2E MCP Editor', + ]); +} + +export function createAppPasswordForUser(username: string, label: string): string { + return runWpEnv([ + 'run', + 'cli', + 'wp', + 'user', + 'application-password', + 'create', + username, + label, + '--porcelain', + ]).trim(); +} + +export function createDraftPost(title: string, content: string): number { + const result = runWpEnv([ + 'run', + 'cli', + 'wp', + 'post', + 'create', + `--post_title=${title}`, + '--post_type=post', + '--post_status=draft', + `--post_content=${content}`, + '--porcelain', + ]).trim(); + + return Number.parseInt(result, 10); +} + +export function deletePost(postId: number): void { + runWpEnv(['run', 'cli', 'wp', 'post', 'delete', String(postId), '--force']); +} diff --git a/tests/integration/two-client-sync.test.ts b/tests/integration/two-client-sync.test.ts index cfd3217..12aaad9 100644 --- a/tests/integration/two-client-sync.test.ts +++ b/tests/integration/two-client-sync.test.ts @@ -42,7 +42,7 @@ describe('Two-client sync integration', () => { // Collect updates from docA const updatesFromA: Uint8Array[] = []; - docA.on('update', (update: Uint8Array) => { + docA.on('updateV2', (update: Uint8Array) => { updatesFromA.push(update); }); @@ -150,10 +150,10 @@ describe('Two-client sync integration', () => { const updatesFromA: SyncUpdate[] = []; const updatesFromB: SyncUpdate[] = []; - docA.on('update', (update: Uint8Array) => { + docA.on('updateV2', (update: Uint8Array) => { updatesFromA.push(createUpdateFromChange(update)); }); - docB.on('update', (update: Uint8Array) => { + docB.on('updateV2', (update: Uint8Array) => { updatesFromB.push(createUpdateFromChange(update)); }); @@ -199,7 +199,7 @@ describe('Two-client sync integration', () => { // Collect updates from A const updatesFromA: SyncUpdate[] = []; - docA.on('update', (update: Uint8Array) => { + docA.on('updateV2', (update: Uint8Array) => { updatesFromA.push(createUpdateFromChange(update)); }); @@ -227,10 +227,10 @@ describe('Two-client sync integration', () => { const updatesFromA: SyncUpdate[] = []; const updatesFromB: SyncUpdate[] = []; - docA.on('update', (update: Uint8Array) => { + docA.on('updateV2', (update: Uint8Array) => { updatesFromA.push(createUpdateFromChange(update)); }); - docB.on('update', (update: Uint8Array) => { + docB.on('updateV2', (update: Uint8Array) => { updatesFromB.push(createUpdateFromChange(update)); }); @@ -269,7 +269,7 @@ describe('Two-client sync integration', () => { // Collect updates from A const updatesFromA: SyncUpdate[] = []; - docA.on('update', (update: Uint8Array) => { + docA.on('updateV2', (update: Uint8Array) => { updatesFromA.push(createUpdateFromChange(update)); }); diff --git a/tests/unit/cli/auth-server.test.ts b/tests/unit/cli/auth-server.test.ts index fc60bda..fbf3062 100644 --- a/tests/unit/cli/auth-server.test.ts +++ b/tests/unit/cli/auth-server.test.ts @@ -445,7 +445,7 @@ describe('openBrowserDefault', () => { it('resolves even when execFile fails', async () => { vi.mocked(childProcess.execFile).mockImplementation( - (_cmd: string, _args: readonly string[], cb: unknown) => { + (_cmd: string, _args: readonly string[] | null | undefined, cb: unknown) => { (cb as (err: Error | null) => void)(new Error('spawn failed')); return {} as ReturnType; }, diff --git a/tests/unit/cli/setup.test.ts b/tests/unit/cli/setup.test.ts index ca8a1ea..b939e0d 100644 --- a/tests/unit/cli/setup.test.ts +++ b/tests/unit/cli/setup.test.ts @@ -105,10 +105,11 @@ function createTestDeps( }; } -// Successful fetch responses for validation (user + sync endpoint) +// Successful fetch responses for validation (user + version check + sync endpoint) function mockSuccessfulValidation(): void { fetchMock .mockResolvedValueOnce(mockResponse({ id: 1, name: 'admin', slug: 'admin', avatar_urls: {} })) + .mockResolvedValueOnce(mockResponse({ version: '7.0' })) .mockResolvedValueOnce(mockResponse({ rooms: [] })); } @@ -264,6 +265,7 @@ describe('setup wizard', () => { .mockResolvedValueOnce( mockResponse({ id: 1, name: 'admin', slug: 'admin', avatar_urls: {} }), ) + .mockResolvedValueOnce(mockResponse({ version: '7.0' })) .mockResolvedValueOnce( mockResponse( { code: 'rest_no_route', message: 'No route' }, @@ -324,6 +326,7 @@ describe('setup wizard', () => { .mockResolvedValueOnce( mockResponse({ id: 1, name: 'admin', slug: 'admin', avatar_urls: {} }), ) + .mockResolvedValueOnce(mockResponse({ version: '7.0' })) .mockResolvedValueOnce( mockResponse( { code: 'internal_server_error', message: 'Something broke' }, diff --git a/tests/unit/server.test.ts b/tests/unit/server.test.ts index 78bd9e3..1df23ca 100644 --- a/tests/unit/server.test.ts +++ b/tests/unit/server.test.ts @@ -186,7 +186,7 @@ describe('graceful shutdown', () => { await startServer(); // Trigger the SIGTERM handler - signalHandlers.SIGTERM[0](); + signalHandlers.SIGTERM![0](); // Allow the async cleanup to complete await vi.waitFor(() => { @@ -201,7 +201,7 @@ describe('graceful shutdown', () => { const { startServer } = await import('../../src/server.js'); await startServer(); - signalHandlers.SIGINT[0](); + signalHandlers.SIGINT![0](); await vi.waitFor(() => { expect(processExitSpy).toHaveBeenCalledWith(0); @@ -215,7 +215,7 @@ describe('graceful shutdown', () => { const { startServer } = await import('../../src/server.js'); await startServer(); - stdinHandlers.end[0](); + stdinHandlers.end![0](); await vi.waitFor(() => { expect(processExitSpy).toHaveBeenCalledWith(0); @@ -230,8 +230,8 @@ describe('graceful shutdown', () => { await startServer(); // Trigger both SIGTERM and stdin end simultaneously - signalHandlers.SIGTERM[0](); - stdinHandlers.end[0](); + signalHandlers.SIGTERM![0](); + stdinHandlers.end![0](); await vi.waitFor(() => { expect(processExitSpy).toHaveBeenCalled(); diff --git a/tests/unit/session-manager.test.ts b/tests/unit/session-manager.test.ts index f41bff9..f735363 100644 --- a/tests/unit/session-manager.test.ts +++ b/tests/unit/session-manager.test.ts @@ -15,6 +15,7 @@ const mockUploadMedia = vi.fn(); const mockListTerms = vi.fn<() => Promise>(); const mockSearchTerms = vi.fn<() => Promise>(); const mockCreateTerm = vi.fn<() => Promise>(); +const mockCheckMinimumVersion = vi.fn<() => Promise>(); const mockCheckNotesSupport = vi.fn<() => Promise>(); const mockListNotes = vi.fn<() => Promise>(); const mockCreateNote = vi.fn<() => Promise>(); @@ -36,6 +37,7 @@ vi.mock('../../src/wordpress/api-client.js', () => { this.listTerms = mockListTerms; this.searchTerms = mockSearchTerms; this.createTerm = mockCreateTerm; + this.checkMinimumVersion = mockCheckMinimumVersion; this.checkNotesSupport = mockCheckNotesSupport; this.listNotes = mockListNotes; this.createNote = mockCreateNote; @@ -48,7 +50,7 @@ vi.mock('../../src/wordpress/api-client.js', () => { // --- Mock node:fs/promises for uploadMedia tests --- const mockReadFile = vi.fn<() => Promise>(); vi.mock('node:fs/promises', () => ({ - readFile: (...args: unknown[]) => mockReadFile(...args), + readFile: (...args: unknown[]) => mockReadFile(...(args as [])), })); // --- Mock the sync client --- @@ -117,6 +119,7 @@ const fakePost: WPPost = { async function connectSession(session: SessionManager): Promise { mockValidateConnection.mockResolvedValue(fakeUser); + mockCheckMinimumVersion.mockResolvedValue('7.0'); mockValidateSyncEndpoint.mockResolvedValue(undefined); await session.connect(fakeConfig); } @@ -142,6 +145,7 @@ describe('SessionManager', () => { queueSize: 0, }); mockGetBlockTypes.mockRejectedValue(new Error('Not available')); + mockCheckMinimumVersion.mockResolvedValue('7.0'); mockCheckNotesSupport.mockResolvedValue(false); session = new SessionManager(); session.syncWaitTimeout = 0; // Skip sync wait in tests diff --git a/tests/unit/sync-protocol.test.ts b/tests/unit/sync-protocol.test.ts index 67f3450..2f6d148 100644 --- a/tests/unit/sync-protocol.test.ts +++ b/tests/unit/sync-protocol.test.ts @@ -94,7 +94,7 @@ describe('processIncomingUpdate', () => { // Capture an update from docB let capturedUpdate: Uint8Array | null = null; - docB.on('update', (update: Uint8Array) => { + docB.on('updateV2', (update: Uint8Array) => { capturedUpdate = update; }); docB.getMap('data').set('foo', 'bar'); @@ -127,7 +127,7 @@ describe('createUpdateFromChange', () => { it('wraps a raw update in a SyncUpdate', () => { const doc = new Y.Doc(); let capturedUpdate: Uint8Array | null = null; - doc.on('update', (update: Uint8Array) => { + doc.on('updateV2', (update: Uint8Array) => { capturedUpdate = update; }); doc.getMap('m').set('k', 'v'); @@ -153,7 +153,7 @@ describe('createCompactionUpdate', () => { // Verify it can be applied to a fresh doc const newDoc = new Y.Doc(); - Y.applyUpdate(newDoc, base64ToUint8Array(compaction.data)); + Y.applyUpdateV2(newDoc, base64ToUint8Array(compaction.data)); expect(newDoc.getMap('map1').get('a')).toBe(1); expect(newDoc.getMap('map2').get('b')).toBe(2); }); diff --git a/tsconfig.json b/tsconfig.json index 6acadfa..1cf9023 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,6 @@ "lib": ["ES2022"], "types": ["node"], "outDir": "dist", - "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, @@ -27,6 +26,6 @@ "#blocks/*": ["./src/blocks/*"] } }, - "include": ["src/**/*"], + "include": ["src/**/*", "tests/**/*", "*.config.*"], "exclude": ["node_modules", "dist"] } From be76074503c5bf99aaf75636e53eb3e2bad125b3 Mon Sep 17 00:00:00 2001 From: Gary Pendergast Date: Sat, 28 Mar 2026 10:52:25 +1100 Subject: [PATCH 02/10] chore: linting. --- .markdownlint-cli2.jsonc | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 33b8782..55933ab 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -11,11 +11,5 @@ "siblings_only": true } }, - "ignores": [ - "node_modules/", - "dist/", - "coverage/", - ".gutenberg/", - "test-results/" - ] -} \ No newline at end of file + "ignores": ["node_modules/", "dist/", "coverage/", ".gutenberg/", "test-results/"] +} From ee08c06305595177c1462fbe4e197fb2a6c4b12d Mon Sep 17 00:00:00 2001 From: Gary Pendergast Date: Sat, 28 Mar 2026 10:56:55 +1100 Subject: [PATCH 03/10] test: Add unit tests for checkMinimumVersion to restore coverage Covers: version >= 7.0, version < 7.0, endpoint unavailable, missing version field, empty version, unparseable version, and the setup wizard exit path for old WordPress versions. --- tests/unit/api-client.test.ts | 52 +++++++++++++++++++++++++++++++++++ tests/unit/cli/setup.test.ts | 16 +++++++++++ 2 files changed, 68 insertions(+) diff --git a/tests/unit/api-client.test.ts b/tests/unit/api-client.test.ts index 3218dfe..b737921 100644 --- a/tests/unit/api-client.test.ts +++ b/tests/unit/api-client.test.ts @@ -125,6 +125,58 @@ describe('WordPressApiClient', () => { }); }); + describe('checkMinimumVersion', () => { + it('returns version string when >= 7.0', async () => { + fetchMock.mockResolvedValue(mockResponse({ version: '7.0' })); + const client = createClient(); + const version = await client.checkMinimumVersion(); + expect(version).toBe('7.0'); + }); + + it('accepts higher versions', async () => { + fetchMock.mockResolvedValue(mockResponse({ version: '7.2.1' })); + const client = createClient(); + const version = await client.checkMinimumVersion(); + expect(version).toBe('7.2.1'); + }); + + it('throws for versions below 7.0', async () => { + fetchMock.mockResolvedValue(mockResponse({ version: '6.9.2' })); + const client = createClient(); + await expect(client.checkMinimumVersion()).rejects.toThrow( + /WordPress 7\.0 or later is required/, + ); + }); + + it('returns unknown when endpoint is unavailable', async () => { + fetchMock.mockRejectedValue(new Error('fetch failed')); + const client = createClient(); + const version = await client.checkMinimumVersion(); + expect(version).toBe('unknown'); + }); + + it('returns unknown when version field is missing', async () => { + fetchMock.mockResolvedValue(mockResponse({})); + const client = createClient(); + const version = await client.checkMinimumVersion(); + expect(version).toBe('unknown'); + }); + + it('returns unknown when version field is empty', async () => { + fetchMock.mockResolvedValue(mockResponse({ version: '' })); + const client = createClient(); + const version = await client.checkMinimumVersion(); + expect(version).toBe('unknown'); + }); + + it('returns raw version when parsing fails', async () => { + fetchMock.mockResolvedValue(mockResponse({ version: 'alpha' })); + const client = createClient(); + const version = await client.checkMinimumVersion(); + expect(version).toBe('alpha'); + }); + }); + describe('getCurrentUser', () => { it('fetches /wp/v2/users/me', async () => { fetchMock.mockResolvedValue(mockResponse(fakeUser)); diff --git a/tests/unit/cli/setup.test.ts b/tests/unit/cli/setup.test.ts index b939e0d..ec0d134 100644 --- a/tests/unit/cli/setup.test.ts +++ b/tests/unit/cli/setup.test.ts @@ -260,6 +260,22 @@ describe('setup wizard', () => { expect(errors.join('\n')).toContain('Authentication failed'); }); + it('exits with error when WordPress version is too old', async () => { + fetchMock + .mockResolvedValueOnce( + mockResponse({ id: 1, name: 'admin', slug: 'admin', avatar_urls: {} }), + ) + .mockResolvedValueOnce(mockResponse({ version: '6.7' })); + + const { deps, errors } = createTestDeps(['https://example.com', 'admin', 'xxxx xxxx xxxx'], { + detectClients: () => defaultClientList(), + hasConfig: () => false, + }); + + await expect(runSetup(deps, { manual: true })).rejects.toThrow(SetupExitError); + expect(errors.join('\n')).toContain('WordPress 7.0 or later is required'); + }); + it('exits with error when sync endpoint returns 404', async () => { fetchMock .mockResolvedValueOnce( From 981cda60ea5e024097b1cf7196e8e63b71917896 Mon Sep 17 00:00:00 2001 From: Gary Pendergast Date: Sat, 28 Mar 2026 11:06:13 +1100 Subject: [PATCH 04/10] fix: Address Copilot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - STATE_FILE collision: include repo root hash in temp filename to prevent concurrent e2e runs from different checkouts interfering. - Version check logic: rename checkMinimumVersion() to getWordPressVersion() — now purely informational (never throws). The sync endpoint check remains the real gate. If the endpoint returns 404, the error message includes the detected WP version and mentions both upgrade paths (WP 7.0+ or Gutenberg 22.8+). This fixes a bug where WP 6.x + Gutenberg 22.8+ users were incorrectly blocked. --- src/cli/setup.ts | 17 +++++-------- src/session/session-manager.ts | 5 +--- src/wordpress/api-client.ts | 35 ++++----------------------- tests/e2e/helpers/wp-env.ts | 6 ++++- tests/unit/api-client.test.ts | 38 +++++------------------------- tests/unit/cli/setup.test.ts | 16 +++++++++---- tests/unit/session-manager.test.ts | 7 +++--- 7 files changed, 38 insertions(+), 86 deletions(-) diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 581afa7..f1021a5 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -330,14 +330,8 @@ async function validateCredentials(deps: SetupDeps, credentials: WpCredentials): deps.exit(1); } - try { - const wpVersion = await client.checkMinimumVersion(); - deps.log(` WordPress version: ${wpVersion}`); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - deps.error(message); - deps.exit(1); - } + const wpVersion = await client.getWordPressVersion(); + deps.log(` WordPress version: ${wpVersion}`); try { await client.validateSyncEndpoint(); @@ -346,9 +340,10 @@ async function validateCredentials(deps: SetupDeps, credentials: WpCredentials): if (err instanceof WordPressApiError && err.status === 404) { deps.log(''); deps.error( - 'Collaborative editing is not enabled.\n' + - ' Go to Settings → Writing in your WordPress admin and enable it.\n' + - ' (Requires WordPress 7.0 or later.)', + 'Collaborative editing is not available.\n' + + ' Requires WordPress 7.0 or later, or the Gutenberg plugin 22.8 or later.\n' + + (wpVersion !== 'unknown' ? ` Current WordPress version: ${wpVersion}\n` : '') + + ' If using WordPress 7.0+, enable collaborative editing in Settings → Writing.', ); deps.exit(1); } diff --git a/src/session/session-manager.ts b/src/session/session-manager.ts index f26ae31..bc74cd6 100644 --- a/src/session/session-manager.ts +++ b/src/session/session-manager.ts @@ -348,10 +348,7 @@ export class SessionManager { const user = await this.apiClient.validateConnection(); this._user = user; - // Verify WordPress version meets minimum requirements - await this.apiClient.checkMinimumVersion(); - - // Validate sync endpoint is available + // Validate sync endpoint is available (the real gate for collaborative editing) await this.apiClient.validateSyncEndpoint(); // Fetch block type registry from the API; fall back to hardcoded if unavailable diff --git a/src/wordpress/api-client.ts b/src/wordpress/api-client.ts index 409656d..bc1b1c9 100644 --- a/src/wordpress/api-client.ts +++ b/src/wordpress/api-client.ts @@ -43,26 +43,17 @@ export class WordPressApiClient { } /** - * Check that the WordPress version meets the minimum requirement for - * collaborative editing (WordPress 7.0+ or Gutenberg plugin 22.8+). + * Fetch the WordPress version from the REST API root. * - * Fetches the REST API root (`GET /wp-json/`) and parses the `version` - * field. If the version is below 7.0, throws an error with a clear - * message. If the version cannot be determined (e.g. the endpoint is - * unavailable or the field is missing), the check is silently skipped. - * - * @returns The WordPress version string, or `'unknown'` if it could not - * be determined. + * Returns the version string, or `'unknown'` if it could not be + * determined (e.g. the endpoint is unavailable or the field is missing). + * Never throws — callers decide how to act on the result. */ - async checkMinimumVersion(): Promise { - const MINIMUM_MAJOR = 7; - const MINIMUM_MINOR = 0; - + async getWordPressVersion(): Promise { let data: { version?: string }; try { data = await this.apiFetch<{ version?: string }>('/'); } catch { - // REST API root unavailable — skip the check rather than blocking. return 'unknown'; } @@ -71,22 +62,6 @@ export class WordPressApiClient { return 'unknown'; } - const parts = version.split('.'); - const major = parseInt(parts[0], 10); - const minor = parseInt(parts[1] ?? '0', 10); - - if (isNaN(major) || isNaN(minor)) { - return version; - } - - if (major < MINIMUM_MAJOR || (major === MINIMUM_MAJOR && minor < MINIMUM_MINOR)) { - throw new Error( - `WordPress ${MINIMUM_MAJOR}.${MINIMUM_MINOR} or later is required for collaborative editing. ` + - `Current version: ${version}. ` + - `If you have the Gutenberg plugin installed, version 22.8 or later is required.`, - ); - } - return version; } diff --git a/tests/e2e/helpers/wp-env.ts b/tests/e2e/helpers/wp-env.ts index 74b09ba..5296839 100644 --- a/tests/e2e/helpers/wp-env.ts +++ b/tests/e2e/helpers/wp-env.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; import { writeFileSync, existsSync, readFileSync, unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -11,7 +12,10 @@ export const WP_MCP_USER = process.env.WP_E2E_MCP_USER ?? 'claudaborative-e2e'; export const WP_MCP_PASSWORD = process.env.WP_E2E_MCP_PASSWORD ?? 'claudaborative-e2e-pass'; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); -const STATE_FILE = path.join(tmpdir(), 'claudaborative-editing-e2e-wp-env.json'); +// Include a hash of the repo root to avoid collisions between concurrent +// e2e runs from different checkouts on the same machine. +const repoHash = createHash('md5').update(REPO_ROOT).digest('hex').slice(0, 8); +const STATE_FILE = path.join(tmpdir(), `claudaborative-editing-e2e-wp-env-${repoHash}.json`); function runCommand(command: string, args: string[], inheritOutput: boolean = false): string { return execFileSync(command, args, { diff --git a/tests/unit/api-client.test.ts b/tests/unit/api-client.test.ts index b737921..a100e34 100644 --- a/tests/unit/api-client.test.ts +++ b/tests/unit/api-client.test.ts @@ -125,55 +125,29 @@ describe('WordPressApiClient', () => { }); }); - describe('checkMinimumVersion', () => { - it('returns version string when >= 7.0', async () => { + describe('getWordPressVersion', () => { + it('returns version string', async () => { fetchMock.mockResolvedValue(mockResponse({ version: '7.0' })); const client = createClient(); - const version = await client.checkMinimumVersion(); - expect(version).toBe('7.0'); - }); - - it('accepts higher versions', async () => { - fetchMock.mockResolvedValue(mockResponse({ version: '7.2.1' })); - const client = createClient(); - const version = await client.checkMinimumVersion(); - expect(version).toBe('7.2.1'); - }); - - it('throws for versions below 7.0', async () => { - fetchMock.mockResolvedValue(mockResponse({ version: '6.9.2' })); - const client = createClient(); - await expect(client.checkMinimumVersion()).rejects.toThrow( - /WordPress 7\.0 or later is required/, - ); + expect(await client.getWordPressVersion()).toBe('7.0'); }); it('returns unknown when endpoint is unavailable', async () => { fetchMock.mockRejectedValue(new Error('fetch failed')); const client = createClient(); - const version = await client.checkMinimumVersion(); - expect(version).toBe('unknown'); + expect(await client.getWordPressVersion()).toBe('unknown'); }); it('returns unknown when version field is missing', async () => { fetchMock.mockResolvedValue(mockResponse({})); const client = createClient(); - const version = await client.checkMinimumVersion(); - expect(version).toBe('unknown'); + expect(await client.getWordPressVersion()).toBe('unknown'); }); it('returns unknown when version field is empty', async () => { fetchMock.mockResolvedValue(mockResponse({ version: '' })); const client = createClient(); - const version = await client.checkMinimumVersion(); - expect(version).toBe('unknown'); - }); - - it('returns raw version when parsing fails', async () => { - fetchMock.mockResolvedValue(mockResponse({ version: 'alpha' })); - const client = createClient(); - const version = await client.checkMinimumVersion(); - expect(version).toBe('alpha'); + expect(await client.getWordPressVersion()).toBe('unknown'); }); }); diff --git a/tests/unit/cli/setup.test.ts b/tests/unit/cli/setup.test.ts index ec0d134..3dd55f5 100644 --- a/tests/unit/cli/setup.test.ts +++ b/tests/unit/cli/setup.test.ts @@ -260,12 +260,18 @@ describe('setup wizard', () => { expect(errors.join('\n')).toContain('Authentication failed'); }); - it('exits with error when WordPress version is too old', async () => { + it('includes version in error when old WP lacks sync endpoint', async () => { fetchMock .mockResolvedValueOnce( mockResponse({ id: 1, name: 'admin', slug: 'admin', avatar_urls: {} }), ) - .mockResolvedValueOnce(mockResponse({ version: '6.7' })); + .mockResolvedValueOnce(mockResponse({ version: '6.7' })) + .mockResolvedValueOnce( + mockResponse( + { code: 'rest_no_route', message: 'No route' }, + { status: 404, statusText: 'Not Found' }, + ), + ); const { deps, errors } = createTestDeps(['https://example.com', 'admin', 'xxxx xxxx xxxx'], { detectClients: () => defaultClientList(), @@ -273,7 +279,9 @@ describe('setup wizard', () => { }); await expect(runSetup(deps, { manual: true })).rejects.toThrow(SetupExitError); - expect(errors.join('\n')).toContain('WordPress 7.0 or later is required'); + const errorText = errors.join('\n'); + expect(errorText).toContain('Collaborative editing is not available'); + expect(errorText).toContain('6.7'); }); it('exits with error when sync endpoint returns 404', async () => { @@ -295,7 +303,7 @@ describe('setup wizard', () => { }); await expect(runSetup(deps, { manual: true })).rejects.toThrow(SetupExitError); - expect(errors.join('\n')).toContain('Collaborative editing is not enabled'); + expect(errors.join('\n')).toContain('Collaborative editing is not available'); }); it('exits with error when site URL is empty', async () => { diff --git a/tests/unit/session-manager.test.ts b/tests/unit/session-manager.test.ts index f735363..9db8fb3 100644 --- a/tests/unit/session-manager.test.ts +++ b/tests/unit/session-manager.test.ts @@ -15,7 +15,7 @@ const mockUploadMedia = vi.fn(); const mockListTerms = vi.fn<() => Promise>(); const mockSearchTerms = vi.fn<() => Promise>(); const mockCreateTerm = vi.fn<() => Promise>(); -const mockCheckMinimumVersion = vi.fn<() => Promise>(); +const mockGetWordPressVersion = vi.fn<() => Promise>(); const mockCheckNotesSupport = vi.fn<() => Promise>(); const mockListNotes = vi.fn<() => Promise>(); const mockCreateNote = vi.fn<() => Promise>(); @@ -37,7 +37,7 @@ vi.mock('../../src/wordpress/api-client.js', () => { this.listTerms = mockListTerms; this.searchTerms = mockSearchTerms; this.createTerm = mockCreateTerm; - this.checkMinimumVersion = mockCheckMinimumVersion; + this.getWordPressVersion = mockGetWordPressVersion; this.checkNotesSupport = mockCheckNotesSupport; this.listNotes = mockListNotes; this.createNote = mockCreateNote; @@ -119,7 +119,6 @@ const fakePost: WPPost = { async function connectSession(session: SessionManager): Promise { mockValidateConnection.mockResolvedValue(fakeUser); - mockCheckMinimumVersion.mockResolvedValue('7.0'); mockValidateSyncEndpoint.mockResolvedValue(undefined); await session.connect(fakeConfig); } @@ -145,7 +144,7 @@ describe('SessionManager', () => { queueSize: 0, }); mockGetBlockTypes.mockRejectedValue(new Error('Not available')); - mockCheckMinimumVersion.mockResolvedValue('7.0'); + mockGetWordPressVersion.mockResolvedValue('7.0'); mockCheckNotesSupport.mockResolvedValue(false); session = new SessionManager(); session.syncWaitTimeout = 0; // Skip sync wait in tests From c544100e1dca0cee7613a6a4d5b54e90ad525968 Mon Sep 17 00:00:00 2001 From: Gary Pendergast Date: Sat, 28 Mar 2026 11:12:10 +1100 Subject: [PATCH 05/10] fix: Enable parallel e2e test execution Replace wp-cli commands (npx wp-env run cli) with REST API calls for post creation, deletion, and app password creation. This eliminates concurrent wp-cli execution issues that caused "Environment not initialized" errors when running tests in parallel. Also always run wp-env start in global setup (idempotent) to ensure wp-env's internal state file is valid regardless of how the environment was started. --- playwright.config.ts | 4 +- tests/e2e/block-sync.spec.ts | 36 +++++----- tests/e2e/helpers/wp-env.ts | 128 +++++++++++++++-------------------- 3 files changed, 73 insertions(+), 95 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index b04b66d..18334ff 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,8 +8,8 @@ export default defineConfig({ expect: { timeout: 30_000, }, - fullyParallel: false, - workers: 1, + fullyParallel: true, + workers: 2, reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', outputDir: 'test-results/playwright', use: { diff --git a/tests/e2e/block-sync.spec.ts b/tests/e2e/block-sync.spec.ts index 3981e2a..e88e358 100644 --- a/tests/e2e/block-sync.spec.ts +++ b/tests/e2e/block-sync.spec.ts @@ -90,8 +90,8 @@ test.describe('block sync', () => { test('MCP inserts blocks visible in browser', async ({ page }) => { test.setTimeout(120_000); - const postId = createDraftPost('E2E block-sync insert', HEADING_CONTENT); - const appPassword = createAppPassword(`e2e-ins-${Date.now()}`); + const postId = await createDraftPost('E2E block-sync insert', HEADING_CONTENT); + const appPassword = await createAppPassword(`e2e-ins-${Date.now()}`); const { client, close, stderr } = await createMcpTestClient(); try { @@ -176,15 +176,15 @@ test.describe('block sync', () => { ); } finally { await close(); - deletePost(postId); + await deletePost(postId); } }); test('MCP edits block content visible in browser', async ({ page }) => { test.setTimeout(120_000); - const postId = createDraftPost('E2E block-sync edit', HEADING_CONTENT); - const appPassword = createAppPassword(`e2e-edit-${Date.now()}`); + const postId = await createDraftPost('E2E block-sync edit', HEADING_CONTENT); + const appPassword = await createAppPassword(`e2e-edit-${Date.now()}`); const { client, close, stderr } = await createMcpTestClient(); try { @@ -248,15 +248,15 @@ test.describe('block sync', () => { ); } finally { await close(); - deletePost(postId); + await deletePost(postId); } }); test('MCP changes block attributes visible in browser', async ({ page }) => { test.setTimeout(120_000); - const postId = createDraftPost('E2E block-sync attrs', HEADING_CONTENT); - const appPassword = createAppPassword(`e2e-attr-${Date.now()}`); + const postId = await createDraftPost('E2E block-sync attrs', HEADING_CONTENT); + const appPassword = await createAppPassword(`e2e-attr-${Date.now()}`); const { client, close, stderr } = await createMcpTestClient(); try { @@ -319,15 +319,15 @@ test.describe('block sync', () => { ); } finally { await close(); - deletePost(postId); + await deletePost(postId); } }); test('MCP removes blocks visible in browser', async ({ page }) => { test.setTimeout(120_000); - const postId = createDraftPost('E2E block-sync remove', TWO_PARAGRAPHS); - const appPassword = createAppPassword(`e2e-rm-${Date.now()}`); + const postId = await createDraftPost('E2E block-sync remove', TWO_PARAGRAPHS); + const appPassword = await createAppPassword(`e2e-rm-${Date.now()}`); const { client, close, stderr } = await createMcpTestClient(); try { @@ -413,7 +413,7 @@ test.describe('block sync', () => { ); } finally { await close(); - deletePost(postId); + await deletePost(postId); } }); @@ -422,8 +422,8 @@ test.describe('block sync', () => { const initialContent = '

Browser editable paragraph

'; - const postId = createDraftPost('E2E block-sync browser-to-mcp', initialContent); - const appPassword = createAppPassword(`e2e-b2m-${Date.now()}`); + const postId = await createDraftPost('E2E block-sync browser-to-mcp', initialContent); + const appPassword = await createAppPassword(`e2e-b2m-${Date.now()}`); const { client, close, stderr } = await createMcpTestClient(); try { @@ -504,15 +504,15 @@ test.describe('block sync', () => { ); } finally { await close(); - deletePost(postId); + await deletePost(postId); } }); test('multiple sequential edits in a single session', async ({ page }) => { test.setTimeout(180_000); - const postId = createDraftPost('E2E block-sync workflow', HEADING_CONTENT); - const appPassword = createAppPassword(`e2e-wf-${Date.now()}`); + const postId = await createDraftPost('E2E block-sync workflow', HEADING_CONTENT); + const appPassword = await createAppPassword(`e2e-wf-${Date.now()}`); const { client, close, stderr } = await createMcpTestClient(); try { @@ -619,7 +619,7 @@ test.describe('block sync', () => { ); } finally { await close(); - deletePost(postId); + await deletePost(postId); } }); }); diff --git a/tests/e2e/helpers/wp-env.ts b/tests/e2e/helpers/wp-env.ts index 5296839..0d23eb9 100644 --- a/tests/e2e/helpers/wp-env.ts +++ b/tests/e2e/helpers/wp-env.ts @@ -8,8 +8,6 @@ import { fileURLToPath } from 'node:url'; export const WP_BASE_URL = process.env.WP_BASE_URL ?? 'http://localhost:8888'; export const WP_ADMIN_USER = process.env.WP_E2E_ADMIN_USER ?? 'admin'; export const WP_ADMIN_PASSWORD = process.env.WP_E2E_ADMIN_PASSWORD ?? 'password'; -export const WP_MCP_USER = process.env.WP_E2E_MCP_USER ?? 'claudaborative-e2e'; -export const WP_MCP_PASSWORD = process.env.WP_E2E_MCP_PASSWORD ?? 'claudaborative-e2e-pass'; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); // Include a hash of the repo root to avoid collisions between concurrent @@ -17,26 +15,14 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '.. const repoHash = createHash('md5').update(REPO_ROOT).digest('hex').slice(0, 8); const STATE_FILE = path.join(tmpdir(), `claudaborative-editing-e2e-wp-env-${repoHash}.json`); -function runCommand(command: string, args: string[], inheritOutput: boolean = false): string { - return execFileSync(command, args, { +function runWpEnv(args: string[], inheritOutput: boolean = false): string { + return execFileSync('npx', ['wp-env', ...args], { cwd: REPO_ROOT, encoding: 'utf8', stdio: inheritOutput ? 'inherit' : ['ignore', 'pipe', 'pipe'], }); } -function runWpEnv(args: string[], inheritOutput: boolean = false): string { - return runCommand('npx', ['wp-env', ...args], inheritOutput); -} - -function tryRunWpEnv(args: string[]): string | null { - try { - return runWpEnv(args); - } catch { - return null; - } -} - export async function waitForWordPress(timeoutMs: number = 180_000): Promise { const deadline = Date.now() + timeoutMs; let lastError: unknown; @@ -59,6 +45,9 @@ export async function waitForWordPress(timeoutMs: number = 180_000): Promise { + // Always run wp-env start — it's idempotent (reconnects to existing + // containers) and ensures wp-env's internal state file is valid. + // Without this, `wp-env run cli` fails with "Environment not initialized". const alreadyRunning = await (async () => { try { const response = await fetch(`${WP_BASE_URL}/wp-login.php`, { redirect: 'manual' }); @@ -68,8 +57,9 @@ export async function ensureWpEnvRunning(): Promise { } })(); + runWpEnv(['start'], true); + if (!alreadyRunning) { - runWpEnv(['start'], true); writeFileSync(STATE_FILE, JSON.stringify({ startedBySuite: true }), 'utf8'); } @@ -89,71 +79,59 @@ export function teardownWpEnv(): void { unlinkSync(STATE_FILE); } -export function createAppPassword(label: string): string { - return runWpEnv([ - 'run', - 'cli', - 'wp', - 'user', - 'application-password', - 'create', - WP_ADMIN_USER, - label, - '--porcelain', - ]).trim(); +// --------------------------------------------------------------------------- +// REST API helpers — safe for concurrent test execution (no wp-cli needed) +// --------------------------------------------------------------------------- + +function basicAuth(): string { + return 'Basic ' + Buffer.from(`${WP_ADMIN_USER}:${WP_ADMIN_PASSWORD}`).toString('base64'); } -export function ensureEditorUserExists(): void { - const existing = tryRunWpEnv(['run', 'cli', 'wp', 'user', 'get', WP_MCP_USER, '--field=ID']); - if (existing) { - return; +async function apiFetch(endpoint: string, options: RequestInit = {}): Promise { + const url = `${WP_BASE_URL}/wp-json${endpoint}`; + const headers = new Headers({ + 'Content-Type': 'application/json', + Authorization: basicAuth(), + }); + if (options.headers) { + new Headers(options.headers).forEach((v, k) => { + headers.set(k, v); + }); } - - runWpEnv([ - 'run', - 'cli', - 'wp', - 'user', - 'create', - WP_MCP_USER, - `${WP_MCP_USER}@example.com`, - '--role=editor', - `--user_pass=${WP_MCP_PASSWORD}`, - '--display_name=E2E MCP Editor', - ]); + const response = await fetch(url, { ...options, headers }); + if (!response.ok) { + const text = await response.text(); + throw new Error( + `API ${options.method ?? 'GET'} ${endpoint} failed (${response.status}): ${text}`, + ); + } + return (await response.json()) as T; } -export function createAppPasswordForUser(username: string, label: string): string { - return runWpEnv([ - 'run', - 'cli', - 'wp', - 'user', - 'application-password', - 'create', - username, - label, - '--porcelain', - ]).trim(); +export async function createDraftPost(title: string, content: string): Promise { + const post = await apiFetch<{ id: number }>('/wp/v2/posts', { + method: 'POST', + body: JSON.stringify({ + title, + content, + status: 'draft', + }), + }); + return post.id; } -export function createDraftPost(title: string, content: string): number { - const result = runWpEnv([ - 'run', - 'cli', - 'wp', - 'post', - 'create', - `--post_title=${title}`, - '--post_type=post', - '--post_status=draft', - `--post_content=${content}`, - '--porcelain', - ]).trim(); - - return Number.parseInt(result, 10); +export async function deletePost(postId: number): Promise { + await apiFetch(`/wp/v2/posts/${postId}?force=true`, { + method: 'DELETE', + }); } -export function deletePost(postId: number): void { - runWpEnv(['run', 'cli', 'wp', 'post', 'delete', String(postId), '--force']); +export async function createAppPassword(label: string): Promise { + const result = await apiFetch<{ password: string }>(`/wp/v2/users/me/application-passwords`, { + method: 'POST', + body: JSON.stringify({ + name: label, + }), + }); + return result.password; } From a181406024a230ef548799fa04b184a2c1441621 Mon Sep 17 00:00:00 2001 From: Gary Pendergast Date: Sat, 28 Mar 2026 11:28:19 +1100 Subject: [PATCH 06/10] fix: Parallel e2e tests and CI workflow - Use shared app password (created once in global setup via wp-cli) for all REST API calls, eliminating per-test app password creation race conditions. - Replace wp-cli post CRUD with REST API calls, safe for concurrent execution across workers. - Revert unconditional wp-env start (afterStart script is too slow). - Add e2e job to CI workflow (Node 22, Playwright + wp-env). - Enable fullyParallel with 2 workers. --- .github/workflows/ci.yml | 17 ++++++++++ playwright.config.ts | 2 +- tests/e2e/block-sync.spec.ts | 14 ++++---- tests/e2e/helpers/wp-env.ts | 64 ++++++++++++++++++++++++++++++------ 4 files changed, 79 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f62059..189a38c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,3 +43,20 @@ jobs: ignore-patterns: | **/types.ts github-token: ${{ secrets.GITHUB_TOKEN }} + + e2e: + name: E2E (Node 22) + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e diff --git a/playwright.config.ts b/playwright.config.ts index 18334ff..5da1b79 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ timeout: 30_000, }, fullyParallel: true, - workers: 2, + workers: 4, reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', outputDir: 'test-results/playwright', use: { diff --git a/tests/e2e/block-sync.spec.ts b/tests/e2e/block-sync.spec.ts index e88e358..4c6fa5b 100644 --- a/tests/e2e/block-sync.spec.ts +++ b/tests/e2e/block-sync.spec.ts @@ -4,7 +4,7 @@ import { WP_ADMIN_PASSWORD, WP_ADMIN_USER, WP_BASE_URL, - createAppPassword, + getSharedAppPassword, createDraftPost, deletePost, } from './helpers/wp-env'; @@ -91,7 +91,7 @@ test.describe('block sync', () => { test.setTimeout(120_000); const postId = await createDraftPost('E2E block-sync insert', HEADING_CONTENT); - const appPassword = await createAppPassword(`e2e-ins-${Date.now()}`); + const appPassword = getSharedAppPassword(); const { client, close, stderr } = await createMcpTestClient(); try { @@ -184,7 +184,7 @@ test.describe('block sync', () => { test.setTimeout(120_000); const postId = await createDraftPost('E2E block-sync edit', HEADING_CONTENT); - const appPassword = await createAppPassword(`e2e-edit-${Date.now()}`); + const appPassword = getSharedAppPassword(); const { client, close, stderr } = await createMcpTestClient(); try { @@ -256,7 +256,7 @@ test.describe('block sync', () => { test.setTimeout(120_000); const postId = await createDraftPost('E2E block-sync attrs', HEADING_CONTENT); - const appPassword = await createAppPassword(`e2e-attr-${Date.now()}`); + const appPassword = getSharedAppPassword(); const { client, close, stderr } = await createMcpTestClient(); try { @@ -327,7 +327,7 @@ test.describe('block sync', () => { test.setTimeout(120_000); const postId = await createDraftPost('E2E block-sync remove', TWO_PARAGRAPHS); - const appPassword = await createAppPassword(`e2e-rm-${Date.now()}`); + const appPassword = getSharedAppPassword(); const { client, close, stderr } = await createMcpTestClient(); try { @@ -423,7 +423,7 @@ test.describe('block sync', () => { const initialContent = '

Browser editable paragraph

'; const postId = await createDraftPost('E2E block-sync browser-to-mcp', initialContent); - const appPassword = await createAppPassword(`e2e-b2m-${Date.now()}`); + const appPassword = getSharedAppPassword(); const { client, close, stderr } = await createMcpTestClient(); try { @@ -512,7 +512,7 @@ test.describe('block sync', () => { test.setTimeout(180_000); const postId = await createDraftPost('E2E block-sync workflow', HEADING_CONTENT); - const appPassword = await createAppPassword(`e2e-wf-${Date.now()}`); + const appPassword = getSharedAppPassword(); const { client, close, stderr } = await createMcpTestClient(); try { diff --git a/tests/e2e/helpers/wp-env.ts b/tests/e2e/helpers/wp-env.ts index 0d23eb9..3694001 100644 --- a/tests/e2e/helpers/wp-env.ts +++ b/tests/e2e/helpers/wp-env.ts @@ -44,10 +44,27 @@ export async function waitForWordPress(timeoutMs: number = 180_000): Promise { - // Always run wp-env start — it's idempotent (reconnects to existing - // containers) and ensures wp-env's internal state file is valid. - // Without this, `wp-env run cli` fails with "Environment not initialized". const alreadyRunning = await (async () => { try { const response = await fetch(`${WP_BASE_URL}/wp-login.php`, { redirect: 'manual' }); @@ -57,22 +74,48 @@ export async function ensureWpEnvRunning(): Promise { } })(); - runWpEnv(['start'], true); - if (!alreadyRunning) { - writeFileSync(STATE_FILE, JSON.stringify({ startedBySuite: true }), 'utf8'); + runWpEnv(['start'], true); } await waitForWordPress(); + + // Create a shared app password for REST API access (wp-cli runs once + // here in global setup, then all tests use the REST API). + const appPassword = runWpEnv([ + 'run', + 'cli', + 'wp', + 'user', + 'application-password', + 'create', + WP_ADMIN_USER, + `e2e-shared-${Date.now()}`, + '--porcelain', + ]).trim(); + + sharedAppPassword = appPassword; + + writeFileSync( + STATE_FILE, + JSON.stringify({ + startedBySuite: !alreadyRunning, + appPassword, + }), + 'utf8', + ); } export function teardownWpEnv(): void { - if (!existsSync(STATE_FILE) || process.env.CLAUDABORATIVE_E2E_REUSE_ENV === '1') { + if (!existsSync(STATE_FILE)) { return; } - const state = JSON.parse(readFileSync(STATE_FILE, 'utf8')) as { startedBySuite?: boolean }; - if (state.startedBySuite) { + const state = JSON.parse(readFileSync(STATE_FILE, 'utf8')) as { + startedBySuite?: boolean; + }; + + if (state.startedBySuite && process.env.CLAUDABORATIVE_E2E_REUSE_ENV !== '1') { runWpEnv(['stop'], true); } @@ -84,7 +127,8 @@ export function teardownWpEnv(): void { // --------------------------------------------------------------------------- function basicAuth(): string { - return 'Basic ' + Buffer.from(`${WP_ADMIN_USER}:${WP_ADMIN_PASSWORD}`).toString('base64'); + const password = getSharedAppPassword(); + return 'Basic ' + Buffer.from(`${WP_ADMIN_USER}:${password}`).toString('base64'); } async function apiFetch(endpoint: string, options: RequestInit = {}): Promise { From 133b112949943b218eac1d443047099a8b0bd751 Mon Sep 17 00:00:00 2001 From: Gary Pendergast Date: Sat, 28 Mar 2026 11:41:17 +1100 Subject: [PATCH 07/10] fix: Use shared auth session for parallel e2e tests Add a Playwright setup project that logs in once and saves storage state. All test workers reuse the saved session, eliminating concurrent login race conditions. Bumps workers to 4. --- playwright.config.ts | 6 ++++++ tests/e2e/auth.setup.ts | 16 ++++++++++++++++ tests/e2e/block-sync.spec.ts | 15 --------------- 3 files changed, 22 insertions(+), 15 deletions(-) create mode 100644 tests/e2e/auth.setup.ts diff --git a/playwright.config.ts b/playwright.config.ts index 5da1b79..d4a0022 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -22,11 +22,17 @@ export default defineConfig({ globalSetup: './tests/e2e/global-setup.ts', globalTeardown: './tests/e2e/global-teardown.ts', projects: [ + { + name: 'setup', + testMatch: /auth\.setup\.ts/, + }, { name: 'chromium', use: { ...devices['Desktop Chrome'], + storageState: 'test-results/playwright/.auth/admin.json', }, + dependencies: ['setup'], }, ], }); diff --git a/tests/e2e/auth.setup.ts b/tests/e2e/auth.setup.ts new file mode 100644 index 0000000..8068659 --- /dev/null +++ b/tests/e2e/auth.setup.ts @@ -0,0 +1,16 @@ +/** + * Playwright setup project: log in once and save storage state for all tests. + */ +import { test as setup } from '@playwright/test'; +import { WP_ADMIN_USER, WP_ADMIN_PASSWORD } from './helpers/wp-env'; + +const AUTH_STATE_PATH = 'test-results/playwright/.auth/admin.json'; + +setup('authenticate', async ({ page }) => { + await page.goto('/wp-login.php'); + await page.locator('#user_login').fill(WP_ADMIN_USER); + await page.locator('#user_pass').fill(WP_ADMIN_PASSWORD); + await page.locator('#wp-submit').click(); + await page.waitForURL(/\/wp-admin\/?/); + await page.context().storageState({ path: AUTH_STATE_PATH }); +}); diff --git a/tests/e2e/block-sync.spec.ts b/tests/e2e/block-sync.spec.ts index 4c6fa5b..e651654 100644 --- a/tests/e2e/block-sync.spec.ts +++ b/tests/e2e/block-sync.spec.ts @@ -1,7 +1,6 @@ import { test, expect, type Page } from '@playwright/test'; import { createMcpTestClient, callToolOrThrow, getToolText } from './helpers/mcp'; import { - WP_ADMIN_PASSWORD, WP_ADMIN_USER, WP_BASE_URL, getSharedAppPassword, @@ -25,14 +24,6 @@ interface EditorBlock { innerBlocks?: EditorBlock[]; } -async function loginToWordPress(page: Page): Promise { - await page.goto('/wp-login.php'); - await page.locator('#user_login').fill(WP_ADMIN_USER); - await page.locator('#user_pass').fill(WP_ADMIN_PASSWORD); - await page.locator('#wp-submit').click(); - await page.waitForURL(/\/wp-admin\/?/); -} - async function openEditor(page: Page, postId: number): Promise { await page.goto(`/wp-admin/post.php?post=${postId}&action=edit`); await expect @@ -95,7 +86,6 @@ test.describe('block sync', () => { const { client, close, stderr } = await createMcpTestClient(); try { - await loginToWordPress(page); await openEditor(page, postId); // Verify initial content is loaded in the browser @@ -188,7 +178,6 @@ test.describe('block sync', () => { const { client, close, stderr } = await createMcpTestClient(); try { - await loginToWordPress(page); await openEditor(page, postId); // Verify initial heading is present @@ -260,7 +249,6 @@ test.describe('block sync', () => { const { client, close, stderr } = await createMcpTestClient(); try { - await loginToWordPress(page); await openEditor(page, postId); // Verify initial heading is level 2 @@ -331,7 +319,6 @@ test.describe('block sync', () => { const { client, close, stderr } = await createMcpTestClient(); try { - await loginToWordPress(page); await openEditor(page, postId); // Verify initial two paragraphs are present @@ -427,7 +414,6 @@ test.describe('block sync', () => { const { client, close, stderr } = await createMcpTestClient(); try { - await loginToWordPress(page); await openEditor(page, postId); // Verify initial content in browser @@ -516,7 +502,6 @@ test.describe('block sync', () => { const { client, close, stderr } = await createMcpTestClient(); try { - await loginToWordPress(page); await openEditor(page, postId); await expect From 1ee9a0145cad2c5b97019ba69037e5eb75c43ab1 Mon Sep 17 00:00:00 2001 From: Gary Pendergast Date: Sat, 28 Mar 2026 11:57:29 +1100 Subject: [PATCH 08/10] ci: Use latest pre-built Gutenberg release for e2e tests Fetch the latest Gutenberg release ZIP via GitHub API at CI time instead of cloning and building trunk. This avoids the slow afterStart build step and the directory layout issues in CI. --- .github/workflows/ci.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 189a38c..a7ce02c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,4 +59,23 @@ jobs: - run: npm ci - run: npx playwright install --with-deps chromium + + - name: Use latest pre-built Gutenberg for CI + run: | + LATEST_URL=$(gh api repos/WordPress/gutenberg/releases/latest --jq '.assets[] | select(.name == "gutenberg.zip") | .browser_download_url') + cat > .wp-env.json < Date: Sat, 28 Mar 2026 12:15:07 +1100 Subject: [PATCH 09/10] fix: Address second round of Copilot review comments - CLAUDE.md: Update stale version check docs to match the current informational getWordPressVersion() approach. - session-manager.test.ts: Replace hacky `as []` cast with properly typed variadic mock signature for mockReadFile. --- CLAUDE.md | 2 +- tests/unit/session-manager.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2f431a2..c4d3226 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,7 @@ disconnected ──connect──→ connected ──openPost/createPost──→ ### Key Design Decisions - **Mixed V1/V2 encoding**: Gutenberg 22.8+ uses a mixed encoding approach. Sync step1/step2 use y-protocols' standard encoding (V1 internally — `syncProtocol.readSyncMessage` hardcodes `Y.encodeStateAsUpdate`/`Y.applyUpdate`). Regular updates and compactions use V2 encoding (`encodeStateAsUpdateV2`/`applyUpdateV2`, captured via `doc.on('updateV2')`). This split exists because Gutenberg switched updates/compactions to V2 (PR #76304) but still uses y-protocols for the sync handshake. Minimum compatible Gutenberg version: 22.8. -- **Minimum version check**: During `connect()` and setup, `checkMinimumVersion()` fetches `GET /wp-json/` and verifies WordPress >= 7.0. If the version is below 7.0, an error is thrown with a message suggesting Gutenberg plugin 22.8+ as an alternative. If the REST API root is unavailable or the version field is missing, the check is silently skipped (non-blocking). +- **Version discovery**: During setup, `getWordPressVersion()` fetches `GET /wp-json/` to display the site's WordPress version. This is informational only — actual compatibility is gated by the sync endpoint check (`validateSyncEndpoint()`). If the sync endpoint is unavailable and the version is known, the error message includes the detected version and suggests upgrading to WordPress 7.0+ or Gutenberg plugin 22.8+. - **yjs pinned to 13.6.29**: Must match the version Gutenberg uses. Different versions can produce incompatible binary updates. - **Rich-text attributes**: Block attributes whose type is `rich-text` in the block schema (e.g., `core/paragraph` `content`) are stored as `Y.Text` in the Y.Doc. Other attributes are plain values. Rich-text detection is handled by `BlockTypeRegistry` in `src/yjs/block-type-registry.ts`. - **Room format**: `postType/{type}:{id}` (e.g., `postType/post:123`) diff --git a/tests/unit/session-manager.test.ts b/tests/unit/session-manager.test.ts index 9db8fb3..bc299d1 100644 --- a/tests/unit/session-manager.test.ts +++ b/tests/unit/session-manager.test.ts @@ -48,9 +48,9 @@ vi.mock('../../src/wordpress/api-client.js', () => { }); // --- Mock node:fs/promises for uploadMedia tests --- -const mockReadFile = vi.fn<() => Promise>(); +const mockReadFile = vi.fn<(...args: unknown[]) => Promise>(); vi.mock('node:fs/promises', () => ({ - readFile: (...args: unknown[]) => mockReadFile(...(args as [])), + readFile: (...args: unknown[]) => mockReadFile(...args), })); // --- Mock the sync client --- From f34ad643f0e1a8adbc657b8d5d579b389bb629b6 Mon Sep 17 00:00:00 2001 From: Gary Pendergast Date: Sat, 28 Mar 2026 12:25:35 +1100 Subject: [PATCH 10/10] fix: Reuse app password across e2e runs and preserve state on reuse - ensureWpEnvRunning() checks state file for existing app password before creating a new one, preventing accumulation. - teardownWpEnv() preserves state file when CLAUDABORATIVE_E2E_REUSE_ENV=1 so the next run can reuse the password. --- tests/e2e/helpers/wp-env.ts | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/tests/e2e/helpers/wp-env.ts b/tests/e2e/helpers/wp-env.ts index 3694001..2b0afeb 100644 --- a/tests/e2e/helpers/wp-env.ts +++ b/tests/e2e/helpers/wp-env.ts @@ -80,6 +80,21 @@ export async function ensureWpEnvRunning(): Promise { await waitForWordPress(); + // Reuse an existing app password from a previous run if the state file + // exists (avoids accumulating passwords across repeated runs). + if (existsSync(STATE_FILE)) { + const prev = JSON.parse(readFileSync(STATE_FILE, 'utf8')) as { appPassword?: string }; + if (prev.appPassword) { + sharedAppPassword = prev.appPassword; + writeFileSync( + STATE_FILE, + JSON.stringify({ startedBySuite: !alreadyRunning, appPassword: sharedAppPassword }), + 'utf8', + ); + return; + } + } + // Create a shared app password for REST API access (wp-cli runs once // here in global setup, then all tests use the REST API). const appPassword = runWpEnv([ @@ -98,10 +113,7 @@ export async function ensureWpEnvRunning(): Promise { writeFileSync( STATE_FILE, - JSON.stringify({ - startedBySuite: !alreadyRunning, - appPassword, - }), + JSON.stringify({ startedBySuite: !alreadyRunning, appPassword }), 'utf8', ); } @@ -111,11 +123,17 @@ export function teardownWpEnv(): void { return; } + // When reuse is enabled, keep the state file so the next run can + // reuse the app password without creating a new one. + if (process.env.CLAUDABORATIVE_E2E_REUSE_ENV === '1') { + return; + } + const state = JSON.parse(readFileSync(STATE_FILE, 'utf8')) as { startedBySuite?: boolean; }; - if (state.startedBySuite && process.env.CLAUDABORATIVE_E2E_REUSE_ENV !== '1') { + if (state.startedBySuite) { runWpEnv(['stop'], true); }