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
124 changes: 124 additions & 0 deletions apps/cli/scripts/README-sd-3214.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# SD-3214 — Manual End-to-End Reproduction

Three scenarios that exercise the headless SDK's comment-sync pipeline through real Yjs primitives. Runs in one Node process; no Liveblocks/Hocuspocus required.

## 1. Run the repro (fix on)

From the worktree root:

```bash
NODE_ENV=test bun apps/cli/scripts/repro-sd-3214.ts
```

Expected output (with the fix applied):

```
=== Scenario 1: READ — browser → agent metadata propagation ===
agent.comments.list() returned 1 item(s):
{
id: "<uuid>",
text: "Please review this clause.",
creatorName: "Browser User",
creatorEmail: "browser@example.com",
createdTime: 1779...,
target: "present",
}
READ ✓ — metadata fully propagated

=== Scenario 2: WRITE — agent resolves comment, browser sees it ===
agent.comments.patch({status:'resolved'}).success === true
{
commentId: "<uuid>",
isDone: true,
resolvedTime: 1779...,
}
WRITE ✓ — resolve propagated to Y.Array

=== Scenario 3: DELETE — agent deletes, Y.Array shrinks ===
agent.comments.delete().success === true
Y.Array length after delete: 0
DELETE ✓ — Y.Array entry removed
```

## 2. See the bug (fix off)

To confirm what the pre-fix state looks like, disable each fix individually.

### Disable read-side (bridge.attachEditor)

```bash
# Comment out the attachEditor wiring:
sed -i.bak "s|commentBridge?.attachEditor(editor as never);|// commentBridge?.attachEditor(editor as never);|" \
apps/cli/src/lib/document.ts

NODE_ENV=test bun apps/cli/scripts/repro-sd-3214.ts
# Scenario 1 now prints:
# text: undefined, creatorName: undefined, creatorEmail: undefined, createdTime: undefined
# READ ✗ — metadata missing (this is the SD-3214 read-side bug pre-fix)

# Restore:
mv apps/cli/src/lib/document.ts.bak apps/cli/src/lib/document.ts
```

### Disable write-side (wrapper emits)

```bash
# Comment out the three emits in comments-wrappers.ts:
git stash # save state first
git checkout HEAD~1 -- packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts
# (this reverts the wrapper to the first commit, before the write-side fix)

NODE_ENV=test bun apps/cli/scripts/repro-sd-3214.ts
# Scenarios 2 and 3 now print:
# WRITE ✗ — resolve did not reach Y.Array
# DELETE ✗ — Y.Array still has the entry

# Restore:
git checkout HEAD -- packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts
git stash pop
```

## 3. What each scenario simulates

### Scenario 1 — READ direction

Mirrors the customer's flow: a user authors a comment in the browser SuperDoc; an agent connects to the same Y.Doc and reads the comment via `editor.doc.comments.list()`.

Both sessions are headless Editor instances sharing one in-memory Y.Doc. The "browser" session uses the user identity `Browser User <browser@example.com>`; the "agent" session uses `Headless Agent <agent@superdoc.dev>`. Yjs broadcasts changes between them through the natural CRDT mechanism — no network required.

Pass condition: all four metadata fields (`text`, `creatorName`, `creatorEmail`, `createdTime`) populate on the agent side.

### Scenario 2 — WRITE direction (resolve)

The agent calls `editor.doc.comments.patch({ commentId, status: 'resolved' })`. The Y.Array entry should reflect `isDone: true` and a numeric `resolvedTime`, so other clients observing the Y.Doc see the resolution.

Pass condition: `yEntry.isDone === true && typeof yEntry.resolvedTime === 'number'`.

### Scenario 3 — WRITE direction (delete)

The agent calls `editor.doc.comments.delete({ commentId })`. The Y.Array entry should disappear.

Pass condition: `ydoc.getArray('comments').toJSON().length === 0`.

## 4. Extending to a real two-process setup

If you want to validate against an actual collaboration provider (Liveblocks, Hocuspocus, custom websocket), the same code structure works — replace `providerStub()` with a real provider returned by `@superdoc-dev/cli`'s `createCollaborationRuntime`, and run the "browser" half in the dev server (`pnpm dev`) and the "agent" half via the CLI binary connected to the same room.

The in-memory shared Y.Doc here is the structural equivalent. If it passes, the network case will pass too — Yjs's wire protocol is just the same CRDT updates delivered over a socket.

## 5. Running the unit + integration suites

For machine-readable validation:

```bash
# CLI integration tests (10 cases for SD-3214)
pnpm --filter @superdoc-dev/cli test -- --run sd-3214

# super-editor unit + integration tests
pnpm --filter super-editor test -- --run comment-entity-store
pnpm --filter super-editor test -- --run comments-wrappers

# Full suites (slower, for pre-merge confidence)
pnpm --filter @superdoc-dev/cli test # 1248 pass
pnpm --filter super-editor test # 13117 pass
```
223 changes: 223 additions & 0 deletions apps/cli/scripts/repro-sd-3214.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/**
* SD-3214 end-to-end manual reproduction.
*
* Runs entirely in one Node process — no Liveblocks/Hocuspocus needed.
* Two Editor instances share a single Y.Doc, so changes from one side
* propagate to the other through the exact same Yjs primitives a real
* browser + agent pair would use over the wire.
*
* USAGE (from the worktree root):
*
* NODE_ENV=test bun apps/cli/scripts/repro-sd-3214.ts
*
* Three scenarios print PASS/FAIL lines so you can eyeball whether the
* fix is active. See the "Toggle the fix" section in the guide for how
* to compare before vs after.
*/

import { Doc as YDoc } from 'yjs';
import { openDocument } from '../src/lib/document';

const io = {
stdout: () => {},
stderr: () => {},
readStdinBytes: async () => new Uint8Array(),
now: () => Date.now(),
};

function providerStub() {
const noop = () => {};
return {
synced: true,
awareness: {
on: noop,
off: noop,
getStates: () => new Map(),
setLocalState: noop,
setLocalStateField: noop,
},
on: noop,
off: noop,
connect: noop,
disconnect: noop,
destroy: noop,
};
}

// ---------------------------------------------------------------------------
// Scenario 1: READ — browser authors, agent reads, metadata propagates
// ---------------------------------------------------------------------------

async function scenarioReadSide() {
console.log('\n=== Scenario 1: READ — browser → agent metadata propagation ===');
const ydoc = new YDoc();

const browser = await openDocument(undefined, io, {
documentId: 'sd-3214-readside',
ydoc,
collaborationProvider: providerStub() as never,
isNewFile: true,
user: { name: 'Browser User', email: 'browser@example.com' },
});

browser.editor.doc.create.paragraph({
at: { kind: 'documentEnd' },
text: 'A clause about indemnification.',
});
const block = browser.editor.doc.query.match({
select: { type: 'text', pattern: 'indemnification' },
require: 'first',
}).items[0]!.blocks[0]!;
browser.editor.doc.comments.create({
target: { kind: 'text', blockId: block.blockId, range: block.range } as never,
text: 'Please review this clause.',
});

const agent = await openDocument(undefined, io, {
documentId: 'sd-3214-readside',
ydoc,
collaborationProvider: providerStub() as never,
isNewFile: false,
user: { name: 'Headless Agent', email: 'agent@superdoc.dev' },
});

const list = agent.editor.doc.comments.list();
console.log(` agent.comments.list() returned ${list.items.length} item(s):`);
for (const item of list.items) {
console.log({
id: item.id,
text: (item as { text?: string }).text,
creatorName: (item as { creatorName?: string }).creatorName,
creatorEmail: (item as { creatorEmail?: string }).creatorEmail,
createdTime: (item as { createdTime?: number }).createdTime,
target: (item as { target?: unknown }).target ? 'present' : 'absent',
});
}

const item = list.items[0] as { text?: string; creatorName?: string; createdTime?: number } | undefined;
if (item?.text && item?.creatorName && item?.createdTime) {
console.log(' READ ✓ — metadata fully propagated');
} else {
console.log(' READ ✗ — metadata missing (this is the SD-3214 read-side bug pre-fix)');
}

browser.dispose();
agent.dispose();
}

// ---------------------------------------------------------------------------
// Scenario 2: WRITE / RESOLVE — agent resolves, Y.Array reflects it
// ---------------------------------------------------------------------------

async function scenarioWriteSideResolve() {
console.log('\n=== Scenario 2: WRITE — agent resolves comment, browser sees it ===');
const ydoc = new YDoc();

const browser = await openDocument(undefined, io, {
documentId: 'sd-3214-writeside',
ydoc,
collaborationProvider: providerStub() as never,
isNewFile: true,
user: { name: 'Browser User', email: 'browser@example.com' },
});
browser.editor.doc.create.paragraph({ at: { kind: 'documentEnd' }, text: 'A clause to be resolved.' });
const block = browser.editor.doc.query.match({
select: { type: 'text', pattern: 'resolved' },
require: 'first',
}).items[0]!.blocks[0]!;
browser.editor.doc.comments.create({
target: { kind: 'text', blockId: block.blockId, range: block.range } as never,
text: 'Resolve me.',
});
const targetId = (ydoc.getArray('comments').toJSON() as Array<Record<string, unknown>>)[0]!.commentId as string;

const agent = await openDocument(undefined, io, {
documentId: 'sd-3214-writeside',
ydoc,
collaborationProvider: providerStub() as never,
isNewFile: false,
user: { name: 'Headless Agent', email: 'agent@superdoc.dev' },
});
const patch = agent.editor.doc.comments.patch({ commentId: targetId, status: 'resolved' });
console.log(` agent.comments.patch({status:'resolved'}).success === ${patch.success}`);

const yEntry = (ydoc.getArray('comments').toJSON() as Array<Record<string, unknown>>)[0]!;
console.log({
commentId: yEntry.commentId,
isDone: yEntry.isDone,
resolvedTime: yEntry.resolvedTime,
});

if (yEntry.isDone === true && typeof yEntry.resolvedTime === 'number') {
console.log(' WRITE ✓ — resolve propagated to Y.Array');
} else {
console.log(' WRITE ✗ — resolve did not reach Y.Array (this is the write-side gap pre-fix)');
}

browser.dispose();
agent.dispose();
}

// ---------------------------------------------------------------------------
// Scenario 3: DELETE — agent deletes, Y.Array entry disappears
// ---------------------------------------------------------------------------

async function scenarioDelete() {
console.log('\n=== Scenario 3: DELETE — agent deletes, Y.Array shrinks ===');
const ydoc = new YDoc();

const browser = await openDocument(undefined, io, {
documentId: 'sd-3214-delete',
ydoc,
collaborationProvider: providerStub() as never,
isNewFile: true,
user: { name: 'Browser User', email: 'browser@example.com' },
});
browser.editor.doc.create.paragraph({ at: { kind: 'documentEnd' }, text: 'A clause to delete.' });
const block = browser.editor.doc.query.match({
select: { type: 'text', pattern: 'delete' },
require: 'first',
}).items[0]!.blocks[0]!;
browser.editor.doc.comments.create({
target: { kind: 'text', blockId: block.blockId, range: block.range } as never,
text: 'I will be deleted.',
});
const targetId = (ydoc.getArray('comments').toJSON() as Array<Record<string, unknown>>)[0]!.commentId as string;

const agent = await openDocument(undefined, io, {
documentId: 'sd-3214-delete',
ydoc,
collaborationProvider: providerStub() as never,
isNewFile: false,
user: { name: 'Headless Agent', email: 'agent@superdoc.dev' },
});
const del = agent.editor.doc.comments.delete({ commentId: targetId });
console.log(` agent.comments.delete().success === ${del.success}`);

const yArr = ydoc.getArray('comments').toJSON() as Array<Record<string, unknown>>;
console.log(` Y.Array length after delete: ${yArr.length}`);

if (yArr.length === 0) {
console.log(' DELETE ✓ — Y.Array entry removed');
} else {
console.log(' DELETE ✗ — Y.Array still has the entry (this is the write-side gap pre-fix)');
}

browser.dispose();
agent.dispose();
}

// ---------------------------------------------------------------------------
// Run
// ---------------------------------------------------------------------------

try {
await scenarioReadSide();
await scenarioWriteSideResolve();
await scenarioDelete();
console.log('\nDone.');
process.exit(0);
} catch (err) {
console.error('Repro crashed:', err);
process.exit(1);
}
2 changes: 2 additions & 0 deletions apps/cli/src/__tests__/lib/_collab-password-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ mock.module('superdoc/super-editor', () => ({
getDocumentApiAdapters: () => ({}),
markdownToPmDoc: () => null,
initPartsRuntime: () => ({ dispose: () => {} }),
// SD-3214: bridge imports this to feed Y.Array entries into the store.
syncCommentEntitiesFromCollaboration: () => new Set<string>(),
}));

mock.module('happy-dom', () => ({
Expand Down
Loading
Loading