diff --git a/.github/workflows/css-order-scan.yml b/.github/workflows/css-order-scan.yml new file mode 100644 index 00000000000..525919c9d69 --- /dev/null +++ b/.github/workflows/css-order-scan.yml @@ -0,0 +1,126 @@ +name: CSS Cascade-Order Scan + +on: + schedule: + # Once per day at 05:17 UTC (off the hour to avoid scheduler contention). + - cron: '17 5 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: css-order-scan-${{ github.ref }} + cancel-in-progress: true + +jobs: + scan: + name: Scan CSS cascade-order dependencies + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Prepare node_modules cache key + run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts compile $(node -p process.arch) > .build/packagelockhash + + - name: Restore node_modules cache + id: cache-node-modules + uses: actions/cache@v5 + with: + path: .build/node_modules_cache + key: "node_modules-css-order-scan-${{ hashFiles('.build/packagelockhash') }}" + + - name: Extract node_modules cache + if: steps.cache-node-modules.outputs.cache-hit == 'true' + run: tar -xzf .build/node_modules_cache/cache.tgz + + - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm ci --ignore-scripts + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install build dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm ci + working-directory: build + + - name: Install rspack dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm ci + working-directory: build/rspack + + - name: Create node_modules archive + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: | + set -e + node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt + mkdir -p .build/node_modules_cache + tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt + + - name: Copy codicons + run: cp node_modules/@vscode/codicons/dist/codicon.ttf src/vs/base/browser/ui/codicons/codicon/codicon.ttf + + - name: Transpile source + run: npm run transpile-client + + - name: Install Playwright Chromium + run: npx playwright install chromium + + - name: Start serve-out + # The scan attaches to an already-running serve (component-explorer-attach.json). + # Start it in the background and wait until rspack reports the loopback URL. + run: | + npm run serve-out-rspack > /tmp/serve-out.log 2>&1 & + echo "SERVE_PID=$!" >> "$GITHUB_ENV" + for i in $(seq 1 120); do + if grep -q "Loopback: http://localhost:5123/" /tmp/serve-out.log; then + echo "serve-out is ready" + exit 0 + fi + sleep 2 + done + echo "::error::serve-out did not become ready in time" + cat /tmp/serve-out.log + exit 1 + + - name: Run CSS order scan + run: node test/componentFixtures/cssOrderScan.mts --fixture-id-regex ".*" + + - name: Stop serve-out + if: always() + run: kill "$SERVE_PID" 2>/dev/null || true + + - name: Upload serve-out log + if: always() + uses: actions/upload-artifact@v7 + with: + name: serve-out-log + path: /tmp/serve-out.log + + - name: Upload report + if: always() + uses: actions/upload-artifact@v7 + with: + name: css-order-report + path: test/componentFixtures/.build/css-order-report/ + + - name: Write job summary + if: always() + run: | + REPORT="test/componentFixtures/.build/css-order-report/report.md" + if [ -f "$REPORT" ]; then + cat "$REPORT" >> "$GITHUB_STEP_SUMMARY" + else + echo "No report was produced." >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/build/rspack/cssSourceMarkerLoader.ts b/build/rspack/cssSourceMarkerLoader.ts new file mode 100644 index 00000000000..d4b577a24f9 --- /dev/null +++ b/build/rspack/cssSourceMarkerLoader.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'path'; + +/** + * Prepends a marker comment with the CSS module's repo-relative source path. + * + * Native CSS (`experiments.css`) concatenates every imported `.css` module into + * a single stylesheet, but it preserves comments. This marker therefore lets + * tooling that reads the bundled stylesheet (e.g. the cascade-order-dependency + * detector and its bisection) map each concatenated "document" back to the + * exact source file by name, instead of relying on the generic copyright + * banner which carries no filename. + * + * `this.rootContext` is the rspack `context` option (the repo root), so no + * `__dirname` handling is needed. + */ +export default function cssSourceMarkerLoader(this: { resourcePath: string; rootContext: string }, source: string): string { + const rel = path.relative(this.rootContext, this.resourcePath).replace(/\\/g, '/'); + return `/* @css-source: ${rel} */\n${source}`; +} diff --git a/build/rspack/rspack.serve-out.config.mts b/build/rspack/rspack.serve-out.config.mts index fe277aac46d..611047861f9 100644 --- a/build/rspack/rspack.serve-out.config.mts +++ b/build/rspack/rspack.serve-out.config.mts @@ -66,6 +66,10 @@ export default { { test: /\.css$/, type: 'css', + // Tag every CSS module with its repo-relative source path (as a + // comment that native CSS preserves) so tooling reading the + // bundled stylesheet can map concatenated documents back to files. + use: [path.join(__dirname, 'cssSourceMarkerLoader.ts')], }, { test: /\.ttf$/, diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index 7d7c22960dd..eaeaf497e89 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -21,13 +21,11 @@ import '../../../browser/parts/auxiliarybar/media/auxiliaryBarPart.css'; // Theme import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; import { IExtensionResourceLoaderService } from '../../../../platform/extensionResourceLoader/common/extensionResourceLoader.js'; -import { Registry } from '../../../../platform/registry/common/platform.js'; -import { getIconsStyleSheet } from '../../../../platform/theme/browser/iconsStyleSheet.js'; -import { ColorScheme, ThemeTypeSelector } from '../../../../platform/theme/common/theme.js'; -import { IColorTheme, IThemeService, IThemingRegistry, Extensions as ThemingExtensions } from '../../../../platform/theme/common/themeService.js'; -import { generateColorThemeCSS } from '../../../services/themes/browser/colorThemeCss.js'; +import { ThemeTypeSelector } from '../../../../platform/theme/common/theme.js'; +import { IColorTheme, IThemeService } from '../../../../platform/theme/common/themeService.js'; import { ColorThemeData } from '../../../services/themes/common/colorThemeData.js'; import { ExtensionData } from '../../../services/themes/common/workbenchThemeService.js'; +import { ensureGlobalStylesInstalled, getStylesheetDocumentFiles, overrideStylesheetOrder, ReverseStylesheetsOption } from './fixtureUtilsCss.js'; // Instantiation import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; @@ -246,9 +244,6 @@ class NullStorageService implements IStorageService { // Themes // ============================================================================ -const themingRegistry = Registry.as(ThemingExtensions.ThemingContribution); -const mockEnvironmentService: IEnvironmentService = Object.create(null); - // Eagerly bundle all built-in theme JSON files so they can be served to // `_loadColorTheme` via the IExtensionResourceLoaderService code path. The // rspack config maps these JSON files to `asset/source`, so they are imported @@ -298,68 +293,6 @@ function createBuiltInTheme(themePath: string, uiTheme: ThemeTypeSelector): Colo export const darkTheme = createBuiltInTheme('/extensions/theme-defaults/themes/dark_modern.json', ThemeTypeSelector.VS_DARK); export const lightTheme = createBuiltInTheme('/extensions/theme-defaults/themes/light_modern.json', ThemeTypeSelector.VS); -let globalStyleSheet: CSSStyleSheet | undefined; -let iconsStyleSheetCache: CSSStyleSheet | undefined; -let darkThemeStyleSheet: CSSStyleSheet | undefined; -let lightThemeStyleSheet: CSSStyleSheet | undefined; - -function getGlobalStyleSheet(): CSSStyleSheet { - if (!globalStyleSheet) { - globalStyleSheet = new CSSStyleSheet(); - const globalRules: string[] = []; - for (const sheet of Array.from(document.styleSheets)) { - try { - for (const rule of Array.from(sheet.cssRules)) { - globalRules.push(rule.cssText); - } - } catch { - // Cross-origin stylesheets can't be read - } - } - globalStyleSheet.replaceSync(globalRules.join('\n')); - } - return globalStyleSheet; -} - -function getIconsStyleSheetCached(): CSSStyleSheet { - if (!iconsStyleSheetCache) { - iconsStyleSheetCache = new CSSStyleSheet(); - const iconsSheet = getIconsStyleSheet(undefined); - iconsStyleSheetCache.replaceSync(iconsSheet.getCSS() as string); - iconsSheet.dispose(); - } - return iconsStyleSheetCache; -} - -function getThemeStyleSheet(theme: ColorThemeData): CSSStyleSheet { - const isDark = theme.type === ColorScheme.DARK; - if (isDark && darkThemeStyleSheet) { - return darkThemeStyleSheet; - } - if (!isDark && lightThemeStyleSheet) { - return lightThemeStyleSheet; - } - - const scopeSelector = '.' + theme.classNames[0]; - const sheet = new CSSStyleSheet(); - const css = generateColorThemeCSS( - theme, - scopeSelector, - themingRegistry.getThemingParticipants(), - mockEnvironmentService - ); - sheet.replaceSync(css.code); - - if (isDark) { - darkThemeStyleSheet = sheet; - } else { - lightThemeStyleSheet = sheet; - } - return sheet; -} - -let globalStylesInstalled = false; - let themesLoadedPromise: Promise | undefined; function ensureThemesLoaded(): Promise { if (!themesLoadedPromise) { @@ -371,26 +304,59 @@ function ensureThemesLoaded(): Promise { return themesLoadedPromise; } -function installGlobalStyles(): void { - if (globalStylesInstalled) { - return; - } - globalStylesInstalled = true; - document.adoptedStyleSheets = [ - ...document.adoptedStyleSheets, - getGlobalStyleSheet(), - getIconsStyleSheetCached(), - getThemeStyleSheet(darkTheme), - getThemeStyleSheet(lightTheme), - ]; -} - export async function setupTheme(container: HTMLElement, theme: ColorThemeData): Promise { await ensureThemesLoaded(); - installGlobalStyles(); + await ensureGlobalStylesInstalled([darkTheme, lightTheme]); container.classList.add('monaco-workbench', getPlatformClass(), 'disable-animations', ...theme.classNames); } +/** + * The recognized fields of the per-render `input` (passed via the CLI `--input` + * flag), parsed once into a typed shape by {@link parseFixtureInput}. + */ +interface FixtureRenderInput { + /** See {@link ReverseStylesheetsOption}; `false` when no reversal is requested. */ + readonly reverseStylesheets: ReverseStylesheetsOption; + /** Whether the render should return its virtual-time trace as `output`. */ + readonly outputTimeTrace: boolean; + /** Whether the render should return the bundled stylesheet files as `output`. */ + readonly outputStylesheetFiles: boolean; +} + +/** + * Parses the untyped render `input` into the recognized {@link FixtureRenderInput} + * fields. Unknown/extra fields are ignored; missing fields default to off. + */ +function parseFixtureInput(input: unknown): FixtureRenderInput { + if (!input || typeof input !== 'object') { + return { reverseStylesheets: false, outputTimeTrace: false, outputStylesheetFiles: false }; + } + const record = input as Record; + return { + reverseStylesheets: parseReverseOption(record.reverseStylesheets), + outputTimeTrace: !!record.outputTimeTrace, + outputStylesheetFiles: !!record.outputStylesheetFiles, + }; +} + +/** + * Validates a `reverseStylesheets` input value: `true` (reverse all stylesheet + * documents), `{ fromIndex, toIndex }` (reverse only that index window, used by + * the order-dependency bisection), or `false` when absent/unrecognized. + */ +function parseReverseOption(value: unknown): ReverseStylesheetsOption { + if (value === true) { + return true; + } + if (value && typeof value === 'object') { + const range = value as Record; + if (typeof range.fromIndex === 'number' && typeof range.toIndex === 'number') { + return { fromIndex: range.fromIndex, toIndex: range.toIndex }; + } + } + return false; +} + function getPlatformClass(): string { const alwaysUseMac = true; if (alwaysUseMac) { @@ -879,6 +845,7 @@ export function defineComponentFixture(options: ComponentFixtureOptions): Themed background: theme === darkTheme ? 'dark' : 'light', render: async (container: HTMLElement, context) => { const disposableStore = new DisposableStore(); + const input = parseFixtureInput(context.input); // Replace Math.random with a seeded PRNG so fixtures render deterministically. disposableStore.add(pushRandomOverwrite(42)); @@ -977,6 +944,13 @@ export function defineComponentFixture(options: ComponentFixtureOptions): Themed async function actualRender() { await setupTheme(container, theme); + // The order-dependency fuzzer reorders the bundled CSS for just + // this render; the override is scoped to the fixture's lifetime + // (disposed at teardown, where it is also leak-checked). + if (input.reverseStylesheets !== false) { + disposableStore.add(overrideStylesheetOrder(input.reverseStylesheets)); + } + let renderTimeApi: IDisposable | undefined; if (virtualTimeEnabled) { renderTimeApi = pushGlobalTimeApi(virtualTimeApi); @@ -1044,13 +1018,19 @@ export function defineComponentFixture(options: ComponentFixtureOptions): Themed afterMicrotaskClosure: cb => nextMacrotask(realTimeApi, cb), }); - const wantsTimeTrace = !!context.input && typeof context.input === 'object' && !!(context.input as Record).outputTimeTrace; - - if (wantsTimeTrace && virtualTimeEnabled && p.history.length > 0) { + if (input.outputTimeTrace && virtualTimeEnabled && p.history.length > 0) { const startTime = p.history[0].time; const history = buildHistoryFromTasks(p.history, startTime); return { output: renderSwimlanes(history) }; } + + // The order-dependency bisection driver asks for the list of bundled + // stylesheet documents so it can name a conflicting document by index + // without itself parsing the bundle. Keeping this knowledge in the + // runtime means the driver only deals in indices and image hashes. + if (input.outputStylesheetFiles) { + return { output: { stylesheetFiles: await getStylesheetDocumentFiles() } }; + } return undefined; }, }); diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.ts new file mode 100644 index 00000000000..bec844d7651 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.ts @@ -0,0 +1,263 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { getIconsStyleSheet } from '../../../../platform/theme/browser/iconsStyleSheet.js'; +import { ColorScheme } from '../../../../platform/theme/common/theme.js'; +import { IThemingRegistry, Extensions as ThemingExtensions } from '../../../../platform/theme/common/themeService.js'; +import { generateColorThemeCSS } from '../../../services/themes/browser/colorThemeCss.js'; +import { ColorThemeData } from '../../../services/themes/common/colorThemeData.js'; + +const themingRegistry = Registry.as(ThemingExtensions.ThemingContribution); +const mockEnvironmentService: IEnvironmentService = Object.create(null); + +let overlaySheet: CSSStyleSheet | undefined; +let installedPromise: Promise | undefined; +let bundlePromise: Promise | undefined; +let bundle: Bundle | undefined; +let activeOverride: object | undefined; +let iconsStyleSheetCache: CSSStyleSheet | undefined; +let darkThemeStyleSheet: CSSStyleSheet | undefined; +let lightThemeStyleSheet: CSSStyleSheet | undefined; + +/** + * Controls how the bundled stylesheet documents are reordered. + * - `false`: keep the original (product) order. + * - `true`: reverse the order of all documents. + * - `{ fromIndex, toIndex }`: reverse only the documents in the half-open + * index range `[fromIndex, toIndex)`. This is what the order-dependency + * bisection uses to narrow down which documents conflict. + */ +export type ReverseStylesheetsOption = boolean | { readonly fromIndex: number; readonly toIndex: number }; + +/** + * Matches the `@css-source: ` marker that `cssSourceMarkerLoader` injects + * before each source file's CSS in the concatenated bundle. These markers are + * the canonical per-source-file delimiters: every CSS module processed by the + * build gets exactly one, and it carries the module's repo-relative path. + */ +const CSS_SOURCE_MARKER_REGEX = /\/\*\s*@css-source:\s*(\S+)\s*\*\//g; + +interface StylesheetDocument { + /** Repo-relative source path from the `@css-source` marker. */ + readonly file: string; + /** The marker plus the file's CSS, up to the next marker. */ + readonly text: string; +} + +/** + * Splits the concatenated bundle into per-source-file documents at the + * `@css-source` markers. Any text before the first marker is returned as the + * `preamble` and kept in place. This single definition of a "document" is used + * for both reversal and the index→file reporting, so the bisection driver never + * needs to understand the bundle format. + */ +function parseStylesheetDocuments(cssText: string): { readonly preamble: string; readonly documents: readonly StylesheetDocument[] } { + const markers = Array.from(cssText.matchAll(CSS_SOURCE_MARKER_REGEX)); + if (markers.length === 0) { + return { preamble: cssText, documents: [] }; + } + const preamble = cssText.slice(0, markers[0].index); + const documents = markers.map((marker, i) => { + const start = marker.index; + const end = i + 1 < markers.length ? markers[i + 1].index : cssText.length; + return { file: marker[1], text: cssText.slice(start, end) }; + }); + return { preamble, documents }; +} + +/** + * Reverses the order of whole documents within `[fromIndex, toIndex)` (or all + * documents when `option === true`), preserving rule order inside each document. + * Because adopted stylesheets are applied after the document's ``/`