Skip to content

Commit a24145b

Browse files
committed
fix: doc-api story regressions and export app.xml stats
1 parent 86600ac commit a24145b

9 files changed

Lines changed: 282 additions & 217 deletions

File tree

packages/super-editor/src/core/Editor.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2901,6 +2901,9 @@ export class Editor extends EventEmitter<EditorEventMap> {
29012901
const numberingData = this.converter.convertedXml['word/numbering.xml'];
29022902
const numbering = this.converter.schemaToXml(numberingData.elements[0]);
29032903

2904+
const appXmlData = this.converter.convertedXml['docProps/app.xml'];
2905+
const appXml = appXmlData?.elements?.[0] ? this.converter.schemaToXml(appXmlData.elements[0]) : null;
2906+
29042907
// Export core.xml (contains dcterms:created timestamp)
29052908
const coreXmlData = this.converter.convertedXml['docProps/core.xml'];
29062909
const coreXml = coreXmlData?.elements?.[0] ? this.converter.schemaToXml(coreXmlData.elements[0]) : null;
@@ -2913,6 +2916,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
29132916
'word/numbering.xml': String(numbering),
29142917
'word/styles.xml': String(styles),
29152918
...updatedHeadersFooters,
2919+
...(appXml ? { 'docProps/app.xml': String(appXml) } : {}),
29162920
...(coreXml ? { 'docProps/core.xml': String(coreXml) } : {}),
29172921
};
29182922

packages/super-editor/src/tests/regression/opc-package-metadata-roundtrip.test.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ const TEST_DOC = 'blank-doc.docx';
1717

1818
const CT_CUSTOM = 'application/vnd.openxmlformats-officedocument.custom-properties+xml';
1919
const REL_CUSTOM = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties';
20+
const WORD_STAT_TEXT = 'Alpha beta gamma';
21+
22+
function readXmlTagValue(xml, tagName) {
23+
const match = xml.match(new RegExp(`<${tagName}>([^<]*)</${tagName}>`));
24+
return match?.[1] ?? null;
25+
}
2026

2127
describe('OPC package metadata: custom-properties registration', () => {
2228
it('getUpdatedDocs includes correct [Content_Types].xml and _rels/.rels for new custom.xml', async () => {
@@ -122,4 +128,23 @@ describe('OPC package metadata: custom-properties registration', () => {
122128
editor.destroy();
123129
}
124130
});
131+
132+
it('getUpdatedDocs includes refreshed docProps/app.xml statistics', async () => {
133+
const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(TEST_DOC);
134+
const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true });
135+
136+
try {
137+
editor.commands.insertContent(WORD_STAT_TEXT);
138+
139+
const updatedDocs = await editor.exportDocx({ getUpdatedDocs: true });
140+
const appXml = updatedDocs['docProps/app.xml'];
141+
142+
expect(appXml).toBeTruthy();
143+
expect(readXmlTagValue(appXml, 'Words')).toBe('3');
144+
expect(readXmlTagValue(appXml, 'Characters')).toBe('14');
145+
expect(readXmlTagValue(appXml, 'CharactersWithSpaces')).toBe('16');
146+
} finally {
147+
editor.destroy();
148+
}
149+
});
125150
});

tests/doc-api-stories/tests/content-controls/all-commands.ts

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ const ALL_CONTENT_CONTROL_COMMAND_IDS = [
8484
'contentControls.group.wrap',
8585
'contentControls.group.ungroup',
8686
] as const;
87+
const COMMAND_STORY_TIMEOUT_MS = 60_000;
8788

8889
type ContentControlsCommandId = (typeof ALL_CONTENT_CONTROL_COMMAND_IDS)[number];
8990

@@ -2082,30 +2083,34 @@ describe('document-api story: all content-controls commands', () => {
20822083
});
20832084

20842085
for (const scenario of scenarios) {
2085-
it(`${scenario.operationId}: executes and saves source/result docs`, async () => {
2086-
const sessionId = makeSessionId(scenario.operationId.replace(/\./g, '-'));
2086+
it(
2087+
`${scenario.operationId}: executes and saves source/result docs`,
2088+
async () => {
2089+
const sessionId = makeSessionId(scenario.operationId.replace(/\./g, '-'));
20872090

2088-
try {
2089-
await openSeedDocument(sessionId, scenario.seedDoc ?? BASE_CONTENT_CONTROLS_DOC);
2091+
try {
2092+
await openSeedDocument(sessionId, scenario.seedDoc ?? BASE_CONTENT_CONTROLS_DOC);
20902093

2091-
const fixture = scenario.prepare ? await scenario.prepare(sessionId) : null;
2094+
const fixture = scenario.prepare ? await scenario.prepare(sessionId) : null;
20922095

2093-
await saveSource(sessionId, scenario.operationId);
2096+
await saveSource(sessionId, scenario.operationId);
20942097

2095-
const result = await scenario.run(sessionId, fixture);
2098+
const result = await scenario.run(sessionId, fixture);
20962099

2097-
if (READ_OPERATION_IDS.has(scenario.operationId)) {
2098-
assertReadShape(scenario.operationId, result);
2099-
await saveReadOutput(scenario.operationId, result);
2100-
} else {
2101-
assertMutationSuccess(scenario.operationId, result, scenario.allowNoOpFailure === true);
2102-
}
2100+
if (READ_OPERATION_IDS.has(scenario.operationId)) {
2101+
assertReadShape(scenario.operationId, result);
2102+
await saveReadOutput(scenario.operationId, result);
2103+
} else {
2104+
assertMutationSuccess(scenario.operationId, result, scenario.allowNoOpFailure === true);
2105+
}
21032106

2104-
await saveResult(sessionId, scenario.operationId);
2105-
} finally {
2106-
await closeSession(sessionId).catch(() => {});
2107-
}
2108-
});
2107+
await saveResult(sessionId, scenario.operationId);
2108+
} finally {
2109+
await closeSession(sessionId).catch(() => {});
2110+
}
2111+
},
2112+
COMMAND_STORY_TIMEOUT_MS,
2113+
);
21092114
}
21102115

21112116
it('writes source/result artifacts for every content-controls command', async () => {

tests/doc-api-stories/tests/fields/word-stat-fields-roundtrip.ts

Lines changed: 79 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -13,38 +13,49 @@ const FIXTURE_DOC = path.resolve(import.meta.dirname, 'fixtures', 'numwords.docx
1313
// OOXML inspection helpers (local to this story)
1414
// ---------------------------------------------------------------------------
1515

16+
type ExportedComplexField = {
17+
fieldType: string;
18+
instruction: string;
19+
cachedText: string;
20+
dirty: boolean;
21+
};
22+
23+
type StoryField = {
24+
address?: unknown;
25+
fieldType?: string;
26+
resolvedText?: string;
27+
};
28+
1629
async function readDocxPart(docPath: string, partPath: string): Promise<string> {
1730
const { stdout } = await execFileAsync('unzip', ['-p', docPath, partPath], {
1831
maxBuffer: ZIP_MAX_BUFFER_BYTES,
1932
});
2033
return stdout;
2134
}
2235

23-
/** Extracts all field instruction texts from a document.xml string. */
24-
function extractFieldInstructions(documentXml: string): string[] {
25-
const matches = [...documentXml.matchAll(/<w:instrText[^>]*>([^<]*)<\/w:instrText>/g)];
26-
return matches.map((m) => m[1].trim());
27-
}
28-
29-
/** Extracts text elements (w:t) from field cached result runs. */
30-
function extractCachedFieldResults(documentXml: string): string[] {
31-
// Find all w:t elements that appear between w:fldChar separate and end
32-
const results: string[] = [];
33-
const fieldRegex = /<w:fldChar[^>]*w:fldCharType="separate"[^>]*\/?>[\s\S]*?<w:fldChar[^>]*w:fldCharType="end"/g;
34-
35-
for (const match of documentXml.matchAll(fieldRegex)) {
36-
const segment = match[0];
37-
const textMatches = [...segment.matchAll(/<w:t[^>]*>([^<]*)<\/w:t>/g)];
38-
for (const tm of textMatches) {
39-
results.push(tm[1]);
40-
}
36+
function extractExportedComplexFields(documentXml: string): ExportedComplexField[] {
37+
const complexFieldPattern =
38+
/<w:fldChar[^>]*w:fldCharType="begin"([^>]*)\/?>[\s\S]*?<w:instrText[^>]*>([^<]*)<\/w:instrText>[\s\S]*?<w:fldChar[^>]*w:fldCharType="separate"[^>]*\/?>([\s\S]*?)<w:fldChar[^>]*w:fldCharType="end"/g;
39+
const exportedFields: ExportedComplexField[] = [];
40+
41+
for (const match of documentXml.matchAll(complexFieldPattern)) {
42+
const beginAttributes = match[1] ?? '';
43+
const instruction = (match[2] ?? '').trim();
44+
const cachedSegment = match[3] ?? '';
45+
const cachedText = [...cachedSegment.matchAll(/<w:t[^>]*>([^<]*)<\/w:t>/g)]
46+
.map((textMatch) => textMatch[1])
47+
.join('');
48+
const fieldType = instruction.split(/\s+/)[0]?.toUpperCase() ?? '';
49+
50+
exportedFields.push({
51+
fieldType,
52+
instruction,
53+
cachedText,
54+
dirty: beginAttributes.includes('w:dirty="true"'),
55+
});
4156
}
42-
return results;
43-
}
4457

45-
/** Checks whether w:updateFields is present in settings.xml. */
46-
function hasUpdateFields(settingsXml: string): boolean {
47-
return /<w:updateFields\b[^>]*w:val="true"/.test(settingsXml);
58+
return exportedFields;
4859
}
4960

5061
/** Extracts a simple element's text value from app.xml. */
@@ -53,11 +64,6 @@ function extractAppStat(appXml: string, tagName: string): string | null {
5364
return match?.[1] ?? null;
5465
}
5566

56-
/** Checks for w:dirty attribute on fldChar begin elements. */
57-
function hasDirtyField(documentXml: string): boolean {
58-
return /w:dirty="true"/.test(documentXml);
59-
}
60-
6167
// ---------------------------------------------------------------------------
6268
// Test helpers
6369
// ---------------------------------------------------------------------------
@@ -78,11 +84,28 @@ describe('word-stat-fields roundtrip', () => {
7884
const api = client as any;
7985

8086
async function openSession(docPath: string, sessionId: string) {
81-
await api.doc.open({ filePath: docPath, sessionId });
87+
await api.doc.open({ doc: docPath, sessionId });
8288
}
8389

8490
async function saveSession(sessionId: string, savePath: string) {
85-
await api.doc.save({ sessionId, filePath: savePath });
91+
await api.doc.save({ sessionId, out: savePath, force: true });
92+
}
93+
94+
function toStoryField(item: any): StoryField {
95+
return (item?.domain ?? item ?? {}) as StoryField;
96+
}
97+
98+
async function listFields(sessionId: string): Promise<StoryField[]> {
99+
const listResult = unwrap<any>(await api.doc.fields.list({ sessionId }));
100+
return Array.isArray(listResult?.items) ? listResult.items.map(toStoryField) : [];
101+
}
102+
103+
function listFieldTypes(items: StoryField[]): string[] {
104+
return items.map((item) => item.fieldType ?? '');
105+
}
106+
107+
function findFieldByType(items: StoryField[], fieldType: string): StoryField | undefined {
108+
return items.find((item) => item.fieldType === fieldType);
86109
}
87110

88111
// ─────────────────────────────────────────────────────────────────────────
@@ -94,14 +117,7 @@ describe('word-stat-fields roundtrip', () => {
94117
const sessionId = sid('phase-a');
95118
await openSession(docPath, sessionId);
96119

97-
const listResult = await api.doc.fields.list({ sessionId });
98-
const items = unwrap<any[]>(listResult)?.items ?? listResult?.items ?? [];
99-
100-
// The fixture has NUMWORDS, NUMCHARS, and NUMPAGES fields
101-
const fieldTypes = items.map((item: any) => {
102-
const domain = item?.domain ?? item;
103-
return domain?.fieldType;
104-
});
120+
const fieldTypes = listFieldTypes(await listFields(sessionId));
105121

106122
expect(fieldTypes).toContain('NUMWORDS');
107123
expect(fieldTypes).toContain('NUMCHARS');
@@ -124,25 +140,19 @@ describe('word-stat-fields roundtrip', () => {
124140

125141
// Inspect exported document.xml
126142
const documentXml = await readDocxPart(savedPath, 'word/document.xml');
143+
const exportedFields = extractExportedComplexFields(documentXml);
144+
const exportedFieldByType = new Map(exportedFields.map((field) => [field.fieldType, field]));
127145

128-
// Should contain field instructions for our stat fields
129-
const instructions = extractFieldInstructions(documentXml);
130-
const hasNumwords = instructions.some((instr) => instr.includes('NUMWORDS'));
131-
const hasNumchars = instructions.some((instr) => instr.includes('NUMCHARS'));
132-
const hasNumpages = instructions.some((instr) => instr.includes('NUMPAGES'));
133-
134-
expect(hasNumwords).toBe(true);
135-
expect(hasNumchars).toBe(true);
136-
expect(hasNumpages).toBe(true);
146+
expect(exportedFieldByType.has('NUMWORDS')).toBe(true);
147+
expect(exportedFieldByType.has('NUMCHARS')).toBe(true);
148+
expect(exportedFieldByType.has('NUMPAGES')).toBe(true);
137149

138150
// Should have fldChar structure (complex fields, not fldSimple)
139151
expect(documentXml).toContain('w:fldCharType="begin"');
140152
expect(documentXml).toContain('w:fldCharType="separate"');
141153
expect(documentXml).toContain('w:fldCharType="end"');
142154

143-
// Should have cached result runs between separate and end
144-
const cachedResults = extractCachedFieldResults(documentXml);
145-
expect(cachedResults.length).toBeGreaterThanOrEqual(3);
155+
expect(exportedFields).toHaveLength(3);
146156

147157
// Inspect docProps/app.xml — stat values should be present and consistent
148158
const appXml = await readDocxPart(savedPath, 'docProps/app.xml');
@@ -161,17 +171,16 @@ describe('word-stat-fields roundtrip', () => {
161171
// Characters (no spaces) must be ≤ CharactersWithSpaces (internal consistency)
162172
expect(Number(charsValue)).toBeLessThanOrEqual(Number(charsWithSpaces));
163173

164-
// The NUMWORDS cached result in the field should match the app.xml Words value
165-
// (both are computed from the same helper during export)
166-
const numwordsCachedResult = cachedResults.find((r) => r && /^\d+$/.test(r.trim()));
167-
if (numwordsCachedResult) {
168-
expect(wordsValue).toBe(numwordsCachedResult.trim());
169-
}
174+
expect(exportedFieldByType.get('NUMWORDS')?.cachedText).toBe(wordsValue);
175+
expect(exportedFieldByType.get('NUMCHARS')?.cachedText).toBe(charsValue);
176+
expect(exportedFieldByType.get('NUMPAGES')?.cachedText).toBe(extractAppStat(appXml, 'Pages'));
170177

171178
// Dirty-flag policy: NUMWORDS and NUMCHARS should NOT be dirty (no
172179
// uninterpreted switches). NUMPAGES may or may not be dirty depending
173180
// on whether pagination was available in the test environment.
174181
// We verify the structural invariant rather than a blanket dirty check.
182+
expect(exportedFieldByType.get('NUMWORDS')?.dirty).toBe(false);
183+
expect(exportedFieldByType.get('NUMCHARS')?.dirty).toBe(false);
175184
const settingsXml = await readDocxPart(savedPath, 'word/settings.xml').catch(() => '');
176185
if (settingsXml) {
177186
expect(settingsXml).toContain('w:settings');
@@ -190,17 +199,12 @@ describe('word-stat-fields roundtrip', () => {
190199

191200
await openSession(docPath, sessionId);
192201

193-
// Get initial field list
194-
const initialList = await api.doc.fields.list({ sessionId });
195-
const initialItems = unwrap<any[]>(initialList)?.items ?? initialList?.items ?? [];
196-
const numwordsField = initialItems.find((item: any) => {
197-
const domain = item?.domain ?? item;
198-
return domain?.fieldType === 'NUMWORDS';
199-
});
202+
const initialItems = await listFields(sessionId);
203+
const numwordsField = findFieldByType(initialItems, 'NUMWORDS');
200204

201205
expect(numwordsField).toBeTruthy();
202206

203-
const initialResolvedText = numwordsField?.domain?.resolvedText ?? numwordsField?.resolvedText ?? '';
207+
const initialResolvedText = numwordsField?.resolvedText ?? '';
204208

205209
// Append text to change the word count
206210
await api.doc.create.paragraph({
@@ -209,32 +213,25 @@ describe('word-stat-fields roundtrip', () => {
209213
text: 'These extra words change the count significantly',
210214
});
211215

212-
// Rebuild the NUMWORDS field
213-
const address = numwordsField?.domain?.address ?? numwordsField?.address;
214-
if (address) {
215-
await api.doc.fields.rebuild({ sessionId, target: address });
216+
const address = numwordsField?.address;
217+
expect(address).toBeTruthy();
216218

217-
// Check the value changed
218-
const updatedList = await api.doc.fields.list({ sessionId });
219-
const updatedItems = unwrap<any[]>(updatedList)?.items ?? updatedList?.items ?? [];
220-
const updatedNumwords = updatedItems.find((item: any) => {
221-
const domain = item?.domain ?? item;
222-
return domain?.fieldType === 'NUMWORDS';
223-
});
219+
await api.doc.fields.rebuild({ sessionId, target: address });
224220

225-
const updatedResolvedText = updatedNumwords?.domain?.resolvedText ?? updatedNumwords?.resolvedText ?? '';
221+
const updatedItems = await listFields(sessionId);
222+
const updatedNumwords = findFieldByType(updatedItems, 'NUMWORDS');
223+
const updatedResolvedText = updatedNumwords?.resolvedText ?? '';
226224

227-
// After adding words, the count should be different from the original
228-
expect(updatedResolvedText).not.toBe(initialResolvedText);
229-
}
225+
// After adding words, the count should be different from the original
226+
expect(updatedResolvedText).not.toBe(initialResolvedText);
230227

231228
// Save and re-inspect OOXML
232229
const savedPath = outPath('phase-d-exported.docx');
233230
await saveSession(sessionId, savedPath);
234231

235232
const documentXml = await readDocxPart(savedPath, 'word/document.xml');
236-
const instructions = extractFieldInstructions(documentXml);
237-
expect(instructions.some((instr) => instr.includes('NUMWORDS'))).toBe(true);
233+
const exportedFields = extractExportedComplexFields(documentXml);
234+
expect(exportedFields.some((field) => field.fieldType === 'NUMWORDS')).toBe(true);
238235

239236
await api.doc.close({ sessionId, discard: true });
240237
});
@@ -255,14 +252,7 @@ describe('word-stat-fields roundtrip', () => {
255252
// Reopen the exported file
256253
const secondSessionId = sid('phase-e-second');
257254
await openSession(firstSavedPath, secondSessionId);
258-
259-
const listResult = await api.doc.fields.list({ sessionId: secondSessionId });
260-
const items = unwrap<any[]>(listResult)?.items ?? listResult?.items ?? [];
261-
262-
const fieldTypes = items.map((item: any) => {
263-
const domain = item?.domain ?? item;
264-
return domain?.fieldType;
265-
});
255+
const fieldTypes = listFieldTypes(await listFields(secondSessionId));
266256

267257
// Fields should still be discoverable after roundtrip
268258
expect(fieldTypes).toContain('NUMWORDS');

0 commit comments

Comments
 (0)