From fc36e571283a3bfc4d7255296910a77e8870e71e Mon Sep 17 00:00:00 2001 From: Salvatore Previti Date: Tue, 18 Aug 2026 09:41:03 +0100 Subject: [PATCH 01/15] fix-behavioural-tests-speed (#14859) * fix-behavioural-tests-speed * fix-behavioural-tests-speed * fix-behavioural-tests-speed --- .claude/settings.json | 3 +- .github/workflows/ci.yml | 4 +- .rulesync/rules/ag-grid.md | 10 +- .rulesync/rules/benchmarks.md | 2 + .rulesync/rules/integrated-charts.md | 4 +- .rulesync/rules/testing.md | 45 +- .rulesync/skills/technology-stack/SKILL.md | 2 +- .rulesync/skills/testing/SKILL.md | 10 +- AGENTS.md | 10 +- behave.sh | 130 +- benches.sh | 153 +- checks.sh | 154 +- community-modules/locale/vitest.config.ts | 2 +- docs-e2e.sh | 121 +- .../snippetTransformer.test.ts.snap | 472 - documentation/ag-grid-docs/vitest.config.ts | 1 + .../ag-grid-docs/vitest.e2e.config.ts | 1 + .../ag-shared/.claude-settings.template.json | 6 +- external/ag-website-shared/vitest.config.ts | 1 + package.json | 13 +- packages/ag-grid-community/.npmignore | 1 - .../columnDrag/moveColumnFeature.ts | 5 +- .../src/columns/columnViewportService.ts | 4 +- .../filter/provided/date/dateCompWrapper.ts | 7 +- .../gridBodyComp/viewportSizeFeature.test.ts | 26 +- .../src/rendering/overlays/overlayService.ts | 10 +- .../validation/enableDevValidations.test.ts | 20 +- .../src/validation/logging.test.ts | 23 +- packages/ag-grid-community/vitest.config.ts | 4 +- .../ag-grid-community/vitest.umd.config.ts | 1 + packages/ag-grid-enterprise/.npmignore | 1 - packages/ag-grid-enterprise/eslint.config.mjs | 1 - .../src/agStack/agMenuItemComponent.ts | 22 +- .../src/excelExport/excelXlsxFactory.test.ts | 19 +- .../src/license/shared/licenseManager.test.ts | 9 + .../src/widgets/agRichSelect.test.ts | 2 +- packages/ag-grid-enterprise/vitest.config.ts | 4 +- .../ag-grid-enterprise/vitest.umd.config.ts | 1 + packages/ag-grid-vue3/.snyk | 5 - packages/ag-grid-vue3/osv-scanner.toml | 12 - packages/ag-grid-vue3/package.json | 2 - packages/ag-grid-vue3/tsconfig.vitest.json | 4 +- packages/ag-grid-vue3/vitest.config.ts | 15 - packages/ag-stack/.npmignore | 2 - packages/ag-stack/eslint.config.mjs | 1 - .../src/core/baseAriaAnnouncementService.ts | 11 +- packages/ag-stack/src/fastTestTimings.test.ts | 7 + packages/ag-stack/src/fastTestTimings.ts | 7 + packages/ag-stack/src/main-internal.ts | 1 + packages/ag-stack/src/theming/inject.test.ts | 9 +- packages/ag-stack/src/theming/partImpl.ts | 5 +- .../src/tooltip/baseTooltipStateManager.ts | 5 +- packages/ag-stack/src/utils/dom.ts | 2 +- packages/ag-stack/vitest.config.ts | 4 +- packages/ag-stack/vitest.umd.config.ts | 9 - .../vitest.config.ts | 1 + scripts/gate/args.mjs | 151 + scripts/gate/gates/behave.mjs | 133 + scripts/gate/gates/bench.mjs | 196 + scripts/gate/gates/checks.mjs | 225 + scripts/gate/gates/docs-e2e.mjs | 95 + scripts/gate/main.mjs | 122 + scripts/gate/run-log.mjs | 596 ++ testing/ag-test-utils/eslint.config.mjs | 25 + testing/ag-test-utils/package.json | 11 + testing/ag-test-utils/project.json | 15 + .../src}/cachedJSONObjects.ts | 0 .../src}/dev-validations.ts | 0 .../src}/drag-n-drop/drag-event-dispatcher.ts | 10 +- .../src}/drag-n-drop/drag-n-drop-utils.ts | 0 .../src}/drag-n-drop/row-drag-dispatcher.ts | 85 +- testing/ag-test-utils/src/fastTestTimings.ts | 7 + .../filters/advancedFilterBuilderHarness.ts | 8 +- .../src}/filters/advancedFilterHarness.ts | 0 .../src}/filters/columnFilterHarness.ts | 0 .../src}/filters/filterDom.ts | 0 .../src}/filters/filterDomSerialize.ts | 0 .../src}/filters/filterDomValidator.ts | 6 + .../src}/filters/floatingFilterHarness.ts | 0 .../src}/filters/index.ts | 0 .../src}/grid-test-utils.ts | 11 +- .../gridColumns/columns-diagram/formatting.ts | 0 .../columns-diagram/gridColumnsDiagramTree.ts | 0 .../gridColumnsDomValidator.ts | 0 .../columns-validation/gridColumnErrors.ts | 0 .../columns-validation/gridColumnsErrors.ts | 0 .../gridColumnsValidator.ts | 33 +- .../src}/gridColumns/gridColumns.ts | 4 +- .../src}/gridColumns/gridColumnsOptions.ts | 0 .../src}/gridRows/grid-rows-helpers.ts | 34 +- .../src}/gridRows/gridHtmlRows.ts | 81 +- .../src}/gridRows/gridRows.ts | 9 +- .../src}/gridRows/gridRowsOptions.ts | 4 +- .../src}/gridRows/rows-diagram/formatting.ts | 22 +- .../rows-diagram/gridRowsDiagramNode.ts | 0 .../rows-diagram/gridRowsDiagramTree.ts | 0 .../src}/gridRows/rows-diagram/nodeInfo.ts | 0 .../rows-validation-dom/cell-helpers.ts | 0 .../rows-validation-dom/containers-helpers.ts | 19 +- .../dom-validation-helpers.ts | 48 +- .../gridRowDomCellValidator.ts | 17 +- .../gridRowsDomValidator.ts | 25 +- .../rows-validation-dom/rowAriaValidation.ts | 5 +- .../rows-validation-dom/rowClassValidation.ts | 0 .../rowSelectionValidation.ts | 0 .../src}/gridRows/rows-validation/bugs.ts | 0 .../gridRows/rows-validation/gridRowErrors.ts | 0 .../rows-validation/gridRowsErrors.ts | 0 .../gridRowsValidationState.ts | 0 .../rows-validation/gridRowsValidator.ts | 0 .../rows-validation/validator-computed.ts | 0 .../rows-validation/validator-leafs.ts | 33 +- .../src}/gridRows/snapshot-updater.ts | 81 +- testing/ag-test-utils/src/ignoreKnownNoise.ts | 32 + .../test-utils => ag-test-utils/src}/index.ts | 3 +- .../src}/menu-test-utils.ts | 7 +- .../src}/node-utils.ts | 8 - .../src}/patchBeansToJson.ts | 0 .../src/polyfills/canvasPolyfill.ts | 194 + .../src}/polyfills/clipboard.ts | 0 .../ag-test-utils/src/polyfills/domGlobals.ts | 29 + .../src/polyfills/filterLayoutMock.ts | 62 + .../src/polyfills/mockGridLayout.ts | 512 ++ .../src}/polyfills/objectUrls.ts | 71 +- .../src}/polyfills/pointerEvent.ts | 13 +- .../src/polyfills/virtualListViewports.ts | 14 + .../test-utils => ag-test-utils/src}/prng.ts | 0 .../src}/rows-snapshot.ts | 0 .../src}/ssrm-test-utils.ts | 0 .../src}/string-utils.ts | 0 .../src}/test-utils-assertions.ts | 65 +- .../src}/test-utils-edit.ts | 17 +- .../src}/test-utils-events.ts | 10 + .../src}/testGridsManager.ts | 19 +- .../test-utils => ag-test-utils/src}/utils.ts | 0 .../src}/widgets/dropdowns.ts | 2 +- .../src}/widgets/inputs.ts | 0 testing/ag-test-utils/src/widgets/tooltips.ts | 25 + testing/ag-test-utils/tsconfig.json | 14 + testing/ag-test-utils/typings.d.ts | 4 + testing/behavioural/README.md | 4 +- testing/behavioural/eslint.config.mjs | 19 +- testing/behavioural/package.json | 5 +- .../src/ai-toolkit/structured-schema.test.ts | 4 +- .../src/benchmarks/bench-compare.mjs | 14 +- .../behavioural/src/benchmarks/bench-data.ts | 4 +- .../behavioural/src/benchmarks/bench-utils.ts | 24 +- .../calculated-live-preview.bench.ts | 6 +- .../src/benchmarks/cell-editing-bulk.bench.ts | 2 +- .../benchmarks/cell-render-react.bench.tsx | 2 +- .../bigint-value-parser-formatter.test.ts | 3 +- .../cell-editing-batch-aggregation.test.ts | 4 +- .../cell-editing-batch-clear.test.ts | 10 +- .../cell-editing-batch-value.test.ts | 4 +- .../cell-editing/cell-editing-batch.test.ts | 4 +- .../cell-editing-change-flash.test.ts | 40 +- .../cell-editing-edge-cases.test.ts | 9 +- ...cell-editing-full-row-batch-styles.test.ts | 10 +- .../cell-editing-full-row-batch.test.ts | 12 +- ...l-editing-full-row-virtualization.test.tsx | 25 +- .../cell-editing-get-edit-row-values.test.ts | 3 +- .../cell-editing/cell-editing-purge.test.ts | 3 +- .../cell-editing-regression.test.ts | 35 +- ...l-editing-single-cell-batch-styles.test.ts | 10 +- .../cell-editing-tab-editor-react.test.tsx | 82 +- .../cell-editing-validation-batch.test.ts | 17 +- .../cell-editing-validation-editors.test.ts | 5 +- ...ng-validation-filter-change-react.test.tsx | 32 +- ...l-editing-validation-filter-change.test.ts | 3 +- ...cell-editing-validation-navigation.test.ts | 3 +- .../cell-editing-validation-react.test.tsx | 4 +- ...cell-editing-validation-stop-paths.test.ts | 19 +- .../cell-editing-validation.test.ts | 4 +- .../cell-editing-value-parser-cache.test.ts | 3 +- .../src/cell-editing/cell-editing.test.ts | 26 +- .../cell-flash-unmounted-cell-react.test.tsx | 4 +- .../clipboard/clipboard-copy-batch.test.ts | 3 +- .../clipboard/clipboard-fill-handle.test.ts | 26 +- .../clipboard/clipboard-paste-range.test.ts | 10 +- .../clipboard/clipboard-paste.test.ts | 9 +- .../examples/batch-editing-doc.test.ts | 4 +- .../cell-editing-start-stop-doc.test.ts | 4 +- .../examples/full-row-editing-doc.test.ts | 6 +- .../distribute-aggfunc.test.ts | 3 +- .../distribute-default.test.ts | 3 +- .../distribute-edge-cases.test.ts | 3 +- .../distribute-hierarchy.test.ts | 2 +- .../distribute-modes.test.ts | 3 +- .../distribute-options.test.ts | 3 +- .../distribute-test-utils.ts | 5 +- .../enable-group-edit-pivot.test.ts | 2 +- .../group-edit-clipboard-paste.test.ts | 11 +- .../group-edit/group-edit-test-utils.ts | 5 +- ...itable-aggregation-changed-columns.test.ts | 3 +- .../group-row-editable-aggregation.test.ts | 3 +- .../group-row-editable-pinned-pivot.test.ts | 5 +- .../group-row-editable-pinned-sibling.test.ts | 2 +- .../group-row-editable-pivot.test.ts | 4 +- .../group-edit/group-row-editable.test.ts | 2 +- .../refresh-after-group-edit.test.ts | 3 +- .../rich-select/rich-select-aria.test.ts | 5 +- .../rich-select/rich-select-editor.test.ts | 9 +- .../rich-select-scroll-picker.test.ts | 5 +- .../cell-editing/select/select-editor.test.ts | 5 +- .../set-value/cell-editing-bulk-edit.test.ts | 47 +- .../cell-editing-delete-range.test.ts | 14 +- .../cell-editing-set-data-value.test.ts | 7 +- ...editing-setdatavalue-batch-editors.test.ts | 4 +- ...-editing-setdatavalue-batch-events.test.ts | 17 +- ...editing-setdatavalue-batch-sources.test.ts | 4 +- .../set-value/cell-editing-undo-redo.test.ts | 10 +- .../set-value/cell-editor-value-types.test.ts | 3 +- .../src/charts/chart-range-handle.test.ts | 3 +- .../format-panel-options-column-bar.test.ts | 38 + .../format-panel-options-combo-funnel.test.ts | 37 + .../format-panel-options-line-area.test.ts | 29 + .../charts/format-panel-options-polar.test.ts | 29 + .../format-panel-options-statistical.test.ts | 39 + ...-options.test.ts => formatPanelOptions.ts} | 352 +- .../charts/hidden-columns-in-charts.test.ts | 3 +- .../editable-header-name-react.test.tsx | 3 - ...itable-header-name-tool-panel-drag.test.ts | 5 +- ...e-header-name-tooltip-and-grouping.test.ts | 17 +- .../editable-header-name.test.ts | 3 +- .../column-chooser-test-ids.test.ts | 5 +- .../column-selection-label-renderer.test.ts | 3 +- .../custom-layout-preservation.test.ts | 3 +- .../deferred-pivot-mode-apply-button.test.ts | 426 + .../deferred-pivot-mode-pills.test.ts | 443 + .../deferred-pivot-mode-staging.test.ts | 590 ++ .../deferred-pivot-mode-toggle.test.ts | 399 + .../deferred-pivot-mode.test.ts | 2262 ----- .../deferred-suppress-sync-layout.test.ts | 3 +- .../deferredPivotModeHarness.ts | 537 ++ .../columnToolPanel/drop-zone-aria.test.ts | 3 +- .../tool-panel-context-menu.test.ts | 7 +- .../columns/auto-generate-column-defs.test.ts | 4 +- .../autosize-strategy-ui-actions.test.ts | 5 +- .../cell-data-type-date-inference.test.ts | 3 +- .../cell-data-type-object-validation.test.ts | 3 +- .../src/columns/cols-service-events.test.ts | 4 +- .../src/columns/column-api-extended.test.ts | 4 +- .../src/columns/column-api.test.ts | 4 +- .../src/columns/column-autosize.test.ts | 82 +- .../columns/column-custom-renderers.test.ts | 18 +- .../column-delay-render-react.test.tsx | 7 +- .../src/columns/column-delay-render.test.ts | 6 +- .../src/columns/column-destruction.test.ts | 4 +- .../src/columns/column-edge-cases.test.ts | 4 +- .../src/columns/column-features.test.ts | 17 +- .../src/columns/column-flex.test.ts | 3 +- .../src/columns/column-groups.test.ts | 3 +- .../src/columns/column-interactions.test.ts | 3 +- .../src/columns/column-lookup.test.ts | 3 +- .../columns/column-model-rewrite-p2.test.ts | 4 +- .../src/columns/column-model.test.ts | 4 +- .../apply-column-state.test.ts | 4 +- .../column-mutations/column-identity.test.ts | 3 +- .../mutations-and-transitions.test.ts | 4 +- .../order-restoration.test.ts | 4 +- .../services-and-trees.test.ts | 4 +- .../column-mutations/setColumnDefs.test.ts | 17 +- ...olumn-prototype-key-ids-enterprise.test.ts | 3 +- .../columns/column-prototype-key-ids.test.ts | 3 +- ...mn-span-keyboard-navigation-react.test.tsx | 2 - .../column-span-keyboard-navigation.test.ts | 5 +- .../src/columns/column-span-rendering.test.ts | 4 +- .../columns/grid-columns-framework.test.ts | 4 +- .../column-header-button-toggle.test.ts | 3 +- .../headers/columns-header-template.test.ts | 4 +- .../columns/headers/columns-header.test.tsx | 6 +- .../headers/context-menu-focus.test.tsx | 15 +- .../src/columns/headers/context-menu.test.tsx | 8 +- ...loating-filter-scroll-focus-react.test.tsx | 7 +- .../src/columns/horizontal-scroll-gap.test.ts | 5 +- .../src/columns/order/auto-group-cols.test.ts | 2 +- .../columns/order/column-move-drag.test.ts | 19 +- .../src/columns/order/column-order.test.ts | 3 +- .../src/columns/order/lock-position.test.ts | 2 +- .../src/columns/order/pivot.test.ts | 3 +- .../columns/pinned-columns-overflow.test.ts | 5 +- .../src/columns/row-span-aria-react.test.tsx | 3 - .../src/columns/row-span-aria.test.ts | 4 +- .../row-span-keyboard-navigation.test.ts | 3 +- .../row-span-rowdata-update-react.test.tsx | 3 - .../columns/row-span-rowdata-update.test.ts | 4 +- .../behavioural/src/columns/row-span.test.ts | 41 +- .../csrm-stages/csrm-stage-dispatch.test.ts | 4 +- .../managed-row-group-drag-edit-basic.test.ts | 17 +- ...aged-row-group-drag-edit-selection.test.ts | 23 +- ...aged-row-group-drag-edit-structure.test.ts | 17 +- ...ged-row-group-drag-no-edit-modules.test.ts | 4 +- .../grouping/row-drag-group-expand.test.ts | 10 +- .../row-drag-state-persistence.test.ts | 3 +- .../src/drag-n-drop/is-row-drag.test.ts | 10 +- .../managed-drag-n-drop-selection.test.ts | 8 +- .../drag-n-drop/managed-drag-n-drop.test.ts | 10 +- .../row-drop-highlight-api.test.ts | 4 +- .../tree-data-managed-row-drag-basic.test.ts | 11 +- .../tree-data-managed-row-drag-multi.test.ts | 11 +- ...e-data-managed-row-drag-validation.test.ts | 11 +- .../unmanaged-pagination-drag-n-drop.test.ts | 4 +- .../export/csv-export-header-layout.test.ts | 4 +- .../export/excel-export-header-layout.test.ts | 5 +- .../behavioural/src/export/pdf-export.test.ts | 4 +- .../behavioural/src/fastTestTimings.test.ts | 8 + .../advanced-filter-header-dom.test.ts | 9 +- .../af-autocomplete.test.ts | 12 +- .../af-builder-drag.test.ts | 10 +- .../af-builder-keyboard-nav.test.ts | 11 +- .../af-builder.test.ts | 22 +- .../af-column-reuse.test.ts | 10 +- .../af-dates.test.ts | 4 +- .../af-model.test.ts | 4 +- .../af-options.test.ts | 18 +- .../af-parser.test.ts | 4 +- .../af-validation.test.ts | 22 +- .../advanced-filter-row-position.test.tsx | 5 +- .../advanced-filter-autocomplete.test.ts | 6 +- ...vanced-filter-bigint-custom-parser.test.ts | 10 +- .../advanced-filter-builder.test.ts | 6 +- ...vanced-filter-column-filter-parity.test.ts | 4 +- .../advanced-filter-expression.test.ts | 4 +- .../advanced-filter-model-api.test.ts | 4 +- .../src/filters/aggregate-filters.test.ts | 4 +- .../bigint-filter-range-validation.test.ts | 8 +- ...loating-filter-model-change-react.test.tsx | 3 +- .../date-filter-range-validation.test.ts | 21 +- .../src/filters/date-filter.test.ts | 3 +- .../bigint-filter-custom-parser.test.ts | 8 +- .../date-filter-conditions.test.ts | 8 +- .../filter-feature-interaction.test.ts | 18 +- .../filter-manager-api.test.ts | 20 +- .../filter-state-and-events.test.ts | 18 +- .../filters-tool-panel-editing.test.ts | 19 +- .../filters-tool-panel.test.ts | 9 +- .../floating-filter-editing.test.ts | 23 +- .../filter-behaviour/group-filter.test.ts | 22 +- .../filter-behaviour/multi-filter.test.ts | 10 +- .../number-filter-conditions.test.ts | 8 +- .../quick-filter-extended.test.ts | 22 +- .../set-filter-api-advanced.test.ts | 35 +- .../set-filter-tree-excel.test.ts | 10 +- .../set-filter-values.test.ts | 102 +- ...ilter-conditions-buttons-and-model.test.ts | 8 +- .../text-filter-conditions.test.ts | 8 +- .../filter-behaviour/toolPanelHarness.ts | 9 +- .../filters/filter-option-switching.test.ts | 21 +- .../filters/filter-options-validation.test.ts | 21 +- .../src/filters/filter-value-getter.test.ts | 3 +- .../src/filters/floating-filters.test.ts | 3 +- .../multi-filter-floating-filter.test.ts | 3 +- .../multi-filter-set-filter-refresh.test.ts | 5 +- .../new-filters-tool-panel-handlers.test.ts | 3 +- .../number-filter-range-validation.test.ts | 22 +- .../filters/pivot-value-column-filter.test.ts | 4 +- .../src/filters/quick-filter.test.ts | 4 +- .../filters/set-filter-async-destroy.test.ts | 4 +- .../set-filter-complex-objects.test.ts | 4 +- .../set-filter-empty-before-data.test.ts | 4 +- .../src/find/find-batch-values.test.ts | 3 +- .../src/find/find-cell-info.test.ts | 4 +- .../src/find/find-data-mutations.test.ts | 3 +- .../src/find/find-navigation.test.ts | 4 +- .../behavioural/src/find/find-options.test.ts | 3 +- .../src/find/find-value-formatting.test.ts | 3 +- .../calculated-columns-column-order.test.ts | 546 ++ ...alculated-columns-dialog-anchoring.test.ts | 380 + .../calculated-columns-dialog.test.ts | 470 + .../calculated-columns-errors.test.ts | 228 + .../calculated-columns-evaluation.test.ts | 878 ++ .../calculated-columns-events.test.ts | 417 + .../calculated-columns-grouping.test.ts | 662 ++ .../calculated-columns-highlighting.test.ts | 437 + .../formulas/calculated-columns-menu.test.ts | 359 + .../calculated-columns-ordering.test.ts | 3 +- .../formulas/calculated-columns-pivot.test.ts | 3 +- .../calculated-columns-state-declared.test.ts | 600 ++ ...calculated-columns-state-no-module.test.ts | 75 + ...lated-columns-state-parity-grouped.test.ts | 10 + ...ted-columns-state-parity-top-level.test.ts | 9 + .../calculated-columns-state-resets.test.ts | 351 + ...alculated-columns-state-round-trip.test.ts | 787 ++ .../formulas/calculated-columns-state.test.ts | 2138 ----- .../src/formulas/calculated-columns.test.ts | 4482 --------- .../src/formulas/calculatedColumnsHarness.ts | 296 + .../formulas/calculatedColumnsStateHarness.ts | 359 + .../src/formulas/formulas-cache.test.ts | 3 +- .../src/formulas/formulas-edge-cases.test.ts | 13 +- .../formulas/formulas-excel-export.test.ts | 5 +- .../src/formulas/formulas-filtering.test.ts | 3 +- .../src/formulas/formulas-functions.test.ts | 3 +- .../src/formulas/formulas-interactive.test.ts | 17 +- .../formulas/formulas-module-interop.test.ts | 3 +- .../src/formulas/formulas-row-pinning.test.ts | 4 +- .../src/formulas/formulas-sorting.test.ts | 4 +- .../behavioural/src/formulas/formulas.test.ts | 3 +- .../grid-api-object/grid-api-object.test.tsx | 3 +- .../anonymous-column-header-name.test.ts | 3 +- .../src/grid-state/grid-state-full.test.ts | 2 +- .../src/grid-state/grid-state.test.ts | 3 +- .../src/grid-state/value-column-order.test.ts | 3 +- ...ple-columns-hide-open-parents.test.ts.snap | 811 ++ .../exported-multiple-columns.test.ts.snap | 1054 +++ .../exported-single-column.test.ts.snap | 1054 +++ .../csrm/__snapshots__/exported.test.ts.snap | 3619 -------- .../exported/csrm/exported-group-rows.test.ts | 5 +- ...multiple-columns-hide-open-parents.test.ts | 39 + .../csrm/exported-multiple-columns.test.ts | 39 + .../exported-single-column.test.ts} | 9 +- ...ple-columns-hide-open-parents.test.ts.snap | 973 ++ .../exported-multiple-columns.test.ts.snap | 1324 +++ .../exported-single-column.test.ts.snap | 1324 +++ .../ssrm/__snapshots__/exported.test.ts.snap | 2917 ------ .../exported/ssrm/exported-group-rows.test.ts | 5 +- ...multiple-columns-hide-open-parents.test.ts | 39 + .../ssrm/exported-multiple-columns.test.ts | 39 + .../exported-single-column.test.ts} | 9 +- .../find/csrm/find-checkboxes.test.ts | 3 +- .../find/csrm/find-master-detail.test.ts | 3 +- .../grouping/find/csrm/find-pivot.test.ts | 3 +- .../grouping/find/csrm/find-renderer.test.ts | 3 +- .../find/csrm/find-suppress-count.test.ts | 3 +- ...ind-values-auto-col-inner-renderer.test.ts | 3 +- .../find-values-col-inner-renderer.test.ts | 3 +- .../csrm/find-values-default-renderer.test.ts | 3 +- .../groups/csrm/grouping-checkboxes.test.ts | 3 +- .../csrm/grouping-master-detail.test.ts | 3 +- .../groups/csrm/grouping-pivot.test.ts | 3 +- .../groups/csrm/grouping-renderer.test.ts | 3 +- .../csrm/grouping-suppress-count.test.ts | 3 +- ...ing-values-auto-col-inner-renderer.test.ts | 3 +- .../grouping-values-bigint-formatter.test.ts | 4 +- ...grouping-values-col-inner-renderer.test.ts | 3 +- .../grouping-values-default-renderer.test.ts | 3 +- .../groups/ssrm/grouping-checkboxes.test.ts | 3 +- .../ssrm/grouping-master-detail.test.ts | 3 +- .../groups/ssrm/grouping-pivot.test.ts | 3 +- .../groups/ssrm/grouping-renderer.test.ts | 3 +- .../ssrm/grouping-suppress-count.test.ts | 3 +- ...ing-values-auto-col-inner-renderer.test.ts | 3 +- ...grouping-values-col-inner-renderer.test.ts | 3 +- .../grouping-values-default-renderer.test.ts | 3 +- .../csrm/tree-data-checkboxes.test.ts | 3 +- .../csrm/tree-data-master-detail.test.ts | 3 +- .../tree-data/csrm/tree-data-renderer.test.ts | 3 +- .../tree-data-values-default-renderer.test.ts | 3 +- .../tree-data-values-inner-renderer.test.ts | 3 +- .../ssrm/tree-data-checkboxes.test.ts | 3 +- .../ssrm/tree-data-master-detail.test.ts | 3 +- .../tree-data/ssrm/tree-data-renderer.test.ts | 3 +- .../tree-data-values-default-renderer.test.ts | 3 +- .../tree-data-values-inner-renderer.test.ts | 3 +- .../src/group-cell-renderer/util.ts | 17 +- .../grand-total-row-setstate.test.ts | 3 +- .../clear-sort.test.ts | 4 +- .../delta-sort.test.ts | 4 +- .../filter-cycles.test.ts | 4 +- .../post-sort-rows.test.ts | 4 +- .../sort-isolation.test.ts | 4 +- .../sort-modes.test.ts | 4 +- .../transactions.test.ts | 4 +- ...grouped-pinned-sibling-aggregation.test.ts | 4 +- .../grouping-aggregation-call-count.test.ts | 4 +- ...ouping-aggregation-changed-columns.test.ts | 8 +- ...-aggregation-custom-object-display.test.ts | 4 +- .../grouping-aggregation-options.test.ts | 4 +- .../grouping-aggregation-ratio.test.ts | 4 +- .../grouping-aggregation.test.ts | 4 +- .../grouping-aria-grid-role.test.ts | 4 +- .../grouping-change-column.test.ts | 4 +- .../grouping-column-visibility-events.test.ts | 4 +- .../grouping-complex-transactions.test.ts | 4 +- .../grouping-data/grouping-csv-export.test.ts | 4 +- .../grouping-custom-cell-data-types.test.ts | 3 +- .../grouping-display-types.test.ts | 4 +- .../grouping-data/grouping-edge-cases.test.ts | 7 +- .../grouping-expanded-state.test.ts | 11 +- .../grouping-filter-aggregates-stage.test.ts | 4 +- .../grouping-filter-aggregation.test.ts | 20 +- .../grouping-get-aggregated-children.test.ts | 6 +- .../grouping-initial-group-order.test.ts | 10 +- .../grouping-leafs-order.test.ts | 4 +- .../grouping-pinned-rows.test.ts | 4 +- .../grouping-data/grouping-selection.test.ts | 6 +- ...rouping-show-columns-when-expanded.test.ts | 10 +- ...grouping-show-values-as-batch-edit.test.ts | 4 +- .../grouping-show-values-as-clipboard.test.ts | 3 +- .../grouping-show-values-as-display.test.ts | 4 +- .../grouping-show-values-as-export.test.ts | 5 +- .../grouping-show-values-as-find.test.ts | 3 +- ...grouping-show-values-as-guardrails.test.ts | 4 +- .../grouping-show-values-as-indicator.test.ts | 3 +- .../grouping-show-values-as-menu.test.ts | 3 +- .../grouping-show-values-as-modes.test.ts | 4 +- ...grouping-show-values-as-validation.test.ts | 3 +- .../grouping-show-values-as.test.ts | 5 +- .../grouping-simple-data.test.ts | 13 +- .../grouping-sorting-delta-sorting.test.ts | 4 +- .../grouping-data/grouping-sorting.test.ts | 4 +- .../grouping-sticky-collapse.test.ts | 4 +- .../grouping-with-immutable-row-data.test.ts | 4 +- .../grouping-with-master-detail.test.ts | 4 +- .../grouping-data/grouping-with-pivot.test.ts | 117 +- .../grouping-with-transactions.test.ts | 10 +- .../pivot-column-defs-update.test.ts | 4 +- .../pivot-group-hierarchy.test.ts | 10 +- .../pivot-leaf-group-expansion.test.ts | 3 +- .../pivot-static-pinned-rows.test.ts | 6 +- .../ssrm/ssrm-aria-react.test.tsx | 3 - .../src/grouping-data/ssrm/ssrm-aria.test.ts | 5 +- .../ssrm/ssrm-failure-grouped.test.ts | 10 +- .../ssrm/ssrm-get-child-count.test.ts | 6 +- .../ssrm/ssrm-grand-total.test.ts | 33 +- .../ssrm-grouped-initial-row-count.test.ts | 5 +- .../ssrm/ssrm-grouped-loading.test.ts | 6 +- .../grouping-data/ssrm/ssrm-grouping.test.ts | 4 +- .../ssrm/ssrm-master-detail.test.ts | 4 +- .../ssrm/ssrm-pivot-grand-total.test.ts | 3 +- .../ssrm/ssrm-pivot-operations.test.ts | 6 +- .../ssrm/ssrm-pivot-sort-expand.test.ts | 4 +- .../src/grouping-data/ssrm/ssrm-pivot.test.ts | 3 +- .../ssrm/ssrm-selection-state.test.ts | 5 +- .../ssrm/ssrm-sort-filter-scope.test.ts | 6 +- .../ssrm/ssrm-sticky-collapse.test.ts | 4 +- .../ssrm-suppress-loading-group-rows.test.ts | 3 +- .../sticky-rows-viewport-budget.test.ts | 7 +- ...ss-group-changes-column-visibility.test.ts | 9 +- .../master-detail-open-by-default.test.ts | 4 +- .../src/master-detail/master-detail.test.ts | 3 +- .../src/menu/column-menu-items.test.ts | 7 +- .../arrow-key-cell-navigation-react.test.tsx | 2 - .../arrow-key-cell-navigation.test.ts | 3 +- .../navigation/cell-span-navigation.test.ts | 3 +- .../navigation/dialog-tab-navigation.test.ts | 4 +- .../navigation/enter-key-navigation.test.ts | 3 +- .../header-row-tab-index-overlay.test.ts | 4 +- .../navigation-react-test-utils.tsx | 3 +- .../navigation/page-key-navigation.test.ts | 7 +- .../pinned-column-navigation-react.test.tsx | 2 - .../pinned-column-navigation.test.ts | 3 +- .../navigation/pinned-row-navigation.test.ts | 3 +- .../row-grouping-navigation.test.ts | 3 +- .../suppress-header-focus-tab.test.ts | 4 +- .../suppress-navigable-navigation.test.ts | 3 +- .../tab-cell-navigation-react.test.tsx | 2 - .../navigation/tab-cell-navigation.test.ts | 3 +- .../src/overlays/overlays-export.test.ts | 8 +- .../src/overlays/overlays-file-input.test.ts | 23 +- .../overlays-infinite-scrolling.test.ts | 3 +- .../src/overlays/overlays-infinite.test.ts | 4 +- .../overlays/overlays-provided-params.test.ts | 3 +- .../overlays/overlays-react-unmount.test.tsx | 1 + .../src/overlays/overlays-ssr.test.ts | 4 +- .../src/overlays/overlays-state-basic.test.ts | 9 +- ...lays-state-custom-overlayComponent.test.ts | 3 +- .../overlays/overlays-state-custom.test.ts | 4 +- .../overlays-state-no-matching-rows.test.ts | 3 +- .../overlays/overlays-state-react.test.tsx | 3 +- .../overlays-validation-attribution.test.ts | 4 +- ...rlays-validation-bare-registration.test.ts | 3 +- .../overlays-validation-react.test.tsx | 3 +- .../src/overlays/overlays-validation.test.ts | 21 +- .../pagination/pagination-core-api.test.ts | 4 +- .../pagination-data-changes.test.ts | 4 +- .../pagination/pagination-edge-cases.test.ts | 4 +- .../src/pagination/pagination-events.test.ts | 4 +- .../pagination/pagination-grouping.test.ts | 4 +- .../pagination-page-numbers.test.ts | 5 +- .../pagination-page-size-aria.test.ts | 3 +- .../pagination/pagination-page-size.test.ts | 4 +- .../pagination-page-summary.test.ts | 3 +- .../src/pagination/pagination-panels.test.ts | 9 +- .../bulk-add-cell-flicker-react.test.tsx | 4 +- .../row-data/pinned-row-data-react.test.tsx | 4 +- .../src/row-data/row-data-order.test.ts | 16 +- .../behavioural/src/row-data/row-data.test.ts | 21 +- .../src/row-data/row-node-expanded.test.ts | 4 +- .../row-data/row-node-get-data-value.test.ts | 3 +- ...server-side-row-model-transactions.test.ts | 6 +- .../row-data/ssrm-async-transactions.test.ts | 4 +- .../src/row-data/ssrm-block-loading.test.ts | 3 +- .../src/row-data/ssrm-cache-config.test.ts | 38 +- .../src/row-data/ssrm-edit-retention.test.ts | 6 +- .../src/row-data/ssrm-failure-retry.test.ts | 8 +- .../src/row-data/ssrm-filter-handlers.test.ts | 6 +- .../row-data/ssrm-flat-sort-filter.test.ts | 6 +- .../row-data/ssrm-initial-row-count.test.ts | 5 +- .../src/row-data/ssrm-node-id-prefix.test.ts | 6 +- .../row-data/ssrm-pivot-expand-reset.test.ts | 3 +- .../src/row-data/ssrm-refresh-purge.test.ts | 6 +- .../row-data/ssrm-reset-row-heights.test.ts | 6 +- .../src/row-data/ssrm-selection.test.ts | 4 +- .../src/row-data/ssrm-set-row-data.test.ts | 4 +- .../row-data/ssrm-sync-transactions.test.ts | 6 +- .../src/rows/ensure-dom-order.test.ts | 4 +- .../src/rows/manual-pinned-rows.test.ts | 3 +- .../src/rows/static-pinned-rows.test.ts | 28 +- .../src/rows/viewport-row-model.test.ts | 7 +- .../viewport-stale-index-leak-react.test.tsx | 6 +- .../rows/viewport-stale-index-leak.test.ts | 4 +- .../selection/cell-selection-grid-api.test.ts | 44 +- .../cell-selection-legacy-grid-api.test.ts | 21 +- .../src/selection/cell-selection-raf.test.ts | 38 +- .../src/selection/cell-selection.test.ts | 354 +- .../src/selection/daemon-selection.test.ts | 3 +- .../master-detail-row-selection.test.ts | 57 +- .../selection/row-numbers-selection.test.ts | 503 +- .../selection/row-selection-checkbox.test.ts | 751 ++ .../selection/row-selection-grid-api.test.ts | 328 +- .../row-selection-group-range.test.ts | 1377 +++ .../src/selection/row-selection-group.test.ts | 1509 +++ .../row-selection-header-checkbox.test.ts | 244 + .../selection/row-selection-keyboard.test.ts | 227 +- .../row-selection-legacy-grid-api.test.ts | 152 +- .../row-selection-legacy-options.test.ts | 826 +- .../row-selection-model-updates.test.ts | 347 + .../selection/row-selection-multiple.test.ts | 999 ++ .../selection/row-selection-single.test.ts | 354 + .../src/selection/row-selection.test.ts | 8117 ----------------- .../src/selection/rowSelectionHarness.ts | 82 + .../selection-column-autohide.test.ts | 3 +- .../src/selection/selection-column.test.ts | 2 +- ...-side-row-selection-checkbox-range.test.ts | 616 ++ ...server-side-row-selection-checkbox.test.ts | 205 + ...ver-side-row-selection-group-range.test.ts | 680 ++ .../server-side-row-selection-group.test.ts | 469 + ...side-row-selection-header-checkbox.test.ts | 163 + ...-side-row-selection-multiple-range.test.ts | 828 ++ ...server-side-row-selection-multiple.test.ts | 329 + .../server-side-row-selection-single.test.ts | 297 + .../server-side-row-selection.test.ts | 5027 ---------- .../serverSideRowSelectionHarness.ts | 82 + .../src/selection/source-row-index.test.ts | 4 +- testing/behavioural/src/selection/utils.ts | 10 +- .../src/services/focus-overrides.test.ts | 4 +- .../src/services/focus-service-ssrm.test.ts | 4 +- .../services/popup-cleanup-on-destroy.test.ts | 6 +- .../src/services/value-service-init.test.ts | 4 +- .../src/sorting/delta-sorting.test.ts | 4 +- .../pivot-column-sort-animation.test.ts | 4 +- .../sorting/pivot-column-sort-options.test.ts | 2 +- .../sorting/pivot-column-sort-ssrm.test.ts | 2 +- .../src/sorting/pivot-column-sort.test.ts | 2 +- .../src/sorting/pivot-sort-leaf-order.test.ts | 3 +- .../src/sorting/sort-service.test.ts | 3 +- .../behavioural/src/sorting/sorting.test.ts | 20 +- .../sorting/ssrm-client-side-sorting.test.ts | 6 +- ...aggregation-bigint-value-formatter.test.ts | 4 +- .../src/status-bar/ssrm-status-bar.test.ts | 4 +- .../src/status-bar/status-bar-panels.test.ts | 8 +- .../src/styling/cell-custom-styles.test.ts | 4 +- .../src/suspense/react-suspense.test.tsx | 1 + .../src/test-ids/test-ids.test.tsx | 1 + .../ignoreConsoleLicenseKeyError.ts | 26 - .../test-utils/polyfills/canvasPolyfill.ts | 104 - .../test-utils/polyfills/filterLayoutMock.ts | 61 - .../test-utils/polyfills/mockGridLayout.ts | 447 - .../src/theming/style-injection.test.ts | 25 +- .../theming/styled-root-legacy-popup.test.ts | 3 +- .../src/theming/styled-root-react.test.tsx | 1 + .../src/toolbar/toolbar-action-button.test.ts | 6 +- .../src/toolbar/toolbar-find.test.ts | 4 +- .../src/toolbar/toolbar-keyboard-nav.test.ts | 8 +- .../src/toolbar/toolbar-menu-item.test.ts | 10 +- .../src/toolbar/toolbar-panel-items.test.ts | 4 +- .../src/toolbar/toolbar-quick-filter.test.ts | 4 +- .../src/toolbar/toolbar-react.test.tsx | 3 - .../src/toolbar/toolbar-separator.test.ts | 4 +- .../behavioural/src/toolbar/toolbar.test.ts | 4 +- .../src/tooltip/tooltip-delays.test.ts | 141 + .../tooltip/tooltip-group-aggregation.test.ts | 28 +- .../tooltip-group-col-inheritance.test.ts | 46 +- .../src/tooltip/tooltip-interaction.test.ts | 3 +- .../src/tooltip/tooltip-react.test.tsx | 29 +- .../behavioural/src/tooltip/tooltip.test.ts | 263 +- .../grouping-tree-data-reactive.test.ts | 4 +- .../datapath/simpleHierarchyRowsSnapshot.ts | 2 +- .../datapath/stages/tree-aggregation.test.ts | 8 +- .../stages/tree-filter-aggregation.test.ts | 8 +- .../datapath/stages/tree-filter-sort.test.ts | 8 +- .../datapath/stages/tree-selection.test.ts | 29 +- .../tree-suppressAggFilteredOnly.test.ts | 4 +- .../datapath/tree-data-reset.test.ts | 3 +- .../datapath/tree-data-sorting.test.ts | 4 +- .../tree-data-without-tree-module.test.ts | 3 +- .../src/tree-data/datapath/tree-data.test.ts | 16 +- .../datapath/tree-duplicate-keys.test.ts | 9 +- .../datapath/tree-excel-export.test.ts | 5 +- .../datapath/tree-expanded-state.test.ts | 12 +- .../datapath/tree-file-manager.test.ts | 4 +- .../datapath/tree-group-rows.test.ts | 3 +- .../tree-data/datapath/tree-remove.test.ts | 4 +- .../datapath/tree-transactions.test.ts | 10 +- ...ng-hierarchical-tree-data-reactive.test.ts | 4 +- ...erarchical-with-immutable-row-data.test.ts | 4 +- .../hierarchical-immutable-tree-data.test.ts | 4 +- .../hierarchical-tree-data-reset.test.ts | 3 +- ...ierarchical-tree-data-transactions.test.ts | 11 +- ...ical-tree-data-without-tree-module.test.ts | 3 +- .../hierarchical-tree-data.test.ts | 11 +- .../hierarchical-tree-excel-export.test.ts | 5 +- .../hierarchical-tree-expanded-state.test.ts | 12 +- .../hierarchical-tree-group-rows.test.ts | 4 +- .../hierarchical-tree-aggregation.test.ts | 4 +- ...erarchical-tree-filter-aggregation.test.ts | 4 +- .../hierarchical-tree-filter-sort.test.ts | 4 +- .../hierarchical-tree-selection.test.ts | 4 +- .../treeDataChildrenField.test.ts | 4 +- ...ouping-parentid-tree-data-reactive.test.ts | 4 +- .../parentid-immutable-tree-data.test.ts | 3 +- .../parentid/parentid-tree-data-reset.test.ts | 3 +- ...ntid-tree-data-without-tree-module.test.ts | 3 +- .../parentid/parentid-tree-data.test.ts | 14 +- .../parentid-tree-excel-export.test.ts | 5 +- .../parentid-tree-expanded-state.test.ts | 12 +- .../parentid/parentid-tree-group-rows.test.ts | 4 +- .../parentid-tree-master-detail.test.ts | 4 +- .../parentid/parentid-tree-remove.test.ts | 11 +- .../parentid-tree-transactions.test.ts | 4 +- .../parentid/parentid-tree-warnings.test.ts | 3 +- .../parentid/simpleParentIdRowsSnapshot.ts | 2 +- .../stages/parentid-tree-aggregation.test.ts | 8 +- .../parentid-tree-filter-aggregation.test.ts | 8 +- .../stages/parentid-tree-filter-sort.test.ts | 8 +- .../stages/parentid-tree-selection.test.ts | 4 +- .../ssrm/ssrm-tree-data-drag-n-drop.test.ts | 17 +- .../ssrm-tree-data-empty-group-footer.test.ts | 5 +- .../ssrm/ssrm-tree-data-loading.test.ts | 5 +- .../ssrm/ssrm-tree-data-master-detail.test.ts | 4 +- .../ssrm-tree-data-open-by-default.test.ts | 2 +- .../ssrm/ssrm-tree-data-operations.test.ts | 7 +- .../ssrm/ssrm-tree-data-transactions.test.ts | 5 +- .../ssrm/ssrm-tree-grand-total.test.ts | 6 +- .../ssrm-tree-initial-and-selection.test.ts | 4 +- ...-aggregation-custom-object-display.test.ts | 4 +- .../tree-data-edit-aggregation.test.ts | 11 +- .../tree-data-get-aggregated-children.test.ts | 4 +- .../tree-data/tree-data-pinned-rows.test.ts | 4 +- ...ee-data-pinned-sibling-aggregation.test.ts | 4 +- .../tree-data-without-anything.test.ts | 3 +- .../validation/validation-warnings.test.ts | 3 +- testing/behavioural/vitest.config.ts | 102 +- testing/behavioural/vitest.setup.snapshots.ts | 18 + testing/behavioural/vitest.setup.ts | 56 +- testing/shared/eslint/rules.mjs | 15 + .../shared/vitest/diff.ts | 0 testing/shared/vitest/output.setup.ts | 87 + testing/shared/vitest/shared.ts | 170 + testing/shared/vitest/slow-tests.reporter.ts | 213 + testing/shared/vitest/timings.ts | 53 + testing/typedoc-links/vitest.config.ts | 4 +- vitest.config.ts | 22 +- vitest.output.setup.ts | 28 - vitest.shared.ts | 135 - vitest.workspace.ts | 13 +- yarn.lock | 1020 ++- 757 files changed, 32336 insertions(+), 36061 deletions(-) delete mode 100644 packages/ag-grid-vue3/vitest.config.ts create mode 100644 packages/ag-stack/src/fastTestTimings.test.ts create mode 100644 packages/ag-stack/src/fastTestTimings.ts delete mode 100644 packages/ag-stack/vitest.umd.config.ts create mode 100644 scripts/gate/args.mjs create mode 100644 scripts/gate/gates/behave.mjs create mode 100644 scripts/gate/gates/bench.mjs create mode 100644 scripts/gate/gates/checks.mjs create mode 100644 scripts/gate/gates/docs-e2e.mjs create mode 100644 scripts/gate/main.mjs create mode 100644 scripts/gate/run-log.mjs create mode 100644 testing/ag-test-utils/eslint.config.mjs create mode 100644 testing/ag-test-utils/package.json create mode 100644 testing/ag-test-utils/project.json rename testing/{behavioural/src/test-utils => ag-test-utils/src}/cachedJSONObjects.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/dev-validations.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/drag-n-drop/drag-event-dispatcher.ts (96%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/drag-n-drop/drag-n-drop-utils.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/drag-n-drop/row-drag-dispatcher.ts (78%) create mode 100644 testing/ag-test-utils/src/fastTestTimings.ts rename testing/{behavioural/src/test-utils => ag-test-utils/src}/filters/advancedFilterBuilderHarness.ts (97%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/filters/advancedFilterHarness.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/filters/columnFilterHarness.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/filters/filterDom.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/filters/filterDomSerialize.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/filters/filterDomValidator.ts (93%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/filters/floatingFilterHarness.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/filters/index.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/grid-test-utils.ts (95%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridColumns/columns-diagram/formatting.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridColumns/columns-diagram/gridColumnsDiagramTree.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridColumns/columns-validation-dom/gridColumnsDomValidator.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridColumns/columns-validation/gridColumnErrors.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridColumns/columns-validation/gridColumnsErrors.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridColumns/columns-validation/gridColumnsValidator.ts (98%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridColumns/gridColumns.ts (98%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridColumns/gridColumnsOptions.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/grid-rows-helpers.ts (86%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/gridHtmlRows.ts (71%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/gridRows.ts (97%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/gridRowsOptions.ts (94%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/rows-diagram/formatting.ts (89%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/rows-diagram/gridRowsDiagramNode.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/rows-diagram/gridRowsDiagramTree.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/rows-diagram/nodeInfo.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/rows-validation-dom/cell-helpers.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/rows-validation-dom/containers-helpers.ts (55%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/rows-validation-dom/dom-validation-helpers.ts (65%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/rows-validation-dom/gridRowDomCellValidator.ts (96%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/rows-validation-dom/gridRowsDomValidator.ts (90%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/rows-validation-dom/rowAriaValidation.ts (86%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/rows-validation-dom/rowClassValidation.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/rows-validation-dom/rowSelectionValidation.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/rows-validation/bugs.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/rows-validation/gridRowErrors.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/rows-validation/gridRowsErrors.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/rows-validation/gridRowsValidationState.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/rows-validation/gridRowsValidator.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/rows-validation/validator-computed.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/rows-validation/validator-leafs.ts (84%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/gridRows/snapshot-updater.ts (88%) create mode 100644 testing/ag-test-utils/src/ignoreKnownNoise.ts rename testing/{behavioural/src/test-utils => ag-test-utils/src}/index.ts (95%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/menu-test-utils.ts (81%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/node-utils.ts (62%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/patchBeansToJson.ts (100%) create mode 100644 testing/ag-test-utils/src/polyfills/canvasPolyfill.ts rename testing/{behavioural/src/test-utils => ag-test-utils/src}/polyfills/clipboard.ts (100%) create mode 100644 testing/ag-test-utils/src/polyfills/domGlobals.ts create mode 100644 testing/ag-test-utils/src/polyfills/filterLayoutMock.ts create mode 100644 testing/ag-test-utils/src/polyfills/mockGridLayout.ts rename testing/{behavioural/src/test-utils => ag-test-utils/src}/polyfills/objectUrls.ts (59%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/polyfills/pointerEvent.ts (95%) create mode 100644 testing/ag-test-utils/src/polyfills/virtualListViewports.ts rename testing/{behavioural/src/test-utils => ag-test-utils/src}/prng.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/rows-snapshot.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/ssrm-test-utils.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/string-utils.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/test-utils-assertions.ts (64%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/test-utils-edit.ts (66%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/test-utils-events.ts (90%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/testGridsManager.ts (90%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/utils.ts (100%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/widgets/dropdowns.ts (97%) rename testing/{behavioural/src/test-utils => ag-test-utils/src}/widgets/inputs.ts (100%) create mode 100644 testing/ag-test-utils/src/widgets/tooltips.ts create mode 100644 testing/ag-test-utils/tsconfig.json create mode 100644 testing/ag-test-utils/typings.d.ts create mode 100644 testing/behavioural/src/charts/format-panel-options-column-bar.test.ts create mode 100644 testing/behavioural/src/charts/format-panel-options-combo-funnel.test.ts create mode 100644 testing/behavioural/src/charts/format-panel-options-line-area.test.ts create mode 100644 testing/behavioural/src/charts/format-panel-options-polar.test.ts create mode 100644 testing/behavioural/src/charts/format-panel-options-statistical.test.ts rename testing/behavioural/src/charts/{format-panel-options.test.ts => formatPanelOptions.ts} (59%) create mode 100644 testing/behavioural/src/columnToolPanel/deferred-pivot-mode-apply-button.test.ts create mode 100644 testing/behavioural/src/columnToolPanel/deferred-pivot-mode-pills.test.ts create mode 100644 testing/behavioural/src/columnToolPanel/deferred-pivot-mode-staging.test.ts create mode 100644 testing/behavioural/src/columnToolPanel/deferred-pivot-mode-toggle.test.ts delete mode 100644 testing/behavioural/src/columnToolPanel/deferred-pivot-mode.test.ts create mode 100644 testing/behavioural/src/columnToolPanel/deferredPivotModeHarness.ts create mode 100644 testing/behavioural/src/fastTestTimings.test.ts create mode 100644 testing/behavioural/src/formulas/calculated-columns-column-order.test.ts create mode 100644 testing/behavioural/src/formulas/calculated-columns-dialog-anchoring.test.ts create mode 100644 testing/behavioural/src/formulas/calculated-columns-dialog.test.ts create mode 100644 testing/behavioural/src/formulas/calculated-columns-errors.test.ts create mode 100644 testing/behavioural/src/formulas/calculated-columns-evaluation.test.ts create mode 100644 testing/behavioural/src/formulas/calculated-columns-events.test.ts create mode 100644 testing/behavioural/src/formulas/calculated-columns-grouping.test.ts create mode 100644 testing/behavioural/src/formulas/calculated-columns-highlighting.test.ts create mode 100644 testing/behavioural/src/formulas/calculated-columns-menu.test.ts create mode 100644 testing/behavioural/src/formulas/calculated-columns-state-declared.test.ts create mode 100644 testing/behavioural/src/formulas/calculated-columns-state-no-module.test.ts create mode 100644 testing/behavioural/src/formulas/calculated-columns-state-parity-grouped.test.ts create mode 100644 testing/behavioural/src/formulas/calculated-columns-state-parity-top-level.test.ts create mode 100644 testing/behavioural/src/formulas/calculated-columns-state-resets.test.ts create mode 100644 testing/behavioural/src/formulas/calculated-columns-state-round-trip.test.ts delete mode 100644 testing/behavioural/src/formulas/calculated-columns-state.test.ts delete mode 100644 testing/behavioural/src/formulas/calculated-columns.test.ts create mode 100644 testing/behavioural/src/formulas/calculatedColumnsHarness.ts create mode 100644 testing/behavioural/src/formulas/calculatedColumnsStateHarness.ts create mode 100644 testing/behavioural/src/group-cell-renderer/grouping/exported/csrm/__snapshots__/exported-multiple-columns-hide-open-parents.test.ts.snap create mode 100644 testing/behavioural/src/group-cell-renderer/grouping/exported/csrm/__snapshots__/exported-multiple-columns.test.ts.snap create mode 100644 testing/behavioural/src/group-cell-renderer/grouping/exported/csrm/__snapshots__/exported-single-column.test.ts.snap delete mode 100644 testing/behavioural/src/group-cell-renderer/grouping/exported/csrm/__snapshots__/exported.test.ts.snap create mode 100644 testing/behavioural/src/group-cell-renderer/grouping/exported/csrm/exported-multiple-columns-hide-open-parents.test.ts create mode 100644 testing/behavioural/src/group-cell-renderer/grouping/exported/csrm/exported-multiple-columns.test.ts rename testing/behavioural/src/group-cell-renderer/grouping/exported/{ssrm/exported.test.ts => csrm/exported-single-column.test.ts} (81%) create mode 100644 testing/behavioural/src/group-cell-renderer/grouping/exported/ssrm/__snapshots__/exported-multiple-columns-hide-open-parents.test.ts.snap create mode 100644 testing/behavioural/src/group-cell-renderer/grouping/exported/ssrm/__snapshots__/exported-multiple-columns.test.ts.snap create mode 100644 testing/behavioural/src/group-cell-renderer/grouping/exported/ssrm/__snapshots__/exported-single-column.test.ts.snap delete mode 100644 testing/behavioural/src/group-cell-renderer/grouping/exported/ssrm/__snapshots__/exported.test.ts.snap create mode 100644 testing/behavioural/src/group-cell-renderer/grouping/exported/ssrm/exported-multiple-columns-hide-open-parents.test.ts create mode 100644 testing/behavioural/src/group-cell-renderer/grouping/exported/ssrm/exported-multiple-columns.test.ts rename testing/behavioural/src/group-cell-renderer/grouping/exported/{csrm/exported.test.ts => ssrm/exported-single-column.test.ts} (81%) create mode 100644 testing/behavioural/src/selection/row-selection-checkbox.test.ts create mode 100644 testing/behavioural/src/selection/row-selection-group-range.test.ts create mode 100644 testing/behavioural/src/selection/row-selection-group.test.ts create mode 100644 testing/behavioural/src/selection/row-selection-header-checkbox.test.ts create mode 100644 testing/behavioural/src/selection/row-selection-model-updates.test.ts create mode 100644 testing/behavioural/src/selection/row-selection-multiple.test.ts create mode 100644 testing/behavioural/src/selection/row-selection-single.test.ts delete mode 100644 testing/behavioural/src/selection/row-selection.test.ts create mode 100644 testing/behavioural/src/selection/rowSelectionHarness.ts create mode 100644 testing/behavioural/src/selection/server-side-row-selection-checkbox-range.test.ts create mode 100644 testing/behavioural/src/selection/server-side-row-selection-checkbox.test.ts create mode 100644 testing/behavioural/src/selection/server-side-row-selection-group-range.test.ts create mode 100644 testing/behavioural/src/selection/server-side-row-selection-group.test.ts create mode 100644 testing/behavioural/src/selection/server-side-row-selection-header-checkbox.test.ts create mode 100644 testing/behavioural/src/selection/server-side-row-selection-multiple-range.test.ts create mode 100644 testing/behavioural/src/selection/server-side-row-selection-multiple.test.ts create mode 100644 testing/behavioural/src/selection/server-side-row-selection-single.test.ts delete mode 100644 testing/behavioural/src/selection/server-side-row-selection.test.ts create mode 100644 testing/behavioural/src/selection/serverSideRowSelectionHarness.ts delete mode 100644 testing/behavioural/src/test-utils/ignoreConsoleLicenseKeyError.ts delete mode 100644 testing/behavioural/src/test-utils/polyfills/canvasPolyfill.ts delete mode 100644 testing/behavioural/src/test-utils/polyfills/filterLayoutMock.ts delete mode 100644 testing/behavioural/src/test-utils/polyfills/mockGridLayout.ts create mode 100644 testing/behavioural/src/tooltip/tooltip-delays.test.ts create mode 100644 testing/behavioural/vitest.setup.snapshots.ts create mode 100644 testing/shared/eslint/rules.mjs rename vitest.diff.ts => testing/shared/vitest/diff.ts (100%) create mode 100644 testing/shared/vitest/output.setup.ts create mode 100644 testing/shared/vitest/shared.ts create mode 100644 testing/shared/vitest/slow-tests.reporter.ts create mode 100644 testing/shared/vitest/timings.ts delete mode 100644 vitest.output.setup.ts delete mode 100644 vitest.shared.ts diff --git a/.claude/settings.json b/.claude/settings.json index 47a56e14ef6..77967fc4346 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -86,7 +86,8 @@ ], "deny": [ "Read(.env.local)", - "Read(credentials/)" + "Read(credentials/)", + "Read(credentials/**)" ] }, "hooks": { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bf63b2b275..ee83e05de66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,6 +240,8 @@ jobs: fail-fast: false env: NX_PARALLEL: 1 + # Vite 8 warns once per config that these are not loadable by its future `configLoader: 'native'`. + VITE_CONFIG_NATIVE_IGNORE_WARNING: true steps: - name: Checkout id: checkout @@ -270,7 +272,7 @@ jobs: # config emit reports/workspace.xml for this shard. if: matrix.shard != 0 && needs.init.outputs.unit_projects != '' id: test - run: npx vitest run ${{ needs.init.outputs.unit_projects }} + run: node_modules/.bin/vitest run ${{ needs.init.outputs.unit_projects }} --shard=${{ matrix.shard }}/$((${{ strategy.job-total }} - 1)) - name: Type-check test files # `build:test` (tsc --noEmit on tsconfig.spec) was previously pulled in as the nx `test` target's diff --git a/.rulesync/rules/ag-grid.md b/.rulesync/rules/ag-grid.md index c17d37b6a52..84335477a41 100644 --- a/.rulesync/rules/ag-grid.md +++ b/.rulesync/rules/ag-grid.md @@ -18,7 +18,7 @@ This file provides guidance to AI Agents when working with code in this reposito - **Main branch:** `latest` - **Format:** `yarn nx format --sort-root-tsconfig-paths=false` (run before commits) - **Pre-commit checks:** `./checks.sh` — the preferred gate: type-check + lint + spec type-check across every project in one parallel, cache-aware Nx run. Much faster than chaining separate `yarn nx build:types` / `yarn nx lint` calls. -- **Test:** `./behave.sh` (whole unit suite — package + behavioural — via the Vitest workspace). Single project: `./behave.sh --project ` (e.g. `ag-grid-community`, `behavioural`). +- **Test:** `./behave.sh` (whole unit suite — package + behavioural — as one multi-project Vitest run). Single project: `./behave.sh --project ` (e.g. `ag-grid-community`, `behavioural`). - **Benchmarks:** `./benches.sh` (behavioural benchmarks in headless Chromium). - **E2E:** `./docs-e2e.sh` (Playwright against the docs site; the Nx target is `test:e2e`, not `e2e`). - **Build:** `yarn nx build `; types only: `yarn nx build:types `. @@ -27,6 +27,10 @@ This file provides guidance to AI Agents when working with code in this reposito Each script takes `--help`. Full flag reference lives in the guides below rather than here: test and E2E flags in the Testing Guide, benchmark and profiling flags in the Benchmarks Guide. +**Locally, never run `./checks.sh`, `./behave.sh`, `./benches.sh` or `./docs-e2e.sh` in the foreground** (under `CI` or in a workflow, do the opposite: see below). They take minutes, and a foreground call blocks the whole session: the user cannot reach the agent, no other work proceeds, and the wait is dead time repeated at every gate. Start them with the agent harness's background mechanism, which delivers a completion event in a later turn, then keep working. **Never `sleep` to wait for one either** — that is the same block, wearing a different hat. + +Nothing needs to be arranged to read the result afterwards: every local run captures itself and prints the log path as its first line (`tmp/_-output//output.log`). Grep that file — while the run is going to abort early on the first failure, or after it for the verdict; `--bail 1` makes the run stop itself there instead, which is what you want in a fix-one-error-at-a-time loop. `./behave.sh` also writes Vitest's machine-readable `result.json` beside the log; the other scripts leave only the log and a status file. **All of this is local-only, and so is the rule above.** Backgrounding exists to keep an interactive session reachable; a CI job or workflow has nobody to block, so run these in the foreground there and read the output directly. The scripts capture nothing under `CI` for the same reason. + ### Content Locations - **Plugin marketplace:** Shared skills, subagents, commands, and guides are delivered via Claude Code plugins from [`ag-grid/ag-dev-prompts`](https://github.com/ag-grid/ag-dev-prompts) — `ag-core`, `ag-prodeng`, and `ag-grid` (enabled in `.claude/settings.json`). Invoke with the plugin prefix, e.g. `/ag-prodeng:pr-review`, `/ag-core:recall`. @@ -44,7 +48,7 @@ Each script takes `--help`. Full flag reference lives in the guides below rather - **Self-review before committing:** Re-read your changes as if reviewing someone else's PR and verify: each new function/class has a single clear responsibility; names are meaningful; no unnecessary complexity; no copy-pasted logic that should be extracted; new code follows the patterns of the surrounding codebase. - **Formatting, typechecking and linting:** Run `yarn nx format --sort-root-tsconfig-paths=false` then `./checks.sh` from the repo root before proposing commits. Never chain separate `yarn nx build:types` / `yarn nx lint` invocations for the standard gate — each one re-pays Nx startup and forces the tasks to run serially. - **Batch Nx work into one invocation:** Whenever more than one target or project is needed, use a single `nx run-many -t -p --parallel=` instead of issuing commands one at a time. Every extra `yarn nx …` re-pays Nx startup and project-graph computation, and serialises tasks that Nx would otherwise run concurrently — this applies to builds and any other target, not just the pre-commit gate. -- **Baseline verification:** Expect to run `./behave.sh` (the merged unit suite) and `./docs-e2e.sh` after meaningful grid changes. +- **Baseline verification:** Expect to run `./behave.sh` (the merged unit suite) and `./docs-e2e.sh` after meaningful grid changes — **backgrounded, never in the foreground** (see the Quick Reference), and batched to the end of the change rather than repeated per edit. - **Test verification patterns:** When writing or modifying tests, review similar tests to ensure consistent verification patterns (see the Testing Guide). - **Context docs:** Load the `/technology-stack` skill for stack or architectural decisions before introducing new patterns. @@ -117,7 +121,7 @@ Core dependency chain: `ag-grid-community` → `ag-grid-enterprise` → framewor ### Development Workflow -**Behavioural tests are the primary test suite.** `testing/behavioural/` verifies grid behaviour as a black box; package unit tests are co-located in `packages/*/src`. `./behave.sh` runs both together through the Vitest workspace. The Testing Guide covers layer choice, async waiting patterns, and snapshots. +**Behavioural tests are the primary test suite.** `testing/behavioural/` verifies grid behaviour as a black box; package unit tests are co-located in `packages/*/src`. `./behave.sh` runs both together as one multi-project Vitest run. The Testing Guide covers layer choice, async waiting patterns, and snapshots. **Bug fix or feature work:** update the implementation (typically `packages/ag-grid-*/src/`), sync dependent docs and examples, then run `./behave.sh` and `./checks.sh`. Docs and example workflows are covered by the docs-pages and examples rules, which load when you touch those trees. diff --git a/.rulesync/rules/benchmarks.md b/.rulesync/rules/benchmarks.md index 80162488fe1..b9e52d72fe5 100644 --- a/.rulesync/rules/benchmarks.md +++ b/.rulesync/rules/benchmarks.md @@ -21,6 +21,8 @@ Performance benchmarks help detect regressions and validate optimizations. Behavioural benchmarks run via `./benches.sh`, in a real headless Chromium (Playwright) by **default** so layout-dependent work is measured against a real layout engine. Run `./benches.sh --help` for the full usage — it prints vitest's `bench --help` followed by benches.sh's own options. +An agent must start it in the background, never in the foreground: a benchmark run takes minutes, and a foreground call blocks the session for all of it. Every local run prints its log path first and streams stdout+stderr there (`tmp/_bench-output//output.log`), so the numbers are readable afterwards without a redirect. Benchmark timings are also the one thing a parallel workload distorts — leave the machine alone while one runs, rather than filling the wait with other work. + ```bash # Run all behavioural benchmarks ./benches.sh diff --git a/.rulesync/rules/integrated-charts.md b/.rulesync/rules/integrated-charts.md index 426861c8790..73a9dccc293 100644 --- a/.rulesync/rules/integrated-charts.md +++ b/.rulesync/rules/integrated-charts.md @@ -23,5 +23,5 @@ Several helpers substitute a plausible value for a missing read, which turns a b ## Testing the format panel -- Widget values are **not queryable through `document`** in the jsdom behavioural environment. Assert them by instrumenting the `ChartMenuParamsFactory` factory methods and reading `params.value` once the panel has built — panels amend the params object after the factory returns, so the recorded object holds the final value. -- `format-panel-options.test.ts` walks every binding on every chart type and is the gate for this class of drift. +- Widget values are **not queryable through `document`** in the headless behavioural environment. Assert them by instrumenting the `ChartMenuParamsFactory` factory methods and reading `params.value` once the panel has built — panels amend the params object after the factory returns, so the recorded object holds the final value. +- The `format-panel-options-*.test.ts` suites walk every binding on every chart type and are the gate for this class of drift. They share `formatPanelOptions.ts`, and take a chart family each so the 37 chart builds run in parallel. diff --git a/.rulesync/rules/testing.md b/.rulesync/rules/testing.md index 806a6e8e25b..05d808b794c 100644 --- a/.rulesync/rules/testing.md +++ b/.rulesync/rules/testing.md @@ -25,6 +25,12 @@ api.setGridOption('rowData', ATHLETES); await waitFor(() => expect(panel.setFilterItemLabels('Athlete')).toEqual(LI_MATCHES)); ``` +**In a React suite, flush ticks inside `act`.** `waitFor` and `userEvent` are already act-aware, but a bare `await asyncSetTimeout(0)` is not: the grid re-renders rows asynchronously, so an update scheduled by an api call lands in the *next* tick — after a synchronous `act(...)` has closed — and React reports "An update to RowComp inside a test was not wrapped in act(...)". Wrap the flush instead: + +```typescript +const flush = async () => { await act(async () => { await asyncSetTimeout(0); }); }; +``` + `asyncSetTimeout(0)` is fine for flushing a single tick after a synchronous action. `asyncSetTimeout(1)` is the *same call* — Node clamps 0 to 1ms — so it buys nothing. The skill covers the traps that make a `waitFor` unfalsifiable or a sleep load-bearing — negative assertions, polls that were already true, test IDs landing on a debounce, and sleeps that only look like safety margins — plus how to prove a wait is genuinely necessary. **Load it before converting any timing-dependent test.** @@ -41,13 +47,48 @@ Pick the input that *separates* the two behaviours. A test that passes against b ## Commands -- `./behave.sh` — the whole unit suite (package + behavioural) via the Vitest workspace. +- `./behave.sh` — the whole unit suite (package + behavioural) as one multi-project Vitest run. - `./benches.sh` — behavioural benchmarks in headless Chromium. - `./docs-e2e.sh` — Playwright E2E against the docs site. The Nx target is `test:e2e`; there is **no** `e2e` target. +### Never block on a gate; read its log afterwards + +**Never run `./behave.sh`, `./checks.sh`, `./benches.sh` or `./docs-e2e.sh` in the foreground** — while a Bash call is in flight the user cannot reach the agent at all. Launch with the harness's background mechanism, which delivers a completion event in a later turn, and do other work meanwhile. (`--async` detaches the script itself and reports back to the terminal it was launched from when it ends — useful to a human, useless to an agent, which cannot be woken that way.) + +**Never `sleep` to wait for a run.** One you backgrounded wakes the agent by itself; one started elsewhere has `--async-status` (exit 3 = still running) and `--wait`, below. For progress mid-run, grep the log — it is written live. + +**Every local run captures itself, and prints the log path as its first line** — `▶ tmp/_behave-output//output.log`, the whole of stdout and stderr with the colour codes stripped. That line is also the only proof a run happened: piping a gate (`… | tail`) reports the **pipe's** exit status, so one that failed, or that the shell never found, still comes back `0` — read the summary line, and treat a missing `▶` as "nothing ran". So no redirect has to be arranged in advance and a red run needs no second run: grep that file, during the run or after it, or pass `--bail 1` to make the run stop at the first failure itself. Beside it sit the `command` and a `status`, plus `result.json` (vitest's machine-readable results) for `./behave.sh` only. `latest` symlinks the newest and week-old runs are pruned. + +**Under `CI`, run them in the foreground instead.** Backgrounding is there to keep an interactive session reachable, and a workflow has nobody to block, so take the output directly. The scripts capture nothing under `CI` for the same reason, so there is no log to grep and none is needed. + +- `--async-status [id]` — has a run finished? Exit 0 passed, 1 failed, **3 still running**. Defaults to the newest run and takes an id or any path containing one, so it also reports on a run started elsewhere. +- `--wait [secs]` — the same report, waiting up to `secs` for the run to finish. +- `--kill [id]` — stop a run (the newest by default) and every process it spawned. +- `--quiet` — console gets the paths, summary and failures only (not `./checks.sh`, which is quiet already); `--no-log` turns capture off. + **Run with `--bail 1` by habit.** `./behave.sh --bail 1 ` stops at the first failing test — what you want in a fix-one-error-at-a-time loop, and it skips the rest of the reporting too. `--no-diff` reports names and messages with no diffs, for when a suite fails wholesale. A red run can take minutes where the green one takes seconds: vitest's diff serialisation of grid objects is effectively unbounded. The skill explains why, plus the `--stack-trace-len` trap and how `--bail` reads in a JSON report. -All three take `--help` and resolve only from the repo root — from elsewhere call them by path (`../../behave.sh`), not via `cd "$(git rev-parse --show-toplevel)" &&`, which agent harnesses gate on. `./behave.sh` does not type-check; run `yarn nx run ag-behavioural-testing:build:test` before committing. Some suites take minutes — allow a five-minute timeout and collect the exit status rather than treating silence as success. +`./behave.sh --slowest N` reports what a run spent its time on (default 5; `AG_SLOWEST_TESTS`, 0 to silence). Three tables plus a line: + +- **Slowest tests** and **slowest test files**, each below a floor in `timings.ts`, so a healthy run prints nothing. Read a file by its per-test rate, not its total. +- **Idle** — files ranked by the off-CPU milliseconds themselves, listed above 1s (`AG_WAITING_MIN_MS`). Usually a fixed timer the test out-waited, so usually time a fix gives back — but `eventLoopUtilization` counts every event-loop wait, so worker↔main RPC and inline-snapshot writes land here too and a snapshot-heavy file can rank high with no timer to remove. The ranking is absolute rather than a share of the file, because 3s inside a 10s file is still 3s. +- **Worker time** — the run's total worker-seconds, the parallel factor against wall clock, and the split between `load` (importing the grid plus building a happy-dom) and `tests`. Load is a per-file constant and flat across them, so it is a budget line rather than a table: it is what makes an extra file cost something, and once the parallel factor sits at core count, wall time only falls by spending fewer worker-seconds. Read the current figure off the run rather than from here — it is actively being optimised, so any number written down is stale. + +All four take `--help` and resolve only from the repo root — from elsewhere call them by path (`../../behave.sh`), not via `cd "$(git rev-parse --show-toplevel)" &&`, which agent harnesses gate on. `./behave.sh` does not type-check; run `yarn nx run ag-behavioural-testing:build:test` before committing. Some suites take minutes — allow a five-minute timeout and collect the exit status rather than treating silence as success. + +## Speed + +**A test should take well under 4 seconds.** The suite mean is ~70ms, so 4s already means something is wrong. A slower one is reported as a warning and a much slower one fails outright; both thresholds live in `testing/shared/vitest/timings.ts`, and are looser in CI. + +**Never pass a timeout to `test()`.** It does not raise the limit — `testing/shared/vitest/output.setup.ts` fails on measured duration, so an override only lets a slow test run to completion and then fail anyway. It hid a 147s test for months. If a test needs more time, the time is the bug. + +**A slow test is almost never doing work; it is waiting.** Check CPU before optimising anything: a run at 30% CPU is sleeping, usually on a fixed timer the test could avoid rather than out-wait. Watch for a product timeout that only fires because happy-dom has no layout and no CSS transitions, and for polling on a state the code reaches a second later than the one you can already assert. + +**A hard-coded grid delay can be collapsed for this suite: `FAST_TEST_TIMINGS`.** `packages/ag-stack/src/fastTestTimings.ts` exports a single `false`; `testing/behavioural/vitest.config.ts` aliases that module to a `true` copy, so only behavioural tests are affected — E2E, the docs site and every published bundle read `false`. Each read stays a ternary in the shipped bundle rather than folding, because `ag-stack` is a separate package the grid imports — cheap, but not free, so spend it only where the delay costs the suite real time. Branch where the delay is a constant: `const MIN_TOOLTIP_DELAY = FAST_TEST_TIMINGS ? 0 : 200`. Two rules: a delay a test can set through a **grid option does not go behind the flag** (set the option), and lifting a floor achieves nothing until the test also asks for the small value. Suites that assert the timing itself keep the real values — they are why the constant still has to work. + +**Read a slow file by its per-test rate, not its total** — `--slowest` prints both. A high total with a normal rate is volume, and there is nothing to reclaim without deleting coverage. A high *rate* is a defect worth chasing. + +**Split a file whose tests are individually fast but numerous.** Vitest parallelises across files, not within one, so a long matrix serialises in a single worker. Split it into sibling suites sharing a harness module — one of the few cases that outweighs the preference for extending an existing suite. Prefer `test.each` over one test looping the matrix, so a failure names the case rather than only the file. ## Key practices diff --git a/.rulesync/skills/technology-stack/SKILL.md b/.rulesync/skills/technology-stack/SKILL.md index ff936be6454..4b11ea2a43b 100644 --- a/.rulesync/skills/technology-stack/SKILL.md +++ b/.rulesync/skills/technology-stack/SKILL.md @@ -46,7 +46,7 @@ The core grid logic is framework-agnostic. Framework-specific wrappers (`ag-grid - **Vitest**: Unit, integration, and behavioural testing (`testing/angular-tests` still uses Jest) - **Playwright**: E2E testing, and the default engine for behavioural benchmarks (`./benches.sh`) -- **jsdom**: DOM simulation for unit tests +- **happy-dom**: DOM simulation for every Vitest project (`testing/angular-tests` still uses jsdom, via Jest) ## Code Style diff --git a/.rulesync/skills/testing/SKILL.md b/.rulesync/skills/testing/SKILL.md index 80863685cfa..31a0bac33b0 100644 --- a/.rulesync/skills/testing/SKILL.md +++ b/.rulesync/skills/testing/SKILL.md @@ -26,7 +26,7 @@ Behavioural tests in `testing/behavioural/` are the primary test suite for AG Gr Search `testing/behavioural` for an existing harness before assuming a behaviour can't be black-box tested (e.g. `DragEventDispatcher` drives real header drags); extend the harness rather than dropping to a unit test. -`./behave.sh` runs the merged unit suite in a single Vitest workspace (`vitest.workspace.ts`): the package (London-school) `*.test.ts` files **and** the behavioural (Chicago-school) suite together, no Nx required. `yarn nx test ` still runs one package's tests on its own (retained for retrocompat). +`./behave.sh` runs the merged unit suite as one multi-project Vitest run (the project list in `vitest.workspace.ts`): the package (London-school) `*.test.ts` files **and** the behavioural (Chicago-school) suite together, no Nx required. `yarn nx test ` still runs one package's tests on its own (retained for retrocompat). ## Regression Tests: Cover Every Reproduction Path @@ -115,9 +115,11 @@ packages/ag-grid-community/src/ ## Running Tests +**Background every one of these commands; never call one in the foreground.** `./behave.sh`, `./checks.sh`, `./benches.sh` and `./docs-e2e.sh` all take minutes, and a foreground call holds the session for the whole run — the user cannot interject and no other work happens. Start it with the agent harness's background mechanism (which wakes the agent when it ends) and carry on; do not `sleep` on it. Reading the result needs no preparation: the first line printed is the log path, `tmp/_-output//output.log`, holding the full stdout and stderr; `./behave.sh` also writes `result.json` beside it. Grep the log *during* the run to abort early on the first failure instead of waiting out a run already known to be red. Under `CI` do the opposite and run in the foreground: backgrounding exists to keep an interactive session reachable, a workflow has nobody to block, and the scripts capture nothing there for the same reason. + ### The merged unit suite (Vitest) — `./behave.sh` -`./behave.sh` is the single command for the whole unit suite: the package unit tests (`ag-stack`, `ag-grid-community`, `ag-grid-enterprise`, `locale`) plus the behavioural suite, run together through the Vitest workspace from the repo root. Watch mode is disabled by default: +`./behave.sh` is the single command for the whole unit suite: the package unit tests (`ag-stack`, `ag-grid-community`, `ag-grid-enterprise`, `locale`) plus the behavioural suite, run together as one multi-project Vitest run from the repo root. Watch mode is disabled by default: ```bash # Run the whole unit suite (package + behavioural) @@ -170,9 +172,9 @@ Colour (off for an agent or a pipe, on for a terminal and CI) and `DEBUG_PRINT_L > > `./behave.sh` and `./benches.sh` resolve only from the repository root. From anywhere else call them by path (`../../behave.sh`) — not via `cd "$(git rev-parse --show-toplevel)" &&`, which every agent harness gates on the `cd`, the `&&` and the `$(…)`. > -> Some suites take several minutes; `testing/behavioural/src/charts/format-panel-options.test.ts` alone runs ~2.5 minutes. Allow a timeout of at least five minutes, and wait for the run to finish and report its exit status — if the runner detaches the command, collect the result rather than treating silence as success. +> A whole-suite run takes a few minutes. Allow a timeout of at least five minutes, and wait for the run to finish and report its exit status — if the runner detaches the command, collect the result rather than treating silence as success. > -> The workspace membership and shared config live in `vitest.workspace.ts`, `vitest.config.ts`, and `vitest.shared.ts` at the repo root; each project keeps its own `vitest.config.ts`. Runner-global options (reporters, `onConsoleLog`, pool) must live in the **root** config — Vitest ignores them in a project config during a workspace run. +> The project list and root config live in `vitest.workspace.ts` and `vitest.config.ts` at the repo root; the shared helpers, thresholds, setup file and slow-tests reporter live in `testing/shared/vitest/`; each project keeps its own `vitest.config.ts`. Runner-global options (reporters, `outputFile`, coverage) must live in the **root** config — Vitest ignores them in a project config. Project-scoped options (pool, environment, `setupFiles`) do NOT cascade from the root, so `unitProjectTestConfig` carries them instead. ### Benchmarks diff --git a/AGENTS.md b/AGENTS.md index f5be7b3a3d2..23634906b1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ This file provides guidance to AI Agents when working with code in this reposito - **Main branch:** `latest` - **Format:** `yarn nx format --sort-root-tsconfig-paths=false` (run before commits) - **Pre-commit checks:** `./checks.sh` — the preferred gate: type-check + lint + spec type-check across every project in one parallel, cache-aware Nx run. Much faster than chaining separate `yarn nx build:types` / `yarn nx lint` calls. -- **Test:** `./behave.sh` (whole unit suite — package + behavioural — via the Vitest workspace). Single project: `./behave.sh --project ` (e.g. `ag-grid-community`, `behavioural`). +- **Test:** `./behave.sh` (whole unit suite — package + behavioural — as one multi-project Vitest run). Single project: `./behave.sh --project ` (e.g. `ag-grid-community`, `behavioural`). - **Benchmarks:** `./benches.sh` (behavioural benchmarks in headless Chromium). - **E2E:** `./docs-e2e.sh` (Playwright against the docs site; the Nx target is `test:e2e`, not `e2e`). - **Build:** `yarn nx build `; types only: `yarn nx build:types `. @@ -20,6 +20,10 @@ This file provides guidance to AI Agents when working with code in this reposito Each script takes `--help`. Full flag reference lives in the guides below rather than here: test and E2E flags in the Testing Guide, benchmark and profiling flags in the Benchmarks Guide. +**Locally, never run `./checks.sh`, `./behave.sh`, `./benches.sh` or `./docs-e2e.sh` in the foreground** (under `CI` or in a workflow, do the opposite: see below). They take minutes, and a foreground call blocks the whole session: the user cannot reach the agent, no other work proceeds, and the wait is dead time repeated at every gate. Start them with the agent harness's background mechanism, which delivers a completion event in a later turn, then keep working. **Never `sleep` to wait for one either** — that is the same block, wearing a different hat. + +Nothing needs to be arranged to read the result afterwards: every local run captures itself and prints the log path as its first line (`tmp/_-output//output.log`). Grep that file — while the run is going to abort early on the first failure, or after it for the verdict; `--bail 1` makes the run stop itself there instead, which is what you want in a fix-one-error-at-a-time loop. `./behave.sh` also writes Vitest's machine-readable `result.json` beside the log; the other scripts leave only the log and a status file. **All of this is local-only, and so is the rule above.** Backgrounding exists to keep an interactive session reachable; a CI job or workflow has nobody to block, so run these in the foreground there and read the output directly. The scripts capture nothing under `CI` for the same reason. + ### Content Locations - **Plugin marketplace:** Shared skills, subagents, commands, and guides are delivered via Claude Code plugins from [`ag-grid/ag-dev-prompts`](https://github.com/ag-grid/ag-dev-prompts) — `ag-core`, `ag-prodeng`, and `ag-grid` (enabled in `.claude/settings.json`). Invoke with the plugin prefix, e.g. `/ag-prodeng:pr-review`, `/ag-core:recall`. @@ -37,7 +41,7 @@ Each script takes `--help`. Full flag reference lives in the guides below rather - **Self-review before committing:** Re-read your changes as if reviewing someone else's PR and verify: each new function/class has a single clear responsibility; names are meaningful; no unnecessary complexity; no copy-pasted logic that should be extracted; new code follows the patterns of the surrounding codebase. - **Formatting, typechecking and linting:** Run `yarn nx format --sort-root-tsconfig-paths=false` then `./checks.sh` from the repo root before proposing commits. Never chain separate `yarn nx build:types` / `yarn nx lint` invocations for the standard gate — each one re-pays Nx startup and forces the tasks to run serially. - **Batch Nx work into one invocation:** Whenever more than one target or project is needed, use a single `nx run-many -t -p --parallel=` instead of issuing commands one at a time. Every extra `yarn nx …` re-pays Nx startup and project-graph computation, and serialises tasks that Nx would otherwise run concurrently — this applies to builds and any other target, not just the pre-commit gate. -- **Baseline verification:** Expect to run `./behave.sh` (the merged unit suite) and `./docs-e2e.sh` after meaningful grid changes. +- **Baseline verification:** Expect to run `./behave.sh` (the merged unit suite) and `./docs-e2e.sh` after meaningful grid changes — **backgrounded, never in the foreground** (see the Quick Reference), and batched to the end of the change rather than repeated per edit. - **Test verification patterns:** When writing or modifying tests, review similar tests to ensure consistent verification patterns (see the Testing Guide). - **Context docs:** Load the `/technology-stack` skill for stack or architectural decisions before introducing new patterns. @@ -110,7 +114,7 @@ Core dependency chain: `ag-grid-community` → `ag-grid-enterprise` → framewor ### Development Workflow -**Behavioural tests are the primary test suite.** `testing/behavioural/` verifies grid behaviour as a black box; package unit tests are co-located in `packages/*/src`. `./behave.sh` runs both together through the Vitest workspace. The Testing Guide covers layer choice, async waiting patterns, and snapshots. +**Behavioural tests are the primary test suite.** `testing/behavioural/` verifies grid behaviour as a black box; package unit tests are co-located in `packages/*/src`. `./behave.sh` runs both together as one multi-project Vitest run. The Testing Guide covers layer choice, async waiting patterns, and snapshots. **Bug fix or feature work:** update the implementation (typically `packages/ag-grid-*/src/`), sync dependent docs and examples, then run `./behave.sh` and `./checks.sh`. Docs and example workflows are covered by the docs-pages and examples rules, which load when you touch those trees. diff --git a/behave.sh b/behave.sh index f9f598c65c7..a3847a28b50 100755 --- a/behave.sh +++ b/behave.sh @@ -1,130 +1,6 @@ #!/usr/bin/env bash -# Runs the merged unit-test suite directly via the Vitest workspace (vitest.workspace.ts), bypassing Nx: -# package (London-school) unit tests plus the behavioural (Chicago-school) black-box suite — one command. -# The workspace file also lists the node-env tooling projects (docs, ag-website-shared) so the IDE can -# discover them; by default this script restricts the run to the unit projects. Watch mode is off by -# default; all other arguments are forwarded to vitest. -# -# Usage: -# ./behave.sh # Run the unit suite (package + behavioural) -# ./behave.sh "file-pattern" # Run tests matching a pattern across the unit projects -# ./behave.sh "file-pattern" -t "name" # Run a specific test by name -# ./behave.sh --project docs # Run specific workspace project(s) instead of the unit set -# ./behave.sh --project all # Run every project in the workspace (incl. docs, website) -# ./behave.sh -w | --watch # Run in watch mode -# ./behave.sh --update # Update vitest snapshots -# ./behave.sh --update-grid-rows[=dry] # Update GridRows inline snapshots (dry = preview only) -# -# Output-volume controls, for when a suite fails wholesale and the diffs dwarf the results: -# ./behave.sh --bail 1 # Stop at the first failing test -# ./behave.sh --no-diff # Report which tests fail; no assertion diff, snapshots cut to a line -# ./behave.sh --diff-lines 10 # Cap each diff at 10 lines (0 = unlimited) -# ./behave.sh --stack-trace-len 20 # Shorten captured stacks; default 40, keep >= 20 - +# Runs the merged unit-test suite (package unit tests + the behavioural black-box suite) via Vitest, bypassing Nx. +# Implementation: scripts/gate/gates/behave.mjs, driven by scripts/gate/main.mjs. Run `./behave.sh --help` for the flags. set -euo pipefail - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Default projects when the caller doesn't pick their own with --project (values are vitest test.names). -UNIT_PROJECTS=(ag-stack ag-grid-community ag-grid-enterprise locale behavioural) - -args=() -userChoseProjects=false # caller passed --project → run theirs instead of the defaults -runAllProjects=false # caller passed `--project all` → no filter, every workspace project - -# Reads a non-negative integer for `--opt N` or `--opt=N`, so neither spelling skips validation. -argValue='' -countArgValue() { - local arg="$1" expected="$2" - if [[ "$arg" == *=* ]]; then - argValue="${arg#*=}" - else - argValue="${argv[i + 1]:-}" - skipNext=true - fi - if [[ ! "$argValue" =~ ^[0-9]+$ ]]; then - echo "Missing or invalid value for ${arg%%=*} (expected $expected)" >&2 - exit 1 - fi -} - -argv=("$@") -skipNext=false -for ((i = 0; i < ${#argv[@]}; i++)); do - if $skipNext; then - skipNext=false - continue - fi - arg="${argv[i]}" - case "$arg" in - --update-grid-rows) - export UPDATE_GRID_ROWS_SNAPSHOTS=1 - ;; - --update-grid-rows=dry) - export UPDATE_GRID_ROWS_SNAPSHOTS=dry - ;; - --update-grid-rows=*) - echo "Unknown value: $arg (expected --update-grid-rows or --update-grid-rows=dry)" >&2 - exit 1 - ;; - --no-diff) - export AG_NO_DIFF=1 - ;; - --diff-lines | --diff-lines=*) - countArgValue "$arg" "a line count, 0 = unlimited" - export AG_DIFF_LINES="$argValue" - ;; - --stack-trace-len | --stack-trace-len=*) - countArgValue "$arg" "a frame count, e.g. 20" - # Below ~20 every inline snapshot fails "Couldn't infer stack frame". Allowed, but not silently. - # `10#` or bash reads a leading zero as octal and errors on `08`. - if ((10#$argValue < 20)); then - echo "behave.sh: --stack-trace-len $argValue may break inline snapshots (keep >= 20)" >&2 - fi - export AG_STACK_TRACE_LEN="$argValue" - ;; - --project=all) - runAllProjects=true - ;; - --project=*) - userChoseProjects=true - args+=("$arg") - ;; - --project) - next="${argv[i + 1]:-}" - if [[ -z "$next" ]]; then - echo "Missing value for --project (e.g. --project behavioural or --project all)" >&2 - exit 1 - elif [[ "$next" == "all" ]]; then - runAllProjects=true - else - userChoseProjects=true - args+=("$arg" "$next") - fi - skipNext=true - ;; - *) - args+=("$arg") - ;; - esac -done - -# Run from the repo root so Vitest picks up vitest.workspace.ts. -cd "$SCRIPT_DIR" - -# Colour is for humans: an interactive terminal or CI (whose log viewer renders ANSI). An AI agent or a -# pipe reads the escapes as noise, and vitest emits them regardless of isTTY, so say so explicitly. -if [[ -z "${NO_COLOR:-}" && -z "${FORCE_COLOR:-}" ]]; then - if [[ -n "${CLAUDECODE:-}${AI_AGENT:-}" ]] || { [[ -z "${CI:-}" ]] && [[ ! -t 1 ]]; }; then - export NO_COLOR=1 - fi -fi - -projectArgs=() -if ! $runAllProjects && ! $userChoseProjects; then - for p in "${UNIT_PROJECTS[@]}"; do - projectArgs+=(--project "$p") - done -fi - -exec npx vitest "${projectArgs[@]+"${projectArgs[@]}"}" "${args[@]+"${args[@]}"}" +exec node "$SCRIPT_DIR/scripts/gate/main.mjs" behave "$@" diff --git a/benches.sh b/benches.sh index 090531db894..d6f65a824d7 100755 --- a/benches.sh +++ b/benches.sh @@ -1,153 +1,6 @@ #!/usr/bin/env bash -# Runs behavioural benchmarks directly via Vitest, bypassing Nx. -# Benchmarks run in a real headless Chromium (Playwright) by DEFAULT, so layout-dependent work is -# measured against a real layout engine. All other arguments are forwarded to `vitest bench`. - +# Runs the behavioural benchmarks via Vitest in a real headless Chromium, bypassing Nx. +# Implementation: scripts/gate/gates/bench.mjs, driven by scripts/gate/main.mjs. Run `./benches.sh --help` for the flags. set -euo pipefail - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# Profiles live under benchmarks/tmp/ which is already git-ignored, so no separate ignore needed. -PROFILE_DIR="$SCRIPT_DIR/testing/behavioural/src/benchmarks/tmp/profiles" - -usage() { - cat <<'EOF' -Usage: ./benches.sh [pattern] [options] - - pattern A file-name pattern forwarded to `vitest bench` (e.g. "grouping-pipelines"). - Narrows the run to matching .bench.ts files. Omit to run all. - -Engine: - (default) Real headless Chromium (Playwright) — measures against a real layout engine. - --node, --jsdom Run in node/jsdom instead — faster, no layout engine. - -Modes: - -w, --watch Watch mode (re-runs on file changes). - --headed Visible Chromium, single run — watch the grid render. - --ui Visible Chromium + the Vitest dashboard at a localhost URL; starts WITHOUT - running (pick benches from the dashboard), and stays open. - --profile Node single run with a V8 CPU profile (--cpu-prof) for method-cost analysis. - Writes a .cpuprofile under benchmarks/tmp/profiles/ (printed after the run) — - open it in Chrome DevTools or speedscope. Implies --node (browser can't emit it). - --bench-compare ... Pass through to bench-compare.mjs (base/test/compare/all/backup); everything - after it is forwarded verbatim, e.g. ./benches.sh --bench-compare all --runs 3. - -h, --help Show this help. - -Anything else is forwarded verbatim to `vitest bench`. -EOF -} - -# `--bench-compare [args...]` is a thin pass-through to the bench-compare.mjs tool. Handled before the -# option loop so its sub-commands/flags (base/test/compare/all/backup, --runs, --filter, …) reach the -# script untouched. -if [ "${1:-}" = "--bench-compare" ]; then - shift - exec node "$SCRIPT_DIR/testing/behavioural/src/benchmarks/bench-compare.mjs" "$@" -fi - -# Default to --run (non-watch) unless the caller passes -w / --watch. --node/--jsdom, --headed, --ui -# and --profile are consumed here (not forwarded) and turned into the BENCH_* env vars the vitest -# config reads; everything else is forwarded to `vitest bench`. -run_flag="--run" -profile=0 -show_help=0 -node_flag=0 -browser_mode_flag=0 -forwarded=() -for arg in "$@"; do - case "$arg" in - -h | --help) - show_help=1 - ;; - -w | --watch) - run_flag="" - forwarded+=("$arg") - ;; - --node | --jsdom) - export BENCH_NODE=1 - node_flag=1 - ;; - --headed | --interactive) - export BENCH_BROWSER_HEADED=1 - run_flag="" - browser_mode_flag=1 - ;; - --ui) - # Visible browser + the Vitest dashboard (bench picker) at a localhost URL. --standalone - # starts WITHOUT running anything (pick benches from the dashboard); --watch keeps the - # server + browser alive (and is required by --standalone). CLI --watch beats config watch:false. - export BENCH_BROWSER_HEADED=1 - run_flag="" - forwarded+=("--ui" "--standalone" "--watch") - browser_mode_flag=1 - ;; - --profile) - # V8 CPU profile of the grid code. Node-only: browser mode doesn't use the forks pool the - # --cpu-prof execArgv attaches to. Single run (profiling distorts timing — not for numbers). - export BENCH_NODE=1 - export BENCH_PROFILE=1 - export BENCH_PROFILE_DIR="$PROFILE_DIR" - profile=1 - ;; - *) - forwarded+=("$arg") - ;; - esac -done - -cd "$SCRIPT_DIR/testing/behavioural" - -# --help shows vitest's own bench help first, then ours at the end so our options stay visible. -if [ "$show_help" -eq 1 ]; then - npx vitest bench --help || true - echo "" - usage - exit 0 -fi - -# --profile and --node run in node (no browser), so they can't combine with the browser-only -# --headed/--ui — fail loudly instead of silently picking node and ignoring the visible-browser flag. -if [ "$browser_mode_flag" -eq 1 ] && { [ "$profile" -eq 1 ] || [ "$node_flag" -eq 1 ]; }; then - echo "benches.sh: --headed/--ui need a real browser and can't combine with --node/--jsdom/--profile." >&2 - exit 2 -fi - -# Browser is the default, so ensure the Playwright Chromium build matching the installed `playwright` -# package is present (the launch fails otherwise). `playwright install` is a no-op when up to date. -# Skipped for --node, which needs no browser. -if [ -z "${BENCH_NODE:-}" ]; then - npx playwright install chromium chromium-headless-shell -fi - -# On macOS, run under `caffeinate -i` so a long bench run isn't throttled or interrupted by idle -# sleep / App Nap. It's a built-in (no install), propagates the child's exit status, and is absent -# elsewhere — where we just run vitest directly. -caffeinate_prefix=() -if command -v caffeinate >/dev/null 2>&1; then - caffeinate_prefix=(caffeinate -i) -fi - -# Assemble the command. `${arr[@]+"${arr[@]}"}` expands to nothing when the array is empty — avoids -# the "unbound variable" error `set -u` raises on `"${arr[@]}"` under bash 3.2 (macOS). -cmd=(${caffeinate_prefix[@]+"${caffeinate_prefix[@]}"} npx vitest bench) -if [ -n "$run_flag" ]; then - cmd+=("$run_flag") -fi -cmd+=(${forwarded[@]+"${forwarded[@]}"}) - -# Profiling needs to print the emitted .cpuprofile name afterwards, so run (not exec) and report it. -if [ "$profile" -eq 1 ]; then - mkdir -p "$PROFILE_DIR" - set +e - "${cmd[@]}" - status=$? - set -e - newest=$(ls -t "$PROFILE_DIR"/*.cpuprofile 2>/dev/null | head -1) - if [ -n "$newest" ]; then - echo "" - echo "CPU profile written: $newest" - echo "Open in Chrome DevTools (Performance → Load profile) or https://speedscope.app" - fi - exit "$status" -fi - -exec "${cmd[@]}" +exec node "$SCRIPT_DIR/scripts/gate/main.mjs" bench "$@" diff --git a/checks.sh b/checks.sh index 7ee4239cedf..c2a118db57f 100755 --- a/checks.sh +++ b/checks.sh @@ -1,152 +1,6 @@ #!/usr/bin/env bash -# Pre-commit gate: type-check + lint + spec type-check for the grid packages and the behavioural suite. -# -# Runs every task in ONE Nx invocation so they execute in parallel and hit the Nx cache — much -# faster than chaining a `yarn nx ` per gate, which re-pays Nx startup each time -# and forces the tasks to run serially. Output is suppressed unless something fails. -# -# Usage: -# ./checks.sh # Default gate (every project, matching CI) -# ./checks.sh --projects a,b # Narrow to specific projects -# ./checks.sh --targets lint # Override the target list -# ./checks.sh --fresh # Bypass the Nx cache -# ./checks.sh --warn # Print the warnings a passing run produced -# ./checks.sh --verbose # Print task output even when everything passes -# ./checks.sh # Anything else is forwarded to `nx run-many` - -set -uo pipefail - +# Pre-commit gate: type-check + lint + spec type-check for every project, in one Nx invocation. +# Implementation: scripts/gate/gates/checks.mjs, driven by scripts/gate/main.mjs. Run `./checks.sh --help` for the flags. +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$SCRIPT_DIR" - -# Empty means every project. Nx skips projects that lack a target, so the gate covers the same ground as -# CI's `yarn nx lint` rather than a hand-maintained subset that silently drifts as packages are added. -PROJECTS="" -TARGETS="build:types,lint,build:test" -verbose=false -showWarnings=false -nxArgs=() - -while [[ $# -gt 0 ]]; do - case "$1" in - --projects=*) PROJECTS="${1#*=}" ;; - --targets=*) TARGETS="${1#*=}" ;; - --projects) - PROJECTS="${2:?Missing value for --projects (comma-separated, e.g. ag-grid-community,ag-grid-enterprise)}" - shift - ;; - --targets) - TARGETS="${2:?Missing value for --targets (comma-separated, e.g. build:types,lint)}" - shift - ;; - --fresh) nxArgs+=(--skip-nx-cache) ;; - --warn) showWarnings=true ;; - --verbose) verbose=true ;; - *) nxArgs+=("$1") ;; - esac - shift -done - -# The Nx daemon deadlocks on piped stdio in agent/CI shells; a single invocation only pays graph cost once. -export NX_DAEMON=false - -# Nx has no "auto" and defaults to 3, which idles cores on a gate whose graph is ~5 tasks wide. Capped at 8 -# so small runners do not thrash on memory-hungry tsc processes; a passed --parallel=N wins (nx takes last). -cores="$(nproc 2> /dev/null || sysctl -n hw.ncpu 2> /dev/null || echo 4)" -[[ "$cores" -gt 8 ]] && cores=8 - -IFS=',' read -r -a targetArr <<< "$TARGETS" - -projectArgs=() -if [[ -n "$PROJECTS" ]]; then - IFS=',' read -r -a projectArr <<< "$PROJECTS" - projectArgs=(-p "${projectArr[@]}") -fi - -run() { - npx nx run-many -t "${targetArr[@]}" "${projectArgs[@]+"${projectArgs[@]}"}" \ - --parallel="$cores" --output-style=stream "${nxArgs[@]+"${nxArgs[@]}"}" -} - -# Kept next to the other tooling scratch (ag-watch-status.json), so a passing gate can point at its warnings -# instead of discarding them with the temp log. Rewritten by any run that has warnings to report. -warningsLog="$SCRIPT_DIR/node_modules/.cache/ag-checks-warnings.log" -warnings=0 - -# Nx colours its output, which leaves an escape sequence flush against the word "warning" and defeats any -# word-boundary match, so strip escapes before counting or printing. -stripAnsi() { - sed -E $'s/\033\\[[0-9;]*[a-zA-Z]//g' "$1" -} - -# Sums ESLint's own per-project totals. Counting matching lines instead would miss wrapped messages and -# double-count the "N warnings potentially fixable" footer. -countWarnings() { - stripAnsi "$1" | - sed -nE 's/.*[0-9]+ problems? \([0-9]+ errors?, ([0-9]+) warnings?\).*/\1/p' | - awk '{ total += $1 } END { print total + 0 }' -} - -# Reprinted at the end so the failing tasks are the last thing on screen rather than lost up the stream. -# Two sources because neither is complete on its own: Nx marks each failure inline as "✖ nx run ", -# while its closing bullet list is capped at a handful of tasks but survives an interleaved stream. -failedTasks() { - stripAnsi "$1" | awk ' - /^[[:space:]]*✖[[:space:]]+nx run / { sub(/^[[:space:]]*✖[[:space:]]+nx run[[:space:]]+/, ""); sub(/ .*/, ""); print; next } - /^[[:space:]]*(Failed tasks:|✖[[:space:]]+[0-9]+\/[0-9]+ targets failed)/ { inList = 1; next } - # Only bullets that look like a task id, or a task own output can pose as Nx summary list. - inList && /^[[:space:]]*-[[:space:]]/ { - sub(/^[[:space:]]*-[[:space:]]*/, "") - sub(/^nx run[[:space:]]+/, "") - if ($0 ~ /^[A-Za-z0-9@._\/-]+:[A-Za-z0-9:._-]+$/) print - next - } - inList && NF { inList = 0 } - ' | sort -u -} - -start=$SECONDS - -# An explicit XXXXXX template: GNU mktemp rejects `-t ag-checks`, and with errexit off that failure -# would silently redirect into an empty path and fail the gate before Nx ever runs. -log="$(mktemp "${TMPDIR:-/tmp}/ag-checks.XXXXXX")" || exit 1 -trap 'rm -f "$log"' EXIT - -# Never a pipe: node flushes pipes asynchronously, so nx exits mid-write and loses most of its output. -run > "$log" 2>&1 -status=$? - -if [[ $status -ne 0 ]] || $verbose; then - cat "$log" -fi - -if [[ $status -eq 0 ]]; then - warnings="$(countWarnings "$log")" - # A failed write must not leave the summary pointing at an absent log, or at a previous run's. - if [[ "$warnings" -gt 0 ]] && ! { mkdir -p "$(dirname "$warningsLog")" && stripAnsi "$log" > "$warningsLog"; }; then - warningsLog="" - fi -fi - -elapsed=$((SECONDS - start)) -summary="targets: ${TARGETS} | projects: ${PROJECTS:-all}" - -if [[ $status -eq 0 && "$warnings" -gt 0 ]]; then - echo "CHECKS-PASSED (${elapsed}s) — ${summary}" - if [[ -z "$warningsLog" ]]; then - echo " ${warnings} warnings (could not be written to disk)" - elif $showWarnings; then - # Keep the file-path lines ESLint prints above each block, or the rows say nothing about where. - # Nx prefixes streamed lines with the task name, so the path is not always at the start of a line. - grep -E '[0-9]+:[0-9]+[[:space:]]+warning|problems? \(|^([^:]+: )?/.*\.(ts|tsx|js|jsx|mjs|cjs|vue|astro)$' "$warningsLog" - echo " ${warnings} warnings: ${warningsLog}" - else - echo " ${warnings} warnings (run with --warn to print them): ${warningsLog}" - fi -elif [[ $status -eq 0 ]]; then - echo "CHECKS-PASSED (${elapsed}s) — ${summary}" -else - echo "CHECKS-FAILED (${elapsed}s) — ${summary}" >&2 - failedTasks "$log" | sed 's/^/ failed: /' >&2 -fi -exit $status +exec node "$SCRIPT_DIR/scripts/gate/main.mjs" checks "$@" diff --git a/community-modules/locale/vitest.config.ts b/community-modules/locale/vitest.config.ts index ce1c2779192..f0172db416e 100644 --- a/community-modules/locale/vitest.config.ts +++ b/community-modules/locale/vitest.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from 'vitest/config'; -import { unitProjectTestConfig } from '../../vitest.shared'; +import { unitProjectTestConfig } from '../../testing/shared/vitest/shared'; export default defineConfig({ test: unitProjectTestConfig({ diff --git a/docs-e2e.sh b/docs-e2e.sh index 65170f450f0..0e85817d23d 100755 --- a/docs-e2e.sh +++ b/docs-e2e.sh @@ -1,121 +1,6 @@ #!/usr/bin/env bash -# Runs docs Playwright e2e tests directly, bypassing Nx. -# All arguments are forwarded to playwright. Defaults to chromium only. -# -# Usage: -# ./docs-e2e.sh # Run all tests (chromium) -# ./docs-e2e.sh "file-pattern" # Run tests matching pattern -# ./docs-e2e.sh "file-pattern" --grep "name" # Run specific test by name -# ./docs-e2e.sh --all-browsers # Run all browsers -# ./docs-e2e.sh --framework reactFunctionalTs # Run with specific framework -# ./docs-e2e.sh --framework reactFunctionalTs_Dev # Only the React development-build tests -# ./docs-e2e.sh --url https://localhost:4610 # Run against specific URL -# ./docs-e2e.sh --headed # Run in headed mode -# ./docs-e2e.sh --ui # Open Playwright UI mode -# ./docs-e2e.sh --debug # Debug mode -# ./docs-e2e.sh --last-failed # Re-run only the tests that failed last time -# -# Iterate-until-green loop (re-run only failures each pass): -# ./docs-e2e.sh # initial full run records failures -# ./docs-e2e.sh --last-failed # repeat after each fix until it passes - +# Runs the docs Playwright e2e tests, bypassing Nx. +# Implementation: scripts/gate/gates/docs-e2e.mjs, driven by scripts/gate/main.mjs. Run `./docs-e2e.sh --help` for the flags. set -euo pipefail - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -usage() { - cat <<'EOF' -Usage: ./docs-e2e.sh [options] [playwright-args] - -Runs docs Playwright e2e tests directly, bypassing Nx. Defaults to chromium only. -Any unrecognised arguments are forwarded directly to playwright test. - -Options: - --all-browsers Run all browsers (chromium, firefox, webkit) - --framework Set FRAMEWORK env var. Valid: typescript, vanilla, - reactFunctionalTs, reactFunctionalTs_Dev, angular, vue3. - Mirrors a CI shard, so reactFunctionalTs covers both React - builds: every example on the production one, plus the tests - naming reactFunctionalTs_Dev outright. Pin that instead to - run only those. - --url Set BASE_URL env var (default: https://localhost:4610) - --all-variants Run every example against the production React variant too (or - ALL_FRAMEWORK_VARIANTS=true). By default examples run on one - React build: development locally, production in CI. Tests - naming a framework outright always run and are unaffected. - --help Show this help message - -Playwright options (forwarded as-is): - "file-pattern" Run tests matching pattern - --grep Run tests matching name - --project Run specific browser project - --headed Run in headed mode - --ui Open Playwright UI mode - --debug Debug mode - --last-failed Re-run only the tests that failed in the previous run - -Examples: - ./docs-e2e.sh - ./docs-e2e.sh "toolbar" - ./docs-e2e.sh "toolbar" --grep "Quick filter" - ./docs-e2e.sh --all-browsers - ./docs-e2e.sh --framework reactFunctionalTs - ./docs-e2e.sh --url https://localhost:4610 - ./docs-e2e.sh --headed - ./docs-e2e.sh --ui - -Iterate-until-green loop (re-run only failures each pass): - ./docs-e2e.sh # initial run records failures to .last-run.json - # ...fix a failing test... - ./docs-e2e.sh --last-failed # re-runs only the failures; repeat until it passes -EOF -} - -ALL_BROWSERS=false -args=() - -while [[ $# -gt 0 ]]; do - case "$1" in - --help|-h) - usage - exit 0 - ;; - --all-browsers) - ALL_BROWSERS=true - shift - ;; - --framework=*) - export FRAMEWORK="${1#--framework=}" - shift - ;; - --framework) - export FRAMEWORK="$2" - shift 2 - ;; - --url=*) - export BASE_URL="${1#--url=}" - shift - ;; - --url) - export BASE_URL="$2" - shift 2 - ;; - --all-variants) - export ALL_FRAMEWORK_VARIANTS=true - shift - ;; - *) - args+=("$1") - shift - ;; - esac -done - -# Default to chromium unless --all-browsers or --project already specified -if [ "$ALL_BROWSERS" = false ] && [[ ! " ${args[*]+"${args[*]}"} " =~ "--project" ]]; then - args+=("--project=chromium") -fi - -cd "$SCRIPT_DIR/documentation/ag-grid-docs" - -exec npx playwright test "${args[@]+"${args[@]}"}" +exec node "$SCRIPT_DIR/scripts/gate/main.mjs" docs-e2e "$@" diff --git a/documentation/ag-grid-docs/src/components/snippet/__snapshots__/snippetTransformer.test.ts.snap b/documentation/ag-grid-docs/src/components/snippet/__snapshots__/snippetTransformer.test.ts.snap index d2232bd3e03..869d1031a65 100644 --- a/documentation/ag-grid-docs/src/components/snippet/__snapshots__/snippetTransformer.test.ts.snap +++ b/documentation/ag-grid-docs/src/components/snippet/__snapshots__/snippetTransformer.test.ts.snap @@ -2546,478 +2546,6 @@ const myValueFormatter = params => { };" `; -exports[`Snippet Component > given optional chaining and nullish coalescing > it should create 'angular' snippets 1`] = ` -" - -const noteStore = new Map(); -const noteKey = (rowId, colId) => \`\${rowId}-\${colId}\`; - -this.getRowId = (params) => String(params.data.id); -this.notesDataSource = { - getNote: ({ rowNode, column }) => noteStore.get(noteKey(rowNode.id, column.getColId())), - setNote: ({ rowNode, column, note }) => { - const key = noteKey(rowNode.id, column.getColId()); - const existingNote = noteStore.get(key); - - if (note === undefined) { - noteStore.delete(key); - } else { - noteStore.set(key, { - ...existingNote, - ...note, - author: getCurrentUser(), - createdAt: existingNote?.createdAt ?? getDisplayTimestamp(), - updatedAt: getDisplayTimestamp(), - }); - } - }, -};" -`; - -exports[`Snippet Component > given optional chaining and nullish coalescing > it should create 'angular' snippets with space between properties 1`] = ` -" - -const noteStore = new Map(); -const noteKey = (rowId, colId) => \`\${rowId}-\${colId}\`; - -this.getRowId = (params) => String(params.data.id); - -this.notesDataSource = { - getNote: ({ rowNode, column }) => noteStore.get(noteKey(rowNode.id, column.getColId())), - setNote: ({ rowNode, column, note }) => { - const key = noteKey(rowNode.id, column.getColId()); - const existingNote = noteStore.get(key); - - if (note === undefined) { - noteStore.delete(key); - } else { - noteStore.set(key, { - ...existingNote, - ...note, - author: getCurrentUser(), - createdAt: existingNote?.createdAt ?? getDisplayTimestamp(), - updatedAt: getDisplayTimestamp(), - }); - } - }, -};" -`; - -exports[`Snippet Component > given optional chaining and nullish coalescing > it should create 'angular' snippets without framework context 1`] = ` -"const noteStore = new Map(); -const noteKey = (rowId, colId) => \`\${rowId}-\${colId}\`; - -this.getRowId = (params) => String(params.data.id); -this.notesDataSource = { - getNote: ({ rowNode, column }) => noteStore.get(noteKey(rowNode.id, column.getColId())), - setNote: ({ rowNode, column, note }) => { - const key = noteKey(rowNode.id, column.getColId()); - const existingNote = noteStore.get(key); - - if (note === undefined) { - noteStore.delete(key); - } else { - noteStore.set(key, { - ...existingNote, - ...note, - author: getCurrentUser(), - createdAt: existingNote?.createdAt ?? getDisplayTimestamp(), - updatedAt: getDisplayTimestamp(), - }); - } - }, -};" -`; - -exports[`Snippet Component > given optional chaining and nullish coalescing > it should create 'angular' snippets without framework context and space between properties 1`] = ` -"const noteStore = new Map(); -const noteKey = (rowId, colId) => \`\${rowId}-\${colId}\`; - -this.getRowId = (params) => String(params.data.id); - -this.notesDataSource = { - getNote: ({ rowNode, column }) => noteStore.get(noteKey(rowNode.id, column.getColId())), - setNote: ({ rowNode, column, note }) => { - const key = noteKey(rowNode.id, column.getColId()); - const existingNote = noteStore.get(key); - - if (note === undefined) { - noteStore.delete(key); - } else { - noteStore.set(key, { - ...existingNote, - ...note, - author: getCurrentUser(), - createdAt: existingNote?.createdAt ?? getDisplayTimestamp(), - updatedAt: getDisplayTimestamp(), - }); - } - }, -};" -`; - -exports[`Snippet Component > given optional chaining and nullish coalescing > it should create 'javascript' snippets 1`] = ` -"const noteStore = new Map(); -const noteKey = (rowId, colId) => \`\${rowId}-\${colId}\`; - -const gridOptions = { - getRowId: (params) => String(params.data.id), - notesDataSource: { - getNote: ({ rowNode, column }) => noteStore.get(noteKey(rowNode.id, column.getColId())), - setNote: ({ rowNode, column, note }) => { - const key = noteKey(rowNode.id, column.getColId()); - const existingNote = noteStore.get(key); - - if (note === undefined) { - noteStore.delete(key); - } else { - noteStore.set(key, { - ...existingNote, - ...note, - author: getCurrentUser(), - createdAt: existingNote?.createdAt ?? getDisplayTimestamp(), - updatedAt: getDisplayTimestamp(), - }); - } - }, - }, - - // other grid options ... -}" -`; - -exports[`Snippet Component > given optional chaining and nullish coalescing > it should create 'javascript' snippets with space between properties 1`] = ` -"const noteStore = new Map(); -const noteKey = (rowId, colId) => \`\${rowId}-\${colId}\`; - -const gridOptions = { - getRowId: (params) => String(params.data.id), - - notesDataSource: { - getNote: ({ rowNode, column }) => noteStore.get(noteKey(rowNode.id, column.getColId())), - setNote: ({ rowNode, column, note }) => { - const key = noteKey(rowNode.id, column.getColId()); - const existingNote = noteStore.get(key); - - if (note === undefined) { - noteStore.delete(key); - } else { - noteStore.set(key, { - ...existingNote, - ...note, - author: getCurrentUser(), - createdAt: existingNote?.createdAt ?? getDisplayTimestamp(), - updatedAt: getDisplayTimestamp(), - }); - } - }, - }, - - // other grid options ... -}" -`; - -exports[`Snippet Component > given optional chaining and nullish coalescing > it should create 'javascript' snippets without framework context 1`] = ` -"const noteStore = new Map(); -const noteKey = (rowId, colId) => \`\${rowId}-\${colId}\`; - - -getRowId: (params) => String(params.data.id), -notesDataSource: { - getNote: ({ rowNode, column }) => noteStore.get(noteKey(rowNode.id, column.getColId())), - setNote: ({ rowNode, column, note }) => { - const key = noteKey(rowNode.id, column.getColId()); - const existingNote = noteStore.get(key); - - if (note === undefined) { - noteStore.delete(key); - } else { - noteStore.set(key, { - ...existingNote, - ...note, - author: getCurrentUser(), - createdAt: existingNote?.createdAt ?? getDisplayTimestamp(), - updatedAt: getDisplayTimestamp(), - }); - } - }, -}," -`; - -exports[`Snippet Component > given optional chaining and nullish coalescing > it should create 'javascript' snippets without framework context and space between properties 1`] = ` -"const noteStore = new Map(); -const noteKey = (rowId, colId) => \`\${rowId}-\${colId}\`; - - -getRowId: (params) => String(params.data.id), - -notesDataSource: { - getNote: ({ rowNode, column }) => noteStore.get(noteKey(rowNode.id, column.getColId())), - setNote: ({ rowNode, column, note }) => { - const key = noteKey(rowNode.id, column.getColId()); - const existingNote = noteStore.get(key); - - if (note === undefined) { - noteStore.delete(key); - } else { - noteStore.set(key, { - ...existingNote, - ...note, - author: getCurrentUser(), - createdAt: existingNote?.createdAt ?? getDisplayTimestamp(), - updatedAt: getDisplayTimestamp(), - }); - } - }, -}," -`; - -exports[`Snippet Component > given optional chaining and nullish coalescing > it should create 'react' snippets 1`] = ` -"const noteStore = new Map(); -const noteKey = (rowId, colId) => \`\${rowId}-\${colId}\`; -const getRowId = useCallback((params) => String(params.data.id), []); -const notesDataSource = { - getNote: ({ rowNode, column }) => noteStore.get(noteKey(rowNode.id, column.getColId())), - setNote: ({ rowNode, column, note }) => { - const key = noteKey(rowNode.id, column.getColId()); - const existingNote = noteStore.get(key); - - if (note === undefined) { - noteStore.delete(key); - } else { - noteStore.set(key, { - ...existingNote, - ...note, - author: getCurrentUser(), - createdAt: existingNote?.createdAt ?? getDisplayTimestamp(), - updatedAt: getDisplayTimestamp(), - }); - } - }, -}; - -" -`; - -exports[`Snippet Component > given optional chaining and nullish coalescing > it should create 'react' snippets with space between properties 1`] = ` -"const noteStore = new Map(); -const noteKey = (rowId, colId) => \`\${rowId}-\${colId}\`; -const getRowId = useCallback((params) => String(params.data.id), []); - -const notesDataSource = { - getNote: ({ rowNode, column }) => noteStore.get(noteKey(rowNode.id, column.getColId())), - setNote: ({ rowNode, column, note }) => { - const key = noteKey(rowNode.id, column.getColId()); - const existingNote = noteStore.get(key); - - if (note === undefined) { - noteStore.delete(key); - } else { - noteStore.set(key, { - ...existingNote, - ...note, - author: getCurrentUser(), - createdAt: existingNote?.createdAt ?? getDisplayTimestamp(), - updatedAt: getDisplayTimestamp(), - }); - } - }, -}; - -" -`; - -exports[`Snippet Component > given optional chaining and nullish coalescing > it should create 'react' snippets without framework context 1`] = ` -"const noteStore = new Map(); -const noteKey = (rowId, colId) => \`\${rowId}-\${colId}\`; -const getRowId = useCallback((params) => String(params.data.id), []); -const notesDataSource = { - getNote: ({ rowNode, column }) => noteStore.get(noteKey(rowNode.id, column.getColId())), - setNote: ({ rowNode, column, note }) => { - const key = noteKey(rowNode.id, column.getColId()); - const existingNote = noteStore.get(key); - - if (note === undefined) { - noteStore.delete(key); - } else { - noteStore.set(key, { - ...existingNote, - ...note, - author: getCurrentUser(), - createdAt: existingNote?.createdAt ?? getDisplayTimestamp(), - updatedAt: getDisplayTimestamp(), - }); - } - }, -}; - -" -`; - -exports[`Snippet Component > given optional chaining and nullish coalescing > it should create 'react' snippets without framework context and space between properties 1`] = ` -"const noteStore = new Map(); -const noteKey = (rowId, colId) => \`\${rowId}-\${colId}\`; -const getRowId = useCallback((params) => String(params.data.id), []); - -const notesDataSource = { - getNote: ({ rowNode, column }) => noteStore.get(noteKey(rowNode.id, column.getColId())), - setNote: ({ rowNode, column, note }) => { - const key = noteKey(rowNode.id, column.getColId()); - const existingNote = noteStore.get(key); - - if (note === undefined) { - noteStore.delete(key); - } else { - noteStore.set(key, { - ...existingNote, - ...note, - author: getCurrentUser(), - createdAt: existingNote?.createdAt ?? getDisplayTimestamp(), - updatedAt: getDisplayTimestamp(), - }); - } - }, -}; - -" -`; - -exports[`Snippet Component > given optional chaining and nullish coalescing > it should create 'vue' snippets 1`] = ` -" - - -const noteStore = new Map(); -const noteKey = (rowId, colId) => \`\${rowId}-\${colId}\`; - -this.getRowId = (params) => String(params.data.id); -this.notesDataSource = { - getNote: ({ rowNode, column }) => noteStore.get(noteKey(rowNode.id, column.getColId())), - setNote: ({ rowNode, column, note }) => { - const key = noteKey(rowNode.id, column.getColId()); - const existingNote = noteStore.get(key); - - if (note === undefined) { - noteStore.delete(key); - } else { - noteStore.set(key, { - ...existingNote, - ...note, - author: getCurrentUser(), - createdAt: existingNote?.createdAt ?? getDisplayTimestamp(), - updatedAt: getDisplayTimestamp(), - }); - } - }, -};" -`; - -exports[`Snippet Component > given optional chaining and nullish coalescing > it should create 'vue' snippets with space between properties 1`] = ` -" - - -const noteStore = new Map(); -const noteKey = (rowId, colId) => \`\${rowId}-\${colId}\`; - -this.getRowId = (params) => String(params.data.id); - -this.notesDataSource = { - getNote: ({ rowNode, column }) => noteStore.get(noteKey(rowNode.id, column.getColId())), - setNote: ({ rowNode, column, note }) => { - const key = noteKey(rowNode.id, column.getColId()); - const existingNote = noteStore.get(key); - - if (note === undefined) { - noteStore.delete(key); - } else { - noteStore.set(key, { - ...existingNote, - ...note, - author: getCurrentUser(), - createdAt: existingNote?.createdAt ?? getDisplayTimestamp(), - updatedAt: getDisplayTimestamp(), - }); - } - }, -};" -`; - -exports[`Snippet Component > given optional chaining and nullish coalescing > it should create 'vue' snippets without framework context 1`] = ` -"const noteStore = new Map(); -const noteKey = (rowId, colId) => \`\${rowId}-\${colId}\`; - -this.getRowId = (params) => String(params.data.id); -this.notesDataSource = { - getNote: ({ rowNode, column }) => noteStore.get(noteKey(rowNode.id, column.getColId())), - setNote: ({ rowNode, column, note }) => { - const key = noteKey(rowNode.id, column.getColId()); - const existingNote = noteStore.get(key); - - if (note === undefined) { - noteStore.delete(key); - } else { - noteStore.set(key, { - ...existingNote, - ...note, - author: getCurrentUser(), - createdAt: existingNote?.createdAt ?? getDisplayTimestamp(), - updatedAt: getDisplayTimestamp(), - }); - } - }, -};" -`; - -exports[`Snippet Component > given optional chaining and nullish coalescing > it should create 'vue' snippets without framework context and space between properties 1`] = ` -"const noteStore = new Map(); -const noteKey = (rowId, colId) => \`\${rowId}-\${colId}\`; - -this.getRowId = (params) => String(params.data.id); - -this.notesDataSource = { - getNote: ({ rowNode, column }) => noteStore.get(noteKey(rowNode.id, column.getColId())), - setNote: ({ rowNode, column, note }) => { - const key = noteKey(rowNode.id, column.getColId()); - const existingNote = noteStore.get(key); - - if (note === undefined) { - noteStore.delete(key); - } else { - noteStore.set(key, { - ...existingNote, - ...note, - author: getCurrentUser(), - createdAt: existingNote?.createdAt ?? getDisplayTimestamp(), - updatedAt: getDisplayTimestamp(), - }); - } - }, -};" -`; - exports[`Snippet Component > given simple column definitions > it should create 'angular' snippets 1`] = ` "esbuild#0.27.7": true, "vite>esbuild#0.21.5": true, "vite>sass>@parcel/watcher#2.5.1": true, - "vitest>jsdom>canvas#3.2.3": true, - "@vitest/browser>msw#2.14.6": false + "vitest>jsdom>canvas#3.2.3": true } } } diff --git a/packages/ag-grid-community/.npmignore b/packages/ag-grid-community/.npmignore index 812f05ac7e1..e880de114b5 100644 --- a/packages/ag-grid-community/.npmignore +++ b/packages/ag-grid-community/.npmignore @@ -21,7 +21,6 @@ knip.json jest.config.ts jest.setup.js jest.setup.ts -jest.jsdom-env.cjs vitest.config.ts vitest.umd.config.ts vitest.setup.ts diff --git a/packages/ag-grid-community/src/columnMove/columnDrag/moveColumnFeature.ts b/packages/ag-grid-community/src/columnMove/columnDrag/moveColumnFeature.ts index 842f3e1fdf3..4a578e424db 100644 --- a/packages/ag-grid-community/src/columnMove/columnDrag/moveColumnFeature.ts +++ b/packages/ag-grid-community/src/columnMove/columnDrag/moveColumnFeature.ts @@ -1,4 +1,4 @@ -import { _exists, _last, _missing } from 'ag-stack'; +import { FAST_TEST_TIMINGS, _exists, _last, _missing } from 'ag-stack'; import { _setColsVisible } from '../../columns/columnStateUtils'; import { BeanStub } from '../../context/beanStub'; @@ -23,7 +23,8 @@ const MOVE_FAIL_THRESHOLD = 7; const SCROLL_MOVE_WIDTH = 100; const SCROLL_GAP_NEEDED_BEFORE_MOVE = SCROLL_MOVE_WIDTH / 2; const SCROLL_ACCELERATION_RATE = 5; -const SCROLL_TIME_INTERVAL = 100; +/** Tick of the hold-at-the-edge loop that scrolls, then pins once scrolling can go no further. */ +const SCROLL_TIME_INTERVAL = FAST_TEST_TIMINGS ? 20 : 100; export class MoveColumnFeature extends BeanStub implements DropListener { private gridBodyCon: GridBodyCtrl; diff --git a/packages/ag-grid-community/src/columns/columnViewportService.ts b/packages/ag-grid-community/src/columns/columnViewportService.ts index 34431ad79cc..b4498fabadc 100644 --- a/packages/ag-grid-community/src/columns/columnViewportService.ts +++ b/packages/ag-grid-community/src/columns/columnViewportService.ts @@ -125,8 +125,8 @@ export class ColumnViewportService extends BeanStub implements NamedBean { } private isColumnVirtualisationSuppressed() { - // When running within jsdom the viewportRight is always 0, so we need to return true to allow - // tests to validate all the columns. + // Without a layout engine (headless tests) the viewportRight is always 0, so return true to + // allow tests to validate all the columns. return this.suppressColumnVirtualisation || this.viewportRight === 0; } diff --git a/packages/ag-grid-community/src/filter/provided/date/dateCompWrapper.ts b/packages/ag-grid-community/src/filter/provided/date/dateCompWrapper.ts index af2682166ee..98efdea0f3f 100644 --- a/packages/ag-grid-community/src/filter/provided/date/dateCompWrapper.ts +++ b/packages/ag-grid-community/src/filter/provided/date/dateCompWrapper.ts @@ -1,4 +1,4 @@ -import { _debounce, _setAriaInvalid, _setDisplayed } from 'ag-stack'; +import { FAST_TEST_TIMINGS, _debounce, _setAriaInvalid, _setDisplayed } from 'ag-stack'; import { _getDateCompDetails } from '../../../components/framework/userCompUtils'; import type { UserComponentFactory } from '../../../components/framework/userComponentFactory'; @@ -20,6 +20,9 @@ const CLASS_INPUT_FIELD = '.ag-input-field-input'; */ export type ValidationReportMode = 'immediate' | 'debounce' | 'debounceIfChanged'; +/** Long enough that the native validation bubble isn't re-shown while the user is still typing a date. */ +const REPORT_DEBOUNCE = FAST_TEST_TIMINGS ? 0 : 500; + /** Provides sync access to async component. Date component can be lazy created - this class encapsulates * this by keeping value locally until DateComp has loaded, then passing DateComp the value. */ export class DateCompWrapper { @@ -27,7 +30,7 @@ export class DateCompWrapper { private tempValue: Date | null; private disabled: boolean | null; private alive = true; - private readonly debouncedReport = _debounce({ isAlive: () => this.alive }, reportValidity, 500); + private readonly debouncedReport = _debounce({ isAlive: () => this.alive }, reportValidity, REPORT_DEBOUNCE); private timeoutHandle: number | null = null; private lastValidityMessage: string | null = null; diff --git a/packages/ag-grid-community/src/gridBodyComp/viewportSizeFeature.test.ts b/packages/ag-grid-community/src/gridBodyComp/viewportSizeFeature.test.ts index 99b952ab490..a27ee569d10 100644 --- a/packages/ag-grid-community/src/gridBodyComp/viewportSizeFeature.test.ts +++ b/packages/ag-grid-community/src/gridBodyComp/viewportSizeFeature.test.ts @@ -1,16 +1,13 @@ -import { _observeResize, _requestAnimationFrame } from 'ag-stack'; -import type { Mock } from 'vitest'; +import * as agStack from 'ag-stack'; +import type { Mock, MockInstance } from 'vitest'; import { ViewportSizeFeature } from './viewportSizeFeature'; -vi.mock('ag-stack', async () => { - const actual = await vi.importActual('ag-stack'); - return { - ...actual, - _observeResize: vi.fn(), - _requestAnimationFrame: vi.fn((_beans: unknown, callback: () => void) => callback()), - }; -}); +// Spies, not `vi.mock`: a module mock only lands when this file owns its module graph, which is not +// guaranteed — another file in the same worker may already have imported `ag-stack` unmocked. Spying +// replaces the live binding the subject reads through, so it holds either way. +let _observeResize: MockInstance; +let _requestAnimationFrame: MockInstance; function createFakeFeature(params: { centerContainer: HTMLDivElement; @@ -41,7 +38,14 @@ function createFakeFeature(params: { describe('ViewportSizeFeature', () => { beforeEach(() => { - vi.clearAllMocks(); + _observeResize = vi.spyOn(agStack, '_observeResize').mockImplementation(() => () => undefined); + _requestAnimationFrame = vi + .spyOn(agStack, '_requestAnimationFrame') + .mockImplementation((_beans: any, callback: () => void) => callback()); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); test('listens to center container resize and refreshes scroll visibility', () => { diff --git a/packages/ag-grid-community/src/rendering/overlays/overlayService.ts b/packages/ag-grid-community/src/rendering/overlays/overlayService.ts index 59f1939d159..cea11c227b0 100644 --- a/packages/ag-grid-community/src/rendering/overlays/overlayService.ts +++ b/packages/ag-grid-community/src/rendering/overlays/overlayService.ts @@ -1,4 +1,4 @@ -import { AgPromise } from 'ag-stack'; +import { AgPromise, FAST_TEST_TIMINGS } from 'ag-stack'; import type { NamedBean } from '../../context/bean'; import { BeanStub } from '../../context/beanStub'; @@ -12,6 +12,11 @@ import type { ComponentSelector } from '../../widgets/component'; import type { IOverlayComp, OverlayType } from './overlayComponent'; import { OverlayWrapperComponent, OverlayWrapperSelector } from './overlayWrapperComponent'; +/** A floor on how long the export overlay stays up, so a fast export doesn't flash it. Shortened rather + * than removed under the test flag: a test still has to be able to observe the overlay before it goes, + * and one `waitFor` poll on a saturated worker pool is already 50ms. */ +const MIN_EXPORT_OVERLAY_SHOW_TIME = FAST_TEST_TIMINGS ? 150 : 300; + const overlayCompTypeOptionalMethods = ['refresh']; const overlayCompType = (name: string): ComponentType => ({ name, optionalMethods: overlayCompTypeOptionalMethods }); @@ -292,9 +297,8 @@ export class OverlayService extends BeanStub implements NamedBean { try { heavyOperation(); } finally { - // We apply a minimum show time of 300ms to avoid fast exports having a flicker of the overlay const elapsed = Date.now() - shownAt; - const remaining = Math.max(0, 300 - elapsed); + const remaining = Math.max(0, MIN_EXPORT_OVERLAY_SHOW_TIME - elapsed); const clearExportOverlay = () => { this.exportsInProgress--; diff --git a/packages/ag-grid-community/src/validation/enableDevValidations.test.ts b/packages/ag-grid-community/src/validation/enableDevValidations.test.ts index 41f4d89c8a0..662d2937a6e 100644 --- a/packages/ag-grid-community/src/validation/enableDevValidations.test.ts +++ b/packages/ag-grid-community/src/validation/enableDevValidations.test.ts @@ -1,14 +1,13 @@ import type { MockInstance } from 'vitest'; -import { AllCommunityModule } from '../allCommunityModule'; -import { createGrid } from '../grid'; -import { enableDevValidations } from './validationModule'; - // Lives as a package unit test rather than in the behavioural suite: the behavioural global setup opts // every test into dev validations before it runs, whereas this pins the *default-off* contract — that // AllCommunityModule alone leaves validation disabled until enableDevValidations() is called — so it must -// run where that hook is absent. Registration is process-global, so the before/after assertions run in -// order within one test (Vitest isolates module state per file). +// run where that hook is absent. +// +// Module registration is process-global and one-way, so "off" is only observable in a module graph nobody +// has opted in yet. `vi.resetModules()` + dynamic import buys that outright, rather than depending on the +// runner isolating each file — which it does not have to do, and does not when `isolate` is false. describe('enableDevValidations', () => { let consoleWarnSpy: MockInstance; @@ -22,7 +21,14 @@ describe('enableDevValidations', () => { vi.restoreAllMocks(); }); - test('validations are off until opted into, then on after enableDevValidations()', () => { + test('validations are off until opted into, then on after enableDevValidations()', async () => { + vi.resetModules(); + const [{ AllCommunityModule }, { createGrid }, { enableDevValidations }] = await Promise.all([ + import('../allCommunityModule'), + import('../grid'), + import('./validationModule'), + ]); + const invalidOptions = { columnDefs: [], rowData: [], diff --git a/packages/ag-grid-community/src/validation/logging.test.ts b/packages/ag-grid-community/src/validation/logging.test.ts index 072e22ce5ae..2d779eec0fc 100644 --- a/packages/ag-grid-community/src/validation/logging.test.ts +++ b/packages/ag-grid-community/src/validation/logging.test.ts @@ -1,4 +1,6 @@ -import { _errorOnce, _warnOnce } from '../utils/log'; +import type { MockInstance } from 'vitest'; + +import * as logModule from '../utils/log'; import type { CapturedDiagnostic, MissingModuleReportParams } from './logging'; import { _addDiagnosticListener, @@ -18,13 +20,11 @@ import { } from './logging'; import { _applyDevValidationConfig, _enableDiagnosticCapture } from './validationConfig'; -vi.mock('../utils/log', () => ({ - _warnOnce: vi.fn(), - _errorOnce: vi.fn(), -})); - -const mockWarnOnce = vi.mocked(_warnOnce); -const mockErrorOnce = vi.mocked(_errorOnce); +// Spies, not `vi.mock`: a module mock only lands when this file owns its module graph, which is not +// guaranteed — another file in the same worker may already have imported `../utils/log` unmocked. Spying +// replaces the live binding `logging.ts` calls through, so it holds either way. +let mockWarnOnce: MockInstance; +let mockErrorOnce: MockInstance; // Attaches a page-level listener (no grid id) that receives every captured diagnostic function listenAll(listener: (diagnostic: CapturedDiagnostic) => void): () => void { @@ -38,10 +38,15 @@ function resetDiagnostics(): void { } beforeEach(() => { - vi.clearAllMocks(); + mockWarnOnce = vi.spyOn(logModule, '_warnOnce').mockImplementation(() => undefined); + mockErrorOnce = vi.spyOn(logModule, '_errorOnce').mockImplementation(() => undefined); resetDiagnostics(); }); +afterEach(() => { + vi.restoreAllMocks(); +}); + describe('diagnostic capture', () => { test('does not buffer or notify listeners when capture is disabled', () => { const listener = vi.fn(); diff --git a/packages/ag-grid-community/vitest.config.ts b/packages/ag-grid-community/vitest.config.ts index 2f8325870d8..9728ba793c6 100644 --- a/packages/ag-grid-community/vitest.config.ts +++ b/packages/ag-grid-community/vitest.config.ts @@ -1,10 +1,10 @@ import path from 'path'; import { defineConfig } from 'vitest/config'; -import { packageSourceAliases, unitProjectTestConfig } from '../../vitest.shared'; +import { packageSourceAliases, unitProjectTestConfig } from '../../testing/shared/vitest/shared'; export default defineConfig(async () => ({ - resolve: { alias: await packageSourceAliases(path.resolve(__dirname, '..')) }, + resolve: { alias: await packageSourceAliases(path.resolve(__dirname, '../..')) }, test: unitProjectTestConfig({ name: 'ag-grid-community', junitFile: '../../reports/ag-grid-community.xml', diff --git a/packages/ag-grid-community/vitest.umd.config.ts b/packages/ag-grid-community/vitest.umd.config.ts index 4889be5e6fa..8fba6825ac6 100644 --- a/packages/ag-grid-community/vitest.umd.config.ts +++ b/packages/ag-grid-community/vitest.umd.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + pool: 'threads', globals: true, include: ['e2e/**/*.test.ts'], watch: false, diff --git a/packages/ag-grid-enterprise/.npmignore b/packages/ag-grid-enterprise/.npmignore index c890b1c6201..72e539d82bb 100644 --- a/packages/ag-grid-enterprise/.npmignore +++ b/packages/ag-grid-enterprise/.npmignore @@ -21,7 +21,6 @@ knip.json jest.config.ts jest.setup.ts jest.setup.js -jest.jsdom-env.cjs vitest.config.ts vitest.umd.config.ts vitest.setup.ts diff --git a/packages/ag-grid-enterprise/eslint.config.mjs b/packages/ag-grid-enterprise/eslint.config.mjs index 5b9f4cc7110..b9e6271bd01 100644 --- a/packages/ag-grid-enterprise/eslint.config.mjs +++ b/packages/ag-grid-enterprise/eslint.config.mjs @@ -106,7 +106,6 @@ export default [ 'webpack.config.js', 'jest.*.js', 'eslint.config.mjs', - 'jest.jsdom-env.cjs', 'test-utils/mock.ts', 'e2e/', 'playwright.config.ts', diff --git a/packages/ag-grid-enterprise/src/agStack/agMenuItemComponent.ts b/packages/ag-grid-enterprise/src/agStack/agMenuItemComponent.ts index 80b94c64825..5ffe096a7bf 100644 --- a/packages/ag-grid-enterprise/src/agStack/agMenuItemComponent.ts +++ b/packages/ag-grid-enterprise/src/agStack/agMenuItemComponent.ts @@ -10,7 +10,14 @@ import type { TooltipCtrl, WithoutCommon, } from 'ag-stack'; -import { AgBeanStub, _setAriaDisabled, _setAriaExpanded, _setAriaHasPopup, _setAriaRole } from 'ag-stack'; +import { + AgBeanStub, + FAST_TEST_TIMINGS, + _setAriaDisabled, + _setAriaExpanded, + _setAriaHasPopup, + _setAriaRole, +} from 'ag-stack'; import type { AgEvent, AgPromise, IComponent, IMenuConfigParams, IMenuItem, TapEvent } from 'ag-grid-community'; import { KeyCode, TouchListener, _createElement } from 'ag-grid-community'; @@ -18,6 +25,11 @@ import { KeyCode, TouchListener, _createElement } from 'ag-grid-community'; import { AgMenuList } from './agMenuList'; import { AgMenuPanel } from './agMenuPanel'; +/** Hovering a menu item activates it only after a pause, so a cursor crossing the menu doesn't flicker. */ +const ACTIVATION_DELAY = FAST_TEST_TIMINGS ? 0 : 80; +/** A further pause before an active item opens its submenu, for the same reason. */ +const SUB_MENU_OPEN_DELAY = FAST_TEST_TIMINGS ? 0 : 300; + export interface AgMenuItemLeafDef { /** Name of the menu item. */ name: string; @@ -162,8 +174,6 @@ export class AgMenuItemComponent< TPropertiesService, AgMenuItemComponentEvent > { - private readonly ACTIVATION_DELAY = 80; - private eGui: HTMLElement; private params: AgMenuItemDef; private isAnotherSubMenuOpen: () => boolean; @@ -423,7 +433,7 @@ export class AgMenuItemComponent< if (this.isAlive() && this.isActive) { this.openSubMenu(); } - }, 300); + }, SUB_MENU_OPEN_DELAY); } this.onItemActivated(); @@ -531,7 +541,7 @@ export class AgMenuItemComponent< if (this.isAnotherSubMenuOpen()) { // wait to see if the user enters the open sub-menu - this.activateTimeoutId = window.setTimeout(() => this.activate(true), this.ACTIVATION_DELAY); + this.activateTimeoutId = window.setTimeout(() => this.activate(true), ACTIVATION_DELAY); } else { // activate immediately this.activate(true); @@ -543,7 +553,7 @@ export class AgMenuItemComponent< if (this.isSubMenuOpen()) { // wait to see if the user enters the sub-menu - this.deactivateTimeoutId = window.setTimeout(() => this.deactivate(), this.ACTIVATION_DELAY); + this.deactivateTimeoutId = window.setTimeout(() => this.deactivate(), ACTIVATION_DELAY); } else { // de-activate immediately this.deactivate(); diff --git a/packages/ag-grid-enterprise/src/excelExport/excelXlsxFactory.test.ts b/packages/ag-grid-enterprise/src/excelExport/excelXlsxFactory.test.ts index 5e0e43c2adc..64a3636764d 100644 --- a/packages/ag-grid-enterprise/src/excelExport/excelXlsxFactory.test.ts +++ b/packages/ag-grid-enterprise/src/excelExport/excelXlsxFactory.test.ts @@ -88,12 +88,17 @@ const noteServiceStub = (note?: { text: string; author?: string }) => getNote: () => note, }) as any; -describe('excelXlsxFactory Workbook', () => { - afterEach(() => { - // Clear global factory state between tests. - new Workbook().reset(); - }); +// File scope, not per describe: the factory keeps its sheets, shared strings and comments in module +// globals, so a test from any describe here — or from another file sharing the worker — leaves state that +// shifts the sheet index the notes assertions read back. Both hooks, so it neither inherits nor leaks. +beforeEach(() => { + new Workbook().reset(); +}); +afterEach(() => { + new Workbook().reset(); +}); +describe('excelXlsxFactory Workbook', () => { it('orders multi-sheet exports according to supplied data array', () => { const workbook = new Workbook(); const sheetA = workbook.addWorksheet([], basicWorksheet('First', '1'), stubParams({}, workbook)); @@ -730,10 +735,6 @@ describe('excelXlsxFactory Workbook', () => { }); describe('excelXlsxFactory custom metadata', () => { - afterEach(() => { - new Workbook().reset(); - }); - it('writes custom properties using stringified values', () => { const xml = createXlsxCustomProperties({ 'MSIP_Label_8f3c2a91-bd44-4e6a-9d7c-5e3b9c2f1a84_Enabled': true, diff --git a/packages/ag-grid-enterprise/src/license/shared/licenseManager.test.ts b/packages/ag-grid-enterprise/src/license/shared/licenseManager.test.ts index 3798de3c8ad..dfb8a7d8987 100644 --- a/packages/ag-grid-enterprise/src/license/shared/licenseManager.test.ts +++ b/packages/ag-grid-enterprise/src/license/shared/licenseManager.test.ts @@ -1,4 +1,5 @@ /* eslint no-console: 0 */ +import { _doOnce } from 'ag-stack'; import type { Mock } from 'vitest'; import { LicenseManager } from './licenseManager'; @@ -30,6 +31,14 @@ describe('LicenseManager', () => { } errorLog.apply(console, args); }; + // Two pieces of process-wide state decide what these tests see, so each must start from a clean + // one: the key itself is a static (a leftover makes the next `setLicenseKey` warn), and warning + // 291 goes through `_warnOnce`, which prints it for the first test to trigger it and no other. + // Assigned rather than set through the API, since resetting via `setLicenseKey` warns in its own right. + // Clearing the whole set is local despite being process-wide state: isolation is on for unit projects, + // so each test file owns its own module registry and no other suite's entries are in here. + (LicenseManager as unknown as { licenseKey?: string }).licenseKey = undefined; + _doOnce._set.clear(); }); afterAll(() => { console.warn = warnLog; diff --git a/packages/ag-grid-enterprise/src/widgets/agRichSelect.test.ts b/packages/ag-grid-enterprise/src/widgets/agRichSelect.test.ts index 69ee90a091f..e2d489fcd55 100644 --- a/packages/ag-grid-enterprise/src/widgets/agRichSelect.test.ts +++ b/packages/ag-grid-enterprise/src/widgets/agRichSelect.test.ts @@ -667,7 +667,7 @@ describe('AgRichSelect', () => { secondPill.tabIndex = 0; firstInner.tabIndex = -1; secondInner.tabIndex = -1; - // JSDOM elements are often "not visible" to AG Grid's focus utility unless this is mocked. + // Unlaid-out elements are "not visible" to AG Grid's focus utility unless this is mocked. (firstPill as any).checkVisibility = () => true; (secondPill as any).checkVisibility = () => true; (firstInner as any).checkVisibility = () => true; diff --git a/packages/ag-grid-enterprise/vitest.config.ts b/packages/ag-grid-enterprise/vitest.config.ts index 3536b4eea45..9e8295ff1e7 100644 --- a/packages/ag-grid-enterprise/vitest.config.ts +++ b/packages/ag-grid-enterprise/vitest.config.ts @@ -1,10 +1,10 @@ import path from 'path'; import { defineConfig } from 'vitest/config'; -import { packageSourceAliases, unitProjectTestConfig } from '../../vitest.shared'; +import { packageSourceAliases, unitProjectTestConfig } from '../../testing/shared/vitest/shared'; export default defineConfig(async () => ({ - resolve: { alias: await packageSourceAliases(path.resolve(__dirname, '..')) }, + resolve: { alias: await packageSourceAliases(path.resolve(__dirname, '../..')) }, test: unitProjectTestConfig({ name: 'ag-grid-enterprise', junitFile: '../../reports/ag-grid-enterprise.xml', diff --git a/packages/ag-grid-enterprise/vitest.umd.config.ts b/packages/ag-grid-enterprise/vitest.umd.config.ts index 4889be5e6fa..8fba6825ac6 100644 --- a/packages/ag-grid-enterprise/vitest.umd.config.ts +++ b/packages/ag-grid-enterprise/vitest.umd.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + pool: 'threads', globals: true, include: ['e2e/**/*.test.ts'], watch: false, diff --git a/packages/ag-grid-vue3/.snyk b/packages/ag-grid-vue3/.snyk index 36979c233a7..ce1c3a05134 100644 --- a/packages/ag-grid-vue3/.snyk +++ b/packages/ag-grid-vue3/.snyk @@ -11,11 +11,6 @@ ignore: reason: Used in build & dev - not included in final production build expires: 2026-09-06T00:00:00.000Z created: 2026-06-25T13:22:44.326Z - SNYK-JS-WS-17344547: - - jsdom@24.1.3 > ws@8.18.3: - reason: Used in build & dev - not included in final production build - expires: 2026-09-06T00:00:00.000Z - created: 2026-06-24T11:36:02.257Z SNYK-JS-VITE-17353904: - vite@5.4.21: reason: Used in build & dev - not included in final production build diff --git a/packages/ag-grid-vue3/osv-scanner.toml b/packages/ag-grid-vue3/osv-scanner.toml index c3d1bc9e1e3..71f3ced675e 100644 --- a/packages/ag-grid-vue3/osv-scanner.toml +++ b/packages/ag-grid-vue3/osv-scanner.toml @@ -17,22 +17,10 @@ reason = "We do not use the esbuild development server" id = "GHSA-hmw2-7cc7-3qxx" reason = "We do not use form-data directly and where it is used is only for testing or examples" -[[IgnoredVulns]] -id = "GHSA-72xf-g2v4-qvf3" -reason = "tough-cookie is a transitive test dependency (via jsdom) and is not included in the libraries distributables" - [[IgnoredVulns]] id = "GHSA-qjx8-664m-686j" reason = "js-cookie is a transitive test dependency (via @vue/test-utils > js-beautify) and is not included in the libraries distributables" -[[IgnoredVulns]] -id = "GHSA-58qx-3vcg-4xpx" -reason = "ws is a transitive test dependency (via jsdom) and is not included in the libraries distributables" - -[[IgnoredVulns]] -id = "GHSA-96hv-2xvq-fx4p" -reason = "ws is a transitive test dependency (via jsdom) and is not included in the libraries distributables" - # Additions from OpenSSF Scorecard vulnerability review (2026-06-18) # Reasons verified against yarn.lock dependency chains (2026-06-18). diff --git a/packages/ag-grid-vue3/package.json b/packages/ag-grid-vue3/package.json index 6d5f51f4daa..904ca47967d 100644 --- a/packages/ag-grid-vue3/package.json +++ b/packages/ag-grid-vue3/package.json @@ -50,13 +50,11 @@ "vue": "^3.5.32", "replace-in-file": "8.4.0", "@tsconfig/node20": "^20.1.9", - "@types/jsdom": "^21.1.7", "@types/node": "^22.15.3", "@vitejs/plugin-vue": "^5.0.5", "@vitejs/plugin-vue-jsx": "^4.0.0", "@vue/test-utils": "^2.4.6", "@vue/tsconfig": "^0.5.1", - "jsdom": "^24.1.0", "npm-run-all2": "^8.0.4", "typescript": "~5.8.3", "vite": "~5.4.19", diff --git a/packages/ag-grid-vue3/tsconfig.vitest.json b/packages/ag-grid-vue3/tsconfig.vitest.json index 571995d11e6..9d9a15e0e70 100644 --- a/packages/ag-grid-vue3/tsconfig.vitest.json +++ b/packages/ag-grid-vue3/tsconfig.vitest.json @@ -4,8 +4,6 @@ "compilerOptions": { "composite": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo", - - "lib": [], - "types": ["node", "jsdom"] + "types": ["node"] } } diff --git a/packages/ag-grid-vue3/vitest.config.ts b/packages/ag-grid-vue3/vitest.config.ts deleted file mode 100644 index 3f4f6b56ad1..00000000000 --- a/packages/ag-grid-vue3/vitest.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { fileURLToPath } from 'node:url'; -import { configDefaults, defineConfig, mergeConfig } from 'vitest/config'; - -import viteConfig from './vite.config'; - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'jsdom', - exclude: [...configDefaults.exclude, 'e2e/**'], - root: fileURLToPath(new URL('./', import.meta.url)), - }, - }) -); diff --git a/packages/ag-stack/.npmignore b/packages/ag-stack/.npmignore index 812f05ac7e1..ebb720fa3f7 100644 --- a/packages/ag-stack/.npmignore +++ b/packages/ag-stack/.npmignore @@ -21,9 +21,7 @@ knip.json jest.config.ts jest.setup.js jest.setup.ts -jest.jsdom-env.cjs vitest.config.ts -vitest.umd.config.ts vitest.setup.ts scripts coverage diff --git a/packages/ag-stack/eslint.config.mjs b/packages/ag-stack/eslint.config.mjs index 5133608f84a..8bd2cc961b4 100644 --- a/packages/ag-stack/eslint.config.mjs +++ b/packages/ag-stack/eslint.config.mjs @@ -78,7 +78,6 @@ export default [ 'e2e/', 'playwright.config.ts', 'esbuildBuild.cjs', - 'vitest.umd.config.ts', ], }, ]; diff --git a/packages/ag-stack/src/core/baseAriaAnnouncementService.ts b/packages/ag-stack/src/core/baseAriaAnnouncementService.ts index 154e2025ceb..0fcd4e34a4d 100644 --- a/packages/ag-stack/src/core/baseAriaAnnouncementService.ts +++ b/packages/ag-stack/src/core/baseAriaAnnouncementService.ts @@ -1,3 +1,4 @@ +import { FAST_TEST_TIMINGS } from '../fastTestTimings'; import type { AgCoreBeanCollection } from '../interfaces/agCoreBeanCollection'; import type { BaseEvents } from '../interfaces/baseEvents'; import type { BaseProperties } from '../interfaces/baseProperties'; @@ -7,6 +8,12 @@ import { _setAriaAtomic, _setAriaLive, _setAriaRelevant } from '../utils/aria'; import { _debounce } from '../utils/function'; import { AgBeanStub } from './agBeanStub'; +/** Coalesces bursts of announcements into one; no grid option reaches it, so tests would out-wait it. */ +const ANNOUNCE_DEBOUNCE = FAST_TEST_TIMINGS ? 0 : 200; + +/** Gap that makes a screen reader re-announce after the container is blanked; same reasoning. */ +const ANNOUNCE_REPEAT_DELAY = FAST_TEST_TIMINGS ? 0 : 50; + /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export class BaseAriaAnnouncementService< TBeanCollection extends AgCoreBeanCollection, @@ -28,7 +35,7 @@ export class BaseAriaAnnouncementService< constructor() { super(); - this.updateAnnouncement = _debounce(this, this.updateAnnouncement.bind(this), 200); + this.updateAnnouncement = _debounce(this, this.updateAnnouncement.bind(this), ANNOUNCE_DEBOUNCE); } public setDescriptionContainer(div: HTMLElement): void { @@ -61,7 +68,7 @@ export class BaseAriaAnnouncementService< this.descriptionContainer.textContent = ''; setTimeout(() => { this.handleAnnouncementUpdate(value); - }, 50); + }, ANNOUNCE_REPEAT_DELAY); } private handleAnnouncementUpdate(value: string): void { diff --git a/packages/ag-stack/src/fastTestTimings.test.ts b/packages/ag-stack/src/fastTestTimings.test.ts new file mode 100644 index 00000000000..9c25bc9b4ba --- /dev/null +++ b/packages/ag-stack/src/fastTestTimings.test.ts @@ -0,0 +1,7 @@ +import { FAST_TEST_TIMINGS } from './fastTestTimings'; + +// This project is not aliased, so it reads the constant every published bundle reads. The behavioural +// suite replaces the module, so nothing over there can catch the flag shipping as `true`. +test('the shipped flag is false, so real builds keep the real delays', () => { + expect(FAST_TEST_TIMINGS).toBe(false); +}); diff --git a/packages/ag-stack/src/fastTestTimings.ts b/packages/ag-stack/src/fastTestTimings.ts new file mode 100644 index 00000000000..e144c305043 --- /dev/null +++ b/packages/ag-stack/src/fastTestTimings.ts @@ -0,0 +1,7 @@ +/** + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. + * + * Collapses the grid's hard-coded UX delays, the ones no grid option reaches. Always `false` in every + * build: the behavioural suite alone turns it on, by aliasing this module (testing/behavioural/vitest.config.ts). + */ +export const FAST_TEST_TIMINGS = false; diff --git a/packages/ag-stack/src/main-internal.ts b/packages/ag-stack/src/main-internal.ts index a28d3cd6cbe..47c0b979eb5 100644 --- a/packages/ag-stack/src/main-internal.ts +++ b/packages/ag-stack/src/main-internal.ts @@ -14,6 +14,7 @@ export type { BaseCssChangeKeys, CssVariable } from './core/baseEnvironment'; export { BaseRegistry } from './core/baseRegistry'; export { BaseEventService } from './events/baseEventService'; export { LocalEventService } from './events/localEventService'; +export { FAST_TEST_TIMINGS } from './fastTestTimings'; export { AgManagedFocusFeature, FOCUS_MANAGED_CLASS } from './focus/agManagedFocusFeature'; export type { ManagedFocusCallbacks, StopPropagationCallbacks } from './focus/agManagedFocusFeature'; export { AgTabGuardComp } from './focus/agTabGuardComp'; diff --git a/packages/ag-stack/src/theming/inject.test.ts b/packages/ag-stack/src/theming/inject.test.ts index 504cd871ffa..b0ed0485a65 100644 --- a/packages/ag-stack/src/theming/inject.test.ts +++ b/packages/ag-stack/src/theming/inject.test.ts @@ -8,13 +8,6 @@ import { _useParamsCss, } from './inject'; -// jsdom does not implement the CSS namespace, patch it. -if (typeof (globalThis as { CSS?: unknown }).CSS === 'undefined') { - (globalThis as { CSS: Pick }).CSS = { - escape: (value: string) => value.replace(/[^\w-]/g, '\\$&'), - }; -} - const createEnvironment = (): IEnvironment => ({}) as IEnvironment; const injectedStyles = (container: HTMLElement): HTMLStyleElement[] => @@ -23,7 +16,7 @@ const injectedStyles = (container: HTMLElement): HTMLStyleElement[] => const injectedCssTexts = (container: HTMLElement): string[] => injectedStyles(container).map((el) => el.textContent ?? ''); -// IS_SSR is true under jsdom (no document.fonts), so injection is off by default; force it on for these tests. +// IS_SSR is true under happy-dom (no document.fonts), so injection is off by default; force it on for these tests. beforeAll(() => { _setStyleInjectionEnabledForTesting(true); }); diff --git a/packages/ag-stack/src/theming/partImpl.ts b/packages/ag-stack/src/theming/partImpl.ts index 40b76261ca6..9a4922b3d70 100644 --- a/packages/ag-stack/src/theming/partImpl.ts +++ b/packages/ag-stack/src/theming/partImpl.ts @@ -53,8 +53,9 @@ type CreatePartArgs = { */ export const createPart = (args: CreatePartArgs): Part>> => { - /*#__PURE__*/ - return new PartImpl(args) as any; + // The annotation has to sit on the `new`, not on its own line, or it marks nothing and an unused + // part is never dropped. + return /*#__PURE__*/ new PartImpl(args) as any; }; export const defaultModeName = '$default'; diff --git a/packages/ag-stack/src/tooltip/baseTooltipStateManager.ts b/packages/ag-stack/src/tooltip/baseTooltipStateManager.ts index b12e86a1e79..473de4ca25e 100644 --- a/packages/ag-stack/src/tooltip/baseTooltipStateManager.ts +++ b/packages/ag-stack/src/tooltip/baseTooltipStateManager.ts @@ -1,4 +1,5 @@ import { AgBeanStub } from '../core/agBeanStub'; +import { FAST_TEST_TIMINGS } from '../fastTestTimings'; import type { AgCoreBeanCollection } from '../interfaces/agCoreBeanCollection'; import type { BaseEvents } from '../interfaces/baseEvents'; import type { BaseProperties } from '../interfaces/baseProperties'; @@ -23,6 +24,8 @@ export enum TooltipTrigger { const SHOW_SWITCH_TOOLTIP_DIFF = 1000; const FADE_OUT_TOOLTIP_TIMEOUT = 1000; const INTERACTIVE_HIDE_DELAY = 100; +/** Guards against a tooltip flashing past under a moving cursor, so `tooltipShowDelay: 0` still waits. */ +const MIN_TOOLTIP_DELAY = FAST_TEST_TIMINGS ? 0 : 200; // different instances of tooltipFeature use this to see when the // last tooltip was hidden. @@ -137,7 +140,7 @@ export abstract class BaseTooltipStateManager< delayOption: 'tooltipShowDelay' | 'tooltipHideDelay' | 'tooltipSwitchShowDelay' ): number { const delay = this.gos.get(delayOption)!; - return Math.max(200, delay); + return Math.max(MIN_TOOLTIP_DELAY, delay); } private getTooltipDelay(type: 'Show' | 'Hide' | 'SwitchShow'): number { diff --git a/packages/ag-stack/src/utils/dom.ts b/packages/ag-stack/src/utils/dom.ts index 70e169d74ae..85659723605 100644 --- a/packages/ag-stack/src/utils/dom.ts +++ b/packages/ag-stack/src/utils/dom.ts @@ -463,7 +463,7 @@ export function _observeIntersection( ): () => void { const win = _getWindow(beans); const IntersectionObserver = win.IntersectionObserver; - // support envs like jsdom that don't have IntersectionObserver + // support envs that don't have IntersectionObserver const intersectionObserver = IntersectionObserver ? new IntersectionObserver((entries) => { // use _last because when an element rapidly enters then leaves the screen diff --git a/packages/ag-stack/vitest.config.ts b/packages/ag-stack/vitest.config.ts index 0ee5e2274aa..0a29d736b97 100644 --- a/packages/ag-stack/vitest.config.ts +++ b/packages/ag-stack/vitest.config.ts @@ -1,10 +1,10 @@ import path from 'path'; import { defineConfig } from 'vitest/config'; -import { packageSourceAliases, unitProjectTestConfig } from '../../vitest.shared'; +import { packageSourceAliases, unitProjectTestConfig } from '../../testing/shared/vitest/shared'; export default defineConfig(async () => ({ - resolve: { alias: await packageSourceAliases(path.resolve(__dirname, '..')) }, + resolve: { alias: await packageSourceAliases(path.resolve(__dirname, '../..')) }, test: unitProjectTestConfig({ name: 'ag-stack', junitFile: '../../reports/ag-stack.xml', diff --git a/packages/ag-stack/vitest.umd.config.ts b/packages/ag-stack/vitest.umd.config.ts deleted file mode 100644 index 4889be5e6fa..00000000000 --- a/packages/ag-stack/vitest.umd.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - include: ['e2e/**/*.test.ts'], - watch: false, - }, -}); diff --git a/plugins/ag-grid-generate-example-files/vitest.config.ts b/plugins/ag-grid-generate-example-files/vitest.config.ts index 5606188feed..581314c867d 100644 --- a/plugins/ag-grid-generate-example-files/vitest.config.ts +++ b/plugins/ag-grid-generate-example-files/vitest.config.ts @@ -7,6 +7,7 @@ if (process.env.CI != null) { export default defineConfig({ test: { + pool: 'threads', globals: true, include: ['src/**/*.test.ts'], exclude: ['**/node_modules/**', '**/dist/**'], diff --git a/scripts/gate/args.mjs b/scripts/gate/args.mjs new file mode 100644 index 00000000000..13a8236ca8c --- /dev/null +++ b/scripts/gate/args.mjs @@ -0,0 +1,151 @@ +// Argument parsing shared by the gate scripts. Each gate declares the flags it consumes; everything else is +// forwarded verbatim to the runner it wraps. +// +// The run-capture flags at the bottom are identical across all four gates, and used to be parsed once per +// gate - six flags, four implementations, which is how their spellings drifted apart. + +/** What follows a flag. Every flag accepts both `--flag value` and `--flag=value` spellings. */ +export const NONE = 'none'; // nothing +export const INLINE = 'inline'; // an optional `=value`, or one of `spec.operands` as the next argument +export const VALUE = 'value'; // a required operand, any shape +export const NUMBER = 'number'; // a required whole number +export const ID = 'id'; // an optional run id, defaulting to `latest` + +const wholeNumber = /^\d+$/; + +/** Presence, not truthiness, matching how the vitest config reads it: `CI=` empty is CI to vitest, so the + * gate and the run it wraps must not disagree about which timeouts and reporters are in play. */ +export const isCI = process.env.CI != null; + +function fail(message) { + console.error(message); + process.exit(1); +} + +/** + * Splits argv into the flags a gate understands and the arguments it forwards. `flags` maps each accepted + * spelling to `{ takes, apply, hint }`; `apply(state, value)` records it. Unknown arguments are forwarded. + * + * `state.capture` collects the run-capture flags, which the driver in main.mjs reads rather than the gate. + */ +export function parseArgs(argv, flags) { + const state = { forward: [], capture: {} }; + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + const split = arg.indexOf('='); + const name = split < 0 ? arg : arg.slice(0, split); + const inline = split < 0 ? undefined : arg.slice(split + 1); + const spec = flags[name] ?? captureFlags[name]; + if (!spec) { + state.forward.push(arg); + continue; + } + const next = argv[index + 1]; + switch (spec.takes) { + case INLINE: { + // `spec.operands` lists the words that may also follow with a space. Without it the space + // spelling reaches the runner as a file filter, which for `--update-grid-rows dry` means a + // full snapshot rewrite - the opposite of what was asked for. + const operand = inline === undefined && spec.operands?.includes(next); + spec.apply(state, operand ? next : inline); + index += operand ? 1 : 0; + break; + } + case VALUE: + case NUMBER: { + const value = inline ?? next; + // An empty `=` counts as absent: `--projects=` would otherwise widen the gate to every project. + const absent = !value || (inline === undefined && value.startsWith('-')); + // A number says "missing or invalid" either way: to a caller who typed a word, the difference + // between an absent value and an unusable one is not worth two messages. + if (spec.takes === NUMBER) { + if (absent || !wholeNumber.test(value)) { + fail(`Missing or invalid value for ${name} (expected ${spec.hint})`); + } + } else if (absent) { + fail(`Missing value for ${name}${spec.hint ? ` (${spec.hint})` : ''}`); + } + spec.apply(state, value); + index += inline === undefined ? 1 : 0; + break; + } + case ID: { + // An operand that may be absent, consumed only when it is actually there: a value taken for a + // missing one would eat the following flag instead. A bare number is the timeout rather than + // an id, since `--wait 30` reads as "wait 30s for the newest run" and no id is all digits. + const numeric = spec.timeout && wholeNumber.test(next ?? ''); + const present = inline === undefined && next && !next.startsWith('-') && !numeric; + // `auto`, not `latest`: no id means "the run I mean", which is the one still going. See + // `resolveId` - `latest` is only the newest run *started*. An empty `--wait=` means `auto` + // too: kept as `''` it reads as absent downstream and starts a fresh multi-minute gate. + spec.apply(state, inline || (present ? next : 'auto')); + index += present ? 1 : 0; + // `--wait [secs]` alone takes a second operand, and only when it is a number - a bare + // `--wait` reads its id as `auto`, so a number after it would otherwise reach the runner. + if (spec.timeout && wholeNumber.test(argv[index + 1] ?? '')) { + state.capture.waitTimeout = Number(argv[++index]); + } + break; + } + default: + // A value on a flag that takes none is a typo with consequences: `--async=true` would enable + // async and then survive into the relaunched argv, detaching a child per generation. + if (inline !== undefined) { + fail(`${name} takes no value (got '${arg}')`); + } + spec.apply(state); + } + } + return state; +} + +/** + * The help for those flags, written here beside them: spelled out per gate, the four copies drifted into four + * descriptions of the same behaviour. `runner` names what `--no-log` gives its colours back to. + */ +export function captureUsage({ runner, quiet = true, width = 30 }) { + // One space minimum, so a flag wider than the column still separates from its text. + const row = (flag, ...lines) => { + const head = ` ${flag}`; + const gap = ' '.repeat(Math.max(1, width - head.length)); + return [head + gap + lines[0], ...lines.slice(1).map((line) => ' '.repeat(width) + line)]; + }; + return [ + ...row( + '--async', + 'Start detached and return at once; when it finishes it prints its result', + 'back to this terminal. An agent gets nothing from that, so an agent should', + 'background the call itself, or poll --async-status.' + ), + ...row( + '--async-status [id]', + 'Has it finished? Reports without waiting: exit 0 passed, 1 failed, 3 still', + 'running. Takes an id or any path containing one, so the log path printed', + 'above can be pasted straight back.' + ), + ...row('--wait [id] [secs]', 'The same report, but waiting up to `secs` for the run to finish.'), + ...row( + '--kill [id]', + 'Stop a run and every process it spawned. Other runs are left alone.', + '', + 'All three default to whichever run is still going - however it was', + 'started, --async or a plain background call - and fall back to the newest', + 'when nothing is. So `--wait` alone is "wait for the run", and since an id', + 'is never all digits, `--wait 300` is that with a 300s cap.' + ), + ...(quiet ? row('--quiet', 'Console gets the paths, the summary and the failures; the log gets all.') : []), + ...row('--no-log', `No log file, and ${runner} keeps its colours.`), + ].join('\n'); +} + +// The flags every gate shares, and the reason this module exists. +const captureFlags = { + '--async': { takes: NONE, apply: (state) => (state.capture.async = true) }, + '--async-status': { takes: ID, apply: (state, id) => (state.capture.statusId = id) }, + '--kill': { takes: ID, apply: (state, id) => (state.capture.killId = id) }, + '--wait': { takes: ID, timeout: true, apply: (state, id) => (state.capture.waitId = id) }, + '--quiet': { takes: NONE, apply: (state) => (state.capture.quiet = true) }, + '--no-log': { takes: NONE, apply: (state) => (state.capture.noLog = true) }, + // Internal: the detached child of --async is handed the id its parent already printed. + '--run-id': { takes: VALUE, apply: (state, id) => (state.capture.runId = id) }, +}; diff --git a/scripts/gate/gates/behave.mjs b/scripts/gate/gates/behave.mjs new file mode 100644 index 00000000000..17086ba1866 --- /dev/null +++ b/scripts/gate/gates/behave.mjs @@ -0,0 +1,133 @@ +// Runs the merged unit-test suite directly via the root Vitest config's project list, bypassing Nx: +// package (London-school) unit tests plus the behavioural (Chicago-school) black-box suite — one command. +// That list also carries the node-env tooling projects (docs, ag-website-shared) so the IDE can +// discover them; by default this gate restricts the run to the unit projects. +import { INLINE, NONE, NUMBER, VALUE, captureUsage, isCI } from '../args.mjs'; + +// Default projects when the caller doesn't pick their own with --project (values are vitest test.names). +const UNIT_PROJECTS = ['ag-stack', 'ag-grid-community', 'ag-grid-enterprise', 'locale', 'behavioural']; + +export default { + name: 'behave', + script: 'behave.sh', + helpCommand: ['vitest', '--help'], + failRe: /^( FAIL|\s+×)/, + summaryRe: /^ *(Test Files|Tests|Duration) /, + + flags: { + '--update-grid-rows': { + takes: INLINE, + operands: ['dry'], + apply(state, value) { + if (value !== undefined && value !== 'dry') { + console.error( + `Unknown value: --update-grid-rows=${value} (expected --update-grid-rows or --update-grid-rows=dry)` + ); + process.exit(1); + } + process.env.UPDATE_GRID_ROWS_SNAPSHOTS = value ?? '1'; + }, + }, + '--no-diff': { takes: NONE, apply: () => (process.env.AG_NO_DIFF = '1') }, + '--diff-lines': { + takes: NUMBER, + hint: 'a line count, 0 = unlimited', + apply: (state, value) => (process.env.AG_DIFF_LINES = value), + }, + '--slowest': { + takes: NUMBER, + hint: 'a test count, 0 = no table', + apply: (state, value) => (process.env.AG_SLOWEST_TESTS = value), + }, + '--stack-trace-len': { + takes: NUMBER, + hint: 'a frame count, e.g. 20', + apply(state, value) { + // Below ~20 every inline snapshot fails "Couldn't infer stack frame". Allowed, but not silently. + if (Number(value) < 20) { + console.error(`behave.sh: --stack-trace-len ${value} may break inline snapshots (keep >= 20)`); + } + process.env.AG_STACK_TRACE_LEN = value; + }, + }, + '--project': { + takes: VALUE, + hint: 'e.g. --project behavioural or --project all', + apply(state, value) { + if (value === 'all') { + state.allProjects = true; // no filter, every workspace project + } else { + state.chosenProjects = true; // run theirs instead of the defaults + state.forward.push('--project', value); + } + }, + }, + '-w': { takes: NONE, apply: (state) => watch(state, '-w') }, + '--watch': { takes: NONE, apply: (state) => watch(state, '--watch') }, + }, + + // `--ui` implies watch inside vitest, so it never finishes either and is not spelled here as a flag. + endless: (state) => + state.watch || state.forward.includes('--ui') ? '--watch/--ui, which never finish' : undefined, + + plan({ bin, runLog, state }) { + // Colour is for humans: an interactive terminal or CI (whose log viewer renders ANSI). An AI agent or + // a pipe reads the escapes as noise, and vitest emits them regardless of isTTY, so say so explicitly. + if (!process.env.NO_COLOR && !process.env.FORCE_COLOR) { + const agent = process.env.CLAUDECODE || process.env.AI_AGENT; + if (agent || (!isCI && !process.stdout.isTTY)) { + process.env.NO_COLOR = '1'; + } + } + // A machine-readable copy beside the log, for reading a failure back without parsing console output. + if (runLog.enabled) { + process.env.AG_RESULT_JSON = runLog.resultJson; + } + const projects = + state.allProjects || state.chosenProjects ? [] : UNIT_PROJECTS.flatMap((name) => ['--project', name]); + return { command: bin('vitest'), args: [...projects, ...state.forward] }; + }, + + usage: ` +Usage: ./behave.sh [pattern] [options] + + pattern A file-name pattern forwarded to vitest (e.g. "tooltip"), or a path. + Omit to run the whole unit suite (package + behavioural). + -t "name" Run a single test by name, e.g. ./behave.sh "tooltip" -t "shows tooltip". + +Projects: + (default) The unit projects: ag-stack, ag-grid-community, ag-grid-enterprise, + locale, behavioural. + --project Run specific workspace project(s) instead, e.g. --project ag-grid-docs. + --project all Run every project in the workspace (incl. docs, website). + +Modes: + -w, --watch Watch mode (re-runs on file changes). + --update Update vitest snapshots. + --update-grid-rows[=dry] Update GridRows inline snapshots (dry = preview only). + +Timing: + --slowest N List the N slowest tests and files at the end (default 5, 0 = off). + Quiet below the floors in testing/shared/vitest/timings.ts. + +Run capture (local only; CI keeps its own logs). Every run prints an id first and streams stdout+stderr +to tmp/_behave-output//output.log, plus result.json for machine reading. tmp/_behave-output/latest +points at the newest. Nothing extra is needed to inspect a red run — read that log: +${captureUsage({ runner: 'vitest' })} + +Output volume, for when a suite fails wholesale and the diffs dwarf the results: + --bail 1 Stop at the first failing test. + --no-diff Report which tests fail; no assertion diff, snapshots cut to a line. + --diff-lines 10 Cap each diff at 10 lines (0 = unlimited). + --stack-trace-len 20 Shorten captured stacks; default 40, keep >= 20. + + -h, --help Show vitest's own help, then this. + +Anything else is forwarded verbatim to vitest. +`, +}; + +function watch(state, arg) { + state.watch = true; + state.forward.push(arg); +} diff --git a/scripts/gate/gates/bench.mjs b/scripts/gate/gates/bench.mjs new file mode 100644 index 00000000000..8c1b52263f8 --- /dev/null +++ b/scripts/gate/gates/bench.mjs @@ -0,0 +1,196 @@ +// Runs behavioural benchmarks directly via Vitest, bypassing Nx. +// Benchmarks run in a real headless Chromium (Playwright) by DEFAULT, so layout-dependent work is +// measured against a real layout engine. All other arguments are forwarded to `vitest bench`. +import fs from 'node:fs'; +import path from 'node:path'; + +import { NONE, captureUsage } from '../args.mjs'; +import { spawnAwait } from '../run-log.mjs'; + +// Profiles live under benchmarks/tmp/ which is already git-ignored, so no separate ignore needed. +const PROFILES = 'testing/behavioural/src/benchmarks/tmp/profiles'; + +export default { + name: 'bench', + script: 'benches.sh', + helpCommand: ['vitest', 'bench', '--help'], + failRe: /^( FAIL|\s+×)/, + // `vitest bench` reports a table rather than test counts; these are the lines worth echoing back. The + // profile path is in there because --profile --async is exactly the case where the terminal has nothing + // else to go on. + summaryRe: /^ *(BENCH|Duration|Bench Files|CPU profile written)/, + + // `--bench-compare [args...]` is a thin pass-through to the bench-compare.mjs tool, handled before + // anything else reads argv so its sub-commands and flags (base/test/compare/all/backup, --runs, --filter, + // …) reach that script untouched. + preParse({ argv, rootDir }) { + if (argv[0] !== '--bench-compare') { + return undefined; + } + return spawnAwait( + process.execPath, + [path.join(rootDir, 'testing/behavioural/src/benchmarks/bench-compare.mjs'), ...argv.slice(1)], + { cwd: rootDir, stdio: 'inherit' } + ); + }, + + flags: { + '-w': { takes: NONE, apply: (state) => watchMode(state, '-w') }, + '--watch': { takes: NONE, apply: (state) => watchMode(state, '--watch') }, + '--node': { takes: NONE, apply: (state) => node(state) }, + '--happy-dom': { takes: NONE, apply: (state) => node(state) }, + '--headed': { takes: NONE, apply: (state) => headed(state) }, + '--interactive': { takes: NONE, apply: (state) => headed(state) }, + '--ui': { + takes: NONE, + apply(state) { + // Visible browser + the Vitest dashboard (bench picker) at a localhost URL. --standalone + // starts WITHOUT running anything (pick benches from the dashboard); --watch keeps the + // server + browser alive (and is required by --standalone). CLI --watch beats config watch:false. + headed(state); + state.endless = true; + state.forward.push('--ui', '--standalone', '--watch'); + }, + }, + '--profile': { + takes: NONE, + apply(state) { + // V8 CPU profile of the grid code. Node-only: browser mode doesn't use the forks pool the + // --cpu-prof execArgv attaches to. Single run (profiling distorts timing — not for numbers). + node(state); + state.profile = true; + process.env.BENCH_PROFILE = '1'; + }, + }, + }, + + // Watch and --ui never end, so there is nothing to capture and nothing to wait for. No reason string: + // --async is refused for them by the generic "the run log is off" message. + endless: (state) => state.endless, + + // --profile and --node run in node (no browser), so they can't combine with the browser-only + // --headed/--ui — say so rather than silently picking node and ignoring the visible-browser flag. + reject: (state) => + state.headed && state.node + ? "--headed/--ui need a real browser and can't combine with --node/--happy-dom/--profile." + : undefined, + + async plan({ bin, rootDir, state }) { + const profileDir = path.join(rootDir, PROFILES); + if (state.profile) { + process.env.BENCH_PROFILE_DIR = profileDir; + fs.mkdirSync(profileDir, { recursive: true }); + // Taken before the run so a failed one cannot report the previous profile as its own. + state.profileBefore = newestProfile(profileDir); + } + return { + command: bin('vitest'), + args: ['bench', ...(state.endless ? [] : ['--run']), ...state.forward], + cwd: path.join(rootDir, 'testing/behavioural'), + }; + }, + + // In `beforeRun` rather than `plan`, so the download lands in the run log and a failed one is closed + // through `finish`: under --async the console is /dev/null, which is where this used to report from. + async beforeRun({ bin, rootDir, runLog, state }) { + // Browser is the default, so ensure the Playwright Chromium build matching the installed `playwright` + // package is present (the launch fails otherwise); `install` is a no-op when it already is. The local + // binary rather than `npx`, which would resolve from the registry if it were ever missing - the exact + // mismatch this call exists to prevent. + if (state.node) { + return 0; + } + const installed = await runLog.exec(bin('playwright'), ['install', 'chromium', 'chromium-headless-shell'], { + cwd: rootDir, + }); + // Stop here rather than let the launch fail later, where the error names a missing browser + // executable instead of the download that did not happen. + if (installed !== 0) { + runLog.echo('benches.sh: `playwright install` failed, so Chromium is not available to run in.'); + } + return installed; + }, + + // Profiling has to name the .cpuprofile it emitted, and through the run log rather than the console: the + // path is this gate's own output, so it would otherwise miss the log entirely and, under --async, go to + // /dev/null after the run had already reported back. + afterRun({ rootDir, runLog, state }, code) { + if (state.profile) { + const newest = newestProfile(path.join(rootDir, PROFILES)); + if (newest && newest !== state.profileBefore) { + runLog.echo(''); + runLog.echo(`CPU profile written: ${newest}`); + runLog.echo('Open in Chrome DevTools (Performance → Load profile) or https://speedscope.app'); + } + } + return code; + }, + + usage: ` +Usage: ./benches.sh [pattern] [options] + + pattern A file-name pattern forwarded to \`vitest bench\` (e.g. "grouping-pipelines"). + Narrows the run to matching .bench.ts files. Omit to run all. + +Engine: + (default) Real headless Chromium (Playwright) — measures against a real layout engine. + --node, --happy-dom Run in node/happy-dom instead — faster, no layout engine. + +Modes: + -w, --watch Watch mode (re-runs on file changes). + --headed, --interactive Visible Chromium, single run — watch the grid render. + --ui Visible Chromium + the Vitest dashboard at a localhost URL; starts WITHOUT + running (pick benches from the dashboard), and stays open. + --profile Node single run with a V8 CPU profile (--cpu-prof) for method-cost analysis. + Writes a .cpuprofile under benchmarks/tmp/profiles/ (printed after the run) — + open it in Chrome DevTools or speedscope. Implies --node (browser can't emit it). + --bench-compare ... Pass through to bench-compare.mjs (base/test/compare/all/backup); everything + after it is forwarded verbatim, e.g. ./benches.sh --bench-compare all --runs 3. + +Run capture (local only, and not for --watch/--ui, which never end). Every run prints its log +path first and streams stdout+stderr to tmp/_bench-output//output.log — a bench run costs minutes, so +read that afterwards rather than running it twice: +${captureUsage({ runner: 'vitest', width: 25 })} + -h, --help Show this help. + +Anything else is forwarded verbatim to \`vitest bench\`. +`, +}; + +// Watch keeps the runner alive between re-runs, so the run has no end for the log or `--wait` to key on. +function watchMode(state, arg) { + state.endless = true; + state.forward.push(arg); +} + +function node(state) { + state.node = true; + process.env.BENCH_NODE = '1'; +} + +// A visible window, not an endless mode: the browser closes when the benches finish, so the run is captured +// and can be awaited like any other. `--ui` is the one that never ends, and it says so itself. +function headed(state) { + state.headed = true; + process.env.BENCH_BROWSER_HEADED = '1'; +} + +// The most recent profile in the directory, or undefined when there is none. One stat per file, rather than +// one per comparison as sorting on `statSync` would do. +function newestProfile(dir) { + let newest; + try { + for (const name of fs.readdirSync(dir)) { + if (name.endsWith('.cpuprofile')) { + const file = path.join(dir, name); + const at = fs.statSync(file).mtimeMs; + if (!newest || at > newest.at) { + newest = { file, at }; + } + } + } + } catch { + // No profile directory yet, so there is no previous profile to be confused with this run's. + } + return newest?.file; +} diff --git a/scripts/gate/gates/checks.mjs b/scripts/gate/gates/checks.mjs new file mode 100644 index 00000000000..e166d56b48f --- /dev/null +++ b/scripts/gate/gates/checks.mjs @@ -0,0 +1,225 @@ +// Pre-commit gate: type-check + lint + spec type-check for the grid packages and the behavioural suite. +// +// Runs every task in ONE Nx invocation so they execute in parallel and hit the Nx cache — much faster than +// chaining a `yarn nx ` per gate, which re-pays Nx startup each time and forces the tasks +// to run serially. Output is suppressed unless something fails. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { NONE, VALUE, captureUsage } from '../args.mjs'; +import { stripAnsi } from '../run-log.mjs'; + +// Empty means every project. Nx skips projects that lack a target, so the gate covers the same ground as +// CI's `yarn nx lint` rather than a hand-maintained subset that silently drifts as packages are added. +const DEFAULT_TARGETS = 'build:types,lint,build:test'; + +// Nx has no "auto" and defaults to 3, which idles cores on a gate whose graph is ~5 tasks wide. Capped at 8 +// so small runners do not thrash on memory-hungry tsc processes; a passed --parallel=N wins (nx takes last). +const MAX_PARALLEL = 8; + +// What Nx prints instead of failing when `-t`/`-p` select nothing at all. +const NOTHING_RAN = /^\s*NX\s+No tasks were run/m; + +// How Nx marks a failing task inline. Spelled once: it is both this gate's `failRe` and how `failedTasks` +// names them back, and two spellings of it would parse the same line differently. +const FAILED_TASK = /^\s*✖\s+nx run\s+(\S+)/; + +export default { + name: 'checks', + script: 'checks.sh', + // Nx output is never watched live, only read back, so it goes straight to the file. + capture: 'file', + // The verdict below is this gate's own summary; the run log must not print it a second time. + report: false, + // The verdict line is echoed into the log too, so `--wait` sees it. + failRe: FAILED_TASK, + summaryRe: /^CHECKS-(PASSED|FAILED)/, + + flags: { + '--projects': { + takes: VALUE, + hint: 'comma-separated, e.g. ag-grid-community,ag-grid-enterprise', + apply: (state, value) => (state.projects = value), + }, + '--targets': { + takes: VALUE, + hint: 'comma-separated, e.g. build:types,lint', + apply: (state, value) => (state.targets = value), + }, + '--fresh': { takes: NONE, apply: (state) => state.forward.push('--skip-nx-cache') }, + '--warn': { takes: NONE, apply: (state) => (state.showWarnings = true) }, + '--verbose': { takes: NONE, apply: (state) => (state.verbose = true) }, + }, + + plan({ bin, rootDir, runLog, state }) { + // The Nx daemon deadlocks on piped stdio in agent/CI shells; a single invocation only pays graph cost once. + process.env.NX_DAEMON = 'false'; + state.targets ??= DEFAULT_TARGETS; + state.started = Date.now(); + // Without the run log there is still a file to read back, just a disposable one - the output is far too + // long to hold in the terminal, and only a failure needs to show any of it. + state.log = runLog.enabled + ? runLog.file + : path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ag-checks-')), 'output.log'); + const projects = state.projects ? ['-p', ...state.projects.split(',')] : []; + return { + command: bin('nx'), + args: [ + 'run-many', + '-t', + ...state.targets.split(','), + ...projects, + `--parallel=${Math.min(os.availableParallelism(), MAX_PARALLEL)}`, + '--output-style=stream', + ...state.forward, + ], + cwd: rootDir, + file: state.log, + // A temp log is only ever printed to a console, so it keeps Nx's colours; a run log is read back + // with grep, where they would be noise. + colour: !runLog.enabled, + }; + }, + + afterRun({ rootDir, runLog, state }, code) { + try { + const raw = readLog(state.log); + if (code !== 0 || state.verbose) { + process.stdout.write(raw); + } + // Nx colours its output, which leaves an escape sequence flush against the word "warning" and + // defeats any word-boundary match, so everything parsed below reads the stripped copy. + const log = stripAnsi(raw); + const elapsed = Math.round((Date.now() - state.started) / 1000); + const summary = `targets: ${state.targets} | projects: ${state.projects || 'all'}`; + // Every verdict is built once and echoed, so the console and the log cannot say different things: + // the log is what a later reader (or `--wait`) has, and it must record whether the gate passed. + if (code !== 0) { + runLog.echo(`CHECKS-FAILED (${elapsed}s) — ${summary}`, console.error); + for (const task of failedTasks(log)) { + console.error(` failed: ${task}`); + } + return code; + } + // Nx exits 0 when a filter matches nothing, which would report a gate that never ran as one that + // passed. Both filters are typed by hand, and these are Nx project names, not the vitest ones + // ./behave.sh takes, so matching nothing is a mistake every time. 2, to tell it from a real failure. + if (NOTHING_RAN.test(log)) { + runLog.echo( + `CHECKS-FAILED (${elapsed}s) — ${summary} — no task matched, nothing was checked`, + console.error + ); + return 2; + } + const passed = `CHECKS-PASSED (${elapsed}s) — ${summary}`; + const warnings = countWarnings(log); + if (!warnings) { + runLog.echo(passed); + return code; + } + runLog.echo(`${passed} — ${warnings} warnings`); + reportWarnings(log, warnings, state.showWarnings, rootDir); + return code; + } finally { + if (!runLog.enabled) { + fs.rmSync(path.dirname(state.log), { recursive: true, force: true }); + } + } + }, + + usage: ` +Usage: ./checks.sh [options] [extra nx args] + + (default) The full gate — build:types, lint and build:test for every project, + in one Nx invocation so the tasks run in parallel and hit the cache. + --projects a,b Narrow to specific projects. + --targets lint Override the target list. + --fresh Bypass the Nx cache. + --warn Print the warnings a passing run produced. + --verbose Print task output even when everything passes. + +Run capture (local only; CI keeps its own logs). Every run prints an id first and writes the full task +output to tmp/_checks-output//output.log, whether it passed or not. Read that instead of re-running: +${captureUsage({ runner: 'Nx', quiet: false })} + + -h, --help Show this. + +Anything else is forwarded verbatim to \`nx run-many\`. +`, +}; + +const readLog = (file) => { + try { + return fs.readFileSync(file, 'utf8'); + } catch { + return ''; + } +}; + +// Sums ESLint's own per-project totals. Counting matching lines instead would miss wrapped messages and +// double-count the "N warnings potentially fixable" footer. +function countWarnings(log) { + let total = 0; + for (const [, count] of log.matchAll(/\d+ problems? \(\d+ errors?, (\d+) warnings?\)/g)) { + total += Number(count); + } + return total; +} + +// Kept next to the other tooling scratch (ag-watch-status.json), so a passing gate can point at its warnings +// instead of discarding them with the temp log. Rewritten by any run that has warnings to report. +const WARNINGS_LOG = 'node_modules/.cache/ag-checks-warnings.log'; +// Keep the file-path lines ESLint prints above each block, or the rows say nothing about where. Nx prefixes +// streamed lines with the task name, so the path is not always at the start of a line. +const WARNING_LINE = /\d+:\d+\s+warning|problems? \(|^([^:]+: )?\/.*\.(ts|tsx|js|jsx|mjs|cjs|vue|astro)$/; + +// Resolved against the repo root, not the cwd: only the Nx child is given that cwd, so a gate invoked by +// path from another directory would otherwise write under the caller's tree, or fail to write at all. +function reportWarnings(log, warnings, print, rootDir) { + let file = path.join(rootDir, WARNINGS_LOG); + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, log); + } catch { + // A failed write must not leave the summary pointing at an absent log, or at a previous run's. + file = ''; + } + if (!file) { + console.log(` ${warnings} warnings (could not be written to disk)`); + } else if (print) { + for (const line of log.split('\n').filter((line) => WARNING_LINE.test(line))) { + console.log(line); + } + console.log(` ${warnings} warnings: ${file}`); + } else { + console.log(` ${warnings} warnings (run with --warn to print them): ${file}`); + } +} + +// Reprinted at the end so the failing tasks are the last thing on screen rather than lost up the stream. +// Two sources because neither is complete on its own: Nx marks each failure inline as "✖ nx run ", +// while its closing bullet list is capped at a handful of tasks but survives an interleaved stream. +const TASK_ID = /^[A-Za-z0-9@._/-]+:[A-Za-z0-9:._-]+$/; + +function failedTasks(log) { + const tasks = new Set(); + let inList = false; + for (const line of log.split('\n')) { + const inline = line.match(FAILED_TASK); + if (inline) { + tasks.add(inline[1]); + } else if (/^\s*(Failed tasks:|✖\s+\d+\/\d+ targets failed)/.test(line)) { + inList = true; + } else if (inList && /^\s*-\s/.test(line)) { + // Only bullets that look like a task id: a task's own output can pose as Nx's summary list. + const task = line.replace(/^\s*-\s*/, '').replace(/^nx run\s+/, ''); + if (TASK_ID.test(task)) { + tasks.add(task); + } + } else if (inList && line.trim()) { + inList = false; + } + } + return [...tasks].sort(); +} diff --git a/scripts/gate/gates/docs-e2e.mjs b/scripts/gate/gates/docs-e2e.mjs new file mode 100644 index 00000000000..ccf7e133396 --- /dev/null +++ b/scripts/gate/gates/docs-e2e.mjs @@ -0,0 +1,95 @@ +// Runs the docs Playwright e2e tests directly, bypassing Nx. Defaults to chromium only. +import path from 'node:path'; + +import { NONE, VALUE, captureUsage } from '../args.mjs'; + +export default { + name: 'docs-e2e', + script: 'docs-e2e.sh', + // Playwright's list reporter marks a failure with `✘`, and closes with `N passed (1.2m)` / `N failed`. + failRe: /^\s*(✘|\d+\) )/, + summaryRe: /^\s*\d+ (passed|failed|flaky|skipped|did not run|interrupted)/, + + flags: { + '--all-browsers': { takes: NONE, apply: (state) => (state.allBrowsers = true) }, + '--framework': { + takes: VALUE, + hint: 'e.g. reactFunctionalTs', + apply: (state, value) => (process.env.FRAMEWORK = value), + }, + '--url': { + takes: VALUE, + hint: 'e.g. https://localhost:4610', + apply: (state, value) => (process.env.BASE_URL = value), + }, + '--all-variants': { takes: NONE, apply: () => (process.env.ALL_FRAMEWORK_VARIANTS = 'true') }, + }, + + // `--ui` and `--debug` hand the terminal to Playwright and never return on their own, so a captured or + // detached run would hang holding a log nobody reads. + endless: (state) => + state.forward.some((arg) => arg === '--ui' || arg.startsWith('--ui-') || arg === '--debug') + ? '--ui/--debug, which need the terminal' + : undefined, + + plan({ bin, rootDir, state }) { + // Default to chromium unless --all-browsers or --project is already specified. + const browser = + state.allBrowsers || state.forward.some((arg) => arg.includes('--project')) ? [] : ['--project=chromium']; + return { + command: bin('playwright'), + args: ['test', ...state.forward, ...browser], + cwd: path.join(rootDir, 'documentation/ag-grid-docs'), + }; + }, + + usage: ` +Usage: ./docs-e2e.sh [options] [playwright-args] + +Runs docs Playwright e2e tests directly, bypassing Nx. Defaults to chromium only. +Any unrecognised arguments are forwarded directly to playwright test. + +Options: + --all-browsers Run all browsers (chromium, firefox, webkit) + --framework Set FRAMEWORK env var. Valid: typescript, vanilla, + reactFunctionalTs, reactFunctionalTs_Dev, angular, vue3. + Mirrors a CI shard, so reactFunctionalTs covers both React + builds: every example on the production one, plus the tests + naming reactFunctionalTs_Dev outright. Pin that instead to + run only those. + --url Set BASE_URL env var (default: https://localhost:4610) + --all-variants Run every example against the production React variant too (or + ALL_FRAMEWORK_VARIANTS=true). By default examples run on one + React build: development locally, production in CI. Tests + naming a framework outright always run and are unaffected. + --help Show this help message + +Run capture (shared with ./behave.sh, ./checks.sh and ./benches.sh). Every run streams stdout+stderr to +tmp/_docs-e2e-output//output.log, whose path is printed first: +${captureUsage({ runner: 'playwright', width: 26 })} + +Playwright options (forwarded as-is): + "file-pattern" Run tests matching pattern + --grep Run tests matching name + --project Run specific browser project + --headed Run in headed mode + --ui Open Playwright UI mode + --debug Debug mode + --last-failed Re-run only the tests that failed in the previous run + +Examples: + ./docs-e2e.sh + ./docs-e2e.sh "toolbar" + ./docs-e2e.sh "toolbar" --grep "Quick filter" + ./docs-e2e.sh --all-browsers + ./docs-e2e.sh --framework reactFunctionalTs + ./docs-e2e.sh --url https://localhost:4610 + ./docs-e2e.sh --headed + ./docs-e2e.sh --ui + +Iterate-until-green loop (re-run only failures each pass): + ./docs-e2e.sh # initial run records failures to .last-run.json + # ...fix a failing test... + ./docs-e2e.sh --last-failed # re-runs only the failures; repeat until it passes +`, +}; diff --git a/scripts/gate/main.mjs b/scripts/gate/main.mjs new file mode 100644 index 00000000000..3f61f10f4fa --- /dev/null +++ b/scripts/gate/main.mjs @@ -0,0 +1,122 @@ +// Entry point for the repo's gate scripts: `node scripts/gate/main.mjs [args...]`. +// +// ./behave.sh, ./checks.sh, ./benches.sh and ./docs-e2e.sh are one-line shims onto this, so the run capture, +// the `--async`/`--wait`/`--kill` dispatch and the argument parsing are written once rather than once per +// gate. Each gate module contributes only what is its own: the command it runs, its flags, and its help. +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isCI, parseArgs } from './args.mjs'; +import { RunLog, preventIdleSleep, spawnAwait } from './run-log.mjs'; + +// Vite 8 (which Vitest 4 nests) warns, once per config it loads, that these configs are not loadable by the +// `configLoader: 'native'` it plans to default to. Clearing it for real needs `.mts` + `import.meta.dirname`, +// which packages/*/tsconfig.spec.json cannot type-check under `module: commonjs`. Revisit when Vite flips. +process.env.VITE_CONFIG_NATIVE_IGNORE_WARNING = 'true'; + +const GATES = ['behave', 'bench', 'checks', 'docs-e2e']; + +const mainPath = fileURLToPath(import.meta.url); +const rootDir = path.resolve(path.dirname(mainPath), '../..'); +const bin = (name) => path.join(rootDir, 'node_modules/.bin', name); + +process.exitCode = await main(); + +async function main() { + const [gateName, ...argv] = process.argv.slice(2); + if (!GATES.includes(gateName)) { + console.error(`Unknown gate '${gateName ?? ''}' (expected one of: ${GATES.join(', ')})`); + return 1; + } + const gate = (await import(`./gates/${gateName}.mjs`)).default; + + // A pass-through to another tool has to win before anything here reads argv. + const passedThrough = await gate.preParse?.({ argv, rootDir, bin }); + if (passedThrough !== undefined) { + return passedThrough; + } + + // Ahead of parseArgs, whose `apply` callbacks can reject a missing operand: `--projects --help` would + // otherwise exit 1 on the flag the caller was asking about. + const wantsHelp = argv.includes('-h') || argv.includes('--help'); + const state = parseArgs(wantsHelp ? [] : argv, gate.flags ?? {}); + const { async: runAsync, statusId, killId, waitId, waitTimeout = 0, quiet, noLog, runId } = state.capture; + + // Some modes hand the terminal to the runner and never end on their own (watch, --ui, --debug), so there + // is nothing to capture and nothing to wait for: the log would grow with every re-run and the status would + // stay `running` forever. A gate returns the mode's name to refuse `--async` in those words, or just + // `true` to let the generic "the run log is off" answer stand. + const endless = gate.endless?.(state); + const capture = noLog || isCI || endless ? 'off' : quiet || gate.capture === 'file' ? 'file' : 'stream'; + + const runLog = new RunLog({ + name: gate.name, + rootDir, + id: runId, + capture, + // A gate that prints its own verdict would otherwise print the same lines twice. + report: gate.report ?? Boolean(quiet), + failRe: gate.failRe, + summaryRe: gate.summaryRe, + }); + const context = { rootDir, bin, runLog, state, argv }; + + if (wantsHelp) { + // The runner's own flag list first, this script's additions last, so the wrapper-specific part is what + // is still on screen next to the prompt. Never captured: help is not a run. + if (gate.helpCommand) { + await spawnAwait(bin(gate.helpCommand[0]), gate.helpCommand.slice(1), { cwd: rootDir, stdio: 'inherit' }); + console.log(); + } + console.log(gate.usage.trim()); + return 0; + } + + // Before the detach below, not inside `plan`: a detached child's console is /dev/null, so a rejection + // raised there is lost entirely and leaves a run recorded as started that never wrote a line. + const rejection = gate.reject?.(state); + if (rejection !== undefined) { + console.error(`${gate.script}: ${rejection}`); + return 2; + } + + // Reports on or stops an existing run instead of starting one. Every gate needs the same three branches, + // and duplicating them is how their spellings drifted apart. + if (waitId) { + return runLog.wait(waitId, waitTimeout); + } + if (statusId) { + return runLog.status(statusId); + } + if (killId) { + return runLog.kill(killId); + } + if (runAsync) { + if (typeof endless === 'string') { + console.error(`${gate.script}: --async cannot combine with ${endless}.`); + return 2; + } + if (!runLog.enabled) { + console.error('--async needs the run log, which is off (CI, --no-log, or an interactive mode)'); + return 1; + } + return runLog.detach({ script: gate.script, mainPath, argv }); + } + + // A plan is the command to run plus any of `exec`'s options (cwd, env, file, colour), or an exit code for + // a gate that has decided the run must not happen at all. + const plan = await gate.plan(context); + if (plan.exitCode !== undefined) { + return plan.exitCode; + } + runLog.start(`./${gate.script} ${argv.join(' ')}`); + preventIdleSleep(); + // Setup the run must own rather than precede: inside `plan` its output misses the log the script promises, + // and its failure would return past `finish`, leaving the status `running` for a run that is already over. + const setup = await gate.beforeRun?.(context); + if (setup) { + return runLog.finish(setup); + } + const code = await runLog.exec(plan.command, plan.args, plan); + return runLog.finish((await gate.afterRun?.(context, code)) ?? code); +} diff --git a/scripts/gate/run-log.mjs b/scripts/gate/run-log.mjs new file mode 100644 index 00000000000..fbe5ee1cb69 --- /dev/null +++ b/scripts/gate/run-log.mjs @@ -0,0 +1,596 @@ +// Shared run capture for the repo's gate scripts. Every local run gets an id and streams its console output +// to tmp/_-output// as it happens, so a red run can be read back rather than re-run - a full suite +// costs minutes. CI skips all of this, having its own log and artefact collection. +import { execFileSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import { constants } from 'node:os'; +import path from 'node:path'; +import { stripVTControlCharacters } from 'node:util'; + +const ESC = String.fromCharCode(27); + +export const stripAnsi = stripVTControlCharacters; + +const escapeRe = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// `YYYY-MM-DD HH:MM:SS` in local time, which is what a person reading a status file wants: offset the clock so +// `toISOString` renders local rather than UTC. +const stamp = (date) => new Date(date - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 19).replace('T', ' '); + +// How long `--async` waits (in 10ms steps) for the spawned child to appear in `ps`. Generous against a slow +// interpreter start, since the cost is only paid on a child that never starts at all. +const CLAIM_TRIES = 200; + +// A pid is not an identity - the OS reuses them - so a run records which incarnation was its own. Empty for +// a pid that is gone, which is what makes a recorded pid verifiable at all. +function pidStart(pid) { + try { + const out = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + return out.trim().replace(/\s+/g, ' '); + } catch { + return ''; + } +} + +// Every descendant of `root`, from one `ps` snapshot: the runner plus its workers are its children, so +// walking the ppid tree stops `--kill` reaching any other instance. +function processTree(root) { + let out; + try { + out = execFileSync('ps', ['-Ao', 'pid=,ppid='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); + } catch { + return [root]; + } + const children = new Map(); + for (const line of out.split('\n')) { + const match = line.match(/^\s*(\d+)\s+(\d+)\s*$/); + if (match) { + const siblings = children.get(Number(match[2])); + if (siblings) { + siblings.push(Number(match[1])); + } else { + children.set(Number(match[2]), [Number(match[1])]); + } + } + } + // Breadth-first over the growing array, so a grandchild is reached without recursion. + const found = [root]; + for (let i = 0; i < found.length; i++) { + found.push(...(children.get(found[i]) ?? [])); + } + return found; +} + +// Bash reports a signalled child as 128+n, and callers (and CI) compare exit codes, so say the same thing. +const exitCodeOf = (code, signal) => code ?? 128 + (constants.signals[signal] ?? 0); + +/** + * Holds off idle sleep while this process lives, so a run costing minutes is not throttled part way through. + * A `-w` sidecar rather than a `caffeinate ` wrapper, so it needs no cleanup and stays out of the + * runner's process tree - leaving exit codes, Ctrl-C and `--kill` exactly as they would be without it. + */ +export function preventIdleSleep() { + const child = spawn('caffeinate', ['-i', '-w', String(process.pid)], { detached: true, stdio: 'ignore' }); + child.on('error', () => {}); // absent anywhere but macOS, which is the normal case rather than a problem + child.unref(); +} + +/** + * Runs a command with all of its output going to one file and none to the console. One fd for both streams, + * so they interleave by write order exactly as a shell's `>file 2>&1` does. `colour` is for a file that will + * be printed to a console after all; a file that is only ever read back keeps the escapes out. + */ +async function execToFile(command, args, { file, colour = false, env = process.env, ...options }) { + const fd = fs.openSync(file, 'a'); + try { + return await spawnAwait(command, args, { + ...options, + env: colour ? env : { ...env, NO_COLOR: '1' }, + stdio: ['inherit', fd, fd], + }); + } finally { + fs.closeSync(fd); + } +} + +/** + * Runs a command to completion, resolving to the exit code a shell would report for it. `onStdout` is handed the + * stream as well as the chunk, so a slow consumer can pause it. + */ +export function spawnAwait(command, args, { onStdout, ...options } = {}) { + return new Promise((resolve) => { + const child = spawn(command, args, options); + if (onStdout && child.stdout) { + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => onStdout(chunk, child.stdout)); + } + // 127 is what a shell reports for a command it could not run at all. + child.on('error', (error) => { + console.error(`cannot run ${command}: ${error.message}`); + resolve(127); + }); + child.on('close', (code, signal) => resolve(exitCodeOf(code, signal))); + }); +} + +// What a status file says, in the words the console used before it became JSON. +function describeStatus(status) { + switch (status.state) { + case 'running': + return `running ${status.pid}`; + case 'exit': + return `exit ${status.code} ${status.at} ${status.elapsed}s`; + case 'killed': + return `killed ${status.at}`; + case 'died': + return `died (pid ${status.pid} is gone or has been reused)`; + default: + return status.state; + } +} + +export class RunLog { + /** + * `capture` is 'stream' (the console keeps the runner's colours, the file gets them stripped), 'file' + * (the console gets nothing, so the gate can report only what matters) or 'off'. `report` closes a run + * that showed the console nothing with the part a human still needs. + */ + constructor({ name, rootDir, id, capture = 'stream', report = false, failRe, summaryRe }) { + this.name = name; + this.rootDir = rootDir; + this.capture = capture; + this.report = report; + this.failRe = failRe; + this.summaryRe = summaryRe; + this.root = path.join(rootDir, 'tmp', `_${name}-output`); + // The id doubles as a sort key and as a hint of when the run happened; the pid keeps concurrent runs apart. + this.id = id || `${stamp(new Date()).replace(/[-:]/g, '').replace(' ', '-')}-${process.pid}`; + this.dir = path.join(this.root, this.id); + this.file = path.join(this.dir, 'output.log'); + // Set by the parent for the child of `--async`, which owns its own process group; `--kill` needs it. + this.detached = process.env.AG_GATE_DETACHED === '1'; + this.started = 0; + } + + get enabled() { + return this.capture !== 'off'; + } + + get resultJson() { + return path.join(this.dir, 'result.json'); + } + + relative(target) { + return path.relative(this.rootDir, target); + } + + // Prints the id first, so it is on screen even if the run is killed. Written synchronously throughout: + // `--async-status` from another terminal, and `--async`'s own child, can read this directory a + // millisecond after this returns, and must not find it half-made. + start(commandLine) { + if (!this.enabled) { + return; + } + fs.mkdirSync(this.dir, { recursive: true }); + // Replaced by rename, which is atomic: remove-then-symlink lets a second run starting at the same + // moment create the link in the gap and the loser throw EEXIST before its runner ever starts. + const pending = path.join(this.root, `.latest-${process.pid}`); + fs.rmSync(pending, { force: true }); // private to this pid, so this cannot race - only a reused pid's leftover + fs.symlinkSync(this.id, pending); + fs.renameSync(pending, path.join(this.root, 'latest')); + this.prune(); + fs.writeFileSync(path.join(this.dir, 'command'), `${commandLine}\n`); + fs.writeFileSync(this.file, ''); + this.writeStatus({ + state: 'running', + pid: process.pid, + pidStart: pidStart(process.pid), + detached: this.detached, + }); + this.started = Date.now(); + console.log( + `▶ ${this.name} log (full stdout+stderr, read it instead of re-running): ${this.relative(this.file)}` + ); + } + + // Drops runs over a week old, without waiting for it: the cleanup must never sit between the caller and + // the run starting. `isDirectory` leaves the `latest` symlink alone. + prune() { + const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; + fs.promises + .readdir(this.root, { withFileTypes: true }) + .then((entries) => + Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map(async (entry) => { + const dir = path.join(this.root, entry.name); + if ((await fs.promises.stat(dir)).mtimeMs < cutoff) { + await fs.promises.rm(dir, { recursive: true, force: true }); + } + }) + ) + ) + .catch(() => {}); + } + + writeStatus(status, dir = this.dir) { + try { + // Rename, as `latest` above: a truncate-and-rewrite is read half-written by another terminal's + // `--wait`, whose parse failure reads as terminal and reports a passing run failed. + const pending = path.join(dir, `.status-${process.pid}`); + fs.writeFileSync(pending, `${JSON.stringify(status)}\n`); + fs.renameSync(pending, path.join(dir, 'status')); + } catch { + // A run whose status cannot be recorded still has its log, which is the part worth keeping. + } + } + + // Reclassifies `running` when that pid is gone or has been reused by something else - otherwise a stale + // file makes `--wait` (whose default timeout is "forever") never return. + readStatus(dir) { + let status; + try { + status = JSON.parse(fs.readFileSync(path.join(dir, 'status'), 'utf8')); + } catch { + return { state: 'unknown' }; + } + if (status.state === 'running' && status.pidStart !== pidStart(status.pid)) { + return { ...status, state: 'died' }; + } + return status; + } + + // A line the gate itself produces rather than the command it wraps. Goes to the log as well as the + // console, so a captured or detached run - whose stdout is a file or /dev/null - keeps it too. + echo(message, stream = console.log) { + if (!this.report) { + stream(message); + } + this.append(message); + } + + // Log only: a verdict the gate has already printed itself, which a later reader would not otherwise see. + append(message) { + if (this.enabled) { + fs.appendFileSync(this.file, `${message}\n`); + } + } + + lines(file) { + let text; + try { + text = fs.readFileSync(file, 'utf8'); + } catch { + return []; + } + // A streamed log was stripped as it was written, so stripping it again would scan tens of MB to match + // nothing; a captured one still carries the odd escape (Nx emits a few even under NO_COLOR). + return (text.includes(ESC) ? stripAnsi(text) : text).split('\n'); + } + + // The two things worth repeating out of a log: what the runner concluded, and every line it failed on. + // One pass: on a wholesale failure this file is the biggest thing the gate touches. + digest(file = this.file) { + const summary = []; + const failures = []; + for (const line of this.lines(file)) { + if (this.summaryRe?.test(line)) { + summary.push(line); + } + if (this.failRe?.test(line)) { + failures.push(line); + } + } + return { summary, failures }; + } + + /** + * Runs the command, capturing it as `capture` asks. Resolves to the exit code a shell would report. + * `file` sends the output to a file of the gate's choosing rather than the run log's - which is how a + * gate whose output is never watched live keeps a log to read back even when capture is off. + */ + async exec(command, args, { cwd = this.rootDir, env = process.env, file, colour } = {}) { + const target = file ?? (this.capture === 'file' ? this.file : undefined); + if (target) { + return execToFile(command, args, { cwd, env, file: target, colour }); + } + if (!this.enabled) { + return spawnAwait(command, args, { cwd, env, stdio: 'inherit' }); + } + // The log is always stripped - even a run with colour off carries the odd hardcoded escape (Vite's + // CJS warning). A pipe is not a terminal and runners drop colour when they see one, so when the + // console IS a terminal, ask for colour back explicitly: a person watching must not lose it. + const wantsColour = !env.NO_COLOR && process.stdout.isTTY; + return this.#stripTee(command, args, { cwd, env: wantsColour ? { FORCE_COLOR: '1', ...env } : env }); + } + + /** + * Console keeps the runner's colours, the log gets them stripped - which a plain `tee` cannot do, both + * its branches being the same bytes. stderr is merged by the shell rather than piped separately, so the + * log's interleaving is the runner's write order and not the event loop's. + */ + async #stripTee(command, args, options) { + const log = fs.createWriteStream(this.file, { flags: 'a' }); + // The json reporter announces itself with a bare absolute path; print the run-relative one instead. + const jsonReport = new RegExp(`^JSON report written to ${escapeRe(this.rootDir)}/(\\S+)\\s*$`); + let pending = ''; + // Whole lines only, so a line can be rewritten and stripped before either copy sees it; a trailing + // partial waits for its newline, and is flushed as it stands when the stream ends. + const write = (text, source) => { + pending += text; + const end = source ? pending.lastIndexOf('\n') + 1 : pending.length; + if (!end) { + return; + } + const lines = pending.slice(0, end).split('\n'); + pending = pending.slice(end); + const terminated = lines.at(-1) === ''; + if (terminated) { + lines.pop(); + } + const shown = lines.map((line) => line.replace(jsonReport, `▶ ${this.name} json report: $1`)).join('\n'); + const suffix = terminated ? '\n' : ''; + process.stdout.write(shown + suffix); + // Stop reading while the log is behind, so a wholesale failure's diffs cannot queue in memory: the + // runner blocks on its own stdout, which is the backpressure a shell pipeline would have given. + if (!log.write(stripAnsi(shown) + suffix) && source) { + source.pause(); + log.once('drain', () => source.resume()); + } + }; + const code = await spawnAwait('sh', ['-c', 'exec "$@" 2>&1', 'sh', command, ...args], { + ...options, + stdio: ['inherit', 'pipe', 'inherit'], + onStdout: write, + }); + // No `source`: the stream is done, so whatever is left is a line that will never get its newline. + write(''); + await new Promise((resolve) => log.end(resolve)); + return code; + } + + // Records the outcome next to the log so `--wait` (and a later reader) can tell a finished run from a + // killed one, and closes a captured run with the part a human still needs. + finish(code) { + if (!this.enabled) { + return code; + } + const elapsed = Math.round((Date.now() - this.started) / 1000); + this.writeStatus({ state: 'exit', code, at: stamp(new Date()), elapsed }); + const tty = process.env.AG_GATE_TTY; + if (!this.report && !tty) { + return code; + } + const { summary, failures } = this.digest(); + if (this.report) { + for (const line of [...summary, ...failures]) { + console.log(line); + } + console.log(`▶ ${this.name} exit ${code} after ${elapsed}s → ${this.relative(this.file)}`); + } + // A detached run's own output went to /dev/null, so it reports back to the terminal it was launched + // from - the shell there has long since returned to a prompt. Skipped when there was no terminal (an + // agent, a cron, a pipe), and best-effort: the terminal may have closed in the meantime. + if (tty) { + const verdict = code === 0 ? 'passed' : `FAILED (exit ${code})`; + try { + fs.appendFileSync( + tty, + [ + '', + `▶ ${this.name} ${this.id} finished: ${verdict} after ${elapsed}s`, + ...summary, + ...failures, + `▶ ${this.relative(this.file)}`, + '', + ].join('\n') + ); + } catch { + // The terminal has closed; the log holds everything this was repeating. + } + } + return code; + } + + /** + * Detaches the run: the child does the work and owns the log, the parent only reports where to look. The + * child is handed the id its parent already printed, so both halves name the same directory, and the + * parent overwrites the status with the CHILD's pid before returning, since its own is about to be gone. + */ + async detach({ script, mainPath, argv }) { + this.start(`${script} ${argv.join(' ')}`); + const relaunch = argv.filter((arg) => arg !== '--async'); + const child = spawn(process.execPath, [mainPath, this.name, ...relaunch, '--run-id', this.id], { + cwd: this.rootDir, + detached: true, + stdio: 'ignore', + env: { + ...process.env, + AG_GATE_DETACHED: '1', + // The child's stdout is discarded, so pass it the terminal to report back to when it finishes. + AG_GATE_TTY: process.stdout.isTTY ? ttyPath() : '', + }, + }); + child.unref(); + let started = ''; + for (let tries = 0; tries < CLAIM_TRIES && !started; tries++) { + started = pidStart(child.pid); + if (!started) { + await sleep(10); + } + } + // Never appearing in `ps` means the child is already gone, and recording that is right: it is a dead + // pid, which is exactly what a reader should report. Only this process's own claim is replaced: the + // child records its own pid as it starts and its exit code when it ends, and a short or cached run + // gets there first - overwriting that would report a finished run as `died`. + if (this.readStatus(this.dir).pid === process.pid) { + this.writeStatus( + started + ? { state: 'running', pid: child.pid, pidStart: started, detached: true } + : { state: 'died', pid: child.pid } + ); + } + console.log(`${this.name} running in the background; check it with ./${script} --wait ${this.id}`); + return 0; + } + + // The newest run still going, or undefined. Ids are timestamped, so the greatest id is the newest - + // compared explicitly, because `readdir` order is the filesystem's, not chronological, and with two + // runs going the wrong one would be waited on or killed. `isDirectory` leaves the `latest` symlink out. + #runningId() { + let found; + let entries = []; + try { + entries = fs.readdirSync(this.root, { withFileTypes: true }); + } catch { + return undefined; + } + for (const entry of entries) { + const name = entry.name; + if ( + entry.isDirectory() && + (found === undefined || name > found) && + this.readStatus(path.join(this.root, name)).state === 'running' + ) { + found = name; + } + } + return found; + } + + // Accepts a bare id, `auto`, `latest`, or any path containing one - the header line prints a path, so + // that is what a caller usually has to hand (`tmp/_behave-output//output.log`, or the absolute form). + // + // `auto` is what a caller who named no id gets, and it means the run still going - however it was + // started, `--async` or a plain background call. `latest` is the newest run *started*, so a short run + // finishing after a long one began would hand back the wrong one and report it passed. Falls back to + // `latest` when nothing is running, which is then the only run left to mean. + resolveId(raw) { + if (raw === 'auto') { + return this.#runningId() ?? 'latest'; + } + if (fs.existsSync(path.join(this.root, raw))) { + return raw; + } + const fromPath = raw.match(new RegExp(`/_${escapeRe(this.name)}-output/([^/]+)`)); + return fromPath?.[1] ?? raw.match(/\d{8}-\d{6}-\d+/)?.[0] ?? raw; + } + + // Resolves an id to its directory, or reports that there is no such run. + #locate(rawId) { + const id = this.resolveId(rawId); + const dir = path.join(this.root, id); + if (!fs.existsSync(dir)) { + console.error(`No ${this.name} run '${id}' under ${this.relative(this.root)}`); + return {}; + } + return { id, dir }; + } + + // What a finished run amounts to: where to read it, its own summary lines, and any failures. + #reportRun(id, dir, status) { + const rel = this.relative(dir); + console.log(`▶ ${this.name} ${id}: ${describeStatus(status)}`); + console.log(`▶ ${this.name} log (full stdout+stderr): ${rel}/output.log`); + if (fs.existsSync(path.join(dir, 'result.json'))) { + console.log(`▶ ${this.name} json report: ${rel}/result.json`); + } + const { summary, failures } = this.digest(path.join(dir, 'output.log')); + for (const line of [...summary, ...failures]) { + console.log(line); + } + return status.state === 'exit' && status.code === 0 ? 0 : 1; + } + + /** The status of a run right now, with no waiting: 0 passed, 1 failed, 3 still running. */ + status(rawId) { + const { id, dir } = this.#locate(rawId); + if (!id) { + return 1; + } + const status = this.readStatus(dir); + if (status.state === 'running') { + console.log(`▶ ${this.name} ${id} still running (pid ${status.pid})`); + return 3; + } + return this.#reportRun(id, dir, status); + } + + /** + * Waits for another run to finish. Returns 3 while it is still going, so a caller can tell "not done + * yet" from "finished and failed" - blocking the terminal on a suite is what this exists to avoid. + */ + async wait(rawId, timeout = 0) { + const { id, dir } = this.#locate(rawId); + if (!id) { + return 1; + } + for (let waited = 0; ; ) { + // Anything but `running` is terminal, and a run that died without recording it reads as terminal too. + const status = this.readStatus(dir); + if (status.state !== 'running') { + return this.#reportRun(id, dir, status); + } + if (timeout > 0 && waited >= timeout) { + console.log(`▶ ${this.name} ${id} still running after ${waited}s (${describeStatus(status)})`); + return 3; + } + // Never sleep past the deadline: a caller's timeout is what it is willing to block for, so a + // fixed interval would make `--wait 1` cost two seconds. + const step = timeout > 0 ? Math.min(2, timeout - waited) : 2; + await sleep(step * 1000); + waited += step; + } + } + + /** Kills a run and only that run, leaving any other instance of the same gate alone. */ + async kill(rawId) { + const { id, dir } = this.#locate(rawId); + if (!id) { + return 1; + } + // The reader has already rejected a pid the OS has reused, so a stale file cannot kill a stranger. + const status = this.readStatus(dir); + if (status.state !== 'running') { + if (status.state === 'died') { + this.writeStatus(status, dir); + } + console.log(`▶ ${this.name} ${id} is not running (${describeStatus(status)})`); + return 1; + } + const pids = processTree(status.pid); + // A detached run leads its own process group, so the group IS "the run and everything it spawned", + // including anything that has since reparented. A foreground run shares the caller's group, where + // only the descendants may be signalled - group-killing there would take the caller's shell with it. + const targets = status.detached ? [-status.pid, ...pids] : pids; + for (const signal of ['SIGTERM', 'SIGKILL']) { + for (const target of targets) { + try { + process.kill(target, signal); + } catch { + // Already gone, which is the outcome being asked for. + } + } + if (signal === 'SIGTERM') { + await sleep(1000); + } + } + this.writeStatus({ state: 'killed', at: stamp(new Date()) }, dir); + console.log(`▶ ${this.name} ${id} killed (${pids.length} processes)`); + return 0; + } +} + +// The terminal a detached run should report back to, for the `--async` handover. +function ttyPath() { + try { + return execFileSync('tty', { encoding: 'utf8', stdio: ['inherit', 'pipe', 'ignore'] }).trim(); + } catch { + return ''; + } +} diff --git a/testing/ag-test-utils/eslint.config.mjs b/testing/ag-test-utils/eslint.config.mjs new file mode 100644 index 00000000000..060191184f8 --- /dev/null +++ b/testing/ag-test-utils/eslint.config.mjs @@ -0,0 +1,25 @@ +import rootESLint from '../../eslint.config.mjs'; +import { noGuessedDelays } from '../shared/eslint/rules.mjs'; + +export default [ + ...rootESLint, + { + // Scoped to the sources the project covers, and named explicitly: left to auto-detection, + // typescript-eslint finds both this repo root and external/ag-shared and refuses to parse at all. + files: ['src/**/*.ts', 'src/**/*.tsx'], + languageOptions: { + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + // These files were under testing/behavioural until this package existed, where the ban applied. + 'no-restricted-syntax': noGuessedDelays, + // As in testing/behavioural, whose config these files inherited before they moved here: + // TypeScript already reports undefined identifiers, while this rule cannot see DOM lib types + // (TouchEventInit, ParentNode) or vitest's globals, and reports every use of them. + 'no-undef': 0, + }, + }, +]; diff --git a/testing/ag-test-utils/package.json b/testing/ag-test-utils/package.json new file mode 100644 index 00000000000..f6455628175 --- /dev/null +++ b/testing/ag-test-utils/package.json @@ -0,0 +1,11 @@ +{ + "name": "ag-test-utils", + "version": "36.1.0-beta.20260809.2045", + "private": true, + "type": "module", + "description": "Harnesses and assertions shared by the behavioural suite: grid lifecycle, GridRows/GridColumns snapshots, DOM widgets and polyfills. Test-only; never published and never imported by grid source.", + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.ts" + } +} diff --git a/testing/ag-test-utils/project.json b/testing/ag-test-utils/project.json new file mode 100644 index 00000000000..c2e9451971a --- /dev/null +++ b/testing/ag-test-utils/project.json @@ -0,0 +1,15 @@ +{ + "name": "ag-test-utils", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "testing/ag-test-utils/src", + "projectType": "library", + "targets": { + "lint": { + "command": "eslint", + "options": { + "cwd": "{projectRoot}" + } + } + }, + "tags": ["test"] +} diff --git a/testing/behavioural/src/test-utils/cachedJSONObjects.ts b/testing/ag-test-utils/src/cachedJSONObjects.ts similarity index 100% rename from testing/behavioural/src/test-utils/cachedJSONObjects.ts rename to testing/ag-test-utils/src/cachedJSONObjects.ts diff --git a/testing/behavioural/src/test-utils/dev-validations.ts b/testing/ag-test-utils/src/dev-validations.ts similarity index 100% rename from testing/behavioural/src/test-utils/dev-validations.ts rename to testing/ag-test-utils/src/dev-validations.ts diff --git a/testing/behavioural/src/test-utils/drag-n-drop/drag-event-dispatcher.ts b/testing/ag-test-utils/src/drag-n-drop/drag-event-dispatcher.ts similarity index 96% rename from testing/behavioural/src/test-utils/drag-n-drop/drag-event-dispatcher.ts rename to testing/ag-test-utils/src/drag-n-drop/drag-event-dispatcher.ts index c46d569925e..62457e0ce1c 100644 --- a/testing/behavioural/src/test-utils/drag-n-drop/drag-event-dispatcher.ts +++ b/testing/ag-test-utils/src/drag-n-drop/drag-event-dispatcher.ts @@ -37,7 +37,7 @@ export class DragEventDispatcher { private _currentX = 0; private _currentY = 0; - /** Lazily created so environments without DataTransfer (e.g. basic jsdom) can still use pointer-only drags. */ + /** Lazily created so environments without DataTransfer can still use pointer-only drags. */ public get dataTransfer(): DataTransfer { let dt = this._dataTransfer; if (!dt) { @@ -114,21 +114,23 @@ export class DragEventDispatcher { if (targetChanged && previousDropTarget) { const leaveOpts = { clientX, clientY, relatedTarget: targetElement }; await this.fire(previousDropTarget, 'dragleave', leaveOpts); - if (previousDropTarget !== dropContainer) { + // Only when the bubbled event cannot reach it: these events bubble, so a fire on a descendant + // already notifies the container, and firing again delivers it twice, unlike a real browser. + if (!dropContainer.contains(previousDropTarget)) { await this.fire(dropContainer, 'dragleave', leaveOpts); } } if (targetChanged) { const enterOpts = { clientX, clientY, relatedTarget: previousDropTarget ?? null }; - if (targetElement !== dropContainer) { + if (!dropContainer.contains(targetElement)) { await this.fire(dropContainer, 'dragenter', enterOpts); } await this.fire(targetElement, 'dragenter', enterOpts); } this.dataTransfer.dropEffect = 'move'; - if (targetElement !== dropContainer) { + if (!dropContainer.contains(targetElement)) { await this.fire(dropContainer, 'dragover', { clientX, clientY }); } await this.fire(targetElement, 'dragover', { clientX, clientY }); diff --git a/testing/behavioural/src/test-utils/drag-n-drop/drag-n-drop-utils.ts b/testing/ag-test-utils/src/drag-n-drop/drag-n-drop-utils.ts similarity index 100% rename from testing/behavioural/src/test-utils/drag-n-drop/drag-n-drop-utils.ts rename to testing/ag-test-utils/src/drag-n-drop/drag-n-drop-utils.ts diff --git a/testing/behavioural/src/test-utils/drag-n-drop/row-drag-dispatcher.ts b/testing/ag-test-utils/src/drag-n-drop/row-drag-dispatcher.ts similarity index 78% rename from testing/behavioural/src/test-utils/drag-n-drop/row-drag-dispatcher.ts rename to testing/ag-test-utils/src/drag-n-drop/row-drag-dispatcher.ts index e3ea771194b..7bfcd7d38f0 100644 --- a/testing/behavioural/src/test-utils/drag-n-drop/row-drag-dispatcher.ts +++ b/testing/ag-test-utils/src/drag-n-drop/row-drag-dispatcher.ts @@ -1,10 +1,9 @@ -import { waitFor } from '@testing-library/dom'; - import type { GridApi, RowDragCancelEvent, RowDragEndEvent, RowDragEvent, RowDragMoveEvent } from 'ag-grid-community'; import { DestroyedRowNodesChecker } from '../grid-test-utils'; import type { RowElementReference } from '../gridRows/gridHtmlRows'; import { getGridOwnerDocument, getRowHtmlElement } from '../gridRows/gridHtmlRows'; +import { asyncSetTimeout } from '../node-utils'; import { mockGridLayout } from '../polyfills/mockGridLayout'; import { initPointerEventPolyfill } from '../polyfills/pointerEvent'; import { TestGridsManager } from '../testGridsManager'; @@ -186,37 +185,36 @@ export class RowDragDispatcher { this.finalDropTarget = targetElement ?? this.finalDropTarget; - this.detachListeners(); - - await this.waitForSettle(); + // Detached only after settling: a completing event delivered asynchronously must still reach the + // recorder that resolves the settle promise, or the caller hangs to the test timeout instead. + try { + await this.waitForSettle(); + } finally { + this.detachListeners(); + } if (this.finalDropTarget && this.sourceRowId) { - if (this.rowDragEnterEvents.length > 1) { - throw new Error('Row drag enter event fired more than once'); + // Exactly one: no enter event means the drag lifecycle never ran, which is a failure rather + // than a reason to skip every assertion below. + expect(this.rowDragEnterEvents).toHaveLength(1); + + const rowDragEnterEvent = this.rowDragEnterEvents[0]; + expect(rowDragEnterEvent.node.id).toBe(this.sourceRowId); + + const expectedOverId = rowDragEnterEvent.overNode?.id; + if (expectedOverId !== this.sourceRowId && expectedOverId !== this.finalDropTarget.getAttribute('row-id')) { + expect(expectedOverId).toBe(this.sourceRowId); } - if (this.rowDragEnterEvents.length === 1) { - const rowDragEnterEvent = this.rowDragEnterEvents[0]; - expect(rowDragEnterEvent.node.id).toBe(this.sourceRowId); - - const expectedOverId = rowDragEnterEvent.overNode?.id; - if ( - expectedOverId !== this.sourceRowId && - expectedOverId !== this.finalDropTarget.getAttribute('row-id') - ) { - expect(expectedOverId).toBe(this.sourceRowId); - } - - expect(this.rowDragMoveEvents.length).toBeGreaterThan(0); - - if (cancel) { - expect(this.rowDragEndEvents.length).toBe(0); - expect(this.rowDragCancelEvents.length).toBeGreaterThan(0); - } else { - expect(this.rowDragEndEvents.length).toBe(1); - expect(this.rowDragEndEvents[0].node).toBe(rowDragEnterEvent.node); - expect(this.rowDragEndEvents[0].nodes).toBe(rowDragEnterEvent.nodes); - } + expect(this.rowDragMoveEvents.length).toBeGreaterThan(0); + + if (cancel) { + expect(this.rowDragEndEvents.length).toBe(0); + expect(this.rowDragCancelEvents.length).toBeGreaterThan(0); + } else { + expect(this.rowDragEndEvents.length).toBe(1); + expect(this.rowDragEndEvents[0].node).toBe(rowDragEnterEvent.node); + expect(this.rowDragEndEvents[0].nodes).toBe(rowDragEnterEvent.nodes); } } @@ -283,7 +281,9 @@ export class RowDragDispatcher { if (!this.settlePromise) { this.settlePromise = new Promise((resolve) => { this.resolveSettle = () => { - this.settlePromise = undefined; + // The promise itself is kept (only `reset` clears it): dropping it here left the + // already-settled drag looking like one whose events had not arrived, and `waitForSettle` + // then polled its whole budget away on every successful drag. this.resolveSettle = undefined; resolve(); }; @@ -291,16 +291,25 @@ export class RowDragDispatcher { } } + /** + * Best-effort: a drag with no completing event never creates the settle promise, so giving up is a + * legitimate outcome. A plain poll rather than `@testing-library`'s `waitFor`, whose timeout message + * calls `prettyDOM`, which throws on a happy-dom document so the rejection never arrives. + * + * The poll is not a per-drag cost: every recorded event creates the promise and only `reset` clears it, + * so by here it is already defined and the loop exits without sleeping. + */ private async waitForSettle(): Promise { - try { - // Best-effort: a drag that recorded no completing event never creates a settle promise, - // so a timeout here is a legitimate outcome rather than a failure. - await waitFor(() => expect(this.settlePromise).toBeDefined(), { timeout: 100, interval: 2 }); - } catch { - // no completing drag event arrived - fall through and let the caller assert + for (let i = 0; this.settlePromise === undefined && i < 50; ++i) { + // eslint-disable-next-line no-restricted-syntax -- poll interval for the 100ms budget above, not a guess + await asyncSetTimeout(2); } - if (this.settlePromise) { - await this.settlePromise; + const settle = this.settlePromise; + if (settle) { + // Capped too: only a completing event resolves it, so an unbounded await turns a lost rowDragEnd + // into a test timeout instead of the assertion failure the caller is about to make. + // eslint-disable-next-line no-restricted-syntax -- the cap on that wait, not a guessed delay + await Promise.race([settle, asyncSetTimeout(100)]); } } diff --git a/testing/ag-test-utils/src/fastTestTimings.ts b/testing/ag-test-utils/src/fastTestTimings.ts new file mode 100644 index 00000000000..9e18023e98d --- /dev/null +++ b/testing/ag-test-utils/src/fastTestTimings.ts @@ -0,0 +1,7 @@ +/** + * Replaces `packages/ag-stack/src/fastTestTimings.ts` in this suite only (aliased in + * `testing/behavioural/vitest.config.ts`), turning the grid's hard-coded UX delays instant. A suite that + * needs the real timing of something asserts it through the grid option that controls it - the flag only + * removes the floors and intervals a test has no way to reach. + */ +export const FAST_TEST_TIMINGS = true; diff --git a/testing/behavioural/src/test-utils/filters/advancedFilterBuilderHarness.ts b/testing/ag-test-utils/src/filters/advancedFilterBuilderHarness.ts similarity index 97% rename from testing/behavioural/src/test-utils/filters/advancedFilterBuilderHarness.ts rename to testing/ag-test-utils/src/filters/advancedFilterBuilderHarness.ts index 58dc2d5b805..9158021c3d3 100644 --- a/testing/behavioural/src/test-utils/filters/advancedFilterBuilderHarness.ts +++ b/testing/ag-test-utils/src/filters/advancedFilterBuilderHarness.ts @@ -29,7 +29,7 @@ function columnPillOrder(): string { /** * Drives the Advanced Filter Builder dialog through public DOM. Requires the layout mock - * (`installFilterLayoutMock`) so the builder VirtualList and pill rich-select popups render rows in jsdom. + * (`installFilterLayoutMock`) so the builder VirtualList and pill rich-select popups render rows without layout. */ export class AdvancedFilterBuilderHarness { private constructor(public readonly api: GridApi) {} @@ -202,7 +202,7 @@ export class AdvancedFilterBuilderHarness { /** Clicks the Remove button on `item` to delete that condition/group. */ public async removeItem(item: HTMLElement): Promise { - const remove = item.querySelector('[aria-label="Remove"]'); + const remove = this.liveItem(item).querySelector('[aria-label="Remove"]'); if (!remove) { throw new Error('Remove button not found on builder item'); } @@ -228,7 +228,7 @@ export class AdvancedFilterBuilderHarness { private moveButton(item: HTMLElement, direction: 'up' | 'down'): HTMLElement { const label = direction === 'up' ? 'Move Up' : 'Move Down'; - const button = item.querySelector(`[aria-label="${label}"]`); + const button = this.liveItem(item).querySelector(`[aria-label="${label}"]`); if (!button) { throw new Error(`"${label}" button not found (is advancedFilterBuilderParams.showMoveButtons set?)`); } @@ -294,7 +294,7 @@ export class AdvancedFilterBuilderHarness { /** * Re-applies the current model to force the builder to recreate its item rows. Needed before - * dragging in jsdom: the synchronous layout mock renders the initial rows before the builder + * dragging without layout: the synchronous layout mock renders the initial rows before the builder * assigns its drag feature, so the first-render rows have no drag source. */ public async forceReRender(): Promise { diff --git a/testing/behavioural/src/test-utils/filters/advancedFilterHarness.ts b/testing/ag-test-utils/src/filters/advancedFilterHarness.ts similarity index 100% rename from testing/behavioural/src/test-utils/filters/advancedFilterHarness.ts rename to testing/ag-test-utils/src/filters/advancedFilterHarness.ts diff --git a/testing/behavioural/src/test-utils/filters/columnFilterHarness.ts b/testing/ag-test-utils/src/filters/columnFilterHarness.ts similarity index 100% rename from testing/behavioural/src/test-utils/filters/columnFilterHarness.ts rename to testing/ag-test-utils/src/filters/columnFilterHarness.ts diff --git a/testing/behavioural/src/test-utils/filters/filterDom.ts b/testing/ag-test-utils/src/filters/filterDom.ts similarity index 100% rename from testing/behavioural/src/test-utils/filters/filterDom.ts rename to testing/ag-test-utils/src/filters/filterDom.ts diff --git a/testing/behavioural/src/test-utils/filters/filterDomSerialize.ts b/testing/ag-test-utils/src/filters/filterDomSerialize.ts similarity index 100% rename from testing/behavioural/src/test-utils/filters/filterDomSerialize.ts rename to testing/ag-test-utils/src/filters/filterDomSerialize.ts diff --git a/testing/behavioural/src/test-utils/filters/filterDomValidator.ts b/testing/ag-test-utils/src/filters/filterDomValidator.ts similarity index 93% rename from testing/behavioural/src/test-utils/filters/filterDomValidator.ts rename to testing/ag-test-utils/src/filters/filterDomValidator.ts index 299a789df80..769d7e56b94 100644 --- a/testing/behavioural/src/test-utils/filters/filterDomValidator.ts +++ b/testing/ag-test-utils/src/filters/filterDomValidator.ts @@ -89,6 +89,12 @@ export class FilterDomValidator { if (!topLevel.length) { return; } + // A virtualised list mounts only the visible rows, so the mounted subset says nothing about the + // whole selection: `aria-setsize` carries the real total the list is scrolled through. + const setSize = Number(setList.querySelector('[aria-setsize]')?.getAttribute('aria-setsize')); + if (setSize > items.length) { + return; + } let allChecked = true; let allUnchecked = true; for (let i = 0, len = topLevel.length; i < len; ++i) { diff --git a/testing/behavioural/src/test-utils/filters/floatingFilterHarness.ts b/testing/ag-test-utils/src/filters/floatingFilterHarness.ts similarity index 100% rename from testing/behavioural/src/test-utils/filters/floatingFilterHarness.ts rename to testing/ag-test-utils/src/filters/floatingFilterHarness.ts diff --git a/testing/behavioural/src/test-utils/filters/index.ts b/testing/ag-test-utils/src/filters/index.ts similarity index 100% rename from testing/behavioural/src/test-utils/filters/index.ts rename to testing/ag-test-utils/src/filters/index.ts diff --git a/testing/behavioural/src/test-utils/grid-test-utils.ts b/testing/ag-test-utils/src/grid-test-utils.ts similarity index 95% rename from testing/behavioural/src/test-utils/grid-test-utils.ts rename to testing/ag-test-utils/src/grid-test-utils.ts index bcc78add5ac..c018bffbe9d 100644 --- a/testing/behavioural/src/test-utils/grid-test-utils.ts +++ b/testing/ag-test-utils/src/grid-test-utils.ts @@ -208,7 +208,7 @@ export function isAgHtmlElementVisible(element: Element | string | null | undefi } } let current: Element | null = element; - while (current && current.role !== 'row') { + for (;;) { const classList = current.classList; if (classList.contains('ag-hidden') || classList.contains('ag-invisible')) { return false; @@ -217,7 +217,14 @@ export function isAgHtmlElementVisible(element: Element | string | null | undefi if (computedStyle.display === 'none' || computedStyle.visibility === 'hidden') { return false; } + // The row is the last ancestor worth asking - above it sit the containers, whose mocked layout says + // nothing about visibility - but it is checked itself, or a cell in a hidden row reads as visible. + if (current.role === 'row') { + return true; + } current = current.parentElement; + if (!current) { + return true; + } } - return true; } diff --git a/testing/behavioural/src/test-utils/gridColumns/columns-diagram/formatting.ts b/testing/ag-test-utils/src/gridColumns/columns-diagram/formatting.ts similarity index 100% rename from testing/behavioural/src/test-utils/gridColumns/columns-diagram/formatting.ts rename to testing/ag-test-utils/src/gridColumns/columns-diagram/formatting.ts diff --git a/testing/behavioural/src/test-utils/gridColumns/columns-diagram/gridColumnsDiagramTree.ts b/testing/ag-test-utils/src/gridColumns/columns-diagram/gridColumnsDiagramTree.ts similarity index 100% rename from testing/behavioural/src/test-utils/gridColumns/columns-diagram/gridColumnsDiagramTree.ts rename to testing/ag-test-utils/src/gridColumns/columns-diagram/gridColumnsDiagramTree.ts diff --git a/testing/behavioural/src/test-utils/gridColumns/columns-validation-dom/gridColumnsDomValidator.ts b/testing/ag-test-utils/src/gridColumns/columns-validation-dom/gridColumnsDomValidator.ts similarity index 100% rename from testing/behavioural/src/test-utils/gridColumns/columns-validation-dom/gridColumnsDomValidator.ts rename to testing/ag-test-utils/src/gridColumns/columns-validation-dom/gridColumnsDomValidator.ts diff --git a/testing/behavioural/src/test-utils/gridColumns/columns-validation/gridColumnErrors.ts b/testing/ag-test-utils/src/gridColumns/columns-validation/gridColumnErrors.ts similarity index 100% rename from testing/behavioural/src/test-utils/gridColumns/columns-validation/gridColumnErrors.ts rename to testing/ag-test-utils/src/gridColumns/columns-validation/gridColumnErrors.ts diff --git a/testing/behavioural/src/test-utils/gridColumns/columns-validation/gridColumnsErrors.ts b/testing/ag-test-utils/src/gridColumns/columns-validation/gridColumnsErrors.ts similarity index 100% rename from testing/behavioural/src/test-utils/gridColumns/columns-validation/gridColumnsErrors.ts rename to testing/ag-test-utils/src/gridColumns/columns-validation/gridColumnsErrors.ts diff --git a/testing/behavioural/src/test-utils/gridColumns/columns-validation/gridColumnsValidator.ts b/testing/ag-test-utils/src/gridColumns/columns-validation/gridColumnsValidator.ts similarity index 98% rename from testing/behavioural/src/test-utils/gridColumns/columns-validation/gridColumnsValidator.ts rename to testing/ag-test-utils/src/gridColumns/columns-validation/gridColumnsValidator.ts index 6e7da97957f..c23bd3e8cca 100644 --- a/testing/behavioural/src/test-utils/gridColumns/columns-validation/gridColumnsValidator.ts +++ b/testing/ag-test-utils/src/gridColumns/columns-validation/gridColumnsValidator.ts @@ -67,7 +67,7 @@ export class GridColumnsValidator { this.validateSection(rightCols, 'right', rightTree, isRtl, pivotMode); // ── Sort index consistency ────────────────────────────────────────── - this.validateSortIndices(allDisplayedCols); + this.validateSortIndices(allGridCols.length ? allGridCols : allDisplayedCols); // ── Pinned boundary markers ───────────────────────────────────────── this.validatePinnedBoundaryMarkers(leftCols, rightCols, isRtl); @@ -313,11 +313,15 @@ export class GridColumnsValidator { const pivotMode = !!api.getGridOption?.('pivotMode'); const pivotResultCols = (api as any).getPivotResultColumns?.() as Column[] | null | undefined; const pivotResultSet = pivotResultCols ? new Set(pivotResultCols) : null; + // Column ids are user-controlled, so the `pivot_` prefix alone cannot decide this: it is only a + // fallback for a leaked result column the api no longer lists, never a verdict on a declared one. + const declaredColIds = collectDeclaredColIds(api.getGridOption?.('columnDefs')); for (let i = 0, len = gridColumns.allDisplayedCols.length; i < len; ++i) { const col = gridColumns.allDisplayedCols[i]; const colId = col.getColId(); - const isPivotResult = (pivotResultSet?.has(col) ?? false) || colId.startsWith('pivot_'); + const isPivotResult = + (pivotResultSet?.has(col) ?? false) || (colId.startsWith('pivot_') && !declaredColIds.has(colId)); if (!isPivotResult) { continue; } @@ -1359,6 +1363,8 @@ export class GridColumnsValidator { // ── Sort validation ───────────────────────────────────────────────────── + /** Over ALL grid columns: a hidden sorted column owns a real sortIndex, so excluding it from the + * sequence makes a valid grid look non-sequential. */ private validateSortIndices(cols: Column[]): void { const sortedCols = cols.filter((c) => c.getSort() != null); if (sortedCols.length <= 1) { @@ -1583,3 +1589,26 @@ export class GridColumnsValidator { function isValueColShownInPivotMode(col: Column, pivotMode: boolean): boolean { return pivotMode && col.isValueActive(); } + +/** Every colId the user declared, groups included: `colId` when given, else `field`. */ +function collectDeclaredColIds(columnDefs: unknown): Set { + const ids = new Set(); + const walk = (defs: unknown): void => { + if (!Array.isArray(defs)) { + return; + } + for (const def of defs as Record[]) { + const children = def?.children; + if (children) { + walk(children); + continue; + } + const id = (def?.colId ?? def?.field) as string | undefined; + if (id) { + ids.add(id); + } + } + }; + walk(columnDefs); + return ids; +} diff --git a/testing/behavioural/src/test-utils/gridColumns/gridColumns.ts b/testing/ag-test-utils/src/gridColumns/gridColumns.ts similarity index 98% rename from testing/behavioural/src/test-utils/gridColumns/gridColumns.ts rename to testing/ag-test-utils/src/gridColumns/gridColumns.ts index 493d06e6f16..c54410cc5be 100644 --- a/testing/behavioural/src/test-utils/gridColumns/gridColumns.ts +++ b/testing/ag-test-utils/src/gridColumns/gridColumns.ts @@ -143,7 +143,7 @@ export class GridColumns { }); } - #makeError(callerFn: (...args: any[]) => any, message = 'Grid columns errors:'): Error { + #makeError(callerFn: (...args: any[]) => any): Error { let diagram: string | undefined; try { diagram = this.makeDiagram(true); @@ -152,7 +152,7 @@ export class GridColumns { this.errors.throwIfAny(callerFn); return error; } - const error = new Error(message); + const error = new Error('Grid columns errors:'); addDiagramToError(error, diagram, this.label); Error.captureStackTrace(error, callerFn); return error; diff --git a/testing/behavioural/src/test-utils/gridColumns/gridColumnsOptions.ts b/testing/ag-test-utils/src/gridColumns/gridColumnsOptions.ts similarity index 100% rename from testing/behavioural/src/test-utils/gridColumns/gridColumnsOptions.ts rename to testing/ag-test-utils/src/gridColumns/gridColumnsOptions.ts diff --git a/testing/behavioural/src/test-utils/gridRows/grid-rows-helpers.ts b/testing/ag-test-utils/src/gridRows/grid-rows-helpers.ts similarity index 86% rename from testing/behavioural/src/test-utils/gridRows/grid-rows-helpers.ts rename to testing/ag-test-utils/src/gridRows/grid-rows-helpers.ts index d7c2f7eebdf..6a25178c7ae 100644 --- a/testing/behavioural/src/test-utils/gridRows/grid-rows-helpers.ts +++ b/testing/ag-test-utils/src/gridRows/grid-rows-helpers.ts @@ -59,7 +59,20 @@ export interface SnapshotCheckTarget { printDiagram(): void; } -const RETRY_DELAYS_MS = [10, 50, 100] as const; +/** `AG_NO_RETRY=1` drops the retries, so a check fails where it would have silently retried: that is how + * you prove a fix for a flaky check is a real wait and not a slightly longer race. */ +const RETRY_DELAYS_MS: readonly number[] = process.env.AG_NO_RETRY ? [] : [10, 50, 100]; + +/** `file:line` of the `check()` call, plus the test name and label when there is one. */ +function describeCallSite(target: SnapshotCheckTarget): string { + const holder: { stack?: string } = {}; + Error.captureStackTrace(holder as Error, target.methodRef); + const frame = holder.stack?.split('\n')[1]?.match(/\(?([^() ]+\.tsx?:\d+):\d+\)?$/)?.[1]; + const testName = expect.getState().currentTestName; + return [frame ?? '', testName && `in "${testName}"`, target.label && `for "${target.label}"`] + .filter(Boolean) + .join(' '); +} const UNDEFINED_SNAPSHOT_NOTICE = (method: SnapshotCheckMethodName, label: string): string => { return `\n❌ ${CLASS_NAME_BY_METHOD[method]}.${method}() called without a snapshot for "${label}". Run \`./behave.sh --update-grid-rows\` to generate one.\n`; }; @@ -165,19 +178,26 @@ export async function runSnapshotCheck( // the grid is mid-render. Rebuild each retry to re-read the latest state. let attempt = target; let lastError: any; + // `makeError` embeds the diagram itself, so only a snapshot mismatch needs one attached below. + let errorCarriesDiagram = false; for (let i = 0; i <= RETRY_DELAYS_MS.length; i++) { attempt.loadErrors(); if (attempt.hasErrors()) { lastError = attempt.makeError(); + errorCarriesDiagram = true; } else { lastError = tryAssertSnapshot(attempt, diagramSnapshot); + errorCarriesDiagram = false; } if (!lastError) { if (i > 0) { + // Located, not just labelled: workers interleave on stderr, so the warning lands next to + // whatever file happens to finish, and most flaky checks carry no label at all. process.stderr.write( - `${CLASS_NAME_BY_METHOD[target.methodName]} flaky ${target.methodName} detected for "${target.label}" — passed only after retrying with delays. ` + - `Add \`await asyncSetTimeout(N)\` before this check to avoid intermittent failures.\n` + `${CLASS_NAME_BY_METHOD[target.methodName]} flaky ${target.methodName} at ${describeCallSite(target)} ` + + `— passed only after ${RETRY_DELAYS_MS.slice(0, i).reduce((a, b) => a + b, 0)}ms of retries. ` + + `Await the state it needs before this check.\n` ); } return; @@ -188,7 +208,9 @@ export async function runSnapshotCheck( } } - addDiagramToError(lastError, attempt.makeDiagram(), target.label); + if (!errorCarriesDiagram) { + addDiagramToError(lastError, attempt.makeDiagram(), target.label); + } Error.captureStackTrace(lastError, target.methodRef); throw lastError; } @@ -240,8 +262,8 @@ export function collectGridRows( const displayedRows: RowNode[] = []; const detailGridRows = new Map | GridApi, GridRows>(); - api.forEachNode((row: RowNode) => { - rowNodes.push(row); + api.forEachNode((row) => { + rowNodes.push(row as RowNode); }); for (let i = 0, len = api.getDisplayedRowCount(); i < len; ++i) { diff --git a/testing/behavioural/src/test-utils/gridRows/gridHtmlRows.ts b/testing/ag-test-utils/src/gridRows/gridHtmlRows.ts similarity index 71% rename from testing/behavioural/src/test-utils/gridRows/gridHtmlRows.ts rename to testing/ag-test-utils/src/gridRows/gridHtmlRows.ts index 12f8b6bbccd..2b8e0ff24aa 100644 --- a/testing/behavioural/src/test-utils/gridRows/gridHtmlRows.ts +++ b/testing/ag-test-utils/src/gridRows/gridHtmlRows.ts @@ -14,13 +14,12 @@ const ROW_SELECTION_CHECKBOX_QUERIES = [ '.ag-selection-checkbox [aria-checked]', '.ag-group-checkbox input[type="checkbox"]', '.ag-group-checkbox [aria-checked]', - '.ag-checkbox-input-wrapper input[type="checkbox"]', - '.ag-checkbox[aria-checked]', - '.ag-checkbox', + // Selection wrappers only: a bare `.ag-checkbox` fallback also matches a checkbox cell renderer, so a + // row without a selection checkbox would have its data cell clicked instead of failing to find one. ]; export function getGridHTMLElement(api: GridApi): HTMLElement | null { - return TestGridsManager.getHTMLElement(api) ?? null; + return TestGridsManager.getHTMLElement(api); } export interface SpannedCellInfo { @@ -82,35 +81,81 @@ export function getGridRowsHtmlElements(api: GridApi): HTMLE } // Find this grid's own root wrapper to exclude rows from nested detail grids const gridRoot = gridElement.querySelector('.ag-root-wrapper'); - const allRows = Array.from(gridElement.querySelectorAll(ROW_SELECTOR)); if (!gridRoot) { - return allRows; + return Array.from(gridElement.querySelectorAll(ROW_SELECTOR)); } - return allRows.filter((row) => row.closest('.ag-root-wrapper') === gridRoot); + // Scoping the query to the wrapper already drops every row a `closest` filter would have: a row + // outside it has no `.ag-root-wrapper` ancestor at all. + const rows = Array.from(gridRoot.querySelectorAll(ROW_SELECTOR)); + const nestedRoots = gridRoot.querySelectorAll('.ag-root-wrapper'); + if (nestedRoots.length === 0) { + return rows; + } + // Only master/detail gets here. Walking parents by identity costs nothing per step; `closest` + // ran the selector engine at every ancestor of every row, on the assertion path. + const nested = new Set(nestedRoots); + return rows.filter((row) => { + for (let el = row.parentElement; el && el !== gridRoot; el = el.parentElement) { + if (nested.has(el)) { + return false; + } + } + return true; + }); } -export function getRowHtmlElements(api: GridApi, reference: RowElementReference): HTMLElement[] { - const rowId = resolveRowElementId(reference); - if (rowId == null) { - return []; +/** Centre-container elements first: a row spanning pinned columns has one element per container. */ +function orderRowElements(rowElements: HTMLElement[]): HTMLElement[] { + if (rowElements.length < 2) { + return rowElements; } - const rowElements = getGridRowsHtmlElements(api); const mainRowElements: HTMLElement[] = []; const secondaryRowElements: HTMLElement[] = []; - for (const rowElement of rowElements) { - if (rowElement.getAttribute('row-id') !== rowId) { - continue; - } - if (CENTER_CONTAINER_SELECTORS.some((selector) => rowElement.closest(selector))) { mainRowElements.push(rowElement); } else { secondaryRowElements.push(rowElement); } } + const ordered = mainRowElements.length ? mainRowElements.concat(secondaryRowElements) : secondaryRowElements; + // A span anchor renders a second element sharing its row-id, holding the merged cells rather than the + // row's own classes and aria - so the row itself must come first for the `rowElements[0]` callers. + const spanned = (el: HTMLElement) => el.classList.contains('ag-spanned-row'); + return [...ordered.filter((el) => !spanned(el)), ...ordered.filter(spanned)]; +} + +/** + * Every row element grouped by `row-id`. Callers that resolve more than one row must build this once + * and index it: resolving each row on its own rescans the whole grid, making a validation pass + * quadratic in the row count. + */ +export function getGridRowsHtmlElementsById(api: GridApi): Map { + const byId = new Map(); + for (const rowElement of getGridRowsHtmlElements(api)) { + const rowId = rowElement.getAttribute('row-id'); + if (rowId == null) { + continue; + } + const existing = byId.get(rowId); + if (existing) { + existing.push(rowElement); + } else { + byId.set(rowId, [rowElement]); + } + } + for (const [rowId, rowElements] of byId) { + byId.set(rowId, orderRowElements(rowElements)); + } + return byId; +} - return mainRowElements.length ? mainRowElements.concat(secondaryRowElements) : secondaryRowElements; +export function getRowHtmlElements(api: GridApi, reference: RowElementReference): HTMLElement[] { + const rowId = resolveRowElementId(reference); + if (rowId == null) { + return []; + } + return orderRowElements(getGridRowsHtmlElements(api).filter((el) => el.getAttribute('row-id') === rowId)); } export function getRowHtmlElement( diff --git a/testing/behavioural/src/test-utils/gridRows/gridRows.ts b/testing/ag-test-utils/src/gridRows/gridRows.ts similarity index 97% rename from testing/behavioural/src/test-utils/gridRows/gridRows.ts rename to testing/ag-test-utils/src/gridRows/gridRows.ts index ce82c45679d..5a1413efd0d 100644 --- a/testing/behavioural/src/test-utils/gridRows/gridRows.ts +++ b/testing/ag-test-utils/src/gridRows/gridRows.ts @@ -10,6 +10,7 @@ import { cellKey, getGridHTMLElement, parseSpannedCell, rowKey } from './gridHtm import type { GridRowsOptions } from './gridRowsOptions'; import { GridRowsDiagramTree } from './rows-diagram/gridRowsDiagramTree'; import { captureDomInvalidCellKeys } from './rows-validation-dom/cell-helpers'; +import { isInNestedGrid } from './rows-validation-dom/containers-helpers'; import { GridRowsDomValidator } from './rows-validation-dom/gridRowsDomValidator'; import type { GridRowsBugs } from './rows-validation/bugs'; import { gridRowsBugs } from './rows-validation/bugs'; @@ -111,6 +112,10 @@ export class GridRows { } const cells = Array.from(root.querySelectorAll('.ag-spanned-row [col-id]')); for (let i = 0, len = cells.length; i < len; ++i) { + // A detail grid's spans key on its own row indexes, which collide with this grid's. + if (isInNestedGrid(cells[i] as HTMLElement, root)) { + continue; + } const info = parseSpannedCell(cells[i]); if (!info) { continue; @@ -345,7 +350,7 @@ export class GridRows { return new Map(this.rowNodes.map((row, index) => [row, index])); } - #makeError(callerFn: (...args: any[]) => any, message = 'Grid errors:'): Error { + #makeError(callerFn: (...args: any[]) => any): Error { let diagram: string | undefined; try { diagram = this.makeDiagram(true); @@ -354,7 +359,7 @@ export class GridRows { this.errors.throwIfAny(callerFn); return error; } - const error = new Error(message); + const error = new Error('Grid errors:'); addDiagramToError(error, diagram, this.label); Error.captureStackTrace(error, callerFn); return error; diff --git a/testing/behavioural/src/test-utils/gridRows/gridRowsOptions.ts b/testing/ag-test-utils/src/gridRows/gridRowsOptions.ts similarity index 94% rename from testing/behavioural/src/test-utils/gridRows/gridRowsOptions.ts rename to testing/ag-test-utils/src/gridRows/gridRowsOptions.ts index 4994b4be0c6..45c3305390b 100644 --- a/testing/behavioural/src/test-utils/gridRows/gridRowsOptions.ts +++ b/testing/ag-test-utils/src/gridRows/gridRowsOptions.ts @@ -22,8 +22,8 @@ export interface GridRowsOptions { /** * Columns to include when making the diagram. If true, or undefined, all columns will be included. - * If an array, it must contain the id of the columns to include. Default is false, no columns. - * Default is true. There is usually no need to defined this and can be useful only if you have way too many columns. + * If an array, it must contain the id of the columns to include. Default is true. There is usually no + * need to define this, and it is useful only if you have way too many columns. */ forcedColumns?: (string | Column)[] | boolean; diff --git a/testing/behavioural/src/test-utils/gridRows/rows-diagram/formatting.ts b/testing/ag-test-utils/src/gridRows/rows-diagram/formatting.ts similarity index 89% rename from testing/behavioural/src/test-utils/gridRows/rows-diagram/formatting.ts rename to testing/ag-test-utils/src/gridRows/rows-diagram/formatting.ts index 23cdc8a12c1..3cb0c225c69 100644 --- a/testing/behavioural/src/test-utils/gridRows/rows-diagram/formatting.ts +++ b/testing/ag-test-utils/src/gridRows/rows-diagram/formatting.ts @@ -6,6 +6,9 @@ import { valuesEqual } from '../grid-rows-helpers'; import type { GridRows } from '../gridRows'; import { getRowStateFlags, getRowTypePrefix } from './nodeInfo'; +/** Control characters and backslashes: only the JSON form carries them through one diagram line. */ +const RAW_UNSAFE = /[\p{Cc}\\]/u; + /** Serialises a value for diagram output. The default path uses `JSON.stringify` (keeping the * established `"abc"` quoting for strings) but rewraps objects/arrays in single quotes when the * JSON output contains embedded `"` characters — that avoids `\"` escapes in the snapshot @@ -25,10 +28,23 @@ export function serialiseValue(value: unknown): string { return '-Infinity'; } } - const json = JSON.stringify(value); + let json: string | undefined; + try { + json = JSON.stringify(value); + } catch { + // Circular, or a nested bigint: a diagram is a description, so describe it rather than making + // every check on the row throw. + return String(value); + } + // Undefined for a function, a symbol, or an object whose toJSON returns undefined - where the object + // branch below would throw on `.includes` and the declared string return would be a lie. + if (json === undefined) { + return typeof value === 'function' ? 'function' : String(value); + } // STRING containing `"` characters → JSON-encoded form is `"...\"...\""`. Use the raw string - // wrapped in single quotes instead, provided it has no single quote of its own. - if (typeof value === 'string' && json.includes('\\"') && !value.includes("'")) { + // wrapped in single quotes instead, provided it has no single quote of its own. Escapes stay + // JSON-encoded: raw, a `\n` would split the row across two diagram lines. + if (typeof value === 'string' && json.includes('\\"') && !value.includes("'") && !RAW_UNSAFE.test(value)) { return `'${value}'`; } // OBJECT / ARRAY whose JSON form contains `\"` (an actual escape sequence — a string with diff --git a/testing/behavioural/src/test-utils/gridRows/rows-diagram/gridRowsDiagramNode.ts b/testing/ag-test-utils/src/gridRows/rows-diagram/gridRowsDiagramNode.ts similarity index 100% rename from testing/behavioural/src/test-utils/gridRows/rows-diagram/gridRowsDiagramNode.ts rename to testing/ag-test-utils/src/gridRows/rows-diagram/gridRowsDiagramNode.ts diff --git a/testing/behavioural/src/test-utils/gridRows/rows-diagram/gridRowsDiagramTree.ts b/testing/ag-test-utils/src/gridRows/rows-diagram/gridRowsDiagramTree.ts similarity index 100% rename from testing/behavioural/src/test-utils/gridRows/rows-diagram/gridRowsDiagramTree.ts rename to testing/ag-test-utils/src/gridRows/rows-diagram/gridRowsDiagramTree.ts diff --git a/testing/behavioural/src/test-utils/gridRows/rows-diagram/nodeInfo.ts b/testing/ag-test-utils/src/gridRows/rows-diagram/nodeInfo.ts similarity index 100% rename from testing/behavioural/src/test-utils/gridRows/rows-diagram/nodeInfo.ts rename to testing/ag-test-utils/src/gridRows/rows-diagram/nodeInfo.ts diff --git a/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/cell-helpers.ts b/testing/ag-test-utils/src/gridRows/rows-validation-dom/cell-helpers.ts similarity index 100% rename from testing/behavioural/src/test-utils/gridRows/rows-validation-dom/cell-helpers.ts rename to testing/ag-test-utils/src/gridRows/rows-validation-dom/cell-helpers.ts diff --git a/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/containers-helpers.ts b/testing/ag-test-utils/src/gridRows/rows-validation-dom/containers-helpers.ts similarity index 55% rename from testing/behavioural/src/test-utils/gridRows/rows-validation-dom/containers-helpers.ts rename to testing/ag-test-utils/src/gridRows/rows-validation-dom/containers-helpers.ts index 334d2a0f706..c9e02602e85 100644 --- a/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/containers-helpers.ts +++ b/testing/ag-test-utils/src/gridRows/rows-validation-dom/containers-helpers.ts @@ -15,14 +15,15 @@ export function getRowContainerType(el: HTMLElement): string { return 'unknown'; } -/** Returns true if the element is inside a nested grid (e.g. a detail grid inside master-detail). */ +/** + * Returns true if the element is inside a nested grid (e.g. a detail grid inside master-detail). `gridElement` + * is the grid's outermost OWNED element, which sits above its own `.ag-root-wrapper`, so "a wrapper anywhere + * between the two" matches everything: nested means the nearest wrapper is not this grid's. + */ export function isInNestedGrid(el: HTMLElement, gridElement: HTMLElement): boolean { - let parent = el.parentElement; - while (parent && parent !== gridElement) { - if (parent.classList.contains('ag-root-wrapper')) { - return true; - } - parent = parent.parentElement; - } - return false; + const ownWrapper = gridElement.classList.contains('ag-root-wrapper') + ? gridElement + : gridElement.querySelector('.ag-root-wrapper'); + const nearestWrapper = el.closest('.ag-root-wrapper'); + return !!nearestWrapper && nearestWrapper !== ownWrapper; } diff --git a/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/dom-validation-helpers.ts b/testing/ag-test-utils/src/gridRows/rows-validation-dom/dom-validation-helpers.ts similarity index 65% rename from testing/behavioural/src/test-utils/gridRows/rows-validation-dom/dom-validation-helpers.ts rename to testing/ag-test-utils/src/gridRows/rows-validation-dom/dom-validation-helpers.ts index 17d696fec26..f7774dde531 100644 --- a/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/dom-validation-helpers.ts +++ b/testing/ag-test-utils/src/gridRows/rows-validation-dom/dom-validation-helpers.ts @@ -6,22 +6,35 @@ import { getRowContainerType, isInNestedGrid } from './containers-helpers'; /** Collects DOM row-ids in order for DOM-order validation. Returns null if order check is not needed. */ export function getDomRowIds(gridRows: GridRows): string[] | null { - const displayedRows = gridRows.displayedRows; - const hasDuplicates = displayedRows.some((row) => gridRows.isDuplicateIdRow(row)); - const ensureDomOrder = !!gridRows.api.getGridOption('ensureDomOrder'); - const domLayoutPrint = gridRows.api.getGridOption('domLayout') === 'print'; - - if (!hasDuplicates && (ensureDomOrder || domLayoutPrint)) { + // Duplicate ids make a position-by-position comparison meaningless, and that is the only case worth + // skipping - `ensureDomOrder` is precisely when the comparison is worth making. + if (gridRows.displayedRows.some((row) => gridRows.isDuplicateIdRow(row))) { return null; } - const rowElements = getGridRowsHtmlElements(gridRows.api); - return rowElements - .map((rowElement) => rowElement.getAttribute('row-id') ?? '') - .filter((id) => { - const row = gridRows.getById(id); - return !(row && row.sticky); - }); + // Mirrors the ordered walk in gridRowsDomValidator, which asserts order for the scrolling container + // only: pinned rows are validated separately and sticky, detail and nested-grid rows are skipped there. + // Any element included here that the walk does not assert shifts every index after it. + const gridElement = getGridHTMLElement(gridRows.api); + const ids: string[] = []; + for (const element of getGridRowsHtmlElements(gridRows.api)) { + if (getRowContainerType(element) !== 'center' || (gridElement && isInNestedGrid(element, gridElement))) { + continue; + } + // A row-span anchor renders a second element in this lane for the merged cells, sharing its row-id. + if (element.classList.contains('ag-spanned-row')) { + continue; + } + const id = element.getAttribute('row-id') ?? ''; + const row = gridRows.getById(id); + // An id the model does not know belongs to `ensureDomRowsBelongToGrid`, not to an order comparison: + // counting it here would shift every index after it and report the wrong row as misplaced. + if (!row || row.sticky || row.detail) { + continue; + } + ids.push(id); + } + return ids; } /** Asserts that a row appears at the expected position in the DOM order. Returns the next expected index. */ @@ -48,7 +61,11 @@ export function assertDomOrder( return domIndex + 1; } -/** Ensures all row elements in the DOM belong to displayed rows. */ +/** + * Ensures all row elements in the DOM belong to displayed rows. Reads `id`, which a row element does not + * carry, so this currently matches nothing: switching it to `row-id` reports a stale detail-grid row that + * survives a rowData replacement, and an SSRM group id that does not compare equal. Both want their own fix. + */ export function ensureDomRowsBelongToGrid(gridRows: GridRows): void { for (const element of getGridRowsHtmlElements(gridRows.api)) { const id = element.getAttribute('id'); @@ -73,7 +90,8 @@ export function validateNoDuplicateRowIds(gridRows: GridRows): void { const seenIds = new Map(); for (const element of rowElements) { const rowId = element.getAttribute('row-id'); - if (rowId === null) { + // A row-span anchor legitimately has a second element in its container for the merged cells. + if (rowId === null || element.classList.contains('ag-spanned-row')) { continue; } let arr = seenIds.get(rowId); diff --git a/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/gridRowDomCellValidator.ts b/testing/ag-test-utils/src/gridRows/rows-validation-dom/gridRowDomCellValidator.ts similarity index 96% rename from testing/behavioural/src/test-utils/gridRows/rows-validation-dom/gridRowDomCellValidator.ts rename to testing/ag-test-utils/src/gridRows/rows-validation-dom/gridRowDomCellValidator.ts index ac6da3b1c3e..4c74db5dbcf 100644 --- a/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/gridRowDomCellValidator.ts +++ b/testing/ag-test-utils/src/gridRows/rows-validation-dom/gridRowDomCellValidator.ts @@ -16,6 +16,7 @@ import { isAgEditorInput, isAutoGroupColumn, } from './cell-helpers'; +import { isInNestedGrid } from './containers-helpers'; /** Validates cell-level DOM content for a single row against the grid model. */ export class GridRowDomCellValidator { @@ -45,7 +46,9 @@ export class GridRowDomCellValidator { : [[], [], []]; this.displayedColumnIds = new Set(this.displayedSections.flat().map((c) => c.getColId())); this.isGroupRowsDisplay = api.getGridOption('groupDisplayType') === 'groupRows'; - this.autoGroupColumn = this.lookupAutoGroupColumn(); + // Behind the same guard as the reads above: `getColumn`/`getAllGridColumns` belong to ColumnApiModule, + // so an unguarded lookup logs error 200 twice on a grid that omits it. + this.autoGroupColumn = hasColumnApi ? this.lookupAutoGroupColumn() : undefined; this.rowSpanCoveredIndexes = this.collectRowSpanCoverage(); // Permissive when virt isn't suppressed; getAllDisplayedVirtualColumns() returns the full // displayed set when viewport=0 (mocked layout), so this stays correct there too. @@ -63,6 +66,9 @@ export class GridRowDomCellValidator { } const spannedCells = rootEl.querySelectorAll('.ag-spanned-row [col-id]'); for (const cellNode of Array.from(spannedCells)) { + if (isInNestedGrid(cellNode as HTMLElement, rootEl)) { + continue; + } const info = parseSpannedCell(cellNode); if (!info) { continue; @@ -313,9 +319,12 @@ export class GridRowDomCellValidator { if (textContent === stringCellValue) { return; } - // Function/class renderers may wrap the value; tolerate that, but flag when the value is - // missing entirely (an empty cellValue + non-empty text is fine — renderer-controlled). - if (typeof cellRenderer === 'function' && (!stringCellValue || textContent.includes(stringCellValue))) { + // Custom renderers may wrap the value; tolerate that, but flag when the value is missing entirely + // (an empty cellValue + non-empty text is fine — renderer-controlled). A registered string renderer + // is as free to reshape the text as a function one; only the grid's own `ag*` ones are predictable. + const isCustomRenderer = + typeof cellRenderer === 'function' || (typeof cellRenderer === 'string' && !cellRenderer.startsWith('ag')); + if (isCustomRenderer && (!stringCellValue || textContent.includes(stringCellValue))) { return; } rowErrors.add(cellValueMismatchMsg(columnId, cellValue, textContent)); diff --git a/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/gridRowsDomValidator.ts b/testing/ag-test-utils/src/gridRows/rows-validation-dom/gridRowsDomValidator.ts similarity index 90% rename from testing/behavioural/src/test-utils/gridRows/rows-validation-dom/gridRowsDomValidator.ts rename to testing/ag-test-utils/src/gridRows/rows-validation-dom/gridRowsDomValidator.ts index 47e6cca6149..2538aa13e81 100644 --- a/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/gridRowsDomValidator.ts +++ b/testing/ag-test-utils/src/gridRows/rows-validation-dom/gridRowsDomValidator.ts @@ -1,6 +1,6 @@ import type { IRowNode, RowNode } from 'ag-grid-community'; -import { getGridHTMLElement, getRowHtmlElements } from '../gridHtmlRows'; +import { getGridHTMLElement, getGridRowsHtmlElementsById } from '../gridHtmlRows'; import type { GridRows } from '../gridRows'; import type { GridRowsDomRowValidatorParams } from '../gridRowsOptions'; import { gridRowsBugs } from '../rows-validation/bugs'; @@ -43,6 +43,8 @@ export class GridRowsDomValidator { const ssrm = gridRows.api.getGridOption?.('rowModelType') === 'serverSide'; const rowVirtualisationActive = ssrm || gridRows.api.getGridOption?.('suppressRowVirtualisation') !== true; + // Once per pass, not once per row — and a local, because a detail grid recurses into `validate`. + const rowElementsById = getGridRowsHtmlElementsById(gridRows.api); const cellValidator = new GridRowDomCellValidator(gridRows); const domRowIds = getDomRowIds(gridRows); let domRowIdx = 0; @@ -66,7 +68,8 @@ export class GridRowsDomValidator { lastPinnedTopIndex, bugs, headerRowCount, - domRowValidator + domRowValidator, + rowElementsById ); } @@ -75,7 +78,7 @@ export class GridRowsDomValidator { continue; } - const rowElements = this.resolveRowElements(gridRows, row, rowVirtualisationActive); + const rowElements = this.resolveRowElements(rowElementsById, row, rowVirtualisationActive); if (!rowElements) { continue; } @@ -118,7 +121,8 @@ export class GridRowsDomValidator { lastPinnedBottomIndex, bugs, headerRowCount, - domRowValidator + domRowValidator, + rowElementsById ); } @@ -134,9 +138,10 @@ export class GridRowsDomValidator { lastDisplayedRowIndex: number, bugs: Readonly, headerRowCount: number, - domRowValidator: ((params: GridRowsDomRowValidatorParams) => boolean | void) | undefined + domRowValidator: ((params: GridRowsDomRowValidatorParams) => boolean | void) | undefined, + rowElementsById: ReadonlyMap ): void { - const rowElements = this.resolveRowElements(gridRows, row); + const rowElements = this.resolveRowElements(rowElementsById, row); if (!rowElements) { return; } @@ -155,13 +160,17 @@ export class GridRowsDomValidator { /** Marks a row as validated, gets its DOM elements, and reports missing elements. Returns null if row was already validated or has no elements. * When `allowMissing` is true (SSRM virtualisation), missing DOM elements are silently * treated as "not rendered yet" instead of erroring. */ - private resolveRowElements(gridRows: GridRows, row: RowNode, allowMissing = false): HTMLElement[] | null { + private resolveRowElements( + rowElementsById: ReadonlyMap, + row: RowNode, + allowMissing = false + ): HTMLElement[] | null { if (this.validatedRows.has(row)) { return null; } this.validatedRows.add(row); const stringId = String(row.id); - const rowElements = getRowHtmlElements(gridRows.api, stringId); + const rowElements = rowElementsById.get(stringId) ?? []; if (!allowMissing) { this.errors.add( row, diff --git a/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/rowAriaValidation.ts b/testing/ag-test-utils/src/gridRows/rows-validation-dom/rowAriaValidation.ts similarity index 86% rename from testing/behavioural/src/test-utils/gridRows/rows-validation-dom/rowAriaValidation.ts rename to testing/ag-test-utils/src/gridRows/rows-validation-dom/rowAriaValidation.ts index 6db55cecf77..41bb0f4e78b 100644 --- a/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/rowAriaValidation.ts +++ b/testing/ag-test-utils/src/gridRows/rows-validation-dom/rowAriaValidation.ts @@ -18,7 +18,10 @@ export function validateRowAriaAttributes( return; } - // aria-expanded: should be present on expandable rows, absent on non-expandable rows + // aria-expanded: must be absent on a non-expandable row, and where an expandable row carries it, agree + // with the model. An expandable row MISSING it is deliberately not claimed - `RowCtrl` stamps the + // attribute only while creating the element, so a row that becomes expandable later legitimately has + // none, and asserting it here would report the grid rather than the row under test. if (bugs.ariaExpanded) { const expandable = row.isExpandable(); const ariaExpanded = el.getAttribute('aria-expanded'); diff --git a/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/rowClassValidation.ts b/testing/ag-test-utils/src/gridRows/rows-validation-dom/rowClassValidation.ts similarity index 100% rename from testing/behavioural/src/test-utils/gridRows/rows-validation-dom/rowClassValidation.ts rename to testing/ag-test-utils/src/gridRows/rows-validation-dom/rowClassValidation.ts diff --git a/testing/behavioural/src/test-utils/gridRows/rows-validation-dom/rowSelectionValidation.ts b/testing/ag-test-utils/src/gridRows/rows-validation-dom/rowSelectionValidation.ts similarity index 100% rename from testing/behavioural/src/test-utils/gridRows/rows-validation-dom/rowSelectionValidation.ts rename to testing/ag-test-utils/src/gridRows/rows-validation-dom/rowSelectionValidation.ts diff --git a/testing/behavioural/src/test-utils/gridRows/rows-validation/bugs.ts b/testing/ag-test-utils/src/gridRows/rows-validation/bugs.ts similarity index 100% rename from testing/behavioural/src/test-utils/gridRows/rows-validation/bugs.ts rename to testing/ag-test-utils/src/gridRows/rows-validation/bugs.ts diff --git a/testing/behavioural/src/test-utils/gridRows/rows-validation/gridRowErrors.ts b/testing/ag-test-utils/src/gridRows/rows-validation/gridRowErrors.ts similarity index 100% rename from testing/behavioural/src/test-utils/gridRows/rows-validation/gridRowErrors.ts rename to testing/ag-test-utils/src/gridRows/rows-validation/gridRowErrors.ts diff --git a/testing/behavioural/src/test-utils/gridRows/rows-validation/gridRowsErrors.ts b/testing/ag-test-utils/src/gridRows/rows-validation/gridRowsErrors.ts similarity index 100% rename from testing/behavioural/src/test-utils/gridRows/rows-validation/gridRowsErrors.ts rename to testing/ag-test-utils/src/gridRows/rows-validation/gridRowsErrors.ts diff --git a/testing/behavioural/src/test-utils/gridRows/rows-validation/gridRowsValidationState.ts b/testing/ag-test-utils/src/gridRows/rows-validation/gridRowsValidationState.ts similarity index 100% rename from testing/behavioural/src/test-utils/gridRows/rows-validation/gridRowsValidationState.ts rename to testing/ag-test-utils/src/gridRows/rows-validation/gridRowsValidationState.ts diff --git a/testing/behavioural/src/test-utils/gridRows/rows-validation/gridRowsValidator.ts b/testing/ag-test-utils/src/gridRows/rows-validation/gridRowsValidator.ts similarity index 100% rename from testing/behavioural/src/test-utils/gridRows/rows-validation/gridRowsValidator.ts rename to testing/ag-test-utils/src/gridRows/rows-validation/gridRowsValidator.ts diff --git a/testing/behavioural/src/test-utils/gridRows/rows-validation/validator-computed.ts b/testing/ag-test-utils/src/gridRows/rows-validation/validator-computed.ts similarity index 100% rename from testing/behavioural/src/test-utils/gridRows/rows-validation/validator-computed.ts rename to testing/ag-test-utils/src/gridRows/rows-validation/validator-computed.ts diff --git a/testing/behavioural/src/test-utils/gridRows/rows-validation/validator-leafs.ts b/testing/ag-test-utils/src/gridRows/rows-validation/validator-leafs.ts similarity index 84% rename from testing/behavioural/src/test-utils/gridRows/rows-validation/validator-leafs.ts rename to testing/ag-test-utils/src/gridRows/rows-validation/validator-leafs.ts index 97108e37cc8..3eb63f45ef4 100644 --- a/testing/behavioural/src/test-utils/gridRows/rows-validation/validator-leafs.ts +++ b/testing/ag-test-utils/src/gridRows/rows-validation/validator-leafs.ts @@ -1,26 +1,33 @@ import { RowNode } from 'ag-grid-community'; -import { rowIdAndIndexToString } from '../../grid-test-utils'; import type { GridRows } from '../gridRows'; import type { GridRowsErrors } from './gridRowsErrors'; export interface RowAllLeafs { row: RowNode; + /** The data rows reached through `childrenAfterAggFilter`, so post-filter and not `row.allLeafChildren`. */ leafs: RowNode[]; count: number | null; - allLeafChildren: Set; } export function verifyLeafs( errors: GridRowsErrors, allLeafsMap: Map, gridRows: GridRows, - row: RowNode + row: RowNode, + /** Rows whose recursion is still in flight, so a cycle two or more nodes long is reported rather than + * overflowing the stack - as the `childrenAfterGroup` validator below already does. */ + visiting: Set = new Set() ): RowAllLeafs { let result = allLeafsMap.get(row); if (result !== undefined) { return result; } + if (visiting.has(row)) { + errors.add(row, 'Circular reference in childrenAfterAggFilter ' + row.id); + return { row, leafs: [], count: null }; + } + visiting.add(row); let count = 0; let duplicates = 0; @@ -39,8 +46,10 @@ export function verifyLeafs( errors.add(row, 'Found self in allChildren'); continue; } - const childAllChildren = verifyLeafs(errors, allLeafsMap, gridRows, array[i]); - for (const leaf of childAllChildren.leafs) { + const childAllChildren = verifyLeafs(errors, allLeafsMap, gridRows, array[i], visiting); + // The child is itself a leaf of `row` when it is a data row, which under tree data every row is. + const childLeafs = treeData || !child.group ? [child, ...childAllChildren.leafs] : childAllChildren.leafs; + for (const leaf of childLeafs) { if (allChildrenSet.has(leaf)) { ++duplicates; } else { @@ -76,25 +85,13 @@ export function verifyLeafs( allLeafChildrenDuplicates > 0 && 'Found ' + allLeafChildrenDuplicates + ' duplicates building allLeafChildren' ); - const allLeafChildren = new Set(Array.isArray(row.allLeafChildren) ? row.allLeafChildren : []); - for (const child of allLeafChildren) { - if (!allLeafChildrenSet.has(child)) { - errors.add(row, 'Missing ' + rowIdAndIndexToString(child) + ' in allLeafChildren'); - } - } - for (const child of allLeafChildrenSet) { - if (!allLeafChildren.has(child)) { - errors.add(row, 'Extra ' + rowIdAndIndexToString(child) + ' in allLeafChildren'); - } - } - result = { row, leafs: Array.from(allChildrenSet), count: count === 0 && row.level >= 0 ? null : count, - allLeafChildren: allChildrenSet, }; allLeafsMap.set(row, result); + visiting.delete(row); return result; } diff --git a/testing/behavioural/src/test-utils/gridRows/snapshot-updater.ts b/testing/ag-test-utils/src/gridRows/snapshot-updater.ts similarity index 88% rename from testing/behavioural/src/test-utils/gridRows/snapshot-updater.ts rename to testing/ag-test-utils/src/gridRows/snapshot-updater.ts index c7e08f2279b..37b0c3416ae 100644 --- a/testing/behavioural/src/test-utils/gridRows/snapshot-updater.ts +++ b/testing/ag-test-utils/src/gridRows/snapshot-updater.ts @@ -178,7 +178,19 @@ export async function processSnapshotUpdates(currentTestFile?: string): Promise< // Deduplicate overlapping replacements const deduped: Replacement[] = []; + // Ranges a conflict has already disqualified: with three or more claimants the loop below has + // dropped the pair by the time the third arrives, and accepting it would write the very snapshot + // the conflict says nobody can agree on. + const conflicted = new Set(); for (const r of replacements) { + const range = `${r.start}:${r.end}`; + if (conflicted.has(range)) { + logWarning( + ` ⚠️️ Skipped ${relPath}:${r.line} — "${r.label}" (shared variable produces different snapshots across parameterizations — expand the test.each/describe.each)` + ); + totalSkipped++; + continue; + } if (deduped.length > 0) { const prev = deduped[deduped.length - 1]; if (r.end > prev.start) { @@ -187,6 +199,7 @@ export async function processSnapshotUpdates(currentTestFile?: string): Promise< // Different content for the same target — shared variable with different parameterizations. // Skip BOTH to avoid corruption. The user needs to expand the parameterized test. deduped.pop(); + conflicted.add(range); logWarning( ` ⚠️️ Skipped ${relPath}:${prev.line} — "${prev.label}" (shared variable produces different snapshots across parameterizations — expand the test.each/describe.each)` ); @@ -273,27 +286,38 @@ export async function processSnapshotUpdates(currentTestFile?: string): Promise< /** The classes a snapshot call must be made on, so an unrelated `.check()` is never rewritten. */ const SNAPSHOT_CLASS_NAMES = new Set(Object.values(CLASS_NAME_BY_METHOD)); -/** Walks the receiver chain of a check call to the underlying `new GridRows/GridColumns(api, LABEL)` - * and returns LABEL when it's a static string/template literal, else undefined (dynamic label). */ -function extractGridInstanceLabel(ts: Typescript, expr: any): string | undefined { +type VarDeclarations = Map; + +/** Walks the receiver chain of a check call to the `new GridRows/GridColumns/FilterDom(...)` behind it, or + * undefined when the call belongs to something else entirely. Both passes gate on this. */ +function snapshotReceiver(ts: Typescript, expr: any, varDeclarations?: VarDeclarations): any { let cursor: any = expr; - while (cursor && ts.isParenthesizedExpression(cursor)) { - cursor = cursor.expression; - } - while (cursor && (ts.isCallExpression(cursor) || ts.isPropertyAccessExpression(cursor))) { - cursor = (cursor as any).expression; + const unwrapParens = () => { while (cursor && ts.isParenthesizedExpression(cursor)) { cursor = cursor.expression; } + }; + unwrapParens(); + while (cursor && (ts.isCallExpression(cursor) || ts.isPropertyAccessExpression(cursor))) { + cursor = (cursor as any).expression; + unwrapParens(); } - if (!cursor || !ts.isNewExpression(cursor) || !cursor.arguments || cursor.arguments.length < 2) { - return undefined; + // `const rows = new GridRows(api, 'x'); rows.check()` holds the instance in a variable, so without + // resolving it the call is unrecognisable and a no-argument one is left to the nearest-line fallback, + // which then rewrites whichever other snapshot happens to sit within five lines of it. + if (cursor && ts.isIdentifier(cursor)) { + cursor = varDeclarations?.get(cursor.text)?.node.initializer ?? cursor; } - if (!ts.isIdentifier(cursor.expression) || !SNAPSHOT_CLASS_NAMES.has(cursor.expression.text)) { + if (!cursor || !ts.isNewExpression(cursor) || !ts.isIdentifier(cursor.expression)) { return undefined; } - const labelArg = cursor.arguments[1]; - if (ts.isStringLiteral(labelArg) || ts.isNoSubstitutionTemplateLiteral(labelArg)) { + return SNAPSHOT_CLASS_NAMES.has(cursor.expression.text) ? cursor : undefined; +} + +/** LABEL from `new GridRows(api, LABEL)` when it is a static string/template literal, else undefined. */ +function receiverLabel(ts: Typescript, receiver: any): string | undefined { + const labelArg = receiver?.arguments?.[1]; + if (labelArg && (ts.isStringLiteral(labelArg) || ts.isNoSubstitutionTemplateLiteral(labelArg))) { return labelArg.text; } return undefined; @@ -345,10 +369,11 @@ function findReplacements( const expr = node.expression; // Match .check(...) / .checkColumns(...) / .checkFilterDom(...) — PropertyAccessExpression if (ts.isPropertyAccessExpression(expr) && SNAPSHOT_CHECK_METHODS.has(expr.name.text)) { - const label = extractGridInstanceLabel(ts, expr.expression); + const receiver = snapshotReceiver(ts, expr.expression, varDeclarations); + const label = receiverLabel(ts, receiver); // A no-argument call has no template literal to identify it, so it is only claimed when // the receiver is recognisably one of ours - `destroyedNodeChecker.check()` is not. - if (node.arguments.length >= 1 || label !== undefined) { + if (node.arguments.length >= 1 || receiver !== undefined) { const callLine = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1; // 1-based checkCalls.push({ callLine, node, arg: node.arguments[0], methodName: expr.name.text, label }); } @@ -484,7 +509,10 @@ function findIndentationFixes(ts: Typescript, source: string, file: string): Rep if ( ts.isPropertyAccessExpression(expr) && SNAPSHOT_CHECK_METHODS.has(expr.name.text) && - node.arguments.length >= 1 + node.arguments.length >= 1 && + // Update mode walks every test file, so without this an unrelated object's multiline + // `check()` argument is rewritten as though it were a snapshot. + snapshotReceiver(ts, expr) ) { const arg = node.arguments[0]; if (ts.isNoSubstitutionTemplateLiteral(arg)) { @@ -524,6 +552,14 @@ function findIndentationFixes(ts: Typescript, source: string, file: string): Rep return; // already correct } + // Only the canonical shape is repaired, where the opening backtick ends its line and the + // closing one is alone on the last. Otherwise the slice below drops real content, and + // `check(`a\nb`)` becomes an empty snapshot. + if (originalLines[0].trim() || originalLines[originalLines.length - 1].trim()) { + ts.forEachChild(node, visit); + return; + } + // Re-apply correct indent: strip existing indent, apply expected const contentLines = originalLines.slice(1, -1); // skip line after opening backtick and closing indent line const stripped = contentLines.map((l) => l.slice(existingIndent.length)); @@ -532,17 +568,8 @@ function findIndentationFixes(ts: Typescript, source: string, file: string): Rep const newText = '`\n' + fixedLines.join('\n') + '\n' + closingIndent + '`'; const callLine = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1; - // Try to extract the label from the second argument of the GridRows constructor call - let label = ''; - const receiver = expr.expression; // the object .check() is called on - if ( - ts.isNewExpression(receiver) && - receiver.arguments && - receiver.arguments.length >= 2 && - ts.isStringLiteral(receiver.arguments[1]) - ) { - label = receiver.arguments[1].text; - } + // The shared walk, so a label still resolves through a wrapped or chained receiver. + const label = receiverLabel(ts, snapshotReceiver(ts, expr)) ?? ''; fixes.push({ start, end, newText, line: callLine, label, indentFixed: true }); } diff --git a/testing/ag-test-utils/src/ignoreKnownNoise.ts b/testing/ag-test-utils/src/ignoreKnownNoise.ts new file mode 100644 index 00000000000..b4250614a84 --- /dev/null +++ b/testing/ag-test-utils/src/ignoreKnownNoise.ts @@ -0,0 +1,32 @@ +// The known-harmless thing a grid under test writes to the console on almost every file. Filtered at its +// own source and forwarding everything else untouched, so a real error is never swallowed. + +/** + * The filter currently on `console.error`, compared by identity rather than by a marker property: + * `vi.spyOn` copies the spied function's own properties onto the spy, so a spy installed over the filter + * would carry the marker and we would skip re-wrapping — letting the licence box through to that spy. + */ +let installedFilter: typeof console.error | undefined; + +/** Drops the AG Grid Enterprise licence box. Re-callable: a test's `spyOn`/`mockRestore` displaces the filter. */ +export function ignoreConsoleLicenseKeyError(): void { + if (console.error === installedFilter) { + return; + } + + const wrapped = console.error; + const consoleErrorImpl = (...args: unknown[]): void => { + if ( + args.length === 1 && + typeof args[0] === 'string' && + args[0].startsWith('*') && + args[0].endsWith('*') && + args[0].length === 124 + ) { + return; // AG Grid license box line + } + wrapped.apply(console, args); + }; + installedFilter = consoleErrorImpl; + console.error = consoleErrorImpl; +} diff --git a/testing/behavioural/src/test-utils/index.ts b/testing/ag-test-utils/src/index.ts similarity index 95% rename from testing/behavioural/src/test-utils/index.ts rename to testing/ag-test-utils/src/index.ts index 97d16e94d9b..145c7809dd9 100644 --- a/testing/behavioural/src/test-utils/index.ts +++ b/testing/ag-test-utils/src/index.ts @@ -4,6 +4,7 @@ export * from './polyfills/mockGridLayout'; export * from './polyfills/filterLayoutMock'; export * from './widgets/dropdowns'; export * from './widgets/inputs'; +export * from './widgets/tooltips'; export * from './filters'; export * from './polyfills/pointerEvent'; export * from './polyfills/clipboard'; @@ -21,7 +22,7 @@ export * from './utils'; export * from './node-utils'; export * from './string-utils'; export * from './cachedJSONObjects'; -export * from './ignoreConsoleLicenseKeyError'; +export * from './ignoreKnownNoise'; export * from './grid-test-utils'; export * from './testGridsManager'; export * from './rows-snapshot'; diff --git a/testing/behavioural/src/test-utils/menu-test-utils.ts b/testing/ag-test-utils/src/menu-test-utils.ts similarity index 81% rename from testing/behavioural/src/test-utils/menu-test-utils.ts rename to testing/ag-test-utils/src/menu-test-utils.ts index d92810851ef..da152e4b5dc 100644 --- a/testing/behavioural/src/test-utils/menu-test-utils.ts +++ b/testing/ag-test-utils/src/menu-test-utils.ts @@ -1,7 +1,7 @@ import { waitFor } from '@testing-library/dom'; /** - * jsdom has no layout engine, so `HTMLElement.offsetParent` is always null and AG Grid's + * happy-dom has no layout engine, so `HTMLElement.offsetParent` is always null and AG Grid's * visibility/focus-management code treats popups (menus) as hidden. Polyfill it so menus render * and behave. Returns a restore function to call in `afterEach`. */ @@ -41,3 +41,8 @@ export function openMenuOption(name: string): Promise { return option; }); } + +/** Wait for the menu option with the given text, then click the `.ag-menu-option` row carrying it. */ +export async function clickMenuOption(name: string): Promise { + (await openMenuOption(name)).closest('.ag-menu-option')!.click(); +} diff --git a/testing/behavioural/src/test-utils/node-utils.ts b/testing/ag-test-utils/src/node-utils.ts similarity index 62% rename from testing/behavioural/src/test-utils/node-utils.ts rename to testing/ag-test-utils/src/node-utils.ts index 3f451e3df22..1b75b80ca46 100644 --- a/testing/behavioural/src/test-utils/node-utils.ts +++ b/testing/ag-test-utils/src/node-utils.ts @@ -1,5 +1,4 @@ import { setTimeout as __asyncSetTimeout } from 'timers/promises'; -import { vitest } from 'vitest'; export const asyncSetTimeout = __asyncSetTimeout; @@ -9,10 +8,3 @@ export const asyncSetTimeout = __asyncSetTimeout; */ // eslint-disable-next-line no-restricted-syntax -- waits out the grid's 50ms missing-module report debounce window export const waitForMissingModuleReports = () => asyncSetTimeout(60); - -export async function flushFakeTimers() { - vitest.advanceTimersByTime(10000); - vitest.useRealTimers(); - // eslint-disable-next-line no-restricted-syntax -- waits for the real timer queue to drain after switching off fake timers - await asyncSetTimeout(2); -} diff --git a/testing/behavioural/src/test-utils/patchBeansToJson.ts b/testing/ag-test-utils/src/patchBeansToJson.ts similarity index 100% rename from testing/behavioural/src/test-utils/patchBeansToJson.ts rename to testing/ag-test-utils/src/patchBeansToJson.ts diff --git a/testing/ag-test-utils/src/polyfills/canvasPolyfill.ts b/testing/ag-test-utils/src/polyfills/canvasPolyfill.ts new file mode 100644 index 00000000000..336254ebfe4 --- /dev/null +++ b/testing/ag-test-utils/src/polyfills/canvasPolyfill.ts @@ -0,0 +1,194 @@ +import type { CanvasLike } from 'ag-charts-core'; + +// Both runtime deps are loaded lazily (inside `init()`, not at module scope) - this module is +// re-exported from test-utils/index.ts, which almost every behavioural test imports regardless of +// whether it touches charts. skia-canvas ships a platform-specific native binary, so a top-level +// import would require every test in the suite to have a working one, not just the ones that call +// `init`; ag-charts-core is 405KB that node then evaluates in all 580 workers to serve the seven +// files that render a real chart. +// Typed against `ConfiguredCanvasMixin`'s own fixed return shape (not skia-canvas's concrete `Canvas` +// type) so no skia-canvas type reference - which would need either a runtime import or the banned +// `import()` type syntax - is needed at module scope. +type ConfiguredCanvasInstance = CanvasLike & { transferToImageBitmap(): CanvasLike }; +let NodeCanvas: (new (...args: any[]) => ConfiguredCanvasInstance) | undefined; +type NodeCanvasInstance = ConfiguredCanvasInstance; + +let initialized = false; +let originalCreateElement: typeof document.createElement | undefined; +/** Descriptors, not values: a global that was absent must go back to absent, or a later + * `'OffscreenCanvas' in globalThis` feature test answers yes for the rest of the worker. */ +let originalGlobals: [string, PropertyDescriptor | undefined][] | undefined; +let originalSizeDescriptors: [DimensionProp, PropertyDescriptor | undefined][] | undefined; + +const PATCHED_GLOBALS = ['Path2D', 'DOMMatrix', 'Image', 'OffscreenCanvas'] as const; + +/** The chart's container is `.ag-chart-canvas-wrapper` (`GridChartComp`'s `eChart`). */ +const CHART_CONTAINER_CLASS = 'ag-chart-canvas-wrapper'; + +type DimensionProp = keyof typeof MOCK_CHART_SIZE; + +/** + * Size the mocked chart container reports. Roughly a docked chart panel, and large enough that a + * cartesian chart lays out axes and labels rather than collapsing to its minimums. + */ +const MOCK_CHART_SIZE = { clientWidth: 600, clientHeight: 400 }; + +/** + * happy-dom pins `clientWidth`/`clientHeight` at 0, so the chart never gets a size and + * `Chart.checkFirstAutoSize()` burns its whole 500ms timeout on every layout-level update. + */ +/** Installed after `mockGridLayout.init()` (a grid manager is constructed in the describe body, this runs in + * `beforeAll`), so the descriptor captured here is the grid mock's and `reset` hands it back. */ +function mockChartContainerSize(): void { + originalSizeDescriptors = []; + for (const prop of ['clientWidth', 'clientHeight'] as const) { + // Own descriptor for the restore, inherited one for the delegate: the DOM defines these on + // `Element.prototype`, so shadowing them here without walking up would pin every other element at 0. + const own = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop); + const inherited = own ?? findInheritedDescriptor(prop); + originalSizeDescriptors.push([prop, own]); + Object.defineProperty(HTMLElement.prototype, prop, { + configurable: true, + get(this: HTMLElement) { + // This element only, not `closest`: AG Charts auto-sizes from the wrapper, and sizing its + // descendants too would hand every canvas and overlay a layout happy-dom never gave them. A + // class check rather than `matches`, since every dimension read in the suite pays for it. + return this.classList.contains(CHART_CONTAINER_CLASS) + ? MOCK_CHART_SIZE[prop] + : (inherited?.get?.call(this) ?? 0); + }, + }); + } +} + +function findInheritedDescriptor(prop: DimensionProp): PropertyDescriptor | undefined { + let proto: object | null = Object.getPrototypeOf(HTMLElement.prototype); + while (proto) { + const descriptor = Object.getOwnPropertyDescriptor(proto, prop); + if (descriptor) { + return descriptor; + } + proto = Object.getPrototypeOf(proto); + } + return undefined; +} + +/** + * Opt-in: patches `document.createElement('canvas')` so each canvas element is backed by + * `skia-canvas` (via `ag-charts-core`'s `ConfiguredCanvasMixin`/`applySkiaPatches`) and provides + * globals (`Path2D`, `DOMMatrix`, `Image`, `OffscreenCanvas`) AG Charts' rendering layer expects. + * Mirrors the setup + * `ag-charts-server-side` uses for its own SSR and image-snapshot tests — happy-dom has no native + * canvas support, so without this AG Charts can't construct a real chart. Call `init` in + * `beforeAll` for tests that render real Integrated Charts, and `reset` in `afterAll` to restore + * the environment's defaults for other tests sharing the same worker. + */ +export const canvasPolyfill = { + init, + reset, +}; + +async function init(): Promise { + if (initialized) { + return false; + } + // Set before the awaits so a second caller cannot install the patches twice, and rolled back below if + // setup throws: leaving it true with no saved globals wedges `reset()` and every later `init()`. + initialized = true; + try { + return await install(); + } catch (error) { + // Whatever `install` had already patched before it threw, restored: a half-installed polyfill + // stays with the worker, and the next `init()` would then save the patched state as the original. + reset(); + throw error; + } +} + +async function install(): Promise { + const [SkiaCanvas, { ConfiguredCanvasMixin, applySkiaPatches }] = await Promise.all([ + import('skia-canvas'), + import('ag-charts-core'), + ]); + const { Canvas, DOMMatrix, Image, Path2D } = SkiaCanvas; + // Destructured off the namespace import (rather than a named import, which collides with the DOM + // lib global of the same name under `isolatedModules`) - skia-canvas exports this as a class but + // its types don't reflect that on the namespace, hence the cast. + const { CanvasRenderingContext2D } = SkiaCanvas as unknown as { + CanvasRenderingContext2D: { prototype: CanvasRenderingContext2D }; + }; + applySkiaPatches(CanvasRenderingContext2D, DOMMatrix); + NodeCanvas = ConfiguredCanvasMixin(Canvas); + + const global = globalThis as unknown as Record; + originalGlobals = PATCHED_GLOBALS.map((name): [string, PropertyDescriptor | undefined] => [ + name, + Object.getOwnPropertyDescriptor(global, name), + ]); + // AG Charts measures text through `new OffscreenCanvas(w, h).getContext('2d')`, which happy-dom has no + // implementation of - without it every layout pass that measures a label throws. + Object.assign(global, { Path2D, DOMMatrix, Image, OffscreenCanvas: NodeCanvas }); + + const canvases = new WeakMap(); + // Before the size patch, because `reset()` keys off this being set: patching first would leave the + // prototype rewritten for the rest of the worker if anything in between threw. + originalCreateElement = document.createElement.bind(document); + + mockChartContainerSize(); + + document.createElement = ((tagName: string, options?: ElementCreationOptions): HTMLElement => { + const element = originalCreateElement!(tagName, options); + if (tagName.toLowerCase() !== 'canvas') { + return element; + } + + const canvasEl = element as HTMLCanvasElement; + const originalGetContext = canvasEl.getContext.bind(canvasEl); + Object.defineProperty(canvasEl, 'getContext', { + value: (contextType: string, ...args: any[]) => { + if (contextType !== '2d') { + return originalGetContext(contextType as '2d', ...args); + } + let nodeCanvas = canvases.get(canvasEl); + if (!nodeCanvas || nodeCanvas.width !== canvasEl.width || nodeCanvas.height !== canvasEl.height) { + nodeCanvas = new NodeCanvas!(canvasEl.width || 1, canvasEl.height || 1); + canvases.set(canvasEl, nodeCanvas); + } + return nodeCanvas.getContext('2d'); + }, + writable: true, + configurable: true, + }); + + return canvasEl; + }) as typeof document.createElement; + + return true; +} + +// Each half is restored on its own rather than behind one guard, so this also undoes a partial install. +function reset(): void { + if (originalCreateElement) { + document.createElement = originalCreateElement; + originalCreateElement = undefined; + } + for (const [name, descriptor] of originalGlobals ?? []) { + restoreProperty(globalThis, name, descriptor); + } + for (const [prop, descriptor] of originalSizeDescriptors ?? []) { + // Nothing of its own before: dropping the shim is what makes the inherited Element.prototype + // getter visible again. + restoreProperty(HTMLElement.prototype, prop, descriptor); + } + originalSizeDescriptors = undefined; + originalGlobals = undefined; + initialized = false; +} + +function restoreProperty(target: object, name: string, descriptor: PropertyDescriptor | undefined): void { + if (descriptor) { + Object.defineProperty(target, name, descriptor); + } else { + Reflect.deleteProperty(target, name); + } +} diff --git a/testing/behavioural/src/test-utils/polyfills/clipboard.ts b/testing/ag-test-utils/src/polyfills/clipboard.ts similarity index 100% rename from testing/behavioural/src/test-utils/polyfills/clipboard.ts rename to testing/ag-test-utils/src/polyfills/clipboard.ts diff --git a/testing/ag-test-utils/src/polyfills/domGlobals.ts b/testing/ag-test-utils/src/polyfills/domGlobals.ts new file mode 100644 index 00000000000..82e4a268dee --- /dev/null +++ b/testing/ag-test-utils/src/polyfills/domGlobals.ts @@ -0,0 +1,29 @@ +/** + * Standard globals the DOM environment omits, recovered from a live instance so tests and grid code can + * be written against the real platform. happy-dom implements these interfaces but never exposes the + * constructors on `window`. + */ +export function polyfillDomGlobals(): void { + const global = globalThis as Record; + if (typeof global.DOMTokenList === 'undefined') { + global.DOMTokenList = Object.getPrototypeOf(document.createElement('div').classList).constructor; + } + if (typeof global.Option === 'undefined') { + // AG Charts parses every colour through `new Option().style.color`, so this is on the chart path, + // not just forms. Returning the element overrides `this` for the `new` call. + function Option(text?: string, value?: string, defaultSelected?: boolean, selected?: boolean) { + const option = document.createElement('option') as HTMLOptionElement; + if (text !== undefined) { + option.text = text; + } + if (value !== undefined) { + option.value = value; + } + option.defaultSelected = !!defaultSelected; + option.selected = !!selected; + return option; + } + Option.prototype = HTMLOptionElement.prototype; + global.Option = Option; + } +} diff --git a/testing/ag-test-utils/src/polyfills/filterLayoutMock.ts b/testing/ag-test-utils/src/polyfills/filterLayoutMock.ts new file mode 100644 index 00000000000..31b0bfe0868 --- /dev/null +++ b/testing/ag-test-utils/src/polyfills/filterLayoutMock.ts @@ -0,0 +1,62 @@ +/** + * happy-dom gives virtual-list viewports 0 height, so filter/rich-select VirtualLists render 0 rows. + * Wraps the active `getBoundingClientRect` to force a tall height so tests can query/click rows. + * Install in `beforeAll`, uninstall in `afterAll`. + */ +import { VIRTUAL_LIST_VIEWPORT_CLASSES } from './virtualListViewports'; + +const DEFAULT_VIEWPORT_HEIGHT = 400; + +/** The shared viewport list plus `.ag-autocomplete-list`, whose own rect is what its list measures. */ +const TALL_VIEWPORT_SELECTORS = [...VIRTUAL_LIST_VIEWPORT_CLASSES, '.ag-autocomplete-list'] as const; + +let saved: typeof Element.prototype.getBoundingClientRect | undefined; +/** The wrapper this module installed, so uninstall can tell it is still the active one. */ +let installed: typeof Element.prototype.getBoundingClientRect | undefined; + +function matchesTallViewport(el: Element): boolean { + for (let i = 0, len = TALL_VIEWPORT_SELECTORS.length; i < len; ++i) { + if (el.classList.contains(TALL_VIEWPORT_SELECTORS[i].slice(1))) { + return true; + } + } + return false; +} + +/** Overrides viewport heights so filter/rich-select VirtualLists render rows without a layout engine. */ +export function installFilterLayoutMock(height: number = DEFAULT_VIEWPORT_HEIGHT): void { + if (saved) { + return; + } + saved = Element.prototype.getBoundingClientRect; + const previous = saved; + installed = function (this: Element): DOMRect { + const rect = previous.call(this); + if (matchesTallViewport(this)) { + return new DOMRect(rect.x, rect.y, rect.width || 200, height); + } + return rect; + }; + Object.defineProperty(Element.prototype, 'getBoundingClientRect', { + configurable: true, + writable: true, + value: installed, + }); +} + +export function uninstallFilterLayoutMock(): void { + if (!saved) { + return; + } + // Only if ours is still the active one: `mockGridLayout.init()` replaces this outright, and restoring + // over it would hand the whole worker the native 0-height rect for the rest of the file. + if (Element.prototype.getBoundingClientRect === installed) { + Object.defineProperty(Element.prototype, 'getBoundingClientRect', { + configurable: true, + writable: true, + value: saved, + }); + } + saved = undefined; + installed = undefined; +} diff --git a/testing/ag-test-utils/src/polyfills/mockGridLayout.ts b/testing/ag-test-utils/src/polyfills/mockGridLayout.ts new file mode 100644 index 00000000000..4b61fee996e --- /dev/null +++ b/testing/ag-test-utils/src/polyfills/mockGridLayout.ts @@ -0,0 +1,512 @@ +// A deterministic fake layout for the test DOM, installed once per worker onto the Element/HTMLElement +// prototypes: happy-dom computes none, so every rect, offset and scroll dimension the grid measures would +// read 0, virtualisation would render nothing and popups would have nowhere to go. +import { VIRTUAL_LIST_VIEWPORT_CLASSES } from './virtualListViewports'; + +let initialized = false; + +/** Backing store for the patched scrollTop/scrollLeft below, which shadow happy-dom's own. */ +const scrollPositions = new WeakMap(); + +function getScrollPos(el: Element): { top: number; left: number } { + let pos = scrollPositions.get(el); + if (!pos) { + pos = { top: 0, left: 0 }; + scrollPositions.set(el, pos); + } + return pos; +} + +export const mockGridLayout = { + /** Same as standard default rowHeight, --ag-row-height */ + rowHeight: 42, + + gridWidth: 1000, + gridHeight: 800, + headerHeight: 30, + columnWidth: 150, + dragHandleWidth: 20, + + /** Must match the widget's `LIST_ITEM_HEIGHT` default: the virtual list hit-tests clicks by + * clientY, so a mismatch drifts row selection past the first couple of rows. */ + listItemHeight: 24, + + /** Source `offset*`/`client*` from `getBoundingClientRect()`. Off by default so snapshots keep the + * implementation's 0; opt in for viewport-aware code such as page-key navigation. */ + useRealOffsetDimensions: false, + + /** Per-element measured height, for cases like wrapped text driving an autoHeight wrapper taller; + * undefined falls back to the standard mock. Needs `useRealOffsetDimensions` to reach `offsetHeight`. */ + elementHeightOverride: undefined as ((el: HTMLElement) => number | undefined) | undefined, + + init, + resetOptions, +}; + +/** Restored by `resetOptions`. Only isolation keeps a suite that threw before its `afterAll` from + * handing its grid size to whatever runs next in the same worker. */ +const DEFAULT_OPTIONS = { ...mockGridLayout }; + +function resetOptions(): void { + Object.assign(mockGridLayout, DEFAULT_OPTIONS); +} + +/** The computed-style properties {@link init}'s `getComputedStyle` wrapper may write, and must undo first. */ +const OVERRIDDEN_STYLE_PROPS = ['width', 'height', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft']; + +const POPUP_OR_DIALOG_SELECTOR = + '.ag-popup,.ag-dialog,.ag-advanced-filter-builder,.ag-tooltip,.ag-rich-select-list,.ag-menu'; +// `.ag-rich-select` on top of the shared list: here the match is by `closest`, so the picker's own wrapper counts. +const VIRTUAL_LIST_SELECTOR = [...VIRTUAL_LIST_VIEWPORT_CLASSES, '.ag-rich-select'].join(','); + +function inPopupOrDialog(el: HTMLElement): boolean { + return !!el.closest(POPUP_OR_DIALOG_SELECTOR); +} + +function inVirtualList(el: HTMLElement): boolean { + return !!el.closest(VIRTUAL_LIST_SELECTOR); +} + +/** + * The grid's scrollbar probe. Answered with 0 it reads as "the DOM isn't ready", so nothing is cached and + * a fresh div is built and measured on every call, which is what makes startup slow. + */ +function isScrollbarProbe(el: HTMLElement): boolean { + // `msOverflowStyle` first: happy-dom doesn't know the property, so the grid's assignment leaves a + // plain own property and this is a bare lookup, undefined for everything else. + const style = el.style as CSSStyleDeclaration & { msOverflowStyle?: string }; + return style.msOverflowStyle === 'scrollbar' && style.overflow === 'scroll' && style.position === 'absolute'; +} + +// Precedence order: lowest matching rank wins. One lookup per class the element has, rather than a +// `contains` per class the mock knows about, on the hottest path in this file. +const ELEMENT_TYPES = [ + 'scrollable-area', + 'scrolling-rows', + 'header-row', + 'advanced-filter-header', + 'row', + 'header', + 'viewport', + 'grid', + 'column', + 'cell', + 'drag-handle', + 'rich-select-row', +] as const; +const ELEMENT_TYPE_RANK = new Map([ + ['ag-grid-scrollable-area', 0], + ['ag-grid-scrolling-rows', 1], + ['ag-header-row', 2], + ['ag-advanced-filter-header', 3], + ['ag-row', 4], + ['ag-header', 5], + ['ag-grid-viewport', 6], + ['ag-root', 7], + ['ag-header-cell', 8], + ['ag-cell', 9], + ['ag-drag-handle', 10], + ['ag-rich-select-row', 11], +]); + +const getElementType = (el: HTMLElement): (typeof ELEMENT_TYPES)[number] | 'body' | 'default' => { + if (el === document.body) { + return 'body'; + } + const classList = el.classList; + let best = -1; + for (let i = 0, len = classList.length; i < len; i++) { + const rank = ELEMENT_TYPE_RANK.get(classList[i]!); + if (rank !== undefined && (best === -1 || rank < best)) { + best = rank; + } + } + return best === -1 ? 'default' : ELEMENT_TYPES[best]; +}; + +/** Pinned rows carry a prefixed `row-index` (`t-0`/`b-0`), so the model index is the trailing number and + * `parseInt` of the whole value is NaN - which spreads into the row's rect and every cell rect under it. + * Kept local rather than shared with the row helpers: this module patches the prototypes before any grid + * module loads, so it pulls in no grid code. */ +const parseRowIndexAttr = (el: HTMLElement): number => { + const index = Number(el.getAttribute('row-index')?.replace(/^\D+/, '')); + return Number.isFinite(index) ? index : 0; +}; + +function getBoundingClientRect(this: HTMLElement): DOMRect { + const { gridWidth, gridHeight, rowHeight, headerHeight, columnWidth, listItemHeight } = mockGridLayout; + + const type = getElementType(this); + + let width = gridWidth; + let height = 20; + let top = 0; + let left = 0; + + switch (type) { + case 'scrollable-area': { + height = gridHeight; + break; + } + case 'scrolling-rows': { + height = gridHeight; + break; + } + case 'header': { + height = headerHeight; + break; + } + case 'viewport': { + top = headerHeight; + height = gridHeight - headerHeight; + break; + } + case 'advanced-filter-header': { + top = headerHeight; + height = headerHeight; + break; + } + case 'grid': { + height = gridHeight; + break; + } + case 'column': { + width = columnWidth; + height = headerHeight; + break; + } + + case 'row': { + const rowIndex = parseRowIndexAttr(this); + const paginationOffset = getPaginationOffset(this); + const adjustedRowIndex = rowIndex - paginationOffset; + top = adjustedRowIndex * rowHeight; + height = rowHeight; + break; + } + case 'header-row': { + height = headerHeight; + break; + } + + case 'cell': { + const rowIndex = parseRowIndexAttr(this); + const colIndex = parseInt(this.getAttribute('col-index') || '0', 10); + const paginationOffset = getPaginationOffset(this); + const adjustedRowIndex = rowIndex - paginationOffset; + + top = adjustedRowIndex * rowHeight; + left = colIndex * columnWidth; + width = columnWidth; + height = rowHeight; + break; + } + + case 'drag-handle': { + const cellRect = + (this.closest('.ag-cell') ?? this.closest('.ag-row'))?.getBoundingClientRect() ?? + new DOMRect(0, 0, 75, mockGridLayout.rowHeight); + + return new DOMRect(cellRect.left, cellRect.top, mockGridLayout.dragHandleWidth, cellRect.height); + } + + case 'rich-select-row': { + height = listItemHeight; + break; + } + + case 'body': + width = gridWidth; + height = gridHeight; + break; + + case 'default': { + // position:fixed = auto-width measurement container; return 0 so auto-sizing falls back to minWidth. + if (this.style?.position === 'fixed') { + width = 0; + height = 0; + } else { + width = 100; + height = 20; + } + break; + } + } + + // Prefer explicit grid-set style dimensions so auto-sizing reads real column/row sizes. + const styleWidth = parseFloat(this.style?.width); + if (!isNaN(styleWidth) && styleWidth > 0) { + width = styleWidth; + } + + const styleHeight = parseFloat(this.style?.height); + if (!isNaN(styleHeight) && styleHeight > 0) { + height = styleHeight; + } + + const overrideHeight = mockGridLayout.elementHeightOverride?.(this); + if (overrideHeight != null) { + height = overrideHeight; + } + + // The parent, not `offsetParent ?? parentElement`: nothing here reports a real offsetParent, so that + // fallback resolved to the parent every time anyway, at the cost of a `closest()` per rect. + const offsetParent = this.parentElement; + if (offsetParent) { + const parentRect = offsetParent.getBoundingClientRect(); + top += parentRect.top || 0; + left += parentRect.left || 0; + } + + return new DOMRect(left, top, width, height); +} + +function init(): boolean { + if (initialized) { + return false; + } + initialized = true; + innerTextPolyfill(); + + const DOMRectInspect = class DOMRect { + constructor( + public x: number, + public y: number, + public width: number, + public height: number + ) {} + }; + + Object.defineProperty(DOMRect.prototype, Symbol.for('nodejs.util.inspect.custom'), { + configurable: true, + writable: true, + value: function inspect(this: DOMRect) { + return new DOMRectInspect(this.x, this.y, this.width, this.height); + }, + }); + + Object.defineProperty(Element.prototype, 'getBoundingClientRect', { + configurable: true, + value: getBoundingClientRect, + }); + + // happy-dom's getComputedStyle is cheap; the per-call work below is not. 89% of the suite's calls + // repeat on the same element inside one synchronous turn, so hand back the same declaration until + // the next microtask, keyed on everything that could change the answer (no author stylesheets are + // in play: the theme is injected as strings and nothing processes CSS). + let styleTurn = 0; + let styleTurnScheduled = false; + const styleCache = new WeakMap(); + // A cached width is only as fresh as the rect it came from, so the key spans the mock's dimensions too: + // a test that resizes the grid mid-turn and re-measures must not be handed the old one. Two things it + // cannot see, both needing a DOM change inside one synchronous turn: an ancestor's rect (which the + // element's own rect sums), and a swap of `elementHeightOverride` for a different function. + const styleKey = (el: Element): string => { + const { gridWidth, gridHeight, rowHeight, headerHeight, columnWidth, listItemHeight } = mockGridLayout; + const own = `${el.getAttribute('style') ?? ''}|${el.getAttribute('class') ?? ''}`; + const rect = `${el.getAttribute('row-index') ?? ''}|${el.getAttribute('col-index') ?? ''}`; + const layout = `${gridWidth},${gridHeight},${rowHeight},${headerHeight},${columnWidth},${listItemHeight}`; + return `${own}|${rect}|${layout}|${mockGridLayout.useRealOffsetDimensions}|${mockGridLayout.elementHeightOverride !== undefined}`; + }; + + const origGetComputedStyle = window.getComputedStyle; + window.getComputedStyle = function patchedGetComputedStyle( + el: Element, + pseudoElement?: string | null + ): CSSStyleDeclaration { + if (!styleTurnScheduled) { + styleTurnScheduled = true; + queueMicrotask(() => { + styleTurn++; + styleTurnScheduled = false; + }); + } + const cacheable = !pseudoElement && el instanceof HTMLElement; + const key = cacheable ? styleKey(el) : ''; + if (cacheable) { + const hit = styleCache.get(el); + if (hit !== undefined && hit.turn === styleTurn && hit.key === key) { + return hit.style; + } + } + const style = origGetComputedStyle.call(window, el, pseudoElement); + if (cacheable) { + styleCache.set(el, { turn: styleTurn, key, style }); + } + if (cacheable) { + // happy-dom hands back one live declaration per element for that element's lifetime, so + // last round's overrides must go first or they read as the implementation's own and pin the + // size forever. `delete` restores the prototype accessor; none are own properties natively. + for (const prop of OVERRIDDEN_STYLE_PROPS) { + delete (style as unknown as Record)[prop]; + } + const rect = el.getBoundingClientRect(); + // Keep width/height consistent with getBoundingClientRect, but only where the DOM has no + // answer of its own: a computed 0 otherwise suppresses column virtualisation (viewportRight === 0). + const origWidth = style.width; + const origHeight = style.height; + if (rect.width > 0 && (!origWidth || origWidth === '0px' || origWidth === '0')) { + Object.defineProperty(style, 'width', { + value: `${rect.width}px`, + writable: true, + configurable: true, + }); + } + if (rect.height > 0 && (!origHeight || origHeight === '0px' || origHeight === '0')) { + Object.defineProperty(style, 'height', { + value: `${rect.height}px`, + writable: true, + configurable: true, + }); + } + // Unset padding computes to '' without layout where a browser says '0px', and callers that + // `parseFloat` it (virtual-list drag hit-testing) would get NaN. + for (const prop of ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'] as const) { + if (style[prop] === '') { + Object.defineProperty(style, prop, { value: '0px', writable: true, configurable: true }); + } + } + } + return style; + }; + + // These live on HTMLElement.prototype, shadowing any Element.prototype patch, so install there too. + // Behind the flag, since the default 0 is what the captured snapshots record. + const installOffsetDimensionPatch = (prop: 'offsetHeight' | 'clientHeight' | 'offsetWidth' | 'clientWidth') => { + const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop); + const axis = prop === 'offsetWidth' || prop === 'clientWidth' ? 'width' : 'height'; + const isHeightProp = prop === 'offsetHeight' || prop === 'clientHeight'; + Object.defineProperty(HTMLElement.prototype, prop, { + configurable: true, + get(this: HTMLElement) { + // Ahead of the mode checks: every suite measures the probe, not only those opting into + // real dimensions, and a rect of 0 would leave it inconclusive for both. + if (isScrollbarProbe(this)) { + return Number.parseFloat(this.style[axis]) || 0; + } + if (mockGridLayout.useRealOffsetDimensions) { + return this.getBoundingClientRect()[axis]; + } + if (isHeightProp && inVirtualList(this)) { + return this.getBoundingClientRect()[axis]; + } + return original?.get?.call(this) ?? 0; + }, + }); + }; + for (const prop of ['offsetHeight', 'clientHeight', 'offsetWidth', 'clientWidth'] as const) { + installOffsetDimensionPatch(prop); + } + + const origOffsetParentDesc = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetParent'); + Object.defineProperty(HTMLElement.prototype, 'offsetParent', { + configurable: true, + get(this: HTMLElement) { + const native = origOffsetParentDesc?.get?.call(this); + if (native != null) { + return native; + } + if (inPopupOrDialog(this)) { + return this.parentElement; + } + return null; + }, + }); + + // scrollHeight/scrollWidth must reflect the grid's virtual container size, which lives as a style on a + // nested child, so recurse to propagate the max upwards. One installer per axis: the two bodies differed + // only in `height`/`width`, which is how a fix reaches one and silently misses the other. + const installScrollSizePatch = (prop: 'scrollHeight' | 'scrollWidth', axis: 'height' | 'width') => { + Object.defineProperty(Element.prototype, prop, { + configurable: true, + get(this: HTMLElement) { + let max = this.getBoundingClientRect()[axis]; + const styleValue = parseFloat(this.style?.[axis]); + if (!isNaN(styleValue) && styleValue > max) { + max = styleValue; + } + const children = this.children; + for (let i = 0, len = children.length; i < len; ++i) { + const childMax = (children[i] as HTMLElement)[prop]; + if (childMax > max) { + max = childMax; + } + } + return max; + }, + }); + }; + installScrollSizePatch('scrollHeight', 'height'); + installScrollSizePatch('scrollWidth', 'width'); + + // No 'scroll' is fired on programmatic scrollTop/scrollLeft, which drives grid virtualisation; + // patch the setters to dispatch it. Values live in a WeakMap since the patched accessor owns them. + const installScrollPositionPatch = (prop: 'scrollTop' | 'scrollLeft', edge: 'top' | 'left') => { + Object.defineProperty(Element.prototype, prop, { + configurable: true, + get(this: Element) { + return getScrollPos(this)[edge]; + }, + set(this: Element, value: number) { + const pos = getScrollPos(this); + const clamped = Math.max(0, value); + if (pos[edge] !== clamped) { + pos[edge] = clamped; + this.dispatchEvent(new Event('scroll')); + } + }, + }); + }; + installScrollPositionPatch('scrollTop', 'top'); + installScrollPositionPatch('scrollLeft', 'left'); + + // Absolute, not offsetParent-relative: nothing here reports a real offsetParent, and the consumers that + // subtract a scroll offset get a consistent frame either way. + const installOffsetPositionPatch = (prop: 'offsetTop' | 'offsetLeft', edge: 'top' | 'left') => { + Object.defineProperty(Element.prototype, prop, { + configurable: true, + get(this: Element) { + return this.getBoundingClientRect()[edge]; + }, + }); + }; + installOffsetPositionPatch('offsetTop', 'top'); + installOffsetPositionPatch('offsetLeft', 'left'); + + return true; +} + +function getPaginationOffset(el: HTMLElement): number { + const body = el.closest('.ag-grid-scrolling-rows'); + if (!body) { + return 0; + } + + const rows = body.querySelectorAll('.ag-row:not(.ag-header-row)'); + let minIndex = Infinity; + + for (let i = 0; i < rows.length; i++) { + const rowIndexAttr = rows[i].getAttribute('row-index'); + if (rowIndexAttr) { + const idx = parseInt(rowIndexAttr, 10); + minIndex = idx < minIndex ? idx : minIndex; + } + } + + return isFinite(minIndex) ? minIndex : 0; +} + +export function innerTextPolyfill() { + // Without layout there is no rendered text, so alias innerText to textContent. Overriding happy-dom's + // own (on HTMLElement.prototype) also drops its throw on a null assignment and its per-descendant + // getComputedStyle. + Object.defineProperty(HTMLElement.prototype, 'innerText', { + configurable: true, + get(this: Element) { + return this.textContent; + }, + set(this: Element, value: unknown) { + this.textContent = value as string; + }, + }); +} diff --git a/testing/behavioural/src/test-utils/polyfills/objectUrls.ts b/testing/ag-test-utils/src/polyfills/objectUrls.ts similarity index 59% rename from testing/behavioural/src/test-utils/polyfills/objectUrls.ts rename to testing/ag-test-utils/src/polyfills/objectUrls.ts index 7e382d8cdc0..71d4012f811 100644 --- a/testing/behavioural/src/test-utils/polyfills/objectUrls.ts +++ b/testing/ag-test-utils/src/polyfills/objectUrls.ts @@ -10,9 +10,8 @@ export interface BlobWithUrl extends Blob { } /** - * This allows to intercept the creation and revocation of object URLs. - * Also, it polyfills the CompressionStream API and URL class to fix jsdom not properly supporting it - * It also patches the MouseEvent constructor to work around jsdom not supporting instantiating it manually with vitest + * Intercepts the creation and revocation of object URLs so a test can pull back what the grid exported. + * Also replaces `CompressionStream`, which happy-dom does not implement. */ export const objectUrls = { init() { @@ -51,6 +50,8 @@ export const objectUrls = { }; function initialize(): void { + // Unconditional, not a fallback: node's own CompressionStream is present in both environments and + // the excel export hangs on it (measured — six export suites time out), so the zlib shim wins. global.CompressionStream = CompressionStreamPolyfill; const oldCreateObjectURL = window.URL.createObjectURL; @@ -65,70 +66,24 @@ function initialize(): void { window.URL.revokeObjectURL = function revokeObjectURL(url: string) { oldRevokeObjectURL?.call(window.URL, url); }; - - Blob.prototype.arrayBuffer ||= function arrayBuffer(this: Blob) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = () => reject(reader.error); - reader.readAsArrayBuffer(this); - }); - }; - - Blob.prototype.text ||= function text(this: Blob) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = () => reject(reader.error); - reader.readAsText(this); - }); - }; - - Blob.prototype.stream ||= function stream(this: Blob): ReadableStream { - const blob = this; - return new ReadableStream({ - start(controller) { - const reader = new FileReader(); - reader.onload = () => { - if (reader.result instanceof ArrayBuffer) { - controller.enqueue(new Uint8Array(reader.result)); - } - controller.close(); - }; - reader.readAsArrayBuffer(blob); - }, - }); - }; - - const oldMouseEventClass = MouseEvent; - - // This is a workaround for jsdom not supporting MouseEvent constructor - - class MouseEventPolyfill extends oldMouseEventClass { - constructor(type: string, eventInitDict?: MouseEventInit) { - super(type, eventInitDict && { ...eventInitDict, view: undefined }); - } - } - - window.MouseEvent = MouseEventPolyfill; } +const COMPRESSORS: Record zlib.Gzip | zlib.Deflate | zlib.DeflateRaw) | undefined> = { + gzip: () => zlib.createGzip(), + deflate: () => zlib.createDeflate(), + 'deflate-raw': () => zlib.createDeflateRaw(), +}; + class CompressionStreamPolyfill implements TransformStream { public writable: WritableStream; public readable: ReadableStream; constructor(format: 'gzip' | 'deflate' | 'deflate-raw') { - const nodeStream = - format === 'gzip' - ? zlib.createGzip() - : format === 'deflate' - ? zlib.createDeflate() - : format === 'deflate-raw' - ? zlib.createDeflateRaw() - : null; - if (!nodeStream) { + const createStream = COMPRESSORS[format]; + if (!createStream) { throw new TypeError('Invalid format.'); } + const nodeStream = createStream(); this.readable = new ReadableStream({ start: (controller) => { nodeStream.on('data', (chunk) => controller.enqueue(chunk)); diff --git a/testing/behavioural/src/test-utils/polyfills/pointerEvent.ts b/testing/ag-test-utils/src/polyfills/pointerEvent.ts similarity index 95% rename from testing/behavioural/src/test-utils/polyfills/pointerEvent.ts rename to testing/ag-test-utils/src/polyfills/pointerEvent.ts index db41f1c245b..0a2082b46c4 100644 --- a/testing/behavioural/src/test-utils/polyfills/pointerEvent.ts +++ b/testing/ag-test-utils/src/polyfills/pointerEvent.ts @@ -303,7 +303,18 @@ function ensurePointerEvent(): void { } function ensureDragEvent(): void { - if (typeof (globalThis as any).DragEvent !== 'function') { + // Capability, not existence: happy-dom aliases DragEvent to plain Event, so the constructor is there + // but drops `dataTransfer` (and every MouseEvent coordinate) — a drop handler then sees no files. + const Ctor = (globalThis as any).DragEvent; + let carriesDataTransfer: boolean; + try { + carriesDataTransfer = + typeof Ctor === 'function' && + new Ctor('dragstart', { dataTransfer: new DataTransfer() }).dataTransfer != null; + } catch { + carriesDataTransfer = false; + } + if (!carriesDataTransfer) { Object.defineProperty(globalThis, 'DragEvent', { configurable: true, writable: true, diff --git a/testing/ag-test-utils/src/polyfills/virtualListViewports.ts b/testing/ag-test-utils/src/polyfills/virtualListViewports.ts new file mode 100644 index 00000000000..45b0e474182 --- /dev/null +++ b/testing/ag-test-utils/src/polyfills/virtualListViewports.ts @@ -0,0 +1,14 @@ +/** + * Viewports whose VirtualList renders nothing without a forced height. Shared by `mockGridLayout` (which + * routes their height props at the rect) and `filterLayoutMock` (which forces the rect itself) — one list, + * since two copies of it drifted while meaning the same thing. Deliberately not on the package barrel. + */ +export const VIRTUAL_LIST_VIEWPORT_CLASSES = [ + '.ag-advanced-filter-builder-virtual-list-viewport', + '.ag-rich-select-virtual-list-viewport', + '.ag-advanced-filter-builder-list', + // The builder root is the drag drop-target container; it needs a tall rect so the drag hover hit-test + // (clientY within the container, row = clientY / rowHeight) can reach every row. + '.ag-advanced-filter-builder', + '.ag-virtual-list-viewport', +] as const; diff --git a/testing/behavioural/src/test-utils/prng.ts b/testing/ag-test-utils/src/prng.ts similarity index 100% rename from testing/behavioural/src/test-utils/prng.ts rename to testing/ag-test-utils/src/prng.ts diff --git a/testing/behavioural/src/test-utils/rows-snapshot.ts b/testing/ag-test-utils/src/rows-snapshot.ts similarity index 100% rename from testing/behavioural/src/test-utils/rows-snapshot.ts rename to testing/ag-test-utils/src/rows-snapshot.ts diff --git a/testing/behavioural/src/test-utils/ssrm-test-utils.ts b/testing/ag-test-utils/src/ssrm-test-utils.ts similarity index 100% rename from testing/behavioural/src/test-utils/ssrm-test-utils.ts rename to testing/ag-test-utils/src/ssrm-test-utils.ts diff --git a/testing/behavioural/src/test-utils/string-utils.ts b/testing/ag-test-utils/src/string-utils.ts similarity index 100% rename from testing/behavioural/src/test-utils/string-utils.ts rename to testing/ag-test-utils/src/string-utils.ts diff --git a/testing/behavioural/src/test-utils/test-utils-assertions.ts b/testing/ag-test-utils/src/test-utils-assertions.ts similarity index 64% rename from testing/behavioural/src/test-utils/test-utils-assertions.ts rename to testing/ag-test-utils/src/test-utils-assertions.ts index 0bc23678484..8d91a861c4b 100644 --- a/testing/behavioural/src/test-utils/test-utils-assertions.ts +++ b/testing/ag-test-utils/src/test-utils-assertions.ts @@ -1,7 +1,7 @@ import { _areEqual } from 'ag-stack'; import { expect } from 'vitest'; -import type { GridApi, IRowNode } from 'ag-grid-community'; +import type { CellRange, GridApi, IRowNode } from 'ag-grid-community'; export function assertSelectedRowsByIndex(indices: number[], api: GridApi): void { const actual = new Set(api.getSelectedNodes().map((n) => n.rowIndex)); @@ -24,7 +24,7 @@ export function assertSelectedRowsByIndexFromNodes(indices: number[], api: GridA expect(actual).toEqual(new Set(indices)); } -export function assertSelectedRowElementsById(ids: string[], api: GridApi): void { +export function assertSelectedRowsById(ids: string[], api: GridApi): void { const selected = new Set(); api.forEachNode((node) => (node.isSelected() ? selected.add(node.id!) : null)); expect(selected).toEqual(new Set(ids)); @@ -50,7 +50,7 @@ export function assertSelectableByIndex(indices: number[], api: GridApi): void { expect(selectable).toEqual(indices); } -export function assertElementDisplayed(element: HTMLElement): boolean { +export function isElementDisplayed(element: HTMLElement): boolean { let el: HTMLElement | null = element; while (el) { if (el.classList.contains('ag-invisible')) { @@ -61,6 +61,13 @@ export function assertElementDisplayed(element: HTMLElement): boolean { return true; } +/** Ranges as `rowStart..rowEnd:colA,colB`, so a leftover selection reads as text rather than a CellRange dump. */ +const describeRanges = (ranges: CellRange[] | undefined): string[] => + (ranges ?? []).map( + (range) => + `${range.startRow?.rowIndex}..${range.endRow?.rowIndex}:${range.columns.map((column) => column.getColId()).join(',')}` + ); + interface CellRangeSpec { rowStartIndex: number; rowEndIndex: number; @@ -95,6 +102,9 @@ export function assertSelectedCellRanges(cellRanges: CellRangeSpec[], api: GridA } } expect(notFound).toEqual([]); + // The expected ones are spliced out above, so anything still here was never asked for: a stale or + // over-broad selection would otherwise pass unnoticed. + expect(describeRanges(selectedCellRanges)).toEqual([]); } export function assertColumnsSelected(ranges: string[][], api: GridApi): void { @@ -104,40 +114,28 @@ export function assertColumnsSelected(ranges: string[][], api: GridApi): void { const nRowsBottom = api.getPinnedBottomRowCount(); const notFound: string[][] = []; - if (ranges.length === 0) { - // negative assertion; i.e. that no full columns are selected - for (const { startRow, endRow } of cellRanges) { - const startsAtFirstRow = startRow?.rowIndex === 0; - const endsAtLastRow = - nRowsBottom > 0 - ? endRow?.rowPinned === 'bottom' && endRow.rowIndex === nRowsBottom - : endRow?.rowIndex === lastRowIdx; - - if (startsAtFirstRow) { - // range starts at first row, then last row can't be at the bottom of the grid - if (nRowsBottom > 0 && endRow?.rowPinned === 'bottom') { - expect(endRow?.rowIndex).not.toBe(nRowsBottom); - } else if (nRowsBottom > 0) { - expect(endRow?.rowPinned).not.toBe('bottom'); - } else { - expect(endRow?.rowIndex).not.toBe(lastRowIdx); - } - } else if (endsAtLastRow) { - // range ends at last row, then first row can't be at the top of the grid - expect(startRow).not.toEqual({ rowIndex: 0, rowPinned: nRowsTop > 0 ? 'top' : null }); - } else { - // we're fine - } - } - } + // Spans every row, top pinned through bottom pinned: a full-column selection, which is the only + // shape this helper speaks about. Pinned-bottom indices run 0..count-1, as the assertions below assume. + const isFullColumnRange = ({ startRow, endRow }: CellRange): boolean => + startRow?.rowIndex === 0 && + startRow?.rowPinned === (nRowsTop > 0 ? 'top' : null) && + (nRowsBottom > 0 + ? endRow?.rowPinned === 'bottom' && endRow.rowIndex === nRowsBottom - 1 + : endRow?.rowIndex === lastRowIdx); for (const columnIds of ranges) { - const idx = cellRanges.findIndex((cellRange) => + const hasColumns = (cellRange: CellRange) => _areEqual( cellRange.columns.map((c) => c.getColId()), columnIds - ) - ); + ); + // A full-column match wins: a partial range may share the same columns, and taking it first would fail + // the endpoint assertions while the full-column range it shadowed sat later. The columns-only fallback + // is what keeps those assertions diagnostic rather than tautological when nothing spans every row. + let idx = cellRanges.findIndex((cellRange) => isFullColumnRange(cellRange) && hasColumns(cellRange)); + if (idx < 0) { + idx = cellRanges.findIndex(hasColumns); + } if (idx > -1) { expect(cellRanges[idx].startRow?.rowIndex).toEqual(0); @@ -153,4 +151,7 @@ export function assertColumnsSelected(ranges: string[][], api: GridApi): void { } expect(notFound).toEqual([]); + // The expected ones are spliced out, so a full-column range still here was never asked for. Partial + // ranges are left alone: they legitimately coexist with a column selection. + expect(describeRanges(cellRanges.filter(isFullColumnRange))).toEqual([]); } diff --git a/testing/behavioural/src/test-utils/test-utils-edit.ts b/testing/ag-test-utils/src/test-utils-edit.ts similarity index 66% rename from testing/behavioural/src/test-utils/test-utils-edit.ts rename to testing/ag-test-utils/src/test-utils-edit.ts index 092954486c3..4267ed470d7 100644 --- a/testing/behavioural/src/test-utils/test-utils-edit.ts +++ b/testing/ag-test-utils/src/test-utils-edit.ts @@ -1,6 +1,10 @@ import type { waitForOptions } from '@testing-library/dom'; import { getByRole, waitFor } from '@testing-library/dom'; +/** What the default selector matches: `agLargeTextCellEditor` is a textarea, so this is not just an input. + * No grid editor or filter renders a `