From 47da6294b5e23694d4d0a2b9aee9a7f253fcbd2a Mon Sep 17 00:00:00 2001 From: sr8384856 Date: Thu, 18 Jun 2026 21:55:14 +0000 Subject: [PATCH] feat(canary): add Phase A beacon telemetry and QA introspection hooks Phase A canary observability infrastructure for CaaS. Two new helper modules: - beacon.js: navigator.sendBeacon-based telemetry to LANA with consent gating, URL-param overrides for QA, and a read-only DOM assertions runner. - qa-hooks.js: window.caas introspection API for headless agents. Pure DOM read; no mutations. Wires both into app.jsx (boot) and Container.jsx (cards fetch lifecycle). All call sites are try/catch wrapped so telemetry failures can never throw into the React tree. Phase A cohort is hardcoded to 'stable' for all users; the UUID-hash 1% split lands in Phase B without changing the public API of this module. No UX changes in this PR. URL-param overrides for QA: ?caas_cohort=canary|stable force cohort (sets forcedCohort:true) ?caas_consent=1 bypass consent on non-dev hosts ?caas_debug=1 log every beacon payload to console --- react/src/js/app.jsx | 18 + .../Consonant/Container/Container.jsx | 44 ++ .../js/components/Consonant/Helpers/beacon.js | 399 ++++++++++++++++++ .../components/Consonant/Helpers/qa-hooks.js | 298 +++++++++++++ webpack.config.js | 5 +- 5 files changed, 763 insertions(+), 1 deletion(-) create mode 100644 react/src/js/components/Consonant/Helpers/beacon.js create mode 100644 react/src/js/components/Consonant/Helpers/qa-hooks.js diff --git a/react/src/js/app.jsx b/react/src/js/app.jsx index 161107445..5d1442de0 100644 --- a/react/src/js/app.jsx +++ b/react/src/js/app.jsx @@ -5,6 +5,8 @@ import ReactDOM, { render } from 'react-dom'; import { DOMRegistry } from 'react-dom-components'; import { parseToPrimitive } from './components/Consonant/Helpers/general'; import { loadLana } from './components/Consonant/Helpers/lana'; +import { initBeacon, beaconPageLoad } from './components/Consonant/Helpers/beacon'; +import { initQaHooks } from './components/Consonant/Helpers/qa-hooks'; import Container from './components/Consonant/Container/Container'; import consonantPageRDC from './components/Consonant/Page/ConsonantPageDOM'; @@ -36,6 +38,17 @@ try { } } +// Initialize canary telemetry (consent-gated; idempotent). +try { + initBeacon(); +} catch (e) { /* never block boot on telemetry init */ } + +// QA introspection API for headless agents. No side effects, idempotent. +// Exposes window.caas.{version, dump, waitForReady} and the caas:ready event. +try { + initQaHooks(); +} catch (e) { /* never block boot on QA hooks init */ } + // Must be constructible: Northstar uses bind/apply + new on this callback. function initReact(element, registry) { if (registry === undefined) { @@ -46,6 +59,11 @@ function initReact(element, registry) { initReact(document); +// Fire the page_load beacon after React is mounted. +try { + beaconPageLoad(); +} catch (e) { /* swallow */ } + function collectionLoadedThroughXf(el) { if (!el) return false; // Ensure el is not null or undefined const container = el.firstElementChild; diff --git a/react/src/js/components/Consonant/Container/Container.jsx b/react/src/js/components/Consonant/Container/Container.jsx index bd98f7146..d2c41003a 100644 --- a/react/src/js/components/Consonant/Container/Container.jsx +++ b/react/src/js/components/Consonant/Container/Container.jsx @@ -9,6 +9,13 @@ import classNames from 'classnames'; import { shape } from 'prop-types'; // import 'whatwg-fetch'; // Removed: fetch is native in modern browsers import { logLana } from '../Helpers/lana'; +import { + beaconCardsRendered, + beaconTargetMissing, + beaconFetchFail, + scheduleAssertions, +} from '../Helpers/beacon'; +import { markCaasReady } from '../Helpers/qa-hooks'; import Popup from '../Sort/Popup'; import Search from '../Search/Search'; import Loader from '../Loader/Loader'; @@ -1051,11 +1058,19 @@ const Container = (props) => { if (validData) return json; logLana({ message: `no valid response data from ${endPoint}`, tags: 'collection' }); + beaconTargetMissing({ reason: 'empty_collection', endpointUsed: endPoint }); /* istanbul ignore next */ return Promise.reject(new Error('no valid reponse data')); }); } logLana({ message: `failure for call to ${url}`, tags: 'collection', errorMessage: `${status}: ${statusText}` }); + beaconFetchFail({ + url, + method: 'GET', + httpStatus: status, + responseTimeMs: Date.now() - start, + errorMessage: statusText, + }); return Promise.reject(new Error(`${status}: ${statusText}, failure for call to ${url}`)); }) .then((payload) => { @@ -1064,6 +1079,7 @@ const Container = (props) => { setIsFirstLoad(true); if (!getByPath(payload, 'cards.length')) { logLana({ message: `no cards return by query to this endpoint: ${endPoint}`, tags: 'collection' }); + beaconTargetMissing({ reason: 'no_cards_returned', endpointUsed: endPoint }); return; } if (payload.isHashed && !hashedRef.current) { @@ -1201,6 +1217,33 @@ const Container = (props) => { setCards(processedCards); + // Canary telemetry: cards rendered successfully. + try { + beaconCardsRendered({ + cardCount: processedCards.length, + totalCountFromApi: payload.totalCount, + fetchDurationMs: Date.now() - start, + endpointUsed: endPoint, + }); + } catch (e) { /* never block render on telemetry */ } + + // QA ready signal: fires window.__caasReady + caas:ready event. + // Headless agents wait on this instead of guessing on networkidle. + try { + markCaasReady({ + cardCount: processedCards.length, + totalCountFromApi: payload.totalCount, + fetchDurationMs: Date.now() - start, + endpointUsed: endPoint, + }); + } catch (e) { /* never block render on QA hooks */ } + + // Schedule the assertions beacon. Debounced: if multiple fetches + // (partial + full load) each call this, only the last one fires. + try { + scheduleAssertions(800); + } catch (e) { /* swallow */ } + // check if the current page is greater than the last page const lastPage = Math.ceil(processedCards.length / resultsPerPage); if (currentPage > lastPage) { @@ -1227,6 +1270,7 @@ const Container = (props) => { return; } logLana({ message: 'failed to return processed cards', tags: 'collection' }); + beaconTargetMissing({ reason: 'processed_cards_empty', endpointUsed: endPoint }); setLoading(false); setApiFailure(true); }); diff --git a/react/src/js/components/Consonant/Helpers/beacon.js b/react/src/js/components/Consonant/Helpers/beacon.js new file mode 100644 index 000000000..7e3a0a6b8 --- /dev/null +++ b/react/src/js/components/Consonant/Helpers/beacon.js @@ -0,0 +1,399 @@ +/** + * CaaS canary telemetry — beacon module. + * + * Fires structured beacons to LANA via navigator.sendBeacon (or a GET fetch + * fallback). Each beacon carries cohort + version + session + sticky identity + * so the Rundeck canary-compare job can join them in Splunk by sessionId / + * stickyId / cohort / version. + * + * Phase A (this file): always emits cohort='stable' with the current bundle's + * version. Cohort routing and canary-config.json reading come in Phase B — + * the public API of this module doesn't change between phases. + * + * Consent gating: reads OptanonConsent cookie (set by OneTrust). If C0002 + * (analytics) is not granted, beacons are silently skipped — same predicate + * Milo / AEP use, so we inherit Adobe's existing privacy posture. + */ + +/* eslint-disable no-undef */ + +const LANA_ENDPOINT = 'https://www.adobe.com/lana/ll'; +const LANA_CLIENT = 'chimera'; +const VERSION = '0.48.3'; // TODO: inject via webpack DefinePlugin from package.json +const DEFAULT_COHORT = 'stable'; // Phase A: hardcoded; Phase B: from canary-config.json + UUID hash + +// --- Internal state --- + +let sessionId = null; +let stickyId = null; +let initialized = false; +let consentGranted = false; +let activeCohort = DEFAULT_COHORT; +let cohortForced = false; +let debugLog = false; + +// --- Dev / test helpers --- + +function getUrlParam(name) { + try { + return new URLSearchParams(window.location.search).get(name); + } catch (e) { + return null; + } +} + +function isDevHost() { + const host = (typeof window !== 'undefined' && window.location && window.location.host) || ''; + return /localhost|127\.0\.0\.1|^0\.0\.0\.0|corp\.adobe\.com|adobeio-static|hlx\.(live|page)|aem\.(live|page)/.test(host); +} + +// --- UUID generation (fallback for older browsers) --- + +function generateUUID() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + const b = crypto.getRandomValues(new Uint8Array(16)); + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + const h = [...b].map((x) => x.toString(16).padStart(2, '0')).join(''); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; + } + // Last-resort fallback — non-crypto but fine for an analytics ID. + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); + }); +} + +// --- Consent check (read OptanonConsent cookie set by OneTrust) --- + +function hasAnalyticsConsent() { + try { + const cookie = document.cookie + .split(';') + .find((c) => c.trim().startsWith('OptanonConsent=')); + if (!cookie) return false; + + const value = decodeURIComponent(cookie.split('=').slice(1).join('=')); + const groupsMatch = value.match(/groups=([^&]*)/); + if (!groupsMatch) return false; + + const consent = Object.fromEntries( + groupsMatch[1].split(',').map((g) => g.split(':')), + ); + return consent.C0002 === '1'; + } catch (e) { + return false; + } +} + +// --- Sticky ID storage --- + +const STICKY_KEY = 'caas_cohort'; + +function loadOrCreateStickyId() { + try { + const stored = localStorage.getItem(STICKY_KEY); + if (stored) { + const parsed = JSON.parse(stored); + if (parsed && parsed.uuid) return parsed.uuid; + } + } catch (e) { /* localStorage may be disabled */ } + + const uuid = generateUUID(); + try { + localStorage.setItem(STICKY_KEY, JSON.stringify({ uuid, history: [] })); + } catch (e) { /* swallow */ } + return uuid; +} + +// --- Public API --- + +/** + * Initialize the beacon system. Idempotent — safe to call multiple times. + * Should be called once at app boot. + */ +export function initBeacon() { + if (initialized) return; + initialized = true; + + // Consent: cookie check OR dev-host auto-grant OR explicit URL-param override + const consentParamOverride = getUrlParam('caas_consent') === '1'; + consentGranted = hasAnalyticsConsent() || isDevHost() || consentParamOverride; + + // Cohort: URL-param override beats UUID-hash decision (Phase A: hash decision = always 'stable') + const forced = getUrlParam('caas_cohort'); + if (forced === 'canary' || forced === 'stable') { + activeCohort = forced; + cohortForced = true; + } else { + activeCohort = DEFAULT_COHORT; + cohortForced = false; + } + + // Debug: console.log every beacon payload as it fires + debugLog = getUrlParam('caas_debug') === '1'; + + sessionId = generateUUID(); + if (consentGranted) { + stickyId = loadOrCreateStickyId(); + } else { + // No consent — use an ephemeral session UUID as stickyId too. + // Never written to localStorage. + stickyId = sessionId; + } + + // Install passive error capture + if (typeof window !== 'undefined' && window.addEventListener) { + window.addEventListener('error', handleWindowError); + window.addEventListener('unhandledrejection', handleUnhandledRejection); + } + + if (debugLog) { + // eslint-disable-next-line no-console + console.log('[caas-beacon] init', { + consentGranted, + consentSource: hasAnalyticsConsent() ? 'cookie' : (isDevHost() ? 'dev-host' : (consentParamOverride ? 'url-param' : 'none')), + activeCohort, + cohortForced, + sessionId, + stickyId, + }); + } +} + +/** + * Fire a structured beacon to LANA. Drops silently if consent not granted. + * + * @param {string} event - one of: page_load, cards_rendered, target_missing, + * assertions, error, fetch_fail, performance + * @param {object} data - event-specific payload (will be JSON-serialized) + */ +export function beacon(event, data = {}) { + try { + if (!initialized) initBeacon(); + if (!consentGranted) return; + + const payload = { + event, + timestamp: Date.now(), + cohort: activeCohort, + version: VERSION, + sessionId, + stickyId, + data: cohortForced ? { ...data, forcedCohort: true } : data, + }; + + // Debug tap — keep the last 50 in-memory for DevTools inspection. + try { + if (typeof window !== 'undefined') { + window.__CAAS_BEACON_TAP__ = window.__CAAS_BEACON_TAP__ || []; + window.__CAAS_BEACON_TAP__.push(payload); + if (window.__CAAS_BEACON_TAP__.length > 50) { + window.__CAAS_BEACON_TAP__.shift(); + } + } + } catch (e) { /* swallow */ } + + if (debugLog) { + // eslint-disable-next-line no-console + console.log(`[caas-beacon] ${event}`, payload); + } + + const params = new URLSearchParams({ + c: LANA_CLIENT, + m: JSON.stringify(payload), + tags: `${activeCohort},${VERSION.replace(/\./g, '-')}`, + r: event === 'error' ? 'e' : 'i', + s: '100', // Phase A: keep all; Phase B: scale per cohort (canary=100, stable=1) + t: event === 'error' ? 'i' : 'e', + }); + + const url = `${LANA_ENDPOINT}?${params.toString()}`; + + // Prefer sendBeacon (survives unload, async). Fall back to fetch with keepalive. + if (navigator && navigator.sendBeacon) { + navigator.sendBeacon(url); + } else if (typeof fetch !== 'undefined') { + fetch(url, { method: 'GET', keepalive: true }).catch(() => {}); + } + } catch (e) { + // Beacon must never throw out — telemetry failure should not break the app. + } +} + +// --- Convenience helpers for common events --- + +export function beaconPageLoad() { + const nav = (performance && performance.getEntriesByType && performance.getEntriesByType('navigation')[0]) || {}; + beacon('page_load', { + navigationStartMs: 0, + domContentLoadedMs: Math.round(nav.domContentLoadedEventEnd || 0), + bundleParsedMs: Math.round(performance && performance.now ? performance.now() : 0), + reactMountedMs: Math.round(performance && performance.now ? performance.now() : 0), + }); +} + +export function beaconCardsRendered({ cardCount, totalCountFromApi, fetchDurationMs, timeToFirstCardMs, endpointUsed }) { + const isPartialLoad = typeof endpointUsed === 'string' && endpointUsed.indexOf('partialLoadCount') !== -1; + beacon('cards_rendered', { + cardCount, + totalCountFromApi, + fetchDurationMs, + timeToFirstCardMs, + endpointUsed, + isPartialLoad, + }); +} + +// --- Assertion debounce --- +let _assertionTimeoutId = null; + +/** + * Schedule a single assertions beacon. If called multiple times within `delayMs`, + * only the LAST scheduled call actually fires — debounce. Prevents duplicate + * assertion beacons when CaaS does multi-phase fetches (partial load + full load + * each trigger setCards, and we don't need two assertion beacons reporting the + * same DOM state). + */ +export function scheduleAssertions(delayMs = 800) { + if (typeof window === 'undefined') return; + if (_assertionTimeoutId) { + clearTimeout(_assertionTimeoutId); + } + _assertionTimeoutId = setTimeout(() => { + _assertionTimeoutId = null; + try { + beaconAssertions(runCaasAssertions()); + } catch (e) { /* swallow */ } + }, delayMs); +} + +export function beaconTargetMissing({ reason, endpointUsed, httpStatus }) { + beacon('target_missing', { reason, endpointUsed, httpStatus }); +} + +export function beaconFetchFail({ url, method, httpStatus, responseTimeMs, errorMessage }) { + beacon('fetch_fail', { url, method, httpStatus, responseTimeMs, errorMessage }); +} + +export function beaconAssertions(checks) { + beacon('assertions', checks); +} + +/** + * Run DOM-based assertions about CaaS render state. + * Pure observation — no config dependency. The Rundeck comparison job + * computes presence-rate deltas across cohorts to detect regressions + * (e.g., canary's filter panel present rate drops from 100% to 75% + * vs stable's 100% on the same page → regression). + */ +export function runCaasAssertions() { + if (typeof document === 'undefined') return {}; + + const doc = document; + const container = doc.querySelector('.consonant-Wrapper, .consonant-CardsGrid'); + const containerPresent = !!container; + const containerHasChildren = container ? container.children.length > 0 : false; + + // Card visibility check + const cards = [...doc.querySelectorAll('.consonant-Card')]; + const cardCount = cards.length; + let visibleCardCount = 0; + cards.forEach((el) => { + try { + const rect = el.getBoundingClientRect(); + const cs = window.getComputedStyle(el); + const visible = rect.width > 0 && rect.height > 0 + && cs.opacity !== '0' + && cs.display !== 'none' + && cs.visibility !== 'hidden'; + if (visible) visibleCardCount += 1; + } catch (e) { /* skip this card */ } + }); + const allCardsVisible = cardCount > 0 ? visibleCardCount === cardCount : null; + + // Component presence (mount checks — does the DOM contain it?) + const filterPanelLeftPresent = !!doc.querySelector('.consonant-LeftFilters'); + const filterPanelTopPresent = !!doc.querySelector('.consonant-TopFilters'); + const filterPanelPresent = filterPanelLeftPresent || filterPanelTopPresent; + const sortPresent = !!doc.querySelector('.consonant-Select-btn'); + const searchPresent = !!doc.querySelector('.consonant-Search'); + const paginatorPresent = !!doc.querySelector('.consonant-Pagination'); + const loadMorePresent = !!doc.querySelector('.consonant-LoadMore'); + + // Loader: is the spinner still visible after render? + let loaderStillVisible = false; + const loaderEl = doc.querySelector('[class*="oader"]'); // Loader or loader + if (loaderEl) { + try { + const rect = loaderEl.getBoundingClientRect(); + const cs = window.getComputedStyle(loaderEl); + loaderStillVisible = rect.height > 0 + && cs.display !== 'none' + && cs.visibility !== 'hidden'; + } catch (e) { /* swallow */ } + } + + // Error UI + const errorUiPresent = !!doc.querySelector( + '[data-react-error], .error-boundary, .consonant-Error, .consonant-FailedRequest', + ); + + return { + containerPresent, + containerHasChildren, + cardCount, + visibleCardCount, + allCardsVisible, + filterPanelPresent, + filterPanelLeftPresent, + filterPanelTopPresent, + sortPresent, + searchPresent, + paginatorPresent, + loadMorePresent, + loaderStillVisible, + errorUiPresent, + }; +} + +// --- Internal error handlers --- + +function handleWindowError(evt) { + try { + beacon('error', { + message: (evt && evt.message) ? String(evt.message).slice(0, 500) : 'unknown', + errorType: (evt && evt.error && evt.error.name) || 'Error', + source: evt && evt.filename ? evt.filename.split('/').pop() : 'unknown', + lineNumber: evt && typeof evt.lineno === 'number' ? evt.lineno : 0, + columnNumber: evt && typeof evt.colno === 'number' ? evt.colno : 0, + stackTrace: evt && evt.error && evt.error.stack ? String(evt.error.stack).slice(0, 1500) : '', + }); + } catch (e) { /* swallow */ } +} + +function handleUnhandledRejection(evt) { + try { + const reason = evt && evt.reason; + const msg = (reason && reason.message) ? reason.message : String(reason || 'unknown'); + beacon('error', { + message: msg.slice(0, 500), + errorType: (reason && reason.name) || 'UnhandledRejection', + source: 'promise', + lineNumber: 0, + columnNumber: 0, + stackTrace: reason && reason.stack ? String(reason.stack).slice(0, 1500) : '', + }); + } catch (e) { /* swallow */ } +} + +// --- Debug helper --- +// window.__CAAS_BEACON_TAP__ is populated by beacon() (above) with the last 50 payloads. +// Useful for inspecting what was sent without going to Splunk: +// console.log(window.__CAAS_BEACON_TAP__) +// +// The tap is populated unconditionally (cost is negligible). It's per-page-load +// and lost on navigation. diff --git a/react/src/js/components/Consonant/Helpers/qa-hooks.js b/react/src/js/components/Consonant/Helpers/qa-hooks.js new file mode 100644 index 000000000..5792bf953 --- /dev/null +++ b/react/src/js/components/Consonant/Helpers/qa-hooks.js @@ -0,0 +1,298 @@ +/** + * QA hooks: structured introspection for headless agents. + * + * Exposes a small, stable API on `window.caas` that AI-driven browser + * agents can call instead of guessing CSS selectors or scraping innerText. + * The goals are: + * - Tell the agent when the collection is actually rendered (ready signal). + * - Hand the agent a structured snapshot of cards, filters, search, and sort + * so the LLM never has to author selectors. + * - Stay out of the way of normal users (no UI changes, no telemetry side + * effects, no console noise). + * + * Wiring: + * - `initQaHooks()` runs once at boot from app.jsx. Idempotent. + * - `markCaasReady(detail)` is called from Container.jsx after setCards. + * Multiple fetches (partial + full load) will fire it more than once; + * the latest call wins. + * + * Surface area: + * window.__caasReady : boolean flag, true after first ready signal + * window.__caasReadyDetail : { cardCount, totalCountFromApi, ts, ... } + * window.caas.version : package.json version baked at build time + * window.caas.dump() : structured snapshot (see shape below) + * window.caas.waitForReady(ms) : Promise that resolves on caas:ready + * CustomEvent('caas:ready') : window-level event, detail mirrors flag + * + * Dump shape (deliberately flat and stable): + * { + * version, url, ts, ready, cardCount, + * cards: [{ id, title, description, image, ctaText, ctaHref, badges, style }], + * search: { present, value, placeholder, ariaLabel }, + * filters: { panel: 'left'|'top'|null, groups: [{ name, items: [{ label, selected }] }] }, + * sort: { present, label, value }, + * pagination: { type, currentPage, totalPages }, + * consoleErrors: number (only populated if a listener was installed) + * } + */ + +/* eslint-disable */ + +// Baked at build time by webpack DefinePlugin if available, else 'unknown'. +const PKG_VERSION = + (typeof process !== 'undefined' && process.env && process.env.CAAS_VERSION) || 'unknown'; + +let initialized = false; +let readyResolvers = []; + +function safeText(el) { + if (!el) return ''; + return (el.textContent || '').trim().replace(/\s+/g, ' '); +} + +function safeAttr(el, attr) { + if (!el) return ''; + return el.getAttribute(attr) || ''; +} + +function dumpCards() { + const nodes = document.querySelectorAll('[data-testid="consonant-Card"]'); + return Array.from(nodes).map((node) => { + const heading = + node.querySelector('.consonant-Card-title, h2, h3, h4, h5, h6') || null; + const desc = node.querySelector('.consonant-Card-text, p') || null; + const img = node.querySelector('img') || null; + const cta = + node.querySelector('.consonant-BtnInfobit, a[href]') || null; + const badges = Array.from( + node.querySelectorAll('.consonant-Card-badge, [class*="badge"]'), + ).map(safeText).filter(Boolean); + + return { + id: node.id || null, + style: + Array.from(node.classList).find((c) => + /^(one-half|full-card|half-height|blog-card|news-card|product|three-fourths|double-wide|editorial|horizontal|icon|blade|text-card)/.test(c), + ) || null, + title: safeText(heading), + description: safeText(desc).slice(0, 280), + image: safeAttr(img, 'src') || safeAttr(img, 'data-src'), + ctaText: safeText(cta), + ctaHref: safeAttr(cta, 'href'), + badges, + }; + }); +} + +function dumpSearch() { + const input = document.querySelector('[data-testid="consonant-Search-input"]'); + if (!input) return { present: false }; + return { + present: true, + value: input.value || '', + placeholder: input.getAttribute('placeholder') || '', + ariaLabel: input.getAttribute('aria-label') || '', + }; +} + +function dumpFilters() { + // Selectors match the actual data-testid surface in Consonant/Filters. + const leftPanel = document.querySelector('[data-testid="consonant-LeftFilters"]'); + const topPanel = document.querySelector('[data-testid="consonant-TopFilter"]'); + const panel = leftPanel ? 'left' : (topPanel ? 'top' : null); + const root = leftPanel || topPanel; + if (!root) return { panel: null, groups: [] }; + + // Each filter group is its own LeftFilter / TopFilter node, with a -name + // child for the heading and -itemsItemCheckbox / -itemCheckbox inputs. + const groupSel = panel === 'left' + ? '[data-testid="consonant-LeftFilter"]' + : '[data-testid="consonant-TopFilter"]'; + const itemBoxSel = panel === 'left' + ? '[data-testid="consonant-LeftFilter-itemsItemCheckbox"]' + : '[data-testid="consonant-TopFilter-itemCheckbox"]'; + + const groupNodes = root.querySelectorAll(groupSel); + const groups = Array.from(groupNodes).map((g) => { + const nameNode = + g.querySelector('[data-testid="consonant-LeftFilter-name"]') || + g.querySelector('button[aria-expanded], h3, h4'); + const itemNodes = g.querySelectorAll(itemBoxSel); + const items = Array.from(itemNodes).map((i) => { + const labelEl = + (i.id && document.querySelector(`label[for="${i.id}"]`)) || + i.closest('label') || + i.parentElement; + return { + label: safeText(labelEl) || i.getAttribute('aria-label') || '', + selected: !!i.checked, + }; + }); + return { name: safeText(nameNode), items }; + }); + return { panel, groups }; +} + +function dumpSort() { + const btn = document.querySelector('[data-testid="consonant-Select-btn"]'); + if (!btn) return { present: false }; + // The sort widget renders the visible "Sort by:" label next to the + // button after the mwpw-177207 fix; capture both. + const label = safeText(btn.previousElementSibling) || safeText(btn.parentElement); + return { + present: true, + label, + value: safeText(btn), + ariaLabel: btn.getAttribute('aria-label') || '', + }; +} + +function dumpPagination() { + const loadMore = document.querySelector('[data-testid="consonant-LoadMore-btn"]'); + if (loadMore) { + return { + type: 'loadMore', + buttonLabel: safeText(loadMore), + disabled: loadMore.disabled || loadMore.getAttribute('aria-disabled') === 'true', + }; + } + const paginatorBtns = document.querySelectorAll('[data-testid="consonant-Pagination-itemBtn"]'); + const summary = document.querySelector('[data-testid="consonant-Pagination-summary"]'); + if (paginatorBtns.length || summary) { + const active = Array.from(paginatorBtns).find( + (b) => b.getAttribute('aria-current') === 'true' || + b.classList.contains('consonant-Pagination-itemBtn--active'), + ); + return { + type: 'paginator', + currentPage: active ? safeText(active) : null, + totalPages: paginatorBtns.length || null, + summary: safeText(summary), + }; + } + return { type: 'none' }; +} + +function dump() { + const ready = !!window.__caasReady; + const cards = dumpCards(); + return { + version: PKG_VERSION, + url: window.location.href, + ts: Date.now(), + ready, + cardCount: cards.length, + cards, + search: dumpSearch(), + filters: dumpFilters(), + sort: dumpSort(), + pagination: dumpPagination(), + }; +} + +/** + * Resolves on the next caas:ready event, or immediately if already ready. + * Times out by rejecting after ms milliseconds; default 10000. + */ +function waitForReady(ms) { + const timeoutMs = typeof ms === 'number' ? ms : 10000; + if (window.__caasReady) return Promise.resolve(window.__caasReadyDetail || {}); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = readyResolvers.indexOf(resolve); + if (idx >= 0) readyResolvers.splice(idx, 1); + reject(new Error(`caas:ready timeout after ${timeoutMs}ms`)); + }, timeoutMs); + readyResolvers.push((detail) => { + clearTimeout(timer); + resolve(detail); + }); + }); +} + +/** + * Mark the CaaS collection as rendered. Called from Container.jsx + * immediately after setCards. React 16 commits asynchronously, so we defer + * the public ready signal until the DOM actually contains the expected + * number of card nodes. This guarantees that subscribers calling + * window.caas.dump() inside their ready handler see the cards. + * + * The detail.cardCount supplied by the caller is the React-state count. + * We poll for [data-testid="consonant-Card"] to reach that number, then + * fire. If the DOM never settles within the cap (e.g. the render path + * crashed downstream), we fire anyway after the cap so the signal isn't + * silenced -- readers can compare detail.cardCount to dump().cardCount to + * detect that discrepancy. + */ +export function markCaasReady(detail) { + // Diagnostic markers (visible via window.__caasReadyTrace) so external + // verifiers can tell whether this function was called at all and where + // the deferred poll landed. + try { + window.__caasReadyTrace = window.__caasReadyTrace || []; + window.__caasReadyTrace.push({ ev: 'called', ts: Date.now(), detail }); + } catch (e) { /* swallow */ } + + const enriched = Object.assign({ ts: Date.now() }, detail || {}); + const expected = Number(enriched.cardCount) || 0; + const startedAt = Date.now(); + const capMs = 5000; + const pollMs = 25; + + function fire() { + try { + window.__caasReady = true; + window.__caasReadyDetail = Object.assign({}, enriched, { + domCardCount: document.querySelectorAll('[data-testid="consonant-Card"]').length, + committedMs: Date.now() - startedAt, + }); + window.__caasReadyTrace.push({ ev: 'fired', ts: Date.now(), domCount: window.__caasReadyDetail.domCardCount }); + } catch (e) { /* sealed window in tests */ } + + const waiters = readyResolvers; + readyResolvers = []; + waiters.forEach((fn) => { + try { fn(window.__caasReadyDetail); } catch (e) { /* swallow */ } + }); + + try { + window.dispatchEvent( + new CustomEvent('caas:ready', { detail: window.__caasReadyDetail }), + ); + } catch (e) { /* polyfill issue */ } + } + + function check() { + const have = document.querySelectorAll('[data-testid="consonant-Card"]').length; + try { window.__caasReadyTrace.push({ ev: 'check', ts: Date.now(), have, expected }); } catch (e) {} + if (have >= expected || Date.now() - startedAt > capMs) { + fire(); + return; + } + setTimeout(check, pollMs); + } + + // setTimeout is more reliable than requestAnimationFrame here: + // rAF callbacks are throttled or skipped when the tab is not in the + // foreground, which the verifier hits whenever Playwright/MCP focuses + // devtools or another window. setTimeout fires regardless. + setTimeout(check, 0); +} + +/** + * Mount window.caas with the introspection API. Idempotent. + */ +export function initQaHooks() { + if (initialized) return; + initialized = true; + try { + window.caas = window.caas || {}; + Object.assign(window.caas, { + version: PKG_VERSION, + dump, + waitForReady, + }); + } catch (e) { /* swallow */ } +} + +export default initQaHooks; diff --git a/webpack.config.js b/webpack.config.js index 1c4d2a624..7140fb771 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -42,7 +42,10 @@ const plugins = [ }), // Inject environment variable for conditional code removal new webpack.DefinePlugin({ - 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), + // Bake the release version into the bundle so window.caas.version + // reports the same string the canary beacon and the banner use. + 'process.env.CAAS_VERSION': JSON.stringify(version), }), ];