test(integrations/client/packages): batch 2-4 unit coverage + zip-slip guard extraction
Batch 2-4 of the test-strategy rollout. Test-only except one minimal, behaviour-preserving extraction in file.utils.ts. All suites green: server 82 suites/836+1todo, editor-ext 86, mcp 270, client (new files) 86. integrations (server): - file.utils.ts: extract pure `isEntryPathSafe(entryName, targetDir)` from extractZipInternal so the zip-slip/path-traversal guard is unit-testable; call site rerouted, behaviour identical (only a warn-message string merged). - file.utils.zip-safety.spec.ts: traversal/strip/__MACOSX/prefix-confusion cases (mutation-resistant: fails if containment loses the path.sep). - import-formatter / import.utils / table-utils / export utils / import.service extractTitleAndRemoveHeading: pure import/export transforms, Notion/XWiki formatting, table colspan widths (idempotent), slug/link rewriting. client: - safeRedirectPath: open-redirect guard, every reject branch independently. - buildChatMarkdown (fence anti-breakout), label-colors, normalize-label, share tree build, page URL builders, notification time-grouping (fake clock). packages: - editor-ext: deriveFootnoteId golden table, parseHtmlEmbedHeight crafted values, orphan footnote extraction. - mcp: deriveFootnoteId parity (drift guard vs editor-ext), applyTextEdits idempotency + cross-block replaceAll, diffDocs/summarizeChange on reorder. Reviewed (APPROVE): extraction behaviour-preserving, assertions mutation-resistant. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { deriveFootnoteId } from "./footnote-util";
|
||||
|
||||
/**
|
||||
* GOLDEN TABLE for `deriveFootnoteId` (and its private alphabetic `suffix`).
|
||||
*
|
||||
* deriveFootnoteId is DELIBERATELY duplicated in
|
||||
* packages/mcp/src/lib/collaboration.ts
|
||||
* and the two copies MUST stay byte-for-byte equivalent in behavior so the same
|
||||
* markdown imported through the editor and through the MCP path yields identical
|
||||
* footnote ids. This table is the SHARED contract: the parity test
|
||||
* packages/mcp/test/unit/derive-id-parity.test.mjs
|
||||
* pins the exact SAME (input -> expected) pairs against the COMPILED mcp build.
|
||||
* If either copy drifts, one of the two tests goes red.
|
||||
*
|
||||
* Keep this constant in sync with GOLDEN in the mcp parity test.
|
||||
*/
|
||||
export const DERIVE_GOLDEN: Array<{
|
||||
originalId: string;
|
||||
occurrence: number;
|
||||
taken: string[];
|
||||
expected: string;
|
||||
why: string;
|
||||
}> = [
|
||||
// Base candidate `${id}__${occurrence}` when nothing collides.
|
||||
{ originalId: "d", occurrence: 2, taken: [], expected: "d__2", why: "plain base, second occurrence" },
|
||||
{ originalId: "d", occurrence: 3, taken: [], expected: "d__3", why: "plain base, third occurrence" },
|
||||
// The base is taken -> first alphabetic bump is "b" (NOT "a": suffix starts at 'b').
|
||||
{ originalId: "d", occurrence: 2, taken: ["d__2"], expected: "d__2b", why: "base taken -> first bump 'b'" },
|
||||
// Base + first bump taken -> "c".
|
||||
{ originalId: "d", occurrence: 2, taken: ["d__2", "d__2b"], expected: "d__2c", why: "base+b taken -> 'c'" },
|
||||
// A non-contiguous taken set still walks deterministically to the first free slot.
|
||||
{
|
||||
originalId: "d",
|
||||
occurrence: 2,
|
||||
taken: ["d__2", "d__2b", "d__2c", "d__2d"],
|
||||
expected: "d__2e",
|
||||
why: "base + b,c,d taken -> 'e'",
|
||||
},
|
||||
// >25 bump: base + b..z (the 25 single-letter suffixes) all taken -> "bb".
|
||||
// suffix(26) === "bb" (base-25 over b..z, carrying to a two-letter suffix).
|
||||
{
|
||||
originalId: "d",
|
||||
occurrence: 2,
|
||||
taken: ["d__2", ...singleLetterSuffixes().map((s) => `d__2${s}`)],
|
||||
expected: "d__2bb",
|
||||
why: ">25 collisions -> two-letter suffix 'bb'",
|
||||
},
|
||||
];
|
||||
|
||||
/** The 25 single-letter suffixes the scheme uses: b, c, ..., z (n = 1..25). */
|
||||
function singleLetterSuffixes(): string[] {
|
||||
// Mirror of the production suffix() for n in 1..25 (all single letters).
|
||||
// n=1 -> 'b' ... n=25 -> 'z'. Used only to BUILD the taken-set for the
|
||||
// >25 row; the EXPECTED value (d__2bb) is asserted against the real function.
|
||||
return Array.from({ length: 25 }, (_, i) => String.fromCharCode(98 + i));
|
||||
}
|
||||
|
||||
describe("deriveFootnoteId golden table (cross-package drift guard)", () => {
|
||||
for (const row of DERIVE_GOLDEN) {
|
||||
it(`derive("${row.originalId}", ${row.occurrence}, {${row.taken.join(",")}}) === "${row.expected}" — ${row.why}`, () => {
|
||||
const got = deriveFootnoteId(
|
||||
row.originalId,
|
||||
row.occurrence,
|
||||
new Set(row.taken),
|
||||
);
|
||||
expect(got).toBe(row.expected);
|
||||
});
|
||||
}
|
||||
|
||||
it("the >25 row's taken-set really contains b..z (25 single letters) plus the base", () => {
|
||||
// Sanity-pin the construction so a typo in singleLetterSuffixes() cannot make
|
||||
// the >25 assertion pass for the wrong reason.
|
||||
const letters = singleLetterSuffixes();
|
||||
expect(letters).toHaveLength(25);
|
||||
expect(letters[0]).toBe("b");
|
||||
expect(letters[24]).toBe("z");
|
||||
});
|
||||
|
||||
it("is a PURE function: it never mutates the taken set it is given", () => {
|
||||
const taken = new Set(["d__2"]);
|
||||
const before = [...taken];
|
||||
deriveFootnoteId("d", 2, taken);
|
||||
expect([...taken]).toEqual(before);
|
||||
});
|
||||
|
||||
it("is deterministic: same input -> same output across calls", () => {
|
||||
const mk = () => new Set(["d__2", "d__2b"]);
|
||||
expect(deriveFootnoteId("d", 2, mk())).toBe(deriveFootnoteId("d", 2, mk()));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseHtmlEmbedHeight,
|
||||
renderHtmlEmbedHeight,
|
||||
} from "./html-embed";
|
||||
|
||||
/**
|
||||
* PIN the CURRENT behavior of `parseHtmlEmbedHeight` for crafted/corrupt
|
||||
* `data-height` attribute values. The function is a thin parseInt + Number.isFinite
|
||||
* guard; these tests document EXACTLY what it does today (including the cases
|
||||
* where today's behavior is arguably wrong) so any future change is a conscious
|
||||
* one and shows up as a failing test rather than a silent regression.
|
||||
*/
|
||||
describe("parseHtmlEmbedHeight: crafted / corrupt data-height", () => {
|
||||
it('"-5" passes through as -5 (DOCUMENTED QUIRK: negative height is not rejected)', () => {
|
||||
// Number.isFinite(-5) is true, so the guard does NOT catch it. A negative
|
||||
// fixed height is almost certainly wrong downstream (it disables auto-resize
|
||||
// and yields a negative/clamped iframe height), but the function as written
|
||||
// returns it verbatim. This asserts the REAL behavior, not the ideal one.
|
||||
expect(parseHtmlEmbedHeight("-5")).toBe(-5);
|
||||
});
|
||||
|
||||
it('"0" returns 0 (NOT null) — note: renderHtmlEmbedHeight treats 0 as auto-resize, so parse/render are asymmetric at 0', () => {
|
||||
// parseInt("0") === 0 and Number.isFinite(0) is true, so parse keeps 0.
|
||||
expect(parseHtmlEmbedHeight("0")).toBe(0);
|
||||
// But the render side treats a falsy 0 as "auto-resize" => emits NO attribute.
|
||||
// So a stored height of 0 does not round-trip back to data-height="0".
|
||||
expect(renderHtmlEmbedHeight(0)).toEqual({});
|
||||
});
|
||||
|
||||
it('" 300 " (surrounding whitespace) parses to 300 — parseInt trims leading space', () => {
|
||||
expect(parseHtmlEmbedHeight(" 300 ")).toBe(300);
|
||||
});
|
||||
|
||||
it('"3.9" truncates to 3 — parseInt drops the fractional part', () => {
|
||||
expect(parseHtmlEmbedHeight("3.9")).toBe(3);
|
||||
});
|
||||
|
||||
it('a huge "99999999999" passes through unclamped (finite => no upper bound here)', () => {
|
||||
// The guard only rejects NaN/Infinity; it does not clamp magnitude. Any
|
||||
// clamping is a downstream concern, NOT this function's job.
|
||||
expect(parseHtmlEmbedHeight("99999999999")).toBe(99999999999);
|
||||
});
|
||||
|
||||
it('"12px" parses the leading integer (12) — parseInt stops at the first non-digit', () => {
|
||||
expect(parseHtmlEmbedHeight("12px")).toBe(12);
|
||||
});
|
||||
|
||||
it("null / empty / whitespace-only / non-numeric => null (the auto-resize sentinel)", () => {
|
||||
expect(parseHtmlEmbedHeight(null)).toBeNull();
|
||||
expect(parseHtmlEmbedHeight("")).toBeNull();
|
||||
expect(parseHtmlEmbedHeight(" ")).toBeNull();
|
||||
expect(parseHtmlEmbedHeight("abc")).toBeNull();
|
||||
});
|
||||
|
||||
it("never returns NaN for a non-numeric value (the Number.isFinite guard's point)", () => {
|
||||
// NaN is typeof "number" and would slip past a naive `typeof n === number`
|
||||
// check; the guard must map it to null. This is the core invariant.
|
||||
const out = parseHtmlEmbedHeight("not-a-number");
|
||||
expect(out).toBeNull();
|
||||
expect(Number.isNaN(out as unknown as number)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractFootnoteDefinitions } from "./footnote.marked";
|
||||
|
||||
/** Pull the ordered list of `data-footnote-def` ids out of the rendered section. */
|
||||
function defIds(section: string): string[] {
|
||||
return [...section.matchAll(/data-footnote-def data-id="([^"]+)"/g)].map(
|
||||
(m) => m[1],
|
||||
);
|
||||
}
|
||||
|
||||
/** Pull the ordered list of `[^id]` markers that remain in the body. */
|
||||
function bodyMarkers(body: string): string[] {
|
||||
return [...body.matchAll(/\[\^([^\]\s]+)\]/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
describe("extractFootnoteDefinitions: more definitions than markers (orphans)", () => {
|
||||
// Body has ONE `[^d]` reference marker but THREE `[^d]:` definitions. The
|
||||
// surplus definitions have no marker to pair with — they must NOT be silently
|
||||
// merged into one footnote (the editor's last-wins sync would otherwise drop
|
||||
// two of them). The dedup gives each colliding definition a deterministic
|
||||
// derived id so all three survive as distinct footnoteDefinition nodes.
|
||||
const md = ["See[^d].", "", "[^d]: a", "[^d]: b", "[^d]: c"].join("\n");
|
||||
|
||||
it("emits 3 DISTINCT definition ids: d, d__2, d__3 (derived scheme, in order)", () => {
|
||||
const { section } = extractFootnoteDefinitions(md);
|
||||
const ids = defIds(section);
|
||||
expect(ids).toEqual(["d", "d__2", "d__3"]);
|
||||
// All distinct: nothing was merged away.
|
||||
expect(new Set(ids).size).toBe(3);
|
||||
});
|
||||
|
||||
it("preserves each definition's text against its (possibly derived) id", () => {
|
||||
const { section } = extractFootnoteDefinitions(md);
|
||||
// First definition keeps the original id and its text.
|
||||
expect(section).toContain('data-footnote-def data-id="d"><p>a</p>');
|
||||
// The two surplus definitions survive as orphans with derived ids.
|
||||
expect(section).toContain('data-footnote-def data-id="d__2"><p>b</p>');
|
||||
expect(section).toContain('data-footnote-def data-id="d__3"><p>c</p>');
|
||||
});
|
||||
|
||||
it("leaves the SINGLE body marker as [^d] (no surplus marker to rewrite)", () => {
|
||||
const { body } = extractFootnoteDefinitions(md);
|
||||
// There is exactly one reference marker and it is untouched: the keeper
|
||||
// definition pairs with it. The orphan defs have no marker, so the body is
|
||||
// unchanged except for the stripped definition lines.
|
||||
expect(bodyMarkers(body)).toEqual(["d"]);
|
||||
expect(body).toContain("See[^d].");
|
||||
// The definition lines themselves were pulled OUT of the body.
|
||||
expect(body).not.toContain("[^d]: a");
|
||||
expect(body).not.toContain("[^d]: b");
|
||||
expect(body).not.toContain("[^d]: c");
|
||||
});
|
||||
|
||||
it("does not crash and produces a well-formed footnotes section", () => {
|
||||
const { section } = extractFootnoteDefinitions(md);
|
||||
expect(section.startsWith("<section data-footnotes>")).toBe(true);
|
||||
expect(section.endsWith("</section>")).toBe(true);
|
||||
// Exactly three definition divs.
|
||||
expect(
|
||||
[...section.matchAll(/<div data-footnote-def/g)],
|
||||
).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
134
packages/mcp/test/unit/derive-id-parity.test.mjs
Normal file
134
packages/mcp/test/unit/derive-id-parity.test.mjs
Normal file
@@ -0,0 +1,134 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { markdownToProseMirror } from "../../build/lib/collaboration.js";
|
||||
|
||||
/**
|
||||
* CROSS-PACKAGE DRIFT GUARD for the footnote id derivation scheme.
|
||||
*
|
||||
* `deriveFootnoteId` is duplicated in two places that MUST behave identically:
|
||||
* - packages/editor-ext/src/lib/footnote/footnote-util.ts (exported)
|
||||
* - packages/mcp/src/lib/collaboration.ts (internal helper)
|
||||
* so the same markdown imported through the editor and through the MCP path
|
||||
* derives identical footnote ids.
|
||||
*
|
||||
* The mcp copy is NOT exported from the compiled build (it is an internal helper
|
||||
* of collaboration.js), and production source must not be modified to export it.
|
||||
* So this test exercises the REAL compiled `deriveFootnoteId` *indirectly*, the
|
||||
* same way production does: through `markdownToProseMirror`, which runs
|
||||
* extractFootnotes -> deriveFootnoteId during duplicate-id dedup. We craft the
|
||||
* `taken` set via literal pre-existing definition ids and read back the derived
|
||||
* footnoteDefinition ids.
|
||||
*
|
||||
* GOLDEN below mirrors DERIVE_GOLDEN in
|
||||
* packages/editor-ext/src/lib/footnote/footnote-util.derive-id.test.ts
|
||||
* (asserted there by a DIRECT call). Same (originalId, occurrence, taken) ->
|
||||
* same expected id. If the two copies drift, one of the two suites goes red.
|
||||
*/
|
||||
|
||||
/** The 25 single-letter suffixes the scheme uses (n=1..25): b, c, ..., z. */
|
||||
function singleLetterSuffixes() {
|
||||
return Array.from({ length: 25 }, (_, i) => String.fromCharCode(98 + i));
|
||||
}
|
||||
|
||||
// Identical matrix + expected values to the editor-ext golden table.
|
||||
const GOLDEN = [
|
||||
{ originalId: "d", occurrence: 2, taken: [], expected: "d__2" },
|
||||
{ originalId: "d", occurrence: 3, taken: [], expected: "d__3" },
|
||||
{ originalId: "d", occurrence: 2, taken: ["d__2"], expected: "d__2b" },
|
||||
{ originalId: "d", occurrence: 2, taken: ["d__2", "d__2b"], expected: "d__2c" },
|
||||
{
|
||||
originalId: "d",
|
||||
occurrence: 2,
|
||||
taken: ["d__2", "d__2b", "d__2c", "d__2d"],
|
||||
expected: "d__2e",
|
||||
},
|
||||
{
|
||||
originalId: "d",
|
||||
occurrence: 2,
|
||||
taken: ["d__2", ...singleLetterSuffixes().map((s) => `d__2${s}`)],
|
||||
expected: "d__2bb",
|
||||
},
|
||||
];
|
||||
|
||||
/** Recursively collect every node of `type`. */
|
||||
function findAll(node, type, acc = []) {
|
||||
if (!node || typeof node !== "object") return acc;
|
||||
if (node.type === type) acc.push(node);
|
||||
if (Array.isArray(node.content)) for (const c of node.content) findAll(c, type, acc);
|
||||
return acc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build markdown that drives the real `deriveFootnoteId(originalId, occurrence,
|
||||
* taken)`:
|
||||
* - `occurrence` duplicate definitions of `[^originalId]` so the dedup walk
|
||||
* reaches the requested occurrence (occurrence=2 -> 1 keeper + 1 duplicate;
|
||||
* occurrence=3 -> keeper + 2 duplicates, of which the LAST is the one whose
|
||||
* id we read);
|
||||
* - one literal pre-existing definition for every id in `taken`, each with its
|
||||
* own reference marker so it is a real (non-orphan) definition. Those ids are
|
||||
* reserved up-front in the dedup `taken` set, exactly forcing the bump.
|
||||
*
|
||||
* Returns the derived id of the FINAL duplicate of `originalId`.
|
||||
*/
|
||||
async function deriveViaMarkdown(originalId, occurrence, takenIds) {
|
||||
// References: one [^originalId] per definition (keeper + duplicates) so each
|
||||
// duplicate has a marker to pair with, plus one marker per taken id.
|
||||
const dupCount = occurrence; // keeper + (occurrence-1) duplicates = `occurrence` defs
|
||||
const refMarkers = [];
|
||||
for (let i = 0; i < dupCount; i++) refMarkers.push(`[^${originalId}]`);
|
||||
for (const id of takenIds) refMarkers.push(`[^${id}]`);
|
||||
const refLine = `Body ${refMarkers.join(" ")}.`;
|
||||
|
||||
// Definitions: `occurrence` copies of [^originalId]: ... then the taken ids.
|
||||
const defLines = [];
|
||||
for (let i = 0; i < dupCount; i++) {
|
||||
defLines.push(`[^${originalId}]: copy ${i}`);
|
||||
}
|
||||
for (const id of takenIds) {
|
||||
defLines.push(`[^${id}]: reserved ${id}`);
|
||||
}
|
||||
|
||||
const md = [refLine, "", ...defLines].join("\n");
|
||||
const json = await markdownToProseMirror(md);
|
||||
const defIds = findAll(json, "footnoteDefinition").map((d) => d.attrs.id);
|
||||
|
||||
// The derived id we want is the one that is neither the keeper (originalId),
|
||||
// nor any reserved taken id, nor a lower-occurrence derived id. For
|
||||
// occurrence=2 that is the single bumped id; for occurrence=3 it is the
|
||||
// highest `${originalId}__3...` id. Compute it generically: among the def ids
|
||||
// that start with `${originalId}__${occurrence}`, the expected one is present.
|
||||
return { defIds, json };
|
||||
}
|
||||
|
||||
for (const row of GOLDEN) {
|
||||
test(`parity: derive("${row.originalId}", ${row.occurrence}, {${row.taken.join(",")}}) -> "${row.expected}"`, async () => {
|
||||
const { defIds } = await deriveViaMarkdown(
|
||||
row.originalId,
|
||||
row.occurrence,
|
||||
row.taken,
|
||||
);
|
||||
// The real compiled deriveFootnoteId must have minted exactly the golden id.
|
||||
assert.ok(
|
||||
defIds.includes(row.expected),
|
||||
`expected derived id "${row.expected}" among def ids ${JSON.stringify(defIds)}`,
|
||||
);
|
||||
// And every id is distinct: nothing collapsed.
|
||||
assert.equal(new Set(defIds).size, defIds.length, "all def ids distinct");
|
||||
});
|
||||
}
|
||||
|
||||
test("parity: the simple keeper+two-duplicate case mints d, d__2, d__3", async () => {
|
||||
// The canonical no-collision path, asserted as a whole set for clarity.
|
||||
const md = [
|
||||
"See[^d] one[^d] two[^d].",
|
||||
"",
|
||||
"[^d]: first",
|
||||
"[^d]: second",
|
||||
"[^d]: third",
|
||||
].join("\n");
|
||||
const json = await markdownToProseMirror(md);
|
||||
const defIds = findAll(json, "footnoteDefinition").map((d) => d.attrs.id);
|
||||
assert.deepEqual([...defIds].sort(), ["d", "d__2", "d__3"]);
|
||||
});
|
||||
88
packages/mcp/test/unit/diff-reorder.test.mjs
Normal file
88
packages/mcp/test/unit/diff-reorder.test.mjs
Normal file
@@ -0,0 +1,88 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { diffDocs, summarizeChange } from "../../build/lib/diff.js";
|
||||
|
||||
const t = (text, marks) => (marks ? { type: "text", text, marks } : { type: "text", text });
|
||||
const para = (s) => ({ type: "paragraph", content: [t(s)] });
|
||||
const doc = (...c) => ({ type: "doc", content: c });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Block REORDER (A,B -> B,A): the two documents contain the SAME blocks in a
|
||||
// different order. A naive set-based comparison would call this "no content
|
||||
// change" (the multiset of blocks is identical), which is wrong: the reader's
|
||||
// document order changed. The changeset-based diff must report it as a real
|
||||
// change and the integrity-/value-based summary must NOT claim "no content
|
||||
// change".
|
||||
// ---------------------------------------------------------------------------
|
||||
const A = para("Alpha paragraph content one");
|
||||
const B = para("Beta paragraph content two");
|
||||
const before = doc(A, B);
|
||||
const after = doc(B, A); // identical blocks, swapped order
|
||||
|
||||
test("diffDocs on a block swap does NOT report 'no textual changes'", () => {
|
||||
const r = diffDocs(before, after);
|
||||
assert.doesNotMatch(
|
||||
r.markdown,
|
||||
/no textual changes/i,
|
||||
"a reorder is a content change, not a no-op",
|
||||
);
|
||||
// The reorder surfaces as both an insertion and a deletion (text moved).
|
||||
assert.ok(r.summary.inserted > 0, "reports inserted chars");
|
||||
assert.ok(r.summary.deleted > 0, "reports deleted chars");
|
||||
const ops = new Set(r.changes.map((c) => c.op));
|
||||
assert.ok(ops.has("insert") && ops.has("delete"), "has both insert and delete changes");
|
||||
});
|
||||
|
||||
test("diffDocs reorder: summary fields are coherent (blocksChanged > 0, counts > 0)", () => {
|
||||
const r = diffDocs(before, after);
|
||||
assert.ok(r.summary.blocksChanged > 0, "blocksChanged must be positive for a reorder");
|
||||
// Symmetric move: the moved text is both inserted and deleted, so the two
|
||||
// counts are equal. (The diff algorithm chooses ONE of the two equal-status
|
||||
// blocks to represent as "moved", so we assert the count equals one of the
|
||||
// block lengths rather than hard-coding which block moved.)
|
||||
assert.equal(
|
||||
r.summary.inserted,
|
||||
r.summary.deleted,
|
||||
"a pure move inserts and deletes the same number of chars",
|
||||
);
|
||||
const blockLens = ["Alpha paragraph content one".length, "Beta paragraph content two".length];
|
||||
assert.ok(
|
||||
blockLens.includes(r.summary.inserted),
|
||||
`moved char count ${r.summary.inserted} should equal one of the block lengths ${JSON.stringify(blockLens)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("summarizeChange on a block swap reports changed:true, NOT 'no content change'", () => {
|
||||
const rep = summarizeChange(before, after);
|
||||
assert.equal(rep.changed, true, "a reorder is a change");
|
||||
assert.notEqual(rep.summary, "no content change");
|
||||
assert.match(rep.summary, /^changed:/, "summary is a 'changed: ...' line");
|
||||
// blocksChanged is coherent with diffDocs.
|
||||
assert.ok(rep.blocksChanged > 0, "blocksChanged > 0");
|
||||
assert.equal(rep.textInserted, rep.textDeleted, "symmetric move");
|
||||
assert.ok(rep.textInserted > 0, "text counts > 0");
|
||||
});
|
||||
|
||||
test("control: an IDENTICAL doc (no reorder) reports no content change", () => {
|
||||
// Guards the reorder assertions from being vacuously true: the same docs in
|
||||
// the SAME order must still cleanly report no change.
|
||||
const rep = summarizeChange(before, before);
|
||||
assert.equal(rep.changed, false);
|
||||
assert.equal(rep.summary, "no content change");
|
||||
const r = diffDocs(before, before);
|
||||
assert.equal(r.summary.blocksChanged, 0);
|
||||
assert.equal(r.changes.length, 0);
|
||||
});
|
||||
|
||||
test("a three-block rotation (A,B,C -> C,A,B) is reported as a change", () => {
|
||||
const C = para("Gamma paragraph content three");
|
||||
const d1 = doc(A, B, C);
|
||||
const d2 = doc(C, A, B);
|
||||
const rep = summarizeChange(d1, d2);
|
||||
assert.equal(rep.changed, true);
|
||||
assert.notEqual(rep.summary, "no content change");
|
||||
const r = diffDocs(d1, d2);
|
||||
assert.ok(r.summary.blocksChanged > 0);
|
||||
assert.doesNotMatch(r.markdown, /no textual changes/i);
|
||||
});
|
||||
146
packages/mcp/test/unit/json-edit-idempotency.test.mjs
Normal file
146
packages/mcp/test/unit/json-edit-idempotency.test.mjs
Normal file
@@ -0,0 +1,146 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { applyTextEdits } from "../../build/lib/json-edit.js";
|
||||
|
||||
const t = (text, marks) => (marks ? { type: "text", text, marks } : { type: "text", text });
|
||||
const para = (...c) => ({ type: "paragraph", content: c });
|
||||
const doc = (...c) => ({ type: "doc", content: c });
|
||||
|
||||
/** Recursively collect every node of `type`. */
|
||||
function findAll(node, type, acc = []) {
|
||||
if (!node || typeof node !== "object") return acc;
|
||||
if (node.type === type) acc.push(node);
|
||||
if (Array.isArray(node.content)) for (const c of node.content) findAll(c, type, acc);
|
||||
return acc;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Idempotency: a second application of an edit whose `find` was consumed by the
|
||||
// first application is a no-op. It must (a) report the edit as failed/not-found
|
||||
// and (b) leave the document byte-for-byte identical to the first output — i.e.
|
||||
// no double-apply, no accidental re-match against the inserted replacement.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("re-applying a consumed edit is a no-op: reports not-found AND output is deep-equal to the first apply", () => {
|
||||
const d0 = doc(para(t("the quick brown fox")));
|
||||
|
||||
const first = applyTextEdits(d0, [{ find: "quick", replace: "slow" }]);
|
||||
// First run applied cleanly.
|
||||
assert.equal(first.failed.length, 0, "first apply has no failures");
|
||||
assert.deepEqual(
|
||||
first.results,
|
||||
[{ find: "quick", replacements: 1 }],
|
||||
"first apply replaced exactly once",
|
||||
);
|
||||
assert.equal(
|
||||
findAll(first.doc, "text")[0].text,
|
||||
"the slow brown fox",
|
||||
"first apply produced the replaced text",
|
||||
);
|
||||
|
||||
// Second run: `quick` no longer exists; the replacement `slow` must NOT be a
|
||||
// new target. Edit goes to failed[], nothing applied.
|
||||
const second = applyTextEdits(first.doc, [{ find: "quick", replace: "slow" }]);
|
||||
assert.equal(second.results.length, 0, "second apply changes nothing");
|
||||
assert.equal(second.failed.length, 1, "second apply records one failure");
|
||||
assert.equal(second.failed[0].find, "quick");
|
||||
assert.match(second.failed[0].reason, /not found/i, "not-found reason");
|
||||
|
||||
// IDEMPOTENCY: second output deep-equals the first output (no double-apply).
|
||||
assert.deepEqual(
|
||||
second.doc,
|
||||
first.doc,
|
||||
"re-running the consumed edit must not mutate the document",
|
||||
);
|
||||
});
|
||||
|
||||
test("idempotency holds for replaceAll too: second run is not-found and output is stable", () => {
|
||||
const d0 = doc(para(t("ab ab ab")));
|
||||
const first = applyTextEdits(d0, [{ find: "ab", replace: "X", replaceAll: true }]);
|
||||
assert.deepEqual(first.results, [{ find: "ab", replacements: 3 }]);
|
||||
assert.equal(findAll(first.doc, "text")[0].text, "X X X");
|
||||
|
||||
const second = applyTextEdits(first.doc, [{ find: "ab", replace: "X", replaceAll: true }]);
|
||||
assert.equal(second.results.length, 0);
|
||||
assert.equal(second.failed.length, 1);
|
||||
assert.deepEqual(second.doc, first.doc, "replaceAll re-run is idempotent");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// replaceAll across TWO distinct blocks: the same needle living in a callout
|
||||
// paragraph AND a table cell must be spliced in BOTH, with the replacement
|
||||
// count summed across every block.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("replaceAll splices every block: callout paragraph (2 hits) + table cell (1 hit) = 3", () => {
|
||||
const callout = {
|
||||
type: "callout",
|
||||
attrs: { type: "info" },
|
||||
content: [para(t("alpha here and alpha again"))],
|
||||
};
|
||||
const table = {
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{ type: "tableCell", content: [para(t("alpha in a cell"))] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const d0 = doc(callout, table);
|
||||
|
||||
const r = applyTextEdits(d0, [{ find: "alpha", replace: "ZZ", replaceAll: true }]);
|
||||
|
||||
assert.equal(r.failed.length, 0, "no failures");
|
||||
// Count across blocks: 2 in the callout paragraph + 1 in the table cell.
|
||||
assert.deepEqual(r.results, [{ find: "alpha", replacements: 3 }]);
|
||||
|
||||
// Callout paragraph: both occurrences replaced.
|
||||
const calloutPara = r.doc.content[0].content[0];
|
||||
assert.equal(calloutPara.content[0].text, "ZZ here and ZZ again");
|
||||
|
||||
// Table cell (table > tableRow > tableCell > paragraph > text): replaced.
|
||||
const cellPara = r.doc.content[1].content[0].content[0].content[0];
|
||||
assert.equal(cellPara.content[0].text, "ZZ in a cell");
|
||||
|
||||
// No stray "alpha" survives anywhere in the document.
|
||||
const allText = findAll(r.doc, "text").map((n) => n.text).join(" ");
|
||||
assert.doesNotMatch(allText, /alpha/, "every occurrence across blocks was spliced");
|
||||
// Exactly three "ZZ" insertions overall.
|
||||
assert.equal((allText.match(/ZZ/g) || []).length, 3, "three replacements total");
|
||||
});
|
||||
|
||||
test("replaceAll across two blocks preserves surrounding text and ids in each block", () => {
|
||||
const callout = {
|
||||
type: "callout",
|
||||
attrs: { type: "info" },
|
||||
content: [{ type: "paragraph", attrs: { id: "p-callout" }, content: [t("keep alpha keep")] }],
|
||||
};
|
||||
const table = {
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{
|
||||
type: "tableCell",
|
||||
content: [{ type: "paragraph", attrs: { id: "p-cell" }, content: [t("pre alpha post")] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const d0 = doc(callout, table);
|
||||
|
||||
const r = applyTextEdits(d0, [{ find: "alpha", replace: "beta", replaceAll: true }]);
|
||||
assert.deepEqual(r.results, [{ find: "alpha", replacements: 2 }]);
|
||||
|
||||
const calloutPara = r.doc.content[0].content[0];
|
||||
assert.equal(calloutPara.attrs.id, "p-callout", "block id preserved");
|
||||
assert.equal(calloutPara.content[0].text, "keep beta keep");
|
||||
|
||||
const cellPara = r.doc.content[1].content[0].content[0].content[0];
|
||||
assert.equal(cellPara.attrs.id, "p-cell", "block id preserved");
|
||||
assert.equal(cellPara.content[0].text, "pre beta post");
|
||||
});
|
||||
Reference in New Issue
Block a user