Skip to content

Commit 20a9705

Browse files
committed
chore: add doc api story test
1 parent b6c2257 commit 20a9705

1 file changed

Lines changed: 299 additions & 0 deletions

File tree

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
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

Comments
 (0)