From 4b5ab0fb651b0b61d9a7ec5d860cd98dcbca036b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:31:16 -0700 Subject: [PATCH 01/25] test(input): add multilingual browser assurance baseline Refs #376. Add cross-engine committed-input fidelity coverage plus a bounded synthetic composition lifecycle probe without claiming real OS IME or mobile-device support. --- tests/browser/input-harness.html | 14 +++ tests/browser/input-harness.ts | 27 +++++ .../composition-clipboard.browser.spec.ts | 99 +++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 tests/browser/input-harness.html create mode 100644 tests/browser/input-harness.ts create mode 100644 tests/browser/specs/composition-clipboard.browser.spec.ts diff --git a/tests/browser/input-harness.html b/tests/browser/input-harness.html new file mode 100644 index 00000000..35eda969 --- /dev/null +++ b/tests/browser/input-harness.html @@ -0,0 +1,14 @@ + + + + + + Inkspan input assurance harness + + +
+
+
+ + + diff --git a/tests/browser/input-harness.ts b/tests/browser/input-harness.ts new file mode 100644 index 00000000..d619f9a0 --- /dev/null +++ b/tests/browser/input-harness.ts @@ -0,0 +1,27 @@ +import { Editor } from '@tiptap/core'; +import { buildExtensions } from 'inkspan-browser-under-test'; + +declare global { + interface Window { + inkspanInputHarness: { + getText: () => string; + isComposing: () => boolean; + }; + } +} + +const element = document.getElementById('editor'); +if (!(element instanceof HTMLElement)) { + throw new Error('Input assurance harness editor host is missing.'); +} + +const editor = new Editor({ + element, + extensions: buildExtensions(), + content: '', +}); + +window.inkspanInputHarness = Object.freeze({ + getText: () => editor.getText(), + isComposing: () => editor.view.composing, +}); diff --git a/tests/browser/specs/composition-clipboard.browser.spec.ts b/tests/browser/specs/composition-clipboard.browser.spec.ts new file mode 100644 index 00000000..51371882 --- /dev/null +++ b/tests/browser/specs/composition-clipboard.browser.spec.ts @@ -0,0 +1,99 @@ +import { expect, test, type Page } from '@playwright/test'; + +type InputHarness = { + getText: () => string; + isComposing: () => boolean; +}; + +const allowHarnessRequest = (requestUrl: string): boolean => { + const url = new URL(requestUrl); + return url.hostname === '127.0.0.1' && url.port === '4173'; +}; + +const committedInputSamples = [ + ['Korean', '한글 입력 테스트 123'], + ['Japanese', 'ひらがな カタカナ 漢字、。'], + ['Simplified Chinese', '简体中文输入测试,标点。'], + ['Traditional Chinese', '繁體中文輸入測試,標點。'], + ['Vietnamese', 'Tiếng Việt đa dạng'], + ['grapheme clusters', 'A👩🏽‍💻e\u0301❤️‍🔥B'], +] as const; + +const rejectedRequestsByPage = new WeakMap(); + +test.beforeEach(async ({ page }) => { + const rejectedExternalRequests: string[] = []; + rejectedRequestsByPage.set(page, rejectedExternalRequests); + await page.route('**/*', async (route) => { + if (allowHarnessRequest(route.request().url())) { + await route.continue(); + return; + } + rejectedExternalRequests.push(new URL(route.request().url()).origin); + await route.abort('blockedbyclient'); + }); + await page.goto('/tests/browser/input-harness.html'); +}); + +test.afterEach(async ({ page }) => { + await page.waitForLoadState('networkidle'); + expect(rejectedRequestsByPage.get(page) ?? []).toEqual([]); +}); + +for (const [label, text] of committedInputSamples) { + test(`preserves ${label} committed input exactly`, async ({ page }) => { + const editable = page.locator('.ProseMirror'); + await editable.click(); + await page.keyboard.insertText(text); + + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getText(), + ), + ) + .toBe(text); + }); +} + +test('tracks a synthetic composition lifecycle without inventing OS IME evidence', async ({ + page, +}) => { + const editable = page.locator('.ProseMirror'); + await editable.click(); + + await editable.evaluate((element) => { + element.dispatchEvent( + new CompositionEvent('compositionstart', { bubbles: true, data: '' }), + ); + }); + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).isComposing(), + ), + ) + .toBe(true); + + await editable.evaluate((element) => { + element.dispatchEvent( + new CompositionEvent('compositionend', { bubbles: true, data: '' }), + ); + }); + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).isComposing(), + ), + ) + .toBe(false); + + await page.keyboard.insertText('한글'); + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getText(), + ), + ) + .toBe('한글'); +}); From 109e5b793447caeb96d251ed275aa433fd37465e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:39:52 -0700 Subject: [PATCH 02/25] test(input): cover structured multilingual edits and history Refs #376. Extend the browser-only assurance lane with committed multilingual edits in headings, list items and table cells plus undo/redo evidence for committed Korean input. This remains synthetic committed-input evidence, not real OS IME or mobile-device support. --- tests/browser/input-harness.ts | 8 ++ .../composition-clipboard.browser.spec.ts | 93 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/tests/browser/input-harness.ts b/tests/browser/input-harness.ts index d619f9a0..47291d43 100644 --- a/tests/browser/input-harness.ts +++ b/tests/browser/input-harness.ts @@ -4,8 +4,12 @@ import { buildExtensions } from 'inkspan-browser-under-test'; declare global { interface Window { inkspanInputHarness: { + getHtml: () => string; getText: () => string; isComposing: () => boolean; + redo: () => boolean; + setHtml: (html: string) => boolean; + undo: () => boolean; }; } } @@ -22,6 +26,10 @@ const editor = new Editor({ }); window.inkspanInputHarness = Object.freeze({ + getHtml: () => editor.getHTML(), getText: () => editor.getText(), isComposing: () => editor.view.composing, + redo: () => editor.commands.redo(), + setHtml: (html: string) => editor.commands.setContent(html, false), + undo: () => editor.commands.undo(), }); diff --git a/tests/browser/specs/composition-clipboard.browser.spec.ts b/tests/browser/specs/composition-clipboard.browser.spec.ts index 51371882..0b863831 100644 --- a/tests/browser/specs/composition-clipboard.browser.spec.ts +++ b/tests/browser/specs/composition-clipboard.browser.spec.ts @@ -1,8 +1,12 @@ import { expect, test, type Page } from '@playwright/test'; type InputHarness = { + getHtml: () => string; getText: () => string; isComposing: () => boolean; + redo: () => boolean; + setHtml: (html: string) => boolean; + undo: () => boolean; }; const allowHarnessRequest = (requestUrl: string): boolean => { @@ -19,6 +23,30 @@ const committedInputSamples = [ ['grapheme clusters', 'A👩🏽‍💻e\u0301❤️‍🔥B'], ] as const; +const structuredCommittedInputCases = [ + { + label: 'heading', + sourceHtml: '

시작

', + selector: 'h2', + insertedText: ' 한글', + expectedText: '시작 한글', + }, + { + label: 'bullet-list item', + sourceHtml: '
  • 項目

', + selector: 'li', + insertedText: ' 日本語', + expectedText: '項目 日本語', + }, + { + label: 'table cell', + sourceHtml: '

内容

', + selector: 'td', + insertedText: ' 中文', + expectedText: '内容 中文', + }, +] as const; + const rejectedRequestsByPage = new WeakMap(); test.beforeEach(async ({ page }) => { @@ -56,6 +84,65 @@ for (const [label, text] of committedInputSamples) { }); } +for (const inputCase of structuredCommittedInputCases) { + test(`preserves committed multilingual input in a ${inputCase.label}`, async ({ + page, + }) => { + await page.evaluate((sourceHtml) => { + (window.inkspanInputHarness as InputHarness).setHtml(sourceHtml); + }, inputCase.sourceHtml); + + const target = page.locator(`.ProseMirror ${inputCase.selector}`).first(); + await target.click(); + await page.keyboard.press('End'); + await page.keyboard.insertText(inputCase.insertedText); + + await expect(target).toHaveText(inputCase.expectedText); + }); +} + +test('keeps committed Korean input atomic across undo and redo', async ({ + page, +}) => { + const editable = page.locator('.ProseMirror'); + await editable.click(); + await page.keyboard.insertText('한글 입력'); + + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getText(), + ), + ) + .toBe('한글 입력'); + + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).undo(), + ), + ).toBe(true); + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getText(), + ), + ) + .toBe(''); + + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).redo(), + ), + ).toBe(true); + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getText(), + ), + ) + .toBe('한글 입력'); +}); + test('tracks a synthetic composition lifecycle without inventing OS IME evidence', async ({ page, }) => { @@ -96,4 +183,10 @@ test('tracks a synthetic composition lifecycle without inventing OS IME evidence ), ) .toBe('한글'); + + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getHtml(), + ), + ).toContain('한글'); }); From 7d0112016c09c6265bd24d45b3a281cd454e1c33 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 11:03:21 -0700 Subject: [PATCH 03/25] test(input): add narrow viewport multilingual assurance --- .../composition-clipboard.browser.spec.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/browser/specs/composition-clipboard.browser.spec.ts b/tests/browser/specs/composition-clipboard.browser.spec.ts index 0b863831..dcc1e68d 100644 --- a/tests/browser/specs/composition-clipboard.browser.spec.ts +++ b/tests/browser/specs/composition-clipboard.browser.spec.ts @@ -190,3 +190,47 @@ test('tracks a synthetic composition lifecycle without inventing OS IME evidence ), ).toContain('한글'); }); + +test('preserves multilingual committed input in a narrow emulated viewport', async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + const editable = page.locator('.ProseMirror'); + await editable.click(); + + const text = '한글 日本語 简体中文 繁體中文 Tiếng Việt 👩🏽‍💻'; + await page.keyboard.insertText(text); + + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getText(), + ), + ) + .toBe(text); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ).toBe(true); +}); + +test('keeps the active insertion point across an emulated orientation resize', async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + const editable = page.locator('.ProseMirror'); + await editable.click(); + await page.keyboard.insertText('한글'); + + await page.setViewportSize({ width: 844, height: 390 }); + await page.keyboard.insertText(' 日本語'); + + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getText(), + ), + ) + .toBe('한글 日本語'); +}); From f144163364826b968dfa47ea1bf22d6ed0e1c66b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 11:09:58 -0700 Subject: [PATCH 04/25] test(input): make structured caret placement engine-neutral --- .../composition-clipboard.browser.spec.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/browser/specs/composition-clipboard.browser.spec.ts b/tests/browser/specs/composition-clipboard.browser.spec.ts index dcc1e68d..24dac78f 100644 --- a/tests/browser/specs/composition-clipboard.browser.spec.ts +++ b/tests/browser/specs/composition-clipboard.browser.spec.ts @@ -92,9 +92,28 @@ for (const inputCase of structuredCommittedInputCases) { (window.inkspanInputHarness as InputHarness).setHtml(sourceHtml); }, inputCase.sourceHtml); + const editable = page.locator('.ProseMirror'); const target = page.locator(`.ProseMirror ${inputCase.selector}`).first(); - await target.click(); - await page.keyboard.press('End'); + await editable.focus(); + await target.evaluate((element) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let textNode: Node | null = null; + for (let candidate = walker.nextNode(); candidate; candidate = walker.nextNode()) { + textNode = candidate; + } + if (!textNode) { + throw new Error('Structured input target has no text node.'); + } + const selection = window.getSelection(); + if (!selection) { + throw new Error('Selection API is unavailable.'); + } + const range = document.createRange(); + range.setStart(textNode, textNode.textContent?.length ?? 0); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + }); await page.keyboard.insertText(inputCase.insertedText); await expect(target).toHaveText(inputCase.expectedText); From e6b5c65398808c22ff597760c9208f627f08055e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:59:58 -0700 Subject: [PATCH 05/25] test(input): exercise touch-capable multilingual focus --- .../specs/touch-clipboard.browser.spec.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/browser/specs/touch-clipboard.browser.spec.ts diff --git a/tests/browser/specs/touch-clipboard.browser.spec.ts b/tests/browser/specs/touch-clipboard.browser.spec.ts new file mode 100644 index 00000000..c46549f0 --- /dev/null +++ b/tests/browser/specs/touch-clipboard.browser.spec.ts @@ -0,0 +1,67 @@ +import { expect, test, type Page } from '@playwright/test'; + +type InputHarness = { + getText: () => string; +}; + +const allowHarnessRequest = (requestUrl: string): boolean => { + const url = new URL(requestUrl); + return url.hostname === '127.0.0.1' && url.port === '4173'; +}; + +const rejectedRequestsByPage = new WeakMap(); + +test.use({ + hasTouch: true, + viewport: { width: 390, height: 844 }, +}); + +test.beforeEach(async ({ page }) => { + const rejectedExternalRequests: string[] = []; + rejectedRequestsByPage.set(page, rejectedExternalRequests); + await page.route('**/*', async (route) => { + if (allowHarnessRequest(route.request().url())) { + await route.continue(); + return; + } + rejectedExternalRequests.push(new URL(route.request().url()).origin); + await route.abort('blockedbyclient'); + }); + await page.goto('/tests/browser/input-harness.html'); +}); + +test.afterEach(async ({ page }) => { + await page.waitForLoadState('networkidle'); + expect(rejectedRequestsByPage.get(page) ?? []).toEqual([]); +}); + +test('preserves multilingual committed input after emulated touch focus', async ({ + page, +}) => { + const editable = page.locator('.ProseMirror'); + const box = await editable.boundingBox(); + expect(box).not.toBeNull(); + if (box === null) { + throw new Error('Editable surface has no touch target bounds.'); + } + + await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2); + await expect(editable).toBeFocused(); + + const text = '한글 日本語 简体中文 繁體中文 Tiếng Việt 👩🏽‍💻'; + await page.keyboard.insertText(text); + + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getText(), + ), + ) + .toBe(text); + + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ).toBe(true); +}); From 448b55c65e1e7fa2c5a53aacd8f32a1c45aa0c60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:08:07 -0700 Subject: [PATCH 06/25] test(input): preserve text across emulated viewport changes --- .../specs/touch-clipboard.browser.spec.ts | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/tests/browser/specs/touch-clipboard.browser.spec.ts b/tests/browser/specs/touch-clipboard.browser.spec.ts index c46549f0..c3d3a5f1 100644 --- a/tests/browser/specs/touch-clipboard.browser.spec.ts +++ b/tests/browser/specs/touch-clipboard.browser.spec.ts @@ -11,6 +11,14 @@ const allowHarnessRequest = (requestUrl: string): boolean => { const rejectedRequestsByPage = new WeakMap(); +const expectNoHorizontalDocumentOverflow = async (page: Page): Promise => { + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ).toBe(true); +}; + test.use({ hasTouch: true, viewport: { width: 390, height: 844 }, @@ -59,9 +67,23 @@ test('preserves multilingual committed input after emulated touch focus', async ) .toBe(text); + await expectNoHorizontalDocumentOverflow(page); + + await page.setViewportSize({ width: 844, height: 390 }); + await expect(editable).toBeFocused(); expect( - await page.evaluate( - () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getText(), ), - ).toBe(true); + ).toBe(text); + await expectNoHorizontalDocumentOverflow(page); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(editable).toBeFocused(); + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getText(), + ), + ).toBe(text); + await expectNoHorizontalDocumentOverflow(page); }); From c56ce124ea8522e442a9251986c4ffe8028712b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:06:51 -0700 Subject: [PATCH 07/25] test(input): prove emulated touch pointer delivery --- .../specs/touch-clipboard.browser.spec.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/browser/specs/touch-clipboard.browser.spec.ts b/tests/browser/specs/touch-clipboard.browser.spec.ts index c3d3a5f1..52735028 100644 --- a/tests/browser/specs/touch-clipboard.browser.spec.ts +++ b/tests/browser/specs/touch-clipboard.browser.spec.ts @@ -43,6 +43,38 @@ test.afterEach(async ({ page }) => { expect(rejectedRequestsByPage.get(page) ?? []).toEqual([]); }); +test('delivers emulated touchscreen input through the Pointer Events touch path', async ({ + page, +}) => { + const editable = page.locator('.ProseMirror'); + const box = await editable.boundingBox(); + expect(box).not.toBeNull(); + if (box === null) { + throw new Error('Editable surface has no touch target bounds.'); + } + + await editable.evaluate((element) => { + element.addEventListener( + 'pointerdown', + (event) => { + if (!(event instanceof PointerEvent)) { + throw new Error('Pointer event is unavailable.'); + } + element.setAttribute('data-last-pointer-type', event.pointerType); + element.setAttribute('data-last-pointer-primary', String(event.isPrimary)); + }, + { once: true }, + ); + }); + + await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2); + + await expect(editable).toHaveAttribute('data-last-pointer-type', 'touch'); + await expect(editable).toHaveAttribute('data-last-pointer-primary', 'true'); + await expect(editable).toBeFocused(); + await expectNoHorizontalDocumentOverflow(page); +}); + test('preserves multilingual committed input after emulated touch focus', async ({ page, }) => { From 0ec1bd035c637de160046f579fbf2bbc69117d82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:38:28 -0700 Subject: [PATCH 08/25] test(input): require read-only committed-input isolation --- .../specs/read-only-clipboard.browser.spec.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/browser/specs/read-only-clipboard.browser.spec.ts diff --git a/tests/browser/specs/read-only-clipboard.browser.spec.ts b/tests/browser/specs/read-only-clipboard.browser.spec.ts new file mode 100644 index 00000000..f8642ec3 --- /dev/null +++ b/tests/browser/specs/read-only-clipboard.browser.spec.ts @@ -0,0 +1,85 @@ +import { expect, test } from '@playwright/test'; + +type InputHarness = { + getText: () => string; + setEditable: (editable: boolean) => boolean; +}; + +const allowHarnessRequest = (requestUrl: string): boolean => { + const url = new URL(requestUrl); + return url.hostname === '127.0.0.1' && url.port === '4173'; +}; + +test.beforeEach(async ({ page }) => { + const rejectedExternalRequests: string[] = []; + await page.route('**/*', async (route) => { + if (allowHarnessRequest(route.request().url())) { + await route.continue(); + return; + } + rejectedExternalRequests.push(new URL(route.request().url()).origin); + await route.abort('blockedbyclient'); + }); + await page.goto('/tests/browser/input-harness.html'); + await page.evaluate((rejected) => { + Object.defineProperty(window, '__inkspanRejectedInputRequests', { + configurable: true, + value: rejected, + }); + }, rejectedExternalRequests); +}); + +test.afterEach(async ({ page }) => { + await page.waitForLoadState('networkidle'); + expect( + await page.evaluate( + () => + (window as typeof window & { __inkspanRejectedInputRequests?: string[] }) + .__inkspanRejectedInputRequests ?? [], + ), + ).toEqual([]); +}); + +test('keeps multilingual committed input inert while read-only and resumes after re-enable', async ({ + page, +}) => { + const editable = page.locator('.ProseMirror'); + await editable.click(); + await page.keyboard.insertText('한글 日本語'); + + await expect + .poll(() => + page.evaluate(() => (window.inkspanInputHarness as InputHarness).getText()), + ) + .toBe('한글 日本語'); + + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).setEditable(false), + ), + ).toBe(false); + await expect(editable).toHaveAttribute('contenteditable', 'false'); + + await editable.focus(); + await page.keyboard.insertText(' 不应写入'); + await expect + .poll(() => + page.evaluate(() => (window.inkspanInputHarness as InputHarness).getText()), + ) + .toBe('한글 日本語'); + + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).setEditable(true), + ), + ).toBe(true); + await expect(editable).toHaveAttribute('contenteditable', 'true'); + await editable.click(); + await page.keyboard.insertText(' Tiếng Việt'); + + await expect + .poll(() => + page.evaluate(() => (window.inkspanInputHarness as InputHarness).getText()), + ) + .toBe('한글 日本語 Tiếng Việt'); +}); From d6c2592da280d4f7467e0f7b8f2a616c02bf132f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:38:40 -0700 Subject: [PATCH 09/25] test(input): expose read-only harness control --- tests/browser/input-harness.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/browser/input-harness.ts b/tests/browser/input-harness.ts index 47291d43..06c16c73 100644 --- a/tests/browser/input-harness.ts +++ b/tests/browser/input-harness.ts @@ -8,6 +8,7 @@ declare global { getText: () => string; isComposing: () => boolean; redo: () => boolean; + setEditable: (editable: boolean) => boolean; setHtml: (html: string) => boolean; undo: () => boolean; }; @@ -30,6 +31,10 @@ window.inkspanInputHarness = Object.freeze({ getText: () => editor.getText(), isComposing: () => editor.view.composing, redo: () => editor.commands.redo(), + setEditable: (editable: boolean) => { + editor.setEditable(editable); + return editor.isEditable; + }, setHtml: (html: string) => editor.commands.setContent(html, false), undo: () => editor.commands.undo(), }); From c9ed56c14483843186c3616c6ff61b11aad53a66 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:38:59 -0700 Subject: [PATCH 10/25] test(input): make network-negative control non-vacuous --- .../specs/read-only-clipboard.browser.spec.ts | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/tests/browser/specs/read-only-clipboard.browser.spec.ts b/tests/browser/specs/read-only-clipboard.browser.spec.ts index f8642ec3..06b79fe1 100644 --- a/tests/browser/specs/read-only-clipboard.browser.spec.ts +++ b/tests/browser/specs/read-only-clipboard.browser.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; type InputHarness = { getText: () => string; @@ -10,8 +10,11 @@ const allowHarnessRequest = (requestUrl: string): boolean => { return url.hostname === '127.0.0.1' && url.port === '4173'; }; +const rejectedRequestsByPage = new WeakMap(); + test.beforeEach(async ({ page }) => { const rejectedExternalRequests: string[] = []; + rejectedRequestsByPage.set(page, rejectedExternalRequests); await page.route('**/*', async (route) => { if (allowHarnessRequest(route.request().url())) { await route.continue(); @@ -21,23 +24,11 @@ test.beforeEach(async ({ page }) => { await route.abort('blockedbyclient'); }); await page.goto('/tests/browser/input-harness.html'); - await page.evaluate((rejected) => { - Object.defineProperty(window, '__inkspanRejectedInputRequests', { - configurable: true, - value: rejected, - }); - }, rejectedExternalRequests); }); test.afterEach(async ({ page }) => { await page.waitForLoadState('networkidle'); - expect( - await page.evaluate( - () => - (window as typeof window & { __inkspanRejectedInputRequests?: string[] }) - .__inkspanRejectedInputRequests ?? [], - ), - ).toEqual([]); + expect(rejectedRequestsByPage.get(page) ?? []).toEqual([]); }); test('keeps multilingual committed input inert while read-only and resumes after re-enable', async ({ From b4d9a3e37f8dda981cb978b83061a2b33cffaffb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 05:08:33 -0700 Subject: [PATCH 11/25] test(input): cover read-only transition during composition --- .../composition-clipboard.browser.spec.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/tests/browser/specs/composition-clipboard.browser.spec.ts b/tests/browser/specs/composition-clipboard.browser.spec.ts index 24dac78f..06f60ed3 100644 --- a/tests/browser/specs/composition-clipboard.browser.spec.ts +++ b/tests/browser/specs/composition-clipboard.browser.spec.ts @@ -5,6 +5,7 @@ type InputHarness = { getText: () => string; isComposing: () => boolean; redo: () => boolean; + setEditable: (editable: boolean) => boolean; setHtml: (html: string) => boolean; undo: () => boolean; }; @@ -210,6 +211,74 @@ test('tracks a synthetic composition lifecycle without inventing OS IME evidence ).toContain('한글'); }); +test('does not admit committed input when read-only begins during composition', async ({ + page, +}) => { + const editable = page.locator('.ProseMirror'); + await editable.click(); + await page.keyboard.insertText('기준'); + + await editable.evaluate((element) => { + element.dispatchEvent( + new CompositionEvent('compositionstart', { bubbles: true, data: '' }), + ); + }); + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).isComposing(), + ), + ) + .toBe(true); + + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).setEditable(false), + ), + ).toBe(false); + await expect(editable).toHaveAttribute('contenteditable', 'false'); + + await editable.evaluate((element) => { + element.dispatchEvent( + new CompositionEvent('compositionend', { bubbles: true, data: '차단' }), + ); + }); + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).isComposing(), + ), + ) + .toBe(false); + + await editable.focus(); + await page.keyboard.insertText(' 차단'); + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getText(), + ), + ) + .toBe('기준'); + + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).setEditable(true), + ), + ).toBe(true); + await editable.click(); + await page.keyboard.press('End'); + await page.keyboard.insertText(' 재개'); + + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getText(), + ), + ) + .toBe('기준 재개'); +}); + test('preserves multilingual committed input in a narrow emulated viewport', async ({ page, }) => { From 9887b06b4648c746e8211b7518fa8d586141afc2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:16:32 -0700 Subject: [PATCH 12/25] test(input): exercise read-only composition through CwlEditor --- tests/browser/input-harness.ts | 56 ++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/tests/browser/input-harness.ts b/tests/browser/input-harness.ts index 06c16c73..43ba08ea 100644 --- a/tests/browser/input-harness.ts +++ b/tests/browser/input-harness.ts @@ -1,5 +1,7 @@ -import { Editor } from '@tiptap/core'; -import { buildExtensions } from 'inkspan-browser-under-test'; +import type { Editor } from '@tiptap/core'; +import { createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { CwlEditor } from 'inkspan-browser-under-test'; declare global { interface Window { @@ -20,21 +22,43 @@ if (!(element instanceof HTMLElement)) { throw new Error('Input assurance harness editor host is missing.'); } -const editor = new Editor({ - element, - extensions: buildExtensions(), - content: '', -}); +let editor: Editor | null = null; +let editable = true; +const root = createRoot(element); + +const renderEditor = () => { + root.render( + createElement(CwlEditor, { + mode: 'html', + defaultValue: '', + editable, + hideToolbar: true, + onReady: (instance: Editor) => { + editor = instance; + }, + }), + ); +}; + +const getEditor = (): Editor => { + if (!editor) { + throw new Error('Input assurance harness editor is not ready.'); + } + return editor; +}; + +renderEditor(); window.inkspanInputHarness = Object.freeze({ - getHtml: () => editor.getHTML(), - getText: () => editor.getText(), - isComposing: () => editor.view.composing, - redo: () => editor.commands.redo(), - setEditable: (editable: boolean) => { - editor.setEditable(editable); - return editor.isEditable; + getHtml: () => getEditor().getHTML(), + getText: () => getEditor().getText(), + isComposing: () => getEditor().view.composing, + redo: () => getEditor().commands.redo(), + setEditable: (nextEditable: boolean) => { + editable = nextEditable; + renderEditor(); + return editable; }, - setHtml: (html: string) => editor.commands.setContent(html, false), - undo: () => editor.commands.undo(), + setHtml: (html: string) => getEditor().commands.setContent(html, false), + undo: () => getEditor().commands.undo(), }); From f67026ac6c93615feccea12e237312ece20ff66e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:21:55 -0700 Subject: [PATCH 13/25] test(input): wait for editor readiness before harness calls --- tests/browser/specs/composition-clipboard.browser.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/browser/specs/composition-clipboard.browser.spec.ts b/tests/browser/specs/composition-clipboard.browser.spec.ts index 06f60ed3..bce01c34 100644 --- a/tests/browser/specs/composition-clipboard.browser.spec.ts +++ b/tests/browser/specs/composition-clipboard.browser.spec.ts @@ -62,6 +62,10 @@ test.beforeEach(async ({ page }) => { await route.abort('blockedbyclient'); }); await page.goto('/tests/browser/input-harness.html'); + await expect(page.locator('.ProseMirror')).toHaveAttribute( + 'contenteditable', + 'true', + ); }); test.afterEach(async ({ page }) => { From c9e9262fe6589d885635c2adfcaacf92ccee2d14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:15:11 -0700 Subject: [PATCH 14/25] test(input): expose native form serialization --- tests/browser/input-harness.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/browser/input-harness.ts b/tests/browser/input-harness.ts index 43ba08ea..d464bad4 100644 --- a/tests/browser/input-harness.ts +++ b/tests/browser/input-harness.ts @@ -33,6 +33,7 @@ const renderEditor = () => { defaultValue: '', editable, hideToolbar: true, + formFieldName: 'message_body', onReady: (instance: Editor) => { editor = instance; }, From 4d8ee51012a650fbafa0e6188173c4ca328b416e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:17:15 -0700 Subject: [PATCH 15/25] test(input): prove native form serialization after composition --- .../specs/composition-clipboard.browser.spec.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/browser/specs/composition-clipboard.browser.spec.ts b/tests/browser/specs/composition-clipboard.browser.spec.ts index bce01c34..b7ccdde8 100644 --- a/tests/browser/specs/composition-clipboard.browser.spec.ts +++ b/tests/browser/specs/composition-clipboard.browser.spec.ts @@ -208,11 +208,13 @@ test('tracks a synthetic composition lifecycle without inventing OS IME evidence ) .toBe('한글'); - expect( - await page.evaluate(() => - (window.inkspanInputHarness as InputHarness).getHtml(), - ), - ).toContain('한글'); + const committedHtml = await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getHtml(), + ); + expect(committedHtml).toContain('한글'); + await expect( + page.locator('[data-inkspan-form-field][name="message_body"]'), + ).toHaveValue(committedHtml); }); test('does not admit committed input when read-only begins during composition', async ({ From c15e361f3b5fa6517182384a00b4430b4de46077 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:12:58 -0700 Subject: [PATCH 16/25] test(input): require narrow touch toolbar targets --- .../specs/touch-clipboard.browser.spec.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/browser/specs/touch-clipboard.browser.spec.ts b/tests/browser/specs/touch-clipboard.browser.spec.ts index 52735028..2d7a9b72 100644 --- a/tests/browser/specs/touch-clipboard.browser.spec.ts +++ b/tests/browser/specs/touch-clipboard.browser.spec.ts @@ -75,6 +75,57 @@ test('delivers emulated touchscreen input through the Pointer Events touch path' await expectNoHorizontalDocumentOverflow(page); }); +test('keeps enabled toolbar targets touch-operable without hover on a narrow viewport', async ({ + page, +}) => { + await page.goto('/tests/browser/input-harness.html?toolbar=1'); + + const toolbar = page.getByRole('toolbar', { name: 'Formatting' }); + await expect(toolbar).toBeVisible(); + + const enabledButtons = toolbar.locator( + 'button[data-cwl-toolbar-item="true"]:not(:disabled)', + ); + const enabledCount = await enabledButtons.count(); + expect(enabledCount).toBeGreaterThan(0); + + for (let index = 0; index < enabledCount; index += 1) { + const box = await enabledButtons.nth(index).boundingBox(); + expect(box).not.toBeNull(); + if (box === null) { + throw new Error('Enabled toolbar control has no touch target bounds.'); + } + expect(box.width).toBeGreaterThanOrEqual(24); + expect(box.height).toBeGreaterThanOrEqual(24); + } + + const editable = page.locator('.ProseMirror'); + const editableBox = await editable.boundingBox(); + expect(editableBox).not.toBeNull(); + if (editableBox === null) { + throw new Error('Editable surface has no touch target bounds.'); + } + await page.touchscreen.tap( + editableBox.x + editableBox.width / 2, + editableBox.y + editableBox.height / 2, + ); + await page.keyboard.insertText('touch target'); + + const bold = page.getByRole('button', { name: 'Bold (Ctrl/Cmd+B)' }); + const boldBox = await bold.boundingBox(); + expect(boldBox).not.toBeNull(); + if (boldBox === null) { + throw new Error('Bold toolbar control has no touch target bounds.'); + } + await page.touchscreen.tap( + boldBox.x + boldBox.width / 2, + boldBox.y + boldBox.height / 2, + ); + + await expect(bold).toHaveAttribute('aria-pressed', 'true'); + await expectNoHorizontalDocumentOverflow(page); +}); + test('preserves multilingual committed input after emulated touch focus', async ({ page, }) => { From 22d4df3a67af5b7dedab476953557f9375aeef4e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:14:26 -0700 Subject: [PATCH 17/25] test(input): expose toolbar in touch harness --- tests/browser/input-harness.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/browser/input-harness.ts b/tests/browser/input-harness.ts index d464bad4..f5756239 100644 --- a/tests/browser/input-harness.ts +++ b/tests/browser/input-harness.ts @@ -22,6 +22,7 @@ if (!(element instanceof HTMLElement)) { throw new Error('Input assurance harness editor host is missing.'); } +const showToolbar = new URLSearchParams(window.location.search).get('toolbar') === '1'; let editor: Editor | null = null; let editable = true; const root = createRoot(element); @@ -32,7 +33,7 @@ const renderEditor = () => { mode: 'html', defaultValue: '', editable, - hideToolbar: true, + hideToolbar: !showToolbar, formFieldName: 'message_body', onReady: (instance: Editor) => { editor = instance; From 30690cf882abe136eb1565ca1326237bb5d1bc45 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:02:35 -0700 Subject: [PATCH 18/25] test(input): bind composition editability to native form state --- .../composition-native-form.browser.spec.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 tests/browser/specs/composition-native-form.browser.spec.ts diff --git a/tests/browser/specs/composition-native-form.browser.spec.ts b/tests/browser/specs/composition-native-form.browser.spec.ts new file mode 100644 index 00000000..96e1d21e --- /dev/null +++ b/tests/browser/specs/composition-native-form.browser.spec.ts @@ -0,0 +1,92 @@ +import { expect, test } from '@playwright/test'; + +type InputHarness = { + getHtml: () => string; + getText: () => string; + isComposing: () => boolean; + setEditable: (editable: boolean) => boolean; +}; + +const HARNESS_URL = '/tests/browser/input-harness.html'; +const FORM_FIELD = '[data-inkspan-form-field][name="message_body"]'; + +test('keeps native-form serialization atomic when editability is revoked during composition', async ({ + page, +}) => { + await page.goto(HARNESS_URL); + + const editable = page.locator('.ProseMirror'); + await expect(editable).toHaveAttribute('contenteditable', 'true'); + await editable.click(); + await page.keyboard.insertText('기준'); + + const baselineHtml = await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getHtml(), + ); + await expect(page.locator(FORM_FIELD)).toHaveValue(baselineHtml); + + await editable.evaluate((element) => { + element.dispatchEvent( + new CompositionEvent('compositionstart', { bubbles: true, data: '' }), + ); + }); + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).isComposing(), + ), + ) + .toBe(true); + + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).setEditable(false), + ), + ).toBe(false); + await expect(editable).toHaveAttribute('contenteditable', 'false'); + + await editable.evaluate((element) => { + element.dispatchEvent( + new CompositionEvent('compositionend', { bubbles: true, data: '차단' }), + ); + }); + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).isComposing(), + ), + ) + .toBe(false); + + await editable.focus(); + await page.keyboard.insertText(' 차단'); + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getText(), + ), + ) + .toBe('기준'); + await expect(page.locator(FORM_FIELD)).toHaveValue(baselineHtml); + + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).setEditable(true), + ), + ).toBe(true); + await editable.click(); + await page.keyboard.press('End'); + await page.keyboard.insertText(' 재개'); + + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getText(), + ), + ) + .toBe('기준 재개'); + const resumedHtml = await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getHtml(), + ); + await expect(page.locator(FORM_FIELD)).toHaveValue(resumedHtml); +}); From bcd789c26554cb63ea855a3be25bc763417041c6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 18:12:25 -0700 Subject: [PATCH 19/25] test(input): require clean remount after active composition --- .../composition-native-form.browser.spec.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/browser/specs/composition-native-form.browser.spec.ts b/tests/browser/specs/composition-native-form.browser.spec.ts index 96e1d21e..10e06761 100644 --- a/tests/browser/specs/composition-native-form.browser.spec.ts +++ b/tests/browser/specs/composition-native-form.browser.spec.ts @@ -4,6 +4,7 @@ type InputHarness = { getHtml: () => string; getText: () => string; isComposing: () => boolean; + remount: () => boolean; setEditable: (editable: boolean) => boolean; }; @@ -90,3 +91,60 @@ test('keeps native-form serialization atomic when editability is revoked during ); await expect(page.locator(FORM_FIELD)).toHaveValue(resumedHtml); }); + +test('destroys an active composition and remounts a clean native-form editor', async ({ + page, +}) => { + await page.goto(HARNESS_URL); + + const editable = page.locator('.ProseMirror'); + await editable.click(); + await page.keyboard.insertText('작성 중'); + await editable.evaluate((element) => { + element.dispatchEvent( + new CompositionEvent('compositionstart', { bubbles: true, data: '' }), + ); + }); + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).isComposing(), + ), + ) + .toBe(true); + + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).remount(), + ), + ).toBe(true); + + const remountedEditable = page.locator('.ProseMirror'); + await expect(remountedEditable).toHaveAttribute('contenteditable', 'true'); + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).isComposing(), + ), + ) + .toBe(false); + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getText(), + ), + ) + .toBe(''); + await expect(page.locator(FORM_FIELD)).toHaveValue(''); + + await remountedEditable.click(); + await page.keyboard.insertText('새 세션'); + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getText(), + ), + ) + .toBe('새 세션'); + await expect(page.locator(FORM_FIELD)).not.toHaveValue(''); +}); From ca7450b6d9f8b9ac10931b71098a2f514a7418d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 18:12:49 -0700 Subject: [PATCH 20/25] test(input): support composition teardown remount assurance --- tests/browser/input-harness.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/browser/input-harness.ts b/tests/browser/input-harness.ts index f5756239..93d909e0 100644 --- a/tests/browser/input-harness.ts +++ b/tests/browser/input-harness.ts @@ -10,6 +10,7 @@ declare global { getText: () => string; isComposing: () => boolean; redo: () => boolean; + remount: () => boolean; setEditable: (editable: boolean) => boolean; setHtml: (html: string) => boolean; undo: () => boolean; @@ -25,7 +26,7 @@ if (!(element instanceof HTMLElement)) { const showToolbar = new URLSearchParams(window.location.search).get('toolbar') === '1'; let editor: Editor | null = null; let editable = true; -const root = createRoot(element); +let root = createRoot(element); const renderEditor = () => { root.render( @@ -56,6 +57,13 @@ window.inkspanInputHarness = Object.freeze({ getText: () => getEditor().getText(), isComposing: () => getEditor().view.composing, redo: () => getEditor().commands.redo(), + remount: () => { + root.unmount(); + editor = null; + root = createRoot(element); + renderEditor(); + return true; + }, setEditable: (nextEditable: boolean) => { editable = nextEditable; renderEditor(); From 4719f830e59e5d0c28a4f90ecaea4a871ac24b69 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 20:03:45 -0700 Subject: [PATCH 21/25] test(browser): include all engine browser specs --- tests/browser/playwright.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/browser/playwright.config.ts b/tests/browser/playwright.config.ts index 375056da..85c2a855 100644 --- a/tests/browser/playwright.config.ts +++ b/tests/browser/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; const HARNESS_ORIGIN = 'http://127.0.0.1:4173'; const HARNESS_URL = `${HARNESS_ORIGIN}/tests/browser/harness.html`; -const ENGINE_BROWSER_SPECS = /(?:clipboard|print)\.browser\.spec\.ts/u; +const ENGINE_BROWSER_SPECS = /\.browser\.spec\.ts$/u; export default defineConfig({ testDir: './specs', @@ -44,4 +44,4 @@ export default defineConfig({ dependencies: ['chromium', 'firefox', 'webkit'], }, ], -}); \ No newline at end of file +}); From e807c522df71886d8a5c13360d99aea41bd8a1c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 20:05:22 -0700 Subject: [PATCH 22/25] test(input): enforce loopback-only native-form assurance --- .../composition-native-form.browser.spec.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/browser/specs/composition-native-form.browser.spec.ts b/tests/browser/specs/composition-native-form.browser.spec.ts index 10e06761..9cce5a81 100644 --- a/tests/browser/specs/composition-native-form.browser.spec.ts +++ b/tests/browser/specs/composition-native-form.browser.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; type InputHarness = { getHtml: () => string; @@ -11,6 +11,31 @@ type InputHarness = { const HARNESS_URL = '/tests/browser/input-harness.html'; const FORM_FIELD = '[data-inkspan-form-field][name="message_body"]'; +const allowHarnessRequest = (requestUrl: string): boolean => { + const url = new URL(requestUrl); + return url.hostname === '127.0.0.1' && url.port === '4173'; +}; + +const rejectedRequestsByPage = new WeakMap(); + +test.beforeEach(async ({ page }) => { + const rejectedExternalRequests: string[] = []; + rejectedRequestsByPage.set(page, rejectedExternalRequests); + await page.route('**/*', async (route) => { + if (allowHarnessRequest(route.request().url())) { + await route.continue(); + return; + } + rejectedExternalRequests.push(new URL(route.request().url()).origin); + await route.abort('blockedbyclient'); + }); +}); + +test.afterEach(async ({ page }) => { + await page.waitForLoadState('networkidle'); + expect(rejectedRequestsByPage.get(page) ?? []).toEqual([]); +}); + test('keeps native-form serialization atomic when editability is revoked during composition', async ({ page, }) => { From bf3be655c177dab75296c8ffbf4dcd2ad82b4172 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 12:07:19 -0700 Subject: [PATCH 23/25] test(input): strengthen narrow viewport touch evidence --- tests/browser/specs/touch-clipboard.browser.spec.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/browser/specs/touch-clipboard.browser.spec.ts b/tests/browser/specs/touch-clipboard.browser.spec.ts index 2d7a9b72..d21d59e8 100644 --- a/tests/browser/specs/touch-clipboard.browser.spec.ts +++ b/tests/browser/specs/touch-clipboard.browser.spec.ts @@ -124,6 +124,18 @@ test('keeps enabled toolbar targets touch-operable without hover on a narrow vie await expect(bold).toHaveAttribute('aria-pressed', 'true'); await expectNoHorizontalDocumentOverflow(page); + + await page.setViewportSize({ width: 320, height: 568 }); + for (let index = 0; index < enabledCount; index += 1) { + const box = await enabledButtons.nth(index).boundingBox(); + expect(box).not.toBeNull(); + if (box === null) { + throw new Error('Enabled toolbar control lost its touch target bounds after reflow.'); + } + expect(box.width).toBeGreaterThanOrEqual(24); + expect(box.height).toBeGreaterThanOrEqual(24); + } + await expectNoHorizontalDocumentOverflow(page); }); test('preserves multilingual committed input after emulated touch focus', async ({ From 4a1ffa4a9f13878d0689027c1443465b747c268b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 13:20:26 -0700 Subject: [PATCH 24/25] test(browser): load production styles in input harness --- tests/browser/input-harness.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/browser/input-harness.ts b/tests/browser/input-harness.ts index 93d909e0..6cf779dd 100644 --- a/tests/browser/input-harness.ts +++ b/tests/browser/input-harness.ts @@ -2,6 +2,7 @@ import type { Editor } from '@tiptap/core'; import { createElement } from 'react'; import { createRoot } from 'react-dom/client'; import { CwlEditor } from 'inkspan-browser-under-test'; +import '../../src/styles.css'; declare global { interface Window { From b89b38e22c10d01d2462b22361e4bc20b08f9ba0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 13:21:00 -0700 Subject: [PATCH 25/25] test(browser): assert canonical form serialization after remount --- .../specs/composition-native-form.browser.spec.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/browser/specs/composition-native-form.browser.spec.ts b/tests/browser/specs/composition-native-form.browser.spec.ts index 9cce5a81..8b638f61 100644 --- a/tests/browser/specs/composition-native-form.browser.spec.ts +++ b/tests/browser/specs/composition-native-form.browser.spec.ts @@ -160,7 +160,11 @@ test('destroys an active composition and remounts a clean native-form editor', a ), ) .toBe(''); - await expect(page.locator(FORM_FIELD)).toHaveValue(''); + const remountedHtml = await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getHtml(), + ); + expect(remountedHtml).not.toContain('작성 중'); + await expect(page.locator(FORM_FIELD)).toHaveValue(remountedHtml); await remountedEditable.click(); await page.keyboard.insertText('새 세션'); @@ -171,5 +175,9 @@ test('destroys an active composition and remounts a clean native-form editor', a ), ) .toBe('새 세션'); - await expect(page.locator(FORM_FIELD)).not.toHaveValue(''); + const newSessionHtml = await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getHtml(), + ); + expect(newSessionHtml).toContain('새 세션'); + await expect(page.locator(FORM_FIELD)).toHaveValue(newSessionHtml); });