From 41044b76c45632de24bb7a51b43ebdd7df6acaae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 04:34:33 +0900 Subject: [PATCH 01/25] test(a11y): require alt intent for pasted and dropped images --- .../Base64ImageAltIntentIngress.test.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/extensions/Base64ImageAltIntentIngress.test.ts diff --git a/src/extensions/Base64ImageAltIntentIngress.test.ts b/src/extensions/Base64ImageAltIntentIngress.test.ts new file mode 100644 index 00000000..94b355cd --- /dev/null +++ b/src/extensions/Base64ImageAltIntentIngress.test.ts @@ -0,0 +1,105 @@ +import { waitFor } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { base64ImagePluginKey } from './Base64Image.js'; +import { buildExtensions } from './kit.js'; + +const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, +]); + +const openEditors: Array<{ editor: Editor; element: HTMLDivElement }> = []; + +function makeEditor(): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const editor = new Editor({ + element, + extensions: buildExtensions({ image: { maxDimension: 0 } }), + content: '

before

', + }); + openEditors.push({ editor, element }); + return editor; +} + +function pngFile(): File { + return new File([PNG_BYTES], 'chart.png', { type: 'image/png' }); +} + +function paste(editor: Editor, event: unknown): boolean { + const plugin = base64ImagePluginKey.get(editor.state)!; + return (plugin.props.handlePaste as (view: unknown, event: unknown) => boolean)( + editor.view, + event, + ); +} + +function drop(editor: Editor, event: unknown): boolean { + const plugin = base64ImagePluginKey.get(editor.state)!; + return (plugin.props.handleDrop as (view: unknown, event: unknown) => boolean)( + editor.view, + event, + ); +} + +afterEach(() => { + for (const { editor, element } of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + element.remove(); + } + vi.restoreAllMocks(); +}); + +describe('Base64Image file-ingress alternative-text intent', () => { + it('requires explicit alternative text before a pasted image becomes document state', async () => { + const editor = makeEditor(); + const prompt = vi + .spyOn(window, 'prompt') + .mockReturnValue('Quarterly revenue chart'); + const file = pngFile(); + const preventDefault = vi.fn(); + + expect( + paste(editor, { + clipboardData: { + items: [{ kind: 'file', getAsFile: () => file }], + }, + preventDefault, + }), + ).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + + await waitFor(() => { + expect(prompt).toHaveBeenCalledWith( + 'Image alternative text. Leave empty only if this image is decorative.', + '', + ); + expect(editor.getHTML()).toContain('alt="Quarterly revenue chart"'); + }); + }); + + it('leaves the document unchanged when dropped-image alternative-text intent is canceled', async () => { + const editor = makeEditor(); + const before = editor.getHTML(); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue(null); + vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: 1, inside: -1 }); + const preventDefault = vi.fn(); + + expect( + drop(editor, { + dataTransfer: { files: [pngFile()] }, + clientX: 4, + clientY: 6, + preventDefault, + }), + ).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + + await waitFor(() => expect(prompt).toHaveBeenCalledTimes(1)); + expect(editor.getHTML()).toBe(before); + expect(editor.getHTML()).not.toContain('data:image/png;base64'); + }); +}); From a2e318da90c5ae4edb53875248f423828f6acfa2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 04:39:17 +0900 Subject: [PATCH 02/25] fix(a11y): require alt intent for file ingress --- src/extensions/Base64Image.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index e506d02c..2fbe9e92 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -196,9 +196,15 @@ export const Base64Image = Image.extend({ imageFileToInlineDataUri(file, options) .then((src) => { if (editor.isDestroyed) return; - // New images are explicitly decorative until an author supplies - // meaningful replacement text through the toolbar. - const node = editor.schema.nodes.image.create({ src, alt: '' }); + const alternativeText = window.prompt( + 'Image alternative text. Leave empty only if this image is decorative.', + '', + ); + if (alternativeText === null) return; + const node = editor.schema.nodes.image.create({ + src, + alt: alternativeText, + }); const pos = typeof at === 'number' ? at : editor.state.selection.from; const transaction = editor.state.tr.insert(pos, node); From 87e9a527f2e81d21c7d7eb8ad02f26df5f5d8b9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 04:43:20 +0900 Subject: [PATCH 03/25] test(a11y): make decorative image intent explicit --- src/extensions/Base64ImageAlt.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/extensions/Base64ImageAlt.test.ts b/src/extensions/Base64ImageAlt.test.ts index 2176e410..c735a6a5 100644 --- a/src/extensions/Base64ImageAlt.test.ts +++ b/src/extensions/Base64ImageAlt.test.ts @@ -28,9 +28,10 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe('Base64Image accessible insertion defaults', () => { - it('adds explicit empty alt text to pasted images', async () => { +describe('Base64Image explicit decorative insertion intent', () => { + it('adds explicit empty alt text to pasted images only after decorative intent', async () => { const editor = createEditor(); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue(''); const plugin = base64ImagePluginKey.get(editor.state)!; const preventDefault = vi.fn(); const handled = ( @@ -45,13 +46,15 @@ describe('Base64Image accessible insertion defaults', () => { expect(handled).toBe(true); expect(preventDefault).toHaveBeenCalledOnce(); await waitFor(() => { + expect(prompt).toHaveBeenCalledOnce(); expect(editor.getHTML()).toContain('data:image/png;base64'); expect(editor.getHTML()).toContain('alt=""'); }); }); - it('adds explicit empty alt text to dropped images', async () => { + it('adds explicit empty alt text to dropped images only after decorative intent', async () => { const editor = createEditor(); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue(''); const plugin = base64ImagePluginKey.get(editor.state)!; vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: 0, inside: -1 }); const preventDefault = vi.fn(); @@ -67,6 +70,7 @@ describe('Base64Image accessible insertion defaults', () => { expect(handled).toBe(true); expect(preventDefault).toHaveBeenCalledOnce(); await waitFor(() => { + expect(prompt).toHaveBeenCalledOnce(); expect(editor.getHTML()).toContain('data:image/png;base64'); expect(editor.getHTML()).toContain('alt=""'); }); From 7aa53b0942d06c1c56734590d9e7205638027658 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 04:44:14 +0900 Subject: [PATCH 04/25] test(a11y): stub file-ingress author intent --- src/extensions/Base64Image.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/extensions/Base64Image.test.ts b/src/extensions/Base64Image.test.ts index 78425991..b363dcaa 100644 --- a/src/extensions/Base64Image.test.ts +++ b/src/extensions/Base64Image.test.ts @@ -270,6 +270,7 @@ describe('Base64Image paste handler', () => { }); it('embeds a pasted image file as inline base64', async () => { + vi.spyOn(window, 'prompt').mockReturnValue(''); const editor = track(makeEditor()); const items = [ { kind: 'file', getAsFile: () => null }, @@ -293,6 +294,7 @@ describe('Base64Image drop handler', () => { }); it('embeds a dropped image at the resolved drop coordinates', async () => { + vi.spyOn(window, 'prompt').mockReturnValue(''); const editor = track(makeEditor()); vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: 1, @@ -314,6 +316,7 @@ describe('Base64Image drop handler', () => { }); it('falls back to the current selection when coords resolve to nothing', async () => { + vi.spyOn(window, 'prompt').mockReturnValue(''); const editor = track(makeEditor()); vi.spyOn(editor.view, 'posAtCoords').mockReturnValue(null); expect( From 37b02f90564ac7c89a141304b2a7d692b5a6ec3a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 04:46:10 +0900 Subject: [PATCH 05/25] test(readonly): reject image file ingress --- .../Base64ImageReadOnlyIngress.test.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/extensions/Base64ImageReadOnlyIngress.test.ts diff --git a/src/extensions/Base64ImageReadOnlyIngress.test.ts b/src/extensions/Base64ImageReadOnlyIngress.test.ts new file mode 100644 index 00000000..8c225e32 --- /dev/null +++ b/src/extensions/Base64ImageReadOnlyIngress.test.ts @@ -0,0 +1,99 @@ +import { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { base64ImagePluginKey } from './Base64Image.js'; +import { buildExtensions } from './kit.js'; + +const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, +]); + +const openEditors: Array<{ editor: Editor; element: HTMLDivElement }> = []; + +function makeReadOnlyEditor(): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const editor = new Editor({ + element, + editable: false, + extensions: buildExtensions({ image: { maxDimension: 0 } }), + content: '

protected

', + }); + openEditors.push({ editor, element }); + return editor; +} + +function pngFile(): File { + return new File([PNG_BYTES], 'readonly.png', { type: 'image/png' }); +} + +function paste(editor: Editor, event: unknown): boolean { + const plugin = base64ImagePluginKey.get(editor.state)!; + return (plugin.props.handlePaste as (view: unknown, event: unknown) => boolean)( + editor.view, + event, + ); +} + +function drop(editor: Editor, event: unknown): boolean { + const plugin = base64ImagePluginKey.get(editor.state)!; + return (plugin.props.handleDrop as (view: unknown, event: unknown) => boolean)( + editor.view, + event, + ); +} + +afterEach(() => { + for (const { editor, element } of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + element.remove(); + } + vi.restoreAllMocks(); +}); + +describe('Base64Image read-only file ingress', () => { + it('does not claim or mutate pasted image input while the editor is read-only', async () => { + const editor = makeReadOnlyEditor(); + const before = editor.getHTML(); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue('must not run'); + const preventDefault = vi.fn(); + + expect( + paste(editor, { + clipboardData: { + items: [{ kind: 'file', getAsFile: () => pngFile() }], + }, + preventDefault, + }), + ).toBe(false); + expect(preventDefault).not.toHaveBeenCalled(); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(prompt).not.toHaveBeenCalled(); + expect(editor.getHTML()).toBe(before); + }); + + it('does not claim or mutate dropped image input while the editor is read-only', async () => { + const editor = makeReadOnlyEditor(); + const before = editor.getHTML(); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue('must not run'); + const preventDefault = vi.fn(); + vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: 1, inside: -1 }); + + expect( + drop(editor, { + dataTransfer: { files: [pngFile()] }, + clientX: 1, + clientY: 1, + preventDefault, + }), + ).toBe(false); + expect(preventDefault).not.toHaveBeenCalled(); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(prompt).not.toHaveBeenCalled(); + expect(editor.getHTML()).toBe(before); + }); +}); From eafce895b2428349324638392ca10e409389fdd2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 04:50:32 +0900 Subject: [PATCH 06/25] fix(readonly): reject image file ingress --- src/extensions/Base64Image.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index 2fbe9e92..a22e8ecf 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -190,6 +190,7 @@ export const Base64Image = Image.extend({ const editor = this.editor; const insertFiles = (files: File[], at?: number) => { + if (!editor.isEditable) return false; const images = files.filter((file) => file.type.startsWith('image/')); if (images.length === 0) return false; for (const file of images) { From 4705a2586c28fcde19e782c922ccc17bd7d7e4af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:00:51 +0900 Subject: [PATCH 07/25] test(a11y): cover read-only transition during image ingress --- .../Base64ImageReadOnlyIngress.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/extensions/Base64ImageReadOnlyIngress.test.ts b/src/extensions/Base64ImageReadOnlyIngress.test.ts index 8c225e32..ac2c8606 100644 --- a/src/extensions/Base64ImageReadOnlyIngress.test.ts +++ b/src/extensions/Base64ImageReadOnlyIngress.test.ts @@ -96,4 +96,28 @@ describe('Base64Image read-only file ingress', () => { expect(prompt).not.toHaveBeenCalled(); expect(editor.getHTML()).toBe(before); }); + + it('does not mutate after an accepted paste becomes read-only during conversion', async () => { + const editor = makeReadOnlyEditor(); + editor.setEditable(true); + const before = editor.getHTML(); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue('must not run'); + const preventDefault = vi.fn(); + + expect( + paste(editor, { + clipboardData: { + items: [{ kind: 'file', getAsFile: () => pngFile() }], + }, + preventDefault, + }), + ).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + + editor.setEditable(false); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(prompt).not.toHaveBeenCalled(); + expect(editor.getHTML()).toBe(before); + }); }); From 0e980777d0ece6e3a2b557a49b1b0bc14a45c6eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:03:59 +0900 Subject: [PATCH 08/25] fix(a11y): recheck editability after image conversion --- src/extensions/Base64Image.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index a22e8ecf..5aaa3357 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -196,7 +196,7 @@ export const Base64Image = Image.extend({ for (const file of images) { imageFileToInlineDataUri(file, options) .then((src) => { - if (editor.isDestroyed) return; + if (editor.isDestroyed || !editor.isEditable) return; const alternativeText = window.prompt( 'Image alternative text. Leave empty only if this image is decorative.', '', From efa28066b29a825a34be125333e0946f422dce6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 06:05:43 +0900 Subject: [PATCH 09/25] test(image): preserve multi-file ingress order --- src/extensions/Base64ImageFileOrder.test.ts | 99 +++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/extensions/Base64ImageFileOrder.test.ts diff --git a/src/extensions/Base64ImageFileOrder.test.ts b/src/extensions/Base64ImageFileOrder.test.ts new file mode 100644 index 00000000..6340e7bf --- /dev/null +++ b/src/extensions/Base64ImageFileOrder.test.ts @@ -0,0 +1,99 @@ +import { waitFor } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { base64ImagePluginKey } from './Base64Image.js'; +import { buildExtensions } from './kit.js'; + +const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, +]); +const JPEG_BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0xd9]); + +const openEditors: Array<{ editor: Editor; element: HTMLDivElement }> = []; + +function makeEditor(): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const editor = new Editor({ + element, + extensions: buildExtensions({ image: { maxDimension: 0 } }), + content: '

before

', + }); + openEditors.push({ editor, element }); + return editor; +} + +function controlledFile( + bytes: Uint8Array, + name: string, + type: string, + delayMs: number, +): File { + const file = new File([bytes], name, { type }); + Object.defineProperty(file, 'arrayBuffer', { + configurable: true, + value: () => + new Promise((resolve) => { + setTimeout(() => resolve(bytes.slice().buffer), delayMs); + }), + }); + return file; +} + +function drop(editor: Editor, event: unknown): boolean { + const plugin = base64ImagePluginKey.get(editor.state)!; + return (plugin.props.handleDrop as (view: unknown, event: unknown) => boolean)( + editor.view, + event, + ); +} + +afterEach(() => { + for (const { editor, element } of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + element.remove(); + } + vi.restoreAllMocks(); +}); + +describe('Base64Image multi-file ingress ordering', () => { + it('preserves dropped file order and author intent when conversion resolves out of order', async () => { + const editor = makeEditor(); + const prompt = vi + .spyOn(window, 'prompt') + .mockReturnValueOnce('First image') + .mockReturnValueOnce('Second image'); + vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: 1, inside: -1 }); + + const first = controlledFile(PNG_BYTES, 'first.png', 'image/png', 25); + const second = controlledFile(JPEG_BYTES, 'second.jpg', 'image/jpeg', 0); + const preventDefault = vi.fn(); + + expect( + drop(editor, { + dataTransfer: { files: [first, second] }, + clientX: 4, + clientY: 6, + preventDefault, + }), + ).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + + await waitFor(() => expect(prompt).toHaveBeenCalledTimes(2)); + await waitFor(() => { + const html = editor.getHTML(); + const pngIndex = html.indexOf('data:image/png;base64'); + const jpegIndex = html.indexOf('data:image/jpeg;base64'); + const firstAltIndex = html.indexOf('alt="First image"'); + const secondAltIndex = html.indexOf('alt="Second image"'); + + expect(pngIndex).toBeGreaterThanOrEqual(0); + expect(jpegIndex).toBeGreaterThan(pngIndex); + expect(firstAltIndex).toBeGreaterThanOrEqual(0); + expect(secondAltIndex).toBeGreaterThan(firstAltIndex); + }); + }); +}); From 38f38b948795d21fccef1e3db3890bb854f5aeac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 06:10:13 +0900 Subject: [PATCH 10/25] test(image): type controlled binary file --- src/extensions/Base64ImageFileOrder.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/extensions/Base64ImageFileOrder.test.ts b/src/extensions/Base64ImageFileOrder.test.ts index 6340e7bf..ed32e25f 100644 --- a/src/extensions/Base64ImageFileOrder.test.ts +++ b/src/extensions/Base64ImageFileOrder.test.ts @@ -32,7 +32,8 @@ function controlledFile( type: string, delayMs: number, ): File { - const file = new File([bytes], name, { type }); + const part = bytes.slice() as unknown as BlobPart; + const file = new File([part], name, { type }); Object.defineProperty(file, 'arrayBuffer', { configurable: true, value: () => From 138e955b601b7f2fb17327b4f2a7d58ee2590484 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 06:13:50 +0900 Subject: [PATCH 11/25] fix(image): preserve ingress order across async conversion --- src/extensions/Base64Image.ts | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index 5aaa3357..6fcc6cac 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -193,28 +193,38 @@ export const Base64Image = Image.extend({ if (!editor.isEditable) return false; const images = files.filter((file) => file.type.startsWith('image/')); if (images.length === 0) return false; - for (const file of images) { - imageFileToInlineDataUri(file, options) - .then((src) => { + + const insertInSourceOrder = async () => { + let insertionPosition = at; + for (const file of images) { + try { + const src = await imageFileToInlineDataUri(file, options); if (editor.isDestroyed || !editor.isEditable) return; const alternativeText = window.prompt( 'Image alternative text. Leave empty only if this image is decorative.', '', ); - if (alternativeText === null) return; + if (alternativeText === null) continue; const node = editor.schema.nodes.image.create({ src, alt: alternativeText, }); const pos = - typeof at === 'number' ? at : editor.state.selection.from; + typeof insertionPosition === 'number' + ? insertionPosition + : editor.state.selection.from; const transaction = editor.state.tr.insert(pos, node); editor.view.dispatch(transaction); - }) - .catch((error: unknown) => { + if (typeof insertionPosition === 'number') { + insertionPosition = pos + node.nodeSize; + } + } catch (error: unknown) { options.onError?.(normalizeImageError(error)); - }); - } + } + } + }; + + void insertInSourceOrder(); return true; }; From 108eb5ab079fc04f2195a7469c339b4493792a09 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 06:18:25 +0900 Subject: [PATCH 12/25] fix(image): map ordered drop insertion positions --- src/extensions/Base64Image.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index 6fcc6cac..4d10e97b 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -214,10 +214,10 @@ export const Base64Image = Image.extend({ ? insertionPosition : editor.state.selection.from; const transaction = editor.state.tr.insert(pos, node); - editor.view.dispatch(transaction); if (typeof insertionPosition === 'number') { - insertionPosition = pos + node.nodeSize; + insertionPosition = transaction.mapping.map(pos, 1); } + editor.view.dispatch(transaction); } catch (error: unknown) { options.onError?.(normalizeImageError(error)); } From 6b8d78c6ea060ad6b8c04dd4c6f1b6e1c4bac735 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 12:32:55 +0900 Subject: [PATCH 13/25] test(reliability): prove hostile image conversion throw escapes --- .../Base64Image.hostileError.test.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/extensions/Base64Image.hostileError.test.ts diff --git a/src/extensions/Base64Image.hostileError.test.ts b/src/extensions/Base64Image.hostileError.test.ts new file mode 100644 index 00000000..9c39e901 --- /dev/null +++ b/src/extensions/Base64Image.hostileError.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import { Base64Image, base64ImagePluginKey } from './Base64Image.js'; + +const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, +]); + +const openEditors: Editor[] = []; + +afterEach(() => { + for (const editor of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + } + vi.restoreAllMocks(); +}); + +function makeEditor(onError: (error: Error) => void): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const editor = new Editor({ + element, + content: '

hello

', + extensions: [ + StarterKit, + Base64Image.configure({ + maxSizeBytes: 1024 * 1024, + maxDimension: 0, + quality: 0.85, + onError, + }), + ], + }); + openEditors.push(editor); + return editor; +} + +function paste(editor: Editor, event: unknown): boolean { + const plugin = base64ImagePluginKey.get(editor.state)!; + return (plugin.props.handlePaste as (view: unknown, event: unknown) => boolean)( + editor.view, + event, + ); +} + +describe('Base64Image hostile conversion failure containment', () => { + it('does not inspect a hostile thrown value before reporting a redacted error', async () => { + const privateSentinel = new Error('private image conversion sentinel'); + const getPrototypeOf = vi.fn(() => { + throw privateSentinel; + }); + const hostileThrownValue = new Proxy({}, { getPrototypeOf }); + const file = new File([PNG_BYTES], 'hostile.png', { type: 'image/png' }); + Object.defineProperty(file, 'arrayBuffer', { + configurable: true, + value: vi.fn().mockRejectedValue(hostileThrownValue), + }); + + const onError = vi.fn<(error: Error) => void>(); + const editor = makeEditor(onError); + const preventDefault = vi.fn(); + + expect( + paste(editor, { + clipboardData: { + items: [{ kind: 'file', getAsFile: () => file }], + }, + preventDefault, + }), + ).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + + await waitFor(() => expect(onError).toHaveBeenCalledOnce()); + expect(getPrototypeOf).not.toHaveBeenCalled(); + expect(onError.mock.calls[0][0]).toBeInstanceOf(Error); + expect(onError.mock.calls[0][0].message).toBe('Image processing failed.'); + expect(editor.getHTML()).not.toContain('data:image'); + }); +}); From 11db0f6dc40046445250d483d387d8f3fd76e4a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 12:36:06 +0900 Subject: [PATCH 14/25] fix(reliability): contain hostile image conversion throw values --- src/extensions/Base64Image.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index 4d10e97b..4282216d 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -218,8 +218,8 @@ export const Base64Image = Image.extend({ insertionPosition = transaction.mapping.map(pos, 1); } editor.view.dispatch(transaction); - } catch (error: unknown) { - options.onError?.(normalizeImageError(error)); + } catch { + options.onError?.(new Error('Image processing failed.')); } } }; From 356a65dc15f0b215dfbad0985ba00144245e7054 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 12:40:27 +0900 Subject: [PATCH 15/25] fix(reliability): preserve safe image ingress diagnostics without reflection --- src/extensions/Base64Image.ts | 65 +++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index 4282216d..5cbc72a9 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -9,7 +9,7 @@ */ import Image from '@tiptap/extension-image'; import { Plugin, PluginKey } from '@tiptap/pm/state'; -import { blobToDataUri } from '../converter/base64.js'; +import { Base64SizeError, blobToDataUri } from '../converter/base64.js'; import { Base64ImageSourceError, validateInlineImageSource, @@ -46,6 +46,48 @@ export interface Base64ImageOptions { export const base64ImagePluginKey = new PluginKey('cwlBase64Image'); +const blobSizeGetter = Object.getOwnPropertyDescriptor( + globalThis.Blob.prototype, + 'size', +)!.get!; +const safeImageIngressErrors = new WeakSet(); + +/** Read Blob byte length from its platform internal slot, ignoring own accessors. */ +function intrinsicBlobSize(blob: Blob): number { + return Reflect.apply(blobSizeGetter, blob, []) as number; +} + +/** Mark an Inkspan-created ingress error without exposing a forgeable property. */ +function markSafeImageIngressError(error: T): T { + safeImageIngressErrors.add(error); + return error; +} + +/** Normalize an untrusted caught value without inspecting or coercing it. */ +function normalizeUntrustedImageIngressError(error: unknown): Error { + return safeImageIngressErrors.has(error as object) + ? (error as Error) + : new Error('Image processing failed.'); +} + +/** + * Validate a generated source and privately brand any deterministic policy + * failure so the async ingress boundary may preserve its safe diagnostic. + */ +function validateGeneratedInlineImageSource( + source: string, + maxSizeBytes: number, +): string { + try { + return validateInlineImageSource(source, maxSizeBytes); + } catch (error) { + if (typeof error === 'object' && error !== null) { + safeImageIngressErrors.add(error); + } + throw error; + } +} + /** * Downscale an image data URI using an offscreen canvas when it exceeds * `maxDimension`. Returns the original URI unchanged when no DOM is available @@ -100,24 +142,33 @@ export async function imageFileToInlineDataUri( file: Blob, options: Pick, ): Promise { + if (options.maxSizeBytes > 0) { + const sourceBytes = intrinsicBlobSize(file); + if (sourceBytes > options.maxSizeBytes) { + throw markSafeImageIngressError( + new Base64SizeError(sourceBytes, options.maxSizeBytes), + ); + } + } + const dataUri = await blobToDataUri(file, { maxBytes: options.maxSizeBytes > 0 ? options.maxSizeBytes : undefined, }); - validateInlineImageSource(dataUri, options.maxSizeBytes); + validateGeneratedInlineImageSource(dataUri, options.maxSizeBytes); if (options.maxDimension && options.maxDimension > 0) { const scaled = await downscaleDataUri( dataUri, options.maxDimension, options.quality, ); - return validateInlineImageSource(scaled, options.maxSizeBytes); + return validateGeneratedInlineImageSource(scaled, options.maxSizeBytes); } return dataUri; } -/** Normalize a caught value to the Error contract exposed to hosts. */ +/** Normalize a caught value from Inkspan-controlled validation paths. */ function normalizeImageError(error: unknown): Error { - /* v8 ignore next -- all shipped validation and conversion paths throw Error. */ + /* v8 ignore next -- all shipped validation paths throw Error. */ return error instanceof Error ? error : new Error('Image processing failed.'); } @@ -218,8 +269,8 @@ export const Base64Image = Image.extend({ insertionPosition = transaction.mapping.map(pos, 1); } editor.view.dispatch(transaction); - } catch { - options.onError?.(new Error('Image processing failed.')); + } catch (error) { + options.onError?.(normalizeUntrustedImageIngressError(error)); } } }; From 60a1225ae6ece1b14406fcf8e68bfc9cf603571a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 14:05:02 +0900 Subject: [PATCH 16/25] test(reliability): reject malformed image processing limits --- .../Base64ImageOptionBounds.test.ts | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/extensions/Base64ImageOptionBounds.test.ts diff --git a/src/extensions/Base64ImageOptionBounds.test.ts b/src/extensions/Base64ImageOptionBounds.test.ts new file mode 100644 index 00000000..46cbddcf --- /dev/null +++ b/src/extensions/Base64ImageOptionBounds.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + downscaleDataUri, + imageFileToInlineDataUri, +} from './Base64Image.js'; + +const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, +]); + +const INVALID_CONFIGURATION_MESSAGE = + 'Image processing options must use bounded numeric values.'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('Base64Image public processing-option boundary', () => { + it.each([Number.NaN, -1, 1.5, Number.POSITIVE_INFINITY])( + 'rejects invalid maxSizeBytes %s before reading the source file', + async (maxSizeBytes) => { + const file = new File([PNG_BYTES], 'bounded.png', { type: 'image/png' }); + const arrayBuffer = vi.fn().mockRejectedValue(new Error('private file read')); + Object.defineProperty(file, 'arrayBuffer', { + configurable: true, + value: arrayBuffer, + }); + + await expect( + imageFileToInlineDataUri(file, { + maxSizeBytes, + maxDimension: 0, + quality: 0.85, + }), + ).rejects.toMatchObject({ + name: 'RangeError', + message: INVALID_CONFIGURATION_MESSAGE, + }); + expect(arrayBuffer).not.toHaveBeenCalled(); + }, + ); + + it.each([Number.NaN, -1, Number.POSITIVE_INFINITY])( + 'rejects invalid maxDimension %s before consulting browser image capabilities', + async (maxDimension) => { + vi.stubGlobal('Image', undefined); + + await expect( + downscaleDataUri('data:image/png;base64,AAAA', maxDimension, 0.85), + ).rejects.toMatchObject({ + name: 'RangeError', + message: INVALID_CONFIGURATION_MESSAGE, + }); + }, + ); + + it.each([Number.NaN, -0.1, 1.1, Number.POSITIVE_INFINITY])( + 'rejects invalid quality %s before consulting browser image capabilities', + async (quality) => { + vi.stubGlobal('Image', undefined); + + await expect( + downscaleDataUri('data:image/png;base64,AAAA', 100, quality), + ).rejects.toMatchObject({ + name: 'RangeError', + message: INVALID_CONFIGURATION_MESSAGE, + }); + }, + ); + + it('preserves zero maxDimension and boundary quality values', async () => { + vi.stubGlobal('Image', undefined); + const uri = 'data:image/png;base64,AAAA'; + + await expect(downscaleDataUri(uri, 0, 0)).resolves.toBe(uri); + await expect(downscaleDataUri(uri, 0, 1)).resolves.toBe(uri); + }); +}); From 04301f120654be3794ec0c3225fdaf7cef2e318c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 14:08:42 +0900 Subject: [PATCH 17/25] fix(reliability): validate image processing limits --- src/extensions/Base64Image.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index 5cbc72a9..8c14de6e 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -51,6 +51,22 @@ const blobSizeGetter = Object.getOwnPropertyDescriptor( 'size', )!.get!; const safeImageIngressErrors = new WeakSet(); +const INVALID_IMAGE_PROCESSING_OPTIONS_MESSAGE = + 'Image processing options must use bounded numeric values.'; + +/** Reject a malformed runtime byte or pixel limit before consuming input. */ +function assertNonNegativeSafeInteger(value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(INVALID_IMAGE_PROCESSING_OPTIONS_MESSAGE); + } +} + +/** Reject a malformed runtime image quality before browser image processing. */ +function assertImageQuality(value: number): void { + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new RangeError(INVALID_IMAGE_PROCESSING_OPTIONS_MESSAGE); + } +} /** Read Blob byte length from its platform internal slot, ignoring own accessors. */ function intrinsicBlobSize(blob: Blob): number { @@ -98,10 +114,12 @@ export async function downscaleDataUri( maxDimension: number, quality: number, ): Promise { + assertNonNegativeSafeInteger(maxDimension); + assertImageQuality(quality); if ( typeof document === 'undefined' || typeof globalThis.Image === 'undefined' || - maxDimension <= 0 + maxDimension === 0 ) { return dataUri; } @@ -142,6 +160,12 @@ export async function imageFileToInlineDataUri( file: Blob, options: Pick, ): Promise { + assertNonNegativeSafeInteger(options.maxSizeBytes); + if (options.maxDimension !== undefined) { + assertNonNegativeSafeInteger(options.maxDimension); + } + assertImageQuality(options.quality); + if (options.maxSizeBytes > 0) { const sourceBytes = intrinsicBlobSize(file); if (sourceBytes > options.maxSizeBytes) { @@ -155,7 +179,7 @@ export async function imageFileToInlineDataUri( maxBytes: options.maxSizeBytes > 0 ? options.maxSizeBytes : undefined, }); validateGeneratedInlineImageSource(dataUri, options.maxSizeBytes); - if (options.maxDimension && options.maxDimension > 0) { + if (options.maxDimension !== undefined && options.maxDimension > 0) { const scaled = await downscaleDataUri( dataUri, options.maxDimension, From e4425c450c8dced8be7d7c71fd62b8052193cc9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:02:46 +0900 Subject: [PATCH 18/25] test(reliability): expose image error observer escape --- .../Base64Image.hostileError.test.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/extensions/Base64Image.hostileError.test.ts b/src/extensions/Base64Image.hostileError.test.ts index 9c39e901..67a1e425 100644 --- a/src/extensions/Base64Image.hostileError.test.ts +++ b/src/extensions/Base64Image.hostileError.test.ts @@ -19,12 +19,15 @@ afterEach(() => { vi.restoreAllMocks(); }); -function makeEditor(onError: (error: Error) => void): Editor { +function makeEditor( + onError: (error: Error) => void, + content = '

hello

', +): Editor { const element = document.createElement('div'); document.body.appendChild(element); const editor = new Editor({ element, - content: '

hello

', + content, extensions: [ StarterKit, Base64Image.configure({ @@ -80,4 +83,24 @@ describe('Base64Image hostile conversion failure containment', () => { expect(onError.mock.calls[0][0].message).toBe('Image processing failed.'); expect(editor.getHTML()).not.toContain('data:image'); }); + + it('contains host error-observer failures while rejecting unsafe parsed images', () => { + const privateSentinel = new Error('private image observer sentinel'); + const onError = vi.fn<(error: Error) => void>(() => { + throw privateSentinel; + }); + + let editor: Editor | undefined; + expect(() => { + editor = makeEditor( + onError, + '

before

after

', + ); + }).not.toThrow(); + + expect(onError).toHaveBeenCalledOnce(); + expect(editor?.getHTML()).not.toContain(' Date: Sun, 16 Aug 2026 20:05:35 +0900 Subject: [PATCH 19/25] fix(reliability): contain image error observer failures --- src/extensions/Base64Image.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index 8c14de6e..6d1c6f04 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -86,6 +86,22 @@ function normalizeUntrustedImageIngressError(error: unknown): Error { : new Error('Image processing failed.'); } +/** + * Notify the host about an image rejection without granting observer failures + * authority over parser, transaction-filter, or asynchronous ingress results. + */ +function reportImageError( + observer: Base64ImageOptions['onError'], + error: Error, +): void { + if (!observer) return; + try { + observer(error); + } catch { + // Host presentation/telemetry is best-effort and cannot change rejection. + } +} + /** * Validate a generated source and privately brand any deterministic policy * failure so the async ingress boundary may preserve its safe diagnostic. @@ -230,7 +246,7 @@ export const Base64Image = Image.extend({ title: element.getAttribute('title'), }; } catch (error) { - this.options.onError?.(normalizeImageError(error)); + reportImageError(this.options.onError, normalizeImageError(error)); return false; } }, @@ -294,7 +310,10 @@ export const Base64Image = Image.extend({ } editor.view.dispatch(transaction); } catch (error) { - options.onError?.(normalizeUntrustedImageIngressError(error)); + reportImageError( + options.onError, + normalizeUntrustedImageIngressError(error), + ); } } }; @@ -324,7 +343,7 @@ export const Base64Image = Image.extend({ } }); if (!rejection) return true; - options.onError?.(rejection); + reportImageError(options.onError, rejection); return false; }, props: { From 7e606f2de92f8acf5fb346213ec16652bc3a4f95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:26:47 +0900 Subject: [PATCH 20/25] test(ci): cover event-specific Python matrix Signed-off-by: Seongho Bae --- office/tests/test_python_support_contract.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index 7104fd66..209f4845 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,10 +50,14 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search(r'python-version:\s*\[([^\]]+)\]', office_job) + matrix_match = re.search(r"python-version:\s*(.+)", office_job) assert matrix_match is not None - matrix_versions = tuple(re.findall(r'"(3\.\d+)"', matrix_match.group(1))) - assert matrix_versions == SUPPORTED_PYTHON_VERSIONS + pull_request_versions, push_versions = ( + tuple(re.findall(r'"(3\.\d+)"', versions)) + for versions in re.findall(r"fromJSON\('(\[[^']+\])'\)", matrix_match.group(1)) + ) + assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) + assert push_versions == SUPPORTED_PYTHON_VERSIONS def test_python_support_documentation_matches_the_fixed_ci_environment() -> None: From e7dcda5eba090bb950e5765d061b6f534539cd6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:32:54 +0900 Subject: [PATCH 21/25] test(ci): bind Python matrix to event Signed-off-by: Seongho Bae --- office/tests/test_python_support_contract.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index 209f4845..a52ddec3 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,11 +50,16 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search(r"python-version:\s*(.+)", office_job) + matrix_match = re.search( + r"python-version:\s*\$\{\{\s*github\.event_name\s*==\s*'pull_request'" + r"\s*&&\s*fromJSON\('(\[[^']+\])'\)\s*\|\|\s*" + r"fromJSON\('(\[[^']+\])'\)\s*\}\}", + office_job, + ) assert matrix_match is not None pull_request_versions, push_versions = ( tuple(re.findall(r'"(3\.\d+)"', versions)) - for versions in re.findall(r"fromJSON\('(\[[^']+\])'\)", matrix_match.group(1)) + for versions in matrix_match.groups() ) assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) assert push_versions == SUPPORTED_PYTHON_VERSIONS From 6a74f2edbea581528b465205f571a1f497b42949 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:16:59 +0900 Subject: [PATCH 22/25] revert(ci): restore Office contract owner Remove the duplicated Python support contract changes from this image-ingress branch. PR #405 remains the single writer while this branch keeps its accessible image intent delta. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) --- office/tests/test_python_support_contract.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index a52ddec3..7104fd66 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,19 +50,10 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search( - r"python-version:\s*\$\{\{\s*github\.event_name\s*==\s*'pull_request'" - r"\s*&&\s*fromJSON\('(\[[^']+\])'\)\s*\|\|\s*" - r"fromJSON\('(\[[^']+\])'\)\s*\}\}", - office_job, - ) + matrix_match = re.search(r'python-version:\s*\[([^\]]+)\]', office_job) assert matrix_match is not None - pull_request_versions, push_versions = ( - tuple(re.findall(r'"(3\.\d+)"', versions)) - for versions in matrix_match.groups() - ) - assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) - assert push_versions == SUPPORTED_PYTHON_VERSIONS + matrix_versions = tuple(re.findall(r'"(3\.\d+)"', matrix_match.group(1))) + assert matrix_versions == SUPPORTED_PYTHON_VERSIONS def test_python_support_documentation_matches_the_fixed_ci_environment() -> None: From 15b9b115228744038903a98c726ee7687704f746 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 08:03:33 +0900 Subject: [PATCH 23/25] test(image): reject read-only changes during alt prompting Signed-off-by: Seongho Bae --- .../Base64ImageReadOnlyIngress.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/extensions/Base64ImageReadOnlyIngress.test.ts b/src/extensions/Base64ImageReadOnlyIngress.test.ts index ac2c8606..a974eb8d 100644 --- a/src/extensions/Base64ImageReadOnlyIngress.test.ts +++ b/src/extensions/Base64ImageReadOnlyIngress.test.ts @@ -1,3 +1,4 @@ +import { waitFor } from '@testing-library/react'; import { Editor } from '@tiptap/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -54,6 +55,32 @@ afterEach(() => { }); describe('Base64Image read-only file ingress', () => { + it.each(['paste', 'drop'] as const)( + 'does not insert after %s alternative-text prompting makes the editor read-only', + async (ingress) => { + const editor = makeReadOnlyEditor(); + editor.setEditable(true); + const before = editor.getHTML(); + const prompt = vi.spyOn(window, 'prompt').mockImplementation(() => { + editor.setEditable(false); + return 'Image description'; + }); + const event = { + clipboardData: { items: [{ kind: 'file', getAsFile: () => pngFile() }] }, + dataTransfer: { files: [pngFile()] }, + clientX: 1, + clientY: 1, + preventDefault: vi.fn(), + }; + vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: 1, inside: -1 }); + + expect((ingress === 'paste' ? paste : drop)(editor, event)).toBe(true); + await waitFor(() => expect(prompt).toHaveBeenCalledOnce()); + expect(editor.isEditable).toBe(false); + expect(editor.getHTML()).toBe(before); + }, + ); + it('does not claim or mutate pasted image input while the editor is read-only', async () => { const editor = makeReadOnlyEditor(); const before = editor.getHTML(); From da315e7bda3de114570d5207ee151dd602d6117b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 08:04:50 +0900 Subject: [PATCH 24/25] fix(image): recheck editor state after alt prompting Signed-off-by: Seongho Bae --- src/extensions/Base64Image.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index 6d1c6f04..42fc9ae2 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -295,6 +295,7 @@ export const Base64Image = Image.extend({ 'Image alternative text. Leave empty only if this image is decorative.', '', ); + if (editor.isDestroyed || !editor.isEditable) return; if (alternativeText === null) continue; const node = editor.schema.nodes.image.create({ src, From 4a53c08314de87ba700acd75b6b8b5a17bc2c1e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 08:15:42 +0900 Subject: [PATCH 25/25] test(image): cover editor destruction during alt prompting Signed-off-by: Seongho Bae --- .../Base64ImageReadOnlyIngress.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/extensions/Base64ImageReadOnlyIngress.test.ts b/src/extensions/Base64ImageReadOnlyIngress.test.ts index a974eb8d..72055141 100644 --- a/src/extensions/Base64ImageReadOnlyIngress.test.ts +++ b/src/extensions/Base64ImageReadOnlyIngress.test.ts @@ -55,14 +55,20 @@ afterEach(() => { }); describe('Base64Image read-only file ingress', () => { - it.each(['paste', 'drop'] as const)( - 'does not insert after %s alternative-text prompting makes the editor read-only', - async (ingress) => { + it.each([ + ['paste', 'read-only'], + ['drop', 'read-only'], + ['paste', 'destroyed'], + ['drop', 'destroyed'], + ] as const)( + 'does not insert after %s alternative-text prompting leaves the editor %s', + async (ingress, state) => { const editor = makeReadOnlyEditor(); editor.setEditable(true); const before = editor.getHTML(); const prompt = vi.spyOn(window, 'prompt').mockImplementation(() => { - editor.setEditable(false); + if (state === 'destroyed') editor.destroy(); + else editor.setEditable(false); return 'Image description'; }); const event = { @@ -76,7 +82,8 @@ describe('Base64Image read-only file ingress', () => { expect((ingress === 'paste' ? paste : drop)(editor, event)).toBe(true); await waitFor(() => expect(prompt).toHaveBeenCalledOnce()); - expect(editor.isEditable).toBe(false); + if (state === 'destroyed') expect(editor.isDestroyed).toBe(true); + else expect(editor.isEditable).toBe(false); expect(editor.getHTML()).toBe(before); }, );