Skip to content

Commit 1515843

Browse files
authored
test(behavior): document-api locked SDT mutations (SD-3144) (#3291)
* fix(content-controls): mutate locked SDTs via AttrSteps and inner-range writes (SD-3123) Per ECMA-376, sdtLocked protects the SDT wrapper from removal but the content stays editable. Before this fix, programmatic mutations flowed through editor.commands.updateStructuredContentById, which dispatches tr.replaceWith(pos, pos + node.nodeSize, ...) spanning the entire SDT. The structured-content lock plugin's filterTransaction reads that as wrapper damage and silently filters the transaction on sdtLocked controls - producing false-success no-ops on operations the API allows (text.setValue, replaceContent, appendContent, prependContent, setLockMode). Two engine changes: - applyAttrsUpdate (sdt-properties-write.ts) - switch from editor.commands.updateStructuredContentById to per-key tr.setNodeAttribute. AttrSteps have no from/to range and are explicitly skipped by the lock plugin's filterTransaction. This is the path that patch, setLockMode, patchRawProperties, setType, and the date/binding/multiline family use. - replaceSdtTextContent (content-controls-wrappers.ts) - replace the SDT's inner content range (pos+1 to pos+nodeSize-1) instead of rewriting the whole wrapper. The lock plugin classifies inner-range steps as wouldModifyContent: allowed on sdtLocked, still blocked on contentLocked / sdtContentLocked (both per spec). The search in applyAttrsUpdate uses SDT_NODE_NAMES (imported from target-resolution.ts) so it resolves the same nodes the upstream resolveSdtByTarget would resolve. documentSection / documentPartObject are intentionally excluded - they have their own write paths and could collide on id. No changes to API-level lock guards (assertNotSdtLocked on patch, setType, setBinding, etc.) - relaxing those is a separate behavior decision and out of scope here. Tests: - 4 new regression tests for sdtLocked (setLockMode, text.setValue, text.clearValue, replaceContent) that assert the actual inner-range positions (pos+1, pos+nodeSize-1), not just call counts. - 11 existing tests migrated from "expect updateStructuredContentById called" to "expect tr.setNodeAttribute / tr.replaceWith / tr.delete". - 19 metadata-mutation operations added to CC_DIRECT_DISPATCH_OPS in conformance (no synthetic-failure mode after this refactor, matching the existing precedent for wrap/unwrap/etc.). All pass: 46 wrappers + 1170 conformance + 104 SDT-adjacent + 1398 document-api. Zero new TypeScript errors. Related: SD-3131, SD-3139, IT-1046. * test(content-controls): add clearContent sdtLocked regression test (SD-3123) * test(behavior): document-api locked SDT mutations (SD-3144) Real-editor regression coverage for SD-3123 (#3287). The engine fix was verified at the unit-test level (mock transaction shapes: AttrSteps for metadata, inner-range ReplaceSteps for content), but mocks don't run the real structured-content lock plugin's filterTransaction. This spec closes that gap by exercising the customer surface (editor.doc.contentControls.*) in a real browser with the real lock plugin loaded. Five tests: - text.setValue updates an sdtLocked inline plain-text control - replaceContent updates an sdtLocked block rich-text control - clearContent empties an sdtLocked block control (wrapper preserved) - round-trip sdtLocked → unlocked → text.setValue (lockMode AttrStep writes) - contentLocked + text.setValue returns LOCK_VIOLATION (API guard fires) Lives in tests/behavior/tests/sdt/document-api-locked-mutations.spec.ts. Does NOT un-fixme the older sdt-lock-modes.spec.ts (different concern — that one exercises editor.commands.*, SD-3123 is about the Document API). 5/5 pass on chromium. Stacked on SD-3123 (#3287).
1 parent cf75357 commit 1515843

1 file changed

Lines changed: 235 additions & 0 deletions

File tree

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/**
2+
* Real-editor regression for SD-3123: programmatic content edits on sdtLocked
3+
* SDTs must flow through (the wrapper is protected, but the content is
4+
* editable per OOXML spec). Before SD-3123, all paths dispatched a
5+
* full-wrapper `tr.replaceWith(pos, pos + nodeSize, ...)` which the
6+
* structured-content lock plugin's `filterTransaction` read as wrapper damage
7+
* and silently filtered — producing false-success no-ops. Mock-based unit
8+
* tests prove the new transaction shapes (AttrSteps for metadata, inner-range
9+
* ReplaceSteps for content), but they do not run the real lock plugin.
10+
* These tests close that gap.
11+
*
12+
* Operates through `editor.doc.contentControls.*` (the customer surface),
13+
* not `editor.commands.*`. Reads results back through `getContent` and the
14+
* painter-rendered DOM rather than PM internals.
15+
*/
16+
17+
import { test, expect } from '../../fixtures/superdoc.js';
18+
19+
const BLOCK_SDT = '.superdoc-structured-content-block';
20+
const INLINE_SDT = '.superdoc-structured-content-inline';
21+
22+
type LockMode = 'unlocked' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked';
23+
type ContentControlTarget = { kind: 'inline' | 'block'; nodeType: 'sdt'; nodeId: string };
24+
type MutationResult =
25+
| { success: true; contentControl: ContentControlTarget }
26+
| { success: false; failure: { code: string; message?: string } };
27+
type GetContentResult = { content: string; format: 'text' | 'html' };
28+
29+
interface CreateOpts {
30+
kind: 'inline' | 'block';
31+
controlType?: 'text' | 'richText';
32+
alias: string;
33+
content?: string;
34+
}
35+
36+
async function createControl(superdoc: any, opts: CreateOpts): Promise<ContentControlTarget> {
37+
const result = await superdoc.page.evaluate((o: CreateOpts) => {
38+
const r = (window as any).editor.doc.create.contentControl(
39+
{ kind: o.kind, controlType: o.controlType, alias: o.alias, content: o.content },
40+
{ changeMode: 'direct' },
41+
);
42+
if (!r.success) throw new Error(`create failed: ${r.failure?.code} ${r.failure?.message}`);
43+
return r.contentControl;
44+
}, opts);
45+
return result as ContentControlTarget;
46+
}
47+
48+
async function setLock(superdoc: any, target: ContentControlTarget, lockMode: LockMode): Promise<MutationResult> {
49+
return superdoc.page.evaluate(
50+
({ target, lockMode }: { target: ContentControlTarget; lockMode: LockMode }) =>
51+
(window as any).editor.doc.contentControls.setLockMode({ target, lockMode }, { changeMode: 'direct' }),
52+
{ target, lockMode },
53+
);
54+
}
55+
56+
async function textSetValue(superdoc: any, target: ContentControlTarget, value: string): Promise<MutationResult> {
57+
return superdoc.page.evaluate(
58+
({ target, value }: { target: ContentControlTarget; value: string }) =>
59+
(window as any).editor.doc.contentControls.text.setValue({ target, value }, { changeMode: 'direct' }),
60+
{ target, value },
61+
);
62+
}
63+
64+
async function replaceContent(superdoc: any, target: ContentControlTarget, content: string): Promise<MutationResult> {
65+
return superdoc.page.evaluate(
66+
({ target, content }: { target: ContentControlTarget; content: string }) =>
67+
(window as any).editor.doc.contentControls.replaceContent(
68+
{ target, content, format: 'text' },
69+
{ changeMode: 'direct' },
70+
),
71+
{ target, content },
72+
);
73+
}
74+
75+
async function clearContent(superdoc: any, target: ContentControlTarget): Promise<MutationResult> {
76+
return superdoc.page.evaluate(
77+
({ target }: { target: ContentControlTarget }) =>
78+
(window as any).editor.doc.contentControls.clearContent({ target }, { changeMode: 'direct' }),
79+
{ target },
80+
);
81+
}
82+
83+
async function getContent(superdoc: any, target: ContentControlTarget): Promise<GetContentResult> {
84+
return superdoc.page.evaluate(
85+
({ target }: { target: ContentControlTarget }) => (window as any).editor.doc.contentControls.getContent({ target }),
86+
{ target },
87+
);
88+
}
89+
90+
// ===========================================================================
91+
// sdtLocked: wrapper protected, content editable
92+
// ===========================================================================
93+
94+
test.describe('SD-3123: Document API mutations on sdtLocked content controls', () => {
95+
test('text.setValue updates an sdtLocked inline plain-text control', async ({ superdoc }) => {
96+
const target = await createControl(superdoc, {
97+
kind: 'inline',
98+
controlType: 'text',
99+
alias: 'Locked plain-text',
100+
content: 'initial',
101+
});
102+
await superdoc.waitForStable();
103+
104+
const lockResult = await setLock(superdoc, target, 'sdtLocked');
105+
expect(lockResult.success).toBe(true);
106+
await superdoc.waitForStable();
107+
108+
const before = await getContent(superdoc, target);
109+
expect(before.content).toBe('initial');
110+
111+
const setResult = await textSetValue(superdoc, target, 'updated');
112+
expect(setResult.success).toBe(true);
113+
await superdoc.waitForStable();
114+
115+
const after = await getContent(superdoc, target);
116+
expect(after.content).toBe('updated');
117+
118+
// Wrapper still in the painter DOM — the lock didn't get bypassed in a way
119+
// that drops the SDT.
120+
await superdoc.assertElementExists(INLINE_SDT);
121+
});
122+
123+
test('replaceContent updates an sdtLocked block rich-text control', async ({ superdoc }) => {
124+
const target = await createControl(superdoc, {
125+
kind: 'block',
126+
controlType: 'richText',
127+
alias: 'Locked rich-text block',
128+
content: 'initial block body',
129+
});
130+
await superdoc.waitForStable();
131+
132+
const lockResult = await setLock(superdoc, target, 'sdtLocked');
133+
expect(lockResult.success).toBe(true);
134+
await superdoc.waitForStable();
135+
136+
const setResult = await replaceContent(superdoc, target, 'updated block body');
137+
expect(setResult.success).toBe(true);
138+
await superdoc.waitForStable();
139+
140+
const after = await getContent(superdoc, target);
141+
expect(after.content).toContain('updated block body');
142+
143+
await superdoc.assertElementExists(BLOCK_SDT);
144+
});
145+
146+
test('clearContent empties an sdtLocked block control without removing the wrapper', async ({ superdoc }) => {
147+
const target = await createControl(superdoc, {
148+
kind: 'block',
149+
controlType: 'richText',
150+
alias: 'Locked clear target',
151+
content: 'will be cleared',
152+
});
153+
await superdoc.waitForStable();
154+
155+
const lockResult = await setLock(superdoc, target, 'sdtLocked');
156+
expect(lockResult.success).toBe(true);
157+
await superdoc.waitForStable();
158+
159+
const clearResult = await clearContent(superdoc, target);
160+
expect(clearResult.success).toBe(true);
161+
await superdoc.waitForStable();
162+
163+
const after = await getContent(superdoc, target);
164+
expect(after.content.trim()).toBe('');
165+
await superdoc.assertElementExists(BLOCK_SDT);
166+
});
167+
168+
test('round-trip: sdtLocked → unlocked → text.setValue succeeds (lock-state attr writes via AttrStep)', async ({
169+
superdoc,
170+
}) => {
171+
const target = await createControl(superdoc, {
172+
kind: 'inline',
173+
controlType: 'text',
174+
alias: 'Round-trip',
175+
content: 'initial',
176+
});
177+
await superdoc.waitForStable();
178+
179+
expect((await setLock(superdoc, target, 'sdtLocked')).success).toBe(true);
180+
await superdoc.waitForStable();
181+
182+
expect((await setLock(superdoc, target, 'unlocked')).success).toBe(true);
183+
await superdoc.waitForStable();
184+
185+
expect((await textSetValue(superdoc, target, 'after unlock')).success).toBe(true);
186+
await superdoc.waitForStable();
187+
188+
expect((await getContent(superdoc, target)).content).toBe('after unlock');
189+
});
190+
});
191+
192+
// ===========================================================================
193+
// contentLocked: content protected. API guard fires before reaching engine.
194+
// ===========================================================================
195+
196+
test.describe('SD-3123: contentLocked still rejects content mutation via API guard', () => {
197+
test('text.setValue on a contentLocked control returns LOCK_VIOLATION and leaves content unchanged', async ({
198+
superdoc,
199+
}) => {
200+
const target = await createControl(superdoc, {
201+
kind: 'inline',
202+
controlType: 'text',
203+
alias: 'Content-locked',
204+
content: 'protected',
205+
});
206+
await superdoc.waitForStable();
207+
208+
expect((await setLock(superdoc, target, 'contentLocked')).success).toBe(true);
209+
await superdoc.waitForStable();
210+
211+
// The wrapper's `assertNotContentLocked` guard throws inside the adapter;
212+
// page.evaluate surfaces it as a rejected promise. Catch and inspect.
213+
const rejection = await superdoc.page.evaluate(
214+
({ target }: { target: ContentControlTarget }) => {
215+
try {
216+
(window as any).editor.doc.contentControls.text.setValue(
217+
{ target, value: 'changed' },
218+
{ changeMode: 'direct' },
219+
);
220+
return { threw: false };
221+
} catch (err) {
222+
const e = err as { code?: string; message?: string };
223+
return { threw: true, code: e.code, message: e.message };
224+
}
225+
},
226+
{ target },
227+
);
228+
229+
expect(rejection.threw).toBe(true);
230+
expect(rejection.code).toBe('LOCK_VIOLATION');
231+
232+
// Content should remain unchanged.
233+
expect((await getContent(superdoc, target)).content).toBe('protected');
234+
});
235+
});

0 commit comments

Comments
 (0)