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..6cf779dd --- /dev/null +++ b/tests/browser/input-harness.ts @@ -0,0 +1,75 @@ +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 { + inkspanInputHarness: { + getHtml: () => string; + getText: () => string; + isComposing: () => boolean; + redo: () => boolean; + remount: () => boolean; + setEditable: (editable: boolean) => boolean; + setHtml: (html: string) => boolean; + undo: () => boolean; + }; + } +} + +const element = document.getElementById('editor'); +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; +let root = createRoot(element); + +const renderEditor = () => { + root.render( + createElement(CwlEditor, { + mode: 'html', + defaultValue: '', + editable, + hideToolbar: !showToolbar, + formFieldName: 'message_body', + 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: () => getEditor().getHTML(), + 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(); + return editable; + }, + setHtml: (html: string) => getEditor().commands.setContent(html, false), + undo: () => getEditor().commands.undo(), +}); diff --git a/tests/browser/playwright.config.ts b/tests/browser/playwright.config.ts index d7d4c2a2..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|focus|print)\.browser\.spec\.ts/u; +const ENGINE_BROWSER_SPECS = /\.browser\.spec\.ts$/u; export default defineConfig({ testDir: './specs', 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..b7ccdde8 --- /dev/null +++ b/tests/browser/specs/composition-clipboard.browser.spec.ts @@ -0,0 +1,330 @@ +import { expect, test, type Page } from '@playwright/test'; + +type InputHarness = { + getHtml: () => string; + getText: () => string; + isComposing: () => boolean; + redo: () => boolean; + setEditable: (editable: boolean) => boolean; + setHtml: (html: string) => boolean; + undo: () => 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 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 }) => { + 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'); + await expect(page.locator('.ProseMirror')).toHaveAttribute( + 'contenteditable', + 'true', + ); +}); + +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); + }); +} + +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 editable = page.locator('.ProseMirror'); + const target = page.locator(`.ProseMirror ${inputCase.selector}`).first(); + 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); + }); +} + +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, +}) => { + 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('한글'); + + 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 ({ + 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, +}) => { + 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('한글 日本語'); +}); 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..8b638f61 --- /dev/null +++ b/tests/browser/specs/composition-native-form.browser.spec.ts @@ -0,0 +1,183 @@ +import { expect, test, type Page } from '@playwright/test'; + +type InputHarness = { + getHtml: () => string; + getText: () => string; + isComposing: () => boolean; + remount: () => boolean; + setEditable: (editable: boolean) => boolean; +}; + +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, +}) => { + 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); +}); + +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(''); + 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('새 세션'); + await expect + .poll(() => + page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getText(), + ), + ) + .toBe('새 세션'); + const newSessionHtml = await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getHtml(), + ); + expect(newSessionHtml).toContain('새 세션'); + await expect(page.locator(FORM_FIELD)).toHaveValue(newSessionHtml); +}); 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..06b79fe1 --- /dev/null +++ b/tests/browser/specs/read-only-clipboard.browser.spec.ts @@ -0,0 +1,76 @@ +import { expect, test, type Page } 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'; +}; + +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([]); +}); + +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'); +}); 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..d21d59e8 --- /dev/null +++ b/tests/browser/specs/touch-clipboard.browser.spec.ts @@ -0,0 +1,184 @@ +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(); + +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 }, +}); + +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('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('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); + + 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 ({ + 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); + + await expectNoHorizontalDocumentOverflow(page); + + await page.setViewportSize({ width: 844, height: 390 }); + await expect(editable).toBeFocused(); + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getText(), + ), + ).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); +});