mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
feat(daemon): add CTA hierarchy static QA pass (refs #2251) (#2427)
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 0s
landing-page-ci / Validate landing page (push) Failing after 1s
landing-page-deploy / Deploy landing page (push) Has been skipped
nix-check / build (push) Failing after 1s
ci / Validate Nix flake (push) Failing after 0s
ci / Preflight (push) Failing after 1s
ci / Core package tests (push) Failing after 1s
ci / Tools workspace tests (push) Failing after 1s
ci / Daemon workspace tests (1/2) (push) Failing after 1s
ci / Daemon workspace tests (2/2) (push) Failing after 1s
ci / Web workspace tests (push) Failing after 1s
ci / E2E vitest (push) Failing after 1s
ci / Playwright critical (starters) (push) Failing after 1s
ci / Playwright critical (core) (push) Failing after 1s
ci / Build workspaces (push) Failing after 1s
ci / App workspace tests (push) Failing after 1s
ci / Validate workspace (push) Failing after 1s
ci / Runtime trace (push) Has been skipped
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 0s
landing-page-ci / Validate landing page (push) Failing after 1s
landing-page-deploy / Deploy landing page (push) Has been skipped
nix-check / build (push) Failing after 1s
ci / Validate Nix flake (push) Failing after 0s
ci / Preflight (push) Failing after 1s
ci / Core package tests (push) Failing after 1s
ci / Tools workspace tests (push) Failing after 1s
ci / Daemon workspace tests (1/2) (push) Failing after 1s
ci / Daemon workspace tests (2/2) (push) Failing after 1s
ci / Web workspace tests (push) Failing after 1s
ci / E2E vitest (push) Failing after 1s
ci / Playwright critical (starters) (push) Failing after 1s
ci / Playwright critical (core) (push) Failing after 1s
ci / Build workspaces (push) Failing after 1s
ci / App workspace tests (push) Failing after 1s
ci / Validate workspace (push) Failing after 1s
ci / Runtime trace (push) Has been skipped
* feat(daemon): add CTA hierarchy static QA pass Introduce apps/daemon/src/qa/cta-hierarchy.ts exporting a pure analyseCtaHierarchy(html) that parses generated prototypes with cheerio and flags three precision-biased findings: multiple-primary CTAs in the same section, ambiguous-weight (all CTAs share identical class + inline style), and misleading-prominence (secondary-coded copy like "Learn more" / "了解更多" styled with primary weight). CTA candidates come from <button>, <a>, role="button" with btn/button/cta class markers plus CTA copy keywords covering both English (Get started, Sign up, Buy, Subscribe, Learn more, ...) and Chinese (立即购买, 立即下单, 了解更多, ...). Weight is inferred from class tokens (primary/solid/filled/accent/cta) and from non-transparent inline background-color, matching the inverse of the issue #2251 sample where the header CTA was rendered with the neutral .btn style. This PR only ships the pure function plus its tests. HTTP route, CLI subcommand, and any auto-repair feedback loop are deliberate follow-ups so the first cut can land without touching the daemon HTTP surface. Refs #2251 * fix(qa): respect container boundaries in CTA hierarchy heuristics Two precision fixes from review of #2427: - computeContainerKey()'s parent fallback keyed by tag name alone, so flat layouts like <div><a class=btn-primary>...</a></div> repeated for sibling cards all landed in 'parent:div' and detectMultiplePrimary() reported a fake shared-section conflict on what is in fact one CTA per card. Switch to parent-node identity (positional index of the matched parent within its tag group, same trick the landmark branch already uses), so each sibling wrapper gets its own bucket. - detectAmbiguousWeight() compared signatures across the entire document, so two unrelated sections each containing one '.btn' CTA with matching style would trigger 'ambiguous-weight' despite neither container having 2+ CTAs. The PR body's rule is narrower — 'every CTA in a container shares the same class + inline style' — so bucket by containerKey first and only emit the finding for containers with 2+ CTAs whose signatures are identical. Tests lock both behaviors down: - sibling <div> card-grid without a landmark ancestor stays under the multiple-primary threshold; - one-CTA-per-section pairs stay under the ambiguous-weight threshold.
This commit is contained in:
parent
3743c65e6e
commit
3e2f037730
4 changed files with 617 additions and 0 deletions
|
|
@ -44,6 +44,7 @@
|
|||
"@opentelemetry/api": "1.9.1",
|
||||
"better-sqlite3": "12.10.0",
|
||||
"blake3-wasm": "2.1.5",
|
||||
"cheerio": "1.2.0",
|
||||
"chokidar": "5.0.0",
|
||||
"express": "4.22.1",
|
||||
"jszip": "3.10.1",
|
||||
|
|
|
|||
378
apps/daemon/src/qa/cta-hierarchy.ts
Normal file
378
apps/daemon/src/qa/cta-hierarchy.ts
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
// Post-generation static QA pass for CTA (call-to-action) hierarchy.
|
||||
//
|
||||
// Background: Open Design's generated HTML/CSS prototypes are sometimes
|
||||
// "functionally correct but feel unfinished" — a primary commerce action
|
||||
// renders as a neutral button, two equally-styled CTAs compete for the
|
||||
// same conversion slot, or a "Learn more" link is styled like the buy
|
||||
// button. See nexu-io/open-design#2251 for the motivating bug report.
|
||||
//
|
||||
// `analyseCtaHierarchy` parses the rendered HTML and returns a small set
|
||||
// of conservative findings. It is intentionally precision-biased: when
|
||||
// the signal is weak, the function says nothing. The output is the
|
||||
// first-useful-version of the QA pass; HTTP/CLI exposure and auto-repair
|
||||
// are explicit follow-ups.
|
||||
|
||||
import { type CheerioAPI, load } from 'cheerio';
|
||||
|
||||
// Cheerio 1.x doesn't re-export the underlying `AnyNode`/`Element` types
|
||||
// from `domhandler`. Importing `domhandler` directly would punch through
|
||||
// daemon's declared dependency boundary, so we derive a generic "node
|
||||
// collection" type from the API surface we actually use.
|
||||
type CheerioCollection = ReturnType<CheerioAPI>;
|
||||
type CheerioNode = CheerioCollection extends ArrayLike<infer N> ? N : never;
|
||||
|
||||
export interface CtaHierarchyIssue {
|
||||
/** Category of the finding; the UI may surface different copy per kind. */
|
||||
kind: 'multiple-primary' | 'ambiguous-weight' | 'misleading-prominence';
|
||||
/** Short CSS-like selector for the offending element, e.g. `a.btn.btn-primary`. */
|
||||
selector: string;
|
||||
/** One-sentence English description of the issue, suitable for a warning UI. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CtaHierarchyReport {
|
||||
issues: CtaHierarchyIssue[];
|
||||
primaryCount: number;
|
||||
secondaryCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the rendered HTML and return CTA-hierarchy findings.
|
||||
*
|
||||
* The function is deterministic and side-effect free. Returns an empty
|
||||
* report (no issues, zero counts) when the document has no CTA-shaped
|
||||
* elements. The set of rules is intentionally narrow — see the issue list
|
||||
* in this file for the categories we currently surface.
|
||||
*/
|
||||
export function analyseCtaHierarchy(html: string): CtaHierarchyReport {
|
||||
const $ = load(html);
|
||||
const candidates = collectCtaCandidates($);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { issues: [], primaryCount: 0, secondaryCount: 0 };
|
||||
}
|
||||
|
||||
const primaryCount = candidates.filter((cta) => cta.weight === 'primary').length;
|
||||
const secondaryCount = candidates.length - primaryCount;
|
||||
|
||||
const issues: CtaHierarchyIssue[] = [
|
||||
...detectMultiplePrimary(candidates),
|
||||
...detectAmbiguousWeight(candidates),
|
||||
...detectMisleadingProminence(candidates),
|
||||
];
|
||||
|
||||
return { issues, primaryCount, secondaryCount };
|
||||
}
|
||||
|
||||
// ---------- internals --------------------------------------------------------
|
||||
|
||||
type Weight = 'primary' | 'secondary';
|
||||
|
||||
interface CtaCandidate {
|
||||
el: CheerioCollection;
|
||||
text: string;
|
||||
classes: string[];
|
||||
inlineStyle: string;
|
||||
weight: Weight;
|
||||
selector: string;
|
||||
/** Section/container key used to group multiple-primary findings. */
|
||||
containerKey: string;
|
||||
}
|
||||
|
||||
// Conversion-oriented copy that strongly suggests the element is the page's
|
||||
// commercial action. Mix of English and Simplified Chinese, matched
|
||||
// case-insensitively as substrings on the element's text content.
|
||||
const CTA_KEYWORDS = [
|
||||
// English
|
||||
'get started',
|
||||
'sign up',
|
||||
'sign in',
|
||||
'log in',
|
||||
'buy',
|
||||
'shop now',
|
||||
'subscribe',
|
||||
'subscribe now',
|
||||
'try it free',
|
||||
'start free trial',
|
||||
'free trial',
|
||||
'add to cart',
|
||||
'order now',
|
||||
'checkout',
|
||||
'continue',
|
||||
'submit',
|
||||
// Secondary English (still CTA-shaped — flagged separately below).
|
||||
'learn more',
|
||||
'read more',
|
||||
'more info',
|
||||
'see more',
|
||||
// Chinese
|
||||
'开始',
|
||||
'立即',
|
||||
'查看',
|
||||
'购买',
|
||||
'选购',
|
||||
'下单',
|
||||
'提交',
|
||||
'加入购物车',
|
||||
'加入询价车',
|
||||
'免费试用',
|
||||
'了解更多',
|
||||
'更多',
|
||||
'详情',
|
||||
];
|
||||
|
||||
// Copy that signals a SECONDARY CTA. If the visual weight on these elements
|
||||
// reads as primary, that's the misleading-prominence case.
|
||||
const SECONDARY_KEYWORDS = [
|
||||
'learn more',
|
||||
'read more',
|
||||
'more info',
|
||||
'see more',
|
||||
'了解更多',
|
||||
'更多',
|
||||
'详情',
|
||||
];
|
||||
|
||||
function collectCtaCandidates($: CheerioAPI): CtaCandidate[] {
|
||||
const candidates: CtaCandidate[] = [];
|
||||
|
||||
// 1. Every <button>, every <a>, and anything with role="button" is a
|
||||
// structural candidate. We then filter on copy + class signals.
|
||||
const selector = 'button, a, [role="button"]';
|
||||
$(selector).each((_, node) => {
|
||||
const el = $(node);
|
||||
const text = normaliseText(el.text());
|
||||
const classes = parseClasses(el.attr('class'));
|
||||
const role = el.attr('role') ?? '';
|
||||
const tag = (node as { tagName?: string; name?: string }).tagName ?? (node as { name?: string }).name ?? '';
|
||||
|
||||
// A button-shaped element is a CTA candidate when EITHER:
|
||||
// (a) its class list contains a btn/button/cta marker, OR
|
||||
// (b) its tag is <button> AND the copy matches a CTA keyword, OR
|
||||
// (c) it carries role="button".
|
||||
// Plain anchors without any of these signals are skipped — there are
|
||||
// usually many of them in nav menus and they'd produce noise.
|
||||
const hasButtonClass = classes.some((c) => /(^|-)btn$|(^|-)button$|(^|-)cta$|^btn(-|$)|^button(-|$)|^cta(-|$)/i.test(c));
|
||||
const isButtonTag = tag.toLowerCase() === 'button';
|
||||
const isRoleButton = role.toLowerCase() === 'button';
|
||||
const hasCtaCopy = matchesAnyKeyword(text, CTA_KEYWORDS);
|
||||
|
||||
if (!hasButtonClass && !isRoleButton && !(isButtonTag && hasCtaCopy)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Even after the structural filter, drop elements whose copy is not
|
||||
// CTA-shaped at all (e.g. icon toggles like "+" / "−"). Anchors with a
|
||||
// .btn class but no actionable copy frequently appear as inert chips.
|
||||
if (!hasCtaCopy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inlineStyle = normaliseStyle(el.attr('style'));
|
||||
const weight = classifyWeight(classes, inlineStyle);
|
||||
const containerKey = computeContainerKey($, el);
|
||||
|
||||
candidates.push({
|
||||
el,
|
||||
text,
|
||||
classes,
|
||||
inlineStyle,
|
||||
weight,
|
||||
selector: buildSelector(tag, classes, role),
|
||||
containerKey,
|
||||
});
|
||||
});
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function classifyWeight(classes: string[], inlineStyle: string): Weight {
|
||||
// Class-name signals are the strongest tell — design systems almost
|
||||
// always tag the primary variant with one of these tokens.
|
||||
const PRIMARY_CLASS_TOKENS = /(^|[-_])(primary|solid|filled|accent|cta)([-_]|$)/i;
|
||||
if (classes.some((c) => PRIMARY_CLASS_TOKENS.test(c))) {
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
// Otherwise infer from inline style: a non-transparent background colour
|
||||
// is the dominant visual signal users read as "this is the main button".
|
||||
// We don't try to evaluate computed CSS — that would require a full
|
||||
// rendering layer; inline style is enough for the precision-biased pass.
|
||||
if (hasNonTransparentBackground(inlineStyle)) {
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
function hasNonTransparentBackground(inlineStyle: string): boolean {
|
||||
const bg = extractStyleValue(inlineStyle, 'background-color') ?? extractStyleValue(inlineStyle, 'background');
|
||||
if (!bg) return false;
|
||||
const value = bg.toLowerCase().trim();
|
||||
if (!value) return false;
|
||||
if (value === 'transparent' || value === 'none' || value === 'inherit' || value === 'initial') return false;
|
||||
if (/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(value)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function extractStyleValue(inlineStyle: string, property: string): string | null {
|
||||
if (!inlineStyle) return null;
|
||||
// Tolerant declaration parser — we don't need full CSS fidelity here.
|
||||
const declarations = inlineStyle.split(';');
|
||||
for (const decl of declarations) {
|
||||
const colon = decl.indexOf(':');
|
||||
if (colon < 0) continue;
|
||||
const name = decl.slice(0, colon).trim().toLowerCase();
|
||||
if (name === property.toLowerCase()) {
|
||||
return decl.slice(colon + 1).trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function computeContainerKey($: CheerioAPI, el: CheerioCollection): string {
|
||||
// Group CTAs by their nearest landmark/section ancestor. Falls back to
|
||||
// the direct parent so that a flat document (no <section>) still gives
|
||||
// us a meaningful grouping for the multiple-primary rule.
|
||||
const landmarks = ['section', 'header', 'footer', 'nav', 'main', 'aside', 'article'];
|
||||
for (const tag of landmarks) {
|
||||
const ancestor = el.closest(tag);
|
||||
if (ancestor.length > 0) {
|
||||
const node = ancestor[0];
|
||||
if (node) {
|
||||
// The DOM identity of the ancestor is stable within one parse, so
|
||||
// we can use a positional index to differentiate two <section>s.
|
||||
const all = $(tag).toArray();
|
||||
const index = all.indexOf(node);
|
||||
return `${tag}:${index}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keyed by parent-node identity, not just tag name: two sibling <div>s
|
||||
// each holding one .btn-primary CTA must NOT collapse into the same
|
||||
// bucket, otherwise detectMultiplePrimary() reports a shared-section
|
||||
// conflict on a flat layout where each card has only one CTA.
|
||||
const parent = el.parent();
|
||||
if (parent.length > 0) {
|
||||
const parentNode = parent[0];
|
||||
if (parentNode) {
|
||||
const parentTag =
|
||||
(parentNode as { tagName?: string }).tagName ?? (parentNode as { name?: string }).name ?? 'root';
|
||||
const all = $(parentTag).toArray();
|
||||
const index = all.indexOf(parentNode);
|
||||
return `parent:${parentTag}:${index}`;
|
||||
}
|
||||
}
|
||||
return 'parent:root';
|
||||
}
|
||||
|
||||
function detectMultiplePrimary(candidates: CtaCandidate[]): CtaHierarchyIssue[] {
|
||||
const byContainer = new Map<string, CtaCandidate[]>();
|
||||
for (const cta of candidates) {
|
||||
if (cta.weight !== 'primary') continue;
|
||||
const bucket = byContainer.get(cta.containerKey) ?? [];
|
||||
bucket.push(cta);
|
||||
byContainer.set(cta.containerKey, bucket);
|
||||
}
|
||||
|
||||
const issues: CtaHierarchyIssue[] = [];
|
||||
for (const bucket of byContainer.values()) {
|
||||
if (bucket.length < 2) continue;
|
||||
// Report the SECOND+ primary CTA in each container as the offender:
|
||||
// the first one is the legitimate primary, the rest dilute it.
|
||||
for (let i = 1; i < bucket.length; i += 1) {
|
||||
const cta = bucket[i];
|
||||
if (!cta) continue;
|
||||
issues.push({
|
||||
kind: 'multiple-primary',
|
||||
selector: cta.selector,
|
||||
message: `Multiple primary CTAs share the same section; "${truncate(cta.text)}" competes with the section's main action.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function detectAmbiguousWeight(candidates: CtaCandidate[]): CtaHierarchyIssue[] {
|
||||
if (candidates.length < 2) return [];
|
||||
// Bucket by containerKey first: comparing signatures across the
|
||||
// entire document yields false positives when two unrelated sections
|
||||
// happen to share the same `.btn` styling but each holds only one
|
||||
// CTA. The rule is "every CTA in a container shares the same class +
|
||||
// inline style", so the container boundary must hold.
|
||||
const byContainer = new Map<string, CtaCandidate[]>();
|
||||
for (const cta of candidates) {
|
||||
const bucket = byContainer.get(cta.containerKey) ?? [];
|
||||
bucket.push(cta);
|
||||
byContainer.set(cta.containerKey, bucket);
|
||||
}
|
||||
const issues: CtaHierarchyIssue[] = [];
|
||||
for (const bucket of byContainer.values()) {
|
||||
if (bucket.length < 2) continue;
|
||||
const first = bucket[0];
|
||||
if (!first) continue;
|
||||
const reference = signature(first);
|
||||
if (!bucket.every((cta) => signature(cta) === reference)) continue;
|
||||
// Report the second one (the first is the natural anchor; subsequent
|
||||
// identical CTAs are the ones a reviewer would diff against).
|
||||
const cta = bucket[1];
|
||||
if (!cta) continue;
|
||||
issues.push({
|
||||
kind: 'ambiguous-weight',
|
||||
selector: cta.selector,
|
||||
message: `All CTAs share identical class and inline style; the visual hierarchy is ambiguous.`,
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function detectMisleadingProminence(candidates: CtaCandidate[]): CtaHierarchyIssue[] {
|
||||
const issues: CtaHierarchyIssue[] = [];
|
||||
for (const cta of candidates) {
|
||||
if (cta.weight !== 'primary') continue;
|
||||
if (!matchesAnyKeyword(cta.text, SECONDARY_KEYWORDS)) continue;
|
||||
issues.push({
|
||||
kind: 'misleading-prominence',
|
||||
selector: cta.selector,
|
||||
message: `"${truncate(cta.text)}" reads as a secondary action but is styled with primary-weight visuals.`,
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function signature(cta: CtaCandidate): string {
|
||||
const classes = [...cta.classes].sort().join('.');
|
||||
return `${classes}|${cta.inlineStyle}`;
|
||||
}
|
||||
|
||||
function buildSelector(tag: string, classes: string[], role: string): string {
|
||||
const tagPart = tag.toLowerCase() || 'element';
|
||||
const classPart = classes.length > 0 ? `.${classes.join('.')}` : '';
|
||||
const rolePart = role && tagPart !== 'button' ? `[role="${role.toLowerCase()}"]` : '';
|
||||
return `${tagPart}${classPart}${rolePart}`;
|
||||
}
|
||||
|
||||
function parseClasses(raw: string | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
return raw.split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function normaliseText(raw: string): string {
|
||||
return raw.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normaliseStyle(raw: string | undefined): string {
|
||||
if (!raw) return '';
|
||||
return raw.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function matchesAnyKeyword(text: string, keywords: readonly string[]): boolean {
|
||||
if (!text) return false;
|
||||
const lower = text.toLowerCase();
|
||||
return keywords.some((kw) => lower.includes(kw.toLowerCase()));
|
||||
}
|
||||
|
||||
function truncate(text: string, max = 40): string {
|
||||
if (text.length <= max) return text;
|
||||
return `${text.slice(0, max - 1)}…`;
|
||||
}
|
||||
151
apps/daemon/tests/qa-cta-hierarchy.test.ts
Normal file
151
apps/daemon/tests/qa-cta-hierarchy.test.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { analyseCtaHierarchy } from '../src/qa/cta-hierarchy.js';
|
||||
|
||||
// These tests pin down the first-useful-version contract for the CTA hierarchy
|
||||
// static QA pass. The function is intentionally conservative: precision > recall.
|
||||
// Adding new heuristics is fine, but they must not regress any case here.
|
||||
|
||||
describe('analyseCtaHierarchy', () => {
|
||||
it('returns an empty report when the document has no buttons or anchors', () => {
|
||||
const report = analyseCtaHierarchy('<main><p>Welcome to the site</p></main>');
|
||||
expect(report.issues).toEqual([]);
|
||||
expect(report.primaryCount).toBe(0);
|
||||
expect(report.secondaryCount).toBe(0);
|
||||
});
|
||||
|
||||
it('does not flag a single clear primary CTA paired with a secondary CTA', () => {
|
||||
const html = `
|
||||
<section>
|
||||
<a class="btn btn-primary" href="/signup">Get started</a>
|
||||
<a class="btn" href="/learn-more">Learn more</a>
|
||||
</section>
|
||||
`;
|
||||
const report = analyseCtaHierarchy(html);
|
||||
expect(report.issues).toEqual([]);
|
||||
expect(report.primaryCount).toBe(1);
|
||||
expect(report.secondaryCount).toBe(1);
|
||||
});
|
||||
|
||||
it('flags two primary CTAs sharing the same section as multiple-primary', () => {
|
||||
const html = `
|
||||
<section>
|
||||
<a class="btn btn-primary" href="/signup">Sign up</a>
|
||||
<a class="btn btn-primary" href="/buy">Buy now</a>
|
||||
</section>
|
||||
`;
|
||||
const report = analyseCtaHierarchy(html);
|
||||
const kinds = report.issues.map((issue) => issue.kind);
|
||||
expect(kinds).toContain('multiple-primary');
|
||||
expect(report.primaryCount).toBe(2);
|
||||
// The selector should be short enough to surface in a one-line UI hint.
|
||||
const offender = report.issues.find((issue) => issue.kind === 'multiple-primary');
|
||||
expect(offender?.selector.length ?? 0).toBeLessThan(80);
|
||||
});
|
||||
|
||||
it('flags ambiguous-weight when every CTA is rendered with identical class and inline style', () => {
|
||||
const html = `
|
||||
<header>
|
||||
<a class="btn" href="/a">Get started</a>
|
||||
<a class="btn" href="/b">Subscribe</a>
|
||||
<a class="btn" href="/c">Buy</a>
|
||||
</header>
|
||||
`;
|
||||
const report = analyseCtaHierarchy(html);
|
||||
const kinds = report.issues.map((issue) => issue.kind);
|
||||
expect(kinds).toContain('ambiguous-weight');
|
||||
// None of these were tagged primary, so primaryCount stays at zero.
|
||||
expect(report.primaryCount).toBe(0);
|
||||
expect(report.secondaryCount).toBe(3);
|
||||
});
|
||||
|
||||
it('flags misleading-prominence when secondary-coded copy uses solid primary styling', () => {
|
||||
const html = `
|
||||
<section>
|
||||
<a class="btn btn-primary" href="/buy">Buy now</a>
|
||||
<a class="btn" style="background-color: #1d4ed8; color: white; font-size: 20px" href="/learn-more">Learn more</a>
|
||||
</section>
|
||||
`;
|
||||
const report = analyseCtaHierarchy(html);
|
||||
const misleading = report.issues.find((issue) => issue.kind === 'misleading-prominence');
|
||||
expect(misleading).toBeDefined();
|
||||
expect(misleading?.message.toLowerCase()).toContain('learn more');
|
||||
});
|
||||
|
||||
it('detects Chinese CTA copy such as "立即购买" and applies the same heuristics', () => {
|
||||
const html = `
|
||||
<section>
|
||||
<button class="btn btn-primary">立即购买</button>
|
||||
<button class="btn btn-primary">立即下单</button>
|
||||
</section>
|
||||
`;
|
||||
const report = analyseCtaHierarchy(html);
|
||||
const kinds = report.issues.map((issue) => issue.kind);
|
||||
expect(kinds).toContain('multiple-primary');
|
||||
expect(report.primaryCount).toBe(2);
|
||||
});
|
||||
|
||||
it('treats inline background-color as a primary-weight signal even without a primary class', () => {
|
||||
// Mirrors the issue #2251 inverse: a "btn" element styled with a solid
|
||||
// accent color is still effectively a primary CTA in the rendered page.
|
||||
const html = `
|
||||
<section>
|
||||
<a class="btn" style="background-color: #1d4ed8; color: white">查看到港货盘</a>
|
||||
<a class="btn btn-primary" href="/checkout">立即下单</a>
|
||||
</section>
|
||||
`;
|
||||
const report = analyseCtaHierarchy(html);
|
||||
expect(report.primaryCount).toBe(2);
|
||||
expect(report.issues.map((issue) => issue.kind)).toContain('multiple-primary');
|
||||
});
|
||||
|
||||
it('ignores non-CTA buttons such as icon toggles with no actionable copy', () => {
|
||||
// Buttons without CTA-style copy (e.g. a "+" toggle) should not be picked
|
||||
// up as CTA candidates; otherwise the hierarchy checks become noisy.
|
||||
const html = `
|
||||
<section>
|
||||
<button class="btn">+</button>
|
||||
<button class="btn">−</button>
|
||||
</section>
|
||||
`;
|
||||
const report = analyseCtaHierarchy(html);
|
||||
expect(report.issues).toEqual([]);
|
||||
expect(report.primaryCount).toBe(0);
|
||||
expect(report.secondaryCount).toBe(0);
|
||||
});
|
||||
|
||||
it('does not collapse sibling <div> wrappers without a landmark ancestor', () => {
|
||||
// Flat card-grid layout with no landmark ancestor: two sibling
|
||||
// <div>s each carry one primary CTA. With a tag-only parent
|
||||
// fallback ("parent:div") both CTAs land in the same bucket and
|
||||
// detectMultiplePrimary() reports a fake shared-section conflict.
|
||||
// The container key must include the parent's identity, not just
|
||||
// its tag name.
|
||||
const html = `
|
||||
<div>
|
||||
<div><a class="btn btn-primary" href="/a">Get started</a></div>
|
||||
<div><a class="btn btn-primary" href="/b">Sign up</a></div>
|
||||
</div>
|
||||
`;
|
||||
const report = analyseCtaHierarchy(html);
|
||||
const kinds = report.issues.map((issue) => issue.kind);
|
||||
expect(kinds).not.toContain('multiple-primary');
|
||||
expect(report.primaryCount).toBe(2);
|
||||
});
|
||||
|
||||
it('does not flag ambiguous-weight when two unrelated sections each contain a single .btn CTA', () => {
|
||||
// Cross-section signature coincidence should not be a hierarchy
|
||||
// warning: each section has only one CTA, so there is no
|
||||
// "everything in this container looks the same" condition to
|
||||
// satisfy. The rule must respect container boundaries.
|
||||
const html = `
|
||||
<article>
|
||||
<section><a class="btn" href="/a">Get started</a></section>
|
||||
<section><a class="btn" href="/b">Subscribe</a></section>
|
||||
</article>
|
||||
`;
|
||||
const report = analyseCtaHierarchy(html);
|
||||
const kinds = report.issues.map((issue) => issue.kind);
|
||||
expect(kinds).not.toContain('ambiguous-weight');
|
||||
});
|
||||
});
|
||||
|
|
@ -78,6 +78,9 @@ importers:
|
|||
blake3-wasm:
|
||||
specifier: 2.1.5
|
||||
version: 2.1.5
|
||||
cheerio:
|
||||
specifier: 1.2.0
|
||||
version: 1.2.0
|
||||
chokidar:
|
||||
specifier: 5.0.0
|
||||
version: 5.0.0
|
||||
|
|
@ -2425,6 +2428,13 @@ packages:
|
|||
character-entities@2.0.2:
|
||||
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
|
||||
|
||||
cheerio-select@2.1.0:
|
||||
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
|
||||
|
||||
cheerio@1.2.0:
|
||||
resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
chokidar@4.0.3:
|
||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
|
|
@ -2759,6 +2769,9 @@ packages:
|
|||
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
encoding-sniffer@0.2.1:
|
||||
resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||
|
||||
|
|
@ -2774,6 +2787,10 @@ packages:
|
|||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@7.0.1:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@8.0.0:
|
||||
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
|
@ -3144,6 +3161,9 @@ packages:
|
|||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
htmlparser2@10.1.0:
|
||||
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
|
||||
|
||||
http-cache-semantics@4.2.0:
|
||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||
|
||||
|
|
@ -3872,6 +3892,12 @@ packages:
|
|||
parse-latin@7.0.0:
|
||||
resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==}
|
||||
|
||||
parse5-htmlparser2-tree-adapter@7.1.0:
|
||||
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
|
||||
|
||||
parse5-parser-stream@7.1.2:
|
||||
resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==}
|
||||
|
||||
parse5@7.3.0:
|
||||
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
||||
|
||||
|
|
@ -4928,6 +4954,15 @@ packages:
|
|||
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
whatwg-encoding@3.1.1:
|
||||
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
|
||||
engines: {node: '>=18'}
|
||||
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
|
||||
|
||||
whatwg-mimetype@4.0.0:
|
||||
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
whatwg-mimetype@5.0.0:
|
||||
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
|
||||
engines: {node: '>=20'}
|
||||
|
|
@ -7022,6 +7057,29 @@ snapshots:
|
|||
|
||||
character-entities@2.0.2: {}
|
||||
|
||||
cheerio-select@2.1.0:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
css-select: 5.2.2
|
||||
css-what: 6.2.2
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
|
||||
cheerio@1.2.0:
|
||||
dependencies:
|
||||
cheerio-select: 2.1.0
|
||||
dom-serializer: 2.0.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
encoding-sniffer: 0.2.1
|
||||
htmlparser2: 10.1.0
|
||||
parse5: 7.3.0
|
||||
parse5-htmlparser2-tree-adapter: 7.1.0
|
||||
parse5-parser-stream: 7.1.2
|
||||
undici: 7.25.0
|
||||
whatwg-mimetype: 4.0.0
|
||||
|
||||
chokidar@4.0.3:
|
||||
dependencies:
|
||||
readdirp: 4.1.2
|
||||
|
|
@ -7375,6 +7433,11 @@ snapshots:
|
|||
|
||||
encodeurl@2.0.0: {}
|
||||
|
||||
encoding-sniffer@0.2.1:
|
||||
dependencies:
|
||||
iconv-lite: 0.6.3
|
||||
whatwg-encoding: 3.1.1
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
|
|
@ -7388,6 +7451,8 @@ snapshots:
|
|||
|
||||
entities@6.0.1: {}
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
entities@8.0.0: {}
|
||||
|
||||
env-paths@2.2.1: {}
|
||||
|
|
@ -7950,6 +8015,13 @@ snapshots:
|
|||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
htmlparser2@10.1.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
entities: 7.0.1
|
||||
|
||||
http-cache-semantics@4.2.0: {}
|
||||
|
||||
http-errors@2.0.1:
|
||||
|
|
@ -8779,6 +8851,15 @@ snapshots:
|
|||
unist-util-visit-children: 3.0.0
|
||||
vfile: 6.0.3
|
||||
|
||||
parse5-htmlparser2-tree-adapter@7.1.0:
|
||||
dependencies:
|
||||
domhandler: 5.0.3
|
||||
parse5: 7.3.0
|
||||
|
||||
parse5-parser-stream@7.1.2:
|
||||
dependencies:
|
||||
parse5: 7.3.0
|
||||
|
||||
parse5@7.3.0:
|
||||
dependencies:
|
||||
entities: 6.0.1
|
||||
|
|
@ -9941,6 +10022,12 @@ snapshots:
|
|||
|
||||
webidl-conversions@8.0.1: {}
|
||||
|
||||
whatwg-encoding@3.1.1:
|
||||
dependencies:
|
||||
iconv-lite: 0.6.3
|
||||
|
||||
whatwg-mimetype@4.0.0: {}
|
||||
|
||||
whatwg-mimetype@5.0.0: {}
|
||||
|
||||
whatwg-url@16.0.1:
|
||||
|
|
|
|||
Loading…
Reference in a new issue