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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<WPENV
{
"\$schema": "https://schemas.wp.org/trunk/wp-env.json",
"core": "WordPress/WordPress#master",
Comment thread
pento marked this conversation as resolved.
"phpVersion": "8.5",
"testsEnvironment": false,
"plugins": ["$LATEST_URL"],
"lifecycleScripts": {
"afterStart": "npx wp-env run cli wp option update wp_collaboration_enabled 1"
}
}
WPENV
env:
GH_TOKEN: ${{ github.token }}

- run: npm run test:e2e
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ dist/
coverage/
*.tsbuildinfo
.gutenberg/
test-results/
.DS_Store
6 changes: 4 additions & 2 deletions .markdownlint-cli2.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
// 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/"]
}
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- **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`)
Expand Down
64 changes: 64 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand All @@ -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",
Expand Down
38 changes: 38 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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'],
},
],
});
10 changes: 7 additions & 3 deletions src/cli/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,16 +330,20 @@ 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');
} catch (err) {
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);
}
Expand Down
24 changes: 15 additions & 9 deletions src/session/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -458,7 +464,7 @@ export class SessionManager {
const done = () => {
if (!resolved) {
resolved = true;
doc.off('update', onDocUpdate);
doc.off('updateV2', onDocUpdate);
resolve();
}
};
Expand All @@ -474,7 +480,7 @@ export class SessionManager {
}
};

doc.on('update', onDocUpdate);
doc.on('updateV2', onDocUpdate);
Comment thread
pento marked this conversation as resolved.
});
}

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

Expand Down
23 changes: 23 additions & 0 deletions src/wordpress/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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
Expand Down
Loading