413 lines
22 KiB
JavaScript
413 lines
22 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import postcss from 'postcss';
|
|
|
|
import {
|
|
analyzeSelectorList,
|
|
createStyleWitnesses,
|
|
createStyleLedger,
|
|
readStyleSource,
|
|
readStyleWitnesses,
|
|
selectorsWithoutWitness,
|
|
styleLeafPaths,
|
|
variableReferences,
|
|
} from './style-source.js';
|
|
|
|
const root = path.resolve(import.meta.dirname, '../..');
|
|
const stylesRoot = path.join(root, 'src/web/styles');
|
|
const indexPath = path.join(stylesRoot, 'index.css');
|
|
const index = fs.readFileSync(indexPath, 'utf8');
|
|
const expectedImports = [
|
|
'./tokens.css',
|
|
'./base.css',
|
|
'./features/devices.css',
|
|
'./features/routing.css',
|
|
'./features/instructions.css',
|
|
'./features/connection.css',
|
|
'./features/subscription.css',
|
|
'./features/servers.css',
|
|
'./primitives.css',
|
|
'./features/diagnostics.css',
|
|
'./layout.css',
|
|
'./themes.css',
|
|
];
|
|
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
const acceptedLedger = {
|
|
counts: {
|
|
cascadeEdges: 749,
|
|
customProperties: 103,
|
|
declarations: 3203,
|
|
important: 0,
|
|
keyframes: 56,
|
|
media: 13,
|
|
rules: 918,
|
|
variableReferences: 794,
|
|
},
|
|
hashes: {
|
|
cascadeEdges: 'a47dbf5212045c865de7a41d420e78df8b992a6ec1b06fb18a8d459cd480f0ae',
|
|
customProperties: 'fc8401b40b8d2cc8a1fc1716360ba8e5baccb694cdabde68427d3c92c04a84d4',
|
|
declarations: '36e7006c508d95d5567a1aefde2a0786789c268329c12f00bd047e2878dde6db',
|
|
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
|
duplicateSelectors: '257eff4727dab9ce25f4e1f9320e89bf2270eb29feae921b96601f103ad7f036',
|
|
keyframes: 'fb859c4d0d1bfd2f5a18904e30a5f79c6ce6931d74fe88cbd506fb28c334b7ac',
|
|
ruleDeclarationSequences: 'afbf47d7d026eaf47d402e4803364c083a6c1a031165aec7926d9b7afab6d030',
|
|
selectors: 'beef8401967d55300d72ea04a2f6d8b95f653bbafc5269d28ed2480c9a34631d',
|
|
variableReferences: 'c7dce56ef422f68a5e54ea8b6514ffd3dec3ae948e4bfc77098efb7342e27e2c',
|
|
witnesses: '8126287c546fee33b029d793db116533bcb5f542ea03e686174a07299d0ee270',
|
|
},
|
|
};
|
|
|
|
test('public stylesheet exposes exactly twelve flat semantic owners', () => {
|
|
const imports = Array.from(index.matchAll(/^@import ['"](\.\/[^'"]+\.css)['"];$/gm), ([, file]) => file);
|
|
assert.deepEqual(imports, expectedImports);
|
|
assert.equal(index, `${expectedImports.map((file) => `@import '${file}';`).join('\n')}\n`);
|
|
assert.equal(new Set(imports).size, imports.length);
|
|
assert.equal(fs.existsSync(path.join(root, 'src/web/styles.css')), false);
|
|
|
|
const actualCssFiles = fs.readdirSync(stylesRoot, { recursive: true, withFileTypes: true })
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.css'))
|
|
.map((entry) => path.relative(stylesRoot, path.join(entry.parentPath, entry.name)).replaceAll('\\', '/'))
|
|
.sort();
|
|
assert.deepEqual(actualCssFiles, ['index.css', ...expectedImports.map((file) => file.slice(2))].sort());
|
|
|
|
for (const leaf of styleLeafPaths(root)) {
|
|
const source = fs.readFileSync(leaf, 'utf8');
|
|
assert.doesNotMatch(source, /@import|@layer/, path.relative(root, leaf));
|
|
}
|
|
});
|
|
|
|
test('tokens, shared primitives, and feature styles have one explicit owner', () => {
|
|
const sources = Object.fromEntries(expectedImports.map((relativePath) => [
|
|
relativePath,
|
|
fs.readFileSync(path.resolve(stylesRoot, relativePath), 'utf8'),
|
|
]));
|
|
const paletteProperties = [
|
|
'--client-bg', '--client-panel', '--client-control', '--client-border', '--client-text', '--client-muted',
|
|
'--harbor-word', '--harbor-connect', '--harbor-gateway', '--client-accent', '--client-accent-soft',
|
|
];
|
|
for (const property of paletteProperties) {
|
|
const owners = Object.entries(sources)
|
|
.filter(([, source]) => new RegExp(`^\\s*${property.replaceAll('-', '\\-')}:`, 'm').test(source))
|
|
.map(([owner]) => owner);
|
|
assert.deepEqual(owners, ['./tokens.css', './themes.css'], property);
|
|
}
|
|
for (const [owner, source] of Object.entries(sources)) {
|
|
assert.doesNotMatch(source, /\[data-theme\]/, owner);
|
|
}
|
|
|
|
for (const keyframe of [
|
|
'client-row-enter', 'client-row-leave', 'client-delete-strike',
|
|
'client-delete-content-dim', 'client-spin', 'client-copy-fade',
|
|
]) {
|
|
const owners = Object.entries(sources)
|
|
.filter(([, source]) => new RegExp(`@keyframes ${keyframe}\\b`).test(source))
|
|
.map(([owner]) => owner);
|
|
assert.deepEqual(owners, ['./primitives.css'], keyframe);
|
|
}
|
|
});
|
|
|
|
test('client typography uses the shared semantic scale outside the token owner', () => {
|
|
const tokenRoot = postcss.parse(fs.readFileSync(path.join(stylesRoot, 'tokens.css'), 'utf8'));
|
|
const tokens = new Map();
|
|
tokenRoot.walkDecls((declaration) => tokens.set(declaration.prop, declaration.value));
|
|
const roleValues = {
|
|
micro: ['0.5rem', 'var(--font-weight-body) var(--font-size-micro)/var(--line-height-micro) var(--font-family-client)', '1.45', 'var(--tracking-normal)', 'var(--text-transform-none)'],
|
|
label: ['0.5625rem', 'var(--font-weight-bold) var(--font-size-label)/var(--line-height-label) var(--font-family-client)', '1.2', 'var(--tracking-label)', 'var(--text-transform-label)'],
|
|
control: ['0.625rem', 'var(--font-weight-bold) var(--font-size-control)/var(--line-height-control) var(--font-family-client)', '1.2', 'var(--tracking-control)', 'var(--text-transform-none)'],
|
|
body: ['0.6875rem', 'var(--font-weight-body) var(--font-size-body)/var(--line-height-body) var(--font-family-client)', '1.7', 'var(--tracking-normal)', 'var(--text-transform-none)'],
|
|
data: ['0.75rem', 'var(--font-weight-strong) var(--font-size-data)/var(--line-height-data) var(--font-family-client)', '1.2', 'var(--tracking-normal)', 'var(--text-transform-none)'],
|
|
'item-title': ['0.875rem', 'var(--font-weight-bold) var(--font-size-item-title)/var(--line-height-item-title) var(--font-family-client)', '1.2', 'var(--tracking-title)', 'var(--text-transform-none)'],
|
|
'section-title': ['1rem', 'var(--font-weight-bold) var(--font-size-section-title)/var(--line-height-section-title) var(--font-family-client)', '1.25', 'var(--tracking-title)', 'var(--text-transform-none)'],
|
|
'state-title': ['1.125rem', 'var(--font-weight-bold) var(--font-size-state-title)/var(--line-height-state-title) var(--font-family-client)', '1.3', 'var(--tracking-tight)', 'var(--text-transform-none)'],
|
|
'drawer-title': ['1.375rem', 'var(--font-weight-bold) var(--font-size-drawer-title)/var(--line-height-drawer-title) var(--font-family-client)', '1.15', 'var(--tracking-tight)', 'var(--text-transform-none)'],
|
|
};
|
|
for (const [role, [size, recipe, lineHeight, tracking, transform]] of Object.entries(roleValues)) {
|
|
assert.equal(tokens.get(`--font-size-${role}`), size, role);
|
|
assert.equal(tokens.get(`--type-${role}`), recipe, role);
|
|
assert.equal(tokens.get(`--line-height-${role}`), lineHeight, role);
|
|
assert.equal(tokens.get(`--type-${role}-tracking`), tracking, role);
|
|
assert.equal(tokens.get(`--type-${role}-transform`), transform, role);
|
|
}
|
|
assert.equal(tokens.get('--type-tooltip'),
|
|
'var(--font-weight-strong) var(--font-size-control)/var(--line-height-tooltip) var(--font-family-client)');
|
|
assert.equal(tokens.get('--line-height-tooltip'), '1.35');
|
|
|
|
const allowed = new Map([
|
|
['font', new Set(['inherit', 'var(--type-icon-close)', 'var(--type-tooltip)', ...Object.keys(roleValues).map((role) => `var(--type-${role})`)])],
|
|
['font-family', new Set(['var(--font-family-client)'])],
|
|
['font-size', new Set([
|
|
'var(--font-size-brand)', 'var(--font-size-brand-tooltip)', 'var(--font-size-brand-tooltip-strong)',
|
|
'var(--font-size-icon-chevron)', 'var(--font-size-icon-delete)',
|
|
])],
|
|
['font-weight', new Set(['var(--font-weight-bold)', 'var(--font-weight-brand)', 'var(--font-weight-strong)'])],
|
|
['line-height', new Set(['var(--line-height-micro)'])],
|
|
['letter-spacing', new Set([
|
|
'inherit', 'var(--tracking-normal)', 'var(--tracking-title)',
|
|
...Object.keys(roleValues).map((role) => `var(--type-${role}-tracking)`),
|
|
'var(--type-tooltip-tracking)',
|
|
])],
|
|
['text-transform', new Set([
|
|
...Object.keys(roleValues).map((role) => `var(--type-${role}-transform)`),
|
|
'var(--type-tooltip-transform)',
|
|
])],
|
|
['font-variant-numeric', new Set(['var(--numeric-tabular)'])],
|
|
]);
|
|
for (const leaf of styleLeafPaths(root).filter((file) => !file.endsWith('/tokens.css'))) {
|
|
const stylesheet = postcss.parse(fs.readFileSync(leaf, 'utf8'), { from: leaf });
|
|
stylesheet.walkDecls((declaration) => {
|
|
if (allowed.has(declaration.prop)) {
|
|
assert.ok(allowed.get(declaration.prop).has(declaration.value),
|
|
`${path.relative(root, leaf)}:${declaration.source.start.line} ${declaration.toString()}`);
|
|
}
|
|
});
|
|
stylesheet.walkRules((rule) => {
|
|
const font = rule.nodes.find((node) => node.type === 'decl' && node.prop === 'font');
|
|
const role = /^var\(--type-(micro|label|control|tooltip|body|data|item-title|section-title|state-title|drawer-title)\)$/.exec(font?.value || '')?.[1];
|
|
if (!role) return;
|
|
const declarations = new Map(rule.nodes
|
|
.filter((node) => node.type === 'decl')
|
|
.map((declaration) => [declaration.prop, declaration.value]));
|
|
assert.equal(declarations.get('letter-spacing'), `var(--type-${role}-tracking)`, rule.selector);
|
|
assert.equal(declarations.get('text-transform'), `var(--type-${role}-transform)`, rule.selector);
|
|
});
|
|
}
|
|
|
|
const styles = postcss.parse(readStyleSource(root));
|
|
for (const selector of [
|
|
'.client-instructions-header h2', '.client-devices-header h2',
|
|
'.client-local-rules-header h2', '.client-profiles-header h2',
|
|
]) {
|
|
const owner = styles.nodes.find((node) => node.type === 'rule' && node.selector === selector);
|
|
assert.equal(owner?.nodes.find((node) => node.type === 'decl' && node.prop === 'font')?.value,
|
|
'var(--type-drawer-title)', selector);
|
|
}
|
|
|
|
for (const selector of [
|
|
'.client-duration', '.client-power-section.is-gateway .client-duration', '.client-proxy-address',
|
|
'.client-device-ip', '.client-device-traffic strong',
|
|
'.client-device-traffic-point-tooltip', '.client-device-traffic-point-tooltip time',
|
|
'.client-device-traffic-point-tooltip strong', '.client-gateway-traffic-total > strong',
|
|
'.client-gateway-traffic-speed', '.client-diagnostics-table code',
|
|
'.client-server-health', '.harbor-versions', '.harbor-version-code', '.harbor-version-number',
|
|
]) {
|
|
const owners = [];
|
|
styles.walkRules((rule) => {
|
|
if (rule.selectors.includes(selector)) owners.push(rule);
|
|
});
|
|
assert.ok(owners.length > 0, selector);
|
|
assert.ok(owners.some((owner) => owner.nodes.some((node) => node.type === 'decl'
|
|
&& node.prop === 'font-variant-numeric' && node.value === 'var(--numeric-tabular)')), selector);
|
|
for (const owner of owners.filter((rule) => rule.nodes.some((node) => node.type === 'decl' && node.prop === 'font'))) {
|
|
assert.equal(owner.nodes.find((node) => node.type === 'decl' && node.prop === 'font-variant-numeric')?.value,
|
|
'var(--numeric-tabular)', selector);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
|
const witnesses = readStyleWitnesses(root);
|
|
assert.equal(witnesses.length, 730);
|
|
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
|
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
|
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
|
assert.deepEqual(ledger.hashes, acceptedLedger.hashes);
|
|
});
|
|
|
|
test('every live production selector has an expanded DOM witness', () => {
|
|
const unmatched = selectorsWithoutWitness(readStyleSource(root), readStyleWitnesses(root));
|
|
assert.deepEqual(unmatched, [
|
|
'.client-diagnostics-section-title button',
|
|
'.client-diagnostics-section-title button:disabled',
|
|
'.client-diagnostics-section-title button:focus-visible',
|
|
]);
|
|
});
|
|
|
|
test('JSX witness expansion follows cross-file components, render props, ReactNode slots, portals, and imperative classes', () => {
|
|
const fixtureWitnesses = createStyleWitnesses([
|
|
{
|
|
file: '/fixture/Child.tsx',
|
|
source: 'export function Child({ slot }) { return <section className="child">{slot}<h2 className="title" /></section>; }',
|
|
},
|
|
{
|
|
file: '/fixture/App.tsx',
|
|
source: 'export function App() { return <main className="scope"><Child slot={<strong className="slot" />} /></main>; }',
|
|
},
|
|
]);
|
|
const title = fixtureWitnesses.find((witness) => witness.classes.includes('title'));
|
|
const slot = fixtureWitnesses.find((witness) => witness.classes.includes('slot'));
|
|
assert.deepEqual(title?.ancestorClasses, ['child', 'scope']);
|
|
assert.deepEqual(slot?.ancestorClasses, ['child', 'scope']);
|
|
const renderPropWitnesses = createStyleWitnesses([
|
|
{
|
|
file: '/fixture/List.tsx',
|
|
source: 'export function List({ renderItem }) { return <section className="list">{renderItem()}</section>; }',
|
|
},
|
|
{
|
|
file: '/fixture/App.tsx',
|
|
source: 'export function App() { return <main className="scope"><List renderItem={() => <button className="item" />} /></main>; }',
|
|
},
|
|
]);
|
|
assert.deepEqual(
|
|
renderPropWitnesses.find((witness) => witness.classes.includes('item'))?.ancestorClasses,
|
|
['list', 'scope'],
|
|
);
|
|
const localBindingWitnesses = createStyleWitnesses([{
|
|
file: '/fixture/App.tsx',
|
|
source: `export function App() {
|
|
const content = <h2 className="local-title" />;
|
|
return <main className="local-scope"><section className="local-wrapper">{content}</section></main>;
|
|
}`,
|
|
}]);
|
|
assert.deepEqual(
|
|
localBindingWitnesses.find((witness) => witness.classes.includes('local-title'))?.ancestorClasses,
|
|
['local-scope', 'local-wrapper'],
|
|
);
|
|
assert.throws(() => createStyleWitnesses([{
|
|
file: '/fixture/App.tsx',
|
|
source: 'export function App() { return <Missing />; }',
|
|
}]), /Unresolved JSX witness component/);
|
|
assert.throws(() => createStyleWitnesses([{
|
|
file: '/fixture/App.tsx',
|
|
source: 'export function App() { const props = {}; return <Child {...props} />; } function Child() { return <div />; }',
|
|
}]), /Unsupported spread props/);
|
|
|
|
const witnesses = readStyleWitnesses(root);
|
|
const app = witnesses.find((witness) => witness.classes.includes('app') && witness.classes.includes('client-app'));
|
|
assert.ok(app);
|
|
const appThemeEdge = createStyleLedger(
|
|
'.app.client-app { --client-bg: white; } '
|
|
+ '@media (prefers-color-scheme: dark) { .app.client-app { --client-bg: black; } }',
|
|
{ witnesses },
|
|
);
|
|
assert.equal(appThemeEdge.counts.cascadeEdges, 1);
|
|
|
|
const marker = witnesses.find((witness) => witness.classes.includes('client-diagnostics-active-marker'));
|
|
assert.ok(marker?.classes.includes('is-visible'));
|
|
assert.ok(marker?.classes.includes('is-moving'));
|
|
const confirmations = witnesses.filter((witness) => witness.classes.includes('client-confirmation-popup'));
|
|
assert.ok(confirmations.some((witness) => witness.ancestorClasses.length === 0));
|
|
assert.ok(confirmations.some((witness) => witness.ancestorClasses.includes('app')));
|
|
const trafficTooltips = witnesses.filter((witness) => witness.classes.includes('client-device-traffic-point-tooltip'));
|
|
assert.ok(trafficTooltips.every((witness) => witness.ancestorClasses.length === 0));
|
|
});
|
|
|
|
test('JSX witnesses keep real multi-class collisions and exclude impossible element collisions', () => {
|
|
const witnesses = readStyleWitnesses(root);
|
|
const collision = createStyleLedger(
|
|
'.client-copy-button { color: red; } .client-instruction-copy-button { color: blue; }',
|
|
{ witnesses },
|
|
);
|
|
assert.equal(collision.counts.cascadeEdges, 1);
|
|
|
|
const impossible = createStyleLedger(
|
|
'.client-copy-button { color: red; } .client-power { color: blue; }',
|
|
{ witnesses },
|
|
);
|
|
assert.equal(impossible.counts.cascadeEdges, 0);
|
|
|
|
const conservativeSibling = createStyleLedger(
|
|
'.client-subscription-drawer .client-servers { margin-inline: auto; } '
|
|
+ '.client-server-group + .client-server-group { margin-top: 8px; }',
|
|
{ witnesses },
|
|
);
|
|
assert.equal(conservativeSibling.counts.cascadeEdges, 1);
|
|
});
|
|
|
|
test('selector proof uses the observed level-four grammar and exact specificity', () => {
|
|
const fixtures = [
|
|
['#root', [{ a: 1, b: 0, c: 0 }]],
|
|
['.client-shell:has(.harbor-brand.is-gateway-active)', [{ a: 0, b: 3, c: 0 }]],
|
|
['.client-instructions-toggle:not(.client-devices-toggle):not(.client-diagnostics-toggle)', [{ a: 0, b: 3, c: 0 }]],
|
|
[".client-power[aria-checked='true']::before", [{ a: 0, b: 2, c: 1 }]],
|
|
['.client-instruction-block:nth-child(n)', [{ a: 0, b: 2, c: 0 }]],
|
|
['.client-confirmation-actions button:hover:not(:disabled), #root', [
|
|
{ a: 0, b: 3, c: 1 },
|
|
{ a: 1, b: 0, c: 0 },
|
|
]],
|
|
];
|
|
for (const [selector, expected] of fixtures) {
|
|
assert.deepEqual(analyzeSelectorList(selector).map((entry) => entry.specificity), expected, selector);
|
|
}
|
|
assert.throws(() => analyzeSelectorList('.broken:has('), /Unsupported CSS selector grammar/);
|
|
assert.throws(() => analyzeSelectorList('& .proof'), /Unsupported CSS selector node nesting/);
|
|
assert.throws(() => analyzeSelectorList('.proof:is(.active)'), /Unsupported CSS pseudo :is/);
|
|
});
|
|
|
|
test('variable proof preserves nested and fallback references and fails closed', () => {
|
|
assert.deepEqual(variableReferences('var(--outer, color-mix(in srgb, var(--inner, red), white))'), [
|
|
{ name: '--outer', fallback: 'color-mix(in srgb, var(--inner, red), white)' },
|
|
{ name: '--inner', fallback: 'red' },
|
|
]);
|
|
assert.throws(() => variableReferences('var(color, red)'), /Invalid var\(\) name/);
|
|
assert.throws(() => variableReferences('var(--unfinished'), /Unterminated var\(\) reference/);
|
|
});
|
|
|
|
test('ledger mutations expose loss, order changes, duplicate selectors, keyframes, variables, and cascade orientation', () => {
|
|
const declaration = createStyleLedger('.proof { color: red; background: black; }');
|
|
const removed = createStyleLedger('.proof { color: red; }');
|
|
const reordered = createStyleLedger('.proof { background: black; color: red; }');
|
|
assert.notEqual(removed.hashes.declarations, declaration.hashes.declarations);
|
|
assert.equal(reordered.hashes.declarations, declaration.hashes.declarations);
|
|
assert.notEqual(reordered.hashes.ruleDeclarationSequences, declaration.hashes.ruleDeclarationSequences);
|
|
|
|
const duplicate = createStyleLedger('.proof { color: red; } .other { color: green; } .proof { color: blue; }');
|
|
const duplicateReordered = createStyleLedger('.proof { color: blue; } .other { color: green; } .proof { color: red; }');
|
|
assert.equal(duplicateReordered.hashes.declarations, duplicate.hashes.declarations);
|
|
assert.notEqual(duplicateReordered.hashes.duplicateSelectors, duplicate.hashes.duplicateSelectors);
|
|
|
|
const keyframe = createStyleLedger('@keyframes pulse { from { opacity: 0; } to { opacity: 1; } }');
|
|
const changedKeyframe = createStyleLedger('@keyframes pulse { from { opacity: 0.1; } to { opacity: 1; } }');
|
|
assert.notEqual(changedKeyframe.hashes.keyframes, keyframe.hashes.keyframes);
|
|
|
|
const variable = createStyleLedger('.proof { color: var(--outer, var(--inner, red)); }');
|
|
const changedVariable = createStyleLedger('.proof { color: var(--outer, var(--fallback, red)); }');
|
|
assert.notEqual(changedVariable.hashes.variableReferences, variable.hashes.variableReferences);
|
|
|
|
const edge = createStyleLedger('.alpha { color: red; } .beta { color: blue; }');
|
|
const reversedEdge = createStyleLedger('.beta { color: blue; } .alpha { color: red; }');
|
|
assert.equal(edge.counts.cascadeEdges, 1);
|
|
assert.equal(reversedEdge.counts.cascadeEdges, 1);
|
|
assert.notEqual(reversedEdge.hashes.cascadeEdges, edge.hashes.cascadeEdges);
|
|
|
|
const independent = createStyleLedger('.alpha { color: red; } .beta { background: blue; }');
|
|
const independentReordered = createStyleLedger('.beta { background: blue; } .alpha { color: red; }');
|
|
assert.equal(independent.counts.cascadeEdges, 0);
|
|
assert.deepEqual(independentReordered.hashes, independent.hashes);
|
|
|
|
for (const shorthand of [
|
|
'.x { place-items: center; } .y { align-items: start; }',
|
|
'.x { place-content: center; } .y { justify-content: start; }',
|
|
'.x { grid-area: 1 / 1; } .y { grid-row: 2; }',
|
|
]) {
|
|
assert.equal(createStyleLedger(shorthand).counts.cascadeEdges, 1, shorthand);
|
|
}
|
|
assert.equal(createStyleLedger('.a.c.d { color: red; } .x:not(.a.b) { color: blue; }').counts.cascadeEdges, 1);
|
|
|
|
assert.throws(() => createStyleLedger('.proof { unknown-proof-property: 1; }'), /Unsupported CSS property/);
|
|
assert.throws(() => createStyleLedger('@supports (display: grid) { .proof { display: grid; } }'), /Unsupported CSS at-rule/);
|
|
assert.throws(() => createStyleLedger('@media screen { .proof { color: red; } }'), /Unsupported media grammar/);
|
|
});
|
|
|
|
test('cascade proof dependencies are exact direct dev dependencies', () => {
|
|
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
assert.equal(packageJson.devDependencies['@babel/parser'], '7.29.3');
|
|
assert.equal(packageJson.devDependencies.postcss, '8.5.14');
|
|
assert.equal(packageJson.devDependencies['postcss-selector-parser'], '7.1.4');
|
|
assert.equal(packageJson.devDependencies['@csstools/selector-specificity'], '6.0.0');
|
|
});
|
|
|
|
test('main owns one public stylesheet and the regrouped production CSS is deterministic', () => {
|
|
const main = fs.readFileSync(path.join(root, 'src/web/main.tsx'), 'utf8');
|
|
assert.equal((main.match(/import ['"]\.\/styles\/index\.css['"]/g) || []).length, 1);
|
|
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
|
|
|
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
|
assert.deepEqual(assets, ['index-BkhdgoL9.css']);
|
|
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
|
assert.equal(built.byteLength, 123588);
|
|
assert.equal(sha256(built), '53f8778322796cb24321b5a876862a14fc48d757e68f9ce63bee4f95f5b56fa9');
|
|
});
|