Compare commits

...

4 Commits

Author SHA1 Message Date
sadpandajoe
0d5b7b8399 fix(embedded): remove unused React import and fix prettier formatting in test
- Add embedded-context simulation test: verifies color-scheme:light is injected
  (not dark) when GlobalStyles renders with the default light theme that
  EmbeddedContextProviders uses, preventing OS dark mode from leaking in
- Add theme-switching test: renders light → clears Emotion styles → renders dark,
  confirms color-scheme updates from light to dark dynamically
- Add Playwright experimental suite (playwright/tests/experimental/embedded-dark-mode.spec.ts):
  five tests that emulate prefers-color-scheme:dark via Playwright's colorScheme
  option and assert color-scheme:light on the html element across welcome page,
  dashboard page, embedded route, dark-theme sanity-check, and fresh-login scenarios

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 06:48:17 +00:00
sadpandajoe
8d392d8ebb fix(embedded): remove unused React import and fix prettier formatting in test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 05:51:33 +00:00
sadpandajoe
5334b9ae3b fix(embedded): use valid CSS comment syntax and strengthen color-scheme tests
- Replace // CSS comments with /* */ in GlobalStyles template literal
- Remove @emotion/cache mock so Global actually injects styles into JSDOM
- Add assertions on document.styleSheets cssRules to verify color-scheme:light
  and color-scheme:dark are injected for the respective theme modes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 04:59:25 +00:00
sadpandajoe
442b3fb0f7 fix(embedded): prevent OS dark mode from applying dark borders in light-mode embedded dashboards
When the OS is in dark mode, browsers apply dark native styling (scrollbars,
form control borders, etc.) to iframes unless the page explicitly declares its
color-scheme. Without this declaration, embedded dashboards using Superset's
default light theme still rendered with dark borders/lines on macOS dark mode.

Fix: set `color-scheme: light` (or `dark` for dark themes) on the `html`
element via GlobalStyles, using the existing `isThemeDark` utility to
determine the correct value from the active Ant Design theme tokens.

Closes sc-100647

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 23:40:30 +00:00
3 changed files with 481 additions and 1 deletions

View File

@@ -0,0 +1,187 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, unmountComponentAtNode } from 'react-dom';
import { act } from '@testing-library/react';
import { ThemeProvider } from '@emotion/react';
import { Theme } from './Theme';
import { GlobalStyles } from './GlobalStyles';
import { ThemeAlgorithm } from './types';
import * as themeUtils from './utils/themeUtils';
/**
* Read all CSS text injected into the document by Emotion's Global component.
* Emotion uses insertRule() so cssRules is authoritative; textContent is empty.
*/
function getInjectedCss(): string {
const parts: string[] = [];
for (const sheet of Array.from(document.styleSheets)) {
try {
for (const rule of Array.from(sheet.cssRules)) {
parts.push(rule.cssText);
}
} catch {
// Cross-origin sheets are inaccessible — skip
}
}
return parts.join('\n');
}
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
unmountComponentAtNode(container);
container.remove();
// Remove any style tags injected by Emotion to keep tests isolated
document.querySelectorAll('style[data-emotion]').forEach(el => el.remove());
});
test('GlobalStyles injects color-scheme: light for a light theme', () => {
const lightTheme = Theme.fromConfig({});
act(() => {
render(
<ThemeProvider theme={lightTheme.theme}>
<GlobalStyles />
</ThemeProvider>,
container,
);
});
expect(getInjectedCss()).toMatch(/color-scheme\s*:\s*light/);
});
test('GlobalStyles injects color-scheme: dark for a dark theme', () => {
const darkTheme = Theme.fromConfig({ algorithm: ThemeAlgorithm.DARK });
act(() => {
render(
<ThemeProvider theme={darkTheme.theme}>
<GlobalStyles />
</ThemeProvider>,
container,
);
});
expect(getInjectedCss()).toMatch(/color-scheme\s*:\s*dark/);
});
/**
* Embedded context simulation:
* Embedded dashboards initialize with ThemeMode.DEFAULT (light). Without
* an explicit color-scheme declaration, OS dark mode leaks into the iframe
* and applies dark borders/scrollbars. This test verifies the injected CSS
* declares color-scheme: light (preventing the OS preference from winning)
* and explicitly does NOT declare color-scheme: dark.
*/
test('GlobalStyles prevents OS dark mode from affecting embedded light-mode dashboards', () => {
// EmbeddedContextProviders always initializes with ThemeMode.DEFAULT (light theme)
const embeddedLightTheme = Theme.fromConfig({});
act(() => {
render(
<ThemeProvider theme={embeddedLightTheme.theme}>
<GlobalStyles />
</ThemeProvider>,
container,
);
});
const css = getInjectedCss();
// Must declare light to override any prefers-color-scheme: dark from OS
expect(css).toMatch(/color-scheme\s*:\s*light/);
// Must NOT declare dark — that would reproduce the original bug
expect(css).not.toMatch(/color-scheme\s*:\s*dark/);
});
/**
* Theme switching test:
* Confirms that when the active theme changes from light to dark (e.g., via
* the Switchboard setThemeMode API), the color-scheme declaration updates
* accordingly so native browser UI stays in sync with the app theme.
*/
test('GlobalStyles updates color-scheme dynamically when theme switches light → dark', () => {
const lightTheme = Theme.fromConfig({});
const darkTheme = Theme.fromConfig({ algorithm: ThemeAlgorithm.DARK });
// Initial render: light theme → color-scheme: light
act(() => {
render(
<ThemeProvider theme={lightTheme.theme}>
<GlobalStyles />
</ThemeProvider>,
container,
);
});
expect(getInjectedCss()).toMatch(/color-scheme\s*:\s*light/);
// Clean up Emotion styles before re-rendering with dark theme
document.querySelectorAll('style[data-emotion]').forEach(el => el.remove());
// Re-render with dark theme → color-scheme: dark
act(() => {
render(
<ThemeProvider theme={darkTheme.theme}>
<GlobalStyles />
</ThemeProvider>,
container,
);
});
expect(getInjectedCss()).toMatch(/color-scheme\s*:\s*dark/);
});
test('GlobalStyles calls isThemeDark with the current theme to determine color-scheme', () => {
const isThemeDarkSpy = jest.spyOn(themeUtils, 'isThemeDark');
const lightTheme = Theme.fromConfig({});
act(() => {
render(
<ThemeProvider theme={lightTheme.theme}>
<GlobalStyles />
</ThemeProvider>,
container,
);
});
expect(isThemeDarkSpy).toHaveBeenCalledWith(lightTheme.theme);
expect(isThemeDarkSpy).toHaveReturnedWith(false);
});
test('GlobalStyles detects dark theme via isThemeDark', () => {
const isThemeDarkSpy = jest.spyOn(themeUtils, 'isThemeDark');
const darkTheme = Theme.fromConfig({ algorithm: ThemeAlgorithm.DARK });
act(() => {
render(
<ThemeProvider theme={darkTheme.theme}>
<GlobalStyles />
</ThemeProvider>,
container,
);
});
expect(isThemeDarkSpy).toHaveBeenCalledWith(darkTheme.theme);
expect(isThemeDarkSpy).toHaveReturnedWith(true);
});

View File

@@ -29,13 +29,24 @@ import '@fontsource/ibm-plex-mono/600.css';
/* eslint-enable import/extensions */
import { css, useTheme, Global } from '@emotion/react';
import type { SupersetTheme } from './types';
import { isThemeDark } from './utils/themeUtils';
export const GlobalStyles = () => {
const theme = useTheme();
const theme = useTheme() as SupersetTheme;
const isDark = isThemeDark(theme);
return (
<Global
key={`global-${theme.colorLink}`}
styles={css`
/* Explicitly declare color-scheme so the browser uses matching native
styling (scrollbars, form controls, etc.) rather than inheriting the
OS dark-mode preference. Without this, an embedded iframe renders
dark borders/scrollbars even when Superset's theme is light. */
html {
color-scheme: ${isDark ? 'dark' : 'light'};
}
// SPA
html,
body,

View File

@@ -0,0 +1,282 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Experimental E2E tests for sc-100647:
* "Dark border in embedded dashboards when OS is in Dark Mode"
*
* Bug: When a user's OS is set to dark mode (e.g. macOS), embedded Superset
* dashboards show unexpected dark borders/lines because the browser's UA
* stylesheet applies dark native styling (scrollbars, form controls, etc.)
* to the iframe. The fix sets `color-scheme: light` on the `html` element
* via GlobalStyles so native browser chrome matches the app's light theme.
*
* These tests emulate OS dark mode via Playwright's `colorScheme: 'dark'`
* option, then verify the `html` element's `color-scheme` CSS property is
* explicitly declared so native styling does not leak in.
*/
import { test, expect, Browser } from '@playwright/test';
import { TIMEOUT } from '../../utils/constants';
const ADMIN_USERNAME = process.env.PLAYWRIGHT_ADMIN_USERNAME || 'admin';
const ADMIN_PASSWORD = process.env.PLAYWRIGHT_ADMIN_PASSWORD || 'general';
/**
* Evaluate the resolved color-scheme on the html element.
* Uses getComputedStyle so both inline and stylesheet declarations are captured.
*/
async function getHtmlColorScheme(
page: import('@playwright/test').Page,
): Promise<string> {
return page.evaluate(() =>
getComputedStyle(document.documentElement).colorScheme.trim(),
);
}
/**
* Helper: create a new browser context emulating OS dark mode with saved admin auth.
*/
async function darkModeAdminContext(browser: Browser) {
return browser.newContext({
colorScheme: 'dark',
storageState: 'playwright/.auth/user.json',
});
}
// ─── Tests ────────────────────────────────────────────────────────────────────
test('html element has color-scheme: light on the welcome page when OS is in dark mode', async ({
browser,
}) => {
const context = await darkModeAdminContext(browser);
const page = await context.newPage();
try {
await page.goto('superset/welcome/', { waitUntil: 'networkidle' });
const colorScheme = await getHtmlColorScheme(page);
// Superset's default theme is light. Even with OS dark mode emulated,
// GlobalStyles must declare color-scheme: light so native browser elements
// (scrollbars, form controls, etc.) render in light mode inside the iframe.
expect(colorScheme).toBe('light');
} finally {
await context.close();
}
});
test('html element has color-scheme: light on a dashboard page when OS is in dark mode', async ({
browser,
}) => {
test.setTimeout(60_000);
// Create an admin context emulating OS dark mode
const context = await darkModeAdminContext(browser);
const page = await context.newPage();
// Admin context for API calls (uses saved auth, not dark-mode context)
const adminContext = await browser.newContext({
storageState: 'playwright/.auth/user.json',
});
const adminPage = await adminContext.newPage();
let dashboardId: number | undefined;
try {
// Create a minimal published dashboard via API
const createRes = await adminPage.request.post('api/v1/dashboard/', {
data: {
dashboard_title: `e2e_dark_mode_test_${Date.now()}`,
published: true,
},
});
expect(createRes.ok()).toBe(true);
const body = await createRes.json();
dashboardId = body.id;
expect(dashboardId).toBeTruthy();
// Navigate to the dashboard with OS dark mode emulated
await page.goto(`superset/dashboard/${dashboardId}/`, {
waitUntil: 'networkidle',
timeout: TIMEOUT.PAGE_LOAD,
});
const colorScheme = await getHtmlColorScheme(page);
// color-scheme: light must be present so the embedded iframe's native
// browser UI (borders, scrollbars, inputs) stays in light mode.
expect(colorScheme).toBe('light');
} finally {
// Cleanup
if (dashboardId) {
await adminPage.request
.delete(`api/v1/dashboard/${dashboardId}`)
.catch(() => {});
}
await adminContext.close();
await context.close();
}
});
test('html element has color-scheme: light on the embedded dashboard route when OS is in dark mode', async ({
browser,
}) => {
test.setTimeout(60_000);
// This test exercises the actual embedded route (/embedded/<uuid>/ or
// /dashboard/<id>/embedded/) which is the code path affected by sc-100647.
// Because the embedded route requires a guest token (set up via Switchboard),
// we test the next best thing: the /dashboard/<id>/embedded/ URL which loads
// the same EmbeddedContextProviders → GlobalStyles stack without a guest token
// check, allowing us to verify the color-scheme declaration is present.
const adminContext = await browser.newContext({
storageState: 'playwright/.auth/user.json',
});
const adminPage = await adminContext.newPage();
const darkContext = await browser.newContext({
colorScheme: 'dark',
storageState: 'playwright/.auth/user.json',
});
const page = await darkContext.newPage();
let dashboardId: number | undefined;
try {
const createRes = await adminPage.request.post('api/v1/dashboard/', {
data: {
dashboard_title: `e2e_embedded_dark_mode_${Date.now()}`,
published: true,
},
});
expect(createRes.ok()).toBe(true);
const body = await createRes.json();
dashboardId = body.id;
// The /embedded/ sub-route renders EmbeddedContextProviders which initializes
// ThemeMode.DEFAULT (light). GlobalStyles must inject color-scheme: light.
await page.goto(`dashboard/${dashboardId}/embedded/`, {
waitUntil: 'load',
timeout: TIMEOUT.PAGE_LOAD,
});
// Wait for React to hydrate and Emotion to inject global styles
await page.waitForFunction(
() => {
const sheets = Array.from(document.styleSheets);
return sheets.some(sheet => {
try {
return Array.from(sheet.cssRules).some(rule =>
rule.cssText.includes('color-scheme'),
);
} catch {
return false;
}
});
},
{ timeout: TIMEOUT.PAGE_LOAD },
);
const colorScheme = await getHtmlColorScheme(page);
expect(colorScheme).toBe('light');
} finally {
if (dashboardId) {
await adminPage.request
.delete(`api/v1/dashboard/${dashboardId}`)
.catch(() => {});
}
await adminContext.close();
await darkContext.close();
}
});
test('html element has color-scheme: dark when Superset dark theme is active with OS dark mode', async ({
browser,
}) => {
// Sanity-check the inverse: when the user explicitly switches to dark theme,
// color-scheme should be dark (not stuck on light).
// This confirms dynamic switching works end-to-end.
//
// Note: This test navigates to the welcome page and toggles dark mode via
// localStorage (the ThemeController persists mode there for non-embedded pages).
const context = await browser.newContext({
colorScheme: 'dark',
storageState: 'playwright/.auth/user.json',
});
const page = await context.newPage();
try {
// Pre-set dark mode in localStorage before loading the page
await context.addInitScript(() => {
try {
localStorage.setItem('superset_theme_mode', 'dark');
} catch {
// localStorage may not be available in all contexts
}
});
await page.goto('superset/welcome/', { waitUntil: 'networkidle' });
const colorScheme = await getHtmlColorScheme(page);
// With dark theme active, color-scheme should be dark so native browser
// UI (scrollbars, inputs) renders dark to match the app theme.
expect(colorScheme).toBe('dark');
} finally {
await context.close();
}
});
/**
* Login helper for tests that need a fresh session (not using saved auth).
*/
async function loginAs(
page: import('@playwright/test').Page,
username: string,
password: string,
) {
await page.goto('login/');
await page.fill('input[name="username"]', username);
await page.fill('input[name="password"]', password);
await page.click('[type="submit"]');
await page.waitForURL('**/superset/welcome/**', {
timeout: TIMEOUT.PAGE_LOAD,
});
}
test('color-scheme: light is present after fresh login with OS dark mode emulated', async ({
browser,
}) => {
// Test the full login → welcome flow without pre-saved auth state
// to ensure GlobalStyles applies color-scheme from the very first paint.
const context = await browser.newContext({ colorScheme: 'dark' });
const page = await context.newPage();
try {
await loginAs(page, ADMIN_USERNAME, ADMIN_PASSWORD);
const colorScheme = await getHtmlColorScheme(page);
expect(colorScheme).toBe('light');
} finally {
await context.close();
}
});