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"]
}