mirror of
https://github.com/apache/superset.git
synced 2026-05-04 23:44:23 +00:00
Compare commits
4 Commits
fix/check-
...
sc-100647-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d5b7b8399 | ||
|
|
8d392d8ebb | ||
|
|
5334b9ae3b | ||
|
|
442b3fb0f7 |
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user