Phone hit kit: pin the className expression, not its source text #140

Merged
void merged 2 commits from void/phone-hit-kit-strict into dev 2026-11-13 08:28:36 +00:00
2 changed files with 218 additions and 25 deletions

View file

@ -1,5 +1,14 @@
import { describe, expect, it } from 'vitest';
import { drawnPx, expectSizeTie, iconButtons } from './phoneHitTestKit';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { relative } from 'path';
import {
drawnPx,
expectPhoneHitClasses,
expectSizeTie,
iconButtons,
phoneHitOf,
} from './phoneHitTestKit';
describe('phone hit test kit', () => {
it('reads <IconButton only, not a longer component name', () => {
@ -38,4 +47,108 @@ describe('phone hit test kit', () => {
it('fails a file with no IconButton, which would otherwise pass vacuously', () => {
expect(() => expectSizeTie('styles/PhoneHit.css.ts')).toThrow(/no IconButton/);
});
describe('phoneHitOf reads the imported class off the AST, applied unconditionally', () => {
const tag = (expr: string) => `<IconButton size="300" className={${expr}}>`;
const ok = (expr: string, name: string) => expect(phoneHitOf(tag(expr))).toBe(name);
const no = (expr: string) => expect(phoneHitOf(tag(expr))).toBeNull();
it('accepts the bare identifier and merges that always include it', () => {
ok('PhoneHitSpread26', 'PhoneHitSpread26');
ok('classNames(PhoneHitRoom36, css.other)', 'PhoneHitRoom36');
ok('clsx(css.a, { [css.b]: on }, PhoneHit)', 'PhoneHit');
ok('cx(PhoneHit)', 'PhoneHit');
ok('`${PhoneHit} ${css.other}`', 'PhoneHit');
ok('classNames(/* PhoneHit */ PhoneHit)', 'PhoneHit');
});
it('rejects the name as a string, a concatenation or a comment', () => {
no("'PhoneHitSpread26'");
no('`PhoneHitSpread26`');
no('"PhoneHitSpread26" + "x"');
no("'PhoneHit' + 'Spread26'");
no('/* PhoneHit */ undefined');
no('css.a // PhoneHit');
});
it('rejects a conditionally applied name', () => {
no('cond ? PhoneHitSpread26 : undefined');
no('cond && PhoneHitSpread26');
no('cond || PhoneHitSpread26');
no('maybe ?? PhoneHitSpread26');
no("classNames(cond ? PhoneHit : '', css.a)");
no('classNames(PhoneHit, cond && PhoneHit)');
});
it('rejects every other expression shape the name can hide in', () => {
no('classNames({ PhoneHit: on })');
no('clsx({ [PhoneHit]: on })');
no('`${`${PhoneHit}`}`');
no('typeof PhoneHit');
no("(PhoneHit = 'x')");
no('(PhoneHit, css.a)');
no('(PhoneHit)');
no('() => PhoneHit');
no('PhoneHit.foo');
no('other.PhoneHit');
no('classNames(css.a, [PhoneHit])');
no('wrap(PhoneHit)');
no('PhoneHitSpread26é');
no('`${css.a}`');
});
it('rejects a tag without className, with two, or with a non-expression one', () => {
expect(phoneHitOf('<IconButton size="300">')).toBeNull();
expect(phoneHitOf('<IconButton className={PhoneHit} className={css.a}>')).toBeNull();
expect(phoneHitOf('<IconButton className="PhoneHit">')).toBeNull();
expect(phoneHitOf('<IconButton className={PhoneHit} />')).toBe('PhoneHit');
});
it('rejects a spread after className, which can override it, and accepts one before', () => {
expect(phoneHitOf('<IconButton className={PhoneHit} {...props}>')).toBeNull();
expect(phoneHitOf('<IconButton className={PhoneHit} size="300" {...props} />')).toBeNull();
expect(phoneHitOf('<IconButton {...props} className={PhoneHit}>')).toBe('PhoneHit');
});
it('reads a className past braces inside strings', () => {
ok("classNames('{', PhoneHit)", 'PhoneHit');
});
});
describe('expectPhoneHitClasses', () => {
const check = (src: string, want: (string | null)[]) => {
const dir = mkdtempSync(`${tmpdir()}/phone-hit-kit-`);
try {
writeFileSync(`${dir}/Probe.tsx`, src);
expectPhoneHitClasses(relative(`${process.cwd()}/src/app`, `${dir}/Probe.tsx`), want);
} finally {
rmSync(dir, { recursive: true });
}
};
const imp = "import { PhoneHit, PhoneHitSpread26 } from '../../styles/PhoneHit.css';\n";
const src =
imp +
'const A = () => (<><IconButton className={PhoneHit} /><IconButton className={classNames(PhoneHitSpread26, a)} /><IconButton /></>);';
it('pins each button in order, null meaning no class', () => {
check(src, ['PhoneHit', 'PhoneHitSpread26', null]);
expect(() => check(src, ['PhoneHit', 'PhoneHit', null])).toThrow();
expect(() => check(src, ['PhoneHit', 'PhoneHitSpread26', 'PhoneHit'])).toThrow();
expect(() => check(src, ['PhoneHit', 'PhoneHitSpread26'])).toThrow();
});
it('fails a name that is not read from PhoneHit.css or is applied conditionally', () => {
const local = "const PhoneHit = 'x';\n<IconButton className={PhoneHit} />";
expect(() => check(local, ['PhoneHit'])).toThrow();
expect(() => check(local, [null])).not.toThrow();
const cond = imp + '<IconButton className={on ? PhoneHit : undefined} />';
expect(() => check(cond, ['PhoneHit'])).toThrow();
const aliased =
"import { Other as PhoneHit } from '../../styles/PhoneHit.css';\n<IconButton className={PhoneHit} />";
expect(() => check(aliased, ['PhoneHit'])).toThrow();
const wrongModule =
"import { PhoneHit } from './other';\n<IconButton className={PhoneHit} />";
expect(() => check(wrongModule, ['PhoneHit'])).toThrow();
});
});
});

View file

@ -2,6 +2,7 @@
// group of IconButtons). Not a test file: it registers nothing, the test files call it.
import { readFileSync } from 'fs';
import { expect } from 'vitest';
import ts from 'typescript';
import zlib from 'zlib';
import { compile, processVanillaFile } from '@vanilla-extract/integration';
@ -72,36 +73,113 @@ const tagEnd = (src: string, from: number) => {
return j;
};
/** Every <IconButton (not <IconButtonX): its opening tag, read to its closing > outside any {} expression, and
* what it wraps (empty when self-closing). */
const tsx = (src: string) =>
ts.createSourceFile('source.tsx', src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
type Opening = ts.JsxOpeningElement | ts.JsxSelfClosingElement;
/** Every IconButton element (not IconButtonX) of a parsed source, in source order. */
const elements = (sf: ts.SourceFile) => {
const found: Opening[] = [];
const walk = (n: ts.Node) => {
if (
(ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) &&
ts.isIdentifier(n.tagName) &&
n.tagName.text === 'IconButton'
)
found.push(n);
ts.forEachChild(n, walk);
};
walk(sf);
return found;
};
const bodyOf = (el: Opening) =>
ts.isJsxOpeningElement(el) && ts.isJsxElement(el.parent)
? el.getSourceFile().text.slice(el.end, el.parent.closingElement.getStart())
: '';
/** Every <IconButton (not <IconButtonX): its opening tag and what it wraps (empty when self-closing). */
export const iconButtons = (src: string) =>
[...src.matchAll(/<IconButton(?=[\s>/])/g)].map((m) => {
const start = m.index ?? 0;
const end = tagEnd(src, start);
const tag = src.slice(start, end + 1);
const close = src.indexOf('</IconButton>', end);
return { tag, body: tag.endsWith('/>') || close === -1 ? '' : src.slice(end + 1, close) };
});
elements(tsx(src)).map((el) => ({ tag: el.getText(), body: bodyOf(el) }));
export const iconButtonTags = (src: string) => iconButtons(src).map((b) => b.tag);
const phoneHitOf = (tag: string) => tag.match(/className=\{[^}]*?\b(PhoneHit\w*)\b/)?.[1] ?? null;
/** The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size and
* whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. Also checks
* the name is the one PhoneHit.css.ts exports, not a local of the same name. */
export const expectPhoneHitClasses = (file: string, want: string[]) => {
const src = readSource(file);
expect(iconButtonTags(src).map(phoneHitOf)).toEqual(want);
[...new Set(want)].forEach((name) =>
expect(src).toMatch(
new RegExp(
`import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'`
)
)
/** Local names the source imports from the PhoneHit.css module (as written, not aliased). */
const importedHits = (sf: ts.SourceFile) =>
new Set(
sf.statements.flatMap((st) => {
const nb = ts.isImportDeclaration(st) ? st.importClause?.namedBindings : undefined;
return nb &&
ts.isNamedImports(nb) &&
ts.isStringLiteral((st as ts.ImportDeclaration).moduleSpecifier) &&
/^(\.\.\/)+styles\/PhoneHit\.css$/.test(
((st as ts.ImportDeclaration).moduleSpecifier as ts.StringLiteral).text
)
? nb.elements.filter((e) => !e.propertyName).map((e) => e.name.text)
: [];
})
);
const MERGERS = new Set(['classNames', 'clsx', 'cx']);
/** The PhoneHit* class an IconButton carries, or null. className must appear exactly once, with no props spread
* after it (a spread can override it at runtime), and its expression must be: the bare identifier; a classNames /
* clsx / cx call with it as a direct argument; or a template with it as a direct span. The identifier must occur
* nowhere else in the expression, so a key, a member, a closure, a conditional or a comma can never carry it. When
* `imported` is given, the identifier must also be a binding imported from the PhoneHit.css module. */
const hitOf = (el: Opening, imported?: Set<string>): string | null => {
const props = el.attributes.properties;
const at = props.flatMap((p, i) =>
ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text === 'className' ? [i] : []
);
if (at.length !== 1 || props.slice(at[0] + 1).some(ts.isJsxSpreadAttribute)) return null;
const attr = props[at[0]] as ts.JsxAttribute;
const expr =
attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression;
if (!expr) return null;
const isHit = (n: ts.Node): n is ts.Identifier =>
ts.isIdentifier(n) && /^PhoneHit\w*$/.test(n.text) && (!imported || imported.has(n.text));
let direct: ts.Node[] = [];
if (ts.isIdentifier(expr)) direct = [expr];
else if (
ts.isCallExpression(expr) &&
ts.isIdentifier(expr.expression) &&
MERGERS.has(expr.expression.text)
)
direct = [...expr.arguments];
else if (ts.isTemplateExpression(expr)) direct = expr.templateSpans.map((sp) => sp.expression);
const hits = direct.filter(isHit);
if (hits.length !== 1) return null;
let occurrences = 0;
const count = (n: ts.Node) => {
if (ts.isIdentifier(n) && n.text === hits[0].text) occurrences += 1;
ts.forEachChild(n, count);
};
count(expr);
return occurrences === 1 ? hits[0].text : null;
};
/** The PhoneHit* class of one tag's text (see hitOf), without the import-binding check. */
export const phoneHitOf = (tag: string) => {
const els = elements(tsx(tag.endsWith('/>') ? tag : `${tag}</IconButton>`));
return els.length === 1 ? hitOf(els[0]) : null;
};
/** The PhoneHit* class of each IconButton of a source, in source order; null when it has none or the name is not
* the one imported from PhoneHit.css. */
const phoneHitClasses = (src: string) => {
const sf = tsx(src);
const imported = importedHits(sf);
return elements(sf).map((el) => hitOf(el, imported));
};
/** The class each IconButton must carry, in source order (null = must carry none): the variant is chosen by the
* button's drawn size and whether a clipping container or neighbours surround it, so a swapped variant is a wrong
* hit area. The name must also be the one PhoneHit.css exports, not a local of the same name. */
export const expectPhoneHitClasses = (file: string, want: (string | null)[]) =>
expect(phoneHitClasses(readSource(file))).toEqual(want);
// IconButton `size` -> padding on each side, Icon `size` -> px (folds 2.7.2). Both default to 400.
const BUTTON_PAD: Record<string, number> = { '300': 4, '400': 8, '500': 12, '600': 16 };
const ICON_PX: Record<string, number> = {
@ -147,10 +225,12 @@ export type SizeAllow = Record<string, Record<number, string>>;
export const expectSizeTie = (file: string, allow: SizeAllow = {}) => {
const allowed = allow[file] ?? {};
const seen = new Set<number>();
const buttons = iconButtons(readSource(file));
const src = readSource(file);
const buttons = iconButtons(src);
const classes = phoneHitClasses(src);
expect(buttons.length, `${file} has no IconButton`).toBeGreaterThan(0);
buttons.forEach(({ tag, body }, i) => {
const variant = phoneHitOf(tag)?.match(/^PhoneHit(?:Spread|Room)(\d+)$/);
const variant = classes[i]?.match(/^PhoneHit(?:Spread|Room)(\d+)$/);
if (!variant) {
expect(allowed[i], `${file} #${i} is allow-listed but has no sized variant`).toBeUndefined();
return;