diff --git a/.env b/.env index 60ebe86519d..905ac9d0fad 100644 --- a/.env +++ b/.env @@ -1,5 +1,5 @@ # Production Build -BUILD_GRID_VERSION=36.1.0-beta.20260817.956 +BUILD_GRID_VERSION=36.1.0-beta.20260818.1041 BUILD_CHARTS_VERSION=14.1.0-beta.20260816 ENV=local NX_BATCH_MODE=true diff --git a/.github/actions/test-framework-examples/action.yml b/.github/actions/test-framework-examples/action.yml index f58a4a375f6..e350fa369a6 100644 --- a/.github/actions/test-framework-examples/action.yml +++ b/.github/actions/test-framework-examples/action.yml @@ -67,11 +67,16 @@ runs: working-directory: documentation/ag-grid-docs env: PW_BROWSERS: ${{ inputs.browsers == 'all' && 'chromium firefox webkit' || 'chromium' }} + # Bounded + retried via the shared wrapper (AG-18231): `install-deps` shells out to + # `apt-get update`, which has no timeout, so a stalled mirror used to hang the job until + # the 6-hour limit cancelled it. ${GITHUB_WORKSPACE} is needed because this step's + # working-directory is documentation/ag-grid-docs. run: | + PW_INSTALL="${GITHUB_WORKSPACE}/.github/actions/test-framework-examples/install-playwright.sh" if [ "${{ steps.pw-cache.outputs.cache-hit }}" = "true" ]; then - npx playwright install-deps ${PW_BROWSERS} + bash "${PW_INSTALL}" deps ${PW_BROWSERS} else - npx playwright install --with-deps ${PW_BROWSERS} + bash "${PW_INSTALL}" full ${PW_BROWSERS} fi # Run Playwright directly via ./docs-e2e.sh (bypasses Nx) so we avoid the Nx target's diff --git a/.github/actions/test-framework-examples/install-playwright.sh b/.github/actions/test-framework-examples/install-playwright.sh new file mode 100755 index 00000000000..77654c8cf44 --- /dev/null +++ b/.github/actions/test-framework-examples/install-playwright.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Bounded, retrying Playwright install. `install-deps` / `install --with-deps` shell out to +# `apt-get update`, which has no timeout: when the mirror stalls (AG-18231) the step hangs until +# the 6-hour job limit cancels the job and no test ever runs. +# +# Usage: install-playwright.sh ... +# deps - `playwright install-deps` (cache hit: OS libraries only; apt) +# full - `playwright install --with-deps` (cache miss: browser download + apt) +# browsers - `playwright install` (browser download only; no apt) +# +# The step must FAIL rather than be cancelled: a job cancelled by `timeout-minutes` makes +# `cancelled()` true and skips the report upload. So the budgets below are sized to give up after +# ~32 min, well inside the 90 min ceiling every doc-tests job now carries. Healthy installs take +# 1-3 min; the bounds are generous because `timeout` cannot tell a stalled mirror from a slow one. +set -uo pipefail + +MODE="${1:-}" +shift || true +BROWSERS=("$@") + +case "$MODE" in + deps) + CMD=(npx playwright install-deps "${BROWSERS[@]}") + DEFAULT_TIMEOUT=600 + DEFAULT_ATTEMPTS=3 + LABEL="Playwright OS dependency install (playwright install-deps)" + ;; + full) + CMD=(npx playwright install --with-deps "${BROWSERS[@]}") + DEFAULT_TIMEOUT=900 + DEFAULT_ATTEMPTS=2 + LABEL="Playwright browser + OS dependency install (playwright install --with-deps)" + ;; + browsers) + CMD=(npx playwright install "${BROWSERS[@]}") + DEFAULT_TIMEOUT=900 + DEFAULT_ATTEMPTS=2 + LABEL="Playwright browser download (playwright install)" + ;; + *) + echo "::error::install-playwright.sh: unknown mode '${MODE}' (expected deps|full|browsers)" + exit 2 + ;; +esac + +ATTEMPT_TIMEOUT_SECONDS="${PW_INSTALL_TIMEOUT_SECONDS:-${DEFAULT_TIMEOUT}}" +MAX_ATTEMPTS="${PW_INSTALL_MAX_ATTEMPTS:-${DEFAULT_ATTEMPTS}}" +RETRY_DELAY_SECONDS="${PW_INSTALL_RETRY_DELAY_SECONDS:-20}" + +for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do + echo "::group::${LABEL} - attempt ${attempt}/${MAX_ATTEMPTS} (bounded to ${ATTEMPT_TIMEOUT_SECONDS}s)" + timeout --signal=TERM --kill-after=30s "${ATTEMPT_TIMEOUT_SECONDS}s" "${CMD[@]}" + status=$? + echo "::endgroup::" + + if [ "${status}" -eq 0 ]; then + exit 0 + fi + + if [ "${status}" -eq 124 ] || [ "${status}" -eq 137 ]; then + reason="did not complete within its ${ATTEMPT_TIMEOUT_SECONDS}s bound and was killed (stalled or very slow package mirror / CDN fetch)" + else + reason="failed with exit code ${status}" + fi + echo "::warning::${LABEL} attempt ${attempt}/${MAX_ATTEMPTS} ${reason}." + + if [ "${attempt}" -lt "${MAX_ATTEMPTS}" ]; then + # Give a TERM-ed apt time to release the dpkg/apt lock before retrying. + sleep "${RETRY_DELAY_SECONDS}" + fi +done + +echo "::error::${LABEL} did not complete after ${MAX_ATTEMPTS} attempts, each bounded to ${ATTEMPT_TIMEOUT_SECONDS}s. This is an infrastructure failure in the dependency install step - no example tests were run, so it is NOT a test failure." +exit 1 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/.github/workflows/doc-tests.yml b/.github/workflows/doc-tests.yml index ef794b09297..0c7eeea887f 100644 --- a/.github/workflows/doc-tests.yml +++ b/.github/workflows/doc-tests.yml @@ -92,6 +92,7 @@ jobs: || github.event.inputs.skip_vue3 != 'true' }} name: Initialise dependencies runs-on: ubuntu-latest + timeout-minutes: 90 steps: - uses: actions/checkout@v4 with: @@ -122,13 +123,15 @@ jobs: - name: Install Playwright browsers if: steps.pw-cache.outputs.cache-hit != 'true' working-directory: documentation/ag-grid-docs - run: npx playwright install chromium firefox webkit + # Bounded + retried via the shared wrapper; no --with-deps here, so no apt. + run: bash "${GITHUB_WORKSPACE}/.github/actions/test-framework-examples/install-playwright.sh" browsers chromium firefox webkit test-vanilla: needs: initialise if: ${{ github.event.inputs.skip_vanilla != 'true' }} name: Test vanilla Examples runs-on: ubuntu-latest + timeout-minutes: 90 # Scoped to the jobs that request the deployed site. At workflow scope the bypass credential would # also be readable by publish-reports and the third-party actions it runs, which have no need for it. env: @@ -152,6 +155,7 @@ jobs: if: ${{ github.event.inputs.skip_typescript != 'true' }} name: Test typescript Examples runs-on: ubuntu-latest + timeout-minutes: 90 env: AWS_CI_BYPASS_SECRET: ${{ secrets.AWS_CI_BYPASS_SECRET }} strategy: @@ -173,6 +177,7 @@ jobs: if: ${{ github.event.inputs.skip_reactFunctionalTs != 'true' }} name: Test reactFunctionalTs Examples runs-on: ubuntu-latest + timeout-minutes: 90 env: AWS_CI_BYPASS_SECRET: ${{ secrets.AWS_CI_BYPASS_SECRET }} strategy: @@ -194,6 +199,7 @@ jobs: if: ${{ github.event.inputs.skip_vue3 != 'true' }} name: Test vue3 Examples runs-on: ubuntu-latest + timeout-minutes: 90 env: AWS_CI_BYPASS_SECRET: ${{ secrets.AWS_CI_BYPASS_SECRET }} strategy: @@ -215,6 +221,7 @@ jobs: if: ${{ github.event.inputs.skip_angular != 'true' }} name: Test angular Examples runs-on: ubuntu-latest + timeout-minutes: 90 env: AWS_CI_BYPASS_SECRET: ${{ secrets.AWS_CI_BYPASS_SECRET }} strategy: @@ -236,6 +243,7 @@ jobs: if: ${{ github.event.inputs.skip_recipes != 'true' }} name: Test Public Testing Recipes runs-on: ubuntu-latest + timeout-minutes: 90 strategy: fail-fast: false steps: @@ -263,6 +271,9 @@ jobs: publish-reports: if: always() needs: + # Without `initialise` here and in IS_SUCCESS, a failure there skips every shard + # ('skipped', not 'failure') and the run reports SUCCESS having run no tests. + - initialise - test-reactFunctionalTs - test-angular - test-typescript @@ -270,8 +281,9 @@ jobs: - test-vue3 - test-recipes env: - IS_SUCCESS: ${{ needs.test-angular.result != 'failure' && needs.test-reactFunctionalTs.result != 'failure' && needs.test-typescript.result != 'failure' && needs.test-vanilla.result != 'failure' && needs.test-vue3.result != 'failure' && needs.test-recipes.result != 'failure' }} + IS_SUCCESS: ${{ needs.initialise.result != 'failure' && needs.test-angular.result != 'failure' && needs.test-reactFunctionalTs.result != 'failure' && needs.test-typescript.result != 'failure' && needs.test-vanilla.result != 'failure' && needs.test-vue3.result != 'failure' && needs.test-recipes.result != 'failure' }} runs-on: ubuntu-latest + timeout-minutes: 90 steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/gh-comment-hook.yml b/.github/workflows/gh-comment-hook.yml index d2feda17dc9..e0213393e44 100644 --- a/.github/workflows/gh-comment-hook.yml +++ b/.github/workflows/gh-comment-hook.yml @@ -39,6 +39,7 @@ jobs: PR_ID: ${{ github.event.pull_request.number }} PR_URL: ${{ github.event.pull_request.html_url }} PR_TITLE: ${{ github.event.pull_request.title }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} EVENT_ACTION: ${{ github.event.action }} JIRA_API_AUTH: ${{ secrets.JIRA_API_AUTH }} JOB_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -52,6 +53,7 @@ jobs: const pr_url = process.env.PR_URL; const pr_title = process.env.PR_TITLE; const eventAction = process.env.EVENT_ACTION; + const headRef = process.env.PR_HEAD_REF || ''; // Expand bare JIRA ticket mentions (#AG-XXXX) into markdown links const body = old_body.replace(/#((AG|RTI)-\d{3,})/g, `[$1](https://ag-grid.atlassian.net/browse/$1)`); @@ -65,6 +67,13 @@ jobs: if (eventAction !== 'opened' && eventAction !== 'edited') return; if (!process.env.JIRA_API_AUTH) return; + // AI Workflow Delivery PRs post their own JIRA comment once the run is + // actually ready for review, so skip the generic mention comment here. + if (headRef.startsWith('ghabot-ag-')) { + console.log(`Skipping JIRA mention comment for AI workflow PR (branch ${headRef}).`); + return; + } + const { addJiraComment, getJiraIssueComments } = require('./scripts/ci/_utils.mjs'); diff --git a/.github/workflows/github-triage-pipeline.yml b/.github/workflows/github-triage-pipeline.yml index 08671833503..dc3853eedb3 100644 --- a/.github/workflows/github-triage-pipeline.yml +++ b/.github/workflows/github-triage-pipeline.yml @@ -260,6 +260,9 @@ jobs: # CLAUDE.md § "Auto-chaining browser-verify / repro-rebuild". issue_key: ${{ steps.pipeline.outputs.issue_key }} confirmed_bug: ${{ steps.pipeline.outputs.confirmed_bug }} + # NEW (2026-08-18) — confidence-refresh-chain's own gate. See the + # action's CLAUDE.md § "Confidence-gap auto-refresh". + confidence_gap: ${{ steps.pipeline.outputs.confidence_gap }} steps: - uses: actions/checkout@v4 with: @@ -469,3 +472,64 @@ jobs: jira_api_token: ${{ secrets.JIRA_AI_BOT_API_TOKEN }} jira_site_url: ${{ vars.JIRA_SITE_URL }} jira_ai_bot_account_id: ${{ vars.JIRA_AI_BOT_ACCOUNT_ID }} + + # -------------------------------------------------- confidence-refresh-chain (NEW, 2026-08-18) + # Auto-fires ONLY when the ORIGINAL triage recorded an eligible clean-pick + # whose OWN confidence was below the execute threshold (confidence_gap == + # 'true') — never a decline, never an already-confident pick. Needs ALL + # three prior jobs so the evidence browser-verify-chain/repro-rebuild-chain + # gather (if either ran) is already on the ticket by the time this fires; + # confidence_gap itself comes from `run` alone, so this still fires even + # when browser-verify-chain/repro-rebuild-chain were skipped (e.g. the + # `workflow_dispatch stage=triage` gate on browser-verify-chain not met) — + # a re-triage informed only by what triage itself gathered is still a + # genuine second look, not a no-op. + # + # Rationale + the AITGH-32/37 finding this is built from (including why it + # is NOT a guaranteed confidence bump) are in the action's own CLAUDE.md + # § "Confidence-gap auto-refresh". Same job-graph chaining pattern as the + # two browser stages above — no new event type, no new execution path: + # `resume` still cannot execute on its own regardless of what triggered it. + # + # SECURITY NOTE: same posture as browser-verify-chain/repro-rebuild-chain — + # no dedicated minimal-permission job split yet. This job runs NO browser + # and touches no untrusted repro page directly, but it DOES run the same + # read-only triage/resume agent `run` does, so it inherits `run`'s own + # threat model (an agent reading attacker-authored issue/comment text), + # not the two browser stages' — hence `issues: read` (never write) here + # too, for the same "an unused permission is a mistake" reason as `run`. + confidence-refresh-chain: + needs: [run, browser-verify-chain, repro-rebuild-chain] + if: | + !cancelled() && + needs.run.outputs.confidence_gap == 'true' && + ( + github.event_name == 'issues' + || (github.event_name == 'workflow_dispatch' && inputs.stage == 'triage') + ) + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + packages: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Install @ag-grid/dev-prompts + uses: ./external/ag-shared/github/actions/install-ag-dev-prompts + with: + channel: ${{ env.DEV_PROMPTS_CHANNEL }} + - uses: ./.ag-dev-prompts/node_modules/@ag-grid/dev-prompts/.github/actions/github-triage-pipeline + with: + stage: resume + trigger: confidence-refresh + product: grid + issue_key: ${{ needs.run.outputs.issue_key }} + channel: ${{ env.DEV_PROMPTS_CHANNEL }} + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} + jira_email: ${{ secrets.JIRA_AI_BOT_EMAIL }} + jira_api_token: ${{ secrets.JIRA_AI_BOT_API_TOKEN }} + jira_site_url: ${{ vars.JIRA_SITE_URL }} + jira_ai_bot_account_id: ${{ vars.JIRA_AI_BOT_ACCOUNT_ID }} diff --git a/.idea/ag-grid.iml b/.idea/ag-grid.iml index b60e4f0a7a2..b6a037255ab 100644 --- a/.idea/ag-grid.iml +++ b/.idea/ag-grid.iml @@ -6,172 +6,26 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - + + - - - + + + + + + + + + + - - - @@ -182,4 +36,4 @@ - \ No newline at end of file + 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/package.json b/community-modules/locale/package.json index c68c8090f00..9d53f6713af 100644 --- a/community-modules/locale/package.json +++ b/community-modules/locale/package.json @@ -1,6 +1,6 @@ { "name": "@ag-grid-community/locale", - "version": "36.1.0-beta.20260817.956", + "version": "36.1.0-beta.20260818.1041", "description": "Localisation Module for AG Grid, providing translations in 31 languages.", "main": "./dist/package/main.cjs.js", "types": "./dist/types/src/main.d.ts", diff --git a/community-modules/locale/src/ar-EG.ts b/community-modules/locale/src/ar-EG.ts index 4558e554b05..438c14d1767 100644 --- a/community-modules/locale/src/ar-EG.ts +++ b/community-modules/locale/src/ar-EG.ts @@ -693,6 +693,7 @@ export const AG_GRID_LOCALE_EG = { ariaFilterColumnsInput: 'إدخال فلترة الأعمدة', ariaFilterFromValue: 'الفلترة من القيمة', ariaFilterInput: 'إدخال الفلترة', + ariaLabelInputClear: 'مسح', ariaFilterList: 'قائمة الفلترة', ariaFilterToValue: 'الفلترة إلى القيمة', ariaFilterValue: 'قيمة الفلترة', diff --git a/community-modules/locale/src/bg-BG.ts b/community-modules/locale/src/bg-BG.ts index 33ddae8b65c..6eb84a83d73 100644 --- a/community-modules/locale/src/bg-BG.ts +++ b/community-modules/locale/src/bg-BG.ts @@ -698,6 +698,7 @@ export const AG_GRID_LOCALE_BG = { ariaFilterColumnsInput: 'Вход за филтриране на колони', ariaFilterFromValue: 'Филтър от стойност', ariaFilterInput: 'Вход за филтър', + ariaLabelInputClear: 'Изчисти', ariaFilterList: 'Списък за филтриране', ariaFilterToValue: 'Филтър до стойност', ariaFilterValue: 'Стойност на филтъра', diff --git a/community-modules/locale/src/cs-CZ.ts b/community-modules/locale/src/cs-CZ.ts index 6226b0b5f42..260d288e3df 100644 --- a/community-modules/locale/src/cs-CZ.ts +++ b/community-modules/locale/src/cs-CZ.ts @@ -695,6 +695,7 @@ export const AG_GRID_LOCALE_CZ = { ariaFilterColumnsInput: 'Vstup filtru sloupců', ariaFilterFromValue: 'Filtrovat od hodnoty', ariaFilterInput: 'Vstup filtru', + ariaLabelInputClear: 'Vymazat', ariaFilterList: 'Seznam filtrů', ariaFilterToValue: 'Filtrovat do hodnoty', ariaFilterValue: 'Hodnota filtru', diff --git a/community-modules/locale/src/da-DK.ts b/community-modules/locale/src/da-DK.ts index 0401800b3a2..5ec7e3d7e74 100644 --- a/community-modules/locale/src/da-DK.ts +++ b/community-modules/locale/src/da-DK.ts @@ -697,6 +697,7 @@ export const AG_GRID_LOCALE_DK = { ariaFilterColumnsInput: 'Filtrer Kolonner Input', ariaFilterFromValue: 'Filtrer fra værdi', ariaFilterInput: 'Filter Input', + ariaLabelInputClear: 'Ryd', ariaFilterList: 'Filterliste', ariaFilterToValue: 'Filtrer til værdi', ariaFilterValue: 'Filtrerværdi', diff --git a/community-modules/locale/src/de-DE.ts b/community-modules/locale/src/de-DE.ts index bae84d4cf56..12c72a66b14 100644 --- a/community-modules/locale/src/de-DE.ts +++ b/community-modules/locale/src/de-DE.ts @@ -700,6 +700,7 @@ export const AG_GRID_LOCALE_DE = { ariaFilterColumnsInput: 'Filterspalteneingang', ariaFilterFromValue: 'Filter vom Wert', ariaFilterInput: 'Filtereingang', + ariaLabelInputClear: 'Löschen', ariaFilterList: 'Filterliste', ariaFilterToValue: 'Filter zum Wert', ariaFilterValue: 'Filterwert', diff --git a/community-modules/locale/src/el-GR.ts b/community-modules/locale/src/el-GR.ts index 58ca999bc91..b80d5c35dc6 100644 --- a/community-modules/locale/src/el-GR.ts +++ b/community-modules/locale/src/el-GR.ts @@ -700,6 +700,7 @@ export const AG_GRID_LOCALE_GR = { ariaFilterColumnsInput: 'Εισαγωγή Φιλτραρίσματος Στηλών', ariaFilterFromValue: 'Φίλτρο από τιμή', ariaFilterInput: 'Εισαγωγή Φίλτρου', + ariaLabelInputClear: 'Εκκαθάριση', ariaFilterList: 'Λίστα Φίλτρων', ariaFilterToValue: 'Φίλτρο σε τιμή', ariaFilterValue: 'Τιμή Φίλτρου', diff --git a/community-modules/locale/src/en-US.ts b/community-modules/locale/src/en-US.ts index 63989252fd5..3743d3343b4 100644 --- a/community-modules/locale/src/en-US.ts +++ b/community-modules/locale/src/en-US.ts @@ -699,6 +699,7 @@ export const AG_GRID_LOCALE_EN = { ariaFilterColumnsInput: 'Filter Columns Input', ariaFilterFromValue: 'Filter from value', ariaFilterInput: 'Filter Input', + ariaLabelInputClear: 'Clear', ariaFilterList: 'Filter List', ariaFilterToValue: 'Filter to value', ariaFilterValue: 'Filter Value', diff --git a/community-modules/locale/src/es-ES.ts b/community-modules/locale/src/es-ES.ts index 9f37c5a82e0..bed61b26cf3 100644 --- a/community-modules/locale/src/es-ES.ts +++ b/community-modules/locale/src/es-ES.ts @@ -698,6 +698,7 @@ export const AG_GRID_LOCALE_ES = { ariaFilterColumnsInput: 'Entrada de Filtrado de Columnas', ariaFilterFromValue: 'Filtrar desde valor', ariaFilterInput: 'Entrada de Filtro', + ariaLabelInputClear: 'Borrar', ariaFilterList: 'Lista de Filtros', ariaFilterToValue: 'Filtrar hasta valor', ariaFilterValue: 'Valor del Filtro', diff --git a/community-modules/locale/src/fa-IR.ts b/community-modules/locale/src/fa-IR.ts index 3b49f928724..4b2a49b0124 100644 --- a/community-modules/locale/src/fa-IR.ts +++ b/community-modules/locale/src/fa-IR.ts @@ -695,6 +695,7 @@ export const AG_GRID_LOCALE_IR = { ariaFilterColumnsInput: 'ورودی فیلتر ستون‌ها', ariaFilterFromValue: 'فیلتر از مقدار', ariaFilterInput: 'ورودی فیلتر', + ariaLabelInputClear: 'پاک کردن', ariaFilterList: 'لیست فیلتر', ariaFilterToValue: 'فیلتر تا مقدار', ariaFilterValue: 'مقدار فیلتر', diff --git a/community-modules/locale/src/fi-FI.ts b/community-modules/locale/src/fi-FI.ts index d08cba21d91..d09fc863fff 100644 --- a/community-modules/locale/src/fi-FI.ts +++ b/community-modules/locale/src/fi-FI.ts @@ -698,6 +698,7 @@ export const AG_GRID_LOCALE_FI = { ariaFilterColumnsInput: 'Suodata sarakkeiden syöte', ariaFilterFromValue: 'Suodata arvosta', ariaFilterInput: 'Suodattimen syöte', + ariaLabelInputClear: 'Tyhjennä', ariaFilterList: 'Suodatinlista', ariaFilterToValue: 'Suodata arvoon', ariaFilterValue: 'Suodattimen arvo', diff --git a/community-modules/locale/src/fr-FR.ts b/community-modules/locale/src/fr-FR.ts index 90bc48070cd..12077b12c9b 100644 --- a/community-modules/locale/src/fr-FR.ts +++ b/community-modules/locale/src/fr-FR.ts @@ -702,6 +702,7 @@ export const AG_GRID_LOCALE_FR = { ariaFilterColumnsInput: 'Entrée de filtre de colonnes', ariaFilterFromValue: 'Filtrer depuis la valeur', ariaFilterInput: 'Entrée de filtre', + ariaLabelInputClear: 'Effacer', ariaFilterList: 'Liste de filtres', ariaFilterToValue: "Filtrer jusqu'à la valeur", ariaFilterValue: 'Valeur du filtre', diff --git a/community-modules/locale/src/he-IL.ts b/community-modules/locale/src/he-IL.ts index 0bdea91e476..7e91c37e182 100644 --- a/community-modules/locale/src/he-IL.ts +++ b/community-modules/locale/src/he-IL.ts @@ -693,6 +693,7 @@ export const AG_GRID_LOCALE_IL = { ariaFilterColumnsInput: 'קלט סינון עמודות', ariaFilterFromValue: 'סנן מערך', ariaFilterInput: 'קלט סינון', + ariaLabelInputClear: 'נקה', ariaFilterList: 'רשימת סינון', ariaFilterToValue: 'סנן לערך', ariaFilterValue: 'ערך סינון', diff --git a/community-modules/locale/src/hr-HR.ts b/community-modules/locale/src/hr-HR.ts index 529a5f3d3ae..d4dac609e4a 100644 --- a/community-modules/locale/src/hr-HR.ts +++ b/community-modules/locale/src/hr-HR.ts @@ -697,6 +697,7 @@ export const AG_GRID_LOCALE_HR = { ariaFilterColumnsInput: 'Unos za filtriranje stupaca', ariaFilterFromValue: 'Filtriraj od vrijednosti', ariaFilterInput: 'Unos filtera', + ariaLabelInputClear: 'Očisti', ariaFilterList: 'Popis filtera', ariaFilterToValue: 'Filtriraj do vrijednosti', ariaFilterValue: 'Vrijednost filtera', diff --git a/community-modules/locale/src/hu-HU.ts b/community-modules/locale/src/hu-HU.ts index 60caf078f0f..d189c2b3400 100644 --- a/community-modules/locale/src/hu-HU.ts +++ b/community-modules/locale/src/hu-HU.ts @@ -699,6 +699,7 @@ export const AG_GRID_LOCALE_HU = { ariaFilterColumnsInput: 'Oszlopok szűrése bevitel', ariaFilterFromValue: 'Szűrés értéktől', ariaFilterInput: 'Szűrő bevitel', + ariaLabelInputClear: 'Törlés', ariaFilterList: 'Szűrő lista', ariaFilterToValue: 'Szűrés értékig', ariaFilterValue: 'Szűrő érték', diff --git a/community-modules/locale/src/it-IT.ts b/community-modules/locale/src/it-IT.ts index e89d47e47cf..721f1fd3fd6 100644 --- a/community-modules/locale/src/it-IT.ts +++ b/community-modules/locale/src/it-IT.ts @@ -700,6 +700,7 @@ export const AG_GRID_LOCALE_IT = { ariaFilterColumnsInput: 'Inserimento Filtro Colonne', ariaFilterFromValue: 'Filtra dal valore', ariaFilterInput: 'Inserimento Filtro', + ariaLabelInputClear: 'Cancella', ariaFilterList: 'Lista dei Filtri', ariaFilterToValue: 'Filtra al valore', ariaFilterValue: 'Valore del Filtro', diff --git a/community-modules/locale/src/ja-JP.ts b/community-modules/locale/src/ja-JP.ts index b2ec36b2ed8..5a2238bbcbb 100644 --- a/community-modules/locale/src/ja-JP.ts +++ b/community-modules/locale/src/ja-JP.ts @@ -694,6 +694,7 @@ export const AG_GRID_LOCALE_JP = { ariaFilterColumnsInput: 'フィルター列入力', ariaFilterFromValue: '値からフィルター', ariaFilterInput: 'フィルター入力', + ariaLabelInputClear: 'クリア', ariaFilterList: 'フィルターリスト', ariaFilterToValue: '値までフィルター', ariaFilterValue: 'フィルター値', diff --git a/community-modules/locale/src/ko-KR.ts b/community-modules/locale/src/ko-KR.ts index afa827c189b..a4a0410cac7 100644 --- a/community-modules/locale/src/ko-KR.ts +++ b/community-modules/locale/src/ko-KR.ts @@ -694,6 +694,7 @@ export const AG_GRID_LOCALE_KR = { ariaFilterColumnsInput: '열 필터 입력', ariaFilterFromValue: '값에서 필터', ariaFilterInput: '필터 입력', + ariaLabelInputClear: '지우기', ariaFilterList: '필터 목록', ariaFilterToValue: '값까지 필터', ariaFilterValue: '필터 값', diff --git a/community-modules/locale/src/nb-NO.ts b/community-modules/locale/src/nb-NO.ts index b44c5b1dc9c..784442c16f5 100644 --- a/community-modules/locale/src/nb-NO.ts +++ b/community-modules/locale/src/nb-NO.ts @@ -695,6 +695,7 @@ export const AG_GRID_LOCALE_NO = { ariaFilterColumnsInput: 'Filtrer kolonner inndata', ariaFilterFromValue: 'Filtrer fra verdi', ariaFilterInput: 'Filterinndata', + ariaLabelInputClear: 'Tøm', ariaFilterList: 'Filterliste', ariaFilterToValue: 'Filtrer til verdi', ariaFilterValue: 'Filterverdi', diff --git a/community-modules/locale/src/nl-NL.ts b/community-modules/locale/src/nl-NL.ts index 01ba188329a..a5eb5810bd2 100644 --- a/community-modules/locale/src/nl-NL.ts +++ b/community-modules/locale/src/nl-NL.ts @@ -696,6 +696,7 @@ export const AG_GRID_LOCALE_NL = { ariaFilterColumnsInput: 'Filter Kolommen Invoer', ariaFilterFromValue: 'Filter vanuit waarde', ariaFilterInput: 'Filter Invoer', + ariaLabelInputClear: 'Wissen', ariaFilterList: 'Filter Lijst', ariaFilterToValue: 'Filter naar waarde', ariaFilterValue: 'Filter Waarde', diff --git a/community-modules/locale/src/pl-PL.ts b/community-modules/locale/src/pl-PL.ts index b2c707d4452..a151defeab6 100644 --- a/community-modules/locale/src/pl-PL.ts +++ b/community-modules/locale/src/pl-PL.ts @@ -698,6 +698,7 @@ export const AG_GRID_LOCALE_PL = { ariaFilterColumnsInput: 'Wejście Filtrowania Kolumn', ariaFilterFromValue: 'Filtr od wartości', ariaFilterInput: 'Wejście Filtra', + ariaLabelInputClear: 'Wyczyść', ariaFilterList: 'Lista Filtrowania', ariaFilterToValue: 'Filtr do wartości', ariaFilterValue: 'Wartość Filtra', diff --git a/community-modules/locale/src/pt-BR.ts b/community-modules/locale/src/pt-BR.ts index 032059b965e..8110bca6baf 100644 --- a/community-modules/locale/src/pt-BR.ts +++ b/community-modules/locale/src/pt-BR.ts @@ -698,6 +698,7 @@ export const AG_GRID_LOCALE_BR = { ariaFilterColumnsInput: 'Entrada de Colunas de Filtro', ariaFilterFromValue: 'Filtrar do valor', ariaFilterInput: 'Entrada de Filtro', + ariaLabelInputClear: 'Limpar', ariaFilterList: 'Lista de Filtros', ariaFilterToValue: 'Filtrar até o valor', ariaFilterValue: 'Valor do Filtro', diff --git a/community-modules/locale/src/pt-PT.ts b/community-modules/locale/src/pt-PT.ts index 715cbad2c2e..d51e1fc0997 100644 --- a/community-modules/locale/src/pt-PT.ts +++ b/community-modules/locale/src/pt-PT.ts @@ -698,6 +698,7 @@ export const AG_GRID_LOCALE_PT = { ariaFilterColumnsInput: 'Entrada de Filtro de Colunas', ariaFilterFromValue: 'Filtrar a partir do valor', ariaFilterInput: 'Entrada de Filtro', + ariaLabelInputClear: 'Limpar', ariaFilterList: 'Lista de Filtros', ariaFilterToValue: 'Filtrar até o valor', ariaFilterValue: 'Valor do Filtro', diff --git a/community-modules/locale/src/ro-RO.ts b/community-modules/locale/src/ro-RO.ts index 947995f6565..532a473c3f1 100644 --- a/community-modules/locale/src/ro-RO.ts +++ b/community-modules/locale/src/ro-RO.ts @@ -698,6 +698,7 @@ export const AG_GRID_LOCALE_RO = { ariaFilterColumnsInput: 'Intrare Filtrare Coloane', ariaFilterFromValue: 'Filtrează de la valoare', ariaFilterInput: 'Intrare Filtru', + ariaLabelInputClear: 'Curăță', ariaFilterList: 'Listă de Filtre', ariaFilterToValue: 'Filtrează până la valoare', ariaFilterValue: 'Valoare Filtrată', diff --git a/community-modules/locale/src/sk-SK.ts b/community-modules/locale/src/sk-SK.ts index 863608bf1ad..b2cc8d9eb02 100644 --- a/community-modules/locale/src/sk-SK.ts +++ b/community-modules/locale/src/sk-SK.ts @@ -694,6 +694,7 @@ export const AG_GRID_LOCALE_SK = { ariaFilterColumnsInput: 'Vstup Filtra pre Stĺpce', ariaFilterFromValue: 'Filtrovať od hodnoty', ariaFilterInput: 'Vstup Filtra', + ariaLabelInputClear: 'Vyčistiť', ariaFilterList: 'Zoznam Filtrov', ariaFilterToValue: 'Filtrovať do hodnoty', ariaFilterValue: 'Hodnota Filtra', diff --git a/community-modules/locale/src/sv-SE.ts b/community-modules/locale/src/sv-SE.ts index b14b59c072a..8c410cdea52 100644 --- a/community-modules/locale/src/sv-SE.ts +++ b/community-modules/locale/src/sv-SE.ts @@ -697,6 +697,7 @@ export const AG_GRID_LOCALE_SE = { ariaFilterColumnsInput: 'Filterkolumnsinmatning', ariaFilterFromValue: 'Filtrera från värde', ariaFilterInput: 'Filterinmatning', + ariaLabelInputClear: 'Rensa', ariaFilterList: 'Filterlista', ariaFilterToValue: 'Filtrera till värde', ariaFilterValue: 'Filtervärde', diff --git a/community-modules/locale/src/tr-TR.ts b/community-modules/locale/src/tr-TR.ts index d860ff9ca1b..ac51904d2d8 100644 --- a/community-modules/locale/src/tr-TR.ts +++ b/community-modules/locale/src/tr-TR.ts @@ -698,6 +698,7 @@ export const AG_GRID_LOCALE_TR = { ariaFilterColumnsInput: 'Sütunları Filtrele Girişi', ariaFilterFromValue: 'Değerden filtrele', ariaFilterInput: 'Filtre Girişi', + ariaLabelInputClear: 'Temizle', ariaFilterList: 'Filtre Listesi', ariaFilterToValue: 'Değere filtrele', ariaFilterValue: 'Filtre Değeri', diff --git a/community-modules/locale/src/uk-UA.ts b/community-modules/locale/src/uk-UA.ts index 1f5c3dfc879..575348a66ae 100644 --- a/community-modules/locale/src/uk-UA.ts +++ b/community-modules/locale/src/uk-UA.ts @@ -697,6 +697,7 @@ export const AG_GRID_LOCALE_UA = { ariaFilterColumnsInput: 'Ввід колонок для фільтрування', ariaFilterFromValue: 'Фільтрувати від значення', ariaFilterInput: 'Ввід фільтру', + ariaLabelInputClear: 'Очистити', ariaFilterList: 'Список фільтрів', ariaFilterToValue: 'Фільтрувати до значення', ariaFilterValue: 'Значення фільтру', diff --git a/community-modules/locale/src/ur-PK.ts b/community-modules/locale/src/ur-PK.ts index 5cdd56a2d39..87afc89ba2c 100644 --- a/community-modules/locale/src/ur-PK.ts +++ b/community-modules/locale/src/ur-PK.ts @@ -694,6 +694,7 @@ export const AG_GRID_LOCALE_PK = { ariaFilterColumnsInput: 'کالمز فلٹر انپٹ', ariaFilterFromValue: 'قدر سے فلٹر کریں', ariaFilterInput: 'فلٹر انپٹ', + ariaLabelInputClear: 'صاف کریں', ariaFilterList: 'فلٹر فہرست', ariaFilterToValue: 'قدر تک فلٹر کریں', ariaFilterValue: 'فلٹر قدر', diff --git a/community-modules/locale/src/vi-VN.ts b/community-modules/locale/src/vi-VN.ts index a9f458a65f9..ea7295869dc 100644 --- a/community-modules/locale/src/vi-VN.ts +++ b/community-modules/locale/src/vi-VN.ts @@ -695,6 +695,7 @@ export const AG_GRID_LOCALE_VN = { ariaFilterColumnsInput: 'Đầu vào Lọc Cột', ariaFilterFromValue: 'Lọc từ giá trị', ariaFilterInput: 'Đầu vào Bộ lọc', + ariaLabelInputClear: 'Xóa', ariaFilterList: 'Danh sách Lọc', ariaFilterToValue: 'Lọc đến giá trị', ariaFilterValue: 'Giá trị Lọc', diff --git a/community-modules/locale/src/zh-CN.ts b/community-modules/locale/src/zh-CN.ts index ff801fefc95..a822147432d 100644 --- a/community-modules/locale/src/zh-CN.ts +++ b/community-modules/locale/src/zh-CN.ts @@ -693,6 +693,7 @@ export const AG_GRID_LOCALE_CN = { ariaFilterColumnsInput: '过滤列输入', ariaFilterFromValue: '过滤从值', ariaFilterInput: '过滤器输入', + ariaLabelInputClear: '清除', ariaFilterList: '过滤器列表', ariaFilterToValue: '过滤至值', ariaFilterValue: '过滤值', diff --git a/community-modules/locale/src/zh-HK.ts b/community-modules/locale/src/zh-HK.ts index bbab668ed62..3c54f2e1d95 100644 --- a/community-modules/locale/src/zh-HK.ts +++ b/community-modules/locale/src/zh-HK.ts @@ -693,6 +693,7 @@ export const AG_GRID_LOCALE_HK = { ariaFilterColumnsInput: '篩選列輸入', ariaFilterFromValue: '從值篩選', ariaFilterInput: '篩選器輸入', + ariaLabelInputClear: '清除', ariaFilterList: '篩選列表', ariaFilterToValue: '篩選到值', ariaFilterValue: '篩選值', diff --git a/community-modules/locale/src/zh-TW.ts b/community-modules/locale/src/zh-TW.ts index aca7a060a99..d41fb8c815b 100644 --- a/community-modules/locale/src/zh-TW.ts +++ b/community-modules/locale/src/zh-TW.ts @@ -693,6 +693,7 @@ export const AG_GRID_LOCALE_TW = { ariaFilterColumnsInput: '篩選欄輸入', ariaFilterFromValue: '從值篩選', ariaFilterInput: '篩選輸入', + ariaLabelInputClear: '清除', ariaFilterList: '篩選列表', ariaFilterToValue: '篩選到值', ariaFilterValue: '篩選值', 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/community-modules/styles/package.json b/community-modules/styles/package.json index aa9231bfdca..d88cf95a3c6 100644 --- a/community-modules/styles/package.json +++ b/community-modules/styles/package.json @@ -1,6 +1,6 @@ { "name": "@ag-grid-community/styles", - "version": "36.1.0-beta.20260817.956", + "version": "36.1.0-beta.20260818.1041", "description": "AG Grid Styles and Themes", "main": "_index.scss", "files": [ diff --git a/community-modules/styles/src/internal/base/parts/_toolbar.scss b/community-modules/styles/src/internal/base/parts/_toolbar.scss index c7996eeb1a4..9fee5d9876a 100644 --- a/community-modules/styles/src/internal/base/parts/_toolbar.scss +++ b/community-modules/styles/src/internal/base/parts/_toolbar.scss @@ -146,7 +146,6 @@ @include ag.unthemed-rtl( ( padding-left: calc(var(--ag-icon-size) + var(--ag-grid-size) * 2), - padding-right: var(--ag-grid-size), ) ); } @@ -161,6 +160,11 @@ color: var(--ag-disabled-foreground-color); } + .ag-toolbar-input-widget { + flex: 1; + min-width: 0; + } + .ag-toolbar-panel .ag-column-drop-horizontal { background-color: transparent; border-bottom: none; diff --git a/community-modules/styles/src/internal/base/parts/_widgets.scss b/community-modules/styles/src/internal/base/parts/_widgets.scss index 302c0b1f6c3..ea7e924df90 100644 --- a/community-modules/styles/src/internal/base/parts/_widgets.scss +++ b/community-modules/styles/src/internal/base/parts/_widgets.scss @@ -77,6 +77,35 @@ } } + .ag-input-field-clear-button { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + width: var(--ag-icon-size); + height: var(--ag-icon-size); + padding: 0; + border: 0; + border-radius: var(--ag-border-radius); + color: inherit; + background: transparent; + cursor: pointer; + @include ag.unthemed-rtl( + ( + right: var(--ag-grid-size), + ) + ); + } + + input[class^='ag-'][type='text'].ag-input-field-input-with-clear-button, + input[class^='ag-'][type='number'].ag-input-field-input-with-clear-button { + @include ag.unthemed-rtl( + ( + padding-right: calc(var(--ag-icon-size) + var(--ag-grid-size) * 2), + ) + ); + } + input[class^='ag-'][type='number']:not(.ag-number-field-input-stepper) { -moz-appearance: textfield; &::-webkit-outer-spin-button, 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/astro.config.mjs b/documentation/ag-grid-docs/astro.config.mjs index da3d57b46a6..c5bb94a47b7 100644 --- a/documentation/ag-grid-docs/astro.config.mjs +++ b/documentation/ag-grid-docs/astro.config.mjs @@ -182,20 +182,55 @@ const httpsEnabled = !['0', 'false'].includes(PUBLIC_HTTPS_SERVER); // https://astro.build/config export default defineConfig({ + /** + * Site fonts, resolved from the installed `@fontsource-variable` packages rather than + * `fontProviders.google()`. + * + * The Google provider resolves a `fonts.gstatic.com` URL at build time and downloads it + * with no retry. Google periodically re-cuts those files without bumping the version in + * the URL, and stale CSS lingering on some edge nodes then hands out filenames gstatic + * has already deleted - a 404 that fails the whole docs build. Resolving from + * node_modules removes the build-time network call entirely and pins the files to the + * lockfile. + * + * Each family ships as one variable woff2, declared at the SAME discrete weights the + * Google provider declared (sans 400/500/700, mono 400/700) rather than at the file's + * full variable range. That is deliberate: CSS matches a requested weight to the nearest + * declared one, so the ~58 `font-weight: 600` call sites in the docs currently resolve to + * 700. Exposing the continuous range would let them resolve to a true 600 and lighten + * text across the site - a typography change, which does not belong in a build fix. + * Widening these to a range is a deliberate follow-up, not a free win. + * + * No `unicodeRange` here, unlike the faces the Google provider emitted. That descriptor + * exists to let a browser skip downloading a subset it has no characters for, which needs + * more than one subset to mean anything - there is a single latin file per family, it is + * preloaded from Layout.astro regardless, and its coverage is exactly the range fontsource + * declares for it. Anything outside that range falls back per glyph either way. + */ fonts: [ { - provider: fontProviders.google(), + provider: fontProviders.local(), name: 'IBM Plex Sans', cssVariable: '--font-ibm-plex-sans', - weights: ['400', '500', '700'], - styles: ['normal'], + options: { + variants: [400, 500, 700].map((weight) => ({ + weight, + style: 'normal', + src: ['@fontsource-variable/ibm-plex-sans/files/ibm-plex-sans-latin-wght-normal.woff2'], + })), + }, }, { - provider: fontProviders.google(), + provider: fontProviders.local(), name: 'JetBrains Mono', cssVariable: '--font-jetbrains-mono', - weights: ['400', '700'], - styles: ['normal'], + options: { + variants: [400, 700].map((weight) => ({ + weight, + style: 'normal', + src: ['@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-normal.woff2'], + })), + }, }, ], site: PUBLIC_SITE_URL, diff --git a/documentation/ag-grid-docs/eslint.config.mjs b/documentation/ag-grid-docs/eslint.config.mjs index dbd5814c8dc..e9780fc8c6e 100644 --- a/documentation/ag-grid-docs/eslint.config.mjs +++ b/documentation/ag-grid-docs/eslint.config.mjs @@ -17,8 +17,6 @@ export default [ '**/_examples/', 'scripts/showcase-github/tmp/', '**/.angular', - '**/systemjs.config.js', - '**/systemjs.config.dev.js', '.playwright-network-cache/', '**/*.ics', 'public/**/*.css', @@ -50,20 +48,6 @@ export default [ }, }, }, - // Example runner boilerplate files - { - files: ['public/example-runner/**/*[.js|.ts]'], - languageOptions: { - globals: { - System: 'readonly', - systemJsPaths: 'readonly', - boilerplatePath: 'readonly', - startFile: 'readonly', - appLocation: 'readonly', - systemJsMap: 'readonly', - }, - }, - }, // Public files { files: ['public/**/*[.js|.ts]'], diff --git a/documentation/ag-grid-docs/package.json b/documentation/ag-grid-docs/package.json index 63099262315..4c150eb0c7c 100644 --- a/documentation/ag-grid-docs/package.json +++ b/documentation/ag-grid-docs/package.json @@ -2,7 +2,7 @@ "name": "ag-grid-docs", "description": "Documentation for AG Grid", "type": "module", - "version": "36.1.0-beta.20260817.956", + "version": "36.1.0-beta.20260818.1041", "repository": { "type": "git", "url": "https://github.com/ag-grid/ag-grid.git" @@ -59,11 +59,11 @@ "ag-charts-types": "14.1.0-beta.20260816", "ag-charts-react": "14.1.0-beta.20260816", "ag-charts-vue3": "14.1.0-beta.20260816", - "ag-grid-angular": "36.1.0-beta.20260817.956", - "ag-grid-community": "36.1.0-beta.20260817.956", - "ag-grid-enterprise": "36.1.0-beta.20260817.956", - "ag-grid-react": "36.1.0-beta.20260817.956", - "ag-grid-vue3": "36.1.0-beta.20260817.956", + "ag-grid-angular": "36.1.0-beta.20260818.1041", + "ag-grid-community": "36.1.0-beta.20260818.1041", + "ag-grid-enterprise": "36.1.0-beta.20260818.1041", + "ag-grid-react": "36.1.0-beta.20260818.1041", + "ag-grid-vue3": "36.1.0-beta.20260818.1041", "algoliasearch": "^5.51.0", "astro": "6.1.9", "cheerio": "^1.0.0", @@ -95,6 +95,8 @@ "vue": "^3.5.13" }, "devDependencies": { + "@fontsource-variable/ibm-plex-sans": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", "@playwright/test": "^1.59.1", "@trivago/prettier-plugin-sort-imports": "^6.0.2", diff --git a/documentation/ag-grid-docs/plugins/agDevExampleAssetCors.ts b/documentation/ag-grid-docs/plugins/agDevExampleAssetCors.ts index 43ae1cd4ace..5ebc6519523 100644 --- a/documentation/ag-grid-docs/plugins/agDevExampleAssetCors.ts +++ b/documentation/ag-grid-docs/plugins/agDevExampleAssetCors.ts @@ -10,7 +10,7 @@ const FILES_PREFIX = `${FILES_BASE_PATH}/`; * `security.allowedDomains`. That is the right policy for pages, but it breaks * examples opened on an external host (Plunker, CodeSandbox): * - * - SystemJS fetches the library JS over XHR (a CORS request) so it sends + * - The library JS is fetched as a module script (a CORS request) so it sends * `Origin: https://run.plnkr.co`, matches the allow-list, and loads fine. * - `import '…/styles/ag-grid.css'` is loaded with a `` * — a *no-cors* request that sends NO `Origin` header, so it can never match diff --git a/documentation/ag-grid-docs/public/example-runner/example-runner.js b/documentation/ag-grid-docs/public/example-runner/example-runner.js new file mode 100644 index 00000000000..fe73ffebd03 --- /dev/null +++ b/documentation/ag-grid-docs/public/example-runner/example-runner.js @@ -0,0 +1,222 @@ +/* + * Browser-side runtime for documentation examples, exposed as `window.agExampleRunner`. + * + * Each generated example page calls into it to: + * - set up the page shell (fake `process.env`, global error logging) + * - inject an import map, resolving the `?version=` / `?prod=` query params + * - seed deterministic randomness for screenshot-stable examples + * - notify the parent frame once the example has rendered + * - transpile the example's TypeScript modules with the in-page TypeScript + * compiler, rewriting relative and CSS imports, and run the entry module + * + * No build step is involved: modules are fetched, transpiled and served to the + * browser as blob URLs at load time. + */ +/* global ts */ +(function () { + const VERSION_PARAM = 'version'; + const PROD_PARAM = 'prod'; + const VERSION_PLACEHOLDER = '0.0.0-ag-framework-version'; + const VERSION_PATTERN = '^\\d+\\.\\d+\\.\\d+(?:-[\\w.-]+)?(?:\\+[\\w.-]+)?$'; + const BUILD_TOKENS = { + production: { '?ag-dev-query': '', '&ag-dev-appended': '' }, + development: { '?ag-dev-query': '?dev', '&ag-dev-appended': '&dev' }, + }; + + const COMPILER_OPTION_ENUMS = { module: 'ModuleKind', target: 'ScriptTarget', jsx: 'JsxEmit' }; + + function setUpPage() { + window.process = { env: { NODE_ENV: 'development' } }; + + window.addEventListener('error', function (e) { + console.error('ERROR', e.message, e.filename); + }); + } + + function injectImportMap(options) { + const urlParams = new URLSearchParams(window.location.search); + const requestedVersion = urlParams.get(VERSION_PARAM); + const requestedProd = urlParams.get(PROD_PARAM); + const isProd = requestedProd === null ? options.defaultProd !== false : requestedProd !== 'false'; + let version = options.defaultVersion; + + if (requestedVersion !== null) { + if (!new RegExp(VERSION_PATTERN).test(requestedVersion)) { + const message = `Example not loaded: "${requestedVersion}" is not a valid ?${VERSION_PARAM}= value. Expected a framework version such as ${options.defaultVersion}.`; + + const banner = document.createElement('div'); + banner.textContent = message; + banner.setAttribute('style', 'padding: 1rem; font-family: monospace; color: #b00020;'); + document.body.appendChild(banner); + + throw new Error(message); + } + version = requestedVersion; + } + + const substitutions = Object.assign({}, isProd ? BUILD_TOKENS.production : BUILD_TOKENS.development); + substitutions[VERSION_PLACEHOLDER] = version; + + const rendered = options.imports || JSON.parse(options.template).imports; + const imports = {}; + Object.keys(rendered).forEach(function (specifier) { + let url = rendered[specifier]; + Object.keys(substitutions).forEach(function (token) { + url = url.split(token).join(substitutions[token]); + }); + imports[specifier] = url; + }); + + const importMap = document.createElement('script'); + importMap.type = 'importmap'; + importMap.textContent = JSON.stringify({ imports: imports }); + + if (options.nonce) { + importMap.nonce = options.nonce; + } + + document.head.appendChild(importMap); + } + + function seedRandom(seed) { + window.agRandom = new Math.seedrandom(seed); + + window.agRandom(); + window.agRandom(); + } + + function postInitMessage(options) { + const checkInit = function () { + if (document.querySelector(options.initSelector)) { + window.parent?.postMessage({ + type: 'init', + pageName: options.pageName, + exampleName: options.exampleName, + }); + } else { + requestAnimationFrame(checkInit); + } + }; + + checkInit(); + } + + function runTranspiled(options) { + const specifierRegex = () => new RegExp(options.specifierRegex, 'g'); + const cssImportRegex = () => new RegExp(options.cssImportRegex, 'gm'); + const assetRegex = new RegExp(options.assetRegex, 'i'); + const moduleExtensionRegex = new RegExp(options.moduleExtensionRegex, 'i'); + const loader = options.stylesheetLoaderName; + + const compilerOptions = Object.fromEntries( + Object.entries(options.compilerOptions).map(([name, value]) => [ + name, + COMPILER_OPTION_ENUMS[name] ? ts[COMPILER_OPTION_ENUMS[name]][value] : value, + ]) + ); + + window[loader] = (href) => + new Promise((resolve) => { + const { pathname } = new URL(href, document.baseURI); + const linked = (link) => new URL(link.href, document.baseURI).pathname === pathname; + if (Array.from(document.querySelectorAll('link[rel="stylesheet"]')).some(linked)) { + resolve(); + return; + } + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + link.addEventListener('load', () => resolve()); + link.addEventListener('error', () => resolve()); + document.head.appendChild(link); + }); + + const isRelative = (specifier) => specifier.startsWith('./') || specifier.startsWith('../'); + + const blobUrls = new Map(); + + const moduleUrls = new Map( + options.moduleFiles.map((fileName) => { + const url = new URL(fileName, document.baseURI).href; + return [url.replace(moduleExtensionRegex, ''), url]; + }) + ); + + const fetchModule = async (url) => { + const resolved = moduleExtensionRegex.test(url) ? url : moduleUrls.get(url); + const response = resolved && (await fetch(resolved)); + + if (!response || !response.ok) { + throw new Error('Could not resolve example module: ' + url); + } + + return { url: resolved, source: await response.text() }; + }; + + const rewriteCssImports = (source, url) => { + const rewritten = source.replace(cssImportRegex(), (match, quote, specifier) => { + if (!isRelative(specifier)) { + return match; + } + return 'await window.' + loader + '(' + JSON.stringify(new URL(specifier, url).href) + ');'; + }); + + return rewritten.replace(cssImportRegex(), (_match, _quote, specifier) => { + return 'await window.' + loader + '(import.meta.resolve(' + JSON.stringify(specifier) + '));'; + }); + }; + + const rewriteSpecifiers = async (source, url) => { + const rewrites = new Map(); + + for (const [, , , specifier] of source.matchAll(specifierRegex())) { + if (!isRelative(specifier) || rewrites.has(specifier)) { + continue; + } + + const resolved = new URL(specifier, url).href; + rewrites.set(specifier, assetRegex.test(specifier) ? resolved : await toBlobUrl(resolved)); + } + + return source.replace(specifierRegex(), (match, prefix, quote, specifier) => + rewrites.has(specifier) ? prefix + quote + rewrites.get(specifier) + quote : match + ); + }; + + const toBlobUrl = async (requestedUrl) => { + if (blobUrls.has(requestedUrl)) { + return blobUrls.get(requestedUrl); + } + + const pending = (async () => { + const { url, source } = await fetchModule(requestedUrl); + const { outputText } = ts.transpileModule(rewriteCssImports(source, url), { + fileName: url, + compilerOptions: compilerOptions, + }); + const withRealUrl = outputText.replaceAll('import.meta.url', JSON.stringify(url)); + const code = await rewriteSpecifiers(withRealUrl, url); + + return URL.createObjectURL(new Blob([code], { type: 'text/javascript' })); + })(); + + blobUrls.set(requestedUrl, pending); + + return pending; + }; + + return toBlobUrl(new URL(options.entry, document.baseURI).href) + .then((entryUrl) => import(entryUrl)) + .catch((error) => { + console.error('ERROR', error && error.message); + }); + } + + window.agExampleRunner = { + setUpPage: setUpPage, + injectImportMap: injectImportMap, + seedRandom: seedRandom, + postInitMessage: postInitMessage, + runTranspiled: runTranspiled, + }; +})(); diff --git a/documentation/ag-grid-docs/public/example-runner/grid-angular-boilerplate/css.js b/documentation/ag-grid-docs/public/example-runner/grid-angular-boilerplate/css.js deleted file mode 100644 index 1037fcb8ec3..00000000000 --- a/documentation/ag-grid-docs/public/example-runner/grid-angular-boilerplate/css.js +++ /dev/null @@ -1,189 +0,0 @@ -if (typeof window !== 'undefined') { - var waitSeconds = 100; - - var head = document.getElementsByTagName('head')[0]; - - var isWebkit = !!window.navigator.userAgent.match(/AppleWebKit\/([^ ;]*)/); - var webkitLoadCheck = function (link, callback) { - setTimeout(function () { - for (var i = 0; i < document.styleSheets.length; i++) { - var sheet = document.styleSheets[i]; - if (sheet.href == link.href) { - return callback(); - } - } - webkitLoadCheck(link, callback); - }, 10); - }; - - var cssIsReloadable = function cssIsReloadable(links) { - // Css loaded on the page initially should be skipped by the first - // systemjs load, and marked for reload - var reloadable = true; - forEach(links, function (link) { - if (!link.hasAttribute('data-systemjs-css')) { - reloadable = false; - link.setAttribute('data-systemjs-css', ''); - } - }); - return reloadable; - }; - - var findExistingCSS = function findExistingCSS(url) { - // Search for existing link to reload - var links = head.getElementsByTagName('link'); - return filter(links, function (link) { - return link.href === url; - }); - }; - - var noop = function () {}; - - var loadCSS = function (url, existingLinks) { - const stylesUrl = url.includes('styles.css') || url.includes('style.css'); - return new Promise((outerResolve, outerReject) => { - setTimeout( - () => { - new Promise(function (resolve, reject) { - var timeout = setTimeout(function () { - reject('Unable to load CSS'); - }, waitSeconds * 1000); - var _callback = function (error) { - clearTimeout(timeout); - link.onload = link.onerror = noop; - setTimeout(function () { - if (error) { - reject(error); - outerReject(error); - } else { - resolve(''); - outerResolve(''); - } - }, 7); - }; - var link = document.createElement('link'); - link.type = 'text/css'; - link.rel = 'stylesheet'; - link.href = url; - link.setAttribute('data-systemjs-css', ''); - if (!isWebkit) { - link.onload = function () { - _callback(); - }; - } else { - webkitLoadCheck(link, _callback); - } - link.onerror = function (event) { - _callback(event.error || new Error('Error loading CSS file.')); - }; - if (existingLinks.length) { - head.insertBefore(link, existingLinks[0]); - } else { - head.appendChild(link); - } - }) - // Remove the old link regardless of loading outcome - .then( - function (result) { - forEach(existingLinks, function (link) { - link.parentElement.removeChild(link); - }); - return result; - }, - function (err) { - forEach(existingLinks, function (link) { - link.parentElement.removeChild(link); - }); - throw err; - } - ); - }, - stylesUrl ? 5 : 0 - ); - }); - }; - - exports.fetch = function (load) { - // dont reload styles loaded in the head - var links = findExistingCSS(load.address); - if (!cssIsReloadable(links)) { - return ''; - } - return loadCSS(load.address, links); - }; -} else { - var builderPromise; - - function getBuilder(loader) { - if (builderPromise) { - return builderPromise; - } - - return (builderPromise = System['import']('./css-plugin-base.js', module.id).then(function (CSSPluginBase) { - return new CSSPluginBase(function compile(source, address) { - return { - css: source, - map: null, - moduleSource: null, - moduleFormat: null, - }; - }); - })); - } - - exports.cssPlugin = true; - exports.fetch = function (load, fetch) { - if (!this.builder) { - return ''; - } - return fetch(load); - }; - exports.translate = function (load, opts) { - if (!this.builder) { - return ''; - } - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.translate.call(loader, load, opts); - }); - }; - exports.instantiate = function (load, opts) { - if (!this.builder) { - return; - } - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.instantiate.call(loader, load, opts); - }); - }; - exports.bundle = function (loads, compileOpts, outputOpts) { - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.bundle.call(loader, loads, compileOpts, outputOpts); - }); - }; - exports.listAssets = function (loads, opts) { - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.listAssets.call(loader, loads, opts); - }); - }; -} - -// Because IE8? -function filter(arrayLike, func) { - var arr = []; - forEach(arrayLike, function (item) { - if (func(item)) { - arr.push(item); - } - }); - return arr; -} - -// Because IE8? -function forEach(arrayLike, func) { - for (var i = 0; i < arrayLike.length; i++) { - func(arrayLike[i]); - } -} diff --git a/documentation/ag-grid-docs/public/example-runner/grid-angular-boilerplate/main.ts b/documentation/ag-grid-docs/public/example-runner/grid-angular-boilerplate/main.ts index cb3586cda54..cb8271b88ff 100644 --- a/documentation/ag-grid-docs/public/example-runner/grid-angular-boilerplate/main.ts +++ b/documentation/ag-grid-docs/public/example-runner/grid-angular-boilerplate/main.ts @@ -5,7 +5,7 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { AppComponent } from './app.component'; -if ((window as any).ENABLE_PROD_MODE) { +if (new URLSearchParams(window.location.search).get('prod') !== 'false') { enableProdMode(); } diff --git a/documentation/ag-grid-docs/public/example-runner/grid-angular-boilerplate/systemjs.config.dev.js b/documentation/ag-grid-docs/public/example-runner/grid-angular-boilerplate/systemjs.config.dev.js deleted file mode 100755 index 2ca714d31fb..00000000000 --- a/documentation/ag-grid-docs/public/example-runner/grid-angular-boilerplate/systemjs.config.dev.js +++ /dev/null @@ -1,130 +0,0 @@ -(function (global) { - const urlParams = new URLSearchParams(window.location.search); - const config = { - version: urlParams.get('version') ?? '20.0.0', - prod: urlParams.get('prod') === 'false' ? false : (urlParams.get('prod') ?? false), - }; - - process = { env: { NODE_ENV: 'development' } }; - var ANGULAR_VERSION = config.version; - window.ENABLE_PROD_MODE = config.prod; - - System.config({ - // DEMO ONLY! REAL CODE SHOULD NOT TRANSPILE IN THE BROWSER - transpiler: 'ts', - typescriptOptions: { - target: 'es2020', - emitDecoratorMetadata: true, - experimentalDecorators: true, - }, - meta: { - typescript: { - exports: 'ts', - }, - '*.css': { loader: 'css' }, - }, - paths: { - // paths serve as alias - 'npm:': 'https://cdn.jsdelivr.net/npm/', - ...systemJsPaths, - }, - // map tells the System loader where to look for things - map: { - '@angular/compiler': 'npm:@angular/compiler@' + ANGULAR_VERSION + '/fesm2022/compiler.mjs', - '@angular/platform-browser-dynamic': - 'npm:@angular/platform-browser-dynamic@' + ANGULAR_VERSION + '/fesm2022/platform-browser-dynamic.mjs', - '@angular/core': 'npm:@angular/core@' + ANGULAR_VERSION + '/fesm2022/core.mjs', - '@angular/core/primitives/di': 'npm:@angular/core@' + ANGULAR_VERSION + '/fesm2022/primitives/di.mjs', - '@angular/core/primitives/signals': - 'npm:@angular/core@' + ANGULAR_VERSION + '/fesm2022/primitives/signals.mjs', - '@angular/core/primitives/event-dispatch': - 'npm:@angular/core@' + ANGULAR_VERSION + '/fesm2022/primitives/event-dispatch.mjs', - '@angular/common': 'npm:@angular/common@' + ANGULAR_VERSION + '/fesm2022/common.mjs', - '@angular/common/http': 'npm:@angular/common@' + ANGULAR_VERSION + '/fesm2022/http.mjs', - - '@angular/platform-browser': - 'npm:@angular/platform-browser@' + ANGULAR_VERSION + '/fesm2022/platform-browser.mjs', - '@angular/platform-browser/animations': - 'npm:@angular/platform-browser@' + ANGULAR_VERSION + '/fesm2022/animations.mjs', - - '@angular/forms': 'npm:@angular/forms@' + ANGULAR_VERSION + '/fesm2022/forms.mjs', - '@angular/animations': 'npm:@angular/animations@' + ANGULAR_VERSION + '/fesm2022/animations.mjs', - '@angular/animations/browser': 'npm:@angular/animations@' + ANGULAR_VERSION + '/fesm2022/browser.mjs', - - rxjs: 'npm:rxjs@7.8.1/dist/bundles/rxjs.umd.min.js', - 'rxjs/operators': 'npm:rxjs@7.8.1/dist/bundles/rxjs.umd.min.js', - - css: 'npm:systemjs-plugin-css@0.1.37/css.js', - - ts: 'npm:plugin-typescript@8.0.0/lib/plugin.js', - tslib: 'npm:tslib@2.3.1/tslib.js', - typescript: 'npm:typescript@4.4/lib/typescript.min.js', - - // our app is within the app folder, appLocation comes from index.html - app: appLocation, - ...systemJsMap, - }, - // packages tells the System loader how to load when no filename and/or no extension - packages: { - css: {}, // Stop css.js from defaulting to apps .ts extension - app: { - main: './main.ts', - defaultExtension: 'ts', - }, - 'ag-stack': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-community': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-enterprise': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-angular': { - main: './fesm2022/ag-grid-angular.mjs', - defaultExtension: 'mjs', - }, - 'ag-charts-types': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-core': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-community': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-enterprise': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - '@ag-grid-community/locale': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - }, - }); - - window.addEventListener('error', (e) => { - console.error('ERROR', e.message, e.filename); - }); - - System.import(startFile).catch(function (err) { - document.body.innerHTML = - '
' + 'Example Error: ' + err + '
'; - console.error(err); - }); -})(this); diff --git a/documentation/ag-grid-docs/public/example-runner/grid-angular-boilerplate/systemjs.config.js b/documentation/ag-grid-docs/public/example-runner/grid-angular-boilerplate/systemjs.config.js deleted file mode 100755 index da5151e4df2..00000000000 --- a/documentation/ag-grid-docs/public/example-runner/grid-angular-boilerplate/systemjs.config.js +++ /dev/null @@ -1,121 +0,0 @@ -(function (global) { - const urlParams = new URLSearchParams(window.location.search); - const config = { - version: urlParams.get('version') ?? '20.0.0', - prod: urlParams.get('prod') === 'false' ? false : (urlParams.get('prod') ?? true), - }; - - process = { env: { NODE_ENV: 'development' } }; - var ANGULAR_VERSION = config.version; - window.ENABLE_PROD_MODE = config.prod; - - System.config({ - // DEMO ONLY! REAL CODE SHOULD NOT TRANSPILE IN THE BROWSER - transpiler: 'ts', - typescriptOptions: { - target: 'es2020', - emitDecoratorMetadata: true, - experimentalDecorators: true, - }, - meta: { - typescript: { - exports: 'ts', - }, - '*.css': { loader: 'css' }, - }, - paths: { - // paths serve as alias - 'npm:': 'https://cdn.jsdelivr.net/npm/', - ...systemJsPaths, - }, - // map tells the System loader where to look for things - map: { - '@angular/compiler': 'npm:@angular/compiler@' + ANGULAR_VERSION + '/fesm2022/compiler.mjs', - '@angular/platform-browser-dynamic': - 'npm:@angular/platform-browser-dynamic@' + ANGULAR_VERSION + '/fesm2022/platform-browser-dynamic.mjs', - - '@angular/core': 'npm:@angular/core@' + ANGULAR_VERSION + '/fesm2022/core.mjs', - '@angular/core/primitives/di': 'npm:@angular/core@' + ANGULAR_VERSION + '/fesm2022/primitives/di.mjs', - '@angular/core/primitives/signals': - 'npm:@angular/core@' + ANGULAR_VERSION + '/fesm2022/primitives/signals.mjs', - '@angular/core/primitives/event-dispatch': - 'npm:@angular/core@' + ANGULAR_VERSION + '/fesm2022/primitives/event-dispatch.mjs', - '@angular/common': 'npm:@angular/common@' + ANGULAR_VERSION + '/fesm2022/common.mjs', - '@angular/common/http': 'npm:@angular/common@' + ANGULAR_VERSION + '/fesm2022/http.mjs', - - '@angular/platform-browser': - 'npm:@angular/platform-browser@' + ANGULAR_VERSION + '/fesm2022/platform-browser.mjs', - '@angular/platform-browser/animations': - 'npm:@angular/platform-browser@' + ANGULAR_VERSION + '/fesm2022/animations.mjs', - - '@angular/forms': 'npm:@angular/forms@' + ANGULAR_VERSION + '/fesm2022/forms.mjs', - '@angular/animations': 'npm:@angular/animations@' + ANGULAR_VERSION + '/fesm2022/animations.mjs', - '@angular/animations/browser': 'npm:@angular/animations@' + ANGULAR_VERSION + '/fesm2022/browser.mjs', - - rxjs: 'npm:rxjs@7.8.1/dist/bundles/rxjs.umd.min.js', - 'rxjs/operators': 'npm:rxjs@7.8.1/dist/bundles/rxjs.umd.min.js', - - css: (boilerplatePath.length === 0 ? `./` : `${boilerplatePath}/`) + 'css.js', - - ts: 'npm:plugin-typescript@8.0.0/lib/plugin.js', - tslib: 'npm:tslib@2.3.1/tslib.js', - typescript: 'npm:typescript@4.4/lib/typescript.min.js', - - // our app is within the app folder, appLocation comes from index.html - app: appLocation, - ...systemJsMap, - }, - // packages tells the System loader how to load when no filename and/or no extension - packages: { - css: {}, // Stop css.js from defaulting to apps .ts extension - app: { - main: './main.ts', - defaultExtension: 'ts', - }, - 'ag-stack': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-community': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-enterprise': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-angular': { - main: './fesm2022/ag-grid-angular.mjs', - defaultExtension: 'mjs', - }, - 'ag-charts-core': { - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-community': { - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-enterprise': { - defaultExtension: 'js', - format: 'cjs', - }, - '@ag-grid-community/locale': { - format: 'cjs', - }, - }, - }); - - window.addEventListener('error', (e) => { - console.error('ERROR', e.message, e.filename); - }); - - System.import(startFile).catch(function (err) { - document.body.innerHTML = - '
' + 'Example Error: ' + err + '
'; - console.error(err); - }); -})(this); diff --git a/documentation/ag-grid-docs/public/example-runner/grid-react-boilerplate/css.js b/documentation/ag-grid-docs/public/example-runner/grid-react-boilerplate/css.js deleted file mode 100644 index 8336acf9ea5..00000000000 --- a/documentation/ag-grid-docs/public/example-runner/grid-react-boilerplate/css.js +++ /dev/null @@ -1,188 +0,0 @@ -if (typeof window !== 'undefined') { - var waitSeconds = 100; - - var head = document.getElementsByTagName('head')[0]; - - var isWebkit = !!window.navigator.userAgent.match(/AppleWebKit\/([^ ;]*)/); - var webkitLoadCheck = function (link, callback) { - setTimeout(function () { - for (var i = 0; i < document.styleSheets.length; i++) { - var sheet = document.styleSheets[i]; - if (sheet.href == link.href) { - return callback(); - } - } - webkitLoadCheck(link, callback); - }, 10); - }; - - var cssIsReloadable = function cssIsReloadable(links) { - // Css loaded on the page initially should be skipped by the first - // systemjs load, and marked for reload - var reloadable = true; - forEach(links, function (link) { - if (!link.hasAttribute('data-systemjs-css')) { - reloadable = false; - link.setAttribute('data-systemjs-css', ''); - } - }); - return reloadable; - }; - - var findExistingCSS = function findExistingCSS(url) { - // Search for existing link to reload - var links = head.getElementsByTagName('link'); - return filter(links, function (link) { - return link.href === url; - }); - }; - - var noop = function () {}; - - var loadCSS = function (url, existingLinks) { - const stylesUrl = url.includes('styles.css') || url.includes('style.css'); - return new Promise((outerResolve, outerReject) => { - setTimeout( - () => { - new Promise(function (resolve, reject) { - var timeout = setTimeout(function () { - reject('Unable to load CSS'); - }, waitSeconds * 1000); - var _callback = function (error) { - clearTimeout(timeout); - link.onload = link.onerror = noop; - setTimeout(function () { - if (error) { - reject(error); - outerReject(error); - } else { - resolve(''); - outerResolve(''); - } - }, 7); - }; - var link = document.createElement('link'); - link.type = 'text/css'; - link.rel = 'stylesheet'; - link.href = url; - link.setAttribute('data-systemjs-css', ''); - if (!isWebkit) { - link.onload = function () { - _callback(); - }; - } else { - webkitLoadCheck(link, _callback); - } - link.onerror = function (event) { - _callback(event.error || new Error('Error loading CSS file.')); - }; - if (existingLinks.length) { - head.insertBefore(link, existingLinks[0]); - } else { - head.appendChild(link); - } - }) - // Remove the old link regardless of loading outcome - .then( - function (result) { - forEach(existingLinks, function (link) { - link.parentElement.removeChild(link); - }); - return result; - }, - function (err) { - forEach(existingLinks, function (link) { - link.parentElement.removeChild(link); - }); - throw err; - } - ); - }, - stylesUrl ? 5 : 0 - ); - }); - }; - - exports.fetch = function (load) { - // dont reload styles loaded in the head - var links = findExistingCSS(load.address); - if (!cssIsReloadable(links)) { - return ''; - } - return loadCSS(load.address, links); - }; -} else { - var builderPromise; - function getBuilder(loader) { - if (builderPromise) { - return builderPromise; - } - - return (builderPromise = System['import']('./css-plugin-base.js', module.id).then(function (CSSPluginBase) { - return new CSSPluginBase(function compile(source, address) { - return { - css: source, - map: null, - moduleSource: null, - moduleFormat: null, - }; - }); - })); - } - - exports.cssPlugin = true; - exports.fetch = function (load, fetch) { - if (!this.builder) { - return ''; - } - return fetch(load); - }; - exports.translate = function (load, opts) { - if (!this.builder) { - return ''; - } - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.translate.call(loader, load, opts); - }); - }; - exports.instantiate = function (load, opts) { - if (!this.builder) { - return; - } - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.instantiate.call(loader, load, opts); - }); - }; - exports.bundle = function (loads, compileOpts, outputOpts) { - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.bundle.call(loader, loads, compileOpts, outputOpts); - }); - }; - exports.listAssets = function (loads, opts) { - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.listAssets.call(loader, loads, opts); - }); - }; -} - -// Because IE8? -function filter(arrayLike, func) { - var arr = []; - forEach(arrayLike, function (item) { - if (func(item)) { - arr.push(item); - } - }); - return arr; -} - -// Because IE8? -function forEach(arrayLike, func) { - for (var i = 0; i < arrayLike.length; i++) { - func(arrayLike[i]); - } -} diff --git a/documentation/ag-grid-docs/public/example-runner/grid-react-boilerplate/systemjs.config.dev.js b/documentation/ag-grid-docs/public/example-runner/grid-react-boilerplate/systemjs.config.dev.js deleted file mode 100644 index 4a90609ede8..00000000000 --- a/documentation/ag-grid-docs/public/example-runner/grid-react-boilerplate/systemjs.config.dev.js +++ /dev/null @@ -1,147 +0,0 @@ -(function (global) { - const urlParams = new URLSearchParams(window.location.search); - const config = { - // Suggested defaults: 19.2.1 or 18.2.0 - version: urlParams.get('version') ?? '19.2.1', - prod: urlParams.get('prod') === 'false' ? false : (urlParams.get('prod') ?? false), - }; - - process = { env: { NODE_ENV: 'development' } }; - const REACT_VERSION = config.version; - const filePart = config.prod ? 'production.min' : 'development'; - const reactConfig = !config.version.startsWith('19') - ? { - map: { - react: `npm:react@${REACT_VERSION}`, - 'react-dom': `npm:react-dom@${REACT_VERSION}`, - 'react-dom/client': `npm:react-dom@${REACT_VERSION}`, - }, - packages: { - react: { - main: `./umd/react.${filePart}.js`, - }, - 'react-dom': { - main: `./umd/react-dom.${filePart}.js`, - }, - }, - } - : { - map: { - react: `npm:react@${REACT_VERSION}/cjs/react.${filePart}.js`, - 'react-dom': `npm:react-dom@${REACT_VERSION}/cjs/react-dom.${filePart}.js`, - 'react-dom/client': `npm:react-dom@${REACT_VERSION}/cjs/react-dom-client.${filePart}.js`, - scheduler: `npm:scheduler@0.26.0/cjs/scheduler.${filePart}.js`, - }, - packages: { - react: { - format: 'cjs', - }, - 'react-dom': { - format: 'cjs', - }, - scheduler: { - format: 'cjs', - }, - }, - }; - - var sjsPaths = {}; - if (typeof systemJsPaths !== 'undefined') { - sjsPaths = systemJsPaths; - } - - System.config({ - transpiler: 'ts', - typescriptOptions: { - target: 'es2020', - jsx: 'react', - }, - paths: { - // paths serve as alias - 'npm:': 'https://cdn.jsdelivr.net/npm/', - ...sjsPaths, - }, - map: { - css: 'npm:systemjs-plugin-css@0.1.37/css.js', - - ...reactConfig.map, - - ts: 'npm:plugin-typescript@8.0.0/lib/plugin.js', - typescript: 'npm:typescript@5.4.5/lib/typescript.min.js', - - app: appLocation, - // systemJsMap comes from index.html - ...systemJsMap, - }, - - packages: { - css: {}, - ...reactConfig.packages, - app: { - main: './index.jsx', - defaultExtension: 'jsx', - }, - 'ag-stack': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-community': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-enterprise': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-react': { - main: './dist/package/index.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-types': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-core': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-community': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-enterprise': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - '@ag-grid-community/locale': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - }, - meta: { - typescript: { - exports: 'ts', - }, - '*.css': { loader: 'css' }, - }, - }); - - window.addEventListener('error', (e) => { - console.error('ERROR', e.message, e.filename); - }); - - System.import(startFile).catch(function (err) { - document.body.innerHTML = - '
' + 'Example Error: ' + err + '
'; - console.error(err); - }); -})(this); diff --git a/documentation/ag-grid-docs/public/example-runner/grid-react-boilerplate/systemjs.config.js b/documentation/ag-grid-docs/public/example-runner/grid-react-boilerplate/systemjs.config.js deleted file mode 100644 index 0f8d4ff7d4c..00000000000 --- a/documentation/ag-grid-docs/public/example-runner/grid-react-boilerplate/systemjs.config.js +++ /dev/null @@ -1,135 +0,0 @@ -(function (global) { - const urlParams = new URLSearchParams(window.location.search); - const config = { - // Suggested defaults: 19.2.1 or 18.2.0 - version: urlParams.get('version') ?? '19.2.1', - prod: urlParams.get('prod') === 'false' ? false : (urlParams.get('prod') ?? true), - }; - - process = { env: { NODE_ENV: 'development' } }; - const REACT_VERSION = config.version; - const filePart = config.prod ? 'production.min' : 'development'; - const reactConfig = !config.version.startsWith('19') - ? { - map: { - react: `npm:react@${REACT_VERSION}`, - 'react-dom': `npm:react-dom@${REACT_VERSION}`, - 'react-dom/client': `npm:react-dom@${REACT_VERSION}`, - }, - packages: { - react: { - main: `./umd/react.${filePart}.js`, - }, - 'react-dom': { - main: `./umd/react-dom.${filePart}.js`, - }, - }, - } - : { - map: { - react: `npm:react@${REACT_VERSION}/cjs/react.${filePart}.js`, - 'react-dom': `npm:react-dom@${REACT_VERSION}/cjs/react-dom.${filePart}.js`, - 'react-dom/client': `npm:react-dom@${REACT_VERSION}/cjs/react-dom-client.${filePart}.js`, - scheduler: `npm:scheduler@0.26.0/cjs/scheduler.${filePart}.js`, - }, - packages: { - react: { - format: 'cjs', - }, - 'react-dom': { - format: 'cjs', - }, - scheduler: { - format: 'cjs', - }, - }, - }; - - System.config({ - transpiler: 'ts', - typescriptOptions: { - target: 'es2020', - jsx: 'react', - }, - paths: { - // paths serve as alias - 'npm:': 'https://cdn.jsdelivr.net/npm/', - ...systemJsPaths, - }, - map: { - css: (boilerplatePath.length === 0 ? `./` : `${boilerplatePath}/`) + 'css.js', - - ...reactConfig.map, - - ts: 'npm:plugin-typescript@8.0.0/lib/plugin.js', - typescript: 'npm:typescript@5.4.5/lib/typescript.min.js', - - app: appLocation, - // systemJsMap comes from index.html - ...systemJsMap, - }, - packages: { - css: {}, - ...reactConfig.packages, - app: { - main: './index.jsx', - defaultExtension: 'jsx', - }, - 'ag-stack': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-community': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-enterprise': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-react': { - main: './dist/package/index.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-types': { - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-core': { - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-community': { - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-enterprise': { - defaultExtension: 'js', - format: 'cjs', - }, - '@ag-grid-community/locale': { - format: 'cjs', - }, - }, - meta: { - typescript: { - exports: 'ts', - }, - '*.css': { loader: 'css' }, - }, - }); - - window.addEventListener('error', (e) => { - console.error('ERROR', e.message, e.filename); - }); - - System.import(startFile).catch(function (err) { - document.body.innerHTML = - '
' + 'Example Error: ' + err + '
'; - console.error(err); - }); -})(this); diff --git a/documentation/ag-grid-docs/public/example-runner/grid-react-ts-boilerplate/css.js b/documentation/ag-grid-docs/public/example-runner/grid-react-ts-boilerplate/css.js deleted file mode 100644 index 8336acf9ea5..00000000000 --- a/documentation/ag-grid-docs/public/example-runner/grid-react-ts-boilerplate/css.js +++ /dev/null @@ -1,188 +0,0 @@ -if (typeof window !== 'undefined') { - var waitSeconds = 100; - - var head = document.getElementsByTagName('head')[0]; - - var isWebkit = !!window.navigator.userAgent.match(/AppleWebKit\/([^ ;]*)/); - var webkitLoadCheck = function (link, callback) { - setTimeout(function () { - for (var i = 0; i < document.styleSheets.length; i++) { - var sheet = document.styleSheets[i]; - if (sheet.href == link.href) { - return callback(); - } - } - webkitLoadCheck(link, callback); - }, 10); - }; - - var cssIsReloadable = function cssIsReloadable(links) { - // Css loaded on the page initially should be skipped by the first - // systemjs load, and marked for reload - var reloadable = true; - forEach(links, function (link) { - if (!link.hasAttribute('data-systemjs-css')) { - reloadable = false; - link.setAttribute('data-systemjs-css', ''); - } - }); - return reloadable; - }; - - var findExistingCSS = function findExistingCSS(url) { - // Search for existing link to reload - var links = head.getElementsByTagName('link'); - return filter(links, function (link) { - return link.href === url; - }); - }; - - var noop = function () {}; - - var loadCSS = function (url, existingLinks) { - const stylesUrl = url.includes('styles.css') || url.includes('style.css'); - return new Promise((outerResolve, outerReject) => { - setTimeout( - () => { - new Promise(function (resolve, reject) { - var timeout = setTimeout(function () { - reject('Unable to load CSS'); - }, waitSeconds * 1000); - var _callback = function (error) { - clearTimeout(timeout); - link.onload = link.onerror = noop; - setTimeout(function () { - if (error) { - reject(error); - outerReject(error); - } else { - resolve(''); - outerResolve(''); - } - }, 7); - }; - var link = document.createElement('link'); - link.type = 'text/css'; - link.rel = 'stylesheet'; - link.href = url; - link.setAttribute('data-systemjs-css', ''); - if (!isWebkit) { - link.onload = function () { - _callback(); - }; - } else { - webkitLoadCheck(link, _callback); - } - link.onerror = function (event) { - _callback(event.error || new Error('Error loading CSS file.')); - }; - if (existingLinks.length) { - head.insertBefore(link, existingLinks[0]); - } else { - head.appendChild(link); - } - }) - // Remove the old link regardless of loading outcome - .then( - function (result) { - forEach(existingLinks, function (link) { - link.parentElement.removeChild(link); - }); - return result; - }, - function (err) { - forEach(existingLinks, function (link) { - link.parentElement.removeChild(link); - }); - throw err; - } - ); - }, - stylesUrl ? 5 : 0 - ); - }); - }; - - exports.fetch = function (load) { - // dont reload styles loaded in the head - var links = findExistingCSS(load.address); - if (!cssIsReloadable(links)) { - return ''; - } - return loadCSS(load.address, links); - }; -} else { - var builderPromise; - function getBuilder(loader) { - if (builderPromise) { - return builderPromise; - } - - return (builderPromise = System['import']('./css-plugin-base.js', module.id).then(function (CSSPluginBase) { - return new CSSPluginBase(function compile(source, address) { - return { - css: source, - map: null, - moduleSource: null, - moduleFormat: null, - }; - }); - })); - } - - exports.cssPlugin = true; - exports.fetch = function (load, fetch) { - if (!this.builder) { - return ''; - } - return fetch(load); - }; - exports.translate = function (load, opts) { - if (!this.builder) { - return ''; - } - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.translate.call(loader, load, opts); - }); - }; - exports.instantiate = function (load, opts) { - if (!this.builder) { - return; - } - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.instantiate.call(loader, load, opts); - }); - }; - exports.bundle = function (loads, compileOpts, outputOpts) { - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.bundle.call(loader, loads, compileOpts, outputOpts); - }); - }; - exports.listAssets = function (loads, opts) { - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.listAssets.call(loader, loads, opts); - }); - }; -} - -// Because IE8? -function filter(arrayLike, func) { - var arr = []; - forEach(arrayLike, function (item) { - if (func(item)) { - arr.push(item); - } - }); - return arr; -} - -// Because IE8? -function forEach(arrayLike, func) { - for (var i = 0; i < arrayLike.length; i++) { - func(arrayLike[i]); - } -} diff --git a/documentation/ag-grid-docs/public/example-runner/grid-react-ts-boilerplate/systemjs.config.dev.js b/documentation/ag-grid-docs/public/example-runner/grid-react-ts-boilerplate/systemjs.config.dev.js deleted file mode 100644 index 4d843288066..00000000000 --- a/documentation/ag-grid-docs/public/example-runner/grid-react-ts-boilerplate/systemjs.config.dev.js +++ /dev/null @@ -1,148 +0,0 @@ -(function (global) { - const urlParams = new URLSearchParams(window.location.search); - const config = { - // Suggested defaults: 19.2.1 or 18.2.0 - version: urlParams.get('version') ?? '19.2.1', - prod: urlParams.get('prod') === 'false' ? false : (urlParams.get('prod') ?? false), - }; - - process = { env: { NODE_ENV: 'development' } }; - const REACT_VERSION = config.version; - - const filePart = config.prod ? 'production.min' : 'development'; - const reactConfig = !config.version.startsWith('19') - ? { - map: { - react: `npm:react@${REACT_VERSION}`, - 'react-dom': `npm:react-dom@${REACT_VERSION}`, - 'react-dom/client': `npm:react-dom@${REACT_VERSION}`, - }, - packages: { - react: { - main: `./umd/react.${filePart}.js`, - }, - 'react-dom': { - main: `./umd/react-dom.${filePart}.js`, - }, - }, - } - : { - map: { - react: `npm:react@${REACT_VERSION}/cjs/react.${filePart}.js`, - 'react-dom': `npm:react-dom@${REACT_VERSION}/cjs/react-dom.${filePart}.js`, - 'react-dom/client': `npm:react-dom@${REACT_VERSION}/cjs/react-dom-client.${filePart}.js`, - scheduler: `npm:scheduler@0.26.0/cjs/scheduler.${filePart}.js`, - }, - packages: { - react: { - format: 'cjs', - }, - 'react-dom': { - format: 'cjs', - }, - scheduler: { - format: 'cjs', - }, - }, - }; - - var sjsPaths = {}; - if (typeof systemJsPaths !== 'undefined') { - sjsPaths = systemJsPaths; - } - System.config({ - transpiler: 'ts', - typescriptOptions: { - target: 'es2020', - jsx: 'react', - }, - paths: { - // paths serve as alias - 'npm:': 'https://cdn.jsdelivr.net/npm/', - ...sjsPaths, - }, - map: { - // css: boilerplatePath + "css.js", - css: 'npm:systemjs-plugin-css@0.1.37/css.js', - - ...reactConfig.map, - - ts: 'npm:plugin-typescript@8.0.0/lib/plugin.js', - typescript: 'npm:typescript@5.4.5/lib/typescript.min.js', - - app: appLocation, - // systemJsMap comes from index.html - ...systemJsMap, - }, - - packages: { - css: {}, - ...reactConfig.packages, - app: { - main: './index.tsx', - defaultExtension: 'tsx', - }, - 'ag-stack': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-community': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-enterprise': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-react': { - main: './dist/package/index.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-types': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-core': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-community': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-enterprise': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - '@ag-grid-community/locale': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - }, - meta: { - typescript: { - exports: 'ts', - }, - '*.css': { loader: 'css' }, - }, - }); - - window.addEventListener('error', (e) => { - console.error('ERROR', e.message, e.filename); - }); - - System.import(startFile).catch(function (err) { - document.body.innerHTML = - '
' + 'Example Error: ' + err + '
'; - console.error(err); - }); -})(this); diff --git a/documentation/ag-grid-docs/public/example-runner/grid-react-ts-boilerplate/systemjs.config.js b/documentation/ag-grid-docs/public/example-runner/grid-react-ts-boilerplate/systemjs.config.js deleted file mode 100644 index bcb12d921e5..00000000000 --- a/documentation/ag-grid-docs/public/example-runner/grid-react-ts-boilerplate/systemjs.config.js +++ /dev/null @@ -1,137 +0,0 @@ -(function (global) { - const urlParams = new URLSearchParams(window.location.search); - const config = { - // Suggested defaults: 19.2.1 or 18.2.0 - version: urlParams.get('version') ?? '19.2.1', - prod: urlParams.get('prod') === 'false' ? false : (urlParams.get('prod') ?? true), - }; - - process = { env: { NODE_ENV: 'development' } }; - - const REACT_VERSION = config.version; - - const filePart = config.prod ? 'production.min' : 'development'; - const reactConfig = !config.version.startsWith('19') - ? { - map: { - react: `npm:react@${REACT_VERSION}`, - 'react-dom': `npm:react-dom@${REACT_VERSION}`, - 'react-dom/client': `npm:react-dom@${REACT_VERSION}`, - }, - packages: { - react: { - main: `./umd/react.${filePart}.js`, - }, - 'react-dom': { - main: `./umd/react-dom.${filePart}.js`, - }, - }, - } - : { - map: { - react: `npm:react@${REACT_VERSION}/cjs/react.${filePart}.js`, - 'react-dom': `npm:react-dom@${REACT_VERSION}/cjs/react-dom.${filePart}.js`, - 'react-dom/client': `npm:react-dom@${REACT_VERSION}/cjs/react-dom-client.${filePart}.js`, - scheduler: `npm:scheduler@0.26.0/cjs/scheduler.${filePart}.js`, - }, - packages: { - react: { - format: 'cjs', - }, - 'react-dom': { - format: 'cjs', - }, - scheduler: { - format: 'cjs', - }, - }, - }; - - System.config({ - transpiler: 'ts', - typescriptOptions: { - target: 'es2020', - jsx: 'react', - }, - paths: { - // paths serve as alias - 'npm:': 'https://cdn.jsdelivr.net/npm/', - ...systemJsPaths, - }, - map: { - css: (boilerplatePath.length === 0 ? `./` : `${boilerplatePath}/`) + 'css.js', - - ...reactConfig.map, - - ts: 'npm:plugin-typescript@8.0.0/lib/plugin.js', - typescript: 'npm:typescript@5.4.5/lib/typescript.min.js', - - app: appLocation, - // systemJsMap comes from index.html - ...systemJsMap, - }, - packages: { - css: {}, - ...reactConfig.packages, - app: { - main: './index.tsx', - defaultExtension: 'tsx', - }, - 'ag-stack': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-community': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-enterprise': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-react': { - main: './dist/package/index.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-types': { - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-core': { - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-community': { - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-enterprise': { - defaultExtension: 'js', - format: 'cjs', - }, - '@ag-grid-community/locale': { - format: 'cjs', - }, - }, - meta: { - typescript: { - exports: 'ts', - }, - '*.css': { loader: 'css' }, - }, - }); - - window.addEventListener('error', (e) => { - console.error('ERROR', e.message, e.filename); - }); - - System.import(startFile).catch(function (err) { - document.body.innerHTML = - '
' + 'Example Error: ' + err + '
'; - console.error(err); - }); -})(this); diff --git a/documentation/ag-grid-docs/public/example-runner/grid-typescript-boilerplate/css.js b/documentation/ag-grid-docs/public/example-runner/grid-typescript-boilerplate/css.js deleted file mode 100644 index 8336acf9ea5..00000000000 --- a/documentation/ag-grid-docs/public/example-runner/grid-typescript-boilerplate/css.js +++ /dev/null @@ -1,188 +0,0 @@ -if (typeof window !== 'undefined') { - var waitSeconds = 100; - - var head = document.getElementsByTagName('head')[0]; - - var isWebkit = !!window.navigator.userAgent.match(/AppleWebKit\/([^ ;]*)/); - var webkitLoadCheck = function (link, callback) { - setTimeout(function () { - for (var i = 0; i < document.styleSheets.length; i++) { - var sheet = document.styleSheets[i]; - if (sheet.href == link.href) { - return callback(); - } - } - webkitLoadCheck(link, callback); - }, 10); - }; - - var cssIsReloadable = function cssIsReloadable(links) { - // Css loaded on the page initially should be skipped by the first - // systemjs load, and marked for reload - var reloadable = true; - forEach(links, function (link) { - if (!link.hasAttribute('data-systemjs-css')) { - reloadable = false; - link.setAttribute('data-systemjs-css', ''); - } - }); - return reloadable; - }; - - var findExistingCSS = function findExistingCSS(url) { - // Search for existing link to reload - var links = head.getElementsByTagName('link'); - return filter(links, function (link) { - return link.href === url; - }); - }; - - var noop = function () {}; - - var loadCSS = function (url, existingLinks) { - const stylesUrl = url.includes('styles.css') || url.includes('style.css'); - return new Promise((outerResolve, outerReject) => { - setTimeout( - () => { - new Promise(function (resolve, reject) { - var timeout = setTimeout(function () { - reject('Unable to load CSS'); - }, waitSeconds * 1000); - var _callback = function (error) { - clearTimeout(timeout); - link.onload = link.onerror = noop; - setTimeout(function () { - if (error) { - reject(error); - outerReject(error); - } else { - resolve(''); - outerResolve(''); - } - }, 7); - }; - var link = document.createElement('link'); - link.type = 'text/css'; - link.rel = 'stylesheet'; - link.href = url; - link.setAttribute('data-systemjs-css', ''); - if (!isWebkit) { - link.onload = function () { - _callback(); - }; - } else { - webkitLoadCheck(link, _callback); - } - link.onerror = function (event) { - _callback(event.error || new Error('Error loading CSS file.')); - }; - if (existingLinks.length) { - head.insertBefore(link, existingLinks[0]); - } else { - head.appendChild(link); - } - }) - // Remove the old link regardless of loading outcome - .then( - function (result) { - forEach(existingLinks, function (link) { - link.parentElement.removeChild(link); - }); - return result; - }, - function (err) { - forEach(existingLinks, function (link) { - link.parentElement.removeChild(link); - }); - throw err; - } - ); - }, - stylesUrl ? 5 : 0 - ); - }); - }; - - exports.fetch = function (load) { - // dont reload styles loaded in the head - var links = findExistingCSS(load.address); - if (!cssIsReloadable(links)) { - return ''; - } - return loadCSS(load.address, links); - }; -} else { - var builderPromise; - function getBuilder(loader) { - if (builderPromise) { - return builderPromise; - } - - return (builderPromise = System['import']('./css-plugin-base.js', module.id).then(function (CSSPluginBase) { - return new CSSPluginBase(function compile(source, address) { - return { - css: source, - map: null, - moduleSource: null, - moduleFormat: null, - }; - }); - })); - } - - exports.cssPlugin = true; - exports.fetch = function (load, fetch) { - if (!this.builder) { - return ''; - } - return fetch(load); - }; - exports.translate = function (load, opts) { - if (!this.builder) { - return ''; - } - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.translate.call(loader, load, opts); - }); - }; - exports.instantiate = function (load, opts) { - if (!this.builder) { - return; - } - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.instantiate.call(loader, load, opts); - }); - }; - exports.bundle = function (loads, compileOpts, outputOpts) { - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.bundle.call(loader, loads, compileOpts, outputOpts); - }); - }; - exports.listAssets = function (loads, opts) { - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.listAssets.call(loader, loads, opts); - }); - }; -} - -// Because IE8? -function filter(arrayLike, func) { - var arr = []; - forEach(arrayLike, function (item) { - if (func(item)) { - arr.push(item); - } - }); - return arr; -} - -// Because IE8? -function forEach(arrayLike, func) { - for (var i = 0; i < arrayLike.length; i++) { - func(arrayLike[i]); - } -} diff --git a/documentation/ag-grid-docs/public/example-runner/grid-typescript-boilerplate/systemjs.config.dev.js b/documentation/ag-grid-docs/public/example-runner/grid-typescript-boilerplate/systemjs.config.dev.js deleted file mode 100755 index 234848d6649..00000000000 --- a/documentation/ag-grid-docs/public/example-runner/grid-typescript-boilerplate/systemjs.config.dev.js +++ /dev/null @@ -1,96 +0,0 @@ -(function (global) { - process = { env: { NODE_ENV: 'development' } }; - var sjsPaths = {}; - if (typeof systemJsPaths !== 'undefined') { - sjsPaths = systemJsPaths; - } - System.config({ - // DEMO ONLY! REAL CODE SHOULD NOT TRANSPILE IN THE BROWSER - transpiler: 'ts', - typescriptOptions: { - target: 'es2020', - }, - meta: { - typescript: { - exports: 'ts', - }, - '*.css': { loader: 'css' }, - }, - paths: { - // paths serve as alias - 'npm:': 'https://cdn.jsdelivr.net/npm/', - ...sjsPaths, - }, - // map tells the System loader where to look for things - map: { - css: 'npm:systemjs-plugin-css@0.1.37/css.js', - - ts: 'npm:plugin-typescript@8.0.0/lib/plugin.js', - tslib: 'npm:tslib@2.3.1/tslib.js', - typescript: 'npm:typescript@5.4.5/lib/typescript.min.js', - - // appLocation comes from index.html - app: appLocation, - - ...systemJsMap, - }, - // packages tells the System loader how to load when no filename and/or no extension - packages: { - css: {}, - app: { - main: './main.ts', - defaultExtension: 'ts', - }, - 'ag-stack': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-community': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-enterprise': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-types': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-core': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-community': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-enterprise': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - '@ag-grid-community/locale': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - }, - }); - - window.addEventListener('error', (e) => { - console.error('ERROR', e.message, e.filename); - }); - - System.import(startFile).catch(function (err) { - document.body.innerHTML = - '
' + 'Example Error: ' + err + '
'; - console.error(err); - }); -})(this); diff --git a/documentation/ag-grid-docs/public/example-runner/grid-typescript-boilerplate/systemjs.config.js b/documentation/ag-grid-docs/public/example-runner/grid-typescript-boilerplate/systemjs.config.js deleted file mode 100755 index 5d6fb4068ba..00000000000 --- a/documentation/ag-grid-docs/public/example-runner/grid-typescript-boilerplate/systemjs.config.js +++ /dev/null @@ -1,85 +0,0 @@ -(function (global) { - process = { env: { NODE_ENV: 'development' } }; - System.config({ - // DEMO ONLY! REAL CODE SHOULD NOT TRANSPILE IN THE BROWSER - transpiler: 'ts', - typescriptOptions: { - target: 'es2020', - }, - meta: { - typescript: { - exports: 'ts', - }, - '*.css': { loader: 'css' }, - }, - paths: { - // paths serve as alias - 'npm:': 'https://cdn.jsdelivr.net/npm/', - ...systemJsPaths, - }, - // map tells the System loader where to look for things - map: { - css: (boilerplatePath.length === 0 ? `./` : `${boilerplatePath}/`) + 'css.js', - - ts: 'npm:plugin-typescript@8.0.0/lib/plugin.js', - tslib: 'npm:tslib@2.3.1/tslib.js', - typescript: 'npm:typescript@5.4.5/lib/typescript.min.js', - - // appLocation comes from index.html - app: appLocation, - ...systemJsMap, - }, - // packages tells the System loader how to load when no filename and/or no extension - packages: { - css: {}, - app: { - main: './main.ts', - defaultExtension: 'ts', - }, - 'ag-stack': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-community': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-enterprise': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-types': { - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-core': { - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-community': { - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-enterprise': { - defaultExtension: 'js', - format: 'cjs', - }, - '@ag-grid-community/locale': { - format: 'cjs', - }, - }, - }); - - window.addEventListener('error', (e) => { - console.error('ERROR', e.message, e.filename); - }); - - System.import(startFile).catch(function (err) { - document.body.innerHTML = - '
' + 'Example Error: ' + err + '
'; - console.error(err); - }); -})(this); diff --git a/documentation/ag-grid-docs/public/example-runner/grid-vue3-boilerplate/css.js b/documentation/ag-grid-docs/public/example-runner/grid-vue3-boilerplate/css.js deleted file mode 100644 index 8336acf9ea5..00000000000 --- a/documentation/ag-grid-docs/public/example-runner/grid-vue3-boilerplate/css.js +++ /dev/null @@ -1,188 +0,0 @@ -if (typeof window !== 'undefined') { - var waitSeconds = 100; - - var head = document.getElementsByTagName('head')[0]; - - var isWebkit = !!window.navigator.userAgent.match(/AppleWebKit\/([^ ;]*)/); - var webkitLoadCheck = function (link, callback) { - setTimeout(function () { - for (var i = 0; i < document.styleSheets.length; i++) { - var sheet = document.styleSheets[i]; - if (sheet.href == link.href) { - return callback(); - } - } - webkitLoadCheck(link, callback); - }, 10); - }; - - var cssIsReloadable = function cssIsReloadable(links) { - // Css loaded on the page initially should be skipped by the first - // systemjs load, and marked for reload - var reloadable = true; - forEach(links, function (link) { - if (!link.hasAttribute('data-systemjs-css')) { - reloadable = false; - link.setAttribute('data-systemjs-css', ''); - } - }); - return reloadable; - }; - - var findExistingCSS = function findExistingCSS(url) { - // Search for existing link to reload - var links = head.getElementsByTagName('link'); - return filter(links, function (link) { - return link.href === url; - }); - }; - - var noop = function () {}; - - var loadCSS = function (url, existingLinks) { - const stylesUrl = url.includes('styles.css') || url.includes('style.css'); - return new Promise((outerResolve, outerReject) => { - setTimeout( - () => { - new Promise(function (resolve, reject) { - var timeout = setTimeout(function () { - reject('Unable to load CSS'); - }, waitSeconds * 1000); - var _callback = function (error) { - clearTimeout(timeout); - link.onload = link.onerror = noop; - setTimeout(function () { - if (error) { - reject(error); - outerReject(error); - } else { - resolve(''); - outerResolve(''); - } - }, 7); - }; - var link = document.createElement('link'); - link.type = 'text/css'; - link.rel = 'stylesheet'; - link.href = url; - link.setAttribute('data-systemjs-css', ''); - if (!isWebkit) { - link.onload = function () { - _callback(); - }; - } else { - webkitLoadCheck(link, _callback); - } - link.onerror = function (event) { - _callback(event.error || new Error('Error loading CSS file.')); - }; - if (existingLinks.length) { - head.insertBefore(link, existingLinks[0]); - } else { - head.appendChild(link); - } - }) - // Remove the old link regardless of loading outcome - .then( - function (result) { - forEach(existingLinks, function (link) { - link.parentElement.removeChild(link); - }); - return result; - }, - function (err) { - forEach(existingLinks, function (link) { - link.parentElement.removeChild(link); - }); - throw err; - } - ); - }, - stylesUrl ? 5 : 0 - ); - }); - }; - - exports.fetch = function (load) { - // dont reload styles loaded in the head - var links = findExistingCSS(load.address); - if (!cssIsReloadable(links)) { - return ''; - } - return loadCSS(load.address, links); - }; -} else { - var builderPromise; - function getBuilder(loader) { - if (builderPromise) { - return builderPromise; - } - - return (builderPromise = System['import']('./css-plugin-base.js', module.id).then(function (CSSPluginBase) { - return new CSSPluginBase(function compile(source, address) { - return { - css: source, - map: null, - moduleSource: null, - moduleFormat: null, - }; - }); - })); - } - - exports.cssPlugin = true; - exports.fetch = function (load, fetch) { - if (!this.builder) { - return ''; - } - return fetch(load); - }; - exports.translate = function (load, opts) { - if (!this.builder) { - return ''; - } - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.translate.call(loader, load, opts); - }); - }; - exports.instantiate = function (load, opts) { - if (!this.builder) { - return; - } - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.instantiate.call(loader, load, opts); - }); - }; - exports.bundle = function (loads, compileOpts, outputOpts) { - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.bundle.call(loader, loads, compileOpts, outputOpts); - }); - }; - exports.listAssets = function (loads, opts) { - var loader = this; - return getBuilder(loader).then(function (builder) { - return builder.listAssets.call(loader, loads, opts); - }); - }; -} - -// Because IE8? -function filter(arrayLike, func) { - var arr = []; - forEach(arrayLike, function (item) { - if (func(item)) { - arr.push(item); - } - }); - return arr; -} - -// Because IE8? -function forEach(arrayLike, func) { - for (var i = 0; i < arrayLike.length; i++) { - func(arrayLike[i]); - } -} diff --git a/documentation/ag-grid-docs/public/example-runner/grid-vue3-boilerplate/systemjs.config.dev.js b/documentation/ag-grid-docs/public/example-runner/grid-vue3-boilerplate/systemjs.config.dev.js deleted file mode 100644 index 1eea68981cc..00000000000 --- a/documentation/ag-grid-docs/public/example-runner/grid-vue3-boilerplate/systemjs.config.dev.js +++ /dev/null @@ -1,113 +0,0 @@ -(function (global) { - const urlParams = new URLSearchParams(window.location.search); - const config = { - version: urlParams.get('version') ?? '3.5.17', - }; - - var VUE_VERSION = config.version; - process = { env: { NODE_ENV: 'development' } }; - var sjsPaths = {}; - if (typeof systemJsPaths !== 'undefined') { - sjsPaths = systemJsPaths; - } - System.config({ - // DEMO ONLY! REAL CODE SHOULD NOT TRANSPILE IN THE BROWSER - transpiler: 'ts', - typescriptOptions: { - target: 'es2020', - }, - meta: { - typescript: { - exports: 'ts', - }, - '*.css': { loader: 'css' }, - }, - defaultExtension: 'js', - paths: { - 'npm:': 'https://cdn.jsdelivr.net/npm/', - ...sjsPaths, - }, - map: { - css: 'npm:systemjs-plugin-css@0.1.37/css.js', - - ts: 'npm:plugin-typescript@8.0.0/lib/plugin.js', - tslib: 'npm:tslib@2.3.1/tslib.js', - typescript: 'npm:typescript@5.4.5/lib/typescript.min.js', - - vue: `npm:vue@${VUE_VERSION}/dist/vue.esm-browser.js`, - '@vue/reactivity': `npm:@vue/reactivity@${VUE_VERSION}/dist/reactivity.esm-browser.js`, - // vue class component - 'vue-class-component': 'npm:vue-class-component@^8.0.0-beta.3/dist/vue-class-component.cjs.js', - - app: appLocation, - // systemJsMap comes from index.html - ...systemJsMap, - }, - packages: { - 'css.js': { - defaultExtension: 'js', - }, - vue: { - defaultExtension: 'js', - }, - app: { - defaultExtension: 'ts', - }, - 'ag-stack': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-community': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-enterprise': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-vue3': { - main: './dist/main.umd.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-types': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-core': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-community': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-enterprise': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - '@ag-grid-community/locale': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - }, - }); - - window.addEventListener('error', (e) => { - console.error('ERROR', e.message, e.filename); - }); - - System.import(startFile).catch(function (err) { - document.body.innerHTML = - '
' + 'Example Error: ' + err + '
'; - console.error(err); - }); -})(this); diff --git a/documentation/ag-grid-docs/public/example-runner/grid-vue3-boilerplate/systemjs.config.js b/documentation/ag-grid-docs/public/example-runner/grid-vue3-boilerplate/systemjs.config.js deleted file mode 100644 index 9ac04ea1af1..00000000000 --- a/documentation/ag-grid-docs/public/example-runner/grid-vue3-boilerplate/systemjs.config.js +++ /dev/null @@ -1,105 +0,0 @@ -(function (global) { - const urlParams = new URLSearchParams(window.location.search); - const config = { - version: urlParams.get('version') ?? '3.5.17', - }; - - var VUE_VERSION = config.version; - process = { env: { NODE_ENV: 'development' } }; - System.config({ - // DEMO ONLY! REAL CODE SHOULD NOT TRANSPILE IN THE BROWSER - transpiler: 'ts', - typescriptOptions: { - target: 'es2020', - }, - meta: { - typescript: { - exports: 'ts', - }, - '*.css': { loader: 'css' }, - }, - defaultExtension: 'js', - paths: { - // paths serve as alias - 'npm:': 'https://cdn.jsdelivr.net/npm/', - ...systemJsPaths, - }, - map: { - css: (boilerplatePath.length === 0 ? `./` : `${boilerplatePath}/`) + 'css.js', - - ts: 'npm:plugin-typescript@8.0.0/lib/plugin.js', - tslib: 'npm:tslib@2.3.1/tslib.js', - typescript: 'npm:typescript@5.4.5/lib/typescript.min.js', - - vue: `npm:vue@${VUE_VERSION}/dist/vue.esm-browser.js`, - '@vue/reactivity': `npm:@vue/reactivity@${VUE_VERSION}/dist/reactivity.esm-browser.prod.js`, - - // vue class component - 'vue-class-component': 'npm:vue-class-component@^8.0.0-beta.3/dist/vue-class-component.cjs.js', - - app: appLocation, - // systemJsMap comes from index.html - ...systemJsMap, - }, - packages: { - 'css.js': { - defaultExtension: 'js', - }, - vue: { - defaultExtension: 'js', - }, - app: { - defaultExtension: 'ts', - }, - 'ag-stack': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-community': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-enterprise': { - main: './dist/package/main.cjs.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-grid-vue3': { - main: './dist/main.umd.js', - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-types': { - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-core': { - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-community': { - defaultExtension: 'js', - format: 'cjs', - }, - 'ag-charts-enterprise': { - defaultExtension: 'js', - format: 'cjs', - }, - '@ag-grid-community/locale': { - format: 'cjs', - }, - }, - }); - - window.addEventListener('error', (e) => { - console.error('ERROR', e.message, e.filename); - }); - - System.import(startFile).catch(function (err) { - document.body.innerHTML = - '
' + 'Example Error: ' + err + '
'; - console.error(err); - }); -})(this); diff --git a/documentation/ag-grid-docs/public/example-runner/systemjs.test.ts b/documentation/ag-grid-docs/public/example-runner/systemjs.test.ts deleted file mode 100644 index acff80f80c9..00000000000 --- a/documentation/ag-grid-docs/public/example-runner/systemjs.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { existsSync, readdirSync } from 'fs'; -import { join } from 'path'; -import { vi } from 'vitest'; - -vi.stubGlobal('appLocation', {}); -vi.stubGlobal('startFile', {}); -vi.stubGlobal('boilerplatePath', {}); -vi.stubGlobal('systemJsMap', {}); -vi.stubGlobal('systemJsPaths', {}); -vi.stubGlobal('window', { - location: { - search: '', - }, - addEventListener: () => {}, -}); - -const systemjsFiles = []; -const entries = readdirSync(__dirname, { withFileTypes: true }); -entries.forEach((entry) => { - if (entry.isDirectory()) { - const dir = join(__dirname, entry.name); - - const entries = readdirSync(dir, { withFileTypes: true }); - const files = entries - .filter((file) => file.isFile()) - .filter((file) => file.name.includes('systemjs.config.')) - .map((file) => join(dir, file.name)); - systemjsFiles.push(...files); - } -}); - -describe('Test cases for SystemJs Mappings', () => { - systemjsFiles.forEach((file, i) => { - test(`file is ${file}`, async () => { - // capture the config supplied to System.config - let config = {}; - vi.stubGlobal('System', { - config: (conf) => { - config = conf; - }, - import: () => ({ - catch: () => {}, - }), - }); - - const load = () => import(file); - await load(); - - Object.keys(config.packages) - .filter((key) => key.startsWith('ag-') || key.startsWith('@ag-')) - .forEach((key) => { - // we dont specify main in prod files as they're often not necessary - use default - let mainFile = config.packages[key].main || './dist/package/main.cjs.js'; - - // angular packages are in dist/angular - that is what will be published - if (key.includes('angular')) { - mainFile = `./dist/${key}/${mainFile}`; - } - expect( - existsSync(join(__dirname, `../../../../node_modules/${key}/${mainFile}`)), - key - ).toBeTruthy(); - }); - }); - }); -}); diff --git a/documentation/ag-grid-docs/public/scripts/enzuzo-policy-tidy.js b/documentation/ag-grid-docs/public/scripts/enzuzo-policy-tidy.js new file mode 100644 index 00000000000..013db358b76 --- /dev/null +++ b/documentation/ag-grid-docs/public/scripts/enzuzo-policy-tidy.js @@ -0,0 +1,141 @@ +/* + * Two clean-ups on the Enzuzo cookie-policy embed (AG-18194), both of which have to happen here + * rather than in CSS or in the markup: + * + * 1. Drop the vendor's inline + diff --git a/documentation/ag-grid-docs/src/components/example-runner/framework-templates/AngularTemplate.astro b/documentation/ag-grid-docs/src/components/example-runner/framework-templates/AngularTemplate.astro index 5ef644ded88..d6a7589825a 100644 --- a/documentation/ag-grid-docs/src/components/example-runner/framework-templates/AngularTemplate.astro +++ b/documentation/ag-grid-docs/src/components/example-runner/framework-templates/AngularTemplate.astro @@ -3,8 +3,7 @@ import { MetaData } from './lib/MetaData'; import ExampleStyle from './lib/ExampleStyle.astro'; import Styles from './lib/Styles.astro'; import Extras from './lib/Extras.astro'; -import { SystemJs } from './lib/SystemJs'; -import { pathJoin } from '@utils/pathJoin'; +import { ExampleModules } from './lib/ExampleModules'; import { getCacheBustingUrl } from '@utils/gridLibraryPaths'; import { Scripts } from './lib/Scripts'; @@ -12,18 +11,20 @@ interface Props { isDev: boolean; title: string; isEnterprise: boolean; + isIntegratedCharts?: boolean; modifiedTimeMs: number; entryFileName: string; + fileNames: string[]; styleFiles?: string[]; scriptFiles: string[]; appLocation: string; - boilerplatePath: string; extraStyles?: string; headFragment?: string; extras?: string[]; usesMathRandom?: boolean; + transpileInBrowser?: boolean; nonce?: string; } @@ -31,20 +32,20 @@ const { title, isDev, isEnterprise, + isIntegratedCharts, modifiedTimeMs, appLocation, entryFileName, + fileNames, styleFiles, scriptFiles, - boilerplatePath, extraStyles, headFragment, extras, usesMathRandom, + transpileInBrowser, nonce, } = Astro.props as Props; - -const startFile = pathJoin(appLocation, entryFileName); --- @@ -83,14 +84,15 @@ const startFile = pathJoin(appLocation, entryFileName); > {scriptFiles && } - @@ -76,15 +74,16 @@ const startFile = pathJoin(appLocation, entryFileName); {scriptFiles && } { - !ignoreSystemJs && ( - ) diff --git a/documentation/ag-grid-docs/src/components/example-runner/framework-templates/TypescriptTemplate.astro b/documentation/ag-grid-docs/src/components/example-runner/framework-templates/TypescriptTemplate.astro index 6f4dd361aa1..4db060cb031 100644 --- a/documentation/ag-grid-docs/src/components/example-runner/framework-templates/TypescriptTemplate.astro +++ b/documentation/ag-grid-docs/src/components/example-runner/framework-templates/TypescriptTemplate.astro @@ -3,26 +3,27 @@ import { MetaData } from './lib/MetaData'; import ExampleStyle from './lib/ExampleStyle.astro'; import Styles from './lib/Styles.astro'; import Extras from './lib/Extras.astro'; -import { SystemJs } from './lib/SystemJs'; -import { pathJoin } from '@utils/pathJoin'; +import { ExampleModules } from './lib/ExampleModules'; import { getCacheBustingUrl } from '@utils/gridLibraryPaths'; interface Props { isDev: boolean; title: string; isEnterprise: boolean; + isIntegratedCharts?: boolean; modifiedTimeMs: number; entryFileName: string; + fileNames: string[]; styleFiles?: string[]; indexFragment: string; appLocation: string; - boilerplatePath: string; extraStyles?: string; extras?: string[]; headFragment?: string; usesMathRandom?: boolean; + transpileInBrowser?: boolean; nonce?: string; } @@ -30,20 +31,20 @@ const { title, isDev, isEnterprise, + isIntegratedCharts, modifiedTimeMs, appLocation, entryFileName, + fileNames, styleFiles, indexFragment, - boilerplatePath, extraStyles, extras, headFragment, usesMathRandom, + transpileInBrowser, nonce, } = Astro.props as Props; - -const startFile = pathJoin(appLocation, entryFileName); --- @@ -74,14 +75,15 @@ const startFile = pathJoin(appLocation, entryFileName); window.__basePath = appLocation; - @@ -70,14 +68,15 @@ const startFile = pathJoin(appLocation, entryFileName); - diff --git a/documentation/ag-grid-docs/src/components/example-runner/framework-templates/lib/BrowserTranspiler.tsx b/documentation/ag-grid-docs/src/components/example-runner/framework-templates/lib/BrowserTranspiler.tsx new file mode 100644 index 00000000000..ae859a7c82a --- /dev/null +++ b/documentation/ag-grid-docs/src/components/example-runner/framework-templates/lib/BrowserTranspiler.tsx @@ -0,0 +1,48 @@ +import type { InternalFramework } from '@ag-grid-types'; +import { + ASSET_REGEX, + CSS_IMPORT_REGEX, + SPECIFIER_REGEX, + STYLESHEET_LOADER_NAME, + getCompilerOptionNames, +} from '@utils/exampleModules/transformExampleModule'; +import ts from 'typescript'; + +import { ExampleRunnerCall } from './ExampleRunnerClient'; + +interface Props { + entryFileName: string; + fileNames: string[]; + internalFramework: InternalFramework; + nonce?: string; +} + +const TYPESCRIPT_URL = `https://cdn.jsdelivr.net/npm/typescript@${ts.version}/lib/typescript.js`; + +const MODULE_EXTENSION_REGEX = /\.(tsx?|jsx?|mjs|cjs)$/i; + +export const getTranspilerOptions = ( + entryFileName: string, + fileNames: string[], + internalFramework: InternalFramework +) => ({ + entry: `./${entryFileName}`, + specifierRegex: SPECIFIER_REGEX.source, + cssImportRegex: CSS_IMPORT_REGEX.source, + assetRegex: ASSET_REGEX.source, + moduleExtensionRegex: MODULE_EXTENSION_REGEX.source, + moduleFiles: fileNames.filter((fileName) => MODULE_EXTENSION_REGEX.test(fileName)), + compilerOptions: getCompilerOptionNames(internalFramework), + stylesheetLoaderName: STYLESHEET_LOADER_NAME, +}); + +export const BrowserTranspiler = ({ entryFileName, fileNames, internalFramework, nonce }: Props) => ( + <> + `)); + if (!call) { + throw new Error(`No ${fn} call rendered in:\n${html}`); + } + + return JSON.parse(`[${call[1].replaceAll('"', '"')}]`); +}; + +const renderMarkup = async ({ + internalFramework, + transpileInBrowser, + usesMathRandom, + fileNames = EXAMPLE_FILE_NAMES, +}: { + internalFramework: InternalFramework; + transpileInBrowser?: boolean; + usesMathRandom?: boolean; + fileNames?: string[]; +}) => { + vi.stubEnv('PUBLIC_USE_PUBLISHED_PACKAGES', 'true'); + vi.stubEnv('PUBLIC_BASE_URL', BASE_URL); + vi.stubEnv('PUBLIC_SITE_URL', SITE_URL); + vi.resetModules(); + + const { ExampleModules } = await import('./ExampleModules'); + const html = renderToStaticMarkup( + + ); + + return html; +}; + +const renderImportMap = async (props: { internalFramework: InternalFramework; transpileInBrowser?: boolean }) => { + const html = await renderMarkup(props); + + const served = html.match(/ -
-

{content.cookiesSection.heading}

-
-

{content.cookiesSection.note}

- -
+ { + /* Strips the vendor's self-promotional section from the injected policy; see + public/scripts/enzuzo-policy-tidy.js. Externalised to a 'self' script so the site CSP + can keep script-src free of 'unsafe-inline' without needing a per-build hash, and + re-run alongside the loader above so it observes the re-injected policy too. */ + } + diff --git a/external/ag-website-shared/src/components/policies/policyContent.ts b/external/ag-website-shared/src/components/policies/policyContent.ts index 87b5f0ee012..f8ff10b614d 100644 --- a/external/ag-website-shared/src/components/policies/policyContent.ts +++ b/external/ag-website-shared/src/components/policies/policyContent.ts @@ -20,12 +20,6 @@ export interface PolicyContent { meta: string[]; /** Introductory paragraphs, as inline HTML (`` only). */ intro: string[]; - /** Framing for a data-driven section rendered below the policy body (the cookie inventory). */ - cookiesSection?: { - id: string; - heading: string; - note: string; - }; } export const POLICY_CONTENT = { @@ -43,22 +37,17 @@ export const POLICY_CONTENT = { 'We strongly recommend you read our policy and understand what we collect, how we collect it, what we do with it, how we protect it, and your rights regarding information, before you use or access any of our services.', ], }, + /** + * The cookies page renders the Enzuzo policy embed, which supplies its own heading, body and + * cookie inventory (AG-18194) — so unlike the other policies, only the document metadata here + * reaches the page. `heading` is used by the `/cookies.md` twin. + */ cookies: { - heading: 'Cookies Policy', + heading: '{name} Cookies Policy', metaTitle: 'Cookies Policy', description: 'This page outlines our policy in relation to the cookies that we collect on our website.', - meta: ['Effective Date: May 17, 2018'], + meta: [], intro: [], - /** - * The cookie inventory rendered below the policy body, from - * `@ag-website-shared/content/policies/cookies-data-*.json` (AG-18105). Shared so the - * `CookiesTable` section on the page and the `/cookies.md` twin carry the same framing. - */ - cookiesSection: { - id: 'cookies-we-use', - heading: 'Cookies We Use', - note: 'The cookies listed below were last reviewed on 7 August 2026.', - }, }, 'modern-slavery': { heading: '{name} Modern Slavery and Human Trafficking Statement', diff --git a/external/ag-website-shared/src/constants.ts b/external/ag-website-shared/src/constants.ts index ea408b80faa..9ea92a63f8d 100644 --- a/external/ag-website-shared/src/constants.ts +++ b/external/ag-website-shared/src/constants.ts @@ -78,6 +78,12 @@ export const STUDIO_FORM_DATA = { // Relative to website folder export const SITEMAP_CACHE_DIR = '.astro/cache/sitemap'; +/** + * `User-Agent` identifying build-time fetches against the live AG sites (sitemaps, robots disallow + * lists), which are not served to the default agent. + */ +export const BUILD_USER_AGENT = 'Mozilla/5.0 (compatible; ag-website-build)'; + export const PRIVACY_POLICY_URL = 'https://www.ag-grid.com/privacy'; // Figma @@ -88,3 +94,6 @@ export const YOUTUBE_LICENSE_PRICING_URL = 'https://www.youtube.com/watch?v=VPr_ // Zendesk export const ZENDESK_URL = 'https://ag-grid.zendesk.com/hc/en-us'; + +// Enzuzo consent-management platform: the AG Grid site's cookie policy UUID +export const AG_GRID_ENZUZO_POLICY_ID = '061e8460-91b3-11f1-98ff-978c2fcf2681'; diff --git a/external/ag-website-shared/src/content/policies/cookies-data-07-08-26.json b/external/ag-website-shared/src/content/policies/cookies-data-07-08-26.json deleted file mode 100644 index 7ceb4262908..00000000000 --- a/external/ag-website-shared/src/content/policies/cookies-data-07-08-26.json +++ /dev/null @@ -1,330 +0,0 @@ -{ - "categories": [ - { - "name": "Strictly Necessary Cookies", - "description": "These cookies are necessary for the website to function and cannot be switched off in our systems. They are usually only set in response to actions made by you which amount to a request for services, such as setting your privacy preferences, logging in or filling in forms. You can set your browser to block or alert you about these cookies, but some parts of the site will not then work. These cookies do not store any personally identifiable information.", - "cookies": [ - { - "name": "agGridFramework", - "subgroup": "www.ag-grid.com", - "party": "First Party", - "moreInfo": "https://cookiepedia.co.uk/cookies/agGridFramework" - }, - { - "name": "__cflb", - "subgroup": "blog.ag-grid.com", - "party": "First Party", - "moreInfo": "https://cookiepedia.co.uk/cookies/__cflb" - } - ] - }, - { - "name": "Performance Cookies", - "description": "These cookies allow us to count visits and traffic sources so we can measure and improve the performance of our site. They help us to know which pages are the most and least popular and see how visitors move around the site. All information these cookies collect is aggregated and therefore anonymous. If you do not allow these cookies we will not know when you have visited our site, and will not be able to monitor its performance.", - "cookies": [ - { - "name": "__auc", - "subgroup": "ag-grid.com", - "party": "First Party", - "moreInfo": "https://cookiepedia.co.uk/cookies/__auc" - }, - { - "name": "_ga_xxxxxxx", - "subgroup": "ag-grid.com", - "party": "First Party", - "moreInfo": "https://cookiepedia.co.uk/cookies/_ga_xxxxxxx" - }, - { - "name": "__asc", - "subgroup": "ag-grid.com", - "party": "First Party", - "moreInfo": "https://cookiepedia.co.uk/cookies/__asc" - }, - { - "name": "_gid", - "subgroup": "ag-grid.com", - "party": "First Party", - "moreInfo": "https://cookiepedia.co.uk/cookies/_gid" - }, - { - "name": "_ga_xxxxxxxxxx", - "subgroup": "ag-grid.com", - "party": "First Party", - "moreInfo": "https://cookiepedia.co.uk/cookies/_ga_xxxxxxxxxx" - }, - { - "name": "_gat_UA-", - "subgroup": "ag-grid.com", - "party": "First Party", - "moreInfo": "https://cookiepedia.co.uk/cookies/_gat_UA-" - }, - { - "name": "_ga", - "subgroup": "ag-grid.com", - "party": "First Party", - "moreInfo": "https://cookiepedia.co.uk/cookies/_ga" - }, - { - "name": "JSESSIONID", - "subgroup": "nr-data.net", - "party": "Third Party", - "moreInfo": null - } - ] - }, - { - "name": "Functional Cookies", - "description": "These cookies enable the website to provide enhanced functionality and personalisation. They may be set by us or by third party providers whose services we have added to our pages. If you do not allow these cookies then some or all of these services may not function properly.", - "cookies": [ - { - "name": "_octo", - "subgroup": "github.com", - "party": "Third Party", - "moreInfo": null - }, - { - "name": "logged_in", - "subgroup": "github.com", - "party": "Third Party", - "moreInfo": null - }, - { - "name": "_gh_sess", - "subgroup": "github.com", - "party": "Third Party", - "moreInfo": null - } - ] - }, - { - "name": "Targeting Cookies", - "description": "These cookies may be set through our site by our advertising partners. They may be used by those companies to build a profile of your interests and show you relevant adverts on other sites. They do not store directly personal information, but are based on uniquely identifying your browser and internet device. If you do not allow these cookies, you will experience less targeted advertising.", - "cookies": [ - { - "name": "GoogleAdServingTest", - "subgroup": "www.ag-grid.com", - "party": "First Party", - "description": "This cookie is used to determine what ads have been shown to the website visitor.", - "moreInfo": "https://cookiepedia.co.uk/cookies/GoogleAdServingTest" - }, - { - "name": "__gads", - "subgroup": "ag-grid.com", - "party": "First Party", - "description": "Used by Facebook to deliver a series of advertisement products such as real time bidding from third party advertisers", - "moreInfo": "https://cookiepedia.co.uk/cookies/__gads" - }, - { - "name": "_gat_UA-XXXXXX-X", - "subgroup": "ag-grid.com", - "party": "First Party", - "description": "Used by Facebook to deliver a series of advertisement products such as real time bidding from third party advertisers", - "moreInfo": "https://cookiepedia.co.uk/cookies/_gat_UA-XXXXXX-X" - }, - { - "name": "_fbp", - "subgroup": "ag-grid.com", - "party": "First Party", - "description": "Used by Facebook to deliver a series of advertisement products such as real time bidding from third party advertisers", - "moreInfo": "https://cookiepedia.co.uk/cookies/_fbp" - }, - { - "name": null, - "subgroup": "www.facebook.com", - "party": "Third Party", - "description": "This domain is owned by Facebook, which is the world's largest social networking service. As a third party host provider, it mostly collects data on the interests of users via widgets such as the 'Like' button found on many websites. This is used to serve targeted advertising to its users when logged into its services. In 2014 it also started serving up behaviourally targeted advertising on other websites, similar to most dedicated online marketing companies.", - "moreInfo": null - }, - { - "name": "signedIn", - "subgroup": "*.csb.app", - "party": "Third Party", - "description": "Tracking cookie for code sandbox code examples", - "moreInfo": null - }, - { - "name": "atlassian.xsrf.token", - "subgroup": "ag-grid.atlassian.net", - "party": "Third Party", - "description": "Atlassian xsrf cookie to prevent cross site request forgery for security purposes.", - "moreInfo": null - }, - { - "name": "ahoy_visitor", - "subgroup": "stackblitz.com", - "party": "Third Party", - "description": "This cookie creates an interim session ID used as an in-session user ID.", - "moreInfo": null - }, - { - "name": "ahoy_visit", - "subgroup": "stackblitz.com", - "party": "Third Party", - "description": "This cookie creates an interim session ID used as an in-session user ID.", - "moreInfo": null - }, - { - "name": "guest_id", - "subgroup": "stackblitz.com", - "party": "Third Party", - "description": "This cookie creates an interim session ID used as an in-session user ID.", - "moreInfo": null - }, - { - "name": "CSRF-TOKEN", - "subgroup": "stackblitz.com", - "party": "Third Party", - "description": "This cookie creates an interim session ID used as an in-session user ID.", - "moreInfo": null - }, - { - "name": "_session_id", - "subgroup": "stackblitz.com", - "party": "Third Party", - "description": "This cookie creates an interim session ID used as an in-session user ID.", - "moreInfo": null - }, - { - "name": "lang", - "subgroup": "linkedin.com", - "party": "Third Party", - "description": "This domain is owned by LinkedIn, the business networking platform. It typically acts as a third party host where website owners have placed one of its content sharing buttons in their pages, although its content and services can be embedded in other ways. Although such buttons add functionality to the website they are on, cookies are set regardless of whether or not the visitor has an active Linkedin profile, or agreed to their terms and conditions. For this reason it is classified as a primarily tracking/targeting domain.", - "moreInfo": null - }, - { - "name": "AnalyticsSyncHistory", - "subgroup": "linkedin.com", - "party": "Third Party", - "description": "This domain is owned by LinkedIn, the business networking platform. It typically acts as a third party host where website owners have placed one of its content sharing buttons in their pages, although its content and services can be embedded in other ways. Although such buttons add functionality to the website they are on, cookies are set regardless of whether or not the visitor has an active Linkedin profile, or agreed to their terms and conditions. For this reason it is classified as a primarily tracking/targeting domain.", - "moreInfo": null - }, - { - "name": "bcookie", - "subgroup": "linkedin.com", - "party": "Third Party", - "description": "This domain is owned by LinkedIn, the business networking platform. It typically acts as a third party host where website owners have placed one of its content sharing buttons in their pages, although its content and services can be embedded in other ways. Although such buttons add functionality to the website they are on, cookies are set regardless of whether or not the visitor has an active Linkedin profile, or agreed to their terms and conditions. For this reason it is classified as a primarily tracking/targeting domain.", - "moreInfo": null - }, - { - "name": "li_gc", - "subgroup": "linkedin.com", - "party": "Third Party", - "description": "This domain is owned by LinkedIn, the business networking platform. It typically acts as a third party host where website owners have placed one of its content sharing buttons in their pages, although its content and services can be embedded in other ways. Although such buttons add functionality to the website they are on, cookies are set regardless of whether or not the visitor has an active Linkedin profile, or agreed to their terms and conditions. For this reason it is classified as a primarily tracking/targeting domain.", - "moreInfo": null - }, - { - "name": "lidc", - "subgroup": "linkedin.com", - "party": "Third Party", - "description": "This domain is owned by LinkedIn, the business networking platform. It typically acts as a third party host where website owners have placed one of its content sharing buttons in their pages, although its content and services can be embedded in other ways. Although such buttons add functionality to the website they are on, cookies are set regardless of whether or not the visitor has an active Linkedin profile, or agreed to their terms and conditions. For this reason it is classified as a primarily tracking/targeting domain.", - "moreInfo": null - }, - { - "name": "UserMatchHistory", - "subgroup": "linkedin.com", - "party": "Third Party", - "description": "This domain is owned by LinkedIn, the business networking platform. It typically acts as a third party host where website owners have placed one of its content sharing buttons in their pages, although its content and services can be embedded in other ways. Although such buttons add functionality to the website they are on, cookies are set regardless of whether or not the visitor has an active Linkedin profile, or agreed to their terms and conditions. For this reason it is classified as a primarily tracking/targeting domain.", - "moreInfo": null - }, - { - "name": "lang", - "subgroup": "cdn.syndication.twimg.com", - "party": "Third Party", - "description": "This domain is owned by Twitter. The main business activity is: Social Networking Services. Where twitter acts as a third party host, it collects data through a range of plug-ins and integrations, that is primarily used for tracking and targeting. Twitter does not currently provide information on the use of specific cookies.", - "moreInfo": null - }, - { - "name": "CONSENT", - "subgroup": "google.com", - "party": "Third Party", - "description": "This domain is owned by Google Inc. Although Google is primarily known as a search engine, the company provides a diverse range of products and services. Its main source of revenue however is advertising. Google tracks users extensively both through its own products and sites, and the numerous technologies embedded into many millions of websites around the world. It uses the data gathered from most of these services to profile the interests of web users and sell advertising space to organisations based on such interest profiles as well as aligning adverts to the content on the pages where its customer's adverts appear.", - "moreInfo": null - }, - { - "name": "test_cookie", - "subgroup": "doubleclick.net", - "party": "Third Party", - "description": "This domain is owned by Doubleclick (Google). The main business activity is: Doubleclick is Googles real time bidding advertising exchange", - "moreInfo": null - }, - { - "name": "IDE", - "subgroup": "doubleclick.net", - "party": "Third Party", - "description": "This domain is owned by Doubleclick (Google). The main business activity is: Doubleclick is Googles real time bidding advertising exchange", - "moreInfo": null - }, - { - "name": "lang", - "subgroup": "ads.linkedin.com", - "party": "Third Party", - "description": "This domain is owned by LinkedIn, the business networking platform. This sub-domain is connected with LinkedIn's marketing services that enable website owners to gain insight into types of users on their site based on LinkedIn profile data, to improve targetng.", - "moreInfo": null - }, - { - "name": "bscookie", - "subgroup": "www.linkedin.com", - "party": "Third Party", - "description": "This domain is owned by LinkedIn, the business networking platform. It typically acts as a third party host where website owners have placed one of its content sharing buttons in their pages, although its content and services can be embedded in other ways. Although such buttons add functionality to the website they are on, cookies are set regardless of whether or not the visitor has an active Linkedin profile, or agreed to their terms and conditions. For this reason it is classified as a primarily tracking/targeting domain.", - "moreInfo": null - }, - { - "name": "CONSENT", - "subgroup": "youtube.com", - "party": "Third Party", - "description": "YouTube is a Google owned platform for hosting and sharing videos. YouTube collects user data through videos embedded in websites, which is aggregated with profile data from other Google services in order to display targeted advertising to web visitors across a broad range of their own and other websites.", - "moreInfo": null - }, - { - "name": "VISITOR_INFO1_LIVE", - "subgroup": "youtube.com", - "party": "Third Party", - "description": "YouTube is a Google owned platform for hosting and sharing videos. YouTube collects user data through videos embedded in websites, which is aggregated with profile data from other Google services in order to display targeted advertising to web visitors across a broad range of their own and other websites.", - "moreInfo": null - }, - { - "name": "YSC", - "subgroup": "youtube.com", - "party": "Third Party", - "description": "YouTube is a Google owned platform for hosting and sharing videos. YouTube collects user data through videos embedded in websites, which is aggregated with profile data from other Google services in order to display targeted advertising to web visitors across a broad range of their own and other websites.", - "moreInfo": null - }, - { - "name": "VISITOR_PRIVACY_METADATA", - "subgroup": "youtube.com", - "party": "Third Party", - "description": "YouTube is a Google owned platform for hosting and sharing videos. YouTube collects user data through videos embedded in websites, which is aggregated with profile data from other Google services in order to display targeted advertising to web visitors across a broad range of their own and other websites.", - "moreInfo": null - }, - { - "name": "DEVICE_INFO", - "subgroup": "youtube.com", - "party": "Third Party", - "description": "YouTube is a Google owned platform for hosting and sharing videos. YouTube collects user data through videos embedded in websites, which is aggregated with profile data from other Google services in order to display targeted advertising to web visitors across a broad range of their own and other websites.", - "moreInfo": null - }, - { - "name": "ahoy_visit", - "subgroup": "*.stackblitz.io", - "party": "Third Party", - "description": "StackBlitz.io is an application for hosting JavaScript apps and scripts", - "moreInfo": null - }, - { - "name": "ahoy_visitor", - "subgroup": "*.stackblitz.io", - "party": "Third Party", - "description": "StackBlitz.io is an application for hosting JavaScript apps and scripts", - "moreInfo": null - }, - { - "name": "nextId", - "subgroup": "www.youtube.com", - "party": "Third Party", - "description": "YouTube is a Google owned platform for hosting and sharing videos. YouTube collects user data through videos embedded in websites, which is aggregated with profile data from other Google services in order to display targeted advertising to web visitors across a broad range of their own and other websites.", - "moreInfo": null - } - ] - } - ] -} diff --git a/external/ag-website-shared/src/content/policies/cookies.mdoc b/external/ag-website-shared/src/content/policies/cookies.mdoc deleted file mode 100644 index 43e67ca11fd..00000000000 --- a/external/ag-website-shared/src/content/policies/cookies.mdoc +++ /dev/null @@ -1,39 +0,0 @@ -1. ### What is a Cookie? {% id="intro-privacy" %} - - *** - - A **"cookie"** is a piece of information that is stored on your computer's hard drive and which records how you move your way around a website so that, when you revisit that website, it can present tailored options based on the information stored about your last visit. Cookies can also be used to analyse traffic and for advertising and marketing purposes. - - Cookies are used by nearly all websites and do not harm your system. - - If you want to check or change what types of cookies you accept, this can usually be altered within your browser settings. You can block cookies at any time by activating the setting on your browser that allows you to refuse the setting of all or some cookies. However, if you use your browser settings to block all cookies (including essential cookies) you may not be able to access all or parts of our site. - -2. ### How Do We Use Cookies? {% id="cookies-how-we-use" %} - - *** - - We use cookies to track your use of our website. This enables us to understand how you use the site and track any patterns with regards how you are using our website. This helps us to develop and improve our website as well as products and / or services in response to what you might need or want. - - #### Cookies are either: - - **Session cookies:** these are only stored on your computer during your web session and are automatically deleted when you close your browser – they usually store an anonymous session ID allowing you to browse a website without having to log in to each page, but they do not collect any personal data from your computer; or - - **Persistent cookies:** a persistent cookie is stored as a file on your computer and it remains there when you close your web browser. The cookie can be read by the website that created it when you visit that website again. We use persistent cookies for Google Analytics. - - #### Cookies can also be categorised as follows: - - **Strictly necessary cookies:** These cookies are essential to enable you to use the website effectively, such as when buying a product and / or service, and therefore cannot be turned off. Without these cookies, the services available to you on our website cannot be provided. These cookies do not gather information about you that could be used for marketing or remembering where you have been on the internet. - - **Performance cookies:** These cookies enable us to monitor and improve the performance of our website. For example, they allow us to count visits, identify traffic sources and see which parts of the site are most popular. - - **Functionality cookies:** These cookies allow our website to remember choices you make and provide enhanced features. For instance, we may be able to provide you with news or updates relevant to the services you use. They may also be used to provide services you have requested such as viewing a video or commenting on a blog. The information these cookies collect is usually anonymised. - - **Targeting cookies:** These cookies may be set through our site by our advertising partners. They may be used by those companies to build a profile of your interests and show you relevant adverts on other sites. They do not store directly personal information, but are based on uniquely identifying your browser and internet device. If you do not allow these cookies, you will experience less targeted advertising. - -3. ### Third-party Cookies {% id="third-party-cookies" %} - - *** - - Some of our pages display content from external sites, e.g. YouTube, StackBlitz. The embedding of content from these sites may create third-party cookies over which we have no control. - - To view this third-party content, you may have to accept their terms and conditions. This includes their cookie policies, over which we have no control. - - If you do not view this content, no third-party cookies are installed on your device. - - Third-party providers on AG Grid website and blogs include: [StackBlitz](https://stackblitz.com/privacy-policy) , [YouTube](https://www.youtube.com/t/terms) , [Atlassian](https://www.atlassian.com/legal/cookies) , [Code Sandbox](https://codesandbox.io/legal/privacy) , [Google](https://policies.google.com/terms?hl=en&gl=be) , [LinkedIn](https://www.linkedin.com/legal/user-agreement) , [Facebook](https://www.facebook.com/legal/terms) , and [GitHub](https://docs.github.com/en/github/site-policy/github-privacy-statement). - - These third-party services are outside of the control of AG Grid. Third Parties may, at any time, change their terms of service, purpose and use of cookies, etc. diff --git a/external/ag-website-shared/src/markdown-pages/policies/buildPolicyMarkdown.ts b/external/ag-website-shared/src/markdown-pages/policies/buildPolicyMarkdown.ts index 3eee5481a69..e5dc4041833 100644 --- a/external/ag-website-shared/src/markdown-pages/policies/buildPolicyMarkdown.ts +++ b/external/ag-website-shared/src/markdown-pages/policies/buildPolicyMarkdown.ts @@ -1,13 +1,14 @@ -import type { PolicyContent, PolicyName } from '@ag-website-shared/components/policies/policyContent'; +import type { PolicyName } from '@ag-website-shared/components/policies/policyContent'; import { POLICY_CONTENT, policyHeading } from '@ag-website-shared/components/policies/policyContent'; -import cookiesData from '@ag-website-shared/content/policies/cookies-data-07-08-26.json'; import { htmlInlineToMarkdown } from '@ag-website-shared/markdoc/htmlInlineToMarkdown'; -import { markdownTable } from '@ag-website-shared/markdoc/markdownTable'; import type { MarkdocConfigLike, MarkdownResolvers } from '@ag-website-shared/markdoc/renderMarkdocToMarkdown'; import { renderMarkdocToMarkdown } from '@ag-website-shared/markdoc/renderMarkdocToMarkdown'; +/** Policies whose page renders a `.mdoc` body. `cookies` renders the Enzuzo embed instead. */ +export type MdocPolicyName = Exclude; + export interface BuildPolicyMarkdownOptions { - policy: PolicyName; + policy: MdocPolicyName; /** Product name substituted into the heading, e.g. `AG Grid`. */ name: string; /** Raw `.mdoc` source for the policy body. Import it with Vite's `?raw` suffix. */ @@ -51,38 +52,43 @@ export async function buildPolicyMarkdown({ const policyBody = renderedBody.replace(/^---\n[\s\S]*?\n---\n+/, '').trim(); const document = [ - [ - '---', - `title: ${JSON.stringify(`${name}: ${content.metaTitle}`)}`, - `description: ${JSON.stringify(content.description)}`, - '---', - ].join('\n'), + frontmatter(policy, name), `# ${heading}`, ...content.meta.map((line) => htmlInlineToMarkdown(line, siteRoot)), ...content.intro.map((line) => htmlInlineToMarkdown(line, siteRoot)), policyBody, - ...cookiesInventory(content.cookiesSection), ].filter(Boolean); return `${document.join('\n\n').trimEnd()}\n`; } /** - * The cookie inventory the page renders below the policy body via `CookiesTable` (AG-18105), as one - * markdown table per category. Reads the same JSON the component does, so the two cannot drift. - * Returns nothing for policies with no such section. + * The `/cookies.md` twin. The cookies page renders the Enzuzo embed, which builds the policy in the + * browser from an automated scan (AG-18194), so there is no source this can re-render as markdown + * the way the other policies' twins re-render their `.mdoc`. Point readers at the page instead of + * fetching the embed at build time, which would make the build depend on a third-party request. */ -function cookiesInventory(section: PolicyContent['cookiesSection']): string[] { - if (!section) { - return []; - } - const categories = cookiesData.categories.flatMap(({ name, description, cookies }) => { - // Mirrors the component: a few entries cover a whole domain rather than a named cookie. - const rows = cookies.map(({ name: cookieName, subgroup, party, moreInfo }) => { - const label = cookieName ?? '—'; - return [subgroup, moreInfo ? `[${label}](${moreInfo})` : label, party]; - }); - return [`### ${name}`, description, markdownTable(['Cookie Subgroup', 'Cookies', 'Cookies used'], rows)]; - }); - return [`## ${section.heading}`, section.note, ...categories].filter(Boolean); +export function buildCookiesMarkdown({ name, siteRoot }: { name: string; siteRoot?: string }): string { + const policy = 'cookies'; + const url = `${(siteRoot ?? '/').replace(/\/$/, '')}/cookies/`; + + const document = [ + frontmatter(policy, name), + `# ${policyHeading(policy, name)}`, + `${POLICY_CONTENT[policy].description} It is generated from our consent-management platform, which scans the site for the cookies actually in use, and is published in full at [${url}](${url}).`, + ]; + + return `${document.join('\n\n').trimEnd()}\n`; +} + +/** The frontmatter block every policy twin opens with, from the copy shared with its page. */ +function frontmatter(policy: PolicyName, name: string): string { + const content = POLICY_CONTENT[policy]; + + return [ + '---', + `title: ${JSON.stringify(`${name}: ${content.metaTitle}`)}`, + `description: ${JSON.stringify(content.description)}`, + '---', + ].join('\n'); } diff --git a/external/ag-website-shared/src/utils/fetchRuntimeFiles.ts b/external/ag-website-shared/src/utils/fetchRuntimeFiles.ts new file mode 100644 index 00000000000..66c22f8a6eb --- /dev/null +++ b/external/ag-website-shared/src/utils/fetchRuntimeFiles.ts @@ -0,0 +1,9 @@ +import { fetchTextFile } from '@utils/fetchTextFile'; + +export const fetchRuntimeFiles = async (urls?: Record): Promise> => { + const entries = await Promise.all( + Object.entries(urls ?? {}).map(async ([fileName, url]) => [fileName, await fetchTextFile(url)] as const) + ); + + return Object.fromEntries(entries); +}; diff --git a/external/ag-website-shared/src/utils/getSitemapXml.test.ts b/external/ag-website-shared/src/utils/getSitemapXml.test.ts new file mode 100644 index 00000000000..ff4095fc438 --- /dev/null +++ b/external/ag-website-shared/src/utils/getSitemapXml.test.ts @@ -0,0 +1,44 @@ +import { vi } from 'vitest'; + +import { BUILD_USER_AGENT } from '../constants'; +import { getSitemapXml } from './getSitemapXml'; + +const SITEMAP_URL = 'https://www.ag-grid.com/sitemap-0.xml'; +const SITEMAP_XML = 'https://www.ag-grid.com/'; +// A cache folder that does not exist, so every case here takes the "fetch from the live site" path +// — the one a production build hits after `--clean-cache=true`. +const MISSING_CACHE_DIR = '.astro/cache/sitemap-does-not-exist'; + +const logger = { info: vi.fn(), warn: vi.fn(), log: vi.fn() }; + +const fetchSitemap = () => + getSitemapXml({ cacheDir: MISSING_CACHE_DIR, sitemapUrl: SITEMAP_URL, logger, gitHash: 'test-hash' }); + +describe('getSitemapXml', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('identifies the build in the User-Agent', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => SITEMAP_XML }); + vi.stubGlobal('fetch', fetchMock); + + await expect(fetchSitemap()).resolves.toBe(SITEMAP_XML); + + expect(fetchMock).toHaveBeenCalledWith(SITEMAP_URL, { headers: { 'User-Agent': BUILD_USER_AGENT } }); + }); + + test('throws on a failed request rather than using the error page as the sitemap', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + text: async () => '

ERROR

', + }) + ); + + await expect(fetchSitemap()).rejects.toThrow(`Failed to fetch sitemap ${SITEMAP_URL}: 503 Service Unavailable`); + }); +}); diff --git a/external/ag-website-shared/src/utils/getSitemapXml.ts b/external/ag-website-shared/src/utils/getSitemapXml.ts index 238e45e5b41..985718ec4e7 100644 --- a/external/ag-website-shared/src/utils/getSitemapXml.ts +++ b/external/ag-website-shared/src/utils/getSitemapXml.ts @@ -1,6 +1,7 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; +import { BUILD_USER_AGENT } from '../constants'; import { getGitHash } from './gitUtils'; type Logger = Pick; @@ -55,7 +56,12 @@ export const getSitemapXml = async ({ } if (xmlSitemap == null) { - const response = await fetch(sitemapUrl); + const response = await fetch(sitemapUrl, { headers: { 'User-Agent': BUILD_USER_AGENT } }); + if (!response.ok) { + // Without this the error response body is used as the sitemap, silently producing a + // broken `/sitemap` page instead of failing the build. + throw new Error(`Failed to fetch sitemap ${sitemapUrl}: ${response.status} ${response.statusText}`); + } xmlSitemap = await response.text(); logger.log(`⚠️ No cached sitemap found, fetched from live site: ${sitemapUrl}`); } diff --git a/external/ag-website-shared/vitest.config.ts b/external/ag-website-shared/vitest.config.ts index 09ad4c2ca25..6e925466a9d 100644 --- a/external/ag-website-shared/vitest.config.ts +++ b/external/ag-website-shared/vitest.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', + pool: 'threads', include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], reporters: ['default'], coverage: { reportsDirectory: '../../coverage/ag-website-shared', provider: 'v8' }, diff --git a/nx.json b/nx.json index 2734c6ea751..0c1682c734e 100644 --- a/nx.json +++ b/nx.json @@ -62,7 +62,7 @@ "externalDependencies": ["npm:typescript", "npm:esbuild"] } ], - "sharedGlobals": ["{workspaceRoot}/esbuild.config*.cjs", "{workspaceRoot}/tsconfig.*.json"], + "sharedGlobals": ["{workspaceRoot}/esbuild*.cjs", "{workspaceRoot}/tsconfig.*.json"], "charts": ["chartsPackages", "chartsTypes"], "chartsPackages": [ { diff --git a/package.json b/package.json index 083c7097728..ea89a2f3702 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid", - "version": "36.1.0-beta.20260817.956", + "version": "36.1.0-beta.20260818.1041", "license": "MIT", "scripts": { "compressVideo": "tsx external/ag-website-shared/scripts/compress-video", @@ -54,9 +54,9 @@ "@types/prompts": "^2.4.9", "@typescript-eslint/eslint-plugin": "8.59.0", "@typescript-eslint/parser": "8.59.0", - "@vitest/browser": "2.1.9", - "@vitest/coverage-v8": "2.1.9", - "@vitest/ui": "2.1.9", + "@vitest/browser-playwright": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "@vue/compiler-sfc": "3.5.33", "JSONStream": "1.3.5", "autoprefixer": "^10.5.0", @@ -74,6 +74,7 @@ "event-stream": "4.0.1", "glob": "^11.1.0", "globals": "^17.5.0", + "happy-dom": "20.11.2", "knip": "6.6.2", "node-fetch": "2.7.0", "nx": "20.8.4", @@ -94,7 +95,7 @@ "typescript": "~5.8.3", "typescript-eslint": "^8.58.2", "vite": "~5.4.19", - "vitest": "2.1.9" + "vitest": "4.1.10" }, "workspaces": { "packages": [ @@ -111,6 +112,7 @@ "plugins/ag-grid-generate-code-reference-files", "plugins/ag-grid-generate-example-files", "plugins/ag-grid-task-autogen", + "testing/ag-test-utils", "testing/behavioural", "testing/typedoc-links", "testing/module-size", @@ -148,8 +150,7 @@ "tsx>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-angular/package.json b/packages/ag-grid-angular/package.json index 1b79c610fab..024e5a09010 100644 --- a/packages/ag-grid-angular/package.json +++ b/packages/ag-grid-angular/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-angular", - "version": "36.1.0-beta.20260817.956", + "version": "36.1.0-beta.20260818.1041", "description": "AG Grid Angular Component", "scripts": { "clean": "rimraf dist", @@ -15,7 +15,7 @@ "module": "./dist/ag-grid-angular/fesm2022/ag-grid-angular.mjs", "typings": "./dist/ag-grid-angular/index.d.ts", "dependencies": { - "ag-grid-community": "36.1.0-beta.20260817.956", + "ag-grid-community": "36.1.0-beta.20260818.1041", "@angular/animations": "^20.3.25", "@angular/common": "^20.3.25", "@angular/compiler": "^20.3.25", @@ -27,7 +27,7 @@ "zone.js": "~0.15.1" }, "devDependencies": { - "ag-grid-community": "36.1.0-beta.20260817.956", + "ag-grid-community": "36.1.0-beta.20260818.1041", "@angular/build": "^20.3.25", "@angular/cli": "^20.3.25", "@angular/forms": "^20.3.25", diff --git a/packages/ag-grid-angular/projects/ag-grid-angular/package.json b/packages/ag-grid-angular/projects/ag-grid-angular/package.json index bb9e92bd59d..8ea6837418c 100644 --- a/packages/ag-grid-angular/projects/ag-grid-angular/package.json +++ b/packages/ag-grid-angular/projects/ag-grid-angular/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-angular", - "version": "36.1.0-beta.20260817.956", + "version": "36.1.0-beta.20260818.1041", "description": "AG Grid Angular Component", "license": "MIT", "peerDependencies": { @@ -8,7 +8,7 @@ "@angular/core": ">= 20.0.0" }, "dependencies": { - "ag-grid-community": "36.1.0-beta.20260817.956", + "ag-grid-community": "36.1.0-beta.20260818.1041", "tslib": "^2.8.1" }, "repository": { diff --git a/packages/ag-grid-angular/projects/ag-grid-angular/src/lib/ag-grid-angular.component.ts b/packages/ag-grid-angular/projects/ag-grid-angular/src/lib/ag-grid-angular.component.ts index c48326f8175..fc503898968 100644 --- a/packages/ag-grid-angular/projects/ag-grid-angular/src/lib/ag-grid-angular.component.ts +++ b/packages/ag-grid-angular/projects/ag-grid-angular/src/lib/ag-grid-angular.component.ts @@ -1000,6 +1000,15 @@ export class AgGridAngular = ColDef = new Set([ + 'button', + 'checkbox', + 'file', + 'hidden', + 'radio', + 'range', + 'reset', + 'submit', +]); + function buildTemplate( displayFieldTag: keyof HTMLElementTagNameMap ): AgElementParams { @@ -51,6 +62,7 @@ export abstract class AgAbstractInputField< protected readonly eLabel: HTMLElement = RefPlaceholder; protected readonly eWrapper: HTMLElement = RefPlaceholder; protected readonly eInput: TElement = RefPlaceholder; + private autoCompleteOverride: boolean | string | undefined; constructor( config?: TConfig, @@ -85,7 +97,10 @@ export abstract class AgAbstractInputField< } if (autoComplete != null) { this.setAutoComplete(autoComplete); + } else { + this.refreshAutoComplete(); } + this.addManagedPropertyListener('enableInputAutoComplete', () => this.refreshAutoComplete()); this.addInputListeners(); this.activateTabIndex([eInput], tabIndex); @@ -154,7 +169,35 @@ export abstract class AgAbstractInputField< return super.setDisabled(disabled); } - public setAutoComplete(value: boolean | string) { + public setAutoComplete(value?: boolean | string): this { + this.autoCompleteOverride = value; + if (value == null) { + this.refreshAutoComplete(); + } else { + this.applyAutoComplete(value); + } + return this; + } + + private refreshAutoComplete(): void { + if (this.autoCompleteOverride == null && this.isAutoCompleteCapableField()) { + this.applyAutoComplete(this.gos.get('enableInputAutoComplete') === true); + } + } + + private isAutoCompleteCapableField(): boolean { + const { eInput } = this; + if (eInput.tagName === 'TEXTAREA') { + return true; + } + if (eInput.tagName !== 'INPUT') { + return false; + } + const { type } = eInput as HTMLInputElement; + return !NON_AUTOCOMPLETE_INPUT_TYPES.has(type); + } + + private applyAutoComplete(value: boolean | string): void { if (value === true) { // Remove the autocomplete attribute if the value is explicitly set to true // to allow the default browser autocomplete/autofill behaviour. @@ -166,6 +209,5 @@ export abstract class AgAbstractInputField< const autoCompleteValue = typeof value === 'string' ? value : 'off'; _addOrRemoveAttribute(this.eInput, 'autocomplete', autoCompleteValue); } - return this; } } diff --git a/packages/ag-grid-community/src/agWidgets/agFieldParams.ts b/packages/ag-grid-community/src/agWidgets/agFieldParams.ts index 2f483fc237e..3267e43037f 100644 --- a/packages/ag-grid-community/src/agWidgets/agFieldParams.ts +++ b/packages/ag-grid-community/src/agWidgets/agFieldParams.ts @@ -27,7 +27,7 @@ export interface AgInputFieldParams exten inputWidth?: number | 'flex'; template?: AgElementParams; inputPlaceholder?: string; - autoComplete?: boolean; + autoComplete?: boolean | string; tabIndex?: number; } diff --git a/packages/ag-grid-community/src/agWidgets/agInputTextField.ts b/packages/ag-grid-community/src/agWidgets/agInputTextField.ts index 5ed6f77fc22..c95f8500511 100644 --- a/packages/ag-grid-community/src/agWidgets/agInputTextField.ts +++ b/packages/ag-grid-community/src/agWidgets/agInputTextField.ts @@ -5,20 +5,32 @@ import type { BaseProperties, IPropertiesService, } from 'ag-stack'; -import { _exists, _isEventFromPrintableCharacter, _setAriaInvalid } from 'ag-stack'; +import { + _createAgElement, + _exists, + _isEventFromPrintableCharacter, + _setAriaInvalid, + _setAriaLabel, + _setDisplayed, +} from 'ag-stack'; import type { AgAbstractInputFieldEvent } from './agAbstractInputField'; import { AgAbstractInputField } from './agAbstractInputField'; import type { AgInputFieldParams } from './agFieldParams'; import type { AgWidgetSelectorType } from './agWidgetSelectorType'; +// date inputs retain their browser-provided clear control; these types need the grid-provided button. +const CUSTOM_CLEAR_BUTTON_INPUT_TYPES: ReadonlySet = new Set(['number', 'text']); + /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export interface AgInputTextFieldParams< TComponentSelectorType extends string, > extends AgInputFieldParams { allowedCharPattern?: string; + clearButton?: boolean; + onValueClear?: () => void; } -export type AgInputTextFieldEvent = AgAbstractInputFieldEvent; +export type AgInputTextFieldEvent = AgAbstractInputFieldEvent | 'fieldValueCleared'; /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export class AgInputTextField< TBeanCollection extends AgCoreBeanCollection, @@ -41,6 +53,9 @@ export class AgInputTextField< TConfig, AgInputTextFieldEvent | TEventType > { + private eClearButton: HTMLButtonElement | undefined; + private clearButtonEnabled: boolean = false; + constructor(config?: TConfig, className = 'ag-text-field', inputType = 'text') { super(config, className, inputType); } @@ -48,9 +63,23 @@ export class AgInputTextField< public override postConstruct() { super.postConstruct(); - if (this.config.allowedCharPattern) { + const { allowedCharPattern, clearButton, onValueClear } = this.config; + + if (allowedCharPattern) { this.preventDisallowedCharacters(); } + if (clearButton) { + this.setClearButtonEnabled(true); + } + if (onValueClear) { + this.onValueClear(onValueClear); + } + this.addManagedPropertyListener('suppressInputClearButton', () => this.refreshClearButton()); + } + + public override setInputType(inputType?: string): void { + super.setInputType(inputType); + this.refreshClearButton(); } public override setValue(value?: string | null, silent?: boolean): this { @@ -59,10 +88,31 @@ export class AgInputTextField< if (eInput.value !== value) { eInput.value = _exists(value) ? value : ''; } + this.refreshClearButton(); return super.setValue(value, silent); } + public setClearButtonEnabled(enabled: boolean): this { + this.clearButtonEnabled = enabled; + if (enabled && !this.eClearButton) { + this.createClearButton(); + } + this.refreshClearButton(); + return this; + } + + public onValueClear(callbackFn: () => void): this { + this.addManagedListeners(this, { fieldValueCleared: callbackFn }); + return this; + } + + public override setDisabled(disabled: boolean): this { + super.setDisabled(disabled); + this.refreshClearButton(); + return this; + } + /** Used to set an initial value into the input without necessarily setting `this.value` or triggering events (e.g. to set an invalid value) */ public setStartValue(value?: string | null): void { this.setValue(value, true); @@ -82,6 +132,53 @@ export class AgInputTextField< _setAriaInvalid(eInput, isInvalid); } + private clearInput(): void { + const { eInput } = this; + eInput.focus(); + if (!eInput.value) { + return; + } + + // silent, so consumers get exactly one notification per clear: fieldValueCleared + this.setValue('', true); + this.dispatchLocalEvent({ type: 'fieldValueCleared' }); + } + + private createClearButton(): void { + const eClearButton = _createAgElement({ + tag: 'button', + cls: 'ag-input-field-clear-button', + attrs: { type: 'button', tabindex: '-1' }, + }); + + const clearIcon = this.beans.iconSvc.createIconNoSpan('cancel'); + if (clearIcon) { + eClearButton.appendChild(clearIcon); + } + _setAriaLabel(eClearButton, this.getLocaleTextFunc()('ariaLabelInputClear', 'Clear')); + this.addManagedElementListeners(eClearButton, { + mousedown: (event: MouseEvent) => event.preventDefault(), + click: () => this.clearInput(), + }); + this.eWrapper.appendChild(eClearButton); + this.eClearButton = eClearButton; + } + + private refreshClearButton(): void { + const { eClearButton, eInput } = this; + if (!eClearButton || !eInput) { + return; + } + const supportsClearButton = + this.clearButtonEnabled && + !this.gos.get('suppressInputClearButton') && + CUSTOM_CLEAR_BUTTON_INPUT_TYPES.has(eInput.type); + + const canDisplay = supportsClearButton && !this.isDisabled(); + eInput.classList.toggle('ag-input-field-input-with-clear-button', canDisplay); + _setDisplayed(eClearButton, canDisplay && !!eInput.value); + } + private preventDisallowedCharacters(): void { const pattern = new RegExp(`[${this.config.allowedCharPattern}]`); 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/entities/gridOptions.ts b/packages/ag-grid-community/src/entities/gridOptions.ts index 5177cb387a2..7529700ec09 100644 --- a/packages/ag-grid-community/src/entities/gridOptions.ts +++ b/packages/ag-grid-community/src/entities/gridOptions.ts @@ -951,6 +951,17 @@ export interface GridOptions { * @initial */ tabIndex?: number; + /** + * Set to `true` to hide the clear button shown in supported input fields when they contain a value. + * @default false + */ + suppressInputClearButton?: boolean; + /** + * Set to `true` to enable the browser's autocomplete/autofill behaviour for eligible grid input fields. + * Inputs that provide grid-owned suggestions, such as Rich Select and Advanced Filter inputs, keep browser autocomplete disabled. + * @default false + */ + enableInputAutoComplete?: boolean; /** * The number of rows rendered outside the viewable area the grid renders. * Having a buffer means the grid will have rows ready to show as the user slowly scrolls vertically. diff --git a/packages/ag-grid-community/src/filter/floating/provided/floatingFilterTextInputService.ts b/packages/ag-grid-community/src/filter/floating/provided/floatingFilterTextInputService.ts index 179f6a7282f..908f7d5eec7 100644 --- a/packages/ag-grid-community/src/filter/floating/provided/floatingFilterTextInputService.ts +++ b/packages/ag-grid-community/src/filter/floating/provided/floatingFilterTextInputService.ts @@ -10,13 +10,20 @@ import type { FloatingFilterInputService } from './iFloatingFilterInputService'; export class FloatingFilterTextInputService extends BeanStub implements FloatingFilterInputService { private eInput: GridInputTextField = RefPlaceholder; private onValueChanged: (e: KeyboardEvent) => void = () => {}; + private onValueCleared: () => void = () => {}; constructor(private readonly params?: { config?: AgInputTextFieldParams }) { super(); } public setupGui(parentElement: HTMLElement): void { - this.eInput = this.createManagedBean(new AgInputTextField(this.params?.config)); + this.eInput = this.createManagedBean( + new AgInputTextField({ + ...this.params?.config, + clearButton: true, + onValueClear: () => this.onValueCleared(), + }) + ); const eInput = this.eInput.getGui(); @@ -49,6 +56,10 @@ export class FloatingFilterTextInputService extends BeanStub implements Floating this.onValueChanged = listener; } + public setValueClearedListener(listener: () => void): void { + this.onValueCleared = listener; + } + public setParams({ ariaLabel, autoComplete, @@ -61,9 +72,7 @@ export class FloatingFilterTextInputService extends BeanStub implements Floating const { eInput } = this; eInput.setInputAriaLabel(ariaLabel); - if (autoComplete !== undefined) { - eInput.setAutoComplete(autoComplete); - } + eInput.setAutoComplete(autoComplete); eInput.toggleCss('ag-floating-filter-search-icon', !!placeholder); eInput.setInputPlaceholder(placeholder); diff --git a/packages/ag-grid-community/src/filter/floating/provided/iFloatingFilterInputService.ts b/packages/ag-grid-community/src/filter/floating/provided/iFloatingFilterInputService.ts index 3006486e7a1..f1f5cfef1fa 100644 --- a/packages/ag-grid-community/src/filter/floating/provided/iFloatingFilterInputService.ts +++ b/packages/ag-grid-community/src/filter/floating/provided/iFloatingFilterInputService.ts @@ -7,5 +7,6 @@ export interface FloatingFilterInputService extends Bean { getValue(): string | null | undefined; setValue(value: string | null | undefined, silent?: boolean): void; setValueChangedListener(listener: (e: KeyboardEvent) => void): void; + setValueClearedListener(listener: () => void): void; setParams(params: { ariaLabel: string; autoComplete?: boolean | string; placeholder?: string }): void; } diff --git a/packages/ag-grid-community/src/filter/floating/provided/textInputFloatingFilter.ts b/packages/ag-grid-community/src/filter/floating/provided/textInputFloatingFilter.ts index 36733579ee7..d2d189416b6 100644 --- a/packages/ag-grid-community/src/filter/floating/provided/textInputFloatingFilter.ts +++ b/packages/ag-grid-community/src/filter/floating/provided/textInputFloatingFilter.ts @@ -80,7 +80,7 @@ export abstract class TextInputFloatingFilter< inputSvc.setParams({ ariaLabel: this.getAriaLabel(column as AgColumn), - autoComplete: browserAutoComplete ?? false, + autoComplete: browserAutoComplete, placeholder, }); @@ -89,9 +89,14 @@ export abstract class TextInputFloatingFilter< if (!readOnly) { const debounceMs = getDebounceMs(this.beans.log, filterParams as TextFilterParams, defaultDebounceMs); const debouncedSync = _debounce(this, this.syncUpWithParentFilter.bind(this), debounceMs); + let debounceTimeout: number | undefined; inputSvc.setValueChangedListener((e) => { this.pendingEdit = true; - debouncedSync(e); + debounceTimeout = debouncedSync(e); + }); + inputSvc.setValueClearedListener(() => { + clearTimeout(debounceTimeout); + this.syncUpWithParentFilter(); }); } } @@ -110,8 +115,8 @@ export abstract class TextInputFloatingFilter< inputSvc.setValue(value, true); } - private syncUpWithParentFilter(e: KeyboardEvent): void { - const isEnterKey = e.key === KeyCode.ENTER; + private syncUpWithParentFilter(e?: KeyboardEvent): void { + const isEnterKey = e?.key === KeyCode.ENTER; const reactive = this.reactive; if (reactive) { diff --git a/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilter.ts b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilter.ts index b7327923373..4c5e2fff4c9 100644 --- a/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilter.ts @@ -153,7 +153,7 @@ export class BigIntFilter extends SimpleFilter< allowedCharPattern: string | null ): GridInputTextField { const eValue = this.createManagedBean( - allowedCharPattern ? new AgInputTextField({ allowedCharPattern }) : new AgInputTextField() + new AgInputTextField({ allowedCharPattern: allowedCharPattern ?? undefined, clearButton: true }) ); eValue.addCss(`ag-filter-${fromTo}`); eValue.addCss('ag-filter-filter'); 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/filter/provided/date/dateFilter.ts b/packages/ag-grid-community/src/filter/provided/date/dateFilter.ts index ede52a5ff28..bb45a8137e0 100644 --- a/packages/ag-grid-community/src/filter/provided/date/dateFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/date/dateFilter.ts @@ -146,6 +146,10 @@ export class DateFilter extends SimpleFilter { + this.refreshInputPairValidation(position, isFrom, 'immediate'); + this.onUiCleared(); + }, onFocusIn: () => this.refreshInputPairValidation(position, isFrom, 'debounceIfChanged'), filterParams: params as any, location: 'filter', diff --git a/packages/ag-grid-community/src/filter/provided/date/dateFloatingFilter.ts b/packages/ag-grid-community/src/filter/provided/date/dateFloatingFilter.ts index 7e9d73e0b89..96d2f83d84f 100644 --- a/packages/ag-grid-community/src/filter/provided/date/dateFloatingFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/date/dateFloatingFilter.ts @@ -10,7 +10,7 @@ import type { FloatingFilterDisplayParams, IFloatingFilterParams } from '../../f import { SimpleFloatingFilter } from '../../floating/provided/simpleFloatingFilter'; import type { ISimpleFilterModel } from '../iSimpleFilter'; import type { OptionsFactory } from '../optionsFactory'; -import { getDebounceMs } from '../providedFilterUtils'; +import { _isUseApplyButton, getDebounceMs } from '../providedFilterUtils'; import { DateCompWrapper } from './dateCompWrapper'; import type { DateFilter } from './dateFilter'; import { DEFAULT_DATE_FILTER_OPTIONS } from './dateFilterConstants'; @@ -120,8 +120,18 @@ export class DateFloatingFilter extends SimpleFloatingFilter { + debounceTimeout = debouncedDateChanged(); + }, + onDateCleared: _isUseApplyButton(filterParams as DateFilterParams) + ? undefined + : () => { + clearTimeout(debounceTimeout); + this.onDateChanged(); + }, filterParams, location: 'floatingFilter', }); diff --git a/packages/ag-grid-community/src/filter/provided/date/defaultDateComponent.ts b/packages/ag-grid-community/src/filter/provided/date/defaultDateComponent.ts index 30bf5d8b66e..d5b62ff2fe9 100644 --- a/packages/ag-grid-community/src/filter/provided/date/defaultDateComponent.ts +++ b/packages/ag-grid-community/src/filter/provided/date/defaultDateComponent.ts @@ -34,6 +34,7 @@ export class DefaultDateComponent extends Component implements IDateComp { public init(params: IDateParams): void { this.params = params; + this.eDateInput.setClearButtonEnabled(true).onValueClear(() => this.params.onDateCleared?.()); this.setParams(params); const inputElement = this.eDateInput.getInputElement(); @@ -41,7 +42,7 @@ export class DefaultDateComponent extends Component implements IDateComp { this.addManagedListeners(inputElement, { // ensures that the input element is focussed when a clear button is clicked, // unless using safari as there is no clear button and focus does not work properly - mouseDown: () => { + mousedown: () => { if (this.eDateInput.isDisabled() || this.usingSafariDatePicker) { return; } @@ -101,13 +102,13 @@ export class DefaultDateComponent extends Component implements IDateComp { if (shouldUseBrowserDatePicker) { if (shouldUseDateTimeLocal) { - inputElement.type = 'datetime-local'; + this.eDateInput.setInputType('datetime-local'); inputElement.step = '1'; // enforce seconds part to show up by default } else { - inputElement.type = 'date'; + this.eDateInput.setInputType('date'); } } else { - inputElement.type = 'text'; + this.eDateInput.setInputType('text'); } const parsedMinValidDate = parseOrConstructDate(this.beans.log, minValidDate, minValidYear, true); const parsedMaxValidDate = parseOrConstructDate(this.beans.log, maxValidDate, maxValidYear, false); diff --git a/packages/ag-grid-community/src/filter/provided/number/numberFilter.ts b/packages/ag-grid-community/src/filter/provided/number/numberFilter.ts index 374d51da3d5..7bcfc7c1f17 100644 --- a/packages/ag-grid-community/src/filter/provided/number/numberFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/number/numberFilter.ts @@ -156,7 +156,9 @@ export class NumberFilter extends SimpleFilter< allowedCharPattern: string | null ): GridInputTextField | GridInputNumberField { const eValue = this.createManagedBean( - allowedCharPattern ? new AgInputTextField({ allowedCharPattern }) : new AgInputNumberField() + allowedCharPattern + ? new AgInputTextField({ allowedCharPattern, clearButton: true }) + : new AgInputNumberField({ clearButton: true }) ); eValue.addCss(`ag-filter-${fromTo}`); eValue.addCss('ag-filter-filter'); diff --git a/packages/ag-grid-community/src/filter/provided/number/numberFloatingFilter.ts b/packages/ag-grid-community/src/filter/provided/number/numberFloatingFilter.ts index 9598b0c14f3..9f9f2b1425f 100644 --- a/packages/ag-grid-community/src/filter/provided/number/numberFloatingFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/number/numberFloatingFilter.ts @@ -22,12 +22,17 @@ class FloatingFilterNumberInputService extends BeanStub implements FloatingFilte private eTextInput: GridInputTextField; private eNumberInput: GridInputNumberField; private onValueChanged: (e: KeyboardEvent) => void = () => {}; + private onValueCleared: () => void = () => {}; private numberInputActive = true; public setupGui(parentElement: HTMLElement): void { - this.eNumberInput = this.createManagedBean(new AgInputNumberField()); - this.eTextInput = this.createManagedBean(new AgInputTextField()); + this.eNumberInput = this.createManagedBean( + new AgInputNumberField({ clearButton: true, onValueClear: () => this.onValueCleared() }) + ); + this.eTextInput = this.createManagedBean( + new AgInputTextField({ clearButton: true, onValueClear: () => this.onValueCleared() }) + ); this.eTextInput.setDisabled(true); @@ -47,7 +52,7 @@ class FloatingFilterNumberInputService extends BeanStub implements FloatingFilte this.eTextInput.setDisplayed(!this.numberInputActive); } - public setAutoComplete(autoComplete: boolean | string): void { + public setAutoComplete(autoComplete?: boolean | string): void { this.eNumberInput.setAutoComplete(autoComplete); this.eTextInput.setAutoComplete(autoComplete); } @@ -72,6 +77,10 @@ class FloatingFilterNumberInputService extends BeanStub implements FloatingFilte this.onValueChanged = listener; } + public setValueClearedListener(listener: () => void): void { + this.onValueCleared = listener; + } + private setupListeners(element: HTMLElement, listener: (e: KeyboardEvent) => void): void { this.addManagedListeners(element, { input: listener, @@ -90,9 +99,7 @@ class FloatingFilterNumberInputService extends BeanStub implements FloatingFilte }): void { this.setAriaLabel(ariaLabel); - if (autoComplete !== undefined) { - this.setAutoComplete(autoComplete); - } + this.setAutoComplete(autoComplete); this.setPlaceholder(this.eNumberInput, placeholder); this.setPlaceholder(this.eTextInput, placeholder); diff --git a/packages/ag-grid-community/src/filter/provided/providedFilter.ts b/packages/ag-grid-community/src/filter/provided/providedFilter.ts index a213562c387..d422fc4175a 100644 --- a/packages/ag-grid-community/src/filter/provided/providedFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/providedFilter.ts @@ -288,12 +288,17 @@ export abstract class ProvidedFilter< apply ??= applyActive ? undefined : 'debounce'; if (apply === 'immediately') { + this.debouncePending = false; this.doApplyModel({ afterFloatingFilter, afterDataChange: false }); } else if (apply === 'debounce') { this.applyDebounced(); } } + protected onUiCleared(): void { + this.onUiChanged(this.applyActive ? 'prevent' : 'immediately'); + } + protected getState(): any { return undefined; } diff --git a/packages/ag-grid-community/src/filter/provided/simpleFilter.ts b/packages/ag-grid-community/src/filter/provided/simpleFilter.ts index b84b8d21fc5..074f969ebec 100644 --- a/packages/ag-grid-community/src/filter/provided/simpleFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/simpleFilter.ts @@ -9,6 +9,7 @@ import { } from 'ag-stack'; import { AgAbstractInputField } from '../../agWidgets/agAbstractInputField'; +import { AgInputTextField } from '../../agWidgets/agInputTextField'; import type { ListOption } from '../../agWidgets/agList'; import { AgRadioButton } from '../../agWidgets/agRadioButton'; import { AgSelect } from '../../agWidgets/agSelect'; @@ -654,6 +655,9 @@ export abstract class SimpleFilter< if (element instanceof AgAbstractInputField) { element.onValueChange(listener); } + if (element instanceof AgInputTextField) { + element.onValueClear(() => this.onUiCleared()); + } } protected forEachInput(cb: (element: E, index: number, position: number, numberOfInputs: number) => void): void { diff --git a/packages/ag-grid-community/src/filter/provided/text/iTextFilter.ts b/packages/ag-grid-community/src/filter/provided/text/iTextFilter.ts index 8394a6c3cd2..1842001f33e 100644 --- a/packages/ag-grid-community/src/filter/provided/text/iTextFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/text/iTextFilter.ts @@ -101,8 +101,8 @@ export interface ITextInputFloatingFilterParams extends IFloatingFilterParams(new AgInputTextField()); + const eValue = this.createManagedBean(new AgInputTextField({ clearButton: true })); eValue.addCss(`ag-filter-${fromTo}`); eValue.addCss('ag-filter-filter'); eValues.push(eValue); 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/gridComp/gridCtrl.ts b/packages/ag-grid-community/src/gridComp/gridCtrl.ts index 3e2b3d865c5..511e95b2140 100644 --- a/packages/ag-grid-community/src/gridComp/gridCtrl.ts +++ b/packages/ag-grid-community/src/gridComp/gridCtrl.ts @@ -2,6 +2,7 @@ import { Direction, _findTabbableParent, _focusInto, + _focusIntoTabbableFirst, _getActiveDomElement, _last, _observeIntersection, @@ -38,7 +39,7 @@ export interface OptionalGridComponents { } const focusContainer = (comp: FocusableContainer, up?: boolean): boolean => { - return _runWithContainerFocusAllowed(comp, () => _focusInto(comp.getGui(), up, false, true)); + return _runWithContainerFocusAllowed(comp, () => _focusIntoTabbableFirst(comp.getGui(), up, true)); }; const getGridContainerName = (container?: FocusableContainer): GridContainerName => { diff --git a/packages/ag-grid-community/src/gridOptionsDefault.ts b/packages/ag-grid-community/src/gridOptionsDefault.ts index d5a329b0d4a..654d885bc4e 100644 --- a/packages/ag-grid-community/src/gridOptionsDefault.ts +++ b/packages/ag-grid-community/src/gridOptionsDefault.ts @@ -65,6 +65,8 @@ export const GRID_OPTION_DEFAULTS = { keepDetailRowsCount: 10, detailRowAutoHeight: false, tabIndex: 0, + suppressInputClearButton: false, + enableInputAutoComplete: false, rowBuffer: 10, stickyRowsMaxViewportRatio: 0.5, valueCache: false, diff --git a/packages/ag-grid-community/src/interfaces/dateComponent.ts b/packages/ag-grid-community/src/interfaces/dateComponent.ts index 52256036c9d..be3e1612c78 100644 --- a/packages/ag-grid-community/src/interfaces/dateComponent.ts +++ b/packages/ag-grid-community/src/interfaces/dateComponent.ts @@ -54,6 +54,8 @@ export interface BaseDateParams extends AgGridCommo export interface IDateParams extends BaseDateParams { /** Method for component to tell AG Grid that the date has changed. */ onDateChanged: () => void; + /** Method for the provided component to tell AG Grid that its input was cleared. */ + onDateCleared?: () => void; } export interface IDateComp extends IComponent, IDate {} diff --git a/packages/ag-grid-community/src/propertyKeys.ts b/packages/ag-grid-community/src/propertyKeys.ts index d4c07072da9..183b55ece70 100644 --- a/packages/ag-grid-community/src/propertyKeys.ts +++ b/packages/ag-grid-community/src/propertyKeys.ts @@ -202,6 +202,8 @@ export const _BOOLEAN_MIXED_GRID_OPTIONS: KeysWithType[] = [ // Used in validations to check type of pure boolean inputs export const _BOOLEAN_GRID_OPTIONS: KeysWithType[] = [ 'loadThemeGoogleFonts', + 'suppressInputClearButton', + 'enableInputAutoComplete', 'suppressMakeColumnVisibleAfterUnGroup', 'suppressRowClickSelection', 'suppressCellFocus', 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/theming/parts/input-style/input-style-base.css b/packages/ag-grid-community/src/theming/parts/input-style/input-style-base.css index 35d69afd7c5..dc7ed04e185 100644 --- a/packages/ag-grid-community/src/theming/parts/input-style/input-style-base.css +++ b/packages/ag-grid-community/src/theming/parts/input-style/input-style-base.css @@ -8,6 +8,22 @@ } } +.ag-input-field-clear-button { + position: absolute; + inset-inline-end: var(--ag-spacing); + display: flex; + align-items: center; + justify-content: center; + width: var(--ag-icon-size); + height: var(--ag-icon-size); + padding: 0; + border: 0; + border-radius: var(--ag-border-radius); + color: inherit; + background: transparent; + cursor: pointer; +} + .ag-input-field-input:where( input:not([type]), input[type='text'], @@ -85,3 +101,7 @@ padding-left: calc(var(--ag-spacing) * 1.5 + 12px); } } + +.ag-input-field-input-with-clear-button { + padding-inline-end: calc(var(--ag-icon-size) + var(--ag-spacing) * 2); +} 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/src/version.ts b/packages/ag-grid-community/src/version.ts index ce52f35f146..17134801960 100644 --- a/packages/ag-grid-community/src/version.ts +++ b/packages/ag-grid-community/src/version.ts @@ -1,2 +1,2 @@ // DO NOT UPDATE MANUALLY: Generated from script during build time -export const VERSION = '36.1.0-beta.20260817.956'; +export const VERSION = '36.1.0-beta.20260818.1041'; 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/package.json b/packages/ag-grid-enterprise/package.json index 587642e1762..431455d24ba 100644 --- a/packages/ag-grid-enterprise/package.json +++ b/packages/ag-grid-enterprise/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-enterprise", - "version": "36.1.0-beta.20260817.956", + "version": "36.1.0-beta.20260818.1041", "description": "Advanced Data Grid / Data Table supporting Javascript / Typescript / React / Angular / Vue", "main": "./dist/package/main.cjs.js", "types": "./dist/types/src/main.d.ts", @@ -113,8 +113,8 @@ ], "homepage": "https://www.ag-grid.com/", "dependencies": { - "ag-stack": "36.1.0-beta.20260817.956", - "ag-grid-community": "36.1.0-beta.20260817.956" + "ag-stack": "36.1.0-beta.20260818.1041", + "ag-grid-community": "36.1.0-beta.20260818.1041" }, "optionalDependencies": { "ag-charts-community": "14.1.0-beta.20260816", diff --git a/packages/ag-grid-enterprise/src/advancedFilter/autocomplete/agAutocomplete.ts b/packages/ag-grid-enterprise/src/advancedFilter/autocomplete/agAutocomplete.ts index b82c1814a27..f75040a6092 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/autocomplete/agAutocomplete.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/autocomplete/agAutocomplete.ts @@ -79,7 +79,7 @@ export class AgAutocomplete extends Component { public postConstruct(): void { this.eAutocompleteInput.onValueChange((value) => this.onValueChanged(value)); - this.eAutocompleteInput.getInputElement().setAttribute('autocomplete', 'off'); + this.eAutocompleteInput.setAutoComplete(false); this.addGuiEventListener('keydown', this.onKeyDown.bind(this)); diff --git a/packages/ag-grid-enterprise/src/agStack/agGroupComponent.ts b/packages/ag-grid-enterprise/src/agStack/agGroupComponent.ts index 90bd2a1359a..9bd31ae13cb 100644 --- a/packages/ag-grid-enterprise/src/agStack/agGroupComponent.ts +++ b/packages/ag-grid-enterprise/src/agStack/agGroupComponent.ts @@ -549,16 +549,25 @@ class DefaultTitleBar< this.addManagedElementListeners(this.getGui(), { click: () => this.dispatchExpandChanged(), keydown: (e: KeyboardEvent) => { + const { ENTER, SPACE, RIGHT, LEFT, UP, DOWN, PAGE_UP, PAGE_DOWN, PAGE_HOME, PAGE_END } = KeyCode; switch (e.key) { - case KeyCode.ENTER: - case KeyCode.SPACE: + case UP: + case DOWN: + case PAGE_UP: + case PAGE_DOWN: + case PAGE_HOME: + case PAGE_END: + e.preventDefault(); + return; + case ENTER: + case SPACE: e.preventDefault(); this.dispatchExpandChanged(); break; - case KeyCode.RIGHT: - case KeyCode.LEFT: + case RIGHT: + case LEFT: e.preventDefault(); - this.dispatchExpandChanged(e.key === KeyCode.RIGHT); + this.dispatchExpandChanged(e.key === RIGHT); break; } }, 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/calculatedColumns/calculatedColumnForm.ts b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnForm.ts index 63e82b9a19d..7c46ce4f2b1 100644 --- a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnForm.ts +++ b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnForm.ts @@ -198,7 +198,6 @@ export class CalculatedColumnForm extends Component { this.eTitle .setLabel(translate('calculatedColumnTitle', 'Title')) .setLabelAlignment('top') - .setAutoComplete(false) .setValue(this.draft.headerName, true); this.eType .setLabel(translate('calculatedColumnType', 'Type')) diff --git a/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsHeader.ts b/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsHeader.ts index d2389c2451d..2993775feab 100644 --- a/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsHeader.ts +++ b/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsHeader.ts @@ -36,7 +36,8 @@ export class AgPrimaryColsHeader extends Component { private expandState: ExpandState; private selectState?: boolean; - private onFilterTextChangedDebounced: () => void; + private onFilterTextChangedDebounced: () => number; + private filterTextChangedTimeout: number | undefined; private params: ToolPanelColumnCompParams; @@ -60,7 +61,10 @@ export class AgPrimaryColsHeader extends Component { this.addManagedElementListeners(this.eSelect.getInputElement(), { click: this.onSelectClicked.bind(this) }); this.addManagedPropertyListener('functionsReadOnly', () => this.onFunctionsReadOnlyPropChanged()); - this.eFilterTextField.setAutoComplete(false).onValueChange(() => this.onFilterTextChanged()); + this.eFilterTextField + .setClearButtonEnabled(true) + .onValueChange(() => this.onFilterTextChanged()) + .onValueClear(() => this.onFilterTextCleared()); this.addManagedEventListeners({ newColumnsLoaded: this.showOrHideOptions.bind(this) }); @@ -129,7 +133,12 @@ export class AgPrimaryColsHeader extends Component { ); } - this.onFilterTextChangedDebounced(); + this.filterTextChangedTimeout = this.onFilterTextChangedDebounced(); + } + + private onFilterTextCleared(): void { + clearTimeout(this.filterTextChangedTimeout); + this.dispatchLocalEvent({ type: 'filterChanged', filterText: this.eFilterTextField.getValue() }); } private onSelectClicked(): void { 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/filterToolPanel/agFiltersToolPanelHeader.ts b/packages/ag-grid-enterprise/src/filterToolPanel/agFiltersToolPanelHeader.ts index ca514131a07..b83a8a3b3db 100644 --- a/packages/ag-grid-enterprise/src/filterToolPanel/agFiltersToolPanelHeader.ts +++ b/packages/ag-grid-enterprise/src/filterToolPanel/agFiltersToolPanelHeader.ts @@ -37,7 +37,8 @@ export class AgFiltersToolPanelHeader extends Component void; + private onSearchTextChangedDebounced: () => number; + private searchTextChangedTimeout: number | undefined; private currentExpandState: EXPAND_STATE; @@ -49,9 +50,10 @@ export class AgFiltersToolPanelHeader extends Component this.onSearchTextCleared()); this.createExpandIcons(); this.setExpandState(EXPAND_STATE.EXPANDED); @@ -99,7 +101,12 @@ export class AgFiltersToolPanelHeader extends Component { } 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/setFilter/setFilter.ts b/packages/ag-grid-enterprise/src/setFilter/setFilter.ts index 9f8f4d823a6..79b92c80031 100644 --- a/packages/ag-grid-enterprise/src/setFilter/setFilter.ts +++ b/packages/ag-grid-enterprise/src/setFilter/setFilter.ts @@ -583,8 +583,9 @@ export class SetFilter private initMiniFilter() { const { eMiniFilter } = this; + eMiniFilter.setClearButtonEnabled(true); this.updateMiniFilter(); - eMiniFilter.onValueChange(() => this.onMiniFilterInput()); + eMiniFilter.onValueChange(() => this.onMiniFilterInput()).onValueClear(() => this.onMiniFilterInput(true)); eMiniFilter.setInputAriaLabel(translateForSetFilter(this, 'ariaSearchFilterValues')); this.addManagedElementListeners(eMiniFilter.getInputElement(), { @@ -690,7 +691,7 @@ export class SetFilter // we don't warn here because the multi filter can call this } - private onMiniFilterInput(silent?: boolean) { + private onMiniFilterInput(forceImmediate = false, silent?: boolean) { if (!this.doSetMiniFilter(this.eMiniFilter.getValue())) { return; } @@ -703,23 +704,34 @@ export class SetFilter const { applyMiniFilterWhileTyping, readOnly, excelMode } = this.params; const updateSelections = !readOnly && (applyMiniFilterWhileTyping || !!excelMode); - const apply = applyMiniFilterWhileTyping && !readOnly ? 'debounce' : undefined; + const apply = + forceImmediate && updateSelections + ? 'immediately' + : applyMiniFilterWhileTyping && !readOnly + ? 'debounce' + : undefined; this.updateUiAfterMiniFilterChange(updateSelections, apply); } private updateUiAfterMiniFilterChange(updateSelections: boolean, apply?: 'immediately' | 'debounce'): void { + let effectiveApply = apply; if (updateSelections) { const { excelMode, readOnly, model } = this.params; if (excelMode && !readOnly && this.miniFilterText == null) { // reset to applied model this.setModelAndRefresh(model?.values ?? null); + if (effectiveApply === 'immediately') { + // the reset can land asynchronously (async values), when an immediate apply + // would submit the pre-reset UI and bypass an active apply button + effectiveApply = undefined; + } } else { this.selectAllMatchingMiniFilter(true); } } this.checkAndRefreshVirtualList(); - this.onUiChanged(updateSelections ? apply : 'prevent'); + this.onUiChanged(updateSelections ? effectiveApply : 'prevent'); this.showOrHideResults(); } @@ -834,7 +846,7 @@ export class SetFilter public setMiniFilter(newMiniFilter: string | null, silent?: boolean): void { this.eMiniFilter.setValue(newMiniFilter, silent); - this.onMiniFilterInput(silent); + this.onMiniFilterInput(false, silent); } /** Sets mini filter value. Returns true if it changed from last value, otherwise false. */ diff --git a/packages/ag-grid-enterprise/src/toolbar/agToolbar.css b/packages/ag-grid-enterprise/src/toolbar/agToolbar.css index 3ac8fa65ed4..61fc957190d 100644 --- a/packages/ag-grid-enterprise/src/toolbar/agToolbar.css +++ b/packages/ag-grid-enterprise/src/toolbar/agToolbar.css @@ -148,7 +148,12 @@ border-radius: var(--ag-input-border-radius); width: 100%; padding-block: calc(var(--ag-spacing) * 0.5); - padding-inline: calc(var(--ag-icon-size) + var(--ag-spacing) * 2) var(--ag-spacing); + padding-inline-start: calc(var(--ag-icon-size) + var(--ag-spacing) * 2); +} + +.ag-toolbar-input-widget { + flex: 1; + min-width: 0; } .ag-toolbar-input-field:focus { diff --git a/packages/ag-grid-enterprise/src/toolbar/providedItems/findToolbarItem.ts b/packages/ag-grid-enterprise/src/toolbar/providedItems/findToolbarItem.ts index 8471bee4e2a..96a8b60aaf1 100644 --- a/packages/ag-grid-enterprise/src/toolbar/providedItems/findToolbarItem.ts +++ b/packages/ag-grid-enterprise/src/toolbar/providedItems/findToolbarItem.ts @@ -1,7 +1,7 @@ import { _debounce, _setDisabled } from 'ag-stack'; -import type { FindChangedEvent, IToolbarItemComp, IToolbarItemParams } from 'ag-grid-community'; -import { Component, _createElement } from 'ag-grid-community'; +import type { FindChangedEvent, GridInputTextField, IToolbarItemComp, IToolbarItemParams } from 'ag-grid-community'; +import { AgInputTextField, Component, _createElement } from 'ag-grid-community'; import { createToolbarIconButton, createToolbarInput } from './toolbarItemUtils'; @@ -18,6 +18,7 @@ function createMatchCount(inputId: string): HTMLLabelElement { } export class FindToolbarItem extends Component implements IToolbarItemComp { + private eInputField!: GridInputTextField; private eInput!: HTMLInputElement; private eMatchCount!: HTMLLabelElement; private ePrevButton!: HTMLButtonElement; @@ -41,8 +42,20 @@ export class FindToolbarItem extends Component implements IToolbarItemComp { const localeTextFunc = this.getLocaleTextFunc(); const label = localeTextFunc('toolbarFind', 'Find'); const eGui = this.getGui(); + let findSearchValueTimeout: number | undefined; + const flushFindSearchValue = () => + this.gos.updateGridOptions({ options: { findSearchValue: this.eInput.value } }); - const { eIconWrapper, eInput } = createToolbarInput(this.beans, { + this.eInputField = this.createManagedBean( + new AgInputTextField({ + clearButton: true, + onValueClear: () => { + clearTimeout(findSearchValueTimeout); + flushFindSearchValue(); + }, + }) + ); + const { eIconWrapper, eInput } = createToolbarInput(this.beans, this.eInputField, { label, iconName: 'search', initialValue: this.gos.get('findSearchValue'), @@ -53,7 +66,7 @@ export class FindToolbarItem extends Component implements IToolbarItemComp { eGui.appendChild(eIconWrapper); } this.eInput = eInput; - eGui.appendChild(this.eInput); + eGui.appendChild(this.eInputField.getGui()); this.eMatchCount = createMatchCount(inputId); eGui.appendChild(this.eMatchCount); @@ -74,12 +87,10 @@ export class FindToolbarItem extends Component implements IToolbarItemComp { }); eGui.appendChild(this.eNextButton); - const flushFindSearchValue = () => - this.gos.updateGridOptions({ options: { findSearchValue: this.eInput.value } }); const updateFindSearchValueDebounced = _debounce(this, flushFindSearchValue, INPUT_DEBOUNCE_MS); this.addManagedElementListeners(this.eInput, { - input: () => updateFindSearchValueDebounced(), + input: () => (findSearchValueTimeout = updateFindSearchValueDebounced()), keydown: (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); @@ -112,7 +123,7 @@ export class FindToolbarItem extends Component implements IToolbarItemComp { if (!this.eInput) { return false; } - this.eInput.value = this.gos.get('findSearchValue') ?? ''; + this.eInputField.setValue(this.gos.get('findSearchValue'), true); this.syncMatchState(); return true; } diff --git a/packages/ag-grid-enterprise/src/toolbar/providedItems/quickFilterToolbarItem.ts b/packages/ag-grid-enterprise/src/toolbar/providedItems/quickFilterToolbarItem.ts index 2641dc7811f..14202e30009 100644 --- a/packages/ag-grid-enterprise/src/toolbar/providedItems/quickFilterToolbarItem.ts +++ b/packages/ag-grid-enterprise/src/toolbar/providedItems/quickFilterToolbarItem.ts @@ -1,13 +1,14 @@ import { _debounce } from 'ag-stack'; -import type { IToolbarItemComp, IToolbarItemParams } from 'ag-grid-community'; -import { Component } from 'ag-grid-community'; +import type { GridInputTextField, IToolbarItemComp, IToolbarItemParams } from 'ag-grid-community'; +import { AgInputTextField, Component } from 'ag-grid-community'; import { createToolbarInput } from './toolbarItemUtils'; const INPUT_DEBOUNCE_MS = 300; export class QuickFilterToolbarItem extends Component implements IToolbarItemComp { + private eInputField!: GridInputTextField; private eInput!: HTMLInputElement; constructor() { @@ -28,8 +29,18 @@ export class QuickFilterToolbarItem extends Component implements IToolbarItemCom const localeTextFunc = this.getLocaleTextFunc(); const label = localeTextFunc('toolbarQuickFilter', 'Filter'); const eGui = this.getGui(); + let quickFilterTextTimeout: number | undefined; - const { eIconWrapper, eInput } = createToolbarInput(this.beans, { + this.eInputField = this.createManagedBean( + new AgInputTextField({ + clearButton: true, + onValueClear: () => { + clearTimeout(quickFilterTextTimeout); + this.gos.updateGridOptions({ options: { quickFilterText: '' } }); + }, + }) + ); + const { eIconWrapper, eInput } = createToolbarInput(this.beans, this.eInputField, { label, iconName: 'filter', initialValue: this.gos.get('quickFilterText'), @@ -38,7 +49,7 @@ export class QuickFilterToolbarItem extends Component implements IToolbarItemCom eGui.appendChild(eIconWrapper); } this.eInput = eInput; - eGui.appendChild(this.eInput); + eGui.appendChild(this.eInputField.getGui()); const updateQuickFilterText = _debounce( this, @@ -47,7 +58,7 @@ export class QuickFilterToolbarItem extends Component implements IToolbarItemCom ); this.addManagedElementListeners(this.eInput, { - input: () => updateQuickFilterText(), + input: () => (quickFilterTextTimeout = updateQuickFilterText()), }); } @@ -55,7 +66,7 @@ export class QuickFilterToolbarItem extends Component implements IToolbarItemCom if (!this.eInput) { return false; } - this.eInput.value = this.gos.get('quickFilterText') ?? ''; + this.eInputField.setValue(this.gos.get('quickFilterText'), true); return true; } } diff --git a/packages/ag-grid-enterprise/src/toolbar/providedItems/toolbarItemUtils.ts b/packages/ag-grid-enterprise/src/toolbar/providedItems/toolbarItemUtils.ts index 7407e1c39ae..936e3b2b574 100644 --- a/packages/ag-grid-enterprise/src/toolbar/providedItems/toolbarItemUtils.ts +++ b/packages/ag-grid-enterprise/src/toolbar/providedItems/toolbarItemUtils.ts @@ -1,6 +1,6 @@ import { _addOrRemoveAttribute, _clearElement, _setAriaLabel, _setDisabled, _setDisplayed } from 'ag-stack'; -import type { BeanCollection, IconName } from 'ag-grid-community'; +import type { BeanCollection, GridInputTextField, IconName } from 'ag-grid-community'; import { _createElement, _createIconNoSpan } from 'ag-grid-community'; interface CreateToolbarInputParams { @@ -11,6 +11,7 @@ interface CreateToolbarInputParams { export function createToolbarInput( beans: BeanCollection, + eInputField: GridInputTextField, { label, iconName, initialValue }: CreateToolbarInputParams ): { eIconWrapper: HTMLElement | undefined; eInput: HTMLInputElement } { const eIcon = _createIconNoSpan(iconName, beans); @@ -24,19 +25,10 @@ export function createToolbarInput( eIconWrapper.appendChild(eIcon); } - const eInput = _createElement({ - tag: 'input', - cls: 'ag-toolbar-input-field', - attrs: { - type: 'text', - placeholder: `${label}...`, - 'aria-label': label, - }, - }); - - if (initialValue) { - eInput.value = initialValue; - } + eInputField.setInputPlaceholder(`${label}...`).setInputAriaLabel(label).setValue(initialValue, true); + eInputField.addCss('ag-toolbar-input-widget'); + const eInput = eInputField.getInputElement(); + eInput.classList.add('ag-toolbar-input-field'); return { eIconWrapper, eInput }; } diff --git a/packages/ag-grid-enterprise/src/version.ts b/packages/ag-grid-enterprise/src/version.ts index ce52f35f146..17134801960 100644 --- a/packages/ag-grid-enterprise/src/version.ts +++ b/packages/ag-grid-enterprise/src/version.ts @@ -1,2 +1,2 @@ // DO NOT UPDATE MANUALLY: Generated from script during build time -export const VERSION = '36.1.0-beta.20260817.956'; +export const VERSION = '36.1.0-beta.20260818.1041'; 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-react/package.json b/packages/ag-grid-react/package.json index 3aba8fcc27a..6d46ecdd608 100644 --- a/packages/ag-grid-react/package.json +++ b/packages/ag-grid-react/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-react", - "version": "36.1.0-beta.20260817.956", + "version": "36.1.0-beta.20260818.1041", "description": "AG Grid React Component", "main": "./dist/package/index.cjs.js", "types": "./dist/types/src/index.d.ts", @@ -31,7 +31,7 @@ "devDependencies": { "@babel/runtime": "^7.29.2", "prop-types": "^15.6.2", - "ag-grid-community": "36.1.0-beta.20260817.956", + "ag-grid-community": "36.1.0-beta.20260818.1041", "@babel/plugin-proposal-throw-expressions": "^7.27.1", "@babel/preset-typescript": "^7.28.5", "@types/react": "~18.3.26", @@ -44,7 +44,7 @@ }, "dependencies": { "prop-types": "^15.8.1", - "ag-grid-community": "36.1.0-beta.20260817.956" + "ag-grid-community": "36.1.0-beta.20260818.1041" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 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..10994971ca9 100644 --- a/packages/ag-grid-vue3/package.json +++ b/packages/ag-grid-vue3/package.json @@ -1,7 +1,7 @@ { "name": "ag-grid-vue3", "description": "AG Grid Vue 3 Component", - "version": "36.1.0-beta.20260817.956", + "version": "36.1.0-beta.20260818.1041", "author": "Sean Landsman ", "license": "MIT", "files": [ @@ -44,19 +44,17 @@ "build-only:watch": "vite build --watch" }, "dependencies": { - "ag-grid-community": "36.1.0-beta.20260817.956" + "ag-grid-community": "36.1.0-beta.20260818.1041" }, "devDependencies": { "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/src/components/utils.ts b/packages/ag-grid-vue3/src/components/utils.ts index 78b69dbc637..f5b2a33e4a5 100644 --- a/packages/ag-grid-vue3/src/components/utils.ts +++ b/packages/ag-grid-vue3/src/components/utils.ts @@ -825,6 +825,15 @@ export interface Props { * @initial */ tabIndex?: number, + /** Set to `true` to hide the clear button shown in supported input fields when they contain a value. + * @default false + */ + suppressInputClearButton?: boolean, + /** Set to `true` to enable the browser's autocomplete/autofill behaviour for eligible grid input fields. + * Inputs that provide grid-owned suggestions, such as Rich Select and Advanced Filter inputs, keep browser autocomplete disabled. + * @default false + */ + enableInputAutoComplete?: boolean, /** The number of rows rendered outside the viewable area the grid renders. * Having a buffer means the grid will have rows ready to show as the user slowly scrolls vertically. * @default 10 @@ -2299,6 +2308,8 @@ export function getProps() { context: undefined, alignedGrids: undefined, tabIndex: undefined, + suppressInputClearButton: undefined, + enableInputAutoComplete: undefined, rowBuffer: undefined, valueCache: undefined, valueCacheNeverExpires: undefined, 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/package.json b/packages/ag-stack/package.json index 07d186a886b..813667cae14 100644 --- a/packages/ag-stack/package.json +++ b/packages/ag-stack/package.json @@ -1,6 +1,6 @@ { "name": "ag-stack", - "version": "36.1.0-beta.20260817.956", + "version": "36.1.0-beta.20260818.1041", "description": "Advanced Data Grid / Data Table supporting Javascript / Typescript / React / Angular / Vue", "main": "./dist/package/main.cjs.js", "types": "./dist/types/src/main.d.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/focus/agManagedFocusFeature.ts b/packages/ag-stack/src/focus/agManagedFocusFeature.ts index 0d30879bc98..394743dc8f3 100644 --- a/packages/ag-stack/src/focus/agManagedFocusFeature.ts +++ b/packages/ag-stack/src/focus/agManagedFocusFeature.ts @@ -4,7 +4,7 @@ import type { AgCoreBeanCollection } from '../interfaces/agCoreBeanCollection'; import type { BaseEvents } from '../interfaces/baseEvents'; import type { BaseProperties } from '../interfaces/baseProperties'; import type { IPropertiesService } from '../interfaces/iProperties'; -import { _findNextFocusableElement } from '../utils/focus'; +import { FOCUS_MANAGED_CLASS, _findNextFocusableElement } from '../utils/focus'; /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export interface ManagedFocusCallbacks { @@ -15,9 +15,6 @@ export interface ManagedFocusCallbacks { onFocusOut?: (e: FocusEvent) => void; } -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export const FOCUS_MANAGED_CLASS = 'ag-focus-managed'; - /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export interface StopPropagationCallbacks { isStopPropagation: (e: Event) => boolean; diff --git a/packages/ag-stack/src/interfaces/baseProperties.ts b/packages/ag-stack/src/interfaces/baseProperties.ts index 478d9b6757c..bd331a04f06 100644 --- a/packages/ag-stack/src/interfaces/baseProperties.ts +++ b/packages/ag-stack/src/interfaces/baseProperties.ts @@ -23,4 +23,6 @@ export interface BaseProperties { tooltipInteraction?: boolean; getDocument?: () => Document; suppressTouch?: boolean; + suppressInputClearButton?: boolean; + enableInputAutoComplete?: boolean; } diff --git a/packages/ag-stack/src/main-internal.ts b/packages/ag-stack/src/main-internal.ts index a28d3cd6cbe..774b98a4b4e 100644 --- a/packages/ag-stack/src/main-internal.ts +++ b/packages/ag-stack/src/main-internal.ts @@ -14,7 +14,8 @@ export type { BaseCssChangeKeys, CssVariable } from './core/baseEnvironment'; export { BaseRegistry } from './core/baseRegistry'; export { BaseEventService } from './events/baseEventService'; export { LocalEventService } from './events/localEventService'; -export { AgManagedFocusFeature, FOCUS_MANAGED_CLASS } from './focus/agManagedFocusFeature'; +export { FAST_TEST_TIMINGS } from './fastTestTimings'; +export { AgManagedFocusFeature } from './focus/agManagedFocusFeature'; export type { ManagedFocusCallbacks, StopPropagationCallbacks } from './focus/agManagedFocusFeature'; export { AgTabGuardComp } from './focus/agTabGuardComp'; export { AgTabGuardFeature } from './focus/agTabGuardFeature'; @@ -236,10 +237,12 @@ export { } from './utils/event'; export type { TempEventHandler } from './utils/event'; export { + FOCUS_MANAGED_CLASS, _findFocusableElements, _findNextFocusableElement, _findTabbableParent, _focusInto, + _focusIntoTabbableFirst, _isKeyboardMode, _registerKeyboardFocusEvents, _scrollHorizontallyToShow, 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.test.ts b/packages/ag-stack/src/utils/dom.test.ts new file mode 100644 index 00000000000..354ec0a9714 --- /dev/null +++ b/packages/ag-stack/src/utils/dom.test.ts @@ -0,0 +1,72 @@ +import { _isFocusableFormField } from './dom'; + +describe('_isFocusableFormField', () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => container.remove()); + + /** Creates an element inside the container, so the visibility check sees it in the document. */ + function render(tag: string, cssClass?: string): HTMLElement { + const element = document.createElement(tag); + if (cssClass) { + element.className = cssClass; + } + container.appendChild(element); + return element; + } + + /** Creates a form field nested inside a wrapper carrying `wrapperClass`. */ + function renderNested(wrapperClass: string): HTMLElement { + const input = document.createElement('input'); + render('div', wrapperClass).appendChild(input); + return input; + } + + test('accepts a visible form field', () => { + expect(_isFocusableFormField(render('input'))).toBe(true); + expect(_isFocusableFormField(render('select'))).toBe(true); + expect(_isFocusableFormField(render('button'))).toBe(true); + expect(_isFocusableFormField(render('textarea'))).toBe(true); + }); + + test('rejects a missing element or anything that is not a form field', () => { + expect(_isFocusableFormField(null)).toBe(false); + + // focusable in the tab-order sense, but not a form field the browser should keep focus on + const div = render('div'); + div.tabIndex = 0; + expect(_isFocusableFormField(div)).toBe(false); + + const anchor = render('a'); + anchor.setAttribute('href', '#'); + expect(_isFocusableFormField(anchor)).toBe(false); + }); + + test('rejects disabled fields and fields inside a disabled subtree', () => { + const disabled = render('input') as HTMLInputElement; + disabled.disabled = true; + expect(_isFocusableFormField(disabled)).toBe(false); + + expect(_isFocusableFormField(render('input', 'ag-disabled'))).toBe(false); + expect(_isFocusableFormField(renderNested('ag-disabled'))).toBe(false); + }); + + test('accepts a disabled-styled button, which opts out of the ag-disabled exclusion', () => { + expect(_isFocusableFormField(render('button', 'ag-disabled ag-button'))).toBe(true); + }); + + test('rejects fields hidden by ag-hidden, even without the CSS that hides them', () => { + // no stylesheet here, so these are "visible" to the visibility check — the class alone must exclude them + expect(_isFocusableFormField(render('input', 'ag-hidden'))).toBe(false); + expect(_isFocusableFormField(renderNested('ag-hidden'))).toBe(false); + }); + + test('rejects a field that is not in the document', () => { + expect(_isFocusableFormField(document.createElement('input'))).toBe(false); + }); +}); diff --git a/packages/ag-stack/src/utils/dom.ts b/packages/ag-stack/src/utils/dom.ts index 70e169d74ae..aaaddaaf018 100644 --- a/packages/ag-stack/src/utils/dom.ts +++ b/packages/ag-stack/src/utils/dom.ts @@ -27,7 +27,8 @@ export function _radioCssClass(element: HTMLElement, elementClass: string | null } export const FOCUSABLE_SELECTOR = '[tabindex], input, select, button, textarea, [href]'; -export const FOCUSABLE_EXCLUDE = '[disabled], .ag-disabled:not(.ag-button), .ag-disabled *'; +// ag-hidden subtrees are excluded even where CSS keeps them visible (e.g. a tool panel animating closed) +export const FOCUSABLE_EXCLUDE = '[disabled], .ag-disabled:not(.ag-button), .ag-disabled *, .ag-hidden, .ag-hidden *'; /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export function _isFocusableFormField(element: Element | null): boolean { @@ -39,7 +40,7 @@ export function _isFocusableFormField(element: Element | null): boolean { return false; } const isNotFocusable = element.matches(FOCUSABLE_EXCLUDE); - if (!isNotFocusable) { + if (isNotFocusable) { return false; } return _isVisible(element); @@ -463,7 +464,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/src/utils/focus.test.ts b/packages/ag-stack/src/utils/focus.test.ts new file mode 100644 index 00000000000..beeb07ab064 --- /dev/null +++ b/packages/ag-stack/src/utils/focus.test.ts @@ -0,0 +1,70 @@ +import { FOCUS_MANAGED_CLASS, _focusIntoTabbableFirst } from './focus'; + +describe('_focusIntoTabbableFirst', () => { + const originalOffsetParent = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetParent'); + + beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'offsetParent', { + configurable: true, + get() { + return this.parentNode; + }, + }); + }); + + afterAll(() => { + if (originalOffsetParent) { + Object.defineProperty(HTMLElement.prototype, 'offsetParent', originalOffsetParent); + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'offsetParent'); + } + }); + + test('prefers tabbable elements over managed ones in either direction', () => { + const root = document.createElement('div'); + const managedButton = document.createElement('button'); + const tabbableButton = document.createElement('button'); + const trailingManagedButton = document.createElement('button'); + + managedButton.tabIndex = -1; + trailingManagedButton.tabIndex = -1; + root.append(managedButton, tabbableButton, trailingManagedButton); + document.body.appendChild(root); + + expect(_focusIntoTabbableFirst(root)).toBe(true); + expect(document.activeElement).toBe(tabbableButton); + expect(_focusIntoTabbableFirst(root, true)).toBe(true); + expect(document.activeElement).toBe(tabbableButton); + root.remove(); + }); + + test('falls back to managed content owned by a managed-focus wrapper inside the container', () => { + const root = document.createElement('div'); + const managedButton = document.createElement('button'); + + root.classList.add(FOCUS_MANAGED_CLASS); + managedButton.tabIndex = -1; + root.appendChild(managedButton); + document.body.appendChild(root); + + expect(_focusIntoTabbableFirst(root)).toBe(true); + expect(document.activeElement).toBe(managedButton); + root.remove(); + }); + + test('refuses managed content whose managed-focus wrapper is outside the container', () => { + const managedAncestor = document.createElement('div'); + const root = document.createElement('div'); + const managedButton = document.createElement('button'); + + managedAncestor.classList.add(FOCUS_MANAGED_CLASS); + managedButton.tabIndex = -1; + root.appendChild(managedButton); + managedAncestor.appendChild(root); + document.body.appendChild(managedAncestor); + + expect(_focusIntoTabbableFirst(root)).toBe(false); + expect(document.activeElement).not.toBe(managedButton); + managedAncestor.remove(); + }); +}); diff --git a/packages/ag-stack/src/utils/focus.ts b/packages/ag-stack/src/utils/focus.ts index b377ce9a5d6..7113c439b51 100644 --- a/packages/ag-stack/src/utils/focus.ts +++ b/packages/ag-stack/src/utils/focus.ts @@ -4,6 +4,9 @@ import { _getTabIndex } from './browser'; import { _getActiveDomElement, _getDocument } from './document'; import { FOCUSABLE_EXCLUDE, FOCUSABLE_SELECTOR, _isVisible } from './dom'; +/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export const FOCUS_MANAGED_CLASS = 'ag-focus-managed'; + let keyboardModeActive: boolean = false; let instanceCount: number = 0; @@ -80,14 +83,13 @@ export function _findFocusableElements( .filter((node: HTMLElement) => { return _isVisible(node); }) as HTMLElement[]; - const excludeNodes = Array.prototype.slice.apply(rootNode.querySelectorAll(excludeString)) as HTMLElement[]; + const excludeNodes = new Set(rootNode.querySelectorAll(excludeString)); - if (!excludeNodes.length) { + if (!excludeNodes.size) { return nodes; } - const diff = (a: HTMLElement[], b: HTMLElement[]) => a.filter((element) => b.indexOf(element) === -1); - return diff(nodes, excludeNodes); + return nodes.filter((element) => !excludeNodes.has(element)); } /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ @@ -112,6 +114,31 @@ export function _focusInto( return false; } +/** + * Focuses into `rootNode` the way native Tab would: tabbable elements win. Elements with a + * negative tabindex are only eligible when a managed-focus wrapper inside `rootNode` owns their + * keyboard handling — bare tabindex="-1" content would otherwise be a keyboard dead end. + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. + */ +export function _focusIntoTabbableFirst(rootNode: HTMLElement, up = false, excludeTabGuards = false): boolean { + const candidates = _findFocusableElements(rootNode, excludeTabGuards ? '.ag-tab-guard' : null); + const tabbable = candidates.filter((element) => element.tabIndex >= 0); + const pool = tabbable.length + ? tabbable + : candidates.filter((element) => { + const managedRoot = element.closest(`.${FOCUS_MANAGED_CLASS}`); + return managedRoot !== null && rootNode.contains(managedRoot); + }); + const toFocus = up ? _last(pool) : pool[0]; + + if (toFocus) { + toFocus.focus({ preventScroll: true }); + return true; + } + + return false; +} + /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export function _findNextFocusableElement( beans: UtilBeanCollection, diff --git a/packages/ag-stack/src/version.ts b/packages/ag-stack/src/version.ts index ce52f35f146..17134801960 100644 --- a/packages/ag-stack/src/version.ts +++ b/packages/ag-stack/src/version.ts @@ -1,2 +1,2 @@ // DO NOT UPDATE MANUALLY: Generated from script during build time -export const VERSION = '36.1.0-beta.20260817.956'; +export const VERSION = '36.1.0-beta.20260818.1041'; 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-code-reference-files/package.json b/plugins/ag-grid-generate-code-reference-files/package.json index 84cf80b5b95..cdf1e3e1db1 100644 --- a/plugins/ag-grid-generate-code-reference-files/package.json +++ b/plugins/ag-grid-generate-code-reference-files/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-generate-code-reference-files", - "version": "36.1.0-beta.20260817.956", + "version": "36.1.0-beta.20260818.1041", "private": true, "dependencies": { "ag-shared": "0.0.1", diff --git a/plugins/ag-grid-generate-example-files/package.json b/plugins/ag-grid-generate-example-files/package.json index d336b201c13..d1123a9b955 100644 --- a/plugins/ag-grid-generate-example-files/package.json +++ b/plugins/ag-grid-generate-example-files/package.json @@ -1,10 +1,10 @@ { "name": "ag-grid-generate-example-files", - "version": "36.1.0-beta.20260817.956", + "version": "36.1.0-beta.20260818.1041", "private": true, "dependencies": { "ag-shared": "0.0.1", - "ag-grid-community": "36.1.0-beta.20260817.956", + "ag-grid-community": "36.1.0-beta.20260818.1041", "glob": "^11.1.0", "typescript": "~5.8.3", "cheerio": "^1.2.0", diff --git a/plugins/ag-grid-generate-example-files/src/executors/generate/executor.ts b/plugins/ag-grid-generate-example-files/src/executors/generate/executor.ts index df3367b0d8e..f3d4ba3b338 100644 --- a/plugins/ag-grid-generate-example-files/src/executors/generate/executor.ts +++ b/plugins/ag-grid-generate-example-files/src/executors/generate/executor.ts @@ -177,7 +177,7 @@ export async function generateFiles(options: ExecutorOptions, gridOptionsTypes: throw new Error(`No entry file config generator for '${internalFramework}'`); } - const boilerPlateFiles = await getBoilerPlateFiles(isDev, internalFramework); + const boilerPlateFiles = await getBoilerPlateFiles(internalFramework); const entryFileName = getEntryFileName(internalFramework)!; const mainFileName = getMainFileName(internalFramework)!; const scriptNonce = getScriptNonce(htmlFiles)!; @@ -238,7 +238,6 @@ export async function generateFiles(options: ExecutorOptions, gridOptionsTypes: styleFiles, ignoreDarkMode: false, transformEntryFile, - isDev, exampleConfig: frameworkExampleConfig, }); files = result.files; @@ -256,7 +255,6 @@ export async function generateFiles(options: ExecutorOptions, gridOptionsTypes: provideFrameworkFiles, mergedStyleFiles, transformEntryFile, - isDev, isIntegratedCharts, mainFileName, folderPath @@ -353,7 +351,6 @@ async function processProvidedFiles( provideFrameworkFiles: any, mergedStyleFiles: { [x: string]: string }, transformEntryFile: TransformEntryFile, - isDev: boolean, isIntegratedCharts: boolean, mainFileName: string, folderPath: string @@ -394,7 +391,7 @@ async function processProvidedFiles( delete provideFrameworkFiles[fileName]; } - if (!isDev && provideFrameworkFiles[writeToFileName]?.length > 0 && !writeToFileName.endsWith('.css')) { + if (provideFrameworkFiles[writeToFileName]?.length > 0 && !writeToFileName.endsWith('.css')) { provideFrameworkFiles[writeToFileName] = await formatFile( internalFramework, provideFrameworkFiles[writeToFileName] diff --git a/plugins/ag-grid-generate-example-files/src/executors/generate/generator/transformation-scripts/parser-utils.ts b/plugins/ag-grid-generate-example-files/src/executors/generate/generator/transformation-scripts/parser-utils.ts index d31a7144c0b..3d93940c45c 100644 --- a/plugins/ag-grid-generate-example-files/src/executors/generate/generator/transformation-scripts/parser-utils.ts +++ b/plugins/ag-grid-generate-example-files/src/executors/generate/generator/transformation-scripts/parser-utils.ts @@ -575,9 +575,27 @@ export function handleRowGenericInterface(fileTxt: string, tData: string): strin return fileTxt; } +const GENERIC_INTERFACE_NAMES: Record = { + IOlympicData: ['IOlympicData'], + IOlympicDataWithId: ['IOlympicDataWithId', 'IOlympicData'], + IAccount: ['IAccount', 'ICallRecord'], +}; + export function addGenericInterfaceImport(imports: string[], tData: string, bindings) { - if (tData && !bindings.interfaces.some((i) => i.includes(tData)) && !imports.some((i) => i.includes(tData))) { - imports.push(`import { ${tData} } from './interfaces';`); + if (!tData) { + return; + } + + const source = JSON.stringify(bindings); + const names = (GENERIC_INTERFACE_NAMES[tData] ?? [tData]).filter( + (name) => + (name === tData || new RegExp(`\\b${name}\\b`).test(source)) && + !bindings.interfaces.some((i) => i.includes(name)) && + !imports.some((i) => i.includes(name)) + ); + + if (names.length > 0) { + imports.push(`import { ${names.join(', ')} } from './interfaces';`); } } @@ -797,13 +815,13 @@ export function getEnableAGTestIdLogic(isUmd: boolean = false): string { // Support dynamically adding modules during integration testing const agGridCommunityImport = isUmd ? '' : `import * as ${community} from 'ag-grid-community';`; - const agGridEnterpriseImport = isUmd ? '' : `import * as ${enterprise} from 'ag-grid-enterprise';`; const extraModules = isUmd ? '' : ` const modulesCSV = url.get('modules'); if (modulesCSV) { + const ${enterprise} = await import('ag-grid-enterprise'); ${community}.ModuleRegistry.registerModules( modulesCSV.split(',').map(name => ${community}[name] || ${enterprise}[name]) ); @@ -817,7 +835,6 @@ export function getEnableAGTestIdLogic(isUmd: boolean = false): string { const method = ` ${agGridCommunityImport} -${agGridEnterpriseImport} const url = new URLSearchParams(window.location.search); const enableTestIds = url.get('enableTestIds'); if (enableTestIds) { diff --git a/plugins/ag-grid-generate-example-files/src/executors/generate/generator/utils/fileUtils.test.ts b/plugins/ag-grid-generate-example-files/src/executors/generate/generator/utils/fileUtils.test.ts index 472ded9a315..4ad44e55d9f 100644 --- a/plugins/ag-grid-generate-example-files/src/executors/generate/generator/utils/fileUtils.test.ts +++ b/plugins/ag-grid-generate-example-files/src/executors/generate/generator/utils/fileUtils.test.ts @@ -1,6 +1,3 @@ -/** - * @jest-environment node - */ import { getBoilerPlateName, getEntryFileName, getMainFileName, getTransformTsFileExt } from './fileUtils'; describe('getEntryFileName', () => { @@ -41,11 +38,11 @@ describe('getBoilerPlateName', () => { ${undefined} | ${undefined} ${'other'} | ${undefined} ${'vanilla'} | ${undefined} - ${'typescript'} | ${'grid-typescript-boilerplate'} - ${'reactFunctional'} | ${'grid-react-boilerplate'} - ${'reactFunctionalTs'} | ${'grid-react-ts-boilerplate'} + ${'typescript'} | ${undefined} + ${'reactFunctional'} | ${undefined} + ${'reactFunctionalTs'} | ${undefined} ${'angular'} | ${'grid-angular-boilerplate'} - ${'vue3'} | ${'grid-vue3-boilerplate'} + ${'vue3'} | ${undefined} `('$internalFramework is $expected', ({ internalFramework, expected }) => { expect(getBoilerPlateName(internalFramework)).toEqual(expected); }); diff --git a/plugins/ag-grid-generate-example-files/src/executors/generate/generator/utils/fileUtils.ts b/plugins/ag-grid-generate-example-files/src/executors/generate/generator/utils/fileUtils.ts index 61423a3c9b9..5a0a08e835c 100644 --- a/plugins/ag-grid-generate-example-files/src/executors/generate/generator/utils/fileUtils.ts +++ b/plugins/ag-grid-generate-example-files/src/executors/generate/generator/utils/fileUtils.ts @@ -8,22 +8,8 @@ import { TYPESCRIPT_INTERNAL_FRAMEWORKS } from '../types'; const BOILER_PLATE_FILE_PATH = './documentation/ag-grid-docs/public/example-runner'; -export const getBoilerPlateName = (internalFramework: InternalFramework) => { - const boilerPlateTemplate = (boilerPlateKey: string) => `grid-${boilerPlateKey}-boilerplate`; - - switch (internalFramework) { - case 'reactFunctional': - return boilerPlateTemplate('react'); - case 'reactFunctionalTs': - return boilerPlateTemplate('react-ts'); - case 'typescript': - case 'angular': - case 'vue3': - return boilerPlateTemplate(internalFramework); - default: - return undefined; - } -}; +export const getBoilerPlateName = (internalFramework: InternalFramework) => + internalFramework === 'angular' ? 'grid-angular-boilerplate' : undefined; export const getTransformTsFileExt = (internalFramework: InternalFramework): TransformTsFileExt => { let transformTsFileExt: TransformTsFileExt; @@ -38,7 +24,7 @@ export const getTransformTsFileExt = (internalFramework: InternalFramework): Tra return transformTsFileExt; }; -export const getBoilerPlateFiles = async (isDev: boolean, internalFramework: InternalFramework) => { +export const getBoilerPlateFiles = async (internalFramework: InternalFramework) => { const boilerplateName = getBoilerPlateName(internalFramework); if (!boilerplateName) { @@ -50,10 +36,6 @@ export const getBoilerPlateFiles = async (isDev: boolean, internalFramework: Int const files: Record = {}; const fileContentPromises = fileNames.map(async (fileName) => { - if (!isDev && fileName === 'systemjs.config.dev.js') { - // Ignore systemjs dev file if on production - return; - } const filePath = path.join(boilerPlatePath, fileName); try { const contents = readFileSync(filePath, 'utf-8'); diff --git a/plugins/ag-grid-generate-example-files/src/executors/generate/generator/utils/frameworkFilesGenerator.ts b/plugins/ag-grid-generate-example-files/src/executors/generate/generator/utils/frameworkFilesGenerator.ts index 9189456f231..2c0fb13d70b 100644 --- a/plugins/ag-grid-generate-example-files/src/executors/generate/generator/utils/frameworkFilesGenerator.ts +++ b/plugins/ag-grid-generate-example-files/src/executors/generate/generator/utils/frameworkFilesGenerator.ts @@ -35,7 +35,6 @@ type ConfigGenerator = ({ styleFiles, ignoreDarkMode, transformEntryFile, - isDev, exampleConfig, }: { entryFile: string; @@ -48,7 +47,6 @@ type ConfigGenerator = ({ styleFiles: FileContents; ignoreDarkMode?: boolean; transformEntryFile?: TransformEntryFile; - isDev: boolean; exampleConfig: ExampleConfig; }) => Promise; @@ -57,15 +55,7 @@ type ConfigGenerator = ({ const AG_GRID_EXPORTED_FUNCS_USED_IN_EXAMPLES_COMPS = ['isCombinedFilterModel']; export const frameworkFilesGenerator: Partial> = { - vanilla: async ({ - bindings, - entryFile, - indexHtml, - componentScriptFiles, - otherScriptFiles, - transformEntryFile, - isDev, - }) => { + vanilla: async ({ bindings, entryFile, indexHtml, componentScriptFiles, otherScriptFiles, transformEntryFile }) => { const internalFramework: InternalFramework = 'vanilla'; const entryFileName = getEntryFileName(internalFramework)!; let mainJs = readAsJsFile(entryFile, 'vanilla'); @@ -106,9 +96,7 @@ export const frameworkFilesGenerator: Partial { const internalFramework: InternalFramework = 'typescript'; const entryFileName = getEntryFileName(internalFramework)!; @@ -138,9 +125,7 @@ export const frameworkFilesGenerator: Partial { const internalFramework: InternalFramework = 'reactFunctional'; @@ -179,9 +163,7 @@ export const frameworkFilesGenerator: Partial { const internalFramework: InternalFramework = 'reactFunctionalTs'; @@ -217,9 +198,7 @@ export const frameworkFilesGenerator: Partial { const internalFramework: InternalFramework = 'angular'; const entryFileName = getEntryFileName(internalFramework)!; - const boilerPlateFiles = await getBoilerPlateFiles(isDev, internalFramework); + const boilerPlateFiles = await getBoilerPlateFiles(internalFramework); const componentNames = getComponentName(componentScriptFiles); let appComponent = vanillaToAngular( @@ -256,9 +234,7 @@ export const frameworkFilesGenerator: Partial { const internalFramework: InternalFramework = 'vue3'; @@ -296,9 +271,7 @@ export const frameworkFilesGenerator: Partial [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/accessibility/package.json b/testing/accessibility/package.json index 1d84aa867cd..109adf4288f 100644 --- a/testing/accessibility/package.json +++ b/testing/accessibility/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-accessibility", - "version": "36.1.0-beta.20260817.956", + "version": "36.1.0-beta.20260818.1041", "scripts": { "download-examples": "curl --retry 5 -retry-all-errors https://grid-staging.ag-grid.com/debug/all-examples.json > ./all-examples.json", "download-examples-local": "curl https://localhost:4610/debug/all-examples.json > ./all-examples.json", @@ -18,9 +18,9 @@ "@angular/platform-browser": "^20.3.25", "@angular/platform-browser-dynamic": "^20.3.25", "@angular/router": "^20.3.25", - "ag-grid-angular": "36.1.0-beta.20260817.956", - "ag-grid-community": "36.1.0-beta.20260817.956", - "ag-grid-enterprise": "36.1.0-beta.20260817.956", + "ag-grid-angular": "36.1.0-beta.20260818.1041", + "ag-grid-community": "36.1.0-beta.20260818.1041", + "ag-grid-enterprise": "36.1.0-beta.20260818.1041", "ag-charts-community": "14.1.0-beta.20260816", "ag-charts-enterprise": "14.1.0-beta.20260816", "rxjs": "~7.8.2", 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..14616f70c48 --- /dev/null +++ b/testing/ag-test-utils/package.json @@ -0,0 +1,11 @@ +{ + "name": "ag-test-utils", + "version": "36.1.0-beta.20260818.1041", + "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 `