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);
+});