From e8790cb0969dd133ea1d536560c9638b4dced2f5 Mon Sep 17 00:00:00 2001 From: rrewll Date: Thu, 5 Nov 2026 21:40:01 +0300 Subject: [PATCH 1/4] test(webview): finish CSS transitions after the visual theme changes applyVisualTheme changes the theme classes after the story mounts. The Button transition-colors class then changes the border color over about 150 ms. expectContrast can read a color between the two themes, so the WelcomeViewProvider contrast check fails at random. Finish each running CSSTransition after the theme classes change. Other animation types stay as they are, because finishing an infinite CSS animation throws an error. Fixes #2326 Co-Authored-By: Claude Opus 5.5 --- webview-ui/playwright/themes.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/webview-ui/playwright/themes.ts b/webview-ui/playwright/themes.ts index 65ec5ee384..cff590bcdd 100644 --- a/webview-ui/playwright/themes.ts +++ b/webview-ui/playwright/themes.ts @@ -24,5 +24,11 @@ export async function applyVisualTheme(page: Page, theme: VisualTheme) { document.body.className = bodyClass document.body.removeAttribute("style") document.body.dataset.vscodeThemeId = themeId + + for (const animation of document.getAnimations()) { + if (animation instanceof CSSTransition) { + animation.finish() + } + } }, theme) } From 8df5de27a61a03bcd4c9399fe0a87d9b8ed635b9 Mon Sep 17 00:00:00 2001 From: rrewll Date: Thu, 5 Nov 2026 23:56:53 +0300 Subject: [PATCH 2/4] test: add e2e test for finishing transitions --- .../ui/__tests__/VisualTheme.visual.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 webview-ui/src/components/ui/__tests__/VisualTheme.visual.tsx diff --git a/webview-ui/src/components/ui/__tests__/VisualTheme.visual.tsx b/webview-ui/src/components/ui/__tests__/VisualTheme.visual.tsx new file mode 100644 index 0000000000..f884a7b0a5 --- /dev/null +++ b/webview-ui/src/components/ui/__tests__/VisualTheme.visual.tsx @@ -0,0 +1,25 @@ +import { expect, test } from "../../../../playwright/coverage-fixture" +import { mountedStory } from "../../../../playwright/mounted-story" +import { applyVisualTheme, visualThemes } from "../../../../playwright/themes" + +test("finishes theme color transitions before contrast checks read them", async ({ mount, page }) => { + const component = mountedStory(await mount("welcome")) + const action = component.getByRole("button", { name: /provider/i }).first() + await expect(action).toBeVisible() + + // The gallery starts in the dark theme, so this switch starts the Button border-color transition. + await applyVisualTheme( + page, + visualThemes.find((theme) => theme.name === "high-contrast-light")!, + ) + + const runningTransitions = await page.evaluate( + () => + document + .getAnimations() + .filter((animation) => animation instanceof CSSTransition && animation.playState === "running").length, + ) + expect(runningTransitions).toBe(0) + // Read once instead of toHaveCSS, which retries until the transition ends on its own. + expect(await action.evaluate((element) => getComputedStyle(element).borderTopColor)).toBe("rgb(15, 74, 133)") +}) From 6ecbc3d71897f8bcf024688ca6f728d6b23e81da Mon Sep 17 00:00:00 2001 From: rrewll Date: Fri, 6 Nov 2026 07:39:08 +0300 Subject: [PATCH 3/4] fix: setup the long transition --- .../src/components/ui/__tests__/VisualTheme.visual.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/webview-ui/src/components/ui/__tests__/VisualTheme.visual.tsx b/webview-ui/src/components/ui/__tests__/VisualTheme.visual.tsx index f884a7b0a5..807460d212 100644 --- a/webview-ui/src/components/ui/__tests__/VisualTheme.visual.tsx +++ b/webview-ui/src/components/ui/__tests__/VisualTheme.visual.tsx @@ -7,6 +7,14 @@ test("finishes theme color transitions before contrast checks read them", async const action = component.getByRole("button", { name: /provider/i }).first() await expect(action).toBeVisible() + // Stretch the about 150 ms transition so it cannot end on its own before the assertions read it. + // Reading the computed style flushes it, so the theme switch below starts the long transition. + const transitionDuration = await action.evaluate((element) => { + element.style.transitionDuration = "10s" + return getComputedStyle(element).transitionDuration + }) + expect(transitionDuration).toBe("10s") + // The gallery starts in the dark theme, so this switch starts the Button border-color transition. await applyVisualTheme( page, From 1e2339fd2a946d278f0cacbbf0f6274081897dc7 Mon Sep 17 00:00:00 2001 From: rrewll Date: Fri, 6 Nov 2026 10:23:48 +0300 Subject: [PATCH 4/4] test(webview): keep infinite CSS animations running across theme switches applyVisualTheme finishes only CSS transitions, because finish() throws on an infinite animation. Add an infinite animation to the visual theme test and check that it is still running after the theme switch. Co-Authored-By: Claude Opus 5.5 --- .../ui/__tests__/VisualTheme.visual.tsx | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/webview-ui/src/components/ui/__tests__/VisualTheme.visual.tsx b/webview-ui/src/components/ui/__tests__/VisualTheme.visual.tsx index 807460d212..594f68155a 100644 --- a/webview-ui/src/components/ui/__tests__/VisualTheme.visual.tsx +++ b/webview-ui/src/components/ui/__tests__/VisualTheme.visual.tsx @@ -15,19 +15,41 @@ test("finishes theme color transitions before contrast checks read them", async }) expect(transitionDuration).toBe("10s") + // finish() throws on an infinite animation, so the theme switch must leave CSS animations running. + await page.evaluate(() => { + const style = document.createElement("style") + style.id = "visual-theme-infinite-animation" + style.textContent = + "@keyframes visual-theme-pulse { to { opacity: 0.5; } }" + + "#visual-theme-infinite-animation-probe { animation: visual-theme-pulse 1s infinite; }" + const probe = document.createElement("div") + probe.id = "visual-theme-infinite-animation-probe" + document.head.append(style) + document.body.append(probe) + }) + // The gallery starts in the dark theme, so this switch starts the Button border-color transition. await applyVisualTheme( page, visualThemes.find((theme) => theme.name === "high-contrast-light")!, ) - const runningTransitions = await page.evaluate( - () => - document - .getAnimations() - .filter((animation) => animation instanceof CSSTransition && animation.playState === "running").length, - ) + const { runningTransitions, runningAnimations } = await page.evaluate(() => { + const running = document.getAnimations().filter((animation) => animation.playState === "running") + return { + runningTransitions: running.filter((animation) => animation instanceof CSSTransition).length, + runningAnimations: running.filter( + (animation) => animation instanceof CSSAnimation && animation.animationName === "visual-theme-pulse", + ).length, + } + }) expect(runningTransitions).toBe(0) + expect(runningAnimations).toBe(1) // Read once instead of toHaveCSS, which retries until the transition ends on its own. expect(await action.evaluate((element) => getComputedStyle(element).borderTopColor)).toBe("rgb(15, 74, 133)") + + await page.evaluate(() => { + document.getElementById("visual-theme-infinite-animation")?.remove() + document.getElementById("visual-theme-infinite-animation-probe")?.remove() + }) })