From 5454a265bf3d4bf8b3126b0a0ae5976b48b0a7f5 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 21 Apr 2026 15:46:18 +0200 Subject: [PATCH 01/12] chore: Refactor snapshot comparison --- test/compare-screenshots.ts | 54 ++++++++++++++++++++++++++++++++++++ test/scenario-page-object.ts | 53 +++++++++++++++++++++++++++++++++++ test/utils.ts | 27 ------------------ test/visual/index.test.ts | 17 ++++-------- 4 files changed, 113 insertions(+), 38 deletions(-) create mode 100644 test/compare-screenshots.ts create mode 100644 test/scenario-page-object.ts delete mode 100644 test/utils.ts diff --git a/test/compare-screenshots.ts b/test/compare-screenshots.ts new file mode 100644 index 0000000..91821aa --- /dev/null +++ b/test/compare-screenshots.ts @@ -0,0 +1,54 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ScreenshotWithOffset } from "@cloudscape-design/browser-test-tools/page-objects"; +import ScenarioPageObject from "./scenario-page-object"; +import useBrowser from "@cloudscape-design/browser-test-tools/use-browser"; + +// Default window size to ensure 4-columns layout is used. +const windowSize = { width: 1600, height: 800 }; + +export interface ScreenshotTestConfiguration { + width?: number; + height?: number; + skipBrowsers?: Array; + retries?: number; +} + +interface PermutationScreenshot extends ScreenshotWithOffset { + id: string; +} + +export type TestCallback = (page: ScenarioPageObject) => Promise; + +export default function compareScreenshots( + configuration: ScreenshotTestConfiguration, + testFn: TestCallback, +): () => Promise; +export default function compareScreenshots(testFn: TestCallback): () => Promise; +export default function compareScreenshots( + ...args: [ScreenshotTestConfiguration, TestCallback] | [TestCallback] +): () => Promise { + let testFn: TestCallback; + let configuration: ScreenshotTestConfiguration; + if (args.length === 1) { + configuration = {}; + testFn = args[0]; + } else { + configuration = args[0]; + testFn = args[1]; + } + + return useBrowser({ ...windowSize, ...configuration }, async (browser) => { + const page = new ScenarioPageObject(browser); + const result = await testFn(page); + + if (!("image" in result)) { + throw new Error("Screenshot was not captured by the test handler"); + } + + const imageBuffer = result.image; + + expect(imageBuffer).toMatchImageSnapshot(); + }); +} diff --git a/test/scenario-page-object.ts b/test/scenario-page-object.ts new file mode 100644 index 0000000..1ccab25 --- /dev/null +++ b/test/scenario-page-object.ts @@ -0,0 +1,53 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ScreenshotPageObject, ScreenshotWithOffset } from "@cloudscape-design/browser-test-tools/page-objects"; + +const screenshotAreaSelector = ".screenshot-area"; + +export default class ScenarioPageObject extends ScreenshotPageObject { + private baseUrl?: URL; + + constructor( + browser: ConstructorParameters[0], + public readonly forceScrollAndMerge: boolean = false, + ) { + super(browser); + } + + captureScreenshotArea(): Promise { + return this.captureBySelector(screenshotAreaSelector); + } + + async openScenario(scenarioName: string, queryParams?: Record) { + await this.openIntegrationTestPage(scenarioName, queryParams); + await this.waitForVisible(screenshotAreaSelector); + } + + async openIntegrationTestPage(pageName: string, queryParams: Record = {}) { + if (pageName.includes("?")) { + throw new Error( + `Scenario name "${pageName}" may not contain query parameters. To pass extra params use the second argument.`, + ); + } + const params = new URLSearchParams(queryParams); + params.append("motionDisabled", "true"); + params.append("screenshotMode", "true"); + const pagePath = `#/${pageName}?${params.toString()}`; + await this.openPage(pagePath); + // we use hash routing on our test pages. Geckodriver in this case does not handle navigation correctly + // https://github.com/mozilla/geckodriver/issues/1678 + await this.browser.waitUntil(async () => { + const currentUrl = await this.browser.getUrl(); + const pageUrl = new URL(pagePath, this.baseUrl).toString(); + return currentUrl.startsWith(pageUrl); + }); + await this.waitForVisible("main"); + await this.waitForJsTimers(100); + } + + async openPage(pagePath: string) { + const pageUrl = new URL(pagePath, this.baseUrl).toString(); + await this.browser.url(pageUrl); + } +} diff --git a/test/utils.ts b/test/utils.ts deleted file mode 100644 index 4df03d6..0000000 --- a/test/utils.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { BasePageObject } from "@cloudscape-design/browser-test-tools/page-objects"; -import useBrowser from "@cloudscape-design/browser-test-tools/use-browser"; - -// Default window size to ensure 4-columns layout is used. -const windowSize = { width: 1600, height: 800 }; - -export function setupTest

}>( - url: string, - PageClass: new (browser: WebdriverIO.Browser) => P, - test: (page: P) => Promise, -) { - return useBrowser(windowSize, async (browser) => { - await browser.url(`${url}?screenshotMode=true`); - const page = new PageClass(browser); - await page.waitForVisible("main"); - - // Custom initialization. - if (page.init) { - await page.init(); - } - - await test(page); - }); -} diff --git a/test/visual/index.test.ts b/test/visual/index.test.ts index cef09f7..d384c1b 100644 --- a/test/visual/index.test.ts +++ b/test/visual/index.test.ts @@ -1,11 +1,10 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import path from "path"; -import { expect, test } from "vitest"; +import { test } from "vitest"; -import { ScreenshotPageObject } from "@cloudscape-design/browser-test-tools/page-objects"; - -import { setupTest } from "../utils"; +import compareScreenshots from "../compare-screenshots"; +import ScenarioPageObject from "../scenario-page-object"; const pagesMap = import.meta.glob("../../pages/**/*.page.tsx", { as: "raw" }); const pages = Object.keys(pagesMap) @@ -13,12 +12,8 @@ const pages = Object.keys(pagesMap) .map((page) => "/#/" + path.relative("../../pages/", page)); test.each(pages)("matches snapshot for %s", (route) => - setupTest(route, ScreenshotPageObject, async (page) => { - const hasScreenshotArea = await page.isExisting(".screenshot-area"); - - if (hasScreenshotArea) { - const pngString = await page.fullPageScreenshot(); - expect(pngString).toMatchImageSnapshot(); - } + compareScreenshots(async (page: ScenarioPageObject) => { + await page.openScenario(route); + return page.captureScreenshotArea(); })(), ); From 83c29b5eb4113e9b061686cde4a0e6f1dce02af5 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 21 Apr 2026 16:54:37 +0200 Subject: [PATCH 02/12] Update eslint config --- .eslintrc | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.eslintrc b/.eslintrc index 05fd975..49de453 100644 --- a/.eslintrc +++ b/.eslintrc @@ -94,7 +94,13 @@ } ], "simple-import-sort/imports": "warn", - "@vitest/no-focused-tests": "error" + "@vitest/no-focused-tests": "error", + "@vitest/expect-expect": [ + "error", + { + "assertFunctionNames": ["expect", "compareScreenshots"] + } + ] }, "settings": { "react": { From 0035ba43c2186b652122918c4ff5846cd313d413 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 21 Apr 2026 17:11:17 +0200 Subject: [PATCH 03/12] Fix path --- test/visual/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/visual/index.test.ts b/test/visual/index.test.ts index d384c1b..bf892a6 100644 --- a/test/visual/index.test.ts +++ b/test/visual/index.test.ts @@ -9,7 +9,7 @@ import ScenarioPageObject from "../scenario-page-object"; const pagesMap = import.meta.glob("../../pages/**/*.page.tsx", { as: "raw" }); const pages = Object.keys(pagesMap) .map((page) => page.replace(/\.page\.tsx$/, "")) - .map((page) => "/#/" + path.relative("../../pages/", page)); + .map((page) => path.relative("../../pages/", page)); test.each(pages)("matches snapshot for %s", (route) => compareScreenshots(async (page: ScenarioPageObject) => { From 357bcc6852b37a6ffd1f42b1a08e8790937dd29a Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 21 Apr 2026 17:34:16 +0200 Subject: [PATCH 04/12] Fix URL --- test/scenario-page-object.ts | 9 +-------- test/visual/index.test.ts | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/test/scenario-page-object.ts b/test/scenario-page-object.ts index 1ccab25..98a1799 100644 --- a/test/scenario-page-object.ts +++ b/test/scenario-page-object.ts @@ -34,14 +34,7 @@ export default class ScenarioPageObject extends ScreenshotPageObject { params.append("motionDisabled", "true"); params.append("screenshotMode", "true"); const pagePath = `#/${pageName}?${params.toString()}`; - await this.openPage(pagePath); - // we use hash routing on our test pages. Geckodriver in this case does not handle navigation correctly - // https://github.com/mozilla/geckodriver/issues/1678 - await this.browser.waitUntil(async () => { - const currentUrl = await this.browser.getUrl(); - const pageUrl = new URL(pagePath, this.baseUrl).toString(); - return currentUrl.startsWith(pageUrl); - }); + await this.browser.url(pagePath); await this.waitForVisible("main"); await this.waitForJsTimers(100); } diff --git a/test/visual/index.test.ts b/test/visual/index.test.ts index bf892a6..d384c1b 100644 --- a/test/visual/index.test.ts +++ b/test/visual/index.test.ts @@ -9,7 +9,7 @@ import ScenarioPageObject from "../scenario-page-object"; const pagesMap = import.meta.glob("../../pages/**/*.page.tsx", { as: "raw" }); const pages = Object.keys(pagesMap) .map((page) => page.replace(/\.page\.tsx$/, "")) - .map((page) => path.relative("../../pages/", page)); + .map((page) => "/#/" + path.relative("../../pages/", page)); test.each(pages)("matches snapshot for %s", (route) => compareScreenshots(async (page: ScenarioPageObject) => { From d7a8d64c8e8007099c8f668a3faf7a030186771b Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 21 Apr 2026 17:48:42 +0200 Subject: [PATCH 05/12] Add trailing slash --- test/scenario-page-object.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/scenario-page-object.ts b/test/scenario-page-object.ts index 98a1799..f81defa 100644 --- a/test/scenario-page-object.ts +++ b/test/scenario-page-object.ts @@ -33,7 +33,7 @@ export default class ScenarioPageObject extends ScreenshotPageObject { const params = new URLSearchParams(queryParams); params.append("motionDisabled", "true"); params.append("screenshotMode", "true"); - const pagePath = `#/${pageName}?${params.toString()}`; + const pagePath = `/#/${pageName}?${params.toString()}`; await this.browser.url(pagePath); await this.waitForVisible("main"); await this.waitForJsTimers(100); From 0444812d2a341a65024edfaa82dfc113ad64ba7d Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 21 Apr 2026 17:49:03 +0200 Subject: [PATCH 06/12] Add visual test for Avatar --- test/visual-test-setup.ts | 13 ++++++++++++- test/visual/avatar.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 test/visual/avatar.test.ts diff --git a/test/visual-test-setup.ts b/test/visual-test-setup.ts index 161f3b0..701d054 100644 --- a/test/visual-test-setup.ts +++ b/test/visual-test-setup.ts @@ -2,7 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { configureToMatchImageSnapshot } from "jest-image-snapshot"; import { join } from "path"; -import { expect } from "vitest"; +import { expect, test as baseTest } from "vitest"; + +import compareScreenshots from "./compare-screenshots"; const snapshotDir = join(__dirname, "./..", process.env.VISUAL_REGRESSION_SNAPSHOT_DIRECTORY ?? "__image_snapshots__"); @@ -11,3 +13,12 @@ const toMatchImageSnapshot = configureToMatchImageSnapshot({ }); expect.extend({ toMatchImageSnapshot }); + +const test = baseTest.extend<{ compareScreenshots: typeof compareScreenshots }>({ + // eslint-disable-next-line no-empty-pattern + compareScreenshots: async ({}, use) => { + await use(compareScreenshots); + }, +}); + +export { test }; diff --git a/test/visual/avatar.test.ts b/test/visual/avatar.test.ts new file mode 100644 index 0000000..69145e9 --- /dev/null +++ b/test/visual/avatar.test.ts @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { describe } from "vitest"; + +import { test } from "../visual-test-setup"; + +describe("Avatar", () => { + test("custom style", ({ compareScreenshots }) => { + compareScreenshots(async (page) => { + await page.openScenario("avatar/custom-style"); + return page.captureScreenshotArea(); + })(); + }); + + test("permutations", ({ compareScreenshots }) => { + compareScreenshots(async (page) => { + await page.openScenario("avatar/permutations"); + // The page does not actually use our permutations component, + // therefore we capture the entire screenshot area at once. + return page.captureScreenshotArea(); + })(); + }); +}); From 59584cb13591d2155da9af11108135a61d20fcbd Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 21 Apr 2026 18:06:34 +0200 Subject: [PATCH 07/12] Only compare screenshots for pages with screenshot area --- test/compare-screenshots.ts | 17 +++++++++-------- test/scenario-page-object.ts | 1 - test/visual/index.test.ts | 6 +++++- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/test/compare-screenshots.ts b/test/compare-screenshots.ts index 91821aa..b5de921 100644 --- a/test/compare-screenshots.ts +++ b/test/compare-screenshots.ts @@ -2,9 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { ScreenshotWithOffset } from "@cloudscape-design/browser-test-tools/page-objects"; -import ScenarioPageObject from "./scenario-page-object"; import useBrowser from "@cloudscape-design/browser-test-tools/use-browser"; +import ScenarioPageObject from "./scenario-page-object"; + // Default window size to ensure 4-columns layout is used. const windowSize = { width: 1600, height: 800 }; @@ -19,7 +20,9 @@ interface PermutationScreenshot extends ScreenshotWithOffset { id: string; } -export type TestCallback = (page: ScenarioPageObject) => Promise; +export type TestCallback = ( + page: ScenarioPageObject, +) => Promise; export default function compareScreenshots( configuration: ScreenshotTestConfiguration, @@ -43,12 +46,10 @@ export default function compareScreenshots( const page = new ScenarioPageObject(browser); const result = await testFn(page); - if (!("image" in result)) { - throw new Error("Screenshot was not captured by the test handler"); - } - - const imageBuffer = result.image; + if (result && "image" in result) { + const imageBuffer = result.image; - expect(imageBuffer).toMatchImageSnapshot(); + expect(imageBuffer).toMatchImageSnapshot(); + } }); } diff --git a/test/scenario-page-object.ts b/test/scenario-page-object.ts index f81defa..f3e0b29 100644 --- a/test/scenario-page-object.ts +++ b/test/scenario-page-object.ts @@ -21,7 +21,6 @@ export default class ScenarioPageObject extends ScreenshotPageObject { async openScenario(scenarioName: string, queryParams?: Record) { await this.openIntegrationTestPage(scenarioName, queryParams); - await this.waitForVisible(screenshotAreaSelector); } async openIntegrationTestPage(pageName: string, queryParams: Record = {}) { diff --git a/test/visual/index.test.ts b/test/visual/index.test.ts index d384c1b..b296620 100644 --- a/test/visual/index.test.ts +++ b/test/visual/index.test.ts @@ -14,6 +14,10 @@ const pages = Object.keys(pagesMap) test.each(pages)("matches snapshot for %s", (route) => compareScreenshots(async (page: ScenarioPageObject) => { await page.openScenario(route); - return page.captureScreenshotArea(); + const hasScreenshotArea = await page.isExisting(".screenshot-area"); + + if (hasScreenshotArea) { + return page.captureScreenshotArea(); + } })(), ); From 8ed3d2ce7d6cb592a0f1a43d7c74a7e99fe900a6 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 21 Apr 2026 18:21:37 +0200 Subject: [PATCH 08/12] Refactor --- test/visual/avatar.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/test/visual/avatar.test.ts b/test/visual/avatar.test.ts index 69145e9..ce92724 100644 --- a/test/visual/avatar.test.ts +++ b/test/visual/avatar.test.ts @@ -5,19 +5,17 @@ import { describe } from "vitest"; import { test } from "../visual-test-setup"; describe("Avatar", () => { - test("custom style", ({ compareScreenshots }) => { + test("custom style", ({ compareScreenshots }) => compareScreenshots(async (page) => { await page.openScenario("avatar/custom-style"); return page.captureScreenshotArea(); - })(); - }); + })); - test("permutations", ({ compareScreenshots }) => { + test("permutations", ({ compareScreenshots }) => compareScreenshots(async (page) => { await page.openScenario("avatar/permutations"); // The page does not actually use our permutations component, // therefore we capture the entire screenshot area at once. return page.captureScreenshotArea(); - })(); - }); + })); }); From 749071d15acb6d40a457131baca174196749d85a Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 21 Apr 2026 18:40:11 +0200 Subject: [PATCH 09/12] Actually run avatar tests --- test/compare-screenshots.ts | 11 ++--------- test/visual/index.test.ts | 2 +- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/test/compare-screenshots.ts b/test/compare-screenshots.ts index b5de921..32bab6f 100644 --- a/test/compare-screenshots.ts +++ b/test/compare-screenshots.ts @@ -24,14 +24,7 @@ export type TestCallback = ( page: ScenarioPageObject, ) => Promise; -export default function compareScreenshots( - configuration: ScreenshotTestConfiguration, - testFn: TestCallback, -): () => Promise; -export default function compareScreenshots(testFn: TestCallback): () => Promise; -export default function compareScreenshots( - ...args: [ScreenshotTestConfiguration, TestCallback] | [TestCallback] -): () => Promise { +export default function compareScreenshots(...args: [ScreenshotTestConfiguration, TestCallback] | [TestCallback]) { let testFn: TestCallback; let configuration: ScreenshotTestConfiguration; if (args.length === 1) { @@ -51,5 +44,5 @@ export default function compareScreenshots( expect(imageBuffer).toMatchImageSnapshot(); } - }); + })(); } diff --git a/test/visual/index.test.ts b/test/visual/index.test.ts index b296620..2f7ea17 100644 --- a/test/visual/index.test.ts +++ b/test/visual/index.test.ts @@ -19,5 +19,5 @@ test.each(pages)("matches snapshot for %s", (route) => if (hasScreenshotArea) { return page.captureScreenshotArea(); } - })(), + }), ); From 6b48b2a0afa8ed2e3a74eb0d2566c9d618d72cdf Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 21 Apr 2026 18:42:59 +0200 Subject: [PATCH 10/12] Add import --- test/compare-screenshots.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/compare-screenshots.ts b/test/compare-screenshots.ts index 32bab6f..0c5f405 100644 --- a/test/compare-screenshots.ts +++ b/test/compare-screenshots.ts @@ -1,5 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { expect } from "vitest"; import { ScreenshotWithOffset } from "@cloudscape-design/browser-test-tools/page-objects"; import useBrowser from "@cloudscape-design/browser-test-tools/use-browser"; From 3abb309e3d7e70f23db0c3f502c1adcdedfd0456 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 21 Apr 2026 18:58:54 +0200 Subject: [PATCH 11/12] Remove context-based vitest config --- test/visual-test-setup.ts | 13 +------------ test/visual/avatar.test.ts | 5 +++-- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/test/visual-test-setup.ts b/test/visual-test-setup.ts index 701d054..161f3b0 100644 --- a/test/visual-test-setup.ts +++ b/test/visual-test-setup.ts @@ -2,9 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { configureToMatchImageSnapshot } from "jest-image-snapshot"; import { join } from "path"; -import { expect, test as baseTest } from "vitest"; - -import compareScreenshots from "./compare-screenshots"; +import { expect } from "vitest"; const snapshotDir = join(__dirname, "./..", process.env.VISUAL_REGRESSION_SNAPSHOT_DIRECTORY ?? "__image_snapshots__"); @@ -13,12 +11,3 @@ const toMatchImageSnapshot = configureToMatchImageSnapshot({ }); expect.extend({ toMatchImageSnapshot }); - -const test = baseTest.extend<{ compareScreenshots: typeof compareScreenshots }>({ - // eslint-disable-next-line no-empty-pattern - compareScreenshots: async ({}, use) => { - await use(compareScreenshots); - }, -}); - -export { test }; diff --git a/test/visual/avatar.test.ts b/test/visual/avatar.test.ts index ce92724..9e0c668 100644 --- a/test/visual/avatar.test.ts +++ b/test/visual/avatar.test.ts @@ -2,16 +2,17 @@ // SPDX-License-Identifier: Apache-2.0 import { describe } from "vitest"; +import compareScreenshots from "../compare-screenshots"; import { test } from "../visual-test-setup"; describe("Avatar", () => { - test("custom style", ({ compareScreenshots }) => + test("custom style", () => compareScreenshots(async (page) => { await page.openScenario("avatar/custom-style"); return page.captureScreenshotArea(); })); - test("permutations", ({ compareScreenshots }) => + test("permutations", () => compareScreenshots(async (page) => { await page.openScenario("avatar/permutations"); // The page does not actually use our permutations component, From 3df03e023056f65d0613b2147c91292d2a2eb7d1 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 21 Apr 2026 19:03:38 +0200 Subject: [PATCH 12/12] Fix imports --- test/visual/avatar.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/visual/avatar.test.ts b/test/visual/avatar.test.ts index 9e0c668..66610db 100644 --- a/test/visual/avatar.test.ts +++ b/test/visual/avatar.test.ts @@ -1,9 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe } from "vitest"; +import { describe, test } from "vitest"; import compareScreenshots from "../compare-screenshots"; -import { test } from "../visual-test-setup"; describe("Avatar", () => { test("custom style", () =>