From fb19ea7e9312169efa7feaebf6c482b94328d148 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 11 May 2026 19:31:39 +0200 Subject: [PATCH 01/95] chore: Add visual regression testing --- .github/workflows/visual-regression.yml | 86 +++++++++++++++ build-tools/tasks/visual.js | 107 +++++++++++++++++++ build-tools/visual/global-setup.js | 4 + build-tools/visual/global-teardown.js | 4 + build-tools/visual/setup.js | 18 ++++ docs/RUNNING_TESTS.md | 59 +++++++++- eslint.config.mjs | 2 +- gulpfile.js | 2 + jest.visual.config.js | 25 +++++ package.json | 1 + test/visual/compare-screenshots.ts | 76 +++++++++++++ test/visual/definitions/alert.ts | 15 +++ test/visual/definitions/button.ts | 15 +++ test/visual/definitions/date-range-picker.ts | 40 +++++++ test/visual/definitions/index.ts | 12 +++ test/visual/definitions/table.ts | 15 +++ test/visual/types.ts | 23 ++++ test/visual/visual.test.ts | 6 ++ 18 files changed, 504 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/visual-regression.yml create mode 100644 build-tools/tasks/visual.js create mode 100644 build-tools/visual/global-setup.js create mode 100644 build-tools/visual/global-teardown.js create mode 100644 build-tools/visual/setup.js create mode 100644 jest.visual.config.js create mode 100644 test/visual/compare-screenshots.ts create mode 100644 test/visual/definitions/alert.ts create mode 100644 test/visual/definitions/button.ts create mode 100644 test/visual/definitions/date-range-picker.ts create mode 100644 test/visual/definitions/index.ts create mode 100644 test/visual/definitions/table.ts create mode 100644 test/visual/types.ts create mode 100644 test/visual/visual.test.ts diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml new file mode 100644 index 0000000000..a211178f4c --- /dev/null +++ b/.github/workflows/visual-regression.yml @@ -0,0 +1,86 @@ +name: Visual Regression Tests + +on: + pull_request: + branches: + - main + +defaults: + run: + shell: bash + +permissions: + id-token: write + contents: read + +jobs: + visual: + name: Visual regression + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + cache: npm + + - name: Install ChromeDriver + run: npm install -g chromedriver + + # ── Build PR (test) pages ────────────────────────────────────────────── + # Install the PR's dependencies and build its pages. + - name: Install PR dependencies + run: npm ci + + - name: Build PR pages + run: npx gulp quick-build + env: + NODE_ENV: production + + - name: Bundle PR pages + run: node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path pages/lib/static-default + env: + NODE_ENV: production + + # ── Build baseline (main) pages ──────────────────────────────────────── + # Use a git worktree so the baseline has its own directory and its own + # node_modules. This means a PR that changes package-lock.json will still + # produce a correct baseline: the baseline installs from main's lockfile + # and the PR build installs from the PR's lockfile, so both sides use the + # dependency versions that are correct for their respective source trees. + - name: Create baseline worktree from origin/main + run: git worktree add /tmp/baseline origin/main + + - name: Install baseline dependencies + run: npm ci + working-directory: /tmp/baseline + + - name: Build baseline pages + run: npx gulp quick-build + working-directory: /tmp/baseline + env: + NODE_ENV: production + + - name: Bundle baseline pages + run: node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path ${{ github.workspace }}/pages/lib/static-visual-baseline + working-directory: /tmp/baseline + env: + NODE_ENV: production + + # ── Run tests ───────────────────────────────────────────────────────── + - name: Run visual regression tests + run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js + env: + TZ: UTC + + - name: Upload diff artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: visual-regression-diffs + path: visual-regression-output/ + retention-days: 14 diff --git a/build-tools/tasks/visual.js b/build-tools/tasks/visual.js new file mode 100644 index 0000000000..0864790afb --- /dev/null +++ b/build-tools/tasks/visual.js @@ -0,0 +1,107 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +const execa = require('execa'); +const path = require('path'); +const fs = require('fs'); +const waitOn = require('wait-on'); +const { task } = require('../utils/gulp-utils.js'); +const { parseArgs } = require('node:util'); + +const BASELINE_WORKTREE = '/tmp/visual-baseline'; +const BASELINE_OUTPUT = path.resolve('pages/lib/static-visual-baseline'); +const TEST_OUTPUT = path.resolve('pages/lib/static-default'); + +// Port assignments: +// 8080 — test build (PR / local changes) +// 8081 — baseline build (main branch) +const TEST_PORT = 8080; +const BASELINE_PORT = 8081; + +/** + * Serves a pre-built static directory using webpack-dev-server in static mode. + */ +function serveStatic(dir, port) { + return execa( + 'node_modules/.bin/webpack', + ['serve', '--config', 'pages/webpack.config.integ.cjs', '--port', String(port), '--static', dir, '--no-hot'], + { env: { ...process.env, NODE_ENV: 'development' } } + ); +} + +/** + * Builds the dev pages from the source tree at `cwd` into `outputPath`. + * Uses the node_modules present in `cwd`. + */ +async function buildPages(cwd, outputPath) { + await execa('npx', ['gulp', 'quick-build'], { + stdio: 'inherit', + cwd, + env: { ...process.env, NODE_ENV: 'production' }, + }); + await execa( + path.join(cwd, 'node_modules/.bin/webpack'), + ['--config', 'pages/webpack.config.integ.cjs', '--output-path', outputPath], + { stdio: 'inherit', cwd, env: { ...process.env, NODE_ENV: 'production' } } + ); +} + +module.exports = task('test:visual', async () => { + const options = { + shard: { type: 'string' }, + // Pass --skip-build to skip the build steps when artifacts are already present. + skipBuild: { type: 'boolean' }, + }; + const { shard, skipBuild } = parseArgs({ options, strict: false }).values; + + const cwd = process.cwd(); + + if (!skipBuild) { + // ── 1. Build the test (PR) pages ──────────────────────────────────────── + console.log('Building test pages (current branch)…'); + await buildPages(cwd, TEST_OUTPUT); + + // ── 2. Build the baseline (main) pages ────────────────────────────────── + // Create a worktree for origin/main so it gets its own node_modules. + // This correctly handles PRs that change package-lock.json: each side + // installs from its own lockfile. + console.log('Setting up baseline worktree from origin/main…'); + if (fs.existsSync(BASELINE_WORKTREE)) { + await execa('git', ['worktree', 'remove', '--force', BASELINE_WORKTREE]); + } + await execa('git', ['worktree', 'add', BASELINE_WORKTREE, 'origin/main']); + + try { + console.log('Installing baseline dependencies…'); + await execa('npm', ['ci'], { stdio: 'inherit', cwd: BASELINE_WORKTREE }); + + console.log('Building baseline pages (origin/main)…'); + await buildPages(BASELINE_WORKTREE, BASELINE_OUTPUT); + } finally { + await execa('git', ['worktree', 'remove', '--force', BASELINE_WORKTREE]); + } + } + + // ── 3. Start both static servers ────────────────────────────────────────── + console.log(`Starting test server on :${TEST_PORT} (${TEST_OUTPUT})…`); + const testServer = serveStatic(TEST_OUTPUT, TEST_PORT); + + console.log(`Starting baseline server on :${BASELINE_PORT} (${BASELINE_OUTPUT})…`); + const baselineServer = serveStatic(BASELINE_OUTPUT, BASELINE_PORT); + + try { + await waitOn({ resources: [`http://localhost:${TEST_PORT}`, `http://localhost:${BASELINE_PORT}`] }); + + // ── 4. Run visual tests ────────────────────────────────────────────────── + const jestArgs = ['-c', 'jest.visual.config.js']; + if (shard) { + jestArgs.push(`--shard=${shard}`); + } + await execa('jest', jestArgs, { + stdio: 'inherit', + env: { ...process.env, NODE_OPTIONS: '--experimental-vm-modules' }, + }); + } finally { + testServer.cancel(); + baselineServer.cancel(); + } +}); diff --git a/build-tools/visual/global-setup.js b/build-tools/visual/global-setup.js new file mode 100644 index 0000000000..075ee6e398 --- /dev/null +++ b/build-tools/visual/global-setup.js @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +const { startWebdriver } = require('@cloudscape-design/browser-test-tools/chrome-launcher'); +module.exports = () => startWebdriver(); diff --git a/build-tools/visual/global-teardown.js b/build-tools/visual/global-teardown.js new file mode 100644 index 0000000000..57ad21b454 --- /dev/null +++ b/build-tools/visual/global-teardown.js @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +const { shutdownWebdriver } = require('@cloudscape-design/browser-test-tools/chrome-launcher'); +module.exports = () => shutdownWebdriver(); diff --git a/build-tools/visual/setup.js b/build-tools/visual/setup.js new file mode 100644 index 0000000000..2625a43809 --- /dev/null +++ b/build-tools/visual/setup.js @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +/* global jest */ +const { configure } = require('@cloudscape-design/browser-test-tools/use-browser'); + +// The PR build (the code under test) is served on port 8080. +// The baseline build (main branch, same node_modules) is served on port 8081. +configure({ + browserName: 'ChromeHeadlessIntegration', + browserCreatorOptions: { + seleniumUrl: 'http://localhost:9515', + }, + webdriverOptions: { + baseUrl: 'http://localhost:8080', + }, +}); + +jest.retryTimes(2, { logErrorsBeforeRetry: true }); diff --git a/docs/RUNNING_TESTS.md b/docs/RUNNING_TESTS.md index 525cdf181d..c5c483e981 100644 --- a/docs/RUNNING_TESTS.md +++ b/docs/RUNNING_TESTS.md @@ -60,11 +60,60 @@ TZ=UTC npx jest -u -c jest.unit.config.js src/ ``` ## Visual Regression Tests -> **Note:** The components repository does not have visual regression tests on GitHub. This section applies to other repositories such as chat-components, code-view, chart-components, and board-components. +Visual regression tests run automatically when opening a pull request in GitHub (see `.github/workflows/visual-regression.yml`). -Visual regression tests for permutation pages run automatically when opening a pull request in GitHub. +They compare permutation pages between the PR build and a baseline build of `main`, both served locally in the same CI job. Each side installs from its own `package-lock.json` via a git worktree, so dependency changes in the PR are handled correctly and unpinned updates in sister repositories affect both sides equally. -To check results: look at the "Visual Regression Tests" action in the PR. The "Test for regressions" step logs which pages failed. For a full report, download the `visual-regression-snapshots-results` artifact from the action summary. +### How it works -If there are unexpected regressions, fix your pull request. -If the changes are expected, call this out in your pull request comments. +1. The PR pages are built and served on port 8080. +2. A git worktree of `origin/main` is created, its dependencies installed, and its pages built and served on port 8081. +3. The single test runner (`test/visual/visual.test.ts`) iterates over all test definitions, captures the `.screenshot-area` element from both servers for each test, and fails if any pixels differ. + +### Running locally + +``` +npm run test:visual +``` + +This handles the full build and comparison in one command. If both outputs are already built, skip the build step: + +``` +NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js +``` + +(Requires both servers to be running — start the PR build with `npm run start:integ` on port 8080 and the baseline build on port 8081, or set `NEW_HOST` / `OLD_HOST` env vars to point at different hosts.) + +### Adding tests for a new component + +Create `test/visual/definitions/.ts`: + +```ts +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'my-component', + tests: [ + { + description: 'permutations', + path: 'my-component/permutations', + }, + ], +}; + +export default suite; +``` + +Then import and add it to `test/visual/definitions/index.ts`: + +```ts +import myComponent from './my-component'; + +export const allSuites: TestSuite[] = [..., myComponent]; +``` + +### Reviewing failures + +If the CI job fails, download the `visual-regression-diffs` artifact from the Actions summary. + +If the diff is expected (intentional visual change), note it in your PR description. diff --git a/eslint.config.mjs b/eslint.config.mjs index 0d9423aa5b..f03eb9ce60 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -225,7 +225,7 @@ export default tsEslint.config( }, }, { - files: ['**/__integ__/**', '**/__motion__/**', '**/__a11y__/**'], + files: ['**/__integ__/**', '**/__motion__/**', '**/__a11y__/**', 'test/visual/**'], rules: { // useBrowser is not a hook 'react-hooks/rules-of-hooks': 'off', diff --git a/gulpfile.js b/gulpfile.js index 6e3f389082..221c065982 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -19,6 +19,7 @@ const { generateI18nMessages, integ, motion, + visual, copyFiles, themeableSource, bundleVendorFiles, @@ -43,6 +44,7 @@ exports['test:unit'] = unit; exports['test:integ'] = integ; exports['test:a11y'] = a11y; exports['test:motion'] = motion; +exports['test:visual'] = visual; exports.watch = () => { watch( diff --git a/jest.visual.config.js b/jest.visual.config.js new file mode 100644 index 0000000000..7f1d020880 --- /dev/null +++ b/jest.visual.config.js @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +const path = require('path'); +const os = require('os'); + +module.exports = { + verbose: true, + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.integ.json', + }, + ], + }, + reporters: ['default', 'github-actions'], + testTimeout: 120_000, // 2min — pages can be tall and slow to capture + maxWorkers: os.cpus().length * (process.env.GITHUB_ACTION ? 3 : 1), + globalSetup: '/build-tools/visual/global-setup.js', + globalTeardown: '/build-tools/visual/global-teardown.js', + setupFilesAfterEnv: [path.join(__dirname, 'build-tools', 'visual', 'setup.js')], + moduleFileExtensions: ['js', 'ts'], + testMatch: ['/test/visual/visual.test.ts'], +}; diff --git a/package.json b/package.json index fda995324a..5a7a02c59f 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test:a11y": "gulp test:a11y", "test:integ": "gulp test:integ", "test:motion": "gulp test:motion", + "test:visual": "gulp test:visual", "lint": "npm-run-all --parallel lint:*", "lint:eslint": "eslint .", "lint:stylelint": "stylelint --ignore-path .gitignore '{src,pages}/**/*.{css,scss}'", diff --git a/test/visual/compare-screenshots.ts b/test/visual/compare-screenshots.ts new file mode 100644 index 0000000000..51254363ac --- /dev/null +++ b/test/visual/compare-screenshots.ts @@ -0,0 +1,76 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import pixelmatch from 'pixelmatch'; +import { PNG } from 'pngjs'; + +import { ScreenshotPageObject } from '@cloudscape-design/browser-test-tools/page-objects'; +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +import { TestDefinition, TestSuite } from './types'; + +const screenshotAreaSelector = '.screenshot-area'; +const defaultWindowSize = { width: 1600, height: 800 }; + +// NEW_HOST serves the PR's pages, OLD_HOST serves the baseline (main) pages. +const newHost = process.env.NEW_HOST || 'http://localhost:8080'; +const oldHost = process.env.OLD_HOST || 'http://localhost:8081'; + +async function captureScreenshot( + browser: WebdriverIO.Browser, + url: string, + setup?: (page: ScreenshotPageObject) => Promise +): Promise { + await browser.url(url); + const page = new ScreenshotPageObject(browser); + await page.waitForVisible(screenshotAreaSelector); + if (setup) { + await setup(page); + } + const { image } = await page.captureBySelector(screenshotAreaSelector); + return image; +} + +function buildUrl(host: string, path: string, queryParams?: Record): string { + const params = new URLSearchParams(queryParams); + const qs = params.toString(); + return `${host}/#/${path}${qs ? `?${qs}` : ''}`; +} + +function compareImages(newImage: PNG, oldImage: PNG): number { + const { width, height } = newImage; + const diff = new PNG({ width, height }); + return pixelmatch(newImage.data, oldImage.data, diff.data, width, height, { threshold: 0.1 }); +} + +function isTestDefinition(item: TestDefinition | TestSuite): item is TestDefinition { + return (item as TestDefinition).path !== undefined; +} + +export function runTestSuites(suites: Array) { + for (const item of suites) { + if (isTestDefinition(item)) { + runSingleTest(item); + } else { + describe(item.description, () => { + runTestSuites(item.tests); + }); + } + } +} + +function runSingleTest(testDef: TestDefinition) { + const windowSize = { ...defaultWindowSize, ...testDef.configuration }; + + test( + testDef.description, + useBrowser(windowSize, async browser => { + const newUrl = buildUrl(newHost, testDef.path, testDef.queryParams); + const newScreenshot = await captureScreenshot(browser, newUrl, testDef.setup); + + const oldUrl = buildUrl(oldHost, testDef.path, testDef.queryParams); + const oldScreenshot = await captureScreenshot(browser, oldUrl, testDef.setup); + const diffPixels = compareImages(newScreenshot, oldScreenshot); + expect(diffPixels).toBe(0); + }) + ); +} diff --git a/test/visual/definitions/alert.ts b/test/visual/definitions/alert.ts new file mode 100644 index 0000000000..37f0985f24 --- /dev/null +++ b/test/visual/definitions/alert.ts @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'alert', + tests: [ + { + description: 'permutations', + path: 'alert/permutations', + }, + ], +}; + +export default suite; diff --git a/test/visual/definitions/button.ts b/test/visual/definitions/button.ts new file mode 100644 index 0000000000..cb7d590a59 --- /dev/null +++ b/test/visual/definitions/button.ts @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'button', + tests: [ + { + description: 'permutations', + path: 'button/permutations', + }, + ], +}; + +export default suite; diff --git a/test/visual/definitions/date-range-picker.ts b/test/visual/definitions/date-range-picker.ts new file mode 100644 index 0000000000..6800eebc90 --- /dev/null +++ b/test/visual/definitions/date-range-picker.ts @@ -0,0 +1,40 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'date-range-picker', + tests: [ + { + description: 'with value', + path: 'date-range-picker/with-value', + }, + { + description: 'range calendar', + path: 'date-range-picker/range-calendar', + }, + { + description: 'month calendar permutations', + path: 'date-range-picker/month-calendar-permutations', + }, + { + description: 'year calendar permutations', + path: 'date-range-picker/year-calendar-permutations', + }, + { + description: 'in small viewport', + path: 'date-range-picker/small-viewport', + configuration: { width: 400 }, + }, + { + description: 'with dropdown open', + path: 'date-range-picker/with-value', + setup: async page => { + await page.click('[data-testid="date-range-picker-trigger"]'); + await page.waitForVisible('.awsui-context-content-header'); + }, + }, + ], +}; + +export default suite; diff --git a/test/visual/definitions/index.ts b/test/visual/definitions/index.ts new file mode 100644 index 0000000000..27f5c9e7ca --- /dev/null +++ b/test/visual/definitions/index.ts @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Each component has its own test definition file. +// Import them here manually to form the full test suite. +import { TestSuite } from '../types'; +import alert from './alert'; +import button from './button'; +import dateRangePicker from './date-range-picker'; +import table from './table'; + +export const allSuites: TestSuite[] = [alert, button, dateRangePicker, table]; diff --git a/test/visual/definitions/table.ts b/test/visual/definitions/table.ts new file mode 100644 index 0000000000..6529046d7b --- /dev/null +++ b/test/visual/definitions/table.ts @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'table', + tests: [ + { + description: 'permutations', + path: 'table/permutations', + }, + ], +}; + +export default suite; diff --git a/test/visual/types.ts b/test/visual/types.ts new file mode 100644 index 0000000000..7c4c90809a --- /dev/null +++ b/test/visual/types.ts @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ScreenshotPageObject } from '@cloudscape-design/browser-test-tools/page-objects'; + +export interface ScreenshotTestConfiguration { + width?: number; + height?: number; +} + +export type TestCallback = (page: ScreenshotPageObject) => Promise; + +export interface TestDefinition { + description: string; + path: string; + queryParams?: Record; + configuration?: ScreenshotTestConfiguration; + setup?: TestCallback; +} + +export interface TestSuite { + description: string; + tests: Array; +} diff --git a/test/visual/visual.test.ts b/test/visual/visual.test.ts new file mode 100644 index 0000000000..06ef0fa8a4 --- /dev/null +++ b/test/visual/visual.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from './compare-screenshots'; +import { allSuites } from './definitions'; + +runTestSuites(allSuites); From 47b7f087e49491d7619f6fa7af13d88fe74a70ca Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 11 May 2026 19:36:06 +0200 Subject: [PATCH 02/95] Install dependencies with npm i --- .github/workflows/visual-regression.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index a211178f4c..33e01c79fd 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -34,7 +34,7 @@ jobs: # ── Build PR (test) pages ────────────────────────────────────────────── # Install the PR's dependencies and build its pages. - name: Install PR dependencies - run: npm ci + run: npm i - name: Build PR pages run: npx gulp quick-build @@ -56,7 +56,7 @@ jobs: run: git worktree add /tmp/baseline origin/main - name: Install baseline dependencies - run: npm ci + run: npm i working-directory: /tmp/baseline - name: Build baseline pages From fb78d3a9e2478e883e1be8a12042acbd0df17cc9 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 11 May 2026 20:05:09 +0200 Subject: [PATCH 03/95] Use node 20 --- .github/workflows/visual-regression.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 33e01c79fd..642ab08bac 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -25,7 +25,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20 cache: npm - name: Install ChromeDriver From 16d40d7ede15e33af175c5fca3dea3e291aa97de Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 11 May 2026 20:12:53 +0200 Subject: [PATCH 04/95] Add pixelmatch types --- package-lock.json | 11 +++++++++++ package.json | 1 + 2 files changed, 12 insertions(+) diff --git a/package-lock.json b/package-lock.json index 1c7a32ecfd..660e509f79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "@types/jest": "^29.5.13", "@types/lodash": "^4.14.176", "@types/node": "^20.17.14", + "@types/pixelmatch": "^5.2.6", "@types/react": "^16.14.20", "@types/react-dom": "^16.9.14", "@types/react-is": "^18.2.0", @@ -4846,6 +4847,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pixelmatch": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/@types/pixelmatch/-/pixelmatch-5.2.6.tgz", + "integrity": "sha512-wC83uexE5KGuUODn6zkm9gMzTwdY5L0chiK+VrKcDfEjzxh1uadlWTvOmAbCpnM9zx/Ww3f8uKlYQVnO/TrqVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pngjs": { "version": "6.0.5", "dev": true, diff --git a/package.json b/package.json index 5a7a02c59f..7884df953b 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@types/jest": "^29.5.13", "@types/lodash": "^4.14.176", "@types/node": "^20.17.14", + "@types/pixelmatch": "^5.2.6", "@types/react": "^16.14.20", "@types/react-dom": "^16.9.14", "@types/react-is": "^18.2.0", From b6f63856503ae099a4b78b7ceab67fbbbe3bc2a9 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 11 May 2026 20:44:04 +0200 Subject: [PATCH 05/95] Install Chromedriver in CI --- .github/workflows/visual-regression.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 642ab08bac..90af77becf 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -28,8 +28,10 @@ jobs: node-version: 20 cache: npm - - name: Install ChromeDriver - run: npm install -g chromedriver + - name: Setup Chrome and ChromeDriver + uses: browser-actions/setup-chrome@v1 + with: + chrome-version: stable # ── Build PR (test) pages ────────────────────────────────────────────── # Install the PR's dependencies and build its pages. From 0df42fc04f9a3b30358303d5c5ffc9bcd2ec4b51 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 11 May 2026 21:01:25 +0200 Subject: [PATCH 06/95] Start servers --- .github/workflows/visual-regression.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 90af77becf..7ee7d42669 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -74,6 +74,19 @@ jobs: NODE_ENV: production # ── Run tests ───────────────────────────────────────────────────────── + - name: Start test server (port 8080) + run: node_modules/.bin/webpack serve --config pages/webpack.config.integ.cjs --port 8080 --static pages/lib/static-default --no-hot & + env: + NODE_ENV: development + + - name: Start baseline server (port 8081) + run: node_modules/.bin/webpack serve --config pages/webpack.config.integ.cjs --port 8081 --static pages/lib/static-visual-baseline --no-hot & + env: + NODE_ENV: development + + - name: Wait for servers to be ready + run: node_modules/.bin/wait-on http://localhost:8080 http://localhost:8081 + - name: Run visual regression tests run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js env: From 632df9b7ddf17b02d565a675d070c655279f96e2 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 11 May 2026 21:39:49 +0200 Subject: [PATCH 07/95] Install Puppeteer --- package-lock.json | 71 +++++++++++++++++++++++++++++++++++++++++++++-- package.json | 1 + 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 660e509f79..555d3fabdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,6 +97,7 @@ "mockdate": "^3.0.5", "npm-run-all": "^4.1.5", "prettier": "^3.6.1", + "puppeteer-core": "^24.43.1", "react": "^16.14.0", "react-dom": "^16.14.0", "react-dom18": "npm:react-dom@^18.3.1", @@ -3522,9 +3523,9 @@ } }, "node_modules/@puppeteer/browsers": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", - "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.2.tgz", + "integrity": "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -7273,6 +7274,20 @@ "node": ">=6.0" } }, + "node_modules/chromium-bidi": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", + "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/ci-info": { "version": "3.9.0", "dev": true, @@ -8789,6 +8804,13 @@ "dev": true, "license": "MIT" }, + "node_modules/devtools-protocol": { + "version": "0.0.1608973", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1608973.tgz", + "integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/diff-sequences": { "version": "29.6.3", "dev": true, @@ -17746,6 +17768,25 @@ "node": ">=6" } }, + "node_modules/puppeteer-core": { + "version": "24.43.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.1.tgz", + "integrity": "sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.2", + "chromium-bidi": "14.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1608973", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.1", + "ws": "^8.20.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "dev": true, @@ -21158,6 +21199,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, "node_modules/typedarray": { "version": "0.0.6", "dev": true, @@ -21675,6 +21723,13 @@ "node": ">=18.20.0" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", + "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/webdriver/node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -22452,6 +22507,16 @@ "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 7884df953b..d25129aa23 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "mockdate": "^3.0.5", "npm-run-all": "^4.1.5", "prettier": "^3.6.1", + "puppeteer-core": "^24.43.1", "react": "^16.14.0", "react-dom": "^16.14.0", "react-dom18": "npm:react-dom@^18.3.1", From 30bb9dbd17e6b5a8663740c8b0a004dac24f4c2e Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 11 May 2026 22:19:28 +0200 Subject: [PATCH 08/95] Capture screenshot area or permutations --- test/visual/compare-screenshots.ts | 51 +++++++++++++++++--- test/visual/definitions/action-card.ts | 31 ++++++++++++ test/visual/definitions/alert.ts | 11 +++++ test/visual/definitions/button.ts | 15 ------ test/visual/definitions/date-range-picker.ts | 40 --------------- test/visual/definitions/index.ts | 7 +-- test/visual/definitions/table.ts | 15 ------ test/visual/types.ts | 5 ++ 8 files changed, 93 insertions(+), 82 deletions(-) create mode 100644 test/visual/definitions/action-card.ts delete mode 100644 test/visual/definitions/button.ts delete mode 100644 test/visual/definitions/date-range-picker.ts delete mode 100644 test/visual/definitions/table.ts diff --git a/test/visual/compare-screenshots.ts b/test/visual/compare-screenshots.ts index 51254363ac..8847e10d21 100644 --- a/test/visual/compare-screenshots.ts +++ b/test/visual/compare-screenshots.ts @@ -3,6 +3,7 @@ import pixelmatch from 'pixelmatch'; import { PNG } from 'pngjs'; +import { parsePng } from '@cloudscape-design/browser-test-tools/image-utils'; import { ScreenshotPageObject } from '@cloudscape-design/browser-test-tools/page-objects'; import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; @@ -15,19 +16,52 @@ const defaultWindowSize = { width: 1600, height: 800 }; const newHost = process.env.NEW_HOST || 'http://localhost:8080'; const oldHost = process.env.OLD_HOST || 'http://localhost:8081'; +/** + * Captures the .screenshot-area element on a focused page. + * Uses a standard ScreenshotPageObject (no forced scroll-and-merge). + */ +async function captureScreenshotArea(browser: WebdriverIO.Browser, url: string): Promise { + await browser.url(url); + const page = new ScreenshotPageObject(browser); + await page.waitForVisible(screenshotAreaSelector); + const { image } = await page.captureBySelector(screenshotAreaSelector); + return image; +} + +/** + * Captures the full page as a PNG for permutation pages. + * Uses fullPageScreenshot which handles pages taller than the viewport. + */ +async function capturePermutations(browser: WebdriverIO.Browser, url: string): Promise { + await browser.url(url); + const page = new ScreenshotPageObject(browser); + await page.waitForVisible(screenshotAreaSelector); + const base64 = await page.fullPageScreenshot(); + return parsePng(base64); +} + async function captureScreenshot( browser: WebdriverIO.Browser, url: string, + testDef: TestDefinition, setup?: (page: ScreenshotPageObject) => Promise ): Promise { - await browser.url(url); - const page = new ScreenshotPageObject(browser); - await page.waitForVisible(screenshotAreaSelector); if (setup) { + await browser.url(url); + const page = new ScreenshotPageObject(browser); + await page.waitForVisible(screenshotAreaSelector); await setup(page); + if (testDef.screenshotType === 'permutations') { + const base64 = await page.fullPageScreenshot(); + return parsePng(base64); + } + const { image } = await page.captureBySelector(screenshotAreaSelector); + return image; } - const { image } = await page.captureBySelector(screenshotAreaSelector); - return image; + if (testDef.screenshotType === 'permutations') { + return capturePermutations(browser, url); + } + return captureScreenshotArea(browser, url); } function buildUrl(host: string, path: string, queryParams?: Record): string { @@ -65,12 +99,15 @@ function runSingleTest(testDef: TestDefinition) { testDef.description, useBrowser(windowSize, async browser => { const newUrl = buildUrl(newHost, testDef.path, testDef.queryParams); - const newScreenshot = await captureScreenshot(browser, newUrl, testDef.setup); + const newScreenshot = await captureScreenshot(browser, newUrl, testDef, testDef.setup); const oldUrl = buildUrl(oldHost, testDef.path, testDef.queryParams); - const oldScreenshot = await captureScreenshot(browser, oldUrl, testDef.setup); + const oldScreenshot = await captureScreenshot(browser, oldUrl, testDef, testDef.setup); const diffPixels = compareImages(newScreenshot, oldScreenshot); expect(diffPixels).toBe(0); }) ); } + +// Export the capture functions for use in custom setup callbacks if needed. +export { captureScreenshotArea, capturePermutations }; diff --git a/test/visual/definitions/action-card.ts b/test/visual/definitions/action-card.ts new file mode 100644 index 0000000000..8730ac9504 --- /dev/null +++ b/test/visual/definitions/action-card.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'action-card', + tests: [ + { + description: 'permutations', + path: 'action-card/permutations', + screenshotType: 'permutations', + }, + { + description: 'variant permutations', + path: 'action-card/variant-permutations', + screenshotType: 'permutations', + }, + { + description: 'padding permutations', + path: 'action-card/padding-permutations', + screenshotType: 'permutations', + }, + { + description: 'simple', + path: 'action-card/simple', + screenshotType: 'screenshotArea', + }, + ], +}; + +export default suite; diff --git a/test/visual/definitions/alert.ts b/test/visual/definitions/alert.ts index 37f0985f24..a272e4dfee 100644 --- a/test/visual/definitions/alert.ts +++ b/test/visual/definitions/alert.ts @@ -8,6 +8,17 @@ const suite: TestSuite = { { description: 'permutations', path: 'alert/permutations', + screenshotType: 'permutations', + }, + { + description: 'simple', + path: 'alert/simple', + screenshotType: 'screenshotArea', + }, + { + description: 'custom types', + path: 'alert/style-custom-types', + screenshotType: 'screenshotArea', }, ], }; diff --git a/test/visual/definitions/button.ts b/test/visual/definitions/button.ts deleted file mode 100644 index cb7d590a59..0000000000 --- a/test/visual/definitions/button.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { TestSuite } from '../types'; - -const suite: TestSuite = { - description: 'button', - tests: [ - { - description: 'permutations', - path: 'button/permutations', - }, - ], -}; - -export default suite; diff --git a/test/visual/definitions/date-range-picker.ts b/test/visual/definitions/date-range-picker.ts deleted file mode 100644 index 6800eebc90..0000000000 --- a/test/visual/definitions/date-range-picker.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { TestSuite } from '../types'; - -const suite: TestSuite = { - description: 'date-range-picker', - tests: [ - { - description: 'with value', - path: 'date-range-picker/with-value', - }, - { - description: 'range calendar', - path: 'date-range-picker/range-calendar', - }, - { - description: 'month calendar permutations', - path: 'date-range-picker/month-calendar-permutations', - }, - { - description: 'year calendar permutations', - path: 'date-range-picker/year-calendar-permutations', - }, - { - description: 'in small viewport', - path: 'date-range-picker/small-viewport', - configuration: { width: 400 }, - }, - { - description: 'with dropdown open', - path: 'date-range-picker/with-value', - setup: async page => { - await page.click('[data-testid="date-range-picker-trigger"]'); - await page.waitForVisible('.awsui-context-content-header'); - }, - }, - ], -}; - -export default suite; diff --git a/test/visual/definitions/index.ts b/test/visual/definitions/index.ts index 27f5c9e7ca..55c1374550 100644 --- a/test/visual/definitions/index.ts +++ b/test/visual/definitions/index.ts @@ -4,9 +4,6 @@ // Each component has its own test definition file. // Import them here manually to form the full test suite. import { TestSuite } from '../types'; -import alert from './alert'; -import button from './button'; -import dateRangePicker from './date-range-picker'; -import table from './table'; +import actionCard from './action-card'; -export const allSuites: TestSuite[] = [alert, button, dateRangePicker, table]; +export const allSuites: TestSuite[] = [actionCard]; diff --git a/test/visual/definitions/table.ts b/test/visual/definitions/table.ts deleted file mode 100644 index 6529046d7b..0000000000 --- a/test/visual/definitions/table.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { TestSuite } from '../types'; - -const suite: TestSuite = { - description: 'table', - tests: [ - { - description: 'permutations', - path: 'table/permutations', - }, - ], -}; - -export default suite; diff --git a/test/visual/types.ts b/test/visual/types.ts index 7c4c90809a..f0fa665863 100644 --- a/test/visual/types.ts +++ b/test/visual/types.ts @@ -9,9 +9,14 @@ export interface ScreenshotTestConfiguration { export type TestCallback = (page: ScreenshotPageObject) => Promise; +// 'screenshotArea' — captures the .screenshot-area element on a focused page. +// 'permutations' — captures the entire page and crops permutations out of it. +export type ScreenshotType = 'screenshotArea' | 'permutations'; + export interface TestDefinition { description: string; path: string; + screenshotType: ScreenshotType; queryParams?: Record; configuration?: ScreenshotTestConfiguration; setup?: TestCallback; From 3b11fe35ca5c7d04f9ba3321170c2d018586de02 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 13 May 2026 11:54:13 +0200 Subject: [PATCH 09/95] Reuse build --- .github/workflows/deploy.yml | 9 +++++ .github/workflows/visual-regression.yml | 45 ++++++++++++------------- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5593984949..01bb5a45d2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -65,3 +65,12 @@ jobs: with: artifact-name: dev-pages-react${{ matrix.react }} deployment-path: pages/lib/static-default + + visual: + name: Visual regression + needs: quick-build + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} + uses: ./.github/workflows/visual-regression.yml + secrets: inherit + with: + pr-artifact-name: dev-pages-react18 \ No newline at end of file diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 7ee7d42669..f358626055 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -1,9 +1,12 @@ name: Visual Regression Tests on: - pull_request: - branches: - - main + workflow_call: + inputs: + pr-artifact-name: + description: Name of the GitHub Actions artifact containing the PR's built dev pages + required: true + type: string defaults: run: @@ -33,27 +36,21 @@ jobs: with: chrome-version: stable - # ── Build PR (test) pages ────────────────────────────────────────────── - # Install the PR's dependencies and build its pages. - - name: Install PR dependencies + - name: Install dependencies run: npm i - - name: Build PR pages - run: npx gulp quick-build - env: - NODE_ENV: production - - - name: Bundle PR pages - run: node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path pages/lib/static-default - env: - NODE_ENV: production - - # ── Build baseline (main) pages ──────────────────────────────────────── - # Use a git worktree so the baseline has its own directory and its own - # node_modules. This means a PR that changes package-lock.json will still - # produce a correct baseline: the baseline installs from main's lockfile - # and the PR build installs from the PR's lockfile, so both sides use the - # dependency versions that are correct for their respective source trees. + # ── PR pages: reuse the artifact built by the build job ─────────────── + - name: Download PR pages artifact + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.pr-artifact-name }} + path: pages/lib/static-default + + # ── Baseline (main) pages: build from origin/main ───────────────────── + # GitHub Actions artifacts are scoped to a workflow run, so there is no + # built-in way to reuse a previous main-branch artifact without the API. + # We build main locally instead — it shares node_modules with the PR + # checkout, so both sides resolve the same dependency versions. - name: Create baseline worktree from origin/main run: git worktree add /tmp/baseline origin/main @@ -73,8 +70,8 @@ jobs: env: NODE_ENV: production - # ── Run tests ───────────────────────────────────────────────────────── - - name: Start test server (port 8080) + # ── Start both servers and run tests ────────────────────────────────── + - name: Start PR server (port 8080) run: node_modules/.bin/webpack serve --config pages/webpack.config.integ.cjs --port 8080 --static pages/lib/static-default --no-hot & env: NODE_ENV: development From d59cf2ed587c2401ce346c99c945e6f49000585e Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 13 May 2026 12:16:27 +0200 Subject: [PATCH 10/95] Fix wofklow deps --- .github/workflows/visual-regression.yml | 98 ------------------------- 1 file changed, 98 deletions(-) delete mode 100644 .github/workflows/visual-regression.yml diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml deleted file mode 100644 index f358626055..0000000000 --- a/.github/workflows/visual-regression.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Visual Regression Tests - -on: - workflow_call: - inputs: - pr-artifact-name: - description: Name of the GitHub Actions artifact containing the PR's built dev pages - required: true - type: string - -defaults: - run: - shell: bash - -permissions: - id-token: write - contents: read - -jobs: - visual: - name: Visual regression - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - - name: Setup Chrome and ChromeDriver - uses: browser-actions/setup-chrome@v1 - with: - chrome-version: stable - - - name: Install dependencies - run: npm i - - # ── PR pages: reuse the artifact built by the build job ─────────────── - - name: Download PR pages artifact - uses: actions/download-artifact@v4 - with: - name: ${{ inputs.pr-artifact-name }} - path: pages/lib/static-default - - # ── Baseline (main) pages: build from origin/main ───────────────────── - # GitHub Actions artifacts are scoped to a workflow run, so there is no - # built-in way to reuse a previous main-branch artifact without the API. - # We build main locally instead — it shares node_modules with the PR - # checkout, so both sides resolve the same dependency versions. - - name: Create baseline worktree from origin/main - run: git worktree add /tmp/baseline origin/main - - - name: Install baseline dependencies - run: npm i - working-directory: /tmp/baseline - - - name: Build baseline pages - run: npx gulp quick-build - working-directory: /tmp/baseline - env: - NODE_ENV: production - - - name: Bundle baseline pages - run: node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path ${{ github.workspace }}/pages/lib/static-visual-baseline - working-directory: /tmp/baseline - env: - NODE_ENV: production - - # ── Start both servers and run tests ────────────────────────────────── - - name: Start PR server (port 8080) - run: node_modules/.bin/webpack serve --config pages/webpack.config.integ.cjs --port 8080 --static pages/lib/static-default --no-hot & - env: - NODE_ENV: development - - - name: Start baseline server (port 8081) - run: node_modules/.bin/webpack serve --config pages/webpack.config.integ.cjs --port 8081 --static pages/lib/static-visual-baseline --no-hot & - env: - NODE_ENV: development - - - name: Wait for servers to be ready - run: node_modules/.bin/wait-on http://localhost:8080 http://localhost:8081 - - - name: Run visual regression tests - run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js - env: - TZ: UTC - - - name: Upload diff artifacts - if: failure() - uses: actions/upload-artifact@v4 - with: - name: visual-regression-diffs - path: visual-regression-output/ - retention-days: 14 From f54fbc516deae8c85a2c79f84b5bb91384f5c1d4 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 13 May 2026 12:36:12 +0200 Subject: [PATCH 11/95] Again --- .github/workflows/visual-regression.yml | 120 ++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 .github/workflows/visual-regression.yml diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml new file mode 100644 index 0000000000..bbe372b7d6 --- /dev/null +++ b/.github/workflows/visual-regression.yml @@ -0,0 +1,120 @@ +name: Visual Regression Tests + +on: + workflow_run: + workflows: + - Build, lint and test + types: + - completed + +defaults: + run: + shell: bash + +permissions: + id-token: write + contents: read + actions: read + +jobs: + visual: + name: Visual regression + # Only run on PRs from the same repo (not forks), and only when the build succeeded. + if: > + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_repository.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Check out the PR head commit, not the merge commit that workflow_run uses. + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Setup Chrome and ChromeDriver + uses: browser-actions/setup-chrome@v1 + with: + chrome-version: stable + + - name: Install dependencies + run: npm ci + + # ── PR pages: download the artifact produced by the build workflow ───── + - name: Download PR pages artifact + uses: actions/github-script@v7 + with: + script: | + const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{ github.event.workflow_run.id }}, + }); + const artifact = artifacts.data.artifacts.find(a => a.name === 'dev-pages-react18'); + if (!artifact) throw new Error('dev-pages-react18 artifact not found'); + const download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: artifact.id, + archive_format: 'zip', + }); + const fs = require('fs'); + fs.writeFileSync('/tmp/dev-pages.zip', Buffer.from(download.data)); + + - name: Extract PR pages artifact + run: | + mkdir -p pages/lib/static-default + unzip /tmp/dev-pages.zip -d pages/lib/static-default + + # ── Baseline (main) pages: build from origin/main ───────────────────── + - name: Create baseline worktree from origin/main + run: git worktree add /tmp/baseline origin/main + + - name: Install baseline dependencies + run: npm ci + working-directory: /tmp/baseline + + - name: Build baseline pages + run: npx gulp quick-build + working-directory: /tmp/baseline + env: + NODE_ENV: production + + - name: Bundle baseline pages + run: node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path ${{ github.workspace }}/pages/lib/static-visual-baseline + working-directory: /tmp/baseline + env: + NODE_ENV: production + + # ── Start both servers and run tests ────────────────────────────────── + - name: Start PR server (port 8080) + run: node_modules/.bin/webpack serve --config pages/webpack.config.integ.cjs --port 8080 --static pages/lib/static-default --no-hot & + env: + NODE_ENV: development + + - name: Start baseline server (port 8081) + run: node_modules/.bin/webpack serve --config pages/webpack.config.integ.cjs --port 8081 --static pages/lib/static-visual-baseline --no-hot & + env: + NODE_ENV: development + + - name: Wait for servers to be ready + run: node_modules/.bin/wait-on http://localhost:8080 http://localhost:8081 + + - name: Run visual regression tests + run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js + env: + TZ: UTC + + - name: Upload diff artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: visual-regression-diffs + path: visual-regression-output/ + retention-days: 14 From 286c91394fb4b2eb8e622d4b087b23d38494b8e1 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 13 May 2026 12:50:50 +0200 Subject: [PATCH 12/95] Fix npm install command --- .github/workflows/visual-regression.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index bbe372b7d6..858a35a01e 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -44,7 +44,7 @@ jobs: chrome-version: stable - name: Install dependencies - run: npm ci + run: npm i # ── PR pages: download the artifact produced by the build workflow ───── - name: Download PR pages artifact @@ -77,7 +77,7 @@ jobs: run: git worktree add /tmp/baseline origin/main - name: Install baseline dependencies - run: npm ci + run: npm i working-directory: /tmp/baseline - name: Build baseline pages From 61d7f035bb4c8f668bad7c480dbda8ed8b05da67 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 13 May 2026 13:09:44 +0200 Subject: [PATCH 13/95] Wait only for the build --- .github/workflows/build-lint-test.yml | 14 ++++------- .github/workflows/build.yml | 31 +++++++++++++++++++++++++ .github/workflows/visual-regression.yml | 2 +- 3 files changed, 37 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build-lint-test.yml b/.github/workflows/build-lint-test.yml index d9e7487d3d..553dbc4d02 100644 --- a/.github/workflows/build-lint-test.yml +++ b/.github/workflows/build-lint-test.yml @@ -1,15 +1,11 @@ name: Build, lint and test on: - pull_request: - branches: - - main - merge_group: - branches: - - main - push: - branches: - - main + workflow_run: + workflows: + - Build + types: + - completed permissions: id-token: write diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..9ddca205f7 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,31 @@ +name: Build + +on: + pull_request: + branches: + - main + merge_group: + branches: + - main + push: + branches: + - main + +permissions: + id-token: write + actions: read + contents: read + security-events: write + +jobs: + build: + name: build${{ matrix.react != 16 && format(' (React {0})', matrix.react) || '' }} + strategy: + matrix: + react: [16, 18] + uses: cloudscape-design/actions/.github/workflows/build-lint-test.yml@main + secrets: inherit + with: + artifact-path: pages/lib/static-default + artifact-name: dev-pages-react${{ matrix.react }} + react-version: ${{ matrix.react }} diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 858a35a01e..224e7bb94a 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -3,7 +3,7 @@ name: Visual Regression Tests on: workflow_run: workflows: - - Build, lint and test + - Build types: - completed From 9f7c5c5817ce507d9a4958841b14de2c052ed0db Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 13 May 2026 14:05:37 +0200 Subject: [PATCH 14/95] Revert "Wait only for the build" This reverts commit 23a62c420b23f83e90190060649e3b6895e84e50. --- .github/workflows/build-lint-test.yml | 14 +++++++---- .github/workflows/build.yml | 31 ------------------------- .github/workflows/visual-regression.yml | 2 +- 3 files changed, 10 insertions(+), 37 deletions(-) delete mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build-lint-test.yml b/.github/workflows/build-lint-test.yml index 553dbc4d02..d9e7487d3d 100644 --- a/.github/workflows/build-lint-test.yml +++ b/.github/workflows/build-lint-test.yml @@ -1,11 +1,15 @@ name: Build, lint and test on: - workflow_run: - workflows: - - Build - types: - - completed + pull_request: + branches: + - main + merge_group: + branches: + - main + push: + branches: + - main permissions: id-token: write diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 9ddca205f7..0000000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Build - -on: - pull_request: - branches: - - main - merge_group: - branches: - - main - push: - branches: - - main - -permissions: - id-token: write - actions: read - contents: read - security-events: write - -jobs: - build: - name: build${{ matrix.react != 16 && format(' (React {0})', matrix.react) || '' }} - strategy: - matrix: - react: [16, 18] - uses: cloudscape-design/actions/.github/workflows/build-lint-test.yml@main - secrets: inherit - with: - artifact-path: pages/lib/static-default - artifact-name: dev-pages-react${{ matrix.react }} - react-version: ${{ matrix.react }} diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 224e7bb94a..858a35a01e 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -3,7 +3,7 @@ name: Visual Regression Tests on: workflow_run: workflows: - - Build + - Build, lint and test types: - completed From 043bf4eb1046e1a0a8d1bcb221c8e768361fcbfb Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 13 May 2026 14:05:44 +0200 Subject: [PATCH 15/95] Revert "Fix npm install command" This reverts commit ffb88c1feb2fa9076c3463d4800c921f05fa66d7. --- .github/workflows/visual-regression.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 858a35a01e..bbe372b7d6 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -44,7 +44,7 @@ jobs: chrome-version: stable - name: Install dependencies - run: npm i + run: npm ci # ── PR pages: download the artifact produced by the build workflow ───── - name: Download PR pages artifact @@ -77,7 +77,7 @@ jobs: run: git worktree add /tmp/baseline origin/main - name: Install baseline dependencies - run: npm i + run: npm ci working-directory: /tmp/baseline - name: Build baseline pages From d1c1a8b3c9304bda5182ba9cca1af12b2be135e6 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 13 May 2026 14:05:46 +0200 Subject: [PATCH 16/95] Revert "Again" This reverts commit 263a253c442ce54ea385252ef8291cce91839172. --- .github/workflows/visual-regression.yml | 120 ------------------------ 1 file changed, 120 deletions(-) delete mode 100644 .github/workflows/visual-regression.yml diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml deleted file mode 100644 index bbe372b7d6..0000000000 --- a/.github/workflows/visual-regression.yml +++ /dev/null @@ -1,120 +0,0 @@ -name: Visual Regression Tests - -on: - workflow_run: - workflows: - - Build, lint and test - types: - - completed - -defaults: - run: - shell: bash - -permissions: - id-token: write - contents: read - actions: read - -jobs: - visual: - name: Visual regression - # Only run on PRs from the same repo (not forks), and only when the build succeeded. - if: > - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_repository.full_name == github.repository - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - # Check out the PR head commit, not the merge commit that workflow_run uses. - ref: ${{ github.event.workflow_run.head_sha }} - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - - name: Setup Chrome and ChromeDriver - uses: browser-actions/setup-chrome@v1 - with: - chrome-version: stable - - - name: Install dependencies - run: npm ci - - # ── PR pages: download the artifact produced by the build workflow ───── - - name: Download PR pages artifact - uses: actions/github-script@v7 - with: - script: | - const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: ${{ github.event.workflow_run.id }}, - }); - const artifact = artifacts.data.artifacts.find(a => a.name === 'dev-pages-react18'); - if (!artifact) throw new Error('dev-pages-react18 artifact not found'); - const download = await github.rest.actions.downloadArtifact({ - owner: context.repo.owner, - repo: context.repo.repo, - artifact_id: artifact.id, - archive_format: 'zip', - }); - const fs = require('fs'); - fs.writeFileSync('/tmp/dev-pages.zip', Buffer.from(download.data)); - - - name: Extract PR pages artifact - run: | - mkdir -p pages/lib/static-default - unzip /tmp/dev-pages.zip -d pages/lib/static-default - - # ── Baseline (main) pages: build from origin/main ───────────────────── - - name: Create baseline worktree from origin/main - run: git worktree add /tmp/baseline origin/main - - - name: Install baseline dependencies - run: npm ci - working-directory: /tmp/baseline - - - name: Build baseline pages - run: npx gulp quick-build - working-directory: /tmp/baseline - env: - NODE_ENV: production - - - name: Bundle baseline pages - run: node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path ${{ github.workspace }}/pages/lib/static-visual-baseline - working-directory: /tmp/baseline - env: - NODE_ENV: production - - # ── Start both servers and run tests ────────────────────────────────── - - name: Start PR server (port 8080) - run: node_modules/.bin/webpack serve --config pages/webpack.config.integ.cjs --port 8080 --static pages/lib/static-default --no-hot & - env: - NODE_ENV: development - - - name: Start baseline server (port 8081) - run: node_modules/.bin/webpack serve --config pages/webpack.config.integ.cjs --port 8081 --static pages/lib/static-visual-baseline --no-hot & - env: - NODE_ENV: development - - - name: Wait for servers to be ready - run: node_modules/.bin/wait-on http://localhost:8080 http://localhost:8081 - - - name: Run visual regression tests - run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js - env: - TZ: UTC - - - name: Upload diff artifacts - if: failure() - uses: actions/upload-artifact@v4 - with: - name: visual-regression-diffs - path: visual-regression-output/ - retention-days: 14 From a6ef608a0364490d39245e303e0722343a649000 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 13 May 2026 14:05:52 +0200 Subject: [PATCH 17/95] Revert "Fix wofklow deps" This reverts commit b1eee0b5ba3596aa6e90c6be8e917edf7af898ba. --- .github/workflows/visual-regression.yml | 98 +++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 .github/workflows/visual-regression.yml diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml new file mode 100644 index 0000000000..f358626055 --- /dev/null +++ b/.github/workflows/visual-regression.yml @@ -0,0 +1,98 @@ +name: Visual Regression Tests + +on: + workflow_call: + inputs: + pr-artifact-name: + description: Name of the GitHub Actions artifact containing the PR's built dev pages + required: true + type: string + +defaults: + run: + shell: bash + +permissions: + id-token: write + contents: read + +jobs: + visual: + name: Visual regression + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Setup Chrome and ChromeDriver + uses: browser-actions/setup-chrome@v1 + with: + chrome-version: stable + + - name: Install dependencies + run: npm i + + # ── PR pages: reuse the artifact built by the build job ─────────────── + - name: Download PR pages artifact + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.pr-artifact-name }} + path: pages/lib/static-default + + # ── Baseline (main) pages: build from origin/main ───────────────────── + # GitHub Actions artifacts are scoped to a workflow run, so there is no + # built-in way to reuse a previous main-branch artifact without the API. + # We build main locally instead — it shares node_modules with the PR + # checkout, so both sides resolve the same dependency versions. + - name: Create baseline worktree from origin/main + run: git worktree add /tmp/baseline origin/main + + - name: Install baseline dependencies + run: npm i + working-directory: /tmp/baseline + + - name: Build baseline pages + run: npx gulp quick-build + working-directory: /tmp/baseline + env: + NODE_ENV: production + + - name: Bundle baseline pages + run: node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path ${{ github.workspace }}/pages/lib/static-visual-baseline + working-directory: /tmp/baseline + env: + NODE_ENV: production + + # ── Start both servers and run tests ────────────────────────────────── + - name: Start PR server (port 8080) + run: node_modules/.bin/webpack serve --config pages/webpack.config.integ.cjs --port 8080 --static pages/lib/static-default --no-hot & + env: + NODE_ENV: development + + - name: Start baseline server (port 8081) + run: node_modules/.bin/webpack serve --config pages/webpack.config.integ.cjs --port 8081 --static pages/lib/static-visual-baseline --no-hot & + env: + NODE_ENV: development + + - name: Wait for servers to be ready + run: node_modules/.bin/wait-on http://localhost:8080 http://localhost:8081 + + - name: Run visual regression tests + run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js + env: + TZ: UTC + + - name: Upload diff artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: visual-regression-diffs + path: visual-regression-output/ + retention-days: 14 From f6661e74a6a86e1778ba11a5fe59e7c52e5bb7c1 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 13 May 2026 14:05:53 +0200 Subject: [PATCH 18/95] Revert "Reuse build" This reverts commit 1f71f10a69ef3b99627b3f8bec675dc7a2e4802f. --- .github/workflows/visual-regression.yml | 45 +++++++++++++------------ 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index f358626055..7ee7d42669 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -1,12 +1,9 @@ name: Visual Regression Tests on: - workflow_call: - inputs: - pr-artifact-name: - description: Name of the GitHub Actions artifact containing the PR's built dev pages - required: true - type: string + pull_request: + branches: + - main defaults: run: @@ -36,21 +33,27 @@ jobs: with: chrome-version: stable - - name: Install dependencies + # ── Build PR (test) pages ────────────────────────────────────────────── + # Install the PR's dependencies and build its pages. + - name: Install PR dependencies run: npm i - # ── PR pages: reuse the artifact built by the build job ─────────────── - - name: Download PR pages artifact - uses: actions/download-artifact@v4 - with: - name: ${{ inputs.pr-artifact-name }} - path: pages/lib/static-default - - # ── Baseline (main) pages: build from origin/main ───────────────────── - # GitHub Actions artifacts are scoped to a workflow run, so there is no - # built-in way to reuse a previous main-branch artifact without the API. - # We build main locally instead — it shares node_modules with the PR - # checkout, so both sides resolve the same dependency versions. + - name: Build PR pages + run: npx gulp quick-build + env: + NODE_ENV: production + + - name: Bundle PR pages + run: node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path pages/lib/static-default + env: + NODE_ENV: production + + # ── Build baseline (main) pages ──────────────────────────────────────── + # Use a git worktree so the baseline has its own directory and its own + # node_modules. This means a PR that changes package-lock.json will still + # produce a correct baseline: the baseline installs from main's lockfile + # and the PR build installs from the PR's lockfile, so both sides use the + # dependency versions that are correct for their respective source trees. - name: Create baseline worktree from origin/main run: git worktree add /tmp/baseline origin/main @@ -70,8 +73,8 @@ jobs: env: NODE_ENV: production - # ── Start both servers and run tests ────────────────────────────────── - - name: Start PR server (port 8080) + # ── Run tests ───────────────────────────────────────────────────────── + - name: Start test server (port 8080) run: node_modules/.bin/webpack serve --config pages/webpack.config.integ.cjs --port 8080 --static pages/lib/static-default --no-hot & env: NODE_ENV: development From 824aeb36ac11ed55619f5cb3076a41b6c3c1f797 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 13 May 2026 15:20:37 +0200 Subject: [PATCH 19/95] Include alert tests --- test/visual/definitions/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/visual/definitions/index.ts b/test/visual/definitions/index.ts index 55c1374550..318ce7c68b 100644 --- a/test/visual/definitions/index.ts +++ b/test/visual/definitions/index.ts @@ -5,5 +5,6 @@ // Import them here manually to form the full test suite. import { TestSuite } from '../types'; import actionCard from './action-card'; +import alert from './alert'; -export const allSuites: TestSuite[] = [actionCard]; +export const allSuites: TestSuite[] = [actionCard, alert]; From 533cade9c3a4a8035f4e0660d3561b229f292478 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 13 May 2026 15:21:01 +0200 Subject: [PATCH 20/95] Add more alert tests --- test/visual/definitions/alert.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/test/visual/definitions/alert.ts b/test/visual/definitions/alert.ts index a272e4dfee..30792c0d2d 100644 --- a/test/visual/definitions/alert.ts +++ b/test/visual/definitions/alert.ts @@ -1,25 +1,36 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 + import { TestSuite } from '../types'; const suite: TestSuite = { description: 'alert', tests: [ - { - description: 'permutations', - path: 'alert/permutations', - screenshotType: 'permutations', - }, { description: 'simple', path: 'alert/simple', screenshotType: 'screenshotArea', }, { - description: 'custom types', + description: 'style custom page', path: 'alert/style-custom-types', screenshotType: 'screenshotArea', }, + ...[600, 1280].map(width => ({ + description: `width ${width}px`, + tests: [ + { + description: 'permutations', + path: 'alert/permutations', + screenshotType: 'permutations' as const, + }, + { + description: 'custom types', + path: 'alert/style-custom-types', + screenshotType: 'screenshotArea' as const, + }, + ], + })), ], }; From 57086c62443b5111ad0de46f48042c60fb816ce0 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 13 May 2026 15:46:26 +0200 Subject: [PATCH 21/95] chore: Export visual test definitions --- build-tools/tasks/index.js | 5 +++++ build-tools/tasks/visual-definitions.js | 8 ++++++++ gulpfile.js | 2 ++ test/visual/types.ts | 2 +- tsconfig.visual-definitions.json | 19 +++++++++++++++++++ 5 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 build-tools/tasks/visual-definitions.js create mode 100644 tsconfig.visual-definitions.json diff --git a/build-tools/tasks/index.js b/build-tools/tasks/index.js index d7db3c0784..413695be14 100644 --- a/build-tools/tasks/index.js +++ b/build-tools/tasks/index.js @@ -21,5 +21,10 @@ module.exports = { themeableSource: require('./themeable-source'), bundleVendorFiles: require('./bundle-vendor-files'), sizeLimit: require('./size-limit'), +<<<<<<< HEAD testDefinitions: require('./test-definitions'), +======= + visual: require('./visual'), + visualDefinitions: require('./visual-definitions'), +>>>>>>> 4213557f5 (chore: Export visual test definitions) }; diff --git a/build-tools/tasks/visual-definitions.js b/build-tools/tasks/visual-definitions.js new file mode 100644 index 0000000000..34dfe16adf --- /dev/null +++ b/build-tools/tasks/visual-definitions.js @@ -0,0 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +const execa = require('execa'); +const { task } = require('../utils/gulp-utils'); + +module.exports = task('visual-definitions', () => + execa('tsc', ['-p', 'tsconfig.visual-definitions.json'], { stdio: 'inherit' }) +); diff --git a/gulpfile.js b/gulpfile.js index 221c065982..09e0833868 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -20,6 +20,7 @@ const { integ, motion, visual, + visualDefinitions, copyFiles, themeableSource, bundleVendorFiles, @@ -45,6 +46,7 @@ exports['test:integ'] = integ; exports['test:a11y'] = a11y; exports['test:motion'] = motion; exports['test:visual'] = visual; +exports['build:visual-definitions'] = visualDefinitions; exports.watch = () => { watch( diff --git a/test/visual/types.ts b/test/visual/types.ts index f0fa665863..c4c23b622f 100644 --- a/test/visual/types.ts +++ b/test/visual/types.ts @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { ScreenshotPageObject } from '@cloudscape-design/browser-test-tools/page-objects'; +import type { ScreenshotPageObject } from '@cloudscape-design/browser-test-tools/page-objects'; export interface ScreenshotTestConfiguration { width?: number; diff --git a/tsconfig.visual-definitions.json b/tsconfig.visual-definitions.json new file mode 100644 index 0000000000..103f77ee47 --- /dev/null +++ b/tsconfig.visual-definitions.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "lib": ["ES2021"], + "target": "ES2019", + "types": [], + "module": "CommonJS", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "inlineSources": true, + "rootDir": "test/visual", + "outDir": "lib/visual-test-definitions" + }, + "include": ["test/visual/definitions", "test/visual/types.ts"], + "exclude": [] +} From aa01ece186645382f509a751030cdc312bc6b044 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 18 May 2026 15:17:49 +0200 Subject: [PATCH 22/95] Use the quick build --- .github/workflows/visual-regression.yml | 31 ++++++++++++++++--------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 7ee7d42669..35ae55ba9b 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -13,6 +13,11 @@ permissions: id-token: write contents: read +inputs: + pr-artifact-name: + description: 'Name of the artifact containing PR pages (built by quick-build job). If not provided, pages will be built locally.' + required: false + jobs: visual: name: Visual regression @@ -33,20 +38,24 @@ jobs: with: chrome-version: stable - # ── Build PR (test) pages ────────────────────────────────────────────── - # Install the PR's dependencies and build its pages. - - name: Install PR dependencies - run: npm i - - - name: Build PR pages - run: npx gulp quick-build + # ── Build or download PR (test) pages ───────────────────────────────── + # When called from deploy.yml with pr-artifact-name, download the artifact. + # When run standalone (no pr-artifact-name provided), build the pages locally. + - name: Build PR pages locally + if: ${{ !inputs.pr-artifact-name }} + run: | + npm i + npx gulp quick-build + node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path pages/lib/static-default env: NODE_ENV: production - - name: Bundle PR pages - run: node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path pages/lib/static-default - env: - NODE_ENV: production + - name: Download PR pages artifact + if: ${{ inputs.pr-artifact-name }} + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.pr-artifact-name }} + path: pages/lib/static-default # ── Build baseline (main) pages ──────────────────────────────────────── # Use a git worktree so the baseline has its own directory and its own From b6767f634d0b9e9c677b5d180fb4e4ad47f57cc1 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 18 May 2026 18:16:20 +0200 Subject: [PATCH 23/95] Fix tsconfig --- tsconfig.visual-definitions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.visual-definitions.json b/tsconfig.visual-definitions.json index 103f77ee47..a31ccd02d3 100644 --- a/tsconfig.visual-definitions.json +++ b/tsconfig.visual-definitions.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "lib": ["ES2021"], + "lib": ["ES2021", "DOM"], "target": "ES2019", "types": [], "module": "CommonJS", From d7182b239c28fe031ad9cec15e3888bad64e02ae Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 18 May 2026 18:20:07 +0200 Subject: [PATCH 24/95] Fix workflow --- .github/workflows/visual-regression.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 35ae55ba9b..c6d1bf031a 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -4,6 +4,12 @@ on: pull_request: branches: - main + workflow_call: + inputs: + pr-artifact-name: + description: 'Name of the artifact containing PR pages (built by quick-build job). If not provided, pages will be built locally.' + required: false + type: string defaults: run: @@ -13,11 +19,6 @@ permissions: id-token: write contents: read -inputs: - pr-artifact-name: - description: 'Name of the artifact containing PR pages (built by quick-build job). If not provided, pages will be built locally.' - required: false - jobs: visual: name: Visual regression From 6324d9a5d2e7461d49fdc56b583d53ea1a711652 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 18 May 2026 18:41:58 +0200 Subject: [PATCH 25/95] Fix workflow --- .github/workflows/visual-regression.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index c6d1bf031a..d3b36a4ac9 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -39,13 +39,15 @@ jobs: with: chrome-version: stable + - name: Install dependencies + run: npm i + # ── Build or download PR (test) pages ───────────────────────────────── # When called from deploy.yml with pr-artifact-name, download the artifact. # When run standalone (no pr-artifact-name provided), build the pages locally. - name: Build PR pages locally if: ${{ !inputs.pr-artifact-name }} run: | - npm i npx gulp quick-build node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path pages/lib/static-default env: From 49325de9893dcc14f14d2af3effc3f42fdcba207 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 18 May 2026 19:01:10 +0200 Subject: [PATCH 26/95] Use serve --- .github/workflows/visual-regression.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index d3b36a4ac9..742cc72f68 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -87,12 +87,12 @@ jobs: # ── Run tests ───────────────────────────────────────────────────────── - name: Start test server (port 8080) - run: node_modules/.bin/webpack serve --config pages/webpack.config.integ.cjs --port 8080 --static pages/lib/static-default --no-hot & + run: npx serve --no-clipboard --listen 8080 pages/lib/static-default & env: NODE_ENV: development - name: Start baseline server (port 8081) - run: node_modules/.bin/webpack serve --config pages/webpack.config.integ.cjs --port 8081 --static pages/lib/static-visual-baseline --no-hot & + run: npx serve --no-clipboard --listen 8081 pages/lib/static-visual-baseline & env: NODE_ENV: development From d5b8d6cec8eb010da58988f6c832091d0182f664 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 18 May 2026 19:10:56 +0200 Subject: [PATCH 27/95] Run tests on Safari --- .github/workflows/visual-regression.yml | 27 ++++++++++++++++--------- build-tools/visual/global-setup.js | 20 ++++++++++++++++-- build-tools/visual/global-teardown.js | 12 +++++++++-- build-tools/visual/setup.js | 6 ++++-- 4 files changed, 49 insertions(+), 16 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 742cc72f68..39cea38599 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -21,8 +21,16 @@ permissions: jobs: visual: - name: Visual regression - runs-on: ubuntu-latest + name: Visual regression (${{ matrix.browser }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - browser: chrome + os: ubuntu-latest + - browser: safari + os: macos-latest steps: - uses: actions/checkout@v4 with: @@ -35,16 +43,19 @@ jobs: cache: npm - name: Setup Chrome and ChromeDriver + if: matrix.browser == 'chrome' uses: browser-actions/setup-chrome@v1 with: chrome-version: stable + - name: Enable SafariDriver + if: matrix.browser == 'safari' + run: sudo safaridriver --enable + - name: Install dependencies run: npm i # ── Build or download PR (test) pages ───────────────────────────────── - # When called from deploy.yml with pr-artifact-name, download the artifact. - # When run standalone (no pr-artifact-name provided), build the pages locally. - name: Build PR pages locally if: ${{ !inputs.pr-artifact-name }} run: | @@ -61,11 +72,6 @@ jobs: path: pages/lib/static-default # ── Build baseline (main) pages ──────────────────────────────────────── - # Use a git worktree so the baseline has its own directory and its own - # node_modules. This means a PR that changes package-lock.json will still - # produce a correct baseline: the baseline installs from main's lockfile - # and the PR build installs from the PR's lockfile, so both sides use the - # dependency versions that are correct for their respective source trees. - name: Create baseline worktree from origin/main run: git worktree add /tmp/baseline origin/main @@ -103,11 +109,12 @@ jobs: run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js env: TZ: UTC + BROWSER: ${{ matrix.browser }} - name: Upload diff artifacts if: failure() uses: actions/upload-artifact@v4 with: - name: visual-regression-diffs + name: visual-regression-diffs-${{ matrix.browser }} path: visual-regression-output/ retention-days: 14 diff --git a/build-tools/visual/global-setup.js b/build-tools/visual/global-setup.js index 075ee6e398..f27f7d04cb 100644 --- a/build-tools/visual/global-setup.js +++ b/build-tools/visual/global-setup.js @@ -1,4 +1,20 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -const { startWebdriver } = require('@cloudscape-design/browser-test-tools/chrome-launcher'); -module.exports = () => startWebdriver(); +const { spawn } = require('child_process'); +const waitOn = require('wait-on'); + +let driverProcess; + +module.exports = async () => { + if (process.env.BROWSER === 'safari') { + driverProcess = spawn('safaridriver', ['--port', '4444']); + driverProcess.on('error', err => { + throw err; + }); + await waitOn({ resources: ['http-get://localhost:4444/status'], timeout: 10000 }); + } else { + const { startWebdriver } = require('@cloudscape-design/browser-test-tools/chrome-launcher'); + await startWebdriver(); + } + global.__DRIVER_PROCESS__ = driverProcess; +}; diff --git a/build-tools/visual/global-teardown.js b/build-tools/visual/global-teardown.js index 57ad21b454..366c3f7660 100644 --- a/build-tools/visual/global-teardown.js +++ b/build-tools/visual/global-teardown.js @@ -1,4 +1,12 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -const { shutdownWebdriver } = require('@cloudscape-design/browser-test-tools/chrome-launcher'); -module.exports = () => shutdownWebdriver(); +module.exports = () => { + if (process.env.BROWSER === 'safari') { + if (global.__DRIVER_PROCESS__) { + global.__DRIVER_PROCESS__.kill(); + } + } else { + const { shutdownWebdriver } = require('@cloudscape-design/browser-test-tools/chrome-launcher'); + shutdownWebdriver(); + } +}; diff --git a/build-tools/visual/setup.js b/build-tools/visual/setup.js index 2625a43809..79050cab6a 100644 --- a/build-tools/visual/setup.js +++ b/build-tools/visual/setup.js @@ -3,12 +3,14 @@ /* global jest */ const { configure } = require('@cloudscape-design/browser-test-tools/use-browser'); +const isSafari = process.env.BROWSER === 'safari'; + // The PR build (the code under test) is served on port 8080. // The baseline build (main branch, same node_modules) is served on port 8081. configure({ - browserName: 'ChromeHeadlessIntegration', + browserName: isSafari ? 'Safari' : 'ChromeHeadlessIntegration', browserCreatorOptions: { - seleniumUrl: 'http://localhost:9515', + seleniumUrl: isSafari ? 'http://localhost:4444' : 'http://localhost:9515', }, webdriverOptions: { baseUrl: 'http://localhost:8080', From c158a1d6ecd700e489c636837c832651aab3bb2f Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 18 May 2026 19:27:05 +0200 Subject: [PATCH 28/95] Prevent visual regression workflow from running twice --- .github/workflows/visual-regression.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 39cea38599..09685d9bf5 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -1,9 +1,6 @@ name: Visual Regression Tests on: - pull_request: - branches: - - main workflow_call: inputs: pr-artifact-name: From 45e188f4fdc73073241d508941e298e75e542713 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 18 May 2026 19:45:39 +0200 Subject: [PATCH 29/95] Reuse baseline build across browsers --- .github/workflows/visual-regression.yml | 99 ++++++++++++++++++------- 1 file changed, 73 insertions(+), 26 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 09685d9bf5..2c9e633b05 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -17,17 +17,10 @@ permissions: contents: read jobs: - visual: - name: Visual regression (${{ matrix.browser }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - browser: chrome - os: ubuntu-latest - - browser: safari - os: macos-latest + # Build the baseline (main branch) pages once and share them across all browser jobs. + build-baseline: + name: Build baseline pages + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: @@ -39,20 +32,12 @@ jobs: node-version: 20 cache: npm - - name: Setup Chrome and ChromeDriver - if: matrix.browser == 'chrome' - uses: browser-actions/setup-chrome@v1 - with: - chrome-version: stable - - - name: Enable SafariDriver - if: matrix.browser == 'safari' - run: sudo safaridriver --enable - - name: Install dependencies run: npm i # ── Build or download PR (test) pages ───────────────────────────────── + # When called from deploy.yml with pr-artifact-name, download the artifact. + # When run standalone (no pr-artifact-name provided), build the pages locally. - name: Build PR pages locally if: ${{ !inputs.pr-artifact-name }} run: | @@ -69,6 +54,11 @@ jobs: path: pages/lib/static-default # ── Build baseline (main) pages ──────────────────────────────────────── + # Use a git worktree so the baseline has its own directory and its own + # node_modules. This means a PR that changes package-lock.json will still + # produce a correct baseline: the baseline installs from main's lockfile + # and the PR build installs from the PR's lockfile, so both sides use the + # dependency versions that are correct for their respective source trees. - name: Create baseline worktree from origin/main run: git worktree add /tmp/baseline origin/main @@ -83,21 +73,78 @@ jobs: NODE_ENV: production - name: Bundle baseline pages - run: node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path ${{ github.workspace }}/pages/lib/static-visual-baseline + run: node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path pages/lib/static-visual-baseline working-directory: /tmp/baseline env: NODE_ENV: production + - name: Upload baseline artifact + uses: actions/upload-artifact@v4 + with: + name: visual-baseline-pages + path: pages/lib/static-visual-baseline + retention-days: 1 + + - name: Upload PR pages artifact + if: ${{ !inputs.pr-artifact-name }} + uses: actions/upload-artifact@v4 + with: + name: visual-pr-pages + path: pages/lib/static-default + retention-days: 1 + + visual: + name: Visual regression (${{ matrix.browser }}) + needs: build-baseline + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - browser: chrome + os: ubuntu-latest + - browser: safari + os: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Setup Chrome and ChromeDriver + if: matrix.browser == 'chrome' + uses: browser-actions/setup-chrome@v1 + with: + chrome-version: stable + + - name: Enable SafariDriver + if: matrix.browser == 'safari' + run: sudo safaridriver --enable + + - name: Install dependencies + run: npm i + + - name: Download PR pages artifact + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.pr-artifact-name || 'visual-pr-pages' }} + path: pages/lib/static-default + + - name: Download baseline artifact + uses: actions/download-artifact@v4 + with: + name: visual-baseline-pages + path: pages/lib/static-visual-baseline + # ── Run tests ───────────────────────────────────────────────────────── - name: Start test server (port 8080) run: npx serve --no-clipboard --listen 8080 pages/lib/static-default & - env: - NODE_ENV: development - name: Start baseline server (port 8081) run: npx serve --no-clipboard --listen 8081 pages/lib/static-visual-baseline & - env: - NODE_ENV: development - name: Wait for servers to be ready run: node_modules/.bin/wait-on http://localhost:8080 http://localhost:8081 From 5c052621272d10b97166e9698460667b37d421c7 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 18 May 2026 19:46:53 +0200 Subject: [PATCH 30/95] Limit Safari concurrency --- jest.visual.config.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/jest.visual.config.js b/jest.visual.config.js index 7f1d020880..0d3abc58fd 100644 --- a/jest.visual.config.js +++ b/jest.visual.config.js @@ -16,7 +16,9 @@ module.exports = { }, reporters: ['default', 'github-actions'], testTimeout: 120_000, // 2min — pages can be tall and slow to capture - maxWorkers: os.cpus().length * (process.env.GITHUB_ACTION ? 3 : 1), + // Safari's WebDriver only supports one concurrent session, so tests must run serially. + // Chrome can run multiple workers to speed things up. + maxWorkers: process.env.BROWSER === 'safari' ? 1 : os.cpus().length * (process.env.GITHUB_ACTION ? 3 : 1), globalSetup: '/build-tools/visual/global-setup.js', globalTeardown: '/build-tools/visual/global-teardown.js', setupFilesAfterEnv: [path.join(__dirname, 'build-tools', 'visual', 'setup.js')], From d1103c6a648f2532f755f33f5a447a5a1c7c87dc Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 18 May 2026 20:05:39 +0200 Subject: [PATCH 31/95] Fix workflow --- .github/workflows/deploy.yml | 3 ++- .github/workflows/visual-regression.yml | 34 +++++++++++++++---------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 01bb5a45d2..8705c33048 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -73,4 +73,5 @@ jobs: uses: ./.github/workflows/visual-regression.yml secrets: inherit with: - pr-artifact-name: dev-pages-react18 \ No newline at end of file + pr-artifact-name: dev-pages-react18 + caller-run-id: ${{ github.run_id }} \ No newline at end of file diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 2c9e633b05..bedbb5435e 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -7,6 +7,10 @@ on: description: 'Name of the artifact containing PR pages (built by quick-build job). If not provided, pages will be built locally.' required: false type: string + caller-run-id: + description: 'The run ID of the calling workflow, used to download artifacts it uploaded.' + required: false + type: string defaults: run: @@ -15,9 +19,11 @@ defaults: permissions: id-token: write contents: read + actions: read jobs: # Build the baseline (main branch) pages once and share them across all browser jobs. + # Also stages the PR pages artifact within this run so all jobs use the same run context. build-baseline: name: Build baseline pages runs-on: ubuntu-latest @@ -35,9 +41,10 @@ jobs: - name: Install dependencies run: npm i - # ── Build or download PR (test) pages ───────────────────────────────── - # When called from deploy.yml with pr-artifact-name, download the artifact. - # When run standalone (no pr-artifact-name provided), build the pages locally. + # ── Stage PR (test) pages ────────────────────────────────────────────── + # Download from the caller's run (deploy.yml) or build locally. + # Either way, re-upload as 'visual-pr-pages' within this run so the + # matrix jobs can download it without needing cross-run artifact access. - name: Build PR pages locally if: ${{ !inputs.pr-artifact-name }} run: | @@ -46,12 +53,21 @@ jobs: env: NODE_ENV: production - - name: Download PR pages artifact + - name: Download PR pages artifact from caller run if: ${{ inputs.pr-artifact-name }} uses: actions/download-artifact@v4 with: name: ${{ inputs.pr-artifact-name }} path: pages/lib/static-default + github-token: ${{ github.token }} + run-id: ${{ inputs.caller-run-id }} + + - name: Upload PR pages artifact (for matrix jobs) + uses: actions/upload-artifact@v4 + with: + name: visual-pr-pages + path: pages/lib/static-default + retention-days: 1 # ── Build baseline (main) pages ──────────────────────────────────────── # Use a git worktree so the baseline has its own directory and its own @@ -85,14 +101,6 @@ jobs: path: pages/lib/static-visual-baseline retention-days: 1 - - name: Upload PR pages artifact - if: ${{ !inputs.pr-artifact-name }} - uses: actions/upload-artifact@v4 - with: - name: visual-pr-pages - path: pages/lib/static-default - retention-days: 1 - visual: name: Visual regression (${{ matrix.browser }}) needs: build-baseline @@ -130,7 +138,7 @@ jobs: - name: Download PR pages artifact uses: actions/download-artifact@v4 with: - name: ${{ inputs.pr-artifact-name || 'visual-pr-pages' }} + name: visual-pr-pages path: pages/lib/static-default - name: Download baseline artifact From 7ba5aca6e7adff76578d1cc1f078f6ea1bf6cc44 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 18 May 2026 20:27:19 +0200 Subject: [PATCH 32/95] Fix workflows --- .github/workflows/deploy.yml | 57 +++++++++++++++++++++++- .github/workflows/visual-regression.yml | 59 ++++++++++--------------- 2 files changed, 78 insertions(+), 38 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8705c33048..8a3b4459b7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -53,6 +53,58 @@ jobs: name: dev-pages-react${{ matrix.react }} path: pages/lib/static-default + # Build the baseline (main branch) pages in parallel with quick-build. + # The result is passed to the visual regression workflow so it doesn't + # need to rebuild the baseline for each browser. + build-baseline: + name: Build baseline pages + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm i + + # Use a git worktree so the baseline has its own directory and its own + # node_modules. This means a PR that changes package-lock.json will still + # produce a correct baseline: the baseline installs from main's lockfile + # and the PR build installs from the PR's lockfile, so both sides use the + # dependency versions that are correct for their respective source trees. + - name: Create baseline worktree from origin/main + run: git worktree add /tmp/baseline origin/main + + - name: Install baseline dependencies + run: npm i + working-directory: /tmp/baseline + + - name: Build baseline pages + run: npx gulp quick-build + working-directory: /tmp/baseline + env: + NODE_ENV: production + + - name: Bundle baseline pages + run: node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path pages/lib/static-visual-baseline + working-directory: /tmp/baseline + env: + NODE_ENV: production + + - name: Upload baseline artifact + uses: actions/upload-artifact@v4 + with: + name: visual-baseline-pages + path: pages/lib/static-visual-baseline + retention-days: 1 + deploy: needs: quick-build name: deploy${{ matrix.react != 16 && format(' (React {0})', matrix.react) || '' }} @@ -68,10 +120,11 @@ jobs: visual: name: Visual regression - needs: quick-build + needs: [quick-build, build-baseline] if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} uses: ./.github/workflows/visual-regression.yml secrets: inherit with: pr-artifact-name: dev-pages-react18 - caller-run-id: ${{ github.run_id }} \ No newline at end of file + baseline-artifact-name: visual-baseline-pages + caller-run-id: ${{ github.run_id }} diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index bedbb5435e..5fab2453e2 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -7,6 +7,10 @@ on: description: 'Name of the artifact containing PR pages (built by quick-build job). If not provided, pages will be built locally.' required: false type: string + baseline-artifact-name: + description: 'Name of the artifact containing baseline pages (built by build-baseline job in the caller workflow).' + required: true + type: string caller-run-id: description: 'The run ID of the calling workflow, used to download artifacts it uploaded.' required: false @@ -22,15 +26,13 @@ permissions: actions: read jobs: - # Build the baseline (main branch) pages once and share them across all browser jobs. - # Also stages the PR pages artifact within this run so all jobs use the same run context. - build-baseline: - name: Build baseline pages + # Stage the PR pages within this run so matrix jobs can download them without + # needing cross-run artifact access. Runs in parallel with stage-baseline. + stage-pr-pages: + name: Stage PR pages runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 @@ -41,10 +43,6 @@ jobs: - name: Install dependencies run: npm i - # ── Stage PR (test) pages ────────────────────────────────────────────── - # Download from the caller's run (deploy.yml) or build locally. - # Either way, re-upload as 'visual-pr-pages' within this run so the - # matrix jobs can download it without needing cross-run artifact access. - name: Build PR pages locally if: ${{ !inputs.pr-artifact-name }} run: | @@ -69,32 +67,21 @@ jobs: path: pages/lib/static-default retention-days: 1 - # ── Build baseline (main) pages ──────────────────────────────────────── - # Use a git worktree so the baseline has its own directory and its own - # node_modules. This means a PR that changes package-lock.json will still - # produce a correct baseline: the baseline installs from main's lockfile - # and the PR build installs from the PR's lockfile, so both sides use the - # dependency versions that are correct for their respective source trees. - - name: Create baseline worktree from origin/main - run: git worktree add /tmp/baseline origin/main - - - name: Install baseline dependencies - run: npm i - working-directory: /tmp/baseline - - - name: Build baseline pages - run: npx gulp quick-build - working-directory: /tmp/baseline - env: - NODE_ENV: production - - - name: Bundle baseline pages - run: node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path pages/lib/static-visual-baseline - working-directory: /tmp/baseline - env: - NODE_ENV: production + # Stage the baseline pages within this run so matrix jobs can download them + # without needing cross-run artifact access. Runs in parallel with stage-pr-pages. + stage-baseline: + name: Stage baseline pages + runs-on: ubuntu-latest + steps: + - name: Download baseline artifact from caller run + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.baseline-artifact-name }} + path: pages/lib/static-visual-baseline + github-token: ${{ github.token }} + run-id: ${{ inputs.caller-run-id }} - - name: Upload baseline artifact + - name: Upload baseline artifact (for matrix jobs) uses: actions/upload-artifact@v4 with: name: visual-baseline-pages @@ -103,7 +90,7 @@ jobs: visual: name: Visual regression (${{ matrix.browser }}) - needs: build-baseline + needs: [stage-pr-pages, stage-baseline] runs-on: ${{ matrix.os }} strategy: fail-fast: false From f3744e8d5fee33b055a16816265595cb8a1ce632 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 18 May 2026 21:22:00 +0200 Subject: [PATCH 33/95] Fix workflow --- .github/workflows/visual-regression.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 5fab2453e2..9b68239196 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -14,7 +14,7 @@ on: caller-run-id: description: 'The run ID of the calling workflow, used to download artifacts it uploaded.' required: false - type: string + type: number defaults: run: From ea3ec1a0ee86284d5fa615b1839cc49cc37f4caa Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 18 May 2026 21:41:50 +0200 Subject: [PATCH 34/95] Fix workflow --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8a3b4459b7..45f7ac8fbb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -121,7 +121,7 @@ jobs: visual: name: Visual regression needs: [quick-build, build-baseline] - if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} uses: ./.github/workflows/visual-regression.yml secrets: inherit with: From 034dc8785e5b37c9d9d6a5d35560eaf28d9ac9cf Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 18 May 2026 21:47:43 +0200 Subject: [PATCH 35/95] Fix workflow --- .github/workflows/visual-regression.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 9b68239196..5fab2453e2 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -14,7 +14,7 @@ on: caller-run-id: description: 'The run ID of the calling workflow, used to download artifacts it uploaded.' required: false - type: number + type: string defaults: run: From cdf087dfc61c39b16809152377e6ee9342b0e129 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 19 May 2026 01:56:14 +0200 Subject: [PATCH 36/95] Fix workflow --- .github/workflows/deploy.yml | 55 +---------------------- .github/workflows/visual-regression.yml | 59 ++++++++++++++++++------- 2 files changed, 43 insertions(+), 71 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 45f7ac8fbb..2add3494cd 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -53,58 +53,6 @@ jobs: name: dev-pages-react${{ matrix.react }} path: pages/lib/static-default - # Build the baseline (main branch) pages in parallel with quick-build. - # The result is passed to the visual regression workflow so it doesn't - # need to rebuild the baseline for each browser. - build-baseline: - name: Build baseline pages - if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - - name: Install dependencies - run: npm i - - # Use a git worktree so the baseline has its own directory and its own - # node_modules. This means a PR that changes package-lock.json will still - # produce a correct baseline: the baseline installs from main's lockfile - # and the PR build installs from the PR's lockfile, so both sides use the - # dependency versions that are correct for their respective source trees. - - name: Create baseline worktree from origin/main - run: git worktree add /tmp/baseline origin/main - - - name: Install baseline dependencies - run: npm i - working-directory: /tmp/baseline - - - name: Build baseline pages - run: npx gulp quick-build - working-directory: /tmp/baseline - env: - NODE_ENV: production - - - name: Bundle baseline pages - run: node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path pages/lib/static-visual-baseline - working-directory: /tmp/baseline - env: - NODE_ENV: production - - - name: Upload baseline artifact - uses: actions/upload-artifact@v4 - with: - name: visual-baseline-pages - path: pages/lib/static-visual-baseline - retention-days: 1 - deploy: needs: quick-build name: deploy${{ matrix.react != 16 && format(' (React {0})', matrix.react) || '' }} @@ -120,11 +68,10 @@ jobs: visual: name: Visual regression - needs: [quick-build, build-baseline] + needs: quick-build if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} uses: ./.github/workflows/visual-regression.yml secrets: inherit with: pr-artifact-name: dev-pages-react18 - baseline-artifact-name: visual-baseline-pages caller-run-id: ${{ github.run_id }} diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 5fab2453e2..8c3c44c0ff 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -7,10 +7,6 @@ on: description: 'Name of the artifact containing PR pages (built by quick-build job). If not provided, pages will be built locally.' required: false type: string - baseline-artifact-name: - description: 'Name of the artifact containing baseline pages (built by build-baseline job in the caller workflow).' - required: true - type: string caller-run-id: description: 'The run ID of the calling workflow, used to download artifacts it uploaded.' required: false @@ -27,7 +23,7 @@ permissions: jobs: # Stage the PR pages within this run so matrix jobs can download them without - # needing cross-run artifact access. Runs in parallel with stage-baseline. + # needing cross-run artifact access. Runs in parallel with build-baseline. stage-pr-pages: name: Stage PR pages runs-on: ubuntu-latest @@ -67,21 +63,50 @@ jobs: path: pages/lib/static-default retention-days: 1 - # Stage the baseline pages within this run so matrix jobs can download them - # without needing cross-run artifact access. Runs in parallel with stage-pr-pages. - stage-baseline: - name: Stage baseline pages + # Build the baseline (main branch) pages once and share them across all browser jobs. + # Runs in parallel with stage-pr-pages. + build-baseline: + name: Build baseline pages runs-on: ubuntu-latest steps: - - name: Download baseline artifact from caller run - uses: actions/download-artifact@v4 + - uses: actions/checkout@v4 with: - name: ${{ inputs.baseline-artifact-name }} - path: pages/lib/static-visual-baseline - github-token: ${{ github.token }} - run-id: ${{ inputs.caller-run-id }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm i + + # Use a git worktree so the baseline has its own directory and its own + # node_modules. This means a PR that changes package-lock.json will still + # produce a correct baseline: the baseline installs from main's lockfile + # and the PR build installs from the PR's lockfile, so both sides use the + # dependency versions that are correct for their respective source trees. + - name: Create baseline worktree from origin/main + run: git worktree add /tmp/baseline origin/main + + - name: Install baseline dependencies + run: npm i + working-directory: /tmp/baseline + + - name: Build baseline pages + run: npx gulp quick-build + working-directory: /tmp/baseline + env: + NODE_ENV: production + + - name: Bundle baseline pages + run: node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path pages/lib/static-visual-baseline + working-directory: /tmp/baseline + env: + NODE_ENV: production - - name: Upload baseline artifact (for matrix jobs) + - name: Upload baseline artifact uses: actions/upload-artifact@v4 with: name: visual-baseline-pages @@ -90,7 +115,7 @@ jobs: visual: name: Visual regression (${{ matrix.browser }}) - needs: [stage-pr-pages, stage-baseline] + needs: [stage-pr-pages, build-baseline] runs-on: ${{ matrix.os }} strategy: fail-fast: false From 5230a6d41d055b5d4bf876b5401d6d09b8f1a638 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 19 May 2026 02:07:06 +0200 Subject: [PATCH 37/95] Fix workflow --- .github/workflows/visual-regression.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 8c3c44c0ff..4f62e0fa16 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -101,7 +101,7 @@ jobs: NODE_ENV: production - name: Bundle baseline pages - run: node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path pages/lib/static-visual-baseline + run: node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path ${{ github.workspace }}/pages/lib/static-visual-baseline working-directory: /tmp/baseline env: NODE_ENV: production From 11ec0be0bd21d32886732125820358117310222c Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 19 May 2026 12:01:18 +0200 Subject: [PATCH 38/95] Add delay between retries in Safari --- build-tools/visual/setup.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/build-tools/visual/setup.js b/build-tools/visual/setup.js index 79050cab6a..a9013a3d61 100644 --- a/build-tools/visual/setup.js +++ b/build-tools/visual/setup.js @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -/* global jest */ +/* global jest, afterEach */ const { configure } = require('@cloudscape-design/browser-test-tools/use-browser'); const isSafari = process.env.BROWSER === 'safari'; @@ -18,3 +18,11 @@ configure({ }); jest.retryTimes(2, { logErrorsBeforeRetry: true }); + +// Safari's WebDriver needs a moment to fully release a session before a new one +// can be created. Without this delay, retried tests hit "already paired" errors. +if (isSafari) { + afterEach(async () => { + await new Promise(resolve => setTimeout(resolve, 1000)); + }); +} From 0f6f0e79de3499379062b3bb3d1f3503fdde1337 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 19 May 2026 12:21:48 +0200 Subject: [PATCH 39/95] Fine tune Safari delay --- build-tools/visual/setup.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/build-tools/visual/setup.js b/build-tools/visual/setup.js index a9013a3d61..e6d166d7f3 100644 --- a/build-tools/visual/setup.js +++ b/build-tools/visual/setup.js @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -/* global jest, afterEach */ +/* global jest, beforeEach */ const { configure } = require('@cloudscape-design/browser-test-tools/use-browser'); const isSafari = process.env.BROWSER === 'safari'; @@ -20,9 +20,9 @@ configure({ jest.retryTimes(2, { logErrorsBeforeRetry: true }); // Safari's WebDriver needs a moment to fully release a session before a new one -// can be created. Without this delay, retried tests hit "already paired" errors. +// can be created. Without this delay, the next test hits "already paired" errors. if (isSafari) { - afterEach(async () => { - await new Promise(resolve => setTimeout(resolve, 1000)); + beforeEach(async () => { + await new Promise(resolve => setTimeout(resolve, 5000)); }); } From f44e03d75a4b558aab329ce7efac24b42401e1db Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 19 May 2026 14:00:13 +0200 Subject: [PATCH 40/95] Release Safari session between tests --- build-tools/visual/global-setup.js | 6 ++---- build-tools/visual/setup.js | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/build-tools/visual/global-setup.js b/build-tools/visual/global-setup.js index f27f7d04cb..b4acf683c9 100644 --- a/build-tools/visual/global-setup.js +++ b/build-tools/visual/global-setup.js @@ -3,18 +3,16 @@ const { spawn } = require('child_process'); const waitOn = require('wait-on'); -let driverProcess; - module.exports = async () => { if (process.env.BROWSER === 'safari') { - driverProcess = spawn('safaridriver', ['--port', '4444']); + const driverProcess = spawn('safaridriver', ['--port', '4444']); driverProcess.on('error', err => { throw err; }); await waitOn({ resources: ['http-get://localhost:4444/status'], timeout: 10000 }); + global.__DRIVER_PROCESS__ = driverProcess; } else { const { startWebdriver } = require('@cloudscape-design/browser-test-tools/chrome-launcher'); await startWebdriver(); } - global.__DRIVER_PROCESS__ = driverProcess; }; diff --git a/build-tools/visual/setup.js b/build-tools/visual/setup.js index e6d166d7f3..d8e7ab5779 100644 --- a/build-tools/visual/setup.js +++ b/build-tools/visual/setup.js @@ -1,6 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 /* global jest, beforeEach */ +const { spawn } = require('child_process'); +const waitOn = require('wait-on'); const { configure } = require('@cloudscape-design/browser-test-tools/use-browser'); const isSafari = process.env.BROWSER === 'safari'; @@ -19,10 +21,18 @@ configure({ jest.retryTimes(2, { logErrorsBeforeRetry: true }); -// Safari's WebDriver needs a moment to fully release a session before a new one -// can be created. Without this delay, the next test hits "already paired" errors. +// Local safaridriver only supports one session at a time and doesn't reliably +// release the session lock between tests. Restarting the process before each +// test guarantees a clean state. This is not needed with BrowserStack. if (isSafari) { + let safariDriverProcess; + beforeEach(async () => { - await new Promise(resolve => setTimeout(resolve, 5000)); + if (safariDriverProcess) { + safariDriverProcess.kill(); + await new Promise(resolve => setTimeout(resolve, 500)); + } + safariDriverProcess = spawn('safaridriver', ['--port', '4444']); + await waitOn({ resources: ['http-get://localhost:4444/status'], timeout: 10000 }); }); } From 16dd3dd99cf393f56cddb9cd589947e994111cc3 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 19 May 2026 17:07:46 +0200 Subject: [PATCH 41/95] Do not retry with Safari --- build-tools/visual/setup.js | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/build-tools/visual/setup.js b/build-tools/visual/setup.js index d8e7ab5779..c63a18416f 100644 --- a/build-tools/visual/setup.js +++ b/build-tools/visual/setup.js @@ -1,8 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -/* global jest, beforeEach */ -const { spawn } = require('child_process'); -const waitOn = require('wait-on'); +/* global jest */ const { configure } = require('@cloudscape-design/browser-test-tools/use-browser'); const isSafari = process.env.BROWSER === 'safari'; @@ -19,20 +17,9 @@ configure({ }, }); -jest.retryTimes(2, { logErrorsBeforeRetry: true }); - -// Local safaridriver only supports one session at a time and doesn't reliably -// release the session lock between tests. Restarting the process before each -// test guarantees a clean state. This is not needed with BrowserStack. -if (isSafari) { - let safariDriverProcess; - - beforeEach(async () => { - if (safariDriverProcess) { - safariDriverProcess.kill(); - await new Promise(resolve => setTimeout(resolve, 500)); - } - safariDriverProcess = spawn('safaridriver', ['--port', '4444']); - await waitOn({ resources: ['http-get://localhost:4444/status'], timeout: 10000 }); - }); +// Retries help with flaky tests, but Safari's single-session constraint means +// a retry can hit "already paired" if the previous attempt's session hasn't +// fully released. Disable retries for Safari. +if (!isSafari) { + jest.retryTimes(2, { logErrorsBeforeRetry: true }); } From 09c8134f0c555bfa7e4116bb63e1514731070462 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 19 May 2026 18:43:41 +0200 Subject: [PATCH 42/95] Change target directory --- build-tools/tasks/index.js | 5 -- build-tools/tasks/visual-definitions.js | 8 -- build-tools/tasks/visual.js | 107 ------------------------ tsconfig.visual-definitions.json | 2 +- 4 files changed, 1 insertion(+), 121 deletions(-) delete mode 100644 build-tools/tasks/visual-definitions.js delete mode 100644 build-tools/tasks/visual.js diff --git a/build-tools/tasks/index.js b/build-tools/tasks/index.js index 413695be14..d7db3c0784 100644 --- a/build-tools/tasks/index.js +++ b/build-tools/tasks/index.js @@ -21,10 +21,5 @@ module.exports = { themeableSource: require('./themeable-source'), bundleVendorFiles: require('./bundle-vendor-files'), sizeLimit: require('./size-limit'), -<<<<<<< HEAD testDefinitions: require('./test-definitions'), -======= - visual: require('./visual'), - visualDefinitions: require('./visual-definitions'), ->>>>>>> 4213557f5 (chore: Export visual test definitions) }; diff --git a/build-tools/tasks/visual-definitions.js b/build-tools/tasks/visual-definitions.js deleted file mode 100644 index 34dfe16adf..0000000000 --- a/build-tools/tasks/visual-definitions.js +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -const execa = require('execa'); -const { task } = require('../utils/gulp-utils'); - -module.exports = task('visual-definitions', () => - execa('tsc', ['-p', 'tsconfig.visual-definitions.json'], { stdio: 'inherit' }) -); diff --git a/build-tools/tasks/visual.js b/build-tools/tasks/visual.js deleted file mode 100644 index 0864790afb..0000000000 --- a/build-tools/tasks/visual.js +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -const execa = require('execa'); -const path = require('path'); -const fs = require('fs'); -const waitOn = require('wait-on'); -const { task } = require('../utils/gulp-utils.js'); -const { parseArgs } = require('node:util'); - -const BASELINE_WORKTREE = '/tmp/visual-baseline'; -const BASELINE_OUTPUT = path.resolve('pages/lib/static-visual-baseline'); -const TEST_OUTPUT = path.resolve('pages/lib/static-default'); - -// Port assignments: -// 8080 — test build (PR / local changes) -// 8081 — baseline build (main branch) -const TEST_PORT = 8080; -const BASELINE_PORT = 8081; - -/** - * Serves a pre-built static directory using webpack-dev-server in static mode. - */ -function serveStatic(dir, port) { - return execa( - 'node_modules/.bin/webpack', - ['serve', '--config', 'pages/webpack.config.integ.cjs', '--port', String(port), '--static', dir, '--no-hot'], - { env: { ...process.env, NODE_ENV: 'development' } } - ); -} - -/** - * Builds the dev pages from the source tree at `cwd` into `outputPath`. - * Uses the node_modules present in `cwd`. - */ -async function buildPages(cwd, outputPath) { - await execa('npx', ['gulp', 'quick-build'], { - stdio: 'inherit', - cwd, - env: { ...process.env, NODE_ENV: 'production' }, - }); - await execa( - path.join(cwd, 'node_modules/.bin/webpack'), - ['--config', 'pages/webpack.config.integ.cjs', '--output-path', outputPath], - { stdio: 'inherit', cwd, env: { ...process.env, NODE_ENV: 'production' } } - ); -} - -module.exports = task('test:visual', async () => { - const options = { - shard: { type: 'string' }, - // Pass --skip-build to skip the build steps when artifacts are already present. - skipBuild: { type: 'boolean' }, - }; - const { shard, skipBuild } = parseArgs({ options, strict: false }).values; - - const cwd = process.cwd(); - - if (!skipBuild) { - // ── 1. Build the test (PR) pages ──────────────────────────────────────── - console.log('Building test pages (current branch)…'); - await buildPages(cwd, TEST_OUTPUT); - - // ── 2. Build the baseline (main) pages ────────────────────────────────── - // Create a worktree for origin/main so it gets its own node_modules. - // This correctly handles PRs that change package-lock.json: each side - // installs from its own lockfile. - console.log('Setting up baseline worktree from origin/main…'); - if (fs.existsSync(BASELINE_WORKTREE)) { - await execa('git', ['worktree', 'remove', '--force', BASELINE_WORKTREE]); - } - await execa('git', ['worktree', 'add', BASELINE_WORKTREE, 'origin/main']); - - try { - console.log('Installing baseline dependencies…'); - await execa('npm', ['ci'], { stdio: 'inherit', cwd: BASELINE_WORKTREE }); - - console.log('Building baseline pages (origin/main)…'); - await buildPages(BASELINE_WORKTREE, BASELINE_OUTPUT); - } finally { - await execa('git', ['worktree', 'remove', '--force', BASELINE_WORKTREE]); - } - } - - // ── 3. Start both static servers ────────────────────────────────────────── - console.log(`Starting test server on :${TEST_PORT} (${TEST_OUTPUT})…`); - const testServer = serveStatic(TEST_OUTPUT, TEST_PORT); - - console.log(`Starting baseline server on :${BASELINE_PORT} (${BASELINE_OUTPUT})…`); - const baselineServer = serveStatic(BASELINE_OUTPUT, BASELINE_PORT); - - try { - await waitOn({ resources: [`http://localhost:${TEST_PORT}`, `http://localhost:${BASELINE_PORT}`] }); - - // ── 4. Run visual tests ────────────────────────────────────────────────── - const jestArgs = ['-c', 'jest.visual.config.js']; - if (shard) { - jestArgs.push(`--shard=${shard}`); - } - await execa('jest', jestArgs, { - stdio: 'inherit', - env: { ...process.env, NODE_OPTIONS: '--experimental-vm-modules' }, - }); - } finally { - testServer.cancel(); - baselineServer.cancel(); - } -}); diff --git a/tsconfig.visual-definitions.json b/tsconfig.visual-definitions.json index a31ccd02d3..fbc322c92a 100644 --- a/tsconfig.visual-definitions.json +++ b/tsconfig.visual-definitions.json @@ -12,7 +12,7 @@ "sourceMap": true, "inlineSources": true, "rootDir": "test/visual", - "outDir": "lib/visual-test-definitions" + "outDir": "lib/test-definitions" }, "include": ["test/visual/definitions", "test/visual/types.ts"], "exclude": [] From 83af893e958e74ff6a3a5aabe7f0f05d58f0c021 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 21 May 2026 16:35:14 +0200 Subject: [PATCH 43/95] Remove local testing setup --- gulpfile.js | 4 ---- tsconfig.visual-definitions.json | 19 ------------------- 2 files changed, 23 deletions(-) delete mode 100644 tsconfig.visual-definitions.json diff --git a/gulpfile.js b/gulpfile.js index 09e0833868..6e3f389082 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -19,8 +19,6 @@ const { generateI18nMessages, integ, motion, - visual, - visualDefinitions, copyFiles, themeableSource, bundleVendorFiles, @@ -45,8 +43,6 @@ exports['test:unit'] = unit; exports['test:integ'] = integ; exports['test:a11y'] = a11y; exports['test:motion'] = motion; -exports['test:visual'] = visual; -exports['build:visual-definitions'] = visualDefinitions; exports.watch = () => { watch( diff --git a/tsconfig.visual-definitions.json b/tsconfig.visual-definitions.json deleted file mode 100644 index fbc322c92a..0000000000 --- a/tsconfig.visual-definitions.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "compilerOptions": { - "lib": ["ES2021", "DOM"], - "target": "ES2019", - "types": [], - "module": "CommonJS", - "moduleResolution": "node", - "esModuleInterop": true, - "strict": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "inlineSources": true, - "rootDir": "test/visual", - "outDir": "lib/test-definitions" - }, - "include": ["test/visual/definitions", "test/visual/types.ts"], - "exclude": [] -} From 574d1a587a45bbc96b3c09cda9502ecdc9798868 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 21 May 2026 16:44:41 +0200 Subject: [PATCH 44/95] Refactor --- docs/RUNNING_TESTS.md | 4 +- .../visual/compare-screenshots.ts | 4 +- test/{visual => }/visual.test.ts | 2 +- test/visual/definitions/action-card.ts | 31 ---------------- test/visual/definitions/alert.ts | 37 ------------------- test/visual/definitions/index.ts | 10 ----- test/visual/types.ts | 28 -------------- 7 files changed, 6 insertions(+), 110 deletions(-) rename test/{ => definitions}/visual/compare-screenshots.ts (96%) rename test/{visual => }/visual.test.ts (70%) delete mode 100644 test/visual/definitions/action-card.ts delete mode 100644 test/visual/definitions/alert.ts delete mode 100644 test/visual/definitions/index.ts delete mode 100644 test/visual/types.ts diff --git a/docs/RUNNING_TESTS.md b/docs/RUNNING_TESTS.md index c5c483e981..6e0eb84503 100644 --- a/docs/RUNNING_TESTS.md +++ b/docs/RUNNING_TESTS.md @@ -86,7 +86,7 @@ NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.con ### Adding tests for a new component -Create `test/visual/definitions/.ts`: +Create `test/definitions/visual/.ts`: ```ts import { TestSuite } from '../types'; @@ -104,7 +104,7 @@ const suite: TestSuite = { export default suite; ``` -Then import and add it to `test/visual/definitions/index.ts`: +Then import and add it to `test/definitions/visual/index.ts`: ```ts import myComponent from './my-component'; diff --git a/test/visual/compare-screenshots.ts b/test/definitions/visual/compare-screenshots.ts similarity index 96% rename from test/visual/compare-screenshots.ts rename to test/definitions/visual/compare-screenshots.ts index 8847e10d21..f81b8afc56 100644 --- a/test/visual/compare-screenshots.ts +++ b/test/definitions/visual/compare-screenshots.ts @@ -7,7 +7,7 @@ import { parsePng } from '@cloudscape-design/browser-test-tools/image-utils'; import { ScreenshotPageObject } from '@cloudscape-design/browser-test-tools/page-objects'; import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; -import { TestDefinition, TestSuite } from './types'; +import { TestDefinition, TestSuite } from '../types'; const screenshotAreaSelector = '.screenshot-area'; const defaultWindowSize = { width: 1600, height: 800 }; @@ -97,6 +97,8 @@ function runSingleTest(testDef: TestDefinition) { test( testDef.description, + // useBrowser is not a React hook, despite the name + // eslint-disable-next-line react-hooks/rules-of-hooks useBrowser(windowSize, async browser => { const newUrl = buildUrl(newHost, testDef.path, testDef.queryParams); const newScreenshot = await captureScreenshot(browser, newUrl, testDef, testDef.setup); diff --git a/test/visual/visual.test.ts b/test/visual.test.ts similarity index 70% rename from test/visual/visual.test.ts rename to test/visual.test.ts index 06ef0fa8a4..bc7369d6a9 100644 --- a/test/visual/visual.test.ts +++ b/test/visual.test.ts @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from './compare-screenshots'; import { allSuites } from './definitions'; +import { runTestSuites } from './definitions/visual/compare-screenshots'; runTestSuites(allSuites); diff --git a/test/visual/definitions/action-card.ts b/test/visual/definitions/action-card.ts deleted file mode 100644 index 8730ac9504..0000000000 --- a/test/visual/definitions/action-card.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { TestSuite } from '../types'; - -const suite: TestSuite = { - description: 'action-card', - tests: [ - { - description: 'permutations', - path: 'action-card/permutations', - screenshotType: 'permutations', - }, - { - description: 'variant permutations', - path: 'action-card/variant-permutations', - screenshotType: 'permutations', - }, - { - description: 'padding permutations', - path: 'action-card/padding-permutations', - screenshotType: 'permutations', - }, - { - description: 'simple', - path: 'action-card/simple', - screenshotType: 'screenshotArea', - }, - ], -}; - -export default suite; diff --git a/test/visual/definitions/alert.ts b/test/visual/definitions/alert.ts deleted file mode 100644 index 30792c0d2d..0000000000 --- a/test/visual/definitions/alert.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { TestSuite } from '../types'; - -const suite: TestSuite = { - description: 'alert', - tests: [ - { - description: 'simple', - path: 'alert/simple', - screenshotType: 'screenshotArea', - }, - { - description: 'style custom page', - path: 'alert/style-custom-types', - screenshotType: 'screenshotArea', - }, - ...[600, 1280].map(width => ({ - description: `width ${width}px`, - tests: [ - { - description: 'permutations', - path: 'alert/permutations', - screenshotType: 'permutations' as const, - }, - { - description: 'custom types', - path: 'alert/style-custom-types', - screenshotType: 'screenshotArea' as const, - }, - ], - })), - ], -}; - -export default suite; diff --git a/test/visual/definitions/index.ts b/test/visual/definitions/index.ts deleted file mode 100644 index 318ce7c68b..0000000000 --- a/test/visual/definitions/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Each component has its own test definition file. -// Import them here manually to form the full test suite. -import { TestSuite } from '../types'; -import actionCard from './action-card'; -import alert from './alert'; - -export const allSuites: TestSuite[] = [actionCard, alert]; diff --git a/test/visual/types.ts b/test/visual/types.ts deleted file mode 100644 index c4c23b622f..0000000000 --- a/test/visual/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import type { ScreenshotPageObject } from '@cloudscape-design/browser-test-tools/page-objects'; - -export interface ScreenshotTestConfiguration { - width?: number; - height?: number; -} - -export type TestCallback = (page: ScreenshotPageObject) => Promise; - -// 'screenshotArea' — captures the .screenshot-area element on a focused page. -// 'permutations' — captures the entire page and crops permutations out of it. -export type ScreenshotType = 'screenshotArea' | 'permutations'; - -export interface TestDefinition { - description: string; - path: string; - screenshotType: ScreenshotType; - queryParams?: Record; - configuration?: ScreenshotTestConfiguration; - setup?: TestCallback; -} - -export interface TestSuite { - description: string; - tests: Array; -} From 735e123fe57567544a3e7902f3b4dd91b457a124 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 27 May 2026 10:34:20 +0200 Subject: [PATCH 45/95] Refactor --- test/definitions/utils.ts | 72 +++++++++++ .../definitions/visual/compare-screenshots.ts | 115 ------------------ test/visual.test.ts | 2 +- 3 files changed, 73 insertions(+), 116 deletions(-) create mode 100644 test/definitions/utils.ts delete mode 100644 test/definitions/visual/compare-screenshots.ts diff --git a/test/definitions/utils.ts b/test/definitions/utils.ts new file mode 100644 index 0000000000..2a650f79ed --- /dev/null +++ b/test/definitions/utils.ts @@ -0,0 +1,72 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { cropAndCompare } from '@cloudscape-design/browser-test-tools/image-utils'; +import { ScreenshotPageObject } from '@cloudscape-design/browser-test-tools/page-objects'; +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +import { TestDefinition, TestSuite } from './types'; + +const screenshotAreaSelector = '.screenshot-area'; +const defaultWindowSize = { width: 1600, height: 800 }; + +// NEW_HOST serves the PR's pages, OLD_HOST serves the baseline (main) pages. +const newHost = process.env.NEW_HOST || 'http://localhost:8080'; +const oldHost = process.env.OLD_HOST || 'http://localhost:8081'; + +function buildUrl(host: string, path: string, queryParams?: Record): string { + const params = new URLSearchParams(queryParams); + const qs = params.toString(); + return `${host}/#/${path}${qs ? `?${qs}` : ''}`; +} + +function isTestDefinition(item: TestDefinition | TestSuite): item is TestDefinition { + return (item as TestDefinition).path !== undefined; +} + +export function runTestSuites(suites: Array) { + for (const item of suites) { + if (isTestDefinition(item)) { + runSingleTest(item); + } else { + describe(item.description, () => { + runTestSuites(item.tests); + }); + } + } +} + +function runSingleTest(testDef: TestDefinition) { + const windowSize = { ...defaultWindowSize, ...testDef.configuration }; + + test( + testDef.description, + // useBrowser is not a React hook, despite the name + // eslint-disable-next-line react-hooks/rules-of-hooks + useBrowser(windowSize, async browser => { + const page = new ScreenshotPageObject(browser); + + const capture = async (host: string) => { + await browser.url(buildUrl(host, testDef.path, testDef.queryParams)); + await page.waitForVisible(screenshotAreaSelector); + if (testDef.setup) { + await testDef.setup(page); + } + return testDef.screenshotType === 'permutations' + ? page.capturePermutations() + : page.captureBySelector(screenshotAreaSelector); + }; + + const newScreenshots = await capture(newHost); + const oldScreenshots = await capture(oldHost); + + const newArr = Array.isArray(newScreenshots) ? newScreenshots : [newScreenshots]; + const oldArr = Array.isArray(oldScreenshots) ? oldScreenshots : [oldScreenshots]; + + expect(newArr.length).toBe(oldArr.length); + for (let i = 0; i < newArr.length; i++) { + const { diffPixels } = await cropAndCompare(newArr[i], oldArr[i]); + expect(diffPixels).toBe(0); + } + }) + ); +} diff --git a/test/definitions/visual/compare-screenshots.ts b/test/definitions/visual/compare-screenshots.ts deleted file mode 100644 index f81b8afc56..0000000000 --- a/test/definitions/visual/compare-screenshots.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import pixelmatch from 'pixelmatch'; -import { PNG } from 'pngjs'; - -import { parsePng } from '@cloudscape-design/browser-test-tools/image-utils'; -import { ScreenshotPageObject } from '@cloudscape-design/browser-test-tools/page-objects'; -import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; - -import { TestDefinition, TestSuite } from '../types'; - -const screenshotAreaSelector = '.screenshot-area'; -const defaultWindowSize = { width: 1600, height: 800 }; - -// NEW_HOST serves the PR's pages, OLD_HOST serves the baseline (main) pages. -const newHost = process.env.NEW_HOST || 'http://localhost:8080'; -const oldHost = process.env.OLD_HOST || 'http://localhost:8081'; - -/** - * Captures the .screenshot-area element on a focused page. - * Uses a standard ScreenshotPageObject (no forced scroll-and-merge). - */ -async function captureScreenshotArea(browser: WebdriverIO.Browser, url: string): Promise { - await browser.url(url); - const page = new ScreenshotPageObject(browser); - await page.waitForVisible(screenshotAreaSelector); - const { image } = await page.captureBySelector(screenshotAreaSelector); - return image; -} - -/** - * Captures the full page as a PNG for permutation pages. - * Uses fullPageScreenshot which handles pages taller than the viewport. - */ -async function capturePermutations(browser: WebdriverIO.Browser, url: string): Promise { - await browser.url(url); - const page = new ScreenshotPageObject(browser); - await page.waitForVisible(screenshotAreaSelector); - const base64 = await page.fullPageScreenshot(); - return parsePng(base64); -} - -async function captureScreenshot( - browser: WebdriverIO.Browser, - url: string, - testDef: TestDefinition, - setup?: (page: ScreenshotPageObject) => Promise -): Promise { - if (setup) { - await browser.url(url); - const page = new ScreenshotPageObject(browser); - await page.waitForVisible(screenshotAreaSelector); - await setup(page); - if (testDef.screenshotType === 'permutations') { - const base64 = await page.fullPageScreenshot(); - return parsePng(base64); - } - const { image } = await page.captureBySelector(screenshotAreaSelector); - return image; - } - if (testDef.screenshotType === 'permutations') { - return capturePermutations(browser, url); - } - return captureScreenshotArea(browser, url); -} - -function buildUrl(host: string, path: string, queryParams?: Record): string { - const params = new URLSearchParams(queryParams); - const qs = params.toString(); - return `${host}/#/${path}${qs ? `?${qs}` : ''}`; -} - -function compareImages(newImage: PNG, oldImage: PNG): number { - const { width, height } = newImage; - const diff = new PNG({ width, height }); - return pixelmatch(newImage.data, oldImage.data, diff.data, width, height, { threshold: 0.1 }); -} - -function isTestDefinition(item: TestDefinition | TestSuite): item is TestDefinition { - return (item as TestDefinition).path !== undefined; -} - -export function runTestSuites(suites: Array) { - for (const item of suites) { - if (isTestDefinition(item)) { - runSingleTest(item); - } else { - describe(item.description, () => { - runTestSuites(item.tests); - }); - } - } -} - -function runSingleTest(testDef: TestDefinition) { - const windowSize = { ...defaultWindowSize, ...testDef.configuration }; - - test( - testDef.description, - // useBrowser is not a React hook, despite the name - // eslint-disable-next-line react-hooks/rules-of-hooks - useBrowser(windowSize, async browser => { - const newUrl = buildUrl(newHost, testDef.path, testDef.queryParams); - const newScreenshot = await captureScreenshot(browser, newUrl, testDef, testDef.setup); - - const oldUrl = buildUrl(oldHost, testDef.path, testDef.queryParams); - const oldScreenshot = await captureScreenshot(browser, oldUrl, testDef, testDef.setup); - const diffPixels = compareImages(newScreenshot, oldScreenshot); - expect(diffPixels).toBe(0); - }) - ); -} - -// Export the capture functions for use in custom setup callbacks if needed. -export { captureScreenshotArea, capturePermutations }; diff --git a/test/visual.test.ts b/test/visual.test.ts index bc7369d6a9..4e6e992f4f 100644 --- a/test/visual.test.ts +++ b/test/visual.test.ts @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { allSuites } from './definitions'; -import { runTestSuites } from './definitions/visual/compare-screenshots'; +import { runTestSuites } from './definitions/utils'; runTestSuites(allSuites); From a2639989eda4539595f12cef8bdb476cb49c67be Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 27 May 2026 10:38:40 +0200 Subject: [PATCH 46/95] Fix setup --- tsconfig.integ.json | 2 +- tsconfig.test-definitions.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tsconfig.integ.json b/tsconfig.integ.json index e816f749e9..784a3d2f1f 100644 --- a/tsconfig.integ.json +++ b/tsconfig.integ.json @@ -11,5 +11,5 @@ "resolveJsonModule": true, "moduleResolution": "node" }, - "include": ["**/__integ__/**/*.ts", "**/__a11y__/**/*.ts", "**/__motion__/**/*.ts", "types"] + "include": ["**/__integ__/**/*.ts", "**/__a11y__/**/*.ts", "**/__motion__/**/*.ts", "test/definitions/utils.ts", "test/visual.test.ts", "types"] } diff --git a/tsconfig.test-definitions.json b/tsconfig.test-definitions.json index 30e82b4047..3f5961a78b 100644 --- a/tsconfig.test-definitions.json +++ b/tsconfig.test-definitions.json @@ -16,5 +16,5 @@ "skipLibCheck": true }, "include": ["test/definitions", "test/types.ts"], - "exclude": [] + "exclude": ["test/definitions/utils.ts"] } From 77e9470a839d3bd1c2760576a7ffeaca71077c30 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 27 May 2026 10:49:40 +0200 Subject: [PATCH 47/95] Fix setup --- docs/RUNNING_TESTS.md | 2 +- eslint.config.mjs | 2 +- jest.visual.config.js | 2 +- test/definitions/utils.ts | 2 -- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/RUNNING_TESTS.md b/docs/RUNNING_TESTS.md index 6e0eb84503..c7832dca72 100644 --- a/docs/RUNNING_TESTS.md +++ b/docs/RUNNING_TESTS.md @@ -68,7 +68,7 @@ They compare permutation pages between the PR build and a baseline build of `mai 1. The PR pages are built and served on port 8080. 2. A git worktree of `origin/main` is created, its dependencies installed, and its pages built and served on port 8081. -3. The single test runner (`test/visual/visual.test.ts`) iterates over all test definitions, captures the `.screenshot-area` element from both servers for each test, and fails if any pixels differ. +3. The single test runner (`test/visual.test.ts`) iterates over all test definitions, captures the `.screenshot-area` element from both servers for each test, and fails if any pixels differ. ### Running locally diff --git a/eslint.config.mjs b/eslint.config.mjs index f03eb9ce60..b92a169696 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -225,7 +225,7 @@ export default tsEslint.config( }, }, { - files: ['**/__integ__/**', '**/__motion__/**', '**/__a11y__/**', 'test/visual/**'], + files: ['**/__integ__/**', '**/__motion__/**', '**/__a11y__/**', 'test/definitions/**'], rules: { // useBrowser is not a hook 'react-hooks/rules-of-hooks': 'off', diff --git a/jest.visual.config.js b/jest.visual.config.js index 0d3abc58fd..097054bb69 100644 --- a/jest.visual.config.js +++ b/jest.visual.config.js @@ -23,5 +23,5 @@ module.exports = { globalTeardown: '/build-tools/visual/global-teardown.js', setupFilesAfterEnv: [path.join(__dirname, 'build-tools', 'visual', 'setup.js')], moduleFileExtensions: ['js', 'ts'], - testMatch: ['/test/visual/visual.test.ts'], + testMatch: ['/test/visual.test.ts'], }; diff --git a/test/definitions/utils.ts b/test/definitions/utils.ts index 2a650f79ed..b29d3a8543 100644 --- a/test/definitions/utils.ts +++ b/test/definitions/utils.ts @@ -40,8 +40,6 @@ function runSingleTest(testDef: TestDefinition) { test( testDef.description, - // useBrowser is not a React hook, despite the name - // eslint-disable-next-line react-hooks/rules-of-hooks useBrowser(windowSize, async browser => { const page = new ScreenshotPageObject(browser); From d022c5bf748cfcbd8787ff3f0077ed22c432f2f0 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 27 May 2026 11:59:05 +0200 Subject: [PATCH 48/95] Create one single browser session for all tests --- test/definitions/utils.ts | 88 ++++++++++++++++++++++++--------------- 1 file changed, 55 insertions(+), 33 deletions(-) diff --git a/test/definitions/utils.ts b/test/definitions/utils.ts index b29d3a8543..253d5c008b 100644 --- a/test/definitions/utils.ts +++ b/test/definitions/utils.ts @@ -1,8 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { cropAndCompare } from '@cloudscape-design/browser-test-tools/image-utils'; -import { ScreenshotPageObject } from '@cloudscape-design/browser-test-tools/page-objects'; -import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; +import { ScreenshotPageObject, ScreenshotWithOffset } from '@cloudscape-design/browser-test-tools/page-objects'; import { TestDefinition, TestSuite } from './types'; @@ -23,48 +22,71 @@ function isTestDefinition(item: TestDefinition | TestSuite): item is TestDefinit return (item as TestDefinition).path !== undefined; } +/** + * Registers all test suites. Creates a single shared browser session for the + * entire suite rather than spinning up a new session per test, which is the + * main source of overhead compared to AWS-UI-IntegrationTests. + */ export function runTestSuites(suites: Array) { + let browser: WebdriverIO.Browser; + + beforeAll(async () => { + const { default: getBrowserCreator } = await import('@cloudscape-design/browser-test-tools/browser'); + const isSafari = process.env.BROWSER === 'safari'; + const browserName = isSafari ? 'Safari' : 'ChromeHeadlessIntegration'; + const seleniumUrl = isSafari ? 'http://localhost:4444' : 'http://localhost:9515'; + const creator = getBrowserCreator(browserName, 'local', { seleniumUrl }); + browser = await creator.getBrowser({ width: defaultWindowSize.width, height: defaultWindowSize.height }); + }); + + afterAll(async () => { + await browser?.deleteSession(); + }); + + registerSuites(suites, () => browser); +} + +function registerSuites(suites: Array, getBrowser: () => WebdriverIO.Browser) { for (const item of suites) { if (isTestDefinition(item)) { - runSingleTest(item); + registerTest(item, getBrowser); } else { describe(item.description, () => { - runTestSuites(item.tests); + registerSuites(item.tests, getBrowser); }); } } } -function runSingleTest(testDef: TestDefinition) { +function registerTest(testDef: TestDefinition, getBrowser: () => WebdriverIO.Browser) { const windowSize = { ...defaultWindowSize, ...testDef.configuration }; - test( - testDef.description, - useBrowser(windowSize, async browser => { - const page = new ScreenshotPageObject(browser); - - const capture = async (host: string) => { - await browser.url(buildUrl(host, testDef.path, testDef.queryParams)); - await page.waitForVisible(screenshotAreaSelector); - if (testDef.setup) { - await testDef.setup(page); - } - return testDef.screenshotType === 'permutations' - ? page.capturePermutations() - : page.captureBySelector(screenshotAreaSelector); - }; - - const newScreenshots = await capture(newHost); - const oldScreenshots = await capture(oldHost); - - const newArr = Array.isArray(newScreenshots) ? newScreenshots : [newScreenshots]; - const oldArr = Array.isArray(oldScreenshots) ? oldScreenshots : [oldScreenshots]; - - expect(newArr.length).toBe(oldArr.length); - for (let i = 0; i < newArr.length; i++) { - const { diffPixels } = await cropAndCompare(newArr[i], oldArr[i]); - expect(diffPixels).toBe(0); + test(testDef.description, async () => { + const browser = getBrowser(); + await browser.setWindowSize(windowSize.width, windowSize.height); + const page = new ScreenshotPageObject(browser); + + const capture = async (host: string) => { + await browser.url(buildUrl(host, testDef.path, testDef.queryParams)); + await page.waitForVisible(screenshotAreaSelector); + if (testDef.setup) { + await testDef.setup(page); } - }) - ); + return testDef.screenshotType === 'permutations' + ? page.capturePermutations() + : page.captureBySelector(screenshotAreaSelector); + }; + + const newScreenshots = await capture(newHost); + const oldScreenshots = await capture(oldHost); + + const newArr: ScreenshotWithOffset[] = Array.isArray(newScreenshots) ? newScreenshots : [newScreenshots]; + const oldArr: ScreenshotWithOffset[] = Array.isArray(oldScreenshots) ? oldScreenshots : [oldScreenshots]; + + expect(newArr.length).toBe(oldArr.length); + for (let i = 0; i < newArr.length; i++) { + const { diffPixels } = await cropAndCompare(newArr[i], oldArr[i]); + expect(diffPixels).toBe(0); + } + }); } From c6c3e5d56fbc759288b3da5a73c3c573108b61a4 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 27 May 2026 12:35:06 +0200 Subject: [PATCH 49/95] Try to fix Safari --- test/definitions/utils.ts | 115 +++++++++++++++++++++++--------------- 1 file changed, 70 insertions(+), 45 deletions(-) diff --git a/test/definitions/utils.ts b/test/definitions/utils.ts index 253d5c008b..369026bd02 100644 --- a/test/definitions/utils.ts +++ b/test/definitions/utils.ts @@ -12,41 +12,55 @@ const defaultWindowSize = { width: 1600, height: 800 }; const newHost = process.env.NEW_HOST || 'http://localhost:8080'; const oldHost = process.env.OLD_HOST || 'http://localhost:8081'; +const isSafari = process.env.BROWSER === 'safari'; + function buildUrl(host: string, path: string, queryParams?: Record): string { const params = new URLSearchParams(queryParams); const qs = params.toString(); return `${host}/#/${path}${qs ? `?${qs}` : ''}`; } +async function createBrowser(windowSize = defaultWindowSize): Promise { + const { default: getBrowserCreator } = await import('@cloudscape-design/browser-test-tools/browser'); + const browserName = isSafari ? 'Safari' : 'ChromeHeadlessIntegration'; + const seleniumUrl = isSafari ? 'http://localhost:4444' : 'http://localhost:9515'; + const creator = getBrowserCreator(browserName, 'local', { seleniumUrl }); + return creator.getBrowser({ width: windowSize.width, height: windowSize.height }); +} + function isTestDefinition(item: TestDefinition | TestSuite): item is TestDefinition { return (item as TestDefinition).path !== undefined; } /** - * Registers all test suites. Creates a single shared browser session for the - * entire suite rather than spinning up a new session per test, which is the - * main source of overhead compared to AWS-UI-IntegrationTests. + * Registers all test suites. For Chrome, creates a single shared browser + * session for the entire suite to avoid per-test session overhead. + * + * Safari only supports one WebDriver session at a time and is sensitive to + * session teardown timing, so for Safari we create a fresh session per test. */ export function runTestSuites(suites: Array) { - let browser: WebdriverIO.Browser; - - beforeAll(async () => { - const { default: getBrowserCreator } = await import('@cloudscape-design/browser-test-tools/browser'); - const isSafari = process.env.BROWSER === 'safari'; - const browserName = isSafari ? 'Safari' : 'ChromeHeadlessIntegration'; - const seleniumUrl = isSafari ? 'http://localhost:4444' : 'http://localhost:9515'; - const creator = getBrowserCreator(browserName, 'local', { seleniumUrl }); - browser = await creator.getBrowser({ width: defaultWindowSize.width, height: defaultWindowSize.height }); - }); - - afterAll(async () => { - await browser?.deleteSession(); - }); - - registerSuites(suites, () => browser); + if (isSafari) { + // Per-test sessions: safe for Safari's single-session constraint. + registerSuites(suites, null); + } else { + // Shared session: one browser for all tests in this worker. + let browser: WebdriverIO.Browser; + + beforeAll(async () => { + browser = await createBrowser(); + }); + + afterAll(async () => { + await browser?.deleteSession(); + }); + + registerSuites(suites, () => browser); + } } -function registerSuites(suites: Array, getBrowser: () => WebdriverIO.Browser) { +// getBrowser === null means "create a fresh session per test" (Safari mode). +function registerSuites(suites: Array, getBrowser: (() => WebdriverIO.Browser) | null) { for (const item of suites) { if (isTestDefinition(item)) { registerTest(item, getBrowser); @@ -58,35 +72,46 @@ function registerSuites(suites: Array, getBrowser: ( } } -function registerTest(testDef: TestDefinition, getBrowser: () => WebdriverIO.Browser) { +function registerTest(testDef: TestDefinition, getBrowser: (() => WebdriverIO.Browser) | null) { const windowSize = { ...defaultWindowSize, ...testDef.configuration }; test(testDef.description, async () => { - const browser = getBrowser(); - await browser.setWindowSize(windowSize.width, windowSize.height); - const page = new ScreenshotPageObject(browser); - - const capture = async (host: string) => { - await browser.url(buildUrl(host, testDef.path, testDef.queryParams)); - await page.waitForVisible(screenshotAreaSelector); - if (testDef.setup) { - await testDef.setup(page); - } - return testDef.screenshotType === 'permutations' - ? page.capturePermutations() - : page.captureBySelector(screenshotAreaSelector); - }; - - const newScreenshots = await capture(newHost); - const oldScreenshots = await capture(oldHost); - - const newArr: ScreenshotWithOffset[] = Array.isArray(newScreenshots) ? newScreenshots : [newScreenshots]; - const oldArr: ScreenshotWithOffset[] = Array.isArray(oldScreenshots) ? oldScreenshots : [oldScreenshots]; + // Safari: create and destroy a session per test. + // Chrome: reuse the shared session, just resize the window. + const browser = getBrowser ? getBrowser() : await createBrowser(windowSize); + if (getBrowser) { + await browser.setWindowSize(windowSize.width, windowSize.height); + } - expect(newArr.length).toBe(oldArr.length); - for (let i = 0; i < newArr.length; i++) { - const { diffPixels } = await cropAndCompare(newArr[i], oldArr[i]); - expect(diffPixels).toBe(0); + try { + const page = new ScreenshotPageObject(browser); + + const capture = async (host: string) => { + await browser.url(buildUrl(host, testDef.path, testDef.queryParams)); + await page.waitForVisible(screenshotAreaSelector); + if (testDef.setup) { + await testDef.setup(page); + } + return testDef.screenshotType === 'permutations' + ? page.capturePermutations() + : page.captureBySelector(screenshotAreaSelector); + }; + + const newScreenshots = await capture(newHost); + const oldScreenshots = await capture(oldHost); + + const newArr: ScreenshotWithOffset[] = Array.isArray(newScreenshots) ? newScreenshots : [newScreenshots]; + const oldArr: ScreenshotWithOffset[] = Array.isArray(oldScreenshots) ? oldScreenshots : [oldScreenshots]; + + expect(newArr.length).toBe(oldArr.length); + for (let i = 0; i < newArr.length; i++) { + const { diffPixels } = await cropAndCompare(newArr[i], oldArr[i]); + expect(diffPixels).toBe(0); + } + } finally { + if (!getBrowser) { + await browser.deleteSession(); + } } }); } From de6525b40c7d28cb28101562a40411b0cfce670b Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 27 May 2026 12:56:37 +0200 Subject: [PATCH 50/95] Try again --- build-tools/visual/global-setup.js | 11 +++ test/definitions/utils.ts | 112 ++++++++++++----------------- 2 files changed, 55 insertions(+), 68 deletions(-) diff --git a/build-tools/visual/global-setup.js b/build-tools/visual/global-setup.js index b4acf683c9..e4082c183d 100644 --- a/build-tools/visual/global-setup.js +++ b/build-tools/visual/global-setup.js @@ -5,6 +5,17 @@ const waitOn = require('wait-on'); module.exports = async () => { if (process.env.BROWSER === 'safari') { + // Kill any lingering safaridriver process from a previous run to ensure + // no stale sessions exist (Safari only supports one session at a time). + const { execSync } = require('child_process'); + try { + execSync('pkill -f safaridriver', { stdio: 'ignore' }); + // Give the OS a moment to release the port. + await new Promise(resolve => setTimeout(resolve, 1000)); + } catch { + // No existing process — that's fine. + } + const driverProcess = spawn('safaridriver', ['--port', '4444']); driverProcess.on('error', err => { throw err; diff --git a/test/definitions/utils.ts b/test/definitions/utils.ts index 369026bd02..7597a465e2 100644 --- a/test/definitions/utils.ts +++ b/test/definitions/utils.ts @@ -20,47 +20,34 @@ function buildUrl(host: string, path: string, queryParams?: Record { - const { default: getBrowserCreator } = await import('@cloudscape-design/browser-test-tools/browser'); - const browserName = isSafari ? 'Safari' : 'ChromeHeadlessIntegration'; - const seleniumUrl = isSafari ? 'http://localhost:4444' : 'http://localhost:9515'; - const creator = getBrowserCreator(browserName, 'local', { seleniumUrl }); - return creator.getBrowser({ width: windowSize.width, height: windowSize.height }); -} - function isTestDefinition(item: TestDefinition | TestSuite): item is TestDefinition { return (item as TestDefinition).path !== undefined; } /** - * Registers all test suites. For Chrome, creates a single shared browser - * session for the entire suite to avoid per-test session overhead. - * - * Safari only supports one WebDriver session at a time and is sensitive to - * session teardown timing, so for Safari we create a fresh session per test. + * Registers all test suites with a single shared browser session per worker. + * This avoids the per-test session creation overhead that made tests slow. + * Safari runs with maxWorkers: 1, so there's only ever one session. */ export function runTestSuites(suites: Array) { - if (isSafari) { - // Per-test sessions: safe for Safari's single-session constraint. - registerSuites(suites, null); - } else { - // Shared session: one browser for all tests in this worker. - let browser: WebdriverIO.Browser; - - beforeAll(async () => { - browser = await createBrowser(); - }); - - afterAll(async () => { - await browser?.deleteSession(); - }); - - registerSuites(suites, () => browser); - } + let browser: WebdriverIO.Browser; + + beforeAll(async () => { + const { default: getBrowserCreator } = await import('@cloudscape-design/browser-test-tools/browser'); + const browserName = isSafari ? 'Safari' : 'ChromeHeadlessIntegration'; + const seleniumUrl = isSafari ? 'http://localhost:4444' : 'http://localhost:9515'; + const creator = getBrowserCreator(browserName, 'local', { seleniumUrl }); + browser = await creator.getBrowser({ width: defaultWindowSize.width, height: defaultWindowSize.height }); + }); + + afterAll(async () => { + await browser?.deleteSession(); + }); + + registerSuites(suites, () => browser); } -// getBrowser === null means "create a fresh session per test" (Safari mode). -function registerSuites(suites: Array, getBrowser: (() => WebdriverIO.Browser) | null) { +function registerSuites(suites: Array, getBrowser: () => WebdriverIO.Browser) { for (const item of suites) { if (isTestDefinition(item)) { registerTest(item, getBrowser); @@ -72,46 +59,35 @@ function registerSuites(suites: Array, getBrowser: ( } } -function registerTest(testDef: TestDefinition, getBrowser: (() => WebdriverIO.Browser) | null) { +function registerTest(testDef: TestDefinition, getBrowser: () => WebdriverIO.Browser) { const windowSize = { ...defaultWindowSize, ...testDef.configuration }; test(testDef.description, async () => { - // Safari: create and destroy a session per test. - // Chrome: reuse the shared session, just resize the window. - const browser = getBrowser ? getBrowser() : await createBrowser(windowSize); - if (getBrowser) { - await browser.setWindowSize(windowSize.width, windowSize.height); - } - - try { - const page = new ScreenshotPageObject(browser); - - const capture = async (host: string) => { - await browser.url(buildUrl(host, testDef.path, testDef.queryParams)); - await page.waitForVisible(screenshotAreaSelector); - if (testDef.setup) { - await testDef.setup(page); - } - return testDef.screenshotType === 'permutations' - ? page.capturePermutations() - : page.captureBySelector(screenshotAreaSelector); - }; - - const newScreenshots = await capture(newHost); - const oldScreenshots = await capture(oldHost); - - const newArr: ScreenshotWithOffset[] = Array.isArray(newScreenshots) ? newScreenshots : [newScreenshots]; - const oldArr: ScreenshotWithOffset[] = Array.isArray(oldScreenshots) ? oldScreenshots : [oldScreenshots]; - - expect(newArr.length).toBe(oldArr.length); - for (let i = 0; i < newArr.length; i++) { - const { diffPixels } = await cropAndCompare(newArr[i], oldArr[i]); - expect(diffPixels).toBe(0); - } - } finally { - if (!getBrowser) { - await browser.deleteSession(); + const browser = getBrowser(); + await browser.setWindowSize(windowSize.width, windowSize.height); + const page = new ScreenshotPageObject(browser); + + const capture = async (host: string) => { + await browser.url(buildUrl(host, testDef.path, testDef.queryParams)); + await page.waitForVisible(screenshotAreaSelector); + if (testDef.setup) { + await testDef.setup(page); } + return testDef.screenshotType === 'permutations' + ? page.capturePermutations() + : page.captureBySelector(screenshotAreaSelector); + }; + + const newScreenshots = await capture(newHost); + const oldScreenshots = await capture(oldHost); + + const newArr: ScreenshotWithOffset[] = Array.isArray(newScreenshots) ? newScreenshots : [newScreenshots]; + const oldArr: ScreenshotWithOffset[] = Array.isArray(oldScreenshots) ? oldScreenshots : [oldScreenshots]; + + expect(newArr.length).toBe(oldArr.length); + for (let i = 0; i < newArr.length; i++) { + const { diffPixels } = await cropAndCompare(newArr[i], oldArr[i]); + expect(diffPixels).toBe(0); } }); } From 38a09d43db4224886a66c2e1e3d0c0dbad8ef8c9 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 27 May 2026 15:52:13 +0200 Subject: [PATCH 51/95] Start safaridriver from workflow --- .github/workflows/visual-regression.yml | 4 +++- build-tools/visual/global-setup.js | 18 +----------------- build-tools/visual/global-teardown.js | 7 ++----- 3 files changed, 6 insertions(+), 23 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 4f62e0fa16..a763c7868a 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -142,7 +142,9 @@ jobs: - name: Enable SafariDriver if: matrix.browser == 'safari' - run: sudo safaridriver --enable + run: | + sudo safaridriver --enable + safaridriver --port 4444 & - name: Install dependencies run: npm i diff --git a/build-tools/visual/global-setup.js b/build-tools/visual/global-setup.js index e4082c183d..a1c19b7022 100644 --- a/build-tools/visual/global-setup.js +++ b/build-tools/visual/global-setup.js @@ -1,27 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -const { spawn } = require('child_process'); const waitOn = require('wait-on'); module.exports = async () => { if (process.env.BROWSER === 'safari') { - // Kill any lingering safaridriver process from a previous run to ensure - // no stale sessions exist (Safari only supports one session at a time). - const { execSync } = require('child_process'); - try { - execSync('pkill -f safaridriver', { stdio: 'ignore' }); - // Give the OS a moment to release the port. - await new Promise(resolve => setTimeout(resolve, 1000)); - } catch { - // No existing process — that's fine. - } - - const driverProcess = spawn('safaridriver', ['--port', '4444']); - driverProcess.on('error', err => { - throw err; - }); + // safaridriver is started by the CI workflow on port 4444. await waitOn({ resources: ['http-get://localhost:4444/status'], timeout: 10000 }); - global.__DRIVER_PROCESS__ = driverProcess; } else { const { startWebdriver } = require('@cloudscape-design/browser-test-tools/chrome-launcher'); await startWebdriver(); diff --git a/build-tools/visual/global-teardown.js b/build-tools/visual/global-teardown.js index 366c3f7660..61f45caa02 100644 --- a/build-tools/visual/global-teardown.js +++ b/build-tools/visual/global-teardown.js @@ -1,12 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 module.exports = () => { - if (process.env.BROWSER === 'safari') { - if (global.__DRIVER_PROCESS__) { - global.__DRIVER_PROCESS__.kill(); - } - } else { + if (process.env.BROWSER !== 'safari') { const { shutdownWebdriver } = require('@cloudscape-design/browser-test-tools/chrome-launcher'); shutdownWebdriver(); } + // Safari: safaridriver is managed by the CI workflow, nothing to tear down. }; From b607c4da56256df4ef886875ad0d3d47477c61b5 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 27 May 2026 17:05:43 +0200 Subject: [PATCH 52/95] Move Safaridriver initialization back to local test setup --- .github/workflows/visual-regression.yml | 8 +++----- build-tools/visual/global-setup.js | 7 ++++++- build-tools/visual/global-teardown.js | 7 +++++-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index a763c7868a..50891f2558 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -142,9 +142,7 @@ jobs: - name: Enable SafariDriver if: matrix.browser == 'safari' - run: | - sudo safaridriver --enable - safaridriver --port 4444 & + run: sudo safaridriver --enable - name: Install dependencies run: npm i @@ -163,10 +161,10 @@ jobs: # ── Run tests ───────────────────────────────────────────────────────── - name: Start test server (port 8080) - run: npx serve --no-clipboard --listen 8080 pages/lib/static-default & + run: npx --yes serve --no-clipboard --listen 8080 pages/lib/static-default & - name: Start baseline server (port 8081) - run: npx serve --no-clipboard --listen 8081 pages/lib/static-visual-baseline & + run: npx --yes serve --no-clipboard --listen 8081 pages/lib/static-visual-baseline & - name: Wait for servers to be ready run: node_modules/.bin/wait-on http://localhost:8080 http://localhost:8081 diff --git a/build-tools/visual/global-setup.js b/build-tools/visual/global-setup.js index a1c19b7022..b4acf683c9 100644 --- a/build-tools/visual/global-setup.js +++ b/build-tools/visual/global-setup.js @@ -1,11 +1,16 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +const { spawn } = require('child_process'); const waitOn = require('wait-on'); module.exports = async () => { if (process.env.BROWSER === 'safari') { - // safaridriver is started by the CI workflow on port 4444. + const driverProcess = spawn('safaridriver', ['--port', '4444']); + driverProcess.on('error', err => { + throw err; + }); await waitOn({ resources: ['http-get://localhost:4444/status'], timeout: 10000 }); + global.__DRIVER_PROCESS__ = driverProcess; } else { const { startWebdriver } = require('@cloudscape-design/browser-test-tools/chrome-launcher'); await startWebdriver(); diff --git a/build-tools/visual/global-teardown.js b/build-tools/visual/global-teardown.js index 61f45caa02..366c3f7660 100644 --- a/build-tools/visual/global-teardown.js +++ b/build-tools/visual/global-teardown.js @@ -1,9 +1,12 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 module.exports = () => { - if (process.env.BROWSER !== 'safari') { + if (process.env.BROWSER === 'safari') { + if (global.__DRIVER_PROCESS__) { + global.__DRIVER_PROCESS__.kill(); + } + } else { const { shutdownWebdriver } = require('@cloudscape-design/browser-test-tools/chrome-launcher'); shutdownWebdriver(); } - // Safari: safaridriver is managed by the CI workflow, nothing to tear down. }; From dedf6ecbe56844ceca58fe753b6a28476ae62b58 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 28 May 2026 10:23:36 +0200 Subject: [PATCH 53/95] Remove outdated npm script --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index d25129aa23..8110f028b0 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "test:a11y": "gulp test:a11y", "test:integ": "gulp test:integ", "test:motion": "gulp test:motion", - "test:visual": "gulp test:visual", "lint": "npm-run-all --parallel lint:*", "lint:eslint": "eslint .", "lint:stylelint": "stylelint --ignore-path .gitignore '{src,pages}/**/*.{css,scss}'", From 3781167eefc43472650ae436221abc49cbaf7b32 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 29 May 2026 09:02:08 +0200 Subject: [PATCH 54/95] Remove Safari setup --- .github/workflows/visual-regression.yml | 7 ---- build-tools/visual/global-setup.js | 15 ++------- build-tools/visual/global-teardown.js | 11 ++----- build-tools/visual/setup.js | 15 ++------- jest.visual.config.js | 4 +-- test/definitions/utils.ts | 43 +++++++++++-------------- 6 files changed, 28 insertions(+), 67 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 50891f2558..40493f7e9e 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -123,8 +123,6 @@ jobs: include: - browser: chrome os: ubuntu-latest - - browser: safari - os: macos-latest steps: - uses: actions/checkout@v4 @@ -135,15 +133,10 @@ jobs: cache: npm - name: Setup Chrome and ChromeDriver - if: matrix.browser == 'chrome' uses: browser-actions/setup-chrome@v1 with: chrome-version: stable - - name: Enable SafariDriver - if: matrix.browser == 'safari' - run: sudo safaridriver --enable - - name: Install dependencies run: npm i diff --git a/build-tools/visual/global-setup.js b/build-tools/visual/global-setup.js index b4acf683c9..52ce2f271c 100644 --- a/build-tools/visual/global-setup.js +++ b/build-tools/visual/global-setup.js @@ -1,18 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -const { spawn } = require('child_process'); -const waitOn = require('wait-on'); module.exports = async () => { - if (process.env.BROWSER === 'safari') { - const driverProcess = spawn('safaridriver', ['--port', '4444']); - driverProcess.on('error', err => { - throw err; - }); - await waitOn({ resources: ['http-get://localhost:4444/status'], timeout: 10000 }); - global.__DRIVER_PROCESS__ = driverProcess; - } else { - const { startWebdriver } = require('@cloudscape-design/browser-test-tools/chrome-launcher'); - await startWebdriver(); - } + const { startWebdriver } = require('@cloudscape-design/browser-test-tools/chrome-launcher'); + await startWebdriver(); }; diff --git a/build-tools/visual/global-teardown.js b/build-tools/visual/global-teardown.js index 366c3f7660..0fa05eebfe 100644 --- a/build-tools/visual/global-teardown.js +++ b/build-tools/visual/global-teardown.js @@ -1,12 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 + module.exports = () => { - if (process.env.BROWSER === 'safari') { - if (global.__DRIVER_PROCESS__) { - global.__DRIVER_PROCESS__.kill(); - } - } else { - const { shutdownWebdriver } = require('@cloudscape-design/browser-test-tools/chrome-launcher'); - shutdownWebdriver(); - } + const { shutdownWebdriver } = require('@cloudscape-design/browser-test-tools/chrome-launcher'); + shutdownWebdriver(); }; diff --git a/build-tools/visual/setup.js b/build-tools/visual/setup.js index c63a18416f..d52cd606fb 100644 --- a/build-tools/visual/setup.js +++ b/build-tools/visual/setup.js @@ -3,23 +3,14 @@ /* global jest */ const { configure } = require('@cloudscape-design/browser-test-tools/use-browser'); -const isSafari = process.env.BROWSER === 'safari'; - -// The PR build (the code under test) is served on port 8080. -// The baseline build (main branch, same node_modules) is served on port 8081. configure({ - browserName: isSafari ? 'Safari' : 'ChromeHeadlessIntegration', + browserName: 'ChromeHeadlessIntegration', browserCreatorOptions: { - seleniumUrl: isSafari ? 'http://localhost:4444' : 'http://localhost:9515', + seleniumUrl: 'http://localhost:9515', }, webdriverOptions: { baseUrl: 'http://localhost:8080', }, }); -// Retries help with flaky tests, but Safari's single-session constraint means -// a retry can hit "already paired" if the previous attempt's session hasn't -// fully released. Disable retries for Safari. -if (!isSafari) { - jest.retryTimes(2, { logErrorsBeforeRetry: true }); -} +jest.retryTimes(2, { logErrorsBeforeRetry: true }); diff --git a/jest.visual.config.js b/jest.visual.config.js index 097054bb69..a4d06d46ae 100644 --- a/jest.visual.config.js +++ b/jest.visual.config.js @@ -16,9 +16,7 @@ module.exports = { }, reporters: ['default', 'github-actions'], testTimeout: 120_000, // 2min — pages can be tall and slow to capture - // Safari's WebDriver only supports one concurrent session, so tests must run serially. - // Chrome can run multiple workers to speed things up. - maxWorkers: process.env.BROWSER === 'safari' ? 1 : os.cpus().length * (process.env.GITHUB_ACTION ? 3 : 1), + maxWorkers: os.cpus().length * (process.env.GITHUB_ACTION ? 3 : 1), globalSetup: '/build-tools/visual/global-setup.js', globalTeardown: '/build-tools/visual/global-teardown.js', setupFilesAfterEnv: [path.join(__dirname, 'build-tools', 'visual', 'setup.js')], diff --git a/test/definitions/utils.ts b/test/definitions/utils.ts index 7597a465e2..2467c3801b 100644 --- a/test/definitions/utils.ts +++ b/test/definitions/utils.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { cropAndCompare } from '@cloudscape-design/browser-test-tools/image-utils'; -import { ScreenshotPageObject, ScreenshotWithOffset } from '@cloudscape-design/browser-test-tools/page-objects'; +import { ScreenshotPageObject } from '@cloudscape-design/browser-test-tools/page-objects'; import { TestDefinition, TestSuite } from './types'; @@ -12,8 +12,6 @@ const defaultWindowSize = { width: 1600, height: 800 }; const newHost = process.env.NEW_HOST || 'http://localhost:8080'; const oldHost = process.env.OLD_HOST || 'http://localhost:8081'; -const isSafari = process.env.BROWSER === 'safari'; - function buildUrl(host: string, path: string, queryParams?: Record): string { const params = new URLSearchParams(queryParams); const qs = params.toString(); @@ -26,17 +24,16 @@ function isTestDefinition(item: TestDefinition | TestSuite): item is TestDefinit /** * Registers all test suites with a single shared browser session per worker. - * This avoids the per-test session creation overhead that made tests slow. - * Safari runs with maxWorkers: 1, so there's only ever one session. + * This avoids the per-test session creation overhead. */ export function runTestSuites(suites: Array) { let browser: WebdriverIO.Browser; beforeAll(async () => { const { default: getBrowserCreator } = await import('@cloudscape-design/browser-test-tools/browser'); - const browserName = isSafari ? 'Safari' : 'ChromeHeadlessIntegration'; - const seleniumUrl = isSafari ? 'http://localhost:4444' : 'http://localhost:9515'; - const creator = getBrowserCreator(browserName, 'local', { seleniumUrl }); + const creator = getBrowserCreator('ChromeHeadlessIntegration', 'local', { + seleniumUrl: 'http://localhost:9515', + }); browser = await creator.getBrowser({ width: defaultWindowSize.width, height: defaultWindowSize.height }); }); @@ -60,11 +57,13 @@ function registerSuites(suites: Array, getBrowser: ( } function registerTest(testDef: TestDefinition, getBrowser: () => WebdriverIO.Browser) { - const windowSize = { ...defaultWindowSize, ...testDef.configuration }; - test(testDef.description, async () => { const browser = getBrowser(); - await browser.setWindowSize(windowSize.width, windowSize.height); + // Only resize if the test needs a non-default window size. + if (testDef.configuration) { + const windowSize = { ...defaultWindowSize, ...testDef.configuration }; + await browser.setWindowSize(windowSize.width, windowSize.height); + } const page = new ScreenshotPageObject(browser); const capture = async (host: string) => { @@ -73,21 +72,17 @@ function registerTest(testDef: TestDefinition, getBrowser: () => WebdriverIO.Bro if (testDef.setup) { await testDef.setup(page); } - return testDef.screenshotType === 'permutations' - ? page.capturePermutations() - : page.captureBySelector(screenshotAreaSelector); + // For screenshotArea pages the element fits in the viewport, so we use + // viewportOnly to avoid the expensive scroll-and-merge full-page capture. + // Permutations pages can be taller than the viewport and need the full strategy. + const viewportOnly = testDef.screenshotType !== 'permutations'; + return page.captureBySelector(screenshotAreaSelector, { viewportOnly }); }; - const newScreenshots = await capture(newHost); - const oldScreenshots = await capture(oldHost); + const newScreenshot = await capture(newHost); + const oldScreenshot = await capture(oldHost); - const newArr: ScreenshotWithOffset[] = Array.isArray(newScreenshots) ? newScreenshots : [newScreenshots]; - const oldArr: ScreenshotWithOffset[] = Array.isArray(oldScreenshots) ? oldScreenshots : [oldScreenshots]; - - expect(newArr.length).toBe(oldArr.length); - for (let i = 0; i < newArr.length; i++) { - const { diffPixels } = await cropAndCompare(newArr[i], oldArr[i]); - expect(diffPixels).toBe(0); - } + const { diffPixels } = await cropAndCompare(newScreenshot, oldScreenshot); + expect(diffPixels).toBe(0); }); } From 5fdb3af4da450bf42d967f30385c87b416f567ec Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 29 May 2026 09:13:40 +0200 Subject: [PATCH 55/95] use captureScreenshotArea for permutation comparison unless there is a failure --- test/definitions/utils.ts | 65 +++++++++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/test/definitions/utils.ts b/test/definitions/utils.ts index 2467c3801b..a1e8a3bd1f 100644 --- a/test/definitions/utils.ts +++ b/test/definitions/utils.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { cropAndCompare } from '@cloudscape-design/browser-test-tools/image-utils'; -import { ScreenshotPageObject } from '@cloudscape-design/browser-test-tools/page-objects'; +import { ScreenshotPageObject, ScreenshotWithOffset } from '@cloudscape-design/browser-test-tools/page-objects'; import { TestDefinition, TestSuite } from './types'; @@ -56,33 +56,72 @@ function registerSuites(suites: Array, getBrowser: ( } } +/** + * Captures the .screenshot-area element using a viewport-only screenshot (fast). + */ +async function captureScreenshotArea( + browser: WebdriverIO.Browser, + page: ScreenshotPageObject, + url: string, + setup?: (page: ScreenshotPageObject) => Promise +): Promise { + await browser.url(url); + await page.waitForVisible(screenshotAreaSelector); + if (setup) { + await setup(page); + } + return page.captureBySelector(screenshotAreaSelector, { viewportOnly: true }); +} + function registerTest(testDef: TestDefinition, getBrowser: () => WebdriverIO.Browser) { test(testDef.description, async () => { const browser = getBrowser(); - // Only resize if the test needs a non-default window size. if (testDef.configuration) { const windowSize = { ...defaultWindowSize, ...testDef.configuration }; await browser.setWindowSize(windowSize.width, windowSize.height); } const page = new ScreenshotPageObject(browser); - const capture = async (host: string) => { - await browser.url(buildUrl(host, testDef.path, testDef.queryParams)); + const newUrl = buildUrl(newHost, testDef.path, testDef.queryParams); + const oldUrl = buildUrl(oldHost, testDef.path, testDef.queryParams); + + // Fast path: compare the screenshot area (viewport-only, no scroll-and-merge). + const newScreenshot = await captureScreenshotArea(browser, page, newUrl, testDef.setup); + const oldScreenshot = await captureScreenshotArea(browser, page, oldUrl, testDef.setup); + const { diffPixels } = await cropAndCompare(newScreenshot, oldScreenshot); + + if (diffPixels === 0) { + return; + } + + // For permutations pages, a screenshot-area diff might be a false positive + // caused by content extending beyond the viewport. Re-capture using the + // full capturePermutations strategy which resizes the window to fit all + // content and returns individual permutation crops for precise comparison. + if (testDef.screenshotType === 'permutations') { + await browser.url(newUrl); await page.waitForVisible(screenshotAreaSelector); if (testDef.setup) { await testDef.setup(page); } - // For screenshotArea pages the element fits in the viewport, so we use - // viewportOnly to avoid the expensive scroll-and-merge full-page capture. - // Permutations pages can be taller than the viewport and need the full strategy. - const viewportOnly = testDef.screenshotType !== 'permutations'; - return page.captureBySelector(screenshotAreaSelector, { viewportOnly }); - }; + const newPermutations = await page.capturePermutations(); - const newScreenshot = await capture(newHost); - const oldScreenshot = await capture(oldHost); + await browser.url(oldUrl); + await page.waitForVisible(screenshotAreaSelector); + if (testDef.setup) { + await testDef.setup(page); + } + const oldPermutations = await page.capturePermutations(); - const { diffPixels } = await cropAndCompare(newScreenshot, oldScreenshot); + expect(newPermutations.length).toBe(oldPermutations.length); + for (let i = 0; i < newPermutations.length; i++) { + const { diffPixels: permDiff } = await cropAndCompare(newPermutations[i], oldPermutations[i]); + expect(permDiff).toBe(0); + } + return; + } + + // For screenshotArea type, the diff is a real failure. expect(diffPixels).toBe(0); }); } From f95ef17c66b3a8ca5e49fd04bf0be7898701ba85 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 29 May 2026 09:38:28 +0200 Subject: [PATCH 56/95] Add more test definitions --- test/definitions/index.ts | 3 +- test/definitions/visual/app-layout.ts | 586 ++++++++++++++++++++++++++ 2 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 test/definitions/visual/app-layout.ts diff --git a/test/definitions/index.ts b/test/definitions/index.ts index 91e90a89f7..6ff236cfa6 100644 --- a/test/definitions/index.ts +++ b/test/definitions/index.ts @@ -6,5 +6,6 @@ import { TestSuite } from './types'; import actionCard from './visual/action-card'; import alert from './visual/alert'; +import appLayout from './visual/app-layout'; -export const allSuites: TestSuite[] = [actionCard, alert]; +export const allSuites: TestSuite[] = [actionCard, alert, appLayout]; diff --git a/test/definitions/visual/app-layout.ts b/test/definitions/visual/app-layout.ts new file mode 100644 index 0000000000..936f07152f --- /dev/null +++ b/test/definitions/visual/app-layout.ts @@ -0,0 +1,586 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestDefinition, TestSuite } from '../types'; + +function responsiveTests(width: number): TestSuite { + return { + description: `width ${width}px`, + componentName: 'app-layout', + tests: [ + { + description: 'default', + path: 'app-layout/default', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'navigation drawer is open', + path: 'app-layout/with-wizard', + screenshotType: 'screenshotArea', + configuration: { width }, + setup: async page => { + await page.click('[aria-label="Open navigation"]'); + }, + }, + { + description: 'wizard', + path: 'app-layout/with-wizard', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'with wizard and table', + path: 'app-layout/with-wizard-and-table', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'with wizard, table, and breadcrumbs', + path: 'app-layout/with-wizard-and-table', + screenshotType: 'screenshotArea', + configuration: { width }, + queryParams: { hasBreadcrumbs: 'true' }, + }, + { + description: 'notifications', + path: 'app-layout/with-notifications', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'breadcrumbs', + path: 'app-layout/with-breadcrumbs', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'notifications and breadcrumbs', + path: 'app-layout/with-breadcrumbs-notifications', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'dashboard content type', + path: 'app-layout/dashboard-content-type', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'fixed header and footer', + path: 'app-layout/with-fixed-header-footer', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'disableBodyScroll - empty', + path: 'app-layout/legacy-nav-empty', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'disableBodyScroll - with content', + path: 'app-layout/legacy-nav-scrollable', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'disableBodyScroll - with split panel', + path: 'app-layout/legacy-nav-scrollable-with-split-panel', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'disable paddings', + path: 'app-layout/disable-paddings', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'disable paddings with breadcrumbs', + path: 'app-layout/disable-paddings-breadcrumbs', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'sticky notifications', + path: 'app-layout/with-sticky-notifications', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'sticky notifications scrolled down', + path: 'app-layout/with-sticky-notifications', + screenshotType: 'screenshotArea', + configuration: { width }, + setup: async page => { + await page.windowScrollTo({ top: 2000 }); + }, + }, + { + description: 'layout without panels', + path: 'app-layout/no-panels', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'layout without panels but with notifications', + path: 'app-layout/no-panels-with-notifications', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'with drawers', + path: 'app-layout/with-drawers', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'with empty drawers', + path: 'app-layout/with-drawers-empty', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'with open drawer', + path: 'app-layout/with-drawers', + screenshotType: 'screenshotArea', + configuration: { width }, + setup: async page => { + await page.click('[aria-label="Security trigger button"]'); + }, + }, + ], + }; +} + +const suite: TestSuite = { + description: 'AppLayout', + componentName: 'app-layout', + tests: [ + // ── Responsive tests at multiple breakpoints ────────────────────────── + responsiveTests(600), + responsiveTests(1280), + responsiveTests(1400), + responsiveTests(1920), + responsiveTests(2540), + + // ── General tests ───────────────────────────────────────────────────── + { + description: 'no scrollbars at 320px', + path: 'app-layout/default', + screenshotType: 'screenshotArea', + configuration: { width: 320 }, + }, + { + description: 'drawer buttons alignment', + path: 'app-layout/default', + screenshotType: 'screenshotArea', + configuration: { width: 800 }, + setup: async page => { + await page.click('[aria-label="Open tools"]'); + }, + }, + { + description: 'disable paddings - navigation closed', + path: 'app-layout/disable-paddings', + screenshotType: 'screenshotArea', + configuration: { width: 1280 }, + setup: async page => { + await page.click('[aria-label="Close navigation"]'); + }, + }, + { + description: 'panels stacking on mobile', + path: 'app-layout/all-panels-open', + screenshotType: 'screenshotArea', + configuration: { width: 600 }, + }, + { + description: 'wrapping long words', + path: 'app-layout/text-wrap', + screenshotType: 'screenshotArea', + }, + { + description: 'fill content area', + path: 'app-layout/fill-content-area', + screenshotType: 'screenshotArea', + }, + { + description: 'with tools and drawers', + path: 'app-layout/with-drawers', + screenshotType: 'screenshotArea', + queryParams: { hasTools: 'true' }, + }, + { + description: 'with open drawer and open side split panel', + path: 'app-layout/with-drawers', + screenshotType: 'screenshotArea', + configuration: { width: 1400 }, + queryParams: { splitPanelPosition: 'side' }, + setup: async page => { + await page.click('[aria-label="Security trigger button"]'); + await page.click('[aria-label="Open panel"]'); + }, + }, + + // ── Content paddings ────────────────────────────────────────────────── + { + description: 'Content paddings', + tests: [ + ...(['true', 'false'] as const).flatMap(toolsEnabled => + (['true', 'false'] as const).flatMap(splitPanelEnabled => + (['bottom', 'side'] as const).map(splitPanelPosition => ({ + description: `toolsEnabled=${toolsEnabled} splitPanelEnabled=${splitPanelEnabled} splitPanelPosition=${splitPanelPosition}`, + path: 'app-layout/with-split-panel', + screenshotType: 'screenshotArea' as const, + queryParams: { toolsEnabled, splitPanelEnabled, splitPanelPosition }, + })) + ) + ), + ...[1500, 600].map(width => ({ + description: `with split panel and disabled content paddings - width=${width}`, + path: 'app-layout/disable-paddings-with-split-panel', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + queryParams: { splitPanelOpen: 'true', splitPanelPosition: 'side' }, + })), + ], + }, + + // ── Drawers ─────────────────────────────────────────────────────────── + { + description: 'Drawers', + tests: [ + { + description: 'with split panel', + path: 'app-layout/with-drawers', + screenshotType: 'screenshotArea', + setup: async page => { + await page.click('[aria-label="Pro help trigger button"]'); + }, + }, + { + description: 'with tooltip on hover', + path: 'app-layout/with-drawers', + screenshotType: 'screenshotArea', + setup: async page => { + await page.hoverElement('[aria-label="Pro help trigger button"]'); + }, + }, + { + description: 'with custom scrollable drawer content', + path: 'app-layout/with-drawers-scrollable', + screenshotType: 'screenshotArea', + queryParams: { sideNavFill: 'false' }, + setup: async page => { + await page.click('[aria-label="Chat trigger button"]'); + }, + }, + ], + }, + + // ── Headers ─────────────────────────────────────────────────────────── + { + description: 'Headers', + tests: [600, 1280].flatMap(width => [ + { + description: `alignment with full-page table (${width}px)`, + path: 'app-layout/with-table', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + }, + { + description: `alignment with full-page table in sticky state (${width}px)`, + path: 'app-layout/with-table', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + setup: async page => { + await page.windowScrollTo({ top: 200 }); + }, + }, + { + description: `alignment with full-page table in sticky state with sticky notifications (${width}px)`, + path: 'app-layout/with-table', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + queryParams: { stickyNotifications: 'true' }, + setup: async page => { + await page.windowScrollTo({ top: 200 }); + }, + }, + { + description: `high contrast header variant in landing page (${width}px)`, + path: 'app-layout/landing-page', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + }, + ]), + }, + + // ── High contrast header variant ────────────────────────────────────── + { + description: 'High contrast header variant', + tests: [ + ...[1400, 600].flatMap(width => [ + { + description: `with breadcrumbs and notifications at ${width}px`, + path: 'app-layout/high-contrast-header-variant', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + queryParams: { hasBreadcrumbs: 'true', hasNotifications: 'true', hasContainer: 'true' }, + }, + { + description: `without overlap at ${width}px`, + path: 'app-layout/high-contrast-header-variant', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + queryParams: { disableOverlap: 'true' }, + }, + { + description: `with content layout at ${width}px`, + path: 'app-layout/high-contrast-header-variant', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + queryParams: { + hasBreadcrumbs: 'true', + hasNotifications: 'true', + hasContainer: 'true', + hasContentLayout: 'true', + }, + }, + ]), + ], + }, + + // ── Multiple instances ───────────────────────────────────────────────── + { + description: 'Multiple instances', + tests: [600, 1280].flatMap(width => [ + { + description: `simple (${width}px)`, + path: 'app-layout/multi-layout-simple', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + }, + { + description: `iframe (${width}px)`, + path: 'app-layout/multi-layout-iframe', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + }, + ]), + }, + + // ── Z-index (absolute components) ───────────────────────────────────── + { + description: 'Z-index', + tests: [ + ...[600, 1280].flatMap(width => [ + { + description: `button dropdown (${width}px)`, + path: 'app-layout/with-absolute-components', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + setup: async page => { + await page.click('button=Button dropdown'); + await page.click('[data-testid="2"]'); + await page.windowScrollTo({ top: 300 }); + }, + } as TestDefinition, + { + description: `select (${width}px)`, + path: 'app-layout/with-absolute-components', + screenshotType: 'screenshotArea' as const, + configuration: { width, height: 800 }, + setup: async page => { + await page.click('[data-testid="select-demo"] button'); + await page.windowScrollTo({ top: 300 }); + }, + } as TestDefinition, + { + description: `split-panel and full-page table (${width}px)`, + path: 'app-layout/with-full-page-table-and-split-panel', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + }, + ]), + { + description: 'split-panel and full-page with open navigation (600px)', + path: 'app-layout/with-full-page-table-and-split-panel', + screenshotType: 'screenshotArea' as const, + configuration: { width: 600 }, + setup: async page => { + await page.click('button[aria-label="Open navigation"]'); + }, + }, + { + description: 'split-panel and full-page with open tools (600px)', + path: 'app-layout/with-full-page-table-and-split-panel', + screenshotType: 'screenshotArea' as const, + configuration: { width: 600 }, + setup: async page => { + await page.click('button[aria-label="Open tools"]'); + }, + }, + ], + }, + + // ── Toolbar ─────────────────────────────────────────────────────────── + { + description: 'Toolbar', + tests: [ + { + description: 'multiple nested instances (no breadcrumbs dedup)', + path: 'app-layout-toolbar/multi-layout-with-hidden-instances', + screenshotType: 'screenshotArea', + }, + { + description: 'no toolbar', + path: 'app-layout-toolbar/without-toolbar', + screenshotType: 'screenshotArea', + }, + ], + }, + + // ── Max content width ───────────────────────────────────────────────── + { + description: 'Max content width', + tests: [ + { + description: 'maxContentWidth set to Number.MAX_VALUE', + path: 'app-layout/refresh-content-width', + screenshotType: 'screenshotArea', + configuration: { width: 1280, height: 700 }, + setup: async page => { + await page.click('[data-test-id="button_width-number-max_value"]'); + }, + }, + ], + }, + + // ── Sticky table header with split panel ────────────────────────────── + { + description: 'Sticky header with split panel', + tests: [ + { + description: 'scrolling to bottom with closed split panel (1 table row)', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'screenshotArea', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-1"]'); + await page.scrollToBottom('html'); + }, + }, + { + description: 'scrolling to bottom with closed split panel (30 table rows)', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'screenshotArea', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-30"]'); + await page.scrollToBottom('html'); + }, + }, + { + description: 'header stays sticky with open split panel (1 table row)', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'screenshotArea', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-1"]'); + await page.click('aria/Open panel'); + await page.scrollToBottom('html'); + }, + }, + { + description: 'header stays sticky with open split panel (30 table rows)', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'screenshotArea', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-30"]'); + await page.click('aria/Open panel'); + await page.scrollToBottom('html'); + }, + }, + { + description: 'header stays sticky when mounting and unmounting a second table', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'screenshotArea', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-30"]'); + await page.click('aria/Open panel'); + await page.windowScrollTo({ top: 0 }); + await page.click('aria/Close panel'); + await page.scrollToBottom('html'); + }, + }, + ], + }, + + // ── Flashbar ────────────────────────────────────────────────────────── + { + description: 'Flashbar', + tests: [true, false].flatMap(disableContentPaddings => + [true, false].flatMap(stickyNotifications => + [true, false].flatMap(stickyTableHeader => + [true, false].map(stackNotifications => ({ + description: `disableContentPaddings: ${disableContentPaddings}, stickyNotifications: ${stickyNotifications}, stickyTableHeader: ${stickyTableHeader}, stackNotifications: ${stackNotifications}`, + path: 'app-layout/with-stacked-notifications-and-table', + screenshotType: 'screenshotArea' as const, + configuration: { width: 1280, height: 900 }, + setup: async ( + page: import('@cloudscape-design/browser-test-tools/page-objects').ScreenshotPageObject + ) => { + if (!disableContentPaddings) { + await page.click('[data-id="toggle-content-paddings"]'); + } + if (stickyNotifications) { + await page.click('[data-id="toggle-sticky-notifications"]'); + } + if (!stickyTableHeader) { + await page.click('[data-id="toggle-sticky-table-header"]'); + } + if (!stackNotifications) { + await page.click('[data-id="toggle-stack-items"]'); + } + await page.click('[data-id="add-notification"]'); + await page.click('[data-id="add-notification"]'); + }, + })) + ) + ) + ), + }, + + // ── Transitions ─────────────────────────────────────────────────────── + { + description: 'Transitions', + tests: [ + { + description: 'transition from 400px to 1800px', + path: 'app-layout/default', + screenshotType: 'screenshotArea', + configuration: { width: 400, height: 400 }, + setup: async page => { + await page.setWindowSize({ width: 1800, height: 400 }); + }, + }, + { + description: 'transition from 1800px to 400px', + path: 'app-layout/default', + screenshotType: 'screenshotArea', + configuration: { width: 1800, height: 400 }, + setup: async page => { + await page.setWindowSize({ width: 400, height: 400 }); + }, + }, + ], + }, + ], +}; + +export default suite; From 354af7d0e7ef44ba4554317d6fad98257faa64ef Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 29 May 2026 09:47:07 +0200 Subject: [PATCH 57/95] Refine config --- tsconfig.integ.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.integ.json b/tsconfig.integ.json index 784a3d2f1f..e23c746e71 100644 --- a/tsconfig.integ.json +++ b/tsconfig.integ.json @@ -5,6 +5,7 @@ "types": ["jest"], "noEmit": true, "strict": true, + "isolatedModules": true, "sourceMap": true, "allowSyntheticDefaultImports": true, "esModuleInterop": true, From e3937e7a4763a10451410b96c12231d9b70a37de Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 29 May 2026 10:46:16 +0200 Subject: [PATCH 58/95] Shard tests --- .github/workflows/visual-regression.yml | 13 +++++-------- jest.visual.config.js | 2 +- test/visual.test.ts | 6 ------ test/visual/action-card.test.ts | 6 ++++++ test/visual/alert.test.ts | 6 ++++++ test/visual/app-layout.test.ts | 6 ++++++ tsconfig.integ.json | 2 +- 7 files changed, 25 insertions(+), 16 deletions(-) delete mode 100644 test/visual.test.ts create mode 100644 test/visual/action-card.test.ts create mode 100644 test/visual/alert.test.ts create mode 100644 test/visual/app-layout.test.ts diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 40493f7e9e..86f0fdc7ea 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -114,15 +114,13 @@ jobs: retention-days: 1 visual: - name: Visual regression (${{ matrix.browser }}) + name: Visual regression (shard ${{ matrix.shard }}) needs: [stage-pr-pages, build-baseline] - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest strategy: fail-fast: false matrix: - include: - - browser: chrome - os: ubuntu-latest + shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 @@ -163,15 +161,14 @@ jobs: run: node_modules/.bin/wait-on http://localhost:8080 http://localhost:8081 - name: Run visual regression tests - run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js + run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js --shard=${{ matrix.shard }}/4 env: TZ: UTC - BROWSER: ${{ matrix.browser }} - name: Upload diff artifacts if: failure() uses: actions/upload-artifact@v4 with: - name: visual-regression-diffs-${{ matrix.browser }} + name: visual-regression-diffs-shard-${{ matrix.shard }} path: visual-regression-output/ retention-days: 14 diff --git a/jest.visual.config.js b/jest.visual.config.js index a4d06d46ae..69a5b71f8b 100644 --- a/jest.visual.config.js +++ b/jest.visual.config.js @@ -21,5 +21,5 @@ module.exports = { globalTeardown: '/build-tools/visual/global-teardown.js', setupFilesAfterEnv: [path.join(__dirname, 'build-tools', 'visual', 'setup.js')], moduleFileExtensions: ['js', 'ts'], - testMatch: ['/test/visual.test.ts'], + testMatch: ['/test/visual/**/*.test.ts'], }; diff --git a/test/visual.test.ts b/test/visual.test.ts deleted file mode 100644 index 4e6e992f4f..0000000000 --- a/test/visual.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { allSuites } from './definitions'; -import { runTestSuites } from './definitions/utils'; - -runTestSuites(allSuites); diff --git a/test/visual/action-card.test.ts b/test/visual/action-card.test.ts new file mode 100644 index 0000000000..8505dad23b --- /dev/null +++ b/test/visual/action-card.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import actionCard from '../definitions/visual/action-card'; + +runTestSuites([actionCard]); diff --git a/test/visual/alert.test.ts b/test/visual/alert.test.ts new file mode 100644 index 0000000000..431e4907e4 --- /dev/null +++ b/test/visual/alert.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import alert from '../definitions/visual/alert'; + +runTestSuites([alert]); diff --git a/test/visual/app-layout.test.ts b/test/visual/app-layout.test.ts new file mode 100644 index 0000000000..93500354cc --- /dev/null +++ b/test/visual/app-layout.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import appLayout from '../definitions/visual/app-layout'; + +runTestSuites([appLayout]); diff --git a/tsconfig.integ.json b/tsconfig.integ.json index e23c746e71..b3f5080284 100644 --- a/tsconfig.integ.json +++ b/tsconfig.integ.json @@ -12,5 +12,5 @@ "resolveJsonModule": true, "moduleResolution": "node" }, - "include": ["**/__integ__/**/*.ts", "**/__a11y__/**/*.ts", "**/__motion__/**/*.ts", "test/definitions/utils.ts", "test/visual.test.ts", "types"] + "include": ["**/__integ__/**/*.ts", "**/__a11y__/**/*.ts", "**/__motion__/**/*.ts", "test/definitions/utils.ts", "test/visual/**/*.test.ts", "types"] } From c33545b6366aaf56795c40428512ee9012869982 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 29 May 2026 11:55:07 +0200 Subject: [PATCH 59/95] Fix config --- jest.visual.config.js | 2 +- tsconfig.integ.json | 3 +-- tsconfig.visual.json | 7 +++++++ 3 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 tsconfig.visual.json diff --git a/jest.visual.config.js b/jest.visual.config.js index 69a5b71f8b..bd04fe5d73 100644 --- a/jest.visual.config.js +++ b/jest.visual.config.js @@ -10,7 +10,7 @@ module.exports = { '^.+\\.tsx?$': [ 'ts-jest', { - tsconfig: 'tsconfig.integ.json', + tsconfig: 'tsconfig.visual.json', }, ], }, diff --git a/tsconfig.integ.json b/tsconfig.integ.json index b3f5080284..e816f749e9 100644 --- a/tsconfig.integ.json +++ b/tsconfig.integ.json @@ -5,12 +5,11 @@ "types": ["jest"], "noEmit": true, "strict": true, - "isolatedModules": true, "sourceMap": true, "allowSyntheticDefaultImports": true, "esModuleInterop": true, "resolveJsonModule": true, "moduleResolution": "node" }, - "include": ["**/__integ__/**/*.ts", "**/__a11y__/**/*.ts", "**/__motion__/**/*.ts", "test/definitions/utils.ts", "test/visual/**/*.test.ts", "types"] + "include": ["**/__integ__/**/*.ts", "**/__a11y__/**/*.ts", "**/__motion__/**/*.ts", "types"] } diff --git a/tsconfig.visual.json b/tsconfig.visual.json new file mode 100644 index 0000000000..41f554c24f --- /dev/null +++ b/tsconfig.visual.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.integ.json", + "compilerOptions": { + "isolatedModules": true + }, + "include": ["test/definitions/utils.ts", "test/visual/**/*.test.ts", "types"] +} From 790b506632a1b27b23975a8702227d645dd2c934 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 29 May 2026 12:03:21 +0200 Subject: [PATCH 60/95] Fix config --- tsconfig.visual.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/tsconfig.visual.json b/tsconfig.visual.json index 41f554c24f..be61d962ef 100644 --- a/tsconfig.visual.json +++ b/tsconfig.visual.json @@ -1,7 +1,4 @@ { "extends": "./tsconfig.integ.json", - "compilerOptions": { - "isolatedModules": true - }, "include": ["test/definitions/utils.ts", "test/visual/**/*.test.ts", "types"] } From eaa391101386b5468bccae3f058c4c3d3968c1de Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 29 May 2026 15:15:22 +0200 Subject: [PATCH 61/95] Increase timeout --- jest.visual.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest.visual.config.js b/jest.visual.config.js index bd04fe5d73..19418a86dd 100644 --- a/jest.visual.config.js +++ b/jest.visual.config.js @@ -15,7 +15,7 @@ module.exports = { ], }, reporters: ['default', 'github-actions'], - testTimeout: 120_000, // 2min — pages can be tall and slow to capture + testTimeout: 240_000, // 4min — pages can be tall and slow to capture maxWorkers: os.cpus().length * (process.env.GITHUB_ACTION ? 3 : 1), globalSetup: '/build-tools/visual/global-setup.js', globalTeardown: '/build-tools/visual/global-teardown.js', From ef58b90452229fd1dcf12c4dddcb883f05cf0f2b Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 29 May 2026 15:32:46 +0200 Subject: [PATCH 62/95] Use 3 shards --- .github/workflows/visual-regression.yml | 4 ++-- test/definitions/visual/app-layout.ts | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 86f0fdc7ea..e004f354d7 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -120,7 +120,7 @@ jobs: strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4] + shard: [1, 2, 3] steps: - uses: actions/checkout@v4 @@ -161,7 +161,7 @@ jobs: run: node_modules/.bin/wait-on http://localhost:8080 http://localhost:8081 - name: Run visual regression tests - run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js --shard=${{ matrix.shard }}/4 + run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js --shard=${{ matrix.shard }}/3 env: TZ: UTC diff --git a/test/definitions/visual/app-layout.ts b/test/definitions/visual/app-layout.ts index 936f07152f..12c76d9138 100644 --- a/test/definitions/visual/app-layout.ts +++ b/test/definitions/visual/app-layout.ts @@ -1,8 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import createWrapper from '../../../lib/components/test-utils/selectors'; import { TestDefinition, TestSuite } from '../types'; +const wrapper = createWrapper(); + function responsiveTests(width: number): TestSuite { return { description: `width ${width}px`, @@ -257,7 +260,7 @@ const suite: TestSuite = { path: 'app-layout/with-drawers', screenshotType: 'screenshotArea', setup: async page => { - await page.click('[aria-label="Pro help trigger button"]'); + await page.click(wrapper.findAppLayout().findDrawerTriggerById('pro-help').toSelector()); }, }, { @@ -265,7 +268,7 @@ const suite: TestSuite = { path: 'app-layout/with-drawers', screenshotType: 'screenshotArea', setup: async page => { - await page.hoverElement('[aria-label="Pro help trigger button"]'); + await page.hoverElement(wrapper.findAppLayout().findDrawerTriggerById('pro-help').toSelector()); }, }, { @@ -274,7 +277,7 @@ const suite: TestSuite = { screenshotType: 'screenshotArea', queryParams: { sideNavFill: 'false' }, setup: async page => { - await page.click('[aria-label="Chat trigger button"]'); + await page.click(wrapper.findAppLayout().findDrawerTriggerById('chat').toSelector()); }, }, ], From ab2ffefc4d34ffb71b4c5079394b1b5dc7532c7c Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 29 May 2026 15:32:58 +0200 Subject: [PATCH 63/95] Fix tests --- test/definitions/utils.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/definitions/utils.ts b/test/definitions/utils.ts index a1e8a3bd1f..c5b237dc73 100644 --- a/test/definitions/utils.ts +++ b/test/definitions/utils.ts @@ -63,8 +63,12 @@ async function captureScreenshotArea( browser: WebdriverIO.Browser, page: ScreenshotPageObject, url: string, + windowSize: { width: number; height: number } | undefined, setup?: (page: ScreenshotPageObject) => Promise ): Promise { + if (windowSize) { + await browser.setWindowSize(windowSize.width, windowSize.height); + } await browser.url(url); await page.waitForVisible(screenshotAreaSelector); if (setup) { @@ -76,18 +80,15 @@ async function captureScreenshotArea( function registerTest(testDef: TestDefinition, getBrowser: () => WebdriverIO.Browser) { test(testDef.description, async () => { const browser = getBrowser(); - if (testDef.configuration) { - const windowSize = { ...defaultWindowSize, ...testDef.configuration }; - await browser.setWindowSize(windowSize.width, windowSize.height); - } + const windowSize = testDef.configuration ? { ...defaultWindowSize, ...testDef.configuration } : undefined; const page = new ScreenshotPageObject(browser); const newUrl = buildUrl(newHost, testDef.path, testDef.queryParams); const oldUrl = buildUrl(oldHost, testDef.path, testDef.queryParams); // Fast path: compare the screenshot area (viewport-only, no scroll-and-merge). - const newScreenshot = await captureScreenshotArea(browser, page, newUrl, testDef.setup); - const oldScreenshot = await captureScreenshotArea(browser, page, oldUrl, testDef.setup); + const newScreenshot = await captureScreenshotArea(browser, page, newUrl, windowSize, testDef.setup); + const oldScreenshot = await captureScreenshotArea(browser, page, oldUrl, windowSize, testDef.setup); const { diffPixels } = await cropAndCompare(newScreenshot, oldScreenshot); if (diffPixels === 0) { From 2462deb3461fdfd38a9ed99355c0d829e7c89f44 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 29 May 2026 23:56:13 +0200 Subject: [PATCH 64/95] Build the test selectors before running the tests --- .github/workflows/visual-regression.yml | 38 ++++++++++++++++--------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index e004f354d7..ed5739b0e3 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -4,12 +4,12 @@ on: workflow_call: inputs: pr-artifact-name: - description: 'Name of the artifact containing PR pages (built by quick-build job). If not provided, pages will be built locally.' - required: false + description: 'Name of the artifact containing PR pages (built by the caller workflow).' + required: true type: string caller-run-id: description: 'The run ID of the calling workflow, used to download artifacts it uploaded.' - required: false + required: true type: string defaults: @@ -22,8 +22,8 @@ permissions: actions: read jobs: - # Stage the PR pages within this run so matrix jobs can download them without - # needing cross-run artifact access. Runs in parallel with build-baseline. + # Stage the PR pages and build test utils for the visual regression shards. + # Runs in parallel with build-baseline. stage-pr-pages: name: Stage PR pages runs-on: ubuntu-latest @@ -39,16 +39,7 @@ jobs: - name: Install dependencies run: npm i - - name: Build PR pages locally - if: ${{ !inputs.pr-artifact-name }} - run: | - npx gulp quick-build - node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path pages/lib/static-default - env: - NODE_ENV: production - - name: Download PR pages artifact from caller run - if: ${{ inputs.pr-artifact-name }} uses: actions/download-artifact@v4 with: name: ${{ inputs.pr-artifact-name }} @@ -56,6 +47,11 @@ jobs: github-token: ${{ github.token }} run-id: ${{ inputs.caller-run-id }} + - name: Build (test utils selectors) + run: npx gulp quick-build + env: + NODE_ENV: production + - name: Upload PR pages artifact (for matrix jobs) uses: actions/upload-artifact@v4 with: @@ -63,6 +59,13 @@ jobs: path: pages/lib/static-default retention-days: 1 + - name: Upload test utils artifact + uses: actions/upload-artifact@v4 + with: + name: visual-test-utils + path: lib/components/test-utils + retention-days: 1 + # Build the baseline (main branch) pages once and share them across all browser jobs. # Runs in parallel with stage-pr-pages. build-baseline: @@ -150,7 +153,14 @@ jobs: name: visual-baseline-pages path: pages/lib/static-visual-baseline + - name: Download test utils artifact + uses: actions/download-artifact@v4 + with: + name: visual-test-utils + path: lib/components/test-utils + # ── Run tests ───────────────────────────────────────────────────────── + - name: Start test server (port 8080) run: npx --yes serve --no-clipboard --listen 8080 pages/lib/static-default & From 507f57c56b4fdcceb007c491f0775892bb6dc804 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Sat, 30 May 2026 00:35:48 +0200 Subject: [PATCH 65/95] Optimizations --- .github/workflows/deploy.yml | 8 +++++++ .github/workflows/visual-regression.yml | 31 ++++++------------------- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2add3494cd..df56701906 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -53,6 +53,13 @@ jobs: name: dev-pages-react${{ matrix.react }} path: pages/lib/static-default + - name: Upload test utils selectors artifact + if: matrix.react == 18 + uses: actions/upload-artifact@v4 + with: + name: test-utils-selectors + path: lib/components/test-utils + deploy: needs: quick-build name: deploy${{ matrix.react != 16 && format(' (React {0})', matrix.react) || '' }} @@ -74,4 +81,5 @@ jobs: secrets: inherit with: pr-artifact-name: dev-pages-react18 + test-utils-artifact-name: test-utils-selectors caller-run-id: ${{ github.run_id }} diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index ed5739b0e3..71349ddb28 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -7,6 +7,10 @@ on: description: 'Name of the artifact containing PR pages (built by the caller workflow).' required: true type: string + test-utils-artifact-name: + description: 'Name of the artifact containing test-utils selectors.' + required: true + type: string caller-run-id: description: 'The run ID of the calling workflow, used to download artifacts it uploaded.' required: true @@ -28,17 +32,6 @@ jobs: name: Stage PR pages runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - - name: Install dependencies - run: npm i - - name: Download PR pages artifact from caller run uses: actions/download-artifact@v4 with: @@ -47,11 +40,6 @@ jobs: github-token: ${{ github.token }} run-id: ${{ inputs.caller-run-id }} - - name: Build (test utils selectors) - run: npx gulp quick-build - env: - NODE_ENV: production - - name: Upload PR pages artifact (for matrix jobs) uses: actions/upload-artifact@v4 with: @@ -59,13 +47,6 @@ jobs: path: pages/lib/static-default retention-days: 1 - - name: Upload test utils artifact - uses: actions/upload-artifact@v4 - with: - name: visual-test-utils - path: lib/components/test-utils - retention-days: 1 - # Build the baseline (main branch) pages once and share them across all browser jobs. # Runs in parallel with stage-pr-pages. build-baseline: @@ -156,8 +137,10 @@ jobs: - name: Download test utils artifact uses: actions/download-artifact@v4 with: - name: visual-test-utils + name: ${{ inputs.test-utils-artifact-name }} path: lib/components/test-utils + github-token: ${{ github.token }} + run-id: ${{ inputs.caller-run-id }} # ── Run tests ───────────────────────────────────────────────────────── From 09157563c9ae021cffdfebaec5399ac951016b42 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Sat, 30 May 2026 00:38:30 +0200 Subject: [PATCH 66/95] Upload the entire lib/components directory --- .github/workflows/deploy.yml | 2 +- .github/workflows/visual-regression.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index df56701906..36964ddc1f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -58,7 +58,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: test-utils-selectors - path: lib/components/test-utils + path: lib/components deploy: needs: quick-build diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 71349ddb28..b1333cf1f4 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -138,7 +138,7 @@ jobs: uses: actions/download-artifact@v4 with: name: ${{ inputs.test-utils-artifact-name }} - path: lib/components/test-utils + path: lib/components github-token: ${{ github.token }} run-id: ${{ inputs.caller-run-id }} From 3909a3f9b8624f997e691347e0f781444158faa3 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Sat, 30 May 2026 00:48:38 +0200 Subject: [PATCH 67/95] Remove unnecessary step --- .github/workflows/visual-regression.yml | 28 ++++--------------------- 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index b1333cf1f4..a35b2fa877 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -26,29 +26,7 @@ permissions: actions: read jobs: - # Stage the PR pages and build test utils for the visual regression shards. - # Runs in parallel with build-baseline. - stage-pr-pages: - name: Stage PR pages - runs-on: ubuntu-latest - steps: - - name: Download PR pages artifact from caller run - uses: actions/download-artifact@v4 - with: - name: ${{ inputs.pr-artifact-name }} - path: pages/lib/static-default - github-token: ${{ github.token }} - run-id: ${{ inputs.caller-run-id }} - - - name: Upload PR pages artifact (for matrix jobs) - uses: actions/upload-artifact@v4 - with: - name: visual-pr-pages - path: pages/lib/static-default - retention-days: 1 - # Build the baseline (main branch) pages once and share them across all browser jobs. - # Runs in parallel with stage-pr-pages. build-baseline: name: Build baseline pages runs-on: ubuntu-latest @@ -99,7 +77,7 @@ jobs: visual: name: Visual regression (shard ${{ matrix.shard }}) - needs: [stage-pr-pages, build-baseline] + needs: [build-baseline] runs-on: ubuntu-latest strategy: fail-fast: false @@ -125,8 +103,10 @@ jobs: - name: Download PR pages artifact uses: actions/download-artifact@v4 with: - name: visual-pr-pages + name: ${{ inputs.pr-artifact-name }} path: pages/lib/static-default + github-token: ${{ github.token }} + run-id: ${{ inputs.caller-run-id }} - name: Download baseline artifact uses: actions/download-artifact@v4 From e37268b445dd8e4d7c01a5fb0f536d02b2eb4a25 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Sat, 30 May 2026 01:07:41 +0200 Subject: [PATCH 68/95] Add more components tests --- .github/workflows/visual-regression.yml | 4 +- test/definitions/index.ts | 6 +- test/definitions/visual/area-chart.ts | 136 ++++++++++++++++++++ test/definitions/visual/attribute-editor.ts | 31 +++++ test/definitions/visual/autosuggest.ts | 70 ++++++++++ test/definitions/visual/badge.ts | 23 ++++ test/visual/area-chart.test.ts | 6 + test/visual/attribute-editor.test.ts | 5 + test/visual/autosuggest.test.ts | 6 + test/visual/badge.test.ts | 6 + 10 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 test/definitions/visual/area-chart.ts create mode 100644 test/definitions/visual/attribute-editor.ts create mode 100644 test/definitions/visual/autosuggest.ts create mode 100644 test/definitions/visual/badge.ts create mode 100644 test/visual/area-chart.test.ts create mode 100644 test/visual/attribute-editor.test.ts create mode 100644 test/visual/autosuggest.test.ts create mode 100644 test/visual/badge.test.ts diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index a35b2fa877..adb4d3ed5b 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -82,7 +82,7 @@ jobs: strategy: fail-fast: false matrix: - shard: [1, 2, 3] + shard: [1, 2, 3, 4, 5, 6] steps: - uses: actions/checkout@v4 @@ -134,7 +134,7 @@ jobs: run: node_modules/.bin/wait-on http://localhost:8080 http://localhost:8081 - name: Run visual regression tests - run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js --shard=${{ matrix.shard }}/3 + run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js --shard=${{ matrix.shard }}/6 env: TZ: UTC diff --git a/test/definitions/index.ts b/test/definitions/index.ts index 6ff236cfa6..5f6d790d65 100644 --- a/test/definitions/index.ts +++ b/test/definitions/index.ts @@ -7,5 +7,9 @@ import { TestSuite } from './types'; import actionCard from './visual/action-card'; import alert from './visual/alert'; import appLayout from './visual/app-layout'; +import areaChart from './visual/area-chart'; +import attributeEditor from './visual/attribute-editor'; +import autosuggest from './visual/autosuggest'; +import badge from './visual/badge'; -export const allSuites: TestSuite[] = [actionCard, alert, appLayout]; +export const allSuites: TestSuite[] = [actionCard, alert, appLayout, areaChart, attributeEditor, autosuggest, badge]; diff --git a/test/definitions/visual/area-chart.ts b/test/definitions/visual/area-chart.ts new file mode 100644 index 0000000000..10855239f6 --- /dev/null +++ b/test/definitions/visual/area-chart.ts @@ -0,0 +1,136 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const TEST_CHART_FILTER_TRIGGER = '#linear-latency-chart button'; +const TEST_CHART_TOOLTIP_HEADER = '#linear-latency-chart h2'; + +const suite: TestSuite = { + description: 'Area chart', + componentName: 'area-chart', + tests: [ + { + description: 'permutations', + path: 'area-chart/permutations', + screenshotType: 'permutations', + }, + { + description: 'fit-height', + path: 'area-chart/fit-height', + screenshotType: 'screenshotArea', + }, + { + description: 'fit-height no filter, no legend', + path: 'area-chart/fit-height', + screenshotType: 'screenshotArea', + queryParams: { hideFilter: 'true', hideLegend: 'true' }, + }, + { + description: 'fit-height, no legend', + path: 'area-chart/fit-height', + screenshotType: 'screenshotArea', + queryParams: { hideLegend: 'true' }, + }, + { + description: 'chart plot has a focus outline', + path: 'area-chart/test', + screenshotType: 'screenshotArea', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.click(TEST_CHART_FILTER_TRIGGER); + await page.keys(['Escape']); + await page.focusNextElement(); + }, + }, + { + description: 'can navigate along X axis highlighting all series with keyboard', + path: 'area-chart/test', + screenshotType: 'screenshotArea', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.click(TEST_CHART_FILTER_TRIGGER); + await page.keys(['Escape']); + await page.focusNextElement(); + await page.keys(['ArrowRight', 'ArrowRight']); + await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); + }, + }, + { + description: 'can navigate a specific series with keyboard', + path: 'area-chart/test', + screenshotType: 'screenshotArea', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.click(TEST_CHART_FILTER_TRIGGER); + await page.keys(['Escape']); + await page.focusNextElement(); + await page.keys(['ArrowRight']); + await page.keys(['ArrowDown']); + await page.keys(['ArrowRight']); + await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); + }, + }, + { + description: 'selects correct series when navigated back from legend', + path: 'area-chart/test', + screenshotType: 'screenshotArea', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.click(TEST_CHART_FILTER_TRIGGER); + await page.keys(['Escape']); + await page.keys(['Tab']); + await page.keys(['Tab']); + await page.keys(['ArrowRight']); + await page.keys(['Shift', 'Tab']); + await page.keys(['ArrowRight']); + await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); + }, + }, + { + description: 'can pin popover for all data points at a given X coordinate with keyboard', + path: 'area-chart/test', + screenshotType: 'screenshotArea', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.click(TEST_CHART_FILTER_TRIGGER); + await page.keys(['Escape']); + await page.focusNextElement(); + await page.keys(['ArrowRight']); + await page.keys(['ArrowRight']); + await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); + await page.keys(['Enter']); + await page.waitForVisible('[aria-label="Dismiss"]'); + }, + }, + { + description: 'can pin popover for a point in a specific series with keyboard', + path: 'area-chart/test', + screenshotType: 'screenshotArea', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.click(TEST_CHART_FILTER_TRIGGER); + await page.keys(['Escape']); + await page.focusNextElement(); + await page.keys(['ArrowRight']); + await page.keys(['ArrowDown']); + await page.keys(['ArrowRight']); + await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); + await page.keys(['Enter']); + await page.waitForVisible('[aria-label="Dismiss"]'); + }, + }, + { + description: 'shows popover on hover', + path: 'area-chart/test', + screenshotType: 'screenshotArea', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.hoverElement('[aria-label="Linear latency chart"]', 200, 50); + await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); + }, + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/attribute-editor.ts b/test/definitions/visual/attribute-editor.ts new file mode 100644 index 0000000000..33da3fad86 --- /dev/null +++ b/test/definitions/visual/attribute-editor.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Attribute Editor', + componentName: 'attribute-editor', + tests: [360, 768, 992].flatMap(width => [ + { + description: `permutations at ${width}px`, + path: 'attribute-editor/permutations', + screenshotType: 'permutations' as const, + configuration: { width }, + }, + { + description: `customizable-footer at ${width}px`, + path: 'attribute-editor/customizable-footer', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + }, + { + description: `with long select at ${width}px`, + path: 'attribute-editor/select-with-long-value', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + }, + ]), +}; + +export default suite; diff --git a/test/definitions/visual/autosuggest.ts b/test/definitions/visual/autosuggest.ts new file mode 100644 index 0000000000..92f4d46897 --- /dev/null +++ b/test/definitions/visual/autosuggest.ts @@ -0,0 +1,70 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import createWrapper from '../../../lib/components/test-utils/selectors'; +import { TestDefinition, TestSuite } from '../types'; + +const wrapper = createWrapper(); + +const suite: TestSuite = { + description: 'Autosuggest', + componentName: 'autosuggest', + tests: [ + { + description: 'permutations', + path: 'autosuggest/permutations', + screenshotType: 'permutations', + setup: async page => { + await page.click('input'); + }, + }, + { + description: 'permutations for async properties', + path: 'autosuggest/permutations-async', + screenshotType: 'permutations', + setup: async page => { + await page.click('input'); + }, + }, + { + description: 'Displays options with groups correctly', + path: 'autosuggest/scenarios', + screenshotType: 'screenshotArea', + setup: async page => { + await page.click('input'); + }, + }, + { + description: 'Correctly displays dropdown regions', + path: 'autosuggest/regions-scenarios', + screenshotType: 'screenshotArea', + setup: async page => { + await page.click('input'); + }, + }, + { + description: 'Long virtual list - navigate to last item', + path: 'autosuggest/virtual-scroll', + screenshotType: 'screenshotArea', + setup: async page => { + await page.click(wrapper.findAutosuggest().findNativeInput().toSelector()); + await page.keys(['ArrowUp']); + }, + }, + ...[true, false].map( + virtualScroll => + ({ + description: `with custom renderOption (virtualScroll=${virtualScroll})`, + path: 'autosuggest/custom-render-option', + screenshotType: 'screenshotArea' as const, + queryParams: { virtualScroll: String(virtualScroll) }, + setup: async page => { + await page.click(wrapper.findAutosuggest().findNativeInput().toSelector()); + await page.keys(['ArrowDown']); + }, + }) as TestDefinition + ), + ], +}; + +export default suite; diff --git a/test/definitions/visual/badge.ts b/test/definitions/visual/badge.ts new file mode 100644 index 0000000000..7d405ed843 --- /dev/null +++ b/test/definitions/visual/badge.ts @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Badge', + componentName: 'badge', + tests: [ + { + description: 'permutation page', + path: 'badge/permutations', + screenshotType: 'permutations', + }, + { + description: 'style custom page', + path: 'badge/style-custom-types', + screenshotType: 'screenshotArea', + }, + ], +}; + +export default suite; diff --git a/test/visual/area-chart.test.ts b/test/visual/area-chart.test.ts new file mode 100644 index 0000000000..51d6631016 --- /dev/null +++ b/test/visual/area-chart.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import areaChart from '../definitions/visual/area-chart'; + +runTestSuites([areaChart]); diff --git a/test/visual/attribute-editor.test.ts b/test/visual/attribute-editor.test.ts new file mode 100644 index 0000000000..f57e8785b2 --- /dev/null +++ b/test/visual/attribute-editor.test.ts @@ -0,0 +1,5 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import attributeEditor from '../definitions/visual/attribute-editor'; +runTestSuites([attributeEditor]); diff --git a/test/visual/autosuggest.test.ts b/test/visual/autosuggest.test.ts new file mode 100644 index 0000000000..454c00c648 --- /dev/null +++ b/test/visual/autosuggest.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import autosuggest from '../definitions/visual/autosuggest'; + +runTestSuites([autosuggest]); diff --git a/test/visual/badge.test.ts b/test/visual/badge.test.ts new file mode 100644 index 0000000000..6b24838a42 --- /dev/null +++ b/test/visual/badge.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import badge from '../definitions/visual/badge'; + +runTestSuites([badge]); From 41c993d557b6c33832254214347cfd3ecc639542 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Sat, 30 May 2026 08:36:55 +0200 Subject: [PATCH 69/95] Split app layout test definition files --- .github/workflows/visual-regression.yml | 4 +- test/definitions/index.ts | 28 +- .../visual/app-layout-content-paddings.ts | 30 + test/definitions/visual/app-layout-drawers.ts | 41 ++ .../definitions/visual/app-layout-flashbar.ts | 39 ++ test/definitions/visual/app-layout-header.ts | 85 +++ test/definitions/visual/app-layout-multi.ts | 25 + .../visual/app-layout-responsive.ts | 169 ++++++ ...-layout-sticky-table-header-split-panel.ts | 78 +++ test/definitions/visual/app-layout-toolbar.ts | 23 + test/definitions/visual/app-layout-z-index.ts | 60 ++ test/definitions/visual/app-layout.ts | 539 +----------------- .../app-layout-content-paddings.test.ts | 6 + test/visual/app-layout-drawers.test.ts | 6 + test/visual/app-layout-flashbar.test.ts | 6 + test/visual/app-layout-header.test.ts | 6 + test/visual/app-layout-multi.test.ts | 6 + test/visual/app-layout-responsive.test.ts | 6 + ...ut-sticky-table-header-split-panel.test.ts | 6 + test/visual/app-layout-toolbar.test.ts | 6 + test/visual/app-layout-z-index.test.ts | 6 + test/visual/app-layout.test.ts | 4 +- 22 files changed, 662 insertions(+), 517 deletions(-) create mode 100644 test/definitions/visual/app-layout-content-paddings.ts create mode 100644 test/definitions/visual/app-layout-drawers.ts create mode 100644 test/definitions/visual/app-layout-flashbar.ts create mode 100644 test/definitions/visual/app-layout-header.ts create mode 100644 test/definitions/visual/app-layout-multi.ts create mode 100644 test/definitions/visual/app-layout-responsive.ts create mode 100644 test/definitions/visual/app-layout-sticky-table-header-split-panel.ts create mode 100644 test/definitions/visual/app-layout-toolbar.ts create mode 100644 test/definitions/visual/app-layout-z-index.ts create mode 100644 test/visual/app-layout-content-paddings.test.ts create mode 100644 test/visual/app-layout-drawers.test.ts create mode 100644 test/visual/app-layout-flashbar.test.ts create mode 100644 test/visual/app-layout-header.test.ts create mode 100644 test/visual/app-layout-multi.test.ts create mode 100644 test/visual/app-layout-responsive.test.ts create mode 100644 test/visual/app-layout-sticky-table-header-split-panel.test.ts create mode 100644 test/visual/app-layout-toolbar.test.ts create mode 100644 test/visual/app-layout-z-index.test.ts diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index adb4d3ed5b..8e03c9967c 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -82,7 +82,7 @@ jobs: strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4, 5, 6] + shard: [1, 2, 3, 4, 5, 6, 7, 8] steps: - uses: actions/checkout@v4 @@ -134,7 +134,7 @@ jobs: run: node_modules/.bin/wait-on http://localhost:8080 http://localhost:8081 - name: Run visual regression tests - run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js --shard=${{ matrix.shard }}/6 + run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js --shard=${{ matrix.shard }}/8 env: TZ: UTC diff --git a/test/definitions/index.ts b/test/definitions/index.ts index 5f6d790d65..17dc1902a0 100644 --- a/test/definitions/index.ts +++ b/test/definitions/index.ts @@ -7,9 +7,35 @@ import { TestSuite } from './types'; import actionCard from './visual/action-card'; import alert from './visual/alert'; import appLayout from './visual/app-layout'; +import appLayoutContentPaddings from './visual/app-layout-content-paddings'; +import appLayoutDrawers from './visual/app-layout-drawers'; +import appLayoutFlashbar from './visual/app-layout-flashbar'; +import appLayoutHeader from './visual/app-layout-header'; +import appLayoutMulti from './visual/app-layout-multi'; +import appLayoutResponsive from './visual/app-layout-responsive'; +import appLayoutStickyTableHeaderSplitPanel from './visual/app-layout-sticky-table-header-split-panel'; +import appLayoutToolbar from './visual/app-layout-toolbar'; +import appLayoutZIndex from './visual/app-layout-z-index'; import areaChart from './visual/area-chart'; import attributeEditor from './visual/attribute-editor'; import autosuggest from './visual/autosuggest'; import badge from './visual/badge'; -export const allSuites: TestSuite[] = [actionCard, alert, appLayout, areaChart, attributeEditor, autosuggest, badge]; +export const allSuites: TestSuite[] = [ + actionCard, + alert, + appLayout, + appLayoutContentPaddings, + appLayoutDrawers, + appLayoutFlashbar, + appLayoutHeader, + appLayoutMulti, + appLayoutResponsive, + appLayoutStickyTableHeaderSplitPanel, + appLayoutToolbar, + appLayoutZIndex, + areaChart, + attributeEditor, + autosuggest, + badge, +]; diff --git a/test/definitions/visual/app-layout-content-paddings.ts b/test/definitions/visual/app-layout-content-paddings.ts new file mode 100644 index 0000000000..068e5dd08e --- /dev/null +++ b/test/definitions/visual/app-layout-content-paddings.ts @@ -0,0 +1,30 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Content paddings', + componentName: 'app-layout', + tests: [ + ...(['true', 'false'] as const).flatMap(toolsEnabled => + (['true', 'false'] as const).flatMap(splitPanelEnabled => + (['bottom', 'side'] as const).map(splitPanelPosition => ({ + description: `toolsEnabled=${toolsEnabled} splitPanelEnabled=${splitPanelEnabled} splitPanelPosition=${splitPanelPosition}`, + path: 'app-layout/with-split-panel', + screenshotType: 'screenshotArea' as const, + queryParams: { toolsEnabled, splitPanelEnabled, splitPanelPosition }, + })) + ) + ), + ...[1500, 600].map(width => ({ + description: `with split panel and disabled content paddings - width=${width}`, + path: 'app-layout/disable-paddings-with-split-panel', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + queryParams: { splitPanelOpen: 'true', splitPanelPosition: 'side' }, + })), + ], +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-drawers.ts b/test/definitions/visual/app-layout-drawers.ts new file mode 100644 index 0000000000..2af69c2ca1 --- /dev/null +++ b/test/definitions/visual/app-layout-drawers.ts @@ -0,0 +1,41 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import createWrapper from '../../../lib/components/test-utils/selectors'; +import { TestSuite } from '../types'; + +const wrapper = createWrapper(); + +const suite: TestSuite = { + description: 'Drawers', + componentName: 'app-layout', + tests: [ + { + description: 'with split panel', + path: 'app-layout/with-drawers', + screenshotType: 'screenshotArea', + setup: async page => { + await page.click(wrapper.findAppLayout().findDrawerTriggerById('pro-help').toSelector()); + }, + }, + { + description: 'with tooltip on hover', + path: 'app-layout/with-drawers', + screenshotType: 'screenshotArea', + setup: async page => { + await page.hoverElement(wrapper.findAppLayout().findDrawerTriggerById('pro-help').toSelector()); + }, + }, + { + description: 'with custom scrollable drawer content', + path: 'app-layout/with-drawers-scrollable', + screenshotType: 'screenshotArea', + queryParams: { sideNavFill: 'false' }, + setup: async page => { + await page.click(wrapper.findAppLayout().findDrawerTriggerById('chat').toSelector()); + }, + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-flashbar.ts b/test/definitions/visual/app-layout-flashbar.ts new file mode 100644 index 0000000000..5849a5cbf5 --- /dev/null +++ b/test/definitions/visual/app-layout-flashbar.ts @@ -0,0 +1,39 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Flashbar', + componentName: 'app-layout', + tests: [true, false].flatMap(disableContentPaddings => + [true, false].flatMap(stickyNotifications => + [true, false].flatMap(stickyTableHeader => + [true, false].map(stackNotifications => ({ + description: `disableContentPaddings: ${disableContentPaddings}, stickyNotifications: ${stickyNotifications}, stickyTableHeader: ${stickyTableHeader}, stackNotifications: ${stackNotifications}`, + path: 'app-layout/with-stacked-notifications-and-table', + screenshotType: 'screenshotArea' as const, + configuration: { width: 1280, height: 900 }, + setup: async (page: import('@cloudscape-design/browser-test-tools/page-objects').ScreenshotPageObject) => { + if (!disableContentPaddings) { + await page.click('[data-id="toggle-content-paddings"]'); + } + if (stickyNotifications) { + await page.click('[data-id="toggle-sticky-notifications"]'); + } + if (!stickyTableHeader) { + await page.click('[data-id="toggle-sticky-table-header"]'); + } + if (!stackNotifications) { + await page.click('[data-id="toggle-stack-items"]'); + } + await page.click('[data-id="add-notification"]'); + await page.click('[data-id="add-notification"]'); + }, + })) + ) + ) + ), +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-header.ts b/test/definitions/visual/app-layout-header.ts new file mode 100644 index 0000000000..55f674860c --- /dev/null +++ b/test/definitions/visual/app-layout-header.ts @@ -0,0 +1,85 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestDefinition, TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Headers', + componentName: 'app-layout', + tests: [ + // ── Headers ─────────────────────────────────────────────────────────── + { + description: 'Headers', + tests: [600, 1280].flatMap(width => [ + { + description: `alignment with full-page table (${width}px)`, + path: 'app-layout/with-table', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + }, + { + description: `alignment with full-page table in sticky state (${width}px)`, + path: 'app-layout/with-table', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + setup: async page => { + await page.windowScrollTo({ top: 200 }); + }, + }, + { + description: `alignment with full-page table in sticky state with sticky notifications (${width}px)`, + path: 'app-layout/with-table', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + queryParams: { stickyNotifications: 'true' }, + setup: async page => { + await page.windowScrollTo({ top: 200 }); + }, + }, + { + description: `high contrast header variant in landing page (${width}px)`, + path: 'app-layout/landing-page', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + }, + ]), + }, + + // ── High contrast header variant ────────────────────────────────────── + { + description: 'High contrast header variant', + tests: [ + ...[1400, 600].flatMap(width => [ + { + description: `with breadcrumbs and notifications at ${width}px`, + path: 'app-layout/high-contrast-header-variant', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + queryParams: { hasBreadcrumbs: 'true', hasNotifications: 'true', hasContainer: 'true' }, + } as TestDefinition, + { + description: `without overlap at ${width}px`, + path: 'app-layout/high-contrast-header-variant', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + queryParams: { disableOverlap: 'true' }, + } as TestDefinition, + { + description: `with content layout at ${width}px`, + path: 'app-layout/high-contrast-header-variant', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + queryParams: { + hasBreadcrumbs: 'true', + hasNotifications: 'true', + hasContainer: 'true', + hasContentLayout: 'true', + }, + } as TestDefinition, + ]), + ], + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-multi.ts b/test/definitions/visual/app-layout-multi.ts new file mode 100644 index 0000000000..7efbbcd473 --- /dev/null +++ b/test/definitions/visual/app-layout-multi.ts @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Multiple instances', + componentName: 'app-layout', + tests: [600, 1280].flatMap(width => [ + { + description: `simple (${width}px)`, + path: 'app-layout/multi-layout-simple', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + }, + { + description: `iframe (${width}px)`, + path: 'app-layout/multi-layout-iframe', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + }, + ]), +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-responsive.ts b/test/definitions/visual/app-layout-responsive.ts new file mode 100644 index 0000000000..8f227a5814 --- /dev/null +++ b/test/definitions/visual/app-layout-responsive.ts @@ -0,0 +1,169 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +function responsiveTests(width: number): TestSuite { + return { + description: `width ${width}px`, + componentName: 'app-layout', + tests: [ + { + description: 'default', + path: 'app-layout/default', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'navigation drawer is open', + path: 'app-layout/with-wizard', + screenshotType: 'screenshotArea', + configuration: { width }, + setup: async page => { + await page.click('[aria-label="Open navigation"]'); + }, + }, + { + description: 'wizard', + path: 'app-layout/with-wizard', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'with wizard and table', + path: 'app-layout/with-wizard-and-table', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'with wizard, table, and breadcrumbs', + path: 'app-layout/with-wizard-and-table', + screenshotType: 'screenshotArea', + configuration: { width }, + queryParams: { hasBreadcrumbs: 'true' }, + }, + { + description: 'notifications', + path: 'app-layout/with-notifications', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'breadcrumbs', + path: 'app-layout/with-breadcrumbs', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'notifications and breadcrumbs', + path: 'app-layout/with-breadcrumbs-notifications', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'dashboard content type', + path: 'app-layout/dashboard-content-type', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'fixed header and footer', + path: 'app-layout/with-fixed-header-footer', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'disableBodyScroll - empty', + path: 'app-layout/legacy-nav-empty', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'disableBodyScroll - with content', + path: 'app-layout/legacy-nav-scrollable', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'disableBodyScroll - with split panel', + path: 'app-layout/legacy-nav-scrollable-with-split-panel', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'disable paddings', + path: 'app-layout/disable-paddings', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'disable paddings with breadcrumbs', + path: 'app-layout/disable-paddings-breadcrumbs', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'sticky notifications', + path: 'app-layout/with-sticky-notifications', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'sticky notifications scrolled down', + path: 'app-layout/with-sticky-notifications', + screenshotType: 'screenshotArea', + configuration: { width }, + setup: async page => { + await page.windowScrollTo({ top: 2000 }); + }, + }, + { + description: 'layout without panels', + path: 'app-layout/no-panels', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'layout without panels but with notifications', + path: 'app-layout/no-panels-with-notifications', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'with drawers', + path: 'app-layout/with-drawers', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'with empty drawers', + path: 'app-layout/with-drawers-empty', + screenshotType: 'screenshotArea', + configuration: { width }, + }, + { + description: 'with open drawer', + path: 'app-layout/with-drawers', + screenshotType: 'screenshotArea', + configuration: { width }, + setup: async page => { + await page.click('[aria-label="Security trigger button"]'); + }, + }, + ], + }; +} + +const suite: TestSuite = { + description: 'AppLayout responsive', + componentName: 'app-layout', + tests: [ + responsiveTests(600), + responsiveTests(1280), + responsiveTests(1400), + responsiveTests(1920), + responsiveTests(2540), + ], +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-sticky-table-header-split-panel.ts b/test/definitions/visual/app-layout-sticky-table-header-split-panel.ts new file mode 100644 index 0000000000..91628802ff --- /dev/null +++ b/test/definitions/visual/app-layout-sticky-table-header-split-panel.ts @@ -0,0 +1,78 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Sticky header with split panel', + componentName: 'app-layout', + tests: [ + { + description: 'scrolling to bottom with closed split panel (1 table row)', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'screenshotArea', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-1"]'); + await page.scrollToBottom('html'); + }, + }, + { + description: 'scrolling to bottom with closed split panel (30 table rows)', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'screenshotArea', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-30"]'); + await page.scrollToBottom('html'); + }, + }, + { + description: 'header stays sticky with open split panel (1 table row)', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'screenshotArea', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-1"]'); + await page.click('aria/Open panel'); + await page.scrollToBottom('html'); + }, + }, + { + description: 'header stays sticky with open split panel (30 table rows)', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'screenshotArea', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-30"]'); + await page.click('aria/Open panel'); + await page.scrollToBottom('html'); + }, + }, + { + description: 'header stays sticky when mounting and unmounting a second table', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'screenshotArea', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-30"]'); + await page.click('aria/Open panel'); + await page.windowScrollTo({ top: 0 }); + await page.click('aria/Close panel'); + await page.scrollToBottom('html'); + }, + }, + // ── Max content width ───────────────────────────────────────────────── + { + description: 'maxContentWidth set to Number.MAX_VALUE', + path: 'app-layout/refresh-content-width', + screenshotType: 'screenshotArea', + configuration: { width: 1280, height: 700 }, + setup: async page => { + await page.click('[data-test-id="button_width-number-max_value"]'); + }, + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-toolbar.ts b/test/definitions/visual/app-layout-toolbar.ts new file mode 100644 index 0000000000..dc9f5861fe --- /dev/null +++ b/test/definitions/visual/app-layout-toolbar.ts @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Toolbar', + componentName: 'app-layout', + tests: [ + { + description: 'multiple nested instances (no breadcrumbs dedup)', + path: 'app-layout-toolbar/multi-layout-with-hidden-instances', + screenshotType: 'screenshotArea', + }, + { + description: 'no toolbar', + path: 'app-layout-toolbar/without-toolbar', + screenshotType: 'screenshotArea', + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-z-index.ts b/test/definitions/visual/app-layout-z-index.ts new file mode 100644 index 0000000000..7765a496a9 --- /dev/null +++ b/test/definitions/visual/app-layout-z-index.ts @@ -0,0 +1,60 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestDefinition, TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Z-index', + componentName: 'app-layout', + tests: [ + ...[600, 1280].flatMap(width => [ + { + description: `button dropdown (${width}px)`, + path: 'app-layout/with-absolute-components', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + setup: async page => { + await page.click('button=Button dropdown'); + await page.click('[data-testid="2"]'); + await page.windowScrollTo({ top: 300 }); + }, + } as TestDefinition, + { + description: `select (${width}px)`, + path: 'app-layout/with-absolute-components', + screenshotType: 'screenshotArea' as const, + configuration: { width, height: 800 }, + setup: async page => { + await page.click('[data-testid="select-demo"] button'); + await page.windowScrollTo({ top: 300 }); + }, + } as TestDefinition, + { + description: `split-panel and full-page table (${width}px)`, + path: 'app-layout/with-full-page-table-and-split-panel', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + }, + ]), + { + description: 'split-panel and full-page with open navigation (600px)', + path: 'app-layout/with-full-page-table-and-split-panel', + screenshotType: 'screenshotArea' as const, + configuration: { width: 600 }, + setup: async page => { + await page.click('button[aria-label="Open navigation"]'); + }, + }, + { + description: 'split-panel and full-page with open tools (600px)', + path: 'app-layout/with-full-page-table-and-split-panel', + screenshotType: 'screenshotArea' as const, + configuration: { width: 600 }, + setup: async page => { + await page.click('button[aria-label="Open tools"]'); + }, + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/app-layout.ts b/test/definitions/visual/app-layout.ts index 12c76d9138..c2f6a41f5c 100644 --- a/test/definitions/visual/app-layout.ts +++ b/test/definitions/visual/app-layout.ts @@ -1,174 +1,12 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import createWrapper from '../../../lib/components/test-utils/selectors'; -import { TestDefinition, TestSuite } from '../types'; - -const wrapper = createWrapper(); - -function responsiveTests(width: number): TestSuite { - return { - description: `width ${width}px`, - componentName: 'app-layout', - tests: [ - { - description: 'default', - path: 'app-layout/default', - screenshotType: 'screenshotArea', - configuration: { width }, - }, - { - description: 'navigation drawer is open', - path: 'app-layout/with-wizard', - screenshotType: 'screenshotArea', - configuration: { width }, - setup: async page => { - await page.click('[aria-label="Open navigation"]'); - }, - }, - { - description: 'wizard', - path: 'app-layout/with-wizard', - screenshotType: 'screenshotArea', - configuration: { width }, - }, - { - description: 'with wizard and table', - path: 'app-layout/with-wizard-and-table', - screenshotType: 'screenshotArea', - configuration: { width }, - }, - { - description: 'with wizard, table, and breadcrumbs', - path: 'app-layout/with-wizard-and-table', - screenshotType: 'screenshotArea', - configuration: { width }, - queryParams: { hasBreadcrumbs: 'true' }, - }, - { - description: 'notifications', - path: 'app-layout/with-notifications', - screenshotType: 'screenshotArea', - configuration: { width }, - }, - { - description: 'breadcrumbs', - path: 'app-layout/with-breadcrumbs', - screenshotType: 'screenshotArea', - configuration: { width }, - }, - { - description: 'notifications and breadcrumbs', - path: 'app-layout/with-breadcrumbs-notifications', - screenshotType: 'screenshotArea', - configuration: { width }, - }, - { - description: 'dashboard content type', - path: 'app-layout/dashboard-content-type', - screenshotType: 'screenshotArea', - configuration: { width }, - }, - { - description: 'fixed header and footer', - path: 'app-layout/with-fixed-header-footer', - screenshotType: 'screenshotArea', - configuration: { width }, - }, - { - description: 'disableBodyScroll - empty', - path: 'app-layout/legacy-nav-empty', - screenshotType: 'screenshotArea', - configuration: { width }, - }, - { - description: 'disableBodyScroll - with content', - path: 'app-layout/legacy-nav-scrollable', - screenshotType: 'screenshotArea', - configuration: { width }, - }, - { - description: 'disableBodyScroll - with split panel', - path: 'app-layout/legacy-nav-scrollable-with-split-panel', - screenshotType: 'screenshotArea', - configuration: { width }, - }, - { - description: 'disable paddings', - path: 'app-layout/disable-paddings', - screenshotType: 'screenshotArea', - configuration: { width }, - }, - { - description: 'disable paddings with breadcrumbs', - path: 'app-layout/disable-paddings-breadcrumbs', - screenshotType: 'screenshotArea', - configuration: { width }, - }, - { - description: 'sticky notifications', - path: 'app-layout/with-sticky-notifications', - screenshotType: 'screenshotArea', - configuration: { width }, - }, - { - description: 'sticky notifications scrolled down', - path: 'app-layout/with-sticky-notifications', - screenshotType: 'screenshotArea', - configuration: { width }, - setup: async page => { - await page.windowScrollTo({ top: 2000 }); - }, - }, - { - description: 'layout without panels', - path: 'app-layout/no-panels', - screenshotType: 'screenshotArea', - configuration: { width }, - }, - { - description: 'layout without panels but with notifications', - path: 'app-layout/no-panels-with-notifications', - screenshotType: 'screenshotArea', - configuration: { width }, - }, - { - description: 'with drawers', - path: 'app-layout/with-drawers', - screenshotType: 'screenshotArea', - configuration: { width }, - }, - { - description: 'with empty drawers', - path: 'app-layout/with-drawers-empty', - screenshotType: 'screenshotArea', - configuration: { width }, - }, - { - description: 'with open drawer', - path: 'app-layout/with-drawers', - screenshotType: 'screenshotArea', - configuration: { width }, - setup: async page => { - await page.click('[aria-label="Security trigger button"]'); - }, - }, - ], - }; -} +import { TestSuite } from '../types'; const suite: TestSuite = { description: 'AppLayout', componentName: 'app-layout', tests: [ - // ── Responsive tests at multiple breakpoints ────────────────────────── - responsiveTests(600), - responsiveTests(1280), - responsiveTests(1400), - responsiveTests(1920), - responsiveTests(2540), - - // ── General tests ───────────────────────────────────────────────────── { description: 'no scrollbars at 320px', path: 'app-layout/default', @@ -227,361 +65,38 @@ const suite: TestSuite = { }, }, - // ── Content paddings ────────────────────────────────────────────────── - { - description: 'Content paddings', - tests: [ - ...(['true', 'false'] as const).flatMap(toolsEnabled => - (['true', 'false'] as const).flatMap(splitPanelEnabled => - (['bottom', 'side'] as const).map(splitPanelPosition => ({ - description: `toolsEnabled=${toolsEnabled} splitPanelEnabled=${splitPanelEnabled} splitPanelPosition=${splitPanelPosition}`, - path: 'app-layout/with-split-panel', - screenshotType: 'screenshotArea' as const, - queryParams: { toolsEnabled, splitPanelEnabled, splitPanelPosition }, - })) - ) - ), - ...[1500, 600].map(width => ({ - description: `with split panel and disabled content paddings - width=${width}`, - path: 'app-layout/disable-paddings-with-split-panel', - screenshotType: 'screenshotArea' as const, - configuration: { width }, - queryParams: { splitPanelOpen: 'true', splitPanelPosition: 'side' }, - })), - ], - }, - - // ── Drawers ─────────────────────────────────────────────────────────── - { - description: 'Drawers', - tests: [ - { - description: 'with split panel', - path: 'app-layout/with-drawers', - screenshotType: 'screenshotArea', - setup: async page => { - await page.click(wrapper.findAppLayout().findDrawerTriggerById('pro-help').toSelector()); - }, - }, - { - description: 'with tooltip on hover', - path: 'app-layout/with-drawers', - screenshotType: 'screenshotArea', - setup: async page => { - await page.hoverElement(wrapper.findAppLayout().findDrawerTriggerById('pro-help').toSelector()); - }, - }, - { - description: 'with custom scrollable drawer content', - path: 'app-layout/with-drawers-scrollable', - screenshotType: 'screenshotArea', - queryParams: { sideNavFill: 'false' }, - setup: async page => { - await page.click(wrapper.findAppLayout().findDrawerTriggerById('chat').toSelector()); - }, - }, - ], - }, - - // ── Headers ─────────────────────────────────────────────────────────── - { - description: 'Headers', - tests: [600, 1280].flatMap(width => [ - { - description: `alignment with full-page table (${width}px)`, - path: 'app-layout/with-table', - screenshotType: 'screenshotArea' as const, - configuration: { width }, - }, - { - description: `alignment with full-page table in sticky state (${width}px)`, - path: 'app-layout/with-table', - screenshotType: 'screenshotArea' as const, - configuration: { width }, - setup: async page => { - await page.windowScrollTo({ top: 200 }); - }, - }, - { - description: `alignment with full-page table in sticky state with sticky notifications (${width}px)`, - path: 'app-layout/with-table', - screenshotType: 'screenshotArea' as const, - configuration: { width }, - queryParams: { stickyNotifications: 'true' }, - setup: async page => { - await page.windowScrollTo({ top: 200 }); - }, - }, - { - description: `high contrast header variant in landing page (${width}px)`, - path: 'app-layout/landing-page', - screenshotType: 'screenshotArea' as const, - configuration: { width }, - }, - ]), - }, - - // ── High contrast header variant ────────────────────────────────────── + // regression for https://github.com/cloudscape-design/components/pull/1612 { - description: 'High contrast header variant', - tests: [ - ...[1400, 600].flatMap(width => [ - { - description: `with breadcrumbs and notifications at ${width}px`, - path: 'app-layout/high-contrast-header-variant', - screenshotType: 'screenshotArea' as const, - configuration: { width }, - queryParams: { hasBreadcrumbs: 'true', hasNotifications: 'true', hasContainer: 'true' }, - }, - { - description: `without overlap at ${width}px`, - path: 'app-layout/high-contrast-header-variant', - screenshotType: 'screenshotArea' as const, - configuration: { width }, - queryParams: { disableOverlap: 'true' }, - }, - { - description: `with content layout at ${width}px`, - path: 'app-layout/high-contrast-header-variant', - screenshotType: 'screenshotArea' as const, - configuration: { width }, - queryParams: { - hasBreadcrumbs: 'true', - hasNotifications: 'true', - hasContainer: 'true', - hasContentLayout: 'true', - }, - }, - ]), - ], - }, - - // ── Multiple instances ───────────────────────────────────────────────── - { - description: 'Multiple instances', - tests: [600, 1280].flatMap(width => [ - { - description: `simple (${width}px)`, - path: 'app-layout/multi-layout-simple', - screenshotType: 'screenshotArea' as const, - configuration: { width }, - }, - { - description: `iframe (${width}px)`, - path: 'app-layout/multi-layout-iframe', - screenshotType: 'screenshotArea' as const, - configuration: { width }, - }, - ]), - }, - - // ── Z-index (absolute components) ───────────────────────────────────── - { - description: 'Z-index', - tests: [ - ...[600, 1280].flatMap(width => [ - { - description: `button dropdown (${width}px)`, - path: 'app-layout/with-absolute-components', - screenshotType: 'screenshotArea' as const, - configuration: { width }, - setup: async page => { - await page.click('button=Button dropdown'); - await page.click('[data-testid="2"]'); - await page.windowScrollTo({ top: 300 }); - }, - } as TestDefinition, - { - description: `select (${width}px)`, - path: 'app-layout/with-absolute-components', - screenshotType: 'screenshotArea' as const, - configuration: { width, height: 800 }, - setup: async page => { - await page.click('[data-testid="select-demo"] button'); - await page.windowScrollTo({ top: 300 }); - }, - } as TestDefinition, - { - description: `split-panel and full-page table (${width}px)`, - path: 'app-layout/with-full-page-table-and-split-panel', - screenshotType: 'screenshotArea' as const, - configuration: { width }, - }, - ]), - { - description: 'split-panel and full-page with open navigation (600px)', - path: 'app-layout/with-full-page-table-and-split-panel', - screenshotType: 'screenshotArea' as const, - configuration: { width: 600 }, - setup: async page => { - await page.click('button[aria-label="Open navigation"]'); - }, - }, - { - description: 'split-panel and full-page with open tools (600px)', - path: 'app-layout/with-full-page-table-and-split-panel', - screenshotType: 'screenshotArea' as const, - configuration: { width: 600 }, - setup: async page => { - await page.click('button[aria-label="Open tools"]'); - }, - }, - ], - }, - - // ── Toolbar ─────────────────────────────────────────────────────────── - { - description: 'Toolbar', - tests: [ - { - description: 'multiple nested instances (no breadcrumbs dedup)', - path: 'app-layout-toolbar/multi-layout-with-hidden-instances', - screenshotType: 'screenshotArea', - }, - { - description: 'no toolbar', - path: 'app-layout-toolbar/without-toolbar', - screenshotType: 'screenshotArea', - }, - ], - }, - - // ── Max content width ───────────────────────────────────────────────── - { - description: 'Max content width', - tests: [ - { - description: 'maxContentWidth set to Number.MAX_VALUE', - path: 'app-layout/refresh-content-width', - screenshotType: 'screenshotArea', - configuration: { width: 1280, height: 700 }, - setup: async page => { - await page.click('[data-test-id="button_width-number-max_value"]'); - }, - }, - ], - }, - - // ── Sticky table header with split panel ────────────────────────────── - { - description: 'Sticky header with split panel', - tests: [ - { - description: 'scrolling to bottom with closed split panel (1 table row)', - path: 'app-layout/with-sticky-table-and-split-panel', - screenshotType: 'screenshotArea', - configuration: { width: 1280, height: 900 }, - setup: async page => { - await page.click('[data-testid="set-item-count-to-1"]'); - await page.scrollToBottom('html'); - }, - }, - { - description: 'scrolling to bottom with closed split panel (30 table rows)', - path: 'app-layout/with-sticky-table-and-split-panel', - screenshotType: 'screenshotArea', - configuration: { width: 1280, height: 900 }, - setup: async page => { - await page.click('[data-testid="set-item-count-to-30"]'); - await page.scrollToBottom('html'); - }, - }, - { - description: 'header stays sticky with open split panel (1 table row)', - path: 'app-layout/with-sticky-table-and-split-panel', - screenshotType: 'screenshotArea', - configuration: { width: 1280, height: 900 }, - setup: async page => { - await page.click('[data-testid="set-item-count-to-1"]'); - await page.click('aria/Open panel'); - await page.scrollToBottom('html'); - }, - }, - { - description: 'header stays sticky with open split panel (30 table rows)', - path: 'app-layout/with-sticky-table-and-split-panel', - screenshotType: 'screenshotArea', - configuration: { width: 1280, height: 900 }, - setup: async page => { - await page.click('[data-testid="set-item-count-to-30"]'); - await page.click('aria/Open panel'); - await page.scrollToBottom('html'); - }, - }, - { - description: 'header stays sticky when mounting and unmounting a second table', - path: 'app-layout/with-sticky-table-and-split-panel', - screenshotType: 'screenshotArea', - configuration: { width: 1280, height: 900 }, - setup: async page => { - await page.click('[data-testid="set-item-count-to-30"]'); - await page.click('aria/Open panel'); - await page.windowScrollTo({ top: 0 }); - await page.click('aria/Close panel'); - await page.scrollToBottom('html'); - }, - }, - ], + description: 'with open drawer and open side split panel after resize', + path: 'app-layout/with-drawers', + screenshotType: 'screenshotArea', + configuration: { width: 1500 }, + queryParams: { splitPanelPosition: 'side' }, + setup: async page => { + await page.click('[aria-label="Security trigger button"]'); + await page.click('[aria-label="Open panel"]'); + await page.setWindowSize({ width: 1400, height: 800 }); + }, }, - // ── Flashbar ────────────────────────────────────────────────────────── + // ── Transitions ─────────────────────────────────────────────────────── { - description: 'Flashbar', - tests: [true, false].flatMap(disableContentPaddings => - [true, false].flatMap(stickyNotifications => - [true, false].flatMap(stickyTableHeader => - [true, false].map(stackNotifications => ({ - description: `disableContentPaddings: ${disableContentPaddings}, stickyNotifications: ${stickyNotifications}, stickyTableHeader: ${stickyTableHeader}, stackNotifications: ${stackNotifications}`, - path: 'app-layout/with-stacked-notifications-and-table', - screenshotType: 'screenshotArea' as const, - configuration: { width: 1280, height: 900 }, - setup: async ( - page: import('@cloudscape-design/browser-test-tools/page-objects').ScreenshotPageObject - ) => { - if (!disableContentPaddings) { - await page.click('[data-id="toggle-content-paddings"]'); - } - if (stickyNotifications) { - await page.click('[data-id="toggle-sticky-notifications"]'); - } - if (!stickyTableHeader) { - await page.click('[data-id="toggle-sticky-table-header"]'); - } - if (!stackNotifications) { - await page.click('[data-id="toggle-stack-items"]'); - } - await page.click('[data-id="add-notification"]'); - await page.click('[data-id="add-notification"]'); - }, - })) - ) - ) - ), + description: 'transition from 400px to 1800px', + path: 'app-layout/default', + screenshotType: 'screenshotArea', + configuration: { width: 400, height: 400 }, + setup: async page => { + await page.setWindowSize({ width: 1800, height: 400 }); + }, }, - - // ── Transitions ─────────────────────────────────────────────────────── { - description: 'Transitions', - tests: [ - { - description: 'transition from 400px to 1800px', - path: 'app-layout/default', - screenshotType: 'screenshotArea', - configuration: { width: 400, height: 400 }, - setup: async page => { - await page.setWindowSize({ width: 1800, height: 400 }); - }, - }, - { - description: 'transition from 1800px to 400px', - path: 'app-layout/default', - screenshotType: 'screenshotArea', - configuration: { width: 1800, height: 400 }, - setup: async page => { - await page.setWindowSize({ width: 400, height: 400 }); - }, - }, - ], + description: 'transition from 1800px to 400px', + path: 'app-layout/default', + screenshotType: 'screenshotArea', + configuration: { width: 1800, height: 400 }, + setup: async page => { + await page.setWindowSize({ width: 400, height: 400 }); + }, }, ], }; diff --git a/test/visual/app-layout-content-paddings.test.ts b/test/visual/app-layout-content-paddings.test.ts new file mode 100644 index 0000000000..e566d79817 --- /dev/null +++ b/test/visual/app-layout-content-paddings.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-content-paddings'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-drawers.test.ts b/test/visual/app-layout-drawers.test.ts new file mode 100644 index 0000000000..c66454010d --- /dev/null +++ b/test/visual/app-layout-drawers.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-drawers'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-flashbar.test.ts b/test/visual/app-layout-flashbar.test.ts new file mode 100644 index 0000000000..333642e5f3 --- /dev/null +++ b/test/visual/app-layout-flashbar.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-flashbar'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-header.test.ts b/test/visual/app-layout-header.test.ts new file mode 100644 index 0000000000..682f71ffe2 --- /dev/null +++ b/test/visual/app-layout-header.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-header'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-multi.test.ts b/test/visual/app-layout-multi.test.ts new file mode 100644 index 0000000000..244019c8fb --- /dev/null +++ b/test/visual/app-layout-multi.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-multi'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive.test.ts b/test/visual/app-layout-responsive.test.ts new file mode 100644 index 0000000000..668d4b3522 --- /dev/null +++ b/test/visual/app-layout-responsive.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-responsive'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-sticky-table-header-split-panel.test.ts b/test/visual/app-layout-sticky-table-header-split-panel.test.ts new file mode 100644 index 0000000000..c1ad3016a1 --- /dev/null +++ b/test/visual/app-layout-sticky-table-header-split-panel.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-sticky-table-header-split-panel'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-toolbar.test.ts b/test/visual/app-layout-toolbar.test.ts new file mode 100644 index 0000000000..398d6386f8 --- /dev/null +++ b/test/visual/app-layout-toolbar.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-toolbar'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-z-index.test.ts b/test/visual/app-layout-z-index.test.ts new file mode 100644 index 0000000000..5f69f77b71 --- /dev/null +++ b/test/visual/app-layout-z-index.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-z-index'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout.test.ts b/test/visual/app-layout.test.ts index 93500354cc..21c3a6ce25 100644 --- a/test/visual/app-layout.test.ts +++ b/test/visual/app-layout.test.ts @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { runTestSuites } from '../definitions/utils'; -import appLayout from '../definitions/visual/app-layout'; +import suite from '../definitions/visual/app-layout'; -runTestSuites([appLayout]); +runTestSuites([suite]); From 10b48b9ed2f1c5eff9eaacea4213630351b8adfd Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Sat, 30 May 2026 08:58:50 +0200 Subject: [PATCH 70/95] Add viewport screenshot type --- test/definitions/types.ts | 2 +- test/definitions/utils.ts | 28 +++++++----- .../visual/app-layout-content-paddings.ts | 4 +- test/definitions/visual/app-layout-drawers.ts | 6 +-- test/definitions/visual/app-layout-header.ts | 8 ++-- test/definitions/visual/app-layout-multi.ts | 4 +- .../visual/app-layout-responsive.ts | 44 +++++++++---------- ...-layout-sticky-table-header-split-panel.ts | 12 ++--- test/definitions/visual/app-layout-toolbar.ts | 4 +- test/definitions/visual/app-layout-z-index.ts | 10 ++--- test/definitions/visual/app-layout.ts | 22 +++++----- test/definitions/visual/area-chart.ts | 14 +++--- 12 files changed, 83 insertions(+), 75 deletions(-) diff --git a/test/definitions/types.ts b/test/definitions/types.ts index 8cdca9996a..9f8a187c48 100644 --- a/test/definitions/types.ts +++ b/test/definitions/types.ts @@ -9,7 +9,7 @@ export interface ScreenshotTestConfiguration { // 'screenshotArea' — captures the .screenshot-area element on the page. // 'permutations' — captures the entire page and crops permutations out of it. -export type ScreenshotType = 'screenshotArea' | 'permutations'; +export type ScreenshotType = 'screenshotArea' | 'permutations' | 'viewport'; export interface TestDefinition { description: string; diff --git a/test/definitions/utils.ts b/test/definitions/utils.ts index c5b237dc73..683e50cc80 100644 --- a/test/definitions/utils.ts +++ b/test/definitions/utils.ts @@ -57,22 +57,25 @@ function registerSuites(suites: Array, getBrowser: ( } /** - * Captures the .screenshot-area element using a viewport-only screenshot (fast). + * Captures a screenshot based on the test's screenshotType. */ -async function captureScreenshotArea( +async function capture( browser: WebdriverIO.Browser, page: ScreenshotPageObject, url: string, - windowSize: { width: number; height: number } | undefined, - setup?: (page: ScreenshotPageObject) => Promise + testDef: TestDefinition, + windowSize: { width: number; height: number } | undefined ): Promise { if (windowSize) { await browser.setWindowSize(windowSize.width, windowSize.height); } await browser.url(url); await page.waitForVisible(screenshotAreaSelector); - if (setup) { - await setup(page); + if (testDef.setup) { + await testDef.setup(page); + } + if (testDef.screenshotType === 'viewport') { + return page.captureViewport(); } return page.captureBySelector(screenshotAreaSelector, { viewportOnly: true }); } @@ -86,9 +89,8 @@ function registerTest(testDef: TestDefinition, getBrowser: () => WebdriverIO.Bro const newUrl = buildUrl(newHost, testDef.path, testDef.queryParams); const oldUrl = buildUrl(oldHost, testDef.path, testDef.queryParams); - // Fast path: compare the screenshot area (viewport-only, no scroll-and-merge). - const newScreenshot = await captureScreenshotArea(browser, page, newUrl, windowSize, testDef.setup); - const oldScreenshot = await captureScreenshotArea(browser, page, oldUrl, windowSize, testDef.setup); + const newScreenshot = await capture(browser, page, newUrl, testDef, windowSize); + const oldScreenshot = await capture(browser, page, oldUrl, testDef, windowSize); const { diffPixels } = await cropAndCompare(newScreenshot, oldScreenshot); if (diffPixels === 0) { @@ -100,6 +102,9 @@ function registerTest(testDef: TestDefinition, getBrowser: () => WebdriverIO.Bro // full capturePermutations strategy which resizes the window to fit all // content and returns individual permutation crops for precise comparison. if (testDef.screenshotType === 'permutations') { + if (windowSize) { + await browser.setWindowSize(windowSize.width, windowSize.height); + } await browser.url(newUrl); await page.waitForVisible(screenshotAreaSelector); if (testDef.setup) { @@ -107,6 +112,9 @@ function registerTest(testDef: TestDefinition, getBrowser: () => WebdriverIO.Bro } const newPermutations = await page.capturePermutations(); + if (windowSize) { + await browser.setWindowSize(windowSize.width, windowSize.height); + } await browser.url(oldUrl); await page.waitForVisible(screenshotAreaSelector); if (testDef.setup) { @@ -122,7 +130,7 @@ function registerTest(testDef: TestDefinition, getBrowser: () => WebdriverIO.Bro return; } - // For screenshotArea type, the diff is a real failure. + // For screenshotArea and viewport types, the diff is a real failure. expect(diffPixels).toBe(0); }); } diff --git a/test/definitions/visual/app-layout-content-paddings.ts b/test/definitions/visual/app-layout-content-paddings.ts index 068e5dd08e..e76d055749 100644 --- a/test/definitions/visual/app-layout-content-paddings.ts +++ b/test/definitions/visual/app-layout-content-paddings.ts @@ -12,7 +12,7 @@ const suite: TestSuite = { (['bottom', 'side'] as const).map(splitPanelPosition => ({ description: `toolsEnabled=${toolsEnabled} splitPanelEnabled=${splitPanelEnabled} splitPanelPosition=${splitPanelPosition}`, path: 'app-layout/with-split-panel', - screenshotType: 'screenshotArea' as const, + screenshotType: 'viewport' as const, queryParams: { toolsEnabled, splitPanelEnabled, splitPanelPosition }, })) ) @@ -20,7 +20,7 @@ const suite: TestSuite = { ...[1500, 600].map(width => ({ description: `with split panel and disabled content paddings - width=${width}`, path: 'app-layout/disable-paddings-with-split-panel', - screenshotType: 'screenshotArea' as const, + screenshotType: 'viewport' as const, configuration: { width }, queryParams: { splitPanelOpen: 'true', splitPanelPosition: 'side' }, })), diff --git a/test/definitions/visual/app-layout-drawers.ts b/test/definitions/visual/app-layout-drawers.ts index 2af69c2ca1..39c2089783 100644 --- a/test/definitions/visual/app-layout-drawers.ts +++ b/test/definitions/visual/app-layout-drawers.ts @@ -13,7 +13,7 @@ const suite: TestSuite = { { description: 'with split panel', path: 'app-layout/with-drawers', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', setup: async page => { await page.click(wrapper.findAppLayout().findDrawerTriggerById('pro-help').toSelector()); }, @@ -21,7 +21,7 @@ const suite: TestSuite = { { description: 'with tooltip on hover', path: 'app-layout/with-drawers', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', setup: async page => { await page.hoverElement(wrapper.findAppLayout().findDrawerTriggerById('pro-help').toSelector()); }, @@ -29,7 +29,7 @@ const suite: TestSuite = { { description: 'with custom scrollable drawer content', path: 'app-layout/with-drawers-scrollable', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', queryParams: { sideNavFill: 'false' }, setup: async page => { await page.click(wrapper.findAppLayout().findDrawerTriggerById('chat').toSelector()); diff --git a/test/definitions/visual/app-layout-header.ts b/test/definitions/visual/app-layout-header.ts index 55f674860c..3a128a266e 100644 --- a/test/definitions/visual/app-layout-header.ts +++ b/test/definitions/visual/app-layout-header.ts @@ -14,13 +14,13 @@ const suite: TestSuite = { { description: `alignment with full-page table (${width}px)`, path: 'app-layout/with-table', - screenshotType: 'screenshotArea' as const, + screenshotType: 'viewport' as const, configuration: { width }, }, { description: `alignment with full-page table in sticky state (${width}px)`, path: 'app-layout/with-table', - screenshotType: 'screenshotArea' as const, + screenshotType: 'viewport' as const, configuration: { width }, setup: async page => { await page.windowScrollTo({ top: 200 }); @@ -29,7 +29,7 @@ const suite: TestSuite = { { description: `alignment with full-page table in sticky state with sticky notifications (${width}px)`, path: 'app-layout/with-table', - screenshotType: 'screenshotArea' as const, + screenshotType: 'viewport' as const, configuration: { width }, queryParams: { stickyNotifications: 'true' }, setup: async page => { @@ -39,7 +39,7 @@ const suite: TestSuite = { { description: `high contrast header variant in landing page (${width}px)`, path: 'app-layout/landing-page', - screenshotType: 'screenshotArea' as const, + screenshotType: 'viewport' as const, configuration: { width }, }, ]), diff --git a/test/definitions/visual/app-layout-multi.ts b/test/definitions/visual/app-layout-multi.ts index 7efbbcd473..babf9733cf 100644 --- a/test/definitions/visual/app-layout-multi.ts +++ b/test/definitions/visual/app-layout-multi.ts @@ -10,13 +10,13 @@ const suite: TestSuite = { { description: `simple (${width}px)`, path: 'app-layout/multi-layout-simple', - screenshotType: 'screenshotArea' as const, + screenshotType: 'viewport' as const, configuration: { width }, }, { description: `iframe (${width}px)`, path: 'app-layout/multi-layout-iframe', - screenshotType: 'screenshotArea' as const, + screenshotType: 'viewport' as const, configuration: { width }, }, ]), diff --git a/test/definitions/visual/app-layout-responsive.ts b/test/definitions/visual/app-layout-responsive.ts index 8f227a5814..8a53da6a09 100644 --- a/test/definitions/visual/app-layout-responsive.ts +++ b/test/definitions/visual/app-layout-responsive.ts @@ -11,13 +11,13 @@ function responsiveTests(width: number): TestSuite { { description: 'default', path: 'app-layout/default', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, }, { description: 'navigation drawer is open', path: 'app-layout/with-wizard', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, setup: async page => { await page.click('[aria-label="Open navigation"]'); @@ -26,92 +26,92 @@ function responsiveTests(width: number): TestSuite { { description: 'wizard', path: 'app-layout/with-wizard', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, }, { description: 'with wizard and table', path: 'app-layout/with-wizard-and-table', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, }, { description: 'with wizard, table, and breadcrumbs', path: 'app-layout/with-wizard-and-table', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, queryParams: { hasBreadcrumbs: 'true' }, }, { description: 'notifications', path: 'app-layout/with-notifications', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, }, { description: 'breadcrumbs', path: 'app-layout/with-breadcrumbs', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, }, { description: 'notifications and breadcrumbs', path: 'app-layout/with-breadcrumbs-notifications', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, }, { description: 'dashboard content type', path: 'app-layout/dashboard-content-type', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, }, { description: 'fixed header and footer', path: 'app-layout/with-fixed-header-footer', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, }, { description: 'disableBodyScroll - empty', path: 'app-layout/legacy-nav-empty', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, }, { description: 'disableBodyScroll - with content', path: 'app-layout/legacy-nav-scrollable', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, }, { description: 'disableBodyScroll - with split panel', path: 'app-layout/legacy-nav-scrollable-with-split-panel', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, }, { description: 'disable paddings', path: 'app-layout/disable-paddings', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, }, { description: 'disable paddings with breadcrumbs', path: 'app-layout/disable-paddings-breadcrumbs', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, }, { description: 'sticky notifications', path: 'app-layout/with-sticky-notifications', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, }, { description: 'sticky notifications scrolled down', path: 'app-layout/with-sticky-notifications', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, setup: async page => { await page.windowScrollTo({ top: 2000 }); @@ -120,31 +120,31 @@ function responsiveTests(width: number): TestSuite { { description: 'layout without panels', path: 'app-layout/no-panels', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, }, { description: 'layout without panels but with notifications', path: 'app-layout/no-panels-with-notifications', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, }, { description: 'with drawers', path: 'app-layout/with-drawers', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, }, { description: 'with empty drawers', path: 'app-layout/with-drawers-empty', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, }, { description: 'with open drawer', path: 'app-layout/with-drawers', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width }, setup: async page => { await page.click('[aria-label="Security trigger button"]'); diff --git a/test/definitions/visual/app-layout-sticky-table-header-split-panel.ts b/test/definitions/visual/app-layout-sticky-table-header-split-panel.ts index 91628802ff..6a0b899686 100644 --- a/test/definitions/visual/app-layout-sticky-table-header-split-panel.ts +++ b/test/definitions/visual/app-layout-sticky-table-header-split-panel.ts @@ -10,7 +10,7 @@ const suite: TestSuite = { { description: 'scrolling to bottom with closed split panel (1 table row)', path: 'app-layout/with-sticky-table-and-split-panel', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 1280, height: 900 }, setup: async page => { await page.click('[data-testid="set-item-count-to-1"]'); @@ -20,7 +20,7 @@ const suite: TestSuite = { { description: 'scrolling to bottom with closed split panel (30 table rows)', path: 'app-layout/with-sticky-table-and-split-panel', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 1280, height: 900 }, setup: async page => { await page.click('[data-testid="set-item-count-to-30"]'); @@ -30,7 +30,7 @@ const suite: TestSuite = { { description: 'header stays sticky with open split panel (1 table row)', path: 'app-layout/with-sticky-table-and-split-panel', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 1280, height: 900 }, setup: async page => { await page.click('[data-testid="set-item-count-to-1"]'); @@ -41,7 +41,7 @@ const suite: TestSuite = { { description: 'header stays sticky with open split panel (30 table rows)', path: 'app-layout/with-sticky-table-and-split-panel', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 1280, height: 900 }, setup: async page => { await page.click('[data-testid="set-item-count-to-30"]'); @@ -52,7 +52,7 @@ const suite: TestSuite = { { description: 'header stays sticky when mounting and unmounting a second table', path: 'app-layout/with-sticky-table-and-split-panel', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 1280, height: 900 }, setup: async page => { await page.click('[data-testid="set-item-count-to-30"]'); @@ -66,7 +66,7 @@ const suite: TestSuite = { { description: 'maxContentWidth set to Number.MAX_VALUE', path: 'app-layout/refresh-content-width', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 1280, height: 700 }, setup: async page => { await page.click('[data-test-id="button_width-number-max_value"]'); diff --git a/test/definitions/visual/app-layout-toolbar.ts b/test/definitions/visual/app-layout-toolbar.ts index dc9f5861fe..fb174b0a71 100644 --- a/test/definitions/visual/app-layout-toolbar.ts +++ b/test/definitions/visual/app-layout-toolbar.ts @@ -10,12 +10,12 @@ const suite: TestSuite = { { description: 'multiple nested instances (no breadcrumbs dedup)', path: 'app-layout-toolbar/multi-layout-with-hidden-instances', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', }, { description: 'no toolbar', path: 'app-layout-toolbar/without-toolbar', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', }, ], }; diff --git a/test/definitions/visual/app-layout-z-index.ts b/test/definitions/visual/app-layout-z-index.ts index 7765a496a9..b0dacf8bc5 100644 --- a/test/definitions/visual/app-layout-z-index.ts +++ b/test/definitions/visual/app-layout-z-index.ts @@ -11,7 +11,7 @@ const suite: TestSuite = { { description: `button dropdown (${width}px)`, path: 'app-layout/with-absolute-components', - screenshotType: 'screenshotArea' as const, + screenshotType: 'viewport' as const, configuration: { width }, setup: async page => { await page.click('button=Button dropdown'); @@ -22,7 +22,7 @@ const suite: TestSuite = { { description: `select (${width}px)`, path: 'app-layout/with-absolute-components', - screenshotType: 'screenshotArea' as const, + screenshotType: 'viewport' as const, configuration: { width, height: 800 }, setup: async page => { await page.click('[data-testid="select-demo"] button'); @@ -32,14 +32,14 @@ const suite: TestSuite = { { description: `split-panel and full-page table (${width}px)`, path: 'app-layout/with-full-page-table-and-split-panel', - screenshotType: 'screenshotArea' as const, + screenshotType: 'viewport' as const, configuration: { width }, }, ]), { description: 'split-panel and full-page with open navigation (600px)', path: 'app-layout/with-full-page-table-and-split-panel', - screenshotType: 'screenshotArea' as const, + screenshotType: 'viewport' as const, configuration: { width: 600 }, setup: async page => { await page.click('button[aria-label="Open navigation"]'); @@ -48,7 +48,7 @@ const suite: TestSuite = { { description: 'split-panel and full-page with open tools (600px)', path: 'app-layout/with-full-page-table-and-split-panel', - screenshotType: 'screenshotArea' as const, + screenshotType: 'viewport' as const, configuration: { width: 600 }, setup: async page => { await page.click('button[aria-label="Open tools"]'); diff --git a/test/definitions/visual/app-layout.ts b/test/definitions/visual/app-layout.ts index c2f6a41f5c..683efdcc25 100644 --- a/test/definitions/visual/app-layout.ts +++ b/test/definitions/visual/app-layout.ts @@ -10,13 +10,13 @@ const suite: TestSuite = { { description: 'no scrollbars at 320px', path: 'app-layout/default', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 320 }, }, { description: 'drawer buttons alignment', path: 'app-layout/default', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 800 }, setup: async page => { await page.click('[aria-label="Open tools"]'); @@ -25,7 +25,7 @@ const suite: TestSuite = { { description: 'disable paddings - navigation closed', path: 'app-layout/disable-paddings', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 1280 }, setup: async page => { await page.click('[aria-label="Close navigation"]'); @@ -34,29 +34,29 @@ const suite: TestSuite = { { description: 'panels stacking on mobile', path: 'app-layout/all-panels-open', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 600 }, }, { description: 'wrapping long words', path: 'app-layout/text-wrap', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', }, { description: 'fill content area', path: 'app-layout/fill-content-area', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', }, { description: 'with tools and drawers', path: 'app-layout/with-drawers', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', queryParams: { hasTools: 'true' }, }, { description: 'with open drawer and open side split panel', path: 'app-layout/with-drawers', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 1400 }, queryParams: { splitPanelPosition: 'side' }, setup: async page => { @@ -69,7 +69,7 @@ const suite: TestSuite = { { description: 'with open drawer and open side split panel after resize', path: 'app-layout/with-drawers', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 1500 }, queryParams: { splitPanelPosition: 'side' }, setup: async page => { @@ -83,7 +83,7 @@ const suite: TestSuite = { { description: 'transition from 400px to 1800px', path: 'app-layout/default', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 400, height: 400 }, setup: async page => { await page.setWindowSize({ width: 1800, height: 400 }); @@ -92,7 +92,7 @@ const suite: TestSuite = { { description: 'transition from 1800px to 400px', path: 'app-layout/default', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 1800, height: 400 }, setup: async page => { await page.setWindowSize({ width: 400, height: 400 }); diff --git a/test/definitions/visual/area-chart.ts b/test/definitions/visual/area-chart.ts index 10855239f6..cfa0b5e47b 100644 --- a/test/definitions/visual/area-chart.ts +++ b/test/definitions/visual/area-chart.ts @@ -35,7 +35,7 @@ const suite: TestSuite = { { description: 'chart plot has a focus outline', path: 'area-chart/test', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 800, height: 800 }, setup: async page => { await page.click(TEST_CHART_FILTER_TRIGGER); @@ -46,7 +46,7 @@ const suite: TestSuite = { { description: 'can navigate along X axis highlighting all series with keyboard', path: 'area-chart/test', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 800, height: 800 }, setup: async page => { await page.click(TEST_CHART_FILTER_TRIGGER); @@ -59,7 +59,7 @@ const suite: TestSuite = { { description: 'can navigate a specific series with keyboard', path: 'area-chart/test', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 800, height: 800 }, setup: async page => { await page.click(TEST_CHART_FILTER_TRIGGER); @@ -74,7 +74,7 @@ const suite: TestSuite = { { description: 'selects correct series when navigated back from legend', path: 'area-chart/test', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 800, height: 800 }, setup: async page => { await page.click(TEST_CHART_FILTER_TRIGGER); @@ -90,7 +90,7 @@ const suite: TestSuite = { { description: 'can pin popover for all data points at a given X coordinate with keyboard', path: 'area-chart/test', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 800, height: 800 }, setup: async page => { await page.click(TEST_CHART_FILTER_TRIGGER); @@ -106,7 +106,7 @@ const suite: TestSuite = { { description: 'can pin popover for a point in a specific series with keyboard', path: 'area-chart/test', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 800, height: 800 }, setup: async page => { await page.click(TEST_CHART_FILTER_TRIGGER); @@ -123,7 +123,7 @@ const suite: TestSuite = { { description: 'shows popover on hover', path: 'area-chart/test', - screenshotType: 'screenshotArea', + screenshotType: 'viewport', configuration: { width: 800, height: 800 }, setup: async page => { await page.hoverElement('[aria-label="Linear latency chart"]', 200, 50); From d0a3012bcd2696dd97861eb7c8fb3e362d102c6f Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 5 Jun 2026 06:35:49 +0200 Subject: [PATCH 71/95] Split app layout responsive test definitions --- test/definitions/index.ts | 12 ++++++++-- .../visual/app-layout-responsive-1280.ts | 7 ++++++ .../visual/app-layout-responsive-1400.ts | 7 ++++++ .../visual/app-layout-responsive-1920.ts | 7 ++++++ .../visual/app-layout-responsive-2540.ts | 7 ++++++ .../visual/app-layout-responsive-600.ts | 7 ++++++ ...sive.ts => app-layout-responsive-tests.ts} | 22 +++++-------------- .../visual/app-layout-responsive-1280.test.ts | 6 +++++ .../visual/app-layout-responsive-1400.test.ts | 6 +++++ .../visual/app-layout-responsive-1920.test.ts | 6 +++++ .../visual/app-layout-responsive-2540.test.ts | 6 +++++ ...t.ts => app-layout-responsive-600.test.ts} | 2 +- 12 files changed, 76 insertions(+), 19 deletions(-) create mode 100644 test/definitions/visual/app-layout-responsive-1280.ts create mode 100644 test/definitions/visual/app-layout-responsive-1400.ts create mode 100644 test/definitions/visual/app-layout-responsive-1920.ts create mode 100644 test/definitions/visual/app-layout-responsive-2540.ts create mode 100644 test/definitions/visual/app-layout-responsive-600.ts rename test/definitions/visual/{app-layout-responsive.ts => app-layout-responsive-tests.ts} (92%) create mode 100644 test/visual/app-layout-responsive-1280.test.ts create mode 100644 test/visual/app-layout-responsive-1400.test.ts create mode 100644 test/visual/app-layout-responsive-1920.test.ts create mode 100644 test/visual/app-layout-responsive-2540.test.ts rename test/visual/{app-layout-responsive.test.ts => app-layout-responsive-600.test.ts} (73%) diff --git a/test/definitions/index.ts b/test/definitions/index.ts index 17dc1902a0..3bd6fa66e2 100644 --- a/test/definitions/index.ts +++ b/test/definitions/index.ts @@ -12,7 +12,11 @@ import appLayoutDrawers from './visual/app-layout-drawers'; import appLayoutFlashbar from './visual/app-layout-flashbar'; import appLayoutHeader from './visual/app-layout-header'; import appLayoutMulti from './visual/app-layout-multi'; -import appLayoutResponsive from './visual/app-layout-responsive'; +import appLayoutResponsive600 from './visual/app-layout-responsive-600'; +import appLayoutResponsive1280 from './visual/app-layout-responsive-1280'; +import appLayoutResponsive1400 from './visual/app-layout-responsive-1400'; +import appLayoutResponsive1920 from './visual/app-layout-responsive-1920'; +import appLayoutResponsive2540 from './visual/app-layout-responsive-2540'; import appLayoutStickyTableHeaderSplitPanel from './visual/app-layout-sticky-table-header-split-panel'; import appLayoutToolbar from './visual/app-layout-toolbar'; import appLayoutZIndex from './visual/app-layout-z-index'; @@ -30,7 +34,11 @@ export const allSuites: TestSuite[] = [ appLayoutFlashbar, appLayoutHeader, appLayoutMulti, - appLayoutResponsive, + appLayoutResponsive600, + appLayoutResponsive1280, + appLayoutResponsive1400, + appLayoutResponsive1920, + appLayoutResponsive2540, appLayoutStickyTableHeaderSplitPanel, appLayoutToolbar, appLayoutZIndex, diff --git a/test/definitions/visual/app-layout-responsive-1280.ts b/test/definitions/visual/app-layout-responsive-1280.ts new file mode 100644 index 0000000000..d5fe823f54 --- /dev/null +++ b/test/definitions/visual/app-layout-responsive-1280.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { responsiveTests } from './app-layout-responsive-tests'; + +const suite = responsiveTests(1280); +export default suite; diff --git a/test/definitions/visual/app-layout-responsive-1400.ts b/test/definitions/visual/app-layout-responsive-1400.ts new file mode 100644 index 0000000000..1cb519005c --- /dev/null +++ b/test/definitions/visual/app-layout-responsive-1400.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { responsiveTests } from './app-layout-responsive-tests'; + +const suite = responsiveTests(1400); +export default suite; diff --git a/test/definitions/visual/app-layout-responsive-1920.ts b/test/definitions/visual/app-layout-responsive-1920.ts new file mode 100644 index 0000000000..88b8c6caf3 --- /dev/null +++ b/test/definitions/visual/app-layout-responsive-1920.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { responsiveTests } from './app-layout-responsive-tests'; + +const suite = responsiveTests(1920); +export default suite; diff --git a/test/definitions/visual/app-layout-responsive-2540.ts b/test/definitions/visual/app-layout-responsive-2540.ts new file mode 100644 index 0000000000..9dd62c00db --- /dev/null +++ b/test/definitions/visual/app-layout-responsive-2540.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { responsiveTests } from './app-layout-responsive-tests'; + +const suite = responsiveTests(2540); +export default suite; diff --git a/test/definitions/visual/app-layout-responsive-600.ts b/test/definitions/visual/app-layout-responsive-600.ts new file mode 100644 index 0000000000..f6fd3665cc --- /dev/null +++ b/test/definitions/visual/app-layout-responsive-600.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { responsiveTests } from './app-layout-responsive-tests'; + +const suite = responsiveTests(600); +export default suite; diff --git a/test/definitions/visual/app-layout-responsive.ts b/test/definitions/visual/app-layout-responsive-tests.ts similarity index 92% rename from test/definitions/visual/app-layout-responsive.ts rename to test/definitions/visual/app-layout-responsive-tests.ts index 8a53da6a09..f0ac91d121 100644 --- a/test/definitions/visual/app-layout-responsive.ts +++ b/test/definitions/visual/app-layout-responsive-tests.ts @@ -3,9 +3,13 @@ import { TestSuite } from '../types'; -function responsiveTests(width: number): TestSuite { +/** + * Shared responsive test scenarios for app-layout. Each width gets its own + * definition file and test runner so that Jest sharding can parallelize them. + */ +export function responsiveTests(width: number): TestSuite { return { - description: `width ${width}px`, + description: `AppLayout responsive width ${width}px`, componentName: 'app-layout', tests: [ { @@ -153,17 +157,3 @@ function responsiveTests(width: number): TestSuite { ], }; } - -const suite: TestSuite = { - description: 'AppLayout responsive', - componentName: 'app-layout', - tests: [ - responsiveTests(600), - responsiveTests(1280), - responsiveTests(1400), - responsiveTests(1920), - responsiveTests(2540), - ], -}; - -export default suite; diff --git a/test/visual/app-layout-responsive-1280.test.ts b/test/visual/app-layout-responsive-1280.test.ts new file mode 100644 index 0000000000..0324b2bf39 --- /dev/null +++ b/test/visual/app-layout-responsive-1280.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-responsive-1280'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive-1400.test.ts b/test/visual/app-layout-responsive-1400.test.ts new file mode 100644 index 0000000000..745372c34e --- /dev/null +++ b/test/visual/app-layout-responsive-1400.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-responsive-1400'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive-1920.test.ts b/test/visual/app-layout-responsive-1920.test.ts new file mode 100644 index 0000000000..bee5fc8763 --- /dev/null +++ b/test/visual/app-layout-responsive-1920.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-responsive-1920'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive-2540.test.ts b/test/visual/app-layout-responsive-2540.test.ts new file mode 100644 index 0000000000..48bc8580da --- /dev/null +++ b/test/visual/app-layout-responsive-2540.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-responsive-2540'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive.test.ts b/test/visual/app-layout-responsive-600.test.ts similarity index 73% rename from test/visual/app-layout-responsive.test.ts rename to test/visual/app-layout-responsive-600.test.ts index 668d4b3522..cd9243cb32 100644 --- a/test/visual/app-layout-responsive.test.ts +++ b/test/visual/app-layout-responsive-600.test.ts @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { runTestSuites } from '../definitions/utils'; -import suite from '../definitions/visual/app-layout-responsive'; +import suite from '../definitions/visual/app-layout-responsive-600'; runTestSuites([suite]); From 94c3f2a1a0407a08b5c802abea1a6c6920c8c315 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 5 Jun 2026 06:59:26 +0200 Subject: [PATCH 72/95] Autogenerate test files --- .github/workflows/visual-regression.yml | 3 + .gitignore | 1 + build-tools/visual/generate-tests.js | 71 +++++++++++++++++++ test/visual/action-card.test.ts | 6 -- test/visual/alert.test.ts | 6 -- .../app-layout-content-paddings.test.ts | 6 -- test/visual/app-layout-drawers.test.ts | 6 -- test/visual/app-layout-flashbar.test.ts | 6 -- test/visual/app-layout-header.test.ts | 6 -- test/visual/app-layout-multi.test.ts | 6 -- .../visual/app-layout-responsive-1280.test.ts | 6 -- .../visual/app-layout-responsive-1400.test.ts | 6 -- .../visual/app-layout-responsive-1920.test.ts | 6 -- .../visual/app-layout-responsive-2540.test.ts | 6 -- test/visual/app-layout-responsive-600.test.ts | 6 -- ...ut-sticky-table-header-split-panel.test.ts | 6 -- test/visual/app-layout-toolbar.test.ts | 6 -- test/visual/app-layout-z-index.test.ts | 6 -- test/visual/app-layout.test.ts | 6 -- test/visual/area-chart.test.ts | 6 -- test/visual/attribute-editor.test.ts | 5 -- test/visual/autosuggest.test.ts | 6 -- test/visual/badge.test.ts | 6 -- 23 files changed, 75 insertions(+), 119 deletions(-) create mode 100644 build-tools/visual/generate-tests.js delete mode 100644 test/visual/action-card.test.ts delete mode 100644 test/visual/alert.test.ts delete mode 100644 test/visual/app-layout-content-paddings.test.ts delete mode 100644 test/visual/app-layout-drawers.test.ts delete mode 100644 test/visual/app-layout-flashbar.test.ts delete mode 100644 test/visual/app-layout-header.test.ts delete mode 100644 test/visual/app-layout-multi.test.ts delete mode 100644 test/visual/app-layout-responsive-1280.test.ts delete mode 100644 test/visual/app-layout-responsive-1400.test.ts delete mode 100644 test/visual/app-layout-responsive-1920.test.ts delete mode 100644 test/visual/app-layout-responsive-2540.test.ts delete mode 100644 test/visual/app-layout-responsive-600.test.ts delete mode 100644 test/visual/app-layout-sticky-table-header-split-panel.test.ts delete mode 100644 test/visual/app-layout-toolbar.test.ts delete mode 100644 test/visual/app-layout-z-index.test.ts delete mode 100644 test/visual/app-layout.test.ts delete mode 100644 test/visual/area-chart.test.ts delete mode 100644 test/visual/attribute-editor.test.ts delete mode 100644 test/visual/autosuggest.test.ts delete mode 100644 test/visual/badge.test.ts diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 8e03c9967c..007be5a144 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -124,6 +124,9 @@ jobs: # ── Run tests ───────────────────────────────────────────────────────── + - name: Generate visual test files + run: node build-tools/visual/generate-tests.js + - name: Start test server (port 8080) run: npx --yes serve --no-clipboard --listen 8080 pages/lib/static-default & diff --git a/.gitignore b/.gitignore index db3d62b944..3aa354895f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ coverage lib # generated sources src/index.ts +test/visual src/test-utils/dom/index.ts src/test-utils/selectors src/icon/generated diff --git a/build-tools/visual/generate-tests.js b/build-tools/visual/generate-tests.js new file mode 100644 index 0000000000..a56b996b3d --- /dev/null +++ b/build-tools/visual/generate-tests.js @@ -0,0 +1,71 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Auto-generates test/visual/*.test.ts files from test/definitions/visual/*.ts. + * + * Each definition file that exports a default TestSuite gets a corresponding + * test runner. Helper files (those without a default export, e.g. shared utils + * like app-layout-responsive-tests.ts) are skipped. + * + * Run this script before executing the visual test suite: + * node build-tools/visual/generate-tests.js + */ +const fs = require('fs'); +const path = require('path'); + +const definitionsDir = path.resolve(__dirname, '../../test/definitions/visual'); +const outputDir = path.resolve(__dirname, '../../test/visual'); + +// Files that are shared helpers (export named functions, not a default suite). +// These are detected by checking if the file contains "export default" or +// a conventional "const suite" pattern followed by "export default suite". +const HELPER_SUFFIXES = ['-tests']; + +function isHelperFile(basename) { + return HELPER_SUFFIXES.some(suffix => basename.endsWith(suffix)); +} + +function generate() { + // Ensure output directory exists + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const files = fs.readdirSync(definitionsDir).filter(f => f.endsWith('.ts') && !f.endsWith('.d.ts')); + + const generated = []; + + for (const file of files) { + const basename = file.replace(/\.ts$/, ''); + + if (isHelperFile(basename)) { + continue; + } + + // Verify the file has a default export by scanning for the pattern + const content = fs.readFileSync(path.join(definitionsDir, file), 'utf-8'); + if (!content.includes('export default')) { + continue; + } + + const testContent = [ + '// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.', + '// SPDX-License-Identifier: Apache-2.0', + '// Auto-generated by build-tools/visual/generate-tests.js — do not edit manually.', + `import { runTestSuites } from '../definitions/utils';`, + `import suite from '../definitions/visual/${basename}';`, + '', + 'runTestSuites([suite]);', + '', + ].join('\n'); + + const outputPath = path.join(outputDir, `${basename}.test.ts`); + fs.writeFileSync(outputPath, testContent); + generated.push(basename); + } + + console.log(`Generated ${generated.length} visual test files in test/visual/`); +} + +generate(); diff --git a/test/visual/action-card.test.ts b/test/visual/action-card.test.ts deleted file mode 100644 index 8505dad23b..0000000000 --- a/test/visual/action-card.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import actionCard from '../definitions/visual/action-card'; - -runTestSuites([actionCard]); diff --git a/test/visual/alert.test.ts b/test/visual/alert.test.ts deleted file mode 100644 index 431e4907e4..0000000000 --- a/test/visual/alert.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import alert from '../definitions/visual/alert'; - -runTestSuites([alert]); diff --git a/test/visual/app-layout-content-paddings.test.ts b/test/visual/app-layout-content-paddings.test.ts deleted file mode 100644 index e566d79817..0000000000 --- a/test/visual/app-layout-content-paddings.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import suite from '../definitions/visual/app-layout-content-paddings'; - -runTestSuites([suite]); diff --git a/test/visual/app-layout-drawers.test.ts b/test/visual/app-layout-drawers.test.ts deleted file mode 100644 index c66454010d..0000000000 --- a/test/visual/app-layout-drawers.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import suite from '../definitions/visual/app-layout-drawers'; - -runTestSuites([suite]); diff --git a/test/visual/app-layout-flashbar.test.ts b/test/visual/app-layout-flashbar.test.ts deleted file mode 100644 index 333642e5f3..0000000000 --- a/test/visual/app-layout-flashbar.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import suite from '../definitions/visual/app-layout-flashbar'; - -runTestSuites([suite]); diff --git a/test/visual/app-layout-header.test.ts b/test/visual/app-layout-header.test.ts deleted file mode 100644 index 682f71ffe2..0000000000 --- a/test/visual/app-layout-header.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import suite from '../definitions/visual/app-layout-header'; - -runTestSuites([suite]); diff --git a/test/visual/app-layout-multi.test.ts b/test/visual/app-layout-multi.test.ts deleted file mode 100644 index 244019c8fb..0000000000 --- a/test/visual/app-layout-multi.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import suite from '../definitions/visual/app-layout-multi'; - -runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive-1280.test.ts b/test/visual/app-layout-responsive-1280.test.ts deleted file mode 100644 index 0324b2bf39..0000000000 --- a/test/visual/app-layout-responsive-1280.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import suite from '../definitions/visual/app-layout-responsive-1280'; - -runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive-1400.test.ts b/test/visual/app-layout-responsive-1400.test.ts deleted file mode 100644 index 745372c34e..0000000000 --- a/test/visual/app-layout-responsive-1400.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import suite from '../definitions/visual/app-layout-responsive-1400'; - -runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive-1920.test.ts b/test/visual/app-layout-responsive-1920.test.ts deleted file mode 100644 index bee5fc8763..0000000000 --- a/test/visual/app-layout-responsive-1920.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import suite from '../definitions/visual/app-layout-responsive-1920'; - -runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive-2540.test.ts b/test/visual/app-layout-responsive-2540.test.ts deleted file mode 100644 index 48bc8580da..0000000000 --- a/test/visual/app-layout-responsive-2540.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import suite from '../definitions/visual/app-layout-responsive-2540'; - -runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive-600.test.ts b/test/visual/app-layout-responsive-600.test.ts deleted file mode 100644 index cd9243cb32..0000000000 --- a/test/visual/app-layout-responsive-600.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import suite from '../definitions/visual/app-layout-responsive-600'; - -runTestSuites([suite]); diff --git a/test/visual/app-layout-sticky-table-header-split-panel.test.ts b/test/visual/app-layout-sticky-table-header-split-panel.test.ts deleted file mode 100644 index c1ad3016a1..0000000000 --- a/test/visual/app-layout-sticky-table-header-split-panel.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import suite from '../definitions/visual/app-layout-sticky-table-header-split-panel'; - -runTestSuites([suite]); diff --git a/test/visual/app-layout-toolbar.test.ts b/test/visual/app-layout-toolbar.test.ts deleted file mode 100644 index 398d6386f8..0000000000 --- a/test/visual/app-layout-toolbar.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import suite from '../definitions/visual/app-layout-toolbar'; - -runTestSuites([suite]); diff --git a/test/visual/app-layout-z-index.test.ts b/test/visual/app-layout-z-index.test.ts deleted file mode 100644 index 5f69f77b71..0000000000 --- a/test/visual/app-layout-z-index.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import suite from '../definitions/visual/app-layout-z-index'; - -runTestSuites([suite]); diff --git a/test/visual/app-layout.test.ts b/test/visual/app-layout.test.ts deleted file mode 100644 index 21c3a6ce25..0000000000 --- a/test/visual/app-layout.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import suite from '../definitions/visual/app-layout'; - -runTestSuites([suite]); diff --git a/test/visual/area-chart.test.ts b/test/visual/area-chart.test.ts deleted file mode 100644 index 51d6631016..0000000000 --- a/test/visual/area-chart.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import areaChart from '../definitions/visual/area-chart'; - -runTestSuites([areaChart]); diff --git a/test/visual/attribute-editor.test.ts b/test/visual/attribute-editor.test.ts deleted file mode 100644 index f57e8785b2..0000000000 --- a/test/visual/attribute-editor.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import attributeEditor from '../definitions/visual/attribute-editor'; -runTestSuites([attributeEditor]); diff --git a/test/visual/autosuggest.test.ts b/test/visual/autosuggest.test.ts deleted file mode 100644 index 454c00c648..0000000000 --- a/test/visual/autosuggest.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import autosuggest from '../definitions/visual/autosuggest'; - -runTestSuites([autosuggest]); diff --git a/test/visual/badge.test.ts b/test/visual/badge.test.ts deleted file mode 100644 index 6b24838a42..0000000000 --- a/test/visual/badge.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { runTestSuites } from '../definitions/utils'; -import badge from '../definitions/visual/badge'; - -runTestSuites([badge]); From 234bb8164562c148ab74c600c20c591e882fe64f Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 5 Jun 2026 07:12:36 +0200 Subject: [PATCH 73/95] Generate Allure reports --- .github/workflows/visual-regression.yml | 34 ++ .gitignore | 2 + jest.visual.config.js | 11 +- package-lock.json | 737 ++++++++++++++++++++++++ package.json | 1 + 5 files changed, 784 insertions(+), 1 deletion(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 007be5a144..0b4e20a816 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -148,3 +148,37 @@ jobs: name: visual-regression-diffs-shard-${{ matrix.shard }} path: visual-regression-output/ retention-days: 14 + + - name: Upload Allure results + if: always() + uses: actions/upload-artifact@v4 + with: + name: allure-results-shard-${{ matrix.shard }} + path: allure-results/ + retention-days: 3 + + report: + name: Generate Allure Report + if: always() + needs: [visual] + runs-on: ubuntu-latest + steps: + - name: Download all Allure results + uses: actions/download-artifact@v4 + with: + pattern: allure-results-shard-* + path: allure-results + merge-multiple: true + + - name: Generate Allure HTML report + uses: simple-elf/allure-report-action@v1.12 + with: + allure_results: allure-results + allure_report: allure-report + + - name: Upload Allure report + uses: actions/upload-artifact@v4 + with: + name: allure-report + path: allure-report/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index 3aa354895f..38d6053611 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ lib # generated sources src/index.ts test/visual +allure-results +allure-report src/test-utils/dom/index.ts src/test-utils/selectors src/icon/generated diff --git a/jest.visual.config.js b/jest.visual.config.js index 19418a86dd..5cb7f033fe 100644 --- a/jest.visual.config.js +++ b/jest.visual.config.js @@ -14,7 +14,16 @@ module.exports = { }, ], }, - reporters: ['default', 'github-actions'], + reporters: [ + 'default', + 'github-actions', + [ + 'jest-allure2-reporter', + { + resultsDir: 'allure-results', + }, + ], + ], testTimeout: 240_000, // 4min — pages can be tall and slow to capture maxWorkers: os.cpus().length * (process.env.GITHUB_ACTION ? 3 : 1), globalSetup: '/build-tools/visual/global-setup.js', diff --git a/package-lock.json b/package-lock.json index 555d3fabdc..7a1ac68bdb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -88,6 +88,7 @@ "html-webpack-plugin": "^5.5.0", "husky": "^9.0.0", "jest": "^29.7.0", + "jest-allure2-reporter": "^2.3.0", "jest-environment-jsdom": "^29.7.0", "lint-staged": "^15.2.2", "loader-utils": "^3.2.1", @@ -2083,6 +2084,13 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@flatten-js/interval-tree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@flatten-js/interval-tree/-/interval-tree-1.1.4.tgz", + "integrity": "sha512-o4emRDDvGdkwX18BSVSXH8q27qAL7Z2WDHSN75C8xyRSE4A8UOkig0mWSGoT5M5KaTHZxoLmalFwOTQmbRusUg==", + "dev": true, + "license": "MIT" + }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.3.4", "license": "MIT", @@ -5904,6 +5912,19 @@ "dev": true, "license": "MIT" }, + "node_modules/allure-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/allure-store/-/allure-store-1.2.0.tgz", + "integrity": "sha512-YUKLBrYA/qmZTqBgmC/ZgoTBTcbaFLV6OcQ/DsruWX5YwqVSCJUmMWi5cdGDzw5QL+b9EindJJ6KVHYjIL66Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "properties": "^1.2.1" + }, + "engines": { + "node": ">=16.14.0" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "dev": true, @@ -6856,6 +6877,13 @@ "node": ">=8" } }, + "node_modules/browser-process-hrtime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", + "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -6971,6 +6999,94 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bunyamin": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/bunyamin/-/bunyamin-1.6.3.tgz", + "integrity": "sha512-m1hAijFhu8pFiidsVc0XEDic46uxPK+mKNLqkb5mluNx0nTolNzx/DjwMqHChQWCgfOLMjKYJJ2uPTQLE6t4Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@flatten-js/interval-tree": "^1.1.2", + "multi-sort-stream": "^1.0.4", + "stream-json": "^1.7.5", + "trace-event-lib": "^1.3.1" + }, + "engines": { + "node": ">=14.18.2" + }, + "peerDependencies": { + "@types/bunyan": "^1.8.8", + "bunyan": "^1.8.15 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@types/bunyan": { + "optional": true + }, + "bunyan": { + "optional": true + } + } + }, + "node_modules/bunyan": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-2.0.5.tgz", + "integrity": "sha512-Jvl74TdxCN6rSP9W1I6+UOUtwslTDqsSFkDqZlFb/ilaSvQ+bZAnXT/GT97IZ5L+Vph0joPZPhxUyn6FLNmFAA==", + "dev": true, + "engines": [ + "node >=0.10.0" + ], + "license": "MIT", + "dependencies": { + "exeunt": "1.1.0" + }, + "bin": { + "bunyan": "bin/bunyan" + }, + "optionalDependencies": { + "dtrace-provider": "~0.8", + "moment": "^2.19.3", + "mv": "~2", + "safe-json-stringify": "~1" + } + }, + "node_modules/bunyan-debug-stream": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bunyan-debug-stream/-/bunyan-debug-stream-3.1.1.tgz", + "integrity": "sha512-LfMcz4yKM6s9BP5dfT63Prb5B2hAjReLAfQzLbNQF7qBHtn3P1v+/yn0SZ6UAr4PC3VZRX/QzK7HYkkY0ytokQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=0.12.0" + }, + "peerDependencies": { + "bunyan": "*" + }, + "peerDependenciesMeta": { + "bunyan": { + "optional": true + } + } + }, + "node_modules/bunyan-debug-stream/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/bytes": { "version": "3.1.2", "dev": true, @@ -9042,6 +9158,21 @@ "tslib": "^2.0.3" } }, + "node_modules/dtrace-provider": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.8.tgz", + "integrity": "sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "nan": "^2.14.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "dev": true, @@ -9083,6 +9214,16 @@ "dev": true, "license": "MIT" }, + "node_modules/easy-stack": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/easy-stack/-/easy-stack-1.0.1.tgz", + "integrity": "sha512-wK2sCs4feiiJeFXn3zvY0p41mdU5VUgbgs1rNsc/y5ngFUijdWd+iIN8eoyuZHKB8xN6BL4PdWmzqFmxNg6V2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/edge-paths": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/edge-paths/-/edge-paths-3.0.5.tgz", @@ -9318,6 +9459,16 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, "node_modules/es-abstract": { "version": "1.24.0", "dev": true, @@ -10154,6 +10305,16 @@ "node": ">= 0.6" } }, + "node_modules/event-pubsub": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/event-pubsub/-/event-pubsub-4.3.0.tgz", + "integrity": "sha512-z7IyloorXvKbFx9Bpie2+vMJKKx1fH1EN5yiTfp8CiLOTptSYy1g8H4yDpGlEdshL1PBiFtBHepF2cNsqeEeFQ==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "dev": true, @@ -10211,6 +10372,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/exeunt": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/exeunt/-/exeunt-1.1.0.tgz", + "integrity": "sha512-dd++Yn/0Fp+gtJ04YHov7MeAii+LFivJc6KqnJNfplzLVUkUDrfKoQDTLlCgzcW15vY5hKlHasWeIsQJ8agHsw==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/exit": { "version": "0.1.2", "dev": true, @@ -11188,6 +11359,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/funpermaproxy": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/funpermaproxy/-/funpermaproxy-1.1.0.tgz", + "integrity": "sha512-2Sp1hWuO8m5fqeFDusyhKqYPT+7rGLw34N3qonDcdRP8+n7M7Gl/yKp/q7oCxnnJ6pWCectOmLFJpsMU/++KrQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.3.0" + } + }, "node_modules/geckodriver": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/geckodriver/-/geckodriver-6.1.0.tgz", @@ -12365,6 +12546,19 @@ "node": ">=4" } }, + "node_modules/import-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-from/-/import-from-4.0.0.tgz", + "integrity": "sha512-P9J71vT5nLlDeV8FHs5nNxaLbrpfAV5cF5srvbZfpwpcJoM/xZR3hiv+q+SAnuSmuGbXMWud063iIMx/V/EWZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/import-local": { "version": "3.2.0", "dev": true, @@ -13181,6 +13375,59 @@ } } }, + "node_modules/jest-allure2-reporter": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/jest-allure2-reporter/-/jest-allure2-reporter-2.3.0.tgz", + "integrity": "sha512-snDc5geSUcMbIegjqGuEPyYrZZxlajUH61Z+3EHQsJEF+m50lPonX9KQdqDC+xksCuwpM6mxGuBjQDGOIa2w6w==", + "dev": true, + "license": "MIT", + "workspaces": [ + "e2e", + "package-e2e" + ], + "dependencies": { + "allure-store": "^1.1.0", + "bunyamin": "^1.6.1", + "handlebars": "^4.7.8", + "import-from": "^4.0.0", + "jest-metadata": "^1.6.0", + "lodash": "^4.17.21", + "node-fetch": "^2.6.7", + "pkg-up": "^3.1.0", + "properties": "^1.2.1", + "stacktrace-js": "^2.0.2", + "strip-ansi": "^6.0.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=16.20.0" + }, + "peerDependencies": { + "jest": ">=27.2.5", + "jest-docblock": ">=27.2.5" + }, + "peerDependenciesMeta": { + "jest": { + "optional": true + }, + "jest-docblock": { + "optional": true + } + } + }, + "node_modules/jest-allure2-reporter/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jest-changed-files": { "version": "29.7.0", "dev": true, @@ -13630,6 +13877,63 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/jest-environment-emit": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-emit/-/jest-environment-emit-1.2.0.tgz", + "integrity": "sha512-dSFBrRuIiWbHK2LSUA6CutXpMcNGjjuhvxFLF+TVz5tYFAAH0eesrZgrQ3UtOptajDYNt/fIGRqtlHqGq/bLbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bunyamin": "^1.5.2", + "bunyan": "^2.0.5", + "bunyan-debug-stream": "^3.1.0", + "funpermaproxy": "^1.1.0", + "lodash.merge": "^4.6.2", + "node-ipc": "9.2.1", + "strip-ansi": "^6.0.0", + "tslib": "^2.5.3" + }, + "engines": { + "node": ">=16.14.0" + }, + "peerDependencies": { + "@jest/environment": ">=27.2.5", + "@jest/types": ">=27.2.5", + "jest": ">=27.2.5", + "jest-environment-jsdom": ">=27.2.5", + "jest-environment-node": ">=27.2.5" + }, + "peerDependenciesMeta": { + "@jest/environment": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "jest": { + "optional": true + }, + "jest-environment-jsdom": { + "optional": true + }, + "jest-environment-node": { + "optional": true + } + } + }, + "node_modules/jest-environment-emit/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jest-environment-jsdom": { "version": "29.7.0", "dev": true, @@ -13879,6 +14183,67 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/jest-metadata": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/jest-metadata/-/jest-metadata-1.6.0.tgz", + "integrity": "sha512-penaOkD6tN0Vpqd+9xnbS+iYSLqaZpsx08gz44mOBvyNGBHPglnNKOsBMr3cbIe0bFYGlnouDy4N5SfLtNgVBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bunyamin": "^1.5.2", + "funpermaproxy": "^1.1.0", + "jest-environment-emit": "^1.0.8", + "lodash.merge": "^4.6.2", + "lodash.snakecase": "^4.1.1", + "node-ipc": "9.2.1", + "strip-ansi": "^6.0.0", + "tslib": "^2.5.3" + }, + "engines": { + "node": ">=16.14.0" + }, + "peerDependencies": { + "@jest/environment": ">=27.2.5", + "@jest/reporters": ">=27.2.5", + "@jest/types": ">=27.2.5", + "jest": ">=27.2.5", + "jest-environment-jsdom": ">=27.2.5", + "jest-environment-node": ">=27.2.5" + }, + "peerDependenciesMeta": { + "@jest/environment": { + "optional": true + }, + "@jest/reporters": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "jest": { + "optional": true + }, + "jest-environment-jsdom": { + "optional": true + }, + "jest-environment-node": { + "optional": true + } + } + }, + "node_modules/jest-metadata/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jest-mock": { "version": "29.7.0", "dev": true, @@ -14387,6 +14752,29 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/js-message": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz", + "integrity": "sha512-efJLHhLjIyKRewNS9EGZ4UpI8NguuL6fKkhRxVuMmrGV2xN/0APGdQYwLFky5w9naebSZ0OwAGp0G6/2Cg90rA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/js-queue": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/js-queue/-/js-queue-2.0.2.tgz", + "integrity": "sha512-pbKLsbCfi7kriM3s1J4DDCo7jQkI58zPLHi0heXPzPlj0hjUsm+FesPUbE0DSbIVIK503A36aUBoCN7eMFedkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "easy-stack": "^1.0.1" + }, + "engines": { + "node": ">=1.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "license": "MIT" @@ -15058,6 +15446,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.truncate": { "version": "4.4.2", "dev": true, @@ -15539,6 +15934,20 @@ "dev": true, "license": "MIT" }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/mnth": { "version": "2.0.0", "license": "MIT", @@ -15577,6 +15986,13 @@ "dev": true, "license": "MIT" }, + "node_modules/multi-sort-stream": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/multi-sort-stream/-/multi-sort-stream-1.0.4.tgz", + "integrity": "sha512-hAZ8JOEQFbgdLe8HWZbb7gdZg0/yAIHF00Qfo3kd0rXFv96nXe+/bPTrKHZ2QMHugGX4FiAyET1Lt+jiB+7Qlg==", + "dev": true, + "license": "bsd" + }, "node_modules/multicast-dns": { "version": "7.2.5", "dev": true, @@ -15630,6 +16046,78 @@ "node": ">= 10.13.0" } }, + "node_modules/mv": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", + "integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "~0.5.1", + "ncp": "~2.0.0", + "rimraf": "~2.4.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/mv/node_modules/glob": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", + "integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mv/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mv/node_modules/rimraf": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", + "integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^6.0.1" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/nan": { + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", + "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", @@ -15662,6 +16150,17 @@ "dev": true, "license": "MIT" }, + "node_modules/ncp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", + "integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "ncp": "bin/ncp" + } + }, "node_modules/negotiator": { "version": "0.6.4", "dev": true, @@ -15705,11 +16204,72 @@ "license": "MIT", "optional": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "dev": true, "license": "MIT" }, + "node_modules/node-ipc": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/node-ipc/-/node-ipc-9.2.1.tgz", + "integrity": "sha512-mJzaM6O3xHf9VT8BULvJSbdVbmHUKRNOH7zDDkCrA1/T+CVjq2WVIDfLt0azZRXpgArJtl3rtmEozrbXPZ9GaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-pubsub": "4.3.0", + "js-message": "1.0.7", + "js-queue": "2.0.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -16684,6 +17244,85 @@ "node": ">=8" } }, + "node_modules/pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-up/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-up/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/pkijs": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", @@ -17585,6 +18224,16 @@ "version": "16.13.1", "license": "MIT" }, + "node_modules/properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/properties/-/properties-1.2.1.tgz", + "integrity": "sha512-qYNxyMj1JeW54i/EWEFsM1cVwxJbtgPp8+0Wg9XjNaK6VE/c4oRi6PNu5p7w1mNXEIQIjV5Wwn8v8Gz82/QzdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "dev": true, @@ -18762,6 +19411,14 @@ ], "license": "MIT" }, + "node_modules/safe-json-stringify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz", + "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/safe-push-apply": { "version": "1.0.0", "dev": true, @@ -19669,6 +20326,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stack-generator": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.10.tgz", + "integrity": "sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "dev": true, @@ -19688,6 +20355,46 @@ "node": ">=8" } }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stacktrace-gps": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.1.2.tgz", + "integrity": "sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map": "0.5.6", + "stackframe": "^1.3.4" + } + }, + "node_modules/stacktrace-gps/node_modules/source-map": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", + "integrity": "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stacktrace-js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz", + "integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-stack-parser": "^2.0.6", + "stack-generator": "^2.0.5", + "stacktrace-gps": "^3.0.4" + } + }, "node_modules/statuses": { "version": "2.0.1", "dev": true, @@ -19708,6 +20415,13 @@ "node": ">= 0.4" } }, + "node_modules/stream-chain": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", + "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/stream-composer": { "version": "1.0.2", "dev": true, @@ -19730,6 +20444,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stream-json": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", + "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "stream-chain": "^2.2.5" + } + }, "node_modules/stream-shift": { "version": "1.0.3", "dev": true, @@ -20905,6 +21629,19 @@ "node": ">=12" } }, + "node_modules/trace-event-lib": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/trace-event-lib/-/trace-event-lib-1.4.1.tgz", + "integrity": "sha512-TOgFolKG8JFY+9d5EohGWMvwvteRafcyfPWWNIqcuD1W/FUvxWcy2MSCZ/beYHM63oYPHYHCd3tkbgCctHVP7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "browser-process-hrtime": "^1.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tree-dump": { "version": "1.0.3", "dev": true, diff --git a/package.json b/package.json index 8110f028b0..faaf828b39 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "html-webpack-plugin": "^5.5.0", "husky": "^9.0.0", "jest": "^29.7.0", + "jest-allure2-reporter": "^2.3.0", "jest-environment-jsdom": "^29.7.0", "lint-staged": "^15.2.2", "loader-utils": "^3.2.1", From 3537daa3c56c0ebc4e319c6cb1136bd6236e5d89 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 5 Jun 2026 19:30:35 +0200 Subject: [PATCH 74/95] Increase to 10 shards --- .github/workflows/visual-regression.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 0b4e20a816..4942a136f9 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -82,7 +82,7 @@ jobs: strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4, 5, 6, 7, 8] + shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] steps: - uses: actions/checkout@v4 @@ -137,7 +137,7 @@ jobs: run: node_modules/.bin/wait-on http://localhost:8080 http://localhost:8081 - name: Run visual regression tests - run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js --shard=${{ matrix.shard }}/8 + run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js --shard=${{ matrix.shard }}/10 env: TZ: UTC From 3ca00aa0843fee00d4ba04abeb034e86798f4b3c Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 5 Jun 2026 19:33:04 +0200 Subject: [PATCH 75/95] Download Allure directly --- .github/workflows/visual-regression.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 4942a136f9..6c37dafdb5 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -170,11 +170,13 @@ jobs: path: allure-results merge-multiple: true + - name: Install Allure CLI + run: | + curl -sL https://github.com/allure-framework/allure2/releases/download/2.32.0/allure-2.32.0.tgz | tar -xz + echo "$PWD/allure-2.32.0/bin" >> $GITHUB_PATH + - name: Generate Allure HTML report - uses: simple-elf/allure-report-action@v1.12 - with: - allure_results: allure-results - allure_report: allure-report + run: allure generate allure-results --clean -o allure-report - name: Upload Allure report uses: actions/upload-artifact@v4 From 9342f83c9406044267843315119543cf4ece86fa Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 5 Jun 2026 23:22:46 +0200 Subject: [PATCH 76/95] Deploy Allure results --- .github/workflows/visual-regression.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 6c37dafdb5..ded9ff9344 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -24,6 +24,7 @@ permissions: id-token: write contents: read actions: read + deployments: write jobs: # Build the baseline (main branch) pages once and share them across all browser jobs. @@ -178,9 +179,19 @@ jobs: - name: Generate Allure HTML report run: allure generate allure-results --clean -o allure-report - - name: Upload Allure report + - name: Upload Allure report artifact uses: actions/upload-artifact@v4 with: name: allure-report path: allure-report/ retention-days: 14 + + deploy-report: + name: Deploy Allure Report + if: always() + needs: [report] + uses: cloudscape-design/actions/.github/workflows/deploy.yml@main + secrets: inherit + with: + artifact-name: allure-report + deployment-path: allure-report From e69aede82f0d33d1a203d358efee9c3a0226251c Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 5 Jun 2026 23:22:59 +0200 Subject: [PATCH 77/95] Add image diffs to Allure reports --- test/definitions/utils.ts | 71 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/test/definitions/utils.ts b/test/definitions/utils.ts index 683e50cc80..1b4dd0fee2 100644 --- a/test/definitions/utils.ts +++ b/test/definitions/utils.ts @@ -1,5 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; + import { cropAndCompare } from '@cloudscape-design/browser-test-tools/image-utils'; import { ScreenshotPageObject, ScreenshotWithOffset } from '@cloudscape-design/browser-test-tools/page-objects'; @@ -12,6 +16,8 @@ const defaultWindowSize = { width: 1600, height: 800 }; const newHost = process.env.NEW_HOST || 'http://localhost:8080'; const oldHost = process.env.OLD_HOST || 'http://localhost:8081'; +const allureResultsDir = path.resolve(process.cwd(), 'allure-results'); + function buildUrl(host: string, path: string, queryParams?: Record): string { const params = new URLSearchParams(queryParams); const qs = params.toString(); @@ -22,6 +28,55 @@ function isTestDefinition(item: TestDefinition | TestSuite): item is TestDefinit return (item as TestDefinition).path !== undefined; } +/** + * Writes a PNG buffer to the allure-results directory and returns the filename. + * Allure picks up files in this directory and attaches them to the report. + */ +function writeAllureAttachment(buffer: Buffer): string { + if (!fs.existsSync(allureResultsDir)) { + fs.mkdirSync(allureResultsDir, { recursive: true }); + } + const uuid = crypto.randomUUID(); + const filename = `${uuid}-attachment.png`; + fs.writeFileSync(path.join(allureResultsDir, filename), buffer); + return filename; +} + +/** + * Attaches visual diff images (new, baseline, diff) to the Allure results + * for the current test by writing an attachment JSON file. + */ +function attachDiffImages( + result: { firstImage: Buffer; secondImage: Buffer; diffImage: Buffer | null }, + testName: string +): void { + const newFile = writeAllureAttachment(result.firstImage); + const baselineFile = writeAllureAttachment(result.secondImage); + + const attachments: Array<{ name: string; source: string; type: string }> = [ + { name: `${testName} — new (PR)`, source: newFile, type: 'image/png' }, + { name: `${testName} — baseline (main)`, source: baselineFile, type: 'image/png' }, + ]; + + if (result.diffImage) { + const diffFile = writeAllureAttachment(result.diffImage); + attachments.push({ name: `${testName} — diff`, source: diffFile, type: 'image/png' }); + } + + // Write a container JSON that Allure merges into the test result. + // jest-allure2-reporter reads attachment files from allure-results/. + const containerUuid = crypto.randomUUID(); + const containerFile = path.join(allureResultsDir, `${containerUuid}-container.json`); + fs.writeFileSync( + containerFile, + JSON.stringify({ + uuid: containerUuid, + name: testName, + attachments, + }) + ); +} + /** * Registers all test suites with a single shared browser session per worker. * This avoids the per-test session creation overhead. @@ -91,9 +146,9 @@ function registerTest(testDef: TestDefinition, getBrowser: () => WebdriverIO.Bro const newScreenshot = await capture(browser, page, newUrl, testDef, windowSize); const oldScreenshot = await capture(browser, page, oldUrl, testDef, windowSize); - const { diffPixels } = await cropAndCompare(newScreenshot, oldScreenshot); + const result = await cropAndCompare(newScreenshot, oldScreenshot); - if (diffPixels === 0) { + if (result.diffPixels === 0) { return; } @@ -124,13 +179,19 @@ function registerTest(testDef: TestDefinition, getBrowser: () => WebdriverIO.Bro expect(newPermutations.length).toBe(oldPermutations.length); for (let i = 0; i < newPermutations.length; i++) { - const { diffPixels: permDiff } = await cropAndCompare(newPermutations[i], oldPermutations[i]); - expect(permDiff).toBe(0); + const permResult = await cropAndCompare(newPermutations[i], oldPermutations[i]); + if (permResult.diffPixels !== 0) { + attachDiffImages(permResult, `${testDef.description} [permutation ${i}]`); + } + expect(permResult.diffPixels).toBe(0); } return; } + // Attach diff images to Allure report for visual inspection. + attachDiffImages(result, testDef.description); + // For screenshotArea and viewport types, the diff is a real failure. - expect(diffPixels).toBe(0); + expect(result.diffPixels).toBe(0); }); } From 42cc955b4772f8870c0c6cd31045f1fb0a08e432 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 5 Jun 2026 23:25:33 +0200 Subject: [PATCH 78/95] Use Allure 3 --- .github/workflows/visual-regression.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index ded9ff9344..977c6cd96d 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -164,6 +164,11 @@ jobs: needs: [visual] runs-on: ubuntu-latest steps: + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Download all Allure results uses: actions/download-artifact@v4 with: @@ -171,13 +176,8 @@ jobs: path: allure-results merge-multiple: true - - name: Install Allure CLI - run: | - curl -sL https://github.com/allure-framework/allure2/releases/download/2.32.0/allure-2.32.0.tgz | tar -xz - echo "$PWD/allure-2.32.0/bin" >> $GITHUB_PATH - - name: Generate Allure HTML report - run: allure generate allure-results --clean -o allure-report + run: npx --yes allure generate allure-results -o allure-report - name: Upload Allure report artifact uses: actions/upload-artifact@v4 From e8257320701c5231fe3bbf86c5f18ed40d84d8f6 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Sat, 6 Jun 2026 08:28:36 +0200 Subject: [PATCH 79/95] Fix image attachments --- jest.visual.config.js | 16 +- package-lock.json | 829 +++++--------------------------------- package.json | 2 +- test/definitions/utils.ts | 58 +-- 4 files changed, 108 insertions(+), 797 deletions(-) diff --git a/jest.visual.config.js b/jest.visual.config.js index 5cb7f033fe..98938768b3 100644 --- a/jest.visual.config.js +++ b/jest.visual.config.js @@ -5,7 +5,10 @@ const os = require('os'); module.exports = { verbose: true, - testEnvironment: 'node', + testEnvironment: 'allure-jest/node', + testEnvironmentOptions: { + resultsDir: 'allure-results', + }, transform: { '^.+\\.tsx?$': [ 'ts-jest', @@ -14,16 +17,7 @@ module.exports = { }, ], }, - reporters: [ - 'default', - 'github-actions', - [ - 'jest-allure2-reporter', - { - resultsDir: 'allure-results', - }, - ], - ], + reporters: ['default', 'github-actions'], testTimeout: 240_000, // 4min — pages can be tall and slow to capture maxWorkers: os.cpus().length * (process.env.GITHUB_ACTION ? 3 : 1), globalSetup: '/build-tools/visual/global-setup.js', diff --git a/package-lock.json b/package-lock.json index 7a1ac68bdb..22ac706458 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "@types/react-test-renderer": "^16.9.12", "@types/react-transition-group": "^4.4.4", "@types/webpack-env": "^1.16.3", + "allure-jest": "^3.9.0", "axe-core": "^4.7.2", "babel-jest": "^29.7.0", "change-case": "^4.1.2", @@ -88,7 +89,6 @@ "html-webpack-plugin": "^5.5.0", "husky": "^9.0.0", "jest": "^29.7.0", - "jest-allure2-reporter": "^2.3.0", "jest-environment-jsdom": "^29.7.0", "lint-staged": "^15.2.2", "loader-utils": "^3.2.1", @@ -2084,13 +2084,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@flatten-js/interval-tree": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@flatten-js/interval-tree/-/interval-tree-1.1.4.tgz", - "integrity": "sha512-o4emRDDvGdkwX18BSVSXH8q27qAL7Z2WDHSN75C8xyRSE4A8UOkig0mWSGoT5M5KaTHZxoLmalFwOTQmbRusUg==", - "dev": true, - "license": "MIT" - }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.3.4", "license": "MIT", @@ -5912,17 +5905,56 @@ "dev": true, "license": "MIT" }, - "node_modules/allure-store": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/allure-store/-/allure-store-1.2.0.tgz", - "integrity": "sha512-YUKLBrYA/qmZTqBgmC/ZgoTBTcbaFLV6OcQ/DsruWX5YwqVSCJUmMWi5cdGDzw5QL+b9EindJJ6KVHYjIL66Ww==", + "node_modules/allure-jest": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/allure-jest/-/allure-jest-3.9.0.tgz", + "integrity": "sha512-hEW4DKjvb3engGoHUPQaDEdyrFkUxQnqULiSQAehL1eDEggqdPbQro86Nch8Cj1yuIqUTn9UP1FMuuuwl/5jnQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "properties": "^1.2.1" + "allure-js-commons": "3.9.0" }, - "engines": { - "node": ">=16.14.0" + "peerDependencies": { + "jest": ">=24.8.0", + "jest-circus": ">=24.8.0", + "jest-cli": ">=24.8.0", + "jest-environment-jsdom": ">=24.8.0", + "jest-environment-node": ">=24.8.0" + }, + "peerDependenciesMeta": { + "jest": { + "optional": true + }, + "jest-circus": { + "optional": true + }, + "jest-cli": { + "optional": true + }, + "jest-environment-jsdom": { + "optional": true + }, + "jest-environment-node": { + "optional": true + } + } + }, + "node_modules/allure-js-commons": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/allure-js-commons/-/allure-js-commons-3.9.0.tgz", + "integrity": "sha512-uVQcGE6MWIvGR/zW1XEUwHXUQa1EJKY0Cah+0TZK1qKuw6ptyhftDr34XE3wExTyCZirRrI98dbRtPeYYuyI+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "md5": "^2.3.0" + }, + "peerDependencies": { + "allure-playwright": "3.9.0" + }, + "peerDependenciesMeta": { + "allure-playwright": { + "optional": true + } } }, "node_modules/ansi-escapes": { @@ -6877,13 +6909,6 @@ "node": ">=8" } }, - "node_modules/browser-process-hrtime": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", - "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -6999,94 +7024,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bunyamin": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/bunyamin/-/bunyamin-1.6.3.tgz", - "integrity": "sha512-m1hAijFhu8pFiidsVc0XEDic46uxPK+mKNLqkb5mluNx0nTolNzx/DjwMqHChQWCgfOLMjKYJJ2uPTQLE6t4Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@flatten-js/interval-tree": "^1.1.2", - "multi-sort-stream": "^1.0.4", - "stream-json": "^1.7.5", - "trace-event-lib": "^1.3.1" - }, - "engines": { - "node": ">=14.18.2" - }, - "peerDependencies": { - "@types/bunyan": "^1.8.8", - "bunyan": "^1.8.15 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@types/bunyan": { - "optional": true - }, - "bunyan": { - "optional": true - } - } - }, - "node_modules/bunyan": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-2.0.5.tgz", - "integrity": "sha512-Jvl74TdxCN6rSP9W1I6+UOUtwslTDqsSFkDqZlFb/ilaSvQ+bZAnXT/GT97IZ5L+Vph0joPZPhxUyn6FLNmFAA==", - "dev": true, - "engines": [ - "node >=0.10.0" - ], - "license": "MIT", - "dependencies": { - "exeunt": "1.1.0" - }, - "bin": { - "bunyan": "bin/bunyan" - }, - "optionalDependencies": { - "dtrace-provider": "~0.8", - "moment": "^2.19.3", - "mv": "~2", - "safe-json-stringify": "~1" - } - }, - "node_modules/bunyan-debug-stream": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/bunyan-debug-stream/-/bunyan-debug-stream-3.1.1.tgz", - "integrity": "sha512-LfMcz4yKM6s9BP5dfT63Prb5B2hAjReLAfQzLbNQF7qBHtn3P1v+/yn0SZ6UAr4PC3VZRX/QzK7HYkkY0ytokQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2" - }, - "engines": { - "node": ">=0.12.0" - }, - "peerDependencies": { - "bunyan": "*" - }, - "peerDependenciesMeta": { - "bunyan": { - "optional": true - } - } - }, - "node_modules/bunyan-debug-stream/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/bytes": { "version": "3.1.2", "dev": true, @@ -7282,6 +7219,16 @@ "node": ">=10" } }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/cheerio": { "version": "1.1.0", "dev": true, @@ -7990,6 +7937,16 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/css-declaration-sorter": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.3.1.tgz", @@ -9158,21 +9115,6 @@ "tslib": "^2.0.3" } }, - "node_modules/dtrace-provider": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.8.tgz", - "integrity": "sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==", - "dev": true, - "hasInstallScript": true, - "license": "BSD-2-Clause", - "optional": true, - "dependencies": { - "nan": "^2.14.0" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "dev": true, @@ -9214,16 +9156,6 @@ "dev": true, "license": "MIT" }, - "node_modules/easy-stack": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/easy-stack/-/easy-stack-1.0.1.tgz", - "integrity": "sha512-wK2sCs4feiiJeFXn3zvY0p41mdU5VUgbgs1rNsc/y5ngFUijdWd+iIN8eoyuZHKB8xN6BL4PdWmzqFmxNg6V2w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/edge-paths": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/edge-paths/-/edge-paths-3.0.5.tgz", @@ -9459,16 +9391,6 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/error-stack-parser": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", - "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "stackframe": "^1.3.4" - } - }, "node_modules/es-abstract": { "version": "1.24.0", "dev": true, @@ -10305,16 +10227,6 @@ "node": ">= 0.6" } }, - "node_modules/event-pubsub": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/event-pubsub/-/event-pubsub-4.3.0.tgz", - "integrity": "sha512-z7IyloorXvKbFx9Bpie2+vMJKKx1fH1EN5yiTfp8CiLOTptSYy1g8H4yDpGlEdshL1PBiFtBHepF2cNsqeEeFQ==", - "dev": true, - "license": "Unlicense", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/event-target-shim": { "version": "5.0.1", "dev": true, @@ -10372,16 +10284,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/exeunt": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/exeunt/-/exeunt-1.1.0.tgz", - "integrity": "sha512-dd++Yn/0Fp+gtJ04YHov7MeAii+LFivJc6KqnJNfplzLVUkUDrfKoQDTLlCgzcW15vY5hKlHasWeIsQJ8agHsw==", - "dev": true, - "license": "MPL-2.0", - "engines": { - "node": ">=0.10" - } - }, "node_modules/exit": { "version": "0.1.2", "dev": true, @@ -11359,16 +11261,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/funpermaproxy": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/funpermaproxy/-/funpermaproxy-1.1.0.tgz", - "integrity": "sha512-2Sp1hWuO8m5fqeFDusyhKqYPT+7rGLw34N3qonDcdRP8+n7M7Gl/yKp/q7oCxnnJ6pWCectOmLFJpsMU/++KrQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8.3.0" - } - }, "node_modules/geckodriver": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/geckodriver/-/geckodriver-6.1.0.tgz", @@ -12546,19 +12438,6 @@ "node": ">=4" } }, - "node_modules/import-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/import-from/-/import-from-4.0.0.tgz", - "integrity": "sha512-P9J71vT5nLlDeV8FHs5nNxaLbrpfAV5cF5srvbZfpwpcJoM/xZR3hiv+q+SAnuSmuGbXMWud063iIMx/V/EWZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/import-local": { "version": "3.2.0", "dev": true, @@ -12786,6 +12665,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, "node_modules/is-builtin-module": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-5.0.0.tgz", @@ -13375,59 +13261,6 @@ } } }, - "node_modules/jest-allure2-reporter": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/jest-allure2-reporter/-/jest-allure2-reporter-2.3.0.tgz", - "integrity": "sha512-snDc5geSUcMbIegjqGuEPyYrZZxlajUH61Z+3EHQsJEF+m50lPonX9KQdqDC+xksCuwpM6mxGuBjQDGOIa2w6w==", - "dev": true, - "license": "MIT", - "workspaces": [ - "e2e", - "package-e2e" - ], - "dependencies": { - "allure-store": "^1.1.0", - "bunyamin": "^1.6.1", - "handlebars": "^4.7.8", - "import-from": "^4.0.0", - "jest-metadata": "^1.6.0", - "lodash": "^4.17.21", - "node-fetch": "^2.6.7", - "pkg-up": "^3.1.0", - "properties": "^1.2.1", - "stacktrace-js": "^2.0.2", - "strip-ansi": "^6.0.0", - "uuid": "^8.3.2" - }, - "engines": { - "node": ">=16.20.0" - }, - "peerDependencies": { - "jest": ">=27.2.5", - "jest-docblock": ">=27.2.5" - }, - "peerDependenciesMeta": { - "jest": { - "optional": true - }, - "jest-docblock": { - "optional": true - } - } - }, - "node_modules/jest-allure2-reporter/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-changed-files": { "version": "29.7.0", "dev": true, @@ -13877,63 +13710,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-environment-emit": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/jest-environment-emit/-/jest-environment-emit-1.2.0.tgz", - "integrity": "sha512-dSFBrRuIiWbHK2LSUA6CutXpMcNGjjuhvxFLF+TVz5tYFAAH0eesrZgrQ3UtOptajDYNt/fIGRqtlHqGq/bLbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bunyamin": "^1.5.2", - "bunyan": "^2.0.5", - "bunyan-debug-stream": "^3.1.0", - "funpermaproxy": "^1.1.0", - "lodash.merge": "^4.6.2", - "node-ipc": "9.2.1", - "strip-ansi": "^6.0.0", - "tslib": "^2.5.3" - }, - "engines": { - "node": ">=16.14.0" - }, - "peerDependencies": { - "@jest/environment": ">=27.2.5", - "@jest/types": ">=27.2.5", - "jest": ">=27.2.5", - "jest-environment-jsdom": ">=27.2.5", - "jest-environment-node": ">=27.2.5" - }, - "peerDependenciesMeta": { - "@jest/environment": { - "optional": true - }, - "@jest/types": { - "optional": true - }, - "jest": { - "optional": true - }, - "jest-environment-jsdom": { - "optional": true - }, - "jest-environment-node": { - "optional": true - } - } - }, - "node_modules/jest-environment-emit/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-environment-jsdom": { "version": "29.7.0", "dev": true, @@ -14183,75 +13959,14 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-metadata": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/jest-metadata/-/jest-metadata-1.6.0.tgz", - "integrity": "sha512-penaOkD6tN0Vpqd+9xnbS+iYSLqaZpsx08gz44mOBvyNGBHPglnNKOsBMr3cbIe0bFYGlnouDy4N5SfLtNgVBQ==", + "node_modules/jest-mock": { + "version": "29.7.0", "dev": true, "license": "MIT", "dependencies": { - "bunyamin": "^1.5.2", - "funpermaproxy": "^1.1.0", - "jest-environment-emit": "^1.0.8", - "lodash.merge": "^4.6.2", - "lodash.snakecase": "^4.1.1", - "node-ipc": "9.2.1", - "strip-ansi": "^6.0.0", - "tslib": "^2.5.3" - }, - "engines": { - "node": ">=16.14.0" - }, - "peerDependencies": { - "@jest/environment": ">=27.2.5", - "@jest/reporters": ">=27.2.5", - "@jest/types": ">=27.2.5", - "jest": ">=27.2.5", - "jest-environment-jsdom": ">=27.2.5", - "jest-environment-node": ">=27.2.5" - }, - "peerDependenciesMeta": { - "@jest/environment": { - "optional": true - }, - "@jest/reporters": { - "optional": true - }, - "@jest/types": { - "optional": true - }, - "jest": { - "optional": true - }, - "jest-environment-jsdom": { - "optional": true - }, - "jest-environment-node": { - "optional": true - } - } - }, - "node_modules/jest-metadata/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-mock": { - "version": "29.7.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -14752,29 +14467,6 @@ "@sideway/pinpoint": "^2.0.0" } }, - "node_modules/js-message": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz", - "integrity": "sha512-efJLHhLjIyKRewNS9EGZ4UpI8NguuL6fKkhRxVuMmrGV2xN/0APGdQYwLFky5w9naebSZ0OwAGp0G6/2Cg90rA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/js-queue": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/js-queue/-/js-queue-2.0.2.tgz", - "integrity": "sha512-pbKLsbCfi7kriM3s1J4DDCo7jQkI58zPLHi0heXPzPlj0hjUsm+FesPUbE0DSbIVIK503A36aUBoCN7eMFedkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "easy-stack": "^1.0.1" - }, - "engines": { - "node": ">=1.0.0" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "license": "MIT" @@ -15446,13 +15138,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.truncate": { "version": "4.4.2", "dev": true, @@ -15666,6 +15351,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/mdn-data": { "version": "2.12.2", "dev": true, @@ -15934,20 +15631,6 @@ "dev": true, "license": "MIT" }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/mnth": { "version": "2.0.0", "license": "MIT", @@ -15986,13 +15669,6 @@ "dev": true, "license": "MIT" }, - "node_modules/multi-sort-stream": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/multi-sort-stream/-/multi-sort-stream-1.0.4.tgz", - "integrity": "sha512-hAZ8JOEQFbgdLe8HWZbb7gdZg0/yAIHF00Qfo3kd0rXFv96nXe+/bPTrKHZ2QMHugGX4FiAyET1Lt+jiB+7Qlg==", - "dev": true, - "license": "bsd" - }, "node_modules/multicast-dns": { "version": "7.2.5", "dev": true, @@ -16046,78 +15722,6 @@ "node": ">= 10.13.0" } }, - "node_modules/mv": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", - "integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "mkdirp": "~0.5.1", - "ncp": "~2.0.0", - "rimraf": "~2.4.0" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/mv/node_modules/glob": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", - "integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "2 || 3", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - } - }, - "node_modules/mv/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/mv/node_modules/rimraf": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", - "integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "glob": "^6.0.1" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/nan": { - "version": "2.27.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", - "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/nanoid": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", @@ -16150,17 +15754,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ncp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", - "integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==", - "dev": true, - "license": "MIT", - "optional": true, - "bin": { - "ncp": "bin/ncp" - } - }, "node_modules/negotiator": { "version": "0.6.4", "dev": true, @@ -16204,72 +15797,11 @@ "license": "MIT", "optional": true }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/node-int64": { "version": "0.4.0", "dev": true, "license": "MIT" }, - "node_modules/node-ipc": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/node-ipc/-/node-ipc-9.2.1.tgz", - "integrity": "sha512-mJzaM6O3xHf9VT8BULvJSbdVbmHUKRNOH7zDDkCrA1/T+CVjq2WVIDfLt0azZRXpgArJtl3rtmEozrbXPZ9GaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "event-pubsub": "4.3.0", - "js-message": "1.0.7", - "js-queue": "2.0.2" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -17244,85 +16776,6 @@ "node": ">=8" } }, - "node_modules/pkg-up": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-up/node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-up/node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/pkijs": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", @@ -18224,16 +17677,6 @@ "version": "16.13.1", "license": "MIT" }, - "node_modules/properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/properties/-/properties-1.2.1.tgz", - "integrity": "sha512-qYNxyMj1JeW54i/EWEFsM1cVwxJbtgPp8+0Wg9XjNaK6VE/c4oRi6PNu5p7w1mNXEIQIjV5Wwn8v8Gz82/QzdQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "dev": true, @@ -19411,14 +18854,6 @@ ], "license": "MIT" }, - "node_modules/safe-json-stringify": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz", - "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/safe-push-apply": { "version": "1.0.0", "dev": true, @@ -20326,16 +19761,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stack-generator": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.10.tgz", - "integrity": "sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "stackframe": "^1.3.4" - } - }, "node_modules/stack-utils": { "version": "2.0.6", "dev": true, @@ -20355,46 +19780,6 @@ "node": ">=8" } }, - "node_modules/stackframe": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", - "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", - "dev": true, - "license": "MIT" - }, - "node_modules/stacktrace-gps": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.1.2.tgz", - "integrity": "sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "source-map": "0.5.6", - "stackframe": "^1.3.4" - } - }, - "node_modules/stacktrace-gps/node_modules/source-map": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", - "integrity": "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stacktrace-js": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz", - "integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "error-stack-parser": "^2.0.6", - "stack-generator": "^2.0.5", - "stacktrace-gps": "^3.0.4" - } - }, "node_modules/statuses": { "version": "2.0.1", "dev": true, @@ -20415,13 +19800,6 @@ "node": ">= 0.4" } }, - "node_modules/stream-chain": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", - "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/stream-composer": { "version": "1.0.2", "dev": true, @@ -20444,16 +19822,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stream-json": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", - "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "stream-chain": "^2.2.5" - } - }, "node_modules/stream-shift": { "version": "1.0.3", "dev": true, @@ -21629,19 +20997,6 @@ "node": ">=12" } }, - "node_modules/trace-event-lib": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/trace-event-lib/-/trace-event-lib-1.4.1.tgz", - "integrity": "sha512-TOgFolKG8JFY+9d5EohGWMvwvteRafcyfPWWNIqcuD1W/FUvxWcy2MSCZ/beYHM63oYPHYHCd3tkbgCctHVP7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "browser-process-hrtime": "^1.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/tree-dump": { "version": "1.0.3", "dev": true, diff --git a/package.json b/package.json index faaf828b39..b672732db3 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@types/react-test-renderer": "^16.9.12", "@types/react-transition-group": "^4.4.4", "@types/webpack-env": "^1.16.3", + "allure-jest": "^3.9.0", "axe-core": "^4.7.2", "babel-jest": "^29.7.0", "change-case": "^4.1.2", @@ -111,7 +112,6 @@ "html-webpack-plugin": "^5.5.0", "husky": "^9.0.0", "jest": "^29.7.0", - "jest-allure2-reporter": "^2.3.0", "jest-environment-jsdom": "^29.7.0", "lint-staged": "^15.2.2", "loader-utils": "^3.2.1", diff --git a/test/definitions/utils.ts b/test/definitions/utils.ts index 1b4dd0fee2..67e38799c7 100644 --- a/test/definitions/utils.ts +++ b/test/definitions/utils.ts @@ -1,8 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import * as crypto from 'crypto'; -import * as fs from 'fs'; -import * as path from 'path'; +import { attachment, ContentType } from 'allure-js-commons'; import { cropAndCompare } from '@cloudscape-design/browser-test-tools/image-utils'; import { ScreenshotPageObject, ScreenshotWithOffset } from '@cloudscape-design/browser-test-tools/page-objects'; @@ -16,8 +14,6 @@ const defaultWindowSize = { width: 1600, height: 800 }; const newHost = process.env.NEW_HOST || 'http://localhost:8080'; const oldHost = process.env.OLD_HOST || 'http://localhost:8081'; -const allureResultsDir = path.resolve(process.cwd(), 'allure-results'); - function buildUrl(host: string, path: string, queryParams?: Record): string { const params = new URLSearchParams(queryParams); const qs = params.toString(); @@ -29,52 +25,18 @@ function isTestDefinition(item: TestDefinition | TestSuite): item is TestDefinit } /** - * Writes a PNG buffer to the allure-results directory and returns the filename. - * Allure picks up files in this directory and attaches them to the report. + * Attaches visual diff images (new, baseline, diff) to the Allure report + * via the allure-js-commons runtime API. */ -function writeAllureAttachment(buffer: Buffer): string { - if (!fs.existsSync(allureResultsDir)) { - fs.mkdirSync(allureResultsDir, { recursive: true }); - } - const uuid = crypto.randomUUID(); - const filename = `${uuid}-attachment.png`; - fs.writeFileSync(path.join(allureResultsDir, filename), buffer); - return filename; -} - -/** - * Attaches visual diff images (new, baseline, diff) to the Allure results - * for the current test by writing an attachment JSON file. - */ -function attachDiffImages( +async function attachDiffImages( result: { firstImage: Buffer; secondImage: Buffer; diffImage: Buffer | null }, testName: string -): void { - const newFile = writeAllureAttachment(result.firstImage); - const baselineFile = writeAllureAttachment(result.secondImage); - - const attachments: Array<{ name: string; source: string; type: string }> = [ - { name: `${testName} — new (PR)`, source: newFile, type: 'image/png' }, - { name: `${testName} — baseline (main)`, source: baselineFile, type: 'image/png' }, - ]; - +): Promise { + await attachment(`${testName} — new (PR)`, result.firstImage, ContentType.PNG); + await attachment(`${testName} — baseline (main)`, result.secondImage, ContentType.PNG); if (result.diffImage) { - const diffFile = writeAllureAttachment(result.diffImage); - attachments.push({ name: `${testName} — diff`, source: diffFile, type: 'image/png' }); + await attachment(`${testName} — diff`, result.diffImage, ContentType.PNG); } - - // Write a container JSON that Allure merges into the test result. - // jest-allure2-reporter reads attachment files from allure-results/. - const containerUuid = crypto.randomUUID(); - const containerFile = path.join(allureResultsDir, `${containerUuid}-container.json`); - fs.writeFileSync( - containerFile, - JSON.stringify({ - uuid: containerUuid, - name: testName, - attachments, - }) - ); } /** @@ -181,7 +143,7 @@ function registerTest(testDef: TestDefinition, getBrowser: () => WebdriverIO.Bro for (let i = 0; i < newPermutations.length; i++) { const permResult = await cropAndCompare(newPermutations[i], oldPermutations[i]); if (permResult.diffPixels !== 0) { - attachDiffImages(permResult, `${testDef.description} [permutation ${i}]`); + await attachDiffImages(permResult, `${testDef.description} [permutation ${i}]`); } expect(permResult.diffPixels).toBe(0); } @@ -189,7 +151,7 @@ function registerTest(testDef: TestDefinition, getBrowser: () => WebdriverIO.Bro } // Attach diff images to Allure report for visual inspection. - attachDiffImages(result, testDef.description); + await attachDiffImages(result, testDef.description); // For screenshotArea and viewport types, the diff is a real failure. expect(result.diffPixels).toBe(0); From 37f3e1735ba6ba10424aec4262c4d3a78fca467d Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Sat, 6 Jun 2026 20:07:54 +0200 Subject: [PATCH 80/95] Optimize comparison --- test/definitions/utils.ts | 55 ++++++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/test/definitions/utils.ts b/test/definitions/utils.ts index 67e38799c7..1bffb732df 100644 --- a/test/definitions/utils.ts +++ b/test/definitions/utils.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { attachment, ContentType } from 'allure-js-commons'; -import { cropAndCompare } from '@cloudscape-design/browser-test-tools/image-utils'; +import { cropAndCompare, parsePng } from '@cloudscape-design/browser-test-tools/image-utils'; import { ScreenshotPageObject, ScreenshotWithOffset } from '@cloudscape-design/browser-test-tools/page-objects'; import { TestDefinition, TestSuite } from './types'; @@ -14,6 +14,13 @@ const defaultWindowSize = { width: 1600, height: 800 }; const newHost = process.env.NEW_HOST || 'http://localhost:8080'; const oldHost = process.env.OLD_HOST || 'http://localhost:8081'; +interface RawCapture { + /** The raw base64-encoded PNG string from WebDriver (before decoding). */ + rawBase64: string; + /** The fully parsed screenshot with offset metadata (lazily resolved). */ + screenshot: () => Promise; +} + function buildUrl(host: string, path: string, queryParams?: Record): string { const params = new URLSearchParams(queryParams); const qs = params.toString(); @@ -74,15 +81,16 @@ function registerSuites(suites: Array, getBrowser: ( } /** - * Captures a screenshot based on the test's screenshotType. + * Captures a screenshot and returns both the raw PNG base64 and the parsed result. + * Having the raw base64 allows a fast byte-equality check before expensive pixel decoding. */ -async function capture( +async function captureRaw( browser: WebdriverIO.Browser, page: ScreenshotPageObject, url: string, testDef: TestDefinition, windowSize: { width: number; height: number } | undefined -): Promise { +): Promise { if (windowSize) { await browser.setWindowSize(windowSize.width, windowSize.height); } @@ -91,10 +99,29 @@ async function capture( if (testDef.setup) { await testDef.setup(page); } + if (testDef.screenshotType === 'viewport') { - return page.captureViewport(); + const { height, width } = await page.getViewportSize(); + const rawBase64 = await browser.takeScreenshot(); + return { + rawBase64, + screenshot: async () => { + const image = await parsePng(rawBase64); + return { image, offset: { top: 0, left: 0 }, height, width }; + }, + }; } - return page.captureBySelector(screenshotAreaSelector, { viewportOnly: true }); + + // screenshotArea / permutations — capture by selector with viewportOnly + const box = await page.getBoundingBox(screenshotAreaSelector); + const rawBase64 = await browser.takeScreenshot(); + return { + rawBase64, + screenshot: async () => { + const image = await parsePng(rawBase64); + return { image, offset: { top: box.top, left: box.left }, height: box.height, width: box.width }; + }, + }; } function registerTest(testDef: TestDefinition, getBrowser: () => WebdriverIO.Browser) { @@ -106,9 +133,19 @@ function registerTest(testDef: TestDefinition, getBrowser: () => WebdriverIO.Bro const newUrl = buildUrl(newHost, testDef.path, testDef.queryParams); const oldUrl = buildUrl(oldHost, testDef.path, testDef.queryParams); - const newScreenshot = await capture(browser, page, newUrl, testDef, windowSize); - const oldScreenshot = await capture(browser, page, oldUrl, testDef, windowSize); - const result = await cropAndCompare(newScreenshot, oldScreenshot); + const newCapture = await captureRaw(browser, page, newUrl, testDef, windowSize); + const oldCapture = await captureRaw(browser, page, oldUrl, testDef, windowSize); + + // Fast path: if the raw PNG bytes are identical, the images are guaranteed + // to be the same. This skips the expensive crop + pixelmatch decode path + // for the common case (no visual difference). + if (newCapture.rawBase64 === oldCapture.rawBase64) { + return; + } + + // Raw bytes differ — could be a real diff or just offset/crop differences. + // Fall through to full pixel comparison. + const result = await cropAndCompare(await newCapture.screenshot(), await oldCapture.screenshot()); if (result.diffPixels === 0) { return; From 23bea365d29ff1890a59d12a167c3b5cf91911c5 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Sat, 6 Jun 2026 20:55:25 +0200 Subject: [PATCH 81/95] Export types --- .gitignore | 1 + build-tools/tasks/package-json.js | 4 ++ build-tools/visual/generate-tests.js | 63 +++++++++++++++++++++------- docs/RUNNING_TESTS.md | 10 ++--- test/definitions/index.ts | 49 ---------------------- 5 files changed, 59 insertions(+), 68 deletions(-) delete mode 100644 test/definitions/index.ts diff --git a/.gitignore b/.gitignore index 38d6053611..1f46343d58 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ lib # generated sources src/index.ts test/visual +test/definitions/index.ts allure-results allure-report src/test-utils/dom/index.ts diff --git a/build-tools/tasks/package-json.js b/build-tools/tasks/package-json.js index a7c69a53f5..2c447682a1 100644 --- a/build-tools/tasks/package-json.js +++ b/build-tools/tasks/package-json.js @@ -103,6 +103,10 @@ const devPagesPackageJson = generatePackageJson(path.join(workspace.targetPath, const testDefinitionsPackageJson = generatePackageJson(path.join(workspace.targetPath, 'test-definitions'), { name: '@cloudscape-design/test-definitions', + exports: { + '.': './index.js', + './types': './types.js', + }, }); module.exports = parallel([ diff --git a/build-tools/visual/generate-tests.js b/build-tools/visual/generate-tests.js index a56b996b3d..f82b54d037 100644 --- a/build-tools/visual/generate-tests.js +++ b/build-tools/visual/generate-tests.js @@ -2,11 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 /** - * Auto-generates test/visual/*.test.ts files from test/definitions/visual/*.ts. + * Auto-generates: + * 1. test/visual/*.test.ts — one test runner per definition file (for Jest sharding) + * 2. test/definitions/index.ts — barrel that exports allSuites from all definitions * - * Each definition file that exports a default TestSuite gets a corresponding - * test runner. Helper files (those without a default export, e.g. shared utils - * like app-layout-responsive-tests.ts) are skipped. + * Each definition file that exports a default TestSuite gets included. + * Helper files (those without a default export, e.g. shared utils like + * app-layout-responsive-tests.ts) are skipped. * * Run this script before executing the visual test suite: * node build-tools/visual/generate-tests.js @@ -15,26 +17,29 @@ const fs = require('fs'); const path = require('path'); const definitionsDir = path.resolve(__dirname, '../../test/definitions/visual'); -const outputDir = path.resolve(__dirname, '../../test/visual'); +const testOutputDir = path.resolve(__dirname, '../../test/visual'); +const indexOutputPath = path.resolve(__dirname, '../../test/definitions/index.ts'); // Files that are shared helpers (export named functions, not a default suite). -// These are detected by checking if the file contains "export default" or -// a conventional "const suite" pattern followed by "export default suite". const HELPER_SUFFIXES = ['-tests']; function isHelperFile(basename) { return HELPER_SUFFIXES.some(suffix => basename.endsWith(suffix)); } +function toCamelCase(basename) { + return basename.replace(/-([a-z0-9])/g, (_, char) => char.toUpperCase()); +} + function generate() { // Ensure output directory exists - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); + if (!fs.existsSync(testOutputDir)) { + fs.mkdirSync(testOutputDir, { recursive: true }); } const files = fs.readdirSync(definitionsDir).filter(f => f.endsWith('.ts') && !f.endsWith('.d.ts')); - const generated = []; + const suiteFiles = []; for (const file of files) { const basename = file.replace(/\.ts$/, ''); @@ -49,6 +54,9 @@ function generate() { continue; } + suiteFiles.push(basename); + + // Generate test/visual/.test.ts const testContent = [ '// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.', '// SPDX-License-Identifier: Apache-2.0', @@ -60,12 +68,39 @@ function generate() { '', ].join('\n'); - const outputPath = path.join(outputDir, `${basename}.test.ts`); - fs.writeFileSync(outputPath, testContent); - generated.push(basename); + fs.writeFileSync(path.join(testOutputDir, `${basename}.test.ts`), testContent); } - console.log(`Generated ${generated.length} visual test files in test/visual/`); + // Generate test/definitions/index.ts + // Sort by module path for consistent import ordering + suiteFiles.sort(); + + const imports = suiteFiles.map(basename => { + const varName = toCamelCase(basename); + return `import ${varName} from './visual/${basename}';`; + }); + + const arrayEntries = suiteFiles.map(basename => ` ${toCamelCase(basename)},`); + + const indexContent = [ + '// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.', + '// SPDX-License-Identifier: Apache-2.0', + '// Auto-generated by build-tools/visual/generate-tests.js — do not edit manually.', + '', + `import { TestSuite } from './types';`, + `export { TestSuite, TestDefinition, ScreenshotType, ScreenshotTestConfiguration } from './types';`, + ...imports, + '', + 'export const allSuites: TestSuite[] = [', + ...arrayEntries, + '];', + '', + ].join('\n'); + + fs.writeFileSync(indexOutputPath, indexContent); + + console.log(`Generated ${suiteFiles.length} visual test files in test/visual/`); + console.log(`Generated test/definitions/index.ts with ${suiteFiles.length} suites`); } generate(); diff --git a/docs/RUNNING_TESTS.md b/docs/RUNNING_TESTS.md index c7832dca72..4db82e361b 100644 --- a/docs/RUNNING_TESTS.md +++ b/docs/RUNNING_TESTS.md @@ -104,14 +104,14 @@ const suite: TestSuite = { export default suite; ``` -Then import and add it to `test/definitions/visual/index.ts`: +Then run the generation script to pick it up automatically: -```ts -import myComponent from './my-component'; - -export const allSuites: TestSuite[] = [..., myComponent]; +```bash +node build-tools/visual/generate-tests.js ``` +This generates both the test runner (`test/visual/my-component.test.ts`) and updates `test/definitions/index.ts`. No manual imports needed. + ### Reviewing failures If the CI job fails, download the `visual-regression-diffs` artifact from the Actions summary. diff --git a/test/definitions/index.ts b/test/definitions/index.ts deleted file mode 100644 index 3bd6fa66e2..0000000000 --- a/test/definitions/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Each component has its own test definition file. -// Import them here manually to form the full test suite. -import { TestSuite } from './types'; -import actionCard from './visual/action-card'; -import alert from './visual/alert'; -import appLayout from './visual/app-layout'; -import appLayoutContentPaddings from './visual/app-layout-content-paddings'; -import appLayoutDrawers from './visual/app-layout-drawers'; -import appLayoutFlashbar from './visual/app-layout-flashbar'; -import appLayoutHeader from './visual/app-layout-header'; -import appLayoutMulti from './visual/app-layout-multi'; -import appLayoutResponsive600 from './visual/app-layout-responsive-600'; -import appLayoutResponsive1280 from './visual/app-layout-responsive-1280'; -import appLayoutResponsive1400 from './visual/app-layout-responsive-1400'; -import appLayoutResponsive1920 from './visual/app-layout-responsive-1920'; -import appLayoutResponsive2540 from './visual/app-layout-responsive-2540'; -import appLayoutStickyTableHeaderSplitPanel from './visual/app-layout-sticky-table-header-split-panel'; -import appLayoutToolbar from './visual/app-layout-toolbar'; -import appLayoutZIndex from './visual/app-layout-z-index'; -import areaChart from './visual/area-chart'; -import attributeEditor from './visual/attribute-editor'; -import autosuggest from './visual/autosuggest'; -import badge from './visual/badge'; - -export const allSuites: TestSuite[] = [ - actionCard, - alert, - appLayout, - appLayoutContentPaddings, - appLayoutDrawers, - appLayoutFlashbar, - appLayoutHeader, - appLayoutMulti, - appLayoutResponsive600, - appLayoutResponsive1280, - appLayoutResponsive1400, - appLayoutResponsive1920, - appLayoutResponsive2540, - appLayoutStickyTableHeaderSplitPanel, - appLayoutToolbar, - appLayoutZIndex, - areaChart, - attributeEditor, - autosuggest, - badge, -]; From 7f7867e1ad2e0e81b35ffe6e2eae2a39b98c5262 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 9 Jun 2026 00:07:55 +0200 Subject: [PATCH 82/95] Remove new test definitions --- .../visual/app-layout-content-paddings.ts | 30 ---- test/definitions/visual/app-layout-drawers.ts | 41 ----- .../definitions/visual/app-layout-flashbar.ts | 39 ----- test/definitions/visual/app-layout-header.ts | 85 ---------- test/definitions/visual/app-layout-multi.ts | 25 --- .../visual/app-layout-responsive-1280.ts | 7 - .../visual/app-layout-responsive-1400.ts | 7 - .../visual/app-layout-responsive-1920.ts | 7 - .../visual/app-layout-responsive-2540.ts | 7 - .../visual/app-layout-responsive-600.ts | 7 - .../visual/app-layout-responsive-tests.ts | 159 ------------------ ...-layout-sticky-table-header-split-panel.ts | 78 --------- test/definitions/visual/app-layout-toolbar.ts | 23 --- test/definitions/visual/app-layout-z-index.ts | 60 ------- test/definitions/visual/app-layout.ts | 104 ------------ test/definitions/visual/area-chart.ts | 136 --------------- test/definitions/visual/attribute-editor.ts | 31 ---- test/definitions/visual/autosuggest.ts | 70 -------- test/definitions/visual/badge.ts | 23 --- 19 files changed, 939 deletions(-) delete mode 100644 test/definitions/visual/app-layout-content-paddings.ts delete mode 100644 test/definitions/visual/app-layout-drawers.ts delete mode 100644 test/definitions/visual/app-layout-flashbar.ts delete mode 100644 test/definitions/visual/app-layout-header.ts delete mode 100644 test/definitions/visual/app-layout-multi.ts delete mode 100644 test/definitions/visual/app-layout-responsive-1280.ts delete mode 100644 test/definitions/visual/app-layout-responsive-1400.ts delete mode 100644 test/definitions/visual/app-layout-responsive-1920.ts delete mode 100644 test/definitions/visual/app-layout-responsive-2540.ts delete mode 100644 test/definitions/visual/app-layout-responsive-600.ts delete mode 100644 test/definitions/visual/app-layout-responsive-tests.ts delete mode 100644 test/definitions/visual/app-layout-sticky-table-header-split-panel.ts delete mode 100644 test/definitions/visual/app-layout-toolbar.ts delete mode 100644 test/definitions/visual/app-layout-z-index.ts delete mode 100644 test/definitions/visual/app-layout.ts delete mode 100644 test/definitions/visual/area-chart.ts delete mode 100644 test/definitions/visual/attribute-editor.ts delete mode 100644 test/definitions/visual/autosuggest.ts delete mode 100644 test/definitions/visual/badge.ts diff --git a/test/definitions/visual/app-layout-content-paddings.ts b/test/definitions/visual/app-layout-content-paddings.ts deleted file mode 100644 index e76d055749..0000000000 --- a/test/definitions/visual/app-layout-content-paddings.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { TestSuite } from '../types'; - -const suite: TestSuite = { - description: 'Content paddings', - componentName: 'app-layout', - tests: [ - ...(['true', 'false'] as const).flatMap(toolsEnabled => - (['true', 'false'] as const).flatMap(splitPanelEnabled => - (['bottom', 'side'] as const).map(splitPanelPosition => ({ - description: `toolsEnabled=${toolsEnabled} splitPanelEnabled=${splitPanelEnabled} splitPanelPosition=${splitPanelPosition}`, - path: 'app-layout/with-split-panel', - screenshotType: 'viewport' as const, - queryParams: { toolsEnabled, splitPanelEnabled, splitPanelPosition }, - })) - ) - ), - ...[1500, 600].map(width => ({ - description: `with split panel and disabled content paddings - width=${width}`, - path: 'app-layout/disable-paddings-with-split-panel', - screenshotType: 'viewport' as const, - configuration: { width }, - queryParams: { splitPanelOpen: 'true', splitPanelPosition: 'side' }, - })), - ], -}; - -export default suite; diff --git a/test/definitions/visual/app-layout-drawers.ts b/test/definitions/visual/app-layout-drawers.ts deleted file mode 100644 index 39c2089783..0000000000 --- a/test/definitions/visual/app-layout-drawers.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import createWrapper from '../../../lib/components/test-utils/selectors'; -import { TestSuite } from '../types'; - -const wrapper = createWrapper(); - -const suite: TestSuite = { - description: 'Drawers', - componentName: 'app-layout', - tests: [ - { - description: 'with split panel', - path: 'app-layout/with-drawers', - screenshotType: 'viewport', - setup: async page => { - await page.click(wrapper.findAppLayout().findDrawerTriggerById('pro-help').toSelector()); - }, - }, - { - description: 'with tooltip on hover', - path: 'app-layout/with-drawers', - screenshotType: 'viewport', - setup: async page => { - await page.hoverElement(wrapper.findAppLayout().findDrawerTriggerById('pro-help').toSelector()); - }, - }, - { - description: 'with custom scrollable drawer content', - path: 'app-layout/with-drawers-scrollable', - screenshotType: 'viewport', - queryParams: { sideNavFill: 'false' }, - setup: async page => { - await page.click(wrapper.findAppLayout().findDrawerTriggerById('chat').toSelector()); - }, - }, - ], -}; - -export default suite; diff --git a/test/definitions/visual/app-layout-flashbar.ts b/test/definitions/visual/app-layout-flashbar.ts deleted file mode 100644 index 5849a5cbf5..0000000000 --- a/test/definitions/visual/app-layout-flashbar.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { TestSuite } from '../types'; - -const suite: TestSuite = { - description: 'Flashbar', - componentName: 'app-layout', - tests: [true, false].flatMap(disableContentPaddings => - [true, false].flatMap(stickyNotifications => - [true, false].flatMap(stickyTableHeader => - [true, false].map(stackNotifications => ({ - description: `disableContentPaddings: ${disableContentPaddings}, stickyNotifications: ${stickyNotifications}, stickyTableHeader: ${stickyTableHeader}, stackNotifications: ${stackNotifications}`, - path: 'app-layout/with-stacked-notifications-and-table', - screenshotType: 'screenshotArea' as const, - configuration: { width: 1280, height: 900 }, - setup: async (page: import('@cloudscape-design/browser-test-tools/page-objects').ScreenshotPageObject) => { - if (!disableContentPaddings) { - await page.click('[data-id="toggle-content-paddings"]'); - } - if (stickyNotifications) { - await page.click('[data-id="toggle-sticky-notifications"]'); - } - if (!stickyTableHeader) { - await page.click('[data-id="toggle-sticky-table-header"]'); - } - if (!stackNotifications) { - await page.click('[data-id="toggle-stack-items"]'); - } - await page.click('[data-id="add-notification"]'); - await page.click('[data-id="add-notification"]'); - }, - })) - ) - ) - ), -}; - -export default suite; diff --git a/test/definitions/visual/app-layout-header.ts b/test/definitions/visual/app-layout-header.ts deleted file mode 100644 index 3a128a266e..0000000000 --- a/test/definitions/visual/app-layout-header.ts +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { TestDefinition, TestSuite } from '../types'; - -const suite: TestSuite = { - description: 'Headers', - componentName: 'app-layout', - tests: [ - // ── Headers ─────────────────────────────────────────────────────────── - { - description: 'Headers', - tests: [600, 1280].flatMap(width => [ - { - description: `alignment with full-page table (${width}px)`, - path: 'app-layout/with-table', - screenshotType: 'viewport' as const, - configuration: { width }, - }, - { - description: `alignment with full-page table in sticky state (${width}px)`, - path: 'app-layout/with-table', - screenshotType: 'viewport' as const, - configuration: { width }, - setup: async page => { - await page.windowScrollTo({ top: 200 }); - }, - }, - { - description: `alignment with full-page table in sticky state with sticky notifications (${width}px)`, - path: 'app-layout/with-table', - screenshotType: 'viewport' as const, - configuration: { width }, - queryParams: { stickyNotifications: 'true' }, - setup: async page => { - await page.windowScrollTo({ top: 200 }); - }, - }, - { - description: `high contrast header variant in landing page (${width}px)`, - path: 'app-layout/landing-page', - screenshotType: 'viewport' as const, - configuration: { width }, - }, - ]), - }, - - // ── High contrast header variant ────────────────────────────────────── - { - description: 'High contrast header variant', - tests: [ - ...[1400, 600].flatMap(width => [ - { - description: `with breadcrumbs and notifications at ${width}px`, - path: 'app-layout/high-contrast-header-variant', - screenshotType: 'screenshotArea' as const, - configuration: { width }, - queryParams: { hasBreadcrumbs: 'true', hasNotifications: 'true', hasContainer: 'true' }, - } as TestDefinition, - { - description: `without overlap at ${width}px`, - path: 'app-layout/high-contrast-header-variant', - screenshotType: 'screenshotArea' as const, - configuration: { width }, - queryParams: { disableOverlap: 'true' }, - } as TestDefinition, - { - description: `with content layout at ${width}px`, - path: 'app-layout/high-contrast-header-variant', - screenshotType: 'screenshotArea' as const, - configuration: { width }, - queryParams: { - hasBreadcrumbs: 'true', - hasNotifications: 'true', - hasContainer: 'true', - hasContentLayout: 'true', - }, - } as TestDefinition, - ]), - ], - }, - ], -}; - -export default suite; diff --git a/test/definitions/visual/app-layout-multi.ts b/test/definitions/visual/app-layout-multi.ts deleted file mode 100644 index babf9733cf..0000000000 --- a/test/definitions/visual/app-layout-multi.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { TestSuite } from '../types'; - -const suite: TestSuite = { - description: 'Multiple instances', - componentName: 'app-layout', - tests: [600, 1280].flatMap(width => [ - { - description: `simple (${width}px)`, - path: 'app-layout/multi-layout-simple', - screenshotType: 'viewport' as const, - configuration: { width }, - }, - { - description: `iframe (${width}px)`, - path: 'app-layout/multi-layout-iframe', - screenshotType: 'viewport' as const, - configuration: { width }, - }, - ]), -}; - -export default suite; diff --git a/test/definitions/visual/app-layout-responsive-1280.ts b/test/definitions/visual/app-layout-responsive-1280.ts deleted file mode 100644 index d5fe823f54..0000000000 --- a/test/definitions/visual/app-layout-responsive-1280.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { responsiveTests } from './app-layout-responsive-tests'; - -const suite = responsiveTests(1280); -export default suite; diff --git a/test/definitions/visual/app-layout-responsive-1400.ts b/test/definitions/visual/app-layout-responsive-1400.ts deleted file mode 100644 index 1cb519005c..0000000000 --- a/test/definitions/visual/app-layout-responsive-1400.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { responsiveTests } from './app-layout-responsive-tests'; - -const suite = responsiveTests(1400); -export default suite; diff --git a/test/definitions/visual/app-layout-responsive-1920.ts b/test/definitions/visual/app-layout-responsive-1920.ts deleted file mode 100644 index 88b8c6caf3..0000000000 --- a/test/definitions/visual/app-layout-responsive-1920.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { responsiveTests } from './app-layout-responsive-tests'; - -const suite = responsiveTests(1920); -export default suite; diff --git a/test/definitions/visual/app-layout-responsive-2540.ts b/test/definitions/visual/app-layout-responsive-2540.ts deleted file mode 100644 index 9dd62c00db..0000000000 --- a/test/definitions/visual/app-layout-responsive-2540.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { responsiveTests } from './app-layout-responsive-tests'; - -const suite = responsiveTests(2540); -export default suite; diff --git a/test/definitions/visual/app-layout-responsive-600.ts b/test/definitions/visual/app-layout-responsive-600.ts deleted file mode 100644 index f6fd3665cc..0000000000 --- a/test/definitions/visual/app-layout-responsive-600.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { responsiveTests } from './app-layout-responsive-tests'; - -const suite = responsiveTests(600); -export default suite; diff --git a/test/definitions/visual/app-layout-responsive-tests.ts b/test/definitions/visual/app-layout-responsive-tests.ts deleted file mode 100644 index f0ac91d121..0000000000 --- a/test/definitions/visual/app-layout-responsive-tests.ts +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { TestSuite } from '../types'; - -/** - * Shared responsive test scenarios for app-layout. Each width gets its own - * definition file and test runner so that Jest sharding can parallelize them. - */ -export function responsiveTests(width: number): TestSuite { - return { - description: `AppLayout responsive width ${width}px`, - componentName: 'app-layout', - tests: [ - { - description: 'default', - path: 'app-layout/default', - screenshotType: 'viewport', - configuration: { width }, - }, - { - description: 'navigation drawer is open', - path: 'app-layout/with-wizard', - screenshotType: 'viewport', - configuration: { width }, - setup: async page => { - await page.click('[aria-label="Open navigation"]'); - }, - }, - { - description: 'wizard', - path: 'app-layout/with-wizard', - screenshotType: 'viewport', - configuration: { width }, - }, - { - description: 'with wizard and table', - path: 'app-layout/with-wizard-and-table', - screenshotType: 'viewport', - configuration: { width }, - }, - { - description: 'with wizard, table, and breadcrumbs', - path: 'app-layout/with-wizard-and-table', - screenshotType: 'viewport', - configuration: { width }, - queryParams: { hasBreadcrumbs: 'true' }, - }, - { - description: 'notifications', - path: 'app-layout/with-notifications', - screenshotType: 'viewport', - configuration: { width }, - }, - { - description: 'breadcrumbs', - path: 'app-layout/with-breadcrumbs', - screenshotType: 'viewport', - configuration: { width }, - }, - { - description: 'notifications and breadcrumbs', - path: 'app-layout/with-breadcrumbs-notifications', - screenshotType: 'viewport', - configuration: { width }, - }, - { - description: 'dashboard content type', - path: 'app-layout/dashboard-content-type', - screenshotType: 'viewport', - configuration: { width }, - }, - { - description: 'fixed header and footer', - path: 'app-layout/with-fixed-header-footer', - screenshotType: 'viewport', - configuration: { width }, - }, - { - description: 'disableBodyScroll - empty', - path: 'app-layout/legacy-nav-empty', - screenshotType: 'viewport', - configuration: { width }, - }, - { - description: 'disableBodyScroll - with content', - path: 'app-layout/legacy-nav-scrollable', - screenshotType: 'viewport', - configuration: { width }, - }, - { - description: 'disableBodyScroll - with split panel', - path: 'app-layout/legacy-nav-scrollable-with-split-panel', - screenshotType: 'viewport', - configuration: { width }, - }, - { - description: 'disable paddings', - path: 'app-layout/disable-paddings', - screenshotType: 'viewport', - configuration: { width }, - }, - { - description: 'disable paddings with breadcrumbs', - path: 'app-layout/disable-paddings-breadcrumbs', - screenshotType: 'viewport', - configuration: { width }, - }, - { - description: 'sticky notifications', - path: 'app-layout/with-sticky-notifications', - screenshotType: 'viewport', - configuration: { width }, - }, - { - description: 'sticky notifications scrolled down', - path: 'app-layout/with-sticky-notifications', - screenshotType: 'viewport', - configuration: { width }, - setup: async page => { - await page.windowScrollTo({ top: 2000 }); - }, - }, - { - description: 'layout without panels', - path: 'app-layout/no-panels', - screenshotType: 'viewport', - configuration: { width }, - }, - { - description: 'layout without panels but with notifications', - path: 'app-layout/no-panels-with-notifications', - screenshotType: 'viewport', - configuration: { width }, - }, - { - description: 'with drawers', - path: 'app-layout/with-drawers', - screenshotType: 'viewport', - configuration: { width }, - }, - { - description: 'with empty drawers', - path: 'app-layout/with-drawers-empty', - screenshotType: 'viewport', - configuration: { width }, - }, - { - description: 'with open drawer', - path: 'app-layout/with-drawers', - screenshotType: 'viewport', - configuration: { width }, - setup: async page => { - await page.click('[aria-label="Security trigger button"]'); - }, - }, - ], - }; -} diff --git a/test/definitions/visual/app-layout-sticky-table-header-split-panel.ts b/test/definitions/visual/app-layout-sticky-table-header-split-panel.ts deleted file mode 100644 index 6a0b899686..0000000000 --- a/test/definitions/visual/app-layout-sticky-table-header-split-panel.ts +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { TestSuite } from '../types'; - -const suite: TestSuite = { - description: 'Sticky header with split panel', - componentName: 'app-layout', - tests: [ - { - description: 'scrolling to bottom with closed split panel (1 table row)', - path: 'app-layout/with-sticky-table-and-split-panel', - screenshotType: 'viewport', - configuration: { width: 1280, height: 900 }, - setup: async page => { - await page.click('[data-testid="set-item-count-to-1"]'); - await page.scrollToBottom('html'); - }, - }, - { - description: 'scrolling to bottom with closed split panel (30 table rows)', - path: 'app-layout/with-sticky-table-and-split-panel', - screenshotType: 'viewport', - configuration: { width: 1280, height: 900 }, - setup: async page => { - await page.click('[data-testid="set-item-count-to-30"]'); - await page.scrollToBottom('html'); - }, - }, - { - description: 'header stays sticky with open split panel (1 table row)', - path: 'app-layout/with-sticky-table-and-split-panel', - screenshotType: 'viewport', - configuration: { width: 1280, height: 900 }, - setup: async page => { - await page.click('[data-testid="set-item-count-to-1"]'); - await page.click('aria/Open panel'); - await page.scrollToBottom('html'); - }, - }, - { - description: 'header stays sticky with open split panel (30 table rows)', - path: 'app-layout/with-sticky-table-and-split-panel', - screenshotType: 'viewport', - configuration: { width: 1280, height: 900 }, - setup: async page => { - await page.click('[data-testid="set-item-count-to-30"]'); - await page.click('aria/Open panel'); - await page.scrollToBottom('html'); - }, - }, - { - description: 'header stays sticky when mounting and unmounting a second table', - path: 'app-layout/with-sticky-table-and-split-panel', - screenshotType: 'viewport', - configuration: { width: 1280, height: 900 }, - setup: async page => { - await page.click('[data-testid="set-item-count-to-30"]'); - await page.click('aria/Open panel'); - await page.windowScrollTo({ top: 0 }); - await page.click('aria/Close panel'); - await page.scrollToBottom('html'); - }, - }, - // ── Max content width ───────────────────────────────────────────────── - { - description: 'maxContentWidth set to Number.MAX_VALUE', - path: 'app-layout/refresh-content-width', - screenshotType: 'viewport', - configuration: { width: 1280, height: 700 }, - setup: async page => { - await page.click('[data-test-id="button_width-number-max_value"]'); - }, - }, - ], -}; - -export default suite; diff --git a/test/definitions/visual/app-layout-toolbar.ts b/test/definitions/visual/app-layout-toolbar.ts deleted file mode 100644 index fb174b0a71..0000000000 --- a/test/definitions/visual/app-layout-toolbar.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { TestSuite } from '../types'; - -const suite: TestSuite = { - description: 'Toolbar', - componentName: 'app-layout', - tests: [ - { - description: 'multiple nested instances (no breadcrumbs dedup)', - path: 'app-layout-toolbar/multi-layout-with-hidden-instances', - screenshotType: 'viewport', - }, - { - description: 'no toolbar', - path: 'app-layout-toolbar/without-toolbar', - screenshotType: 'viewport', - }, - ], -}; - -export default suite; diff --git a/test/definitions/visual/app-layout-z-index.ts b/test/definitions/visual/app-layout-z-index.ts deleted file mode 100644 index b0dacf8bc5..0000000000 --- a/test/definitions/visual/app-layout-z-index.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { TestDefinition, TestSuite } from '../types'; - -const suite: TestSuite = { - description: 'Z-index', - componentName: 'app-layout', - tests: [ - ...[600, 1280].flatMap(width => [ - { - description: `button dropdown (${width}px)`, - path: 'app-layout/with-absolute-components', - screenshotType: 'viewport' as const, - configuration: { width }, - setup: async page => { - await page.click('button=Button dropdown'); - await page.click('[data-testid="2"]'); - await page.windowScrollTo({ top: 300 }); - }, - } as TestDefinition, - { - description: `select (${width}px)`, - path: 'app-layout/with-absolute-components', - screenshotType: 'viewport' as const, - configuration: { width, height: 800 }, - setup: async page => { - await page.click('[data-testid="select-demo"] button'); - await page.windowScrollTo({ top: 300 }); - }, - } as TestDefinition, - { - description: `split-panel and full-page table (${width}px)`, - path: 'app-layout/with-full-page-table-and-split-panel', - screenshotType: 'viewport' as const, - configuration: { width }, - }, - ]), - { - description: 'split-panel and full-page with open navigation (600px)', - path: 'app-layout/with-full-page-table-and-split-panel', - screenshotType: 'viewport' as const, - configuration: { width: 600 }, - setup: async page => { - await page.click('button[aria-label="Open navigation"]'); - }, - }, - { - description: 'split-panel and full-page with open tools (600px)', - path: 'app-layout/with-full-page-table-and-split-panel', - screenshotType: 'viewport' as const, - configuration: { width: 600 }, - setup: async page => { - await page.click('button[aria-label="Open tools"]'); - }, - }, - ], -}; - -export default suite; diff --git a/test/definitions/visual/app-layout.ts b/test/definitions/visual/app-layout.ts deleted file mode 100644 index 683efdcc25..0000000000 --- a/test/definitions/visual/app-layout.ts +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { TestSuite } from '../types'; - -const suite: TestSuite = { - description: 'AppLayout', - componentName: 'app-layout', - tests: [ - { - description: 'no scrollbars at 320px', - path: 'app-layout/default', - screenshotType: 'viewport', - configuration: { width: 320 }, - }, - { - description: 'drawer buttons alignment', - path: 'app-layout/default', - screenshotType: 'viewport', - configuration: { width: 800 }, - setup: async page => { - await page.click('[aria-label="Open tools"]'); - }, - }, - { - description: 'disable paddings - navigation closed', - path: 'app-layout/disable-paddings', - screenshotType: 'viewport', - configuration: { width: 1280 }, - setup: async page => { - await page.click('[aria-label="Close navigation"]'); - }, - }, - { - description: 'panels stacking on mobile', - path: 'app-layout/all-panels-open', - screenshotType: 'viewport', - configuration: { width: 600 }, - }, - { - description: 'wrapping long words', - path: 'app-layout/text-wrap', - screenshotType: 'viewport', - }, - { - description: 'fill content area', - path: 'app-layout/fill-content-area', - screenshotType: 'viewport', - }, - { - description: 'with tools and drawers', - path: 'app-layout/with-drawers', - screenshotType: 'viewport', - queryParams: { hasTools: 'true' }, - }, - { - description: 'with open drawer and open side split panel', - path: 'app-layout/with-drawers', - screenshotType: 'viewport', - configuration: { width: 1400 }, - queryParams: { splitPanelPosition: 'side' }, - setup: async page => { - await page.click('[aria-label="Security trigger button"]'); - await page.click('[aria-label="Open panel"]'); - }, - }, - - // regression for https://github.com/cloudscape-design/components/pull/1612 - { - description: 'with open drawer and open side split panel after resize', - path: 'app-layout/with-drawers', - screenshotType: 'viewport', - configuration: { width: 1500 }, - queryParams: { splitPanelPosition: 'side' }, - setup: async page => { - await page.click('[aria-label="Security trigger button"]'); - await page.click('[aria-label="Open panel"]'); - await page.setWindowSize({ width: 1400, height: 800 }); - }, - }, - - // ── Transitions ─────────────────────────────────────────────────────── - { - description: 'transition from 400px to 1800px', - path: 'app-layout/default', - screenshotType: 'viewport', - configuration: { width: 400, height: 400 }, - setup: async page => { - await page.setWindowSize({ width: 1800, height: 400 }); - }, - }, - { - description: 'transition from 1800px to 400px', - path: 'app-layout/default', - screenshotType: 'viewport', - configuration: { width: 1800, height: 400 }, - setup: async page => { - await page.setWindowSize({ width: 400, height: 400 }); - }, - }, - ], -}; - -export default suite; diff --git a/test/definitions/visual/area-chart.ts b/test/definitions/visual/area-chart.ts deleted file mode 100644 index cfa0b5e47b..0000000000 --- a/test/definitions/visual/area-chart.ts +++ /dev/null @@ -1,136 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { TestSuite } from '../types'; - -const TEST_CHART_FILTER_TRIGGER = '#linear-latency-chart button'; -const TEST_CHART_TOOLTIP_HEADER = '#linear-latency-chart h2'; - -const suite: TestSuite = { - description: 'Area chart', - componentName: 'area-chart', - tests: [ - { - description: 'permutations', - path: 'area-chart/permutations', - screenshotType: 'permutations', - }, - { - description: 'fit-height', - path: 'area-chart/fit-height', - screenshotType: 'screenshotArea', - }, - { - description: 'fit-height no filter, no legend', - path: 'area-chart/fit-height', - screenshotType: 'screenshotArea', - queryParams: { hideFilter: 'true', hideLegend: 'true' }, - }, - { - description: 'fit-height, no legend', - path: 'area-chart/fit-height', - screenshotType: 'screenshotArea', - queryParams: { hideLegend: 'true' }, - }, - { - description: 'chart plot has a focus outline', - path: 'area-chart/test', - screenshotType: 'viewport', - configuration: { width: 800, height: 800 }, - setup: async page => { - await page.click(TEST_CHART_FILTER_TRIGGER); - await page.keys(['Escape']); - await page.focusNextElement(); - }, - }, - { - description: 'can navigate along X axis highlighting all series with keyboard', - path: 'area-chart/test', - screenshotType: 'viewport', - configuration: { width: 800, height: 800 }, - setup: async page => { - await page.click(TEST_CHART_FILTER_TRIGGER); - await page.keys(['Escape']); - await page.focusNextElement(); - await page.keys(['ArrowRight', 'ArrowRight']); - await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); - }, - }, - { - description: 'can navigate a specific series with keyboard', - path: 'area-chart/test', - screenshotType: 'viewport', - configuration: { width: 800, height: 800 }, - setup: async page => { - await page.click(TEST_CHART_FILTER_TRIGGER); - await page.keys(['Escape']); - await page.focusNextElement(); - await page.keys(['ArrowRight']); - await page.keys(['ArrowDown']); - await page.keys(['ArrowRight']); - await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); - }, - }, - { - description: 'selects correct series when navigated back from legend', - path: 'area-chart/test', - screenshotType: 'viewport', - configuration: { width: 800, height: 800 }, - setup: async page => { - await page.click(TEST_CHART_FILTER_TRIGGER); - await page.keys(['Escape']); - await page.keys(['Tab']); - await page.keys(['Tab']); - await page.keys(['ArrowRight']); - await page.keys(['Shift', 'Tab']); - await page.keys(['ArrowRight']); - await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); - }, - }, - { - description: 'can pin popover for all data points at a given X coordinate with keyboard', - path: 'area-chart/test', - screenshotType: 'viewport', - configuration: { width: 800, height: 800 }, - setup: async page => { - await page.click(TEST_CHART_FILTER_TRIGGER); - await page.keys(['Escape']); - await page.focusNextElement(); - await page.keys(['ArrowRight']); - await page.keys(['ArrowRight']); - await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); - await page.keys(['Enter']); - await page.waitForVisible('[aria-label="Dismiss"]'); - }, - }, - { - description: 'can pin popover for a point in a specific series with keyboard', - path: 'area-chart/test', - screenshotType: 'viewport', - configuration: { width: 800, height: 800 }, - setup: async page => { - await page.click(TEST_CHART_FILTER_TRIGGER); - await page.keys(['Escape']); - await page.focusNextElement(); - await page.keys(['ArrowRight']); - await page.keys(['ArrowDown']); - await page.keys(['ArrowRight']); - await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); - await page.keys(['Enter']); - await page.waitForVisible('[aria-label="Dismiss"]'); - }, - }, - { - description: 'shows popover on hover', - path: 'area-chart/test', - screenshotType: 'viewport', - configuration: { width: 800, height: 800 }, - setup: async page => { - await page.hoverElement('[aria-label="Linear latency chart"]', 200, 50); - await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); - }, - }, - ], -}; - -export default suite; diff --git a/test/definitions/visual/attribute-editor.ts b/test/definitions/visual/attribute-editor.ts deleted file mode 100644 index 33da3fad86..0000000000 --- a/test/definitions/visual/attribute-editor.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { TestSuite } from '../types'; - -const suite: TestSuite = { - description: 'Attribute Editor', - componentName: 'attribute-editor', - tests: [360, 768, 992].flatMap(width => [ - { - description: `permutations at ${width}px`, - path: 'attribute-editor/permutations', - screenshotType: 'permutations' as const, - configuration: { width }, - }, - { - description: `customizable-footer at ${width}px`, - path: 'attribute-editor/customizable-footer', - screenshotType: 'screenshotArea' as const, - configuration: { width }, - }, - { - description: `with long select at ${width}px`, - path: 'attribute-editor/select-with-long-value', - screenshotType: 'screenshotArea' as const, - configuration: { width }, - }, - ]), -}; - -export default suite; diff --git a/test/definitions/visual/autosuggest.ts b/test/definitions/visual/autosuggest.ts deleted file mode 100644 index 92f4d46897..0000000000 --- a/test/definitions/visual/autosuggest.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import createWrapper from '../../../lib/components/test-utils/selectors'; -import { TestDefinition, TestSuite } from '../types'; - -const wrapper = createWrapper(); - -const suite: TestSuite = { - description: 'Autosuggest', - componentName: 'autosuggest', - tests: [ - { - description: 'permutations', - path: 'autosuggest/permutations', - screenshotType: 'permutations', - setup: async page => { - await page.click('input'); - }, - }, - { - description: 'permutations for async properties', - path: 'autosuggest/permutations-async', - screenshotType: 'permutations', - setup: async page => { - await page.click('input'); - }, - }, - { - description: 'Displays options with groups correctly', - path: 'autosuggest/scenarios', - screenshotType: 'screenshotArea', - setup: async page => { - await page.click('input'); - }, - }, - { - description: 'Correctly displays dropdown regions', - path: 'autosuggest/regions-scenarios', - screenshotType: 'screenshotArea', - setup: async page => { - await page.click('input'); - }, - }, - { - description: 'Long virtual list - navigate to last item', - path: 'autosuggest/virtual-scroll', - screenshotType: 'screenshotArea', - setup: async page => { - await page.click(wrapper.findAutosuggest().findNativeInput().toSelector()); - await page.keys(['ArrowUp']); - }, - }, - ...[true, false].map( - virtualScroll => - ({ - description: `with custom renderOption (virtualScroll=${virtualScroll})`, - path: 'autosuggest/custom-render-option', - screenshotType: 'screenshotArea' as const, - queryParams: { virtualScroll: String(virtualScroll) }, - setup: async page => { - await page.click(wrapper.findAutosuggest().findNativeInput().toSelector()); - await page.keys(['ArrowDown']); - }, - }) as TestDefinition - ), - ], -}; - -export default suite; diff --git a/test/definitions/visual/badge.ts b/test/definitions/visual/badge.ts deleted file mode 100644 index 7d405ed843..0000000000 --- a/test/definitions/visual/badge.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { TestSuite } from '../types'; - -const suite: TestSuite = { - description: 'Badge', - componentName: 'badge', - tests: [ - { - description: 'permutation page', - path: 'badge/permutations', - screenshotType: 'permutations', - }, - { - description: 'style custom page', - path: 'badge/style-custom-types', - screenshotType: 'screenshotArea', - }, - ], -}; - -export default suite; From 90ad35edc1bff8e37fc4e44e5a51fc9879ec3c79 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 9 Jun 2026 00:14:58 +0200 Subject: [PATCH 83/95] Reduce to 1 shard --- .github/workflows/visual-regression.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 977c6cd96d..cca8a2bf97 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -83,7 +83,7 @@ jobs: strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + shard: [1] steps: - uses: actions/checkout@v4 @@ -93,7 +93,7 @@ jobs: node-version: 20 cache: npm - - name: Setup Chrome and ChromeDriver + - name: Set up Chrome and ChromeDriver uses: browser-actions/setup-chrome@v1 with: chrome-version: stable @@ -138,7 +138,7 @@ jobs: run: node_modules/.bin/wait-on http://localhost:8080 http://localhost:8081 - name: Run visual regression tests - run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js --shard=${{ matrix.shard }}/10 + run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js --shard=${{ matrix.shard }}/1 env: TZ: UTC From d4e87e59131b31f2d821e26dc36edfdc37853efa Mon Sep 17 00:00:00 2001 From: Nathnael_D <109980176+NathanZlion@users.noreply.github.com> Date: Mon, 8 Jun 2026 09:51:07 +0200 Subject: [PATCH 84/95] feat: Table Column Groups Table (#4482) --- pages/table/column-groups.page.tsx | 440 +++++++ .../__snapshots__/documenter.test.ts.snap | 63 +- src/table/__tests__/column-groups.test.tsx | 1024 +++++++++++++++++ src/table/__tests__/columns-width.test.tsx | 172 +++ src/table/column-groups/__tests__/fixtures.ts | 71 ++ .../__tests__/split-utils.test.ts | 108 ++ .../__tests__/use-column-groups.test.tsx | 94 ++ .../column-groups/__tests__/utils.test.ts | 252 ++++ src/table/column-groups/col-group.tsx | 43 + src/table/column-groups/split-utils.ts | 69 ++ src/table/column-groups/use-column-groups.tsx | 32 + src/table/column-groups/utils.ts | 294 +++++ src/table/header-cell/common-props.ts | 27 + src/table/header-cell/group-header-cell.tsx | 145 +++ src/table/header-cell/index.tsx | 45 +- src/table/header-cell/styles.scss | 47 +- src/table/header-cell/th-element.tsx | 30 +- src/table/index.tsx | 5 +- src/table/interfaces.tsx | 43 +- src/table/internal.tsx | 27 + src/table/resizer/index.tsx | 36 +- src/table/resizer/styles.scss | 39 +- src/table/selection/selection-cell.tsx | 5 +- src/table/sticky-header.tsx | 22 +- src/table/sticky-scrolling.ts | 6 +- src/table/styles.scss | 9 + src/table/table-role/__tests__/utils.test.ts | 150 +++ src/table/table-role/grid-navigation.tsx | 14 +- src/table/table-role/table-role-helper.ts | 14 +- src/table/table-role/utils.ts | 112 +- src/table/thead.tsx | 392 ++++++- src/table/use-column-widths.tsx | 105 +- src/table/use-sticky-header.ts | 4 +- src/table/utils.ts | 18 +- src/test-utils/dom/table/index.ts | 33 +- 35 files changed, 3832 insertions(+), 158 deletions(-) create mode 100644 pages/table/column-groups.page.tsx create mode 100644 src/table/__tests__/column-groups.test.tsx create mode 100644 src/table/column-groups/__tests__/fixtures.ts create mode 100644 src/table/column-groups/__tests__/split-utils.test.ts create mode 100644 src/table/column-groups/__tests__/use-column-groups.test.tsx create mode 100644 src/table/column-groups/__tests__/utils.test.ts create mode 100644 src/table/column-groups/col-group.tsx create mode 100644 src/table/column-groups/split-utils.ts create mode 100644 src/table/column-groups/use-column-groups.tsx create mode 100644 src/table/column-groups/utils.ts create mode 100644 src/table/header-cell/common-props.ts create mode 100644 src/table/header-cell/group-header-cell.tsx create mode 100644 src/table/table-role/__tests__/utils.test.ts diff --git a/pages/table/column-groups.page.tsx b/pages/table/column-groups.page.tsx new file mode 100644 index 0000000000..34327f6175 --- /dev/null +++ b/pages/table/column-groups.page.tsx @@ -0,0 +1,440 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useState } from 'react'; + +import { useCollection } from '@cloudscape-design/collection-hooks'; + +import { + Box, + FormField, + Header, + Input, + Link, + Pagination, + Select, + SpaceBetween, + Table, + TableProps, + TextFilter, + Toggle, +} from '~components'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { SimplePage } from '../app/templates'; + +// ============================================================================ +// Data +// ============================================================================ + +interface Instance { + id: string; + name: string; + type: string; + az: string; + state: string; + cpu: number; + memory: number; + netIn: number; + netOut: number; + cost: number; +} + +const TYPES = ['t3.medium', 't3.large', 'r5.xlarge', 'c5.large', 'p3.2xlarge']; +const AZS = ['us-east-1a', 'us-east-1b', 'us-east-1c', 'us-east-1d']; +const STATES = ['running', 'stopped', 'pending']; + +const allInstances: Instance[] = Array.from({ length: 35 }, (_, i) => ({ + id: `i-${String(i + 1).padStart(3, '0')}`, + name: `instance-${i + 1}`, + type: TYPES[i % TYPES.length], + az: AZS[i % AZS.length], + state: STATES[i % STATES.length], + cpu: +(Math.random() * 100).toFixed(1), + memory: +(Math.random() * 100).toFixed(1), + netIn: Math.round(Math.random() * 10000), + netOut: Math.round(Math.random() * 10000), + cost: +(Math.random() * 500).toFixed(2), +})); + +// ============================================================================ +// Column & group definitions +// ============================================================================ + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { + id: 'id', + header: 'Instance ID', + cell: item => {item.id}, + sortingField: 'id', + isRowHeader: true, + }, + { id: 'name', header: 'Name', cell: item => item.name, sortingField: 'name' }, + { id: 'type', header: 'Type', cell: item => item.type, sortingField: 'type' }, + { id: 'az', header: 'AZ', cell: item => item.az, sortingField: 'az' }, + { id: 'state', header: 'State', cell: item => item.state, sortingField: 'state' }, + { id: 'cpu', header: 'CPU (%)', cell: item => `${item.cpu}%`, sortingField: 'cpu' }, + { id: 'memory', header: 'Memory (%)', cell: item => `${item.memory}%`, sortingField: 'memory' }, + { id: 'netIn', header: 'Network in', cell: item => item.netIn.toLocaleString(), sortingField: 'netIn' }, + { id: 'netOut', header: 'Network out', cell: item => item.netOut.toLocaleString(), sortingField: 'netOut' }, + { id: 'cost', header: 'Cost ($)', cell: item => `$${item.cost}`, sortingField: 'cost' }, +]; + +const groupDefinitions: TableProps.GroupDefinition[] = [ + { id: 'config', header: 'Configuration' }, + { id: 'performance', header: 'Performance' }, + { id: 'network', header: 'Network' }, + { id: 'metrics', header: 'Metrics' }, +]; + +// ============================================================================ +// Column display presets +// ============================================================================ + +type GroupingPreset = 'flat' | 'single-level' | 'nested' | 'single-child-groups'; + +const columnDisplayPresets: Record = { + flat: [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { id: 'type', visible: true }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + { id: 'netIn', visible: true }, + { id: 'netOut', visible: true }, + { id: 'cost', visible: true }, + ], + 'single-level': [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + ], + }, + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, + { + type: 'group', + id: 'network', + visible: true, + children: [ + { id: 'netIn', visible: true }, + { id: 'netOut', visible: true }, + ], + }, + { id: 'cost', visible: true }, + ], + nested: [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + ], + }, + { + type: 'group', + id: 'metrics', + visible: true, + children: [ + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, + { + type: 'group', + id: 'network', + visible: true, + children: [ + { id: 'netIn', visible: true }, + { id: 'netOut', visible: true }, + ], + }, + ], + }, + { id: 'cost', visible: true }, + ], + 'single-child-groups': [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { type: 'group', id: 'config', visible: true, children: [{ id: 'type', visible: true }] }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + { type: 'group', id: 'performance', visible: true, children: [{ id: 'cpu', visible: true }] }, + { id: 'memory', visible: true }, + { id: 'netIn', visible: true }, + { id: 'netOut', visible: true }, + { id: 'cost', visible: true }, + ], +}; + +const presetOptions = [ + { value: 'single-level', label: 'Single-level groups' }, + { value: 'nested', label: 'Nested groups (3 levels)' }, + { value: 'single-child-groups', label: 'Single-child groups' }, + { value: 'flat', label: 'Without grouping' }, +]; + +// ============================================================================ +// Page component +// ============================================================================ + +type DemoContext = React.Context< + AppContextType<{ + groupingPreset: GroupingPreset; + variant: TableProps.Variant; + selectionType: string; + resizable: boolean; + stickyHeader: boolean; + stickyHeaderOffset: number; + firstSticky: number; + lastSticky: number; + wrapLines: boolean; + stripedRows: boolean; + contentDensity: string; + enableKeyboardNavigation: boolean; + loading: boolean; + empty: boolean; + cellVerticalAlign: string; + sortingDisabled: boolean; + }> +>; + +export default function ColumnGroupsPage() { + const { + urlParams: { + groupingPreset = 'single-level' as GroupingPreset, + variant = 'container' as TableProps.Variant, + selectionType = 'multi', + resizable = true, + stickyHeader = false, + stickyHeaderOffset = 0, + firstSticky = 0, + lastSticky = 0, + wrapLines = false, + stripedRows = false, + contentDensity = 'comfortable', + enableKeyboardNavigation = true, + loading = false, + empty = false, + cellVerticalAlign = 'middle', + sortingDisabled = false, + }, + setUrlParams, + } = useContext(AppContext as DemoContext); + + const [columnDisplay, setColumnDisplay] = useState( + columnDisplayPresets[groupingPreset] + ); + + const tableItems = empty ? [] : allInstances; + + const { items, filteredItemsCount, collectionProps, filterProps, paginationProps } = useCollection(tableItems, { + filtering: { + empty: No instances, + noMatch: No matches, + }, + pagination: { pageSize: 25 }, + sorting: {}, + selection: {}, + }); + + return ( + +
+ Feature controls +
+ + + + setUrlParams({ variant: detail.selectedOption.value as TableProps.Variant })} + ariaLabel="Table variant" + /> + + + setUrlParams({ cellVerticalAlign: detail.selectedOption.value! })} + ariaLabel="Cell vertical align" + /> + + + + + + setUrlParams({ firstSticky: +detail.value })} + value={String(firstSticky)} + inputMode="numeric" + type="number" + /> + + + setUrlParams({ lastSticky: +detail.value })} + value={String(lastSticky)} + inputMode="numeric" + type="number" + /> + + + setUrlParams({ stickyHeaderOffset: +detail.value })} + value={String(stickyHeaderOffset)} + inputMode="numeric" + type="number" + /> + + + + + setUrlParams({ resizable: detail.checked })}> + Resizable columns + + setUrlParams({ stickyHeader: detail.checked })}> + Sticky header + + setUrlParams({ wrapLines: detail.checked })}> + Wrap lines + + setUrlParams({ stripedRows: detail.checked })}> + Striped rows + + setUrlParams({ contentDensity: detail.checked ? 'compact' : 'comfortable' })} + > + Compact mode + + setUrlParams({ enableKeyboardNavigation: detail.checked })} + > + Keyboard navigation + + setUrlParams({ sortingDisabled: detail.checked })} + > + Sorting disabled + + setUrlParams({ loading: detail.checked })}> + Loading state + + setUrlParams({ empty: detail.checked })}> + Empty state + + + + } + > + `${selectedItems.length} items selected`, + itemSelectionLabel: (_, item) => `Select ${item.name}`, + }} + header={
Instances
} + filter={ + + } + pagination={} + empty={No instances} + /> + + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 3fcf9aa5d4..897e51a55d 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -27809,7 +27809,18 @@ To target individual cells use \`columnDefinitions.verticalAlign\`, that takes p If not set, all columns are displayed and the order is dictated by the \`columnDefinitions\` property. -Use it in conjunction with the content display preference of the [collection preferences](/components/collection-preferences/) component.", +Use it in conjunction with the content display preference of the [collection preferences](/components/collection-preferences/) component. + +Each entry is one of the following: +- \`ColumnDisplay\` - Represents a single column. + - \`type\` ('column') - (Optional) Identifies the entry as a column. Defaults to \`'column'\` when omitted. + - \`id\` (string) - The column identifier. Must match a column \`id\` from \`columnDefinitions\`. + - \`visible\` (boolean) - Whether the column is visible. +- \`GroupDisplay\` - Represents a column group. + - \`type\` ('group') - Identifies the entry as a group. + - \`id\` (string) - The group identifier. Must match a group \`id\` from \`groupDefinitions\`. + - \`visible\` (boolean) - Whether the group is visible. + - \`children\` (ReadonlyArray) - The columns or nested groups within this group.", "name": "columnDisplay", "optional": true, "type": "ReadonlyArray", @@ -28031,6 +28042,20 @@ table with \`item=null\` and then for each expanded item. The function result is "optional": true, "type": "TableProps.GetLoadingStatus", }, + { + "description": "Defines the column groups. Each group has an \`id\` and \`header\` used to label the group header cell. + +When using grouped columns, you must also provide the \`columnDisplay\` property with \`{ type: 'group', id, children }\` entries +to assign columns to their respective groups and define the display hierarchy. + +Each group definition contains the following: +- \`id\` (string) - A unique identifier for the group. +- \`header\` (ReactNode) - The content displayed in the group header cell. +- \`ariaLabel\` ((LabelData) => string) - (Optional) A function that provides an \`aria-label\` for the group header.", + "name": "groupDefinitions", + "optional": true, + "type": "ReadonlyArray>", + }, { "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must @@ -43148,8 +43173,22 @@ Returns the current value of the input.", }, }, { + "description": "Returns column header cells from the table's header region. + +By default, returns all leaf-column headers (\`scope="col"\`). +For tables without column grouping this is equivalent to the previous behavior. +For tables with column grouping this excludes group header cells.", "name": "findColumnHeaders", - "parameters": [], + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ groupId?: string | undefined; }", + }, + ], "returnType": { "isNullable": false, "name": "Array", @@ -43184,9 +43223,11 @@ Returns the current value of the input.", }, }, { + "description": "Returns the clickable sorting area of a column header.", "name": "findColumnSortingArea", "parameters": [ { + "description": "1-based index of the column.", "flags": { "isOptional": false, }, @@ -52495,8 +52536,22 @@ In this case, use findContentEditableElement() instead.", }, }, { + "description": "Returns column header cells from the table's header region. + +By default, returns all leaf-column headers (\`scope="col"\`). +For tables without column grouping this is equivalent to the previous behavior. +For tables with column grouping this excludes group header cells.", "name": "findColumnHeaders", - "parameters": [], + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ groupId?: string | undefined; }", + }, + ], "returnType": { "isNullable": false, "name": "MultiElementWrapper", @@ -52526,9 +52581,11 @@ In this case, use findContentEditableElement() instead.", }, }, { + "description": "Returns the clickable sorting area of a column header.", "name": "findColumnSortingArea", "parameters": [ { + "description": "1-based index of the column.", "flags": { "isOptional": false, }, diff --git a/src/table/__tests__/column-groups.test.tsx b/src/table/__tests__/column-groups.test.tsx new file mode 100644 index 0000000000..f2c35c920b --- /dev/null +++ b/src/table/__tests__/column-groups.test.tsx @@ -0,0 +1,1024 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import { PointerEventMock } from '../../../lib/components/internal/utils/pointer-events-mock'; +import Table, { TableProps } from '../../../lib/components/table'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +beforeAll(() => { + (window as any).PointerEvent ??= PointerEventMock; +}); + +interface Item { + id: string; + name: string; + type: string; + az: string; + cpu: number; + memory: number; +} + +const items: Item[] = [ + { id: 'i-1', name: 'web', type: 't3.medium', az: 'us-east-1a', cpu: 45, memory: 62 }, + { id: 'i-2', name: 'api', type: 't3.large', az: 'us-east-1b', cpu: 78, memory: 81 }, +]; + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', cell: item => item.id }, + { id: 'name', header: 'Name', cell: item => item.name }, + { id: 'type', header: 'Type', cell: item => item.type }, + { id: 'az', header: 'AZ', cell: item => item.az }, + { id: 'cpu', header: 'CPU', cell: item => `${item.cpu}%` }, + { id: 'memory', header: 'Memory', cell: item => `${item.memory}%` }, +]; + +const groupDefinitions: TableProps.GroupDefinition[] = [ + { id: 'config', header: 'Configuration' }, + { id: 'perf', header: 'Performance' }, +]; + +const singleLevelDisplay: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + ], + }, + { + type: 'group', + id: 'perf', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, +]; + +function renderTable(props: Partial> = {}) { + const { container } = render( +
+ ); + return createWrapper(container).findTable()!; +} + +describe('Column grouping rendering', () => { + test('renders two header rows for single-level grouping', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows).toHaveLength(2); + }); + + test('renders group header cells with correct colspan', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const firstRow = thead.findAll('tr')[0]; + const groupCells = firstRow.findAll('th[scope="colgroup"]'); + + expect(groupCells).toHaveLength(2); + expect(groupCells[0].getElement().getAttribute('colspan')).toBe('2'); + expect(groupCells[1].getElement().getAttribute('colspan')).toBe('2'); + }); + + test('renders group header labels', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const firstRow = thead.findAll('tr')[0]; + const groupCells = firstRow.findAll('th[scope="colgroup"]'); + + expect(groupCells[0].getElement().textContent).toContain('Configuration'); + expect(groupCells[1].getElement().textContent).toContain('Performance'); + }); + + test('ungrouped columns get rowspan=2 in first row', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const firstRow = thead.findAll('tr')[0]; + const columnCells = firstRow.findAll('th[scope="col"]'); + + // id and name are ungrouped, should span both rows + const idCell = columnCells.find(el => el.getElement().textContent?.includes('ID')); + const nameCell = columnCells.find(el => el.getElement().textContent?.includes('Name')); + expect(idCell!.getElement().getAttribute('rowspan')).toBe('2'); + expect(nameCell!.getElement().getAttribute('rowspan')).toBe('2'); + }); + + test('columns under groups appear in second row', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const secondRow = thead.findAll('tr')[1]; + const cells = secondRow.findAll('th[scope="col"]'); + + const labels = cells.map(c => c.getElement().textContent?.trim()); + expect(labels).toEqual(expect.arrayContaining(['Type', 'AZ', 'CPU', 'Memory'])); + expect(cells).toHaveLength(4); + }); + + test('columns under groups have data-column-group-id', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + + const configColumns = thead.findAll('th[data-column-group-id="config"]'); + const perfColumns = thead.findAll('th[data-column-group-id="perf"]'); + + expect(configColumns).toHaveLength(2); // type, az + expect(perfColumns).toHaveLength(2); // cpu, memory + }); + + test('columns have data-column-index', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const columnCells = thead.findAll('th[data-column-index]'); + + // All 6 columns should have data-column-index + expect(columnCells).toHaveLength(6); + expect(columnCells[0].getElement().getAttribute('data-column-index')).toBe('1'); + expect(columnCells[5].getElement().getAttribute('data-column-index')).toBe('6'); + }); + + test('group header cells do not have data-column-index', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + groupCells.forEach(cell => { + expect(cell.getElement().hasAttribute('data-column-index')).toBe(false); + }); + }); + + test('findColumnHeaders returns only columns by default', () => { + const wrapper = renderTable(); + const headers = wrapper.findColumnHeaders(); + + expect(headers.length).toBeGreaterThanOrEqual(6); + const texts = headers.map(h => h.getElement().textContent); + expect(texts).toContain('ID'); + expect(texts.find(t => t?.includes('Memory'))).toBeDefined(); + }); + + test('findColumnHeaders with groupId returns only that group columns', () => { + const wrapper = renderTable(); + const configHeaders = wrapper.findColumnHeaders({ groupId: 'config' }); + const perfHeaders = wrapper.findColumnHeaders({ groupId: 'perf' }); + + expect(configHeaders).toHaveLength(2); + expect(configHeaders[0].getElement().textContent).toContain('Type'); + expect(configHeaders[1].getElement().textContent).toContain('AZ'); + expect(perfHeaders).toHaveLength(2); + }); + + test('renders single row when no groupDefinitions provided', () => { + const wrapper = renderTable({ groupDefinitions: undefined, columnDisplay: undefined }); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows).toHaveLength(1); + }); + + test('renders resizers on group header cells when resizableColumns is true', () => { + const wrapper = renderTable({ resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + groupCells.forEach(cell => { + expect(cell.find('[class*="resizer"]')).not.toBeNull(); + }); + }); + + test('renders dividers on non-rightmost cells when resizableColumns is false', () => { + const wrapper = renderTable({ resizableColumns: false }); + const thead = wrapper.find('thead')!; + + // All non-rightmost column cells should have a divider + const columnCells = thead.findAll('th[scope="col"]'); + const nonRightmost = columnCells.filter(c => !c.getElement().hasAttribute('data-rightmost')); + nonRightmost.forEach(cell => { + expect(cell.find('[class*="divider"]')).not.toBeNull(); + }); + + // Rightmost cell should not have a divider (CSS hides it via data-rightmost) + const rightmost = columnCells.find(c => c.getElement().hasAttribute('data-rightmost')); + expect(rightmost).toBeDefined(); + }); + + test('selection cell spans all header rows', () => { + const wrapper = renderTable({ selectionType: 'multi' }); + const thead = wrapper.find('thead')!; + const firstRow = thead.findAll('tr')[0]; + const selectionCell = firstRow.findAll('th')[0]; + + expect(selectionCell.getElement().getAttribute('rowspan')).toBe('2'); + }); + + test('hidden columns are excluded from rendering', () => { + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: false }, + ], + }, + ]; + const wrapper = renderTable({ columnDisplay: display }); + const headers = wrapper.findColumnHeaders(); + + const labels = headers.map(h => h.getElement().textContent?.trim()); + expect(labels).toContain('ID'); + expect(labels).toContain('Type'); + expect(labels).not.toContain('AZ'); + }); + + test('group is omitted when all children are hidden', () => { + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: false }, + { id: 'az', visible: false }, + ], + }, + { type: 'group', id: 'perf', visible: true, children: [{ id: 'cpu', visible: true }] }, + ]; + const wrapper = renderTable({ columnDisplay: display }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + // Only perf group should render + expect(groupCells).toHaveLength(1); + expect(groupCells[0].getElement().textContent).toContain('Performance'); + }); +}); + +describe('Column grouping with sticky columns', () => { + test('renders correctly with stickyColumns first', () => { + const wrapper = renderTable({ stickyColumns: { first: 1 } }); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows).toHaveLength(2); + + // First column (id) should have sticky styles + const firstCol = thead.find('th[data-column-index="1"]')!; + expect(firstCol.getElement().style.position || firstCol.getElement().className).toBeDefined(); + }); + + test('renders correctly with stickyColumns last', () => { + const wrapper = renderTable({ stickyColumns: { last: 1 } }); + const thead = wrapper.find('thead')!; + const columnCells = thead.findAll('th[scope="col"]'); + expect(columnCells.length).toBe(6); + }); + + test('group spanning sticky-first boundary renders split cells', () => { + // stickyColumns.first = 3 means columns at index 0,1,2 are sticky (id, name, type) + // 'config' group has type(colIndex=2), az(colIndex=3) — straddles boundary + const wrapper = renderTable({ stickyColumns: { first: 3 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + // config group split into 2 halves + perf group = 3 group cells + expect(groupCells.length).toBe(3); + + // The split config halves: first half has colspan=1 (type), second has colspan=1 (az) + const configCells = groupCells.filter(c => c.getElement().textContent?.includes('Configuration')); + expect(configCells).toHaveLength(2); + expect(configCells[0].getElement().getAttribute('colspan')).toBe('1'); + expect(configCells[1].getElement().getAttribute('colspan')).toBe('1'); + }); + + test('group spanning sticky-last boundary renders split cells', () => { + // stickyColumns.last = 1 means last column (memory, colIndex=5) is sticky + // 'perf' group has cpu(colIndex=4), memory(colIndex=5) — straddles boundary + const wrapper = renderTable({ stickyColumns: { last: 1 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + // perf group split into 2 halves + config group = 3 group cells + expect(groupCells.length).toBe(3); + + const perfCells = groupCells.filter(c => c.getElement().textContent?.includes('Performance')); + expect(perfCells).toHaveLength(2); + }); + + test('fully sticky group (all children within boundary) is not split', () => { + // stickyColumns.first = 4 means id, name, type, az are sticky + // 'config' group has type(2), az(3) — both within boundary, no split + const wrapper = renderTable({ stickyColumns: { first: 4 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + const configCells = groupCells.filter(c => c.getElement().textContent?.includes('Configuration')); + expect(configCells).toHaveLength(1); + expect(configCells[0].getElement().getAttribute('colspan')).toBe('2'); + }); + + test('group entirely outside sticky boundary is not split', () => { + // stickyColumns.first = 1 means only id is sticky + // Both groups are entirely outside the boundary + const wrapper = renderTable({ stickyColumns: { first: 1 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + expect(groupCells).toHaveLength(2); + expect(groupCells[0].getElement().getAttribute('colspan')).toBe('2'); + expect(groupCells[1].getElement().getAttribute('colspan')).toBe('2'); + }); +}); + +describe('Column grouping with resizable columns', () => { + test('group header cells have resizers', () => { + const wrapper = renderTable({ resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + groupCells.forEach(cell => { + const resizer = cell.find('button[class*="resizer"]'); + expect(resizer).not.toBeNull(); + }); + }); + + test('column cells have resizers', () => { + const wrapper = renderTable({ resizableColumns: true }); + const thead = wrapper.find('thead')!; + const columnCells = thead.findAll('th[scope="col"]'); + + columnCells.forEach(cell => { + const resizer = cell.find('button[class*="resizer"]'); + expect(resizer).not.toBeNull(); + }); + }); + + test('findColumnResizer works with grouped columns', () => { + const wrapper = renderTable({ resizableColumns: true }); + // Column index 3 = 'type' (first child of config group) + const resizer = wrapper.findColumnResizer(3); + expect(resizer).not.toBeNull(); + }); + + test('group resizer has aria-labelledby pointing to group header', () => { + const wrapper = renderTable({ resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCell = thead.findAll('th[scope="colgroup"]')[0]; + const headerId = groupCell.find('[id^="table-group-header"]')!.getElement().id; + const resizer = groupCell.find('button[class*="resizer"]')!; + + expect(resizer.getElement().getAttribute('aria-labelledby')).toBe(headerId); + }); + + test('renders resizable grouped table with onColumnWidthsChange callback', () => { + const onColumnWidthsChange = jest.fn(); + const wrapper = renderTable({ resizableColumns: true, onColumnWidthsChange }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + expect(thead.findAll('button[class*="resizer"]').length).toBeGreaterThanOrEqual(2); + }); + + test('columns have width styles when resizable', () => { + const colDefs = columnDefinitions.map(col => ({ ...col, width: 150 })); + const wrapper = renderTable({ resizableColumns: true, columnDefinitions: colDefs }); + const columnCells = wrapper.findColumnHeaders(); + + // At least some cells should have width set + const hasWidth = columnCells.some(cell => cell.getElement().style.width !== ''); + expect(hasWidth).toBe(true); + }); +}); + +describe('Column grouping with sticky header', () => { + test('renders with stickyHeader enabled', () => { + const wrapper = renderTable({ stickyHeader: true }); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows).toHaveLength(2); + }); + + test('sticky header renders group cells', () => { + const wrapper = renderTable({ stickyHeader: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + expect(groupCells).toHaveLength(2); + }); + + test('sticky header with resizable columns renders correctly', () => { + const wrapper = renderTable({ stickyHeader: true, resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + expect(groupCells).toHaveLength(2); + + groupCells.forEach(cell => { + expect(cell.find('button[class*="resizer"]')).not.toBeNull(); + }); + }); + + test('sticky header with sticky columns and groups', () => { + const wrapper = renderTable({ stickyHeader: true, stickyColumns: { first: 2 } }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('tr')).toHaveLength(2); + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + }); +}); + +describe('Column grouping with selection', () => { + test('multi selection with groups renders correctly', () => { + const wrapper = renderTable({ selectionType: 'multi' }); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows).toHaveLength(2); + + // Selection cell in first row spans all header rows + const firstRowCells = rows[0].findAll('th'); + const selectionCell = firstRowCells[0]; + expect(selectionCell.getElement().getAttribute('rowspan')).toBe('2'); + }); + + test('single selection with groups renders correctly', () => { + const wrapper = renderTable({ selectionType: 'single' }); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows).toHaveLength(2); + }); + + test('renders colgroup with selection col for grouped table', () => { + const wrapper = renderTable({ selectionType: 'multi', resizableColumns: true }); + const cols = wrapper.getElement().querySelectorAll('colgroup col'); + // 6 data columns + 1 selection col + expect(cols.length).toBe(7); + }); +}); + +describe('Column grouping with other features', () => { + test('renders with wrapLines enabled', () => { + const wrapper = renderTable({ wrapLines: true }); + const headers = wrapper.findColumnHeaders(); + expect(headers.length).toBeGreaterThanOrEqual(6); + }); + + test('renders with stripedRows enabled', () => { + const wrapper = renderTable({ stripedRows: true }); + const headers = wrapper.findColumnHeaders(); + expect(headers.length).toBeGreaterThanOrEqual(6); + }); + + test('renders with contentDensity compact', () => { + const wrapper = renderTable({ contentDensity: 'compact' }); + const headers = wrapper.findColumnHeaders(); + expect(headers.length).toBeGreaterThanOrEqual(6); + }); + + test('renders with sortingDisabled', () => { + const wrapper = renderTable({ sortingDisabled: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + expect(groupCells).toHaveLength(2); + }); + + test('renders in loading state', () => { + const wrapper = renderTable({ loading: true, loadingText: 'Loading...' }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('tr')).toHaveLength(2); + }); + + test('renders with empty items', () => { + const wrapper = renderTable({ items: [] }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('tr')).toHaveLength(2); + const groupCells = thead.findAll('th[scope="colgroup"]'); + expect(groupCells).toHaveLength(2); + }); + + test('renders with variant full-page', () => { + const wrapper = renderTable({ variant: 'full-page' }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + }); + + test('renders with variant borderless', () => { + const wrapper = renderTable({ variant: 'borderless' }); + const headers = wrapper.findColumnHeaders(); + expect(headers.length).toBeGreaterThanOrEqual(6); + }); + + test('renders with enableKeyboardNavigation', () => { + const wrapper = renderTable({ enableKeyboardNavigation: true }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + }); +}); + +describe('Column grouping sorting', () => { + test('findColumnSortingArea works with grouped columns', () => { + const sortableColumns: TableProps.ColumnDefinition[] = columnDefinitions.map(col => ({ + ...col, + sortingField: col.id, + })); + const { container } = render( +
+ ); + const tableWrapper = createWrapper(container).findTable()!; + const sortArea = tableWrapper.findColumnSortingArea(3); + expect(sortArea).not.toBeNull(); + }); + + test('sorting area is not present on group header cells', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + groupCells.forEach(cell => { + expect(cell.find('[role="button"]')).toBeNull(); + }); + }); +}); + +describe('Column grouping divider positioning', () => { + test('group header cells render dividers', () => { + const wrapper = renderTable({ resizableColumns: false }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + // Non-rightmost groups should have dividers + const nonRightmostGroups = groupCells.filter(c => !c.getElement().hasAttribute('data-rightmost')); + nonRightmostGroups.forEach(cell => { + expect(cell.find('[class*="divider"]')).not.toBeNull(); + }); + }); + + test('column cells under groups render dividers', () => { + const wrapper = renderTable({ resizableColumns: false }); + const thead = wrapper.find('thead')!; + const groupedLeaves = thead.findAll('th[data-column-group-id]'); + + groupedLeaves.forEach(cell => { + expect(cell.find('[class*="divider"]')).not.toBeNull(); + }); + }); + + test('rightmost cell has data-rightmost attribute', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const rightmostCells = thead.findAll('th[data-rightmost]'); + expect(rightmostCells.length).toBeGreaterThanOrEqual(1); + }); + + test('non-rightmost cells do not have data-rightmost', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const typeCell = thead.find('th[data-column-index="3"]')!; + expect(typeCell.getElement().hasAttribute('data-rightmost')).toBe(false); + }); +}); + +describe('Column grouping with keyboard navigation', () => { + test('renders with enableKeyboardNavigation', () => { + const wrapper = renderTable({ enableKeyboardNavigation: true }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + expect(thead.findAll('th[scope="col"]')).toHaveLength(6); + }); + + test('group cells have correct aria-colindex', () => { + const wrapper = renderTable({ enableKeyboardNavigation: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + // config group starts at colIndex 2 (0-based), rendered as aria-colindex 3 (1-based) + const configGroup = groupCells.find(c => c.getElement().textContent?.includes('Configuration')); + const perfGroup = groupCells.find(c => c.getElement().textContent?.includes('Performance')); + + expect(configGroup!.getElement().getAttribute('aria-colindex')).toBeDefined(); + expect(perfGroup!.getElement().getAttribute('aria-colindex')).toBeDefined(); + }); + + test('column cells have correct aria-colindex', () => { + const wrapper = renderTable({ enableKeyboardNavigation: true }); + const thead = wrapper.find('thead')!; + + // type is at colIndex 2 (0-based), aria-colindex should be 3 (1-based) + const typeCell = thead.find('th[data-column-index="3"]')!; + expect(typeCell.getElement().getAttribute('aria-colindex')).toBe('3'); + }); +}); + +describe('Column grouping aria attributes', () => { + test('group cells have scope=colgroup', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + expect(groupCells).toHaveLength(2); + }); + + test('column cells have scope=col', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const columnCells = thead.findAll('th[scope="col"]'); + expect(columnCells).toHaveLength(6); + }); + + test('header rows have aria-rowindex', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + + expect(rows[0].getElement().getAttribute('aria-rowindex')).toBe('1'); + expect(rows[1].getElement().getAttribute('aria-rowindex')).toBe('2'); + }); +}); + +describe('Column grouping sticky split rendering', () => { + test('split group renders two group cells with updateGroupWidth callbacks', () => { + // stickyColumns.first = 3: id(0), name(1), type(2) are sticky + // config group has type(2), az(3) — straddles boundary + const wrapper = renderTable({ stickyColumns: { first: 3 }, resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + // config split into 2 + perf = 3 + expect(groupCells.length).toBe(3); + }); + + test('split group with stickyColumns.last renders correctly', () => { + // stickyColumns.last = 1: memory(5) is sticky + // perf group has cpu(4), memory(5) — straddles boundary + const wrapper = renderTable({ stickyColumns: { last: 1 }, resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + expect(groupCells.length).toBe(3); + }); + + test('fully sticky group gets stickyColumnId from first child', () => { + // stickyColumns.first = 4: id, name, type, az are sticky + // config group (type, az) is fully within sticky boundary + const wrapper = renderTable({ stickyColumns: { first: 4 }, resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + const configGroup = groupCells.find(c => c.getElement().textContent?.includes('Configuration')); + expect(configGroup).toBeDefined(); + }); + + test('fully sticky last group gets stickyColumnId from last child', () => { + // stickyColumns.last = 2: cpu(4), memory(5) are sticky + // perf group (cpu, memory) is fully within sticky-last boundary + const wrapper = renderTable({ stickyColumns: { last: 2 }, resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + const perfGroup = groupCells.find(c => c.getElement().textContent?.includes('Performance')); + expect(perfGroup).toBeDefined(); + }); +}); + +describe('Column grouping focus handling', () => { + test('onFocusedComponentChange is called on header focus', () => { + const { container } = render( +
+ ); + const wrapper = createWrapper(container).findTable()!; + const thead = wrapper.find('thead')!; + const firstRow = thead.findAll('tr')[0]; + + // Focus a header cell — verify it has focus tracking wired up + const th = firstRow.findAll('th')[0]; + fireEvent.focus(th.getElement()); + expect(th.getElement().getAttribute('data-focus-id')).toBeTruthy(); + }); + + test('onBlur resets focused component', () => { + const { container } = render( +
+ ); + const wrapper = createWrapper(container).findTable()!; + const thead = wrapper.find('thead')!; + const firstRow = thead.findAll('tr')[0]; + + const th = firstRow.findAll('th')[0]; + fireEvent.focus(th.getElement()); + fireEvent.blur(th.getElement()); + // After blur, the focus indicator should be removed + expect(th.getElement().classList.toString()).not.toContain('fake-focus'); + }); +}); + +describe('Column grouping with non-resizable columns', () => { + test('grouped column cells get inline styles when not resizable', () => { + const colDefs = columnDefinitions.map(col => ({ ...col, width: 150, minWidth: 100 })); + const wrapper = renderTable({ resizableColumns: false, columnDefinitions: colDefs }); + const thead = wrapper.find('thead')!; + const columnCells = thead.findAll('th[scope="col"]'); + // Cells should have width styles applied directly + expect(columnCells.length).toBe(6); + }); + + test('sorting fires onSortingChange for grouped columns', () => { + const onSortingChange = jest.fn(); + const sortableColumns = columnDefinitions.map(col => ({ ...col, sortingField: col.id })); + const { container } = render( +
onSortingChange(event.detail)} + /> + ); + const wrapper = createWrapper(container).findTable()!; + const sortArea = wrapper.findColumnSortingArea(3); + sortArea!.click(); + expect(onSortingChange).toHaveBeenCalledWith( + expect.objectContaining({ sortingColumn: expect.objectContaining({ id: 'type' }) }) + ); + }); +}); + +describe('Column grouping resize interactions', () => { + test('grouped resizable table renders with colgroup and col elements', () => { + const colDefs = columnDefinitions.map(col => ({ ...col, width: 150 })); + const { container } = render( +
+ ); + const colgroup = container.querySelector('colgroup'); + expect(colgroup).not.toBeNull(); + const cols = colgroup!.querySelectorAll('col'); + // 6 columns + expect(cols.length).toBe(6); + }); + + test('col elements have data-column-id attributes', () => { + const colDefs = columnDefinitions.map(col => ({ ...col, width: 150 })); + const { container } = render( +
+ ); + const cols = container.querySelectorAll('col[data-column-id]'); + expect(cols.length).toBe(6); + expect(cols[0].getAttribute('data-column-id')).toBe('id'); + expect(cols[5].getAttribute('data-column-id')).toBe('memory'); + }); + + test('non-grouped resizable table does not render colgroup', () => { + const colDefs = columnDefinitions.map(col => ({ ...col, width: 150 })); + const { container } = render(
); + const colgroup = container.querySelector('colgroup'); + expect(colgroup).toBeNull(); + }); +}); + +describe('Column grouping keyboard navigation', () => { + test('handles arrow key events across grouped header rows', () => { + const { container } = render( +
({ ...col, sortingField: col.id }))} + items={items} + groupDefinitions={groupDefinitions} + columnDisplay={singleLevelDisplay} + enableKeyboardNavigation={true} + /> + ); + const table = container.querySelector('table')!; + const thead = container.querySelector('thead')!; + const firstTh = thead.querySelector('th')!; + + // Focus the first header cell + firstTh.focus(); + + // Press arrow down — should move to first body cell + fireEvent.keyDown(table, { key: 'ArrowDown', keyCode: 40 }); + expect(container.querySelector('tbody td')).toBeTruthy(); + }); + + test('handles keyboard events on cells with colspan', () => { + const { container } = render( +
({ ...col, sortingField: col.id }))} + items={items} + groupDefinitions={groupDefinitions} + columnDisplay={singleLevelDisplay} + enableKeyboardNavigation={true} + /> + ); + const table = container.querySelector('table')!; + const thead = container.querySelector('thead')!; + + // Focus a group header cell (has colspan) + const groupTh = thead.querySelector('th[scope="colgroup"]') as HTMLElement; + groupTh.focus(); + + // Navigate down from group header to column row + fireEvent.keyDown(table, { key: 'ArrowDown', keyCode: 40 }); + + // Column cells exist in the second header row for navigation targets + const secondRow = thead.querySelectorAll('tr')[1]; + expect(secondRow.querySelector('th')).toBeTruthy(); + }); +}); + +describe('Column grouping vertical navigation with rowspan', () => { + test('handles arrow up from body with rowspan header cells', () => { + const { container } = render( +
({ ...col, sortingField: col.id }))} + items={items} + groupDefinitions={groupDefinitions} + columnDisplay={singleLevelDisplay} + enableKeyboardNavigation={true} + /> + ); + const table = container.querySelector('table')!; + const thead = container.querySelector('thead')!; + const tbody = container.querySelector('tbody')!; + const firstBodyCell = tbody.querySelector('td') as HTMLElement; + + // Focus a body cell + firstBodyCell.focus(); + + // Navigate up — should go to the header cell in the same column + fireEvent.keyDown(table, { key: 'ArrowUp', keyCode: 38 }); + expect(thead.querySelector('th')).toBeTruthy(); + }); + + test('handles arrow up from column header row', () => { + const { container } = render( +
({ ...col, sortingField: col.id }))} + items={items} + groupDefinitions={groupDefinitions} + columnDisplay={singleLevelDisplay} + enableKeyboardNavigation={true} + /> + ); + const table = container.querySelector('table')!; + const thead = container.querySelector('thead')!; + + // Focus a column cell in the second header row + const secondRow = thead.querySelectorAll('tr')[1]; + const columnTh = secondRow?.querySelector('th') as HTMLElement; + if (columnTh) { + columnTh.focus(); + // Navigate up — should go to the group header in the first row + fireEvent.keyDown(table, { key: 'ArrowUp', keyCode: 38 }); + expect(thead.querySelector('th[scope="colgroup"]')).toBeTruthy(); + } + }); +}); + +describe('Column grouping with sticky header scrolling', () => { + test('renders with stickyHeader and grouped columns without error', () => { + const { container } = render( +
+ ); + const wrapper = createWrapper(container).findTable()!; + expect(wrapper.find('thead')).not.toBeNull(); + // Sticky header with grouped columns renders both header rows + const thead = wrapper.find('thead')!; + expect(thead.findAll('tr').length).toBe(2); + }); +}); +describe('Column grouping group resize callbacks', () => { + const resizableColDefs = columnDefinitions.map(col => ({ ...col, width: 200, minWidth: 100 })); + + function renderResizableGroupedTable(props: Partial> = {}) { + const { container } = render( +
+ ); + return createWrapper(container).findTable()!; + } + + test('group header can be resized with pointer drag', () => { + const wrapper = renderResizableGroupedTable(); + const thead = wrapper.find('thead')!; + const groupCell = thead.findAll('th[scope="colgroup"]')[0]; + const resizerBtn = groupCell.find('button')!; + + resizerBtn.fireEvent(new PointerEvent('pointerdown', { pointerType: 'mouse', button: 0, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'mouse', bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointerup', { pointerType: 'mouse', bubbles: true })); + + // No error — updateGroup was called + expect(thead.findAll('th[scope="colgroup"]').length).toBe(2); + }); + + test('group resize completes full pointer lifecycle without errors', () => { + const onColumnWidthsChange = jest.fn(); + const wrapper = renderResizableGroupedTable({ onColumnWidthsChange }); + const thead = wrapper.find('thead')!; + const groupCell = thead.findAll('th[scope="colgroup"]')[0]; + const resizerBtn = groupCell.find('button')!; + + resizerBtn.fireEvent(new PointerEvent('pointerdown', { pointerType: 'mouse', button: 0, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'mouse', clientX: 100, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointerup', { pointerType: 'mouse', bubbles: true })); + + // Table structure remains intact after resize lifecycle + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + expect(thead.findAll('th[scope="col"]')).toHaveLength(6); + }); + + test('split group resize works with stickyColumns.first', () => { + const wrapper = renderResizableGroupedTable({ stickyColumns: { first: 3 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + // Split group should have resizers + expect(groupCells.length).toBe(3); + const splitGroupCell = groupCells[0]; + const resizerBtn = splitGroupCell.find('button')!; + expect(resizerBtn).not.toBeNull(); + + resizerBtn.fireEvent(new PointerEvent('pointerdown', { pointerType: 'mouse', button: 0, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointerup', { pointerType: 'mouse', bubbles: true })); + + // Table structure remains intact after split resize + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(3); + expect(thead.findAll('th[scope="col"]')).toHaveLength(6); + }); + + test('split group resize works with stickyColumns.last', () => { + const wrapper = renderResizableGroupedTable({ stickyColumns: { last: 1 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + expect(groupCells.length).toBe(3); + const lastSplitCell = groupCells[groupCells.length - 1]; + const resizerBtn = lastSplitCell.find('button')!; + expect(resizerBtn).not.toBeNull(); + + resizerBtn.fireEvent(new PointerEvent('pointerdown', { pointerType: 'mouse', button: 0, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointerup', { pointerType: 'mouse', bubbles: true })); + + // Table structure remains intact after split resize + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(3); + expect(thead.findAll('th[scope="col"]')).toHaveLength(6); + }); + + test('column resize completes pointer lifecycle in grouped table', () => { + const wrapper = renderResizableGroupedTable(); + const resizer = wrapper.findColumnResizer(3); + expect(resizer).not.toBeNull(); + + resizer!.fireEvent(new PointerEvent('pointerdown', { pointerType: 'mouse', button: 0, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'mouse', clientX: 100, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointerup', { pointerType: 'mouse', bubbles: true })); + + // Columns and group structure remain intact after resize + const thead = wrapper.find('thead')!; + expect(thead.findAll('th[scope="col"]')).toHaveLength(6); + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + }); +}); diff --git a/src/table/__tests__/columns-width.test.tsx b/src/table/__tests__/columns-width.test.tsx index 3dfb4ab564..9ec3a24158 100644 --- a/src/table/__tests__/columns-width.test.tsx +++ b/src/table/__tests__/columns-width.test.tsx @@ -6,7 +6,19 @@ import { render } from '@testing-library/react'; import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; import Table, { TableProps } from '../../../lib/components/table'; +import { ColumnWidthsProvider, useColumnWidths } from '../../../lib/components/table/use-column-widths'; import createWrapper, { ElementWrapper } from '../../../lib/components/test-utils/dom'; +import { fakeBoundingClientRect, firePointerdown, firePointermove, firePointerup } from './utils/resize-actions'; + +jest.mock('../../../lib/components/internal/utils/scrollable-containers', () => ({ + ...jest.requireActual('../../../lib/components/internal/utils/scrollable-containers'), + getOverflowParents: jest.fn(() => { + const overflowParent = document.createElement('div'); + overflowParent.style.width = '1000px'; + overflowParent.getBoundingClientRect = fakeBoundingClientRect; + return [overflowParent]; + }), +})); jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), @@ -37,6 +49,57 @@ interface Item { const defaultItems = [{ id: 0, text: 'test' }]; +test('useColumnWidths returns safe defaults without provider', () => { + function Bare() { + const ctx = useColumnWidths(); + expect(ctx.getColumnStyles(false, 'x')).toEqual({}); + expect(ctx.columnWidths).toEqual(new Map()); + expect(() => ctx.updateColumn('x', 100)).not.toThrow(); + expect(() => ctx.updateGroup('x', 100)).not.toThrow(); + expect(() => ctx.setCell(false, 'x', null)).not.toThrow(); + expect(() => ctx.setCol('x', null)).not.toThrow(); + return null; + } + render(); +}); + +test('updateGroup does not crash without groupColumnMap', () => { + let updateGroup: (groupId: PropertyKey, width: number) => void; + function Consumer() { + ({ updateGroup } = useColumnWidths()); + return null; + } + const containerRef = { current: document.createElement('div') } as React.RefObject; + render( + + + + ); + // No groupColumnMap passed → guard returns early + expect(() => updateGroup!('any', 200)).not.toThrow(); +}); + +test('updateGroup does not crash for unknown groupId', () => { + let updateGroup: (groupId: PropertyKey, width: number) => void; + function Consumer() { + ({ updateGroup } = useColumnWidths()); + return null; + } + const containerRef = { current: document.createElement('div') } as React.RefObject; + render( + + + + ); + // groupColumnMap exists but 'unknown' not in it → columnIds=[], rightmostColumn undefined → guard returns + expect(() => updateGroup!('unknown', 200)).not.toThrow(); +}); + test('assigns width configuration to columns', () => { const columns: TableProps.ColumnDefinition[] = [ { header: 'id', cell: item => item.id, minWidth: '30%', width: '50%', maxWidth: '80%' }, @@ -271,3 +334,112 @@ describe('with stickyHeader=true', () => { ]); }); }); + +describe('with grouped columns', () => { + const groupedColumns: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', cell: item => item.id, width: 150 }, + { id: 'name', header: 'Name', cell: item => item.text, width: 150 }, + { id: 'type', header: 'Type', cell: () => '-', width: 200 }, + { id: 'az', header: 'AZ', cell: () => '-', width: 200 }, + ]; + const groupDefinitions: TableProps.GroupDefinition[] = [{ id: 'config', header: 'Configuration' }]; + const columnDisplay: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + ], + }, + ]; + + function renderGroupedTable(props: Partial> = {}) { + const { container } = render( +
+ ); + return createWrapper(container).findTable()!; + } + + test('renders colgroup with col elements for grouped resizable table', () => { + const wrapper = renderGroupedTable(); + const cols = wrapper.getElement().querySelectorAll('colgroup col'); + expect(cols.length).toBe(4); + }); + + test('assigns widths to columns in grouped table', () => { + const wrapper = renderGroupedTable(); + const columnCells = wrapper.findAll('thead th[scope="col"]'); + expect(columnCells[0].getElement().style.width).toBe('150px'); + expect(columnCells[1].getElement().style.width).toBe('150px'); + expect(columnCells[2].getElement().style.width).toBe('200px'); + expect(columnCells[3].getElement().style.width).toBe('200px'); + }); + + test('resizing a group applies the width delta to the last column in the group', () => { + const onColumnWidthsChange = jest.fn(); + const wrapper = renderGroupedTable({ onColumnWidthsChange }); + const groupCell = wrapper.find('thead th[scope="colgroup"]')!; + const resizerBtn = new ElementWrapper(groupCell.find('button')!.getElement()); + + firePointerdown(resizerBtn); + firePointermove(500); + firePointerup(500); + + expect(onColumnWidthsChange).toHaveBeenCalledTimes(1); + // Group total was 400 (200+200), resized to 500 → delta 100 applied to last column 'az' + expect(onColumnWidthsChange.mock.calls[0][0].detail).toEqual({ widths: [150, 150, 200, 300] }); + }); + + test('shrinking a group only reduces the last column in the group', () => { + const onColumnWidthsChange = jest.fn(); + const wrapper = renderGroupedTable({ onColumnWidthsChange }); + const groupCell = wrapper.find('thead th[scope="colgroup"]')!; + const resizerBtn = new ElementWrapper(groupCell.find('button')!.getElement()); + + firePointerdown(resizerBtn); + firePointermove(350); + firePointerup(350); + + // Group shrunk from 400 to 350 → delta -50 applied to last column 'az' (200→150) + expect(onColumnWidthsChange.mock.calls[0][0].detail).toEqual({ widths: [150, 150, 200, 150] }); + }); + + test('resizing a split group applies delta to the last column of the split half', () => { + const onColumnWidthsChange = jest.fn(); + const wrapper = renderGroupedTable({ onColumnWidthsChange, stickyColumns: { first: 3 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + // With stickyColumns.first=3: id(0), name(1), type(2) are sticky. + // config group (type, az) straddles boundary → split into left (type) and right (az). + const leftSplitCell = groupCells[0]; + const leftResizerBtn = new ElementWrapper(leftSplitCell.find('button')!.getElement()); + + firePointerdown(leftResizerBtn); + firePointermove(250); + firePointerup(250); + + expect(onColumnWidthsChange).toHaveBeenCalledTimes(1); + + // Also resize the right half of the split group + onColumnWidthsChange.mockClear(); + const rightSplitCell = groupCells[1]; + const rightResizerBtn = rightSplitCell.find('button'); + if (rightResizerBtn) { + firePointerdown(new ElementWrapper(rightResizerBtn.getElement())); + firePointermove(300); + firePointerup(300); + expect(onColumnWidthsChange).toHaveBeenCalledTimes(1); + } + }); +}); diff --git a/src/table/column-groups/__tests__/fixtures.ts b/src/table/column-groups/__tests__/fixtures.ts new file mode 100644 index 0000000000..ef69a9d552 --- /dev/null +++ b/src/table/column-groups/__tests__/fixtures.ts @@ -0,0 +1,71 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { TableProps } from '../../interfaces'; + +export const COLUMN_DEFS: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', cell: () => 'id' }, + { id: 'name', header: 'Name', cell: () => 'name' }, + { id: 'cpu', header: 'CPU', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', cell: () => 'memory' }, + { id: 'networkIn', header: 'Network In', cell: () => 'networkIn' }, + { id: 'type', header: 'Type', cell: () => 'type' }, + { id: 'az', header: 'AZ', cell: () => 'az' }, + { id: 'cost', header: 'Cost', cell: () => 'cost' }, +]; + +export const ALL_IDS = COLUMN_DEFS.map(c => c.id!); + +export const GROUP_DEFS: TableProps.GroupDefinition[] = [ + { id: 'performance', header: 'Performance' }, + { id: 'config', header: 'Config' }, + { id: 'pricing', header: 'Pricing' }, +]; + +export const FLAT_DISPLAY: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + { id: 'networkIn', visible: true }, + ], + }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + ], + }, + { type: 'group', id: 'pricing', visible: true, children: [{ id: 'cost', visible: true }] }, +]; + +export const NESTED_GROUPS: TableProps.GroupDefinition[] = [ + { id: 'metrics', header: 'Metrics' }, + { id: 'performance', header: 'Performance' }, +]; + +export const NESTED_DISPLAY: TableProps.ColumnDisplayProperties[] = [ + { + type: 'group', + id: 'metrics', + visible: true, + children: [ + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, + ], + }, +]; diff --git a/src/table/column-groups/__tests__/split-utils.test.ts b/src/table/column-groups/__tests__/split-utils.test.ts new file mode 100644 index 0000000000..dbeba6f99c --- /dev/null +++ b/src/table/column-groups/__tests__/split-utils.test.ts @@ -0,0 +1,108 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { TableProps } from '../../interfaces'; +import { getGroupColumnIds, getGroupSplit } from '../split-utils'; +import { calculateHierarchyTree } from '../utils'; + +const COLUMN_DEFS: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', cell: () => 'id' }, + { id: 'name', header: 'Name', cell: () => 'name' }, + { id: 'type', header: 'Type', cell: () => 'type' }, + { id: 'az', header: 'AZ', cell: () => 'az' }, + { id: 'cpu', header: 'CPU', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', cell: () => 'memory' }, +]; + +const GROUP_DEFS: TableProps.GroupDefinition[] = [ + { id: 'config', header: 'Configuration' }, + { id: 'perf', header: 'Performance' }, +]; + +const DISPLAY: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + ], + }, + { + type: 'group', + id: 'perf', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, +]; + +const ALL_IDS = COLUMN_DEFS.map(c => c.id!); + +function buildStructure() { + return calculateHierarchyTree(COLUMN_DEFS, ALL_IDS, GROUP_DEFS, DISPLAY); +} + +describe('getGroupColumnIds', () => { + test('returns column IDs for a group', () => { + const structure = buildStructure(); + expect(getGroupColumnIds(structure, 'config')).toEqual(['type', 'az']); + expect(getGroupColumnIds(structure, 'perf')).toEqual(['cpu', 'memory']); + }); + + test('returns empty array for unknown group', () => { + const structure = buildStructure(); + expect(getGroupColumnIds(structure, 'nonexistent')).toEqual([]); + }); +}); + +describe('getGroupSplit', () => { + test('no split when group is fully within sticky-first boundary', () => { + const structure = buildStructure(); + const configGroup = structure.rows[0].columns.find(c => c.id === 'config')!; + const split = getGroupSplit({ col: configGroup, stickyCount: 4, side: 'first', totalColumns: 6 }); + expect(split.stickyColspan).toBe(0); + expect(split.staticColspan).toBe(0); + }); + + test('no split when group is fully outside sticky boundary', () => { + const structure = buildStructure(); + const configGroup = structure.rows[0].columns.find(c => c.id === 'config')!; + const split = getGroupSplit({ col: configGroup, stickyCount: 1, side: 'first', totalColumns: 6 }); + expect(split.stickyColspan).toBe(0); + expect(split.staticColspan).toBe(0); + }); + + test('detects split by sticky-first boundary', () => { + const structure = buildStructure(); + const configGroup = structure.rows[0].columns.find(c => c.id === 'config')!; + const split = getGroupSplit({ col: configGroup, stickyCount: 3, side: 'first', totalColumns: 6 }); + expect(split).toEqual({ stickyColspan: 1, staticColspan: 1 }); + }); + + test('detects split by sticky-last boundary', () => { + const structure = buildStructure(); + const perfGroup = structure.rows[0].columns.find(c => c.id === 'perf')!; + const split = getGroupSplit({ col: perfGroup, stickyCount: 1, side: 'last', totalColumns: 6 }); + expect(split).toEqual({ stickyColspan: 1, staticColspan: 1 }); + }); + + test('non-group cells return no split', () => { + const structure = buildStructure(); + const col = structure.rows[1].columns[0]; + const split = getGroupSplit({ col: col, stickyCount: 3, side: 'first', totalColumns: 6 }); + expect(split.stickyColspan).toBe(0); + }); + + test('no split when stickyCount is 0', () => { + const structure = buildStructure(); + const configGroup = structure.rows[0].columns.find(c => c.id === 'config')!; + const split = getGroupSplit({ col: configGroup, stickyCount: 0, side: 'first', totalColumns: 6 }); + expect(split.stickyColspan).toBe(0); + expect(split.staticColspan).toBe(0); + }); +}); diff --git a/src/table/column-groups/__tests__/use-column-groups.test.tsx b/src/table/column-groups/__tests__/use-column-groups.test.tsx new file mode 100644 index 0000000000..c0886f5a1c --- /dev/null +++ b/src/table/column-groups/__tests__/use-column-groups.test.tsx @@ -0,0 +1,94 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { renderHook } from '../../../__tests__/render-hook'; +import { TableProps } from '../../interfaces'; +import { useColumnGroups } from '../use-column-groups'; +import { ALL_IDS, COLUMN_DEFS, FLAT_DISPLAY, GROUP_DEFS, NESTED_DISPLAY, NESTED_GROUPS } from './fixtures'; + +const warnOnceMock = jest.fn(); +jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ + ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), + warnOnce: (...args: unknown[]) => warnOnceMock(...args), +})); + +afterEach(() => warnOnceMock.mockReset()); + +describe('useColumnGroups', () => { + describe('no grouping', () => { + test('returns a single flat row when no groups are defined', () => { + const { result } = renderHook(() => useColumnGroups(COLUMN_DEFS, ALL_IDS)); + expect(result.current.maxDepth).toBe(1); + expect(result.current.rows).toHaveLength(1); + expect(result.current.rows[0].columns).toHaveLength(COLUMN_DEFS.length); + }); + + test('treats empty groups array the same as no groups', () => { + const { result } = renderHook(() => useColumnGroups(COLUMN_DEFS, ALL_IDS, [])); + expect(result.current.maxDepth).toBe(1); + expect(result.current.rows).toHaveLength(1); + }); + }); + + describe('grouped columns', () => { + test('creates two rows for flat grouping', () => { + const { result } = renderHook(() => useColumnGroups(COLUMN_DEFS, ALL_IDS, GROUP_DEFS, FLAT_DISPLAY)); + expect(result.current.maxDepth).toBe(2); + expect(result.current.rows).toHaveLength(2); + }); + + test('creates three rows for nested grouping', () => { + const cols: TableProps.ColumnDefinition[] = [ + { id: 'cpu', header: 'CPU', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', cell: () => 'memory' }, + ]; + const { result } = renderHook(() => useColumnGroups(cols, ['cpu', 'memory'], NESTED_GROUPS, NESTED_DISPLAY)); + expect(result.current.maxDepth).toBe(3); + expect(result.current.rows).toHaveLength(3); + expect(result.current.columnToParentIds.get('cpu')).toEqual(['metrics', 'performance']); + }); + }); + + describe('visibleColumnIds filtering', () => { + test('excludes hidden columns via visibleColumnIds', () => { + const visibleIds = ['id', 'cpu']; + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { type: 'group', id: 'performance', visible: true, children: [{ id: 'cpu', visible: true }] }, + ]; + const { result } = renderHook(() => useColumnGroups(COLUMN_DEFS, visibleIds, GROUP_DEFS, display)); + const allIds = result.current.rows.flatMap(r => r.columns.map(c => c.id)); + expect(allIds).toContain('cpu'); + expect(allIds).not.toContain('memory'); + expect(allIds).not.toContain('type'); + }); + + test('hides a group entirely when all its children are outside visibleColumnIds', () => { + const visibleIds = ['id', 'name']; + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { type: 'group', id: 'performance', visible: true, children: [{ id: 'cpu', visible: false }] }, + ]; + const { result } = renderHook(() => useColumnGroups(COLUMN_DEFS, visibleIds, GROUP_DEFS, display)); + const groupIds = result.current.rows.flatMap(r => r.columns.filter(c => c.isGroup).map(c => c.id)); + expect(groupIds).not.toContain('performance'); + }); + }); + + describe('edge cases', () => { + test('handles columns without IDs gracefully', () => { + const cols: TableProps.ColumnDefinition[] = [{ header: 'No ID', cell: () => 'x' } as any]; + const { result } = renderHook(() => useColumnGroups(cols, ['0'], [])); + expect(result.current.rows).toBeDefined(); + }); + + test('warns in dev when a group referenced in columnDisplay is not in groupDefinitions', () => { + const display: TableProps.ColumnDisplayProperties[] = [ + { type: 'group', id: 'ghost-group', visible: true, children: [{ id: 'cpu', visible: true }] }, + ]; + renderHook(() => useColumnGroups(COLUMN_DEFS, ALL_IDS, [], display)); + + expect(warnOnceMock).toHaveBeenCalledWith('[Table]', expect.stringContaining('ghost-group')); + }); + }); +}); diff --git a/src/table/column-groups/__tests__/utils.test.ts b/src/table/column-groups/__tests__/utils.test.ts new file mode 100644 index 0000000000..b65be1d3ca --- /dev/null +++ b/src/table/column-groups/__tests__/utils.test.ts @@ -0,0 +1,252 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { TableProps } from '../../interfaces'; +import { calculateHierarchyTree, TableHeaderNode } from '../utils'; +import { ALL_IDS, COLUMN_DEFS, FLAT_DISPLAY, GROUP_DEFS, NESTED_DISPLAY, NESTED_GROUPS } from './fixtures'; + +describe('TableHeaderNode', () => { + test('creates node with default properties', () => { + const node = new TableHeaderNode('test-id'); + expect(node.id).toBe('test-id'); + expect(node.colSpan).toBe(1); + expect(node.rowSpan).toBe(1); + expect(node.children).toEqual([]); + expect(node.rowIndex).toBe(-1); + expect(node.colIndex).toBe(-1); + expect(node.isRoot).toBe(false); + expect(node.isColumn).toBe(true); + expect(node.isGroup).toBe(false); + }); + + test('accepts constructor props and identifies node types', () => { + const colDef: TableProps.ColumnDefinition = { id: 'col', header: 'Col', cell: () => 'col' }; + const groupDef: TableProps.GroupDefinition = { id: 'grp', header: 'Grp' }; + + const colNode = new TableHeaderNode('col', { + columnDefinition: colDef, + colSpan: 2, + rowSpan: 3, + rowIndex: 1, + colIndex: 2, + }); + const groupNode = new TableHeaderNode('grp', { groupDefinition: groupDef }); + const rootNode = new TableHeaderNode('root', { isRoot: true }); + + expect(colNode.colSpan).toBe(2); + expect(colNode.rowSpan).toBe(3); + expect(colNode.columnDefinition).toBe(colDef); + expect(colNode.isGroup).toBe(false); + expect(groupNode.isGroup).toBe(true); + expect(rootNode.isRoot).toBe(true); + expect(rootNode.isColumn).toBe(false); + }); + + test('manages parent/child relationships', () => { + const parent = new TableHeaderNode('parent'); + const child1 = new TableHeaderNode('child1'); + const child2 = new TableHeaderNode('child2'); + + parent.addChild(child1); + parent.addChild(child2); + + expect(parent.children).toHaveLength(2); + expect(child1.parent).toBe(parent); + expect(child2.parent).toBe(parent); + expect(parent.isColumn).toBe(false); + expect(child1.isColumn).toBe(true); + }); +}); + +describe('calculateHierarchyTree', () => { + describe('no grouping', () => { + test('returns a single row with all visible columns', () => { + const result = calculateHierarchyTree(COLUMN_DEFS, ALL_IDS, []); + + expect(result.maxDepth).toBe(1); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].columns).toHaveLength(COLUMN_DEFS.length); + result.rows[0].columns.forEach((col, i) => { + expect(col.rowSpan).toBe(1); + expect(col.colSpan).toBe(1); + expect(col.isGroup).toBe(false); + expect(col.colIndex).toBe(i); + }); + expect(result.columnToParentIds.size).toBe(0); + }); + }); + + describe('flat grouping', () => { + test('creates two rows with correct structure', () => { + const result = calculateHierarchyTree(COLUMN_DEFS, ALL_IDS, GROUP_DEFS, FLAT_DISPLAY); + + expect(result.maxDepth).toBe(2); + expect(result.rows).toHaveLength(2); + + // Row 0: ungrouped columns (rowSpan=2) + group headers + const row0 = result.rows[0].columns; + expect(row0.map(c => c.id)).toEqual(['id', 'name', 'performance', 'config', 'pricing']); + expect(row0.find(c => c.id === 'id')).toMatchObject({ rowSpan: 2 }); + expect(row0.find(c => c.id === 'name')).toMatchObject({ rowSpan: 2 }); + expect(row0.find(c => c.id === 'performance')).toMatchObject({ isGroup: true, colSpan: 3, rowSpan: 1 }); + expect(row0.find(c => c.id === 'config')).toMatchObject({ isGroup: true, colSpan: 2 }); + expect(row0.find(c => c.id === 'pricing')).toMatchObject({ isGroup: true, colSpan: 1 }); + + // Row 1: columns under groups + const row1 = result.rows[1].columns; + expect(row1.map(c => c.id)).toEqual(['cpu', 'memory', 'networkIn', 'type', 'az', 'cost']); + expect(row1.every(c => !c.isGroup && c.rowSpan === 1 && c.colSpan === 1)).toBe(true); + }); + + test('tracks parent IDs and colIndex correctly', () => { + const result = calculateHierarchyTree(COLUMN_DEFS, ALL_IDS, GROUP_DEFS, FLAT_DISPLAY); + + expect(result.columnToParentIds.get('cpu')).toEqual(['performance']); + expect(result.columnToParentIds.get('type')).toEqual(['config']); + expect(result.columnToParentIds.has('id')).toBe(false); + + const row0 = result.rows[0].columns; + expect(row0.find(c => c.id === 'performance')?.colIndex).toBe(2); + expect(row0.find(c => c.id === 'config')?.colIndex).toBe(5); + }); + }); + + describe('nested grouping', () => { + const nestedCols: TableProps.ColumnDefinition[] = [ + { id: 'cpu', header: 'CPU', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', cell: () => 'memory' }, + ]; + + test('creates three rows for nested groups', () => { + const result = calculateHierarchyTree(nestedCols, ['cpu', 'memory'], NESTED_GROUPS, NESTED_DISPLAY); + + expect(result.maxDepth).toBe(3); + expect(result.rows).toHaveLength(3); + expect(result.rows[0].columns[0]).toMatchObject({ id: 'metrics', colSpan: 2 }); + expect(result.rows[1].columns[0]).toMatchObject({ id: 'performance', colSpan: 2 }); + expect(result.rows[2].columns.map(c => c.id)).toEqual(['cpu', 'memory']); + expect(result.columnToParentIds.get('cpu')).toEqual(['metrics', 'performance']); + }); + + test('handles 3-level nesting', () => { + const groups: TableProps.GroupDefinition[] = [ + { id: 'l1', header: 'L1' }, + { id: 'l2', header: 'L2' }, + { id: 'l3', header: 'L3' }, + ]; + const display: TableProps.ColumnDisplayProperties[] = [ + { + type: 'group', + id: 'l1', + visible: true, + children: [ + { + type: 'group', + id: 'l2', + visible: true, + children: [{ type: 'group', id: 'l3', visible: true, children: [{ id: 'cpu', visible: true }] }], + }, + ], + }, + ]; + const result = calculateHierarchyTree(nestedCols, ['cpu'], groups, display); + expect(result.maxDepth).toBe(4); + expect(result.columnToParentIds.get('cpu')).toEqual(['l1', 'l2', 'l3']); + }); + + test('handles mixed nested and flat groups', () => { + const groups: TableProps.GroupDefinition[] = [ + { id: 'metrics', header: 'Metrics' }, + { id: 'performance', header: 'Performance' }, + { id: 'config', header: 'Config' }, + ]; + const display: TableProps.ColumnDisplayProperties[] = [ + { + type: 'group', + id: 'metrics', + visible: true, + children: [{ type: 'group', id: 'performance', visible: true, children: [{ id: 'cpu', visible: true }] }], + }, + { type: 'group', id: 'config', visible: true, children: [{ id: 'memory', visible: true }] }, + ]; + const result = calculateHierarchyTree(nestedCols, ['cpu', 'memory'], groups, display); + + expect(result.maxDepth).toBe(3); + const row0 = result.rows[0].columns; + expect(row0.map(c => c.id)).toEqual(['metrics', 'config']); + expect(row0.find(c => c.id === 'config')).toMatchObject({ rowSpan: 2 }); + expect(result.rows[1].columns.map(c => c.id)).toEqual(['performance']); + }); + }); + + describe('visibility filtering', () => { + test('includes only visible columns and adjusts group colSpan', () => { + const groups: TableProps.GroupDefinition[] = [{ id: 'g', header: 'G' }]; + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { + type: 'group', + id: 'g', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: false }, + ], + }, + ]; + const result = calculateHierarchyTree(COLUMN_DEFS, ['id', 'cpu'], groups, display); + + const allIds = result.rows.flatMap(r => r.columns.map(c => c.id)); + expect(allIds).toContain('cpu'); + expect(allIds).not.toContain('memory'); + expect(result.rows[0].columns.find(c => c.id === 'g')?.colSpan).toBe(1); + }); + + test('omits a group entirely when all its children are hidden', () => { + const groups: TableProps.GroupDefinition[] = [{ id: 'g', header: 'G' }]; + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { type: 'group', id: 'g', visible: true, children: [{ id: 'cpu', visible: false }] }, + ]; + const result = calculateHierarchyTree(COLUMN_DEFS, ['id'], groups, display); + expect(result.rows[0].columns.map(c => c.id)).not.toContain('g'); + }); + }); + + describe('edge cases', () => { + test('returns empty structure for empty column list', () => { + const result = calculateHierarchyTree([], [], []); + expect(result.rows).toHaveLength(0); + expect(result.maxDepth).toBe(0); + }); + + test('skips columns without id', () => { + const cols: TableProps.ColumnDefinition[] = [ + { header: 'No ID', cell: () => 'x' } as any, + { id: 'cpu', header: 'CPU', cell: () => 'cpu' }, + ]; + const groups: TableProps.GroupDefinition[] = [{ id: 'g', header: 'G' }]; + const display: TableProps.ColumnDisplayProperties[] = [ + { type: 'group', id: 'g', visible: true, children: [{ id: 'cpu', visible: true }] }, + ]; + const result = calculateHierarchyTree(cols, ['cpu'], groups, display); + expect(result.rows[1].columns[0].id).toBe('cpu'); + }); + + test('skips subtree when group id is not in groupDefinitions', () => { + const display: TableProps.ColumnDisplayProperties[] = [ + { type: 'group', id: 'nonexistent', visible: true, children: [{ id: 'cpu', visible: true }] }, + ]; + const result = calculateHierarchyTree(COLUMN_DEFS, ['cpu'], [], display); + expect(result.rows).toHaveLength(0); + }); + + test('treats a group with no visible children as absent', () => { + const groups: TableProps.GroupDefinition[] = [{ id: 'g', header: 'G' }]; + const display: TableProps.ColumnDisplayProperties[] = [ + { type: 'group', id: 'g', visible: true, children: [{ id: 'cpu', visible: false }] }, + ]; + const result = calculateHierarchyTree(COLUMN_DEFS, [], groups, display); + expect(result.rows).toHaveLength(0); + }); + }); +}); diff --git a/src/table/column-groups/col-group.tsx b/src/table/column-groups/col-group.tsx new file mode 100644 index 0000000000..002e8cbffc --- /dev/null +++ b/src/table/column-groups/col-group.tsx @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { TableProps } from '../interfaces'; +import { useColumnWidths } from '../use-column-widths'; +import { getColumnKey } from '../utils'; + +/* + * Renders a with elements for each column. + * With table-layout:fixed, widths control actual column widths, + * which makes colspan headers automatically span the correct width. + * Must be rendered inside ColumnWidthsProvider. + */ +export function TableColGroup({ + visibleColumnDefinitions, + hasSelection, + sticky = false, + selectionColumnId, +}: { + visibleColumnDefinitions: ReadonlyArray>; + hasSelection: boolean; + sticky?: boolean; + selectionColumnId?: PropertyKey; +}) { + const { getColumnStyles, setCol } = useColumnWidths(); + return ( + + {hasSelection && ( + + )} + {visibleColumnDefinitions.map((column, colIndex) => { + const columnId = getColumnKey(column, colIndex); + if (sticky) { + return ; + } + return setCol(columnId, node)} />; + })} + + ); +} diff --git a/src/table/column-groups/split-utils.ts b/src/table/column-groups/split-utils.ts new file mode 100644 index 0000000000..61e53cc88b --- /dev/null +++ b/src/table/column-groups/split-utils.ts @@ -0,0 +1,69 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ColumnGroupsLayout, HeaderRowColumn } from './utils'; + +/** + * Describes how a group header is split by a single sticky column boundary. + * `stickyColspan` is the number of columns on the sticky side. + * `staticColspan` is the number of columns on the scrollable side. + * When both are 0, the group is not affected by this boundary. + */ +export interface StickyGroupSplit { + stickyColspan: number; + staticColspan: number; +} + +/** Returns all column IDs that are descendants of the given group (including nested subgroups). */ +export function getGroupColumnIds(columnGroupsLayout: ColumnGroupsLayout, groupId: string): string[] { + const columnsRow = columnGroupsLayout.rows[columnGroupsLayout.rows.length - 1]; + const childIds: string[] = []; + for (const col of columnsRow.columns) { + if (!col.isGroup && col.parentGroupIds.includes(groupId)) { + childIds.push(col.id); + } + } + return childIds; +} + +/** + * Computes how a group header cell is split by a sticky boundary. + * Call once for sticky-first and once for sticky-last. + * + * @param stickyCount - number of sticky columns from that side (first or last) + * @param side - which boundary to check + */ +export function getGroupSplit({ + col, + stickyCount, + side, + totalColumns, +}: { + col: HeaderRowColumn; + stickyCount: number; + side: 'first' | 'last'; + totalColumns: number; +}): StickyGroupSplit { + if (!col.isGroup || stickyCount === 0) { + return { stickyColspan: 0, staticColspan: 0 }; + } + + const groupStart = col.colIndex; + const groupEnd = col.colIndex + col.colSpan - 1; + + if (side === 'first') { + const lastStickyFirst = stickyCount - 1; + if (groupStart <= lastStickyFirst && groupEnd > lastStickyFirst) { + const stickyColspan = lastStickyFirst - groupStart + 1; + return { stickyColspan, staticColspan: col.colSpan - stickyColspan }; + } + } else { + const firstStickyLast = totalColumns - stickyCount; + if (groupStart < firstStickyLast && groupEnd >= firstStickyLast) { + const staticColspan = firstStickyLast - groupStart; + return { stickyColspan: col.colSpan - staticColspan, staticColspan }; + } + } + + return { stickyColspan: 0, staticColspan: 0 }; +} diff --git a/src/table/column-groups/use-column-groups.tsx b/src/table/column-groups/use-column-groups.tsx new file mode 100644 index 0000000000..1b4a84e32f --- /dev/null +++ b/src/table/column-groups/use-column-groups.tsx @@ -0,0 +1,32 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TableProps } from '../interfaces'; +import { calculateHierarchyTree } from './utils'; + +export function useColumnGroups( + columnDefinitions: ReadonlyArray>, + visibleColumns: string[], + groupDefinitions?: ReadonlyArray, + columnDisplay?: ReadonlyArray +) { + const layout = calculateHierarchyTree(columnDefinitions, visibleColumns, groupDefinitions ?? [], columnDisplay); + + let groupColumnMap: Map | undefined; + if (layout.rows.length > 1) { + groupColumnMap = new Map(); + const columnsRow = layout.rows[layout.rows.length - 1]; + for (const row of layout.rows) { + for (const col of row.columns) { + if (col.isGroup) { + const childColumnIds = columnsRow.columns + .filter(l => !l.isGroup && l.parentGroupIds.includes(col.id)) + .map(l => l.id); + groupColumnMap.set(col.id, childColumnIds); + } + } + } + } + + return { ...layout, groupColumnMap }; +} diff --git a/src/table/column-groups/utils.ts b/src/table/column-groups/utils.ts new file mode 100644 index 0000000000..e0258fa3d4 --- /dev/null +++ b/src/table/column-groups/utils.ts @@ -0,0 +1,294 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; + +import { TableProps } from '../interfaces'; +import { getVisibleColumnDefinitions } from '../utils'; + +export interface HeaderRowColumn { + id: string; + header?: React.ReactNode; + colSpan: number; + rowSpan: number; + isGroup: boolean; + columnDefinition?: TableProps.ColumnDefinition; + groupDefinition?: TableProps.GroupDefinition; + parentGroupIds: string[]; + colIndex: number; +} + +export interface HeaderRow { + columns: HeaderRowColumn[]; +} + +export interface ColumnGroupsLayout { + rows: HeaderRow[]; + maxDepth: number; + columnToParentIds: Map; +} + +export interface TableHeaderNodeProps { + columnDefinition?: TableProps.ColumnDefinition; + groupDefinition?: TableProps.GroupDefinition; + isRoot?: boolean; + colSpan?: number; + rowSpan?: number; + rowIndex?: number; + colIndex?: number; +} + +/** + * A node in the table header tree. + * - Column nodes map to column definitions. + * - Internal nodes map to group definitions. + * - The root is a virtual container (never rendered). + */ +export class TableHeaderNode { + public readonly id: string; + public readonly isRoot: boolean; + public readonly columnDefinition?: TableProps.ColumnDefinition; + public readonly groupDefinition?: TableProps.GroupDefinition; + + public colSpan: number; + public rowSpan: number; + public rowIndex: number; + public colIndex: number; + public subTreeHeight: number = 1; + + public children: TableHeaderNode[] = []; + public parent?: TableHeaderNode; + + constructor(id: string, props: TableHeaderNodeProps = {}) { + this.id = id; + this.isRoot = props.isRoot ?? false; + this.columnDefinition = props.columnDefinition; + this.groupDefinition = props.groupDefinition; + this.colSpan = props.colSpan ?? 1; + this.rowSpan = props.rowSpan ?? 1; + this.rowIndex = props.rowIndex ?? -1; + this.colIndex = props.colIndex ?? -1; + } + + get isGroup(): boolean { + return !!this.groupDefinition; + } + + get isColumn(): boolean { + return !this.isRoot && this.children.length === 0; + } + + addChild(child: TableHeaderNode): void { + this.children.push(child); + child.parent = this; + } +} + +/** + * Builds the tree from the nested columnDisplay structure. + * Groups are only attached if they contain at least one visible descendant. + */ +function buildTreeFromColumnDisplay( + displayItems: ReadonlyArray, + nodeMap: Map>, + parent: TableHeaderNode +): void { + for (const item of displayItems) { + if (item.type === 'group') { + const groupNode = nodeMap.get(item.id); + if (!groupNode) { + warnOnce('[Table]', `Group "${item.id}" referenced in columnDisplay not found in groupDefinitions. Skipping.`); + continue; + } + buildTreeFromColumnDisplay(item.children, nodeMap, groupNode); + // Only attach group if it has visible descendants. The recursive call above + // only adds children that are either visible columns or nested groups with + // their own visible descendants, so this check handles all nesting levels. + if (groupNode.children.length > 0) { + parent.addChild(groupNode); + } + } else { + if (!item.visible) { + continue; + } + const colNode = nodeMap.get(item.id); + if (colNode) { + parent.addChild(colNode); + } + } + } +} + +/** + * Fallback when no columnDisplay is provided: all visible columns attach directly to root. + */ +function buildTreeFromVisibleColumns( + visibleColumns: Readonly[]>, + nodeMap: Map>, + root: TableHeaderNode +): void { + for (const col of visibleColumns) { + // Columns without IDs cannot participate in grouping, they have no key + // to match against columnDisplay entries or groupDefinitions. + if (!col.id) { + continue; + } + const node = nodeMap.get(col.id); + if (node) { + root.addChild(node); + } + } +} + +function computeSubTreeHeights(node: TableHeaderNode): number { + if (node.isColumn || node.children.length === 0) { + node.subTreeHeight = 1; + return 1; + } + const maxChildHeight = Math.max(...node.children.map(child => computeSubTreeHeights(child))); + node.subTreeHeight = maxChildHeight + 1; + return node.subTreeHeight; +} + +function computeRowSpansAndIndices(node: TableHeaderNode, treeHeight: number, ancestorRows: number = 0): void { + const maxChildHeight = Math.max(...node.children.map(c => c.subTreeHeight), 0); + node.rowSpan = treeHeight - ancestorRows - maxChildHeight; + + if (node.parent) { + node.rowIndex = node.parent.rowIndex + node.parent.rowSpan; + } + + for (const child of node.children) { + computeRowSpansAndIndices(child, treeHeight, ancestorRows + node.rowSpan); + } +} + +function computeColSpansAndIndices(node: TableHeaderNode, startCol: number = 0): number { + node.colIndex = startCol; + + if (node.isColumn) { + node.colSpan = 1; + return startCol + 1; + } + + let nextCol = startCol; + for (const child of node.children) { + nextCol = computeColSpansAndIndices(child, nextCol); + } + + node.colSpan = nextCol - startCol; + return nextCol; +} + +export function calculateHierarchyTree( + columnDefinitions: ReadonlyArray>, + visibleColumnIds: readonly string[], + groupDefinitions: ReadonlyArray, + columnDisplay?: ReadonlyArray +): ColumnGroupsLayout { + const visibleColumns = getVisibleColumnDefinitions({ + columnDisplay, + visibleColumns: visibleColumnIds, + columnDefinitions, + }); + + const nodeMap = new Map>(); + + for (const col of visibleColumns) { + if (col.id) { + nodeMap.set(col.id, new TableHeaderNode(col.id, { columnDefinition: col })); + } + } + + for (const group of groupDefinitions) { + nodeMap.set(group.id, new TableHeaderNode(group.id, { groupDefinition: group })); + } + + const root = new TableHeaderNode('*', { isRoot: true }); + + if (columnDisplay && columnDisplay.length > 0) { + buildTreeFromColumnDisplay(columnDisplay, nodeMap, root); + } else { + buildTreeFromVisibleColumns(visibleColumns, nodeMap, root); + } + + computeSubTreeHeights(root); + + const treeHeight = root.subTreeHeight - 1; + root.rowIndex = -1; + root.rowSpan = 1; + root.colSpan = visibleColumns.length; + + for (const child of root.children) { + computeRowSpansAndIndices(child, treeHeight); + } + + computeColSpansAndIndices(root); + + return buildOutput(root, treeHeight); +} + +function getParentChain(node: TableHeaderNode): string[] { + const chain: string[] = []; + let current = node.parent; + while (current && !current.isRoot) { + chain.unshift(current.id); + current = current.parent; + } + return chain; +} + +function buildOutput(root: TableHeaderNode, maxDepth: number): ColumnGroupsLayout { + const rowsMap = new Map[]>(); + const columnToParentIds = new Map(); + + const queue: TableHeaderNode[] = [...root.children]; + + while (queue.length > 0) { + const node = queue.shift()!; + const parentChain = getParentChain(node); + + const entry: HeaderRowColumn = { + id: node.id, + header: node.groupDefinition?.header ?? node.columnDefinition?.header, + colSpan: node.colSpan, + rowSpan: node.rowSpan, + isGroup: node.isGroup, + columnDefinition: node.columnDefinition, + groupDefinition: node.groupDefinition, + parentGroupIds: parentChain, + colIndex: node.colIndex, + }; + + if (!rowsMap.has(node.rowIndex)) { + rowsMap.set(node.rowIndex, []); + } + rowsMap.get(node.rowIndex)!.push(entry); + + if (node.isColumn && node.columnDefinition && parentChain.length > 0) { + columnToParentIds.set(node.id, parentChain); + } + + queue.push(...node.children); + } + + // Sort row indices to ensure rows are ordered top-to-bottom, + // then sort columns within each row by their horizontal position. + const rows: HeaderRow[] = Array.from(rowsMap.keys()) + .sort((a, b) => a - b) + .map(key => ({ columns: rowsMap.get(key)!.sort((a, b) => a.colIndex - b.colIndex) })); + + return { rows, maxDepth, columnToParentIds }; +} + +export function getColumnGroupsDepth(columnDisplay?: ReadonlyArray): number { + if (!columnDisplay) { + return 0; + } + let maxDepth = 0; + for (const item of columnDisplay) { + if (item.type === 'group') { + maxDepth = Math.max(maxDepth, 1 + getColumnGroupsDepth(item.children)); + } + } + return maxDepth; +} diff --git a/src/table/header-cell/common-props.ts b/src/table/header-cell/common-props.ts new file mode 100644 index 0000000000..a5eecee09d --- /dev/null +++ b/src/table/header-cell/common-props.ts @@ -0,0 +1,27 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ColumnWidthStyle } from '../column-widths-utils'; +import { TableProps } from '../interfaces'; +import { StickyColumnsModel } from '../sticky-columns'; +import { TableRole } from '../table-role'; + +export interface BaseHeaderCellProps { + tabIndex: number; + colIndex: number; + focusedComponent?: null | string; + resizableColumns?: boolean; + resizableStyle?: ColumnWidthStyle; + onResizeFinish: () => void; + sticky?: boolean; + hidden?: boolean; + stripedRows?: boolean; + stickyState: StickyColumnsModel; + cellRef: React.RefCallback; + tableRole: TableRole; + resizerRoleDescription?: string; + resizerTooltipText?: string; + variant: TableProps.Variant; + tableVariant?: TableProps.Variant; + wrapLines?: boolean; +} diff --git a/src/table/header-cell/group-header-cell.tsx b/src/table/header-cell/group-header-cell.tsx new file mode 100644 index 0000000000..f36b2705db --- /dev/null +++ b/src/table/header-cell/group-header-cell.tsx @@ -0,0 +1,145 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useRef } from 'react'; +import clsx from 'clsx'; + +import { useSingleTabStopNavigation } from '@cloudscape-design/component-toolkit/internal'; +import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; + +import { TableProps } from '../interfaces'; +import { Divider, Resizer } from '../resizer'; +import { useStickyCellStyles } from '../sticky-columns'; +import { DEFAULT_COLUMN_WIDTH, useColumnWidths } from '../use-column-widths'; +import { getStickyClassNames } from '../utils'; +import { BaseHeaderCellProps } from './common-props'; +import { TableThElement } from './th-element'; + +import styles from './styles.css.js'; + +export interface TableGroupHeaderCellProps extends BaseHeaderCellProps { + group: TableProps.GroupDefinition; + colspan: number; + rowspan: number; + groupId: string; + updateGroupWidth: (groupId: PropertyKey, newWidth: number) => void; + childColumnIds: PropertyKey[]; + firstChildColumnId?: PropertyKey; + lastChildColumnId?: PropertyKey; + columnGroupId?: string; + stickyColumnId?: PropertyKey; + stickyBoundaryColumnId?: PropertyKey; + isLast?: boolean; +} + +export function TableGroupHeaderCell({ + group, + colspan, + rowspan, + colIndex, + groupId, + resizableColumns, + resizableStyle, + onResizeFinish, + updateGroupWidth, + childColumnIds, + focusedComponent, + tabIndex, + sticky, + hidden, + stripedRows, + stickyState, + cellRef, + tableRole, + resizerRoleDescription, + resizerTooltipText, + variant, + tableVariant, + columnGroupId, + stickyColumnId, + stickyBoundaryColumnId, + isLast, + wrapLines, +}: TableGroupHeaderCellProps) { + const headerId = useUniqueId('table-group-header-'); + const { columnWidths } = useColumnWidths(); + + // Effective min = sum of non-rightmost children's current widths (fixed) + rightmost child's minWidth + const lastChild = childColumnIds[childColumnIds.length - 1]; + const groupMinWidth = childColumnIds.reduce((sum, id) => { + if (id === lastChild) { + return sum + DEFAULT_COLUMN_WIDTH; + } + return sum + (columnWidths.get(id) || DEFAULT_COLUMN_WIDTH); + }, 0); + const clickableHeaderRef = useRef(null); + const { tabIndex: clickableHeaderTabIndex } = useSingleTabStopNavigation(clickableHeaderRef, { tabIndex }); + + // Subscribe to the boundary column's sticky state to inherit shadow/clip-path classes. + // The offset/position comes from stickyColumnId (first child); this only adds boundary classes. + const boundaryStyles = useStickyCellStyles({ + stickyColumns: stickyState, + columnId: stickyBoundaryColumnId ?? stickyColumnId ?? groupId, + getClassName: props => getStickyClassNames(styles, props), + }); + + // boundaryStyles.className is populated by scroll/intersection observers in the browser. + // In JSDOM these observers don't fire, so this branch is only exercised in integration tests. + const boundaryClassName = stickyBoundaryColumnId && boundaryStyles.className ? boundaryStyles.className : undefined; + + return ( + + ); +} diff --git a/src/table/header-cell/index.tsx b/src/table/header-cell/index.tsx index ff8e92ed41..eb4f6fd553 100644 --- a/src/table/header-cell/index.tsx +++ b/src/table/header-cell/index.tsx @@ -11,46 +11,32 @@ import { useInternalI18n } from '../../i18n/context'; import InternalIcon from '../../icon/internal'; import { KeyCode } from '../../internal/keycode'; import { GeneratedAnalyticsMetadataTableSort } from '../analytics-metadata/interfaces'; -import { ColumnWidthStyle } from '../column-widths-utils'; import { TableProps } from '../interfaces'; import { Divider, Resizer } from '../resizer'; -import { StickyColumnsModel } from '../sticky-columns'; -import { TableRole } from '../table-role'; +import { BaseHeaderCellProps } from './common-props'; import { TableThElement } from './th-element'; import { getSortingIconName, getSortingStatus, isSorted } from './utils'; import analyticsSelectors from '../analytics-metadata/styles.css.js'; import styles from './styles.css.js'; -export interface TableHeaderCellProps { - tabIndex: number; +export interface TableHeaderCellProps extends BaseHeaderCellProps { column: TableProps.ColumnDefinition; activeSortingColumn?: TableProps.SortingColumn; sortingDescending?: boolean; sortingDisabled?: boolean; - wrapLines?: boolean; stuck?: boolean; - sticky?: boolean; - hidden?: boolean; - stripedRows?: boolean; onClick(detail: TableProps.SortingState): void; - onResizeFinish: () => void; - colIndex: number; updateColumn: (columnId: PropertyKey, newWidth: number) => void; - resizableColumns?: boolean; - resizableStyle?: ColumnWidthStyle; isEditable?: boolean; columnId: PropertyKey; - stickyState: StickyColumnsModel; - cellRef: React.RefCallback; - focusedComponent?: null | string; - tableRole: TableRole; - resizerRoleDescription?: string; - resizerTooltipText?: string; isExpandable?: boolean; hasDynamicContent?: boolean; - variant: TableProps.Variant; - tableVariant?: TableProps.Variant; + colSpan?: number; + rowSpan?: number; + columnGroupId?: string; + isLastChildOfGroup?: boolean; + isLast?: boolean; } export function TableHeaderCell({ @@ -81,6 +67,11 @@ export function TableHeaderCell({ isExpandable, hasDynamicContent, variant, + colSpan, + rowSpan, + columnGroupId, + isLastChildOfGroup, + isLast, tableVariant, }: TableHeaderCellProps) { const i18n = useInternalI18n('table'); @@ -139,6 +130,10 @@ export function TableHeaderCell({ tableRole={tableRole} variant={variant} tableVariant={tableVariant} + colSpan={colSpan} + rowSpan={rowSpan} + columnGroupId={columnGroupId} + isLast={isLast} {...(sortingDisabled ? {} : getAnalyticsMetadataAttribute({ @@ -214,9 +209,15 @@ export function TableHeaderCell({ // tooltipText={i18n('ariaLabels.resizerTooltipText', resizerTooltipText)} tooltipText={resizerTooltipText} isBorderless={variant === 'full-page' || variant === 'embedded' || variant === 'borderless'} + isLast={isLast} + dividerPosition={isLastChildOfGroup ? 'top' : undefined} /> ) : ( - + 1) ? 'interactive' : 'default'} + /> )} ); diff --git a/src/table/header-cell/styles.scss b/src/table/header-cell/styles.scss index d004215c5e..efaeb5caba 100644 --- a/src/table/header-cell/styles.scss +++ b/src/table/header-cell/styles.scss @@ -53,7 +53,7 @@ $cell-horizontal-padding: awsui.$space-scaled-l; position: relative; text-align: start; box-sizing: border-box; - border-block-end: awsui.$border-divider-section-width solid awsui.$color-border-divider-default; + border-block-end: awsui.$border-divider-list-width solid awsui.$color-border-divider-interactive-default; background: awsui.$color-background-table-header; color: awsui.$color-text-column-header; font-weight: awsui.$font-weight-heading-s; @@ -63,6 +63,13 @@ $cell-horizontal-padding: awsui.$space-scaled-l; @include header-cell-focus-outline(awsui.$space-scaled-xxs); + &.header-cell-group, + &.header-cell-grouped, + &.header-cell-spans-rows { + padding-block: awsui.$space-xxxs; + padding-inline: awsui.$space-scaled-xs; + } + &-sticky { border-block-end: awsui.$border-table-sticky-width solid awsui.$color-border-divider-default; } @@ -80,6 +87,7 @@ $cell-horizontal-padding: awsui.$space-scaled-l; background: none; } &:last-child, + &[data-rightmost], &.header-cell-sortable { padding-inline-end: awsui.$space-xs; } @@ -143,6 +151,12 @@ $cell-horizontal-padding: awsui.$space-scaled-l; padding-inline-end: awsui.$space-s; @include cell-offset(awsui.$space-s); + .header-cell-group > &, + .header-cell-grouped > &, + .header-cell-spans-rows > & { + padding-block: awsui.$space-xxxs; + } + .header-cell-sortable > & { padding-inline-end: calc(#{awsui.$space-xl} + #{awsui.$space-xxs}); } @@ -160,6 +174,26 @@ $cell-horizontal-padding: awsui.$space-scaled-l; } } +.header-cell-spans-rows { + block-size: 100%; + vertical-align: bottom; + + > .header-cell-content { + block-size: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: flex-end; + + // stylelint-disable-next-line no-descending-specificity + > .sorting-icon { + inset-block-start: auto; + inset-block-end: awsui.$space-scaled-xxs; + transform: none; + } + } +} + .header-cell-sortable:not(.header-cell-disabled) { & > .header-cell-content { cursor: pointer; @@ -206,12 +240,14 @@ settings icon in the pagination slot. &:first-child { @include header-cell-focus-outline-first(awsui.$space-scaled-xxs); } - &:first-child > .header-cell-content { + &:first-child:not(.header-cell-grouped):not(.header-cell-group) > .header-cell-content { @include cell-offset(0px); @include header-cell-focus-outline-first(awsui.$space-table-header-focus-outline-gutter); } - &:first-child:not(.has-striped-rows):not(.sticky-cell-pad-inline-start) { + &:first-child:not(.has-striped-rows):not(.sticky-cell-pad-inline-start):not(.header-cell-group):not( + .header-cell-grouped + ) { @include cell-offset(awsui.$space-xxxs); } @@ -220,11 +256,12 @@ settings icon in the pagination slot. shaded background makes the child content appear too close to the table edge. */ - &:first-child.has-striped-rows:not(.sticky-cell-pad-inline-start) { + &:first-child.has-striped-rows:not(.sticky-cell-pad-inline-start):not(.header-cell-group):not(.header-cell-grouped) { @include cell-offset(awsui.$space-xxs); } - &:last-child.header-cell-sortable:not(.header-cell-resizable) { + &:last-child.header-cell-sortable:not(.header-cell-resizable), + &[data-rightmost].header-cell-sortable:not(.header-cell-resizable) { padding-inline-end: awsui.$space-xxxs; } diff --git a/src/table/header-cell/th-element.tsx b/src/table/header-cell/th-element.tsx index 55e5739e02..36434de61b 100644 --- a/src/table/header-cell/th-element.tsx +++ b/src/table/header-cell/th-element.tsx @@ -38,6 +38,15 @@ export interface TableThElementProps { variant: TableProps.Variant; tableVariant?: TableProps.Variant; ariaLabel?: string; + colSpan?: number; + rowSpan?: number; + scope?: 'col' | 'colgroup'; + columnGroupId?: string; + isLast?: boolean; + /** Additional className to merge (e.g. boundary shadow classes from a secondary sticky subscription). */ + className?: string; + /** Additional ref for boundary sticky subscription (imperatively updates shadow classes). */ + boundaryRef?: React.RefCallback; } export function TableThElement({ @@ -60,6 +69,13 @@ export function TableThElement({ variant, ariaLabel, tableVariant, + colSpan, + rowSpan, + scope, + columnGroupId, + isLast, + className, + boundaryRef, ...props }: TableThElementProps) { const isVisualRefresh = useVisualRefresh(); @@ -71,7 +87,7 @@ export function TableThElement({ }); const cellRefObject = useRef(null); - const mergedRef = useMergeRefs(stickyStyles.ref, cellRef, cellRefObject); + const mergedRef = useMergeRefs(stickyStyles.ref, cellRef, cellRefObject, boundaryRef); const { tabIndex: cellTabIndex } = useSingleTabStopNavigation(cellRefObject); return ( @@ -87,6 +103,7 @@ export function TableThElement({ isVisualRefresh && styles['is-visual-refresh'], isSelection && clsx(tableStyles['selection-control'], tableStyles['selection-control-header']), tableVariant && styles[`table-variant-${tableVariant}`], + scope === 'colgroup' && styles['header-cell-group'], { [styles['header-cell-fake-focus']]: focusedComponent === `header-${String(columnId)}`, [styles['header-cell-sortable']]: sortingStatus, @@ -95,15 +112,24 @@ export function TableThElement({ [styles['header-cell-ascending']]: sortingStatus === 'ascending', [styles['header-cell-descending']]: sortingStatus === 'descending', [styles['header-cell-hidden']]: hidden, + [styles['header-cell-spans-rows']]: (rowSpan ?? 1) > 1, + [styles['header-cell-grouped']]: !!columnGroupId, }, - stickyStyles.className + stickyStyles.className, + className )} + colSpan={colSpan} + rowSpan={rowSpan} style={{ ...resizableStyle, ...stickyStyles.style }} ref={mergedRef} {...getTableColHeaderRoleProps({ tableRole, sortingStatus, colIndex })} + scope={scope ?? 'col'} tabIndex={cellTabIndex === -1 ? undefined : cellTabIndex} {...copyAnalyticsMetadataAttribute(props)} {...(ariaLabel ? { 'aria-label': ariaLabel } : {})} + {...(isLast ? { 'data-rightmost': true } : {})} + {...(scope !== 'colgroup' ? { 'data-column-index': colIndex + 1 } : {})} + {...(columnGroupId ? { 'data-column-group-id': columnGroupId } : {})} > {children} diff --git a/src/table/index.tsx b/src/table/index.tsx index 5bb3cd03a6..418e8ec73b 100644 --- a/src/table/index.tsx +++ b/src/table/index.tsx @@ -11,6 +11,7 @@ import { CollectionPreferencesMetadata } from '../internal/context/collection-pr import useBaseComponent from '../internal/hooks/use-base-component'; import { applyDisplayName } from '../internal/utils/apply-display-name'; import { GeneratedAnalyticsMetadataTableComponent } from './analytics-metadata/interfaces'; +import { getColumnGroupsDepth } from './column-groups/utils'; import { getSortingColumnId } from './header-cell/utils'; import { TableForwardRefType, TableProps } from './interfaces'; import InternalTable, { InternalTableAsSubstep } from './internal'; @@ -32,7 +33,7 @@ const Table = React.forwardRef( const analyticsMetadata = getAnalyticsMetadataProps(props as BasePropsWithAnalyticsMetadata); const hasHiddenColumns = (props.visibleColumns && props.visibleColumns.length < props.columnDefinitions.length) || - props.columnDisplay?.some(col => !col.visible); + props.columnDisplay?.some(col => col.type !== 'group' && !col.visible); const hasStickyColumns = !!props.stickyColumns?.first || !!props.stickyColumns?.last; const baseComponentProps = useBaseComponent( 'Table', @@ -54,6 +55,8 @@ const Table = React.forwardRef( expandableRows: !!props.expandableRows, progressiveLoading: !!props.getLoadingStatus, groupSelection: !!props.expandableRows?.groupSelection, + columnGroups: !!props.groupDefinitions?.length, + columnGroupsDepth: getColumnGroupsDepth(props.columnDisplay), cellCounters: props.columnDefinitions.filter(dev => !!dev.counter).length, loaderCounters: !!props.renderLoaderCounter, inlineEdit: props.columnDefinitions.some(def => !!def.editConfig), diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index 1628cf5492..c083d57ba9 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -252,9 +252,33 @@ export interface TableProps extends BaseComponentProps { * If not set, all columns are displayed and the order is dictated by the `columnDefinitions` property. * * Use it in conjunction with the content display preference of the [collection preferences](/components/collection-preferences/) component. + * + * Each entry is one of the following: + * - `ColumnDisplay` - Represents a single column. + * - `type` ('column') - (Optional) Identifies the entry as a column. Defaults to `'column'` when omitted. + * - `id` (string) - The column identifier. Must match a column `id` from `columnDefinitions`. + * - `visible` (boolean) - Whether the column is visible. + * - `GroupDisplay` - Represents a column group. + * - `type` ('group') - Identifies the entry as a group. + * - `id` (string) - The group identifier. Must match a group `id` from `groupDefinitions`. + * - `visible` (boolean) - Whether the group is visible. + * - `children` (ReadonlyArray) - The columns or nested groups within this group. */ columnDisplay?: ReadonlyArray; + /** + * Defines the column groups. Each group has an `id` and `header` used to label the group header cell. + * + * When using grouped columns, you must also provide the `columnDisplay` property with `{ type: 'group', id, children }` entries + * to assign columns to their respective groups and define the display hierarchy. + * + * Each group definition contains the following: + * - `id` (string) - A unique identifier for the group. + * - `header` (ReactNode) - The content displayed in the group header cell. + * - `ariaLabel` ((LabelData) => string) - (Optional) A function that provides an `aria-label` for the group header. + */ + groupDefinitions?: ReadonlyArray>; + /** * Specifies an array containing the `id`s of visible columns. If not set, all columns are displayed. * @@ -507,6 +531,13 @@ export namespace TableProps { cell(item: T): React.ReactNode; } & SortingColumn; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface GroupDefinition { + id: string; + header: React.ReactNode; + ariaLabel?: (data: LabelData) => string; + } + export interface ItemCounterData { item: T; itemsCount?: number; @@ -602,11 +633,21 @@ export namespace TableProps { newValue: ValueType ) => Promise | void; - export interface ColumnDisplayProperties { + export interface ColumnDisplay { + type?: 'column'; id: string; visible: boolean; } + export interface GroupDisplay { + type: 'group'; + id: string; + visible: boolean; + children: ReadonlyArray; + } + + export type ColumnDisplayProperties = ColumnDisplay | GroupDisplay; + export interface ExpandableRows { getItemChildren: (item: T) => readonly T[]; isItemExpandable: (item: T) => boolean; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index cf2e4db610..fdfbf85194 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -37,6 +37,8 @@ import { SomeRequired } from '../internal/types'; import InternalLiveRegion from '../live-region/internal'; import { GeneratedAnalyticsMetadataTableComponent } from './analytics-metadata/interfaces'; import { TableBodyCell } from './body-cell'; +import { TableColGroup } from './column-groups/col-group'; +import { useColumnGroups } from './column-groups/use-column-groups'; import { checkColumnWidths } from './column-widths-utils'; import { useExpandableTableProps } from './expandable-rows/expandable-rows-utils'; import { TableForwardRefType, TableProps, TableRow } from './interfaces'; @@ -107,6 +109,7 @@ const InternalTable = React.forwardRef( preferences, items, columnDefinitions, + groupDefinitions, trackBy, loading, loadingText, @@ -300,6 +303,15 @@ const InternalTable = React.forwardRef( visibleColumns, }); + const visibleColumnIds = visibleColumnDefinitions.map((col, idx) => getColumnKey(col, idx).toString()); + + const { groupColumnMap, ...columnGroupsLayout } = useColumnGroups( + columnDefinitions, + visibleColumnIds, + groupDefinitions, + columnDisplay + ); + const selectionProps = { items: allItems, rootItems: items, @@ -394,6 +406,8 @@ const InternalTable = React.forwardRef( selectionType, getSelectAllProps: selection.getSelectAllProps, columnDefinitions: visibleColumnDefinitions, + groupDefinitions, + columnGroupsLayout, variant: computedVariant, tableVariant: computedVariant, wrapLines, @@ -418,6 +432,8 @@ const InternalTable = React.forwardRef( resizerTooltipText: ariaLabels?.resizerTooltipText, stripedRows, stickyState, + stickyColumnsFirst: stickyColumns?.first ?? 0, + stickyColumnsLast: stickyColumns?.last ?? 0, selectionColumnId, tableRole, isExpandable, @@ -452,6 +468,7 @@ const InternalTable = React.forwardRef( const colIndexOffset = selectionType ? 1 : 0; const totalColumnsCount = visibleColumnDefinitions.length + colIndexOffset; + const headerRowCount = columnGroupsLayout?.rows.length || 1; return ( @@ -460,6 +477,7 @@ const InternalTable = React.forwardRef( visibleColumns={visibleColumnWidthsWithSelection} resizableColumns={resizableColumns} containerRef={wrapperMeasureRefObject} + groupColumnMap={groupColumnMap} > 1} + columnDefinitions={visibleColumnDefinitions} + hasSelection={hasSelection} /> )} @@ -560,16 +581,21 @@ const InternalTable = React.forwardRef( className={clsx( styles.table, resizableColumns && styles['table-layout-fixed'], + columnGroupsLayout && columnGroupsLayout.rows.length > 1 && styles['has-grouped-header'], contentDensity === 'compact' && getVisualContextClassname('compact-table') )} {...getTableRoleProps({ tableRole, totalItemsCount, totalColumnsCount: totalColumnsCount, + headerRowCount, ariaLabel: ariaLabels?.tableLabel, ariaLabelledby, })} > + {resizableColumns && columnGroupsLayout && columnGroupsLayout.rows.length > 1 && ( + + )} ; +export type DividerPosition = 'default' | 'top' | 'bottom' | 'full'; + +export function Divider({ + className, + position, + variant, +}: { + className?: string; + position?: DividerPosition; + variant?: 'default' | 'interactive'; +}) { + return ( + + ); } export function Resizer({ @@ -52,6 +73,8 @@ export function Resizer({ roleDescription, tooltipText, isBorderless, + isLast, + dividerPosition, }: ResizerProps) { onWidthUpdate = useStableCallback(onWidthUpdate); onWidthUpdateCommit = useStableCallback(onWidthUpdateCommit); @@ -330,7 +353,8 @@ export function Resizer({ className={clsx( styles['resizer-wrapper'], isVisualRefresh && styles['visual-refresh'], - (!isVisualRefresh || isBorderless) && styles['is-borderless'] + (!isVisualRefresh || isBorderless) && styles['is-borderless'], + isLast && styles['is-last'] )} ref={positioningWrapperRef} > @@ -411,7 +435,11 @@ export function Resizer({ data-focus-id={focusId} /> .divider, +th:not([data-rightmost]) > .divider, .divider-interactive { position: absolute; outline: none; @@ -46,11 +46,30 @@ th:not(:last-child) > .divider, max-block-size: calc(100% - #{$block-gap}); margin-block: auto; margin-inline: auto; - border-inline-start: awsui.$border-item-width solid awsui.$color-border-divider-interactive-default; + border-inline-start: awsui.$border-divider-list-width solid awsui.$color-border-divider-interactive-default; box-sizing: border-box; + + // Position variants for grouped column headers. + // All Column dividers maintain the same bottom gap ($block-gap / 2) as the default. + &.divider-position-top { + // Leaf column under a group: extends upward, same bottom gap as default. + margin-block-start: 0; + margin-block-end: auto; + max-block-size: calc(100% - #{$block-gap} / 2); + } + &.divider-position-bottom { + // Group header: extends downward to meet the horizontal border below. + margin-block-start: auto; + margin-block-end: 0; + max-block-size: calc(100% - #{$block-gap} / 2); + } + &.divider-position-full { + margin-block: 0; + max-block-size: 100%; + } } -th:not(:last-child) > .divider-disabled { +th:not([data-rightmost]) > .divider-disabled { border-inline-start-color: awsui.$color-border-divider-default; } @@ -58,13 +77,14 @@ th:not(:last-child) > .divider-disabled { inset-inline-end: calc(#{$handle-width} / 2); } -// stylelint-disable-next-line selector-combinator-disallowed-list -th:last-child > .resizer-wrapper.visual-refresh.is-borderless .divider-interactive { - inset-inline-end: 0; +.divider-active { + /* used in test-utils */ } -.divider-active { - border-inline-start: $active-separator-width solid awsui.$color-border-divider-active; +.resizer-wrapper.visual-refresh.is-borderless.is-last { + > .divider-interactive { + inset-inline-end: 0; + } } .resizer { @@ -84,9 +104,6 @@ th:last-child > .resizer-wrapper.visual-refresh.is-borderless .divider-interacti .resize-active & { pointer-events: none; } - &:hover + .divider { - border-inline-start: $active-separator-width solid awsui.$color-border-divider-active; - } &.has-focus { @include focus-visible.when-visible-unfocused { @include styles.focus-highlight(calc(#{awsui.$space-table-header-focus-outline-gutter} - 2px)); diff --git a/src/table/selection/selection-cell.tsx b/src/table/selection/selection-cell.tsx index f2b894af81..bd58358428 100644 --- a/src/table/selection/selection-cell.tsx +++ b/src/table/selection/selection-cell.tsx @@ -56,7 +56,10 @@ export function TableHeaderSelectionCell({ ) : ( {singleSelectionHeaderAriaLabel} )} - + 1 ? 'interactive' : 'default'} + /> ); } diff --git a/src/table/sticky-header.tsx b/src/table/sticky-header.tsx index ae5ddf4fcb..c7b2616a9e 100644 --- a/src/table/sticky-header.tsx +++ b/src/table/sticky-header.tsx @@ -5,6 +5,7 @@ import clsx from 'clsx'; import { StickyHeaderContext } from '../container/use-sticky-header'; import { getVisualContextClassname } from '../internal/components/visual-context'; +import { TableColGroup } from './column-groups/col-group'; import { TableProps } from './interfaces'; import { getTableRoleProps, TableRole } from './table-role'; import Thead, { TheadProps } from './thead'; @@ -29,6 +30,9 @@ interface StickyHeaderProps { contentDensity?: 'comfortable' | 'compact'; tableHasHeader?: boolean; tableRole: TableRole; + hasGroupedColumns?: boolean; + columnDefinitions?: ReadonlyArray>; + hasSelection?: boolean; } export default forwardRef(StickyHeader); @@ -40,11 +44,14 @@ function StickyHeader( wrapperRef, theadRef, secondaryWrapperRef, - onScroll, tableRef, + onScroll, tableHasHeader, contentDensity, tableRole, + hasGroupedColumns, + columnDefinitions, + hasSelection, }: StickyHeaderProps, ref: React.Ref ) { @@ -67,6 +74,10 @@ function StickyHeader( setFocus: setFocusedComponent, })); + // For grouped columns, the secondary table needs a to define column + // widths. Without it, table-layout:fixed uses the first row (which has colspan group + // headers) to determine widths — giving wrong results. + return (
+ {hasGroupedColumns && columnDefinitions && ( + + )}
rows, stickyRef points to the first . + // Use the full bottom so we account for all header rows. + /* istanbul ignore next: requires DOM scroll measurements */ + const stickyEl = stickyRef.current.closest('thead') ?? stickyRef.current; + const stickyBottom = getLogicalBoundingClientRect(stickyEl).insetBlockEnd; const scrollingOffset = stickyBottom - getLogicalBoundingClientRect(item).insetBlockStart; if (scrollingOffset > 0) { scrollUpBy(scrollingOffset, containerRef.current); diff --git a/src/table/styles.scss b/src/table/styles.scss index 186090c9bd..2260dd7f03 100644 --- a/src/table/styles.scss +++ b/src/table/styles.scss @@ -142,6 +142,15 @@ filter search icon. padding-inline: awsui.$space-scaled-l; border-inline-start: awsui.$border-width-item-selected solid transparent; } + + // When the selection cell spans multiple header rows, use flex to push the + // checkbox to the bottom of the cell, matching bottom-aligned column headers. + &-content-spans-rows { + display: flex; + flex-direction: column; + justify-content: flex-end; + block-size: 100%; + } } .header-secondary { diff --git a/src/table/table-role/__tests__/utils.test.ts b/src/table/table-role/__tests__/utils.test.ts new file mode 100644 index 0000000000..268dcbe475 --- /dev/null +++ b/src/table/table-role/__tests__/utils.test.ts @@ -0,0 +1,150 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { + findClosestCellByAriaColIndex, + findNextCell, + findTableRowCellByAriaColIndex, + getAllCellsInRow, +} from '../utils'; + +function createCell(colIndex: number, colspan = 1, rowspan = 1): HTMLTableCellElement { + const cell = document.createElement('td'); + cell.setAttribute('aria-colindex', String(colIndex)); + cell.colSpan = colspan; + cell.rowSpan = rowspan; + return cell; +} + +function createRow(ariaRowIndex: number, cells: HTMLTableCellElement[]): HTMLTableRowElement { + const row = document.createElement('tr'); + row.setAttribute('aria-rowindex', String(ariaRowIndex)); + cells.forEach(c => row.appendChild(c)); + return row; +} + +function createTable(rows: HTMLTableRowElement[]): HTMLTableElement { + const table = document.createElement('table'); + rows.forEach(r => table.appendChild(r)); + return table; +} + +describe('findTableRowCellByAriaColIndex', () => { + test('finds cell by exact colindex', () => { + const row = createRow(1, [createCell(1), createCell(2), createCell(3)]); + expect(findTableRowCellByAriaColIndex(row, 2, 1)).toBe(row.children[1]); + }); +}); + +describe('findClosestCellByAriaColIndex', () => { + test('finds cell covered by colspan', () => { + const cells = [createCell(1, 3), createCell(4)]; + // Target 2 is within colspan of cell at colindex=1 (covers 1,2,3) + expect(findClosestCellByAriaColIndex(cells, 2, 1)).toBe(cells[0]); + }); + + test('finds closest cell when target is beyond last cell (forward)', () => { + const cells = [createCell(1), createCell(2), createCell(3)]; + // Target 5 doesn't exist — closest in forward direction is cell 3 + expect(findClosestCellByAriaColIndex(cells, 5, 1)).toBe(cells[2]); + }); + + test('breaks when columnIndex exceeds target in forward direction', () => { + // Cells at 1, 3, 5 — target is 4, delta >= 0 + // Sorted: 1, 3, 5. Loop: targetCell=1, targetCell=3, targetCell=5 (5>4 → break) + const cells = [createCell(1), createCell(3), createCell(5)]; + expect(findClosestCellByAriaColIndex(cells, 4, 1)).toBe(cells[2]); + }); + + test('breaks when columnIndex is below target in reverse direction', () => { + // Cells at 1, 3, 5 — target is 4, delta < 0 + // Sorted reversed: 5, 3, 1. Loop: targetCell=5, targetCell=3 (3<4 → break) + const cells = [createCell(1), createCell(3), createCell(5)]; + expect(findClosestCellByAriaColIndex(cells, 4, -1)).toBe(cells[1]); + }); +}); + +describe('getAllCellsInRow', () => { + test('includes cells from earlier rows that span into target row', () => { + const cell1 = createCell(1, 1, 2); // rowspan=2, spans rows 1-2 + const cell2 = createCell(2); + const row1 = createRow(1, [cell1, cell2]); + const cell3 = createCell(2); + const row2 = createRow(2, [cell3]); + const table = createTable([row1, row2]); + + const cells = getAllCellsInRow(table, 2); + // cell1 (rowspan=2 from row 1) + cell3 (row 2) + expect(cells).toContain(cell1); + expect(cells).toContain(cell3); + expect(cells).not.toContain(cell2); // cell2 only in row 1 + }); +}); + +describe('findNextCell', () => { + test('skips past current cell when vertical movement lands on same cell due to rowspan', () => { + const cell1 = createCell(1, 1, 2); // rowspan=2, spans rows 1-2 + const cell2 = createCell(2); + const row1 = createRow(1, [cell1, cell2]); + const cell3 = createCell(1); + const cell4 = createCell(2); + const row2 = createRow(2, [cell3, cell4]); + const cell5 = createCell(1); + const row3 = createRow(3, [cell5]); + const table = createTable([row1, row2, row3]); + + // Moving down from cell1 (rowspan=2, in row1) targeting row2 — lands on cell1 again + // Skips to row 1+2=3, finds cell5 + const result = findNextCell(table, row2, 1, { x: 0, y: 1 }, cell1); + expect(result).toBe(cell5); + }); + + test('returns target cell for horizontal movement', () => { + const cell1 = createCell(1); + const cell2 = createCell(2); + const row = createRow(1, [cell1, cell2]); + const table = createTable([row]); + + const result = findNextCell(table, row, 2, { x: 1, y: 0 }, cell1); + expect(result).toBe(cell2); + }); + + test('returns null when horizontal skip lands on same cell (boundary)', () => { + const cell1 = createCell(1, 3); // colspan=3, covers cols 1-3 + const row = createRow(1, [cell1]); + const table = createTable([row]); + + // Moving right from cell1 — target col 2 lands on cell1 (colspan covers it) + // Skip to col 1+3=4, but no cell there → returns null + const result = findNextCell(table, row, 2, { x: 1, y: 0 }, cell1); + expect(result).toBeNull(); + }); + + test('returns null when vertical skip cannot find a row beyond rowspan', () => { + const cell1 = createCell(1, 1, 2); // rowspan=2 + const row1 = createRow(1, [cell1]); + const table = createTable([row1]); + + // Moving down from cell1 (rowspan=2) at row 1. + // getAllCellsInRow(table, 1) finds cell1. targetCell = cell1 = currentCell. + // Skip: skipToRowIndex = 1+2 = 3. findTableRowByAriaRowIndex searches tr[aria-rowindex]. + // Only row1 exists (index=1). Loop sets targetRow=row1, no break fires, returns row1. + // To make it return null, we need no tr[aria-rowindex] for the skip search. + // Mock: temporarily remove aria-rowindex after getAllCellsInRow runs. + const origQuerySelectorAll = table.querySelectorAll.bind(table); + let callCount = 0; + jest.spyOn(table, 'querySelectorAll').mockImplementation((selector: string) => { + callCount++; + // First call is getAllCellsInRow, let it work normally. + // Second call is findTableRowByAriaRowIndex for the skip — return empty. + if (callCount > 1 && selector === 'tr[aria-rowindex]') { + return document.createElement('div').querySelectorAll('tr'); // empty NodeList + } + return origQuerySelectorAll(selector); + }); + + const result = findNextCell(table, row1, 1, { x: 0, y: 1 }, cell1); + expect(result).toBeNull(); + + jest.restoreAllMocks(); + }); +}); diff --git a/src/table/table-role/grid-navigation.tsx b/src/table/table-role/grid-navigation.tsx index f655d4aec6..e419d2e3fd 100644 --- a/src/table/table-role/grid-navigation.tsx +++ b/src/table/table-role/grid-navigation.tsx @@ -17,8 +17,8 @@ import { nodeBelongs } from '../../internal/utils/node-belongs'; import { FocusedCell, GridNavigationProps } from './interfaces'; import { defaultIsSuppressed, + findNextCell, findTableRowByAriaRowIndex, - findTableRowCellByAriaColIndex, focusNextElement, getClosestCell, isElementDisabled, @@ -330,15 +330,13 @@ export class GridNavigationProcessor { return cellFocusables[nextElementIndex]; } - // Find next cell to focus or move focus into (can be null if the left/right edge is reached). + // Find next cell to focus or move focus into. const targetAriaColIndex = from.colIndex + delta.x; - const targetCell = findTableRowCellByAriaColIndex(targetRow, targetAriaColIndex, delta.x); - if (!targetCell) { - return null; - } - // When target cell matches the current cell it means we reached the left or right boundary. - if (targetCell === cellElement && delta.x !== 0) { + const targetCell = this.table + ? findNextCell(this.table, targetRow, targetAriaColIndex, delta, cellElement as HTMLTableCellElement | null) + : null; + if (!targetCell) { return null; } diff --git a/src/table/table-role/table-role-helper.ts b/src/table/table-role/table-role-helper.ts index f28752e3fd..ac0748421b 100644 --- a/src/table/table-role/table-role-helper.ts +++ b/src/table/table-role/table-role-helper.ts @@ -22,6 +22,7 @@ export function getTableRoleProps(options: { ariaLabelledby?: string; totalItemsCount?: number; totalColumnsCount?: number; + headerRowCount?: number; }): React.TableHTMLAttributes { const nativeProps: React.TableHTMLAttributes = {}; @@ -33,8 +34,9 @@ export function getTableRoleProps(options: { nativeProps['aria-labelledby'] = options.ariaLabelledby; // Incrementing the total count by one to account for the header row. + const headerRows = options.headerRowCount ?? 1; if (typeof options.totalItemsCount === 'number' && options.totalItemsCount > 0) { - nativeProps['aria-rowcount'] = options.totalItemsCount + 1; + nativeProps['aria-rowcount'] = options.totalItemsCount + headerRows; } if (options.tableRole === 'grid' || options.tableRole === 'treegrid') { @@ -68,12 +70,12 @@ export function getTableWrapperRoleProps(options: { return nativeProps; } -export function getTableHeaderRowRoleProps(options: { tableRole: TableRole }) { +export function getTableHeaderRowRoleProps(options: { tableRole: TableRole; rowIndex?: number }) { const nativeProps: React.HTMLAttributes = {}; // For grids headers are treated similar to data rows and are indexed accordingly. if (options.tableRole === 'grid' || options.tableRole === 'grid-default' || options.tableRole === 'treegrid') { - nativeProps['aria-rowindex'] = 1; + nativeProps['aria-rowindex'] = (options.rowIndex ?? 0) + 1; } return nativeProps; @@ -83,6 +85,7 @@ export function getTableRowRoleProps(options: { tableRole: TableRole; rowIndex: number; firstIndex?: number; + headerRowCount?: number; level?: number; setSize?: number; posInSet?: number; @@ -90,12 +93,13 @@ export function getTableRowRoleProps(options: { const nativeProps: React.HTMLAttributes = {}; // The data cell indices are incremented by 1 to account for the header cells. + const headerRows = options.headerRowCount ?? 1; if (options.tableRole === 'grid' || options.tableRole === 'treegrid') { - nativeProps['aria-rowindex'] = (options.firstIndex || 1) + options.rowIndex + 1; + nativeProps['aria-rowindex'] = (options.firstIndex || 1) + options.rowIndex + headerRows; } // For tables indices are only added when the first index is not 0 (not the first page/frame). else if (options.firstIndex !== undefined) { - nativeProps['aria-rowindex'] = options.firstIndex + options.rowIndex + 1; + nativeProps['aria-rowindex'] = options.firstIndex + options.rowIndex + headerRows; } if (options.tableRole === 'treegrid' && options.level && options.level !== 0) { nativeProps['aria-level'] = options.level; diff --git a/src/table/table-role/utils.ts b/src/table/table-role/utils.ts index c39809a50d..5902e0f04a 100644 --- a/src/table/table-role/utils.ts +++ b/src/table/table-role/utils.ts @@ -68,18 +68,72 @@ export function findTableRowCellByAriaColIndex( targetAriaColIndex: number, delta: number ) { + const cellElements = Array.from( + tableRow.querySelectorAll('td[aria-colindex],th[aria-colindex]') + ); + return findClosestCellByAriaColIndex(cellElements, targetAriaColIndex, delta); +} + +/** + * Collects all cells visually present in a row, including cells from earlier rows + * that span into this row via rowspan. This is needed because cells with rowspan > 1 + * are only in one in the DOM but visually occupy multiple rows. + */ +export function getAllCellsInRow(table: HTMLTableElement, targetAriaRowIndex: number): HTMLTableCellElement[] { + const cells: HTMLTableCellElement[] = []; + const rows = table.querySelectorAll('tr[aria-rowindex]'); + + for (const row of Array.from(rows)) { + const rowIndex = parseInt(row.getAttribute('aria-rowindex') ?? ''); + if (isNaN(rowIndex) || rowIndex > targetAriaRowIndex) { + continue; + } + + const rowCells = row.querySelectorAll('td[aria-colindex],th[aria-colindex]'); + for (const cell of Array.from(rowCells)) { + const rowspan = cell.rowSpan || 1; + // Cell is visible in target row if: rowIndex <= targetAriaRowIndex < rowIndex + rowspan + if (rowIndex + rowspan > targetAriaRowIndex) { + cells.push(cell); + } + } + } + + return cells; +} + +/** + * From a list of cell elements, find the closest one to targetAriaColIndex in the direction of delta. + * Accounts for colspan: a cell with colindex=2 and colspan=4 covers columns 2,3,4,5. + */ +export function findClosestCellByAriaColIndex( + cellElements: HTMLTableCellElement[], + targetAriaColIndex: number, + delta: number +): HTMLTableCellElement | null { + // First check if any cell's colspan range covers the target exactly. + for (const element of cellElements) { + const colIndex = parseInt(element.getAttribute('aria-colindex') ?? ''); + const colspan = element.colSpan || 1; + if (colIndex <= targetAriaColIndex && targetAriaColIndex < colIndex + colspan) { + return element; + } + } + + // Otherwise find the closest cell in the direction of delta. let targetCell: null | HTMLTableCellElement = null; - const cellElements = Array.from(tableRow.querySelectorAll('td[aria-colindex],th[aria-colindex]')); + const sorted = [...cellElements].sort((a, b) => { + const aIdx = parseInt(a.getAttribute('aria-colindex') ?? '0'); + const bIdx = parseInt(b.getAttribute('aria-colindex') ?? '0'); + return aIdx - bIdx; + }); if (delta < 0) { - cellElements.reverse(); + sorted.reverse(); } - for (const element of cellElements) { + for (const element of sorted) { const columnIndex = parseInt(element.getAttribute('aria-colindex') ?? ''); - targetCell = element as HTMLTableCellElement; + targetCell = element; - if (columnIndex === targetAriaColIndex) { - break; - } if (delta >= 0 && columnIndex > targetAriaColIndex) { break; } @@ -90,6 +144,50 @@ export function findTableRowCellByAriaColIndex( return targetCell; } +/** + * Finds the next cell to navigate to, handling colspan and rowspan for grouped columns. + * Skips past the current cell when movement lands on it due to span attributes. + */ +export function findNextCell( + table: HTMLTableElement, + targetRow: HTMLTableRowElement, + targetAriaColIndex: number, + delta: { x: number; y: number }, + currentCell: HTMLTableCellElement | null +): HTMLTableCellElement | null { + const targetRowAriaIndex = parseInt(targetRow.getAttribute('aria-rowindex') ?? ''); + let allVisibleCells = getAllCellsInRow(table, targetRowAriaIndex); + let targetCell = findClosestCellByAriaColIndex(allVisibleCells, targetAriaColIndex, delta.x); + + // When vertical movement lands on the same cell (due to rowspan), skip past it. + if (targetCell === currentCell && delta.y !== 0 && currentCell) { + const cellRow = currentCell.closest('tr'); + const cellRowIndex = parseInt(cellRow?.getAttribute('aria-rowindex') ?? '0'); + const cellRowSpan = currentCell.rowSpan || 1; + const skipToRowIndex = delta.y > 0 ? cellRowIndex + cellRowSpan : cellRowIndex - 1; + const skipRow = findTableRowByAriaRowIndex(table, skipToRowIndex, delta.y); + if (!skipRow) { + return null; + } + const skipRowAriaIndex = parseInt(skipRow.getAttribute('aria-rowindex') ?? ''); + allVisibleCells = getAllCellsInRow(table, skipRowAriaIndex); + targetCell = findClosestCellByAriaColIndex(allVisibleCells, targetAriaColIndex, delta.x); + } + + // When horizontal movement lands on the same cell (due to colspan), skip past it. + if (targetCell === currentCell && delta.x !== 0 && currentCell) { + const cellColIndex = parseInt(currentCell.getAttribute('aria-colindex') ?? '0'); + const cellColSpan = currentCell.colSpan || 1; + const skipToColIndex = delta.x > 0 ? cellColIndex + cellColSpan : cellColIndex - 1; + targetCell = findClosestCellByAriaColIndex(allVisibleCells, skipToColIndex, delta.x); + if (!targetCell || targetCell === currentCell) { + return null; + } + } + + return targetCell; +} + export function isTableCell(element: Element) { return element.tagName === 'TD' || element.tagName === 'TH'; } diff --git a/src/table/thead.tsx b/src/table/thead.tsx index 10024c014e..d5d381d652 100644 --- a/src/table/thead.tsx +++ b/src/table/thead.tsx @@ -6,13 +6,16 @@ import clsx from 'clsx'; import { findUpUntil } from '@cloudscape-design/component-toolkit/dom'; import { fireNonCancelableEvent, NonCancelableEventHandler } from '../internal/events'; +import { getGroupColumnIds, getGroupSplit } from './column-groups/split-utils'; +import { ColumnGroupsLayout } from './column-groups/utils'; import { TableHeaderCell } from './header-cell'; +import { TableGroupHeaderCell } from './header-cell/group-header-cell'; import { InternalSelectionType, TableProps } from './interfaces'; import { focusMarkers, ItemSelectionProps } from './selection'; import { TableHeaderSelectionCell } from './selection/selection-cell'; import { StickyColumnsModel } from './sticky-columns'; import { getTableHeaderRowRoleProps, TableRole } from './table-role'; -import { useColumnWidths } from './use-column-widths'; +import { DEFAULT_COLUMN_WIDTH, useColumnWidths } from './use-column-widths'; import { getColumnKey } from './utils'; import styles from './styles.css.js'; @@ -20,6 +23,8 @@ import styles from './styles.css.js'; export interface TheadProps { selectionType: undefined | InternalSelectionType; columnDefinitions: ReadonlyArray>; + groupDefinitions?: ReadonlyArray; + columnGroupsLayout?: ColumnGroupsLayout; sortingColumn: TableProps.SortingColumn | undefined; sortingDescending: boolean | undefined; sortingDisabled: boolean | undefined; @@ -39,6 +44,8 @@ export interface TheadProps { resizerTooltipText?: string; stripedRows?: boolean; stickyState: StickyColumnsModel; + stickyColumnsFirst: number; + stickyColumnsLast: number; selectionColumnId: PropertyKey; focusedComponent?: null | string; onFocusedComponentChange?: (focusId: null | string) => void; @@ -53,6 +60,7 @@ const Thead = React.forwardRef( selectionType, getSelectAllProps, columnDefinitions, + columnGroupsLayout, sortingColumn, sortingDisabled, sortingDescending, @@ -69,6 +77,8 @@ const Thead = React.forwardRef( hidden = false, stuck = false, stickyState, + stickyColumnsFirst, + stickyColumnsLast, selectionColumnId, focusedComponent, onFocusedComponentChange, @@ -80,7 +90,20 @@ const Thead = React.forwardRef( }: TheadProps, outerRef: React.Ref ) => { - const { getColumnStyles, columnWidths, updateColumn, setCell } = useColumnWidths(); + const { getColumnStyles, columnWidths, updateColumn, updateGroup, setCell } = useColumnWidths(); + + const handleSplitGroupResize = (columnIds: string[], newWidth: number) => { + const lastColumn = columnIds[columnIds.length - 1]; + if (lastColumn) { + const currentGroupWidth = columnIds.reduce( + (sum, id) => sum + (columnWidths.get(id) || DEFAULT_COLUMN_WIDTH), + 0 + ); + const delta = newWidth - currentGroupWidth; + const currentColumnWidth = columnWidths.get(lastColumn) || DEFAULT_COLUMN_WIDTH; + updateColumn(lastColumn, currentColumnWidth + delta); + } + }; const commonCellProps = { stuck, @@ -91,69 +114,322 @@ const Thead = React.forwardRef( variant, tableVariant, stickyState, + wrapLines, }; + const sharedTrProps = { + onFocus: (event: React.FocusEvent) => { + const focusControlElement = findUpUntil( + event.target as HTMLElement, + element => !!element.getAttribute('data-focus-id') + ); + const focusId = focusControlElement?.getAttribute('data-focus-id') ?? null; + onFocusedComponentChange?.(focusId); + }, + onBlur: () => onFocusedComponentChange?.(null), + }; + + // No grouping - render single row + if (!columnGroupsLayout || columnGroupsLayout.rows.length <= 1) { + return ( + + + {selectionType ? ( + setCell(sticky, selectionColumnId, node)} + getSelectAllProps={getSelectAllProps} + onFocusMove={onFocusMove} + singleSelectionHeaderAriaLabel={singleSelectionHeaderAriaLabel} + /> + ) : null} + + {columnDefinitions.map((column, colIndex) => { + const columnId = getColumnKey(column, colIndex); + return ( + onResizeFinish(columnWidths)} + resizableColumns={resizableColumns} + resizableStyle={getColumnStyles(sticky, columnId)} + onClick={detail => { + setLastUserAction('sorting'); + fireNonCancelableEvent(onSortingChange, detail); + }} + isEditable={!!column.editConfig} + cellRef={node => setCell(sticky, columnId, node)} + tableRole={tableRole} + resizerRoleDescription={resizerRoleDescription} + resizerTooltipText={resizerTooltipText} + isExpandable={colIndex === 0 && isExpandable} + hasDynamicContent={hidden && !resizableColumns && column.hasDynamicContent} + isLast={colIndex === columnDefinitions.length - 1} + /> + ); + })} + + + ); + } + + // Grouped columns + const totalColumns = columnDefinitions.length; return ( - { - const focusControlElement = findUpUntil(event.target, element => !!element.getAttribute('data-focus-id')); - const focusId = focusControlElement?.getAttribute('data-focus-id') ?? null; - onFocusedComponentChange?.(focusId); - }} - onBlur={() => onFocusedComponentChange?.(null)} - > - {selectionType ? ( - - ) : null} - - {columnDefinitions.map((column, colIndex) => { - const columnId = getColumnKey(column, colIndex); - return ( - ( + + {/* Selection column — render once in the first row with rowSpan covering all header rows */} + {selectionType && rowIndex === 0 ? ( + onResizeFinish(columnWidths)} - resizableColumns={resizableColumns} - resizableStyle={getColumnStyles(sticky, columnId)} - onClick={detail => { - setLastUserAction('sorting'); - fireNonCancelableEvent(onSortingChange, detail); - }} - isEditable={!!column.editConfig} - cellRef={node => setCell(sticky, columnId, node)} - tableRole={tableRole} - resizerRoleDescription={resizerRoleDescription} - resizerTooltipText={resizerTooltipText} - // Expandable option is only applicable to the first data column of the table. - // When present, the header content receives extra padding to match the first offset in the data cells. - isExpandable={colIndex === 0 && isExpandable} - hasDynamicContent={hidden && !resizableColumns && column.hasDynamicContent} + columnId={selectionColumnId} + cellRef={node => setCell(sticky, selectionColumnId, node)} + getSelectAllProps={getSelectAllProps} + onFocusMove={onFocusMove} + singleSelectionHeaderAriaLabel={singleSelectionHeaderAriaLabel} + rowSpan={columnGroupsLayout.rows.length} /> - ); - })} - + ) : null} + + {row.columns.map((col, colIndexInRow) => { + // A cell is the last child of its parent group when the next rendered cell + // in the same row belongs to a different top-level parent, i.e. they don't + // share the same immediate parent group. + const nextCol = row.columns[colIndexInRow + 1]; + const thisParent = col.parentGroupIds[col.parentGroupIds.length - 1] ?? null; + const nextParent = nextCol ? (nextCol.parentGroupIds[nextCol.parentGroupIds.length - 1] ?? null) : null; + // A column is also considered last-child-of-group when the sticky boundary + // bisects its parent group just after this column — visually it's the rightmost + // column of the sticky half, so its resizer should span full-height like a + // normal last-child-of-group. + const isColumnAtStickyFirstBoundary = + !col.isGroup && + thisParent !== null && + stickyColumnsFirst > 0 && + col.colIndex === stickyColumnsFirst - 1; + const isColumnAtStickyLastBoundary = + !col.isGroup && + thisParent !== null && + stickyColumnsLast > 0 && + col.colIndex === columnDefinitions.length - stickyColumnsLast - 1; + const isLastChildOfGroup = + (thisParent !== null && thisParent !== nextParent) || + isColumnAtStickyFirstBoundary || + isColumnAtStickyLastBoundary; + + if (col.isGroup) { + // Group header cell + const groupDefinition = col.groupDefinition!; + const childIds = getGroupColumnIds(columnGroupsLayout!, col.id); + const sharedGroupCellProps = { + ...commonCellProps, + tabIndex: sticky ? -1 : 0, + focusedComponent, + group: groupDefinition, + rowspan: col.rowSpan, + resizableColumns, + onResizeFinish: () => onResizeFinish(columnWidths), + columnGroupId: + col.parentGroupIds.length > 0 ? col.parentGroupIds[col.parentGroupIds.length - 1] : undefined, + }; + const splitFirst = getGroupSplit({ + col, + stickyCount: stickyColumnsFirst, + side: 'first', + totalColumns, + }); + const splitLast = getGroupSplit({ + col, + stickyCount: stickyColumnsLast, + side: 'last', + totalColumns, + }); + const split = splitFirst.stickyColspan > 0 ? splitFirst : splitLast; + const isSplit = split.stickyColspan > 0; + + if (isSplit) { + // Group is bisected by the sticky boundary — render two + ))} ); } diff --git a/src/table/use-column-widths.tsx b/src/table/use-column-widths.tsx index 694972aa12..fbc232fd1d 100644 --- a/src/table/use-column-widths.tsx +++ b/src/table/use-column-widths.tsx @@ -39,7 +39,7 @@ function updateWidths( oldWidths: Map, newWidth: number, columnId: PropertyKey -) { +): Map { const column = visibleColumns.find(column => column.id === columnId); let minWidth = DEFAULT_COLUMN_WIDTH; if (typeof column?.width === 'number' && column.width < DEFAULT_COLUMN_WIDTH) { @@ -61,14 +61,18 @@ interface WidthsContext { getColumnStyles(sticky: boolean, columnId: PropertyKey): ColumnWidthStyle; columnWidths: Map; updateColumn: (columnId: PropertyKey, newWidth: number) => void; + updateGroup: (groupId: PropertyKey, newWidth: number) => void; setCell: (sticky: boolean, columnId: PropertyKey, node: null | HTMLElement) => void; + setCol: (columnId: PropertyKey, node: null | HTMLElement) => void; } const WidthsContext = createContext({ getColumnStyles: () => ({}), columnWidths: new Map(), updateColumn: () => {}, + updateGroup: () => {}, setCell: () => {}, + setCol: () => {}, }); interface WidthProviderProps { @@ -76,15 +80,24 @@ interface WidthProviderProps { resizableColumns: boolean | undefined; containerRef: React.RefObject; children: React.ReactNode; + groupColumnMap?: Map; } -export function ColumnWidthsProvider({ visibleColumns, resizableColumns, containerRef, children }: WidthProviderProps) { +export function ColumnWidthsProvider({ + visibleColumns, + resizableColumns, + containerRef, + groupColumnMap, + children, +}: WidthProviderProps) { const visibleColumnsRef = useRef(null); const containerWidthRef = useRef(0); const [columnWidths, setColumnWidths] = useState>(null); const cellsRef = useRef(new Map()); const stickyCellsRef = useRef(new Map()); + const colsRef = useRef(new Map()); + const hasColElements = useRef(false); const getCell = (columnId: PropertyKey): null | HTMLElement => cellsRef.current.get(columnId) ?? null; const setCell = (sticky: boolean, columnId: PropertyKey, node: null | HTMLElement) => { const ref = sticky ? stickyCellsRef : cellsRef; @@ -94,19 +107,32 @@ export function ColumnWidthsProvider({ visibleColumns, resizableColumns, contain ref.current.delete(columnId); } }; + const setCol = (columnId: PropertyKey, node: null | HTMLElement) => { + if (node) { + colsRef.current.set(columnId, node); + hasColElements.current = true; + } else { + colsRef.current.delete(columnId); + hasColElements.current = colsRef.current.size > 0; + } + }; const getColumnStyles = (sticky: boolean, columnId: PropertyKey): ColumnWidthStyle => { - const column = visibleColumns.find(column => column.id === columnId); - if (!column) { - return {}; - } + const column = visibleColumns.find(col => col.id === columnId); if (sticky) { - return { - width: - cellsRef.current.get(column.id)?.getBoundingClientRect().width || - (columnWidths?.get(column.id) ?? column.width), - }; + // For sticky headers, mirror the primary cell's width. + // Try DOM measurement first (handles columns not in visibleColumns like selection). + const measured = cellsRef.current.get(columnId)?.getBoundingClientRect().width; + /* istanbul ignore next: getBoundingClientRect returns 0 in JSDOM */ + if (measured) { + return { width: measured }; + } + return { width: columnWidths?.get(columnId) ?? column?.width }; + } + + if (!column) { + return {}; } if (resizableColumns && columnWidths) { @@ -116,11 +142,11 @@ export function ColumnWidthsProvider({ visibleColumns, resizableColumns, contain 0 ); if (isLastColumn && containerWidthRef.current > totalWidth) { - return { width: 'auto', minWidth: column?.minWidth }; - } else { - return { width: columnWidths.get(column.id), minWidth: column?.minWidth }; + return { width: 'auto', minWidth: column.minWidth }; } + return { width: columnWidths.get(column.id), minWidth: column.minWidth }; } + return { width: column.width, minWidth: column.minWidth, @@ -131,13 +157,29 @@ export function ColumnWidthsProvider({ visibleColumns, resizableColumns, contain // Imperatively sets width style for a cell avoiding React state. // This allows setting the style as soon container's size change is observed. const updateColumnWidths = useStableCallback(() => { - for (const { id } of visibleColumns) { - const element = cellsRef.current.get(id); - if (element) { - setElementWidths(element, getColumnStyles(false, id)); + // When col elements exist (grouped columns), apply widths to elements. + // With table-layout:fixed, widths control the actual column widths. + if (hasColElements.current) { + for (const { id } of visibleColumns) { + const colElement = colsRef.current.get(id); + if (colElement) { + setElementWidths(colElement, getColumnStyles(false, id)); + } + const element = cellsRef.current.get(id); + if (element) { + setElementWidths(element, getColumnStyles(false, id)); + } + } + } else { + for (const { id } of visibleColumns) { + const element = cellsRef.current.get(id); + if (element) { + setElementWidths(element, getColumnStyles(false, id)); + } } } - // Sticky column widths must be synchronized once all real column widths are assigned. + + // Sticky column widths must always be synchronized regardless of columnWidths state. for (const { id } of visibleColumns) { const element = stickyCellsRef.current.get(id); if (element) { @@ -195,8 +237,31 @@ export function ColumnWidthsProvider({ visibleColumns, resizableColumns, contain setColumnWidths(columnWidths => updateWidths(visibleColumns, columnWidths ?? new Map(), newWidth, columnId)); } + function updateGroup(groupId: PropertyKey, newGroupWidth: number) { + if (!columnWidths || !groupColumnMap) { + return; + } + + const columnIds = groupColumnMap.get(String(groupId)) ?? []; + const rightmostColumn = columnIds[columnIds.length - 1]; + if (!rightmostColumn) { + return; + } + + let currentGroupWidth = 0; + for (const id of columnIds) { + currentGroupWidth += columnWidths.get(id) || DEFAULT_COLUMN_WIDTH; + } + + const delta = newGroupWidth - currentGroupWidth; + const currentColumnWidth = columnWidths.get(rightmostColumn) || DEFAULT_COLUMN_WIDTH; + updateColumn(rightmostColumn, currentColumnWidth + delta); + } + return ( - + {children} ); diff --git a/src/table/use-sticky-header.ts b/src/table/use-sticky-header.ts index 952ccb6b49..bbf516ece3 100644 --- a/src/table/use-sticky-header.ts +++ b/src/table/use-sticky-header.ts @@ -24,7 +24,9 @@ export const useStickyHeader = ( secondaryTableRef.current && tableWrapperRef.current ) { - tableWrapperRef.current.style.marginBlockStart = `-${theadRef.current.getBoundingClientRect().height}px`; + // Use the full thead height to account for multi-row headers (grouped columns). + const thead = theadRef.current.closest('thead') ?? theadRef.current; + tableWrapperRef.current.style.marginBlockStart = `-${thead.getBoundingClientRect().height}px`; } }, [theadRef, secondaryTheadRef, secondaryTableRef, tableWrapperRef, tableRef]); useLayoutEffect(() => { diff --git a/src/table/utils.ts b/src/table/utils.ts index 6822e581e4..6166bfac10 100644 --- a/src/table/utils.ts +++ b/src/table/utils.ts @@ -79,10 +79,8 @@ function getVisibleColumnDefinitionsFromColumnDisplay({ (accumulator, item) => (item.id === undefined ? accumulator : { ...accumulator, [item.id]: item }), {} ); - return columnDisplay - .filter(item => item.visible) - .map(item => columnDefinitionsById[item.id]) - .filter(Boolean); + const visibleIds = flattenVisibleColumnIds(columnDisplay); + return visibleIds.map(id => columnDefinitionsById[id]).filter(Boolean); } function getVisibleColumnDefinitionsFromVisibleColumns({ @@ -104,3 +102,15 @@ export function getStickyClassNames(styles: Record, props: Stick [styles['sticky-cell-last-inline-end']]: !!props?.lastInsetInlineEnd, }; } + +function flattenVisibleColumnIds(items: ReadonlyArray): string[] { + const ids: string[] = []; + for (const item of items) { + if (item.type === 'group') { + ids.push(...flattenVisibleColumnIds(item.children)); + } else if (item.visible) { + ids.push(item.id); + } + } + return ids; +} diff --git a/src/test-utils/dom/table/index.ts b/src/test-utils/dom/table/index.ts index 4858341902..268ef6ee5e 100644 --- a/src/test-utils/dom/table/index.ts +++ b/src/test-utils/dom/table/index.ts @@ -46,7 +46,25 @@ export default class TableWrapper extends ComponentWrapper { return this.containerWrapper.findFooter(); } - findColumnHeaders(): Array { + /** + * Returns column header cells from the table's header region. + * + * By default, returns all leaf-column headers (`scope="col"`). + * For tables without column grouping this is equivalent to the previous behavior. + * For tables with column grouping this excludes group header cells. + * + * @param option.groupId When provided, returns only leaf columns belonging to this group + * (matched via `data-column-group-id` attribute). + */ + findColumnHeaders( + option: { + groupId?: string; + } = {} + ): Array { + const { groupId } = option; + if (groupId !== undefined) { + return this.findActiveTHead().findAll(`th[data-column-group-id="${groupId}"]`); + } return this.findActiveTHead().findAll('tr > *'); } @@ -56,7 +74,9 @@ export default class TableWrapper extends ComponentWrapper { * @param columnIndex 1-based index of the column containing the resizer. */ findColumnResizer(columnIndex: number): ElementWrapper | null { - return this.findActiveTHead().find(`th:nth-child(${columnIndex}) .${resizerStyles.resizer}`); + return this.findActiveTHead().find( + `th[data-column-index="${columnIndex}"] .${resizerStyles.resizer}, tr:not([data-group-level]) > th:nth-child(${columnIndex}) .${resizerStyles.resizer}` + ); } /** @@ -105,8 +125,15 @@ export default class TableWrapper extends ComponentWrapper { return this.findByClassName(styles.loading); } + /** + * Returns the clickable sorting area of a column header. + * + * @param colIndex 1-based index of the column. + */ findColumnSortingArea(colIndex: number): ElementWrapper | null { - return this.findActiveTHead().find(`tr > *:nth-child(${colIndex}) [role=button]`); + return this.findActiveTHead().find( + `th[data-column-index="${colIndex}"] [role=button], tr:not([data-group-level]) > *:nth-child(${colIndex}) [role=button]` + ); } /** From ea332d08eb460cdb52d3b87b7d864f040c2868f2 Mon Sep 17 00:00:00 2001 From: Nathnael_D <109980176+NathanZlion@users.noreply.github.com> Date: Mon, 8 Jun 2026 09:51:36 +0200 Subject: [PATCH 85/95] feat: Table Column Groups Collection Prefs (#4487) --- .../content-display-groups.page.tsx | 49 +++ .../collection-preferences/shared-configs.tsx | 53 ++++ pages/common/i18n-strings.ts | 1 + .../__snapshots__/documenter.test.ts.snap | 137 +++++++- .../test-utils-selectors.test.tsx.snap | 2 + .../__tests__/shared.tsx | 2 + .../__integ__/content-display-groups.test.ts | 74 +++++ .../__integ__/pages/content-display-page.ts | 18 ++ .../__tests__/content-display.test.tsx | 208 ++++++++++++ .../content-display/__tests__/utils.test.ts | 262 ++++++++++++++- .../content-display/content-display-list.scss | 10 + .../content-display-option.scss | 12 + .../content-display-option.tsx | 2 +- .../content-display/index.tsx | 298 ++++++++++++++---- .../content-display/utils.ts | 153 ++++++++- src/collection-preferences/index.tsx | 8 +- src/collection-preferences/interfaces.ts | 35 +- src/collection-preferences/utils.tsx | 17 + src/i18n/messages-types.ts | 4 + src/i18n/messages/all.en.json | 1 + .../content-display-preference.ts | 70 +++- 21 files changed, 1319 insertions(+), 97 deletions(-) create mode 100644 pages/collection-preferences/content-display-groups.page.tsx create mode 100644 src/collection-preferences/content-display/__integ__/content-display-groups.test.ts diff --git a/pages/collection-preferences/content-display-groups.page.tsx b/pages/collection-preferences/content-display-groups.page.tsx new file mode 100644 index 0000000000..90291499b4 --- /dev/null +++ b/pages/collection-preferences/content-display-groups.page.tsx @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Box from '~components/box'; +import CollectionPreferences, { CollectionPreferencesProps } from '~components/collection-preferences'; +import SpaceBetween from '~components/space-between'; + +import { contentDisplayPreferenceI18nStrings } from '../common/i18n-strings'; +import { + baseProperties, + contentDisplayGroups, + groupedContentDisplay, + groupedContentDisplayOptions, +} from './shared-configs'; + +export default function ContentDisplayGroupsPage() { + const [preferences, setPreferences] = useState({ + contentDisplay: groupedContentDisplay, + }); + + return ( + +

Content Display with Groups

+ + setPreferences(detail)} + contentDisplayPreference={{ + title: 'Column preferences', + description: 'Customize column visibility and order.', + options: groupedContentDisplayOptions, + groups: contentDisplayGroups, + enableColumnFiltering: true, + ...contentDisplayPreferenceI18nStrings, + }} + /> + + Current preferences.contentDisplay +
+        {JSON.stringify(preferences.contentDisplay, null, 2)}
+      
+
+ ); +} diff --git a/pages/collection-preferences/shared-configs.tsx b/pages/collection-preferences/shared-configs.tsx index f474598fe8..17d254856d 100644 --- a/pages/collection-preferences/shared-configs.tsx +++ b/pages/collection-preferences/shared-configs.tsx @@ -96,3 +96,56 @@ export const customPreference = (customState: boolean) => ( View as ); + +export const groupedContentDisplayOptions: CollectionPreferencesProps.ContentDisplayOption[] = [ + { id: 'id', label: 'Instance ID', alwaysVisible: true }, + { id: 'name', label: 'Name' }, + { id: 'type', label: 'Instance type' }, + { id: 'az', label: 'Availability zone' }, + { id: 'state', label: 'State' }, + { id: 'cpu', label: 'CPU (%)' }, + { id: 'memory', label: 'Memory (%)' }, + { id: 'netIn', label: 'Network in (MB/s)' }, + { id: 'netOut', label: 'Network out (MB/s)' }, + { id: 'cost', label: 'Monthly cost ($)' }, +]; + +export const contentDisplayGroups: CollectionPreferencesProps.ContentDisplayOptionGroup[] = [ + { id: 'config', label: 'Configuration' }, + { id: 'performance', label: 'Performance' }, + { id: 'network', label: 'Network' }, +]; + +export const groupedContentDisplay: CollectionPreferencesProps.ContentDisplayItem[] = [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + ], + }, + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, + { + type: 'group', + id: 'network', + visible: true, + children: [ + { id: 'netIn', visible: true }, + { id: 'netOut', visible: true }, + ], + }, + { id: 'cost', visible: true }, +]; diff --git a/pages/common/i18n-strings.ts b/pages/common/i18n-strings.ts index 5b6c57edda..5a0f52d93c 100644 --- a/pages/common/i18n-strings.ts +++ b/pages/common/i18n-strings.ts @@ -16,6 +16,7 @@ export const contentDisplayPreferenceI18nStrings: Partial `${label}, ${count} ${count === 1 ? 'item' : 'items'}`, i18nStrings: { columnFilteringPlaceholder: 'Filter columns', columnFilteringAriaLabel: 'Filter columns', diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 897e51a55d..a327e6590a 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -8916,6 +8916,9 @@ It contains the following: - \`title\` (string) - Specifies the text displayed at the top of the preference. - \`description\` (string) - Specifies the description displayed below the title. - \`options\` - Specifies an array of options for reordering and visible content selection. +- \`groups\` - (Optional) Specifies an array of column group definitions for multi-level content display. Each group contains: + - \`id\` (string) - A unique identifier for the group. + - \`label\` (string) - The text displayed as the group label. - \`enableColumnFiltering\` (boolean) - Adds a columns filter. - \`liveAnnouncementDndStarted\` ((position: number, total: number) => string) - (Optional) Adds a message to be announced by screen readers when an option is picked. - \`liveAnnouncementDndDiscarded\` (string) - (Optional) Adds a message to be announced by screen readers when a reordering action is canceled. @@ -8923,13 +8926,24 @@ It contains the following: - \`liveAnnouncementDndItemCommitted\` ((initialPosition: number, finalPosition: number, total: number) => string) - (Optional) Adds a message to be announced by screen readers when a reordering action is committed. - \`dragHandleAriaDescription\` (string) - (Optional) Adds an ARIA description for the drag handle. - \`dragHandleAriaLabel\` (string) - (Optional) Adds an ARIA label for the drag handle. +- \`liveAnnouncementDndGroupLabel\` ((label: string, count: number) => string) - (Optional) Adds a label for a group item to be announced by screen readers during drag and drop operations. Each option contains the following: - \`id\` (string) - Corresponds to a table column \`id\`. - \`label\` (string) - Specifies a short description of the content. - \`alwaysVisible\` (boolean) - (Optional) Determines whether the visibility is always on and therefore cannot be toggled. This is set to \`false\` by default. -You must provide an ordered list of the items to display in the \`preferences.contentDisplay\` property.", +You must provide an ordered list of the items to display in the \`preferences.contentDisplay\` property. +Each content display item is one of the following: +- \`ContentDisplayColumn\` - Represents a single column. + - \`type\` ('column') - (Optional) Identifies the entry as a column. Defaults to \`'column'\` when omitted. + - \`id\` (string) - The column identifier. + - \`visible\` (boolean) - Whether the column is visible. +- \`ContentDisplayGroup\` - Represents a column group. + - \`type\` ('group') - Identifies the entry as a group. + - \`id\` (string) - The group identifier. + - \`visible\` (boolean) - Whether the group is visible. + - \`children\` (ReadonlyArray) - The columns or nested groups within this group.", "i18nTag": true, "inlineType": { "name": "CollectionPreferencesProps.ContentDisplayPreference", @@ -8954,6 +8968,11 @@ You must provide an ordered list of the items to display in the \`preferences.co "optional": true, "type": "boolean", }, + { + "name": "groups", + "optional": true, + "type": "ReadonlyArray", + }, { "inlineType": { "name": "CollectionPreferencesProps.ContentDisplayPreferenceI18nStrings", @@ -9006,6 +9025,26 @@ You must provide an ordered list of the items to display in the \`preferences.co "optional": true, "type": "string", }, + { + "inlineType": { + "name": "(label: string, count: number) => string", + "parameters": [ + { + "name": "label", + "type": "string", + }, + { + "name": "count", + "type": "number", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "liveAnnouncementDndGroupLabel", + "optional": true, + "type": "((label: string, count: number) => string)", + }, { "inlineType": { "name": "(initialPosition: number, finalPosition: number, total: number) => string", @@ -38060,9 +38099,23 @@ Returns the current value of the input.", }, }, { - "description": "Returns options that the user can reorder.", + "description": "Returns the top-level items in the preference list. + +For tables **without** column grouping this returns all column options. +For tables **with** column grouping this returns the top-level entries only +(which are group items). Use \`.findChildrenOptions()\` on a group item to +access the leaf columns nested within it.", "name": "findOptions", - "parameters": [], + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ group?: boolean | undefined; }", + }, + ], "returnType": { "isNullable": false, "name": "Array", @@ -38101,6 +38154,33 @@ Returns the current value of the input.", }, { "methods": [ + { + "description": "Returns all child option items nested under this item when it is a group. +Returns \`null\` when this item is a leaf column (has no nested children). + +The children are the leaf-level \`ContentDisplayOptionWrapper\`s inside the group's +nested \`InternalList\` — i.e. they already carry a drag handle and visibility toggle.", + "name": "findChildrenOptions", + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ group?: boolean | undefined; }", + }, + ], + "returnType": { + "isNullable": true, + "name": "Array", + "typeArguments": [ + { + "name": "ContentDisplayOptionWrapper", + }, + ], + }, + }, { "description": "Returns the drag handle for the option item.", "name": "findDragHandle", @@ -38130,7 +38210,8 @@ Returns the current value of the input.", }, }, { - "description": "Returns the visibility toggle for the option item.", + "description": "Returns the visibility toggle for the option item. +Note that, despite its typings, this may return null for group items since groups do not have a visibility toggle.", "name": "findVisibilityToggle", "parameters": [], "returnType": { @@ -49011,9 +49092,23 @@ To find a specific item use the \`findBreadcrumbLink(n)\` function as chaining \ }, }, { - "description": "Returns options that the user can reorder.", + "description": "Returns the top-level items in the preference list. + +For tables **without** column grouping this returns all column options. +For tables **with** column grouping this returns the top-level entries only +(which are group items). Use \`.findChildrenOptions()\` on a group item to +access the leaf columns nested within it.", "name": "findOptions", - "parameters": [], + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ group?: boolean | undefined; }", + }, + ], "returnType": { "isNullable": false, "name": "MultiElementWrapper", @@ -49047,6 +49142,33 @@ To find a specific item use the \`findBreadcrumbLink(n)\` function as chaining \ }, { "methods": [ + { + "description": "Returns all child option items nested under this item when it is a group. +Returns \`null\` when this item is a leaf column (has no nested children). + +The children are the leaf-level \`ContentDisplayOptionWrapper\`s inside the group's +nested \`InternalList\` — i.e. they already carry a drag handle and visibility toggle.", + "name": "findChildrenOptions", + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ group?: boolean | undefined; }", + }, + ], + "returnType": { + "isNullable": true, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ContentDisplayOptionWrapper", + }, + ], + }, + }, { "description": "Returns the drag handle for the option item.", "name": "findDragHandle", @@ -49066,7 +49188,8 @@ To find a specific item use the \`findBreadcrumbLink(n)\` function as chaining \ }, }, { - "description": "Returns the visibility toggle for the option item.", + "description": "Returns the visibility toggle for the option item. +Note that, despite its typings, this may return null for group items since groups do not have a visibility toggle.", "name": "findVisibilityToggle", "parameters": [], "returnType": { diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index 5e1d0b1e2d..90d771415e 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -155,6 +155,8 @@ exports[`test-utils selectors 1`] = ` "awsui_content-before_tc96w", "awsui_content-density_tc96w", "awsui_content-display-description_tc96w", + "awsui_content-display-group-children_tc96w", + "awsui_content-display-group-header_tc96w", "awsui_content-display-no-match_tc96w", "awsui_content-display-option-content_tc96w", "awsui_content-display-option-label_tc96w", diff --git a/src/collection-preferences/__tests__/shared.tsx b/src/collection-preferences/__tests__/shared.tsx index 3c4900f691..ea377e431c 100644 --- a/src/collection-preferences/__tests__/shared.tsx +++ b/src/collection-preferences/__tests__/shared.tsx @@ -21,6 +21,8 @@ const i18nMessages = { "Use Space or Enter to activate drag for an item, then use the arrow keys to move the item's position. To complete the position move, use Space or Enter, or to discard the move, use Escape. You may need to toggle your browsing mode on your screen reader.", 'contentDisplayPreference.liveAnnouncementDndStarted': 'Picked up item at position {position} of {total}', 'contentDisplayPreference.liveAnnouncementDndDiscarded': 'Reordering canceled', + 'contentDisplayPreference.liveAnnouncementDndGroupLabel': + '{label}, {count, plural, one {1 item} other {{count} items}}', 'contentDisplayPreference.liveAnnouncementDndItemReordered': '{isInitialPosition, select, true {Moving item back to position {currentPosition} of {total}} false {Moving item to position {currentPosition} of {total}} other {}}', 'contentDisplayPreference.liveAnnouncementDndItemCommitted': diff --git a/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts b/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts new file mode 100644 index 0000000000..b5ad71acb6 --- /dev/null +++ b/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts @@ -0,0 +1,74 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +import createWrapper from '../../../../lib/components/test-utils/selectors'; +import ContentDisplayPageObject from './pages/content-display-page'; + +const windowDimensions = { + width: 1200, + height: 1200, +}; + +const setupTest = (testFn: (page: ContentDisplayPageObject) => Promise) => { + return useBrowser(async browser => { + const page = new ContentDisplayPageObject(browser); + await browser.url('#/light/collection-preferences/content-display-groups'); + await page.setWindowSize(windowDimensions); + page.wrapper = createWrapper().findCollectionPreferences(); + await page.openCollectionPreferencesModal(); + await testFn(page); + }); +}; + +describe('Collection preferences - Grouped Content Display', () => { + test( + 'reorders a top-level item with drag and drop', + setupTest(async page => { + const modal = page.wrapper.findModal().findContentDisplayPreference(); + const options = modal.findOptions(); + + // findLabel() on group items returns the first nested child's label + expect(await page.getOptionLabels(options, 6)).toEqual([ + 'Instance ID', + 'Name', + 'Instance type', + 'CPU (%)', + 'Network in (MB/s)', + 'Monthly cost ($)', + ]); + + // Drag Instance ID past Name + const activeDragHandle = page.findDragHandle(0); + const targetDragHandle = page.findDragHandle(1); + await page.dragAndDropTo(activeDragHandle.toSelector(), targetDragHandle.toSelector()); + + expect(await page.getOptionLabels(options, 6)).toEqual([ + 'Name', + 'Instance ID', + 'Instance type', + 'CPU (%)', + 'Network in (MB/s)', + 'Monthly cost ($)', + ]); + }) + ); + + test( + 'reorders an individual item within a group with drag and drop', + setupTest(async page => { + const modal = page.wrapper.findModal().findContentDisplayPreference(); + const configGroup = modal.findOptions().get(3); + const children = configGroup.findChildrenOptions()!; + + expect(await page.getOptionLabels(children, 3)).toEqual(['Instance type', 'Availability zone', 'State']); + + // Drag Instance type past Availability zone + const activeDragHandle = children.get(1).findDragHandle(); + const targetDragHandle = children.get(2).findDragHandle(); + await page.dragAndDropTo(activeDragHandle.toSelector(), targetDragHandle.toSelector()); + + expect(await page.getOptionLabels(children, 3)).toEqual(['Availability zone', 'Instance type', 'State']); + }) + ); +}); diff --git a/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts b/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts index 2b5c52a690..002accd846 100644 --- a/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts +++ b/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts @@ -15,6 +15,24 @@ export default class ContentDisplayPageObject extends CollectionPreferencesPageO return true; } + async getOptionLabels( + options: { get(index: number): { findLabel(): { toSelector(): string } } }, + count: number + ): Promise { + const labels: string[] = []; + for (let i = 0; i < count; i++) { + labels.push( + await this.getText( + options + .get(i + 1) + .findLabel() + .toSelector() + ) + ); + } + return labels; + } + async expectAnnouncement(announcement: string) { const liveRegion = await this.browser.$('[aria-live="assertive"]'); // Using getHTML because getText returns an empty string if the live region is outside the viewport. diff --git a/src/collection-preferences/content-display/__tests__/content-display.test.tsx b/src/collection-preferences/content-display/__tests__/content-display.test.tsx index 4600424105..0c6fafb8ae 100644 --- a/src/collection-preferences/content-display/__tests__/content-display.test.tsx +++ b/src/collection-preferences/content-display/__tests__/content-display.test.tsx @@ -569,3 +569,211 @@ function expectLabelForToggle(option: ContentDisplayOptionWrapper) { function pressKey(element: HTMLElement, key: string) { fireEvent.keyDown(element, { key, code: key }); } + +describe('Content Display preference with groups', () => { + const groupedPreference: CollectionPreferencesProps.ContentDisplayPreference = { + ...contentDisplayPreference, + groups: [ + { id: 'g1', label: 'Group 1' }, + { id: 'g2', label: 'Group 2' }, + ], + }; + + const groupedContentDisplay: CollectionPreferencesProps.ContentDisplayItem[] = [ + { id: 'id1', visible: true }, + { + type: 'group', + id: 'g1', + visible: true, + children: [ + { id: 'id2', visible: true }, + { id: 'id3', visible: false }, + ], + }, + { type: 'group', id: 'g2', visible: true, children: [{ id: 'id4', visible: true }] }, + ]; + + function renderGroupedContentDisplay(props: Partial = {}) { + const wrapper = renderCollectionPreferences( + { + contentDisplayPreference: groupedPreference, + preferences: { contentDisplay: groupedContentDisplay }, + ...props, + }, + true + ); + wrapper.findTriggerButton().click(); + return wrapper.findModal()!.findContentDisplayPreference()!; + } + + it('renders group headers', () => { + const wrapper = renderGroupedContentDisplay(); + const element = wrapper.getElement(); + expect(element.textContent).toContain('Group 1'); + expect(element.textContent).toContain('Group 2'); + }); + + it('renders leaf options within groups', () => { + const wrapper = renderGroupedContentDisplay(); + const options = wrapper.findOptions(); + // findOptions returns all items (groups + leaves) in DOM order + // Verify leaf labels are present + const labels = options.map(opt => opt.findLabel()?.getElement().textContent).filter(Boolean); + expect(labels).toContain('Item 1'); + expect(labels).toContain('Item 2'); + expect(labels).toContain('Item 3'); + expect(labels).toContain('Item 4'); + }); + + it('renders options with correct visibility state', () => { + const wrapper = renderGroupedContentDisplay(); + const options = wrapper.findOptions(); + const toggleStates = options.map(opt => opt.findVisibilityToggle().findNativeInput().getElement().checked); + // All items with visibility toggles in DOM order: id1, g1, id2, id3, g2, id4 + expect(toggleStates).toEqual([true, true, true, false, true, true]); + }); + + it('renders nested lists with aria-label for groups', () => { + const wrapper = renderGroupedContentDisplay(); + const lists = wrapper.findAll('ol'); + // Top-level list + 2 nested lists (one per group) + expect(lists).toHaveLength(3); + const ariaLabels = lists.map(l => l.getElement().getAttribute('aria-label')).filter(Boolean); + expect(ariaLabels).toEqual(['Group 1', 'Group 2']); + }); + + it('filters options within groups', () => { + const wrapper = renderGroupedContentDisplay({ + contentDisplayPreference: { ...groupedPreference, enableColumnFiltering: true }, + }); + const filterInput = wrapper.findTextFilter()!; + filterInput.findInput().setInputValue('Item 2'); + // Only Item 2 and its parent group should be visible + const element = wrapper.getElement(); + expect(element.textContent).toContain('Item 2'); + expect(element.textContent).toContain('Group 1'); + expect(element.textContent).not.toContain('Item 4'); + }); + + it('shows no match state when filter has no results', () => { + const wrapper = renderGroupedContentDisplay({ + contentDisplayPreference: { + ...groupedPreference, + enableColumnFiltering: true, + i18nStrings: { columnFilteringNoMatchText: 'No matches found', columnFilteringClearFilterText: 'Clear' }, + }, + }); + const filterInput = wrapper.findTextFilter()!; + filterInput.findInput().setInputValue('nonexistent'); + expect(wrapper.getElement().textContent).toContain('No matches found'); + }); + + it('reorders top-level items via keyboard drag and drop', async () => { + const onConfirm = jest.fn(); + const collectionPreferencesWrapper = renderCollectionPreferences( + { + contentDisplayPreference: { + ...groupedPreference, + groups: [{ id: 'g1', label: 'Group 1' }], + }, + preferences: { + contentDisplay: [ + { id: 'id1', visible: true }, + { type: 'group', id: 'g1', visible: true, children: [] }, + { id: 'id2', visible: true }, + ], + }, + onConfirm, + }, + true + ); + collectionPreferencesWrapper.findTriggerButton().click(); + const wrapper = collectionPreferencesWrapper.findModal()!.findContentDisplayPreference()!; + + const dragHandle = wrapper.findOptionByIndex(1)!.findDragHandle().getElement(); + pressKey(dragHandle, 'Space'); + await expectAnnouncement('Picked up item at position 1 of 3'); + pressKey(dragHandle, 'ArrowDown'); + await expectAnnouncement('Moving item to position 2 of 3'); + pressKey(dragHandle, 'Space'); + await expectAnnouncement('Item moved from position 1 to position 2 of 3'); + + // Confirm and verify reorder + collectionPreferencesWrapper.findModal()!.findConfirmButton()!.click(); + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + contentDisplay: [ + { type: 'group', id: 'g1', visible: true, children: [] }, + { id: 'id1', visible: true }, + { id: 'id2', visible: true }, + ], + }, + }) + ); + }); + + it('has drag handles for items within groups', () => { + const wrapper = renderGroupedContentDisplay(); + const options = wrapper.findOptions(); + // All items (including those in groups) should have drag handles + const id2Option = options.find(opt => opt.findLabel()?.getElement().textContent === 'Item 2'); + expect(id2Option).toBeDefined(); + expect(id2Option!.findDragHandle()).not.toBeNull(); + expect(id2Option!.findDragHandle().getElement().getAttribute('aria-disabled')).toBe('false'); + }); + + it('renders correct nested leaf options within a group', () => { + const wrapper = renderGroupedContentDisplay(); + // Group 1 contains Item 2 and Item 3 + const element = wrapper.getElement(); + expect(element.textContent).toContain('Group 1'); + expect(element.textContent).toContain('Item 2'); + expect(element.textContent).toContain('Item 3'); + // Group 2 contains Item 4 + expect(element.textContent).toContain('Group 2'); + expect(element.textContent).toContain('Item 4'); + }); + + it('toggling a grouped leaf option calls onConfirm with the updated tree structure', () => { + const onConfirm = jest.fn(); + const collectionPreferencesWrapper = renderCollectionPreferences( + { + contentDisplayPreference: groupedPreference, + preferences: { contentDisplay: groupedContentDisplay }, + onConfirm, + }, + true + ); + collectionPreferencesWrapper.findTriggerButton().click(); + const wrapper = collectionPreferencesWrapper.findModal()!.findContentDisplayPreference()!; + + // Find id3 (Item 3, currently visible: false) and toggle it to visible + const options = wrapper.findOptions(); + const id3Option = options.find(opt => opt.findLabel()?.getElement().textContent === 'Item 3'); + expect(id3Option).toBeDefined(); + id3Option!.findVisibilityToggle().findNativeInput().click(); + + // Confirm + collectionPreferencesWrapper.findModal()!.findConfirmButton()!.click(); + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + contentDisplay: [ + { id: 'id1', visible: true }, + { + type: 'group', + id: 'g1', + visible: true, + children: [ + { id: 'id2', visible: true }, + { id: 'id3', visible: true }, + ], + }, + { type: 'group', id: 'g2', visible: true, children: [{ id: 'id4', visible: true }] }, + ], + }, + }) + ); + }); +}); diff --git a/src/collection-preferences/content-display/__tests__/utils.test.ts b/src/collection-preferences/content-display/__tests__/utils.test.ts index 94d4fb52ae..ceca062ed7 100644 --- a/src/collection-preferences/content-display/__tests__/utils.test.ts +++ b/src/collection-preferences/content-display/__tests__/utils.test.ts @@ -1,6 +1,15 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { getSortedOptions } from '../utils'; +import { collectVisibleIds } from '../../../../lib/components/collection-preferences/utils'; +import { + buildOptionTree, + getFilteredOptions, + getFilteredTree, + getSortedOptions, + OptionGroupNode, + toContentDisplayItems, + walkLeaves, +} from '../utils'; describe('getSortedOptions', () => { it('returns the passed-in options with the desired order and visibility', () => { @@ -71,3 +80,254 @@ describe('getSortedOptions', () => { ]); }); }); + +describe('walkLeaves', () => { + it('extracts leaves from flat list', () => { + const items = [ + { id: 'a', visible: true }, + { id: 'b', visible: false }, + ]; + expect(walkLeaves(items)).toEqual([ + { id: 'a', visible: true }, + { id: 'b', visible: false }, + ]); + }); + + it('extracts leaves from nested groups', () => { + const items = [ + { id: 'a', visible: true }, + { + type: 'group' as const, + id: 'g1', + visible: true, + children: [ + { id: 'b', visible: true }, + { id: 'c', visible: false }, + ], + }, + ]; + expect(walkLeaves(items)).toEqual([ + { id: 'a', visible: true }, + { id: 'b', visible: true }, + { id: 'c', visible: false }, + ]); + }); +}); + +describe('buildOptionTree', () => { + it('returns flat leaf nodes when no groups provided', () => { + const options = [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + ]; + const contentDisplay = [ + { id: 'a', visible: true }, + { id: 'b', visible: false }, + ]; + const tree = buildOptionTree(options, [], contentDisplay); + expect(tree).toHaveLength(2); + expect(tree[0]).toMatchObject({ type: 'leaf' as const, id: 'a', label: 'A', visible: true }); + expect(tree[1]).toMatchObject({ type: 'leaf' as const, id: 'b', label: 'B', visible: false }); + }); + + it('builds grouped tree from contentDisplay', () => { + const options = [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + { id: 'c', label: 'C' }, + ]; + const groups = [{ id: 'g1', label: 'Group 1' }]; + const contentDisplay = [ + { id: 'a', visible: true }, + { + type: 'group' as const, + id: 'g1', + visible: true, + children: [ + { id: 'b', visible: true }, + { id: 'c', visible: false }, + ], + }, + ]; + const tree = buildOptionTree(options, groups, contentDisplay); + expect(tree).toHaveLength(2); + expect(tree[0]).toMatchObject({ type: 'leaf' as const, id: 'a', label: 'A' }); + expect(tree[1]).toMatchObject({ type: 'group' as const, id: 'g1', label: 'Group 1', visible: true }); + expect((tree[1] as OptionGroupNode).children).toHaveLength(2); + expect((tree[1] as OptionGroupNode).children[0]).toMatchObject({ type: 'leaf' as const, id: 'b', visible: true }); + expect((tree[1] as OptionGroupNode).children[1]).toMatchObject({ type: 'leaf' as const, id: 'c', visible: false }); + }); + + it('uses group id as label when group definition not found', () => { + const options = [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + ]; + const groups = [{ id: 'existing', label: 'Existing' }]; + const contentDisplay = [ + { id: 'a', visible: true }, + { type: 'group' as const, id: 'nonexistent', visible: true, children: [{ id: 'b', visible: true }] }, + ]; + const tree = buildOptionTree(options, groups, contentDisplay); + expect(tree).toHaveLength(2); + expect(tree[1]).toMatchObject({ type: 'group' as const, id: 'nonexistent', label: 'nonexistent' }); + }); +}); + +describe('toContentDisplayItems', () => { + it('converts leaf nodes back to ContentDisplayItem', () => { + const tree = [ + { type: 'leaf' as const, id: 'a', label: 'A', visible: true }, + { type: 'leaf' as const, id: 'b', label: 'B', visible: false }, + ]; + const result = toContentDisplayItems(tree); + expect(result).toEqual([ + { id: 'a', visible: true }, + { id: 'b', visible: false }, + ]); + }); + + it('converts group nodes back to ContentDisplayGroup', () => { + const tree = [ + { type: 'leaf' as const, id: 'a', label: 'A', visible: true }, + { + type: 'group' as const, + id: 'g1', + label: 'G1', + visible: true, + children: [ + { type: 'leaf' as const, id: 'b', label: 'B', visible: true }, + { type: 'leaf' as const, id: 'c', label: 'C', visible: false }, + ], + }, + ]; + const result = toContentDisplayItems(tree); + expect(result).toEqual([ + { id: 'a', visible: true }, + { + type: 'group' as const, + id: 'g1', + visible: true, + children: [ + { id: 'b', visible: true }, + { id: 'c', visible: false }, + ], + }, + ]); + }); +}); + +describe('getFilteredTree', () => { + it('returns full tree when filter is empty', () => { + const tree = [ + { type: 'leaf' as const, id: 'a', label: 'Alpha', visible: true }, + { + type: 'group' as const, + id: 'g', + label: 'Group', + visible: true, + children: [{ type: 'leaf' as const, id: 'b', label: 'Beta', visible: true }], + }, + ]; + expect(getFilteredTree(tree, '')).toEqual(tree); + expect(getFilteredTree(tree, ' ')).toEqual(tree); + }); + + it('filters leaf nodes by label', () => { + const tree = [ + { type: 'leaf' as const, id: 'a', label: 'Alpha', visible: true }, + { type: 'leaf' as const, id: 'b', label: 'Beta', visible: true }, + ]; + const result = getFilteredTree(tree, 'alp'); + expect(result).toEqual([{ type: 'leaf', id: 'a', label: 'Alpha', visible: true }]); + }); + + it('keeps groups with matching descendants', () => { + const tree = [ + { + type: 'group' as const, + id: 'g', + label: 'Group', + visible: true, + children: [ + { type: 'leaf' as const, id: 'a', label: 'Alpha', visible: true }, + { type: 'leaf' as const, id: 'b', label: 'Beta', visible: true }, + ], + }, + ]; + const result = getFilteredTree(tree, 'alpha'); + expect(result).toEqual([ + { + type: 'group', + id: 'g', + label: 'Group', + visible: true, + children: [{ type: 'leaf', id: 'a', label: 'Alpha', visible: true }], + }, + ]); + }); + + it('removes groups with no matching descendants', () => { + const tree = [ + { + type: 'group' as const, + id: 'g', + label: 'Group', + visible: true, + children: [{ type: 'leaf' as const, id: 'a', label: 'Alpha', visible: true }], + }, + ]; + const result = getFilteredTree(tree, 'xyz'); + expect(result).toHaveLength(0); + }); +}); + +describe('getFilteredOptions', () => { + it('returns all options when filter is empty', () => { + const options = [ + { id: 'a', label: 'Alpha', visible: true }, + { id: 'b', label: 'Beta', visible: true }, + ]; + expect(getFilteredOptions(options, '')).toEqual(options); + }); + + it('filters by label', () => { + const options = [ + { id: 'a', label: 'Alpha', visible: true }, + { id: 'b', label: 'Beta', visible: true }, + ]; + const result = getFilteredOptions(options, 'bet'); + expect(result).toEqual([{ id: 'b', label: 'Beta', visible: true }]); + }); +}); + +describe('collectVisibleIds', () => { + it('collects visible leaf ids from grouped content display', () => { + const items = [ + { id: 'id1', visible: true }, + { + type: 'group' as const, + id: 'g1', + visible: true, + children: [ + { id: 'id2', visible: true }, + { id: 'id3', visible: false }, + ], + }, + { type: 'group' as const, id: 'g2', visible: true, children: [{ id: 'id4', visible: true }] }, + ]; + expect(collectVisibleIds(items, true)).toEqual(['id1', 'id2', 'id4']); + }); + + it('excludes children of non-visible groups', () => { + const items = [ + { + type: 'group' as const, + id: 'g1', + visible: false, + children: [{ id: 'id1', visible: true }], + }, + ]; + expect(collectVisibleIds(items, true)).toEqual([]); + }); +}); diff --git a/src/collection-preferences/content-display/content-display-list.scss b/src/collection-preferences/content-display/content-display-list.scss index 7ad93c0c62..aa35430898 100644 --- a/src/collection-preferences/content-display/content-display-list.scss +++ b/src/collection-preferences/content-display/content-display-list.scss @@ -32,3 +32,13 @@ padding-block: 0; padding-inline: 0; } + +$group-children-indentation: 28px; + +// Text-to-text indentation between group header and child items. +// The drag handle (~20px) is rendered before the content, so we subtract it. +.content-display-group-children { + padding-inline-start: calc( + #{$group-children-indentation} - #{awsui.$size-icon-normal} - 2 * #{awsui.$space-scaled-xxxs} + ); +} diff --git a/src/collection-preferences/content-display/content-display-option.scss b/src/collection-preferences/content-display/content-display-option.scss index 8105fb1b93..e13a28831c 100644 --- a/src/collection-preferences/content-display/content-display-option.scss +++ b/src/collection-preferences/content-display/content-display-option.scss @@ -27,3 +27,15 @@ @include styles.text-wrapping; padding-inline-end: awsui.$space-l; } + +.content-display-group-header { + @include styles.styles-reset; + display: flex; + align-items: flex-start; + padding-block: awsui.$space-scaled-xs; + padding-inline-end: awsui.$space-xs; + border-start-start-radius: awsui.$border-radius-item; + border-start-end-radius: awsui.$border-radius-item; + border-end-start-radius: awsui.$border-radius-item; + border-end-end-radius: awsui.$border-radius-item; +} diff --git a/src/collection-preferences/content-display/content-display-option.tsx b/src/collection-preferences/content-display/content-display-option.tsx index ab4faa1782..0db9417de6 100644 --- a/src/collection-preferences/content-display/content-display-option.tsx +++ b/src/collection-preferences/content-display/content-display-option.tsx @@ -22,7 +22,7 @@ const ContentDisplayOption = forwardRef( const idPrefix = useUniqueId(componentPrefix); const controlId = `${idPrefix}-control-${option.id}`; return ( -
+
diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx index b0465188a9..e59aa35ef7 100644 --- a/src/collection-preferences/content-display/index.tsx +++ b/src/collection-preferences/content-display/index.tsx @@ -7,6 +7,7 @@ import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; import InternalBox from '../../box/internal'; import InternalButton from '../../button/internal'; import { useInternalI18n } from '../../i18n/context'; +import { SortableAreaProps } from '../../internal/components/sortable-area/interfaces'; import { formatDndItemCommitted, formatDndItemReordered, @@ -18,7 +19,15 @@ import InternalTextFilter from '../../text-filter/internal'; import { getAnalyticsInnerContextAttribute } from '../analytics-metadata/utils'; import { CollectionPreferencesProps } from '../interfaces'; import ContentDisplayOption from './content-display-option'; -import { getFilteredOptions, getSortedOptions, OptionWithVisibility } from './utils'; +import { + buildOptionTree, + getFilteredOptions, + getFilteredTree, + getSortedOptions, + OptionGroupNode, + OptionTreeNode, + toContentDisplayItems, +} from './utils'; import styles from '../styles.css.js'; @@ -30,6 +39,153 @@ interface ContentDisplayPreferenceProps extends CollectionPreferencesProps.Conte onChange: (value: ReadonlyArray) => void; value?: ReadonlyArray; } +function getDndI18nStrings( + i18n: ReturnType>, + props: Pick< + ContentDisplayPreferenceProps, + | 'liveAnnouncementDndStarted' + | 'liveAnnouncementDndItemReordered' + | 'liveAnnouncementDndItemCommitted' + | 'liveAnnouncementDndDiscarded' + | 'dragHandleAriaLabel' + | 'dragHandleAriaDescription' + > +) { + return { + liveAnnouncementDndStarted: i18n( + 'contentDisplayPreference.liveAnnouncementDndStarted', + props.liveAnnouncementDndStarted, + formatDndStarted + ), + liveAnnouncementDndItemReordered: i18n( + 'contentDisplayPreference.liveAnnouncementDndItemReordered', + props.liveAnnouncementDndItemReordered, + formatDndItemReordered + ), + liveAnnouncementDndItemCommitted: i18n( + 'contentDisplayPreference.liveAnnouncementDndItemCommitted', + props.liveAnnouncementDndItemCommitted, + formatDndItemCommitted + ), + liveAnnouncementDndDiscarded: i18n( + 'contentDisplayPreference.liveAnnouncementDndDiscarded', + props.liveAnnouncementDndDiscarded + ), + dragHandleAriaLabel: i18n('contentDisplayPreference.dragHandleAriaLabel', props.dragHandleAriaLabel), + dragHandleAriaDescription: i18n( + 'contentDisplayPreference.dragHandleAriaDescription', + props.dragHandleAriaDescription + ), + }; +} + +interface HierarchicalContentDisplayProps { + tree: OptionTreeNode[]; + onToggle: (id: string) => void; + onTreeChange: (newTree: OptionTreeNode[]) => void; + ariaLabel?: string; + ariaLabelledby?: string; + ariaDescribedby?: string; + i18nStrings: SortableAreaProps.DndAreaI18nStrings; + sortDisabled: boolean; + parentGroupLabel?: string; + groupLabelFormatter: (label: string, count: number) => string; +} + +function GroupItem({ + node, + onToggle, + onChildrenChange, + i18nStrings, + sortDisabled, + groupLabelFormatter, +}: { + node: OptionGroupNode; + onToggle: (id: string) => void; + onChildrenChange: (children: OptionTreeNode[]) => void; + i18nStrings: SortableAreaProps.DndAreaI18nStrings; + sortDisabled: boolean; + groupLabelFormatter: (label: string, count: number) => string; +}) { + return ( +
+ +
+ + {node.label} + +
+ {node.children.length > 0 && ( +
+ +
+ )} +
+
+ ); +} + +function HierarchicalContentDisplay({ + tree, + onToggle, + onTreeChange, + ariaLabel, + ariaLabelledby, + ariaDescribedby, + i18nStrings, + sortDisabled, + parentGroupLabel, + groupLabelFormatter, +}: HierarchicalContentDisplayProps) { + return ( + onTreeChange([...items])} + renderItem={node => ({ + id: node.id, + announcementLabel: + node.type === 'group' + ? groupLabelFormatter(node.label, node.children.length) + : parentGroupLabel + ? `${node.label}, ${parentGroupLabel}` + : node.label, + content: + node.type === 'group' ? ( + + onTreeChange( + tree.map(n => (n.id === node.id && n.type === 'group' ? { ...n, children: newChildren } : n)) + ) + } + i18nStrings={i18nStrings} + sortDisabled={sortDisabled} + groupLabelFormatter={groupLabelFormatter} + /> + ) : ( + onToggle(node.id)} /> + ), + })} + /> + ); +} export default function ContentDisplayPreference({ title, @@ -39,15 +195,11 @@ export default function ContentDisplayPreference({ id, visible: true, })), + groups, onChange, - liveAnnouncementDndStarted, - liveAnnouncementDndItemReordered, - liveAnnouncementDndItemCommitted, - liveAnnouncementDndDiscarded, - dragHandleAriaDescription, - dragHandleAriaLabel, enableColumnFiltering = false, i18nStrings, + ...dndProps }: ContentDisplayPreferenceProps) { const idPrefix = useUniqueId(componentPrefix); const i18n = useInternalI18n('collection-preferences'); @@ -56,18 +208,49 @@ export default function ContentDisplayPreference({ const titleId = `${idPrefix}-title`; const descriptionId = `${idPrefix}-description`; - const [sortedOptions, sortedAndFilteredOptions] = useMemo(() => { - const sorted = getSortedOptions({ options, contentDisplay: value }); - const filtered = getFilteredOptions(sorted, columnFilteringText); - return [sorted, filtered]; - }, [columnFilteringText, options, value]); + const listI18nStrings = getDndI18nStrings(i18n, dndProps); + const groupLabelFormatter = (label: string, count: number) => + i18n( + 'contentDisplayPreference.liveAnnouncementDndGroupLabel', + dndProps.liveAnnouncementDndGroupLabel?.(label, count) ?? `${label}, ${count} ${count === 1 ? 'item' : 'items'}`, + format => format({ label, count }) + ); + const hasGroups = !!groups && groups.length > 0; + const isFiltering = columnFilteringText.trim().length > 0; - const onToggle = (option: OptionWithVisibility) => { - // We use sortedOptions as base and not value because there might be options that - // are not in the value yet, so they're added as non-visible after the known ones. - onChange(sortedOptions.map(({ id, visible }) => ({ id, visible: id === option.id ? !option.visible : visible }))); - }; + const sortedOptions = useMemo(() => getSortedOptions({ options, contentDisplay: value }), [options, value]); + const filteredOptions = useMemo( + () => getFilteredOptions(sortedOptions, columnFilteringText), + [sortedOptions, columnFilteringText] + ); + const optionTree = useMemo( + () => (hasGroups ? buildOptionTree(options, groups, value) : null), + [hasGroups, groups, options, value] + ); + const filteredTree = useMemo( + () => (optionTree ? getFilteredTree(optionTree, columnFilteringText) : null), + [optionTree, columnFilteringText] + ); + const handleToggle = (id: string) => { + // For flat (non-grouped) mode, rebuild from sortedOptions to handle items not in value + if (!hasGroups) { + onChange(sortedOptions.map(opt => ({ id: opt.id, visible: opt.id === id ? !opt.visible : opt.visible }))); + return; + } + // For grouped mode, walk the tree and flip the matching item + const toggle = ( + items: ReadonlyArray + ): CollectionPreferencesProps.ContentDisplayItem[] => + items.map(item => { + if (item.type === 'group') { + return { ...item, children: toggle(item.children) }; + } + return item.id === id ? { ...item, visible: !item.visible } : item; + }); + onChange(toggle(value)); + }; + const noResults = filteredTree ? filteredTree.length === 0 : filteredOptions.length === 0; return (
setColumnFilteringText(detail.filteringText)} countText={i18n( 'contentDisplayPreference.i18nStrings.columnFilteringCountText', - i18nStrings?.columnFilteringCountText - ? i18nStrings?.columnFilteringCountText(sortedAndFilteredOptions.length) - : undefined, - format => format({ count: sortedAndFilteredOptions.length }) + i18nStrings?.columnFilteringCountText?.(filteredOptions.length), + format => format({ count: filteredOptions.length }) )} />
)} - {/* No match */} - {sortedAndFilteredOptions.length === 0 && ( + {noResults && (
@@ -132,48 +312,36 @@ export default function ContentDisplayPreference({
)} - ({ - id: item.id, - content: , - announcementLabel: item.label, - })} - disableItemPaddings={true} - sortable={true} - sortDisabled={columnFilteringText.trim().length > 0} - onSortingChange={({ detail: { items } }) => { - onChange(items); - }} - ariaDescribedby={descriptionId} - ariaLabelledby={titleId} - i18nStrings={{ - liveAnnouncementDndStarted: i18n( - 'contentDisplayPreference.liveAnnouncementDndStarted', - liveAnnouncementDndStarted, - formatDndStarted - ), - liveAnnouncementDndItemReordered: i18n( - 'contentDisplayPreference.liveAnnouncementDndItemReordered', - liveAnnouncementDndItemReordered, - formatDndItemReordered - ), - liveAnnouncementDndItemCommitted: i18n( - 'contentDisplayPreference.liveAnnouncementDndItemCommitted', - liveAnnouncementDndItemCommitted, - formatDndItemCommitted - ), - liveAnnouncementDndDiscarded: i18n( - 'contentDisplayPreference.liveAnnouncementDndDiscarded', - liveAnnouncementDndDiscarded - ), - dragHandleAriaLabel: i18n('contentDisplayPreference.dragHandleAriaLabel', dragHandleAriaLabel), - dragHandleAriaDescription: i18n( - 'contentDisplayPreference.dragHandleAriaDescription', - dragHandleAriaDescription - ), - }} - /> +
+ {optionTree && filteredTree ? ( + onChange(toContentDisplayItems(newTree))} + ariaLabelledby={titleId} + ariaDescribedby={descriptionId} + i18nStrings={listI18nStrings} + sortDisabled={isFiltering} + groupLabelFormatter={groupLabelFormatter} + /> + ) : ( + onChange(items.map(({ id, visible }) => ({ id, visible })))} + renderItem={item => ({ + id: item.id, + announcementLabel: item.label, + content: handleToggle(item.id)} />, + })} + /> + )} +
); } diff --git a/src/collection-preferences/content-display/utils.ts b/src/collection-preferences/content-display/utils.ts index 9877ce3ed6..f313523cb3 100644 --- a/src/collection-preferences/content-display/utils.ts +++ b/src/collection-preferences/content-display/utils.ts @@ -2,10 +2,47 @@ // SPDX-License-Identifier: Apache-2.0 import { CollectionPreferencesProps } from '../interfaces'; -export interface OptionWithVisibility extends CollectionPreferencesProps.ContentDisplayOption { +type ContentDisplayItem = CollectionPreferencesProps.ContentDisplayItem; +type ContentDisplayOption = CollectionPreferencesProps.ContentDisplayOption; +type ContentDisplayOptionGroup = CollectionPreferencesProps.ContentDisplayOptionGroup; + +export interface OptionWithVisibility extends ContentDisplayOption { + visible: boolean; +} + +export interface OptionGroupNode { + type: 'group'; + id: string; + label: string; visible: boolean; + children: OptionTreeNode[]; +} + +export interface OptionLeafNode extends OptionWithVisibility { + type: 'leaf'; +} + +export type OptionTreeNode = OptionGroupNode | OptionLeafNode; + +/** + * Extracts a flat ordered list of leaf items from the contentDisplay tree (depth-first). + */ +export function walkLeaves(items: ReadonlyArray): { id: string; visible: boolean }[] { + const result: { id: string; visible: boolean }[] = []; + for (const item of items) { + if (item.type === 'group') { + result.push(...walkLeaves(item.children)); + } else { + result.push({ id: item.id, visible: item.visible }); + } + } + return result; } +/** + * Returns options ordered by contentDisplay, with visibility applied. + * Options not in contentDisplay are appended as non-visible. + */ export function getSortedOptions({ options, contentDisplay, @@ -13,27 +50,111 @@ export function getSortedOptions({ options: ReadonlyArray; contentDisplay: ReadonlyArray; }): ReadonlyArray { - // By using a Map, we are guaranteed to preserve insertion order on future iteration. - const optionsById = new Map(); - // We insert contentDisplay first so we respect the currently selected order - for (const { id, visible } of contentDisplay) { - // If an option is provided in contentDisplay and not options, we default the label to the id - optionsById.set(id, { id, label: id, visible }); + const optionMap = new Map(options.map(o => [o.id, o])); + const result = new Map(); + + for (const { id, visible } of walkLeaves(contentDisplay)) { + const option = optionMap.get(id); + if (option) { + result.set(id, { ...option, visible }); + } } - // We merge options data, and insert any that were not in contentDisplay as non-visible + for (const option of options) { - const existing = optionsById.get(option.id); - optionsById.set(option.id, { ...option, visible: !!existing?.visible }); + if (!result.has(option.id)) { + result.set(option.id, { ...option, visible: false }); + } } - return Array.from(optionsById.values()); + + return Array.from(result.values()); } -export function getFilteredOptions(options: ReadonlyArray, filterText: string) { - filterText = filterText.trim().toLowerCase(); +/** + * Converts contentDisplay tree into an internal OptionTreeNode tree, + * resolving labels from options/groups definitions. + */ +export function buildOptionTree( + options: ReadonlyArray, + groups: ReadonlyArray, + contentDisplay: ReadonlyArray +): OptionTreeNode[] { + if (!groups.length) { + const sorted = getSortedOptions({ options, contentDisplay }); + return sorted.map(opt => ({ ...opt, type: 'leaf' as const })); + } - if (!filterText) { - return options; + const optionMap = new Map(options.map(o => [o.id, o])); + const groupMap = new Map(groups.map(g => [g.id, g])); + + const convert = (items: ReadonlyArray): OptionTreeNode[] => { + const result: OptionTreeNode[] = []; + for (const item of items) { + if (item.type === 'group') { + const group = groupMap.get(item.id); + result.push({ + type: 'group', + id: item.id, + label: group?.label ?? item.id, + visible: item.visible, + children: convert(item.children), + }); + } else { + const option = optionMap.get(item.id); + if (option) { + result.push({ type: 'leaf', ...option, visible: item.visible }); + } + } + } + return result; + }; + + return convert(contentDisplay); +} + +/** + * Converts OptionTreeNode[] back to ContentDisplayItem[]. + */ +export function toContentDisplayItems(tree: OptionTreeNode[]): ContentDisplayItem[] { + return tree.map(node => { + if (node.type === 'group') { + return { + type: 'group' as const, + id: node.id, + visible: node.visible, + children: toContentDisplayItems(node.children), + }; + } + return { id: node.id, visible: node.visible }; + }); +} + +/** + * Filters tree, keeping leaves matching filterText and groups with matching descendants. + */ +export function getFilteredTree(tree: OptionTreeNode[], filterText: string): OptionTreeNode[] { + const text = filterText.trim().toLowerCase(); + if (!text) { + return tree; + } + + const result: OptionTreeNode[] = []; + for (const node of tree) { + if (node.type === 'group') { + const children = getFilteredTree(node.children, text); + if (children.length > 0) { + result.push({ ...node, children }); + } + } else if (node.label.toLowerCase().includes(text)) { + result.push(node); + } } + return result; +} - return options.filter(option => option.label.toLowerCase().trim().includes(filterText)); +export function getFilteredOptions(options: ReadonlyArray, filterText: string) { + const text = filterText.trim().toLowerCase(); + if (!text) { + return options; + } + return options.filter(option => option.label.toLowerCase().includes(text)); } diff --git a/src/collection-preferences/index.tsx b/src/collection-preferences/index.tsx index 6e9bdecea5..ec882f66c1 100644 --- a/src/collection-preferences/index.tsx +++ b/src/collection-preferences/index.tsx @@ -24,6 +24,7 @@ import { getComponentAnalyticsMetadata } from './analytics-metadata/utils'; import ContentDisplayPreference from './content-display'; import { CollectionPreferencesProps } from './interfaces'; import { + collectVisibleIds, ContentDensityPreference, copyPreferences, CustomPreference, @@ -138,9 +139,10 @@ export default function CollectionPreferences({ // When both are used contentDisplayPreference takes preference and so we always prefer to use this as our visible columns if available if (preferences?.contentDisplay) { - tableComponentContext.preferencesRef.current.visibleColumns = preferences?.contentDisplay - .filter(column => column.visible) - .map(column => column.id); + tableComponentContext.preferencesRef.current.visibleColumns = collectVisibleIds( + preferences.contentDisplay, + true + ); } else if (preferences?.visibleContent) { tableComponentContext.preferencesRef.current.visibleColumns = [...preferences.visibleContent]; } diff --git a/src/collection-preferences/interfaces.ts b/src/collection-preferences/interfaces.ts index 5768238b0a..9e8c8045fb 100644 --- a/src/collection-preferences/interfaces.ts +++ b/src/collection-preferences/interfaces.ts @@ -109,6 +109,9 @@ export interface CollectionPreferencesProps extends * - `title` (string) - Specifies the text displayed at the top of the preference. * - `description` (string) - Specifies the description displayed below the title. * - `options` - Specifies an array of options for reordering and visible content selection. + * - `groups` - (Optional) Specifies an array of column group definitions for multi-level content display. Each group contains: + * - `id` (string) - A unique identifier for the group. + * - `label` (string) - The text displayed as the group label. * - `enableColumnFiltering` (boolean) - Adds a columns filter. * - `liveAnnouncementDndStarted` ((position: number, total: number) => string) - (Optional) Adds a message to be announced by screen readers when an option is picked. * - `liveAnnouncementDndDiscarded` (string) - (Optional) Adds a message to be announced by screen readers when a reordering action is canceled. @@ -116,6 +119,7 @@ export interface CollectionPreferencesProps extends * - `liveAnnouncementDndItemCommitted` ((initialPosition: number, finalPosition: number, total: number) => string) - (Optional) Adds a message to be announced by screen readers when a reordering action is committed. * - `dragHandleAriaDescription` (string) - (Optional) Adds an ARIA description for the drag handle. * - `dragHandleAriaLabel` (string) - (Optional) Adds an ARIA label for the drag handle. + * - `liveAnnouncementDndGroupLabel` ((label: string, count: number) => string) - (Optional) Adds a label for a group item to be announced by screen readers during drag and drop operations. * * Each option contains the following: * - `id` (string) - Corresponds to a table column `id`. @@ -123,6 +127,16 @@ export interface CollectionPreferencesProps extends * - `alwaysVisible` (boolean) - (Optional) Determines whether the visibility is always on and therefore cannot be toggled. This is set to `false` by default. * * You must provide an ordered list of the items to display in the `preferences.contentDisplay` property. + * Each content display item is one of the following: + * - `ContentDisplayColumn` - Represents a single column. + * - `type` ('column') - (Optional) Identifies the entry as a column. Defaults to `'column'` when omitted. + * - `id` (string) - The column identifier. + * - `visible` (boolean) - Whether the column is visible. + * - `ContentDisplayGroup` - Represents a column group. + * - `type` ('group') - Identifies the entry as a group. + * - `id` (string) - The group identifier. + * - `visible` (boolean) - Whether the group is visible. + * - `children` (ReadonlyArray) - The columns or nested groups within this group. * @i18n */ contentDisplayPreference?: CollectionPreferencesProps.ContentDisplayPreference; @@ -229,19 +243,36 @@ export namespace CollectionPreferencesProps { title?: string; description?: string; options: ReadonlyArray; + groups?: ReadonlyArray; enableColumnFiltering?: boolean; i18nStrings?: ContentDisplayPreferenceI18nStrings; + liveAnnouncementDndGroupLabel?: (label: string, count: number) => string; } + export interface ContentDisplayColumn { + type?: 'column'; + id: string; + visible: boolean; + } + + export interface ContentDisplayGroup { + type: 'group'; + id: string; + visible: boolean; + children: ReadonlyArray; + } + + export type ContentDisplayItem = ContentDisplayColumn | ContentDisplayGroup; + export interface ContentDisplayOption { id: string; label: string; alwaysVisible?: boolean; } - export interface ContentDisplayItem { + export interface ContentDisplayOptionGroup { id: string; - visible: boolean; + label: string; } export interface VisibleContentPreference { diff --git a/src/collection-preferences/utils.tsx b/src/collection-preferences/utils.tsx index f02981cab2..aaffcd8239 100644 --- a/src/collection-preferences/utils.tsx +++ b/src/collection-preferences/utils.tsx @@ -230,6 +230,23 @@ export const StickyColumnsPreference = ({ ); }; +export const collectVisibleIds = ( + items: ReadonlyArray, + ancestorVisible: boolean +): string[] => { + const result: string[] = []; + for (const item of items) { + if (item.type === 'group') { + if (ancestorVisible && item.visible) { + result.push(...collectVisibleIds(item.children, true)); + } + } else if (ancestorVisible && item.visible) { + result.push(item.id); + } + } + return result; +}; + interface CustomPreferenceProps extends Pick, 'customPreference'> { onChange: (value: T) => void; value: T; diff --git a/src/i18n/messages-types.ts b/src/i18n/messages-types.ts index 88254ede45..63da31c470 100644 --- a/src/i18n/messages-types.ts +++ b/src/i18n/messages-types.ts @@ -143,6 +143,10 @@ export interface I18nFormatArgTypes { total: string | number; }; 'contentDisplayPreference.liveAnnouncementDndDiscarded': never; + 'contentDisplayPreference.liveAnnouncementDndGroupLabel': { + label: string; + count: number; + }; 'contentDisplayPreference.i18nStrings.columnFilteringPlaceholder': never; 'contentDisplayPreference.i18nStrings.columnFilteringAriaLabel': never; 'contentDisplayPreference.i18nStrings.columnFilteringNoMatchText': never; diff --git a/src/i18n/messages/all.en.json b/src/i18n/messages/all.en.json index 8187003788..e075e74f0d 100644 --- a/src/i18n/messages/all.en.json +++ b/src/i18n/messages/all.en.json @@ -119,6 +119,7 @@ "contentDisplayPreference.dragHandleAriaDescription": "Use Space or Enter to activate drag for an item, then use the arrow keys to move the item's position. To complete the position move, use Space or Enter, or to discard the move, use Escape. You may need to toggle your browsing mode on your screen reader.", "contentDisplayPreference.liveAnnouncementDndStarted": "Picked up item at position {position} of {total}", "contentDisplayPreference.liveAnnouncementDndDiscarded": "Reordering canceled", + "contentDisplayPreference.liveAnnouncementDndGroupLabel": "{label}, {count, plural, one {1 item} other {{count} items}}", "contentDisplayPreference.i18nStrings.columnFilteringPlaceholder": "Filter columns", "contentDisplayPreference.i18nStrings.columnFilteringAriaLabel": "Filter columns", "contentDisplayPreference.i18nStrings.columnFilteringNoMatchText": "No matches found", diff --git a/src/test-utils/dom/collection-preferences/content-display-preference.ts b/src/test-utils/dom/collection-preferences/content-display-preference.ts index f3a9952be4..83f4ec20af 100644 --- a/src/test-utils/dom/collection-preferences/content-display-preference.ts +++ b/src/test-utils/dom/collection-preferences/content-display-preference.ts @@ -30,12 +30,53 @@ export class ContentDisplayOptionWrapper extends ComponentWrapper { /** * Returns the visibility toggle for the option item. + * Note that, despite its typings, this may return null for group items since groups do not have a visibility toggle. */ findVisibilityToggle(): ToggleWrapper { return this.getListItem() .findContent() .findComponent(`.${styles['content-display-option-toggle']}`, ToggleWrapper)!; } + + /** + * Returns all child option items nested under this item when it is a group. + * Returns `null` when this item is a leaf column (has no nested children). + * + * The children are the leaf-level `ContentDisplayOptionWrapper`s inside the group's + * nested `InternalList` — i.e. they already carry a drag handle and visibility toggle. + * + * @param option.group When `true`, returns only group items. When `false`, returns only leaf column items. + * When omitted, returns all child items regardless of type. + */ + /* istanbul ignore next: :has() selector not supported in JSDOM */ + findChildrenOptions( + option: { + group?: boolean; + } = {} + ): Array | null { + const groupWrapper = this.getListItem().findContent().find('[data-item-type="group"]'); + if (!groupWrapper) { + return null; + } + const nestedList = groupWrapper.find(`.${ListWrapper.rootSelector}`); + if (!nestedList) { + return null; + } + const list = new ListWrapper(nestedList.getElement()); + + if (option.group === true) { + return list + .findAll(`li:has([data-item-type="group"])`) + .map(item => new ContentDisplayOptionWrapper(item.getElement())); + } + if (option.group === false) { + return list + .findAll(`li:has([data-item-type="column"])`) + .map(item => new ContentDisplayOptionWrapper(item.getElement())); + } + + return list.findItems().map(item => new ContentDisplayOptionWrapper(item.getElement())); + } } export default class ContentDisplayPreferenceWrapper extends ComponentWrapper { @@ -70,9 +111,34 @@ export default class ContentDisplayPreferenceWrapper extends ComponentWrapper { } /** - * Returns options that the user can reorder. + * Returns the top-level items in the preference list. + * + * For tables **without** column grouping this returns all column options. + * For tables **with** column grouping this returns the top-level entries only + * (which are group items). Use `.findChildrenOptions()` on a group item to + * access the leaf columns nested within it. + * + * @param option.group When `true`, returns only group items. When `false`, returns only leaf column items. + * When omitted, returns all top-level items regardless of type. + * @param option.visible When `true`, returns only visible items. When `false`, returns only hidden items. + * Note that group items have no visibility toggle and are excluded when this filter is active. */ - findOptions(): Array { + findOptions(option: { group?: boolean } = {}): Array { + /* istanbul ignore next: :has() selector not supported in JSDOM */ if (option.group === true) { + // Only group items — identified by the data-item-type="group" wrapper inside the list item + return this.getList() + .findAll(`li:has([data-item-type="group"])`) + .map(wrapper => new ContentDisplayOptionWrapper(wrapper.getElement())); + } + /* istanbul ignore next: :has() selector not supported in JSDOM */ + if (option.group === false) { + // Only leaf column items — identified by the data-item-type="column" wrapper + return this.getList() + .findAll(`li:has([data-item-type="column"])`) + .map(wrapper => new ContentDisplayOptionWrapper(wrapper.getElement())); + } + + // No group filter — return all top-level items return this.getList() .findItems() .map(wrapper => new ContentDisplayOptionWrapper(wrapper.getElement())); From adf1cdc31b1c219f92fd79f21652a2fe826fd312 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Mon, 8 Jun 2026 11:37:46 +0200 Subject: [PATCH 86/95] fix: Prevent focus from being lost when pagination previous/next buttons are disabled (#4576) --- pages/pagination/simple.page.tsx | 52 +++++++++++++++++++ src/pagination/__tests__/pagination.test.tsx | 20 +++---- src/pagination/internal.tsx | 6 ++- .../__tests__/hooks-integration.test.tsx | 6 +-- 4 files changed, 70 insertions(+), 14 deletions(-) create mode 100644 pages/pagination/simple.page.tsx diff --git a/pages/pagination/simple.page.tsx b/pages/pagination/simple.page.tsx new file mode 100644 index 0000000000..68a9a460ba --- /dev/null +++ b/pages/pagination/simple.page.tsx @@ -0,0 +1,52 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import I18nProvider from '~components/i18n'; +import messages from '~components/i18n/messages/all.en'; +import Pagination, { PaginationProps } from '~components/pagination'; + +import ScreenshotArea from '../utils/screenshot-area'; + +const paginationLabels: PaginationProps.Labels = { + nextPageLabel: 'Next page', + previousPageLabel: 'Previous page', + pageLabel: pageNumber => `Page ${pageNumber} of all pages`, + jumpToPageButton: 'Go to page', +}; + +const i18nStrings: PaginationProps.I18nStrings = { + jumpToPageInputLabel: 'Page number', + jumpToPageError: 'Enter a valid page number', + jumpToPageLoadingText: 'Loading page', +}; + +export default function PaginationSimplePage() { + const [basicPageIndex, setBasicPageIndex] = useState(1); + const [jumpPageIndex, setJumpPageIndex] = useState(1); + + return ( + +

Pagination simple

+ +

Basic pagination with 20 pages

+ setBasicPageIndex(event.detail.currentPageIndex)} + /> + +

Basic pagination with jump to page

+ setJumpPageIndex(event.detail.currentPageIndex)} + /> +
+
+ ); +} diff --git a/src/pagination/__tests__/pagination.test.tsx b/src/pagination/__tests__/pagination.test.tsx index f4ee7f7910..692a764b39 100644 --- a/src/pagination/__tests__/pagination.test.tsx +++ b/src/pagination/__tests__/pagination.test.tsx @@ -47,24 +47,24 @@ test('should re-render component correctly after current page state change', () test('should have both arrows disabled when there is only one page', () => { const { wrapper } = renderPagination(); - expect(wrapper.findPreviousPageButton().getElement()).toBeDisabled(); - expect(wrapper.findNextPageButton().getElement()).toBeDisabled(); + expect(wrapper.findPreviousPageButton().getElement()).toHaveAttribute('aria-disabled', 'true'); + expect(wrapper.findNextPageButton().getElement()).toHaveAttribute('aria-disabled', 'true'); expect(wrapper.findPageNumberByIndex(1)!.getElement()).toHaveTextContent('1'); expect(getItemsContent(wrapper)).toEqual(['1']); }); test('should have both arrows disabled when there are no pages', () => { const { wrapper } = renderPagination(); - expect(wrapper.findPreviousPageButton().getElement()).toBeDisabled(); - expect(wrapper.findNextPageButton().getElement()).toBeDisabled(); + expect(wrapper.findPreviousPageButton().getElement()).toHaveAttribute('aria-disabled', 'true'); + expect(wrapper.findNextPageButton().getElement()).toHaveAttribute('aria-disabled', 'true'); expect(wrapper.findPageNumberByIndex(1)!.getElement()).toHaveTextContent('1'); expect(getItemsContent(wrapper)).toEqual(['1']); }); test('should show all buttons when middle page selected', () => { const { wrapper } = renderPagination(); - expect(wrapper.findPreviousPageButton().getElement()).toBeEnabled(); - expect(wrapper.findNextPageButton().getElement()).toBeEnabled(); + expect(wrapper.findPreviousPageButton().getElement()).not.toHaveAttribute('aria-disabled'); + expect(wrapper.findNextPageButton().getElement()).not.toHaveAttribute('aria-disabled'); expect(wrapper.findCurrentPage().getElement()).toHaveTextContent('5'); expect(getItemsContent(wrapper)).toEqual(['1', '2', '3', '4', '5', '6', '7', '8', '9']); }); @@ -86,8 +86,8 @@ test('should not fire nextPageClick event when clicking next page with the last test('should disable `previous` button when first page selected', () => { const { wrapper } = renderPagination(); - expect(wrapper.findPreviousPageButton().getElement()).toBeDisabled(); - expect(wrapper.findNextPageButton().getElement()).toBeEnabled(); + expect(wrapper.findPreviousPageButton().getElement()).toHaveAttribute('aria-disabled', 'true'); + expect(wrapper.findNextPageButton().getElement()).not.toHaveAttribute('aria-disabled'); expect(wrapper.findCurrentPage().getElement()).toHaveTextContent('1'); }); @@ -189,8 +189,8 @@ test('should not fire nextPageClick event when clicking next page with the last ); expect(wrapper.isDisabled()).toBe(true); - expect(wrapper.findPreviousPageButton().getElement()).toBeDisabled(); - expect(wrapper.findNextPageButton().getElement()).toBeDisabled(); + expect(wrapper.findPreviousPageButton().getElement()).toHaveAttribute('aria-disabled', 'true'); + expect(wrapper.findNextPageButton().getElement()).toHaveAttribute('aria-disabled', 'true'); wrapper.findPreviousPageButton().click(); expect(onChange).not.toHaveBeenCalled(); diff --git a/src/pagination/internal.tsx b/src/pagination/internal.tsx index 62f07af47c..c7e0ad2274 100644 --- a/src/pagination/internal.tsx +++ b/src/pagination/internal.tsx @@ -47,6 +47,9 @@ function PageButton({ ...rest }: PageButtonProps) { function handleClick(event: React.MouseEvent) { + if (disabled) { + return; + } event.preventDefault(); onClick(pageIndex); } @@ -61,7 +64,8 @@ function PageButton({ )} type="button" aria-label={ariaLabel} - disabled={disabled} + aria-disabled={disabled ? true : undefined} + tabIndex={disabled ? -1 : 0} onClick={handleClick} aria-current={isCurrent} {...(disabled diff --git a/src/table/__tests__/hooks-integration.test.tsx b/src/table/__tests__/hooks-integration.test.tsx index 0c53fad866..adadb08839 100644 --- a/src/table/__tests__/hooks-integration.test.tsx +++ b/src/table/__tests__/hooks-integration.test.tsx @@ -82,10 +82,10 @@ test('should apply filtering and display no-match state', () => { test('should navigate through pagination', () => { const { tableWrapper, paginationWrapper } = renderDemo(); expect(tableWrapper.findBodyCell(1, 1)!.getElement().textContent).toEqual('1'); - expect(paginationWrapper.findPreviousPageButton().getElement()).toBeDisabled(); + expect(paginationWrapper.findPreviousPageButton().getElement()).toHaveAttribute('aria-disabled', 'true'); paginationWrapper.findNextPageButton().click(); expect(tableWrapper.findBodyCell(1, 1)!.getElement().textContent).toEqual('11'); - expect(paginationWrapper.findPreviousPageButton().getElement()).toBeEnabled(); + expect(paginationWrapper.findPreviousPageButton().getElement()).not.toHaveAttribute('aria-disabled'); paginationWrapper.findPageNumberByIndex(4)!.click(); expect(tableWrapper.findBodyCell(1, 1)!.getElement().textContent).toEqual('31'); }); @@ -95,7 +95,7 @@ test('pagination should work when filtering is applied', () => { expect(tableWrapper.findBodyCell(1, 1)!.getElement().textContent).toEqual('1'); expect(paginationWrapper.findPageNumbers()).toHaveLength(2); paginationWrapper.findNextPageButton().click(); - expect(paginationWrapper.findNextPageButton().getElement()).toBeDisabled(); + expect(paginationWrapper.findNextPageButton().getElement()).toHaveAttribute('aria-disabled', 'true'); expect(tableWrapper.findBodyCell(1, 1)!.getElement().textContent).toEqual('19'); }); From 6d63a76a79a3922762b084f464effc6b10e69280 Mon Sep 17 00:00:00 2001 From: Nathnael_D <109980176+NathanZlion@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:48:00 +0200 Subject: [PATCH 87/95] fix: Make every divider have higher contrast (#4590) --- src/table/header-cell/group-header-cell.tsx | 2 +- src/table/header-cell/index.tsx | 6 +----- src/table/resizer/index.tsx | 11 +---------- src/table/resizer/styles.scss | 4 ---- src/table/selection/selection-cell.tsx | 5 +---- 5 files changed, 4 insertions(+), 24 deletions(-) diff --git a/src/table/header-cell/group-header-cell.tsx b/src/table/header-cell/group-header-cell.tsx index f36b2705db..af049ceead 100644 --- a/src/table/header-cell/group-header-cell.tsx +++ b/src/table/header-cell/group-header-cell.tsx @@ -138,7 +138,7 @@ export function TableGroupHeaderCell({ dividerPosition={columnGroupId ? 'full' : 'bottom'} /> ) : ( - + )} ); diff --git a/src/table/header-cell/index.tsx b/src/table/header-cell/index.tsx index eb4f6fd553..b30b8604cc 100644 --- a/src/table/header-cell/index.tsx +++ b/src/table/header-cell/index.tsx @@ -213,11 +213,7 @@ export function TableHeaderCell({ dividerPosition={isLastChildOfGroup ? 'top' : undefined} /> ) : ( - 1) ? 'interactive' : 'default'} - /> + )} ); diff --git a/src/table/resizer/index.tsx b/src/table/resizer/index.tsx index e980992dde..ccdee85ae7 100644 --- a/src/table/resizer/index.tsx +++ b/src/table/resizer/index.tsx @@ -41,20 +41,11 @@ const AUTO_GROW_INCREMENT = 5; export type DividerPosition = 'default' | 'top' | 'bottom' | 'full'; -export function Divider({ - className, - position, - variant, -}: { - className?: string; - position?: DividerPosition; - variant?: 'default' | 'interactive'; -}) { +export function Divider({ className, position }: { className?: string; position?: DividerPosition }) { return ( .divider, } } -th:not([data-rightmost]) > .divider-disabled { - border-inline-start-color: awsui.$color-border-divider-default; -} - .divider-interactive { inset-inline-end: calc(#{$handle-width} / 2); } diff --git a/src/table/selection/selection-cell.tsx b/src/table/selection/selection-cell.tsx index bd58358428..f2b894af81 100644 --- a/src/table/selection/selection-cell.tsx +++ b/src/table/selection/selection-cell.tsx @@ -56,10 +56,7 @@ export function TableHeaderSelectionCell({ ) : ( {singleSelectionHeaderAriaLabel} )} - 1 ? 'interactive' : 'default'} - /> + ); } From ed792c6c984671623a79799696920fd8aa519d0b Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Tue, 9 Jun 2026 14:33:33 +0200 Subject: [PATCH 88/95] feat: Add skeleton loading support to table (#4583) --- pages/table/progressive-loading.page.tsx | 668 ++++++++++++++++++ pages/table/skeleton-rows.page.tsx | 173 +++++ .../__snapshots__/documenter.test.ts.snap | 23 +- src/table/__tests__/skeleton.test.tsx | 98 +++ src/table/interfaces.tsx | 13 + src/table/internal.tsx | 48 +- src/table/skeleton-rows.tsx | 106 +++ src/table/styles.scss | 6 + 8 files changed, 1131 insertions(+), 4 deletions(-) create mode 100644 pages/table/progressive-loading.page.tsx create mode 100644 pages/table/skeleton-rows.page.tsx create mode 100644 src/table/__tests__/skeleton.test.tsx create mode 100644 src/table/skeleton-rows.tsx diff --git a/pages/table/progressive-loading.page.tsx b/pages/table/progressive-loading.page.tsx new file mode 100644 index 0000000000..c8e9cf2e4b --- /dev/null +++ b/pages/table/progressive-loading.page.tsx @@ -0,0 +1,668 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useEffect, useRef, useState } from 'react'; + +import Box from '~components/box'; +import Button from '~components/button'; +import Container from '~components/container'; +import FormField from '~components/form-field'; +import Grid from '~components/grid'; +import Header from '~components/header'; +import Input from '~components/input'; +import Pagination from '~components/pagination'; +import Skeleton from '~components/skeleton'; +import SpaceBetween from '~components/space-between'; +import Spinner from '~components/spinner'; +import StatusIndicator from '~components/status-indicator'; +import Table from '~components/table'; +import TextFilter from '~components/text-filter'; +import Toggle from '~components/toggle'; + +import { useAppContext } from '../app/app-context'; +import ScreenshotArea from '../utils/screenshot-area'; + +interface DataItem { + id: string; + name: string; + description: string; + status: string; + date: string; +} + +const sampleData: DataItem[] = [ + { + id: 'item-1', + name: 'Production Database', + description: 'Primary production database instance', + status: 'Active', + date: '2026-04-15', + }, + { + id: 'item-2', + name: 'Development Environment', + description: 'Testing and development environment', + status: 'Active', + date: '2026-04-14', + }, + { + id: 'item-3', + name: 'Analytics Pipeline', + description: 'Data processing pipeline for analytics', + status: 'Pending', + date: '2026-04-13', + }, + { + id: 'item-4', + name: 'Backup Server', + description: 'Secondary backup server for disaster recovery', + status: 'Active', + date: '2026-04-12', + }, +]; + +const statuses = ['Active', 'Pending', 'Stopped', 'Terminated']; + +function generateLargeDataset(count: number): DataItem[] { + return Array.from({ length: count }, (_, i) => ({ + id: `item-${i + 1}`, + name: `Resource ${i + 1}`, + description: `Description for resource ${i + 1}`, + status: statuses[i % statuses.length], + date: `2026-04-${String((i % 28) + 1).padStart(2, '0')}`, + })); +} + +const largeData = generateLargeDataset(25); +const PAGE_SIZE = 10; + +export default function ProgressiveLoadingExplorations() { + const { urlParams, setUrlParams } = useAppContext<'manyItems' | 'skeletonRows'>(); + + // Page-level settings from URL params + const manyItems = urlParams.manyItems !== 'false' && urlParams.manyItems !== false && !!urlParams.manyItems; + const skeletonRowsCount = String(urlParams.skeletonRows || '10'); + const skeletonRows = parseInt(skeletonRowsCount, 10) || 10; + const items = manyItems ? largeData : sampleData; + + // Shared pagination helper + function paginate(allItems: DataItem[], page: number) { + const start = (page - 1) * PAGE_SIZE; + return allItems.slice(start, start + PAGE_SIZE); + } + + function totalPages(allItems: DataItem[]) { + return Math.ceil(allItems.length / PAGE_SIZE); + } + + // Progressive Row Loading state + const [rowPage, setRowPage] = useState(1); + const rowPageTimerRef = useRef | null>(null); + const rowPageItems = paginate(items, rowPage); + + const [rowLoadedState, setRowLoadedState] = useState(() => rowPageItems.map(() => false)); + const rowTimerRefs = useRef[]>([]); + const [rowLoading, setRowLoading] = useState(false); + const [rowPaused, setRowPaused] = useState(false); + const rowNextIndexRef = useRef(0); + + // Reset row loading when items change (toggle switch) + useEffect(() => { + rowTimerRefs.current.forEach(clearTimeout); + rowTimerRefs.current = []; + setRowLoadedState(rowPageItems.map(() => false)); + setRowPage(1); + }, [items]); // eslint-disable-line react-hooks/exhaustive-deps + + // Once all rows are loaded, show only real items. + const allRowsLoaded = rowLoadedState.length > 0 && rowLoadedState.every(Boolean); + + function handleRowPageChange(page: number) { + if (rowPageTimerRef.current) { + clearTimeout(rowPageTimerRef.current); + } + rowTimerRefs.current.forEach(clearTimeout); + rowTimerRefs.current = []; + setRowPage(page); + const pageItems = paginate(items, page); + setRowLoadedState(pageItems.map(() => false)); + rowNextIndexRef.current = 0; + setRowLoading(true); + setRowPaused(false); + scheduleRowLoading(0); + } + + function scheduleRowLoading(startIndex: number) { + rowTimerRefs.current.forEach(clearTimeout); + rowTimerRefs.current = []; + rowPageItems.slice(startIndex).forEach((_, i) => { + const timer = setTimeout( + () => { + setRowLoadedState(prev => { + const next = [...prev]; + next[startIndex + i] = true; + return next; + }); + rowNextIndexRef.current = startIndex + i + 1; + if (startIndex + i === rowPageItems.length - 1) { + setRowLoading(false); + } + }, + (i + 1) * 800 + ); + rowTimerRefs.current.push(timer); + }); + } + + function startRowLoading() { + const initial = rowPageItems.map(() => false); + setRowLoadedState(initial); + rowTimerRefs.current.forEach(clearTimeout); + rowTimerRefs.current = []; + rowNextIndexRef.current = 0; + setRowLoading(true); + setRowPaused(false); + scheduleRowLoading(0); + } + + function pauseRowLoading() { + rowTimerRefs.current.forEach(clearTimeout); + rowTimerRefs.current = []; + setRowPaused(true); + } + + function resumeRowLoading() { + setRowPaused(false); + scheduleRowLoading(rowNextIndexRef.current); + } + + function resetRowLoading() { + rowTimerRefs.current.forEach(clearTimeout); + rowTimerRefs.current = []; + setRowLoadedState(rowPageItems.map(() => false)); + setRowLoading(false); + setRowPaused(false); + rowNextIndexRef.current = 0; + } + + // Progressive Column Loading state + // Step 1: initial data load (all columns except Status) + // Step 2: Status column loads later + const [colPage, setColPage] = useState(1); + const [colPageLoading, setColPageLoading] = useState(false); + const colPageTimerRef = useRef | null>(null); + const colPageItems = paginate(items, colPage); + + function handleColPageChange(page: number) { + setColPageLoading(true); + setColDataLoaded(false); + setColStatusLoaded(false); + if (colPageTimerRef.current) { + clearTimeout(colPageTimerRef.current); + } + colTimerRefs.current.forEach(clearTimeout); + colTimerRefs.current = []; + colPageTimerRef.current = setTimeout(() => { + setColPage(page); + setColPageLoading(false); + // After page loads, simulate the 2-step column loading + const timer1 = setTimeout(() => { + setColDataLoaded(true); + }, 1500); + const timer2 = setTimeout(() => { + setColStatusLoaded(true); + }, 3500); + colTimerRefs.current.push(timer1, timer2); + }, 1000); + } + + const [colDataLoaded, setColDataLoaded] = useState(false); + const [colStatusLoaded, setColStatusLoaded] = useState(false); + const colTimerRefs = useRef[]>([]); + + function startColLoading() { + setColDataLoaded(true); + setColStatusLoaded(false); + colTimerRefs.current.forEach(clearTimeout); + colTimerRefs.current = []; + // Status column loads after 2s + const timer = setTimeout(() => { + setColStatusLoaded(true); + }, 2000); + colTimerRefs.current.push(timer); + } + + function resetColLoading() { + colTimerRefs.current.forEach(clearTimeout); + colTimerRefs.current = []; + setColDataLoaded(false); + setColStatusLoaded(false); + } + + // Async Filtering state + const [filterPage, setFilterPage] = useState(1); + const [filterText, setFilterText] = useState(''); + const [filterLoading, setFilterLoading] = useState(false); + const [filteredItems, setFilteredItems] = useState(sampleData); + const filterTimerRef = useRef | null>(null); + + // Reset filtering when items change (toggle switch) + useEffect(() => { + setFilterText(''); + setFilterLoading(false); + setFilteredItems(items); + setFilterPage(1); + if (filterTimerRef.current) { + clearTimeout(filterTimerRef.current); + } + }, [items]); + + function handleFilterChange(text: string) { + setFilterText(text); + setFilterLoading(true); + setFilterPage(1); + if (filterTimerRef.current) { + clearTimeout(filterTimerRef.current); + } + filterTimerRef.current = setTimeout(() => { + const lower = text.toLowerCase(); + setFilteredItems( + items.filter( + item => + item.name.toLowerCase().includes(lower) || + item.description.toLowerCase().includes(lower) || + item.status.toLowerCase().includes(lower) + ) + ); + setFilterLoading(false); + }, 1500); + } + + function resetFiltering() { + if (filterTimerRef.current) { + clearTimeout(filterTimerRef.current); + } + setFilterText(''); + setFilterLoading(false); + setFilteredItems(items); + setFilterPage(1); + } + + const filterPageTimerRef = useRef | null>(null); + const [filterPageLoading, setFilterPageLoading] = useState(false); + const filterPageItems = paginate(filterLoading ? [] : filteredItems, filterPage); + + function handleFilterPageChange(page: number) { + setFilterPageLoading(true); + if (filterPageTimerRef.current) { + clearTimeout(filterPageTimerRef.current); + } + filterPageTimerRef.current = setTimeout(() => { + setFilterPage(page); + setFilterPageLoading(false); + }, 1000); + } + + useEffect(() => { + const rowTimers = rowTimerRefs.current; + const colTimers = colTimerRefs.current; + const filterTimer = filterTimerRef.current; + const rowPageTimer = rowPageTimerRef.current; + const colPageTimer = colPageTimerRef.current; + const filterPageTimer = filterPageTimerRef.current; + return () => { + rowTimers.forEach(clearTimeout); + colTimers.forEach(clearTimeout); + if (filterTimer) { + clearTimeout(filterTimer); + } + if (rowPageTimer) { + clearTimeout(rowPageTimer); + } + if (colPageTimer) { + clearTimeout(colPageTimer); + } + if (filterPageTimer) { + clearTimeout(filterPageTimer); + } + }; + }, []); + + return ( + + + +
+ Progressive Loading Explorations +
+ setUrlParams({ manyItems: detail.checked })}> + Many items (25) + + + setUrlParams({ skeletonRows: detail.value })} + type="number" + inputMode="numeric" + step={1} + /> + + Progressive Row Loading}> + + + {!rowLoading ? ( + + ) : rowPaused ? ( + + ) : ( + + )} + + + +
elements. + // Both halves get resizers. Each resizes its own rightmost column child. + const isSplitFirst = splitFirst.stickyColspan > 0; + + // Left half is sticky for 'first', non-sticky for 'last' + const leftColspan = isSplitFirst ? split.stickyColspan : split.staticColspan; + const leftColIndex = col.colIndex; + const leftGroupId = isSplitFirst ? col.id : `${col.id}__split`; + const leftChildIds = childIds.slice(0, leftColspan); + + // Right half is non-sticky for 'first', sticky for 'last' + const rightColspan = isSplitFirst ? split.staticColspan : split.stickyColspan; + const rightColIndex = col.colIndex + leftColspan; + const rightGroupId = isSplitFirst ? `${col.id}__split` : col.id; + const rightChildIds = childIds.slice(leftColspan); + + return ( + + {/* Left half */} + { + handleSplitGroupResize(leftChildIds, newWidth); + }} + childColumnIds={leftChildIds} + firstChildColumnId={leftChildIds[0]} + lastChildColumnId={leftChildIds[leftChildIds.length - 1]} + cellRef={isSplitFirst ? node => setCell(sticky, col.id, node) : () => {}} + isLast={false} + stickyColumnId={isSplitFirst ? childIds[0] : undefined} + stickyBoundaryColumnId={isSplitFirst ? leftChildIds[leftChildIds.length - 1] : undefined} + /> + + {/* Right half */} + { + handleSplitGroupResize(rightChildIds, newWidth); + }} + childColumnIds={rightChildIds} + firstChildColumnId={rightChildIds[0]} + lastChildColumnId={rightChildIds[rightChildIds.length - 1]} + cellRef={!isSplitFirst ? node => setCell(sticky, col.id, node) : () => {}} + resizerRoleDescription={resizerRoleDescription} + resizerTooltipText={resizerTooltipText} + isLast={rightColIndex + rightColspan === totalColumns} + stickyColumnId={!isSplitFirst ? childIds[childIds.length - 1] : undefined} + /> + + ); + } + + // Determine if the entire group is sticky (all children on one side) + const isFullyStickyFirst = + stickyColumnsFirst > 0 && col.colIndex + col.colSpan - 1 < stickyColumnsFirst; + const isFullyStickyLast = + stickyColumnsLast > 0 && col.colIndex >= columnDefinitions.length - stickyColumnsLast; + const fullyStickyColumnId = isFullyStickyFirst + ? childIds[0] + : isFullyStickyLast + ? childIds[childIds.length - 1] + : undefined; + + // When the group's last child is the sticky-first boundary, the group + // needs the shadow from that child (but offset from the first child). + const isAtStickyFirstBoundary = + isFullyStickyFirst && col.colIndex + col.colSpan - 1 === stickyColumnsFirst - 1; + const isAtStickyLastBoundary = + isFullyStickyLast && col.colIndex === columnDefinitions.length - stickyColumnsLast; + const fullyStickyBoundaryColumnId = isAtStickyFirstBoundary + ? childIds[childIds.length - 1] + : isAtStickyLastBoundary + ? childIds[0] + : undefined; + + return ( + { + updateGroup(groupId, newWidth); + }} + childColumnIds={childIds} + firstChildColumnId={childIds[0]} + lastChildColumnId={childIds[childIds.length - 1]} + cellRef={node => setCell(sticky, col.id, node)} + resizerRoleDescription={resizerRoleDescription} + resizerTooltipText={resizerTooltipText} + isLast={col.colIndex + col.colSpan === totalColumns} + stickyColumnId={fullyStickyColumnId} + stickyBoundaryColumnId={fullyStickyBoundaryColumnId} + /> + ); + } else { + // Regular column cell + const column = col.columnDefinition!; + const columnId = col.id; + const colIndex = col.colIndex; + + return ( + onResizeFinish(columnWidths)} + resizableColumns={resizableColumns} + resizableStyle={getColumnStyles(sticky, columnId)} + onClick={detail => { + setLastUserAction('sorting'); + fireNonCancelableEvent(onSortingChange, detail); + }} + isEditable={!!column.editConfig} + cellRef={node => { + setCell(sticky, columnId, node); + }} + tableRole={tableRole} + resizerRoleDescription={resizerRoleDescription} + resizerTooltipText={resizerTooltipText} + isExpandable={colIndex === 0 && isExpandable} + hasDynamicContent={hidden && !resizableColumns && column.hasDynamicContent} + colSpan={col.colSpan} + rowSpan={col.rowSpan} + isLastChildOfGroup={isLastChildOfGroup} + isLast={col.colIndex + col.colSpan === totalColumns} + columnGroupId={ + col.parentGroupIds.length > 0 ? col.parentGroupIds[col.parentGroupIds.length - 1] : undefined + } + /> + ); + } + })} +
Skeleton} + items={rowPageItems.filter((_, index) => rowLoadedState[index])} + trackBy="id" + loading={!allRowsLoaded} + loadingText="Loading items..." + skeleton={!allRowsLoaded ? { totalRows: skeletonRows } : undefined} + pagination={ + `Page ${n}`, + }} + currentPageIndex={rowPage} + pagesCount={totalPages(items)} + onChange={({ detail }) => handleRowPageChange(detail.currentPageIndex)} + /> + } + columnDefinitions={[ + { + id: 'name', + header: 'Name', + cell: (item: DataItem) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: DataItem) => item.description, + }, + { + id: 'status', + header: 'Status', + cell: (item: DataItem) => item.status, + }, + { + id: 'date', + header: 'Date', + cell: (item: DataItem) => item.date, + }, + ]} + /> +
Spinner} + items={rowPageItems.filter((_, index) => rowLoadedState[index])} + trackBy="id" + loading={!rowLoadedState.some(Boolean)} + loadingText="Loading items..." + pagination={ + `Page ${n}`, + }} + currentPageIndex={rowPage} + pagesCount={totalPages(items)} + onChange={({ detail }) => handleRowPageChange(detail.currentPageIndex)} + /> + } + columnDefinitions={[ + { + id: 'name', + header: 'Name', + cell: (item: DataItem) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: DataItem) => item.description, + }, + { + id: 'status', + header: 'Status', + cell: (item: DataItem) => item.status, + }, + { + id: 'date', + header: 'Date', + cell: (item: DataItem) => item.date, + }, + ]} + getLoadingStatus={() => (rowLoadedState.every(Boolean) ? 'finished' : 'loading')} + renderLoaderLoading={() => Loading items} + /> + + + + Progressive Column Loading}> + + + + + + +
Skeleton} + items={colDataLoaded && !colPageLoading ? colPageItems : []} + loading={!colDataLoaded || colPageLoading} + loadingText="Loading items..." + skeleton={!colDataLoaded || colPageLoading ? { totalRows: skeletonRows } : undefined} + pagination={ + `Page ${n}`, + }} + currentPageIndex={colPage} + pagesCount={totalPages(items)} + onChange={({ detail }) => handleColPageChange(detail.currentPageIndex)} + /> + } + columnDefinitions={[ + { + id: 'name', + header: 'Name', + cell: (item: DataItem) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: DataItem) => item.description, + }, + { + id: 'status', + header: 'Status', + cell: (item: DataItem) => (colStatusLoaded ? item.status : ), + }, + { + id: 'date', + header: 'Date', + cell: (item: DataItem) => item.date, + }, + ]} + /> +
Spinner} + items={colDataLoaded && !colPageLoading ? colPageItems : []} + loading={!colDataLoaded || colPageLoading} + loadingText="Loading items..." + pagination={ + `Page ${n}`, + }} + currentPageIndex={colPage} + pagesCount={totalPages(items)} + onChange={({ detail }) => handleColPageChange(detail.currentPageIndex)} + /> + } + columnDefinitions={[ + { + id: 'name', + header: 'Name', + cell: (item: DataItem) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: DataItem) => item.description, + }, + { + id: 'status', + header: + colDataLoaded && !colStatusLoaded ? ( + + Status + + + ) : ( + 'Status' + ), + cell: (item: DataItem) => (colStatusLoaded ? item.status : ''), + }, + { + id: 'date', + header: 'Date', + cell: (item: DataItem) => item.date, + }, + ]} + /> + + + + Asynchronous Filtering}> + + + + + +
Skeleton} + items={filterLoading || filterPageLoading ? [] : filterPageItems} + loading={filterLoading || filterPageLoading} + loadingText="Filtering items..." + skeleton={filterLoading || filterPageLoading ? { totalRows: skeletonRows } : undefined} + pagination={ + `Page ${n}`, + }} + currentPageIndex={filterPage} + pagesCount={totalPages(filteredItems)} + onChange={({ detail }) => handleFilterPageChange(detail.currentPageIndex)} + /> + } + filter={ + handleFilterChange(detail.filteringText)} + filteringPlaceholder="Filter items" + /> + } + columnDefinitions={[ + { + id: 'name', + header: 'Name', + cell: (item: DataItem) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: DataItem) => item.description, + }, + { + id: 'status', + header: 'Status', + cell: (item: DataItem) => item.status, + }, + { + id: 'date', + header: 'Date', + cell: (item: DataItem) => item.date, + }, + ]} + empty={No matching items} + /> +
Spinner} + items={filterLoading || filterPageLoading ? [] : filterPageItems} + loading={filterLoading || filterPageLoading} + loadingText="Filtering items..." + pagination={ + `Page ${n}`, + }} + currentPageIndex={filterPage} + pagesCount={totalPages(filteredItems)} + onChange={({ detail }) => handleFilterPageChange(detail.currentPageIndex)} + /> + } + filter={ + handleFilterChange(detail.filteringText)} + filteringPlaceholder="Filter items" + /> + } + columnDefinitions={[ + { + id: 'name', + header: 'Name', + cell: (item: DataItem) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: DataItem) => item.description, + }, + { + id: 'status', + header: 'Status', + cell: (item: DataItem) => item.status, + }, + { + id: 'date', + header: 'Date', + cell: (item: DataItem) => item.date, + }, + ]} + empty={No matching items} + /> + + + + + + + ); +} diff --git a/pages/table/skeleton-rows.page.tsx b/pages/table/skeleton-rows.page.tsx new file mode 100644 index 0000000000..1c6fae6d38 --- /dev/null +++ b/pages/table/skeleton-rows.page.tsx @@ -0,0 +1,173 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Checkbox from '~components/checkbox'; +import ColumnLayout from '~components/column-layout'; +import Container from '~components/container'; +import FormField from '~components/form-field'; +import Header from '~components/header'; +import Input from '~components/input'; +import RadioGroup from '~components/radio-group'; +import SpaceBetween from '~components/space-between'; +import Table from '~components/table'; + +import { useAppContext } from '../app/app-context'; + +interface Item { + id: string; + name: string; + description: string; + type: string; +} + +function generateItems(count: number): Item[] { + return Array.from({ length: count }, (_, i) => ({ + id: `item-${i + 1}`, + name: `Item ${i + 1}`, + description: `Description for item ${i + 1}`, + type: i % 2 === 0 ? 'Type A' : 'Type B', + })); +} + +type LoadingState = 'skeleton' | 'loading' | 'data'; +type SelectionMode = 'none' | 'single' | 'multi'; + +export default function TableSkeletonRowsPage() { + const { urlParams, setUrlParams } = useAppContext< + 'loadingState' | 'skeletonRows' | 'dataRows' | 'stripedRows' | 'selectionMode' + >(); + + const loadingState = (urlParams.loadingState || 'skeleton') as LoadingState; + const skeletonRowsCount = String(urlParams.skeletonRows || '5'); + const dataRowsCount = String(urlParams.dataRows || '10'); + const stripedRows = urlParams.stripedRows !== 'false' && urlParams.stripedRows !== false; + const selectionMode = (urlParams.selectionMode || 'multi') as SelectionMode; + + const [selectedItems, setSelectedItems] = useState([]); + + const skeletonRows = parseInt(skeletonRowsCount, 10) || 0; + const dataRows = parseInt(dataRowsCount, 10) || 0; + const items = loadingState === 'data' ? generateItems(dataRows) : []; + + const columnDefinitions = [ + { + id: 'id', + header: 'ID', + cell: (item: Item) => item.id, + }, + { + id: 'name', + header: 'Name', + cell: (item: Item) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: Item) => item.description, + }, + { + id: 'type', + header: 'Type', + cell: (item: Item) => item.type, + }, + ]; + + return ( +
+ +
Table with Skeleton Rows - Interactive Demo
+ + Controls}> + + + + setUrlParams({ loadingState: detail.value })} + items={[ + { value: 'skeleton', label: 'Skeleton Rows', description: 'Show skeleton placeholders' }, + { value: 'loading', label: 'Standard Loading', description: 'Show spinner with loading text' }, + { value: 'data', label: 'Actual Data', description: 'Display real table data' }, + ]} + /> + + + + setUrlParams({ selectionMode: detail.value })} + items={[ + { value: 'none', label: 'None', description: 'No selection' }, + { value: 'single', label: 'Single', description: 'Single row selection' }, + { value: 'multi', label: 'Multi', description: 'Multiple row selection' }, + ]} + /> + + + + + + setUrlParams({ skeletonRows: detail.value })} + type="number" + inputMode="numeric" + step={1} + /> + + + + setUrlParams({ dataRows: detail.value })} + type="number" + inputMode="numeric" + step={1} + /> + + + + setUrlParams({ stripedRows: detail.checked })} + > + Striped rows + + + + + + +
setSelectedItems(detail.selectedItems) : undefined + } + ariaLabels={{ + selectionGroupLabel: 'Item selection', + allItemsSelectionLabel: () => 'Select all', + itemSelectionLabel: (_, item) => item.name, + }} + stripedRows={stripedRows} + header={ +
+ Table Demo +
+ } + /> + + + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index a327e6590a..5f33c3ee4f 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -28136,7 +28136,8 @@ by the \`cell\` property of each column definition in the \`columnDefinitions\` "type": "boolean", }, { - "description": "Specifies the text that's displayed when the table is in a loading state.", + "description": "Specifies the text that's displayed when the table is in a loading state. +In skeleton-loading mode this will be used as a label for screenreaders.", "name": "loadingText", "optional": true, "type": "string", @@ -28280,6 +28281,26 @@ the table items array is empty.", "optional": true, "type": "string", }, + { + "description": "Renders skeleton placeholder rows to fill the table while data is loading. Accepts: +- \`totalRows\` (number) - The total number of rows that should be rendered. If \`items\` + are also provided, those items will be rendered first, and \`totalRows - items.length\` + additional skeleton rows rendered after.", + "inlineType": { + "name": "TableProps.SkeletonConfig", + "properties": [ + { + "name": "totalRows", + "optional": false, + "type": "number", + }, + ], + "type": "object", + }, + "name": "skeleton", + "optional": true, + "type": "TableProps.SkeletonConfig", + }, { "description": "Specifies the definition object of the currently sorted column. Make sure you pass an object that's present in the \`columnDefinitions\` array.", diff --git a/src/table/__tests__/skeleton.test.tsx b/src/table/__tests__/skeleton.test.tsx new file mode 100644 index 0000000000..331fb55115 --- /dev/null +++ b/src/table/__tests__/skeleton.test.tsx @@ -0,0 +1,98 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import Table, { TableProps } from '../../../lib/components/table'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +interface Item { + id: number; + name: string; +} + +const defaultColumns: TableProps.ColumnDefinition[] = [ + { header: 'id', cell: item => item.id }, + { header: 'name', cell: item => item.name }, +]; + +const defaultItems: Item[] = [ + { id: 1, name: 'Apples' }, + { id: 2, name: 'Oranges' }, + { id: 3, name: 'Bananas' }, +]; + +function renderTable(props?: Partial) { + const { container } = render(
); + const wrapper = createWrapper(container); + return wrapper; +} + +describe('Table skeleton loading', () => { + describe('initial load (no items)', () => { + test('renders skeleton rows with skeleton components when loading', () => { + const wrapper = renderTable({ items: [], loading: true, skeleton: { totalRows: 5 } }); + const skeletonRows = wrapper.findAll('tr[aria-hidden="true"]'); + expect(skeletonRows).toHaveLength(5); + expect(wrapper.findAllSkeletons()).toHaveLength(10); // 2 columns × 5 rows + }); + + test('does not render skeleton rows when skeleton prop is not provided', () => { + const wrapper = renderTable({ items: [], loading: true }); + const rows = wrapper.findAll('tr[aria-hidden="true"]'); + expect(rows).toHaveLength(0); + }); + + test('does not render skeleton rows when not loading', () => { + const wrapper = renderTable({ items: [], loading: false, skeleton: { totalRows: 5 } }); + const rows = wrapper.findAll('tr[aria-hidden="true"]'); + expect(rows).toHaveLength(0); + }); + + test('renders a screen-reader-only loading announcement', () => { + const wrapper = renderTable({ + items: [], + loading: true, + loadingText: 'Loading resources', + skeleton: { totalRows: 3 }, + }); + expect(wrapper.getElement().textContent).toContain('Loading resources'); + }); + }); + + describe('progressive loading (partial items)', () => { + test('renders data rows and skeleton rows for remaining items', () => { + const wrapper = renderTable({ items: defaultItems, loading: true, skeleton: { totalRows: 6 } }); + const table = wrapper.findTable()!; + const allRows = table.findRows(); + const skeletonRows = wrapper.findAll('tr[aria-hidden="true"]'); + // 3 data + 3 skeleton = 6 rows with .row class + expect(allRows).toHaveLength(6); + expect(skeletonRows).toHaveLength(3); + expect(wrapper.findAllSkeletons()).toHaveLength(6); // 2 columns × 3 rows + expect(table.findBodyCell(1, 2)!.getElement().textContent).toBe('Apples'); + }); + + test('does not render skeleton rows when items fill totalRows', () => { + const wrapper = renderTable({ items: defaultItems, loading: true, skeleton: { totalRows: 3 } }); + const skeletonRows = wrapper.findAll('tr[aria-hidden="true"]'); + expect(skeletonRows).toHaveLength(0); + }); + + test('does not render skeleton rows when loading is false', () => { + const wrapper = renderTable({ items: defaultItems, loading: false, skeleton: { totalRows: 6 } }); + const skeletonRows = wrapper.findAll('tr[aria-hidden="true"]'); + expect(skeletonRows).toHaveLength(0); + }); + + test('renders a screen-reader-only loading announcement', () => { + const wrapper = renderTable({ + items: defaultItems, + loading: true, + loadingText: 'Loading more', + skeleton: { totalRows: 6 }, + }); + expect(wrapper.getElement().textContent).toContain('Loading more'); + }); + }); +}); diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index c083d57ba9..74ea81b4bc 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -58,9 +58,18 @@ export interface TableProps extends BaseComponentProps { /** * Specifies the text that's displayed when the table is in a loading state. + * In skeleton-loading mode this will be used as a label for screenreaders. */ loadingText?: string; + /** + * Renders skeleton placeholder rows to fill the table while data is loading. Accepts: + * - `totalRows` (number) - The total number of rows that should be rendered. If `items` + * are also provided, those items will be rendered first, and `totalRows - items.length` + * additional skeleton rows rendered after. + */ + skeleton?: TableProps.SkeletonConfig; + /** * Specifies a property that uniquely identifies an individual item. * When it's set, it's used to provide [keys for React](https://reactjs.org/docs/lists-and-keys.html#keys) @@ -696,6 +705,10 @@ export namespace TableProps { export interface RenderLoaderEmptyDetail { item: T; } + + export interface SkeletonConfig { + totalRows: number; + } } export type TableRow = TableDataRow | TableLoaderRow; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index fdfbf85194..800214b57e 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -50,6 +50,7 @@ import { ResizeTracker } from './resizer'; import { focusMarkers, useSelection, useSelectionFocusMove } from './selection'; import { TableBodySelectionCell } from './selection/selection-cell'; import { useGroupSelection } from './selection/use-group-selection'; +import { SkeletonRows } from './skeleton-rows'; import { useStickyColumns } from './sticky-columns'; import StickyHeader, { StickyHeaderRef } from './sticky-header'; import { StickyScrollbar } from './sticky-scrollbar'; @@ -113,6 +114,7 @@ const InternalTable = React.forwardRef( trackBy, loading, loadingText, + skeleton, selectionType: externalSelectionType, selectedItems, isItemDisabled, @@ -603,7 +605,25 @@ const InternalTable = React.forwardRef( {...theadProps} /> - {loading || allItems.length === 0 ? ( + {skeleton && allItems.length === 0 && loading ? ( + + ) : !skeleton && (loading || allItems.length === 0) ? ( { const isFirstRow = rowIndex === 0; - const isLastRow = rowIndex === allRows.length - 1; + const hasSkeletonBelow = + loading && skeleton && allItems.length > 0 && skeleton.totalRows - allItems.length > 0; + const isLastDataRow = rowIndex === allRows.length - 1; + const isLastRow = isLastDataRow && !hasSkeletonBelow; const rowExpandableProps = row.type === 'data' ? expandableRows.getExpandableItemProps(row.item) : undefined; const rowRoleProps = getTableRowRoleProps({ @@ -635,7 +658,7 @@ const InternalTable = React.forwardRef( isLastRow, isSelected: hasSelection && isRowSelected(row), isPrevSelected: hasSelection && !isFirstRow && isRowSelected(allRows[rowIndex - 1]), - isNextSelected: hasSelection && !isLastRow && isRowSelected(allRows[rowIndex + 1]), + isNextSelected: hasSelection && !isLastDataRow && isRowSelected(allRows[rowIndex + 1]), isEvenRow: rowIndex % 2 === 0, stripedRows, hasSelection, @@ -797,6 +820,25 @@ const InternalTable = React.forwardRef( ); }) )} + {loading && skeleton && allItems.length > 0 && skeleton.totalRows - allItems.length > 0 && ( + + )}
diff --git a/src/table/skeleton-rows.tsx b/src/table/skeleton-rows.tsx new file mode 100644 index 0000000000..e342dfe9de --- /dev/null +++ b/src/table/skeleton-rows.tsx @@ -0,0 +1,106 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import ScreenreaderOnly from '../internal/components/screenreader-only'; +import InternalSkeleton from '../skeleton/internal'; +import { TableBodyCell } from './body-cell'; +import { TableProps } from './interfaces'; +import { StickyColumnsModel } from './sticky-columns'; +import { TableRole } from './table-role'; +import { getColumnKey } from './utils'; + +import styles from './styles.css.js'; + +const noop = () => {}; + +interface SkeletonRowsProps { + count: number; + hasDataRows: boolean; + totalColumnsCount: number; + loadingText: string | undefined; + hasSelection: boolean; + hasFooter: boolean; + stickyState: StickyColumnsModel; + tableRole: TableRole; + ariaLabels: TableProps['ariaLabels']; + cellVerticalAlign: TableProps.VerticalAlign | undefined; + computedVariant: string; + visibleColumnDefinitions: readonly TableProps.ColumnDefinition[]; + wrapLines: boolean | undefined; + resizableColumns: boolean | undefined; + colIndexOffset: number; +} + +export function SkeletonRows({ + count, + hasDataRows, + totalColumnsCount, + loadingText, + hasSelection, + hasFooter, + stickyState, + tableRole, + ariaLabels, + cellVerticalAlign, + computedVariant, + visibleColumnDefinitions, + wrapLines, + resizableColumns, + colIndexOffset, +}: SkeletonRowsProps) { + return ( + <> + + + {loadingText} + + + {Array.from({ length: count }, (_, i) => { + const isFirstRow = !hasDataRows && i === 0; + const isLastRow = i === count - 1; + return ( + + {hasSelection && } + {visibleColumnDefinitions.map((column: any, colIndex: number) => ( + , + }} + item={{}} + wrapLines={wrapLines} + isEditable={false} + isEditing={false} + isRowHeader={column.isRowHeader} + resizableColumns={resizableColumns} + onEditStart={noop} + onEditEnd={noop} + columnId={column.id ?? colIndex} + colIndex={colIndex + colIndexOffset} + verticalAlign={column.verticalAlign ?? cellVerticalAlign} + tableVariant={computedVariant} + /> + ))} + + ); + })} + + ); +} diff --git a/src/table/styles.scss b/src/table/styles.scss index 2260dd7f03..2585b019ab 100644 --- a/src/table/styles.scss +++ b/src/table/styles.scss @@ -232,3 +232,9 @@ filter search icon. .row-selected { /* used in test-utils */ } + +.skeleton-loading-cell { + padding-block: 0; + padding-inline: 0; + border-block-end: none; +} From bbc06d4905504cf215bfceef92aa4c46ea822b24 Mon Sep 17 00:00:00 2001 From: Boris Serdiuk Date: Wed, 10 Jun 2026 13:08:06 +0200 Subject: [PATCH 89/95] fix: Support pressedIconSvg in button group (#4595) --- src/app-layout/runtime-drawer/index.tsx | 2 +- .../__tests__/button-group.test.tsx | 60 +++++++++++++++++++ src/button-group/icon-toggle-button-item.tsx | 2 +- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/app-layout/runtime-drawer/index.tsx b/src/app-layout/runtime-drawer/index.tsx index 009d8f2826..ab7c359a62 100644 --- a/src/app-layout/runtime-drawer/index.tsx +++ b/src/app-layout/runtime-drawer/index.tsx @@ -95,7 +95,7 @@ function mapRuntimeHeaderActionsToHeaderActions( // eslint-disable-next-line no-restricted-syntax -- Runtime plugin API: property not in TS type ...('pressedIconSvg' in runtimeHeaderAction && runtimeHeaderAction.pressedIconSvg && { - iconSvg: convertRuntimeTriggerToReactNode(runtimeHeaderAction.pressedIconSvg), + pressedIconSvg: convertRuntimeTriggerToReactNode(runtimeHeaderAction.pressedIconSvg), }), }; }); diff --git a/src/button-group/__tests__/button-group.test.tsx b/src/button-group/__tests__/button-group.test.tsx index b398ea6209..e40e03ea5e 100644 --- a/src/button-group/__tests__/button-group.test.tsx +++ b/src/button-group/__tests__/button-group.test.tsx @@ -105,3 +105,63 @@ describe('ButtonGroup Style API', () => { expect(getComputedStyle(itemElement).getPropertyValue(customCssProps.styleFocusRingBorderWidth)).toBe('2px'); }); }); + +test('icon-toggle-button maintains correct icon on state change', () => { + const { rerender, container } = render( + + + + ), + pressedIconSvg: ( + + + + ), + }, + ]} + /> + ); + const wrapper = createWrapper(container).findButtonGroup()!; + + expect(wrapper.findToggleButtonById('test-toggle-state')!.find('.default-icon')).toBeTruthy(); + expect(wrapper.findToggleButtonById('test-toggle-state')!.find('.pressed-icon')).toBeFalsy(); + + // Rerender with pressed state + rerender( + + + + ), + pressedIconSvg: ( + + + + ), + }, + ]} + /> + ); + + expect(wrapper.findToggleButtonById('test-toggle-state')!.find('.default-icon')).toBeFalsy(); + expect(wrapper.findToggleButtonById('test-toggle-state')!.find('.pressed-icon')).toBeTruthy(); +}); diff --git a/src/button-group/icon-toggle-button-item.tsx b/src/button-group/icon-toggle-button-item.tsx index fcbce964ca..f72888ed6a 100644 --- a/src/button-group/icon-toggle-button-item.tsx +++ b/src/button-group/icon-toggle-button-item.tsx @@ -55,7 +55,7 @@ const IconToggleButtonItem = forwardRef( iconSvg={item.iconSvg} pressedIconName={hasIcon ? item.pressedIconName : 'close'} pressedIconUrl={item.pressedIconUrl} - pressedIconSvg={item.pressedIconUrl} + pressedIconSvg={item.pressedIconSvg} ariaLabel={item.text} onChange={event => fireCancelableEvent(onItemClick, { id: item.id, pressed: event.detail.pressed })} ref={ref} From 0d413530acc1956de378171821b56955e902d785 Mon Sep 17 00:00:00 2001 From: at-susie Date: Wed, 10 Jun 2026 18:35:03 +0200 Subject: [PATCH 90/95] chore: Implement one-theme icon (#4588) --- pages/tree-view/items/basic-page-items.tsx | 5 ++- pages/tree-view/items/permutations-items.tsx | 9 +++-- src/button-dropdown/internal.tsx | 8 +++-- src/button-dropdown/styles.scss | 2 ++ src/button/internal.tsx | 5 ++- .../expandable-section-header.tsx | 32 +++++++++++++---- src/expandable-section/styles.scss | 17 +++++++++ .../components/button-trigger/index.tsx | 9 +++-- .../components/button-trigger/styles.scss | 7 ++++ .../components/expand-toggle-button/index.tsx | 6 ++-- src/status-indicator/internal.tsx | 11 ++++-- src/status-indicator/styles.scss | 36 +++++++++++++++++++ src/tree-view/tree-item/index.tsx | 4 ++- src/tree-view/tree-item/styles.scss | 10 ++++++ style-dictionary/one-theme/colors.ts | 1 + style-dictionary/one-theme/spacing.ts | 2 +- 16 files changed, 142 insertions(+), 22 deletions(-) diff --git a/pages/tree-view/items/basic-page-items.tsx b/pages/tree-view/items/basic-page-items.tsx index 4e9aecb1af..4e7cbaaf09 100644 --- a/pages/tree-view/items/basic-page-items.tsx +++ b/pages/tree-view/items/basic-page-items.tsx @@ -6,11 +6,14 @@ import Badge from '~components/badge'; import Popover from '~components/popover'; import SpaceBetween from '~components/space-between'; import StatusIndicator from '~components/status-indicator'; +import * as tokens from '~design-tokens'; const progressiveStepContent = (
Checked 5 nodes -
+
1 diff --git a/pages/tree-view/items/permutations-items.tsx b/pages/tree-view/items/permutations-items.tsx index 180f9336b8..b45b2d492a 100644 --- a/pages/tree-view/items/permutations-items.tsx +++ b/pages/tree-view/items/permutations-items.tsx @@ -7,6 +7,7 @@ import Box from '~components/box'; import Icon from '~components/icon'; import SpaceBetween from '~components/space-between'; import StatusIndicator from '~components/status-indicator'; +import * as tokens from '~design-tokens'; import { Actions } from '../common'; @@ -195,7 +196,9 @@ export const statusIndicatorItems: Item[] = [ content: (
Checked 5 nodes -
+
1 1 @@ -252,7 +255,9 @@ export const statusIndicatorItems: Item[] = [ content: (
Running automation -
+
1 1 diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index 073175e210..c0ca366ec6 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -3,13 +3,14 @@ import React, { useEffect, useImperativeHandle, useRef } from 'react'; import clsx from 'clsx'; -import { useUniqueId, warnOnce } from '@cloudscape-design/component-toolkit/internal'; +import { isThemeActive, Theme, useUniqueId, warnOnce } from '@cloudscape-design/component-toolkit/internal'; import { getAnalyticsMetadataAttribute } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; import InternalBox from '../box/internal'; import { ButtonProps } from '../button/interfaces'; import { InternalButton, InternalButtonProps } from '../button/internal'; import Dropdown from '../dropdown/internal'; +import { IconProps } from '../icon/interfaces'; import { useFunnel } from '../internal/analytics/hooks/use-funnel.js'; import { getBaseProps } from '../internal/base-component'; import OptionsList from '../internal/components/options-list'; @@ -141,13 +142,14 @@ const InternalButtonDropdown = React.forwardRef( const canBeFullWidth = !!fullWidth && (variant === 'primary' || variant === 'normal'); const triggerVariant = variant === 'navigation' ? undefined : variant === 'inline-icon' ? 'inline-icon' : variant; - const iconProps: Partial = + const iconProps: Partial = variant === 'icon' || variant === 'inline-icon' ? { iconName: 'ellipsis', } : { - iconName: 'caret-down-filled', + iconName: isThemeActive(Theme.OneTheme) ? 'angle-down' : 'caret-down-filled', + __iconSize: isThemeActive(Theme.OneTheme) ? 'x-small' : 'normal', iconAlign: 'right', __iconClass: spinWhenOpen(styles, 'rotate', canBeOpened && isOpen), }; diff --git a/src/button-dropdown/styles.scss b/src/button-dropdown/styles.scss index 789901cf71..72e16ce1ef 100644 --- a/src/button-dropdown/styles.scss +++ b/src/button-dropdown/styles.scss @@ -50,6 +50,8 @@ $dropdown-trigger-icon-offset: 2px; } .trigger-button { + display: flex; + align-items: center; &.full-width { display: grid; grid-template-columns: 1fr auto; diff --git a/src/button/internal.tsx b/src/button/internal.tsx index 8ef126bf7b..0253e78bc6 100644 --- a/src/button/internal.tsx +++ b/src/button/internal.tsx @@ -11,6 +11,7 @@ import { } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; import { useInternalI18n } from '../i18n/context'; +import { IconProps } from '../icon/interfaces'; import Icon from '../icon/internal'; import { FunnelMetrics } from '../internal/analytics'; import { useFunnel, useFunnelStep, useFunnelSubStep } from '../internal/analytics/hooks/use-funnel'; @@ -51,6 +52,7 @@ export type InternalButtonProps = Omit & { badge?: boolean; analyticsAction?: string; __iconClass?: string; + __iconSize?: IconProps.Size; __focusable?: boolean; __injectAnalyticsComponentMetadata?: boolean; __title?: string; @@ -64,6 +66,7 @@ export const InternalButton = React.forwardRef( children, iconName, __iconClass, + __iconSize, onClick, onFollow, iconAlign = 'left', @@ -237,7 +240,7 @@ export const InternalButton = React.forwardRef( variant, badge, iconClass: __iconClass, - iconSize: variant === 'modal-dismiss' ? 'medium' : 'normal', + iconSize: __iconSize ?? (variant === 'modal-dismiss' ? 'medium' : 'normal'), }; const buttonContent = ( <> diff --git a/src/expandable-section/expandable-section-header.tsx b/src/expandable-section/expandable-section-header.tsx index 357620cce9..44143e286f 100644 --- a/src/expandable-section/expandable-section-header.tsx +++ b/src/expandable-section/expandable-section-header.tsx @@ -3,7 +3,7 @@ import React, { KeyboardEventHandler, MouseEventHandler, ReactNode } from 'react'; import clsx from 'clsx'; -import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; +import { isThemeActive, Theme, warnOnce } from '@cloudscape-design/component-toolkit/internal'; import { getAnalyticsMetadataAttribute } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; import InternalHeader, { Description as HeaderDescription } from '../header/internal'; @@ -108,7 +108,15 @@ const ExpandableDeprecatedHeader = ({ aria-expanded={expanded} {...getExpandActionAnalyticsMetadataAttribute(expanded)} > -
{icon}
+
+ {icon} +
{children}
); @@ -128,7 +136,11 @@ const ExpandableNavigationHeader = ({ return (
diff --git a/src/internal/components/button-trigger/styles.scss b/src/internal/components/button-trigger/styles.scss index a68a7967f4..1fea8b2ee7 100644 --- a/src/internal/components/button-trigger/styles.scss +++ b/src/internal/components/button-trigger/styles.scss @@ -12,6 +12,7 @@ $padding-inline-inner-filtering-token: styles.$control-padding-horizontal; $padding-block-inner-filtering-token: 0px; +$icon-offset: awsui.$space-xxxs; .button-trigger { @include styles.styles-reset; @@ -69,6 +70,9 @@ $padding-block-inner-filtering-token: 0px; inset-inline-end: styles.$control-icon-horizontal-offset; inset-block-start: styles.$control-icon-vertical-offset; color: awsui.$color-text-button-inline-icon-default; + &.one-theme { + inset-block-start: calc(styles.$control-icon-vertical-offset + $icon-offset); + } } &:hover { @@ -80,6 +84,9 @@ $padding-block-inner-filtering-token: 0px; &.pressed { > .arrow { transform: rotate(-180deg); + &.one-theme { + inset-block-start: calc(styles.$control-icon-vertical-offset - $icon-offset); + } } } diff --git a/src/internal/components/expand-toggle-button/index.tsx b/src/internal/components/expand-toggle-button/index.tsx index 2a435d90c5..6aa2953247 100644 --- a/src/internal/components/expand-toggle-button/index.tsx +++ b/src/internal/components/expand-toggle-button/index.tsx @@ -4,7 +4,7 @@ import React, { useRef } from 'react'; import clsx from 'clsx'; -import { useSingleTabStopNavigation } from '@cloudscape-design/component-toolkit/internal'; +import { isThemeActive, Theme, useSingleTabStopNavigation } from '@cloudscape-design/component-toolkit/internal'; import InternalIcon from '../../../icon/internal'; @@ -42,8 +42,8 @@ export function ExpandToggleButton({ > {customIcon ?? ( )} diff --git a/src/status-indicator/internal.tsx b/src/status-indicator/internal.tsx index db189302bd..eec4a36370 100644 --- a/src/status-indicator/internal.tsx +++ b/src/status-indicator/internal.tsx @@ -3,6 +3,8 @@ import React from 'react'; import clsx from 'clsx'; +import { isThemeActive, Theme } from '@cloudscape-design/component-toolkit/internal'; + import { IconProps } from '../icon/interfaces'; import InternalIcon from '../icon/internal'; import { getBaseProps } from '../internal/base-component'; @@ -63,7 +65,11 @@ export function InternalStatusIcon({ }: InternalStatusIconProps) { return ( @@ -82,7 +88,7 @@ export default function StatusIndicator({ nativeAttributes, __animate = false, __internalRootRef, - __size = 'normal', + __size = isThemeActive(Theme.OneTheme) ? 'x-small' : 'normal', __display = 'inline-block', ...rest }: InternalStatusIndicatorProps) { @@ -107,6 +113,7 @@ export default function StatusIndicator({ className={clsx( styles.container, styles[`display-${__display}`], + isThemeActive(Theme.OneTheme) && styles['one-theme'], wrapText === false && styles['overflow-ellipsis'], __animate && styles['container-fade-in'] )} diff --git a/src/status-indicator/styles.scss b/src/status-indicator/styles.scss index e2039c69fb..ba6b1872ca 100644 --- a/src/status-indicator/styles.scss +++ b/src/status-indicator/styles.scss @@ -41,6 +41,14 @@ $_status-backgrounds: ( 'not-started': awsui.$color-background-status-indicator-neutral, ); +$_background-overrides: ( + 'red': awsui.$color-background-status-indicator-error, + 'grey': awsui.$color-background-status-indicator-neutral, + 'blue': awsui.$color-background-status-indicator-info, + 'green': awsui.$color-background-status-indicator-success, + 'yellow': awsui.$color-background-status-indicator-warning, +); + .root { @include styles.default-text-style; @each $status in map.keys($_status-colors) { @@ -58,6 +66,11 @@ $_status-backgrounds: ( background: #{map.get($_status-backgrounds, $status)}; } } + @each $color in map.keys($_background-overrides) { + &.color-override-#{$color} > .container { + background: #{map.get($_background-overrides, $color)}; + } + } } .container { @@ -72,6 +85,9 @@ $_status-backgrounds: ( > .icon { white-space: nowrap; + &.one-theme { + vertical-align: middle; + } } } @@ -79,9 +95,19 @@ $_status-backgrounds: ( display: inline-block; word-wrap: break-word; word-break: break-all; + &.one-theme { + display: inline-flex; + align-items: flex-start; + } > .icon { padding-inline-end: awsui.$space-xxs; + + &.one-theme { + display: inline-flex; + align-items: center; + margin-block-start: awsui.$space-xxxs; + } } } } @@ -92,4 +118,14 @@ $_status-backgrounds: ( text-overflow: ellipsis; white-space: nowrap; vertical-align: text-bottom; + + &.one-theme { + text-overflow: unset; + + > span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } } diff --git a/src/tree-view/tree-item/index.tsx b/src/tree-view/tree-item/index.tsx index 603ba5f6fe..98b3a8a7fb 100644 --- a/src/tree-view/tree-item/index.tsx +++ b/src/tree-view/tree-item/index.tsx @@ -3,6 +3,8 @@ import React from 'react'; import clsx from 'clsx'; +import { isThemeActive, Theme } from '@cloudscape-design/component-toolkit/internal'; + import { useInternalI18n } from '../../i18n/context'; import { ExpandToggleButton } from '../../internal/components/expand-toggle-button'; import InternalStructuredItem from '../../internal/components/structured-item'; @@ -86,7 +88,7 @@ const InternalTreeItem = ({ data-testid={`awsui-treeitem-${id}`} data-awsui-tree-item-index={allVisibleItemsIndices[id]} > -
+
{isExpandable ? ( diff --git a/src/tree-view/tree-item/styles.scss b/src/tree-view/tree-item/styles.scss index 7d2286c7db..3328a99c12 100644 --- a/src/tree-view/tree-item/styles.scss +++ b/src/tree-view/tree-item/styles.scss @@ -49,6 +49,16 @@ $item-toggle-column-width: 28px; } } + &.one-theme { + align-items: center; + > .expand-toggle-wrapper { + > .toggle { + inset-inline-start: 0px; + inset-block-start: 3px; + } + } + } + > .structured-item-wrapper { grid-column: 2; grid-row: 1 / span 2; diff --git a/style-dictionary/one-theme/colors.ts b/style-dictionary/one-theme/colors.ts index 5b22b7f32b..1c44fc25a6 100644 --- a/style-dictionary/one-theme/colors.ts +++ b/style-dictionary/one-theme/colors.ts @@ -15,6 +15,7 @@ const tokens: StyleDictionary.ColorsDictionary = { colorBackgroundContainerHeader: { light: '{colorWhite}', dark: '{colorNeutralGrey950}' }, colorBackgroundContainerContent: { light: '{colorWhite}', dark: '{colorNeutralGrey950}' }, colorBorderDividerDefault: { light: '{colorNeutralGrey300}', dark: '{colorNeutralGrey750}' }, + colorBorderDividerInteractiveDefault: { light: '{colorNeutralGrey500}', dark: '{colorNeutralGrey600}' }, colorBorderDividerSecondary: { light: '{colorNeutralGrey200}', dark: '{colorNeutralGrey800}' }, colorBorderLayout: { light: '{colorNeutralGrey300}', dark: '{colorNeutralGrey750}' }, colorGapGlobalDrawer: { light: '{colorNeutralGrey250}', dark: '{colorNeutralGrey1000}' }, diff --git a/style-dictionary/one-theme/spacing.ts b/style-dictionary/one-theme/spacing.ts index b68c983af6..33c6dd362a 100644 --- a/style-dictionary/one-theme/spacing.ts +++ b/style-dictionary/one-theme/spacing.ts @@ -10,7 +10,7 @@ const tokens: StyleDictionary.SpacingDictionary = { spaceTabsVertical: '2px', spaceTokenVertical: '1px', spaceFieldVertical: { comfortable: '4px', compact: '2px' }, - spaceStatusIndicatorPaddingHorizontal: '4px', + spaceStatusIndicatorPaddingHorizontal: '2px', }; const expandedTokens: StyleDictionary.ExpandedDensityScopeDictionary = expandDensityDictionary(tokens); From 8c03ce918bd0cc1b1e5ec99585c10a0c90ab2a5e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 06:53:44 +0000 Subject: [PATCH 91/95] chore: Bump shell-quote from 1.8.3 to 1.8.4 x (#4596) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] --- package-lock.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 22ac706458..9ccb757b17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19382,7 +19382,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "dev": true, "license": "MIT", "engines": { From 4c9b6ed6be06dcc23e60490fbffa717eceaaa64b Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 11 Jun 2026 09:40:21 +0200 Subject: [PATCH 92/95] Do not generate visual test files --- .github/workflows/visual-regression.yml | 3 - .gitignore | 2 - build-tools/visual/generate-tests.js | 106 ------------------------ test/definitions/index.ts | 10 +++ test/visual/action-card.test.ts | 7 ++ test/visual/alert.test.ts | 7 ++ 6 files changed, 24 insertions(+), 111 deletions(-) delete mode 100644 build-tools/visual/generate-tests.js create mode 100644 test/definitions/index.ts create mode 100644 test/visual/action-card.test.ts create mode 100644 test/visual/alert.test.ts diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index cca8a2bf97..b7eddf67d4 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -125,9 +125,6 @@ jobs: # ── Run tests ───────────────────────────────────────────────────────── - - name: Generate visual test files - run: node build-tools/visual/generate-tests.js - - name: Start test server (port 8080) run: npx --yes serve --no-clipboard --listen 8080 pages/lib/static-default & diff --git a/.gitignore b/.gitignore index 1f46343d58..9b14cf8ced 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,6 @@ coverage lib # generated sources src/index.ts -test/visual -test/definitions/index.ts allure-results allure-report src/test-utils/dom/index.ts diff --git a/build-tools/visual/generate-tests.js b/build-tools/visual/generate-tests.js deleted file mode 100644 index f82b54d037..0000000000 --- a/build-tools/visual/generate-tests.js +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -/** - * Auto-generates: - * 1. test/visual/*.test.ts — one test runner per definition file (for Jest sharding) - * 2. test/definitions/index.ts — barrel that exports allSuites from all definitions - * - * Each definition file that exports a default TestSuite gets included. - * Helper files (those without a default export, e.g. shared utils like - * app-layout-responsive-tests.ts) are skipped. - * - * Run this script before executing the visual test suite: - * node build-tools/visual/generate-tests.js - */ -const fs = require('fs'); -const path = require('path'); - -const definitionsDir = path.resolve(__dirname, '../../test/definitions/visual'); -const testOutputDir = path.resolve(__dirname, '../../test/visual'); -const indexOutputPath = path.resolve(__dirname, '../../test/definitions/index.ts'); - -// Files that are shared helpers (export named functions, not a default suite). -const HELPER_SUFFIXES = ['-tests']; - -function isHelperFile(basename) { - return HELPER_SUFFIXES.some(suffix => basename.endsWith(suffix)); -} - -function toCamelCase(basename) { - return basename.replace(/-([a-z0-9])/g, (_, char) => char.toUpperCase()); -} - -function generate() { - // Ensure output directory exists - if (!fs.existsSync(testOutputDir)) { - fs.mkdirSync(testOutputDir, { recursive: true }); - } - - const files = fs.readdirSync(definitionsDir).filter(f => f.endsWith('.ts') && !f.endsWith('.d.ts')); - - const suiteFiles = []; - - for (const file of files) { - const basename = file.replace(/\.ts$/, ''); - - if (isHelperFile(basename)) { - continue; - } - - // Verify the file has a default export by scanning for the pattern - const content = fs.readFileSync(path.join(definitionsDir, file), 'utf-8'); - if (!content.includes('export default')) { - continue; - } - - suiteFiles.push(basename); - - // Generate test/visual/.test.ts - const testContent = [ - '// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.', - '// SPDX-License-Identifier: Apache-2.0', - '// Auto-generated by build-tools/visual/generate-tests.js — do not edit manually.', - `import { runTestSuites } from '../definitions/utils';`, - `import suite from '../definitions/visual/${basename}';`, - '', - 'runTestSuites([suite]);', - '', - ].join('\n'); - - fs.writeFileSync(path.join(testOutputDir, `${basename}.test.ts`), testContent); - } - - // Generate test/definitions/index.ts - // Sort by module path for consistent import ordering - suiteFiles.sort(); - - const imports = suiteFiles.map(basename => { - const varName = toCamelCase(basename); - return `import ${varName} from './visual/${basename}';`; - }); - - const arrayEntries = suiteFiles.map(basename => ` ${toCamelCase(basename)},`); - - const indexContent = [ - '// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.', - '// SPDX-License-Identifier: Apache-2.0', - '// Auto-generated by build-tools/visual/generate-tests.js — do not edit manually.', - '', - `import { TestSuite } from './types';`, - `export { TestSuite, TestDefinition, ScreenshotType, ScreenshotTestConfiguration } from './types';`, - ...imports, - '', - 'export const allSuites: TestSuite[] = [', - ...arrayEntries, - '];', - '', - ].join('\n'); - - fs.writeFileSync(indexOutputPath, indexContent); - - console.log(`Generated ${suiteFiles.length} visual test files in test/visual/`); - console.log(`Generated test/definitions/index.ts with ${suiteFiles.length} suites`); -} - -generate(); diff --git a/test/definitions/index.ts b/test/definitions/index.ts new file mode 100644 index 0000000000..67970b0ab6 --- /dev/null +++ b/test/definitions/index.ts @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// Auto-generated by build-tools/visual/generate-tests.js — do not edit manually. + +import { TestSuite } from './types'; +export { TestSuite, TestDefinition, ScreenshotType, ScreenshotTestConfiguration } from './types'; +import actionCard from './visual/action-card'; +import alert from './visual/alert'; + +export const allSuites: TestSuite[] = [actionCard, alert]; diff --git a/test/visual/action-card.test.ts b/test/visual/action-card.test.ts new file mode 100644 index 0000000000..9e62b89ebf --- /dev/null +++ b/test/visual/action-card.test.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// Auto-generated by build-tools/visual/generate-tests.js — do not edit manually. +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/action-card'; + +runTestSuites([suite]); diff --git a/test/visual/alert.test.ts b/test/visual/alert.test.ts new file mode 100644 index 0000000000..95af4acc78 --- /dev/null +++ b/test/visual/alert.test.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// Auto-generated by build-tools/visual/generate-tests.js — do not edit manually. +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/alert'; + +runTestSuites([suite]); From 6281d51c3f8c108d4ce71cb971e4ba8fc09dfe28 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 11 Jun 2026 09:43:57 +0200 Subject: [PATCH 93/95] Attach images correctly --- test/definitions/utils.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/test/definitions/utils.ts b/test/definitions/utils.ts index 1bffb732df..121bae94a6 100644 --- a/test/definitions/utils.ts +++ b/test/definitions/utils.ts @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { attachment, ContentType } from 'allure-js-commons'; +import { attachment } from 'allure-js-commons'; import { cropAndCompare, parsePng } from '@cloudscape-design/browser-test-tools/image-utils'; import { ScreenshotPageObject, ScreenshotWithOffset } from '@cloudscape-design/browser-test-tools/page-objects'; @@ -32,18 +32,24 @@ function isTestDefinition(item: TestDefinition | TestSuite): item is TestDefinit } /** - * Attaches visual diff images (new, baseline, diff) to the Allure report - * via the allure-js-commons runtime API. + * Attaches a visual comparison to the Allure report using the built-in image diff viewer. + * Uses the `application/vnd.allure.image.diff` content type which Allure renders + * as a side-by-side/overlay comparison widget. */ async function attachDiffImages( result: { firstImage: Buffer; secondImage: Buffer; diffImage: Buffer | null }, testName: string ): Promise { - await attachment(`${testName} — new (PR)`, result.firstImage, ContentType.PNG); - await attachment(`${testName} — baseline (main)`, result.secondImage, ContentType.PNG); - if (result.diffImage) { - await attachment(`${testName} — diff`, result.diffImage, ContentType.PNG); - } + const diffPayload = JSON.stringify({ + expected: `data:image/png;base64,${result.secondImage.toString('base64')}`, + actual: `data:image/png;base64,${result.firstImage.toString('base64')}`, + diff: result.diffImage ? `data:image/png;base64,${result.diffImage.toString('base64')}` : undefined, + }); + + await attachment(testName, diffPayload, { + contentType: 'application/vnd.allure.image.diff', + fileExtension: 'imagediff', + } as any); } /** From a61e816e60ef0dc8136835ccc984359b5e95c682 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 11 Jun 2026 09:59:14 +0200 Subject: [PATCH 94/95] Revert "Remove new test definitions" This reverts commit 7f7867e1ad2e0e81b35ffe6e2eae2a39b98c5262. --- .../visual/app-layout-content-paddings.ts | 30 ++++ test/definitions/visual/app-layout-drawers.ts | 41 +++++ .../definitions/visual/app-layout-flashbar.ts | 39 +++++ test/definitions/visual/app-layout-header.ts | 85 ++++++++++ test/definitions/visual/app-layout-multi.ts | 25 +++ .../visual/app-layout-responsive-1280.ts | 7 + .../visual/app-layout-responsive-1400.ts | 7 + .../visual/app-layout-responsive-1920.ts | 7 + .../visual/app-layout-responsive-2540.ts | 7 + .../visual/app-layout-responsive-600.ts | 7 + .../visual/app-layout-responsive-tests.ts | 159 ++++++++++++++++++ ...-layout-sticky-table-header-split-panel.ts | 78 +++++++++ test/definitions/visual/app-layout-toolbar.ts | 23 +++ test/definitions/visual/app-layout-z-index.ts | 60 +++++++ test/definitions/visual/app-layout.ts | 104 ++++++++++++ test/definitions/visual/area-chart.ts | 136 +++++++++++++++ test/definitions/visual/attribute-editor.ts | 31 ++++ test/definitions/visual/autosuggest.ts | 70 ++++++++ test/definitions/visual/badge.ts | 23 +++ 19 files changed, 939 insertions(+) create mode 100644 test/definitions/visual/app-layout-content-paddings.ts create mode 100644 test/definitions/visual/app-layout-drawers.ts create mode 100644 test/definitions/visual/app-layout-flashbar.ts create mode 100644 test/definitions/visual/app-layout-header.ts create mode 100644 test/definitions/visual/app-layout-multi.ts create mode 100644 test/definitions/visual/app-layout-responsive-1280.ts create mode 100644 test/definitions/visual/app-layout-responsive-1400.ts create mode 100644 test/definitions/visual/app-layout-responsive-1920.ts create mode 100644 test/definitions/visual/app-layout-responsive-2540.ts create mode 100644 test/definitions/visual/app-layout-responsive-600.ts create mode 100644 test/definitions/visual/app-layout-responsive-tests.ts create mode 100644 test/definitions/visual/app-layout-sticky-table-header-split-panel.ts create mode 100644 test/definitions/visual/app-layout-toolbar.ts create mode 100644 test/definitions/visual/app-layout-z-index.ts create mode 100644 test/definitions/visual/app-layout.ts create mode 100644 test/definitions/visual/area-chart.ts create mode 100644 test/definitions/visual/attribute-editor.ts create mode 100644 test/definitions/visual/autosuggest.ts create mode 100644 test/definitions/visual/badge.ts diff --git a/test/definitions/visual/app-layout-content-paddings.ts b/test/definitions/visual/app-layout-content-paddings.ts new file mode 100644 index 0000000000..e76d055749 --- /dev/null +++ b/test/definitions/visual/app-layout-content-paddings.ts @@ -0,0 +1,30 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Content paddings', + componentName: 'app-layout', + tests: [ + ...(['true', 'false'] as const).flatMap(toolsEnabled => + (['true', 'false'] as const).flatMap(splitPanelEnabled => + (['bottom', 'side'] as const).map(splitPanelPosition => ({ + description: `toolsEnabled=${toolsEnabled} splitPanelEnabled=${splitPanelEnabled} splitPanelPosition=${splitPanelPosition}`, + path: 'app-layout/with-split-panel', + screenshotType: 'viewport' as const, + queryParams: { toolsEnabled, splitPanelEnabled, splitPanelPosition }, + })) + ) + ), + ...[1500, 600].map(width => ({ + description: `with split panel and disabled content paddings - width=${width}`, + path: 'app-layout/disable-paddings-with-split-panel', + screenshotType: 'viewport' as const, + configuration: { width }, + queryParams: { splitPanelOpen: 'true', splitPanelPosition: 'side' }, + })), + ], +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-drawers.ts b/test/definitions/visual/app-layout-drawers.ts new file mode 100644 index 0000000000..39c2089783 --- /dev/null +++ b/test/definitions/visual/app-layout-drawers.ts @@ -0,0 +1,41 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import createWrapper from '../../../lib/components/test-utils/selectors'; +import { TestSuite } from '../types'; + +const wrapper = createWrapper(); + +const suite: TestSuite = { + description: 'Drawers', + componentName: 'app-layout', + tests: [ + { + description: 'with split panel', + path: 'app-layout/with-drawers', + screenshotType: 'viewport', + setup: async page => { + await page.click(wrapper.findAppLayout().findDrawerTriggerById('pro-help').toSelector()); + }, + }, + { + description: 'with tooltip on hover', + path: 'app-layout/with-drawers', + screenshotType: 'viewport', + setup: async page => { + await page.hoverElement(wrapper.findAppLayout().findDrawerTriggerById('pro-help').toSelector()); + }, + }, + { + description: 'with custom scrollable drawer content', + path: 'app-layout/with-drawers-scrollable', + screenshotType: 'viewport', + queryParams: { sideNavFill: 'false' }, + setup: async page => { + await page.click(wrapper.findAppLayout().findDrawerTriggerById('chat').toSelector()); + }, + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-flashbar.ts b/test/definitions/visual/app-layout-flashbar.ts new file mode 100644 index 0000000000..5849a5cbf5 --- /dev/null +++ b/test/definitions/visual/app-layout-flashbar.ts @@ -0,0 +1,39 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Flashbar', + componentName: 'app-layout', + tests: [true, false].flatMap(disableContentPaddings => + [true, false].flatMap(stickyNotifications => + [true, false].flatMap(stickyTableHeader => + [true, false].map(stackNotifications => ({ + description: `disableContentPaddings: ${disableContentPaddings}, stickyNotifications: ${stickyNotifications}, stickyTableHeader: ${stickyTableHeader}, stackNotifications: ${stackNotifications}`, + path: 'app-layout/with-stacked-notifications-and-table', + screenshotType: 'screenshotArea' as const, + configuration: { width: 1280, height: 900 }, + setup: async (page: import('@cloudscape-design/browser-test-tools/page-objects').ScreenshotPageObject) => { + if (!disableContentPaddings) { + await page.click('[data-id="toggle-content-paddings"]'); + } + if (stickyNotifications) { + await page.click('[data-id="toggle-sticky-notifications"]'); + } + if (!stickyTableHeader) { + await page.click('[data-id="toggle-sticky-table-header"]'); + } + if (!stackNotifications) { + await page.click('[data-id="toggle-stack-items"]'); + } + await page.click('[data-id="add-notification"]'); + await page.click('[data-id="add-notification"]'); + }, + })) + ) + ) + ), +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-header.ts b/test/definitions/visual/app-layout-header.ts new file mode 100644 index 0000000000..3a128a266e --- /dev/null +++ b/test/definitions/visual/app-layout-header.ts @@ -0,0 +1,85 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestDefinition, TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Headers', + componentName: 'app-layout', + tests: [ + // ── Headers ─────────────────────────────────────────────────────────── + { + description: 'Headers', + tests: [600, 1280].flatMap(width => [ + { + description: `alignment with full-page table (${width}px)`, + path: 'app-layout/with-table', + screenshotType: 'viewport' as const, + configuration: { width }, + }, + { + description: `alignment with full-page table in sticky state (${width}px)`, + path: 'app-layout/with-table', + screenshotType: 'viewport' as const, + configuration: { width }, + setup: async page => { + await page.windowScrollTo({ top: 200 }); + }, + }, + { + description: `alignment with full-page table in sticky state with sticky notifications (${width}px)`, + path: 'app-layout/with-table', + screenshotType: 'viewport' as const, + configuration: { width }, + queryParams: { stickyNotifications: 'true' }, + setup: async page => { + await page.windowScrollTo({ top: 200 }); + }, + }, + { + description: `high contrast header variant in landing page (${width}px)`, + path: 'app-layout/landing-page', + screenshotType: 'viewport' as const, + configuration: { width }, + }, + ]), + }, + + // ── High contrast header variant ────────────────────────────────────── + { + description: 'High contrast header variant', + tests: [ + ...[1400, 600].flatMap(width => [ + { + description: `with breadcrumbs and notifications at ${width}px`, + path: 'app-layout/high-contrast-header-variant', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + queryParams: { hasBreadcrumbs: 'true', hasNotifications: 'true', hasContainer: 'true' }, + } as TestDefinition, + { + description: `without overlap at ${width}px`, + path: 'app-layout/high-contrast-header-variant', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + queryParams: { disableOverlap: 'true' }, + } as TestDefinition, + { + description: `with content layout at ${width}px`, + path: 'app-layout/high-contrast-header-variant', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + queryParams: { + hasBreadcrumbs: 'true', + hasNotifications: 'true', + hasContainer: 'true', + hasContentLayout: 'true', + }, + } as TestDefinition, + ]), + ], + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-multi.ts b/test/definitions/visual/app-layout-multi.ts new file mode 100644 index 0000000000..babf9733cf --- /dev/null +++ b/test/definitions/visual/app-layout-multi.ts @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Multiple instances', + componentName: 'app-layout', + tests: [600, 1280].flatMap(width => [ + { + description: `simple (${width}px)`, + path: 'app-layout/multi-layout-simple', + screenshotType: 'viewport' as const, + configuration: { width }, + }, + { + description: `iframe (${width}px)`, + path: 'app-layout/multi-layout-iframe', + screenshotType: 'viewport' as const, + configuration: { width }, + }, + ]), +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-responsive-1280.ts b/test/definitions/visual/app-layout-responsive-1280.ts new file mode 100644 index 0000000000..d5fe823f54 --- /dev/null +++ b/test/definitions/visual/app-layout-responsive-1280.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { responsiveTests } from './app-layout-responsive-tests'; + +const suite = responsiveTests(1280); +export default suite; diff --git a/test/definitions/visual/app-layout-responsive-1400.ts b/test/definitions/visual/app-layout-responsive-1400.ts new file mode 100644 index 0000000000..1cb519005c --- /dev/null +++ b/test/definitions/visual/app-layout-responsive-1400.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { responsiveTests } from './app-layout-responsive-tests'; + +const suite = responsiveTests(1400); +export default suite; diff --git a/test/definitions/visual/app-layout-responsive-1920.ts b/test/definitions/visual/app-layout-responsive-1920.ts new file mode 100644 index 0000000000..88b8c6caf3 --- /dev/null +++ b/test/definitions/visual/app-layout-responsive-1920.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { responsiveTests } from './app-layout-responsive-tests'; + +const suite = responsiveTests(1920); +export default suite; diff --git a/test/definitions/visual/app-layout-responsive-2540.ts b/test/definitions/visual/app-layout-responsive-2540.ts new file mode 100644 index 0000000000..9dd62c00db --- /dev/null +++ b/test/definitions/visual/app-layout-responsive-2540.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { responsiveTests } from './app-layout-responsive-tests'; + +const suite = responsiveTests(2540); +export default suite; diff --git a/test/definitions/visual/app-layout-responsive-600.ts b/test/definitions/visual/app-layout-responsive-600.ts new file mode 100644 index 0000000000..f6fd3665cc --- /dev/null +++ b/test/definitions/visual/app-layout-responsive-600.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { responsiveTests } from './app-layout-responsive-tests'; + +const suite = responsiveTests(600); +export default suite; diff --git a/test/definitions/visual/app-layout-responsive-tests.ts b/test/definitions/visual/app-layout-responsive-tests.ts new file mode 100644 index 0000000000..f0ac91d121 --- /dev/null +++ b/test/definitions/visual/app-layout-responsive-tests.ts @@ -0,0 +1,159 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +/** + * Shared responsive test scenarios for app-layout. Each width gets its own + * definition file and test runner so that Jest sharding can parallelize them. + */ +export function responsiveTests(width: number): TestSuite { + return { + description: `AppLayout responsive width ${width}px`, + componentName: 'app-layout', + tests: [ + { + description: 'default', + path: 'app-layout/default', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'navigation drawer is open', + path: 'app-layout/with-wizard', + screenshotType: 'viewport', + configuration: { width }, + setup: async page => { + await page.click('[aria-label="Open navigation"]'); + }, + }, + { + description: 'wizard', + path: 'app-layout/with-wizard', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'with wizard and table', + path: 'app-layout/with-wizard-and-table', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'with wizard, table, and breadcrumbs', + path: 'app-layout/with-wizard-and-table', + screenshotType: 'viewport', + configuration: { width }, + queryParams: { hasBreadcrumbs: 'true' }, + }, + { + description: 'notifications', + path: 'app-layout/with-notifications', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'breadcrumbs', + path: 'app-layout/with-breadcrumbs', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'notifications and breadcrumbs', + path: 'app-layout/with-breadcrumbs-notifications', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'dashboard content type', + path: 'app-layout/dashboard-content-type', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'fixed header and footer', + path: 'app-layout/with-fixed-header-footer', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'disableBodyScroll - empty', + path: 'app-layout/legacy-nav-empty', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'disableBodyScroll - with content', + path: 'app-layout/legacy-nav-scrollable', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'disableBodyScroll - with split panel', + path: 'app-layout/legacy-nav-scrollable-with-split-panel', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'disable paddings', + path: 'app-layout/disable-paddings', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'disable paddings with breadcrumbs', + path: 'app-layout/disable-paddings-breadcrumbs', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'sticky notifications', + path: 'app-layout/with-sticky-notifications', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'sticky notifications scrolled down', + path: 'app-layout/with-sticky-notifications', + screenshotType: 'viewport', + configuration: { width }, + setup: async page => { + await page.windowScrollTo({ top: 2000 }); + }, + }, + { + description: 'layout without panels', + path: 'app-layout/no-panels', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'layout without panels but with notifications', + path: 'app-layout/no-panels-with-notifications', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'with drawers', + path: 'app-layout/with-drawers', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'with empty drawers', + path: 'app-layout/with-drawers-empty', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'with open drawer', + path: 'app-layout/with-drawers', + screenshotType: 'viewport', + configuration: { width }, + setup: async page => { + await page.click('[aria-label="Security trigger button"]'); + }, + }, + ], + }; +} diff --git a/test/definitions/visual/app-layout-sticky-table-header-split-panel.ts b/test/definitions/visual/app-layout-sticky-table-header-split-panel.ts new file mode 100644 index 0000000000..6a0b899686 --- /dev/null +++ b/test/definitions/visual/app-layout-sticky-table-header-split-panel.ts @@ -0,0 +1,78 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Sticky header with split panel', + componentName: 'app-layout', + tests: [ + { + description: 'scrolling to bottom with closed split panel (1 table row)', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'viewport', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-1"]'); + await page.scrollToBottom('html'); + }, + }, + { + description: 'scrolling to bottom with closed split panel (30 table rows)', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'viewport', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-30"]'); + await page.scrollToBottom('html'); + }, + }, + { + description: 'header stays sticky with open split panel (1 table row)', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'viewport', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-1"]'); + await page.click('aria/Open panel'); + await page.scrollToBottom('html'); + }, + }, + { + description: 'header stays sticky with open split panel (30 table rows)', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'viewport', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-30"]'); + await page.click('aria/Open panel'); + await page.scrollToBottom('html'); + }, + }, + { + description: 'header stays sticky when mounting and unmounting a second table', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'viewport', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-30"]'); + await page.click('aria/Open panel'); + await page.windowScrollTo({ top: 0 }); + await page.click('aria/Close panel'); + await page.scrollToBottom('html'); + }, + }, + // ── Max content width ───────────────────────────────────────────────── + { + description: 'maxContentWidth set to Number.MAX_VALUE', + path: 'app-layout/refresh-content-width', + screenshotType: 'viewport', + configuration: { width: 1280, height: 700 }, + setup: async page => { + await page.click('[data-test-id="button_width-number-max_value"]'); + }, + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-toolbar.ts b/test/definitions/visual/app-layout-toolbar.ts new file mode 100644 index 0000000000..fb174b0a71 --- /dev/null +++ b/test/definitions/visual/app-layout-toolbar.ts @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Toolbar', + componentName: 'app-layout', + tests: [ + { + description: 'multiple nested instances (no breadcrumbs dedup)', + path: 'app-layout-toolbar/multi-layout-with-hidden-instances', + screenshotType: 'viewport', + }, + { + description: 'no toolbar', + path: 'app-layout-toolbar/without-toolbar', + screenshotType: 'viewport', + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-z-index.ts b/test/definitions/visual/app-layout-z-index.ts new file mode 100644 index 0000000000..b0dacf8bc5 --- /dev/null +++ b/test/definitions/visual/app-layout-z-index.ts @@ -0,0 +1,60 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestDefinition, TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Z-index', + componentName: 'app-layout', + tests: [ + ...[600, 1280].flatMap(width => [ + { + description: `button dropdown (${width}px)`, + path: 'app-layout/with-absolute-components', + screenshotType: 'viewport' as const, + configuration: { width }, + setup: async page => { + await page.click('button=Button dropdown'); + await page.click('[data-testid="2"]'); + await page.windowScrollTo({ top: 300 }); + }, + } as TestDefinition, + { + description: `select (${width}px)`, + path: 'app-layout/with-absolute-components', + screenshotType: 'viewport' as const, + configuration: { width, height: 800 }, + setup: async page => { + await page.click('[data-testid="select-demo"] button'); + await page.windowScrollTo({ top: 300 }); + }, + } as TestDefinition, + { + description: `split-panel and full-page table (${width}px)`, + path: 'app-layout/with-full-page-table-and-split-panel', + screenshotType: 'viewport' as const, + configuration: { width }, + }, + ]), + { + description: 'split-panel and full-page with open navigation (600px)', + path: 'app-layout/with-full-page-table-and-split-panel', + screenshotType: 'viewport' as const, + configuration: { width: 600 }, + setup: async page => { + await page.click('button[aria-label="Open navigation"]'); + }, + }, + { + description: 'split-panel and full-page with open tools (600px)', + path: 'app-layout/with-full-page-table-and-split-panel', + screenshotType: 'viewport' as const, + configuration: { width: 600 }, + setup: async page => { + await page.click('button[aria-label="Open tools"]'); + }, + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/app-layout.ts b/test/definitions/visual/app-layout.ts new file mode 100644 index 0000000000..683efdcc25 --- /dev/null +++ b/test/definitions/visual/app-layout.ts @@ -0,0 +1,104 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'AppLayout', + componentName: 'app-layout', + tests: [ + { + description: 'no scrollbars at 320px', + path: 'app-layout/default', + screenshotType: 'viewport', + configuration: { width: 320 }, + }, + { + description: 'drawer buttons alignment', + path: 'app-layout/default', + screenshotType: 'viewport', + configuration: { width: 800 }, + setup: async page => { + await page.click('[aria-label="Open tools"]'); + }, + }, + { + description: 'disable paddings - navigation closed', + path: 'app-layout/disable-paddings', + screenshotType: 'viewport', + configuration: { width: 1280 }, + setup: async page => { + await page.click('[aria-label="Close navigation"]'); + }, + }, + { + description: 'panels stacking on mobile', + path: 'app-layout/all-panels-open', + screenshotType: 'viewport', + configuration: { width: 600 }, + }, + { + description: 'wrapping long words', + path: 'app-layout/text-wrap', + screenshotType: 'viewport', + }, + { + description: 'fill content area', + path: 'app-layout/fill-content-area', + screenshotType: 'viewport', + }, + { + description: 'with tools and drawers', + path: 'app-layout/with-drawers', + screenshotType: 'viewport', + queryParams: { hasTools: 'true' }, + }, + { + description: 'with open drawer and open side split panel', + path: 'app-layout/with-drawers', + screenshotType: 'viewport', + configuration: { width: 1400 }, + queryParams: { splitPanelPosition: 'side' }, + setup: async page => { + await page.click('[aria-label="Security trigger button"]'); + await page.click('[aria-label="Open panel"]'); + }, + }, + + // regression for https://github.com/cloudscape-design/components/pull/1612 + { + description: 'with open drawer and open side split panel after resize', + path: 'app-layout/with-drawers', + screenshotType: 'viewport', + configuration: { width: 1500 }, + queryParams: { splitPanelPosition: 'side' }, + setup: async page => { + await page.click('[aria-label="Security trigger button"]'); + await page.click('[aria-label="Open panel"]'); + await page.setWindowSize({ width: 1400, height: 800 }); + }, + }, + + // ── Transitions ─────────────────────────────────────────────────────── + { + description: 'transition from 400px to 1800px', + path: 'app-layout/default', + screenshotType: 'viewport', + configuration: { width: 400, height: 400 }, + setup: async page => { + await page.setWindowSize({ width: 1800, height: 400 }); + }, + }, + { + description: 'transition from 1800px to 400px', + path: 'app-layout/default', + screenshotType: 'viewport', + configuration: { width: 1800, height: 400 }, + setup: async page => { + await page.setWindowSize({ width: 400, height: 400 }); + }, + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/area-chart.ts b/test/definitions/visual/area-chart.ts new file mode 100644 index 0000000000..cfa0b5e47b --- /dev/null +++ b/test/definitions/visual/area-chart.ts @@ -0,0 +1,136 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const TEST_CHART_FILTER_TRIGGER = '#linear-latency-chart button'; +const TEST_CHART_TOOLTIP_HEADER = '#linear-latency-chart h2'; + +const suite: TestSuite = { + description: 'Area chart', + componentName: 'area-chart', + tests: [ + { + description: 'permutations', + path: 'area-chart/permutations', + screenshotType: 'permutations', + }, + { + description: 'fit-height', + path: 'area-chart/fit-height', + screenshotType: 'screenshotArea', + }, + { + description: 'fit-height no filter, no legend', + path: 'area-chart/fit-height', + screenshotType: 'screenshotArea', + queryParams: { hideFilter: 'true', hideLegend: 'true' }, + }, + { + description: 'fit-height, no legend', + path: 'area-chart/fit-height', + screenshotType: 'screenshotArea', + queryParams: { hideLegend: 'true' }, + }, + { + description: 'chart plot has a focus outline', + path: 'area-chart/test', + screenshotType: 'viewport', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.click(TEST_CHART_FILTER_TRIGGER); + await page.keys(['Escape']); + await page.focusNextElement(); + }, + }, + { + description: 'can navigate along X axis highlighting all series with keyboard', + path: 'area-chart/test', + screenshotType: 'viewport', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.click(TEST_CHART_FILTER_TRIGGER); + await page.keys(['Escape']); + await page.focusNextElement(); + await page.keys(['ArrowRight', 'ArrowRight']); + await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); + }, + }, + { + description: 'can navigate a specific series with keyboard', + path: 'area-chart/test', + screenshotType: 'viewport', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.click(TEST_CHART_FILTER_TRIGGER); + await page.keys(['Escape']); + await page.focusNextElement(); + await page.keys(['ArrowRight']); + await page.keys(['ArrowDown']); + await page.keys(['ArrowRight']); + await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); + }, + }, + { + description: 'selects correct series when navigated back from legend', + path: 'area-chart/test', + screenshotType: 'viewport', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.click(TEST_CHART_FILTER_TRIGGER); + await page.keys(['Escape']); + await page.keys(['Tab']); + await page.keys(['Tab']); + await page.keys(['ArrowRight']); + await page.keys(['Shift', 'Tab']); + await page.keys(['ArrowRight']); + await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); + }, + }, + { + description: 'can pin popover for all data points at a given X coordinate with keyboard', + path: 'area-chart/test', + screenshotType: 'viewport', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.click(TEST_CHART_FILTER_TRIGGER); + await page.keys(['Escape']); + await page.focusNextElement(); + await page.keys(['ArrowRight']); + await page.keys(['ArrowRight']); + await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); + await page.keys(['Enter']); + await page.waitForVisible('[aria-label="Dismiss"]'); + }, + }, + { + description: 'can pin popover for a point in a specific series with keyboard', + path: 'area-chart/test', + screenshotType: 'viewport', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.click(TEST_CHART_FILTER_TRIGGER); + await page.keys(['Escape']); + await page.focusNextElement(); + await page.keys(['ArrowRight']); + await page.keys(['ArrowDown']); + await page.keys(['ArrowRight']); + await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); + await page.keys(['Enter']); + await page.waitForVisible('[aria-label="Dismiss"]'); + }, + }, + { + description: 'shows popover on hover', + path: 'area-chart/test', + screenshotType: 'viewport', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.hoverElement('[aria-label="Linear latency chart"]', 200, 50); + await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); + }, + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/attribute-editor.ts b/test/definitions/visual/attribute-editor.ts new file mode 100644 index 0000000000..33da3fad86 --- /dev/null +++ b/test/definitions/visual/attribute-editor.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Attribute Editor', + componentName: 'attribute-editor', + tests: [360, 768, 992].flatMap(width => [ + { + description: `permutations at ${width}px`, + path: 'attribute-editor/permutations', + screenshotType: 'permutations' as const, + configuration: { width }, + }, + { + description: `customizable-footer at ${width}px`, + path: 'attribute-editor/customizable-footer', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + }, + { + description: `with long select at ${width}px`, + path: 'attribute-editor/select-with-long-value', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + }, + ]), +}; + +export default suite; diff --git a/test/definitions/visual/autosuggest.ts b/test/definitions/visual/autosuggest.ts new file mode 100644 index 0000000000..92f4d46897 --- /dev/null +++ b/test/definitions/visual/autosuggest.ts @@ -0,0 +1,70 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import createWrapper from '../../../lib/components/test-utils/selectors'; +import { TestDefinition, TestSuite } from '../types'; + +const wrapper = createWrapper(); + +const suite: TestSuite = { + description: 'Autosuggest', + componentName: 'autosuggest', + tests: [ + { + description: 'permutations', + path: 'autosuggest/permutations', + screenshotType: 'permutations', + setup: async page => { + await page.click('input'); + }, + }, + { + description: 'permutations for async properties', + path: 'autosuggest/permutations-async', + screenshotType: 'permutations', + setup: async page => { + await page.click('input'); + }, + }, + { + description: 'Displays options with groups correctly', + path: 'autosuggest/scenarios', + screenshotType: 'screenshotArea', + setup: async page => { + await page.click('input'); + }, + }, + { + description: 'Correctly displays dropdown regions', + path: 'autosuggest/regions-scenarios', + screenshotType: 'screenshotArea', + setup: async page => { + await page.click('input'); + }, + }, + { + description: 'Long virtual list - navigate to last item', + path: 'autosuggest/virtual-scroll', + screenshotType: 'screenshotArea', + setup: async page => { + await page.click(wrapper.findAutosuggest().findNativeInput().toSelector()); + await page.keys(['ArrowUp']); + }, + }, + ...[true, false].map( + virtualScroll => + ({ + description: `with custom renderOption (virtualScroll=${virtualScroll})`, + path: 'autosuggest/custom-render-option', + screenshotType: 'screenshotArea' as const, + queryParams: { virtualScroll: String(virtualScroll) }, + setup: async page => { + await page.click(wrapper.findAutosuggest().findNativeInput().toSelector()); + await page.keys(['ArrowDown']); + }, + }) as TestDefinition + ), + ], +}; + +export default suite; diff --git a/test/definitions/visual/badge.ts b/test/definitions/visual/badge.ts new file mode 100644 index 0000000000..7d405ed843 --- /dev/null +++ b/test/definitions/visual/badge.ts @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Badge', + componentName: 'badge', + tests: [ + { + description: 'permutation page', + path: 'badge/permutations', + screenshotType: 'permutations', + }, + { + description: 'style custom page', + path: 'badge/style-custom-types', + screenshotType: 'screenshotArea', + }, + ], +}; + +export default suite; From 9b733917106ddf6ba47c668be9c117d4dfa6ead1 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 11 Jun 2026 10:01:55 +0200 Subject: [PATCH 95/95] Add non-generated test files --- test/visual/app-layout-content-paddings.test.ts | 6 ++++++ test/visual/app-layout-drawers.test.ts | 6 ++++++ test/visual/app-layout-flashbar.test.ts | 6 ++++++ test/visual/app-layout-header.test.ts | 6 ++++++ test/visual/app-layout-multi.test.ts | 6 ++++++ test/visual/app-layout-responsive-1280.test.ts | 6 ++++++ test/visual/app-layout-responsive-1400.test.ts | 6 ++++++ test/visual/app-layout-responsive-1920.test.ts | 6 ++++++ test/visual/app-layout-responsive-2540.test.ts | 6 ++++++ test/visual/app-layout-responsive-600.test.ts | 6 ++++++ .../app-layout-sticky-table-header-split-panel.test.ts | 6 ++++++ test/visual/app-layout-toolbar.test.ts | 6 ++++++ test/visual/app-layout-z-index.test.ts | 6 ++++++ test/visual/app-layout.test.ts | 6 ++++++ test/visual/area-chart.test.ts | 6 ++++++ test/visual/attribute-editor.test.ts | 6 ++++++ test/visual/autosuggest.test.ts | 6 ++++++ test/visual/badge.test.ts | 6 ++++++ 18 files changed, 108 insertions(+) create mode 100644 test/visual/app-layout-content-paddings.test.ts create mode 100644 test/visual/app-layout-drawers.test.ts create mode 100644 test/visual/app-layout-flashbar.test.ts create mode 100644 test/visual/app-layout-header.test.ts create mode 100644 test/visual/app-layout-multi.test.ts create mode 100644 test/visual/app-layout-responsive-1280.test.ts create mode 100644 test/visual/app-layout-responsive-1400.test.ts create mode 100644 test/visual/app-layout-responsive-1920.test.ts create mode 100644 test/visual/app-layout-responsive-2540.test.ts create mode 100644 test/visual/app-layout-responsive-600.test.ts create mode 100644 test/visual/app-layout-sticky-table-header-split-panel.test.ts create mode 100644 test/visual/app-layout-toolbar.test.ts create mode 100644 test/visual/app-layout-z-index.test.ts create mode 100644 test/visual/app-layout.test.ts create mode 100644 test/visual/area-chart.test.ts create mode 100644 test/visual/attribute-editor.test.ts create mode 100644 test/visual/autosuggest.test.ts create mode 100644 test/visual/badge.test.ts diff --git a/test/visual/app-layout-content-paddings.test.ts b/test/visual/app-layout-content-paddings.test.ts new file mode 100644 index 0000000000..e566d79817 --- /dev/null +++ b/test/visual/app-layout-content-paddings.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-content-paddings'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-drawers.test.ts b/test/visual/app-layout-drawers.test.ts new file mode 100644 index 0000000000..c66454010d --- /dev/null +++ b/test/visual/app-layout-drawers.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-drawers'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-flashbar.test.ts b/test/visual/app-layout-flashbar.test.ts new file mode 100644 index 0000000000..333642e5f3 --- /dev/null +++ b/test/visual/app-layout-flashbar.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-flashbar'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-header.test.ts b/test/visual/app-layout-header.test.ts new file mode 100644 index 0000000000..682f71ffe2 --- /dev/null +++ b/test/visual/app-layout-header.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-header'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-multi.test.ts b/test/visual/app-layout-multi.test.ts new file mode 100644 index 0000000000..244019c8fb --- /dev/null +++ b/test/visual/app-layout-multi.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-multi'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive-1280.test.ts b/test/visual/app-layout-responsive-1280.test.ts new file mode 100644 index 0000000000..0324b2bf39 --- /dev/null +++ b/test/visual/app-layout-responsive-1280.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-responsive-1280'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive-1400.test.ts b/test/visual/app-layout-responsive-1400.test.ts new file mode 100644 index 0000000000..745372c34e --- /dev/null +++ b/test/visual/app-layout-responsive-1400.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-responsive-1400'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive-1920.test.ts b/test/visual/app-layout-responsive-1920.test.ts new file mode 100644 index 0000000000..bee5fc8763 --- /dev/null +++ b/test/visual/app-layout-responsive-1920.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-responsive-1920'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive-2540.test.ts b/test/visual/app-layout-responsive-2540.test.ts new file mode 100644 index 0000000000..48bc8580da --- /dev/null +++ b/test/visual/app-layout-responsive-2540.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-responsive-2540'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive-600.test.ts b/test/visual/app-layout-responsive-600.test.ts new file mode 100644 index 0000000000..cd9243cb32 --- /dev/null +++ b/test/visual/app-layout-responsive-600.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-responsive-600'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-sticky-table-header-split-panel.test.ts b/test/visual/app-layout-sticky-table-header-split-panel.test.ts new file mode 100644 index 0000000000..c1ad3016a1 --- /dev/null +++ b/test/visual/app-layout-sticky-table-header-split-panel.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-sticky-table-header-split-panel'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-toolbar.test.ts b/test/visual/app-layout-toolbar.test.ts new file mode 100644 index 0000000000..398d6386f8 --- /dev/null +++ b/test/visual/app-layout-toolbar.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-toolbar'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-z-index.test.ts b/test/visual/app-layout-z-index.test.ts new file mode 100644 index 0000000000..5f69f77b71 --- /dev/null +++ b/test/visual/app-layout-z-index.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-z-index'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout.test.ts b/test/visual/app-layout.test.ts new file mode 100644 index 0000000000..21c3a6ce25 --- /dev/null +++ b/test/visual/app-layout.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout'; + +runTestSuites([suite]); diff --git a/test/visual/area-chart.test.ts b/test/visual/area-chart.test.ts new file mode 100644 index 0000000000..4ffc4e042e --- /dev/null +++ b/test/visual/area-chart.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/area-chart'; + +runTestSuites([suite]); diff --git a/test/visual/attribute-editor.test.ts b/test/visual/attribute-editor.test.ts new file mode 100644 index 0000000000..6a57853125 --- /dev/null +++ b/test/visual/attribute-editor.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/attribute-editor'; + +runTestSuites([suite]); diff --git a/test/visual/autosuggest.test.ts b/test/visual/autosuggest.test.ts new file mode 100644 index 0000000000..d88cad7117 --- /dev/null +++ b/test/visual/autosuggest.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/autosuggest'; + +runTestSuites([suite]); diff --git a/test/visual/badge.test.ts b/test/visual/badge.test.ts new file mode 100644 index 0000000000..1163052443 --- /dev/null +++ b/test/visual/badge.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/badge'; + +runTestSuites([suite]);