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
5 changes: 5 additions & 0 deletions .changeset/indented-mdx-jsx-bridge-fidelity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge": patch
---

Fix a round-trip fidelity bug for documents that use indented MDX-JSX container components (`<Steps>`, `<Tabs>`, and similar nested components). Editing one of these documents no longer risks silent indentation rewrites, content reordering, or duplication: the editor's bridge now recognizes the serializer's container formatting as equivalent to the authored source, so the document settles to a stable state in a single pass and what you typed is what gets stored.
2 changes: 1 addition & 1 deletion docs/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
151 changes: 150 additions & 1 deletion packages/app/tests/fidelity/invariant-i13.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { describe, expect, test } from 'bun:test';
import { normalizeBridge } from '@inkeep/open-knowledge-core';
import type { JSONContent } from '@tiptap/core';
import * as fc from 'fast-check';
import { loadBuiltInFixtures } from '../../../core/src/markdown/fixtures/index.ts';
import {
loadBuiltInFixtures,
loadIndentedJsxFixtures,
} from '../../../core/src/markdown/fixtures/index.ts';
import { assertAcrossSeeds, mdManager, NUM_RUNS } from './helpers';

function walkJsxComponents(node: JSONContent, mutate: (n: JSONContent) => void): void {
Expand Down Expand Up @@ -189,6 +192,7 @@ const INDENTED_JSX_CLASS: IndentedJsxCase[] = [
'',
].join('\n'),
},
...loadIndentedJsxFixtures(),
];

describe('I13 — indented-children MDX JSX bridge fixed-point (PRD-7110)', () => {
Expand All @@ -201,9 +205,154 @@ describe('I13 — indented-children MDX JSX bridge fixed-point (PRD-7110)', () =
const once = dirtyRoundTrip(source);
expect(dirtyRoundTrip(once)).toBe(once);
});

test(`${name}: repeated dirty drains stay a within-tolerance fixed point (no byte growth)`, () => {
const normalizedSource = normalizeBridge(source);
const first = dirtyRoundTrip(source);
expect(normalizeBridge(first)).toBe(normalizedSource);
let cur = first;
for (let i = 1; i < 6; i++) {
cur = dirtyRoundTrip(cur);
expect(normalizeBridge(cur)).toBe(normalizedSource);
expect(cur).toBe(first);
}
});
}
});

function findJsxContainer(root: JSONContent, componentName: string): JSONContent | undefined {
let found: JSONContent | undefined;
const visit = (n: JSONContent): void => {
if (found) return;
if (n.type === 'jsxComponent' && n.attrs?.componentName === componentName) found = n;
else n.content?.forEach(visit);
};
visit(root);
return found;
}

function structurallyEditAndSerialize(md: string, edit: (root: JSONContent) => void): string {
const json = mdManager.parse(md);
edit(json);
walkJsxComponents(json, (node) => {
if (node.attrs) node.attrs.sourceDirty = true;
});
return mdManager.serialize(json);
}

function occurrences(haystack: string, needle: string): number {
return haystack.split(needle).length - 1;
}

function markerOrder(md: string, markers: readonly string[]): string[] {
return markers
.map((m) => ({ m, idx: md.indexOf(m) }))
.filter(({ idx }) => idx >= 0)
.sort((a, b) => a.idx - b.idx)
.map(({ m }) => m);
}

const O3_THREE_STEP = [
'<Steps>',
'',
'<Step>',
'',
'Alpha marker body.',
'',
'</Step>',
'',
'<Step>',
'',
'Bravo marker body.',
'',
'</Step>',
'',
'<Step>',
'',
'Charlie marker body.',
'',
'</Step>',
'',
'</Steps>',
'',
].join('\n');

const O3_MARKERS = ['Alpha marker body.', 'Bravo marker body.', 'Charlie marker body.'] as const;

describe('I13 — structural child-edit invariant (PRD-7110)', () => {
function assertEditedFixedPoint(editedMd: string): void {
expect(dirtyRoundTrip(editedMd)).toBe(editedMd);
expect(normalizeBridge(dirtyRoundTrip(editedMd))).toBe(normalizeBridge(editedMd));
}

test('swap: siblings reorder, each marker once, correct order, fixed point', () => {
const edited = structurallyEditAndSerialize(O3_THREE_STEP, (root) => {
const kids = findJsxContainer(root, 'Steps')?.content;
if (!kids) throw new Error('Steps container not found');
[kids[0], kids[2]] = [kids[2], kids[0]];
});
for (const m of O3_MARKERS) expect(occurrences(edited, m)).toBe(1);
expect(markerOrder(edited, O3_MARKERS)).toEqual([
'Charlie marker body.',
'Bravo marker body.',
'Alpha marker body.',
]);
assertEditedFixedPoint(edited);
});

test('delete: one sibling removed, remaining markers once, order preserved, fixed point', () => {
const edited = structurallyEditAndSerialize(O3_THREE_STEP, (root) => {
const kids = findJsxContainer(root, 'Steps')?.content;
if (!kids) throw new Error('Steps container not found');
kids.splice(1, 1);
});
expect(occurrences(edited, 'Bravo marker body.')).toBe(0);
expect(occurrences(edited, 'Alpha marker body.')).toBe(1);
expect(occurrences(edited, 'Charlie marker body.')).toBe(1);
expect(markerOrder(edited, O3_MARKERS)).toEqual(['Alpha marker body.', 'Charlie marker body.']);
assertEditedFixedPoint(edited);
});

test('insert: new sibling appended, all markers once, order preserved, fixed point', () => {
const all = [...O3_MARKERS, 'Delta marker body.'];
const edited = structurallyEditAndSerialize(O3_THREE_STEP, (root) => {
const kids = findJsxContainer(root, 'Steps')?.content;
if (!kids) throw new Error('Steps container not found');
kids.push({
type: 'jsxComponent',
attrs: {
componentName: 'Step',
kind: 'element',
attributes: [],
props: {},
sourceDirty: true,
},
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Delta marker body.' }] }],
});
});
for (const m of all) expect(occurrences(edited, m)).toBe(1);
expect(markerOrder(edited, all)).toEqual(all);
assertEditedFixedPoint(edited);
});

test('closing-tag edit (componentName rename) re-emits open+close, no sibling reorder/duplication', () => {
const edited = structurallyEditAndSerialize(O3_THREE_STEP, (root) => {
const target = findJsxContainer(root, 'Steps')?.content?.[1];
if (!target?.attrs) throw new Error('Step[1] not found');
target.attrs.componentName = 'Stage';
});
expect(edited).toContain('<Stage>');
expect(edited).toContain('</Stage>');
for (const m of O3_MARKERS) expect(occurrences(edited, m)).toBe(1);
expect(markerOrder(edited, O3_MARKERS)).toEqual([
'Alpha marker body.',
'Bravo marker body.',
'Charlie marker body.',
]);
assertEditedFixedPoint(edited);
});
});

describe('I13 — reconstructAttrs overlays non-JSON preserved expressions (PRD-7110)', () => {
test('a genuine edit overlays a non-JSON preserved expression attr (not kept)', () => {
const source = '<MyBox data={someVar}>\n\n Body paragraph.\n\n</MyBox>\n';
Expand Down
129 changes: 129 additions & 0 deletions packages/app/tests/integration/c14-indented-jsx-concurrent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { setTimeout as wait } from 'node:timers/promises';
import * as Y from 'yjs';
import { HARNESS_BOOT_TIMEOUT_MS } from './harness-boot-timeout';
import {
agentWriteMd,
assertBridgeInvariant,
createTestClients,
createTestServer,
pollUntil,
type TestClient,
type TestServer,
} from './test-harness';

let server: TestServer;

beforeAll(async () => {
server = await createTestServer();
}, HARNESS_BOOT_TIMEOUT_MS);

afterAll(async () => {
await server.cleanup();
});

const STEP_MARKERS = ['STEP-ONE-BODY', 'STEP-TWO-BODY', 'STEP-THREE-BODY', 'STEP-FOUR-BODY'];

const FOUR_STEP_SEED = [
'<Steps>',
'',
'<Step>',
'',
'STEP-ONE-BODY first instruction.',
'',
'</Step>',
'',
'<Step>',
'',
'STEP-TWO-BODY second instruction.',
'',
'</Step>',
'',
'<Step>',
'',
'STEP-THREE-BODY third instruction.',
'',
'</Step>',
'',
'<Step>',
'',
'STEP-FOUR-BODY fourth instruction.',
'',
'</Step>',
'',
'</Steps>',
'',
].join('\n');

function appendParagraph(client: TestClient, text: string): void {
const paragraph = new Y.XmlElement('paragraph');
const ytext = new Y.XmlText();
ytext.applyDelta([{ insert: text }]);
paragraph.insert(0, [ytext]);
client.fragment.push([paragraph]);
}

async function awaitAllContain(clients: TestClient[], markers: string[]): Promise<void> {
for (const marker of markers) {
for (const client of clients) {
await pollUntil(() => client.ytext.toString().includes(marker), 5000);
}
}
await wait(600);
}

describe('C14: concurrent edits on a 4-Step indented-JSX doc', () => {
test('two clients + agent write across a divergence window converge to a bounded, in-order fixed point', async () => {
const docName = `c14-4step-${crypto.randomUUID()}`;
const clients = await createTestClients(server.port, {
count: 2,
docName,
perClientOptions: { skipInvariantWatcher: true, syncControl: true },
});
try {
await agentWriteMd(server.port, FOUR_STEP_SEED, { docName, position: 'replace' });
await awaitAllContain(clients, STEP_MARKERS);

clients[0].pauseSync();
appendParagraph(clients[0], 'C14-WYSIWYG-A');
appendParagraph(clients[1], 'C14-WYSIWYG-B');
await agentWriteMd(server.port, '\n\nC14-AGENT-EDIT trailing.\n', {
docName,
position: 'append',
});
await wait(300);

clients[0].resumeSync();
await awaitAllContain(clients, [
...STEP_MARKERS,
'C14-WYSIWYG-A',
'C14-WYSIWYG-B',
'C14-AGENT-EDIT',
]);

const ytexts = clients.map((c) => c.ytext.toString());
expect(ytexts[1]).toBe(ytexts[0]);
for (const client of clients) assertBridgeInvariant(client.ytext, client.fragment);

const converged = ytexts[0];

const positions = STEP_MARKERS.map((m) => {
expect(converged.split(m).length - 1).toBe(1);
return converged.indexOf(m);
});
for (let i = 1; i < positions.length; i++) {
expect(positions[i]).toBeGreaterThan(positions[i - 1]);
}
for (const m of ['C14-WYSIWYG-A', 'C14-WYSIWYG-B', 'C14-AGENT-EDIT']) {
expect(converged.split(m).length - 1).toBe(1);
}

const authoredBytes =
Buffer.byteLength(FOUR_STEP_SEED) +
Buffer.byteLength('C14-WYSIWYG-A C14-WYSIWYG-B C14-AGENT-EDIT trailing.');
expect(Buffer.byteLength(converged)).toBeLessThanOrEqual(authoredBytes * 3);
} finally {
for (const c of clients) await c.cleanup();
}
}, 30_000);
});
Loading