Files
qwen-code/packages/web-shell/client/build-artifact.test.ts
T
ytahdnand钉萁 df5470e430 feat(web-shell): Add workspace file browser and previews (#13878)
* feat(web-shell): Add workspace file browser and previews

* fix(web-shell): Restore source preview CI fixtures and notices

* fix(web-shell): Address file pane review blockers

* fix(web-shell): Resolve review regressions and strengthen file checks

---------

Co-authored-by: 钉萁 <[email protected]>
2026-10-13 09:38:47 +00:00

675 lines
26 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { readFileSync, readdirSync } from 'node:fs';
import { resolve } from 'node:path';
import postcss, { type Rule } from 'postcss';
import { FILES_MESSAGES_EN } from './components/artifacts/files-messages';
import { LIVE_MESSAGES_EN } from './live/messages';
import { COLLAB_MESSAGES_EN } from './components/workspace-agents/messages';
const DIST_DIR = resolve(__dirname, '../dist');
const DIST_PATH = resolve(DIST_DIR, 'index.js');
const TRANSCRIPT_DIST_PATH = resolve(DIST_DIR, 'transcript.js');
const SERVICE_WORKER_DIST_PATH = resolve(DIST_DIR, 'sw.js');
function readBundle(): string {
return readFileSync(DIST_PATH, 'utf8');
}
function readTranscriptBundle(): string {
return readFileSync(TRANSCRIPT_DIST_PATH, 'utf8');
}
function readPackageJavascript(): string {
return readdirSync(DIST_DIR)
.filter((fileName) => fileName.endsWith('.js'))
.map((fileName) => readFileSync(resolve(DIST_DIR, fileName), 'utf8'))
.join('\n');
}
function readInjectedCss(bundle = readBundle()): string {
const match = bundle.match(/^const __qwenWebShellCss=("(?:[^"\\]|\\.)*");/);
if (!match?.[1]) throw new Error('Injected component CSS not found');
return JSON.parse(match[1]) as string;
}
function enclosingLayer(rule: Rule): string | undefined {
let parent = rule.parent;
while (parent) {
if (parent.type === 'atrule' && parent.name.toLowerCase() === 'layer') {
return parent.params;
}
parent = parent.parent;
}
return undefined;
}
describe('build artifact — package boundary', () => {
it.each(['.', 'assets'])(
'ships the spreadsheet worker in a lazy chunk in dist/%s',
(directory) => {
const chunkDir = resolve(DIST_DIR, directory);
const files = readdirSync(chunkDir).filter((name) =>
name.endsWith('.js'),
);
const previewName = files.find((name) =>
/^SpreadsheetPreview-.*\.js$/.test(name),
);
expect(previewName).toBeDefined();
const preview = readFileSync(resolve(chunkDir, previewName!), 'utf8');
expect(preview).toContain('data-web-shell-excel-preview');
expect(preview).toContain('new Worker(');
expect(preview).toContain('createObjectURL');
const otherChunks = files
.filter((name) => name !== previewName)
.map((name) => readFileSync(resolve(chunkDir, name), 'utf8'))
.join('\n');
expect(otherChunks.includes(`import("./${previewName}")`)).toBe(true);
expect(
/(?:from|import)\s*["']\.\/SpreadsheetPreview-[^"']+\.js["']/.test(
otherChunks,
),
).toBe(false);
expect(otherChunks.includes('data-web-shell-excel-preview')).toBe(false);
const workerCode = preview.match(/["']([A-Za-z0-9+/=]{1000,})["']/)?.[1];
expect(workerCode).toBeDefined();
const decodedWorker = Buffer.from(workerCode!, 'base64').toString();
// Parser code must stay inside the worker, even if the UI remains lazy.
for (const marker of [
'Worksheet index is outside the preview range.',
'Cannot merge already merged cells',
]) {
expect(
decodedWorker.includes(marker),
`Worker contains ${marker}`,
).toBe(true);
expect(
preview.includes(marker),
`Parser leaked into preview: ${marker}`,
).toBe(false);
expect(
otherChunks.includes(marker),
`Parser leaked outside worker: ${marker}`,
).toBe(false);
}
expect(otherChunks.includes(workerCode!)).toBe(false);
// Embedded hosts must not resolve external ExcelJS/SSF packages themselves.
expect(
/(?:from|import)\s*["'](?:exceljs|ssf)["']/.test(
`${preview}\n${otherChunks}\n${decodedWorker}`,
),
).toBe(false);
},
);
it('does not depend on @qwen-code/webui', () => {
const bundle = readPackageJavascript();
expect(bundle).not.toContain('@qwen-code/webui');
});
it('keeps standalone service-worker registration out of embedded bundles', () => {
expect(readPackageJavascript()).not.toContain(
'service worker registration failed',
);
});
it('owns the DaemonSessionProvider source code', () => {
const bundle = readPackageJavascript();
expect(bundle).toContain(
'useDaemonSessionNotices must be used within DaemonSessionProvider',
);
});
it('externalizes react and react-dom', () => {
const bundle = readPackageJavascript();
expect(bundle).toContain('from "react"');
expect(bundle).toContain('from "react/jsx-runtime"');
expect(bundle).not.toContain('react/jsx-dev-runtime');
expect(bundle).not.toContain('jsxDEV');
expect(bundle).not.toContain('fileName:');
});
it('externalizes @qwen-code/sdk subpaths', () => {
const bundle = readPackageJavascript();
// Should not contain raw SDK implementation
expect(bundle).not.toMatch(/DaemonSessionClient\s*\{/);
});
it('bundles Pierre highlighter dependencies instead of resolving the host Shiki major', () => {
const notices = readFileSync(resolve(DIST_DIR, '../NOTICES.txt'), 'utf8');
for (const name of ['diffs', 'trees', 'theming', 'theme']) {
expect(notices).toContain(`@pierre/${name}@`);
}
const treeAttribution = notices
.split('@pierre/trees@')[1]!
.split(
'============================================================',
)[0]!;
// This is the bundled tree's NOTICE credit, not a separate dependency entry.
expect(treeAttribution).toContain('Original license for');
expect(treeAttribution).toContain('headless-tree/core');
expect(notices).toContain('shiki@4.');
expect(readPackageJavascript()).not.toMatch(
/(?:from\s*|import\s*\()\s*["']@pierre\//,
);
const previews = readdirSync(DIST_DIR).filter((name) =>
/^PierreFilePreview-.*\.js$/.test(name),
);
expect(previews).toHaveLength(1);
const preview = readFileSync(resolve(DIST_DIR, previews[0]!), 'utf8');
expect(preview).not.toMatch(/from\s+["']shiki["']/);
expect(preview).toContain('createJavaScriptRegexEngine');
const hostHighlighter = readdirSync(DIST_DIR).find((name) =>
/^codeHighlighter-.*\.js$/.test(name),
);
expect(hostHighlighter).toBeDefined();
expect(readFileSync(resolve(DIST_DIR, hostHighlighter!), 'utf8')).toContain(
'from "shiki"',
);
});
it('scopes every component CSS rule to a WebShell root', () => {
const unscoped: string[] = [];
postcss.parse(readInjectedCss()).walkRules((rule) => {
let parent = rule.parent;
while (parent) {
if (
parent.type === 'atrule' &&
parent.name.toLowerCase().endsWith('keyframes')
) {
return;
}
parent = parent.parent;
}
if (
!rule.selector.includes('[data-web-shell-root]') &&
!rule.selector.includes('[data-web-shell-portal-root]')
) {
unscoped.push(rule.selector);
}
});
expect(unscoped).toEqual([]);
});
it('applies Tailwind theme variables to WebShell roots', () => {
const themeRules: string[] = [];
postcss.parse(readInjectedCss()).walkRules((rule) => {
if (
rule.nodes.some(
(node) => node.type === 'decl' && node.prop === '--spacing',
)
) {
themeRules.push(rule.selector);
}
});
expect(themeRules).toContain(
':is([data-web-shell-root]:where([data-web-shell-shadcn]), [data-web-shell-portal-root]:where([data-web-shell-shadcn]))',
);
expect(themeRules).not.toEqual(
expect.arrayContaining([
expect.stringContaining(':root'),
expect.stringContaining(':host'),
]),
);
});
it('keeps scrollbar styles available inside component and portal roots', () => {
let scrollbarRootRule: Rule | undefined;
postcss.parse(readInjectedCss()).walkRules((rule) => {
if (
rule.nodes.some(
(node) =>
node.type === 'decl' &&
node.prop === 'scrollbar-color' &&
node.value === 'var(--scrollbar-thumb) var(--scrollbar-track)',
)
) {
scrollbarRootRule = rule;
}
});
expect(scrollbarRootRule?.selector).toContain(
':is([data-web-shell-root]:where([data-web-shell-shadcn]),[data-web-shell-portal-root]:where([data-web-shell-shadcn]))',
);
});
it('keeps component resets and utilities above plain host selectors', () => {
const root = postcss.parse(readInjectedCss());
const rules: Rule[] = [];
root.walkRules((rule) => rules.push(rule));
const headingIndex = rules.findIndex(
(rule) =>
rule.selector.includes(':where(h1,h2,h3,h4,h5,h6)') &&
rule.nodes.some(
(node) =>
node.type === 'decl' &&
node.prop === 'font' &&
node.value === 'inherit',
),
);
const formControlIndex = rules.findIndex(
(rule) =>
rule.selector.includes(
':where(button,input,optgroup,select,textarea)',
) &&
rule.nodes.some(
(node) =>
node.type === 'decl' &&
node.prop === 'font' &&
node.value === 'inherit',
),
);
const utilityIndex = rules.findIndex((rule) =>
rule.selector.includes('.px-2\\.5'),
);
const cssModuleIndex = rules.findIndex((rule) =>
rule.nodes.some(
(node) => node.type === 'decl' && node.prop === '--chat-content-width',
),
);
expect(headingIndex).toBeGreaterThanOrEqual(0);
expect(formControlIndex).toBeGreaterThanOrEqual(0);
expect(utilityIndex).toBeGreaterThan(headingIndex);
expect(utilityIndex).toBeGreaterThan(formControlIndex);
expect(cssModuleIndex).toBeGreaterThan(utilityIndex);
const headingRule = rules[headingIndex]!;
const formControlRule = rules[formControlIndex]!;
const utilityRule = rules[utilityIndex]!;
const cssModuleRule = rules[cssModuleIndex]!;
expect(enclosingLayer(headingRule)).toBeUndefined();
expect(enclosingLayer(formControlRule)).toBeUndefined();
expect(enclosingLayer(utilityRule)).toBeUndefined();
expect(headingRule.selector).toContain(
':is([data-web-shell-root]:where([data-web-shell-shadcn]),[data-web-shell-portal-root]:where([data-web-shell-shadcn]))',
);
expect(cssModuleRule.selector).toContain(
':where([data-web-shell-root][data-web-shell-shadcn]',
);
expect(cssModuleRule.selector).not.toContain(':is([data-web-shell-root]');
const conflictingLayers: string[] = [];
root.walkAtRules('layer', (atRule) => {
if (
['theme', 'base', 'components', 'utilities'].includes(atRule.params)
) {
conflictingLayers.push(atRule.params);
}
});
expect(conflictingLayers).toEqual([]);
});
it('removes the transcript width cap from fullscreen panels', () => {
let fullscreenRule: Rule | undefined;
postcss.parse(readInjectedCss()).walkRules((rule) => {
if (
rule.selector.includes('panelFullscreen') &&
rule.nodes.some(
(node) =>
node.type === 'decl' &&
node.prop === '--chat-content-width' &&
node.value === '100%',
)
) {
fullscreenRule = rule;
}
});
expect(fullscreenRule).toBeDefined();
expect(fullscreenRule?.selector).toMatch(
/\._panelFullscreen_[A-Za-z0-9_]+$/,
);
expect(fullscreenRule?.selector).not.toContain('panelDrawer');
expect(fullscreenRule?.selector).not.toContain(':not(');
});
it('prefixes global CSS registrations and animations', () => {
const unscoped: string[] = [];
postcss.parse(readInjectedCss()).walkAtRules((atRule) => {
const name = atRule.name.toLowerCase();
if (
name.endsWith('keyframes') &&
!atRule.params.startsWith('qwen-web-shell-')
) {
unscoped.push(`@${atRule.name} ${atRule.params}`);
}
if (
name === 'property' &&
!atRule.params.startsWith('--qwen-web-shell-')
) {
unscoped.push(`@property ${atRule.params}`);
}
});
expect(unscoped).toEqual([]);
});
it('ships the ::selection highlight for message content in the lib bundle (#8214)', () => {
// The defensive ::selection rule must reach embedded deployments -
// i.e. it must be in the component-scoped CSS injected into dist/index.js,
// not only the standalone app's standalone.css. Asserting the rule is
// present and scoped under the WebShell root pins the lib-bundle fix.
let matched: Rule | undefined;
postcss.parse(readInjectedCss()).walkRules((rule) => {
// Match the effect (selectable rows get a ::selection rule scoped to
// the WebShell root), not the exact notation - a maintainer changing
// `background` to `background-color` (the CSS Pseudo-Elements-4 name)
// should not break this pin while the e2e one stays green.
if (
rule.selector.includes('[data-user-selectable]') &&
rule.selector.includes('::selection')
) {
matched = rule;
}
});
expect(
matched,
'::selection rule for [data-user-selectable] missing from lib bundle',
).toBeDefined();
expect(matched?.selector).toContain('[data-web-shell-root]');
expect(
matched?.nodes.some(
(n) =>
n.type === 'decl' &&
(n.prop === 'background' || n.prop === 'background-color'),
),
).toBe(true);
});
it('ships self-contained KaTeX styles and fonts for embedded transcripts', () => {
const css = readInjectedCss();
const root = postcss.parse(css);
let mathmlRule: Rule | undefined;
let hasInlineFont = false;
root.walkRules((rule) => {
if (rule.selector.includes('.katex-mathml')) {
mathmlRule = rule;
}
});
root.walkAtRules('font-face', (atRule) => {
if (
atRule.nodes?.some(
(node) =>
node.type === 'decl' &&
node.prop === 'src' &&
node.value.includes('data:font/woff2;base64,'),
)
) {
hasInlineFont = true;
}
});
expect(mathmlRule?.selector).toContain('[data-web-shell-root]');
expect(hasInlineFont).toBe(true);
});
});
describe('build artifact — Live Voice capture worklet', () => {
// audioWorklet.addModule() is subject to the Web Shell CSP (`script-src
// 'self'`, no `data:` or `blob:`). At ~2 KB the worklet is under Vite's
// inline limit, and an inlined module fails to load — silently, because the
// client then falls back to the deprecated main-thread capture node. No
// unit test can see that; only the emitted files can.
const ASSETS_DIR = resolve(DIST_DIR, 'assets');
it('ships the worklet as a same-origin file in the app build', () => {
const emitted = readdirSync(ASSETS_DIR).filter((fileName) =>
/^capture-worklet-.+\.js$/.test(fileName),
);
expect(emitted).toHaveLength(1);
const source = readFileSync(resolve(ASSETS_DIR, emitted[0]!), 'utf8');
expect(source).toContain("registerProcessor('qwen-live-capture'");
// Loaded as a classic module by the audio thread: nothing to resolve.
expect(source).not.toMatch(/^\s*import\s/m);
});
it('references that file from the app bundle, not an inlined data: URL', () => {
const appJavascript = readdirSync(ASSETS_DIR)
.filter((fileName) => fileName.endsWith('.js'))
.map((fileName) => readFileSync(resolve(ASSETS_DIR, fileName), 'utf8'))
.join('\n');
expect(appJavascript).toMatch(/assets\/capture-worklet-[^"'`]+\.js/);
expect(appJavascript).not.toContain(
// base64 of the worklet's licence header, i.e. the module inlined
'data:text/javascript;base64,LyoqCiAqIEBsaWNlbnNl',
);
});
});
describe('build artifact — standalone PWA', () => {
it('emits a classic root service worker tied to the package version', () => {
const worker = readFileSync(SERVICE_WORKER_DIST_PATH, 'utf8');
const packageJson = JSON.parse(
readFileSync(resolve(__dirname, '../package.json'), 'utf8'),
) as { version: string };
expect(worker).not.toMatch(/^\s*(?:import|export)\b/m);
expect(worker).not.toContain('import.meta');
expect(worker).toContain('qwen-code-shell-v1-');
expect(worker).toContain(JSON.stringify(packageJson.version));
});
});
describe('build artifact — transcript entry (#11031)', () => {
// `@qwen-code/web-shell/transcript` exists so the self-contained
// `/export html` document renderer can bundle the read-only transcript
// without the interactive shell. Nothing here is enforced by tree shaking:
// the package root injects its stylesheet as a top-level side effect, which
// no bundler can drop, so the boundary has to be a real entry point.
it('does not pull the editor stack into the transcript entry', () => {
// Import specifiers of externals survive minification verbatim, so their
// absence is a reliable signal that the module never entered the graph.
// The signal lives in the JS only: injectCssModules (vite.lib.config.ts)
// prepends the stylesheet as a single-line `__qwenWebShellCss` constant,
// and Tailwind v4 compiles classes from every scanned source file rather
// than this entry's graph, so the CSS carries e.g. drawer.tsx's
// `data-[vaul-drawer-direction=…]` selectors even though no transcript JS
// imports vaul. Guard the JS remainder; if the injection shape changes
// the replace() is a no-op and these checks fail loudly, not falsely.
const js = readTranscriptBundle().replace(
/^const __qwenWebShellCss=[^\n]*\n/,
'',
);
expect(js).not.toContain('@codemirror/');
expect(js).not.toContain('"codemirror"');
expect(js).not.toContain('vaul');
});
it('keeps the transcript entry a fraction of the interactive entry', () => {
// The daemon hook runtime is NOT absent from this entry — MessageList
// renders McpStatusMessage, TasksStatusMessage and the artifact turn
// outputs, and those call the strict useDaemonActions /
// useDaemonWorkspace hooks, so the provider guards ship. Asserting their
// absence would assert something this entry does not deliver (see the
// docblock in client/transcript.ts and #11100).
//
// What the entry does deliver is a bounded payload, so bound it. The JS
// remainder measured 1,298,657 UTF-16 code units on main at fa4a4c92 and
// 1,300,341 here after adding author attribution for multi-agent
// transcripts. O3 result summaries bring it to 1,306,052, and
// session-agent replies (status, steps, token usage, squad labels) to
// 1,315,297 at e951f96; that rendering is intentional transcript UI.
// main grew the same entry in parallel — 1,309,207 at 1aba19c878, plus
// 3,033 for table fullscreen and Shadow DOM keyboard handling, reaching
// 1,312,240. Merging the two measures 1,320,366 in CI at d4304811. The W2
// managed cwd slice (#13760) adds its strings to the shared i18n table
// every entry ships — no interactive cwd code enters this graph — and
// measured 1,323,120 before main's host-status copy. After rebasing onto
// c34b8423, main measures 1,321,586 and this branch 1,324,022; substituting
// main's i18n table yields an identical JS remainder. The selection-copy
// menu strings (#13833) land in that same shared table — the menu itself
// stays stubbed out of this entry — and measure 1,325,251. The
// action-reminder help copy (#13832) entered that same table only after
// the 1,325,251 measurement was taken, so the entry was already past the
// 1,326,500 ceiling at that re-measure's own merge commit (#13964); the
// Managed task panel's copy (#13936) stays stubbed out of this entry.
// At 008c0de7 the remainder measures 1,327,531. The read-only Excel
// preview puts its ten excel.* strings into that same shared table in
// both locales; its parser, worker and markup stay inside the lazily
// imported SpreadsheetPreview chunk, so only the copy enters this graph.
// That is the +2,000 this branch carried over the 1,325,000 budget, so
// the merged ceiling is the two budgets added — 1,331,500 — rather than a
// fresh measurement of a merged build. The ceiling leaves a small margin
// around that intentional UI; re-measure and lower it if the entry gets
// leaner.
const js = readTranscriptBundle().replace(
/^const __qwenWebShellCss=[^\n]*\n/,
'',
);
expect(js.length).toBeLessThan(1_331_500);
});
it('keeps selection copying and its converter out of the transcript entry', () => {
const js = readTranscriptBundle().replace(
/^const __qwenWebShellCss=[^\n]*\n/,
'',
);
expect(js).not.toContain('data-selection-copy-math');
expect(js).not.toContain('selectionCopyListWrapper');
});
it('excludes Files-only messages from the transcript entry', () => {
const bundle = readTranscriptBundle();
for (const key of [
'files.description',
'files.reloadUnavailable',
'files.sourceTooLarge',
] as const)
expect(bundle).not.toContain(FILES_MESSAGES_EN[key]);
});
it('carries no Live Voice strings and looks none up', () => {
// vite.lib.config.ts swaps client/live/messages.ts for an empty stub in
// this build, which keeps ~200 entries out of every exported document. It
// is only safe while nothing in the bundle asks for one: a lookup here
// would render as its key name. Key literals survive minification, so
// their absence shows that no Live surface entered the graph — the app
// itself may use them anywhere (the composer's add menu does).
const js = readTranscriptBundle().replace(
/^const __qwenWebShellCss=[^\n]*\n/,
'',
);
const keys = Object.keys(LIVE_MESSAGES_EN);
expect(keys.length).toBeGreaterThan(50);
const present = keys.filter(
(key) => js.includes(`"${key}"`) || js.includes(`'${key}'`),
);
expect(present).toEqual([]);
expect(js).not.toContain('Talk in this browser');
});
it('keeps dictation strings in the interactive entry only', () => {
const js = readTranscriptBundle().replace(
/^const __qwenWebShellCss=[^\n]*\n/,
'',
);
expect(js).not.toMatch(/["'`]voice\./);
const interactive = readPackageJavascript();
expect(interactive).toContain('voice.error.noModel');
expect(interactive).toContain('Dismiss voice message');
expect(interactive).toContain('当前工作区未配置语音模型。');
});
it('carries no collaboration strings and looks none up', () => {
// Same arrangement as Live Voice: the collaboration dictionary is stubbed
// in this build, which is only safe while no transcript surface asks for
// one of its keys.
const js = readTranscriptBundle().replace(
/^const __qwenWebShellCss=[^\n]*\n/,
'',
);
const keys = Object.keys(COLLAB_MESSAGES_EN);
expect(keys.length).toBeGreaterThan(50);
expect(
keys.filter((key) => js.includes(`"${key}"`) || js.includes(`'${key}'`)),
).toEqual([]);
});
it('carries no Managed task-panel strings or lookups', () => {
const js = readTranscriptBundle().replace(
/^const __qwenWebShellCss=[^\n]*\n/,
'',
);
expect(js.includes('managed.tasks.')).toBe(false);
});
it('carries no settings strings and looks none up', () => {
// Same contract as the Live Voice stub above, for
// client/settings/messages.ts: a settings.* lookup here would render as
// its key name, and a settings.* entry left in i18n.tsx would ship in
// every exported document.
const js = readTranscriptBundle().replace(
/^const __qwenWebShellCss=[^\n]*\n/,
'',
);
expect(js).not.toMatch(/["'`]settings\./);
});
it('keeps trajectory filter strings in the interactive entry only', () => {
const js = readTranscriptBundle().replace(
/^const __qwenWebShellCss=[^\n]*\n/,
'',
);
expect(js).not.toMatch(/["'`]trajectory\.(filter|window)\./);
expect(readPackageJavascript()).toContain('trajectory.window.older');
expect(readPackageJavascript()).toContain('trajectory.filter.search');
expect(readPackageJavascript()).toContain('Search loaded records');
});
it('still carries what a transcript actually renders', () => {
const bundle = readTranscriptBundle();
expect(bundle).toContain('react-markdown');
expect(bundle).toContain('WebShellTranscript');
});
it('injects its stylesheet under its own entry key', () => {
// Separate rollup runs produce different stylesheets per entry, so the
// injection guard is keyed per entry — a shared key would let whichever
// entry loaded first suppress the other's rules.
expect(readBundle()).toContain('data-qwen-web-shell-entry="index"');
expect(readTranscriptBundle()).toContain(
'data-qwen-web-shell-entry="transcript"',
);
// Both keep the shared marker that shadow-root style adoption reads.
expect(readTranscriptBundle()).toContain(
's.dataset.qwenWebShell="component"',
);
});
it('keeps KaTeX border overrides after Tailwind preflight', () => {
for (const bundle of [readBundle(), readTranscriptBundle()]) {
const rules: Rule[] = [];
postcss
.parse(readInjectedCss(bundle))
.walkRules((rule) => rules.push(rule));
const preflightIndex = rules.findIndex(
(rule) =>
rule.selector.includes('[data-web-shell-shadcn]') &&
rule.selector.includes('::backdrop') &&
rule.nodes.some(
(node) =>
node.type === 'decl' &&
node.prop === 'border-color' &&
node.value === 'var(--border)',
),
);
const katexIndex = rules.findIndex(
(rule) =>
rule.selector.includes('.katex *') &&
rule.nodes.some(
(node) =>
node.type === 'decl' &&
node.prop === 'border-color' &&
node.value === 'currentColor',
),
);
expect(preflightIndex).toBeGreaterThanOrEqual(0);
expect(katexIndex).toBeGreaterThan(preflightIndex);
}
});
});