Files
pad/web/e2e/plan-3578-patch-open-editor.spec.ts
T
xarmian bc3e23d55a feat(collab): a patch to an item open in an editor applies as a narrowed replace (PLAN-3578 step 2) (#2056)
* feat(collab): a patch to an item open in an editor applies as a narrowed replace (PLAN-3578 step 2)

A content-anchored patch to an item a browser editor holds now goes to a
patch-capable tab (one that connected with ?patch_capable=1; an older tab
would read an edits-only request as an empty body). The tab applies it to
its live document as a replace of exactly the top-level blocks it changes,
aligned under the ruled equivalence (equal, or differing only in a link's
title or bold/italic on whitespace; a trailing empty paragraph ignored), so
it merges with typing and leaves cursors in place. It acks with the
canonical before/after; the server records the version with ExternalContent
and answers with the diff and content_state applied_pending_flush. A block
with no equivalent refuses patch_unstable_roundtrip; refusals are counted
in pad_patch_refusals_total{reason}. MCP 0.88 (behavior).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(collab): a room with only a non-capable tab is never sent a patch (PLAN-3578 step 2)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 07:45:12 -04:00

56 lines
2.7 KiB
TypeScript

import { test, expect } from './fixtures';
import { browserLogin, EDITOR_SELECTOR, SYNCED_BADGE_SELECTOR, SYNC_TIMEOUT, seedDoc } from './lib/collab-helpers';
import { authJson } from './lib/attachment-viewer';
/**
* PLAN-3578 step 2 (and TASK-3580's demo): an agent's patch to an item open in
* the browser lands as a narrowed replace of the live document. The typist's
* caret stays in the paragraph it was in, and what they type next lands
* there, not at the start or end of a document a whole-body replace rebuilt.
*/
test('an agent patch lands in the open editor without moving the caret', async ({ page, fixture, request }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'one project is enough for the applier path');
test.setTimeout(90_000);
const doc = await seedDoc(fixture, request, 'P3578 patch');
const base = `/api/v1/workspaces/${fixture.workspaceSlug}/items/${doc.slug}`;
const seeded = await request.patch(base, {
headers: authJson(fixture),
data: { content: 'alpha paragraph\n\nbeta paragraph\n\ngamma paragraph', replace: true },
});
expect(seeded.ok(), await seeded.text()).toBeTruthy();
await browserLogin(page);
await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/docs?item=${doc.slug}`);
await expect(page.locator(SYNCED_BADGE_SELECTOR)).toBeVisible({ timeout: SYNC_TIMEOUT });
const editor = page.locator(EDITOR_SELECTOR);
await expect(editor).toContainText('gamma paragraph');
// Caret after "gamma" in the third paragraph.
await editor.locator('p', { hasText: 'gamma paragraph' }).click();
await page.evaluate(() => {
const p = Array.from(document.querySelectorAll('.editor-content .ProseMirror p')).find((n) => n.textContent === 'gamma paragraph')!;
const r = document.createRange();
r.setStart(p.firstChild!, 'gamma'.length);
r.collapse(true);
const s = window.getSelection()!;
s.removeAllRanges();
s.addRange(r);
});
// The agent patches the FIRST paragraph while the caret sits in the third.
const resp = await request.post(`${base}/patch`, {
headers: authJson(fixture),
data: { edits: [{ old: 'alpha paragraph', new: 'ALPHA paragraph, patched' }] },
});
expect(resp.ok(), await resp.text()).toBeTruthy();
const out = (await resp.json()) as { content_state?: string; diff: string };
expect(out.content_state).toBe('applied_pending_flush');
expect(out.diff).toContain('+ALPHA paragraph, patched');
await expect(editor).toContainText('ALPHA paragraph, patched', { timeout: SYNC_TIMEOUT });
// The caret is still in "gamma": typing lands there.
await page.keyboard.type('X');
await expect(editor.locator('p', { hasText: 'gammaX paragraph' })).toBeVisible();
await expect(editor.locator('p').first()).toHaveText('ALPHA paragraph, patched');
});