diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f62059..a7ce02c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,3 +43,39 @@ 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 + + - 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 <=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..d4a0022 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,38 @@ +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: true, + workers: 4, + 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: 'setup', + testMatch: /auth\.setup\.ts/, + }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'test-results/playwright/.auth/admin.json', + }, + dependencies: ['setup'], + }, + ], +}); diff --git a/src/cli/setup.ts b/src/cli/setup.ts index fb3d902..f1021a5 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -330,6 +330,9 @@ async function validateCredentials(deps: SetupDeps, credentials: WpCredentials): deps.exit(1); } + const wpVersion = await client.getWordPressVersion(); + deps.log(` WordPress version: ${wpVersion}`); + try { await client.validateSyncEndpoint(); deps.log(' ✓ Collaborative editing endpoint available'); @@ -337,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 f4e5449..bc74cd6 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,7 +348,7 @@ export class SessionManager { const user = await this.apiClient.validateConnection(); this._user = user; - // 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 @@ -458,7 +464,7 @@ export class SessionManager { const done = () => { if (!resolved) { resolved = true; - doc.off('update', onDocUpdate); + doc.off('updateV2', onDocUpdate); resolve(); } }; @@ -474,7 +480,7 @@ export class SessionManager { } }; - doc.on('update', onDocUpdate); + doc.on('updateV2', onDocUpdate); }); } @@ -536,7 +542,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 +563,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 +618,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..bc1b1c9 100644 --- a/src/wordpress/api-client.ts +++ b/src/wordpress/api-client.ts @@ -42,6 +42,29 @@ export class WordPressApiClient { await this.sendSyncUpdate({ rooms: [] }); } + /** + * Fetch the WordPress version from the REST API root. + * + * 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 getWordPressVersion(): Promise { + let data: { version?: string }; + try { + data = await this.apiFetch<{ version?: string }>('/'); + } catch { + return 'unknown'; + } + + const version = data.version; + if (typeof version !== 'string' || version.trim() === '') { + return 'unknown'; + } + + 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/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 new file mode 100644 index 0000000..e651654 --- /dev/null +++ b/tests/e2e/block-sync.spec.ts @@ -0,0 +1,610 @@ +import { test, expect, type Page } from '@playwright/test'; +import { createMcpTestClient, callToolOrThrow, getToolText } from './helpers/mcp'; +import { + WP_ADMIN_USER, + WP_BASE_URL, + getSharedAppPassword, + 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 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 = await createDraftPost('E2E block-sync insert', HEADING_CONTENT); + const appPassword = getSharedAppPassword(); + const { client, close, stderr } = await createMcpTestClient(); + + try { + 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(); + await deletePost(postId); + } + }); + + test('MCP edits block content visible in browser', async ({ page }) => { + test.setTimeout(120_000); + + const postId = await createDraftPost('E2E block-sync edit', HEADING_CONTENT); + const appPassword = getSharedAppPassword(); + const { client, close, stderr } = await createMcpTestClient(); + + try { + 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(); + await deletePost(postId); + } + }); + + test('MCP changes block attributes visible in browser', async ({ page }) => { + test.setTimeout(120_000); + + const postId = await createDraftPost('E2E block-sync attrs', HEADING_CONTENT); + const appPassword = getSharedAppPassword(); + const { client, close, stderr } = await createMcpTestClient(); + + try { + 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(); + await deletePost(postId); + } + }); + + test('MCP removes blocks visible in browser', async ({ page }) => { + test.setTimeout(120_000); + + const postId = await createDraftPost('E2E block-sync remove', TWO_PARAGRAPHS); + const appPassword = getSharedAppPassword(); + const { client, close, stderr } = await createMcpTestClient(); + + try { + 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(); + await deletePost(postId); + } + }); + + test('browser edits visible in MCP', async ({ page }) => { + test.setTimeout(120_000); + + const initialContent = + '

Browser editable paragraph

'; + const postId = await createDraftPost('E2E block-sync browser-to-mcp', initialContent); + const appPassword = getSharedAppPassword(); + const { client, close, stderr } = await createMcpTestClient(); + + try { + 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(); + await deletePost(postId); + } + }); + + test('multiple sequential edits in a single session', async ({ page }) => { + test.setTimeout(180_000); + + const postId = await createDraftPost('E2E block-sync workflow', HEADING_CONTENT); + const appPassword = getSharedAppPassword(); + const { client, close, stderr } = await createMcpTestClient(); + + try { + 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(); + await 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..2b0afeb --- /dev/null +++ b/tests/e2e/helpers/wp-env.ts @@ -0,0 +1,199 @@ +import { createHash } from 'node:crypto'; +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'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +// 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 runWpEnv(args: string[], inheritOutput: boolean = false): string { + return execFileSync('npx', ['wp-env', ...args], { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: inheritOutput ? 'inherit' : ['ignore', 'pipe', 'pipe'], + }); +} + +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)}`); +} + +/** App password created during global setup, shared by all tests. */ +let sharedAppPassword: string | null = null; + +export function getSharedAppPassword(): string { + if (!sharedAppPassword) { + // Read from state file (workers don't share memory with global setup) + if (existsSync(STATE_FILE)) { + const state = JSON.parse(readFileSync(STATE_FILE, 'utf8')) as { + appPassword?: string; + }; + if (state.appPassword) { + sharedAppPassword = state.appPassword; + return sharedAppPassword; + } + } + throw new Error('No shared app password available. Did global setup run?'); + } + return sharedAppPassword; +} + +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); + } + + 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([ + '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)) { + 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) { + runWpEnv(['stop'], true); + } + + unlinkSync(STATE_FILE); +} + +// --------------------------------------------------------------------------- +// REST API helpers — safe for concurrent test execution (no wp-cli needed) +// --------------------------------------------------------------------------- + +function basicAuth(): string { + const password = getSharedAppPassword(); + return 'Basic ' + Buffer.from(`${WP_ADMIN_USER}:${password}`).toString('base64'); +} + +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); + }); + } + 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 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 async function deletePost(postId: number): Promise { + await apiFetch(`/wp/v2/posts/${postId}?force=true`, { + method: 'DELETE', + }); +} + +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; +} 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/api-client.test.ts b/tests/unit/api-client.test.ts index 3218dfe..a100e34 100644 --- a/tests/unit/api-client.test.ts +++ b/tests/unit/api-client.test.ts @@ -125,6 +125,32 @@ describe('WordPressApiClient', () => { }); }); + describe('getWordPressVersion', () => { + it('returns version string', async () => { + fetchMock.mockResolvedValue(mockResponse({ version: '7.0' })); + const client = createClient(); + expect(await client.getWordPressVersion()).toBe('7.0'); + }); + + it('returns unknown when endpoint is unavailable', async () => { + fetchMock.mockRejectedValue(new Error('fetch failed')); + const client = createClient(); + expect(await client.getWordPressVersion()).toBe('unknown'); + }); + + it('returns unknown when version field is missing', async () => { + fetchMock.mockResolvedValue(mockResponse({})); + const client = createClient(); + expect(await client.getWordPressVersion()).toBe('unknown'); + }); + + it('returns unknown when version field is empty', async () => { + fetchMock.mockResolvedValue(mockResponse({ version: '' })); + const client = createClient(); + expect(await client.getWordPressVersion()).toBe('unknown'); + }); + }); + describe('getCurrentUser', () => { it('fetches /wp/v2/users/me', async () => { fetchMock.mockResolvedValue(mockResponse(fakeUser)); 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..3dd55f5 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: [] })); } @@ -259,11 +260,36 @@ describe('setup wizard', () => { expect(errors.join('\n')).toContain('Authentication failed'); }); + 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( + { 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(), + hasConfig: () => false, + }); + + await expect(runSetup(deps, { manual: true })).rejects.toThrow(SetupExitError); + 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 () => { fetchMock .mockResolvedValueOnce( mockResponse({ id: 1, name: 'admin', slug: 'admin', avatar_urls: {} }), ) + .mockResolvedValueOnce(mockResponse({ version: '7.0' })) .mockResolvedValueOnce( mockResponse( { code: 'rest_no_route', message: 'No route' }, @@ -277,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 () => { @@ -324,6 +350,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..bc299d1 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 mockGetWordPressVersion = 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.getWordPressVersion = mockGetWordPressVersion; this.checkNotesSupport = mockCheckNotesSupport; this.listNotes = mockListNotes; this.createNote = mockCreateNote; @@ -46,7 +48,7 @@ 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), })); @@ -142,6 +144,7 @@ describe('SessionManager', () => { queueSize: 0, }); mockGetBlockTypes.mockRejectedValue(new Error('Not available')); + mockGetWordPressVersion.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"] }