From 3e2f037730cac456b1953c7d98923ef9e762ba3f Mon Sep 17 00:00:00 2001 From: YOMXXX <15901434509@qq.com> Date: Fri, 22 May 2026 16:53:14 +0800 Subject: [PATCH] feat(daemon): add CTA hierarchy static QA pass (refs #2251) (#2427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 + + + `; + 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 = ` +
+ 查看到港货盘 + 立即下单 +
+ `; + 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 = ` +
+ + +
+ `; + const report = analyseCtaHierarchy(html); + expect(report.issues).toEqual([]); + expect(report.primaryCount).toBe(0); + expect(report.secondaryCount).toBe(0); + }); + + it('does not collapse sibling
wrappers without a landmark ancestor', () => { + // Flat card-grid layout with no landmark ancestor: two sibling + //
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 = ` +
+
Get started
+
Sign up
+
+ `; + 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 = ` + + `; + const report = analyseCtaHierarchy(html); + const kinds = report.issues.map((issue) => issue.kind); + expect(kinds).not.toContain('ambiguous-weight'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5a875668..cb0a78bc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: