diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index d9cde200..c136f436 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -28,6 +28,10 @@ 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. + +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/docs/doctoring/browser-stylesheet-provenance.md b/docs/doctoring/browser-stylesheet-provenance.md new file mode 100644 index 00000000..bda79812 --- /dev/null +++ b/docs/doctoring/browser-stylesheet-provenance.md @@ -0,0 +1,61 @@ +# 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. + +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: + +```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 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. + +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 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, 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): 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/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..40c5cf20 --- /dev/null +++ b/tests/browser/input-harness.ts @@ -0,0 +1,138 @@ +import type { Editor } from '@tiptap/core'; +import { createElement, createRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import { + createDocumentEnvelope, + createDocumentEnvelopeRevisionEvidence, + CwlEditor, + type CwlEditorHandle, +} from 'inkspan-browser-under-test'; +import { createDocumentAutosaveQueue } from 'inkspan-autosave-under-test'; +import '/dist/cwl-editor.css'; + +declare global { + interface Window { + inkspanInputHarness: { + getHtml: () => string; + getDocumentChanges: () => string[]; + getDocumentChangeRevisionTags: () => Promise; + getAutosaveRevisionTags: () => Promise; + getRevisionTag: () => Promise; + 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; + }; + } +} + +const element = document.getElementById('editor'); +if (!(element instanceof HTMLElement)) { + throw new Error('Input assurance harness editor host is missing.'); +} + +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[] = []; +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); + +const renderEditor = () => { + root.render( + createElement(CwlEditor, { + ref: editorHandle, + mode: 'html', + ...(controlled ? { value: controlledHtml } : { defaultValue: '' }), + editable, + hideToolbar: !showToolbar, + formFieldName: 'message_body', + onChange: controlled + ? (value: string) => { + controlledHtml = value; + } + : undefined, + onDocumentChange: ({ snapshot }) => { + documentChanges.push(snapshot.value); + documentChangeRevisionTags.push( + createDocumentEnvelopeRevisionEvidence( + createDocumentEnvelope(snapshot.documentJson), + ).then(async (evidence) => { + await autosaveQueue?.enqueue(evidence); + return evidence.revision.strongEntityTag; + }), + ); + }, + 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(), + getDocumentChanges: () => [...documentChanges], + getDocumentChangeRevisionTags: () => + Promise.all(documentChangeRevisionTags), + getAutosaveRevisionTags: async () => { + await Promise.all(documentChangeRevisionTags); + await autosaveQueue?.flush(); + return [...autosaveRevisionTags]; + }, + getRevisionTag: async () => + (await editorHandle.current?.getDocumentEnvelopeRevision()) + ?.strongEntityTag ?? '', + getText: () => getEditor().getText(), + isComposing: () => getEditor().view.composing, + insertText: (text: string) => getEditor().commands.insertContent(text), + redo: () => getEditor().commands.redo(), + remount: () => { + root.unmount(); + editor = null; + root = createRoot(element); + renderEditor(); + return true; + }, + setEditable: (nextEditable: boolean) => { + editable = nextEditable; + renderEditor(); + return editable; + }, + setControlledHtml: (html: string) => { + if (!controlled) return false; + controlledHtml = html; + renderEditor(); + return true; + }, + setHtml: (html: string) => + getEditor().commands.setContent(html, { emitUpdate: 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..fee23792 --- /dev/null +++ b/tests/browser/specs/composition-clipboard.browser.spec.ts @@ -0,0 +1,485 @@ +import { expect, test, type Page } from '@playwright/test'; + +type InputHarness = { + getDocumentChanges: () => string[]; + getDocumentChangeRevisionTags: () => Promise; + getAutosaveRevisionTags: () => Promise; + getHtml: () => string; + getRevisionTag: () => Promise; + getText: () => string; + isComposing: () => boolean; + insertText: (text: string) => boolean; + redo: () => boolean; + setEditable: (editable: boolean) => boolean; + setControlledHtml: (html: string) => 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); + expect( + await page.evaluate(() => + (window.inkspanInputHarness as InputHarness).getDocumentChanges(), + ), + ).toEqual([]); + + 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('publishes the composed revision before applying a newer controlled value', async ({ + page, +}) => { + await page.goto('/tests/browser/input-harness.html?controlled=1&autosave=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); + + 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( + '

저장된 개정

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

작성 중

']); + 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(expectedComposedTag); + 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, +}) => { + 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, +}) => { + 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/stylesheet-identity.browser.spec.ts b/tests/browser/specs/stylesheet-identity.browser.spec.ts new file mode 100644 index 00000000..1fcb162f --- /dev/null +++ b/tests/browser/specs/stylesheet-identity.browser.spec.ts @@ -0,0 +1,40 @@ +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 = 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'); + 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')), + )).toEqual([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()); +}); + +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/specs/touch-clipboard.browser.spec.ts b/tests/browser/specs/touch-clipboard.browser.spec.ts new file mode 100644 index 00000000..e2a92bce --- /dev/null +++ b/tests/browser/specs/touch-clipboard.browser.spec.ts @@ -0,0 +1,185 @@ +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 expect(editable).toBeFocused(); + 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); +}); diff --git a/tests/browser/vite.config.ts b/tests/browser/vite.config.ts index 50c49dd1..91adbfdc 100644 --- a/tests/browser/vite.config.ts +++ b/tests/browser/vite.config.ts @@ -12,7 +12,16 @@ 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-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'), }, }, });