test(e2e): add editor/collab persistence + convergence coverage (TASK-2058) (#920)

The editor/collab path — the subsystem with the strictest cross-component
invariants (Yjs schema-version handshake, WebSocket op-log round-trip) —
had no e2e coverage. Add two Playwright specs:

- persistence: type into the Tiptap editor, wait for the collab-snapshot
  flush that writes items.content, reload, assert the text survives. This
  drives the full chain: WS connect -> schema_version handshake -> op-log
  persist -> reload re-hydrate.
- convergence: two browser contexts open the same item; an edit in one
  appears live in the other via the relay, no reload.

Both authenticate the collab WebSocket via an in-page login (same-UA
session cookie) because a WS upgrade can't carry the suite's Bearer
header. Follow-up under TASK-733.

Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
This commit is contained in:
xarmian
2026-07-11 13:21:01 -04:00
committed by GitHub
parent fce3023bc5
commit 348c8a3d93
+203
View File
@@ -0,0 +1,203 @@
import { test, expect, type SuiteFixture } from './fixtures';
import { ADMIN_EMAIL, ADMIN_PASSWORD } from './global-setup';
import type { Browser, Page } from '@playwright/test';
/**
* Editor / collab persistence e2e (TASK-2058 under TASK-733).
*
* The editor path is the subsystem with the strictest cross-component
* invariants — the Yjs schema-version handshake and the WebSocket
* op-log round-trip — yet it had no e2e coverage. This spec drives the
* full chain through the real embedded UI:
*
* type in Tiptap → Y.Doc update → WS connect + schema_version
* handshake → op-log persist → collab-snapshot flush to items.content
* → reload re-hydrates from items.content
*
* If any layer of that chain regresses (a schema-version bump that
* doesn't match the server's DefaultSchemaVersion, a broken WS auth,
* an op-log flush that never lands), the reloaded editor won't carry
* the typed text and this goes red.
*
* Determinism notes:
* - We wait for the "Synced" collab badge before typing so the Y.Doc
* binding is live — typing into an unbound editor would be a
* false negative.
* - We wait for the actual collab-snapshot PATCH response (the 5s
* idle flush that writes items.content) rather than sleeping, so
* the reload assertion can't race the persistence write.
*
* Why we log in through the browser instead of reusing the fixture's
* Bearer token: the collab WebSocket handshake can't carry an
* `Authorization` header (browsers don't let JS set headers on a WS
* upgrade), so the server authenticates it from the session cookie.
* The suite's default token auth therefore can't reach the WS. We
* establish a same-browser session cookie via an in-page login — the
* cookie is minted under the browser's own User-Agent, so the
* UA-bound session survives the WS upgrade (unlike a node-minted
* session; see fixtures.ts).
*/
async function browserLogin(page: Page) {
// Land on a same-origin page first so the fetch + Set-Cookie land in
// the browser's cookie jar (and under the browser's UA).
await page.goto('/login');
const status = await page.evaluate(
async ({ email, password }) => {
const r = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
return r.status;
},
{ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }
);
if (status !== 200) throw new Error(`in-page login failed with status ${status}`);
}
const enc = (obj: Record<string, unknown>) => JSON.stringify(obj);
async function seedDoc(
fixture: SuiteFixture,
request: { post: (url: string, o: unknown) => Promise<{ ok(): boolean; status(): number; text(): Promise<string>; json(): Promise<unknown> }> }
): Promise<{ slug: string }> {
const ws = fixture.workspaceSlug;
const headers = { Authorization: `Bearer ${fixture.apiToken}`, 'Content-Type': 'application/json' };
// Empty content: the editor starts on a single empty paragraph, so
// the text we type becomes the item's entire body — a clean anchor
// for the post-reload assertion.
const resp = await request.post(`/api/v1/workspaces/${ws}/collections/docs/items`, {
headers,
data: { title: `Collab persistence ${Date.now()}`, fields: enc({}), content: '' }
});
if (!resp.ok()) throw new Error(`doc create failed (${resp.status()}): ${await resp.text()}`);
return (await resp.json()) as { slug: string };
}
test('typed editor content survives a reload via the collab op-log', async ({
page,
fixture,
request
}, testInfo) => {
// Viewport-agnostic: the collab chain is identical on both projects
// and running both in parallel doubles WS load on the single test
// instance for no added coverage. Pin to desktop.
test.skip(
testInfo.project.name !== 'desktop-chromium',
'collab persistence is viewport-agnostic; one project is enough'
);
// The 5s idle flush plus WS handshake needs more than the 30s default
// on a busy CI runner.
test.setTimeout(60_000);
const { slug } = await seedDoc(fixture, request);
const marker = `collab-persist-${Date.now()}`;
await browserLogin(page);
await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/docs/${slug}`);
// Editor mounts and the collab provider reaches "Synced" — this is
// the schema-version handshake completing against the server's
// DefaultSchemaVersion. Typing before this races the Y.Doc binding.
const editor = page.locator('.editor-content .ProseMirror');
await expect(editor).toBeVisible();
await expect(page.locator('.collab-state-synced')).toBeVisible();
// Register the flush wait BEFORE typing so we can't miss the response.
// The collab-snapshot PATCH is the write that persists Y.Doc state to
// items.content — the exact round-trip a reload reads back.
// The flush PATCHes /items/{id}?source=collab-snapshot (by item UUID,
// not slug) and its response echoes the persisted item, so we scope
// the wait to a collab-snapshot PATCH whose response body carries the
// marker. This pins the awaited event to the flush that wrote OUR
// typed text into items.content — an early empty-doc hydration flush
// (which dedupes to a no-op PATCH anyway) can't satisfy it, so the
// reload assertion below can't false-pass on op-log replay alone.
const flushed = page.waitForResponse(
async (r) =>
r.url().includes('source=collab-snapshot') &&
r.request().method() === 'PATCH' &&
r.ok() &&
(await r.text()).includes(marker),
{ timeout: 30_000 }
);
await editor.click();
await page.keyboard.type(marker);
await expect(editor).toContainText(marker);
// Proves items.content now holds the marker (the response is the
// persisted item). This is the storage round-trip, independent of
// what the op-log replay would recover.
await flushed;
// Reload: a fresh page load re-hydrates the Y.Doc from items.content
// (+ op-log replay). The typed text must come back or a layer of the
// persistence chain is broken.
await page.reload();
const reloaded = page.locator('.editor-content .ProseMirror');
await expect(reloaded).toBeVisible();
await expect(reloaded).toContainText(marker);
});
/**
* Two-client convergence: text typed in one browser context appears in
* a second context viewing the same item — no reload. This exercises
* the live relay path (Y.Doc update → server broadcast → peer apply)
* that the persistence test above doesn't: persistence proves the
* op-log survives a round-trip through storage; convergence proves the
* server fans an update out to a concurrently-connected peer.
*/
async function openSyncedEditor(
browser: Browser,
fixture: SuiteFixture,
path: string
): Promise<{ page: Page; close: () => Promise<void> }> {
// Each context has its own cookie jar, so it needs its own in-page
// login for the WS handshake. Carry the Bearer header too so ordinary
// HTTP navigation is authed (matches the suite fixture).
const context = await browser.newContext({
baseURL: fixture.baseURL,
extraHTTPHeaders: { Authorization: `Bearer ${fixture.apiToken}` }
});
const page = await context.newPage();
await browserLogin(page);
await page.goto(path);
await expect(page.locator('.editor-content .ProseMirror')).toBeVisible();
await expect(page.locator('.collab-state-synced')).toBeVisible();
return { page, close: () => context.close() };
}
test('an edit in one client converges to a second client viewing the same item', async ({
browser,
fixture,
request
}, testInfo) => {
test.skip(
testInfo.project.name !== 'desktop-chromium',
'collab convergence is viewport-agnostic; one project is enough'
);
test.setTimeout(60_000);
const { slug } = await seedDoc(fixture, request);
const path = `/${fixture.adminUsername}/${fixture.workspaceSlug}/docs/${slug}`;
const marker = `converge-${Date.now()}`;
const a = await openSyncedEditor(browser, fixture, path);
const b = await openSyncedEditor(browser, fixture, path);
try {
// A types; the relay must fan the op out to B's live Y.Doc without
// either page reloading.
const editorA = a.page.locator('.editor-content .ProseMirror');
await editorA.click();
await a.page.keyboard.type(marker);
const editorB = b.page.locator('.editor-content .ProseMirror');
await expect(editorB).toContainText(marker, { timeout: 15_000 });
} finally {
await a.close();
await b.close();
}
});