Browse Source

Clipboard support for E2E tests

Zihua Li 8 months ago
parent
commit
5d752e3fa8

+ 4 - 2
.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

+ 92 - 20
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",

+ 2 - 1
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",

+ 9 - 1
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'] } },
   ],

+ 93 - 0
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(/<meta[^>]*>/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;

+ 11 - 0
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.';

+ 39 - 0
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;

+ 8 - 0
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');