diff --git a/.eslintrc.js b/.eslintrc.js index 04f778c5..bed453a3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -62,5 +62,6 @@ module.exports = { '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/array-type': ['error', {default: 'array-simple'}], '@typescript-eslint/consistent-type-definitions': 'off', + 'curly': ['error', 'all'], }, }; diff --git a/.github/workflows/web-e2e-test.yml b/.github/workflows/web-e2e-test.yml new file mode 100644 index 00000000..13c37e9f --- /dev/null +++ b/.github/workflows/web-e2e-test.yml @@ -0,0 +1,49 @@ +name: Test web E2E +on: + pull_request: + paths: + - .github/workflows/web-e2e-test.yml + - src/** + - WebExample/** + merge_group: + branches: + - main + push: + branches: + - main + paths: + - .github/workflows/web-e2e-test.yml + - src/** + - WebExample/** + +jobs: + test: + if: github.repository == 'Expensify/react-native-live-markdown' + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./WebExample + + concurrency: + group: web-e2e-test-${{ github.ref }} + cancel-in-progress: true + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Use Node.js 18 + uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Install node_modules + run: yarn install --immutable + + - name: Install browsers + run: npx playwright install --with-deps + + - name: Install dependencies for browsers + run: npx playwright install-deps + + - name: Run Playwright tests + run: yarn test diff --git a/WebExample/.gitignore b/WebExample/.gitignore index 05647d55..a64a4c87 100644 --- a/WebExample/.gitignore +++ b/WebExample/.gitignore @@ -33,3 +33,7 @@ yarn-error.* # typescript *.tsbuildinfo +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/WebExample/__tests__/.eslintrc.js b/WebExample/__tests__/.eslintrc.js new file mode 100644 index 00000000..467443cc --- /dev/null +++ b/WebExample/__tests__/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + rules: { + '@lwc/lwc/no-async-await': 'off', + 'rulesdir/prefer-import-module-contents': 'off', + }, +}; diff --git a/WebExample/__tests__/input.spec.ts b/WebExample/__tests__/input.spec.ts new file mode 100644 index 00000000..7854dcb4 --- /dev/null +++ b/WebExample/__tests__/input.spec.ts @@ -0,0 +1,32 @@ +import {test, expect} from '@playwright/test'; +import * as TEST_CONST from './testConstants'; +import {checkCursorPosition, setupInput} from './utils'; + +test.beforeEach(async ({page}) => { + await page.goto(TEST_CONST.LOCAL_URL, {waitUntil: 'load'}); +}); + +test.describe('typing', () => { + test('short text', async ({page}) => { + const inputLocator = await setupInput(page, 'clear'); + + await inputLocator.focus(); + await inputLocator.pressSequentially(TEST_CONST.EXAMPLE_CONTENT); + const value = await inputLocator.innerText(); + expect(value).toEqual(TEST_CONST.EXAMPLE_CONTENT); + }); + + test('fast type cursor position', async ({page}) => { + const EXAMPLE_LONG_CONTENT = TEST_CONST.EXAMPLE_CONTENT.repeat(3); + + const inputLocator = await setupInput(page, 'clear'); + + await inputLocator.pressSequentially(EXAMPLE_LONG_CONTENT); + + expect(await inputLocator.innerText()).toBe(EXAMPLE_LONG_CONTENT); + + const cursorPosition = await page.evaluate(checkCursorPosition); + + expect(cursorPosition).toBe(EXAMPLE_LONG_CONTENT.length); + }); +}); diff --git a/WebExample/__tests__/styles.spec.ts b/WebExample/__tests__/styles.spec.ts new file mode 100644 index 00000000..2fc87dc5 --- /dev/null +++ b/WebExample/__tests__/styles.spec.ts @@ -0,0 +1,62 @@ +import {test, expect} from '@playwright/test'; +import type {Page} from '@playwright/test'; +import * as TEST_CONST from './testConstants'; +import {setupInput, getElementStyle} from './utils'; + +const testMarkdownContentStyle = async ({testContent, style, page}: {testContent: string; style: string; page: Page}) => { + const inputLocator = await setupInput(page); + + const elementHandle = inputLocator.locator('span', {hasText: testContent}).last(); + const elementStyle = await getElementStyle(elementHandle); + + expect(elementStyle).toEqual(style); +}; + +test.beforeEach(async ({page}) => { + await page.goto(TEST_CONST.LOCAL_URL, {waitUntil: 'load'}); + await page.click('[data-testid="reset"]'); +}); + +test.describe('markdown content styling', () => { + test('bold', async ({page}) => { + await testMarkdownContentStyle({testContent: 'world', style: 'font-weight: bold;', page}); + }); + + test('link', async ({page}) => { + await testMarkdownContentStyle({testContent: 'https://expensify.com', style: 'color: blue; text-decoration: underline;', page}); + }); + + test('h1', async ({page}) => { + await testMarkdownContentStyle({testContent: 'header1', style: 'font-size: 25px; font-weight: bold;', page}); + }); + + test('inline code', async ({page}) => { + await testMarkdownContentStyle({testContent: 'inline code', style: 'font-family: monospace; font-size: 20px; color: black; background-color: lightgray;', page}); + }); + + test('codeblock', async ({page}) => { + await testMarkdownContentStyle({testContent: 'codeblock', style: 'font-family: monospace; font-size: 20px; color: black; background-color: lightgray;', page}); + }); + + test('mention-here', async ({page}) => { + await testMarkdownContentStyle({testContent: 'here', style: 'color: green; background-color: lime;', page}); + }); + + test('mention-user', async ({page}) => { + await testMarkdownContentStyle({testContent: 'someone@swmansion.com', style: 'color: blue; background-color: cyan;', page}); + }); + + test('mention-report', async ({page}) => { + await testMarkdownContentStyle({testContent: 'mention-report', style: 'color: red; background-color: pink;', page}); + }); + + test('blockquote', async ({page, browserName}) => { + const blockquoteStyle = + 'border-color: gray; border-width: 6px; margin-left: 6px; padding-left: 6px; border-left-style: solid; display: inline-block; max-width: 100%; box-sizing: border-box;'; + + // Firefox border properties are serialized slightly differently + const browserStyle = browserName === 'firefox' ? blockquoteStyle.replace('border-left-style: solid', 'border-left: 6px solid gray') : blockquoteStyle; + + await testMarkdownContentStyle({testContent: 'blockquote', style: browserStyle, page}); + }); +}); diff --git a/WebExample/__tests__/testConstants.ts b/WebExample/__tests__/testConstants.ts new file mode 100644 index 00000000..e0fc6076 --- /dev/null +++ b/WebExample/__tests__/testConstants.ts @@ -0,0 +1,18 @@ +const LOCAL_URL = 'http://localhost:19006/'; + +const EXAMPLE_CONTENT = [ + 'Hello, *world*!', + 'https://expensify.com', + '# header1', + '> blockquote', + '`inline code`', + '```\ncodeblock\n```', + '@here', + '@someone@swmansion.com', + '#mention-report', +].join('\n'); + +const INPUT_ID = 'MarkdownInput_Example'; +const INPUT_HISTORY_DEBOUNCE_TIME_MS = 150; + +export {LOCAL_URL, EXAMPLE_CONTENT, INPUT_ID, INPUT_HISTORY_DEBOUNCE_TIME_MS}; diff --git a/WebExample/__tests__/textManipulation.spec.ts b/WebExample/__tests__/textManipulation.spec.ts new file mode 100644 index 00000000..d117da39 --- /dev/null +++ b/WebExample/__tests__/textManipulation.spec.ts @@ -0,0 +1,135 @@ +import {test, expect} from '@playwright/test'; +import type {Locator, Page} from '@playwright/test'; +import * as TEST_CONST from './testConstants'; +import {checkCursorPosition, setupInput, getElementStyle, pressCmd} from './utils'; + +const pasteContent = async ({text, page, inputLocator}: {text: string; page: Page; inputLocator: Locator}) => { + await page.evaluate(async (pasteText) => navigator.clipboard.writeText(pasteText), text); + await inputLocator.focus(); + await pressCmd({inputLocator, command: 'v'}); +}; + +test.beforeEach(async ({page, context, browserName}) => { + await page.goto(TEST_CONST.LOCAL_URL, {waitUntil: 'load'}); + if (browserName === 'chromium') { + await context.grantPermissions(['clipboard-write', 'clipboard-read']); + } +}); + +test.describe('paste content', () => { + test.skip(({browserName}) => !!process.env.CI && browserName === 'webkit', 'Excluded from WebKit CI tests'); + + test('paste', async ({page}) => { + const PASTE_TEXT = 'bold'; + const BOLD_STYLE = 'font-weight: bold;'; + + const inputLocator = await setupInput(page, 'clear'); + + const wrappedText = '*bold*'; + await pasteContent({text: wrappedText, page, inputLocator}); + + const elementHandle = await inputLocator.locator('span', {hasText: PASTE_TEXT}).last(); + const elementStyle = await getElementStyle(elementHandle); + + expect(elementStyle).toEqual(BOLD_STYLE); + }); + + test('paste replace', async ({page}) => { + const inputLocator = await setupInput(page, 'reset'); + + await inputLocator.focus(); + await pressCmd({inputLocator, command: 'a'}); + + const newText = '*bold*'; + await pasteContent({text: newText, page, inputLocator}); + + expect(await inputLocator.innerText()).toBe(newText); + }); + + test('paste undo', async ({page, browserName}) => { + test.skip(!!process.env.CI && browserName === 'firefox', 'Excluded from Firefox CI tests'); + + const PASTE_TEXT_FIRST = '*bold*'; + const PASTE_TEXT_SECOND = '@here'; + + const inputLocator = await setupInput(page, 'clear'); + + await page.evaluate(async (pasteText) => navigator.clipboard.writeText(pasteText), PASTE_TEXT_FIRST); + + await pressCmd({inputLocator, command: 'v'}); + await page.waitForTimeout(TEST_CONST.INPUT_HISTORY_DEBOUNCE_TIME_MS); + await page.evaluate(async (pasteText) => navigator.clipboard.writeText(pasteText), PASTE_TEXT_SECOND); + await pressCmd({inputLocator, command: 'v'}); + await page.waitForTimeout(TEST_CONST.INPUT_HISTORY_DEBOUNCE_TIME_MS); + + await pressCmd({inputLocator, command: 'z'}); + + expect(await inputLocator.innerText()).toBe(PASTE_TEXT_FIRST); + }); + + test('paste redo', async ({page}) => { + const PASTE_TEXT_FIRST = '*bold*'; + const PASTE_TEXT_SECOND = '@here'; + + const inputLocator = await setupInput(page, 'clear'); + + await page.evaluate(async (pasteText) => navigator.clipboard.writeText(pasteText), PASTE_TEXT_FIRST); + await pressCmd({inputLocator, command: 'v'}); + await page.waitForTimeout(TEST_CONST.INPUT_HISTORY_DEBOUNCE_TIME_MS); + await page.evaluate(async (pasteText) => navigator.clipboard.writeText(pasteText), PASTE_TEXT_SECOND); + await page.waitForTimeout(TEST_CONST.INPUT_HISTORY_DEBOUNCE_TIME_MS); + await pressCmd({inputLocator, command: 'v'}); + await page.waitForTimeout(TEST_CONST.INPUT_HISTORY_DEBOUNCE_TIME_MS); + + await pressCmd({inputLocator, command: 'z'}); + await pressCmd({inputLocator, command: 'Shift+z'}); + + expect(await inputLocator.innerText()).toBe(`${PASTE_TEXT_FIRST}${PASTE_TEXT_SECOND}`); + }); +}); + +test('select all', async ({page}) => { + const inputLocator = await setupInput(page, 'reset'); + await inputLocator.focus(); + await pressCmd({inputLocator, command: 'a'}); + + const cursorPosition = await page.evaluate(checkCursorPosition); + + expect(cursorPosition).toBe(TEST_CONST.EXAMPLE_CONTENT.length); +}); + +test('cut content changes', async ({page, browserName}) => { + test.skip(!!process.env.CI && browserName === 'webkit', 'Excluded from WebKit CI tests'); + + const INITIAL_CONTENT = 'bold'; + const WRAPPED_CONTENT = `*${INITIAL_CONTENT}*`; + const EXPECTED_CONTENT = WRAPPED_CONTENT.slice(0, 3); + + const inputLocator = await setupInput(page, 'clear'); + await pasteContent({text: WRAPPED_CONTENT, page, inputLocator}); + const rootHandle = await inputLocator.locator('span.root').first(); + + await page.evaluate(async (initialContent) => { + const filteredNode = Array.from(document.querySelectorAll('div[contenteditable="true"] > span.root span')).find((node) => { + return node.textContent?.includes(initialContent) && node.nextElementSibling && node.nextElementSibling.textContent?.includes('*'); + }); + + const startNode = filteredNode; + const endNode = filteredNode?.nextElementSibling; + + if (startNode?.firstChild && endNode?.lastChild) { + const range = new Range(); + range.setStart(startNode.firstChild, 2); + range.setEnd(endNode.lastChild, endNode.lastChild.textContent?.length ?? 0); + + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } + }, INITIAL_CONTENT); + + await inputLocator.focus(); + await pressCmd({inputLocator, command: 'x'}); + + expect(await rootHandle.innerHTML()).toBe(EXPECTED_CONTENT); +}); diff --git a/WebExample/__tests__/utils.ts b/WebExample/__tests__/utils.ts new file mode 100644 index 00000000..f50e6684 --- /dev/null +++ b/WebExample/__tests__/utils.ts @@ -0,0 +1,58 @@ +import type {Locator, Page} from '@playwright/test'; +import * as TEST_CONST from './testConstants'; + +const setupInput = async (page: Page, action?: 'clear' | 'reset') => { + const inputLocator = await page.locator(`div#${TEST_CONST.INPUT_ID}`); + if (action) { + await page.click(`[data-testid="${action}"]`); + } + + return inputLocator; +}; + +const checkCursorPosition = () => { + const editableDiv = document.querySelector('div[contenteditable="true"]') as HTMLElement; + const range = window.getSelection()?.getRangeAt(0); + if (!range || !editableDiv) { + return null; + } + const preCaretRange = range.cloneRange(); + preCaretRange.selectNodeContents(editableDiv); + preCaretRange.setEnd(range.endContainer, range.endOffset); + return preCaretRange.toString().length; +}; + +const setCursorPosition = ({startNode, endNode}: {startNode?: Element; endNode?: Element | null}) => { + if (!startNode?.firstChild || !endNode?.lastChild) { + return null; + } + + const range = new Range(); + range.setStart(startNode.firstChild, 2); + range.setEnd(endNode.lastChild, endNode.lastChild.textContent?.length ?? 0); + + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + + return selection; +}; + +const getElementStyle = async (elementHandle: Locator) => { + let elementStyle; + + if (elementHandle) { + await elementHandle.waitFor({state: 'attached'}); + + elementStyle = await elementHandle.getAttribute('style'); + } + return elementStyle; +}; + +const pressCmd = async ({inputLocator, command}: {inputLocator: Locator; command: string}) => { + const OPERATION_MODIFIER = process.platform === 'darwin' ? 'Meta' : 'Control'; + + await inputLocator.press(`${OPERATION_MODIFIER}+${command}`); +}; + +export {setupInput, checkCursorPosition, setCursorPosition, getElementStyle, pressCmd}; diff --git a/WebExample/package.json b/WebExample/package.json index 2f85d854..2854d10d 100644 --- a/WebExample/package.json +++ b/WebExample/package.json @@ -6,7 +6,8 @@ "start": "expo start", "android": "expo start --android", "ios": "expo start --ios", - "web": "expo start --web" + "web": "expo start --web", + "test": "playwright test" }, "dependencies": { "@expo/webpack-config": "~19.0.1", @@ -20,6 +21,8 @@ }, "devDependencies": { "@babel/core": "^7.20.0", + "@playwright/test": "^1.43.1", + "@types/node": "^20.12.7", "@types/react": "~18.2.45", "typescript": "^5.1.3" }, diff --git a/WebExample/playwright.config.ts b/WebExample/playwright.config.ts new file mode 100644 index 00000000..0cf53a25 --- /dev/null +++ b/WebExample/playwright.config.ts @@ -0,0 +1,29 @@ +import {defineConfig, devices} from '@playwright/test'; +import * as TEST_CONST from './__tests__/testConstants'; + +export default defineConfig({ + testDir: './__tests__', + preserveOutput: 'never', + outputDir: undefined, + webServer: { + command: 'yarn web', + url: TEST_CONST.LOCAL_URL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + stderr: 'pipe', + }, + projects: [ + { + name: 'Chromium', + use: {...devices['Desktop Chrome']}, + }, + { + name: 'Firefox', + use: {...devices['Desktop Firefox']}, + }, + { + name: 'Webkit', + use: {...devices['Desktop Safari']}, + }, + ], +}); diff --git a/WebExample/tsconfig.json b/WebExample/tsconfig.json index 30bff963..5ede5186 100644 --- a/WebExample/tsconfig.json +++ b/WebExample/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "strict": true }, - "include": ["App.tsx"], + "include": ["App.tsx", "**/*.ts", "__tests__/testConstants.ts"], "exclude": ["node_modules"] } diff --git a/example/src/App.tsx b/example/src/App.tsx index 473126c6..71648f56 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -4,8 +4,7 @@ import {Button, Platform, StyleSheet, Text, View} from 'react-native'; import {MarkdownTextInput} from '@expensify/react-native-live-markdown'; import type {TextInput} from 'react-native'; - -const DEFAULT_TEXT = ['Hello, *world*!', 'https://expensify.com', '# Lorem ipsum', '> Hello world', '`foo`', '```\nbar\n```', '@here', '@someone@swmansion.com', '#room-mention'].join('\n'); +import * as TEST_CONST from '../../WebExample/__tests__/testConstants'; function isWeb() { return Platform.OS === 'web'; @@ -60,7 +59,7 @@ function getRandomColor() { } export default function App() { - const [value, setValue] = React.useState(DEFAULT_TEXT); + const [value, setValue] = React.useState(TEST_CONST.EXAMPLE_CONTENT); const [markdownStyle, setMarkdownStyle] = React.useState({}); const [selection, setSelection] = React.useState({start: 0, end: 0}); @@ -101,6 +100,7 @@ export default function App() { placeholder="Type here..." onSelectionChange={(e) => setSelection(e.nativeEvent.selection)} selection={selection} + id={TEST_CONST.INPUT_ID} /> {/* TextInput singleline */} {JSON.stringify(value)}