All files / packages/design-system-2/scripts/vrt-core css-diff.ts

96.61% Statements 57/59
87.5% Branches 21/24
100% Functions 10/10
96.55% Lines 56/58

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139                                          6x   6x 6x           6x 11x 11x 11x 4x 4x 4x   11x     11x 13x 13x 13x 13x 13x   13x   16x         6x       3x 3x 3x     3x 6x 6x 8x 8x 1x 7x 6x           3x 7x 7x 8x 1x         3x           3x 6x 6x 6x 2x   6x         3x         10x               6x 6x 5x     5x   6x         1x 1x 3x     3x 3x   1x    
import postcss, { Rule, Declaration } from 'postcss';
 
// Tier-1 CSS attribution: text-diffs bundle A (develop) vs bundle B (this PR) to
// find WHAT changed. Pairing a change to a given page's pixels (Tier 2) and to
// an exact declaration per pixel (Tier 3) are handled elsewhere / out of scope.
 
export interface ChangedDecl {
  selector: string;
  prop: string;
  // undefined on a pure add (no A value) or pure remove (no B value).
  valueA?: string;
  valueB?: string;
  kind: 'added' | 'removed' | 'changed';
}
 
// selector -> (prop -> value). Last declaration wins, mirroring the cascade.
type DeclMap = Map<string, Map<string, string>>;
 
// Flatten a stylesheet into selector→prop→value, namespacing nested at-rules
// (@media etc.) into the key so they don't collide with the same top-level selector.
function flatten(css: string): DeclMap {
  const out: DeclMap = new Map();
  let root;
  try {
    root = postcss.parse(css);
  } catch {
    // Unparseable bundle shouldn't crash the report; just yield no rules.
    return out;
  }
 
  root.walkRules((rule: Rule) => {
    const prefixes: string[] = [];
    let parent = rule.parent;
    while (parent && parent.type === 'atrule') {
      const at = parent as unknown as { name: string; params: string };
      prefixes.unshift(`@${at.name} ${at.params}`);
      parent = (parent as unknown as { parent?: typeof parent }).parent;
    }
    const prefix = prefixes.length ? `${prefixes.join(' | ')} | ` : '';
 
    // A comma-separated selector list is recorded one selector at a time.
    for (const sel of rule.selectors ?? [rule.selector]) {
      const key = `${prefix}${sel.trim()}`;
      let props = out.get(key);
      Eif (!props) {
        props = new Map();
        out.set(key, props);
      }
      rule.walkDecls((decl: Declaration) => {
        // PostCSS keeps !important out of decl.value; add it back so toggling it counts as a change.
        props!.set(decl.prop, decl.important ? `${decl.value} !important` : decl.value);
      });
    }
  });
 
  return out;
}
 
export function diffCssBundles(cssA: string, cssB: string): ChangedDecl[] {
  const a = flatten(cssA);
  const b = flatten(cssB);
  const changes: ChangedDecl[] = [];
 
  // Walk B: catches added selectors/props and changed values.
  for (const [selector, bProps] of b) {
    const aProps = a.get(selector);
    for (const [prop, valueB] of bProps) {
      const valueA = aProps?.get(prop);
      if (valueA === undefined) {
        changes.push({ selector, prop, valueB, kind: 'added' });
      } else if (valueA !== valueB) {
        changes.push({ selector, prop, valueA, valueB, kind: 'changed' });
      }
    }
  }
 
  // Walk A: catches removed selectors/props (present in A, gone in B).
  for (const [selector, aProps] of a) {
    const bProps = b.get(selector);
    for (const [prop, valueA] of aProps) {
      if (!bProps || !bProps.has(prop)) {
        changes.push({ selector, prop, valueA, kind: 'removed' });
      }
    }
  }
 
  return changes;
}
 
// Exact match ignoring comments (the build banner embeds the commit SHA). Unlike the declaration
// diff, it also catches rule order and at-rules such as @font-face.
export function sameCss(cssA: string, cssB: string): boolean {
  const strip = (css: string) => {
    try {
      const root = postcss.parse(css);
      root.walkComments((c) => {
        c.remove();
      });
      return root.toString();
    } catch {
      return css;
    }
  };
  return strip(cssA) === strip(cssB);
}
 
// Pseudo/state selectors that querySelectorAll can't match statically, so a page can't rule them out.
export function isUntestableSelector(sel: string): boolean {
  return /::|:hover|:focus|:active|:visited|:checked|:disabled|:enabled|:target|:not\(|:is\(|:where\(|:has\(/.test(
    sel,
  );
}
 
// Unique selectors touched by the PR's CSS change, with the at-rule prefix
// stripped so they can be handed to querySelectorAll.
export function changedSelectors(changes: ChangedDecl[]): string[] {
  const set = new Set<string>();
  for (const c of changes) {
    const bare = c.selector.includes(' | ')
      ? c.selector.slice(c.selector.lastIndexOf(' | ') + 3)
      : c.selector;
    set.add(bare.trim());
  }
  return [...set];
}
 
// Bare selector -> the props the PR changed on it.
export function changedRuleProps(changes: ChangedDecl[]): Record<string, string[]> {
  const out: Record<string, string[]> = {};
  for (const c of changes) {
    const bare = (
      c.selector.includes(' | ') ? c.selector.slice(c.selector.lastIndexOf(' | ') + 3) : c.selector
    ).trim();
    const props = (out[bare] ??= []);
    Eif (!props.includes(c.prop)) props.push(c.prop);
  }
  return out;
}