From 5d752e3fa8155a9b1ea1ddd99a9ae2d9a04f0ade Mon Sep 17 00:00:00 2001 From: Zihua Li <635902+luin@users.noreply.github.com> Date: Fri, 21 Jun 2024 10:47:32 +0800 Subject: [PATCH] Clipboard support for E2E tests --- .github/workflows/_test.yml | 6 +- package-lock.json | 112 ++++++++++++++---- packages/quill/package.json | 3 +- packages/quill/playwright.config.ts | 10 +- packages/quill/test/e2e/fixtures/Clipboard.ts | 93 +++++++++++++++ packages/quill/test/e2e/fixtures/index.ts | 11 ++ .../quill/test/e2e/fixtures/utils/Locker.ts | 39 ++++++ packages/quill/test/e2e/history.spec.ts | 8 ++ 8 files changed, 258 insertions(+), 24 deletions(-) create mode 100644 packages/quill/test/e2e/fixtures/Clipboard.ts create mode 100644 packages/quill/test/e2e/fixtures/utils/Locker.ts diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 3794b276e1..db0386bfae 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -17,8 +17,10 @@ jobs: run: npx playwright install --with-deps working-directory: packages/quill - name: Run Playwright tests - run: npm run test:e2e - working-directory: packages/quill + uses: coactions/setup-xvfb@v1 + with: + run: npm run test:e2e -- --headed + working-directory: packages/quill fuzz: name: Fuzz Tests runs-on: ubuntu-latest diff --git a/package-lock.json b/package-lock.json index f5ede60f74..237dfd0ea1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4283,12 +4283,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.38.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.38.1.tgz", - "integrity": "sha512-NqRp8XMwj3AK+zKLbZShl0r/9wKgzqI/527bkptKXomtuo+dOjU9NdMASQ8DNC9z9zLOMbG53T4eihYr3XR+BQ==", + "version": "1.44.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.1.tgz", + "integrity": "sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==", "dev": true, "dependencies": { - "playwright": "1.38.1" + "playwright": "1.44.1" }, "bin": { "playwright": "cli.js" @@ -14271,9 +14271,9 @@ } }, "node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "engines": { "node": ">=16 || 14 >=14.17" } @@ -14824,6 +14824,12 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "dev": true + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -14956,15 +14962,15 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-scurry": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", - "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dependencies": { - "lru-cache": "^9.1.1 || ^10.0.0", + "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -15167,12 +15173,12 @@ } }, "node_modules/playwright": { - "version": "1.38.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.1.tgz", - "integrity": "sha512-oRMSJmZrOu1FP5iu3UrCx8JEFRIMxLDM0c/3o4bpzU5Tz97BypefWf7TuTNPWeCe279TPal5RtPPZ+9lW/Qkow==", + "version": "1.44.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.1.tgz", + "integrity": "sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==", "dev": true, "dependencies": { - "playwright-core": "1.38.1" + "playwright-core": "1.44.1" }, "bin": { "playwright": "cli.js" @@ -15185,9 +15191,9 @@ } }, "node_modules/playwright-core": { - "version": "1.38.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.1.tgz", - "integrity": "sha512-tQqNFUKa3OfMf4b2jQ7aGLB8o9bS3bOY0yMEtldtC2+spf8QXG9zvXLTXUeRsoNuxEYMgLYR+NXfAa1rjKRcrg==", + "version": "1.44.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.1.tgz", + "integrity": "sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==", "dev": true, "bin": { "playwright-core": "cli.js" @@ -19243,7 +19249,7 @@ "@babel/core": "^7.24.0", "@babel/preset-env": "^7.24.0", "@babel/preset-typescript": "^7.23.3", - "@playwright/test": "1.38.1", + "@playwright/test": "1.44.1", "@types/highlight.js": "^9.12.4", "@types/lodash-es": "^4.17.12", "@types/node": "^20.10.0", @@ -19262,6 +19268,7 @@ "eslint-plugin-jsx-a11y": "^6.8.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-require-extensions": "^0.1.3", + "glob": "10.4.2", "highlight.js": "^9.18.1", "html-loader": "^4.2.0", "html-webpack-plugin": "^5.5.3", @@ -19288,6 +19295,71 @@ "npm": ">=8.2.3" } }, + "packages/quill/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "packages/quill/node_modules/glob": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.2.tgz", + "integrity": "sha512-GwMlUF6PkPo3Gk21UxkCohOv0PLcIXVtKyLlpEI28R/cO/4eNOdmLk3CMW1wROV/WR/EsZOWAfBbBOqYvs88/w==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/quill/node_modules/jackspeak": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.0.tgz", + "integrity": "sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "packages/quill/node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "packages/website": { "version": "2.0.2", "license": "BSD-3-Clause", diff --git a/packages/quill/package.json b/packages/quill/package.json index 3be362bd5b..1ce5f24dc5 100644 --- a/packages/quill/package.json +++ b/packages/quill/package.json @@ -17,7 +17,7 @@ "@babel/core": "^7.24.0", "@babel/preset-env": "^7.24.0", "@babel/preset-typescript": "^7.23.3", - "@playwright/test": "1.38.1", + "@playwright/test": "1.44.1", "@types/highlight.js": "^9.12.4", "@types/lodash-es": "^4.17.12", "@types/node": "^20.10.0", @@ -36,6 +36,7 @@ "eslint-plugin-jsx-a11y": "^6.8.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-require-extensions": "^0.1.3", + "glob": "10.4.2", "highlight.js": "^9.18.1", "html-loader": "^4.2.0", "html-webpack-plugin": "^5.5.3", diff --git a/packages/quill/playwright.config.ts b/packages/quill/playwright.config.ts index 9fa19c6057..91f499d887 100644 --- a/packages/quill/playwright.config.ts +++ b/packages/quill/playwright.config.ts @@ -21,7 +21,15 @@ export default defineConfig({ ignoreHTTPSErrors: true, }, projects: [ - { name: 'Chrome', use: { ...devices['Desktop Chrome'] } }, + { + name: 'Chrome', + use: { + ...devices['Desktop Chrome'], + contextOptions: { + permissions: ['clipboard-read', 'clipboard-write'], + }, + }, + }, { name: 'Firefox', use: { ...devices['Desktop Firefox'] } }, { name: 'Safari', use: { ...devices['Desktop Safari'] } }, ], diff --git a/packages/quill/test/e2e/fixtures/Clipboard.ts b/packages/quill/test/e2e/fixtures/Clipboard.ts new file mode 100644 index 0000000000..7427083078 --- /dev/null +++ b/packages/quill/test/e2e/fixtures/Clipboard.ts @@ -0,0 +1,93 @@ +import type { Page } from '@playwright/test'; +import { SHORTKEY } from '../utils/index.js'; + +class Clipboard { + constructor(private page: Page) {} + + async copy() { + await this.page.keyboard.press(`${SHORTKEY}+c`); + } + + async cut() { + await this.page.keyboard.press(`${SHORTKEY}+x`); + } + + async paste() { + await this.page.keyboard.press(`${SHORTKEY}+v`); + } + + async writeText(value: string) { + // Playwright + Safari + Linux doesn't support async clipboard API + // https://github.com/microsoft/playwright/issues/18901 + const hasFallbackWritten = await this.page.evaluate((value) => { + if (navigator.clipboard) return false; + const textArea = document.createElement('textarea'); + textArea.value = value; + + textArea.style.top = '0'; + textArea.style.left = '0'; + textArea.style.position = 'fixed'; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + const isSupported = document.execCommand('copy'); + textArea.remove(); + return isSupported; + }, value); + + if (!hasFallbackWritten) { + await this.write(value, 'text/plain'); + } + } + + async writeHTML(value: string) { + return this.write(value, 'text/html'); + } + + async readText() { + return this.read('text/plain'); + } + + async readHTML() { + const html = await this.read('text/html'); + return html.replace(/]*>/g, ''); + } + + private async read(type: string) { + const isHTML = type === 'text/html'; + await this.page.evaluate((isHTML) => { + const dataContainer = document.createElement(isHTML ? 'div' : 'textarea'); + if (isHTML) dataContainer.setAttribute('contenteditable', 'true'); + dataContainer.id = '_readClipboard'; + document.body.appendChild(dataContainer); + dataContainer.focus(); + return dataContainer; + }, isHTML); + await this.paste(); + const locator = this.page.locator('#_readClipboard'); + const data = await (isHTML ? locator.innerHTML() : locator.inputValue()); + await locator.evaluate((node) => node.remove()); + return data; + } + + private async write(data: string, type: string) { + await this.page.evaluate( + async ({ data, type }) => { + if (type === 'text/html') { + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/html': new Blob([data], { type: 'text/html' }), + }), + ]); + } else { + await navigator.clipboard.writeText(data); + } + }, + { data, type }, + ); + } +} + +export default Clipboard; diff --git a/packages/quill/test/e2e/fixtures/index.ts b/packages/quill/test/e2e/fixtures/index.ts index fb4ac01f42..c4c006757f 100644 --- a/packages/quill/test/e2e/fixtures/index.ts +++ b/packages/quill/test/e2e/fixtures/index.ts @@ -1,6 +1,8 @@ import { test as base } from '@playwright/test'; import EditorPage from '../pageobjects/EditorPage.js'; import Composition from './Composition.js'; +import Locker from './utils/Locker.js'; +import Clipboard from './Clipboard.js'; export const test = base.extend<{ editorPage: EditorPage; @@ -18,6 +20,15 @@ export const test = base.extend<{ use(new Composition(page, browserName)); }, + clipboard: [ + async ({ page }, use) => { + const locker = new Locker('clipboard'); + await locker.lock(); + await use(new Clipboard(page)); + await locker.release(); + }, + { timeout: 30000 }, + ], }); export const CHAPTER = 'Chapter 1. Loomings.'; diff --git a/packages/quill/test/e2e/fixtures/utils/Locker.ts b/packages/quill/test/e2e/fixtures/utils/Locker.ts new file mode 100644 index 0000000000..a47c2f6ce2 --- /dev/null +++ b/packages/quill/test/e2e/fixtures/utils/Locker.ts @@ -0,0 +1,39 @@ +import { unlink, writeFile } from 'fs/promises'; +import { unlinkSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { globSync } from 'glob'; + +const sleep = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +const PREFIX = 'playwright_locker_'; + +class Locker { + public static clearAll() { + globSync(join(tmpdir(), `${PREFIX}*.txt`)).forEach(unlinkSync); + } + + constructor(private key: string) {} + + private get filePath() { + return join(tmpdir(), `${PREFIX}${this.key}.txt`); + } + + async lock() { + try { + await writeFile(this.filePath, '', { flag: 'wx' }); + } catch { + await sleep(50); + await this.lock(); + } + } + + async release() { + await unlink(this.filePath); + } +} + +export default Locker; diff --git a/packages/quill/test/e2e/history.spec.ts b/packages/quill/test/e2e/history.spec.ts index 80027dcc4b..c082f96b00 100644 --- a/packages/quill/test/e2e/history.spec.ts +++ b/packages/quill/test/e2e/history.spec.ts @@ -38,6 +38,14 @@ test.describe('history', () => { expect(await editorPage.getContents()).toEqual([{ insert: '1234\n' }]); }); + test('clipboard', async ({ clipboard, page, editorPage }) => { + await editorPage.moveCursorAfterText('2'); + await clipboard.writeText('a'); + await clipboard.paste(); + await undo(page); + expect(await editorPage.getContents()).toEqual([{ insert: '1234\n' }]); + }); + test.describe('selection', () => { test('typing', async ({ page, editorPage }) => { await editorPage.moveCursorAfterText('2');