diff --git a/README.md b/README.md index 9f8e038f4..09ff15e7b 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,48 @@ See [this guide](https://deephaven.io/core/docs/how-to-guides/authentication/aut - `npm run e2e:headed`: Runs end-to-end tests in headed debug mode. Also ignores snapshots since a test suite will stop once 1 snapshot comparison fails. Useful if you need to debug why a particular test isn't working. For example, to debug the `table.spec.ts` test directly, you could run `npm run e2e:headed -- ./tests/table.spec.ts`. - `npm run e2e:codegen`: Runs Playwright in codegen mode which can help with creating tests. See [Playwright Codegen](https://playwright.dev/docs/codegen/) for more details. - `npm run e2e:update-snapshots`: Updates the E2E snapshots for your local OS. +- `npm run e2e:performance`: Runs grid performance benchmark tests against the main app (requires a Deephaven server). Skipped by default in CI due to resource constraints. + +### Grid Performance Testing + +For performance-sensitive changes to the Grid component, there are two ways to benchmark: + +**1. Main App Tests** (`grid-performance.spec.ts`) + +Tests scroll performance with real table data from a Deephaven server: + +```bash +npm run e2e:performance +``` + +**2. Standalone Perf App** (`grid-perf-app.spec.ts`) + +A lightweight test app in `tests/grid-perf-app/` that uses mock data. This is useful for: + +- Testing without a Deephaven server +- Comparing performance with different Grid props (e.g., accessibility layer on/off) +- Iterating on Grid changes quickly + +To use the perf app: + +```bash +# Install dependencies (one time) +cd tests/grid-perf-app && npm install + +# Start the app +npm run dev + +# In another terminal (from the repo root), run the perf app tests +npm run e2e:grid-performance +``` + +The perf app supports query params to configure the grid: + +- `rows`: Number of rows (default: 1000000) +- `cols`: Number of columns (default: 100) +- `a11y`: Enable accessibility layer (default: true, set to "false" to disable) + +Example: `http://localhost:4020/?rows=100000&cols=50&a11y=false` ### Docker diff --git a/package.json b/package.json index 18c45de1d..dc58289cf 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,8 @@ "e2e": "playwright test", "e2e:codegen": "playwright codegen http://localhost:4000", "e2e:headed": "playwright test --project=chromium --debug --ignore-snapshots", + "e2e:performance": "RUN_PERF_TESTS=1 playwright test grid-performance.spec.ts", + "e2e:grid-performance": "RUN_PERF_TESTS=1 playwright test grid-perf-app.spec.ts", "e2e:update-snapshots": "playwright test --update-snapshots=changed", "e2e:docker": "./tests/docker-scripts/run.sh web-ui-tests", "e2e:update-ci-snapshots": "./tests/docker-scripts/run.sh web-ui-update-snapshots" diff --git a/packages/dashboard-core-plugins/src/GridWidgetPlugin.tsx b/packages/dashboard-core-plugins/src/GridWidgetPlugin.tsx index b721a8e36..b527ec65c 100644 --- a/packages/dashboard-core-plugins/src/GridWidgetPlugin.tsx +++ b/packages/dashboard-core-plugins/src/GridWidgetPlugin.tsx @@ -171,6 +171,7 @@ export function GridWidgetPlugin({ onContextMenu={onContextMenu} inputFilters={inputFilters} customFilters={customFilters} + enableAccessibilityLayer // eslint-disable-next-line react/jsx-props-no-spreading {...linkerProps} alwaysFetchColumns={alwaysFetchColumns} diff --git a/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx b/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx index ff823b527..c3d4a617f 100644 --- a/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx @@ -1245,6 +1245,7 @@ export class IrisGridPanel extends PureComponent< columnAlignmentMap={columnAlignmentMap} columnSelectionValidator={this.isColumnSelectionValid} conditionalFormats={conditionalFormats} + enableAccessibilityLayer inputFilters={this.getGridInputFilters(model.columns, inputFilters)} applyInputFiltersOnInit={panelState == null} isFilterBarShown={isFilterBarShown} diff --git a/packages/grid/src/Grid.tsx b/packages/grid/src/Grid.tsx index 92e741b9a..c6cea8f27 100644 --- a/packages/grid/src/Grid.tsx +++ b/packages/grid/src/Grid.tsx @@ -79,6 +79,7 @@ import { type GridRenderState, type EditingCellTextSelectionRange, } from './GridRendererTypes'; +import GridAccessibilityLayer from './GridAccessibilityLayer'; type LegacyCanvasRenderingContext2D = CanvasRenderingContext2D & { webkitBackingStorePixelRatio?: number; @@ -147,6 +148,9 @@ export type GridProps = typeof Grid.defaultProps & { stateOverride?: Record; theme?: Partial; + + // Whether to render an invisible accessibility layer for e2e testing and screen readers + enableAccessibilityLayer?: boolean; }; export type GridState = { @@ -2298,6 +2302,21 @@ class Grid extends PureComponent { ); } + /** + * Renders the accessibility layer for e2e testing and screen readers + * @returns The accessibility layer or null if disabled + */ + renderAccessibilityLayer(): ReactNode { + const { enableAccessibilityLayer, model } = this.props; + const { metrics } = this; + + if (enableAccessibilityLayer !== true) { + return null; + } + + return ; + } + /** * Gets the render state * @returns The render state @@ -2379,7 +2398,7 @@ class Grid extends PureComponent { onMouseLeave={this.handleMouseLeave} tabIndex={0} > - Your browser does not support HTML canvas. Update your browser? + {this.renderAccessibilityLayer()} {this.renderInputField()} {children} diff --git a/packages/grid/src/GridAccessibilityLayer.test.tsx b/packages/grid/src/GridAccessibilityLayer.test.tsx new file mode 100644 index 000000000..3494e07fc --- /dev/null +++ b/packages/grid/src/GridAccessibilityLayer.test.tsx @@ -0,0 +1,326 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import GridAccessibilityLayer, { + type GridAccessibilityLayerProps, +} from './GridAccessibilityLayer'; +import type GridMetrics from './GridMetrics'; +import MockGridModel from './MockGridModel'; + +function makeMockMetrics( + overrides: Partial = {} +): GridMetrics | null { + const allColumns = [0, 1, 2]; + const allRows = [0, 1, 2]; + + return { + gridX: 30, + gridY: 20, + allColumns, + allRows, + visibleColumns: allColumns, + visibleRows: allRows, + floatingColumns: [], + floatingRows: [], + allColumnXs: new Map([ + [0, 0], + [1, 100], + [2, 200], + ]), + allRowYs: new Map([ + [0, 0], + [1, 20], + [2, 40], + ]), + allColumnWidths: new Map([ + [0, 100], + [1, 100], + [2, 100], + ]), + allRowHeights: new Map([ + [0, 20], + [1, 20], + [2, 20], + ]), + modelColumns: new Map([ + [0, 0], + [1, 1], + [2, 2], + ]), + modelRows: new Map([ + [0, 0], + [1, 1], + [2, 2], + ]), + rowHeaderWidth: 30, + columnHeaderHeight: 20, + rowHeight: 20, + columnWidth: 100, + rowCount: 3, + columnCount: 3, + rowFooterWidth: 0, + floatingTopRowCount: 0, + floatingBottomRowCount: 0, + floatingLeftColumnCount: 0, + floatingRightColumnCount: 0, + firstRow: 0, + firstColumn: 0, + treePaddingX: 0, + treePaddingY: 0, + left: 0, + top: 0, + bottom: 2, + right: 2, + topOffset: 0, + leftOffset: 0, + topVisible: 0, + leftVisible: 0, + bottomVisible: 2, + rightVisible: 2, + bottomViewport: 2, + rightViewport: 2, + width: 500, + height: 500, + maxX: 300, + maxY: 60, + lastLeft: 0, + lastTop: 0, + barHeight: 0, + barTop: 0, + barWidth: 0, + barLeft: 0, + handleHeight: 0, + handleWidth: 0, + hasHorizontalBar: false, + hasVerticalBar: false, + verticalBarWidth: 0, + horizontalBarHeight: 0, + scrollX: 0, + scrollY: 0, + scrollableContentWidth: 300, + scrollableContentHeight: 60, + scrollableViewportWidth: 500, + scrollableViewportHeight: 500, + visibleRowHeights: new Map([ + [0, 20], + [1, 20], + [2, 20], + ]), + visibleColumnWidths: new Map([ + [0, 100], + [1, 100], + [2, 100], + ]), + floatingTopHeight: 0, + floatingBottomHeight: 0, + floatingLeftWidth: 0, + floatingRightWidth: 0, + visibleRowYs: new Map([ + [0, 0], + [1, 20], + [2, 40], + ]), + visibleColumnXs: new Map([ + [0, 0], + [1, 100], + [2, 200], + ]), + visibleRowTreeBoxes: new Map(), + movedRows: [], + movedColumns: [], + fontWidthsLower: new Map(), + fontWidthsUpper: new Map(), + userColumnWidths: new Map(), + userRowHeights: new Map(), + calculatedRowHeights: new Map(), + calculatedColumnWidths: new Map(), + contentColumnWidths: new Map(), + contentRowHeights: new Map(), + columnHeaderMaxDepth: 1, + ...overrides, + }; +} + +function renderAccessibilityLayer( + propsOverrides: Partial = {} +): ReturnType { + const model = new MockGridModel({ rowCount: 3, columnCount: 3 }); + const metrics = makeMockMetrics(); + + return render( + + ); +} + +describe('GridAccessibilityLayer', () => { + it('renders nothing when metrics is null', () => { + const { container } = renderAccessibilityLayer({ metrics: null }); + expect(container.firstChild).toBeNull(); + }); + + it('renders the accessibility layer container with grid role', () => { + renderAccessibilityLayer(); + const layer = screen.getByTestId('grid-accessibility-layer'); + expect(layer).toBeInTheDocument(); + expect(layer).toHaveAttribute('role', 'grid'); + }); + + it('renders data cells with correct data-testid attributes', () => { + renderAccessibilityLayer(); + + // Check that cells exist for the 3x3 grid + expect(screen.getByTestId('grid-cell-0-0')).toBeInTheDocument(); + expect(screen.getByTestId('grid-cell-1-1')).toBeInTheDocument(); + expect(screen.getByTestId('grid-cell-2-2')).toBeInTheDocument(); + }); + + it('renders data cells with gridcell role and aria attributes', () => { + renderAccessibilityLayer(); + + const cell = screen.getByTestId('grid-cell-0-0'); + expect(cell).toHaveAttribute('role', 'gridcell'); + expect(cell).toHaveAttribute('aria-colindex', '1'); + expect(cell).toHaveAttribute('aria-rowindex', '1'); + }); + + it('renders column headers with correct data-testid attributes', () => { + renderAccessibilityLayer(); + + expect(screen.getByTestId('grid-column-header-0-0')).toBeInTheDocument(); + expect(screen.getByTestId('grid-column-header-1-0')).toBeInTheDocument(); + expect(screen.getByTestId('grid-column-header-2-0')).toBeInTheDocument(); + }); + + it('renders column headers with columnheader role', () => { + renderAccessibilityLayer(); + + const header = screen.getByTestId('grid-column-header-0-0'); + expect(header).toHaveAttribute('role', 'columnheader'); + expect(header).toHaveAttribute('aria-colindex', '1'); + }); + + it('renders row headers when rowHeaderWidth is greater than 0', () => { + renderAccessibilityLayer(); + + expect(screen.getByTestId('grid-row-header-0')).toBeInTheDocument(); + expect(screen.getByTestId('grid-row-header-1')).toBeInTheDocument(); + expect(screen.getByTestId('grid-row-header-2')).toBeInTheDocument(); + }); + + it('does not render row headers when rowHeaderWidth is 0', () => { + const metrics = makeMockMetrics({ rowHeaderWidth: 0 }); + renderAccessibilityLayer({ metrics }); + + expect(screen.queryByTestId('grid-row-header-0')).not.toBeInTheDocument(); + }); + + it('renders row headers with rowheader role', () => { + renderAccessibilityLayer(); + + const header = screen.getByTestId('grid-row-header-0'); + expect(header).toHaveAttribute('role', 'rowheader'); + expect(header).toHaveAttribute('aria-rowindex', '1'); + }); + + it('cells contain text from model.textForCell', () => { + renderAccessibilityLayer(); + + // MockGridModel returns text like "0,0" for cell at column 0, row 0 + const cell = screen.getByTestId('grid-cell-0-0'); + expect(cell.textContent).toBe('0,0'); + + const cell11 = screen.getByTestId('grid-cell-1-1'); + expect(cell11.textContent).toBe('1,1'); + }); + + it('cells are positioned correctly using metrics', () => { + renderAccessibilityLayer(); + + const cell = screen.getByTestId('grid-cell-0-0'); + // gridX = 30, allColumnXs[0] = 0, so left = 30 + // gridY = 20, allRowYs[0] = 0, so top = 20 + expect(cell).toHaveStyle({ + position: 'absolute', + left: '30px', + top: '20px', + width: '100px', + height: '20px', + }); + }); + + it('cells have pointer-events: none to allow clicks through to canvas', () => { + renderAccessibilityLayer(); + + const cell = screen.getByTestId('grid-cell-0-0'); + expect(cell).toHaveStyle({ pointerEvents: 'none' }); + }); + + it('includes aria-rowcount and aria-colcount on the container', () => { + renderAccessibilityLayer(); + + const layer = screen.getByTestId('grid-accessibility-layer'); + expect(layer).toHaveAttribute('aria-rowcount', '3'); + expect(layer).toHaveAttribute('aria-colcount', '3'); + }); + + it('follows WAI-ARIA grid pattern with rowgroups and rows', () => { + renderAccessibilityLayer(); + + const layer = screen.getByTestId('grid-accessibility-layer'); + + // Should have rowgroup elements + const rowGroups = layer.querySelectorAll('[role="rowgroup"]'); + expect(rowGroups.length).toBeGreaterThanOrEqual(1); + + // Should have row elements + const rows = layer.querySelectorAll('[role="row"]'); + expect(rows.length).toBeGreaterThan(0); + }); + + it('places data cells inside row elements', () => { + renderAccessibilityLayer(); + + const cell = screen.getByTestId('grid-cell-0-0'); + const parentRow = cell.closest('[role="row"]'); + expect(parentRow).not.toBeNull(); + }); + + it('places column headers inside row elements', () => { + renderAccessibilityLayer(); + + const header = screen.getByTestId('grid-column-header-0-0'); + const parentRow = header.closest('[role="row"]'); + expect(parentRow).not.toBeNull(); + }); + + it('places row headers inside row elements with data cells', () => { + renderAccessibilityLayer(); + + const rowHeader = screen.getByTestId('grid-row-header-0'); + const parentRow = rowHeader.closest('[role="row"]'); + expect(parentRow).not.toBeNull(); + + // The row should also contain data cells + const cellInSameRow = parentRow?.querySelector( + '[data-testid="grid-cell-0-0"]' + ); + expect(cellInSameRow).toBeInTheDocument(); + }); + + it('adds aria-rowindex to row elements', () => { + renderAccessibilityLayer(); + + const rows = screen.getAllByRole('row'); + // Filter to data rows (those with aria-rowindex) + const dataRows = rows.filter(row => row.hasAttribute('aria-rowindex')); + expect(dataRows.length).toBe(3); // 3 data rows + + expect(dataRows[0]).toHaveAttribute('aria-rowindex', '1'); + expect(dataRows[1]).toHaveAttribute('aria-rowindex', '2'); + expect(dataRows[2]).toHaveAttribute('aria-rowindex', '3'); + }); +}); diff --git a/packages/grid/src/GridAccessibilityLayer.tsx b/packages/grid/src/GridAccessibilityLayer.tsx new file mode 100644 index 000000000..2fe2bc0a6 --- /dev/null +++ b/packages/grid/src/GridAccessibilityLayer.tsx @@ -0,0 +1,223 @@ +import React, { type CSSProperties, memo } from 'react'; +import type GridMetrics from './GridMetrics'; +import type GridModel from './GridModel'; + +export interface GridAccessibilityLayerProps { + /** The metrics for the grid, used to position cells */ + metrics: GridMetrics | null; + /** The model providing cell data */ + model: GridModel; +} + +const containerStyle: CSSProperties = { + position: 'absolute', + inset: 0, + overflow: 'hidden', + pointerEvents: 'none', +}; + +const rowStyle: CSSProperties = { + display: 'contents', +}; + +const cellStyle: CSSProperties = { + position: 'absolute', + opacity: 0, + overflow: 'hidden', + pointerEvents: 'none', +}; + +/** + * An invisible accessibility layer that renders DOM elements overlaid on the canvas grid. + * This enables e2e testing frameworks like Playwright to inspect grid contents and interact + * with cells via data-testid attributes, as well as providing ARIA semantics for screen readers. + * + * The layer follows the WAI-ARIA grid pattern (https://www.w3.org/WAI/ARIA/apg/patterns/grid/): + * - Container has `role="grid"` with `aria-rowcount` and `aria-colcount` + * - Column headers are grouped in `role="rowgroup"` rows + * - Data cells are grouped in `role="row"` elements + * - Cells have appropriate roles and aria-colindex/aria-rowindex attributes + * + * Data-testid attributes: + * - Data cells: `data-testid="grid-cell-{column}-{row}"` + * - Column headers: `data-testid="grid-column-header-{column}-{depth}"` + * - Row headers: `data-testid="grid-row-header-{row}"` + * + * All elements have pointer-events: none so clicks pass through to the underlying canvas. + */ +function GridAccessibilityLayerComponent({ + metrics, + model, +}: GridAccessibilityLayerProps): JSX.Element | null { + if (metrics == null) { + return null; + } + + const { + gridX, + gridY, + allColumns, + allRows, + allColumnXs, + allRowYs, + allColumnWidths, + allRowHeights, + modelColumns, + modelRows, + rowHeaderWidth, + columnHeaderHeight, + } = metrics; + + const { columnHeaderMaxDepth } = model; + + // Build column header rows (one row per depth level) + const columnHeaderRows = Array.from( + { length: columnHeaderMaxDepth }, + (_, depth) => { + const headerY = depth * columnHeaderHeight; + const headerCells = allColumns + .map(column => { + const x = allColumnXs.get(column); + const width = allColumnWidths.get(column); + const modelColumn = modelColumns.get(column); + + if ( + x === undefined || + width === undefined || + modelColumn === undefined + ) { + return null; + } + + const text = model.textForColumnHeader(modelColumn, depth); + + return ( +
+ {text ?? ''} +
+ ); + }) + .filter((cell): cell is JSX.Element => cell !== null); + + return ( +
+ {headerCells} +
+ ); + } + ); + + // Build data rows (one row element per visible row) + const dataRows = allRows + .map(row => { + const y = allRowYs.get(row); + const height = allRowHeights.get(row); + const modelRow = modelRows.get(row); + + if (y === undefined || height === undefined || modelRow === undefined) { + return null; + } + + // Add row header if present + const rowHeaderCell = + rowHeaderWidth > 0 ? ( +
+ {model.textForRowHeader(modelRow)} +
+ ) : null; + + // Add data cells for this row + const dataCells = allColumns + .map(column => { + const x = allColumnXs.get(column); + const width = allColumnWidths.get(column); + const modelColumn = modelColumns.get(column); + + if ( + x === undefined || + width === undefined || + modelColumn === undefined + ) { + return null; + } + + const text = model.textForCell(modelColumn, modelRow); + + return ( +
+ {text} +
+ ); + }) + .filter((cell): cell is JSX.Element => cell !== null); + + return ( +
+ {rowHeaderCell} + {dataCells} +
+ ); + }) + .filter((row): row is JSX.Element => row !== null); + + return ( +
+ {columnHeaderMaxDepth > 0 && ( +
{columnHeaderRows}
+ )} +
{dataRows}
+
+ ); +} + +const GridAccessibilityLayer = memo(GridAccessibilityLayerComponent); +GridAccessibilityLayer.displayName = 'GridAccessibilityLayer'; +export default GridAccessibilityLayer; diff --git a/packages/grid/src/index.ts b/packages/grid/src/index.ts index 29a33f63b..75ca69804 100644 --- a/packages/grid/src/index.ts +++ b/packages/grid/src/index.ts @@ -5,6 +5,8 @@ export * from './ExpandableGridModel'; export * from './ExpandableColumnGridModel'; export { default as Grid } from './Grid'; export * from './Grid'; +export { default as GridAccessibilityLayer } from './GridAccessibilityLayer'; +export * from './GridAccessibilityLayer'; export * from './GridMetricCalculator'; export * from './GridMetrics'; export { default as GridModel } from './GridModel'; diff --git a/packages/iris-grid/src/IrisGrid.tsx b/packages/iris-grid/src/IrisGrid.tsx index 235da947d..bcaea339f 100644 --- a/packages/iris-grid/src/IrisGrid.tsx +++ b/packages/iris-grid/src/IrisGrid.tsx @@ -379,6 +379,9 @@ export interface IrisGridProps { density?: 'compact' | 'regular' | 'spacious'; getMetricCalculator: GetMetricCalculatorType; + + // Whether to render an invisible accessibility layer for e2e testing and screen readers + enableAccessibilityLayer?: boolean; } export interface IrisGridState { @@ -4608,6 +4611,7 @@ class IrisGrid extends Component { const { children, customFilters, + enableAccessibilityLayer, getDownloadWorker, isSelectingColumn, isStuckToBottom, @@ -5188,6 +5192,7 @@ class IrisGrid extends Component { renderer={this.renderer} stateOverride={stateOverride} theme={theme} + enableAccessibilityLayer={enableAccessibilityLayer} > { + const canvas = grid.locator('canvas.grid-canvas'); + const cell = page.getByTestId(`grid-cell-${column}-${row}`); + + await expect(cell).toBeAttached(); + + // Get the position from the element's inline styles since it's hidden inside canvas + const styles = await cell.evaluate(el => { + const style = (el as HTMLElement).style; + return { + left: parseFloat(style.left), + top: parseFloat(style.top), + width: parseFloat(style.width), + height: parseFloat(style.height), + }; + }); + + // Get the canvas bounding box to calculate absolute position + const canvasBox = await canvas.boundingBox(); + if (!canvasBox) { + throw new Error('Canvas bounding box is null'); + } + + // Click at the center of the cell position on the canvas + const clickX = canvasBox.x + styles.left + styles.width / 2; + const clickY = canvasBox.y + styles.top + styles.height / 2; + await page.mouse.click(clickX, clickY); +} + +test.describe('grid accessibility layer', () => { + test.beforeEach(async ({ page }) => { + await gotoPage(page, ''); + await openTable(page, 'simple_table'); + await waitForLoadingDone(page); + }); + + test('renders accessibility layer container', async ({ page }) => { + const accessibilityLayer = page.getByTestId('grid-accessibility-layer'); + // Inside canvas element, so not visible but still attached to DOM + await expect(accessibilityLayer).toBeAttached(); + await expect(accessibilityLayer).toHaveAttribute('role', 'grid'); + }); + + test('renders data cells with correct test ids', async ({ page }) => { + // Check that data cells are rendered with expected test IDs + const cell00 = page.getByTestId('grid-cell-0-0'); + const cell10 = page.getByTestId('grid-cell-1-0'); + const cell01 = page.getByTestId('grid-cell-0-1'); + + await expect(cell00).toBeAttached(); + await expect(cell10).toBeAttached(); + await expect(cell01).toBeAttached(); + }); + + test('data cells have correct ARIA attributes', async ({ page }) => { + const cell = page.getByTestId('grid-cell-0-0'); + + await expect(cell).toHaveAttribute('role', 'gridcell'); + await expect(cell).toHaveAttribute('aria-colindex', '1'); + await expect(cell).toHaveAttribute('aria-rowindex', '1'); + }); + + test('renders column headers with correct test ids', async ({ page }) => { + // Column headers at depth 0 + const header0 = page.getByTestId('grid-column-header-0-0'); + const header1 = page.getByTestId('grid-column-header-1-0'); + + await expect(header0).toBeAttached(); + await expect(header1).toBeAttached(); + }); + + test('column headers have correct ARIA attributes', async ({ page }) => { + const header = page.getByTestId('grid-column-header-0-0'); + + await expect(header).toHaveAttribute('role', 'columnheader'); + await expect(header).toHaveAttribute('aria-colindex', '1'); + }); + + test('column headers contain column names', async ({ page }) => { + // simple_table has columns x and y + const headerX = page.getByTestId('grid-column-header-0-0'); + const headerY = page.getByTestId('grid-column-header-1-0'); + + await expect(headerX).toContainText('x'); + await expect(headerY).toContainText('y'); + }); + + test('data cells contain cell values', async ({ page }) => { + // First row should contain values from the simple_table + const cell00 = page.getByTestId('grid-cell-0-0'); + + // The cell should have some numeric content (from simple_table) + const text = await cell00.textContent(); + expect(text).toBeTruthy(); + expect(text?.length).toBeGreaterThan(0); + }); + + test('can find cells by text content', async ({ page }) => { + // Find a cell containing a specific value + // simple_table contains sin/cos values, look for column header + const xHeader = page.locator('[data-testid^="grid-column-header"]', { + hasText: 'x', + }); + + await expect(xHeader).toBeAttached(); + }); + + test('accessibility layer does not block canvas interactions', async ({ + page, + }) => { + const grid = page.locator('.iris-grid-panel .iris-grid'); + const canvas = grid.locator('canvas.grid-canvas'); + + // Click on the grid - should select a cell + await grid.click({ position: { x: 50, y: 50 } }); + + // The canvas should receive focus, not blocked by the accessibility layer + // The accessibility layer has pointer-events: none + await expect(canvas).toBeFocused(); + }); + + test('row headers are rendered when rowHeaderWidth is greater than 0', async ({ + page, + }) => { + // Note: simple_table has rowHeaderWidth = 0, so row headers are not rendered + // This test verifies the condition - row headers only appear when rowHeaderWidth > 0 + const rowHeader0 = page.getByTestId('grid-row-header-0'); + + // In simple_table, row headers should NOT be present since rowHeaderWidth is 0 + await expect(rowHeader0).not.toBeAttached(); + }); + + test('accessibility layer cells contain expected values', async ({ + page, + }) => { + // Verify multiple cells have content (inside canvas, so use textContent) + const cell02 = page.getByTestId('grid-cell-0-2'); + await expect(cell02).toBeAttached(); + + const text = await cell02.textContent(); + expect(text).toBeTruthy(); + }); + + test('can click on third row using accessibility layer positioning', async ({ + page, + }) => { + const grid = page.locator('.iris-grid-panel .iris-grid'); + + // Click on cell at column 0, row 2 (third row) + await clickCell(page, grid, 0, 2); + + // Take a screenshot to verify the third row is selected + await expect(grid).toHaveScreenshot('third-row-cell-selected.png'); + }); +}); + +test.describe('grid accessibility layer with column groups', () => { + test.beforeEach(async ({ page }) => { + await gotoPage(page, ''); + await openTable(page, 'simple_table_header_group'); + }); + + test('renders column headers at multiple depths', async ({ page }) => { + // Depth 0 - base column headers + const headerDepth0 = page.getByTestId('grid-column-header-0-0'); + await expect(headerDepth0).toBeAttached(); + + // Depth 1 - column group headers (if table has groups) + const headerDepth1 = page.getByTestId('grid-column-header-0-1'); + await expect(headerDepth1).toBeAttached(); + }); +}); diff --git a/tests/grid-accessibility.spec.ts-snapshots/third-row-cell-selected-chromium-linux.png b/tests/grid-accessibility.spec.ts-snapshots/third-row-cell-selected-chromium-linux.png new file mode 100644 index 000000000..a0c5a895a Binary files /dev/null and b/tests/grid-accessibility.spec.ts-snapshots/third-row-cell-selected-chromium-linux.png differ diff --git a/tests/grid-accessibility.spec.ts-snapshots/third-row-cell-selected-firefox-linux.png b/tests/grid-accessibility.spec.ts-snapshots/third-row-cell-selected-firefox-linux.png new file mode 100644 index 000000000..2b6579a2c Binary files /dev/null and b/tests/grid-accessibility.spec.ts-snapshots/third-row-cell-selected-firefox-linux.png differ diff --git a/tests/grid-accessibility.spec.ts-snapshots/third-row-cell-selected-webkit-linux.png b/tests/grid-accessibility.spec.ts-snapshots/third-row-cell-selected-webkit-linux.png new file mode 100644 index 000000000..8accd5872 Binary files /dev/null and b/tests/grid-accessibility.spec.ts-snapshots/third-row-cell-selected-webkit-linux.png differ diff --git a/tests/grid-perf-app.spec.ts b/tests/grid-perf-app.spec.ts new file mode 100644 index 000000000..887111a98 --- /dev/null +++ b/tests/grid-perf-app.spec.ts @@ -0,0 +1,290 @@ +import { test, expect, type Page } from '@playwright/test'; + +/** + * Grid Performance Tests using the standalone perf app. + * + * These tests use the grid-perf-app which provides a standalone Grid component + * with MockGridModel data, allowing proper testing of Grid props without needing + * a Deephaven server. + * + * Prerequisites: + * 1. Install the perf app: cd tests/grid-perf-app && npm install + * 2. Start the perf app: cd tests/grid-perf-app && npm run dev + * 3. Run tests: RUN_PERF_TESTS=1 npx playwright test grid-perf-app.spec.ts + * + * The perf app supports query params: + * - rows: Number of rows (default: 1000000) + * - cols: Number of columns (default: 100) + * - a11y: Enable accessibility layer (default: true, set to "false" to disable) + */ + +const PERF_APP_URL = 'http://localhost:4020'; + +interface FPSResult { + fps: number; + avgFrameTime: number; + minFrameTime: number; + maxFrameTime: number; + frameCount: number; + droppedFrames: number; +} + +async function startFPSMeasurement(page: Page): Promise { + await page.evaluate(() => { + (window as any).__frameTimings = []; + (window as any).__fpsRunning = true; + let lastTime = performance.now(); + + function measureFrame() { + if (!(window as any).__fpsRunning) return; + + const now = performance.now(); + (window as any).__frameTimings.push(now - lastTime); + lastTime = now; + requestAnimationFrame(measureFrame); + } + requestAnimationFrame(measureFrame); + }); +} + +async function stopFPSMeasurement(page: Page): Promise { + const timings = await page.evaluate(() => { + (window as any).__fpsRunning = false; + return (window as any).__frameTimings as number[]; + }); + + const validTimings = timings.filter(t => t < 500 && t > 0); + + if (validTimings.length === 0) { + return { + fps: 0, + avgFrameTime: 0, + minFrameTime: 0, + maxFrameTime: 0, + frameCount: 0, + droppedFrames: 0, + }; + } + + const avgFrameTime = + validTimings.reduce((a, b) => a + b, 0) / validTimings.length; + const fps = 1000 / avgFrameTime; + const minFrameTime = Math.min(...validTimings); + const maxFrameTime = Math.max(...validTimings); + const droppedFrames = validTimings.filter(t => t > 33).length; + + return { + fps, + avgFrameTime, + minFrameTime, + maxFrameTime, + frameCount: validTimings.length, + droppedFrames, + }; +} + +/** + * Scrolls the grid in the perf app using mouse wheel events + */ +async function scrollPerfAppGrid( + page: Page, + totalDelta: number +): Promise { + const canvas = page.locator('canvas').first(); + const box = await canvas.boundingBox(); + if (!box) throw new Error('Grid canvas not found'); + + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + + const scrollStep = 100; + const direction = Math.sign(totalDelta); + let remaining = Math.abs(totalDelta); + + while (remaining > 0) { + const step = Math.min(scrollStep, remaining); + await page.mouse.wheel(0, step * direction); + remaining -= step; + await page.waitForTimeout(16); + } +} + +function logResults( + testName: string, + result: FPSResult, + expected: { minFps: number } +): void { + console.log(`\n${testName}:`); + console.log(` Average FPS: ${result.fps.toFixed(1)}`); + console.log(` Avg frame time: ${result.avgFrameTime.toFixed(2)}ms`); + console.log( + ` Frame time range: ${result.minFrameTime.toFixed( + 2 + )}ms - ${result.maxFrameTime.toFixed(2)}ms` + ); + console.log(` Total frames: ${result.frameCount}`); + console.log( + ` Dropped frames (>33ms): ${result.droppedFrames} (${( + (result.droppedFrames / result.frameCount) * + 100 + ).toFixed(1)}%)` + ); + console.log(` Expected min FPS: ${expected.minFps}`); +} + +test.describe('grid perf app - accessibility layer comparison', () => { + test.skip( + !process.env.RUN_PERF_TESTS, + 'Performance tests skipped. Set RUN_PERF_TESTS=1 to run.' + ); + + test.describe.configure({ mode: 'serial' }); + + test('compare scroll performance: accessibility layer ON vs OFF', async ({ + page, + }) => { + // --- TEST 1: WITH ACCESSIBILITY LAYER --- + await page.goto(`${PERF_APP_URL}/?rows=1000000&cols=100&a11y=true`); + await page.waitForSelector('canvas'); + + // Verify accessibility layer is present + const layerCount = await page + .locator('[data-testid="grid-accessibility-layer"]') + .count(); + expect(layerCount).toBe(1); + + await startFPSMeasurement(page); + await scrollPerfAppGrid(page, 5000); + await scrollPerfAppGrid(page, -3000); + await scrollPerfAppGrid(page, 4000); + await scrollPerfAppGrid(page, -5000); + const withLayerResult = await stopFPSMeasurement(page); + + // --- TEST 2: WITHOUT ACCESSIBILITY LAYER --- + await page.goto(`${PERF_APP_URL}/?rows=1000000&cols=100&a11y=false`); + await page.waitForSelector('canvas'); + + // Verify accessibility layer is NOT present + const layerCountAfter = await page + .locator('[data-testid="grid-accessibility-layer"]') + .count(); + expect(layerCountAfter).toBe(0); + + await startFPSMeasurement(page); + await scrollPerfAppGrid(page, 5000); + await scrollPerfAppGrid(page, -3000); + await scrollPerfAppGrid(page, 4000); + await scrollPerfAppGrid(page, -5000); + const withoutLayerResult = await stopFPSMeasurement(page); + + // --- COMPARISON REPORT --- + console.log('\n========================================'); + console.log('ACCESSIBILITY LAYER PERFORMANCE COMPARISON'); + console.log('========================================\n'); + + logResults('WITH Accessibility Layer', withLayerResult, { minFps: 28 }); + logResults('WITHOUT Accessibility Layer', withoutLayerResult, { + minFps: 28, + }); + + const fpsDiff = withoutLayerResult.fps - withLayerResult.fps; + const fpsPercentDiff = (fpsDiff / withoutLayerResult.fps) * 100; + const frameTimeDiff = + withLayerResult.avgFrameTime - withoutLayerResult.avgFrameTime; + + console.log('\n--- COMPARISON SUMMARY ---'); + console.log(`FPS difference: ${fpsDiff.toFixed(2)} fps`); + console.log( + `Performance impact: ${fpsPercentDiff > 0 ? '-' : '+'}${Math.abs( + fpsPercentDiff + ).toFixed(2)}%` + ); + console.log(`Frame time overhead: ${frameTimeDiff.toFixed(3)}ms`); + + if (Math.abs(fpsPercentDiff) < 5) { + console.log('\n✓ Accessibility layer has NEGLIGIBLE performance impact'); + } else if (fpsPercentDiff > 0) { + console.log( + `\n⚠ Accessibility layer causes ${fpsPercentDiff.toFixed( + 1 + )}% performance decrease` + ); + } else { + console.log( + `\n✓ Accessibility layer has no negative impact (${Math.abs( + fpsPercentDiff + ).toFixed(1)}% faster)` + ); + } + }); +}); + +test.describe('grid perf app - stress tests', () => { + test.skip( + !process.env.RUN_PERF_TESTS, + 'Performance tests skipped. Set RUN_PERF_TESTS=1 to run.' + ); + + test.describe.configure({ mode: 'serial' }); + + test('scroll performance - 1M rows', async ({ page }) => { + await page.goto(`${PERF_APP_URL}/?rows=1000000&cols=100`); + await page.waitForSelector('canvas'); + + await startFPSMeasurement(page); + + await scrollPerfAppGrid(page, 5000); + await scrollPerfAppGrid(page, -3000); + await scrollPerfAppGrid(page, 4000); + await scrollPerfAppGrid(page, -5000); + + const result = await stopFPSMeasurement(page); + logResults('1M Rows Scroll', result, { minFps: 30 }); + }); + + test('scroll performance - many columns', async ({ page }) => { + await page.goto(`${PERF_APP_URL}/?rows=100000&cols=500`); + await page.waitForSelector('canvas'); + + await startFPSMeasurement(page); + + // Horizontal and vertical scrolling + for (let i = 0; i < 20; i += 1) { + await page.mouse.wheel(500, 500); + await page.waitForTimeout(32); + await page.mouse.wheel(-300, 300); + await page.waitForTimeout(32); + } + + const result = await stopFPSMeasurement(page); + logResults('500 Columns Scroll', result, { minFps: 28 }); + }); + + test('sustained scrolling - 3 seconds', async ({ page }) => { + await page.goto(`${PERF_APP_URL}/?rows=1000000&cols=100`); + await page.waitForSelector('canvas'); + + const canvas = page.locator('canvas').first(); + const box = await canvas.boundingBox(); + if (!box) throw new Error('Grid canvas not found'); + + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + + await startFPSMeasurement(page); + + const startTime = Date.now(); + const duration = 3000; + let direction = 1; + + while (Date.now() - startTime < duration) { + await page.mouse.wheel(0, 300 * direction); + await page.waitForTimeout(16); + + if (Math.random() < 0.1) { + direction *= -1; + } + } + + const result = await stopFPSMeasurement(page); + logResults('Sustained Scroll (3s)', result, { minFps: 30 }); + }); +}); diff --git a/tests/grid-perf-app/.gitignore b/tests/grid-perf-app/.gitignore new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/tests/grid-perf-app/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/tests/grid-perf-app/index.html b/tests/grid-perf-app/index.html new file mode 100644 index 000000000..0ab694666 --- /dev/null +++ b/tests/grid-perf-app/index.html @@ -0,0 +1,26 @@ + + + + + + Grid Performance Test + + + +
+ + + diff --git a/tests/grid-perf-app/package-lock.json b/tests/grid-perf-app/package-lock.json new file mode 100644 index 000000000..a1cfff1af --- /dev/null +++ b/tests/grid-perf-app/package-lock.json @@ -0,0 +1,1508 @@ +{ + "name": "grid-perf-app", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "grid-perf-app", + "version": "0.0.0", + "dependencies": { + "@deephaven/grid": "file:../../packages/grid", + "@deephaven/log": "file:../../packages/log", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react-swc": "^3.7.0", + "typescript": "5.3.3", + "vite": "^6.0.11" + } + }, + "../../packages/grid": { + "name": "@deephaven/grid", + "version": "1.13.0", + "license": "Apache-2.0", + "dependencies": { + "@deephaven/utils": "file:../utils", + "classnames": "^2.3.1", + "color-convert": "^2.0.1", + "event-target-shim": "^6.0.2", + "linkifyjs": "^4.1.0", + "lodash.clamp": "^4.0.3", + "memoize-one": "^5.1.1", + "memoizee": "^0.4.15" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "../../packages/log": { + "name": "@deephaven/log", + "version": "1.8.0", + "license": "Apache-2.0", + "dependencies": { + "event-target-shim": "^6.0.2", + "jszip": "^3.10.1", + "safe-stable-stringify": "^2.5.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@deephaven/grid": { + "resolved": "../../packages/grid", + "link": true + }, + "node_modules/@deephaven/log": { + "resolved": "../../packages/log", + "link": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/core": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.18.tgz", + "integrity": "sha512-z87aF9GphWp//fnkRsqvtY+inMVPgYW3zSlXH1kJFvRT5H/wiAn+G32qW5l3oEk63KSF1x3Ov0BfHCObAmT8RA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.25" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.18", + "@swc/core-darwin-x64": "1.15.18", + "@swc/core-linux-arm-gnueabihf": "1.15.18", + "@swc/core-linux-arm64-gnu": "1.15.18", + "@swc/core-linux-arm64-musl": "1.15.18", + "@swc/core-linux-x64-gnu": "1.15.18", + "@swc/core-linux-x64-musl": "1.15.18", + "@swc/core-win32-arm64-msvc": "1.15.18", + "@swc/core-win32-ia32-msvc": "1.15.18", + "@swc/core-win32-x64-msvc": "1.15.18" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.18.tgz", + "integrity": "sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.18.tgz", + "integrity": "sha512-wZle0eaQhnzxWX5V/2kEOI6Z9vl/lTFEC6V4EWcn+5pDjhemCpQv9e/TDJ0GIoiClX8EDWRvuZwh+Z3dhL1NAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.18.tgz", + "integrity": "sha512-ao61HGXVqrJFHAcPtF4/DegmwEkVCo4HApnotLU8ognfmU8x589z7+tcf3hU+qBiU1WOXV5fQX6W9Nzs6hjxDw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.18.tgz", + "integrity": "sha512-3xnctOBLIq3kj8PxOCgPrGjBLP/kNOddr6f5gukYt/1IZxsITQaU9TDyjeX6jG+FiCIHjCuWuffsyQDL5Ew1bg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.18.tgz", + "integrity": "sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.18.tgz", + "integrity": "sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.18.tgz", + "integrity": "sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.18.tgz", + "integrity": "sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.18.tgz", + "integrity": "sha512-yVuTrZ0RccD5+PEkpcLOBAuPbYBXS6rslENvIXfvJGXSdX5QGi1ehC4BjAMl5FkKLiam4kJECUI0l7Hq7T1vwg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.18.tgz", + "integrity": "sha512-7NRmE4hmUQNCbYU3Hn9Tz57mK9Qq4c97ZS+YlamlK6qG9Fb5g/BB3gPDe0iLlJkns/sYv2VWSkm8c3NmbEGjbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", + "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react-swc": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz", + "integrity": "sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.27", + "@swc/core": "^1.12.11" + }, + "peerDependencies": { + "vite": "^4 || ^5 || ^6 || ^7" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/tests/grid-perf-app/package.json b/tests/grid-perf-app/package.json new file mode 100644 index 000000000..d7bac2d28 --- /dev/null +++ b/tests/grid-perf-app/package.json @@ -0,0 +1,24 @@ +{ + "name": "grid-perf-app", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@deephaven/grid": "file:../../packages/grid", + "@deephaven/log": "file:../../packages/log", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react-swc": "^3.7.0", + "typescript": "5.3.3", + "vite": "^6.0.11" + } +} diff --git a/tests/grid-perf-app/src/App.tsx b/tests/grid-perf-app/src/App.tsx new file mode 100644 index 000000000..75effc9b4 --- /dev/null +++ b/tests/grid-perf-app/src/App.tsx @@ -0,0 +1,62 @@ +import { useMemo } from 'react'; +import { Grid, MockGridModel } from '@deephaven/grid'; + +/** + * Grid Performance Test App + * + * Use query params to configure the grid: + * - rows: Number of rows (default: 1000000) + * - cols: Number of columns (default: 100) + * - a11y: Enable accessibility layer (default: true, set to "false" to disable) + * + * Examples: + * http://localhost:4020/ + * http://localhost:4020/?rows=10000&cols=50 + * http://localhost:4020/?a11y=false + * http://localhost:4020/?rows=100000&cols=200&a11y=false + */ +function App(): JSX.Element { + const params = new URLSearchParams(window.location.search); + + const rowCount = parseInt(params.get('rows') ?? '1000000', 10); + const columnCount = parseInt(params.get('cols') ?? '100', 10); + const enableAccessibilityLayer = params.get('a11y') !== 'false'; + + const model = useMemo( + () => new MockGridModel({ rowCount, columnCount }), + [rowCount, columnCount] + ); + + const configInfo = `Rows: ${rowCount.toLocaleString()}, Cols: ${columnCount.toLocaleString()}, A11y Layer: ${enableAccessibilityLayer}`; + + return ( +
+
+ {configInfo} +
+ +
+ ); +} + +export default App; diff --git a/tests/grid-perf-app/src/main.tsx b/tests/grid-perf-app/src/main.tsx new file mode 100644 index 000000000..b31fb6bc3 --- /dev/null +++ b/tests/grid-perf-app/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; + +createRoot(document.getElementById('root')!).render( + + + +); diff --git a/tests/grid-perf-app/tsconfig.json b/tests/grid-perf-app/tsconfig.json new file mode 100644 index 000000000..a4c834a6c --- /dev/null +++ b/tests/grid-perf-app/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/tests/grid-perf-app/vite.config.ts b/tests/grid-perf-app/vite.config.ts new file mode 100644 index 000000000..6263fb42f --- /dev/null +++ b/tests/grid-perf-app/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react-swc'; +import path from 'path'; + +const packagesDir = path.resolve(__dirname, '../../packages'); + +export default defineConfig({ + plugins: [react()], + server: { + port: 4020, + }, + preview: { + port: 4020, + }, + resolve: { + alias: { + // Resolve local packages to their source for development + '@deephaven/grid': path.resolve(packagesDir, 'grid/src'), + '@deephaven/log': path.resolve(packagesDir, 'log/src'), + '@deephaven/utils': path.resolve(packagesDir, 'utils/src'), + }, + }, +}); diff --git a/tests/grid-performance.spec.ts b/tests/grid-performance.spec.ts new file mode 100644 index 000000000..1080b88b5 --- /dev/null +++ b/tests/grid-performance.spec.ts @@ -0,0 +1,274 @@ +import { test, type Page } from '@playwright/test'; +import { gotoPage, openTable, waitForLoadingDone } from './utils'; + +/** + * Performance benchmark tests for the Grid component using the main app. + * Tests FPS during scrolling with real table data from a Deephaven server. + * + * These tests use existing tables from the test environment: + * - simple_table: Small table (100 rows, 2 columns) + * - all_types: Table with many column types + * + * For accessibility layer comparison tests (which require prop toggling), + * see grid-perf-app.spec.ts which uses a standalone test app. + */ + +interface FPSResult { + fps: number; + avgFrameTime: number; + minFrameTime: number; + maxFrameTime: number; + frameCount: number; + droppedFrames: number; +} + +/** + * Injects an FPS counter into the page that measures frame timings + */ +async function startFPSMeasurement(page: Page): Promise { + await page.evaluate(() => { + (window as any).__frameTimings = []; + (window as any).__fpsRunning = true; + let lastTime = performance.now(); + + function measureFrame() { + if (!(window as any).__fpsRunning) return; + + const now = performance.now(); + (window as any).__frameTimings.push(now - lastTime); + lastTime = now; + requestAnimationFrame(measureFrame); + } + requestAnimationFrame(measureFrame); + }); +} + +/** + * Stops FPS measurement and returns the results + */ +async function stopFPSMeasurement(page: Page): Promise { + const timings = await page.evaluate(() => { + (window as any).__fpsRunning = false; + return (window as any).__frameTimings as number[]; + }); + + // Filter out outliers (frames > 500ms are likely idle periods) + const validTimings = timings.filter(t => t < 500 && t > 0); + + if (validTimings.length === 0) { + return { + fps: 0, + avgFrameTime: 0, + minFrameTime: 0, + maxFrameTime: 0, + frameCount: 0, + droppedFrames: 0, + }; + } + + const avgFrameTime = + validTimings.reduce((a, b) => a + b, 0) / validTimings.length; + const fps = 1000 / avgFrameTime; + const minFrameTime = Math.min(...validTimings); + const maxFrameTime = Math.max(...validTimings); + // Frames taking > 33ms (less than 30fps) are considered "dropped" + const droppedFrames = validTimings.filter(t => t > 33).length; + + return { + fps, + avgFrameTime, + minFrameTime, + maxFrameTime, + frameCount: validTimings.length, + droppedFrames, + }; +} + +/** + * Scrolls the grid using mouse wheel events + */ +async function scrollGrid(page: Page, totalDelta: number): Promise { + // Use .last() to get the most recently opened grid if multiple exist + const grid = page.locator('.iris-grid-panel .iris-grid').last(); + const box = await grid.boundingBox(); + if (!box) throw new Error('Grid not found'); + + // Move mouse to center of grid + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + + // Scroll in increments + const scrollStep = 100; + const direction = Math.sign(totalDelta); + let remaining = Math.abs(totalDelta); + + while (remaining > 0) { + const step = Math.min(scrollStep, remaining); + await page.mouse.wheel(0, step * direction); + remaining -= step; + // Small delay to allow rendering + await page.waitForTimeout(16); + } +} + +function logResults( + testName: string, + result: FPSResult, + expected: { minFps: number } +): void { + console.log(`\n${testName}:`); + console.log(` Average FPS: ${result.fps.toFixed(1)}`); + console.log(` Avg frame time: ${result.avgFrameTime.toFixed(2)}ms`); + console.log( + ` Frame time range: ${result.minFrameTime.toFixed( + 2 + )}ms - ${result.maxFrameTime.toFixed(2)}ms` + ); + console.log(` Total frames: ${result.frameCount}`); + console.log( + ` Dropped frames (>33ms): ${result.droppedFrames} (${( + (result.droppedFrames / result.frameCount) * + 100 + ).toFixed(1)}%)` + ); + console.log(` Expected min FPS: ${expected.minFps}`); +} + +/** + * Performance tests are skipped by default as they can be flaky in CI due to + * resource constraints. To run these tests explicitly: + * + * RUN_PERF_TESTS=1 npx playwright test grid-performance.spec.ts + */ +test.describe('grid scroll performance benchmarks', () => { + // Skip by default - these tests are flaky in CI due to resource constraints + test.skip( + !process.env.RUN_PERF_TESTS, + 'Performance tests skipped. Set RUN_PERF_TESTS=1 to run.' + ); + + // Run tests serially to avoid resource contention + test.describe.configure({ mode: 'serial' }); + + test.beforeEach(async ({ page }) => { + await gotoPage(page, ''); + }); + + test.describe('simple_table performance', () => { + test.beforeEach(async ({ page }) => { + // simple_table is a 100-row table with 2 columns + await openTable(page, 'simple_table'); + await waitForLoadingDone(page); + }); + + test('scroll performance - simple_table', async ({ page }) => { + await startFPSMeasurement(page); + + // Scroll down and back up + await scrollGrid(page, 2000); + await scrollGrid(page, -2000); + await scrollGrid(page, 1500); + await scrollGrid(page, -1000); + + const result = await stopFPSMeasurement(page); + logResults('Simple Table Scroll', result, { minFps: 30 }); + }); + }); + + test.describe('all_types table performance', () => { + test.beforeEach(async ({ page }) => { + // all_types is a table with many different column types + await openTable(page, 'all_types'); + await waitForLoadingDone(page); + }); + + test('scroll performance - all_types', async ({ page }) => { + await startFPSMeasurement(page); + + // Scroll down significantly and back + await scrollGrid(page, 5000); + await scrollGrid(page, -3000); + await scrollGrid(page, 2000); + await scrollGrid(page, -4000); + + const result = await stopFPSMeasurement(page); + logResults('All Types Table Scroll', result, { minFps: 30 }); + }); + + test('rapid scroll performance', async ({ page }) => { + await startFPSMeasurement(page); + + // Rapid small scrolls (simulates fast mouse wheel) + for (let i = 0; i < 50; i += 1) { + await page.mouse.wheel(0, 200); + await page.waitForTimeout(8); // ~120fps input rate + } + + const result = await stopFPSMeasurement(page); + logResults('Rapid Scroll', result, { minFps: 24 }); + }); + }); +}); + +test.describe('grid performance stress tests', () => { + // Skip by default - these tests are flaky in CI due to resource constraints + test.skip( + !process.env.RUN_PERF_TESTS, + 'Performance tests skipped. Set RUN_PERF_TESTS=1 to run.' + ); + + // Run tests serially to avoid resource contention + test.describe.configure({ mode: 'serial' }); + + test.beforeEach(async ({ page }) => { + await gotoPage(page, ''); + }); + + test('sustained scrolling performance', async ({ page }) => { + await openTable(page, 'simple_table'); + await waitForLoadingDone(page); + + await startFPSMeasurement(page); + + // Sustained scrolling for 3 seconds + const startTime = Date.now(); + const duration = 3000; + let direction = 1; + + while (Date.now() - startTime < duration) { + await page.mouse.wheel(0, 300 * direction); + await page.waitForTimeout(16); + + // Reverse direction occasionally + if (Math.random() < 0.1) { + direction *= -1; + } + } + + const result = await stopFPSMeasurement(page); + logResults('Sustained Scroll (3s)', result, { minFps: 30 }); + }); + + test('horizontal and vertical scroll combined', async ({ page }) => { + await openTable(page, 'all_types'); + await waitForLoadingDone(page); + + const grid = page.locator('.iris-grid-panel .iris-grid').last(); + const box = await grid.boundingBox(); + if (!box) throw new Error('Grid not found'); + + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + + await startFPSMeasurement(page); + + // Combined horizontal and vertical scrolling + for (let i = 0; i < 20; i += 1) { + await page.mouse.wheel(500, 500); + await page.waitForTimeout(32); + await page.mouse.wheel(-300, 300); + await page.waitForTimeout(32); + } + + const result = await stopFPSMeasurement(page); + logResults('Combined H+V Scroll', result, { minFps: 28 }); + }); +}); diff --git a/tests/table-operations.spec.ts b/tests/table-operations.spec.ts index 8f3faf713..bf3dc49c1 100644 --- a/tests/table-operations.spec.ts +++ b/tests/table-operations.spec.ts @@ -484,14 +484,13 @@ test('rollup rows and aggregate columns', async ({ page }) => { await test.step('Rollup another column', async () => { const intColumn = page.getByRole('button', { name: 'Int', exact: true }); expect(intColumn).toBeTruthy(); - // Move mousee off the grid and ensure the hover state gets removed + // Move mouse off the grid and ensure the hover state gets removed // This is a workaround for React 18 Chrome e2e failing here with the row still hovered after clicking in the side panel await page.mouse.move(300, 0, { steps: 10 }); await intColumn.dblclick(); await waitForLoadingDone(page); await expect(page.locator('.iris-grid-column')).toHaveScreenshot(); - await page.pause(); }); await test.step('Rollup a double column', async () => { @@ -531,12 +530,9 @@ test('rollup rows and aggregate columns', async ({ page }) => { .getByRole('button', { name: 'Edit Columns', exact: true }) .click(); - const locator = page.getByText('Double', { exact: true }); - // Sometimes this becomes flaky presumably because of the animation - // or some React inner workings causing 2 elements to briefly exist. - // They are identical except their label ID assigned by the component we use. - // Waiting for only 1 to exist should hopefully fix flakiness. - await expect(locator).toHaveCount(1); + const locator = page + .locator('.aggregation-edit') + .getByText('Double', { exact: true }); await locator.click(); await waitForLoadingDone(page); await expect(page.locator('.iris-grid-column')).toHaveScreenshot();