@@ -203,4 +203,153 @@ describe('MCP protocol integration', () => {
203203 const body = JSON . parse ( textContent ( result ) ) ;
204204 expect ( body ) . toHaveProperty ( 'session_id' ) ;
205205 } ) ;
206+
207+ // ---------------------------------------------------------------------------
208+ // Block-node deletion via superdoc_edit (delete_block / delete_block_range)
209+ //
210+ // These tests prove the *reported bug* is fixed end-to-end through the MCP
211+ // layer: a text `delete` (by ref) empties a block but leaves the container
212+ // behind, whereas `delete_block` (by {kind:'block', nodeType, nodeId} target)
213+ // physically removes the whole block node.
214+ //
215+ // Addressing model (must be respected or the test fails):
216+ // - get_content action:"blocks" returns { total, blocks: [...], revision };
217+ // each entry exposes BOTH a `nodeId` and a `ref`.
218+ // - text `delete` takes a `ref`.
219+ // - `delete_block` takes `target: {kind:'block', nodeType, nodeId}`.
220+ // - refs and block handles EXPIRE after ANY mutation; we re-fetch blocks
221+ // before every call that consumes a handle.
222+ // ---------------------------------------------------------------------------
223+
224+ interface BlockEntry {
225+ nodeId : string ;
226+ nodeType : string ;
227+ isEmpty : boolean ;
228+ ref ?: string ;
229+ textPreview : string | null ;
230+ }
231+
232+ interface BlocksPayload {
233+ total : number ;
234+ blocks : BlockEntry [ ] ;
235+ revision : string ;
236+ }
237+
238+ // Always re-fetch — never cache a handle across a mutation.
239+ async function fetchBlocks ( sid : string ) : Promise < BlocksPayload > {
240+ const result = await client . callTool ( {
241+ name : 'superdoc_get_content' ,
242+ arguments : { session_id : sid , action : 'blocks' } ,
243+ } ) ;
244+ return parseContent ( result ) as BlocksPayload ;
245+ }
246+
247+ it ( 'exposes delete_block and delete_block_range in the superdoc_edit action enum' , async ( ) => {
248+ await ready ;
249+ const { tools } = await client . listTools ( ) ;
250+
251+ const editTool = tools . find ( ( t ) => t . name === 'superdoc_edit' ) ;
252+ expect ( editTool ) . toBeDefined ( ) ;
253+
254+ const schema = editTool ! . inputSchema as { properties ?: Record < string , { enum ?: string [ ] } > } ;
255+ const actionEnum = schema . properties ?. action ?. enum ;
256+ expect ( actionEnum ) . toBeArray ( ) ;
257+ expect ( actionEnum ) . toContain ( 'delete_block' ) ;
258+ expect ( actionEnum ) . toContain ( 'delete_block_range' ) ;
259+ } ) ;
260+
261+ it ( 'delete_block physically removes a block node (count decrements, nodeId absent)' , async ( ) => {
262+ await ready ;
263+
264+ // Open a blank document.
265+ const openResult = await client . callTool ( { name : 'superdoc_open' , arguments : { path : BLANK_DOCX } } ) ;
266+ const { session_id : sid } = parseContent ( openResult ) as { session_id : string } ;
267+
268+ // Create a heading and a paragraph (proven create workflow).
269+ const headingResult = await client . callTool ( {
270+ name : 'superdoc_create' ,
271+ arguments : { session_id : sid , action : 'heading' , text : 'Block To Delete' , level : 1 } ,
272+ } ) ;
273+ expect ( textContent ( headingResult ) ) . toBeTruthy ( ) ;
274+
275+ const paraResult = await client . callTool ( {
276+ name : 'superdoc_create' ,
277+ arguments : { session_id : sid , action : 'paragraph' , text : 'Surviving paragraph' } ,
278+ } ) ;
279+ expect ( textContent ( paraResult ) ) . toBeTruthy ( ) ;
280+
281+ // Record block count N and the heading's nodeId from the authoritative listing.
282+ const before = await fetchBlocks ( sid ) ;
283+ const heading = before . blocks . find ( ( b ) => b . nodeType === 'heading' ) ;
284+ expect ( heading ) . toBeDefined ( ) ;
285+ const headingNodeId = heading ! . nodeId ;
286+ const countBefore = before . blocks . length ;
287+ expect ( countBefore ) . toBeGreaterThan ( 1 ) ;
288+
289+ // Delete the entire heading block by its target address.
290+ const deleteResult = await client . callTool ( {
291+ name : 'superdoc_edit' ,
292+ arguments : {
293+ session_id : sid ,
294+ action : 'delete_block' ,
295+ target : { kind : 'block' , nodeType : 'heading' , nodeId : headingNodeId } ,
296+ } ,
297+ } ) ;
298+ expect ( deleteResult ) . not . toHaveProperty ( 'isError' ) ;
299+
300+ // Re-fetch (handles expired) and assert PHYSICAL removal.
301+ const after = await fetchBlocks ( sid ) ;
302+ expect ( after . blocks . length ) . toBe ( countBefore - 1 ) ;
303+ expect ( after . blocks . some ( ( b ) => b . nodeId === headingNodeId ) ) . toBe ( false ) ;
304+
305+ await client . callTool ( { name : 'superdoc_close' , arguments : { session_id : sid } } ) ;
306+ } ) ;
307+
308+ it ( 'text delete leaves an empty container; delete_block removes it (the reported bug)' , async ( ) => {
309+ await ready ;
310+
311+ // Open a blank document and create a heading to operate on.
312+ const openResult = await client . callTool ( { name : 'superdoc_open' , arguments : { path : BLANK_DOCX } } ) ;
313+ const { session_id : sid } = parseContent ( openResult ) as { session_id : string } ;
314+
315+ await client . callTool ( {
316+ name : 'superdoc_create' ,
317+ arguments : { session_id : sid , action : 'heading' , text : 'Heading body text' , level : 1 } ,
318+ } ) ;
319+
320+ // Locate the heading; capture its nodeId AND its fresh ref.
321+ const before = await fetchBlocks ( sid ) ;
322+ const heading = before . blocks . find ( ( b ) => b . nodeType === 'heading' ) ;
323+ expect ( heading ) . toBeDefined ( ) ;
324+ const headingNodeId = heading ! . nodeId ;
325+ expect ( heading ! . ref ) . toBeString ( ) ;
326+
327+ // 1) Text `delete` by ref: empties the block but leaves the container.
328+ const textDelete = await client . callTool ( {
329+ name : 'superdoc_edit' ,
330+ arguments : { session_id : sid , action : 'delete' , ref : heading ! . ref } ,
331+ } ) ;
332+ expect ( textDelete ) . not . toHaveProperty ( 'isError' ) ;
333+
334+ const afterTextDelete = await fetchBlocks ( sid ) ;
335+ const survivor = afterTextDelete . blocks . find ( ( b ) => b . nodeId === headingNodeId ) ;
336+ expect ( survivor ) . toBeDefined ( ) ; // container survives — this is the bug
337+ expect ( survivor ! . isEmpty ) . toBe ( true ) ;
338+
339+ // 2) delete_block by target: physically removes the container.
340+ const blockDelete = await client . callTool ( {
341+ name : 'superdoc_edit' ,
342+ arguments : {
343+ session_id : sid ,
344+ action : 'delete_block' ,
345+ target : { kind : 'block' , nodeType : 'heading' , nodeId : headingNodeId } ,
346+ } ,
347+ } ) ;
348+ expect ( blockDelete ) . not . toHaveProperty ( 'isError' ) ;
349+
350+ const afterBlockDelete = await fetchBlocks ( sid ) ;
351+ expect ( afterBlockDelete . blocks . some ( ( b ) => b . nodeId === headingNodeId ) ) . toBe ( false ) ;
352+
353+ await client . callTool ( { name : 'superdoc_close' , arguments : { session_id : sid } } ) ;
354+ } ) ;
206355} ) ;
0 commit comments