diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 7ab46d37db..bb68cc1a70 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -2901,6 +2901,9 @@ export class Editor extends EventEmitter { const numberingData = this.converter.convertedXml['word/numbering.xml']; const numbering = this.converter.schemaToXml(numberingData.elements[0]); + const appXmlData = this.converter.convertedXml['docProps/app.xml']; + const appXml = appXmlData?.elements?.[0] ? this.converter.schemaToXml(appXmlData.elements[0]) : null; + // Export core.xml (contains dcterms:created timestamp) const coreXmlData = this.converter.convertedXml['docProps/core.xml']; const coreXml = coreXmlData?.elements?.[0] ? this.converter.schemaToXml(coreXmlData.elements[0]) : null; @@ -2913,6 +2916,7 @@ export class Editor extends EventEmitter { 'word/numbering.xml': String(numbering), 'word/styles.xml': String(styles), ...updatedHeadersFooters, + ...(appXml ? { 'docProps/app.xml': String(appXml) } : {}), ...(coreXml ? { 'docProps/core.xml': String(coreXml) } : {}), }; diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 939984ff10..969f43d1bf 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -21,7 +21,7 @@ import { } from './v2/exporter/commentsExporter.js'; import { prepareFootnotesXmlForExport } from './v2/exporter/footnotesExporter.js'; import { writeAppStatistics } from '../../document-api-adapters/helpers/app-properties.js'; -import { getWordStatistics } from '../../document-api-adapters/helpers/word-statistics.js'; +import { getWordStatistics, resolveMainBodyEditor } from '../../document-api-adapters/helpers/word-statistics.js'; import { refreshAllStatFields } from '../../document-api-adapters/helpers/refresh-stat-fields.js'; import { ensureSettingsRoot, hasUpdateFields, setUpdateFields } from '../../document-api-adapters/document-settings.js'; import { importFootnoteData, importEndnoteData } from './v2/importer/documentFootnotesImporter.js'; @@ -1334,7 +1334,12 @@ class SuperConverter { if (!editor) return; try { - const stats = getWordStatistics(editor); + // docProps/app.xml is document-scoped metadata. When export runs from a + // linked child editor (for example a header/footer editor), compute the + // statistics from the main body editor so package-level counts stay + // aligned with Word's document-level stat-field semantics. + const statsEditor = resolveMainBodyEditor(editor); + const stats = getWordStatistics(statsEditor); writeAppStatistics(this.convertedXml, stats); // Only set w:updateFields when the document actually contains a diff --git a/packages/super-editor/src/tests/regression/opc-package-metadata-roundtrip.test.js b/packages/super-editor/src/tests/regression/opc-package-metadata-roundtrip.test.js index 82432a2a74..300ad4abf5 100644 --- a/packages/super-editor/src/tests/regression/opc-package-metadata-roundtrip.test.js +++ b/packages/super-editor/src/tests/regression/opc-package-metadata-roundtrip.test.js @@ -17,11 +17,31 @@ const TEST_DOC = 'blank-doc.docx'; const CT_CUSTOM = 'application/vnd.openxmlformats-officedocument.custom-properties+xml'; const REL_CUSTOM = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties'; +const WORD_STAT_TEXT = 'Alpha beta gamma'; +const PARENT_WORD_STAT_TEXT = 'Alpha beta gamma delta'; +const CHILD_ONLY_TEXT = 'Header words only'; + +function readXmlTagValue(xml, tagName) { + const match = xml.match(new RegExp(`<${tagName}>([^<]*)`)); + return match?.[1] ?? null; +} + +function readAppStatistics(xml) { + return { + words: readXmlTagValue(xml, 'Words'), + characters: readXmlTagValue(xml, 'Characters'), + charactersWithSpaces: readXmlTagValue(xml, 'CharactersWithSpaces'), + }; +} + +async function createHeadlessEditor(testDoc = TEST_DOC) { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(testDoc); + return initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); +} describe('OPC package metadata: custom-properties registration', () => { it('getUpdatedDocs includes correct [Content_Types].xml and _rels/.rels for new custom.xml', async () => { - const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(TEST_DOC); - const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + const { editor } = await createHeadlessEditor(); try { const updatedDocs = await editor.exportDocx({ getUpdatedDocs: true }); @@ -48,8 +68,7 @@ describe('OPC package metadata: custom-properties registration', () => { }); it('zipped export includes valid package metadata for new custom.xml', async () => { - const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(TEST_DOC); - const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + const { editor } = await createHeadlessEditor(); try { const exportedBuffer = await editor.exportDocx({ compression: 'STORE' }); @@ -86,8 +105,7 @@ describe('OPC package metadata: custom-properties registration', () => { }); it('preserves existing managed registrations without duplication', async () => { - const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(TEST_DOC); - const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + const { editor } = await createHeadlessEditor(); try { const updatedDocs = await editor.exportDocx({ getUpdatedDocs: true }); @@ -122,4 +140,52 @@ describe('OPC package metadata: custom-properties registration', () => { editor.destroy(); } }); + + it('getUpdatedDocs includes refreshed docProps/app.xml statistics', async () => { + const { editor } = await createHeadlessEditor(); + + try { + editor.commands.insertContent(WORD_STAT_TEXT); + + const updatedDocs = await editor.exportDocx({ getUpdatedDocs: true }); + const appXml = updatedDocs['docProps/app.xml']; + + expect(appXml).toBeTruthy(); + expect(readAppStatistics(appXml)).toEqual({ + words: '3', + characters: '14', + charactersWithSpaces: '16', + }); + } finally { + editor.destroy(); + } + }); + + it('linked child exports keep docProps/app.xml statistics scoped to the main document', async () => { + const { editor } = await createHeadlessEditor(); + let childEditor = null; + + try { + editor.commands.insertContent(PARENT_WORD_STAT_TEXT); + + childEditor = editor.createChildEditor({ + isHeadless: true, + isHeaderOrFooter: true, + }); + childEditor.commands.insertContent(CHILD_ONLY_TEXT); + + const updatedDocs = await childEditor.exportDocx({ getUpdatedDocs: true }); + const appXml = updatedDocs['docProps/app.xml']; + + expect(appXml).toBeTruthy(); + expect(readAppStatistics(appXml)).toEqual({ + words: '4', + characters: '19', + charactersWithSpaces: '22', + }); + } finally { + childEditor?.destroy(); + editor.destroy(); + } + }); }); diff --git a/tests/doc-api-stories/tests/content-controls/all-commands.ts b/tests/doc-api-stories/tests/content-controls/all-commands.ts index 4e835b75b2..c7c66c14b3 100644 --- a/tests/doc-api-stories/tests/content-controls/all-commands.ts +++ b/tests/doc-api-stories/tests/content-controls/all-commands.ts @@ -84,6 +84,7 @@ const ALL_CONTENT_CONTROL_COMMAND_IDS = [ 'contentControls.group.wrap', 'contentControls.group.ungroup', ] as const; +const COMMAND_STORY_TIMEOUT_MS = 60_000; type ContentControlsCommandId = (typeof ALL_CONTENT_CONTROL_COMMAND_IDS)[number]; @@ -2082,30 +2083,34 @@ describe('document-api story: all content-controls commands', () => { }); for (const scenario of scenarios) { - it(`${scenario.operationId}: executes and saves source/result docs`, async () => { - const sessionId = makeSessionId(scenario.operationId.replace(/\./g, '-')); + it( + `${scenario.operationId}: executes and saves source/result docs`, + async () => { + const sessionId = makeSessionId(scenario.operationId.replace(/\./g, '-')); - try { - await openSeedDocument(sessionId, scenario.seedDoc ?? BASE_CONTENT_CONTROLS_DOC); + try { + await openSeedDocument(sessionId, scenario.seedDoc ?? BASE_CONTENT_CONTROLS_DOC); - const fixture = scenario.prepare ? await scenario.prepare(sessionId) : null; + const fixture = scenario.prepare ? await scenario.prepare(sessionId) : null; - await saveSource(sessionId, scenario.operationId); + await saveSource(sessionId, scenario.operationId); - const result = await scenario.run(sessionId, fixture); + const result = await scenario.run(sessionId, fixture); - if (READ_OPERATION_IDS.has(scenario.operationId)) { - assertReadShape(scenario.operationId, result); - await saveReadOutput(scenario.operationId, result); - } else { - assertMutationSuccess(scenario.operationId, result, scenario.allowNoOpFailure === true); - } + if (READ_OPERATION_IDS.has(scenario.operationId)) { + assertReadShape(scenario.operationId, result); + await saveReadOutput(scenario.operationId, result); + } else { + assertMutationSuccess(scenario.operationId, result, scenario.allowNoOpFailure === true); + } - await saveResult(sessionId, scenario.operationId); - } finally { - await closeSession(sessionId).catch(() => {}); - } - }); + await saveResult(sessionId, scenario.operationId); + } finally { + await closeSession(sessionId).catch(() => {}); + } + }, + COMMAND_STORY_TIMEOUT_MS, + ); } it('writes source/result artifacts for every content-controls command', async () => { diff --git a/tests/doc-api-stories/tests/fields/word-stat-fields-roundtrip.ts b/tests/doc-api-stories/tests/fields/word-stat-fields-roundtrip.ts index c6d52d8402..16e3f76c31 100644 --- a/tests/doc-api-stories/tests/fields/word-stat-fields-roundtrip.ts +++ b/tests/doc-api-stories/tests/fields/word-stat-fields-roundtrip.ts @@ -13,6 +13,19 @@ const FIXTURE_DOC = path.resolve(import.meta.dirname, 'fixtures', 'numwords.docx // OOXML inspection helpers (local to this story) // --------------------------------------------------------------------------- +type ExportedComplexField = { + fieldType: string; + instruction: string; + cachedText: string; + dirty: boolean; +}; + +type StoryField = { + address?: unknown; + fieldType?: string; + resolvedText?: string; +}; + async function readDocxPart(docPath: string, partPath: string): Promise { const { stdout } = await execFileAsync('unzip', ['-p', docPath, partPath], { maxBuffer: ZIP_MAX_BUFFER_BYTES, @@ -20,31 +33,29 @@ async function readDocxPart(docPath: string, partPath: string): Promise return stdout; } -/** Extracts all field instruction texts from a document.xml string. */ -function extractFieldInstructions(documentXml: string): string[] { - const matches = [...documentXml.matchAll(/]*>([^<]*)<\/w:instrText>/g)]; - return matches.map((m) => m[1].trim()); -} - -/** Extracts text elements (w:t) from field cached result runs. */ -function extractCachedFieldResults(documentXml: string): string[] { - // Find all w:t elements that appear between w:fldChar separate and end - const results: string[] = []; - const fieldRegex = /]*w:fldCharType="separate"[^>]*\/?>[\s\S]*?]*w:fldCharType="end"/g; - - for (const match of documentXml.matchAll(fieldRegex)) { - const segment = match[0]; - const textMatches = [...segment.matchAll(/]*>([^<]*)<\/w:t>/g)]; - for (const tm of textMatches) { - results.push(tm[1]); - } +function extractExportedComplexFields(documentXml: string): ExportedComplexField[] { + const complexFieldPattern = + /]*w:fldCharType="begin"([^>]*)\/?>[\s\S]*?]*>([^<]*)<\/w:instrText>[\s\S]*?]*w:fldCharType="separate"[^>]*\/?>([\s\S]*?)]*w:fldCharType="end"/g; + const exportedFields: ExportedComplexField[] = []; + + for (const match of documentXml.matchAll(complexFieldPattern)) { + const beginAttributes = match[1] ?? ''; + const instruction = (match[2] ?? '').trim(); + const cachedSegment = match[3] ?? ''; + const cachedText = [...cachedSegment.matchAll(/]*>([^<]*)<\/w:t>/g)] + .map((textMatch) => textMatch[1]) + .join(''); + const fieldType = instruction.split(/\s+/)[0]?.toUpperCase() ?? ''; + + exportedFields.push({ + fieldType, + instruction, + cachedText, + dirty: beginAttributes.includes('w:dirty="true"'), + }); } - return results; -} -/** Checks whether w:updateFields is present in settings.xml. */ -function hasUpdateFields(settingsXml: string): boolean { - return /]*w:val="true"/.test(settingsXml); + return exportedFields; } /** Extracts a simple element's text value from app.xml. */ @@ -53,11 +64,6 @@ function extractAppStat(appXml: string, tagName: string): string | null { return match?.[1] ?? null; } -/** Checks for w:dirty attribute on fldChar begin elements. */ -function hasDirtyField(documentXml: string): boolean { - return /w:dirty="true"/.test(documentXml); -} - // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- @@ -78,11 +84,28 @@ describe('word-stat-fields roundtrip', () => { const api = client as any; async function openSession(docPath: string, sessionId: string) { - await api.doc.open({ filePath: docPath, sessionId }); + await api.doc.open({ doc: docPath, sessionId }); } async function saveSession(sessionId: string, savePath: string) { - await api.doc.save({ sessionId, filePath: savePath }); + await api.doc.save({ sessionId, out: savePath, force: true }); + } + + function toStoryField(item: any): StoryField { + return (item?.domain ?? item ?? {}) as StoryField; + } + + async function listFields(sessionId: string): Promise { + const listResult = unwrap(await api.doc.fields.list({ sessionId })); + return Array.isArray(listResult?.items) ? listResult.items.map(toStoryField) : []; + } + + function listFieldTypes(items: StoryField[]): string[] { + return items.map((item) => item.fieldType ?? ''); + } + + function findFieldByType(items: StoryField[], fieldType: string): StoryField | undefined { + return items.find((item) => item.fieldType === fieldType); } // ───────────────────────────────────────────────────────────────────────── @@ -94,14 +117,7 @@ describe('word-stat-fields roundtrip', () => { const sessionId = sid('phase-a'); await openSession(docPath, sessionId); - const listResult = await api.doc.fields.list({ sessionId }); - const items = unwrap(listResult)?.items ?? listResult?.items ?? []; - - // The fixture has NUMWORDS, NUMCHARS, and NUMPAGES fields - const fieldTypes = items.map((item: any) => { - const domain = item?.domain ?? item; - return domain?.fieldType; - }); + const fieldTypes = listFieldTypes(await listFields(sessionId)); expect(fieldTypes).toContain('NUMWORDS'); expect(fieldTypes).toContain('NUMCHARS'); @@ -124,25 +140,19 @@ describe('word-stat-fields roundtrip', () => { // Inspect exported document.xml const documentXml = await readDocxPart(savedPath, 'word/document.xml'); + const exportedFields = extractExportedComplexFields(documentXml); + const exportedFieldByType = new Map(exportedFields.map((field) => [field.fieldType, field])); - // Should contain field instructions for our stat fields - const instructions = extractFieldInstructions(documentXml); - const hasNumwords = instructions.some((instr) => instr.includes('NUMWORDS')); - const hasNumchars = instructions.some((instr) => instr.includes('NUMCHARS')); - const hasNumpages = instructions.some((instr) => instr.includes('NUMPAGES')); - - expect(hasNumwords).toBe(true); - expect(hasNumchars).toBe(true); - expect(hasNumpages).toBe(true); + expect(exportedFieldByType.has('NUMWORDS')).toBe(true); + expect(exportedFieldByType.has('NUMCHARS')).toBe(true); + expect(exportedFieldByType.has('NUMPAGES')).toBe(true); // Should have fldChar structure (complex fields, not fldSimple) expect(documentXml).toContain('w:fldCharType="begin"'); expect(documentXml).toContain('w:fldCharType="separate"'); expect(documentXml).toContain('w:fldCharType="end"'); - // Should have cached result runs between separate and end - const cachedResults = extractCachedFieldResults(documentXml); - expect(cachedResults.length).toBeGreaterThanOrEqual(3); + expect(exportedFields).toHaveLength(3); // Inspect docProps/app.xml — stat values should be present and consistent const appXml = await readDocxPart(savedPath, 'docProps/app.xml'); @@ -161,17 +171,16 @@ describe('word-stat-fields roundtrip', () => { // Characters (no spaces) must be ≤ CharactersWithSpaces (internal consistency) expect(Number(charsValue)).toBeLessThanOrEqual(Number(charsWithSpaces)); - // The NUMWORDS cached result in the field should match the app.xml Words value - // (both are computed from the same helper during export) - const numwordsCachedResult = cachedResults.find((r) => r && /^\d+$/.test(r.trim())); - if (numwordsCachedResult) { - expect(wordsValue).toBe(numwordsCachedResult.trim()); - } + expect(exportedFieldByType.get('NUMWORDS')?.cachedText).toBe(wordsValue); + expect(exportedFieldByType.get('NUMCHARS')?.cachedText).toBe(charsValue); + expect(exportedFieldByType.get('NUMPAGES')?.cachedText).toBe(extractAppStat(appXml, 'Pages')); // Dirty-flag policy: NUMWORDS and NUMCHARS should NOT be dirty (no // uninterpreted switches). NUMPAGES may or may not be dirty depending // on whether pagination was available in the test environment. // We verify the structural invariant rather than a blanket dirty check. + expect(exportedFieldByType.get('NUMWORDS')?.dirty).toBe(false); + expect(exportedFieldByType.get('NUMCHARS')?.dirty).toBe(false); const settingsXml = await readDocxPart(savedPath, 'word/settings.xml').catch(() => ''); if (settingsXml) { expect(settingsXml).toContain('w:settings'); @@ -190,17 +199,12 @@ describe('word-stat-fields roundtrip', () => { await openSession(docPath, sessionId); - // Get initial field list - const initialList = await api.doc.fields.list({ sessionId }); - const initialItems = unwrap(initialList)?.items ?? initialList?.items ?? []; - const numwordsField = initialItems.find((item: any) => { - const domain = item?.domain ?? item; - return domain?.fieldType === 'NUMWORDS'; - }); + const initialItems = await listFields(sessionId); + const numwordsField = findFieldByType(initialItems, 'NUMWORDS'); expect(numwordsField).toBeTruthy(); - const initialResolvedText = numwordsField?.domain?.resolvedText ?? numwordsField?.resolvedText ?? ''; + const initialResolvedText = numwordsField?.resolvedText ?? ''; // Append text to change the word count await api.doc.create.paragraph({ @@ -209,32 +213,25 @@ describe('word-stat-fields roundtrip', () => { text: 'These extra words change the count significantly', }); - // Rebuild the NUMWORDS field - const address = numwordsField?.domain?.address ?? numwordsField?.address; - if (address) { - await api.doc.fields.rebuild({ sessionId, target: address }); + const address = numwordsField?.address; + expect(address).toBeTruthy(); - // Check the value changed - const updatedList = await api.doc.fields.list({ sessionId }); - const updatedItems = unwrap(updatedList)?.items ?? updatedList?.items ?? []; - const updatedNumwords = updatedItems.find((item: any) => { - const domain = item?.domain ?? item; - return domain?.fieldType === 'NUMWORDS'; - }); + await api.doc.fields.rebuild({ sessionId, target: address }); - const updatedResolvedText = updatedNumwords?.domain?.resolvedText ?? updatedNumwords?.resolvedText ?? ''; + const updatedItems = await listFields(sessionId); + const updatedNumwords = findFieldByType(updatedItems, 'NUMWORDS'); + const updatedResolvedText = updatedNumwords?.resolvedText ?? ''; - // After adding words, the count should be different from the original - expect(updatedResolvedText).not.toBe(initialResolvedText); - } + // After adding words, the count should be different from the original + expect(updatedResolvedText).not.toBe(initialResolvedText); // Save and re-inspect OOXML const savedPath = outPath('phase-d-exported.docx'); await saveSession(sessionId, savedPath); const documentXml = await readDocxPart(savedPath, 'word/document.xml'); - const instructions = extractFieldInstructions(documentXml); - expect(instructions.some((instr) => instr.includes('NUMWORDS'))).toBe(true); + const exportedFields = extractExportedComplexFields(documentXml); + expect(exportedFields.some((field) => field.fieldType === 'NUMWORDS')).toBe(true); await api.doc.close({ sessionId, discard: true }); }); @@ -255,14 +252,7 @@ describe('word-stat-fields roundtrip', () => { // Reopen the exported file const secondSessionId = sid('phase-e-second'); await openSession(firstSavedPath, secondSessionId); - - const listResult = await api.doc.fields.list({ sessionId: secondSessionId }); - const items = unwrap(listResult)?.items ?? listResult?.items ?? []; - - const fieldTypes = items.map((item: any) => { - const domain = item?.domain ?? item; - return domain?.fieldType; - }); + const fieldTypes = listFieldTypes(await listFields(secondSessionId)); // Fields should still be discoverable after roundtrip expect(fieldTypes).toContain('NUMWORDS'); diff --git a/tests/doc-api-stories/tests/header-footers/all-commands.ts b/tests/doc-api-stories/tests/header-footers/all-commands.ts index f35e55ccf2..c367b44a04 100644 --- a/tests/doc-api-stories/tests/header-footers/all-commands.ts +++ b/tests/doc-api-stories/tests/header-footers/all-commands.ts @@ -13,6 +13,7 @@ const ALL_HEADER_FOOTERS_COMMAND_IDS = [ 'headerFooters.parts.create', 'headerFooters.parts.delete', ] as const; +const COMMAND_STORY_TIMEOUT_MS = 60_000; type HeaderFootersCommandId = (typeof ALL_HEADER_FOOTERS_COMMAND_IDS)[number]; @@ -470,21 +471,25 @@ describe('document-api story: all header/footer commands', () => { }); for (const scenario of scenarios) { - it(`${scenario.operationId}: executes and saves source/result docs`, async () => { - const sourceDoc = outPath(sourceDocNameFor(scenario.operationId)); - const resultDoc = outPath(resultDocNameFor(scenario.operationId)); - - const fixture = await scenario.prepareSource(sourceDoc); - - const result = await scenario.run(sourceDoc, resultDoc, fixture); - - if (readOperationIds.has(scenario.operationId)) { - assertReadOutput(scenario.operationId, result); - await saveReadOutput(scenario.operationId, result); - await copyFile(sourceDoc, resultDoc); - } else { - assertMutationSuccess(scenario.operationId, result); - } - }); + it( + `${scenario.operationId}: executes and saves source/result docs`, + async () => { + const sourceDoc = outPath(sourceDocNameFor(scenario.operationId)); + const resultDoc = outPath(resultDocNameFor(scenario.operationId)); + + const fixture = await scenario.prepareSource(sourceDoc); + + const result = await scenario.run(sourceDoc, resultDoc, fixture); + + if (readOperationIds.has(scenario.operationId)) { + assertReadOutput(scenario.operationId, result); + await saveReadOutput(scenario.operationId, result); + await copyFile(sourceDoc, resultDoc); + } else { + assertMutationSuccess(scenario.operationId, result); + } + }, + COMMAND_STORY_TIMEOUT_MS, + ); } }); diff --git a/tests/doc-api-stories/tests/hyperlinks/all-commands.ts b/tests/doc-api-stories/tests/hyperlinks/all-commands.ts index 912954d402..034059afbe 100644 --- a/tests/doc-api-stories/tests/hyperlinks/all-commands.ts +++ b/tests/doc-api-stories/tests/hyperlinks/all-commands.ts @@ -10,6 +10,7 @@ const ALL_HYPERLINK_COMMAND_IDS = [ 'hyperlinks.patch', 'hyperlinks.remove', ] as const; +const COMMAND_STORY_TIMEOUT_MS = 60_000; type HyperlinksCommandId = (typeof ALL_HYPERLINK_COMMAND_IDS)[number]; @@ -339,28 +340,32 @@ describe('document-api story: all hyperlinks commands', () => { }); for (const scenario of scenarios) { - it(`${scenario.operationId}: executes and saves source/result docs`, async () => { - const sessionId = makeSessionId(scenario.operationId.replace(/\./g, '-')); - try { - await callDocOperation('open', { sessionId, doc: CORPUS_HYPERLINK_FIXTURE }); + it( + `${scenario.operationId}: executes and saves source/result docs`, + async () => { + const sessionId = makeSessionId(scenario.operationId.replace(/\./g, '-')); + try { + await callDocOperation('open', { sessionId, doc: CORPUS_HYPERLINK_FIXTURE }); - const fixture = scenario.prepare ? await scenario.prepare(sessionId) : null; + const fixture = scenario.prepare ? await scenario.prepare(sessionId) : null; - await saveSource(sessionId, scenario.operationId); + await saveSource(sessionId, scenario.operationId); - const result = await scenario.run(sessionId, fixture); + const result = await scenario.run(sessionId, fixture); - if (readOperationIds.has(scenario.operationId)) { - assertReadOutput(scenario.operationId, result); - await saveReadOutput(scenario.operationId, result); - } else { - assertMutationSuccess(scenario.operationId, result); - } + if (readOperationIds.has(scenario.operationId)) { + assertReadOutput(scenario.operationId, result); + await saveReadOutput(scenario.operationId, result); + } else { + assertMutationSuccess(scenario.operationId, result); + } - await saveResult(sessionId, scenario.operationId); - } finally { - await callDocOperation('close', { sessionId, discard: true }).catch(() => {}); - } - }); + await saveResult(sessionId, scenario.operationId); + } finally { + await callDocOperation('close', { sessionId, discard: true }).catch(() => {}); + } + }, + COMMAND_STORY_TIMEOUT_MS, + ); } }); diff --git a/tests/doc-api-stories/tests/lists/missing-paraid-address-stability.ts b/tests/doc-api-stories/tests/lists/missing-paraid-address-stability.ts index d788ebea49..34ee55c539 100644 --- a/tests/doc-api-stories/tests/lists/missing-paraid-address-stability.ts +++ b/tests/doc-api-stories/tests/lists/missing-paraid-address-stability.ts @@ -48,6 +48,7 @@ const LIST_FIXTURE_CANDIDATES = [ path.join(REPO_ROOT, 'devtools/document-api-tests/fixtures/matrix-list.input.docx'), path.join(REPO_ROOT, 'e2e-tests/test-data/basic-documents/lists-complex-items.docx'), ]; +const ADDRESS_STABILITY_TIMEOUT_MS = 60_000; const execFileAsync = promisify(execFile); const require = createRequire(import.meta.url); @@ -209,54 +210,65 @@ beforeEach(async () => { }); describe('document-api story: lists missing paraId address stability', () => { - it('keeps list item addresses stable for docs without paraIds across repeated reads and reopen', async () => { - const sourceDoc = await writeListFixtureWithoutParaIds(outPath('lists-without-paraids.docx')); - const firstSessionId = sid('lists-no-paraid-first'); - const reopenedSessionId = sid('lists-no-paraid-reopened'); - - try { - const statelessFirstList = extractListResult(await runCli(['lists', 'list', sourceDoc, '--limit', '20'])); - const firstAddress = expectFirstListItem(statelessFirstList); - - const statelessGetAddress = extractGetAddress( - await runCli(['lists', 'get', sourceDoc, '--address-json', JSON.stringify(firstAddress)]), - ); - expect(statelessGetAddress).toEqual(firstAddress); - - const statelessSecondList = extractListResult(await runCli(['lists', 'list', sourceDoc, '--limit', '20'])); - const secondAddress = expectFirstListItem(statelessSecondList); - expect(secondAddress).toEqual(firstAddress); - - await runCli(['open', sourceDoc, '--session', firstSessionId]); - - const sessionFirstList = extractListResult( - await runCli(['lists', 'list', '--session', firstSessionId, '--limit', '20']), - ); - const sessionFirstAddress = expectFirstListItem(sessionFirstList); - expect(sessionFirstAddress).toEqual(firstAddress); - - const sessionGetAddress = extractGetAddress( - await runCli(['lists', 'get', '--session', firstSessionId, '--address-json', JSON.stringify(firstAddress)]), - ); - expect(sessionGetAddress).toEqual(firstAddress); - - await runCli(['close', '--session', firstSessionId, '--discard']); - - await runCli(['open', sourceDoc, '--session', reopenedSessionId]); - - const reopenedGetAddress = extractGetAddress( - await runCli(['lists', 'get', '--session', reopenedSessionId, '--address-json', JSON.stringify(firstAddress)]), - ); - expect(reopenedGetAddress).toEqual(firstAddress); - - const reopenedList = extractListResult( - await runCli(['lists', 'list', '--session', reopenedSessionId, '--limit', '20']), - ); - const reopenedAddress = expectFirstListItem(reopenedList); - expect(reopenedAddress).toEqual(firstAddress); - } finally { - await runCli(['close', '--session', firstSessionId, '--discard'], { allowError: true }); - await runCli(['close', '--session', reopenedSessionId, '--discard'], { allowError: true }); - } - }, 30_000); + it( + 'keeps list item addresses stable for docs without paraIds across repeated reads and reopen', + async () => { + const sourceDoc = await writeListFixtureWithoutParaIds(outPath('lists-without-paraids.docx')); + const firstSessionId = sid('lists-no-paraid-first'); + const reopenedSessionId = sid('lists-no-paraid-reopened'); + + try { + const statelessFirstList = extractListResult(await runCli(['lists', 'list', sourceDoc, '--limit', '20'])); + const firstAddress = expectFirstListItem(statelessFirstList); + + const statelessGetAddress = extractGetAddress( + await runCli(['lists', 'get', sourceDoc, '--address-json', JSON.stringify(firstAddress)]), + ); + expect(statelessGetAddress).toEqual(firstAddress); + + const statelessSecondList = extractListResult(await runCli(['lists', 'list', sourceDoc, '--limit', '20'])); + const secondAddress = expectFirstListItem(statelessSecondList); + expect(secondAddress).toEqual(firstAddress); + + await runCli(['open', sourceDoc, '--session', firstSessionId]); + + const sessionFirstList = extractListResult( + await runCli(['lists', 'list', '--session', firstSessionId, '--limit', '20']), + ); + const sessionFirstAddress = expectFirstListItem(sessionFirstList); + expect(sessionFirstAddress).toEqual(firstAddress); + + const sessionGetAddress = extractGetAddress( + await runCli(['lists', 'get', '--session', firstSessionId, '--address-json', JSON.stringify(firstAddress)]), + ); + expect(sessionGetAddress).toEqual(firstAddress); + + await runCli(['close', '--session', firstSessionId, '--discard']); + + await runCli(['open', sourceDoc, '--session', reopenedSessionId]); + + const reopenedGetAddress = extractGetAddress( + await runCli([ + 'lists', + 'get', + '--session', + reopenedSessionId, + '--address-json', + JSON.stringify(firstAddress), + ]), + ); + expect(reopenedGetAddress).toEqual(firstAddress); + + const reopenedList = extractListResult( + await runCli(['lists', 'list', '--session', reopenedSessionId, '--limit', '20']), + ); + const reopenedAddress = expectFirstListItem(reopenedList); + expect(reopenedAddress).toEqual(firstAddress); + } finally { + await runCli(['close', '--session', firstSessionId, '--discard'], { allowError: true }); + await runCli(['close', '--session', reopenedSessionId, '--discard'], { allowError: true }); + } + }, + ADDRESS_STABILITY_TIMEOUT_MS, + ); }); diff --git a/tests/doc-api-stories/tests/tables/all-commands.ts b/tests/doc-api-stories/tests/tables/all-commands.ts index d423c32b94..bc78e8985f 100644 --- a/tests/doc-api-stories/tests/tables/all-commands.ts +++ b/tests/doc-api-stories/tests/tables/all-commands.ts @@ -1381,16 +1381,30 @@ describe('document-api story: all table commands', () => { const fixture = await setupTableFixture(sessionId); const f = requireFixture('tables.unmergeCells', fixture); - // Target a coordinate outside the table bounds. - const result = await api.doc.tables.unmergeCells({ - sessionId, - nodeId: f.tableNodeId, - rowIndex: 99, - columnIndex: 99, - }); + const attempt = await (async () => { + try { + const result = unwrap( + await api.doc.tables.unmergeCells({ + sessionId, + nodeId: f.tableNodeId, + rowIndex: 99, + columnIndex: 99, + }), + ); + + return { threw: false as const, result }; + } catch (error) { + return { threw: true as const, error }; + } + })(); + + if (attempt.threw) { + expect(attempt.error).toBeInstanceOf(Error); + expect(String(attempt.error)).toContain('out of bounds'); + return; + } - // Should fail (either thrown error caught or failure result). - const unwrapped = result?.result ?? result; - expect(unwrapped?.success).not.toBe(true); + expect(attempt.result?.success).toBe(false); + expect(attempt.result?.failure?.code ?? attempt.result?.receipt?.failure?.code).toBe('INVALID_TARGET'); }); }); diff --git a/tests/doc-api-stories/tests/toc/all-commands.ts b/tests/doc-api-stories/tests/toc/all-commands.ts index 8259ad1c07..e313ab9bc4 100644 --- a/tests/doc-api-stories/tests/toc/all-commands.ts +++ b/tests/doc-api-stories/tests/toc/all-commands.ts @@ -10,6 +10,7 @@ const ALL_TOC_COMMAND_IDS = [ 'toc.update', 'toc.remove', ] as const; +const COMMAND_STORY_TIMEOUT_MS = 60_000; type TocCommandId = (typeof ALL_TOC_COMMAND_IDS)[number]; @@ -344,21 +345,25 @@ describe('document-api story: all toc commands', () => { }); for (const scenario of scenarios) { - it(`${scenario.operationId}: executes and saves source/result docs`, async () => { - const sourceDoc = outPath(sourceDocNameFor(scenario.operationId)); - const resultDoc = outPath(resultDocNameFor(scenario.operationId)); - - const fixture = await scenario.prepareSource(sourceDoc); - - const result = await scenario.run(sourceDoc, resultDoc, fixture); - - if (readOperationIds.has(scenario.operationId)) { - assertReadOutput(scenario.operationId, result); - await saveReadOutput(scenario.operationId, result); - await copyFile(sourceDoc, resultDoc); - } else { - assertMutationSuccess(scenario.operationId, result); - } - }); + it( + `${scenario.operationId}: executes and saves source/result docs`, + async () => { + const sourceDoc = outPath(sourceDocNameFor(scenario.operationId)); + const resultDoc = outPath(resultDocNameFor(scenario.operationId)); + + const fixture = await scenario.prepareSource(sourceDoc); + + const result = await scenario.run(sourceDoc, resultDoc, fixture); + + if (readOperationIds.has(scenario.operationId)) { + assertReadOutput(scenario.operationId, result); + await saveReadOutput(scenario.operationId, result); + await copyFile(sourceDoc, resultDoc); + } else { + assertMutationSuccess(scenario.operationId, result); + } + }, + COMMAND_STORY_TIMEOUT_MS, + ); } });