@nextcommerce/campaigns-os 1.50.0 → 1.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +474 -0
- package/agents/claude/CLAUDE.md +4 -2
- package/agents/codex/AGENTS.md +3 -1
- package/agents/copilot/copilot-instructions.md +3 -1
- package/agents/cursor/campaigns-os.mdc +3 -1
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
- package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
- package/campaign-spec/dist/rules/design-source-shape.js +13 -3
- package/campaign-spec/dist/rules/sdk-version.js +2 -1
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +26 -46
- package/contracts/effects.v1.json +282 -14
- package/contracts/release-ledger.json +1233 -0
- package/contracts/supported-surface.json +2 -2
- package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
- package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
- package/docs/brand-theme-bridge.md +2 -0
- package/docs/build-packet.md +148 -23
- package/docs/campaign-build-brief.md +57 -2
- package/docs/effects.md +14 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +12 -0
- package/docs/progress-snapshots.md +2 -4
- package/docs/qa-and-test-orders.md +47 -4
- package/docs/readback.md +19 -2
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -3
- package/skills/next-campaigns-os/SKILL.md +11 -20
- package/skills/next-campaigns-os/references/session-intake.md +17 -9
- package/skills/next-campaigns-os-setup/SKILL.md +4 -3
- package/skills/next-campaigns-polish/SKILL.md +7 -5
- package/skills/next-campaigns-qa/SKILL.md +8 -5
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/brand-theme.mjs +33 -20
- package/src/brief-summary.mjs +275 -0
- package/src/build-brief.mjs +229 -30
- package/src/built-site-scope.mjs +39 -6
- package/src/built-smoke-qc.mjs +1117 -0
- package/src/campaign-identity.mjs +36 -2
- package/src/cart-placeholders.mjs +730 -0
- package/src/cli.mjs +467 -143
- package/src/commercial-journey.mjs +65 -4
- package/src/commercial-parity.mjs +6 -1
- package/src/contrast.mjs +649 -0
- package/src/design-source-publication.mjs +110 -3
- package/src/doctor/checks.mjs +415 -33
- package/src/doctor/inspect.mjs +53 -2
- package/src/doctor/next-step.mjs +16 -7
- package/src/input-currency.mjs +394 -0
- package/src/input-refresh.mjs +708 -0
- package/src/invocation.mjs +5 -2
- package/src/local-preview-policy.mjs +1 -1
- package/src/local-proof.mjs +4 -1
- package/src/polish-browser.mjs +660 -141
- package/src/polish-capture.mjs +1 -1
- package/src/polish-deadline.mjs +7 -0
- package/src/polish-media-weight.mjs +492 -0
- package/src/polish-node.mjs +400 -28
- package/src/polish-readability.mjs +828 -0
- package/src/progress-node.mjs +19 -3
- package/src/qa-browser.mjs +439 -180
- package/src/qa-content-params.mjs +889 -0
- package/src/qa-node.mjs +116 -13
- package/src/qa-order-bump.mjs +22 -1
- package/src/qa-policy-links.mjs +1019 -0
- package/src/qa-tracking-params.mjs +1389 -0
- package/src/qa-url-privacy.mjs +168 -0
- package/src/qc-accept.mjs +482 -0
- package/src/qc-check-registry.mjs +91 -0
- package/src/qc-results.mjs +1341 -0
- package/src/readback.mjs +58 -10
- package/src/sdk-attribute-index.mjs +71 -0
- package/src/sdk-markup.mjs +2 -2
- package/src/sdk-storage-compatibility.mjs +63 -3
- package/src/source-prep.mjs +37 -7
- package/src/stage-ledger.mjs +235 -44
- package/src/stage-record.mjs +165 -33
- package/src/tooling-setup.mjs +1 -1
package/src/qa-node.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch
|
|
|
2
2
|
import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD, starterResidueIsExpected } from "./local-preview-policy.mjs";
|
|
3
3
|
import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion, scriptParseAssertion } from './qa-binding-evidence.mjs';
|
|
4
4
|
import { shellToken } from "./shell-token.mjs";
|
|
5
|
+
import { currentBriefMaterial } from "./input-currency.mjs";
|
|
5
6
|
import { applyQaBuildScope, specForQaScope } from "./qa-build-scope.mjs";
|
|
6
7
|
import { requiredActionText } from "./gate-actions.mjs";
|
|
7
8
|
import { parseOrderPathDepthFlag } from "./proof-policy.mjs";
|
|
@@ -33,11 +34,15 @@ function cmd(verb, rest = "") {
|
|
|
33
34
|
}
|
|
34
35
|
import { isSameAnalyticsCapturePage, runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, upsellActionCoverageWithoutOrders, validatedOrderCreationLimit } from "./qa-browser.mjs";
|
|
35
36
|
import { assessReceiptPurchase } from "./qa-analytics-correctness.mjs";
|
|
37
|
+
import { trackingQaAssertion, trackingRunScopeRows } from "./qa-tracking-params.mjs";
|
|
38
|
+
import { contentParamNotRequestedRows, contentParamQaAssertion } from "./qa-content-params.mjs";
|
|
39
|
+
import { policyLinkNotRequestedRows, policyLinkQaAssertion } from "./qa-policy-links.mjs";
|
|
36
40
|
import { createVerdict, isFindingAssertion, QA_ASSERTION_FAMILY_VOCABULARY, SESSION_ENDING_DISPOSITIONS, SEVERITY, STATUS, validateVerdict } from "./qa-verdict.mjs";
|
|
37
41
|
import { normalizeSdkMetaName, lookupSdkIgnoredMetaTag } from "./sdk-meta-tags.mjs";
|
|
38
42
|
import { annotateQaAssertionCauses, formatCauseReportLines, formatCauseTag } from "./finding-cause.mjs";
|
|
39
43
|
import { promoteQaVerdict, writeQaSidecar } from "./qa-sidecar.mjs";
|
|
40
44
|
import { publishQaVerdict, qaPortalUrl, qaVerdictPublishBlock, QA_VERDICT_PUBLISHERS, skippedQaVerdictPublish } from "./qa-verdict-publish.mjs";
|
|
45
|
+
import { redactPersisted } from "./qa-url-privacy.mjs";
|
|
41
46
|
import {
|
|
42
47
|
isLocalServePacket,
|
|
43
48
|
LOCAL_PROOF_BUILD_ENVIRONMENT,
|
|
@@ -203,7 +208,7 @@ Options:
|
|
|
203
208
|
block before browser launch. The default cap is 6; overflow names the exact raise.
|
|
204
209
|
"tiers" is spec-driven: one strict-selection order per selector tier the
|
|
205
210
|
CampaignSpec declares on the checkout page (order-bump rows marked
|
|
206
|
-
is_upsell are add-ons, never tiers), plus one coupon order per declared
|
|
211
|
+
is_order_bump or is_upsell are add-ons, never tiers), plus one coupon order per declared
|
|
207
212
|
offer code (checkout exit_intent / promo_code_input); "tiers:common" and
|
|
208
213
|
"tiers:full" cross every tier with those path shapes. --select-package
|
|
209
214
|
<ref[:qty],...> narrows a tiers run to the listed declared tiers;
|
|
@@ -464,6 +469,9 @@ async function resolveQaInputs(args, {
|
|
|
464
469
|
routeRootNote: routeRootNotes[0] || null,
|
|
465
470
|
});
|
|
466
471
|
const specHash = computeSpecHash(rawSpec);
|
|
472
|
+
// The brief material this run is judged against, bound at run start beside
|
|
473
|
+
// the spec hash: the QA stage write stamps it, not the material at write time.
|
|
474
|
+
const sourceBriefMaterial = packet ? currentBriefMaterial({ packet, packetPath }) : null;
|
|
467
475
|
const templateFamily = stringArg(packet?.assembly?.template_family)
|
|
468
476
|
|| stringArg(normalized?.spec_identity?.preferred_template_family)
|
|
469
477
|
|| stringArg(normalized?.campaign?.preferred_template_family)
|
|
@@ -520,6 +528,7 @@ async function resolveQaInputs(args, {
|
|
|
520
528
|
spec: normalized,
|
|
521
529
|
specVersion: String(rawSpec.schema_version || rawSpec.schemaVersion || "unknown"),
|
|
522
530
|
specHash,
|
|
531
|
+
sourceBriefMaterial,
|
|
523
532
|
templateFamily,
|
|
524
533
|
commerceStructureContract,
|
|
525
534
|
topologies: qaScope.topologies,
|
|
@@ -579,7 +588,7 @@ function resolvePacketCheckpointPreflight(args, {
|
|
|
579
588
|
}
|
|
580
589
|
if (packet?.spec?.local_spec_id != null && (!reportMatchesPacketIdentity(report, packet)
|
|
581
590
|
|| (specStatus === "ok" && !specHashesMatch(report.identity?.spec_material_hash, computeSpecHash(rawSpec))))) {
|
|
582
|
-
throw new Error("Local-spec QA requires the matching Assembly Report and current spec material hash.
|
|
591
|
+
throw new Error("Local-spec QA requires the matching Assembly Report and current spec material hash. Run `record spec` after a material revision (it keeps stage history); do not reuse foreign or stale proof.");
|
|
583
592
|
}
|
|
584
593
|
const checkpointGates = [
|
|
585
594
|
evaluatePageKitStoreProfile({
|
|
@@ -1581,6 +1590,12 @@ function reportPathField(resolved) {
|
|
|
1581
1590
|
return reportPath ? { report_path: reportPath } : {};
|
|
1582
1591
|
}
|
|
1583
1592
|
|
|
1593
|
+
// The verdict with the brief material qa run bound at run start, when the
|
|
1594
|
+
// run had a packet to bind it from.
|
|
1595
|
+
function withSourceBriefMaterial(verdict, sourceBriefMaterial) {
|
|
1596
|
+
return sourceBriefMaterial ? { ...verdict, source_brief_material: sourceBriefMaterial } : verdict;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1584
1599
|
function resolvePayload(resolved, { routeProbe = null } = {}) {
|
|
1585
1600
|
const entryUrls = deriveEntryUrls(resolved.topologies);
|
|
1586
1601
|
const pageUrls = derivePageUrls(resolved.topologies);
|
|
@@ -2328,6 +2343,7 @@ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCam
|
|
|
2328
2343
|
proxyBase: resolved.proxyBase,
|
|
2329
2344
|
});
|
|
2330
2345
|
assertions.push(...liveCampaignRefAssertions({ pages: [...livePages.values()], spec: liveSpec, liveCampaign: liveRead }));
|
|
2346
|
+
const qcResults = [];
|
|
2331
2347
|
if (args.browser === true) {
|
|
2332
2348
|
const browserAssertions = await runBrowserChecks(resolved.topologies, args, {
|
|
2333
2349
|
brandContract: resolved.brandContract,
|
|
@@ -2338,6 +2354,8 @@ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCam
|
|
|
2338
2354
|
: residueSeverityForThemeGate(gate.status),
|
|
2339
2355
|
supportedPaymentMethods: supportedPaymentMethodsFromSpec(resolved.spec),
|
|
2340
2356
|
bindingExpected,
|
|
2357
|
+
spec: resolved.spec,
|
|
2358
|
+
qcResults,
|
|
2341
2359
|
});
|
|
2342
2360
|
// A page-binding row from the browser is the key the SDK actually sent;
|
|
2343
2361
|
// it takes the place of that page's static read.
|
|
@@ -2346,9 +2364,20 @@ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCam
|
|
|
2346
2364
|
if (at >= 0) assertions[at] = observed;
|
|
2347
2365
|
else assertions.push(observed);
|
|
2348
2366
|
}
|
|
2349
|
-
}
|
|
2350
|
-
|
|
2351
|
-
|
|
2367
|
+
} else {
|
|
2368
|
+
// The browser checks were not requested: each declared content parameter
|
|
2369
|
+
// (param, page) is listed as excluded, never left silent.
|
|
2370
|
+
const rows = contentParamNotRequestedRows(resolved.spec, resolved.topologies);
|
|
2371
|
+
qcResults.push(...rows);
|
|
2372
|
+
assertions.push(...rows.map(contentParamQaAssertion));
|
|
2373
|
+
// Each configured policy link field likewise lists its presence and
|
|
2374
|
+
// availability rows as excluded; no probe is sent.
|
|
2375
|
+
const policyRows = policyLinkNotRequestedRows(resolved.spec, resolved.topologies);
|
|
2376
|
+
qcResults.push(...policyRows);
|
|
2377
|
+
assertions.push(...policyRows.map(policyLinkQaAssertion));
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
const testOrders = await runAnalyticsOrderSequence({ args, resolved, runId, assertions, qcResults });
|
|
2352
2381
|
const remainingAssertionBudget = Math.max(0, QA_VERDICT_ASSERTION_LIMIT - assertions.length);
|
|
2353
2382
|
let commercialResult;
|
|
2354
2383
|
try {
|
|
@@ -2375,6 +2404,7 @@ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCam
|
|
|
2375
2404
|
testOrders,
|
|
2376
2405
|
commercial: commercialResult.commercial,
|
|
2377
2406
|
runSessionActive,
|
|
2407
|
+
qcResults,
|
|
2378
2408
|
});
|
|
2379
2409
|
}
|
|
2380
2410
|
|
|
@@ -2389,7 +2419,7 @@ function reportCommercialRunnerError(args, error, write = (message) => process.s
|
|
|
2389
2419
|
// one canonical typed-card run captures its settled terminal and only then do
|
|
2390
2420
|
// we finalize the stable purchase-fires assertion. There is no receipt replay
|
|
2391
2421
|
// and no second order.
|
|
2392
|
-
async function runAnalyticsOrderSequence({ args, resolved, runId, assertions }, overrides = {}) {
|
|
2422
|
+
async function runAnalyticsOrderSequence({ args, resolved, runId, assertions, qcResults = null }, overrides = {}) {
|
|
2393
2423
|
const operations = {
|
|
2394
2424
|
runInventory: runAnalyticsCorrectnessChecks,
|
|
2395
2425
|
runParity: runAnalyticsParityChecks,
|
|
@@ -2427,6 +2457,7 @@ async function runAnalyticsOrderSequence({ args, resolved, runId, assertions },
|
|
|
2427
2457
|
assertions,
|
|
2428
2458
|
captureAnalytics: analyticsLeg === "run",
|
|
2429
2459
|
});
|
|
2460
|
+
if (Array.isArray(qcResults) && Array.isArray(result.qc_results)) qcResults.push(...result.qc_results);
|
|
2430
2461
|
if (analyticsLeg === "run") {
|
|
2431
2462
|
assertions.push(operations.assessReceipt(result.receiptAnalytics, { waivers: resolved.qaWaivers }));
|
|
2432
2463
|
}
|
|
@@ -2554,7 +2585,7 @@ function applyLocalServeAnalyticsReview(assertions, localServe) {
|
|
|
2554
2585
|
}
|
|
2555
2586
|
}
|
|
2556
2587
|
|
|
2557
|
-
async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, testOrders, commercial = null, runSessionActive = false, browser = null }) {
|
|
2588
|
+
async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, testOrders, commercial = null, runSessionActive = false, browser = null, qcResults = [] }) {
|
|
2558
2589
|
const entryUrls = deriveEntryUrls(resolved.topologies);
|
|
2559
2590
|
const pageUrls = derivePageUrls(resolved.topologies);
|
|
2560
2591
|
const testedUrls = deriveTestedUrlsFromAssertions(assertions, pageUrls);
|
|
@@ -2575,7 +2606,10 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2575
2606
|
currentRunId: runId,
|
|
2576
2607
|
isFinding: isFindingAssertion,
|
|
2577
2608
|
});
|
|
2578
|
-
|
|
2609
|
+
// The verdict as it is persisted: the local file, the committed sidecar and
|
|
2610
|
+
// the published copy all read this one projection (no URL query in any
|
|
2611
|
+
// string or key), whichever runner, return path or field produced a value.
|
|
2612
|
+
const verdict = redactPersisted(withSourceBriefMaterial(createVerdict({
|
|
2579
2613
|
runId,
|
|
2580
2614
|
mapId: resolved.mapId,
|
|
2581
2615
|
localSpecId: resolved.localSpecId,
|
|
@@ -2596,7 +2630,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2596
2630
|
commercial,
|
|
2597
2631
|
causeSummary,
|
|
2598
2632
|
browser,
|
|
2599
|
-
});
|
|
2633
|
+
}), resolved.sourceBriefMaterial));
|
|
2600
2634
|
|
|
2601
2635
|
const validationErrors = validateVerdict(verdict);
|
|
2602
2636
|
if (validationErrors.length) throw new Error(`QA verdict failed local validation:\n- ${validationErrors.join("\n- ")}`);
|
|
@@ -2678,6 +2712,9 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2678
2712
|
browser: verdict.browser || null,
|
|
2679
2713
|
commercial: verdict.commercial || null,
|
|
2680
2714
|
next_actions: buildQaCloseoutActions({ packetPath: resolved.packetPath, localPath, runSessionActive, disposition: verdict.disposition }),
|
|
2715
|
+
// The QC rows the QA stage records; readers re-derive them from the qc.*
|
|
2716
|
+
// assertions of the verdict written above.
|
|
2717
|
+
qc_results: qcResults,
|
|
2681
2718
|
verdict,
|
|
2682
2719
|
};
|
|
2683
2720
|
}
|
|
@@ -2944,6 +2981,23 @@ async function runPageChecks(page, args, {
|
|
|
2944
2981
|
["decline", page.expected_decline_url],
|
|
2945
2982
|
]) {
|
|
2946
2983
|
if (!expectedUrl) continue;
|
|
2984
|
+
const deadProxy = findDeadUpsellProxy(html, kind, page);
|
|
2985
|
+
if (deadProxy) {
|
|
2986
|
+
assertions.push(assertion({
|
|
2987
|
+
id: `route-link:${page.page_id}:${kind}`,
|
|
2988
|
+
family: "funnel-flow",
|
|
2989
|
+
page,
|
|
2990
|
+
status: STATUS.FAIL,
|
|
2991
|
+
severity: SEVERITY.BLOCKER,
|
|
2992
|
+
expected: expectedUrl,
|
|
2993
|
+
actual: deadProxy,
|
|
2994
|
+
evidence: {
|
|
2995
|
+
expected: expectedUrl,
|
|
2996
|
+
note: `The page's ${kind} button only forwards its click to the SDK's data-next-upsell-action inside the offer, and the offer has none, so the shopper's click does nothing. Render the in-offer action (it may be hidden).`,
|
|
2997
|
+
},
|
|
2998
|
+
}));
|
|
2999
|
+
continue;
|
|
3000
|
+
}
|
|
2947
3001
|
const staticFound = htmlIncludesRouteReference(html, expectedUrl);
|
|
2948
3002
|
const sdkAction = staticFound ? null : findSdkRouteAction(html, kind, page);
|
|
2949
3003
|
const found = staticFound || Boolean(sdkAction);
|
|
@@ -3050,21 +3104,32 @@ async function maybeRunTestOrders(
|
|
|
3050
3104
|
const coverage = upsellActionCoverageWithoutOrders(resolved.topologies);
|
|
3051
3105
|
if (coverage) assertions.push(coverage);
|
|
3052
3106
|
}
|
|
3107
|
+
// Tracking parameters ride browser test orders only: a run without one
|
|
3108
|
+
// says so with a run-scope excluded row per tracking check.
|
|
3109
|
+
const notRequestedRows = () => {
|
|
3110
|
+
const rows = trackingRunScopeRows({ runId });
|
|
3111
|
+
assertions.push(...rows.map(trackingQaAssertion));
|
|
3112
|
+
return rows;
|
|
3113
|
+
};
|
|
3053
3114
|
if ((!mode || mode === "off") && (!legacyMode || legacyMode === "off")) {
|
|
3054
|
-
return { orders: [], receiptAnalytics: emptyReceiptAnalytics() };
|
|
3115
|
+
return { orders: [], receiptAnalytics: emptyReceiptAnalytics(), qc_results: notRequestedRows() };
|
|
3055
3116
|
}
|
|
3056
3117
|
if (mode && mode !== "off") {
|
|
3057
3118
|
// Test Orders use global test cards: they bypass the payment gateway, create
|
|
3058
3119
|
// no transactions, and need no merchant setup or approval. `--test-order
|
|
3059
3120
|
// <mode>` is sufficient intent — no permission flags or packet policy gate.
|
|
3060
|
-
const result = await operations.runBrowser(resolved.topologies, args, runId, { captureAnalytics });
|
|
3121
|
+
const result = await operations.runBrowser(resolved.topologies, args, runId, { captureAnalytics, spec: resolved.spec });
|
|
3061
3122
|
assertions.push(...result.assertions);
|
|
3062
|
-
return {
|
|
3123
|
+
return {
|
|
3124
|
+
orders: result.orders,
|
|
3125
|
+
receiptAnalytics: result.receiptAnalytics || emptyReceiptAnalytics(),
|
|
3126
|
+
qc_results: Array.isArray(result.qc_results) ? result.qc_results : [],
|
|
3127
|
+
};
|
|
3063
3128
|
}
|
|
3064
3129
|
|
|
3065
3130
|
const orders = await operations.runLegacy({ args: { ...args, "test-order": legacyMode }, resolved, runId, assertions });
|
|
3066
3131
|
// Direct API diagnostics never qualify as canonical receipt proof.
|
|
3067
|
-
return { orders, receiptAnalytics: emptyReceiptAnalytics() };
|
|
3132
|
+
return { orders, receiptAnalytics: emptyReceiptAnalytics(), qc_results: notRequestedRows() };
|
|
3068
3133
|
}
|
|
3069
3134
|
|
|
3070
3135
|
async function maybeRunLegacyApiTestOrders({ args, resolved, runId, assertions }) {
|
|
@@ -3929,6 +3994,43 @@ function htmlIncludesRouteReference(html, expectedUrl) {
|
|
|
3929
3994
|
return html.includes(expectedUrl) || html.includes(path);
|
|
3930
3995
|
}
|
|
3931
3996
|
|
|
3997
|
+
// A data-upsell-proxy button forwards its click to the SDK action inside the
|
|
3998
|
+
// offer. With no such action, the route's URL can still sit in the page's meta
|
|
3999
|
+
// tags, so the reference match would pass a control that does nothing.
|
|
4000
|
+
function findDeadUpsellProxy(html, kind, page) {
|
|
4001
|
+
if (page.page_type !== "upsell" || kind === "next") return null;
|
|
4002
|
+
const action = kind === "accept" ? "add" : "skip";
|
|
4003
|
+
if (!new RegExp(`\\bdata-upsell-proxy\\s*=\\s*["']${action}["']`, "i").test(html)) return null;
|
|
4004
|
+
const target = new RegExp(`\\bdata-next-upsell-action\\s*=\\s*["']${action}["']`, "i");
|
|
4005
|
+
if (upsellOfferElements(html).some((offer) => target.test(offer))) return null;
|
|
4006
|
+
return `data-upsell-proxy="${action}" with no data-next-upsell-action="${action}" inside the offer to forward to`;
|
|
4007
|
+
}
|
|
4008
|
+
|
|
4009
|
+
// The markup of each [data-next-upsell="offer"] element, read to its closing
|
|
4010
|
+
// tag by depth of that tag name: the proxy forwards only to actions inside it.
|
|
4011
|
+
function upsellOfferElements(html) {
|
|
4012
|
+
const offers = [];
|
|
4013
|
+
const attrRe = /\bdata-next-upsell\s*=\s*["']offer["']/gi;
|
|
4014
|
+
let attr;
|
|
4015
|
+
while ((attr = attrRe.exec(html))) {
|
|
4016
|
+
const start = html.lastIndexOf("<", attr.index);
|
|
4017
|
+
const name = /^<([a-z][\w-]*)/i.exec(html.slice(start))?.[1];
|
|
4018
|
+
if (!name) continue;
|
|
4019
|
+
const tagRe = new RegExp(`<(/?)${name}\\b[^>]*>`, "gi");
|
|
4020
|
+
tagRe.lastIndex = start;
|
|
4021
|
+
let depth = 0;
|
|
4022
|
+
let end = html.length;
|
|
4023
|
+
let match;
|
|
4024
|
+
while ((match = tagRe.exec(html))) {
|
|
4025
|
+
if (match[1]) depth -= 1;
|
|
4026
|
+
else if (!match[0].endsWith("/>")) depth += 1;
|
|
4027
|
+
if (depth === 0) { end = match.index; break; }
|
|
4028
|
+
}
|
|
4029
|
+
offers.push(html.slice(start, end));
|
|
4030
|
+
}
|
|
4031
|
+
return offers;
|
|
4032
|
+
}
|
|
4033
|
+
|
|
3932
4034
|
function findSdkRouteAction(html, kind, page) {
|
|
3933
4035
|
if (page.page_type !== "upsell") return null;
|
|
3934
4036
|
if (kind === "accept" && /\bdata-next-upsell-action\s*=\s*["']add["']/i.test(html)) {
|
|
@@ -4009,6 +4111,7 @@ export const __qaNodeTestHooks = Object.freeze({
|
|
|
4009
4111
|
analyticsCorrectnessDisabledAssertion,
|
|
4010
4112
|
runAnalyticsOrderSequence,
|
|
4011
4113
|
maybeRunTestOrders,
|
|
4114
|
+
finalizeQaRun,
|
|
4012
4115
|
campaignOutputDir,
|
|
4013
4116
|
polishBlockedAssertions,
|
|
4014
4117
|
polishGateAssertion,
|
package/src/qa-order-bump.mjs
CHANGED
|
@@ -287,6 +287,25 @@ export function orderBumpEvidenceScript() {
|
|
|
287
287
|
};
|
|
288
288
|
|
|
289
289
|
|
|
290
|
+
// Text painted with a zero-alpha colour (`color: transparent`, or any
|
|
291
|
+
// colour function whose alpha is 0) occupies its box and draws nothing.
|
|
292
|
+
// Computed colours come back as a function: comma form, where a fourth
|
|
293
|
+
// argument is the alpha (`rgba(0, 0, 0, 0)`, `hsla(…, 0)`), or space form
|
|
294
|
+
// with the alpha after a slash (`color(srgb 0 0 0 / 0)`).
|
|
295
|
+
const transparentInk = (style) => {
|
|
296
|
+
const ink = String(style.webkitTextFillColor || style.color || "").trim().toLowerCase();
|
|
297
|
+
if (ink === "transparent") return true;
|
|
298
|
+
const fn = ink.match(/^[a-z-]+\((.*)\)$/);
|
|
299
|
+
if (!fn) return false;
|
|
300
|
+
const args = fn[1];
|
|
301
|
+
const alpha = args.includes("/")
|
|
302
|
+
? args.slice(args.lastIndexOf("/") + 1).trim()
|
|
303
|
+
: (args.split(",").length === 4 ? args.split(",")[3].trim() : null);
|
|
304
|
+
if (alpha === null) return false;
|
|
305
|
+
const value = alpha.endsWith("%") ? Number.parseFloat(alpha) / 100 : Number.parseFloat(alpha);
|
|
306
|
+
return Number.isFinite(value) && value === 0;
|
|
307
|
+
};
|
|
308
|
+
|
|
290
309
|
// The positive signals, in the order that settles which vocabulary the
|
|
291
310
|
// marker speaks. Returns null when a rendered marker carries none.
|
|
292
311
|
const checkedSignal = (marker) => {
|
|
@@ -296,7 +315,9 @@ export function orderBumpEvidenceScript() {
|
|
|
296
315
|
// rendering — never the box's.
|
|
297
316
|
const tick = pseudoTick(marker);
|
|
298
317
|
if (tick) return { signal: "pseudo", checked: tick.shown };
|
|
299
|
-
|
|
318
|
+
// A glyph tick that is always in the DOM can be shown and hidden by its
|
|
319
|
+
// colour alone, so the text is a tick only when it is painted.
|
|
320
|
+
if (/check|\u2713/.test(marker.textContent || "")) return { signal: "glyph", checked: !transparentInk(style) };
|
|
300
321
|
if (style.backgroundColor === acceptedFillColor) return { signal: "fill", checked: true };
|
|
301
322
|
if (hiddenByAMatchingRule(marker)) return { signal: "display_toggled", checked: true };
|
|
302
323
|
return null;
|