fix(agents): prevent model selection failures in WebKit
- Preserve focus during picker clicks to prevent premature dropdown dismissal - Add model picker regression tests for Chromium and WebKit
This commit is contained in:
1 parent
5260ec5a2f
commit
dc16fe1a84
4 files changed
+412
No files matched your search
@@ -599,6 +599,12 @@ function AgentModelPicker({
|
||||
|
||||
return (
|
||||
<div className={`agent-model-picker ${open ? 'open' : ''}`} ref={rootRef}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === 0 && event.currentTarget.contains(document.activeElement)
|
||||
&& event.target instanceof Element && event.target.closest('button')) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setOpen(false);
|
||||
}}>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
const { chromium, webkit } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const base = process.env.AGENT_UI_BASE_URL || 'http://127.0.0.1:1421';
|
||||
|
||||
(async () => {
|
||||
const engines = process.env.PLAYWRIGHT_ENGINES?.split(',') || ['chromium', 'webkit'];
|
||||
for (const name of engines) {
|
||||
const engine = { chromium, webkit }[name];
|
||||
assert.ok(engine, `Unsupported browser: ${name}`);
|
||||
const browser = await engine.launch({
|
||||
headless: true,
|
||||
...(name === 'chromium' ? { channel: 'msedge', args: ['--no-proxy-server'] } : {}),
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } });
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(String(error)));
|
||||
const open = async query => {
|
||||
await page.goto(`${base}/tests/fixtures/agent-backups.html?reset-selections&${query}`);
|
||||
await page.waitForFunction(() => window.fixtureCalls.some(call => call.cmd === 'get_agent_models')
|
||||
&& document.querySelector('button.agent-model-trigger:not(:disabled)'));
|
||||
};
|
||||
const picker = () => page.locator('.agent-model-picker').first();
|
||||
const trigger = () => picker().locator('button.agent-model-trigger');
|
||||
const search = () => picker().locator('.agent-model-search input');
|
||||
const option = model => page.getByRole('option').filter({ has: page.getByText(model, { exact: true }) });
|
||||
const expectSelected = async model => {
|
||||
assert.equal(await trigger().locator('strong').innerText(), model, `${name}: model selection must receive the click`);
|
||||
assert.equal(await page.getByRole('listbox').count(), 0);
|
||||
};
|
||||
const expand = async () => {
|
||||
await trigger().click();
|
||||
await page.waitForFunction(() => document.activeElement?.matches('.agent-model-search input'));
|
||||
};
|
||||
|
||||
for (const embedded of [false, true]) {
|
||||
await open(`client=codex${embedded ? '&embedded' : ''}`);
|
||||
await expand();
|
||||
await option('gpt-two').click();
|
||||
await expectSelected('gpt-two');
|
||||
|
||||
await expand();
|
||||
await search().fill('gpt-one');
|
||||
await option('gpt-one').click();
|
||||
await expectSelected('gpt-one');
|
||||
|
||||
await expand();
|
||||
await search().fill('gpt-two');
|
||||
await picker().getByRole('button', { name: '清空搜索', exact: true }).click();
|
||||
assert.equal(await search().inputValue(), '');
|
||||
assert.equal(await page.getByRole('option').count(), 2);
|
||||
assert.ok(await search().evaluate(input => input === document.activeElement));
|
||||
const refreshes = await page.evaluate(() => window.fixtureCalls.filter(call => call.cmd === 'get_agent_models').length);
|
||||
await picker().getByRole('button', { name: '刷新模型', exact: true }).click();
|
||||
await page.waitForFunction(previous => window.fixtureCalls.filter(call => call.cmd === 'get_agent_models').length > previous, refreshes);
|
||||
assert.equal(await page.getByRole('listbox').count(), 1, 'Refresh keeps the picker open');
|
||||
|
||||
await search().press('ArrowDown');
|
||||
await search().press('Enter');
|
||||
await expectSelected('gpt-two');
|
||||
await expand();
|
||||
await search().fill('gpt-one');
|
||||
await search().press('Enter');
|
||||
await expectSelected('gpt-one');
|
||||
await expand();
|
||||
await search().press('Escape');
|
||||
assert.equal(await page.getByRole('listbox').count(), 0);
|
||||
|
||||
await expand();
|
||||
await page.evaluate(() => {
|
||||
const outside = document.createElement('button');
|
||||
outside.id = 'fixture-after-picker';
|
||||
outside.textContent = 'After picker';
|
||||
outside.style.cssText = 'position:fixed;top:4px;left:4px;z-index:100';
|
||||
document.querySelector('.agent-model-picker').after(outside);
|
||||
});
|
||||
for (let index = 0; index < 8 && await page.getByRole('listbox').count(); index++) {
|
||||
await page.keyboard.press('Tab');
|
||||
}
|
||||
assert.equal(await page.getByRole('listbox').count(), 0, 'Tab outside closes the picker');
|
||||
await expand();
|
||||
await page.locator('#fixture-after-picker').click();
|
||||
assert.equal(await page.getByRole('listbox').count(), 0, 'Click outside closes the picker');
|
||||
}
|
||||
|
||||
for (const embedded of [false, true]) {
|
||||
await open(`client=claude-desktop${embedded ? '&embedded' : ''}`);
|
||||
await expand();
|
||||
await option('gpt-two').click();
|
||||
await expectSelected('gpt-two');
|
||||
const editable = page.locator('.agent-model-picker').filter({ has: page.locator('.agent-model-editable-trigger') }).first();
|
||||
const alias = editable.getByRole('combobox');
|
||||
const toggle = editable.getByRole('button', { name: '别名(Claude系列模型可留空)', exact: true });
|
||||
await alias.click();
|
||||
await option('claude-sonnet-4-6').click();
|
||||
assert.equal(await alias.inputValue(), 'claude-sonnet-4-6', 'Editable suggestions must receive the click');
|
||||
assert.equal(await page.getByRole('listbox').count(), 0);
|
||||
assert.ok(await alias.evaluate(input => input === document.activeElement));
|
||||
await alias.click();
|
||||
await toggle.click();
|
||||
assert.equal(await page.getByRole('listbox').count(), 0, 'Clicking the editable arrow closes an open picker');
|
||||
await toggle.click();
|
||||
assert.equal(await page.getByRole('listbox').count(), 1);
|
||||
await alias.fill('claude-custom-test');
|
||||
await alias.press('Escape');
|
||||
assert.equal(await alias.inputValue(), 'claude-custom-test');
|
||||
assert.equal(await page.getByRole('listbox').count(), 0);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(`PASS (${name}): direct/filtered clicks, keyboard selection, clear/refresh, Tab/Escape/outside dismissal, editable aliases and toggles in full and embedded views.`);
|
||||
} finally { await browser.close(); }
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,151 @@
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
async function audit(page, scope, label) {
|
||||
const result = await page.evaluate(scope => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = 1;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const rgba = value => {
|
||||
context.clearRect(0, 0, 1, 1);
|
||||
context.fillStyle = value;
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
return Array.from(context.getImageData(0, 0, 1, 1).data);
|
||||
};
|
||||
const flatten = (foreground, background, opacity = 1) => {
|
||||
const alpha = foreground[3] / 255 * opacity;
|
||||
return foreground.slice(0, 3).map((channel, index) => channel * alpha + background[index] * (1 - alpha));
|
||||
};
|
||||
const luminance = rgb => {
|
||||
const linear = rgb.map(channel => {
|
||||
const value = channel / 255;
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
|
||||
};
|
||||
const contrast = (a, b) => (Math.max(luminance(a), luminance(b)) + 0.05) / (Math.min(luminance(a), luminance(b)) + 0.05);
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
const grayColors = new Set(['primary', 'secondary', 'tertiary', 'quaternary', 'muted', 'placeholder']
|
||||
.map(role => rgba(root.getPropertyValue(`--text-${role}`)).join(',')));
|
||||
const failures = [];
|
||||
let samples = 0;
|
||||
const check = (element, pseudo) => {
|
||||
if (element.closest(':disabled, [aria-disabled="true"], .locked, .disabled, .is-disabled')) return;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element, pseudo);
|
||||
if (!rect.width || !rect.height || style.visibility !== 'visible' || style.display === 'none') return;
|
||||
if (!pseudo && !Array.from(element.childNodes).some(node => node.nodeType === Node.TEXT_NODE && node.textContent.trim())) return;
|
||||
const foreground = rgba(style.color);
|
||||
if (!grayColors.has(foreground.join(','))) return;
|
||||
const ancestors = [];
|
||||
for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) ancestors.unshift(ancestor);
|
||||
let background = [255, 255, 255];
|
||||
let opacity = pseudo ? Number(style.opacity) : 1;
|
||||
for (const ancestor of ancestors) {
|
||||
const computed = getComputedStyle(ancestor);
|
||||
background = flatten(rgba(computed.backgroundColor), background);
|
||||
opacity *= Number(computed.opacity);
|
||||
}
|
||||
if (!opacity) return;
|
||||
const ratio = contrast(flatten(foreground, background, opacity), background);
|
||||
samples += 1;
|
||||
if (ratio < 4.5) failures.push({
|
||||
element: `${element.tagName}.${element.className}${pseudo || ''}`,
|
||||
text: (pseudo ? element.getAttribute('placeholder') : element.textContent).trim().slice(0, 80),
|
||||
ratio: Number(ratio.toFixed(3)), color: style.color, background,
|
||||
});
|
||||
};
|
||||
for (const element of document.querySelectorAll(`${scope}, ${scope} *`)) {
|
||||
check(element);
|
||||
if (element.matches('input[placeholder], textarea[placeholder]')) check(element, '::placeholder');
|
||||
}
|
||||
return { samples, failures };
|
||||
}, scope);
|
||||
assert.ok(result.samples > 0, `${label}: no text checked`);
|
||||
assert.deepEqual(result.failures, [], `${label}: low-contrast text`);
|
||||
console.log(`${label}: ${result.samples} text samples passed`);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const { build, preview } = await import('vite');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cpa-contrast-fixtures-'));
|
||||
let browser;
|
||||
let server;
|
||||
try {
|
||||
await build({
|
||||
root, logLevel: 'error',
|
||||
build: {
|
||||
outDir, emptyOutDir: true,
|
||||
rollupOptions: { input: ['theme', 'auth-file-requests', 'usage-layout'].map(name => path.join(__dirname, 'fixtures', `${name}.html`)) },
|
||||
},
|
||||
});
|
||||
server = await preview({ root, build: { outDir }, preview: { host: '127.0.0.1', port: 0, strictPort: false }, logLevel: 'error' });
|
||||
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } });
|
||||
page.setDefaultTimeout(15000);
|
||||
page.setDefaultNavigationTimeout(30000);
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(String(error)));
|
||||
await page.route('**/*', route => route.request().url().startsWith(base + '/') ? route.continue() : route.abort());
|
||||
const open = fixture => page.goto(`${base}/tests/fixtures/${fixture}`, { waitUntil: 'domcontentloaded' });
|
||||
const screenshot = async name => {
|
||||
if (!process.env.CONTRAST_SCREENSHOT_DIR) return;
|
||||
fs.mkdirSync(process.env.CONTRAST_SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: path.join(process.env.CONTRAST_SCREENSHOT_DIR, `${name}.png`), animations: 'disabled' });
|
||||
};
|
||||
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await open(`theme.html?reset&platform=browser&saved=${theme}`);
|
||||
await page.waitForFunction(theme => document.documentElement.dataset.theme === theme, theme);
|
||||
await page.locator('.sidebar').waitFor();
|
||||
await audit(page, '.app-shell', `${theme} home and sidebar`);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const section = document.createElement('section');
|
||||
section.id = 'placeholder-probes';
|
||||
section.style.cssText = 'padding:16px;background:var(--bg-card)';
|
||||
section.innerHTML = '<input placeholder="Generic input"><textarea placeholder="Generic textarea"></textarea><input class="compact-text-input" placeholder="Compact input"><div class="simple-mode-field"><input class="text-input" placeholder="Simple mode input"></div>';
|
||||
document.body.append(section);
|
||||
});
|
||||
await audit(page, '#placeholder-probes', `${theme} shared placeholders`);
|
||||
for (const input of await page.locator('#placeholder-probes input, #placeholder-probes textarea').all()) {
|
||||
assert.equal(await input.evaluate(element => getComputedStyle(element, '::placeholder').opacity), '1');
|
||||
}
|
||||
|
||||
await open(`auth-file-requests.html?cards&theme=${theme}&locale=zh-CN`);
|
||||
await page.locator('.auth-file-card').first().waitFor();
|
||||
await audit(page, '.auth-files-page', `${theme} credential cards`);
|
||||
await screenshot(`credential-cards-${theme}`);
|
||||
await page.locator('.auth-file-card').first().getByRole('button', { name: '设置', exact: true }).click();
|
||||
await page.locator('.credential-settings-body[aria-busy="false"]').waitFor();
|
||||
await audit(page, '.credential-settings-dialog', `${theme} credential settings`);
|
||||
await screenshot(`credential-settings-${theme}`);
|
||||
|
||||
await open(`usage-layout.html?theme=${theme}&locale=zh-CN`);
|
||||
const legend = page.locator('.usage-trend-legend-item').first();
|
||||
await legend.waitFor();
|
||||
await audit(page, '.usage-records-page', `${theme} usage overview`);
|
||||
await legend.click();
|
||||
await page.locator('.usage-trend-legend-item.is-hidden').waitFor();
|
||||
assert.equal(await legend.evaluate(element => getComputedStyle(element).opacity), '1');
|
||||
assert.ok((await legend.evaluate(element => getComputedStyle(element).textDecorationLine)).includes('line-through'));
|
||||
await audit(page, '.usage-trend-legend', `${theme} interactive hidden-series label`);
|
||||
await screenshot(`usage-overview-${theme}`);
|
||||
|
||||
await open(`usage-layout.html?tab=events&theme=${theme}&locale=zh-CN`);
|
||||
await page.locator('.usage-events-table tbody tr').first().waitFor();
|
||||
await audit(page, '.usage-records-page', `${theme} usage records`);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: light/dark contrast, shared placeholders, credential cards/settings, usage tables and interactive legend labels.');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (server) await new Promise((resolve, reject) => server.httpServer.close(error => error ? reject(error) : resolve()));
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import postcss from 'postcss';
|
||||
|
||||
const stylesheet = postcss.parse(readFileSync(new URL('../src/styles.css', import.meta.url), 'utf8'));
|
||||
const aliases: Record<string, string> = {
|
||||
'--theme-4b4742': '--text-secondary',
|
||||
'--theme-59544d': '--text-secondary',
|
||||
'--theme-5f5a53': '--text-secondary',
|
||||
'--theme-655f57': '--text-secondary',
|
||||
'--theme-6b655d': '--text-secondary',
|
||||
'--theme-6d6760': '--text-secondary',
|
||||
'--theme-6f6961': '--text-tertiary',
|
||||
'--theme-716b61': '--text-tertiary',
|
||||
'--theme-777067': '--text-tertiary',
|
||||
'--theme-7a746c': '--text-tertiary',
|
||||
'--theme-817b72': '--text-tertiary',
|
||||
'--theme-817b73': '--text-tertiary',
|
||||
'--theme-8a847c': '--text-quaternary',
|
||||
'--theme-918a81': '--text-quaternary',
|
||||
'--theme-9a938a': '--text-quaternary',
|
||||
'--theme-9a958d': '--text-quaternary',
|
||||
'--theme-9b958c': '--text-quaternary',
|
||||
};
|
||||
const surfaces = [
|
||||
'--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-hover', '--bg-quinary', '--bg-card',
|
||||
'--theme-faf9f5', '--theme-fffdf8', '--theme-f4f1ea', '--theme-f0eee8',
|
||||
'--theme-eeece6', '--theme-ece8df', '--theme-e8e4dc', '--theme-dfdcd4',
|
||||
'--theme-eef7f0', '--theme-fff1ee', '--theme-fff7e9', '--ui-accent-soft',
|
||||
];
|
||||
const textTokens = [
|
||||
'--text-primary', '--text-secondary', '--text-tertiary', '--text-quaternary',
|
||||
'--text-muted', '--text-placeholder', ...Object.keys(aliases),
|
||||
];
|
||||
type RGB = [number, number, number];
|
||||
|
||||
function declarations(selector: string): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
stylesheet.walkRules((rule) => {
|
||||
if (rule.selector !== selector) return;
|
||||
rule.walkDecls((declaration) => { values[declaration.prop] = declaration.value; });
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
function resolve(value: string, tokens: Record<string, string>, seen = new Set<string>()): string {
|
||||
const match = /^var\((--[\w-]+)\)$/.exec(value);
|
||||
if (!match) return value;
|
||||
const name = match[1];
|
||||
if (!tokens[name] || seen.has(name)) throw new Error(`Invalid color token: ${name}`);
|
||||
return resolve(tokens[name], tokens, new Set([...seen, name]));
|
||||
}
|
||||
|
||||
function color(value: string, backdrop: RGB = [255, 255, 255]): RGB {
|
||||
if (/^#[\da-f]{3}$/i.test(value)) value = `#${[...value.slice(1)].map((digit) => digit.repeat(2)).join('')}`;
|
||||
if (/^#[\da-f]{6}$/i.test(value)) {
|
||||
return [1, 3, 5].map((offset) => Number.parseInt(value.slice(offset, offset + 2), 16)) as RGB;
|
||||
}
|
||||
const match = /^rgba\(\s*(\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\s*\)$/.exec(value);
|
||||
if (!match) throw new Error(`Unsupported color: ${value}`);
|
||||
const alpha = Number(match[4]);
|
||||
return backdrop.map((channel, index) => Number(match[index + 1]) * alpha + channel * (1 - alpha)) as RGB;
|
||||
}
|
||||
|
||||
function luminance(rgb: RGB): number {
|
||||
const linear = rgb.map((channel) => {
|
||||
const value = channel / 255;
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
|
||||
}
|
||||
|
||||
function contrast(foreground: RGB, background: RGB): number {
|
||||
const values = [luminance(foreground), luminance(background)];
|
||||
return (Math.max(...values) + 0.05) / (Math.min(...values) + 0.05);
|
||||
}
|
||||
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
describe(`${theme} text contrast`, () => {
|
||||
const tokens = {
|
||||
...declarations(':root'),
|
||||
...(theme === 'dark' ? declarations(":root[data-theme='dark']") : {}),
|
||||
};
|
||||
const value = (token: string) => resolve(tokens[token], tokens);
|
||||
|
||||
it('uses semantic colors for legacy gray text instead of separate low-contrast values', () => {
|
||||
for (const [alias, semantic] of Object.entries(aliases)) {
|
||||
expect(tokens[alias]).toBe(`var(${semantic})`);
|
||||
expect(value(alias)).toBe(value(semantic));
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps normal text and placeholders at 4.5:1 across application surfaces', () => {
|
||||
const failures: string[] = [];
|
||||
for (const base of ['--bg-card', '--bg-secondary']) {
|
||||
const backdrop = color(value(base));
|
||||
for (const surface of surfaces) {
|
||||
const background = color(value(surface), backdrop);
|
||||
for (const token of textTokens) {
|
||||
const ratio = contrast(color(value(token)), background);
|
||||
if (ratio < 4.5) failures.push(`${token} on ${surface} over ${base}: ${ratio.toFixed(3)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves background layers and reserves lower contrast for disabled text', () => {
|
||||
expect(value('--bg-primary')).toBe(theme === 'light' ? '#ffffff' : '#141720');
|
||||
expect(value('--bg-card')).toBe(theme === 'light' ? '#ffffff' : '#141720');
|
||||
expect(value('--bg-secondary')).toBe(theme === 'light' ? '#f6f7f5' : '#0b0d11');
|
||||
expect(value('--bg-tertiary')).toBe(theme === 'light' ? '#eff3f0' : '#1c212c');
|
||||
expect(value('--bg-hover')).toBe(theme === 'light' ? '#f6f8f6' : '#252b38');
|
||||
expect(value('--theme-faf9f5')).toBe(theme === 'light' ? '#f6f7f5' : '#0b0d11');
|
||||
const background = color(value('--bg-card'));
|
||||
const hierarchy = ['primary', 'secondary', 'tertiary', 'quaternary', 'disabled']
|
||||
.map((role) => contrast(color(value(`--text-${role}`)), background));
|
||||
for (let index = 1; index < hierarchy.length; index += 1) {
|
||||
expect(hierarchy[index - 1]).toBeGreaterThan(hierarchy[index]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('renders placeholders at full opacity using the shared readable color', () => {
|
||||
expect(declarations('input::placeholder,\ntextarea::placeholder')).toMatchObject({
|
||||
color: 'var(--text-placeholder)', opacity: '1',
|
||||
});
|
||||
for (const selector of ['.compact-text-input::placeholder', '.simple-mode-field .text-input::placeholder']) {
|
||||
expect(declarations(selector).color).toBe('var(--text-placeholder)');
|
||||
}
|
||||
});
|
||||
|
||||
it('does not fade interactive hidden-series labels or ordinary table hints', () => {
|
||||
for (const selector of ['.usage-trend-legend-item.is-hidden', '.usage-col-hint']) {
|
||||
expect(declarations(selector).color).toBe('var(--text-quaternary)');
|
||||
expect(declarations(selector).opacity).toBeUndefined();
|
||||
}
|
||||
expect(declarations('.usage-trend-legend-item.is-hidden')['text-decoration']).toBe('line-through');
|
||||
expect(declarations('.usage-trend-legend-item.is-hidden .usage-trend-swatch').opacity).toBe('0.38');
|
||||
});
|
||||
Reference in new issue
Block a user