@adeu/core 1.23.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,486 @@
1
+ /**
2
+ * Repro tests for the 2026-07-18 black-box QA and UX assessment
3
+ * (adeu 1.23.0+8102b64) — Node parity with
4
+ * python/tests/test_repro_qa_report_v6.py.
5
+ *
6
+ * Finding index:
7
+ * C1 finalize_document reports "Result: CLEAN" while docProps/custom.xml
8
+ * (custom document properties) survives in the SAVED package. The Node
9
+ * engine additionally leaked ALL removed parts (including customXml/*)
10
+ * through pkg.unzipped: save() zips every original member, so parts
11
+ * dropped from pkg.parts still shipped with their original bytes.
12
+ * dc:identifier / dc:language / cp:version were silently retained.
13
+ * H1 a table-row modification followed by an insert_row anchored on the
14
+ * modified row (the shape `generate_structured_edits` emits, replayed
15
+ * without private pins — the MCP process_document_batch shape) either
16
+ * fails or inserts the row at the wrong position: the clean-view
17
+ * fallback resolves a clean-view offset, but the row lookup ran
18
+ * against the raw mapper.
19
+ * H2 a context-wrapped paragraph insertion (target "two.\n\nFinal" →
20
+ * new "two.\n\nAdded.\n\nFinal") is word-diffed after context
21
+ * trimming; dmp cross-matches punctuation between context and inserted
22
+ * text, stranding characters and gluing paragraphs after accept-all.
23
+ *
24
+ * (C2 — extract overwriting its own input — is a Python-CLI-only surface;
25
+ * the Node packages expose no file-writing extract command.)
26
+ */
27
+
28
+ import { describe, it, expect } from "vitest";
29
+ import { strFromU8, strToU8, unzipSync, zipSync } from "fflate";
30
+ import {
31
+ createTestDocument,
32
+ addParagraph,
33
+ addTable,
34
+ setCellText,
35
+ } from "./test-utils.js";
36
+ import { DocumentObject } from "./docx/bridge.js";
37
+ import { extractTextFromBuffer, _extractTextFromDoc } from "./ingest.js";
38
+ import { generate_structured_edits } from "./diff.js";
39
+ import { RedlineEngine } from "./engine.js";
40
+ import { finalize_document } from "./sanitize/core.js";
41
+
42
+ const CUSTOM_PROPS_XML =
43
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
44
+ '<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/custom-properties" ' +
45
+ 'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">' +
46
+ '<property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="2" name="ClientSecret">' +
47
+ "<vt:lpwstr>TOP-SECRET-ORCHID</vt:lpwstr></property>" +
48
+ '<property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="3" name="MatterNumber">' +
49
+ "<vt:lpwstr>MAT-998877</vt:lpwstr></property>" +
50
+ "</Properties>";
51
+
52
+ const CUSTOM_XML_ITEM =
53
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
54
+ "<clientData><name>Kontoso Legal Oy</name><id>SENTINEL-HETU-131052-308T</id></clientData>";
55
+
56
+ /**
57
+ * Injects secret-bearing metadata (custom properties part, customXml part,
58
+ * core identifier/language/version) into a saved DOCX buffer at ZIP level —
59
+ * the same fixture shape the QA report used.
60
+ */
61
+ function injectMetadata(buffer: Buffer): Buffer {
62
+ const unzipped = unzipSync(new Uint8Array(buffer));
63
+
64
+ let ct = strFromU8(unzipped["[Content_Types].xml"]);
65
+ ct = ct.replace(
66
+ "</Types>",
67
+ '<Override PartName="/docProps/custom.xml" ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>' +
68
+ '<Override PartName="/customXml/item1.xml" ContentType="application/xml"/>' +
69
+ "</Types>",
70
+ );
71
+ unzipped["[Content_Types].xml"] = strToU8(ct);
72
+
73
+ let rels = strFromU8(unzipped["_rels/.rels"]);
74
+ rels = rels.replace(
75
+ "</Relationships>",
76
+ '<Relationship Id="rIdCustomProps" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties" Target="docProps/custom.xml"/>' +
77
+ "</Relationships>",
78
+ );
79
+ unzipped["_rels/.rels"] = strToU8(rels);
80
+
81
+ let core = strFromU8(unzipped["docProps/core.xml"]);
82
+ core = core
83
+ .replace("<dc:creator></dc:creator>", "<dc:creator>Alice Example</dc:creator>")
84
+ .replace("</cp:coreProperties>", "<dc:identifier>CLIENT-12345</dc:identifier><dc:language>fi-FI</dc:language><cp:version>9.9-internal</cp:version></cp:coreProperties>");
85
+ unzipped["docProps/core.xml"] = strToU8(core);
86
+
87
+ unzipped["docProps/custom.xml"] = strToU8(CUSTOM_PROPS_XML);
88
+ unzipped["customXml/item1.xml"] = strToU8(CUSTOM_XML_ITEM);
89
+
90
+ return Buffer.from(zipSync(unzipped));
91
+ }
92
+
93
+ async function buildMetadataDoc(): Promise<DocumentObject> {
94
+ const doc = await createTestDocument();
95
+ addParagraph(doc, "Body content that is perfectly fine to share.");
96
+ const buf = injectMetadata(await doc.save());
97
+ return DocumentObject.load(buf);
98
+ }
99
+
100
+ function packageText(buffer: Buffer): string {
101
+ const unzipped = unzipSync(new Uint8Array(buffer));
102
+ return Object.values(unzipped)
103
+ .map((data) => strFromU8(data))
104
+ .join("\n");
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // C1 — sanitize must remove custom properties / customXml from SAVED bytes
109
+ // ---------------------------------------------------------------------------
110
+
111
+ describe("QA v6 C1: custom document properties & retained core fields", () => {
112
+ it("removes docProps/custom.xml from the saved package", async () => {
113
+ const doc = await buildMetadataDoc();
114
+ const { outBuffer, reportText } = await finalize_document(doc, {
115
+ filename: "metadata.docx",
116
+ sanitize_mode: "full",
117
+ });
118
+
119
+ expect(outBuffer).toBeDefined();
120
+ const names = Object.keys(unzipSync(new Uint8Array(outBuffer!)));
121
+ expect(names).not.toContain("docProps/custom.xml");
122
+ const pkgText = packageText(outBuffer!);
123
+ expect(pkgText).not.toContain("TOP-SECRET-ORCHID");
124
+ expect(pkgText).not.toContain("MAT-998877");
125
+ expect(reportText.toLowerCase()).toContain("custom");
126
+ });
127
+
128
+ it("removes customXml parts from the saved package (pkg.unzipped leak)", async () => {
129
+ const doc = await buildMetadataDoc();
130
+ const { outBuffer } = await finalize_document(doc, {
131
+ filename: "metadata.docx",
132
+ sanitize_mode: "full",
133
+ });
134
+
135
+ const names = Object.keys(unzipSync(new Uint8Array(outBuffer!)));
136
+ expect(names.filter((n) => n.startsWith("customXml/"))).toEqual([]);
137
+ expect(packageText(outBuffer!)).not.toContain("SENTINEL-HETU-131052-308T");
138
+ });
139
+
140
+ it("scrubs dc:identifier, dc:language and cp:version from core.xml", async () => {
141
+ const doc = await buildMetadataDoc();
142
+ const { outBuffer, reportText } = await finalize_document(doc, {
143
+ filename: "metadata.docx",
144
+ sanitize_mode: "full",
145
+ });
146
+
147
+ const unzipped = unzipSync(new Uint8Array(outBuffer!));
148
+ const core = strFromU8(unzipped["docProps/core.xml"]);
149
+ expect(core).not.toContain("CLIENT-12345");
150
+ expect(core).not.toContain("fi-FI");
151
+ expect(core).not.toContain("9.9-internal");
152
+ // Scrubbed fields must be enumerated in the report, not silent.
153
+ expect(reportText).toContain("CLIENT-12345");
154
+ });
155
+
156
+ it("keep-markup mode also removes custom properties", async () => {
157
+ const doc = await buildMetadataDoc();
158
+ const { outBuffer } = await finalize_document(doc, {
159
+ filename: "metadata.docx",
160
+ sanitize_mode: "keep-markup",
161
+ });
162
+
163
+ const names = Object.keys(unzipSync(new Uint8Array(outBuffer!)));
164
+ expect(names).not.toContain("docProps/custom.xml");
165
+ expect(packageText(outBuffer!)).not.toContain("TOP-SECRET-ORCHID");
166
+ });
167
+
168
+ it("sanitized output still loads and keeps body content", async () => {
169
+ const doc = await buildMetadataDoc();
170
+ const { outBuffer } = await finalize_document(doc, {
171
+ filename: "metadata.docx",
172
+ sanitize_mode: "full",
173
+ });
174
+
175
+ const text = await extractTextFromBuffer(outBuffer!, true);
176
+ expect(text).toContain("perfectly fine to share");
177
+ });
178
+ });
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // H1 — row ops replayed without pins (the MCP batch shape)
182
+ // ---------------------------------------------------------------------------
183
+
184
+ const ORIG_ROWS = [
185
+ ["Seats", "5", "€100"],
186
+ ["Support", "1", "€500"],
187
+ ];
188
+ const MOD_ROWS = [
189
+ ["Seats", "10", "€125"],
190
+ ["Support", "1", "Included"],
191
+ ["Storage", "100 GB", "€50"],
192
+ ];
193
+
194
+ async function buildTableDoc(rows: string[][]): Promise<DocumentObject> {
195
+ const doc = await createTestDocument();
196
+ addParagraph(doc, "Pricing schedule below.");
197
+ const tbl = addTable(doc, rows.length, rows[0].length);
198
+ for (let r = 0; r < rows.length; r++) {
199
+ for (let c = 0; c < rows[r].length; c++) setCellText(tbl, r, c, rows[r][c]);
200
+ }
201
+ addParagraph(doc, "Terms follow the table.");
202
+ return doc;
203
+ }
204
+
205
+ function extractWithStructure(doc: DocumentObject): { text: string; structure: any } {
206
+ return (_extractTextFromDoc as any)(doc, true, false, false, true);
207
+ }
208
+
209
+ /** Simulates the JSON round trip: private positional pins do not survive. */
210
+ function stripPins(edits: any[]): any[] {
211
+ return edits.map((e) => {
212
+ const clone = { ...e };
213
+ delete clone._match_start_index;
214
+ delete clone._resolved_start_idx;
215
+ return clone;
216
+ });
217
+ }
218
+
219
+ describe("QA v6 H1: diff row ops must replay without pins", () => {
220
+ it("row modify + insert_row anchored on the modified row lands correctly", async () => {
221
+ const orig = await buildTableDoc(ORIG_ROWS);
222
+ const mod = await buildTableDoc(MOD_ROWS);
223
+ const origBuf = await orig.save();
224
+ const modBuf = await mod.save();
225
+
226
+ const o = extractWithStructure(await DocumentObject.load(origBuf));
227
+ const m = extractWithStructure(await DocumentObject.load(modBuf));
228
+ const { edits, warnings } = generate_structured_edits(o.text, o.structure, m.text, m.structure);
229
+ expect(warnings).toEqual([]);
230
+
231
+ const workDoc = await DocumentObject.load(origBuf);
232
+ const engine = new RedlineEngine(workDoc, "QA");
233
+ const stats = engine.process_batch(stripPins(edits));
234
+ expect(stats.edits_skipped, JSON.stringify(stats.skipped_details)).toBe(0);
235
+
236
+ engine.accept_all_revisions(true);
237
+ const finalText = await extractTextFromBuffer(await workDoc.save(), true);
238
+ const wantText = await extractTextFromBuffer(modBuf, true);
239
+ expect(finalText).toBe(wantText);
240
+ });
241
+
242
+ it("inserted row lands below its modified anchor, not at a stale offset", async () => {
243
+ const orig = await buildTableDoc(ORIG_ROWS);
244
+ const mod = await buildTableDoc(MOD_ROWS);
245
+ const origBuf = await orig.save();
246
+
247
+ const o = extractWithStructure(await DocumentObject.load(origBuf));
248
+ const m = extractWithStructure(mod);
249
+ const { edits } = generate_structured_edits(o.text, o.structure, m.text, m.structure);
250
+
251
+ const workDoc = await DocumentObject.load(origBuf);
252
+ const engine = new RedlineEngine(workDoc, "QA");
253
+ engine.process_batch(stripPins(edits));
254
+ engine.accept_all_revisions(true);
255
+ const finalText = await extractTextFromBuffer(await workDoc.save(), true);
256
+
257
+ const support = finalText.indexOf("Support | 1 | Included");
258
+ const storage = finalText.indexOf("Storage | 100 GB | €50");
259
+ expect(support, finalText).toBeGreaterThanOrEqual(0);
260
+ expect(storage, finalText).toBeGreaterThanOrEqual(0);
261
+ expect(support, `wrong row order:\n${finalText}`).toBeLessThan(storage);
262
+ });
263
+
264
+ it("delete_row + modify composition replays without pins", async () => {
265
+ const orig = await buildTableDoc([
266
+ ["Alpha", "1", "a"],
267
+ ["Beta", "2", "b"],
268
+ ["Gamma", "3", "c"],
269
+ ]);
270
+ const mod = await buildTableDoc([
271
+ ["Alpha", "9", "z"],
272
+ ["Gamma", "3", "c"],
273
+ ]);
274
+ const origBuf = await orig.save();
275
+ const modBuf = await mod.save();
276
+
277
+ const o = extractWithStructure(await DocumentObject.load(origBuf));
278
+ const m = extractWithStructure(await DocumentObject.load(modBuf));
279
+ const { edits } = generate_structured_edits(o.text, o.structure, m.text, m.structure);
280
+
281
+ const workDoc = await DocumentObject.load(origBuf);
282
+ const engine = new RedlineEngine(workDoc, "QA");
283
+ const stats = engine.process_batch(stripPins(edits));
284
+ expect(stats.edits_skipped, JSON.stringify(stats.skipped_details)).toBe(0);
285
+
286
+ engine.accept_all_revisions(true);
287
+ const finalText = await extractTextFromBuffer(await workDoc.save(), true);
288
+ const wantText = await extractTextFromBuffer(modBuf, true);
289
+ expect(finalText).toBe(wantText);
290
+ });
291
+ });
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // H2 — paragraph boundaries through context-wrapped insertions
295
+ // ---------------------------------------------------------------------------
296
+
297
+ async function buildParagraphDoc(): Promise<DocumentObject> {
298
+ const doc = await createTestDocument();
299
+ addParagraph(doc, "Intro paragraph one.");
300
+ addParagraph(doc, "Middle paragraph two.");
301
+ addParagraph(doc, "Final sentinel: END-OF-DOCUMENT-9f2c.");
302
+ return doc;
303
+ }
304
+
305
+ describe("QA v6 H2: paragraph boundary preservation", () => {
306
+ it("context-wrapped paragraph insertion keeps its separators", async () => {
307
+ const doc = await buildParagraphDoc();
308
+ const buf = await doc.save();
309
+
310
+ const workDoc = await DocumentObject.load(buf);
311
+ const engine = new RedlineEngine(workDoc, "QA");
312
+ // The self-contained JSON shape the Python CLI emits — and the shape an
313
+ // LLM naturally writes into process_document_batch.
314
+ const stats = engine.process_batch([
315
+ {
316
+ type: "modify",
317
+ target_text: "two.\n\nFinal",
318
+ new_text: "two.\n\nAdditional sentence inserted before the final marker.\n\nFinal",
319
+ } as any,
320
+ ]);
321
+ expect(stats.edits_skipped, JSON.stringify(stats.skipped_details)).toBe(0);
322
+
323
+ engine.accept_all_revisions(true);
324
+ const finalText = await extractTextFromBuffer(await workDoc.save(), true);
325
+ expect(finalText).not.toContain("marker.Final");
326
+ expect(finalText).not.toContain("two..");
327
+ expect(finalText).toBe(
328
+ "Intro paragraph one.\n\nMiddle paragraph two.\n\n" +
329
+ "Additional sentence inserted before the final marker.\n\n" +
330
+ "Final sentinel: END-OF-DOCUMENT-9f2c.",
331
+ );
332
+ });
333
+
334
+ it("docx-to-docx structured diff of an inserted paragraph round-trips (pinned, in-process)", async () => {
335
+ // Node's structured edits are an in-process shape: pure insertions ride
336
+ // the private pins (the Python CLI additionally rewrites them via
337
+ // make_edits_self_contained for its JSON output). The invariant here is
338
+ // apply(A, diff(A,B)) + accept-all == B with the paragraph boundary kept.
339
+ const orig = await buildParagraphDoc();
340
+ const origBuf = await orig.save();
341
+
342
+ const mod = await createTestDocument();
343
+ addParagraph(mod, "Intro paragraph one.");
344
+ addParagraph(mod, "Middle paragraph two.");
345
+ addParagraph(mod, "Additional sentence inserted before the final marker.");
346
+ addParagraph(mod, "Final sentinel: END-OF-DOCUMENT-9f2c.");
347
+ const modBuf = await mod.save();
348
+
349
+ const o = extractWithStructure(await DocumentObject.load(origBuf));
350
+ const m = extractWithStructure(await DocumentObject.load(modBuf));
351
+ const { edits } = generate_structured_edits(o.text, o.structure, m.text, m.structure);
352
+
353
+ const workDoc = await DocumentObject.load(origBuf);
354
+ const engine = new RedlineEngine(workDoc, "QA");
355
+ const stats = engine.process_batch(edits as any);
356
+ expect(stats.edits_skipped, JSON.stringify(stats.skipped_details)).toBe(0);
357
+
358
+ engine.accept_all_revisions(true);
359
+ const finalText = await extractTextFromBuffer(await workDoc.save(), true);
360
+ const wantText = await extractTextFromBuffer(modBuf, true);
361
+ expect(finalText).not.toContain("marker.Final");
362
+ expect(finalText).toBe(wantText);
363
+ });
364
+ });
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Hypothesis-found regressions (2026-07-18 fuzz hunt, ported from Python)
368
+ // ---------------------------------------------------------------------------
369
+
370
+ describe("QA v6 fuzz-found regressions", () => {
371
+ it("paragraph split keeps the suffix in the new paragraph", async () => {
372
+ const doc = await createTestDocument();
373
+ addParagraph(doc, "alpha beta.");
374
+ const buf = await doc.save();
375
+
376
+ const workDoc = await DocumentObject.load(buf);
377
+ const engine = new RedlineEngine(workDoc, "Fuzz");
378
+ const stats = engine.process_batch([
379
+ { type: "modify", target_text: "alpha beta.", new_text: "alpha.\n\nbeta." } as any,
380
+ ]);
381
+ expect(stats.edits_skipped, JSON.stringify(stats.skipped_details)).toBe(0);
382
+
383
+ engine.accept_all_revisions(true);
384
+ const finalText = await extractTextFromBuffer(await workDoc.save(), true);
385
+ expect(finalText).toBe("alpha.\n\nbeta.");
386
+ });
387
+
388
+ it("three-way paragraph split with changed content", async () => {
389
+ const doc = await createTestDocument();
390
+ addParagraph(doc, "p q.");
391
+ const buf = await doc.save();
392
+
393
+ const workDoc = await DocumentObject.load(buf);
394
+ const engine = new RedlineEngine(workDoc, "Fuzz");
395
+ const stats = engine.process_batch([
396
+ { type: "modify", target_text: "p q.", new_text: "p.\n\nq.\n\nr." } as any,
397
+ ]);
398
+ expect(stats.edits_skipped, JSON.stringify(stats.skipped_details)).toBe(0);
399
+
400
+ engine.accept_all_revisions(true);
401
+ const finalText = await extractTextFromBuffer(await workDoc.save(), true);
402
+ expect(finalText).toBe("p.\n\nq.\n\nr.");
403
+ });
404
+
405
+ it("plain paragraph starting with a number stays plain (no phantom list)", async () => {
406
+ const doc = await createTestDocument();
407
+ addParagraph(doc, "Intro.");
408
+ const buf = await doc.save();
409
+
410
+ const workDoc = await DocumentObject.load(buf);
411
+ const engine = new RedlineEngine(workDoc, "Fuzz");
412
+ const stats = engine.process_batch([
413
+ { type: "modify", target_text: "Intro.", new_text: "Intro.\n\n2024. Year in review." } as any,
414
+ ]);
415
+ expect(stats.edits_skipped, JSON.stringify(stats.skipped_details)).toBe(0);
416
+
417
+ engine.accept_all_revisions(true);
418
+ const outBuf = await workDoc.save();
419
+ const finalText = await extractTextFromBuffer(outBuf, true);
420
+ expect(finalText).toBe("Intro.\n\n2024. Year in review.");
421
+ const { strFromU8, unzipSync } = await import("fflate");
422
+ const xml = strFromU8(unzipSync(new Uint8Array(outBuf))["word/document.xml"]);
423
+ expect(xml).not.toContain("ListNumber");
424
+ });
425
+
426
+ it("the projection's own '1. ' marker still creates a list item", async () => {
427
+ const doc = await createTestDocument();
428
+ addParagraph(doc, "Intro.");
429
+ const buf = await doc.save();
430
+
431
+ const workDoc = await DocumentObject.load(buf);
432
+ const engine = new RedlineEngine(workDoc, "Fuzz");
433
+ const stats = engine.process_batch([
434
+ { type: "modify", target_text: "Intro.", new_text: "Intro.\n\n1. first item" } as any,
435
+ ]);
436
+ expect(stats.edits_skipped, JSON.stringify(stats.skipped_details)).toBe(0);
437
+
438
+ const outBuf = await workDoc.save();
439
+ const { strFromU8, unzipSync } = await import("fflate");
440
+ const xml = strFromU8(unzipSync(new Uint8Array(outBuf))["word/document.xml"]);
441
+ expect(xml).toContain("ListNumber");
442
+ });
443
+
444
+ it("cell edit on repetitive content lands in the right cell", async () => {
445
+ const orig = await buildTableDoc([["0", "0", "0"]]);
446
+ const mod = await buildTableDoc([["0", "0 0", "0"]]);
447
+ const origBuf = await orig.save();
448
+ const modBuf = await mod.save();
449
+
450
+ const o = extractWithStructure(await DocumentObject.load(origBuf));
451
+ const m = extractWithStructure(await DocumentObject.load(modBuf));
452
+ const { edits, warnings } = generate_structured_edits(o.text, o.structure, m.text, m.structure);
453
+ expect(warnings).toEqual([]);
454
+
455
+ const workDoc = await DocumentObject.load(origBuf);
456
+ const engine = new RedlineEngine(workDoc, "Fuzz");
457
+ const stats = engine.process_batch(stripPins(edits));
458
+ expect(stats.edits_skipped, JSON.stringify(stats.skipped_details)).toBe(0);
459
+
460
+ engine.accept_all_revisions(true);
461
+ const finalText = await extractTextFromBuffer(await workDoc.save(), true);
462
+ const wantText = await extractTextFromBuffer(modBuf, true);
463
+ expect(finalText).toBe(wantText);
464
+ });
465
+
466
+ it("multi-cell row modification replays without pins", async () => {
467
+ const orig = await buildTableDoc([["0 0", "0 0", "00"]]);
468
+ const mod = await buildTableDoc([["0 0", "0", "0"]]);
469
+ const origBuf = await orig.save();
470
+ const modBuf = await mod.save();
471
+
472
+ const o = extractWithStructure(await DocumentObject.load(origBuf));
473
+ const m = extractWithStructure(await DocumentObject.load(modBuf));
474
+ const { edits } = generate_structured_edits(o.text, o.structure, m.text, m.structure);
475
+
476
+ const workDoc = await DocumentObject.load(origBuf);
477
+ const engine = new RedlineEngine(workDoc, "Fuzz");
478
+ const stats = engine.process_batch(stripPins(edits));
479
+ expect(stats.edits_skipped, JSON.stringify(stats.skipped_details)).toBe(0);
480
+
481
+ engine.accept_all_revisions(true);
482
+ const finalText = await extractTextFromBuffer(await workDoc.save(), true);
483
+ const wantText = await extractTextFromBuffer(modBuf, true);
484
+ expect(finalText).toBe(wantText);
485
+ });
486
+ });