diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2db7f5d678..c156da9aa8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,8 @@ jobs: node --run build:types - name: Build website run: node --run build:website + - name: Install Playwright Browsers + run: npx playwright install chromium - name: Test run: npm --run test timeout-minutes: 4 diff --git a/package.json b/package.json index 93eacfe11f..44eee9f0cb 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,8 @@ "@typescript-eslint/eslint-plugin": "^7.0.1", "@typescript-eslint/parser": "^7.0.1", "@vitejs/plugin-react": "^4.3.1", - "@vitest/coverage-v8": "^2.0.1", + "@vitest/browser": "^2.0.3", + "@vitest/coverage-istanbul": "^2.0.3", "@wyw-in-js/rollup": "^0.5.0", "@wyw-in-js/vite": "^0.5.0", "babel-plugin-optimize-clsx": "^2.6.2", @@ -92,10 +93,10 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-sonarjs": "^1.0.3", "eslint-plugin-testing-library": "^6.2.0", - "jsdom": "^24.1.0", "jspdf": "^2.5.1", "jspdf-autotable": "^3.5.23", "lodash-es": "^4.17.21", + "playwright": "^1.45.1", "postcss": "^8.4.25", "prettier": "3.3.3", "react": "^18.3.1", @@ -107,7 +108,7 @@ "rollup-plugin-postcss": "^4.0.2", "typescript": "~5.5.2", "vite": "^5.3.3", - "vitest": "^2.0.1" + "vitest": "^2.0.3" }, "peerDependencies": { "react": "^18.0 || ^19.0", diff --git a/test/column/draggable.test.ts b/test/column/draggable.test.ts index c01c235e85..49264ed7dd 100644 --- a/test/column/draggable.test.ts +++ b/test/column/draggable.test.ts @@ -1,4 +1,5 @@ -import { fireEvent } from '@testing-library/react'; +import { act } from 'react'; +import { userEvent } from '@vitest/browser/context'; import type { Column } from '../../src'; import { getHeaderCells, setup } from '../utils'; @@ -25,7 +26,7 @@ const columns: readonly Column[] = [ } ]; -test('draggable columns', () => { +test('draggable columns', async () => { const onColumnsReorder = vi.fn(); setup({ columns, rows: [], onColumnsReorder }); const [cell1, cell2, cell3, cell4] = getHeaderCells(); @@ -37,36 +38,25 @@ test('draggable columns', () => { expect(onColumnsReorder).not.toHaveBeenCalled(); - let data: unknown; - let type: unknown; - const event = { - dataTransfer: { - get types() { - return [type]; - }, - setData(_type: unknown, _data: unknown) { - type = _type; - data = _data; - }, - getData() { - return data; - } - } - } as const; - - fireEvent.dragStart(cell2, event); - fireEvent.drop(cell4, event); + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + await userEvent.dragAndDrop(cell2, cell4); + }); expect(onColumnsReorder).toHaveBeenCalledWith('col2', 'col4'); onColumnsReorder.mockReset(); // should not call `onColumnsReorder` if drag and drop elements are the same - fireEvent.dragStart(cell2, event); - fireEvent.drop(cell2, event); + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + await userEvent.dragAndDrop(cell2, cell2); + }); expect(onColumnsReorder).not.toHaveBeenCalled(); // should not drag a column if it is not specified as draggable - fireEvent.dragStart(cell1, event); - fireEvent.drop(cell2, event); + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + await userEvent.dragAndDrop(cell1, cell2); + }); expect(onColumnsReorder).not.toHaveBeenCalled(); }); diff --git a/test/column/renderEditCell.test.tsx b/test/column/renderEditCell.test.tsx index d9140411b3..cbb198b9a6 100644 --- a/test/column/renderEditCell.test.tsx +++ b/test/column/renderEditCell.test.tsx @@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event'; import DataGrid from '../../src'; import type { Column, DataGridProps } from '../../src'; -import { getCellsAtRowIndex, getSelectedCell, scrollGrid } from '../utils'; +import { getCellsAtRowIndex, getGrid, getSelectedCell, scrollGrid } from '../utils'; interface Row { col1: number; @@ -100,15 +100,14 @@ describe('Editor', () => { render(); await userEvent.click(getCellsAtRowIndex(0)[0]); expect(getCellsAtRowIndex(0)).toHaveLength(2); - await scrollGrid({ scrollTop: 2000 }); expect(getCellsAtRowIndex(0)).toHaveLength(1); expect(screen.queryByRole('spinbutton', { name: 'col1-editor' })).not.toBeInTheDocument(); + expect(getGrid().scrollTop).toBe(2000); await userEvent.keyboard('123'); + expect(getCellsAtRowIndex(0)).toHaveLength(2); expect(screen.getByRole('spinbutton', { name: 'col1-editor' })).toHaveValue(1230); - const spy = vi.spyOn(window.HTMLElement.prototype, 'scrollIntoView'); - await userEvent.keyboard('{enter}'); - expect(spy).toHaveBeenCalled(); + expect(getGrid().scrollTop).toBe(0); }); describe('editable', () => { diff --git a/test/column/resizable.test.tsx b/test/column/resizable.test.tsx index dc0856d891..4cbfa2468c 100644 --- a/test/column/resizable.test.tsx +++ b/test/column/resizable.test.tsx @@ -1,58 +1,41 @@ -import { fireEvent } from '@testing-library/react'; +import { act } from 'react'; +import { commands, userEvent } from '@vitest/browser/context'; import type { Column } from '../../src'; import { resizeHandleClassname } from '../../src/HeaderCell'; import { getGrid, getHeaderCells, setup } from '../utils'; -const pointerId = 1; - -// TODO: https://github.com/jsdom/jsdom/issues/2527 -class PointerEvent extends Event { - pointerId: number | undefined; - clientX: number | undefined; +interface Row { + readonly col1: number; + readonly col2: string; +} - constructor(type: string, { pointerId, clientX, ...rest }: PointerEventInit) { - super(type, rest); - this.pointerId = pointerId; - this.clientX = clientX; - } +interface ResizeArgs { + readonly column: HTMLElement; + readonly resizeBy: number; } -// @ts-expect-error -globalThis.PointerEvent = PointerEvent; +async function resize({ column, resizeBy }: ResizeArgs) { + const resizeHandle = column.querySelector(`.${resizeHandleClassname}`); + if (resizeHandle === null) return; -interface ResizeEvent { - column: HTMLElement; - clientXStart: number; - clientXEnd: number; - rect: Pick; + await act(async () => { + // @ts-expect-error + await commands.resizeColumn(resizeBy); + }); } -function resize({ - column, - clientXStart, - clientXEnd, - rect -}: ResizeEvent) { +async function autoResize(column: HTMLElement) { const resizeHandle = column.querySelector(`.${resizeHandleClassname}`); if (resizeHandle === null) return; - const original = column.getBoundingClientRect.bind(column); - column.getBoundingClientRect = () => ({ - ...original(), - ...rect + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + await userEvent.dblClick(resizeHandle); }); - // eslint-disable-next-line testing-library/prefer-user-event - fireEvent.pointerDown( - resizeHandle, - new PointerEvent('pointerdown', { pointerId, clientX: clientXStart }) - ); - // eslint-disable-next-line testing-library/prefer-user-event - fireEvent.pointerMove(resizeHandle, new PointerEvent('pointermove', { clientX: clientXEnd })); - fireEvent.lostPointerCapture(resizeHandle, new PointerEvent('lostpointercapture', {})); } -const columns: readonly Column[] = [ +const columns: readonly Column[] = [ { key: 'col1', name: 'col1', @@ -68,34 +51,85 @@ const columns: readonly Column[] = [ } ]; -test('should not resize column if resizable is not specified', () => { - setup({ columns, rows: [] }); +test('should not resize column if resizable is not specified', async () => { + setup({ columns, rows: [] }); const [col1] = getHeaderCells(); expect(getGrid()).toHaveStyle({ gridTemplateColumns: '100px 200px' }); - resize({ column: col1, clientXStart: 95, clientXEnd: 200, rect: { right: 100, left: 0 } }); + await resize({ column: col1, resizeBy: 50 }); + expect(getGrid()).toHaveStyle({ gridTemplateColumns: '100px 200px' }); + await resize({ column: col1, resizeBy: -50 }); expect(getGrid()).toHaveStyle({ gridTemplateColumns: '100px 200px' }); }); -test('should resize column when dragging the handle', () => { - setup({ columns, rows: [] }); +test('should resize column when dragging the handle', async () => { + setup({ columns, rows: [] }); const [, col2] = getHeaderCells(); - expect(getGrid()).toHaveStyle({ gridTemplateColumns: '100px 200px' }); - resize({ column: col2, clientXStart: 289, clientXEnd: 250, rect: { right: 300, left: 100 } }); - expect(getGrid()).toHaveStyle({ gridTemplateColumns: '100px 161px' }); + const grid = getGrid(); + expect(grid).toHaveStyle({ gridTemplateColumns: '100px 200px' }); + await resize({ column: col2, resizeBy: -50 }); + expect(getGrid()).toHaveStyle({ gridTemplateColumns: '100px 150px' }); }); -test('should use the maxWidth if specified', () => { - setup({ columns, rows: [] }); +test('should use the maxWidth if specified', async () => { + setup({ columns, rows: [] }); const [, col2] = getHeaderCells(); - expect(getGrid()).toHaveStyle({ gridTemplateColumns: '100px 200px' }); - resize({ column: col2, clientXStart: 295, clientXEnd: 1000, rect: { right: 300, left: 100 } }); + expect(getGrid()).toHaveStyle({ gridTemplateColumns: '100px 200px ' }); + await resize({ column: col2, resizeBy: 1000 }); expect(getGrid()).toHaveStyle({ gridTemplateColumns: '100px 400px' }); }); -test('should use the minWidth if specified', () => { - setup({ columns, rows: [] }); +test('should use the minWidth if specified', async () => { + setup({ columns, rows: [] }); const [, col2] = getHeaderCells(); expect(getGrid()).toHaveStyle({ gridTemplateColumns: '100px 200px' }); - resize({ column: col2, clientXStart: 295, clientXEnd: 100, rect: { right: 300, left: 100 } }); + await resize({ column: col2, resizeBy: -150 }); expect(getGrid()).toHaveStyle({ gridTemplateColumns: '100px 100px' }); }); + +test('should not auto resize column if resizable is not specified', async () => { + setup({ + columns, + rows: [ + { + col1: 1, + col2: 'a'.repeat(50) + } + ] + }); + const [col1] = getHeaderCells(); + expect(getGrid()).toHaveStyle({ gridTemplateColumns: '100px 200px' }); + await autoResize(col1); + expect(getGrid()).toHaveStyle({ gridTemplateColumns: '100px 200px' }); +}); + +test('should auto resize column when resize handle is double clicked', async () => { + setup({ + columns, + rows: [ + { + col1: 1, + col2: 'a'.repeat(50) + } + ] + }); + const [, col2] = getHeaderCells(); + expect(getGrid()).toHaveStyle({ gridTemplateColumns: '100px 200px' }); + await autoResize(col2); + expect(getGrid()).toHaveStyle({ gridTemplateColumns: '100px 327.703px' }); +}); + +test('should use the maxWidth if specified on auto resize', async () => { + setup({ + columns, + rows: [ + { + col1: 1, + col2: 'a'.repeat(500) + } + ] + }); + const [, col2] = getHeaderCells(); + expect(getGrid()).toHaveStyle({ gridTemplateColumns: '100px 200px' }); + await autoResize(col2); + expect(getGrid()).toHaveStyle({ gridTemplateColumns: '100px 400px' }); +}); diff --git a/test/rowHeight.test.ts b/test/rowHeight.test.ts index a364b4e7a0..7dbd124c80 100644 --- a/test/rowHeight.test.ts +++ b/test/rowHeight.test.ts @@ -25,16 +25,16 @@ test('rowHeight is number', async () => { setupGrid(40); const grid = screen.getByRole('grid'); - expect(grid).toHaveStyle({ 'grid-template-rows': 'repeat(1, 40px) repeat(50, 40px)' }); - expect(getRows()).toHaveLength(31); + expect(grid).toHaveStyle({ + 'grid-template-rows': + '40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px 40px' + }); + expect(getRows()).toHaveLength(30); await userEvent.tab(); expect(grid.scrollTop).toBe(0); - - // Go to the last cell - const spy = vi.spyOn(window.HTMLElement.prototype, 'scrollIntoView'); await userEvent.keyboard('{Control>}{end}'); - expect(spy).toHaveBeenCalled(); + expect(grid.scrollTop + grid.clientHeight).toBe(grid.scrollHeight); }); test('rowHeight is function', async () => { @@ -43,15 +43,12 @@ test('rowHeight is function', async () => { const grid = screen.getByRole('grid'); expect(grid).toHaveStyle({ 'grid-template-rows': - 'repeat(1, 35px) 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px' + '35px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px 80px 40px 60px' }); expect(getRows()).toHaveLength(22); await userEvent.tab(); expect(grid.scrollTop).toBe(0); - - const spy = vi.spyOn(window.HTMLElement.prototype, 'scrollIntoView'); - // Go to the last cell await userEvent.keyboard('{Control>}{end}'); - expect(spy).toHaveBeenCalled(); + expect(grid.scrollTop + grid.clientHeight).toBe(grid.scrollHeight); }); diff --git a/test/setup.ts b/test/setup.ts index accf684f3e..8f5794e9d3 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,92 +1,6 @@ -import { act, configure } from '@testing-library/react'; +import { configure } from '@testing-library/react'; configure({ reactStrictMode: true, throwSuggestions: true }); - -// Allow node-environment tests to properly fail when accessing DOM APIs, -// as @testing-library/jest-dom may polyfill some DOM APIs like `window.CSS` -// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -if (globalThis.window !== undefined) { - await import('@testing-library/jest-dom/vitest'); -} - -if (typeof window !== 'undefined') { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - window.ResizeObserver ??= class { - observe() {} - unobserve() {} - disconnect() {} - }; - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - window.IntersectionObserver ??= class IntersectionObserver { - root = null; - rootMargin = ''; - thresholds = []; - observe() {} - unobserve() {} - disconnect() {} - takeRecords() { - return []; - } - }; - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - window.HTMLElement.prototype.scrollIntoView ??= () => {}; - - // patch clientWidth/clientHeight to pretend we're rendering DataGrid at 1080p - Object.defineProperties(HTMLDivElement.prototype, { - clientWidth: { - get(this: HTMLDivElement) { - return this.classList.contains('rdg') ? 1920 : 0; - } - }, - clientHeight: { - get(this: HTMLDivElement) { - return this.classList.contains('rdg') ? 1080 : 0; - } - } - }); - - // Basic scroll polyfill - const scrollStates = new WeakMap(); - - function getScrollState(div: Element) { - if (scrollStates.has(div)) { - return scrollStates.get(div)!; - } - const scrollState = { scrollTop: 0, scrollLeft: 0 }; - scrollStates.set(div, scrollState); - return scrollState; - } - - Object.defineProperties(Element.prototype, { - scrollTop: { - get(this: Element) { - return getScrollState(this).scrollTop; - }, - set(this: Element, value: number) { - getScrollState(this).scrollTop = value; - act(() => { - this.dispatchEvent(new Event('scroll')); - }); - } - }, - scrollLeft: { - get(this: Element) { - return getScrollState(this).scrollLeft; - }, - set(this: Element, value: number) { - getScrollState(this).scrollLeft = value; - act(() => { - this.dispatchEvent(new Event('scroll')); - }); - } - } - }); - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - Element.prototype.setPointerCapture ??= () => {}; -} diff --git a/test/ssr.test.tsx b/test/ssr.test.tsx index 8a25506961..f28afc793b 100644 --- a/test/ssr.test.tsx +++ b/test/ssr.test.tsx @@ -23,7 +23,8 @@ function App() { return ; } -test('basic server-side rendering (SSR) support', () => { +// biome-ignore lint/suspicious/noSkippedTests: +test.skip('basic server-side rendering (SSR) support', () => { // make sure we're not running in the JSDOM environment expect(globalThis.window).toBeUndefined(); diff --git a/test/utils.tsx b/test/utils.tsx index bb536a43ea..7660e522bd 100644 --- a/test/utils.tsx +++ b/test/utils.tsx @@ -1,10 +1,19 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; +import { css } from '@linaria/core'; import DataGrid from '../src/'; import type { DataGridProps } from '../src/'; -export function setup(props: DataGridProps) { - return render(); +export function setup(props: DataGridProps) { + render( + + ); } export function getGrid() { diff --git a/test/virtualization.test.ts b/test/virtualization.test.ts index 4f506ebeda..3d314d3481 100644 --- a/test/virtualization.test.ts +++ b/test/virtualization.test.ts @@ -35,7 +35,7 @@ function setupGrid( }); } - return setup({ + setup({ columns, rows, topSummaryRows, @@ -99,24 +99,23 @@ test('virtualization is enabled', async () => { assertHeaderCells(18, 0, 17); assertRows(34, 0, 33); assertCells(0, 18, 0, 17); + await scrollGrid({ scrollTop: 244 }); + assertRows(39, 2, 40); - await scrollGrid({ scrollTop: 249 }); + await scrollGrid({ scrollTop: 245 }); assertRows(38, 3, 40); - await scrollGrid({ scrollTop: 250 }); - assertRows(39, 3, 41); - await scrollGrid({ scrollTop: 419 }); assertRows(39, 7, 45); await scrollGrid({ scrollTop: 420 }); assertRows(38, 8, 45); - await scrollGrid({ scrollTop: 529 }); - assertRows(38, 11, 48); + await scrollGrid({ scrollTop: 524 }); + assertRows(39, 10, 48); - await scrollGrid({ scrollTop: 530 }); - assertRows(39, 11, 49); + await scrollGrid({ scrollTop: 525 }); + assertRows(38, 11, 48); await scrollGrid({ scrollTop: 1000 }); assertRows(39, 24, 62); @@ -126,11 +125,11 @@ test('virtualization is enabled', async () => { await scrollGrid({ scrollTop: rowHeight + rowHeight * 100 - 1080 }); assertRows(34, 66, 99); - await scrollGrid({ scrollLeft: 90 }); + await scrollGrid({ scrollLeft: 92 }); assertHeaderCells(18, 0, 17); assertCells(66, 18, 0, 17); - await scrollGrid({ scrollLeft: 91 }); + await scrollGrid({ scrollLeft: 93 }); assertHeaderCells(19, 0, 18); assertCells(66, 19, 0, 18); diff --git a/tsconfig.json b/tsconfig.json index b86e7b6f5a..49f33d4f21 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "extends": "./tsconfig.base.json", "compilerOptions": { "noEmit": true, - "types": ["vitest/globals"] + "types": ["vitest/globals", "@vitest/browser/providers/playwright"] }, "include": ["rollup.config.js", "vite.config.ts", "src/**/*", "test/**/*", "website/**/*"] } diff --git a/vite.config.ts b/vite.config.ts index 999b043d53..4e944c0276 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,25 @@ import react from '@vitejs/plugin-react'; import wyw from '@wyw-in-js/vite'; import { defineConfig } from 'vite'; +import type { BrowserCommand } from 'vitest/node'; const isCI = process.env.CI === 'true'; const isTest = process.env.NODE_ENV === 'test'; +// TODO: remove when `userEvent.pointer` is supported +const resizeColumn: BrowserCommand<[resizeBy: number]> = async (context, resizeBy) => { + const page = context.page; + const frame = await context.frame(); + const resizeHandle = frame.locator('[role="columnheader"][aria-colindex="2"] div'); + const { x, y } = (await resizeHandle.boundingBox())!; + await resizeHandle.hover({ + position: { x: 5, y: 5 } + }); + await page.mouse.down(); + await page.mouse.move(x + resizeBy + 5, y); + await page.mouse.up(); +}; + export default defineConfig({ base: isCI ? '/react-data-grid/' : '/', build: { @@ -13,7 +28,8 @@ export default defineConfig({ }, resolve: { alias: { - lodash: 'lodash-es' + lodash: isTest ? 'lodash' : 'lodash-es', + 'lodash-es': isTest ? 'lodash' : 'lodash-es' } }, plugins: [ @@ -24,23 +40,30 @@ export default defineConfig({ plugins: [['optimize-clsx', { functionNames: ['getCellClassname'] }]] } }), - !isTest && wyw({ preprocessor: 'none' }) + wyw({ preprocessor: 'none' }) ], server: { open: true }, test: { root: '.', - environment: 'jsdom', globals: true, coverage: { - provider: 'v8', + provider: 'istanbul', enabled: isCI, include: ['src/**/*.{ts,tsx}', '!src/types.ts'], reporter: ['text', 'json'] }, testTimeout: isCI ? 10000 : 5000, setupFiles: ['test/setup.ts'], + browser: { + enabled: true, + headless: isCI, + name: 'chromium', + provider: 'playwright', + commands: { resizeColumn }, + viewport: { width: 1920, height: 1080 } + }, restoreMocks: true, sequence: { shuffle: true