|
| 1 | +import { execFile } from 'node:child_process'; |
| 2 | +import { promisify } from 'node:util'; |
| 3 | +import { describe, expect, it } from 'vitest'; |
| 4 | +import { unwrap, useStoryHarness } from '../harness'; |
| 5 | + |
| 6 | +const execFileAsync = promisify(execFile); |
| 7 | +const ZIP_MAX_BUFFER_BYTES = 10 * 1024 * 1024; |
| 8 | + |
| 9 | +type SectionAddress = { |
| 10 | + kind: 'section'; |
| 11 | + sectionId: string; |
| 12 | +}; |
| 13 | + |
| 14 | +type HeaderFooterKind = 'header' | 'footer'; |
| 15 | +type HeaderFooterVariant = 'default' | 'first' | 'even'; |
| 16 | + |
| 17 | +type HeaderFooterSlotTarget = { |
| 18 | + kind: 'headerFooterSlot'; |
| 19 | + section: SectionAddress; |
| 20 | + headerFooterKind: HeaderFooterKind; |
| 21 | + variant: HeaderFooterVariant; |
| 22 | +}; |
| 23 | + |
| 24 | +type HeaderFooterStoryLocator = { |
| 25 | + kind: 'story'; |
| 26 | + storyType: 'headerFooterSlot'; |
| 27 | + section: SectionAddress; |
| 28 | + headerFooterKind: HeaderFooterKind; |
| 29 | + variant: HeaderFooterVariant; |
| 30 | + onWrite: 'materializeIfInherited'; |
| 31 | +}; |
| 32 | + |
| 33 | +type HeaderFooterResolveResult = |
| 34 | + | { |
| 35 | + status: 'explicit'; |
| 36 | + refId: string; |
| 37 | + } |
| 38 | + | { |
| 39 | + status: 'inherited'; |
| 40 | + refId: string; |
| 41 | + } |
| 42 | + | { |
| 43 | + status: 'none'; |
| 44 | + }; |
| 45 | + |
| 46 | +function makeSessionId(label: string): string { |
| 47 | + return `${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; |
| 48 | +} |
| 49 | + |
| 50 | +async function readDocxPart(docPath: string, partPath: string): Promise<string> { |
| 51 | + const { stdout } = await execFileAsync('unzip', ['-p', docPath, partPath], { |
| 52 | + maxBuffer: ZIP_MAX_BUFFER_BYTES, |
| 53 | + }); |
| 54 | + return stdout; |
| 55 | +} |
| 56 | + |
| 57 | +function escapeForRegex(value: string): string { |
| 58 | + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| 59 | +} |
| 60 | + |
| 61 | +function normalizeRelationshipTarget(target: string): string { |
| 62 | + const trimmedTarget = target.replace(/^\/+/, ''); |
| 63 | + return trimmedTarget.startsWith('word/') ? trimmedTarget : `word/${trimmedTarget}`; |
| 64 | +} |
| 65 | + |
| 66 | +function extractRelationshipTarget(relsXml: string, refId: string): string { |
| 67 | + const relationshipPattern = new RegExp( |
| 68 | + `<Relationship\\b[^>]*Id="${escapeForRegex(refId)}"[^>]*Target="([^"]+)"`, |
| 69 | + 'i', |
| 70 | + ); |
| 71 | + const match = relsXml.match(relationshipPattern); |
| 72 | + if (!match?.[1]) { |
| 73 | + throw new Error(`Unable to resolve relationship target for refId "${refId}".`); |
| 74 | + } |
| 75 | + return normalizeRelationshipTarget(match[1]); |
| 76 | +} |
| 77 | + |
| 78 | +function expectMutationSuccess(operationId: string, result: any): void { |
| 79 | + if (result?.success === true || result?.receipt?.success === true) { |
| 80 | + return; |
| 81 | + } |
| 82 | + |
| 83 | + const code = result?.failure?.code ?? result?.receipt?.failure?.code ?? 'UNKNOWN'; |
| 84 | + throw new Error(`${operationId} did not report success (code: ${code}).`); |
| 85 | +} |
| 86 | + |
| 87 | +async function unwrapResult<T>(promise: Promise<unknown>): Promise<T> { |
| 88 | + return unwrap<T>(await promise); |
| 89 | +} |
| 90 | + |
| 91 | +function requireFirstSectionAddress(sectionsResult: any): SectionAddress { |
| 92 | + const section = sectionsResult?.items?.[0]?.address; |
| 93 | + if (section?.kind !== 'section' || typeof section.sectionId !== 'string') { |
| 94 | + throw new Error('Unable to resolve the first section address from sections.list.'); |
| 95 | + } |
| 96 | + return section as SectionAddress; |
| 97 | +} |
| 98 | + |
| 99 | +function createHeaderFooterTargets( |
| 100 | + section: SectionAddress, |
| 101 | + kind: HeaderFooterKind, |
| 102 | + variant: HeaderFooterVariant = 'default', |
| 103 | +): { |
| 104 | + slot: HeaderFooterSlotTarget; |
| 105 | + story: HeaderFooterStoryLocator; |
| 106 | +} { |
| 107 | + return { |
| 108 | + slot: { |
| 109 | + kind: 'headerFooterSlot', |
| 110 | + section, |
| 111 | + headerFooterKind: kind, |
| 112 | + variant, |
| 113 | + }, |
| 114 | + story: { |
| 115 | + kind: 'story', |
| 116 | + storyType: 'headerFooterSlot', |
| 117 | + section, |
| 118 | + headerFooterKind: kind, |
| 119 | + variant, |
| 120 | + onWrite: 'materializeIfInherited', |
| 121 | + }, |
| 122 | + }; |
| 123 | +} |
| 124 | + |
| 125 | +function expectExplicitRef( |
| 126 | + operationId: string, |
| 127 | + result: HeaderFooterResolveResult, |
| 128 | +): { status: 'explicit'; refId: string } { |
| 129 | + if (result.status !== 'explicit' || typeof result.refId !== 'string' || result.refId.length === 0) { |
| 130 | + throw new Error(`${operationId} did not resolve to an explicit header/footer ref.`); |
| 131 | + } |
| 132 | + return result; |
| 133 | +} |
| 134 | + |
| 135 | +describe('document-api story: blank doc body/header/footer write roundtrip', () => { |
| 136 | + const { client, outPath } = useStoryHarness('header-footers/blank-doc-write-roundtrip', { |
| 137 | + preserveResults: true, |
| 138 | + }); |
| 139 | + |
| 140 | + it('writes body, header, and footer content from a blank doc and roundtrips it through save + reopen', async () => { |
| 141 | + const sessionId = makeSessionId('blank-doc-hf-roundtrip'); |
| 142 | + const reopenSessionId = makeSessionId('blank-doc-hf-roundtrip-reopen'); |
| 143 | + |
| 144 | + const bodyTextBefore = 'Body text inserted before header and footer creation.'; |
| 145 | + const bodyTextAfter = 'Body text inserted after header and footer creation.'; |
| 146 | + const headerText = 'Header text inserted by blank-doc story.'; |
| 147 | + const footerText = 'Footer text inserted by blank-doc story.'; |
| 148 | + |
| 149 | + await client.doc.open({ sessionId }); |
| 150 | + |
| 151 | + const sectionsResult = await unwrapResult<any>(client.doc.sections.list({ sessionId })); |
| 152 | + const firstSection = requireFirstSectionAddress(sectionsResult); |
| 153 | + |
| 154 | + const header = createHeaderFooterTargets(firstSection, 'header'); |
| 155 | + const footer = createHeaderFooterTargets(firstSection, 'footer'); |
| 156 | + |
| 157 | + const initialHeaderResolution = (await unwrapResult<HeaderFooterResolveResult>( |
| 158 | + client.doc.headerFooters.resolve({ |
| 159 | + sessionId, |
| 160 | + target: header.slot, |
| 161 | + }), |
| 162 | + )) as HeaderFooterResolveResult; |
| 163 | + const initialFooterResolution = (await unwrapResult<HeaderFooterResolveResult>( |
| 164 | + client.doc.headerFooters.resolve({ |
| 165 | + sessionId, |
| 166 | + target: footer.slot, |
| 167 | + }), |
| 168 | + )) as HeaderFooterResolveResult; |
| 169 | + |
| 170 | + expect(initialHeaderResolution.status).toBe('none'); |
| 171 | + expect(initialFooterResolution.status).toBe('none'); |
| 172 | + |
| 173 | + expectMutationSuccess( |
| 174 | + 'insert body (before)', |
| 175 | + await unwrapResult<any>( |
| 176 | + client.doc.insert({ |
| 177 | + sessionId, |
| 178 | + value: bodyTextBefore, |
| 179 | + }), |
| 180 | + ), |
| 181 | + ); |
| 182 | + |
| 183 | + expectMutationSuccess( |
| 184 | + 'insert header text', |
| 185 | + await unwrapResult<any>( |
| 186 | + client.doc.insert({ |
| 187 | + sessionId, |
| 188 | + in: header.story, |
| 189 | + value: headerText, |
| 190 | + }), |
| 191 | + ), |
| 192 | + ); |
| 193 | + |
| 194 | + expectMutationSuccess( |
| 195 | + 'insert footer text', |
| 196 | + await unwrapResult<any>( |
| 197 | + client.doc.insert({ |
| 198 | + sessionId, |
| 199 | + in: footer.story, |
| 200 | + value: footerText, |
| 201 | + }), |
| 202 | + ), |
| 203 | + ); |
| 204 | + |
| 205 | + expectMutationSuccess( |
| 206 | + 'insert body (after)', |
| 207 | + await unwrapResult<any>( |
| 208 | + client.doc.insert({ |
| 209 | + sessionId, |
| 210 | + value: bodyTextAfter, |
| 211 | + }), |
| 212 | + ), |
| 213 | + ); |
| 214 | + |
| 215 | + const headerResolution = expectExplicitRef( |
| 216 | + 'headerFooters.resolve(header)', |
| 217 | + (await unwrapResult<HeaderFooterResolveResult>( |
| 218 | + client.doc.headerFooters.resolve({ |
| 219 | + sessionId, |
| 220 | + target: header.slot, |
| 221 | + }), |
| 222 | + )) as HeaderFooterResolveResult, |
| 223 | + ); |
| 224 | + const footerResolution = expectExplicitRef( |
| 225 | + 'headerFooters.resolve(footer)', |
| 226 | + (await unwrapResult<HeaderFooterResolveResult>( |
| 227 | + client.doc.headerFooters.resolve({ |
| 228 | + sessionId, |
| 229 | + target: footer.slot, |
| 230 | + }), |
| 231 | + )) as HeaderFooterResolveResult, |
| 232 | + ); |
| 233 | + |
| 234 | + const liveBodyText = await client.doc.getText({ sessionId }); |
| 235 | + const liveHeaderText = await client.doc.getText({ sessionId, in: header.story }); |
| 236 | + const liveFooterText = await client.doc.getText({ sessionId, in: footer.story }); |
| 237 | + |
| 238 | + expect(liveBodyText).toContain(bodyTextBefore); |
| 239 | + expect(liveBodyText).toContain(bodyTextAfter); |
| 240 | + expect(liveHeaderText).toContain(headerText); |
| 241 | + expect(liveFooterText).toContain(footerText); |
| 242 | + |
| 243 | + const headerParts = await unwrapResult<any>(client.doc.headerFooters.parts.list({ sessionId, kind: 'header' })); |
| 244 | + const footerParts = await unwrapResult<any>(client.doc.headerFooters.parts.list({ sessionId, kind: 'footer' })); |
| 245 | + |
| 246 | + expect(headerParts.items.some((item) => item.refId === headerResolution.refId)).toBe(true); |
| 247 | + expect(footerParts.items.some((item) => item.refId === footerResolution.refId)).toBe(true); |
| 248 | + |
| 249 | + const outputDocPath = outPath('blank-doc-body-header-footer-roundtrip.docx'); |
| 250 | + await client.doc.save({ |
| 251 | + sessionId, |
| 252 | + out: outputDocPath, |
| 253 | + force: true, |
| 254 | + }); |
| 255 | + |
| 256 | + const documentXml = await readDocxPart(outputDocPath, 'word/document.xml'); |
| 257 | + const relationshipsXml = await readDocxPart(outputDocPath, 'word/_rels/document.xml.rels'); |
| 258 | + |
| 259 | + expect(documentXml).toContain(bodyTextBefore); |
| 260 | + expect(documentXml).toContain(bodyTextAfter); |
| 261 | + expect(documentXml).toContain(`r:id="${headerResolution.refId}"`); |
| 262 | + expect(documentXml).toContain(`r:id="${footerResolution.refId}"`); |
| 263 | + |
| 264 | + const headerPartPath = extractRelationshipTarget(relationshipsXml, headerResolution.refId); |
| 265 | + const footerPartPath = extractRelationshipTarget(relationshipsXml, footerResolution.refId); |
| 266 | + |
| 267 | + const headerXml = await readDocxPart(outputDocPath, headerPartPath); |
| 268 | + const footerXml = await readDocxPart(outputDocPath, footerPartPath); |
| 269 | + |
| 270 | + expect(headerXml).toContain(headerText); |
| 271 | + expect(footerXml).toContain(footerText); |
| 272 | + |
| 273 | + await client.doc.open({ |
| 274 | + doc: outputDocPath, |
| 275 | + sessionId: reopenSessionId, |
| 276 | + }); |
| 277 | + |
| 278 | + const reopenedSectionsResult = await unwrapResult<any>(client.doc.sections.list({ sessionId: reopenSessionId })); |
| 279 | + const reopenedFirstSection = requireFirstSectionAddress(reopenedSectionsResult); |
| 280 | + |
| 281 | + const reopenedHeader = createHeaderFooterTargets(reopenedFirstSection, 'header'); |
| 282 | + const reopenedFooter = createHeaderFooterTargets(reopenedFirstSection, 'footer'); |
| 283 | + |
| 284 | + const reopenedBodyText = await client.doc.getText({ sessionId: reopenSessionId }); |
| 285 | + const reopenedHeaderText = await client.doc.getText({ |
| 286 | + sessionId: reopenSessionId, |
| 287 | + in: reopenedHeader.story, |
| 288 | + }); |
| 289 | + const reopenedFooterText = await client.doc.getText({ |
| 290 | + sessionId: reopenSessionId, |
| 291 | + in: reopenedFooter.story, |
| 292 | + }); |
| 293 | + |
| 294 | + expect(reopenedBodyText).toContain(bodyTextBefore); |
| 295 | + expect(reopenedBodyText).toContain(bodyTextAfter); |
| 296 | + expect(reopenedHeaderText).toContain(headerText); |
| 297 | + expect(reopenedFooterText).toContain(footerText); |
| 298 | + }); |
| 299 | +}); |
0 commit comments