mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-10 16:58:55 -05:00
Implements css order scanning for component fixtures
This commit is contained in:
committed by
Henning Dieterichs
parent
72b369e108
commit
1edc47c502
126
.github/workflows/css-order-scan.yml
vendored
Normal file
126
.github/workflows/css-order-scan.yml
vendored
Normal file
@@ -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
|
||||
23
build/rspack/cssSourceMarkerLoader.ts
Normal file
23
build/rspack/cssSourceMarkerLoader.ts
Normal file
@@ -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}`;
|
||||
}
|
||||
@@ -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$/,
|
||||
|
||||
@@ -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<IThemingRegistry>(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<void> | undefined;
|
||||
function ensureThemesLoaded(): Promise<void> {
|
||||
if (!themesLoadedPromise) {
|
||||
@@ -371,26 +304,59 @@ function ensureThemesLoaded(): Promise<void> {
|
||||
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<void> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>).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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<IThemingRegistry>(ThemingExtensions.ThemingContribution);
|
||||
const mockEnvironmentService: IEnvironmentService = Object.create(null);
|
||||
|
||||
let overlaySheet: CSSStyleSheet | undefined;
|
||||
let installedPromise: Promise<void> | undefined;
|
||||
let bundlePromise: Promise<Bundle> | 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: <path>` 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 `<link>`/`<style>`
|
||||
* sheets in the cascade, this reversed copy wins source-order ties and surfaces
|
||||
* order-dependent conflicts.
|
||||
*/
|
||||
function reverseDocuments(cssText: string, option: Exclude<ReverseStylesheetsOption, false>): string {
|
||||
const { preamble, documents } = parseStylesheetDocuments(cssText);
|
||||
if (documents.length < 2) {
|
||||
return cssText;
|
||||
}
|
||||
const from = option === true ? 0 : Math.max(0, Math.min(documents.length, option.fromIndex));
|
||||
const to = option === true ? documents.length : Math.max(from, Math.min(documents.length, option.toIndex));
|
||||
const reordered = [
|
||||
...documents.slice(0, from),
|
||||
...documents.slice(from, to).reverse(),
|
||||
...documents.slice(to),
|
||||
];
|
||||
return preamble + reordered.map(doc => doc.text).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the raw source text of a stylesheet (which, unlike `cssRules`, retains
|
||||
* comments such as the `@css-source` markers). Falls back to serialized rules
|
||||
* when the source cannot be read.
|
||||
*/
|
||||
async function readStyleSheetSource(sheet: CSSStyleSheet): Promise<string> {
|
||||
const owner = sheet.ownerNode;
|
||||
if (owner instanceof HTMLStyleElement) {
|
||||
return owner.textContent ?? '';
|
||||
}
|
||||
if (sheet.href) {
|
||||
try {
|
||||
return await (await fetch(sheet.href)).text();
|
||||
} catch {
|
||||
// Fall back to serialized rules below.
|
||||
}
|
||||
}
|
||||
try {
|
||||
return Array.from(sheet.cssRules, rule => rule.cssText).join('\n');
|
||||
} catch {
|
||||
// Cross-origin stylesheets can't be read
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
interface Bundle {
|
||||
/**
|
||||
* The document's own stylesheets that make up the concatenated product CSS
|
||||
* (those carrying `@css-source` markers), snapshotted once. These are what the
|
||||
* reversal overlay reproduces and what it disables while active; harness
|
||||
* stylesheets (reset, explorer UI, HMR) are deliberately excluded.
|
||||
*/
|
||||
readonly sheets: readonly CSSStyleSheet[];
|
||||
/** Their concatenated raw source, retaining the `@css-source` markers. */
|
||||
readonly rawSources: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the bundled product stylesheets once and caches both the snapshot and
|
||||
* their concatenated raw source. Only sheets carrying `@css-source` markers are
|
||||
* included, so harness-owned stylesheets are never reordered or disabled. Unlike
|
||||
* serialized `cssRules`, the raw source retains the markers that delimit
|
||||
* per-source-file documents, so it can be reordered by {@link reverseDocuments}.
|
||||
*/
|
||||
function readBundle(): Promise<Bundle> {
|
||||
return bundlePromise ??= (async () => {
|
||||
const sheets = Array.from(document.styleSheets);
|
||||
const sources = await Promise.all(sheets.map(readStyleSheetSource));
|
||||
const bundled = sheets
|
||||
.map((sheet, i) => ({ sheet, source: sources[i] }))
|
||||
.filter(entry => /\/\*\s*@css-source:/.test(entry.source));
|
||||
return bundle = {
|
||||
sheets: bundled.map(entry => entry.sheet),
|
||||
rawSources: bundled.map(entry => entry.source).join('\n'),
|
||||
};
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* The repo-relative source files of the bundled stylesheet documents, in product
|
||||
* order. Index `i` is the document that a `reverseStylesheets` window refers to,
|
||||
* so the bisection driver uses this to name a conflicting document — keeping all
|
||||
* knowledge of the bundle format inside the runtime.
|
||||
*/
|
||||
export async function getStylesheetDocumentFiles(): Promise<string[]> {
|
||||
const { rawSources } = await readBundle();
|
||||
return parseStylesheetDocuments(rawSources).documents.map(doc => doc.file);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the runtime-generated global styles once: the icon-font sheet, a
|
||||
* scoped sheet per theme, and an (initially empty) reversal overlay, all as
|
||||
* `document.adoptedStyleSheets`. The overlay keeps a stable identity and a fixed
|
||||
* position so that, once filled by {@link overrideStylesheetOrder}, its reordered
|
||||
* copy reliably wins source-order cascade ties. Idempotent and effectively
|
||||
* write-once: after the returned promise resolves the global sheets never change
|
||||
* except through a scoped {@link overrideStylesheetOrder} override.
|
||||
*/
|
||||
export function ensureGlobalStylesInstalled(themes: readonly ColorThemeData[]): Promise<void> {
|
||||
return installedPromise ??= (async () => {
|
||||
await readBundle();
|
||||
const overlay = overlaySheet = new CSSStyleSheet();
|
||||
document.adoptedStyleSheets = [
|
||||
...document.adoptedStyleSheets,
|
||||
overlay,
|
||||
getIconsStyleSheetCached(),
|
||||
...themes.map(getThemeStyleSheet),
|
||||
];
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporarily reorders the bundled stylesheet documents for the lifetime of the
|
||||
* returned disposable, so the order-dependency fuzzer can flip cascade ties that
|
||||
* are decided purely by source order. While active, the bundled product sheets
|
||||
* are disabled and replaced wholesale by the reordered copy in the overlay, so
|
||||
* the originals cannot contribute competing declarations.
|
||||
*
|
||||
* Exclusive and stack-like: throws if another override is already active (two
|
||||
* fixtures must not render against the shared page concurrently), and the
|
||||
* returned disposable throws if disposed out of order.
|
||||
* {@link ensureGlobalStylesInstalled} must have resolved first.
|
||||
*/
|
||||
export function overrideStylesheetOrder(option: Exclude<ReverseStylesheetsOption, false>): IDisposable {
|
||||
if (!overlaySheet || !bundle) {
|
||||
throw new Error('ensureGlobalStylesInstalled() must resolve before overriding the stylesheet order.');
|
||||
}
|
||||
if (activeOverride) {
|
||||
throw new Error('A stylesheet-order override is already active; fixtures must render sequentially.');
|
||||
}
|
||||
const overlay = overlaySheet;
|
||||
const sheets = bundle.sheets;
|
||||
const token = activeOverride = {};
|
||||
|
||||
// Disable the bundled product sheets and reproduce them, reordered, in the
|
||||
// overlay. The overlay alone then defines the product cascade, so the disabled
|
||||
// originals can't win (or tie) against it.
|
||||
const wasDisabled = sheets.map(sheet => sheet.disabled);
|
||||
for (const sheet of sheets) {
|
||||
sheet.disabled = true;
|
||||
}
|
||||
overlay.replaceSync(reverseDocuments(bundle.rawSources, option));
|
||||
|
||||
return toDisposable(() => {
|
||||
if (activeOverride !== token) {
|
||||
throw new Error('Stylesheet-order override disposed out of order.');
|
||||
}
|
||||
overlay.replaceSync('');
|
||||
sheets.forEach((sheet, i) => { sheet.disabled = wasDisabled[i]; });
|
||||
activeOverride = undefined;
|
||||
});
|
||||
}
|
||||
13
test/componentFixtures/component-explorer-attach.json
Normal file
13
test/componentFixtures/component-explorer-attach.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "../../node_modules/@vscode/component-explorer-cli/dist/component-explorer-config.schema.json",
|
||||
"screenshotDir": ".screenshots",
|
||||
"sessions": [
|
||||
{
|
||||
"name": "current"
|
||||
}
|
||||
],
|
||||
"server": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:5123"
|
||||
}
|
||||
}
|
||||
132
test/componentFixtures/cssOrderBisect.mts
Normal file
132
test/componentFixtures/cssOrderBisect.mts
Normal file
@@ -0,0 +1,132 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* CSS cascade-order dependency bisection driver.
|
||||
*
|
||||
* Localizes which two bundled CSS source files form a cascade-order dependency
|
||||
* in a component fixture, by binary-searching over stylesheet-document reversal
|
||||
* windows. It drives `@vscode/component-explorer`'s `render` command against an
|
||||
* already-running `serve` (see `component-explorer-attach.json`) and uses the
|
||||
* rendered image hash as an oracle: reversing the document order of the bundled
|
||||
* CSS flips cascade ties that are decided purely by source order, so a hash
|
||||
* change under reversal means the fixture's appearance depends on CSS order.
|
||||
*
|
||||
* It performs two independent ~log2(N) searches, reusing the running serve for
|
||||
* every probe (no rebuilds):
|
||||
* - `a` = the largest `fromIndex` for which reversing `[fromIndex, N)` still
|
||||
* changes the image — the later document of the conflicting pair.
|
||||
* - `b` = the smallest `toIndex` (minus one) for which reversing `[0, toIndex)`
|
||||
* changes the image — the earlier document of the conflicting pair.
|
||||
*
|
||||
* The driver itself has no knowledge of the bundle format: the fixture runtime
|
||||
* reports both the document count and the index→source-file mapping in the
|
||||
* render manifest's `output` (requested via `--input '{"outputStylesheetFiles":true}'`),
|
||||
* so this script only ever deals in document indices and image hashes.
|
||||
*
|
||||
* Prerequisites: start the serve, e.g. `npm run serve-out-rspack`, then run:
|
||||
* node test/componentFixtures/cssOrderBisect.mts \
|
||||
* --fixture-id-regex "aiCustomizationManagementEditor/LocalHarness/Dark"
|
||||
*
|
||||
* Notes:
|
||||
* - The search assumes a single dominant conflicting pair; with several, it
|
||||
* localizes the outermost/innermost conflicting documents.
|
||||
*/
|
||||
|
||||
import { mkdir, rm } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { bisectConflict, readServeUrl, Renderer } from './cssOrderShared.mts';
|
||||
|
||||
interface Args {
|
||||
readonly config: string;
|
||||
readonly fixtureRegex: string;
|
||||
readonly keep: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: readonly string[]): Args {
|
||||
const get = (name: string): string | undefined => {
|
||||
const i = argv.indexOf(`--${name}`);
|
||||
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
|
||||
};
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const fixtureRegex = get('fixture-id-regex');
|
||||
if (!fixtureRegex) {
|
||||
throw new Error('Missing required --fixture-id-regex <pattern>');
|
||||
}
|
||||
return {
|
||||
config: resolve(get('config') ?? join(here, 'component-explorer-attach.json')),
|
||||
fixtureRegex,
|
||||
keep: argv.includes('--keep'),
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const configDir = dirname(args.config);
|
||||
const serveUrl = await readServeUrl(args.config);
|
||||
|
||||
process.stdout.write(`Serve: ${serveUrl ?? '(from config)'}\n`);
|
||||
process.stdout.write(`Fixture: ${args.fixtureRegex}\n`);
|
||||
|
||||
const probeRoot = join(configDir, '.build', 'css-bisect', 'bisect');
|
||||
await rm(probeRoot, { recursive: true, force: true });
|
||||
await mkdir(probeRoot, { recursive: true });
|
||||
const renderer = new Renderer(args.config, probeRoot);
|
||||
|
||||
// The unreversed render also serves as the baseline image and reports the
|
||||
// document list, so the driver never has to parse the bundle itself.
|
||||
const baselineEntries = (await renderer.render(args.fixtureRegex, { outputStylesheetFiles: true })).entries;
|
||||
if (baselineEntries.length === 0) {
|
||||
throw new Error(`No fixtures matched ${args.fixtureRegex}`);
|
||||
}
|
||||
const baselineEntry = baselineEntries[0];
|
||||
const files = baselineEntry.output?.stylesheetFiles;
|
||||
if (!files || files.length === 0) {
|
||||
throw new Error('Fixture did not report `output.stylesheetFiles`; rebuild the serve so it includes the runtime change.');
|
||||
}
|
||||
const n = files.length;
|
||||
const baseline = baselineEntry.imageHash;
|
||||
if (!baseline) {
|
||||
throw new Error('Baseline render produced no image.');
|
||||
}
|
||||
process.stdout.write(`Bundle: ${n} documents (reported by the fixture runtime)\n\n`);
|
||||
|
||||
// Each probe spawns a fresh `render` (browser navigation), so they are slow
|
||||
// and otherwise silent; log every probe as it completes to show progress.
|
||||
const reversed = await renderer.hash(args.fixtureRegex, { fromIndex: 0, toIndex: n });
|
||||
process.stdout.write(`baseline (no reversal): ${baseline.slice(0, 12)}\n`);
|
||||
process.stdout.write(`full reversal: ${reversed.slice(0, 12)}\n\n`);
|
||||
|
||||
if (baseline === reversed) {
|
||||
process.stdout.write('No order-dependency detected: full reversal does not change the rendering.\n');
|
||||
if (!args.keep) {
|
||||
await rm(probeRoot, { recursive: true, force: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const probesPerPhase = Math.ceil(Math.log2(n)) + 1;
|
||||
process.stdout.write(`Localizing the conflicting documents (~${2 * probesPerPhase} probes)...\n`);
|
||||
let probeCount = 0;
|
||||
const { later, earlier } = await bisectConflict(renderer, baselineEntry.fixtureId, n, baseline, ({ fromIndex, toIndex, differs, ms }) => {
|
||||
const verdict = differs ? 'DIFFERS' : 'same as baseline';
|
||||
process.stdout.write(` probe #${++probeCount}: reverse[${fromIndex}, ${toIndex}) -> ${verdict} (${ms}ms)\n`);
|
||||
});
|
||||
|
||||
process.stdout.write('\nOrder-dependency localized between two documents:\n');
|
||||
process.stdout.write(` doc ${earlier}: ${files[earlier] ?? '(out of range)'}\n`);
|
||||
process.stdout.write(` doc ${later}: ${files[later] ?? '(out of range)'}\n`);
|
||||
process.stdout.write('\nProduct order renders these with the later document winning the cascade tie.\n');
|
||||
|
||||
if (!args.keep) {
|
||||
await rm(probeRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
process.stderr.write(`${err instanceof Error ? err.stack : String(err)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
291
test/componentFixtures/cssOrderScan.mts
Normal file
291
test/componentFixtures/cssOrderScan.mts
Normal file
@@ -0,0 +1,291 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* CSS cascade-order dependency scanner.
|
||||
*
|
||||
* Renders every component fixture twice — once normally and once with the
|
||||
* bundled CSS documents reversed — and flags every fixture whose screenshot
|
||||
* changes under reversal: its appearance depends on the source-concatenation
|
||||
* order of two CSS files (a cascade tie broken only by order). For each flagged
|
||||
* fixture it then binary-searches to localize the two conflicting source files
|
||||
* (see `cssOrderShared.mts`), and writes a self-contained Markdown report with
|
||||
* the baseline/reversed images so the fragile pairs can be reviewed.
|
||||
*
|
||||
* Intended for a scheduled CI job; runs against an already-running `serve`
|
||||
* (see `component-explorer-attach.json`). Prerequisites: start the serve, e.g.
|
||||
* `npm run serve-out-rspack`, then run:
|
||||
* node test/componentFixtures/cssOrderScan.mts [--out <dir>] [--fixture-id-regex <rx>]
|
||||
*/
|
||||
|
||||
import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { bisectConflict, type FixtureEntry, readServeUrl, Renderer } from './cssOrderShared.mts';
|
||||
|
||||
interface Args {
|
||||
readonly config: string;
|
||||
readonly fixtureRegex: string;
|
||||
readonly out: string;
|
||||
/** When set, images are linked as `<imageBaseUrl>/<hash>` instead of bundled relative paths. */
|
||||
readonly imageBaseUrl: string | undefined;
|
||||
readonly keep: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: readonly string[]): Args {
|
||||
const get = (name: string): string | undefined => {
|
||||
const i = argv.indexOf(`--${name}`);
|
||||
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
|
||||
};
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const config = resolve(get('config') ?? join(here, 'component-explorer-attach.json'));
|
||||
return {
|
||||
config,
|
||||
fixtureRegex: get('fixture-id-regex') ?? '.*',
|
||||
out: resolve(get('out') ?? join(dirname(config), '.build', 'css-order-report')),
|
||||
imageBaseUrl: get('image-base-url'),
|
||||
keep: argv.includes('--keep'),
|
||||
};
|
||||
}
|
||||
|
||||
/** A localized cascade-order conflict for one fixture. */
|
||||
interface Problem {
|
||||
readonly fixtureId: string;
|
||||
readonly background: 'light' | 'dark' | undefined;
|
||||
readonly labels: readonly string[];
|
||||
readonly docCount: number;
|
||||
readonly baselineHash: string;
|
||||
readonly reversedHash: string;
|
||||
/** Document index + source file of the later document (wins the tie in product order). */
|
||||
readonly laterIndex: number;
|
||||
readonly laterFile: string;
|
||||
/** Document index + source file of the earlier document (loses the tie in product order). */
|
||||
readonly earlierIndex: number;
|
||||
readonly earlierFile: string;
|
||||
/** Relative path of the baseline image within the report, if captured. */
|
||||
readonly baselineImage: string | undefined;
|
||||
/** Relative path of the reversed image within the report, if captured. */
|
||||
readonly reversedImage: string | undefined;
|
||||
}
|
||||
|
||||
const GITHUB_SERVER = process.env.GITHUB_SERVER_URL ?? 'https://github.com';
|
||||
const GITHUB_REPO = process.env.GITHUB_REPOSITORY;
|
||||
const GITHUB_SHA = process.env.GITHUB_SHA;
|
||||
|
||||
/** Maps a bundled `out/…` document path back to its `src/…` source. */
|
||||
function toSourcePath(path: string): string {
|
||||
return path.startsWith('out/') ? `src/${path.slice('out/'.length)}` : path;
|
||||
}
|
||||
|
||||
/** A Markdown link to the source file on GitHub (when running in Actions), else inline code. */
|
||||
function fileLink(path: string): string {
|
||||
const src = toSourcePath(path);
|
||||
if (GITHUB_REPO && GITHUB_SHA) {
|
||||
return `[\`${src}\`](${GITHUB_SERVER}/${GITHUB_REPO}/blob/${GITHUB_SHA}/${src})`;
|
||||
}
|
||||
return `\`${src}\``;
|
||||
}
|
||||
|
||||
/** Just the file name of a bundled document path, for compact tables. */
|
||||
function fileBaseName(path: string): string {
|
||||
const src = toSourcePath(path);
|
||||
return src.slice(src.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
function slugify(fixtureId: string): string {
|
||||
return fixtureId.replace(/[^a-zA-Z0-9._-]+/g, '_');
|
||||
}
|
||||
|
||||
/** Markdown image source for a captured render, honoring `--image-base-url`. */
|
||||
function imageSource(args: Args, hash: string, relativePath: string | undefined): string | undefined {
|
||||
if (args.imageBaseUrl) {
|
||||
return `${args.imageBaseUrl.replace(/\/$/, '')}/${hash}`;
|
||||
}
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
function renderMarkdown(args: Args, problems: readonly Problem[], totals: { scanned: number; errored: number }): string {
|
||||
const lines: string[] = [];
|
||||
const clean = totals.scanned - problems.length - totals.errored;
|
||||
|
||||
lines.push('# CSS Cascade-Order Dependency Report', '');
|
||||
lines.push(`- Generated: ${new Date().toISOString()}`);
|
||||
if (GITHUB_REPO && GITHUB_SHA) {
|
||||
lines.push(`- Commit: [\`${GITHUB_SHA.slice(0, 8)}\`](${GITHUB_SERVER}/${GITHUB_REPO}/commit/${GITHUB_SHA})`);
|
||||
}
|
||||
lines.push(`- Fixtures scanned: **${totals.scanned}**`);
|
||||
lines.push(`- Order-dependent: **${problems.length}**`);
|
||||
lines.push(`- Clean: **${clean}**`);
|
||||
lines.push(`- Errored / no image: **${totals.errored}**`, '');
|
||||
|
||||
lines.push('> **How this works.** Each fixture is rendered normally and again with the');
|
||||
lines.push('> bundled CSS documents reversed. A changed screenshot means the appearance');
|
||||
lines.push('> depends on the source-concatenation order of two CSS files — a cascade tie');
|
||||
lines.push('> broken only by order. A binary search then localizes the two source files.');
|
||||
lines.push('> The later document (by product source order) currently wins the tie.');
|
||||
lines.push('>');
|
||||
lines.push('> **Caveats.** Detects the dominant conflicting pair per fixture; only flips');
|
||||
lines.push('> ties of equal specificity and importance; assumes no `@layer`.', '');
|
||||
|
||||
if (problems.length === 0) {
|
||||
lines.push('No order-dependent fixtures detected. :tada:', '');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Aggregate by CSS source file so the most frequently fragile files surface.
|
||||
const byFile = new Map<string, { fixtures: Set<string>; partners: Set<string> }>();
|
||||
const note = (file: string, partner: string, fixtureId: string) => {
|
||||
let entry = byFile.get(file);
|
||||
if (!entry) {
|
||||
entry = { fixtures: new Set(), partners: new Set() };
|
||||
byFile.set(file, entry);
|
||||
}
|
||||
entry.fixtures.add(fixtureId);
|
||||
entry.partners.add(partner);
|
||||
};
|
||||
for (const p of problems) {
|
||||
note(p.laterFile, p.earlierFile, p.fixtureId);
|
||||
note(p.earlierFile, p.laterFile, p.fixtureId);
|
||||
}
|
||||
|
||||
lines.push('## Problematic CSS files', '');
|
||||
lines.push('| CSS source file | Fixtures affected | Conflicts with |');
|
||||
lines.push('| --- | ---: | --- |');
|
||||
const sortedFiles = [...byFile.entries()].sort((a, b) => b[1].fixtures.size - a[1].fixtures.size);
|
||||
for (const [file, entry] of sortedFiles) {
|
||||
const partners = [...entry.partners].map(fileBaseName).sort().join(', ');
|
||||
lines.push(`| ${fileLink(file)} | ${entry.fixtures.size} | ${partners} |`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Affected fixtures', '');
|
||||
lines.push('| Fixture | Theme | Earlier doc (loses) | Later doc (wins) |');
|
||||
lines.push('| --- | --- | --- | --- |');
|
||||
for (const p of problems) {
|
||||
lines.push(`| \`${p.fixtureId}\` | ${p.background ?? '—'} | ${fileLink(p.earlierFile)} (#${p.earlierIndex}) | ${fileLink(p.laterFile)} (#${p.laterIndex}) |`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Details', '');
|
||||
for (const p of problems) {
|
||||
lines.push(`### \`${p.fixtureId}\``, '');
|
||||
lines.push(`- Theme: ${p.background ?? '—'}`);
|
||||
if (p.labels.length > 0) {
|
||||
lines.push(`- Labels: ${p.labels.map(l => `\`${l}\``).join(', ')}`);
|
||||
}
|
||||
lines.push(`- Later document (wins): ${fileLink(p.laterFile)} — document #${p.laterIndex}`);
|
||||
lines.push(`- Earlier document (loses): ${fileLink(p.earlierFile)} — document #${p.earlierIndex}`);
|
||||
lines.push(`- Image hash: \`${p.baselineHash.slice(0, 12)}\` (baseline) → \`${p.reversedHash.slice(0, 12)}\` (reversed)`, '');
|
||||
|
||||
const baseSrc = imageSource(args, p.baselineHash, p.baselineImage);
|
||||
const revSrc = imageSource(args, p.reversedHash, p.reversedImage);
|
||||
if (baseSrc && revSrc) {
|
||||
lines.push('| Baseline (product order) | Reversed order |');
|
||||
lines.push('| --- | --- |');
|
||||
lines.push(`|  |  |`, '');
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const configDir = dirname(args.config);
|
||||
const serveUrl = await readServeUrl(args.config);
|
||||
|
||||
process.stdout.write(`Serve: ${serveUrl ?? '(from config)'}\n`);
|
||||
process.stdout.write(`Fixtures: ${args.fixtureRegex}\n`);
|
||||
process.stdout.write(`Output: ${args.out}\n\n`);
|
||||
|
||||
const probeRoot = join(configDir, '.build', 'css-order-scan');
|
||||
await rm(probeRoot, { recursive: true, force: true });
|
||||
await mkdir(probeRoot, { recursive: true });
|
||||
await rm(args.out, { recursive: true, force: true });
|
||||
const assetsDir = join(args.out, 'assets');
|
||||
await mkdir(assetsDir, { recursive: true });
|
||||
const renderer = new Renderer(args.config, probeRoot);
|
||||
|
||||
// One render of every fixture in product order: this is the per-fixture
|
||||
// baseline image and also reports each fixture's document list, so the scan
|
||||
// never has to parse the bundle itself.
|
||||
process.stdout.write('Rendering all fixtures (baseline)...\n');
|
||||
const baseline = await renderer.render(args.fixtureRegex, { outputStylesheetFiles: true }, { useHashPaths: false });
|
||||
// One render of every fixture with all documents reversed: fixtures whose
|
||||
// image changes here are order-dependent.
|
||||
process.stdout.write('Rendering all fixtures (reversed)...\n');
|
||||
const reversed = await renderer.render(args.fixtureRegex, { reverseStylesheets: true }, { useHashPaths: false });
|
||||
|
||||
const reversedById = new Map<string, FixtureEntry>(reversed.entries.map(e => [e.fixtureId, e]));
|
||||
const candidates = baseline.entries.filter(base => {
|
||||
const rev = reversedById.get(base.fixtureId);
|
||||
return !base.hasError && base.imageHash && rev?.imageHash && base.imageHash !== rev.imageHash;
|
||||
});
|
||||
const errored = baseline.entries.filter(e => e.hasError || !e.imageHash).length;
|
||||
process.stdout.write(`\nScanned ${baseline.entries.length} fixtures: ${candidates.length} order-dependent, ${errored} errored/no-image.\n`);
|
||||
|
||||
const problems: Problem[] = [];
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
const base = candidates[i];
|
||||
const rev = reversedById.get(base.fixtureId)!;
|
||||
const files = base.output?.stylesheetFiles;
|
||||
if (!files || files.length === 0) {
|
||||
process.stderr.write(`::warning::${base.fixtureId} did not report stylesheetFiles; skipping.\n`);
|
||||
continue;
|
||||
}
|
||||
const n = files.length;
|
||||
process.stdout.write(`\n[${i + 1}/${candidates.length}] Localizing ${base.fixtureId} (${n} documents)...\n`);
|
||||
let probeCount = 0;
|
||||
const { later, earlier } = await bisectConflict(renderer, base.fixtureId, n, base.imageHash!, ({ fromIndex, toIndex, differs, ms }) => {
|
||||
const verdict = differs ? 'DIFFERS' : 'same';
|
||||
process.stdout.write(` probe #${++probeCount}: reverse[${fromIndex}, ${toIndex}) -> ${verdict} (${ms}ms)\n`);
|
||||
});
|
||||
|
||||
// Copy the baseline and reversed images into the report so it is
|
||||
// self-contained (links resolve when the artifact is downloaded).
|
||||
const slug = slugify(base.fixtureId);
|
||||
let baselineImage: string | undefined;
|
||||
let reversedImage: string | undefined;
|
||||
if (base.imagePath) {
|
||||
baselineImage = `assets/${slug}.baseline.png`;
|
||||
await copyFile(join(baseline.targetDir, base.imagePath), join(args.out, baselineImage));
|
||||
}
|
||||
if (rev.imagePath) {
|
||||
reversedImage = `assets/${slug}.reversed.png`;
|
||||
await copyFile(join(reversed.targetDir, rev.imagePath), join(args.out, reversedImage));
|
||||
}
|
||||
|
||||
problems.push({
|
||||
fixtureId: base.fixtureId,
|
||||
background: base.background,
|
||||
labels: base.labels ?? [],
|
||||
docCount: n,
|
||||
baselineHash: base.imageHash!,
|
||||
reversedHash: rev.imageHash!,
|
||||
laterIndex: later,
|
||||
laterFile: files[later] ?? '(out of range)',
|
||||
earlierIndex: earlier,
|
||||
earlierFile: files[earlier] ?? '(out of range)',
|
||||
baselineImage,
|
||||
reversedImage,
|
||||
});
|
||||
process.stdout.write(` → #${earlier} ${fileBaseName(files[earlier] ?? '?')} loses to #${later} ${fileBaseName(files[later] ?? '?')}\n`);
|
||||
}
|
||||
|
||||
const markdown = renderMarkdown(args, problems, { scanned: baseline.entries.length, errored });
|
||||
await writeFile(join(args.out, 'report.md'), `${markdown}\n`, 'utf8');
|
||||
await writeFile(join(args.out, 'report.json'), `${JSON.stringify({ generatedAt: new Date().toISOString(), commit: GITHUB_SHA, scanned: baseline.entries.length, errored, problems }, undefined, '\t')}\n`, 'utf8');
|
||||
|
||||
if (!args.keep) {
|
||||
await rm(probeRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
process.stdout.write(`\nReport written to ${join(args.out, 'report.md')} (${problems.length} order-dependent fixtures).\n`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
process.stderr.write(`${err instanceof Error ? err.stack : String(err)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
202
test/componentFixtures/cssOrderShared.mts
Normal file
202
test/componentFixtures/cssOrderShared.mts
Normal file
@@ -0,0 +1,202 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Shared primitives for the CSS cascade-order dependency tools.
|
||||
*
|
||||
* Both `cssOrderBisect.mts` (localize one fixture's conflicting pair) and
|
||||
* `cssOrderScan.mts` (scan every fixture and produce a report) drive
|
||||
* `@vscode/component-explorer`'s `render` command against an already-running
|
||||
* `serve` and use the rendered image hash as an oracle: reversing the document
|
||||
* order of the bundled CSS flips cascade ties that are decided purely by source
|
||||
* order, so a hash change under reversal means the appearance depends on CSS
|
||||
* order. None of this code understands the bundle format — the fixture runtime
|
||||
* reports the document count and the index→source-file mapping in the render
|
||||
* manifest's `output` (requested via `--input '{"outputStylesheetFiles":true}'`).
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Absolute path to the component-explorer CLI entry (its `bin`). */
|
||||
export const cliEntry = resolve(dirname(fileURLToPath(import.meta.url)), '../../node_modules/@vscode/component-explorer-cli/dist/index.js');
|
||||
|
||||
/** A single fixture's entry in a render manifest (only the fields we use). */
|
||||
export interface FixtureEntry {
|
||||
readonly fixtureId: string;
|
||||
readonly imageHash?: string;
|
||||
/** Image path relative to the render target directory (e.g. `images/<hash>.png`). */
|
||||
readonly imagePath?: string;
|
||||
readonly background?: 'light' | 'dark';
|
||||
readonly labels?: readonly string[];
|
||||
readonly hasError?: boolean;
|
||||
/** Arbitrary data returned by the fixture render function. */
|
||||
readonly output?: { readonly stylesheetFiles?: readonly string[] };
|
||||
}
|
||||
|
||||
/** The result of a single `render` invocation. */
|
||||
export interface RenderResult {
|
||||
readonly entries: readonly FixtureEntry[];
|
||||
/** Absolute path to the render target directory, for resolving `imagePath`. */
|
||||
readonly targetDir: string;
|
||||
}
|
||||
|
||||
/** Options accepted by a `reverseStylesheets` input value. */
|
||||
export interface ReverseWindow {
|
||||
readonly fromIndex: number;
|
||||
readonly toIndex: number;
|
||||
}
|
||||
|
||||
/** Reads the serve URL from the component-explorer config (top-level or first session), for logging. */
|
||||
export async function readServeUrl(configPath: string): Promise<string | undefined> {
|
||||
const config = JSON.parse(await readFile(configPath, 'utf8'));
|
||||
const url = config.server?.url ?? config.sessions?.[0]?.server?.url;
|
||||
return typeof url === 'string' ? url.replace(/\/$/, '') : undefined;
|
||||
}
|
||||
|
||||
/** Escapes a string so it can be used as a literal inside a `--fixture-id-regex`. */
|
||||
export function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** A regex that matches exactly the given fixture id. */
|
||||
export function exactFixtureRegex(fixtureId: string): string {
|
||||
return `^${escapeRegex(fixtureId)}$`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the component-explorer `render` command against the configured serve.
|
||||
* Each call spawns a fresh `render` (browser navigation), writing into its own
|
||||
* numbered sub-directory of `probeRoot` so manifests and images never collide.
|
||||
*/
|
||||
export class Renderer {
|
||||
private count = 0;
|
||||
private readonly configPath: string;
|
||||
private readonly configDir: string;
|
||||
private readonly probeRoot: string;
|
||||
|
||||
constructor(configPath: string, probeRoot: string) {
|
||||
this.configPath = configPath;
|
||||
this.configDir = dirname(configPath);
|
||||
this.probeRoot = probeRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders every fixture matching `fixtureRegex` with the given `--input` and
|
||||
* returns all manifest entries plus the target directory. Content-hash image
|
||||
* paths (`useHashPaths`) deduplicate identical images and are the default;
|
||||
* pass `useHashPaths: false` to get readable `<fixtureId>.png` paths when the
|
||||
* images themselves are needed (e.g. for a report).
|
||||
*/
|
||||
async render(fixtureRegex: string, input: object, options?: { readonly useHashPaths?: boolean }): Promise<RenderResult> {
|
||||
const useHashPaths = options?.useHashPaths ?? true;
|
||||
const target = join(this.probeRoot, `r${this.count++}`);
|
||||
const args = [
|
||||
cliEntry, 'render',
|
||||
'-p', this.configPath,
|
||||
'--fixture-id-regex', fixtureRegex,
|
||||
'--target', target,
|
||||
'--input', JSON.stringify(input),
|
||||
];
|
||||
if (useHashPaths) {
|
||||
args.push('--useHashPaths');
|
||||
}
|
||||
await execFileAsync(process.execPath, args, { cwd: this.configDir, maxBuffer: 64 * 1024 * 1024 });
|
||||
|
||||
const targetDir = isAbsolute(target) ? target : join(this.configDir, target);
|
||||
const manifest = JSON.parse(await readFile(join(targetDir, 'manifest.json'), 'utf8'));
|
||||
const entries: FixtureEntry[] = manifest.fixtures ?? [];
|
||||
return { entries, targetDir };
|
||||
}
|
||||
|
||||
/** Renders a single fixture with the given reversal window and returns its image hash. */
|
||||
async hash(fixtureRegex: string, window: ReverseWindow): Promise<string> {
|
||||
const { entries } = await this.render(fixtureRegex, { reverseStylesheets: window });
|
||||
const hash = entries[0]?.imageHash;
|
||||
if (!hash) {
|
||||
throw new Error(`Render produced no image hash for ${fixtureRegex}`);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders a reversal window `[fromIndex, toIndex)` and returns the image hash. */
|
||||
export type HashFn = (fromIndex: number, toIndex: number) => Promise<string>;
|
||||
|
||||
/**
|
||||
* Finds the largest `fromIndex` in `[0, n]` for which `hash(fromIndex, n)`
|
||||
* still differs from `baseline` — the later document of the conflicting pair.
|
||||
* `hash(0, n)` must differ and `hash(n, n)` (an empty window) must equal `baseline`.
|
||||
*/
|
||||
export async function findLastConflictStart(hash: HashFn, n: number, baseline: string): Promise<number> {
|
||||
let lo = 0; // hash(lo, n) differs
|
||||
let hi = n; // hash(hi, n) == baseline (empty window)
|
||||
while (hi - lo > 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (await hash(mid, n) !== baseline) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the smallest `toIndex` in `[0, n]` for which `hash(0, toIndex)` differs
|
||||
* from `baseline`. `hash(0, 0)` must equal `baseline` and `hash(0, n)` must
|
||||
* differ. Returns that boundary `toIndex` (the earlier document is `toIndex - 1`).
|
||||
*/
|
||||
export async function findFirstConflictEnd(hash: HashFn, n: number, baseline: string): Promise<number> {
|
||||
let lo = 0; // hash(0, lo) == baseline
|
||||
let hi = n; // hash(0, hi) differs
|
||||
while (hi - lo > 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (await hash(0, mid) !== baseline) {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
}
|
||||
}
|
||||
return hi;
|
||||
}
|
||||
|
||||
/** The two documents of a localized cascade-order conflict (indices into the document list). */
|
||||
export interface ConflictPair {
|
||||
/** The later document (higher index); wins the cascade tie in product order. */
|
||||
readonly later: number;
|
||||
/** The earlier document (lower index); loses the cascade tie in product order. */
|
||||
readonly earlier: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Localizes the dominant conflicting document pair for a single fixture by
|
||||
* binary-searching reversal windows. `baseline` is the fixture's un-reversed
|
||||
* image hash; reversing the whole document range `[0, n)` must already be known
|
||||
* to change it. `onProbe` is an optional progress callback.
|
||||
*/
|
||||
export async function bisectConflict(
|
||||
renderer: Renderer,
|
||||
fixtureId: string,
|
||||
n: number,
|
||||
baseline: string,
|
||||
onProbe?: (info: { fromIndex: number; toIndex: number; differs: boolean; ms: number }) => void,
|
||||
): Promise<ConflictPair> {
|
||||
const rx = exactFixtureRegex(fixtureId);
|
||||
const hash: HashFn = async (fromIndex, toIndex) => {
|
||||
const started = Date.now();
|
||||
const h = await renderer.hash(rx, { fromIndex, toIndex });
|
||||
onProbe?.({ fromIndex, toIndex, differs: h !== baseline, ms: Date.now() - started });
|
||||
return h;
|
||||
};
|
||||
const later = await findLastConflictStart(hash, n, baseline);
|
||||
const earlier = (await findFirstConflictEnd(hash, n, baseline)) - 1;
|
||||
return { later, earlier };
|
||||
}
|
||||
Reference in New Issue
Block a user