open-design/apps/daemon/tests/qa-cta-hierarchy.test.ts
YOMXXX 3e2f037730
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 (refs #2251) (#2427)
* 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.
2026-05-22 16:53:14 +08:00

151 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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');
});
});