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>
147 lines
5.9 KiB
JavaScript
147 lines
5.9 KiB
JavaScript
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");
|
|
});
|