From 4b5ab0fb651b0b61d9a7ec5d860cd98dcbca036b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:31:16 -0700 Subject: [PATCH 01/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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); }); From a5d624e6e061d46d42fad4e99c090da17f54f1c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 01:32:01 +0900 Subject: [PATCH 26/38] test(input): cover toolbar use during composition Signed-off-by: Seongho Bae --- .../composition-clipboard.browser.spec.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/browser/specs/composition-clipboard.browser.spec.ts b/tests/browser/specs/composition-clipboard.browser.spec.ts index b7ccdde8..c602468b 100644 --- a/tests/browser/specs/composition-clipboard.browser.spec.ts +++ b/tests/browser/specs/composition-clipboard.browser.spec.ts @@ -217,6 +217,62 @@ test('tracks a synthetic composition lifecycle without inventing OS IME evidence ).toHaveValue(committedHtml); }); +test('keeps committed text and editor focus when the toolbar is used during composition', async ({ + page, +}) => { + await page.goto('/tests/browser/input-harness.html?toolbar=1'); + + const editable = page.locator('.ProseMirror'); + await editable.click(); + await page.keyboard.insertText('한글'); + const committedHtml = await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getHtml(), + ); + + await editable.evaluate((element) => { + element.dispatchEvent( + new CompositionEvent('compositionstart', { bubbles: true, data: '' }), + ); + }); + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).isComposing(), + ), + ) + .toBe(true); + + const bold = page.getByRole('button', { name: 'Bold (Ctrl/Cmd+B)' }); + await bold.click(); + await expect(bold).toHaveAttribute('aria-pressed', 'true'); + await expect(editable).toBeFocused(); + await expect( + page.locator('[data-inkspan-form-field][name="message_body"]'), + ).toHaveValue(committedHtml); + + await editable.evaluate((element) => { + element.dispatchEvent( + new CompositionEvent('compositionend', { bubbles: true, data: '' }), + ); + }); + await page.keyboard.insertText(' 입력'); + + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getText(), + ), + ) + .toBe('한글 입력'); + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getHtml(), + ), + ) + .toContain(' 입력'); +}); + test('does not admit committed input when read-only begins during composition', async ({ page, }) => { From 71cb311c30c9e39753102a16cb2b3bc083056d69 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:10:57 +0900 Subject: [PATCH 27/38] test(input): preserve touch toolbar focus Verify that activating an enabled formatting control by touch keeps the editor focused across Chromium, Firefox, and WebKit. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) --- tests/browser/specs/touch-clipboard.browser.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/browser/specs/touch-clipboard.browser.spec.ts b/tests/browser/specs/touch-clipboard.browser.spec.ts index d21d59e8..e2a92bce 100644 --- a/tests/browser/specs/touch-clipboard.browser.spec.ts +++ b/tests/browser/specs/touch-clipboard.browser.spec.ts @@ -123,6 +123,7 @@ test('keeps enabled toolbar targets touch-operable without hover on a narrow vie ); await expect(bold).toHaveAttribute('aria-pressed', 'true'); + await expect(editable).toBeFocused(); await expectNoHorizontalDocumentOverflow(page); await page.setViewportSize({ width: 320, height: 568 }); From a90cf87cbc045b584fab9ab50c98732748453caa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:42:55 +0900 Subject: [PATCH 28/38] test: cover controlled value during composition Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) --- tests/browser/input-harness.ts | 27 +++++++- .../composition-clipboard.browser.spec.ts | 61 +++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/tests/browser/input-harness.ts b/tests/browser/input-harness.ts index 6cf779dd..837fc212 100644 --- a/tests/browser/input-harness.ts +++ b/tests/browser/input-harness.ts @@ -8,11 +8,14 @@ declare global { interface Window { inkspanInputHarness: { getHtml: () => string; + getDocumentChanges: () => string[]; getText: () => string; isComposing: () => boolean; + insertText: (text: string) => boolean; redo: () => boolean; remount: () => boolean; setEditable: (editable: boolean) => boolean; + setControlledHtml: (html: string) => boolean; setHtml: (html: string) => boolean; undo: () => boolean; }; @@ -24,19 +27,31 @@ if (!(element instanceof HTMLElement)) { throw new Error('Input assurance harness editor host is missing.'); } -const showToolbar = new URLSearchParams(window.location.search).get('toolbar') === '1'; +const searchParams = new URLSearchParams(window.location.search); +const showToolbar = searchParams.get('toolbar') === '1'; +const controlled = searchParams.get('controlled') === '1'; let editor: Editor | null = null; let editable = true; +let controlledHtml = ''; +const documentChanges: string[] = []; let root = createRoot(element); const renderEditor = () => { root.render( createElement(CwlEditor, { mode: 'html', - defaultValue: '', + ...(controlled ? { value: controlledHtml } : { defaultValue: '' }), editable, hideToolbar: !showToolbar, formFieldName: 'message_body', + onChange: controlled + ? (value: string) => { + controlledHtml = value; + } + : undefined, + onDocumentChange: ({ snapshot }) => { + documentChanges.push(snapshot.value); + }, onReady: (instance: Editor) => { editor = instance; }, @@ -55,8 +70,10 @@ renderEditor(); window.inkspanInputHarness = Object.freeze({ getHtml: () => getEditor().getHTML(), + getDocumentChanges: () => [...documentChanges], getText: () => getEditor().getText(), isComposing: () => getEditor().view.composing, + insertText: (text: string) => getEditor().commands.insertContent(text), redo: () => getEditor().commands.redo(), remount: () => { root.unmount(); @@ -70,6 +87,12 @@ window.inkspanInputHarness = Object.freeze({ renderEditor(); return editable; }, + setControlledHtml: (html: string) => { + if (!controlled) return false; + controlledHtml = html; + renderEditor(); + return true; + }, setHtml: (html: string) => getEditor().commands.setContent(html, false), undo: () => getEditor().commands.undo(), }); diff --git a/tests/browser/specs/composition-clipboard.browser.spec.ts b/tests/browser/specs/composition-clipboard.browser.spec.ts index c602468b..6adc3c51 100644 --- a/tests/browser/specs/composition-clipboard.browser.spec.ts +++ b/tests/browser/specs/composition-clipboard.browser.spec.ts @@ -1,11 +1,14 @@ import { expect, test, type Page } from '@playwright/test'; type InputHarness = { + getDocumentChanges: () => string[]; getHtml: () => string; getText: () => string; isComposing: () => boolean; + insertText: (text: string) => boolean; redo: () => boolean; setEditable: (editable: boolean) => boolean; + setControlledHtml: (html: string) => boolean; setHtml: (html: string) => boolean; undo: () => boolean; }; @@ -217,6 +220,64 @@ test('tracks a synthetic composition lifecycle without inventing OS IME evidence ).toHaveValue(committedHtml); }); +test('publishes the composed revision before applying a newer controlled value', async ({ + page, +}) => { + await page.goto('/tests/browser/input-harness.html?controlled=1'); + + 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); + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).insertText('작성 중'), + ), + ).toBe(true); + + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).setControlledHtml( + '

저장된 개정

', + ), + ), + ).toBe(true); + await expect(editable).toHaveText('작성 중'); + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getDocumentChanges(), + ), + ).toEqual([]); + + await editable.evaluate((element) => { + element.dispatchEvent( + new CompositionEvent('compositionend', { bubbles: true, data: '' }), + ); + }); + + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getDocumentChanges(), + ), + ) + .toEqual(['

작성 중

']); + await expect(editable).toHaveText('저장된 개정'); + await expect( + page.locator('[data-inkspan-form-field][name="message_body"]'), + ).toHaveValue('

저장된 개정

'); +}); + test('keeps committed text and editor focus when the toolbar is used during composition', async ({ page, }) => { From aceca4b883f5657a9f1ffbbec018fed5d0116283 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:07:15 +0900 Subject: [PATCH 29/38] test: bind composition saves to revisions Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) --- tests/browser/input-harness.ts | 24 +++++++++++++++++-- .../composition-clipboard.browser.spec.ts | 16 +++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/tests/browser/input-harness.ts b/tests/browser/input-harness.ts index 837fc212..6171cd5e 100644 --- a/tests/browser/input-harness.ts +++ b/tests/browser/input-harness.ts @@ -1,7 +1,12 @@ import type { Editor } from '@tiptap/core'; -import { createElement } from 'react'; +import { createElement, createRef } from 'react'; import { createRoot } from 'react-dom/client'; -import { CwlEditor } from 'inkspan-browser-under-test'; +import { + createDocumentEnvelope, + createDocumentEnvelopeRevision, + CwlEditor, + type CwlEditorHandle, +} from 'inkspan-browser-under-test'; import '../../src/styles.css'; declare global { @@ -9,6 +14,8 @@ declare global { inkspanInputHarness: { getHtml: () => string; getDocumentChanges: () => string[]; + getDocumentChangeRevisionTags: () => Promise; + getRevisionTag: () => Promise; getText: () => string; isComposing: () => boolean; insertText: (text: string) => boolean; @@ -34,11 +41,14 @@ let editor: Editor | null = null; let editable = true; let controlledHtml = ''; const documentChanges: string[] = []; +const documentChangeRevisionTags: Promise[] = []; +const editorHandle = createRef(); let root = createRoot(element); const renderEditor = () => { root.render( createElement(CwlEditor, { + ref: editorHandle, mode: 'html', ...(controlled ? { value: controlledHtml } : { defaultValue: '' }), editable, @@ -51,6 +61,11 @@ const renderEditor = () => { : undefined, onDocumentChange: ({ snapshot }) => { documentChanges.push(snapshot.value); + documentChangeRevisionTags.push( + createDocumentEnvelopeRevision( + createDocumentEnvelope(snapshot.documentJson), + ).then((revision) => revision.strongEntityTag), + ); }, onReady: (instance: Editor) => { editor = instance; @@ -71,6 +86,11 @@ renderEditor(); window.inkspanInputHarness = Object.freeze({ getHtml: () => getEditor().getHTML(), getDocumentChanges: () => [...documentChanges], + getDocumentChangeRevisionTags: () => + Promise.all(documentChangeRevisionTags), + getRevisionTag: async () => + (await editorHandle.current?.getDocumentEnvelopeRevision()) + ?.strongEntityTag ?? '', getText: () => getEditor().getText(), isComposing: () => getEditor().view.composing, insertText: (text: string) => getEditor().commands.insertContent(text), diff --git a/tests/browser/specs/composition-clipboard.browser.spec.ts b/tests/browser/specs/composition-clipboard.browser.spec.ts index 6adc3c51..a7c75670 100644 --- a/tests/browser/specs/composition-clipboard.browser.spec.ts +++ b/tests/browser/specs/composition-clipboard.browser.spec.ts @@ -2,7 +2,9 @@ import { expect, test, type Page } from '@playwright/test'; type InputHarness = { getDocumentChanges: () => string[]; + getDocumentChangeRevisionTags: () => Promise; getHtml: () => string; + getRevisionTag: () => Promise; getText: () => string; isComposing: () => boolean; insertText: (text: string) => boolean; @@ -258,6 +260,11 @@ test('publishes the composed revision before applying a newer controlled value', (window.inkspanInputHarness as InputHarness).getDocumentChanges(), ), ).toEqual([]); + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getDocumentChangeRevisionTags(), + ), + ).toEqual([]); await editable.evaluate((element) => { element.dispatchEvent( @@ -272,7 +279,16 @@ test('publishes the composed revision before applying a newer controlled value', ), ) .toEqual(['

작성 중

']); + const [composedRevisionTag] = await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getDocumentChangeRevisionTags(), + ); + expect(composedRevisionTag).toMatch(/^"sha256-[0-9a-f]{64}"$/u); await expect(editable).toHaveText('저장된 개정'); + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getRevisionTag(), + ), + ).not.toBe(composedRevisionTag); await expect( page.locator('[data-inkspan-form-field][name="message_body"]'), ).toHaveValue('

저장된 개정

'); From 5a1ba2e15c782bb943d979305c6a988d55870b0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 15:29:11 +0900 Subject: [PATCH 30/38] test: verify composition autosave revision Exercise the existing autosave queue with composition snapshots and assert the exact composed revision reaches the host save callback once. Resolve the autosave artifact alongside the editor in packed browser tests. Signed-off-by: Seongho Bae --- tests/browser/input-harness.ts | 25 ++++++++++++++-- .../composition-clipboard.browser.spec.ts | 29 +++++++++++++++---- tests/browser/vite.config.ts | 3 ++ 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/tests/browser/input-harness.ts b/tests/browser/input-harness.ts index 6171cd5e..d60cef8b 100644 --- a/tests/browser/input-harness.ts +++ b/tests/browser/input-harness.ts @@ -3,10 +3,11 @@ import { createElement, createRef } from 'react'; import { createRoot } from 'react-dom/client'; import { createDocumentEnvelope, - createDocumentEnvelopeRevision, + createDocumentEnvelopeRevisionEvidence, CwlEditor, type CwlEditorHandle, } from 'inkspan-browser-under-test'; +import { createDocumentAutosaveQueue } from 'inkspan-autosave-under-test'; import '../../src/styles.css'; declare global { @@ -15,6 +16,7 @@ declare global { getHtml: () => string; getDocumentChanges: () => string[]; getDocumentChangeRevisionTags: () => Promise; + getAutosaveRevisionTags: () => Promise; getRevisionTag: () => Promise; getText: () => string; isComposing: () => boolean; @@ -42,6 +44,15 @@ let editable = true; let controlledHtml = ''; const documentChanges: string[] = []; const documentChangeRevisionTags: Promise[] = []; +const autosaveRevisionTags: string[] = []; +const autosaveQueue = searchParams.get('autosave') === '1' + ? createDocumentAutosaveQueue({ + save: (evidence) => { + autosaveRevisionTags.push(evidence.revision.strongEntityTag); + return { status: 'saved' }; + }, + }) + : null; const editorHandle = createRef(); let root = createRoot(element); @@ -62,9 +73,12 @@ const renderEditor = () => { onDocumentChange: ({ snapshot }) => { documentChanges.push(snapshot.value); documentChangeRevisionTags.push( - createDocumentEnvelopeRevision( + createDocumentEnvelopeRevisionEvidence( createDocumentEnvelope(snapshot.documentJson), - ).then((revision) => revision.strongEntityTag), + ).then(async (evidence) => { + await autosaveQueue?.enqueue(evidence); + return evidence.revision.strongEntityTag; + }), ); }, onReady: (instance: Editor) => { @@ -88,6 +102,11 @@ window.inkspanInputHarness = Object.freeze({ getDocumentChanges: () => [...documentChanges], getDocumentChangeRevisionTags: () => Promise.all(documentChangeRevisionTags), + getAutosaveRevisionTags: async () => { + await Promise.all(documentChangeRevisionTags); + await autosaveQueue?.flush(); + return [...autosaveRevisionTags]; + }, getRevisionTag: async () => (await editorHandle.current?.getDocumentEnvelopeRevision()) ?.strongEntityTag ?? '', diff --git a/tests/browser/specs/composition-clipboard.browser.spec.ts b/tests/browser/specs/composition-clipboard.browser.spec.ts index a7c75670..61903134 100644 --- a/tests/browser/specs/composition-clipboard.browser.spec.ts +++ b/tests/browser/specs/composition-clipboard.browser.spec.ts @@ -3,6 +3,7 @@ import { expect, test, type Page } from '@playwright/test'; type InputHarness = { getDocumentChanges: () => string[]; getDocumentChangeRevisionTags: () => Promise; + getAutosaveRevisionTags: () => Promise; getHtml: () => string; getRevisionTag: () => Promise; getText: () => string; @@ -225,7 +226,7 @@ test('tracks a synthetic composition lifecycle without inventing OS IME evidence test('publishes the composed revision before applying a newer controlled value', async ({ page, }) => { - await page.goto('/tests/browser/input-harness.html?controlled=1'); + await page.goto('/tests/browser/input-harness.html?controlled=1&autosave=1'); const editable = page.locator('.ProseMirror'); await editable.click(); @@ -247,6 +248,11 @@ test('publishes the composed revision before applying a newer controlled value', ), ).toBe(true); + const expectedComposedTag = await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getRevisionTag(), + ); + expect(expectedComposedTag).toMatch(/^"sha256-[0-9a-f]{64}"$/u); + expect( await page.evaluate(() => (window.inkspanInputHarness as InputHarness).setControlledHtml( @@ -265,6 +271,11 @@ test('publishes the composed revision before applying a newer controlled value', (window.inkspanInputHarness as InputHarness).getDocumentChangeRevisionTags(), ), ).toEqual([]); + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getAutosaveRevisionTags(), + ), + ).toEqual([]); await editable.evaluate((element) => { element.dispatchEvent( @@ -279,16 +290,22 @@ test('publishes the composed revision before applying a newer controlled value', ), ) .toEqual(['

작성 중

']); - const [composedRevisionTag] = await page.evaluate(() => - (window.inkspanInputHarness as InputHarness).getDocumentChangeRevisionTags(), - ); - expect(composedRevisionTag).toMatch(/^"sha256-[0-9a-f]{64}"$/u); + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getDocumentChangeRevisionTags(), + ), + ).toEqual([expectedComposedTag]); + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getAutosaveRevisionTags(), + ), + ).toEqual([expectedComposedTag]); await expect(editable).toHaveText('저장된 개정'); expect( await page.evaluate(() => (window.inkspanInputHarness as InputHarness).getRevisionTag(), ), - ).not.toBe(composedRevisionTag); + ).not.toBe(expectedComposedTag); await expect( page.locator('[data-inkspan-form-field][name="message_body"]'), ).toHaveValue('

저장된 개정

'); diff --git a/tests/browser/vite.config.ts b/tests/browser/vite.config.ts index 50c49dd1..cb17d3f6 100644 --- a/tests/browser/vite.config.ts +++ b/tests/browser/vite.config.ts @@ -13,6 +13,9 @@ export default defineConfig({ resolve: { alias: { 'inkspan-browser-under-test': packageEntry, + 'inkspan-autosave-under-test': configuredPackageEntry + ? resolve(dirname(packageEntry), 'cwl-autosave.js') + : resolve(repositoryRoot, 'src/autosave/package.ts'), }, }, }); From cdfa061fb19c2b514156e049b0d87e14092087c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 19:16:56 +0900 Subject: [PATCH 31/38] test(browser): bind stylesheet checks to the selected artifact Signed-off-by: Seongho Bae --- .../specs/stylesheet-identity.browser.spec.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/browser/specs/stylesheet-identity.browser.spec.ts diff --git a/tests/browser/specs/stylesheet-identity.browser.spec.ts b/tests/browser/specs/stylesheet-identity.browser.spec.ts new file mode 100644 index 00000000..6989c9ca --- /dev/null +++ b/tests/browser/specs/stylesheet-identity.browser.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from '@playwright/test'; +import { readFileSync, realpathSync } from 'node:fs'; +import { dirname, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const packageEntry = process.env.INKSPAN_BROWSER_PACKAGE_ENTRY?.trim(); +const stylesheetPath = realpathSync(packageEntry + ? resolve(dirname(resolve(packageEntry)), 'cwl-editor.css') + : resolve(repositoryRoot, 'dist/cwl-editor.css')).split(sep).join('/'); + +test('loads the selected stylesheet in the actual input editor', async ({ page }) => { + await page.goto('/tests/browser/input-harness.html?toolbar=1'); + await expect(page.getByRole('textbox', { name: 'Editor' })).toBeVisible(); + await expect.poll(() => page.locator('style[data-vite-dev-id]').evaluateAll( + (elements) => elements.map((element) => element.getAttribute('data-vite-dev-id')), + )).toContain(stylesheetPath); +}); + +test('serves the selected stylesheet at the shared focus and print URL', async ({ request }) => { + const moduleResponse = await request.get('/dist/cwl-editor.css?import'); + expect(moduleResponse.ok()).toBe(true); + expect(await moduleResponse.text()).toContain(JSON.stringify(stylesheetPath)); + + const stylesheetResponse = await request.get('/dist/cwl-editor.css?direct'); + expect(stylesheetResponse.ok()).toBe(true); + expect((await stylesheetResponse.text()).trim()).toBe(readFileSync(stylesheetPath, 'utf8').trim()); +}); From 015e4fd2ab5311c47ece1fb4a98e497354660db0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 19:17:45 +0900 Subject: [PATCH 32/38] test(browser): use the real input textbox role Signed-off-by: Seongho Bae --- tests/browser/specs/stylesheet-identity.browser.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/browser/specs/stylesheet-identity.browser.spec.ts b/tests/browser/specs/stylesheet-identity.browser.spec.ts index 6989c9ca..591fa16d 100644 --- a/tests/browser/specs/stylesheet-identity.browser.spec.ts +++ b/tests/browser/specs/stylesheet-identity.browser.spec.ts @@ -11,7 +11,7 @@ const stylesheetPath = realpathSync(packageEntry test('loads the selected stylesheet in the actual input editor', async ({ page }) => { await page.goto('/tests/browser/input-harness.html?toolbar=1'); - await expect(page.getByRole('textbox', { name: 'Editor' })).toBeVisible(); + await expect(page.getByRole('textbox')).toBeVisible(); await expect.poll(() => page.locator('style[data-vite-dev-id]').evaluateAll( (elements) => elements.map((element) => element.getAttribute('data-vite-dev-id')), )).toContain(stylesheetPath); From 8f73903e4a5d2c4a4eb77d20968303842699f379 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 19:21:17 +0900 Subject: [PATCH 33/38] fix(browser): serve stylesheet from the selected package Signed-off-by: Seongho Bae --- tests/browser/input-harness.ts | 2 +- tests/browser/specs/stylesheet-identity.browser.spec.ts | 7 ++++--- tests/browser/vite.config.ts | 3 +++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/browser/input-harness.ts b/tests/browser/input-harness.ts index 5a81d01e..40c5cf20 100644 --- a/tests/browser/input-harness.ts +++ b/tests/browser/input-harness.ts @@ -8,7 +8,7 @@ import { type CwlEditorHandle, } from 'inkspan-browser-under-test'; import { createDocumentAutosaveQueue } from 'inkspan-autosave-under-test'; -import '../../src/styles.css'; +import '/dist/cwl-editor.css'; declare global { interface Window { diff --git a/tests/browser/specs/stylesheet-identity.browser.spec.ts b/tests/browser/specs/stylesheet-identity.browser.spec.ts index 591fa16d..997594f9 100644 --- a/tests/browser/specs/stylesheet-identity.browser.spec.ts +++ b/tests/browser/specs/stylesheet-identity.browser.spec.ts @@ -5,9 +5,10 @@ import { fileURLToPath } from 'node:url'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const packageEntry = process.env.INKSPAN_BROWSER_PACKAGE_ENTRY?.trim(); -const stylesheetPath = realpathSync(packageEntry - ? resolve(dirname(resolve(packageEntry)), 'cwl-editor.css') - : resolve(repositoryRoot, 'dist/cwl-editor.css')).split(sep).join('/'); +const stylesheetPath = resolve( + realpathSync(packageEntry ? dirname(resolve(packageEntry)) : repositoryRoot), + packageEntry ? 'cwl-editor.css' : 'dist/cwl-editor.css', +).split(sep).join('/'); test('loads the selected stylesheet in the actual input editor', async ({ page }) => { await page.goto('/tests/browser/input-harness.html?toolbar=1'); diff --git a/tests/browser/vite.config.ts b/tests/browser/vite.config.ts index cb17d3f6..f0bb0d92 100644 --- a/tests/browser/vite.config.ts +++ b/tests/browser/vite.config.ts @@ -12,6 +12,9 @@ const packageEntry = configuredPackageEntry export default defineConfig({ resolve: { alias: { + '/dist/cwl-editor.css': configuredPackageEntry + ? resolve(dirname(packageEntry), 'cwl-editor.css') + : resolve(repositoryRoot, 'dist/cwl-editor.css'), 'inkspan-browser-under-test': packageEntry, 'inkspan-autosave-under-test': configuredPackageEntry ? resolve(dirname(packageEntry), 'cwl-autosave.js') From f4400020dcb6fa394829c1bfcc0d469ebcffbf0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 19:28:45 +0900 Subject: [PATCH 34/38] docs(browser): record stylesheet provenance and verification limits Signed-off-by: Seongho Bae --- docs/TEST_STRATEGY.md | 2 + .../browser-stylesheet-provenance.md | 52 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 docs/doctoring/browser-stylesheet-provenance.md diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index d9cde200..adce3e08 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -28,6 +28,8 @@ The protected gate pins **Playwright 1.62.0** in an isolated immutable browser-t Differences are not normalized away merely to produce parity. The default gate has no generic normalization or broad engine allowlist; a permitted difference requires a focused regression fixture, authoritative standards basis, threat analysis, exact affected engine/version evidence, canonical interpretation, compatibility impact, and rollback. Missing, skipped, cancelled, incomplete, stale-run, stale-lock, package-mismatched, or divergent required browser evidence must fail closed rather than becoming successful release evidence. A source movement after evidence generation requires the new exact release candidate to re-prove the gate. +Active PR browser assurance binds the actual editor and shared focus/print stylesheet route to the selected source build or extracted package. Archive-digest metadata alone cannot prove which CSS the browser loaded. The [stylesheet provenance regression](doctoring/browser-stylesheet-provenance.md) checks the loaded stylesheet identity and shared-route bytes in each engine; it does not establish physical-device or protected-release acceptance. + ### Office artifact tests Exercise realistic DOCX, XLSX, and PPTX fixtures and inspect the generated package structure. Cover XML 1.0 validity, formula-injection neutralization, worksheet naming and freeze panes, bounds/depth/cycles, deterministic metadata, publication races, overwrite semantics, and wheel/package contents. No macro, network, model, or Desktop Office execution is required by the deterministic renderer. diff --git a/docs/doctoring/browser-stylesheet-provenance.md b/docs/doctoring/browser-stylesheet-provenance.md new file mode 100644 index 00000000..8308f304 --- /dev/null +++ b/docs/doctoring/browser-stylesheet-provenance.md @@ -0,0 +1,52 @@ +# Browser stylesheet provenance + +Status: Active PR research and verification repair; not protected-release evidence. + +## Failure and smallest repair + +Selecting an extracted editor package changed the browser harness's JavaScript +entry but left the actual input editor loading source CSS. Focus and print +fixtures requested the worktree's rebuilt stylesheet. A receipt could therefore +name an archive without proving that its styles were exercised. + +At `015e4fd2ab5311c47ece1fb4a98e497354660db0`, both route checks failed in +Chromium, Firefox and WebKit: six failures, no skipped cases. Browser metadata +identified the worktree source/build paths instead of the selected archive's +stylesheet. The extracted JavaScript, autosave and CSS bytes matched the build; +path identity exposed the error even while CSS bytes happened to be equal. + +The shared `/dist/cwl-editor.css` route now resolves to the selected package's +stylesheet, or to the local build in source mode. The input harness imports +that same route. Vite's existing absolute-path alias mechanism covers the +callers without a server plugin, a new helper, copied CSS or per-spec routing. +Changing only the input import would leave focus/print consumers uncorrected; +checking archive metadata alone would not observe stylesheet selection. + +## Runnable regression and limits + +After building, run from the repository root: + +```sh +pnpm --dir tests/browser exec playwright test --config playwright.config.ts stylesheet-identity.browser.spec.ts --workers=1 +``` + +Repeat with `INKSPAN_BROWSER_PACKAGE_ENTRY` pointing to the selected extracted +archive's `dist/cwl-editor.js`. The existing release workflow supplies that +entry after verifying and extracting the release archive. The tests check the +actual input editor's loaded CSS identity, shared-route resolution, and served +CSS bytes. Source-mode success alone does not prove package-mode behavior. + +The first fixed focused run passed five cases; Firefox's editor case exceeded +its unchanged 20-second deadline during evaluation on a heavily swapping host. +Its snapshot and failure remain retained. The unchanged case then passed alone +with trace. This does not prove the timeout's cause or erase that failed run; +final acceptance still requires fresh complete source and package runs. + +No editor styles, commands, API, package exports, browser deadline, retry +allowance, dependency or host authority changed. This is a browser-evidence +repair, not a new physical-device support or performance claim. + +## Reference + +Vite. (n.d.). *Shared options: resolve.alias*. Retrieved September 6, 2026, from +https://vite.dev/config/shared-options.html#resolve-alias From d0e4ad4a6b7a7b51e007185c5a94e104f770ea9c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 19:35:54 +0900 Subject: [PATCH 35/38] test(browser): reject an extra worktree stylesheet Signed-off-by: Seongho Bae --- docs/doctoring/browser-stylesheet-provenance.md | 3 ++- tests/browser/specs/stylesheet-identity.browser.spec.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/browser-stylesheet-provenance.md b/docs/doctoring/browser-stylesheet-provenance.md index 8308f304..a4cff073 100644 --- a/docs/doctoring/browser-stylesheet-provenance.md +++ b/docs/doctoring/browser-stylesheet-provenance.md @@ -37,7 +37,8 @@ actual input editor's loaded CSS identity, shared-route resolution, and served CSS bytes. Source-mode success alone does not prove package-mode behavior. The first fixed focused run passed five cases; Firefox's editor case exceeded -its unchanged 20-second deadline during evaluation on a heavily swapping host. +its unchanged 20-second deadline during evaluation on a host reporting about +47 GB of swap in use. Its snapshot and failure remain retained. The unchanged case then passed alone with trace. This does not prove the timeout's cause or erase that failed run; final acceptance still requires fresh complete source and package runs. diff --git a/tests/browser/specs/stylesheet-identity.browser.spec.ts b/tests/browser/specs/stylesheet-identity.browser.spec.ts index 997594f9..e103342a 100644 --- a/tests/browser/specs/stylesheet-identity.browser.spec.ts +++ b/tests/browser/specs/stylesheet-identity.browser.spec.ts @@ -15,7 +15,7 @@ test('loads the selected stylesheet in the actual input editor', async ({ page } await expect(page.getByRole('textbox')).toBeVisible(); await expect.poll(() => page.locator('style[data-vite-dev-id]').evaluateAll( (elements) => elements.map((element) => element.getAttribute('data-vite-dev-id')), - )).toContain(stylesheetPath); + )).toEqual([stylesheetPath]); }); test('serves the selected stylesheet at the shared focus and print URL', async ({ request }) => { From 7e231329f8efed484e61e0c29e42e18527142d4d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:44:50 +0900 Subject: [PATCH 36/38] test: select the matching collaboration browser entry Signed-off-by: Seongho Bae --- docs/doctoring/browser-stylesheet-provenance.md | 8 ++++++++ tests/browser/harness.ts | 6 ++++++ .../browser/specs/stylesheet-identity.browser.spec.ts | 11 +++++++++++ tests/browser/vite.config.ts | 3 +++ 4 files changed, 28 insertions(+) diff --git a/docs/doctoring/browser-stylesheet-provenance.md b/docs/doctoring/browser-stylesheet-provenance.md index a4cff073..bda79812 100644 --- a/docs/doctoring/browser-stylesheet-provenance.md +++ b/docs/doctoring/browser-stylesheet-provenance.md @@ -22,6 +22,14 @@ callers without a server plugin, a new helper, copied CSS or per-spec routing. Changing only the input import would leave focus/print consumers uncorrected; checking archive metadata alone would not observe stylesheet selection. +The downstream forced-colors inspection in #151 also needs the real collaboration +renderer, whose inline colors are missing from hand-written cursor markup. +This browser-harness owner provides a matching collaboration-entry alias and +lazy probe, following the existing source/package selection. The shared test +checks normal label colors; #151 owns the forced-palette and visual regression. +Integrate these helpers into the child by a normal merge so its net delta does +not become a competing browser-configuration writer. + ## Runnable regression and limits After building, run from the repository root: diff --git a/tests/browser/harness.ts b/tests/browser/harness.ts index c4ca1059..779a2662 100644 --- a/tests/browser/harness.ts +++ b/tests/browser/harness.ts @@ -25,6 +25,7 @@ interface BrowserHostileDocumentProbeResult { declare global { interface Window { + renderInkspanCursorProbe(user: Record): Promise; runInkspanClipboardProbe( request: BrowserClipboardProbeRequest, ): BrowserClipboardProbeResult; @@ -34,6 +35,11 @@ declare global { } } +window.renderInkspanCursorProbe = async (user) => { + const { renderCollaborationCursor } = await import('inkspan-collaboration-under-test'); + return renderCollaborationCursor(user); +}; + window.runInkspanClipboardProbe = ( request: BrowserClipboardProbeRequest, ): BrowserClipboardProbeResult => { diff --git a/tests/browser/specs/stylesheet-identity.browser.spec.ts b/tests/browser/specs/stylesheet-identity.browser.spec.ts index e103342a..1fcb162f 100644 --- a/tests/browser/specs/stylesheet-identity.browser.spec.ts +++ b/tests/browser/specs/stylesheet-identity.browser.spec.ts @@ -27,3 +27,14 @@ test('serves the selected stylesheet at the shared focus and print URL', async ( expect(stylesheetResponse.ok()).toBe(true); expect((await stylesheetResponse.text()).trim()).toBe(readFileSync(stylesheetPath, 'utf8').trim()); }); + +test('renders the selected collaboration entry with its authored label colors', async ({ page }) => { + await page.goto('/tests/browser/harness.html'); + await page.locator('#harness').evaluate(async (element) => { + element.append(await window.renderInkspanCursorProbe({ name: 'Remote', color: '#abcdef' })); + }); + const label = page.locator('.collaboration-cursor__label'); + await expect(label).toHaveText('Remote'); + await expect(label).toHaveCSS('background-color', 'rgb(171, 205, 239)'); + await expect(label).toHaveCSS('color', 'rgb(0, 0, 0)'); +}); diff --git a/tests/browser/vite.config.ts b/tests/browser/vite.config.ts index f0bb0d92..91adbfdc 100644 --- a/tests/browser/vite.config.ts +++ b/tests/browser/vite.config.ts @@ -16,6 +16,9 @@ export default defineConfig({ ? resolve(dirname(packageEntry), 'cwl-editor.css') : resolve(repositoryRoot, 'dist/cwl-editor.css'), 'inkspan-browser-under-test': packageEntry, + 'inkspan-collaboration-under-test': configuredPackageEntry + ? resolve(dirname(packageEntry), 'cwl-collaboration.js') + : resolve(repositoryRoot, 'src/collaboration/index.ts'), 'inkspan-autosave-under-test': configuredPackageEntry ? resolve(dirname(packageEntry), 'cwl-autosave.js') : resolve(repositoryRoot, 'src/autosave/package.ts'), From e1bbcf56c67f6f21132e42dc540291114ac39a94 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:24:36 +0900 Subject: [PATCH 37/38] test(browser): reject missing expected package evidence Signed-off-by: Seongho Bae --- src/releaseBrowserEvidence.test.ts | 31 ++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/releaseBrowserEvidence.test.ts b/src/releaseBrowserEvidence.test.ts index 643c6f1d..f5b921fe 100644 --- a/src/releaseBrowserEvidence.test.ts +++ b/src/releaseBrowserEvidence.test.ts @@ -1,7 +1,10 @@ -import { readFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { packedPackageSha256 } from '../tests/browser/evidenceContract'; /** Read one authoritative repository file as UTF-8 text. */ function repositoryFile(path: string): string { @@ -25,6 +28,30 @@ const browserSpec = repositoryFile('tests/browser/specs/clipboard.browser.spec.t const consensusSpec = repositoryFile('tests/browser/specs/clipboard.consensus.spec.ts'); describe('release cross-engine browser evidence contract', () => { + it('requires the actual archive when an expected package digest is supplied', async () => { + const temporaryRoot = mkdtempSync(resolve(tmpdir(), 'inkspan-browser-digest-')); + const packageBytes = 'local package fixture'; + const expectedDigest = createHash('sha256').update(packageBytes).digest('hex'); + try { + vi.stubEnv('INKSPAN_EXPECTED_PACKAGE_SHA256', ''); + await expect(packedPackageSha256(temporaryRoot)).resolves.toBeNull(); + vi.stubEnv('INKSPAN_EXPECTED_PACKAGE_SHA256', expectedDigest); + await expect(packedPackageSha256(temporaryRoot)).rejects.toThrow( + 'Expected packed npm artifact is missing.', + ); + mkdirSync(resolve(temporaryRoot, 'release')); + writeFileSync(resolve(temporaryRoot, 'release/editor.tgz'), packageBytes); + await expect(packedPackageSha256(temporaryRoot)).resolves.toBe(expectedDigest); + vi.stubEnv('INKSPAN_EXPECTED_PACKAGE_SHA256', '0'.repeat(64)); + await expect(packedPackageSha256(temporaryRoot)).rejects.toThrow( + 'Packed npm artifact digest does not match propagated release evidence.', + ); + } finally { + vi.unstubAllEnvs(); + rmSync(temporaryRoot, { recursive: true, force: true }); + } + }); + it('requires the release tag commit to equal the current protected main tip', () => { const buildJob = workflowJob( workflow, From 6a7121e04652348e6dbc7b58bbad843ef07f6f86 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:25:12 +0900 Subject: [PATCH 38/38] fix(browser): require archive for expected package evidence Signed-off-by: Seongho Bae --- docs/TEST_STRATEGY.md | 2 ++ tests/browser/evidenceContract.ts | 10 +++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index adce3e08..c136f436 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -30,6 +30,8 @@ Differences are not normalized away merely to produce parity. The default gate h Active PR browser assurance binds the actual editor and shared focus/print stylesheet route to the selected source build or extracted package. Archive-digest metadata alone cannot prove which CSS the browser loaded. The [stylesheet provenance regression](doctoring/browser-stylesheet-provenance.md) checks the loaded stylesheet identity and shared-route bytes in each engine; it does not establish physical-device or protected-release acceptance. +Active PR browser evidence also rejects a missing archive when an expected package digest is supplied. Ordinary source-only checks without an expected digest may retain a null package identity. This distinction is exercised against the shared evidence helper by `src/releaseBrowserEvidence.test.ts`; it does not change editor runtime or public document contracts. + ### Office artifact tests Exercise realistic DOCX, XLSX, and PPTX fixtures and inspect the generated package structure. Cover XML 1.0 validity, formula-injection neutralization, worksheet naming and freeze panes, bounds/depth/cycles, deterministic metadata, publication races, overwrite semantics, and wheel/package contents. No macro, network, model, or Desktop Office execution is required by the deterministic renderer. diff --git a/tests/browser/evidenceContract.ts b/tests/browser/evidenceContract.ts index 3b575e22..64cc1ee8 100644 --- a/tests/browser/evidenceContract.ts +++ b/tests/browser/evidenceContract.ts @@ -13,16 +13,21 @@ export const BROWSER_EVIDENCE_SCHEMA_VERSION = 2; * Resolve the exact packed npm artifact digest when a release artifact is present. * * Ordinary pull-request browser CI has no `release/` directory and therefore - * returns `null`. The tag release lane must expose exactly one `.tgz`; its digest + * returns `null` only when no expected package digest was supplied. The tag + * release lane must expose exactly one `.tgz`; its digest * is recomputed from bytes rather than trusting a propagated environment value. */ export async function packedPackageSha256(repositoryRoot: string): Promise { const releaseDirectory = resolve(repositoryRoot, 'release'); + const propagatedDigest = process.env.INKSPAN_EXPECTED_PACKAGE_SHA256?.trim(); let entries; try { entries = await readdir(releaseDirectory, { withFileTypes: true }); } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + if (propagatedDigest) throw new Error('Expected packed npm artifact is missing.'); + return null; + } throw error; } @@ -42,7 +47,6 @@ export async function packedPackageSha256(repositoryRoot: string): Promise