@ansonlai/docx-redline-js 0.4.0 → 0.5.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.
- package/AGENTS.md +589 -287
- package/ARCHITECTURE.md +215 -9
- package/CHANGELOG.md +319 -0
- package/README.md +604 -360
- package/adapters/config.js +45 -43
- package/bin/docx-redline.js +3 -0
- package/core/list-targeting.js +101 -110
- package/core/paragraph-targeting.js +501 -61
- package/core/paragraph-text.js +209 -0
- package/core/revision-cloning.js +38 -0
- package/core/types.js +64 -10
- package/core/word-xml.js +43 -15
- package/dist/docx-redline-js.esm.js +2849 -466
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +87 -76
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +342 -23
- package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
- package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
- package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
- package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
- package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
- package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
- package/docs/schemas/document-operations.schema.json +109 -0
- package/docs/test-comparison-dashboard.html +4250 -7
- package/engine/formatting-removal.js +11 -2
- package/engine/oxml-engine.js +491 -336
- package/engine/reconstruction-mode.js +15 -14
- package/engine/reconstruction-writer.js +247 -142
- package/engine/route-selection.js +35 -0
- package/engine/rpr-helpers.js +334 -35
- package/engine/run-builders.js +239 -196
- package/engine/surgical-diff-application.js +222 -37
- package/engine/surgical-mode.js +134 -6
- package/engine/surgical-spans.js +52 -1
- package/engine/table-cell-context.js +3 -6
- package/engine/table-mode.js +1 -1
- package/index.d.ts +234 -6
- package/index.js +24 -1
- package/node/cli.js +317 -0
- package/node/docx-document.js +302 -0
- package/node/index.d.ts +31 -0
- package/node/index.js +2 -0
- package/node/zip-archive.js +52 -0
- package/orchestration/list-markdown.js +10 -16
- package/orchestration/list-parsing.js +7 -12
- package/orchestration/list-structural-fallback.js +21 -10
- package/package.json +24 -3
- package/pipeline/content-analysis.js +12 -17
- package/pipeline/ingestion-export.js +3 -31
- package/pipeline/ingestion-paragraph.js +10 -5
- package/pipeline/list-generation.js +150 -55
- package/pipeline/list-markers.js +70 -3
- package/pipeline/serialization.js +4 -2
- package/pipeline/structured-content.js +160 -0
- package/scripts/apply_changes.mjs +27 -0
- package/scripts/benchmark-operation-session.mjs +137 -0
- package/scripts/benchmark-targeting-browser.html +74 -0
- package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
- package/scripts/benchmark-test-runner.mjs +59 -0
- package/scripts/build-test-dashboard.mjs +23 -0
- package/scripts/export-lane1-fixtures.mjs +380 -0
- package/scripts/export-reredline-stress-fixtures.mjs +317 -0
- package/scripts/export-validation-fixtures.mjs +1 -1
- package/scripts/extract_text.mjs +7 -0
- package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
- package/scripts/generate-test-dashboard.mjs +362 -11
- package/scripts/lib/word-coverage-catalogue.mjs +6 -2
- package/scripts/profile-route-selection.mjs +19 -0
- package/scripts/render-agenda-multilevel.mjs +0 -5
- package/scripts/render-multilevel-cases.mjs +0 -1
- package/scripts/run-tests.mjs +107 -35
- package/scripts/word-com-corpus-suite.ps1 +3 -0
- package/scripts/word-com-differential.ps1 +64 -4
- package/scripts/word-com-suite.ps1 +3 -0
- package/services/batch-operation-orchestrator.js +494 -0
- package/services/capture-engine.js +226 -0
- package/services/comment-builders.js +23 -6
- package/services/comment-engine.js +108 -47
- package/services/comment-locator.js +187 -82
- package/services/comment-replies.js +95 -0
- package/services/document-inspection.js +258 -0
- package/services/document-operation-applier.js +372 -0
- package/services/document-operation-contract.js +323 -0
- package/services/document-operation-mutations.js +1733 -0
- package/services/document-operation-session.js +258 -0
- package/services/numbering-service.js +14 -5
- package/services/operation-heuristics.js +173 -0
- package/services/operation-preflight.js +366 -0
- package/services/receipt-collector.js +288 -0
- package/services/revision-comment-management.js +37 -5
- package/services/revision-token.js +290 -0
- package/services/standalone-docx-plumbing.js +123 -8
- package/services/standalone-operation-runner.d.ts +296 -0
- package/services/standalone-operation-runner.js +10 -1455
- package/services/table-reconciliation.js +15 -6
- package/docs/VALIDATION.md +0 -183
- package/docs/WORD-MANUAL-REVIEW.md +0 -138
- package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
- /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { performance } from 'node:perf_hooks';
|
|
3
|
+
import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
|
|
4
|
+
import { configureXmlProvider, parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
5
|
+
import { configureLogger } from '../adapters/logger.js';
|
|
6
|
+
import { buildTargetReferenceSnapshot } from '../core/paragraph-targeting.js';
|
|
7
|
+
import {
|
|
8
|
+
applyOperationToDocumentXml,
|
|
9
|
+
applyOperationsToDocumentXml
|
|
10
|
+
} from '../services/standalone-operation-runner.js';
|
|
11
|
+
|
|
12
|
+
configureXmlProvider({ DOMParser, XMLSerializer });
|
|
13
|
+
configureLogger({ info() {}, warn() {}, error() {} });
|
|
14
|
+
|
|
15
|
+
const paragraphCount = Math.max(100, Number.parseInt(process.env.DOCX_BENCH_PARAGRAPHS || '1000', 10));
|
|
16
|
+
const operationCount = Math.max(1, Number.parseInt(process.env.DOCX_BENCH_OPERATIONS || '10', 10));
|
|
17
|
+
const iterations = Math.max(3, Number.parseInt(process.env.DOCX_BENCH_ITERATIONS || '7', 10));
|
|
18
|
+
const warmups = Math.max(1, Number.parseInt(process.env.DOCX_BENCH_WARMUPS || '2', 10));
|
|
19
|
+
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
20
|
+
const NS_W14 = 'http://schemas.microsoft.com/office/word/2010/wordml';
|
|
21
|
+
|
|
22
|
+
const paragraphs = Array.from({ length: paragraphCount }, (_, index) => {
|
|
23
|
+
const id = (index + 1).toString(16).toUpperCase().padStart(8, '0');
|
|
24
|
+
return `<w:p w14:paraId="${id}"><w:r><w:t>Benchmark paragraph ${index + 1} with stable unique content.</w:t></w:r></w:p>`;
|
|
25
|
+
}).join('');
|
|
26
|
+
const source = `<w:document xmlns:w="${NS_W}" xmlns:w14="${NS_W14}"><w:body>${paragraphs}<w:sectPr/></w:body></w:document>`;
|
|
27
|
+
const step = Math.max(1, Math.floor(paragraphCount / (operationCount + 1)));
|
|
28
|
+
const operations = Array.from({ length: operationCount }, (_, index) => {
|
|
29
|
+
const paragraphNumber = Math.min(paragraphCount, step * (index + 1));
|
|
30
|
+
return {
|
|
31
|
+
type: 'replace',
|
|
32
|
+
target: {
|
|
33
|
+
paragraphId: paragraphNumber.toString(16).toUpperCase().padStart(8, '0'),
|
|
34
|
+
exactText: `Benchmark paragraph ${paragraphNumber} with stable unique content.`
|
|
35
|
+
},
|
|
36
|
+
modified: `Benchmark paragraph ${paragraphNumber} with verified updated content.`,
|
|
37
|
+
author: 'Session Benchmark'
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function percentile(values, ratio) {
|
|
42
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
43
|
+
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function runBatch() {
|
|
47
|
+
const instrumentation = { parses: 0, serializations: 0 };
|
|
48
|
+
const heapBefore = process.memoryUsage().heapUsed;
|
|
49
|
+
const started = performance.now();
|
|
50
|
+
const result = await applyOperationsToDocumentXml(source, operations, 'Session Benchmark', null, {
|
|
51
|
+
generateRedlines: false,
|
|
52
|
+
_sessionInstrumentation: {
|
|
53
|
+
onDocumentParse: () => { instrumentation.parses += 1; },
|
|
54
|
+
onDocumentSerialize: () => { instrumentation.serializations += 1; }
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
const elapsedMs = performance.now() - started;
|
|
58
|
+
if (!result.hasChanges || result.results.some(item => item.status !== 'applied')) {
|
|
59
|
+
throw new Error('Live-session benchmark batch did not apply every operation.');
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
elapsedMs,
|
|
63
|
+
heapDeltaBytes: process.memoryUsage().heapUsed - heapBefore,
|
|
64
|
+
outputBytes: Buffer.byteLength(result.documentXml),
|
|
65
|
+
...instrumentation
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function runSequential() {
|
|
70
|
+
const parsed = parseOoxmlSafe(source, 'application/xml');
|
|
71
|
+
const context = { targetRefSnapshot: buildTargetReferenceSnapshot(parsed.doc) };
|
|
72
|
+
const instrumentation = { parses: 0, serializations: 0 };
|
|
73
|
+
const heapBefore = process.memoryUsage().heapUsed;
|
|
74
|
+
const started = performance.now();
|
|
75
|
+
let documentXml = source;
|
|
76
|
+
for (const operation of operations) {
|
|
77
|
+
const result = await applyOperationToDocumentXml(documentXml, operation, 'Session Benchmark', context, {
|
|
78
|
+
generateRedlines: false,
|
|
79
|
+
_sessionInstrumentation: {
|
|
80
|
+
onDocumentParse: () => { instrumentation.parses += 1; },
|
|
81
|
+
onDocumentSerialize: () => { instrumentation.serializations += 1; }
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
if (!result.hasChanges) throw new Error('Sequential benchmark operation did not apply.');
|
|
85
|
+
documentXml = result.documentXml;
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
elapsedMs: performance.now() - started,
|
|
89
|
+
heapDeltaBytes: process.memoryUsage().heapUsed - heapBefore,
|
|
90
|
+
outputBytes: Buffer.byteLength(documentXml),
|
|
91
|
+
...instrumentation
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (let index = 0; index < warmups; index += 1) {
|
|
96
|
+
await runBatch();
|
|
97
|
+
await runSequential();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const batchSamples = [];
|
|
101
|
+
const sequentialSamples = [];
|
|
102
|
+
for (let index = 0; index < iterations; index += 1) {
|
|
103
|
+
batchSamples.push(await runBatch());
|
|
104
|
+
sequentialSamples.push(await runSequential());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function summarize(samples) {
|
|
108
|
+
const timings = samples.map(sample => sample.elapsedMs);
|
|
109
|
+
return {
|
|
110
|
+
medianMs: Number(percentile(timings, 0.5).toFixed(2)),
|
|
111
|
+
p95Ms: Number(percentile(timings, 0.95).toFixed(2)),
|
|
112
|
+
medianHeapDeltaBytes: percentile(samples.map(sample => sample.heapDeltaBytes), 0.5),
|
|
113
|
+
parseCount: samples[0].parses,
|
|
114
|
+
serializeCount: samples[0].serializations,
|
|
115
|
+
outputBytes: samples[0].outputBytes
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const batch = summarize(batchSamples);
|
|
120
|
+
const sequential = summarize(sequentialSamples);
|
|
121
|
+
const report = {
|
|
122
|
+
generatedAt: new Date().toISOString(),
|
|
123
|
+
environment: { node: process.version, platform: process.platform, arch: process.arch },
|
|
124
|
+
fixture: { paragraphCount, operationCount, iterations, warmups },
|
|
125
|
+
batch,
|
|
126
|
+
sequential,
|
|
127
|
+
medianSpeedup: Number((sequential.medianMs / batch.medianMs).toFixed(2)),
|
|
128
|
+
note: 'Timing and heap figures are observational. Semantic correctness tests remain the release gate.'
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
await mkdir(new URL('../tmp/benchmarks/', import.meta.url), { recursive: true });
|
|
132
|
+
await writeFile(
|
|
133
|
+
new URL('../tmp/benchmarks/operation-session-latest.json', import.meta.url),
|
|
134
|
+
`${JSON.stringify(report, null, 2)}\n`,
|
|
135
|
+
'utf8'
|
|
136
|
+
);
|
|
137
|
+
console.log(JSON.stringify(report, null, 2));
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Paragraph targeting browser benchmark</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<pre id="result">Running…</pre>
|
|
9
|
+
<script type="module">
|
|
10
|
+
import {
|
|
11
|
+
buildParagraphMetadataIndex,
|
|
12
|
+
buildTargetReferenceSnapshot,
|
|
13
|
+
resolveTargetParagraph
|
|
14
|
+
} from '../core/paragraph-targeting.js';
|
|
15
|
+
|
|
16
|
+
const params = new URLSearchParams(location.search);
|
|
17
|
+
const paragraphCount = Number.parseInt(params.get('paragraphs') || '10000', 10);
|
|
18
|
+
const operationCount = Number.parseInt(params.get('operations') || '1', 10);
|
|
19
|
+
const measuredIterations = Number.parseInt(params.get('iterations') || '7', 10);
|
|
20
|
+
const warmups = 2;
|
|
21
|
+
const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
22
|
+
const W14 = 'http://schemas.microsoft.com/office/word/2010/wordml';
|
|
23
|
+
const body = Array.from({ length: paragraphCount }, (_, index) =>
|
|
24
|
+
`<w:p w14:paraId="${String(index + 1).padStart(8, '0')}"><w:r><w:t>Benchmark paragraph ${index + 1}</w:t></w:r></w:p>`
|
|
25
|
+
).join('');
|
|
26
|
+
const xml = `<w:document xmlns:w="${W}" xmlns:w14="${W14}"><w:body>${body}<w:sectPr/></w:body></w:document>`;
|
|
27
|
+
const targets = Array.from({ length: operationCount }, (_, index) => {
|
|
28
|
+
const paragraphIndex = 1 + Math.floor(index * (paragraphCount - 1) / Math.max(1, operationCount - 1));
|
|
29
|
+
return { text: `Benchmark paragraph ${paragraphIndex}`, index: paragraphIndex };
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function percentile(values, ratio) {
|
|
33
|
+
const sorted = values.slice().sort((a, b) => a - b);
|
|
34
|
+
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function measure(useCache) {
|
|
38
|
+
const doc = new DOMParser().parseFromString(xml, 'application/xml');
|
|
39
|
+
const started = performance.now();
|
|
40
|
+
const paragraphMetadataIndex = useCache ? buildParagraphMetadataIndex(doc) : null;
|
|
41
|
+
buildTargetReferenceSnapshot(doc, paragraphMetadataIndex);
|
|
42
|
+
for (const targetDescriptor of targets) {
|
|
43
|
+
resolveTargetParagraph(doc, {
|
|
44
|
+
targetDescriptor,
|
|
45
|
+
strictAmbiguity: true,
|
|
46
|
+
paragraphMetadataIndex
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return performance.now() - started;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (let index = 0; index < warmups; index++) {
|
|
53
|
+
measure(false);
|
|
54
|
+
measure(true);
|
|
55
|
+
}
|
|
56
|
+
const uncached = [];
|
|
57
|
+
const cached = [];
|
|
58
|
+
for (let index = 0; index < measuredIterations; index++) {
|
|
59
|
+
uncached.push(measure(false));
|
|
60
|
+
cached.push(measure(true));
|
|
61
|
+
}
|
|
62
|
+
const result = {
|
|
63
|
+
runtime: { userAgent: navigator.userAgent },
|
|
64
|
+
fixture: { paragraphCount, operationCount, warmups, measuredIterations },
|
|
65
|
+
uncachedMs: { median: percentile(uncached, 0.5), p95: percentile(uncached, 0.95) },
|
|
66
|
+
sessionCachedMs: { median: percentile(cached, 0.5), p95: percentile(cached, 0.95) }
|
|
67
|
+
};
|
|
68
|
+
result.medianSpeedup = result.uncachedMs.median / result.sessionCachedMs.median;
|
|
69
|
+
window.benchmarkResult = result;
|
|
70
|
+
document.querySelector('#result').textContent = JSON.stringify(result, null, 2);
|
|
71
|
+
document.title = 'Target benchmark complete';
|
|
72
|
+
</script>
|
|
73
|
+
</body>
|
|
74
|
+
</html>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { performance } from 'node:perf_hooks';
|
|
2
|
+
import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
|
|
3
|
+
import { configureXmlProvider } from '../adapters/xml-adapter.js';
|
|
4
|
+
import {
|
|
5
|
+
buildParagraphMetadataIndex,
|
|
6
|
+
buildTargetReferenceSnapshot,
|
|
7
|
+
resolveTargetParagraph
|
|
8
|
+
} from '../core/paragraph-targeting.js';
|
|
9
|
+
|
|
10
|
+
configureXmlProvider({ DOMParser, XMLSerializer });
|
|
11
|
+
|
|
12
|
+
const paragraphCount = Number.parseInt(process.env.DOCX_TARGET_BENCH_PARAGRAPHS || '10000', 10);
|
|
13
|
+
const operationCount = Number.parseInt(process.env.DOCX_TARGET_BENCH_OPERATIONS || '100', 10);
|
|
14
|
+
const measuredIterations = Number.parseInt(process.env.DOCX_TARGET_BENCH_ITERATIONS || '7', 10);
|
|
15
|
+
const warmups = 2;
|
|
16
|
+
const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
17
|
+
const W14 = 'http://schemas.microsoft.com/office/word/2010/wordml';
|
|
18
|
+
|
|
19
|
+
const body = Array.from({ length: paragraphCount }, (_, index) =>
|
|
20
|
+
`<w:p w14:paraId="${String(index + 1).padStart(8, '0')}"><w:r><w:t>Benchmark paragraph ${index + 1}</w:t></w:r></w:p>`
|
|
21
|
+
).join('');
|
|
22
|
+
const xml = `<w:document xmlns:w="${W}" xmlns:w14="${W14}"><w:body>${body}<w:sectPr/></w:body></w:document>`;
|
|
23
|
+
const targets = Array.from({ length: operationCount }, (_, index) => {
|
|
24
|
+
const paragraphIndex = 1 + Math.floor(index * (paragraphCount - 1) / Math.max(1, operationCount - 1));
|
|
25
|
+
return { text: `Benchmark paragraph ${paragraphIndex}`, index: paragraphIndex };
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function percentile(values, ratio) {
|
|
29
|
+
const sorted = values.slice().sort((a, b) => a - b);
|
|
30
|
+
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function measure(useCache) {
|
|
34
|
+
const doc = new DOMParser().parseFromString(xml, 'application/xml');
|
|
35
|
+
const started = performance.now();
|
|
36
|
+
const paragraphMetadataIndex = useCache ? buildParagraphMetadataIndex(doc) : null;
|
|
37
|
+
buildTargetReferenceSnapshot(doc, paragraphMetadataIndex);
|
|
38
|
+
for (const targetDescriptor of targets) {
|
|
39
|
+
resolveTargetParagraph(doc, {
|
|
40
|
+
targetDescriptor,
|
|
41
|
+
strictAmbiguity: true,
|
|
42
|
+
paragraphMetadataIndex
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return performance.now() - started;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (let index = 0; index < warmups; index++) {
|
|
49
|
+
measure(false);
|
|
50
|
+
measure(true);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const uncached = [];
|
|
54
|
+
const cached = [];
|
|
55
|
+
for (let index = 0; index < measuredIterations; index++) {
|
|
56
|
+
uncached.push(measure(false));
|
|
57
|
+
cached.push(measure(true));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const result = {
|
|
61
|
+
runtime: { node: process.version, platform: process.platform, arch: process.arch },
|
|
62
|
+
fixture: { paragraphCount, operationCount, warmups, measuredIterations },
|
|
63
|
+
uncachedMs: { median: percentile(uncached, 0.5), p95: percentile(uncached, 0.95) },
|
|
64
|
+
sessionCachedMs: { median: percentile(cached, 0.5), p95: percentile(cached, 0.95) }
|
|
65
|
+
};
|
|
66
|
+
result.medianSpeedup = result.uncachedMs.median / result.sessionCachedMs.median;
|
|
67
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { performance } from 'node:perf_hooks';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
const measuredIterations = Number.parseInt(process.env.DOCX_TEST_BENCH_ITERATIONS || '3', 10);
|
|
10
|
+
const warmups = 1;
|
|
11
|
+
const parallelConcurrency = Math.max(1, Math.min(4, os.availableParallelism?.() ?? os.cpus().length));
|
|
12
|
+
|
|
13
|
+
function run(concurrency) {
|
|
14
|
+
const started = performance.now();
|
|
15
|
+
const result = spawnSync(process.execPath, ['scripts/run-tests.mjs'], {
|
|
16
|
+
cwd: repoRoot,
|
|
17
|
+
env: { ...process.env, DOCX_TEST_CONCURRENCY: String(concurrency) },
|
|
18
|
+
encoding: 'utf8',
|
|
19
|
+
windowsHide: true,
|
|
20
|
+
maxBuffer: 10 * 1024 * 1024
|
|
21
|
+
});
|
|
22
|
+
if (result.status !== 0) {
|
|
23
|
+
process.stderr.write(result.stdout || '');
|
|
24
|
+
process.stderr.write(result.stderr || '');
|
|
25
|
+
throw new Error(`Test runner failed in concurrency=${concurrency} benchmark mode.`);
|
|
26
|
+
}
|
|
27
|
+
return performance.now() - started;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function percentile(values, ratio) {
|
|
31
|
+
const sorted = values.slice().sort((a, b) => a - b);
|
|
32
|
+
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (let index = 0; index < warmups; index++) {
|
|
36
|
+
run(1);
|
|
37
|
+
run(parallelConcurrency);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const serial = [];
|
|
41
|
+
const parallel = [];
|
|
42
|
+
for (let index = 0; index < measuredIterations; index++) {
|
|
43
|
+
serial.push(run(1));
|
|
44
|
+
parallel.push(run(parallelConcurrency));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const result = {
|
|
48
|
+
runtime: { node: process.version, platform: process.platform, arch: process.arch },
|
|
49
|
+
fixture: { warmups, measuredIterations, parallelConcurrency },
|
|
50
|
+
serialMs: { median: percentile(serial, 0.5), p95: percentile(serial, 0.95), samples: serial },
|
|
51
|
+
parallelMs: { median: percentile(parallel, 0.5), p95: percentile(parallel, 0.95), samples: parallel }
|
|
52
|
+
};
|
|
53
|
+
result.medianSpeedup = result.serialMs.median / result.parallelMs.median;
|
|
54
|
+
result.medianReductionPercent = (1 - (result.parallelMs.median / result.serialMs.median)) * 100;
|
|
55
|
+
|
|
56
|
+
const outputDir = join(repoRoot, 'tmp', 'benchmarks');
|
|
57
|
+
mkdirSync(outputDir, { recursive: true });
|
|
58
|
+
writeFileSync(join(outputDir, 'test-runner-latest.json'), `${JSON.stringify(result, null, 2)}\n`);
|
|
59
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
@@ -38,6 +38,29 @@ if (corpusReady && suppliedCorpusFixturesDir) {
|
|
|
38
38
|
console.warn('Run npm run test:corpus:word once to fetch and validate the pinned corpus.');
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
const lane1FixturesArgIndex = process.argv.indexOf('--lane1-fixtures-dir');
|
|
42
|
+
if (lane1FixturesArgIndex >= 0 && !process.argv[lane1FixturesArgIndex + 1]) {
|
|
43
|
+
throw new Error('--lane1-fixtures-dir requires a path');
|
|
44
|
+
}
|
|
45
|
+
const suppliedLane1FixturesDir = lane1FixturesArgIndex >= 0;
|
|
46
|
+
const lane1FixturesDir = suppliedLane1FixturesDir
|
|
47
|
+
? resolve(repoRoot, process.argv[lane1FixturesArgIndex + 1])
|
|
48
|
+
: join(repoRoot, 'tmp', 'lane1-docx');
|
|
49
|
+
|
|
50
|
+
// The dashboard is self-contained: once generated, it keeps the exact DOCX
|
|
51
|
+
// bytes that were embedded at build time. Regenerate the default Lane 1
|
|
52
|
+
// fixtures on every dashboard build so an existing manifest cannot pin the
|
|
53
|
+
// report to an obsolete tracked document. An explicitly supplied fixture
|
|
54
|
+
// directory is treated as an immutable caller-selected snapshot.
|
|
55
|
+
if (!suppliedLane1FixturesDir) {
|
|
56
|
+
run('scripts/export-lane1-fixtures.mjs', ['--output-dir', lane1FixturesDir]);
|
|
57
|
+
} else if (!existsSync(join(lane1FixturesDir, 'manifest.json'))) {
|
|
58
|
+
throw new Error(`Supplied Lane 1 fixture directory has no manifest.json: ${lane1FixturesDir}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
41
61
|
const args = ['scripts/generate-test-dashboard.mjs', '--fixtures-dir', syntheticDir];
|
|
42
62
|
if (corpusReady) args.push('--corpus-fixtures-dir', corpusFixturesDir);
|
|
63
|
+
if (existsSync(join(lane1FixturesDir, 'manifest.json'))) {
|
|
64
|
+
args.push('--lane1-fixtures-dir', lane1FixturesDir);
|
|
65
|
+
}
|
|
43
66
|
run(args[0], args.slice(1));
|