You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
188 lines
5.9 KiB
188 lines
5.9 KiB
/**
|
|
* __theming-sentinel — the sentinel test of PLAN-theming §7.4 point 11.
|
|
*
|
|
* node scripts/__theming-sentinel.ts <component> [url]
|
|
*
|
|
* For every PUBLIC token the component declares in `recipes/base.ts`, sets an
|
|
* unmistakable value and checks that SOME node's computed style follows it. A
|
|
* token that moves nothing is a token that lies.
|
|
*
|
|
* Three things the naive version got wrong, each found by a false negative:
|
|
* - a portaled panel does not see an override scoped to the component root, so
|
|
* the write goes on `:root` (where the TSC emits root-scoped tokens anyway);
|
|
* - a token for a size / state / part that is not on screen cannot move
|
|
* anything, so the probe OPENS the component and sweeps every size;
|
|
* - a sentinel value equal to the real one reads as "no effect", so the values
|
|
* are deliberately absurd.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { chromium } from 'playwright';
|
|
|
|
const PROPS = [
|
|
'backgroundColor',
|
|
'backgroundImage',
|
|
'color',
|
|
'borderTopColor',
|
|
'borderTopWidth',
|
|
'borderTopLeftRadius',
|
|
'paddingTop',
|
|
'paddingLeft',
|
|
'paddingRight',
|
|
'paddingBottom',
|
|
'marginBottom',
|
|
'marginLeft',
|
|
'rowGap',
|
|
'columnGap',
|
|
'fontSize',
|
|
'fontWeight',
|
|
'fontFamily',
|
|
'lineHeight',
|
|
'letterSpacing',
|
|
'blockSize',
|
|
'inlineSize',
|
|
'minBlockSize',
|
|
'minInlineSize',
|
|
'maxBlockSize',
|
|
'maxInlineSize',
|
|
'boxShadow',
|
|
'opacity',
|
|
'outlineColor',
|
|
'outlineWidth',
|
|
'zIndex'
|
|
];
|
|
|
|
function sentinelFor(key: string): string {
|
|
if (/z$/.test(key)) return '4321';
|
|
if (/font-family/.test(key)) return 'Zapfino, cursive';
|
|
if (/font-weight/.test(key)) return '123';
|
|
if (/line-height/.test(key)) return '3.77';
|
|
if (/opacity/.test(key)) return '0.123';
|
|
if (/letter-spacing/.test(key)) return '4.5px';
|
|
if (/color|bg$|fg$|border$|ring$|separator|-bg-|fill|stroke|outline$/.test(key))
|
|
return 'rgb(1, 2, 3)';
|
|
if (/shadow/.test(key)) return '0 0 0 7px rgb(1, 2, 3)';
|
|
return '1234px';
|
|
}
|
|
|
|
async function main() {
|
|
const [component, urlArg] = process.argv.slice(2);
|
|
const url = urlArg ?? `http://localhost:5173/uix/components/${component}`;
|
|
const contract = readFileSync(resolve('src/uix/eidos/lib/recipes/base.ts'), 'utf8').replace(
|
|
/\r\n/g,
|
|
'\n'
|
|
);
|
|
const start = Math.max(
|
|
contract.indexOf(`\n\t'${component}': {`),
|
|
contract.indexOf(`\n\t${component}: {`)
|
|
);
|
|
if (start < 0) throw new Error(`no recipe block for ${component}`);
|
|
const end = contract.indexOf('\n\t},', start);
|
|
const block = contract.slice(start, end < 0 ? undefined : end);
|
|
const keys = [...block.matchAll(/^\t\t'?([a-z0-9-]+)'?\s*:/gm)]
|
|
.map((m) => m[1])
|
|
.filter((k) => !k.startsWith('_'));
|
|
|
|
const browser = await chromium.launch();
|
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
|
await page.goto(url, { waitUntil: 'networkidle' });
|
|
await page.waitForTimeout(600);
|
|
|
|
// Freeze transitions: a transitioned property reads its STARTING value right
|
|
// after the write, so a live token looked dead (measured on combobox's
|
|
// `box-shadow`, which transitions on `--duration-fast`).
|
|
await page.addStyleTag({
|
|
content: '*, *::before, *::after { transition: none !important; }'
|
|
});
|
|
|
|
// Open whatever can be opened, so portaled parts are in the document.
|
|
for (const sel of [
|
|
`[data-${component}-trigger]`,
|
|
`[data-${component}-input]`,
|
|
`[data-${component}-stop]`
|
|
]) {
|
|
const el = page.locator(sel).first();
|
|
if (await el.count()) {
|
|
try {
|
|
await el.click({ timeout: 1500 });
|
|
await page.waitForTimeout(400);
|
|
break;
|
|
} catch {
|
|
/* not clickable */
|
|
}
|
|
}
|
|
}
|
|
|
|
const dead: string[] = [];
|
|
const live: string[] = [];
|
|
const reopen = async () => {
|
|
const open = await page.locator(`[data-${component}-content]`).count();
|
|
if (open) return;
|
|
for (const sel of [`[data-${component}-trigger]`, `[data-${component}-input]`]) {
|
|
const el = page.locator(sel).first();
|
|
if (await el.count()) {
|
|
try {
|
|
await el.click({ timeout: 1000 });
|
|
await page.waitForTimeout(250);
|
|
return;
|
|
} catch {
|
|
/* keep trying */
|
|
}
|
|
}
|
|
}
|
|
};
|
|
for (const key of keys) {
|
|
await reopen();
|
|
const moved = await page.evaluate(
|
|
([kebab, token, value, props]) => {
|
|
const nodes = () =>
|
|
[...document.querySelectorAll<HTMLElement>('*')].filter((n) =>
|
|
[...n.attributes].some((a) => a.name.startsWith(`data-${kebab}`))
|
|
);
|
|
// A RESOLVED token is declared on `[data-{c}]`, so a `:root` write
|
|
// loses to it; a per-size COORDINATE lives in `:root`, and a portaled
|
|
// panel never sees the component root. Write BOTH places (measured).
|
|
const hosts = () => [
|
|
document.documentElement,
|
|
...([...document.querySelectorAll<HTMLElement>(`[data-${kebab}]`)] as HTMLElement[])
|
|
];
|
|
const sized = nodes().filter((n) => n.hasAttribute('data-size'));
|
|
const original = sized.map((n) => n.getAttribute('data-size'));
|
|
const snap = () =>
|
|
nodes()
|
|
.map((n) => {
|
|
const cs = getComputedStyle(n);
|
|
return (props as string[]).map((p) => cs[p as keyof CSSStyleDeclaration]).join('|');
|
|
})
|
|
.join('#');
|
|
|
|
let moved = false;
|
|
for (const size of ['md', 'xs', 'sm', 'lg', 'xl']) {
|
|
for (const n of sized) n.setAttribute('data-size', size);
|
|
const before = snap();
|
|
for (const h of hosts()) h.style.setProperty(`--${kebab}-${token}`, value as string);
|
|
const after = snap();
|
|
for (const h of hosts()) h.style.removeProperty(`--${kebab}-${token}`);
|
|
if (before !== after) {
|
|
moved = true;
|
|
break;
|
|
}
|
|
}
|
|
sized.forEach((n, i) => (original[i] ? n.setAttribute('data-size', original[i]!) : null));
|
|
return moved;
|
|
},
|
|
[component, key, sentinelFor(key), PROPS] as const
|
|
);
|
|
if (moved) live.push(key);
|
|
else dead.push(key);
|
|
}
|
|
|
|
await browser.close();
|
|
console.log(`sentinel ${component}: ${live.length}/${keys.length} tokens move a computed value`);
|
|
if (dead.length) console.log(`NO EFFECT (${dead.length}): ${dead.join(', ')}`);
|
|
process.exit(dead.length === 0 ? 0 : 1);
|
|
}
|
|
|
|
main();
|