Implements css order scanning for component fixtures

This commit is contained in:
Henning Dieterichs
2026-06-15 21:39:15 +02:00
committed by Henning Dieterichs
parent 72b369e108
commit 1edc47c502
9 changed files with 1122 additions and 88 deletions

126
.github/workflows/css-order-scan.yml vendored Normal file
View 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

View 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}`;
}

View File

@@ -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$/,

View File

@@ -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;
},
});

View File

@@ -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;
});
}

View 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"
}
}

View 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;
});

View 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(`| ![baseline](${baseSrc}) | ![reversed](${revSrc}) |`, '');
}
}
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;
});

View 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 };
}