diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index e5705096e..874107748 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -36,3 +36,9 @@ jobs: run: node common/scripts/install-run-rush.js build -t @visactor/vtable-search - name: Test run: node common/scripts/install-run-rush.js test -t @visactor/vtable-search + - name: Install Playwright Firefox + working-directory: ./packages/vtable + run: npx playwright install firefox + - name: Test Firefox paste + working-directory: ./packages/vtable + run: node ../../common/scripts/install-run-rushx.js test:firefox diff --git a/.gitignore b/.gitignore index 83c3ca471..dc413e3d5 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,8 @@ stats.html report.html coverage/ +playwright-report/ +test-results/ *.local.ts diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 7ec0174aa..a968e6123 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -492,6 +492,9 @@ importers: '@internal/ts-config': specifier: workspace:* version: link:../../share/ts-config + '@playwright/test': + specifier: 1.55.1 + version: 1.55.1 '@resvg/resvg-js': specifier: ^2.5.0 version: 2.6.2 @@ -2132,7 +2135,7 @@ importers: version: 4.9.5 vitest: specifier: 0.30.1 - version: 0.30.1(jsdom@16.7.0)(less@4.1.3)(sass@1.43.5)(terser@5.17.1) + version: 0.30.1(jsdom@16.7.0)(less@4.1.3)(playwright@1.55.1)(sass@1.43.5)(terser@5.17.1) packages: @@ -2967,6 +2970,11 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@playwright/test@1.55.1': + resolution: {integrity: sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==} + engines: {node: '>=18'} + hasBin: true + '@remix-run/router@1.4.0': resolution: {integrity: sha512-BJ9SxXux8zAg991UmT8slpwpsd31K1dHHbD3Ba4VzD+liLQ4WAMSxQp2d2ZPRPfN0jN2NPRowcSSoM7lCaF08Q==} engines: {node: '>=14'} @@ -5472,6 +5480,11 @@ packages: os: [darwin] deprecated: Upgrade to fsevents v2 to mitigate potential security issues + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -7393,6 +7406,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.55.1: + resolution: {integrity: sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.55.1: + resolution: {integrity: sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==} + engines: {node: '>=18'} + hasBin: true + plugin-error@0.1.2: resolution: {integrity: sha512-WzZHcm4+GO34sjFMxQMqZbsz3xiNEgonCskQ9v+IroMmYgk/tas8dG+Hr2D6IbRPybZ12oWpzE/w3cGJ6FJzOw==} engines: {node: '>=0.10.0'} @@ -10587,6 +10610,10 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@playwright/test@1.55.1': + dependencies: + playwright: 1.55.1 + '@remix-run/router@1.4.0': {} '@resvg/resvg-js-android-arm-eabi@2.4.1': @@ -13496,6 +13523,9 @@ snapshots: nan: 2.26.2 optional: true + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -15929,6 +15959,14 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 + playwright-core@1.55.1: {} + + playwright@1.55.1: + dependencies: + playwright-core: 1.55.1 + optionalDependencies: + fsevents: 2.3.2 + plugin-error@0.1.2: dependencies: ansi-cyan: 0.1.1 @@ -17801,7 +17839,7 @@ snapshots: sass: 1.43.5 terser: 5.17.1 - vitest@0.30.1(jsdom@16.7.0)(less@4.1.3)(sass@1.43.5)(terser@5.17.1): + vitest@0.30.1(jsdom@16.7.0)(less@4.1.3)(playwright@1.55.1)(sass@1.43.5)(terser@5.17.1): dependencies: '@types/chai': 4.3.20 '@types/chai-subset': 1.3.6(@types/chai@4.3.20) @@ -17831,6 +17869,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: jsdom: 16.7.0 + playwright: 1.55.1 transitivePeerDependencies: - less - sass diff --git a/packages/vtable/__tests__/clipboard-paste.test.ts b/packages/vtable/__tests__/clipboard-paste.test.ts new file mode 100644 index 000000000..d599fba70 --- /dev/null +++ b/packages/vtable/__tests__/clipboard-paste.test.ts @@ -0,0 +1,56 @@ +// @ts-nocheck +import { ListTable, TABLE_EVENT_TYPE } from '../src'; +import { createDiv, removeDom } from './dom'; + +global.__VERSION__ = 'none'; + +describe('clipboard paste', () => { + test('uses paste event data before the asynchronous Clipboard API', async () => { + const container = createDiv(); + container.style.width = '600px'; + container.style.height = '400px'; + + const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + const read = jest.fn(() => Promise.reject(new Error('clipboard-read is unavailable'))); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { read } + }); + + const table = new ListTable(container, { + records: [{ name: 'before' }], + columns: [{ field: 'name', title: 'Name', editor: '' }], + keyboardOptions: { + pasteValueToCell: true + } + }); + + try { + table.selectCell(0, table.columnHeaderLevelCount); + const pasted = new Promise(resolve => { + table.on(TABLE_EVENT_TYPE.PASTED_DATA, resolve); + }); + const pasteEvent = new Event('paste', { bubbles: true }); + Object.defineProperty(pasteEvent, 'clipboardData', { + value: { + types: ['text/plain'], + getData: (type: string) => (type === 'text/plain' ? 'pasted in Firefox' : '') + } + }); + + table.getElement().dispatchEvent(pasteEvent); + await pasted; + + expect(read).not.toHaveBeenCalled(); + expect(table.getCellOriginValue(0, table.columnHeaderLevelCount)).toBe('pasted in Firefox'); + } finally { + table.release(); + removeDom(container); + if (clipboardDescriptor) { + Object.defineProperty(navigator, 'clipboard', clipboardDescriptor); + } else { + Reflect.deleteProperty(navigator, 'clipboard'); + } + } + }); +}); diff --git a/packages/vtable/e2e/clipboard-paste.firefox.spec.ts b/packages/vtable/e2e/clipboard-paste.firefox.spec.ts new file mode 100644 index 000000000..cac53f266 --- /dev/null +++ b/packages/vtable/e2e/clipboard-paste.firefox.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from '@playwright/test'; + +interface Issue4699Table { + columnHeaderLevelCount: number; + getCellOriginValue: (col: number, row: number) => unknown; + getElement: () => HTMLElement; + selectCell: (col: number, row: number) => void; +} + +interface Issue4699Window extends Window { + __issue4699ClipboardState: { + readCalls: number; + }; + __issue4699Table: Issue4699Table; +} + +test('pastes event clipboard data into a selected cell without an editor caret', async ({ page, browserName }) => { + test.skip(browserName !== 'firefox', 'Firefox-only clipboard regression'); + + await page.addInitScript(() => { + const state = { readCalls: 0 }; + Object.defineProperty(window, '__issue4699ClipboardState', { + configurable: true, + value: state + }); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + read: () => { + state.readCalls++; + return Promise.reject(new Error('Firefox clipboard-read is unavailable')); + } + } + }); + }); + + await page.goto('/'); + await page.waitForFunction(() => Boolean((window as unknown as Issue4699Window).__issue4699Table)); + + const activeElement = await page.evaluate(() => { + const testWindow = window as unknown as Issue4699Window; + const table = testWindow.__issue4699Table; + const row = table.columnHeaderLevelCount; + const clipboardData = new DataTransfer(); + clipboardData.setData('text/plain', 'pasted in Firefox'); + const pasteEvent = new ClipboardEvent('paste', { + bubbles: true, + cancelable: true + }); + Object.defineProperty(pasteEvent, 'clipboardData', { + value: clipboardData + }); + + table.selectCell(0, row); + table.getElement().focus(); + table.getElement().dispatchEvent(pasteEvent); + return document.activeElement?.tagName; + }); + + expect(activeElement).not.toMatch(/^(INPUT|TEXTAREA)$/); + await expect + .poll(() => + page.evaluate(() => { + const table = (window as unknown as Issue4699Window).__issue4699Table; + return table.getCellOriginValue(0, table.columnHeaderLevelCount); + }) + ) + .toBe('pasted in Firefox'); + await expect + .poll(() => page.evaluate(() => (window as unknown as Issue4699Window).__issue4699ClipboardState.readCalls)) + .toBe(0); +}); diff --git a/packages/vtable/e2e/fixtures/app.ts b/packages/vtable/e2e/fixtures/app.ts new file mode 100644 index 000000000..c8460e0ad --- /dev/null +++ b/packages/vtable/e2e/fixtures/app.ts @@ -0,0 +1,19 @@ +import { ListTable } from '../../src'; + +const container = document.getElementById('vtable'); +if (!container) { + throw new Error('Missing #vtable container'); +} + +const table = new ListTable(container, { + records: [{ value: 'before' }], + columns: [{ field: 'value', title: 'Value', editor: '' }], + keyboardOptions: { + pasteValueToCell: true + } +}); + +Object.defineProperty(window, '__issue4699Table', { + configurable: true, + value: table +}); diff --git a/packages/vtable/e2e/fixtures/index.html b/packages/vtable/e2e/fixtures/index.html new file mode 100644 index 000000000..fa8f8f2a0 --- /dev/null +++ b/packages/vtable/e2e/fixtures/index.html @@ -0,0 +1,15 @@ +
+
+
+ + + diff --git a/packages/vtable/e2e/vite.config.ts b/packages/vtable/e2e/vite.config.ts new file mode 100644 index 000000000..5aa218d0d --- /dev/null +++ b/packages/vtable/e2e/vite.config.ts @@ -0,0 +1,15 @@ +import path from 'path'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + define: { + __DEV__: true, + __VERSION__: JSON.stringify('e2e') + }, + resolve: { + alias: { + '@src': path.resolve(__dirname, '../src'), + '@vutils-extension': path.resolve(__dirname, '../src/vutil-extension-temp') + } + } +}); diff --git a/packages/vtable/package.json b/packages/vtable/package.json index aea9cb8e3..1d94e3775 100644 --- a/packages/vtable/package.json +++ b/packages/vtable/package.json @@ -40,11 +40,13 @@ "demo": "vite serve examples", "site-demo": "vite serve site-demo", "compile": "tsc --noEmit", + "e2e:serve": "vite serve e2e/fixtures --config e2e/vite.config.ts --host 127.0.0.1 --port 3010", "eslint": "eslint --debug --fix src/", "build": "npm run fix-memory-limit && bundle --clean", "dev": "bundle --clean -f es -w", "start": "vite serve examples", "test": "jest --silent", + "test:firefox": "playwright test --config playwright.config.ts", "test-cov": "jest --coverage", "ci": "ts-node --transpileOnly --skipProject ./scripts/trigger-test.ts", "build:es": "bundle --clean -f es --ignorePostTasks", @@ -120,7 +122,8 @@ "d3-hexbin": "^0.2.2", "d3-hierarchy": "^3.1.1", "@resvg/resvg-js": "^2.5.0", - "pikaday": "1.8.2" + "pikaday": "1.8.2", + "@playwright/test": "1.55.1" }, "unpkg": "latest", "unpkgFiles": [ diff --git a/packages/vtable/playwright.config.ts b/packages/vtable/playwright.config.ts new file mode 100644 index 000000000..a9c629fb9 --- /dev/null +++ b/packages/vtable/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + outputDir: './test-results', + reporter: 'list', + use: { + baseURL: 'http://127.0.0.1:3010', + trace: 'retain-on-failure' + }, + projects: [ + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'] + } + } + ], + webServer: { + command: 'rushx e2e:serve', + url: 'http://127.0.0.1:3010', + reuseExistingServer: !process.env.CI + } +}); diff --git a/packages/vtable/tsconfig.eslint.json b/packages/vtable/tsconfig.eslint.json index cbfa15726..401bd61b0 100644 --- a/packages/vtable/tsconfig.eslint.json +++ b/packages/vtable/tsconfig.eslint.json @@ -14,6 +14,6 @@ ] } }, - "include": ["src", "__tests__", "examples", "site-demo"], + "include": ["src", "__tests__", "e2e", "examples", "site-demo", "playwright.config.ts"], "exclude": ["bugserver-config"] }