@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-browser.mjs
CHANGED
|
@@ -6,13 +6,18 @@ const PACKAGE_ROOT = installModeResolve(installModeDirname(installModeFileUrl(im
|
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import { runWithDeadline } from "./deadline.mjs";
|
|
8
8
|
import { PLACEHOLDER_TEXT_ASSERTION_SUFFIX, SEVERITY, STATUS } from "./qa-verdict.mjs";
|
|
9
|
+
import { contrastToolkit } from "./contrast.mjs";
|
|
10
|
+
import { generatedTextRenders } from "./polish-readability.mjs";
|
|
9
11
|
import {
|
|
10
12
|
analyticsCaptureError,
|
|
11
13
|
projectAnalyticsCaptureError,
|
|
12
14
|
} from "./qa-analytics-errors.mjs";
|
|
13
15
|
import { attachAnalyticsCapture, diffAnalyticsParity } from "./qa-analytics-parity.mjs";
|
|
14
16
|
import { assessAnalyticsInventory } from "./qa-analytics-correctness.mjs";
|
|
15
|
-
import { redactUrlQuery } from "./qa-url-privacy.mjs";
|
|
17
|
+
import { redactPersisted, redactUrlQueriesInText, redactUrlQuery } from "./qa-url-privacy.mjs";
|
|
18
|
+
import { TRACKING_ADDED_BOUND_MS, TRACKING_OBSERVATION, createTrackingRun, trackingQaAssertion } from "./qa-tracking-params.mjs";
|
|
19
|
+
import { runContentParamChecks } from "./qa-content-params.mjs";
|
|
20
|
+
import { createPolicyLinkBudget, hasPolicyLinkFields, readPageAnchors, runPolicyLinkChecks } from "./qa-policy-links.mjs";
|
|
16
21
|
import {
|
|
17
22
|
canonicalHttpUrl,
|
|
18
23
|
commonTestOrderPaths,
|
|
@@ -44,7 +49,7 @@ import {
|
|
|
44
49
|
} from "./qa-cart-entry.mjs";
|
|
45
50
|
import { ORDER_BUMP_PROBE_INPUT, orderBumpEvidenceScript } from "./qa-order-bump.mjs";
|
|
46
51
|
import { assessPurchaseDataLayer, expectedOrderReferences, purchaseDataLayerAssertion, purchaseDataLayerProbe } from "./qa-purchase-data-layer.mjs";
|
|
47
|
-
import {
|
|
52
|
+
import { declaredOrderBumps, declaredSelectorTiers } from "./commercial-journey.mjs";
|
|
48
53
|
import { bindingAssertion, isSdkApiRequest, sentBinding } from "./qa-binding-evidence.mjs";
|
|
49
54
|
import {
|
|
50
55
|
RESIDUE_PAGE_TYPES,
|
|
@@ -118,18 +123,45 @@ const CART_CREATE_RESPONSE_PATTERN = /\/api\/v1\/carts\/?(?:[?#].*)?$/i;
|
|
|
118
123
|
|
|
119
124
|
export async function runBrowserChecks(topologies, args = {}, options = {}) {
|
|
120
125
|
const browser = await launchChromium(args);
|
|
121
|
-
const
|
|
126
|
+
const contextOptions = {
|
|
122
127
|
viewport: viewportFromArgs(args),
|
|
123
128
|
extraHTTPHeaders: args["auth-cookie"] ? { Cookie: String(args["auth-cookie"]) } : undefined,
|
|
124
|
-
}
|
|
129
|
+
};
|
|
130
|
+
const context = await browser.newContext(contextOptions);
|
|
125
131
|
|
|
126
132
|
try {
|
|
127
133
|
const assertions = [];
|
|
134
|
+
// Policy links (options.spec campaign.store_*): one {read, anchors} entry
|
|
135
|
+
// per page visit, filled by the page checks, and the one run budget every
|
|
136
|
+
// anchor read and the probes draw on.
|
|
137
|
+
const policyLinks = Array.isArray(options.qcResults) && hasPolicyLinkFields(options.spec) ? { pages: [], budget: createPolicyLinkBudget() } : null;
|
|
128
138
|
for (const topology of topologies) {
|
|
129
139
|
for (const page of topology.pages) {
|
|
130
|
-
assertions.push(...await runPageBrowserChecks(context, page, args, options));
|
|
140
|
+
assertions.push(...await runPageBrowserChecks(context, page, args, options, policyLinks));
|
|
131
141
|
}
|
|
132
142
|
}
|
|
143
|
+
// Content parameters (options.spec analytics.params.content), after the
|
|
144
|
+
// page checks: every load in its own fresh context with the same options,
|
|
145
|
+
// closed after the load. The rows go to options.qcResults; their verdict
|
|
146
|
+
// assertions join the page checks'.
|
|
147
|
+
if (Array.isArray(options.qcResults)) {
|
|
148
|
+
const contentParams = await runContentParamChecks({
|
|
149
|
+
topologies,
|
|
150
|
+
spec: options.spec,
|
|
151
|
+
newContext: () => browser.newContext(contextOptions),
|
|
152
|
+
withQueryParam,
|
|
153
|
+
});
|
|
154
|
+
options.qcResults.push(...contentParams.rows);
|
|
155
|
+
assertions.push(...contentParams.assertions);
|
|
156
|
+
}
|
|
157
|
+
// Policy links, after the page checks: presence from the anchors each
|
|
158
|
+
// visit read, availability from one header-only probe per distinct
|
|
159
|
+
// configured URL.
|
|
160
|
+
if (policyLinks) {
|
|
161
|
+
const policyLinkResults = await runPolicyLinkChecks({ spec: options.spec, pages: policyLinks.pages, budget: policyLinks.budget });
|
|
162
|
+
options.qcResults.push(...policyLinkResults.rows);
|
|
163
|
+
assertions.push(...policyLinkResults.assertions);
|
|
164
|
+
}
|
|
133
165
|
return assertions;
|
|
134
166
|
} finally {
|
|
135
167
|
await context.close().catch(() => {});
|
|
@@ -165,6 +197,10 @@ export async function runBrowserTestOrders(topologies, args = {}, runId = "local
|
|
|
165
197
|
const orderPathPlan = isCommonTestOrderMode(args["test-order"]) ? commonOrderPathPlan(topologies, args) : null;
|
|
166
198
|
if (orderPathPlan) (options.warn || ((line) => process.stderr.write(`${line}\n`)))(describeCommonOrderPathPlan(orderPathPlan));
|
|
167
199
|
const creationBudget = createOrderCreationBudget({ plans, args });
|
|
200
|
+
// Synthetic tracking seeds for this run's attempts (options.spec carries
|
|
201
|
+
// analytics.params.tracking.preserve; trackingTestHooks is an in-process
|
|
202
|
+
// test seam only).
|
|
203
|
+
const tracking = createTrackingRun({ runId, spec: options.spec || null, hooks: options.trackingTestHooks || null });
|
|
168
204
|
|
|
169
205
|
const browser = await launchChromium(args);
|
|
170
206
|
const context = await browser.newContext({
|
|
@@ -181,13 +217,18 @@ export async function runBrowserTestOrders(topologies, args = {}, runId = "local
|
|
|
181
217
|
runId,
|
|
182
218
|
// The topologies travel with the options so each attempt can resolve the
|
|
183
219
|
// funnel's cart-entry page for the checkout it drives (campaigns-os#206).
|
|
184
|
-
options: { ...options, creationBudget, topologies, orderPathPlan },
|
|
220
|
+
options: { ...options, creationBudget, topologies, orderPathPlan, tracking },
|
|
185
221
|
});
|
|
186
222
|
return {
|
|
187
|
-
|
|
188
|
-
|
|
223
|
+
// The persisted exit: order URLs leave as origin+path. The in-memory
|
|
224
|
+
// orders stay raw, because recovery reloads the recorded receipt URL.
|
|
225
|
+
// Assertions and QC rows leave through the one persisted projection,
|
|
226
|
+
// so each qc.* assertion and its QC row stay identical.
|
|
227
|
+
orders: dispatched.orders.map(persistedTestOrder),
|
|
228
|
+
assertions: dispatched.assertions.map(redactPersisted),
|
|
189
229
|
receiptAnalytics: dispatched.receiptAnalytics,
|
|
190
230
|
journeyAnalytics: dispatched.journeyAnalytics,
|
|
231
|
+
qc_results: (dispatched.qcResults || []).map(redactPersisted),
|
|
191
232
|
};
|
|
192
233
|
} finally {
|
|
193
234
|
await context.close().catch(() => {});
|
|
@@ -221,6 +262,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
221
262
|
|
|
222
263
|
const assertions = [];
|
|
223
264
|
const orders = [];
|
|
265
|
+
const qcResults = [];
|
|
224
266
|
// The plan behind each entry in `orders`, index for index, so the upsell
|
|
225
267
|
// coverage row can tell which funnel an order ran through.
|
|
226
268
|
const orderPlans = [];
|
|
@@ -387,6 +429,19 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
387
429
|
if (renderedReceiptAssertion) assertions.push(renderedReceiptAssertion);
|
|
388
430
|
const dataLayerAssertion = purchaseDataLayerAssertion(pageForPlan, identifier, result.order);
|
|
389
431
|
if (dataLayerAssertion) assertions.push(dataLayerAssertion);
|
|
432
|
+
// Tracking parameter rows for this plan. They never decide the order's
|
|
433
|
+
// own assertion, and a failure here never breaks the run.
|
|
434
|
+
try {
|
|
435
|
+
const trackingRows = options.tracking
|
|
436
|
+
? options.tracking.rowsFor({ attempts: attemptsForPlan, recoveredAttempt: creationRecord?.action === "recovered" ? firstAttempt : null })
|
|
437
|
+
: [];
|
|
438
|
+
for (const row of trackingRows) {
|
|
439
|
+
qcResults.push(row);
|
|
440
|
+
assertions.push(trackingQaAssertion(row));
|
|
441
|
+
}
|
|
442
|
+
} catch {
|
|
443
|
+
// No row is recorded; the QC handoff lists the check as not captured.
|
|
444
|
+
}
|
|
390
445
|
}
|
|
391
446
|
} catch (error) {
|
|
392
447
|
// Convert runner-level surprises into a blocker assertion so the run still
|
|
@@ -412,7 +467,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
412
467
|
});
|
|
413
468
|
if (coverage) assertions.push(coverage);
|
|
414
469
|
|
|
415
|
-
return { orders, assertions, receiptAnalytics, journeyAnalytics, creationBudget };
|
|
470
|
+
return { orders, assertions, receiptAnalytics, journeyAnalytics, creationBudget, qcResults };
|
|
416
471
|
}
|
|
417
472
|
|
|
418
473
|
// Analytics-parity leg: capture the live dataLayer event stream + GTM/pixel
|
|
@@ -930,8 +985,11 @@ export async function captureAnalyticsForUrls(urls = {}, args = {}) {
|
|
|
930
985
|
}
|
|
931
986
|
}
|
|
932
987
|
|
|
933
|
-
async function runPageBrowserChecks(context, page, args, options = {}) {
|
|
988
|
+
async function runPageBrowserChecks(context, page, args, options = {}, policyLinks = null) {
|
|
934
989
|
const assertions = [];
|
|
990
|
+
// This visit's policy link read; it stays unread unless its anchors are read.
|
|
991
|
+
const policyLinkRead = { read: false, anchors: null };
|
|
992
|
+
policyLinks?.pages.push(policyLinkRead);
|
|
935
993
|
if (!page.url) {
|
|
936
994
|
assertions.push(assertion({
|
|
937
995
|
id: `browser-load:${page.page_id}`,
|
|
@@ -983,6 +1041,12 @@ async function runPageBrowserChecks(context, page, args, options = {}) {
|
|
|
983
1041
|
await browserPage.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
|
|
984
1042
|
observation.settle_ms = elapsedMilliseconds(settleStarted);
|
|
985
1043
|
const status = response?.status() ?? null;
|
|
1044
|
+
// One read of the rendered anchors, from an isolated world. A page that
|
|
1045
|
+
// was not served is not read.
|
|
1046
|
+
if (policyLinks && !(status && status >= 400)) {
|
|
1047
|
+
const anchors = await readPageAnchors(context, browserPage, { spec: options.spec, budget: policyLinks.budget });
|
|
1048
|
+
if (anchors) Object.assign(policyLinkRead, { read: true, anchors });
|
|
1049
|
+
}
|
|
986
1050
|
const title = await browserPage.title().catch(() => "");
|
|
987
1051
|
const bodyPresent = await browserPage.locator("body").count().then((count) => count > 0).catch(() => false);
|
|
988
1052
|
|
|
@@ -1238,9 +1302,7 @@ async function primaryCtaVisualAssertions(browserPage, page) {
|
|
|
1238
1302
|
}
|
|
1239
1303
|
|
|
1240
1304
|
function primaryCtaCheckEligible(page) {
|
|
1241
|
-
|
|
1242
|
-
const pageType = String(page.page_type || "").toLowerCase();
|
|
1243
|
-
return !["checkout", "upsell", "downsell", "thankyou", "receipt"].includes(pageType);
|
|
1305
|
+
return Boolean(page?.expected_next_url);
|
|
1244
1306
|
}
|
|
1245
1307
|
|
|
1246
1308
|
// Candidate CTAs: anything clickable, plus every SDK action control and the
|
|
@@ -1252,10 +1314,12 @@ const PRIMARY_CTA_SELECTOR = ["a[href]", "button", "[role='button']", "[data-nex
|
|
|
1252
1314
|
|
|
1253
1315
|
// The in-page half of the primary-CTA inspection, as the source text the
|
|
1254
1316
|
// page evaluates. The route rule it needs (cartEntryHrefFor, unit-tested in
|
|
1255
|
-
// qa-cart-entry)
|
|
1256
|
-
//
|
|
1257
|
-
//
|
|
1258
|
-
//
|
|
1317
|
+
// qa-cart-entry), the shared contrast helper (contrastToolkit) and the
|
|
1318
|
+
// generated-text rule Polish reads (generatedTextRenders) are handed in as
|
|
1319
|
+
// function values rather than closed over, so every function body must
|
|
1320
|
+
// stay free of module-scope references: the text is run in a fresh context by
|
|
1321
|
+
// a test (primary-CTA inspection script is self-contained) that would surface
|
|
1322
|
+
// a leaked identifier as a ReferenceError.
|
|
1259
1323
|
function primaryCtaInspectionScript(expectedUrl) {
|
|
1260
1324
|
const args = {
|
|
1261
1325
|
routeUrl: expectedUrl,
|
|
@@ -1264,7 +1328,7 @@ function primaryCtaInspectionScript(expectedUrl) {
|
|
|
1264
1328
|
cartEntryRouteAttribute: CART_ENTRY_ROUTE_ATTRIBUTE,
|
|
1265
1329
|
ignoredRouteAttributes: [...UNDECLARED_ROUTE_ATTRIBUTES],
|
|
1266
1330
|
};
|
|
1267
|
-
return `(${inspectPrimaryCtaScript.toString()})(${JSON.stringify(args)}, ${cartEntryHrefFor.toString()})`;
|
|
1331
|
+
return `(${inspectPrimaryCtaScript.toString()})(${JSON.stringify(args)}, ${cartEntryHrefFor.toString()}, ${contrastToolkit.toString()}, ${generatedTextRenders.toString()})`;
|
|
1268
1332
|
}
|
|
1269
1333
|
|
|
1270
1334
|
async function inspectPrimaryCta(browserPage, expectedUrl) {
|
|
@@ -1278,8 +1342,9 @@ async function inspectPrimaryCta(browserPage, expectedUrl) {
|
|
|
1278
1342
|
}));
|
|
1279
1343
|
}
|
|
1280
1344
|
|
|
1281
|
-
function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, cartEntryRouteAttribute, ignoredRouteAttributes }, hrefForImpl) {
|
|
1345
|
+
function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, cartEntryRouteAttribute, ignoredRouteAttributes }, hrefForImpl, contrastToolkitImpl, generatedTextImpl) {
|
|
1282
1346
|
const CTA_SELECTOR = ctaSelector;
|
|
1347
|
+
const toolkit = contrastToolkitImpl();
|
|
1283
1348
|
|
|
1284
1349
|
const trim = (value) => String(value || "").replace(/\s+/g, " ").trim();
|
|
1285
1350
|
const compactPath = (value) => String(value || "").replace(/\/+$/, "") || "/";
|
|
@@ -1290,42 +1355,6 @@ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, car
|
|
|
1290
1355
|
return null;
|
|
1291
1356
|
}
|
|
1292
1357
|
})();
|
|
1293
|
-
const parseColor = (value) => {
|
|
1294
|
-
const raw = String(value || "").trim().toLowerCase();
|
|
1295
|
-
if (!raw || raw === "transparent") return null;
|
|
1296
|
-
const rgb = raw.match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*(\d?(?:\.\d+)?|1(?:\.0+)?))?\s*\)$/);
|
|
1297
|
-
if (!rgb) return null;
|
|
1298
|
-
const parts = rgb.slice(1, 4).map((part) => Number(part));
|
|
1299
|
-
if (parts.some((part) => !Number.isFinite(part) || part < 0 || part > 255)) return null;
|
|
1300
|
-
const alpha = rgb[4] === undefined ? 1 : Number(rgb[4]);
|
|
1301
|
-
return { r: parts[0], g: parts[1], b: parts[2], a: Number.isFinite(alpha) ? alpha : 1 };
|
|
1302
|
-
};
|
|
1303
|
-
const hex = (color) => color ? `#${[color.r, color.g, color.b].map((part) => Math.round(part).toString(16).padStart(2, "0")).join("")}` : null;
|
|
1304
|
-
const luminance = (color) => {
|
|
1305
|
-
const channel = (value) => {
|
|
1306
|
-
const normalized = value / 255;
|
|
1307
|
-
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
|
1308
|
-
};
|
|
1309
|
-
return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
|
|
1310
|
-
};
|
|
1311
|
-
const contrast = (a, b) => {
|
|
1312
|
-
if (!a || !b) return null;
|
|
1313
|
-
const light = Math.max(luminance(a), luminance(b));
|
|
1314
|
-
const dark = Math.min(luminance(a), luminance(b));
|
|
1315
|
-
return Math.round(((light + 0.05) / (dark + 0.05)) * 100) / 100;
|
|
1316
|
-
};
|
|
1317
|
-
const effectiveBackground = (element) => {
|
|
1318
|
-
let current = element;
|
|
1319
|
-
while (current && current.nodeType === Node.ELEMENT_NODE) {
|
|
1320
|
-
const style = getComputedStyle(current);
|
|
1321
|
-
const color = parseColor(style.backgroundColor);
|
|
1322
|
-
if (color && color.a > 0.05) {
|
|
1323
|
-
return { color, source: current === element ? "element" : current.tagName.toLowerCase() };
|
|
1324
|
-
}
|
|
1325
|
-
current = current.parentElement;
|
|
1326
|
-
}
|
|
1327
|
-
return { color: { r: 255, g: 255, b: 255, a: 1 }, source: "assumed_canvas" };
|
|
1328
|
-
};
|
|
1329
1358
|
const isVisible = (element) => {
|
|
1330
1359
|
const rect = element.getBoundingClientRect();
|
|
1331
1360
|
const style = getComputedStyle(element);
|
|
@@ -1378,56 +1407,124 @@ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, car
|
|
|
1378
1407
|
// per-element detail for the listed candidates only.
|
|
1379
1408
|
const ignoredAttributes = Array.from(new Set(visibleElements.flatMap(ignoredAttributesOn))).sort();
|
|
1380
1409
|
|
|
1381
|
-
|
|
1410
|
+
// A route candidate's text, read through the shared contrast helper: each
|
|
1411
|
+
// text-bearing element inside it is compared, unrounded, with the
|
|
1412
|
+
// requirement for its own size. An element with a review trigger (or with
|
|
1413
|
+
// no ratio to compare) is a review member and is not compared; an element
|
|
1414
|
+
// whose control is disabled or loading has no colours read. Text whose
|
|
1415
|
+
// control is disabled is inactive: it is not rendered text the check can
|
|
1416
|
+
// read, so it never counts toward text_rendered or toward a pass. A
|
|
1417
|
+
// disabled candidate is not measured at all, whatever text it generates,
|
|
1418
|
+
// and never counts toward a pass. In an enabled candidate, text an element
|
|
1419
|
+
// inside it generates through ::before or ::after (generatedTextImpl) is
|
|
1420
|
+
// never measured, so the candidate is reviewed, never passed on its other
|
|
1421
|
+
// text.
|
|
1422
|
+
const measureCandidate = (element) => {
|
|
1423
|
+
if (element.matches(":disabled, [aria-disabled=\"true\"]")) {
|
|
1424
|
+
return { disabled: true, text_rendered: null, control_loading: null, contrast_ratio: null, elements: [], below: false, review: false, measured: false, inactive: false };
|
|
1425
|
+
}
|
|
1426
|
+
// Every element the page renders inside the candidate: its own subtree,
|
|
1427
|
+
// open shadow roots included.
|
|
1428
|
+
const subtree = toolkit.flatSubtree(element);
|
|
1429
|
+
const reads = subtree
|
|
1430
|
+
.filter((node) => toolkit.isTextBearing(node, window))
|
|
1431
|
+
.map((node) => toolkit.measureTextElement(node, window));
|
|
1432
|
+
const compared = [];
|
|
1433
|
+
let review = subtree.some((node) => generatedTextImpl(node, window));
|
|
1434
|
+
for (const read of reads) {
|
|
1435
|
+
if (read.disabled || read.control_loading) continue;
|
|
1436
|
+
if (read.review_reason !== null || typeof read.ratio !== "number") review = true;
|
|
1437
|
+
else compared.push(read);
|
|
1438
|
+
}
|
|
1439
|
+
const ratios = compared.map((read) => read.ratio);
|
|
1440
|
+
return {
|
|
1441
|
+
disabled: false,
|
|
1442
|
+
text_rendered: reads.some((read) => !read.disabled),
|
|
1443
|
+
control_loading: reads.some((read) => read.control_loading),
|
|
1444
|
+
contrast_ratio: ratios.length ? toolkit.displayRatio(Math.min(...ratios)) : null,
|
|
1445
|
+
elements: reads.map((read) => ({
|
|
1446
|
+
selector_path: read.selector_path,
|
|
1447
|
+
contrast_ratio_exact: read.ratio,
|
|
1448
|
+
required_ratio: read.required,
|
|
1449
|
+
size_class: read.size_class,
|
|
1450
|
+
review_reason: read.review_reason,
|
|
1451
|
+
})),
|
|
1452
|
+
below: compared.some((read) => !toolkit.meetsRequirement(read.ratio, read.required)),
|
|
1453
|
+
review,
|
|
1454
|
+
measured: compared.length > 0,
|
|
1455
|
+
inactive: reads.some((read) => read.disabled),
|
|
1456
|
+
};
|
|
1457
|
+
};
|
|
1458
|
+
|
|
1459
|
+
// Route candidates carry their measurement; other rows are listed for the
|
|
1460
|
+
// route-recognition view only and are not measured.
|
|
1461
|
+
const verdicts = new Map();
|
|
1462
|
+
const rows = visibleElements
|
|
1382
1463
|
.map((element) => {
|
|
1383
1464
|
const rect = element.getBoundingClientRect();
|
|
1384
|
-
const style = getComputedStyle(element);
|
|
1385
|
-
const fg = parseColor(style.color);
|
|
1386
|
-
const bg = effectiveBackground(element);
|
|
1387
|
-
const ratio = contrast(fg, bg.color);
|
|
1388
1465
|
const href = hrefFor(element);
|
|
1389
1466
|
const label = trim(element.innerText || element.textContent || element.getAttribute("aria-label"));
|
|
1390
1467
|
return {
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
readable: typeof ratio === "number" && ratio >= 4.5,
|
|
1403
|
-
size_ok: rect.width >= 40 && rect.height >= 20,
|
|
1468
|
+
element,
|
|
1469
|
+
row: {
|
|
1470
|
+
selector: selectorFor(element),
|
|
1471
|
+
text: label.slice(0, 120),
|
|
1472
|
+
href,
|
|
1473
|
+
route_matches: routeMatches(href),
|
|
1474
|
+
ignored_attributes: ignoredAttributesOn(element),
|
|
1475
|
+
width: Math.round(rect.width),
|
|
1476
|
+
height: Math.round(rect.height),
|
|
1477
|
+
size_ok: rect.width >= 40 && rect.height >= 20,
|
|
1478
|
+
},
|
|
1404
1479
|
};
|
|
1405
1480
|
})
|
|
1406
|
-
.filter((
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
return (b.contrast_ratio || 0) - (a.contrast_ratio || 0);
|
|
1481
|
+
.filter(({ row }) => row.text || row.href)
|
|
1482
|
+
.map(({ element, row }) => {
|
|
1483
|
+
if (!row.route_matches) return { ...row, disabled: null, text_rendered: null, control_loading: null, contrast_ratio: null, elements: null };
|
|
1484
|
+
const { below, review, measured, inactive, ...read } = measureCandidate(element);
|
|
1485
|
+
const candidate = { ...row, ...read };
|
|
1486
|
+
verdicts.set(candidate, { below, review, measured, inactive });
|
|
1487
|
+
return candidate;
|
|
1414
1488
|
});
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1489
|
+
|
|
1490
|
+
// Every route candidate counts, in document order; none is preferred.
|
|
1491
|
+
// The page outcome is the first that applies. An enabled candidate whose
|
|
1492
|
+
// only text is inactive (its control is disabled) has no rendered text;
|
|
1493
|
+
// cta_disabled is only for every candidate disabled. The page passes only
|
|
1494
|
+
// when every enabled candidate had at least one element compared, so text
|
|
1495
|
+
// that was skipped never stands in for a measurement.
|
|
1496
|
+
const routeCandidates = rows.filter((candidate) => candidate.route_matches);
|
|
1497
|
+
const enabled = routeCandidates.filter((candidate) => !candidate.disabled);
|
|
1498
|
+
const measurability = routeCandidates.length ? toolkit.documentMeasurability(document) : { measurable: true };
|
|
1499
|
+
const reason = !routeCandidates.length
|
|
1500
|
+
? "missing_route_cta"
|
|
1501
|
+
: !measurability.measurable
|
|
1502
|
+
? measurability.reason
|
|
1503
|
+
: enabled.some((candidate) => verdicts.get(candidate).below)
|
|
1504
|
+
? "low_contrast"
|
|
1505
|
+
: !routeCandidates.some((candidate) => candidate.size_ok)
|
|
1506
|
+
? "cta_too_small"
|
|
1507
|
+
: enabled.some((candidate) => !candidate.text_rendered)
|
|
1508
|
+
? "text_not_rendered"
|
|
1509
|
+
: enabled.some((candidate) => candidate.control_loading)
|
|
1510
|
+
? "control_loading"
|
|
1511
|
+
: enabled.some((candidate) => verdicts.get(candidate).review)
|
|
1512
|
+
? "contrast_review"
|
|
1513
|
+
: !enabled.length || enabled.some((candidate) => !verdicts.get(candidate).measured)
|
|
1514
|
+
? "cta_disabled"
|
|
1515
|
+
: "ok";
|
|
1516
|
+
|
|
1517
|
+
// Rows are capped at eight, except that every route candidate is listed.
|
|
1518
|
+
const listed = [];
|
|
1519
|
+
for (const row of rows) if (row.route_matches || listed.length < 8) listed.push(row);
|
|
1424
1520
|
|
|
1425
1521
|
return {
|
|
1426
|
-
ok,
|
|
1522
|
+
ok: reason === "ok",
|
|
1427
1523
|
reason,
|
|
1428
1524
|
expected_url: routeUrl,
|
|
1429
|
-
|
|
1430
|
-
|
|
1525
|
+
// The first route candidate in document order, for display only.
|
|
1526
|
+
primary: routeCandidates[0] || null,
|
|
1527
|
+
candidates: listed,
|
|
1431
1528
|
// Every route-shaped attribute seen on a visible CTA-shaped element and
|
|
1432
1529
|
// not consulted (see above), so a missing-route verdict on a page spelled
|
|
1433
1530
|
// that way reads as a vocabulary gap, not as a removed CTA.
|
|
@@ -1435,8 +1532,20 @@ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, car
|
|
|
1435
1532
|
};
|
|
1436
1533
|
}
|
|
1437
1534
|
|
|
1535
|
+
// The outcomes the page itself earns as a failure. Every other outcome that
|
|
1536
|
+
// is not a pass could not be observed completely (the page was not
|
|
1537
|
+
// measurable, text or a control was not ready, a review trigger, every CTA
|
|
1538
|
+
// disabled, or an inspection the page rejected): manual review, never a pass
|
|
1539
|
+
// and never a contrast failure.
|
|
1540
|
+
const PRIMARY_CTA_FAIL_REASONS = Object.freeze([
|
|
1541
|
+
"missing_route_cta",
|
|
1542
|
+
"low_contrast",
|
|
1543
|
+
"cta_too_small",
|
|
1544
|
+
]);
|
|
1545
|
+
|
|
1438
1546
|
function primaryCtaAssertionFromEvidence(page, evidence) {
|
|
1439
1547
|
const ok = evidence?.ok === true;
|
|
1548
|
+
const review = !ok && !PRIMARY_CTA_FAIL_REASONS.includes(evidence?.reason);
|
|
1440
1549
|
const ignored = Array.isArray(evidence?.ignored_attributes) ? evidence.ignored_attributes.filter(Boolean) : [];
|
|
1441
1550
|
// Named on every verdict the page earns, passing or not: a passing page
|
|
1442
1551
|
// spelled with an undeclared route attribute is still one an operator
|
|
@@ -1447,7 +1556,7 @@ function primaryCtaAssertionFromEvidence(page, evidence) {
|
|
|
1447
1556
|
id: `browser-primary-cta:${page.page_id}`,
|
|
1448
1557
|
family: "browser-runtime",
|
|
1449
1558
|
page,
|
|
1450
|
-
status: ok ? STATUS.PASS : STATUS.FAIL,
|
|
1559
|
+
status: ok ? STATUS.PASS : review ? STATUS.MANUAL_REVIEW : STATUS.FAIL,
|
|
1451
1560
|
severity: ok ? undefined : SEVERITY.WARN,
|
|
1452
1561
|
expected: "visible readable primary CTA linked to the expected next route",
|
|
1453
1562
|
actual: ok
|
|
@@ -3493,6 +3602,12 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
|
|
|
3493
3602
|
let events = { requests: [], responses: [], failed: [], console: [], pageErrors: [] };
|
|
3494
3603
|
const email = testEmail(planArgs);
|
|
3495
3604
|
const entryPage = resolveCartEntryPage(options.topologies, checkoutPage);
|
|
3605
|
+
let tracking = null;
|
|
3606
|
+
try {
|
|
3607
|
+
tracking = options.tracking ? options.tracking.observe(planId(normalizedPlan)) : null;
|
|
3608
|
+
} catch {
|
|
3609
|
+
tracking = null;
|
|
3610
|
+
}
|
|
3496
3611
|
// Reserved immediately before the submit click, never reconciled afterwards:
|
|
3497
3612
|
// an accounting check that runs after the purchase is not a budget. The same
|
|
3498
3613
|
// call records that this attempt did submit, which is what lets a later
|
|
@@ -3567,6 +3682,13 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
|
|
|
3567
3682
|
// hand. Everything the result carries onwards has been through
|
|
3568
3683
|
// `sanitizedEvents`, which keeps the last 20 entries per stream.
|
|
3569
3684
|
result.order_creates = summarizeOrderCreateActivity(events);
|
|
3685
|
+
if (tracking) {
|
|
3686
|
+
try {
|
|
3687
|
+
result[TRACKING_OBSERVATION] = await tracking.finalize({ createActivity: result.order_creates });
|
|
3688
|
+
} catch {
|
|
3689
|
+
// The attempt gets no tracking rows; the order result is unchanged.
|
|
3690
|
+
}
|
|
3691
|
+
}
|
|
3570
3692
|
}
|
|
3571
3693
|
return stampTestOrderPlan(result, normalizedPlan);
|
|
3572
3694
|
};
|
|
@@ -3588,7 +3710,7 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
|
|
|
3588
3710
|
}
|
|
3589
3711
|
}
|
|
3590
3712
|
page.setDefaultTimeout(numberArg(planArgs["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS));
|
|
3591
|
-
events = captureCheckoutEvents(page);
|
|
3713
|
+
events = captureCheckoutEvents(page, tracking);
|
|
3592
3714
|
// The outer race is the hard guarantee: whatever hangs inside the path,
|
|
3593
3715
|
// this returns and the run writes a verdict instead of dying with nothing.
|
|
3594
3716
|
orderDeadline = Date.now() + orderTimeoutMs;
|
|
@@ -3606,6 +3728,7 @@ async function runSingleBrowserTestOrder(context, checkoutPage, plan, args, runI
|
|
|
3606
3728
|
deadline: orderDeadline,
|
|
3607
3729
|
reserveOrderCreation,
|
|
3608
3730
|
selectorProbeCache: options.selectorProbeCache,
|
|
3731
|
+
tracking,
|
|
3609
3732
|
}),
|
|
3610
3733
|
orderTimeoutMs + ORDER_TIMEOUT_GRACE_MS,
|
|
3611
3734
|
`order-path:${planId(normalizedPlan)}`,
|
|
@@ -3684,7 +3807,7 @@ function stablePrivateCaptureError(value) {
|
|
|
3684
3807
|
return projectAnalyticsCaptureError(value, { fallbackKind: "unreadable" });
|
|
3685
3808
|
}
|
|
3686
3809
|
|
|
3687
|
-
async function executeTestOrderPath({ page, events, email, ladder, checkoutPage, entryPage = null, topologyPlan, path, args, deadline, reserveOrderCreation = null, selectorProbeCache = null }) {
|
|
3810
|
+
async function executeTestOrderPath({ page, events, email, ladder, checkoutPage, entryPage = null, topologyPlan, path, args, deadline, reserveOrderCreation = null, selectorProbeCache = null, tracking = null }) {
|
|
3688
3811
|
const stepTimeoutMs = numberArg(args["step-timeout-ms"], DEFAULT_STEP_TIMEOUT_MS);
|
|
3689
3812
|
const budget = () => Math.min(stepTimeoutMs, deadline - Date.now());
|
|
3690
3813
|
const hostedNow = () => hostedRedirectInfo(safePageUrl(page), checkoutPage.url);
|
|
@@ -3704,7 +3827,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3704
3827
|
// page the probe left there rather than loading the same URL a second time,
|
|
3705
3828
|
// which would fire the SDK's page-view events twice into the same capture.
|
|
3706
3829
|
const entry = await ladder.run(CART_ENTRY_STEP, () => enterCartViaLanding({
|
|
3707
|
-
page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache,
|
|
3830
|
+
page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache, tracking,
|
|
3708
3831
|
}), { timeoutMs: budget() });
|
|
3709
3832
|
const enteredViaLanding = Boolean(entry && typeof entry === "object" && entry.entered);
|
|
3710
3833
|
// Responses captured from here on belong to the checkout the ladder drives.
|
|
@@ -3712,7 +3835,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3712
3835
|
// made before the hand-off.
|
|
3713
3836
|
const checkoutResponseOffset = events.responses.length;
|
|
3714
3837
|
|
|
3715
|
-
await ladder.run("opened_checkout", () => openCheckoutForPath({ page, checkoutPage, entry, args }), { timeoutMs: budget() });
|
|
3838
|
+
await ladder.run("opened_checkout", () => openCheckoutForPath({ page, checkoutPage, entry, args, tracking }), { timeoutMs: budget() });
|
|
3716
3839
|
await ladder.run("selected_bundle", async () => {
|
|
3717
3840
|
// The requested package was selected on the entry page, where the cards
|
|
3718
3841
|
// live; checkout renders none, so re-running strict selection here would
|
|
@@ -3765,6 +3888,9 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
|
|
|
3765
3888
|
// runs BEFORE the reservation: nothing is clicked, nothing is spent, and
|
|
3766
3889
|
// the classifier reads the failure as `not_created`.
|
|
3767
3890
|
cartBeforeSubmit = await cartStateBeforeSubmit(page, events, { responseOffset: checkoutResponseOffset, budget });
|
|
3891
|
+
// The checkout's declared tags and inline page script, read after the
|
|
3892
|
+
// SDK is ready (bounded; never throws).
|
|
3893
|
+
await tracking?.readDocument(page);
|
|
3768
3894
|
if (cartBeforeSubmit.empty) {
|
|
3769
3895
|
throw codedError(CART_ENTRY_CODES.CART_EMPTY_BEFORE_SUBMIT, cartEmptyMessage(cartBeforeSubmit));
|
|
3770
3896
|
}
|
|
@@ -3994,7 +4120,7 @@ function createSelectorProbeCache() {
|
|
|
3994
4120
|
// loaded it and found a selection surface; either way a second load of the
|
|
3995
4121
|
// same URL is what is avoided. Anywhere else (a cached probe answer skipped the
|
|
3996
4122
|
// load; a fresh page) the checkout is opened here, once.
|
|
3997
|
-
async function openCheckoutForPath({ page, checkoutPage, entry, args }) {
|
|
4123
|
+
async function openCheckoutForPath({ page, checkoutPage, entry, args, tracking = null }) {
|
|
3998
4124
|
const enteredViaLanding = Boolean(entry && typeof entry === "object" && entry.entered);
|
|
3999
4125
|
if (enteredViaLanding) {
|
|
4000
4126
|
await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
|
|
@@ -4003,14 +4129,14 @@ async function openCheckoutForPath({ page, checkoutPage, entry, args }) {
|
|
|
4003
4129
|
if (checkoutUrlPredicate(checkoutPage.url)(safePageUrl(page))) {
|
|
4004
4130
|
return "already on checkout from the selector probe; not re-opened";
|
|
4005
4131
|
}
|
|
4006
|
-
await gotoAndSettle(page, checkoutPage.url, args);
|
|
4132
|
+
await gotoAndSettle(page, checkoutPage.url, args, tracking);
|
|
4007
4133
|
return null;
|
|
4008
4134
|
}
|
|
4009
4135
|
|
|
4010
4136
|
// The entry step body. Resolves to `{ skip }` when the checkout selects for
|
|
4011
4137
|
// itself, to `{ entered: true, ... }` when the runner came in through the entry
|
|
4012
4138
|
// page, and throws a coded error (never a bare timeout) when it cannot.
|
|
4013
|
-
async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache = null }) {
|
|
4139
|
+
async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPackages, args, budget, selectorProbeCache = null, tracking = null }) {
|
|
4014
4140
|
// Probe the checkout first: whether it carries a selection surface is a fact
|
|
4015
4141
|
// about the rendered page, not about the spec (the spec cannot say it yet —
|
|
4016
4142
|
// that is the design half of #206). The probe's load is the checkout's only
|
|
@@ -4027,7 +4153,7 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4027
4153
|
let probe = cached ? "reused" : "loaded";
|
|
4028
4154
|
let probeError = null;
|
|
4029
4155
|
if (!surface) {
|
|
4030
|
-
await gotoAndSettle(page, checkoutPage.url, args);
|
|
4156
|
+
await gotoAndSettle(page, checkoutPage.url, args, tracking);
|
|
4031
4157
|
const probed = await page.evaluate(checkoutSelectionSurfaceScript())
|
|
4032
4158
|
.then((value) => ({ value }), (error) => ({ value: null, error: error?.message || String(error) }));
|
|
4033
4159
|
if (probed.value) {
|
|
@@ -4053,8 +4179,9 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4053
4179
|
);
|
|
4054
4180
|
}
|
|
4055
4181
|
|
|
4056
|
-
await gotoAndSettle(page, entryPage.url, args);
|
|
4182
|
+
await gotoAndSettle(page, entryPage.url, args, tracking);
|
|
4057
4183
|
const sdkReady = await waitForSdkReady(page, Math.min(budget(), DEFAULT_SETTLE_TIMEOUT_MS));
|
|
4184
|
+
await tracking?.readDocument(page);
|
|
4058
4185
|
const controls = await page.evaluate(cartEntryControlsScript(), { selector: CART_ENTRY_CONTROL_SELECTOR, checkoutUrl: checkoutPage.url }).catch(() => []);
|
|
4059
4186
|
const choice = chooseCartEntryControl(controls, selectedPackages);
|
|
4060
4187
|
if (!choice.control) {
|
|
@@ -4348,8 +4475,11 @@ function assessReceiptRendering(persistedLineCount, evidence = {}) {
|
|
|
4348
4475
|
};
|
|
4349
4476
|
}
|
|
4350
4477
|
|
|
4351
|
-
|
|
4352
|
-
|
|
4478
|
+
// With a tracking observer, the load is a runner navigation: before the
|
|
4479
|
+
// attempt's first page-initiated hop it carries the run's synthetic seeds.
|
|
4480
|
+
async function gotoAndSettle(page, url, args, tracking = null) {
|
|
4481
|
+
const target = tracking ? tracking.runnerUrl(url, withQueryParam) : url;
|
|
4482
|
+
await page.goto(target, { waitUntil: "domcontentloaded", timeout: numberArg(args["browser-timeout"], DEFAULT_BROWSER_TIMEOUT_MS) });
|
|
4353
4483
|
await page.waitForLoadState("networkidle", { timeout: DEFAULT_SETTLE_TIMEOUT_MS }).catch(() => {});
|
|
4354
4484
|
await page.waitForTimeout(750);
|
|
4355
4485
|
}
|
|
@@ -5219,11 +5349,30 @@ async function clickControl(locator, { timeout, forceFallback = true, perpetual
|
|
|
5219
5349
|
}
|
|
5220
5350
|
}
|
|
5221
5351
|
|
|
5352
|
+
// The control a shopper would click. A page may keep the SDK action hidden in
|
|
5353
|
+
// the offer and show a data-upsell-proxy button elsewhere that forwards its
|
|
5354
|
+
// click to it (the starter templates' closing-card decline on a box-style
|
|
5355
|
+
// single offer). Clicking the hidden action fails as not visible, so the
|
|
5356
|
+
// visible proxy is clicked instead, but only while the offer holds an SDK action
|
|
5357
|
+
// for it to forward to: otherwise the hidden action is clicked and fails as before.
|
|
5358
|
+
async function shopperUpsellControl(page, action) {
|
|
5359
|
+
const actions = page.locator(`[data-next-upsell-action="${action}"]`);
|
|
5360
|
+
if (!await actions.count().catch(() => 0)) return actions.first();
|
|
5361
|
+
const visibleAction = actions.filter({ visible: true }).first();
|
|
5362
|
+
if (await visibleAction.count().catch(() => 0)) return visibleAction;
|
|
5363
|
+
// The proxy forwards to `[data-next-upsell="offer"] [data-next-upsell-action]`
|
|
5364
|
+
// (upsells.js), so only an action inside the offer is a target.
|
|
5365
|
+
const inOffer = page.locator(`[data-next-upsell="offer"] [data-next-upsell-action="${action}"]`);
|
|
5366
|
+
const proxy = page.locator(`[data-upsell-proxy="${action}"]`).filter({ visible: true }).first();
|
|
5367
|
+
if (await inOffer.count().catch(() => 0) && await proxy.count().catch(() => 0)) return proxy;
|
|
5368
|
+
return actions.first();
|
|
5369
|
+
}
|
|
5370
|
+
|
|
5222
5371
|
async function clickUpsellPath(page, path, { trace = null } = {}) {
|
|
5223
5372
|
const offerUrl = safePageUrl(page);
|
|
5224
5373
|
const action = path === "accept" ? "add" : "skip";
|
|
5225
5374
|
const selector = `[data-next-upsell-action="${action}"]`;
|
|
5226
|
-
const control = page
|
|
5375
|
+
const control = await shopperUpsellControl(page, action);
|
|
5227
5376
|
if (!await control.count().catch(() => 0)) {
|
|
5228
5377
|
return { path, clicked: false, error: `Missing upsell control ${selector}` };
|
|
5229
5378
|
}
|
|
@@ -6197,7 +6346,7 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
|
|
|
6197
6346
|
...retry,
|
|
6198
6347
|
...creation,
|
|
6199
6348
|
hosted_checkout_url: result.order?.hosted_checkout_url || null,
|
|
6200
|
-
final_url: result.order?.final_url,
|
|
6349
|
+
final_url: redactUrlQuery(result.order?.final_url),
|
|
6201
6350
|
steps: result.order?.evidence?.steps,
|
|
6202
6351
|
note: "Hosted checkout flow is platform-owned; verify the hosted completion manually.",
|
|
6203
6352
|
},
|
|
@@ -6244,13 +6393,13 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
|
|
|
6244
6393
|
...recovery,
|
|
6245
6394
|
ref_id: result.order.ref_id,
|
|
6246
6395
|
order_number: result.order.next_order_id,
|
|
6247
|
-
final_url: result.order.final_url,
|
|
6396
|
+
final_url: redactUrlQuery(result.order.final_url),
|
|
6248
6397
|
is_test: result.order.is_test,
|
|
6249
6398
|
line_count: result.order.receipt_line_items.length,
|
|
6250
6399
|
...(receiptProofEvidence(result.order) ? { receipt_proof: receiptProofEvidence(result.order) } : {}),
|
|
6251
6400
|
...(path === "accept" ? { accepted_upsell_line_present: result.order.verification?.accepted_upsell_line_present } : {}),
|
|
6252
6401
|
...(upsellUnverified ? { upsell_unverified: upsellUnverified } : {}),
|
|
6253
|
-
...(result.order.upsell ? { upsell_clicked: result.order.upsell.clicked, upsell_final_url: result.order.upsell.final_url } : {}),
|
|
6402
|
+
...(result.order.upsell ? { upsell_clicked: result.order.upsell.clicked, upsell_final_url: redactUrlQuery(result.order.upsell.final_url) } : {}),
|
|
6254
6403
|
...(result.order.upsell_steps ? { upsell_steps: result.order.upsell_steps.map(summarizeUpsellStep) } : {}),
|
|
6255
6404
|
...(result.order.verification?.accepted_upsell_matches ? { accepted_upsell_matches: result.order.verification.accepted_upsell_matches } : {}),
|
|
6256
6405
|
...(result.order.verification?.coupon ? { coupon: result.order.verification.coupon } : {}),
|
|
@@ -6261,7 +6410,7 @@ function testOrderAssertion(page, plan, result, firstAttempt = null, creationRec
|
|
|
6261
6410
|
...retry,
|
|
6262
6411
|
...creation,
|
|
6263
6412
|
...recovery,
|
|
6264
|
-
final_url: result.order?.final_url,
|
|
6413
|
+
final_url: redactUrlQuery(result.order?.final_url),
|
|
6265
6414
|
steps: result.order?.evidence?.steps,
|
|
6266
6415
|
...(receiptProofEvidence(result.order) ? { receipt_proof: receiptProofEvidence(result.order) } : {}),
|
|
6267
6416
|
...(result.order?.verification?.coupon ? { coupon: result.order.verification.coupon } : {}),
|
|
@@ -6338,11 +6487,20 @@ function responseRequestStartedAt(response) {
|
|
|
6338
6487
|
}
|
|
6339
6488
|
}
|
|
6340
6489
|
|
|
6341
|
-
function captureCheckoutEvents(page) {
|
|
6490
|
+
function captureCheckoutEvents(page, tracking = null) {
|
|
6342
6491
|
const events = { requests: [], responses: [], failed: [], console: [], pageErrors: [], navigations: [] };
|
|
6343
6492
|
const interesting = /\/api\/v1\/(?:orders|upsells|carts)\/?|\/transactions|spreedly|campaigns\.apps/i;
|
|
6493
|
+
const isMainFrame = (frame) => typeof page.mainFrame !== "function" || frame === page.mainFrame();
|
|
6344
6494
|
page.on("request", (request) => {
|
|
6345
6495
|
if (!interesting.test(request.url())) return;
|
|
6496
|
+
// Tracking equality reads the create body before it is summarized below.
|
|
6497
|
+
if (tracking && ORDER_CREATE_RESPONSE_PATTERN.test(request.url()) && request.method() === "POST") {
|
|
6498
|
+
try {
|
|
6499
|
+
tracking.onCreateRequest(() => request.postData());
|
|
6500
|
+
} catch {
|
|
6501
|
+
// The order is never held up by a tracking read.
|
|
6502
|
+
}
|
|
6503
|
+
}
|
|
6346
6504
|
events.requests.push({
|
|
6347
6505
|
method: request.method(),
|
|
6348
6506
|
url: request.url(),
|
|
@@ -6360,15 +6518,47 @@ function captureCheckoutEvents(page) {
|
|
|
6360
6518
|
const request = responseRequest(response);
|
|
6361
6519
|
let chainUrl = null;
|
|
6362
6520
|
try { chainUrl = request?.url() ?? null; } catch { /* identity only */ }
|
|
6521
|
+
// Tracking: a script the page received is scanned for page-script
|
|
6522
|
+
// attribution calls, and an accepted create marks the post-order point.
|
|
6523
|
+
let orderSource = null;
|
|
6524
|
+
if (tracking) {
|
|
6525
|
+
try {
|
|
6526
|
+
const method = response.request().method();
|
|
6527
|
+
if (response.request().resourceType() === "script") tracking.onScriptResponse(response);
|
|
6528
|
+
if (method === "POST" && ORDER_CREATE_RESPONSE_PATTERN.test(response.url())) {
|
|
6529
|
+
tracking.onCreateResponseStatus(response.status());
|
|
6530
|
+
orderSource = "create_response";
|
|
6531
|
+
} else if (method === "GET" && ORDER_DETAIL_RESPONSE_PATTERN.test(response.url())) {
|
|
6532
|
+
orderSource = "readback";
|
|
6533
|
+
}
|
|
6534
|
+
if (orderSource && !(response.status() >= 200 && response.status() < 300)) orderSource = null;
|
|
6535
|
+
} catch {
|
|
6536
|
+
orderSource = null;
|
|
6537
|
+
try { tracking.onListenerError("response"); } catch { /* never breaks the order */ }
|
|
6538
|
+
}
|
|
6539
|
+
}
|
|
6363
6540
|
if (!interesting.test(response.url()) && !(chainUrl && interesting.test(chainUrl))) return;
|
|
6364
6541
|
// Taken before the body read: an entry lands in the log when its body
|
|
6365
6542
|
// finishes, so its position says nothing about when it was requested.
|
|
6366
6543
|
const requestStartedAt = responseRequestStartedAt(response);
|
|
6544
|
+
// Equality on the in-memory body, before summarizeResponseBody. For an
|
|
6545
|
+
// order response the observer starts the read itself (through its
|
|
6546
|
+
// observeRead) before anything awaits it, so a body still in flight when
|
|
6547
|
+
// the attempt's observation is taken is a failed extractor there, never a
|
|
6548
|
+
// pass. The log entry awaits that same read.
|
|
6549
|
+
let bodyRead = null;
|
|
6550
|
+
const readBody = () => {
|
|
6551
|
+
bodyRead ??= readJsonResponseBodyWhenLoaded(response);
|
|
6552
|
+
return bodyRead;
|
|
6553
|
+
};
|
|
6554
|
+
if (orderSource) {
|
|
6555
|
+
try { tracking.orderResponseBody(orderSource, readBody); } catch { /* never breaks the order */ }
|
|
6556
|
+
}
|
|
6367
6557
|
const entry = {
|
|
6368
6558
|
status: response.status(),
|
|
6369
6559
|
url: response.url(),
|
|
6370
6560
|
request_started_at: requestStartedAt,
|
|
6371
|
-
body: await
|
|
6561
|
+
body: await readBody(),
|
|
6372
6562
|
};
|
|
6373
6563
|
if (request) entry[REQUEST_IDENTITY] = request;
|
|
6374
6564
|
events.responses.push(entry);
|
|
@@ -6385,6 +6575,42 @@ function captureCheckoutEvents(page) {
|
|
|
6385
6575
|
// Navigation evidence is diagnostic only; never break the order path.
|
|
6386
6576
|
}
|
|
6387
6577
|
});
|
|
6578
|
+
if (tracking) {
|
|
6579
|
+
// Tracking hops: a second listener beside the one above. Whether a hop
|
|
6580
|
+
// committed a new document is read only from the browser itself:
|
|
6581
|
+
// Playwright's main frame emits "navigated" with `newDocument` for a
|
|
6582
|
+
// document commit and without it for a same-document one, synchronously
|
|
6583
|
+
// just before "framenavigated" (pinned against the installed Playwright
|
|
6584
|
+
// in qa-tracking-params-hardening.test.mjs). It is an internal emitter;
|
|
6585
|
+
// where a page has none, every hop reads history. The signal is only held
|
|
6586
|
+
// here and handed to the observer with its hop.
|
|
6587
|
+
let commit = null;
|
|
6588
|
+
try {
|
|
6589
|
+
const emitter = typeof page.mainFrame === "function" ? page.mainFrame()?._eventEmitter : null;
|
|
6590
|
+
if (emitter && typeof emitter.on === "function") {
|
|
6591
|
+
emitter.on("navigated", (event) => {
|
|
6592
|
+
commit = event && !event.error ? { url: String(event.url), newDocument: Boolean(event.newDocument) } : null;
|
|
6593
|
+
});
|
|
6594
|
+
}
|
|
6595
|
+
} catch {
|
|
6596
|
+
// No signal: every hop reads history.
|
|
6597
|
+
}
|
|
6598
|
+
page.on("framenavigated", (frame) => {
|
|
6599
|
+
try {
|
|
6600
|
+
if (!isMainFrame(frame)) return;
|
|
6601
|
+
const signal = commit;
|
|
6602
|
+
commit = null;
|
|
6603
|
+
tracking.onFrameNavigated(frame.url(), signal);
|
|
6604
|
+
} catch {
|
|
6605
|
+
// The observer records the missed hop as a gap.
|
|
6606
|
+
try { tracking.onListenerError("hop"); } catch { /* never breaks the order */ }
|
|
6607
|
+
}
|
|
6608
|
+
});
|
|
6609
|
+
page.on("domcontentloaded", () => {
|
|
6610
|
+
try { tracking.readDocument(page, { awaited: false }); } catch { /* never breaks the order */ }
|
|
6611
|
+
});
|
|
6612
|
+
attachCreateResponseTap(page, tracking);
|
|
6613
|
+
}
|
|
6388
6614
|
page.on("console", (message) => {
|
|
6389
6615
|
if (["error", "warning"].includes(message.type())) events.console.push({ type: message.type(), text: trim(message.text()) });
|
|
6390
6616
|
});
|
|
@@ -6392,6 +6618,58 @@ function captureCheckoutEvents(page) {
|
|
|
6392
6618
|
return events;
|
|
6393
6619
|
}
|
|
6394
6620
|
|
|
6621
|
+
// The order create's response body for the tracking echo, read in memory
|
|
6622
|
+
// before the page can leave it: the SDK navigates as soon as the create
|
|
6623
|
+
// answers, and a body read after that navigation finds nothing. Only order
|
|
6624
|
+
// responses are paused, at the response stage, and every one is continued
|
|
6625
|
+
// unchanged; no request is added and nothing is persisted from the body.
|
|
6626
|
+
// An accepted create is held at most TRACKING_ADDED_BOUND_MS, whatever the
|
|
6627
|
+
// body read does: the observer's read is itself bounded by the attempt's
|
|
6628
|
+
// remaining tracking budget (an overrun records the extractor as failed),
|
|
6629
|
+
// and the timer here continues the response even if that bound misbehaves.
|
|
6630
|
+
function attachCreateResponseTap(page, tracking) {
|
|
6631
|
+
let session = null;
|
|
6632
|
+
const onPaused = (event) => {
|
|
6633
|
+
let continued = false;
|
|
6634
|
+
let timer = null;
|
|
6635
|
+
const proceed = () => {
|
|
6636
|
+
if (continued) return;
|
|
6637
|
+
continued = true;
|
|
6638
|
+
clearTimeout(timer);
|
|
6639
|
+
session.send("Fetch.continueRequest", { requestId: event.requestId }).catch(() => {});
|
|
6640
|
+
};
|
|
6641
|
+
try {
|
|
6642
|
+
const status = Number(event.responseStatusCode);
|
|
6643
|
+
const isAcceptedCreate = event.request?.method === "POST"
|
|
6644
|
+
&& ORDER_CREATE_RESPONSE_PATTERN.test(String(event.request?.url || ""))
|
|
6645
|
+
&& status >= 200 && status < 300;
|
|
6646
|
+
if (!isAcceptedCreate) {
|
|
6647
|
+
proceed();
|
|
6648
|
+
return;
|
|
6649
|
+
}
|
|
6650
|
+
timer = setTimeout(proceed, TRACKING_ADDED_BOUND_MS);
|
|
6651
|
+
const readBody = () => session.send("Fetch.getResponseBody", { requestId: event.requestId }).then((read) => {
|
|
6652
|
+
const text = read?.base64Encoded ? Buffer.from(String(read.body || ""), "base64").toString("utf8") : read?.body;
|
|
6653
|
+
return parseMaybeJson(redactSensitive(text));
|
|
6654
|
+
});
|
|
6655
|
+
Promise.resolve(tracking.tapCreateResponse(readBody)).catch(() => {}).finally(proceed);
|
|
6656
|
+
} catch {
|
|
6657
|
+
proceed();
|
|
6658
|
+
}
|
|
6659
|
+
};
|
|
6660
|
+
// The session setup is itself a read the observer holds, like every other
|
|
6661
|
+
// asynchronous step that feeds a tracking row.
|
|
6662
|
+
try {
|
|
6663
|
+
return tracking.attachTap(async () => {
|
|
6664
|
+
session = await page.context().newCDPSession(page);
|
|
6665
|
+
session.on("Fetch.requestPaused", onPaused);
|
|
6666
|
+
await session.send("Fetch.enable", { patterns: [{ urlPattern: "*/api/v1/orders*", requestStage: "Response" }] });
|
|
6667
|
+
});
|
|
6668
|
+
} catch {
|
|
6669
|
+
return Promise.resolve();
|
|
6670
|
+
}
|
|
6671
|
+
}
|
|
6672
|
+
|
|
6395
6673
|
// Playwright's response.text() waits for the body to finish loading. A page
|
|
6396
6674
|
// that navigates away as soon as the headers land (an SDK that reads only the
|
|
6397
6675
|
// status before redirecting) can leave that wait pending forever. Only a
|
|
@@ -6457,21 +6735,24 @@ function lastJsonResponse(events, pattern) {
|
|
|
6457
6735
|
return null;
|
|
6458
6736
|
}
|
|
6459
6737
|
|
|
6738
|
+
// A persisted exit: every URL is origin+path; bodies are summarized; then
|
|
6739
|
+
// every string and key goes through the persisted-value projection.
|
|
6460
6740
|
function sanitizedEvents(events) {
|
|
6461
|
-
return {
|
|
6462
|
-
requests: events.requests.slice(-20),
|
|
6741
|
+
return redactPersisted({
|
|
6742
|
+
requests: events.requests.slice(-20).map((request) => ({ ...request, url: redactUrlQuery(request.url) })),
|
|
6463
6743
|
responses: events.responses.slice(-20).map((response) => ({
|
|
6464
6744
|
status: response.status,
|
|
6465
|
-
url: response.url,
|
|
6745
|
+
url: redactUrlQuery(response.url),
|
|
6466
6746
|
body: summarizeResponseBody(response.body, { status: response.status }),
|
|
6467
6747
|
})),
|
|
6468
|
-
failed: events.failed.slice(-20),
|
|
6469
|
-
|
|
6470
|
-
|
|
6748
|
+
failed: events.failed.slice(-20).map((failure) => ({ ...failure, url: redactUrlQuery(failure.url), failure: redactUrlQueriesInText(failure.failure) })),
|
|
6749
|
+
// Console and page-error text can quote URLs; their queries are dropped.
|
|
6750
|
+
console: events.console.slice(-20).map((entry) => ({ ...entry, text: redactUrlQueriesInText(entry.text) })),
|
|
6751
|
+
pageErrors: events.pageErrors.slice(-20).map((text) => redactUrlQueriesInText(text)),
|
|
6471
6752
|
navigations: (events.navigations || []).slice(-20).map((navigation) => ({
|
|
6472
6753
|
url: redactUrlQuery(navigation.url),
|
|
6473
6754
|
})),
|
|
6474
|
-
};
|
|
6755
|
+
});
|
|
6475
6756
|
}
|
|
6476
6757
|
|
|
6477
6758
|
function summarizeRequestPostData(value) {
|
|
@@ -6490,23 +6771,26 @@ function summarizeRequestPostData(value) {
|
|
|
6490
6771
|
}
|
|
6491
6772
|
}
|
|
6492
6773
|
|
|
6774
|
+
const RESPONSE_BODY_MARKER = "[redacted-response-body]";
|
|
6775
|
+
|
|
6493
6776
|
function summarizeResponseBody(body, { status = null } = {}) {
|
|
6494
|
-
if (
|
|
6495
|
-
|
|
6777
|
+
if (body === null || body === undefined) return null;
|
|
6778
|
+
// Raw body content (text, an array, a bare scalar) is never persisted.
|
|
6779
|
+
if (typeof body !== "object" || Array.isArray(body)) return RESPONSE_BODY_MARKER;
|
|
6496
6780
|
return {
|
|
6497
6781
|
...(body.number ? { number: body.number } : {}),
|
|
6498
6782
|
...(body.ref_id ? { ref_id: body.ref_id } : {}),
|
|
6499
6783
|
...(body.is_test !== undefined ? { is_test: body.is_test } : {}),
|
|
6500
6784
|
...(body.total_incl_tax ? { total_incl_tax: body.total_incl_tax } : {}),
|
|
6501
6785
|
...(body.currency ? { currency: body.currency } : {}),
|
|
6502
|
-
...(body.checkout_url ? { checkout_url: body.checkout_url } : {}),
|
|
6786
|
+
...(body.checkout_url ? { checkout_url: redactUrlQuery(body.checkout_url) } : {}),
|
|
6503
6787
|
...(Array.isArray(body.lines) ? { lines: extractReceiptLines(body) } : {}),
|
|
6504
|
-
...(body.detail ? { detail: body.detail } : {}),
|
|
6788
|
+
...(body.detail ? { detail: redactPersisted(body.detail) } : {}),
|
|
6505
6789
|
// A rejected request names its reason here (for example the
|
|
6506
6790
|
// duplicate-order refusal). Kept only on an error response, so a
|
|
6507
6791
|
// successful create never carries payment_details into evidence.
|
|
6508
6792
|
...(Number(status) >= 400 && typeof body.payment_details === "string"
|
|
6509
|
-
? { payment_details: trim(body.payment_details).slice(0, 300) }
|
|
6793
|
+
? { payment_details: redactUrlQueriesInText(trim(body.payment_details).slice(0, 300)) }
|
|
6510
6794
|
: {}),
|
|
6511
6795
|
};
|
|
6512
6796
|
}
|
|
@@ -6750,7 +7034,7 @@ function specTierPlans(topologies, args, variant, { warn = (line) => process.std
|
|
|
6750
7034
|
const declaredTiers = declaredSelectorTiers(checkoutPage);
|
|
6751
7035
|
const bumps = declaredOrderBumps(checkoutPage);
|
|
6752
7036
|
if (bumps.length) {
|
|
6753
|
-
warn(`[qa:test-order] checkout page "${checkoutPage.page_id || checkoutPage.label || "(unnamed)"}" declares order bump package(s) ${bumps.join(", ")} (is_upsell) — not planned as selector tiers; bump coverage comes from --cart.`);
|
|
7037
|
+
warn(`[qa:test-order] checkout page "${checkoutPage.page_id || checkoutPage.label || "(unnamed)"}" declares order bump package(s) ${bumps.join(", ")} (is_order_bump or is_upsell) — not planned as selector tiers; bump coverage comes from --cart.`);
|
|
6754
7038
|
}
|
|
6755
7039
|
for (const tier of declaredTiers) declaredIdentities.add(selectorTierIdentity(tier));
|
|
6756
7040
|
for (const ref of bumps) bumpRefs.add(ref);
|
|
@@ -6830,7 +7114,7 @@ function specTierPlans(topologies, args, variant, { warn = (line) => process.std
|
|
|
6830
7114
|
const namedBumps = unmatched.filter((identity) => bumpRefs.has(identity.split(":")[0]));
|
|
6831
7115
|
throw new Error([
|
|
6832
7116
|
`--select-package ${unmatched.join(",")}: ${unmatched.length === 1 ? "is not a selector tier" : "are not selector tiers"} the CampaignSpec declares${declaredIdentities.size ? ` (declared tiers: ${[...declaredIdentities].join(", ")}` : " (no tiers declared"}${bumpRefs.size ? `; order bump ref(s) excluded from tiers: ${[...bumpRefs].join(", ")}` : ""}).`,
|
|
6833
|
-
...(namedBumps.length ? [`${namedBumps.join(",")} ${namedBumps.length === 1 ? "is an order bump (is_upsell)" : "are order bumps (is_upsell)"}, an add-on to a selected tier, not a tier; bump coverage comes from --cart.`] : []),
|
|
7117
|
+
...(namedBumps.length ? [`${namedBumps.join(",")} ${namedBumps.length === 1 ? "is an order bump (is_order_bump or is_upsell)" : "are order bumps (is_order_bump or is_upsell)"}, an add-on to a selected tier, not a tier; bump coverage comes from --cart.`] : []),
|
|
6834
7118
|
"Name declared tiers only, as ref or ref:qty, or drop --select-package to iterate every tier.",
|
|
6835
7119
|
].join(" "));
|
|
6836
7120
|
}
|
|
@@ -6874,59 +7158,6 @@ function selectorTierNarrowing(value) {
|
|
|
6874
7158
|
return identities.length ? new Set(identities) : null;
|
|
6875
7159
|
}
|
|
6876
7160
|
|
|
6877
|
-
// Order bumps declared on the checkout page (`is_upsell: true` rows — the
|
|
6878
|
-
// same marker the commercial-journey planner reads). They are add-ons to a
|
|
6879
|
-
// selected tier, not tiers, so the tier planner reports and skips them.
|
|
6880
|
-
function declaredOrderBumps(checkoutPage) {
|
|
6881
|
-
const refs = [];
|
|
6882
|
-
for (const pkg of Array.isArray(checkoutPage?.packages) ? checkoutPage.packages : []) {
|
|
6883
|
-
if (!pkg || typeof pkg !== "object" || !isBumpRow(pkg)) continue;
|
|
6884
|
-
const ref = [pkg.ref_id, pkg.package_id, pkg.id]
|
|
6885
|
-
.map((value) => (value == null ? "" : String(value).trim()))
|
|
6886
|
-
.find(Boolean);
|
|
6887
|
-
if (ref && !refs.includes(ref)) refs.push(ref);
|
|
6888
|
-
}
|
|
6889
|
-
return refs;
|
|
6890
|
-
}
|
|
6891
|
-
|
|
6892
|
-
// Selector tiers are the packages the spec declares on the checkout page —
|
|
6893
|
-
// same ref tolerance as the doctor's specPackageRecords (ref_id/package_id/id).
|
|
6894
|
-
// Order-bump rows (`is_upsell: true`) are add-ons offered alongside the
|
|
6895
|
-
// selected tier, not tiers of their own: they never become a plan.
|
|
6896
|
-
function declaredSelectorTiers(checkoutPage) {
|
|
6897
|
-
const records = [];
|
|
6898
|
-
const quantitiesByRef = new Map();
|
|
6899
|
-
for (const pkg of Array.isArray(checkoutPage?.packages) ? checkoutPage.packages : []) {
|
|
6900
|
-
if (!pkg || typeof pkg !== "object" || isBumpRow(pkg)) continue;
|
|
6901
|
-
const ref = [pkg.ref_id, pkg.package_id, pkg.id]
|
|
6902
|
-
.map((value) => (value == null ? "" : String(value).trim()))
|
|
6903
|
-
.find(Boolean);
|
|
6904
|
-
const declaredQuantity = Number(pkg.qty ?? pkg.quantity ?? 1);
|
|
6905
|
-
if (!ref || !Number.isInteger(declaredQuantity) || declaredQuantity < 1) continue;
|
|
6906
|
-
records.push({ pkg, ref, declaredQuantity });
|
|
6907
|
-
if (!quantitiesByRef.has(ref)) quantitiesByRef.set(ref, new Set());
|
|
6908
|
-
quantitiesByRef.get(ref).add(declaredQuantity);
|
|
6909
|
-
}
|
|
6910
|
-
|
|
6911
|
-
const tiers = [];
|
|
6912
|
-
const seen = new Set();
|
|
6913
|
-
for (const { pkg, ref, declaredQuantity } of records) {
|
|
6914
|
-
// A unique ref is a catalog package bought once, even when that package's
|
|
6915
|
-
// own composition is 3x. Only repeated declarations of the SAME ref at
|
|
6916
|
-
// different quantities express shopper purchase multipliers (Keer 1x/2x).
|
|
6917
|
-
const quantity = quantitiesByRef.get(ref).size > 1 ? declaredQuantity : 1;
|
|
6918
|
-
const identity = `${ref}:${quantity}`;
|
|
6919
|
-
if (seen.has(identity)) continue;
|
|
6920
|
-
seen.add(identity);
|
|
6921
|
-
tiers.push({
|
|
6922
|
-
ref,
|
|
6923
|
-
quantity,
|
|
6924
|
-
declared_by: stringArg(pkg.name) || stringArg(pkg.title) || undefined,
|
|
6925
|
-
});
|
|
6926
|
-
}
|
|
6927
|
-
return tiers;
|
|
6928
|
-
}
|
|
6929
|
-
|
|
6930
7161
|
// Declared coupons follow the repo's offer-surface rule (build-brief, cli
|
|
6931
7162
|
// exit-pop gates): a surface counts only when `enabled === true` and it maps
|
|
6932
7163
|
// an offer_code. Both surfaces mapping the same code collapse into one plan.
|
|
@@ -7678,7 +7909,7 @@ function summarizeUpsellStep(step) {
|
|
|
7678
7909
|
return {
|
|
7679
7910
|
path: step.path,
|
|
7680
7911
|
clicked: step.clicked,
|
|
7681
|
-
final_url: step.final_url,
|
|
7912
|
+
final_url: redactUrlQuery(step.final_url),
|
|
7682
7913
|
expected_items: step.expected_items,
|
|
7683
7914
|
api_response_seen: step.api_response_seen,
|
|
7684
7915
|
api_response_status: step.api_response_status,
|
|
@@ -7721,6 +7952,30 @@ function withQueryParam(value, key, paramValue) {
|
|
|
7721
7952
|
}
|
|
7722
7953
|
}
|
|
7723
7954
|
|
|
7955
|
+
// A test order as it is persisted: its URLs as origin+path, an upsell's raw
|
|
7956
|
+
// response body as its summary, and no URL query in any string or key (the
|
|
7957
|
+
// one persisted-value projection, redactPersisted). The order events were
|
|
7958
|
+
// already summarized where they were captured.
|
|
7959
|
+
function persistedTestOrder(order) {
|
|
7960
|
+
if (!order || typeof order !== "object") return order;
|
|
7961
|
+
const projected = { ...order };
|
|
7962
|
+
if (Object.hasOwn(order, "checkout_url")) projected.checkout_url = redactUrlQuery(order.checkout_url);
|
|
7963
|
+
if (Object.hasOwn(order, "final_url")) projected.final_url = redactUrlQuery(order.final_url);
|
|
7964
|
+
if (order.upsell && typeof order.upsell === "object") projected.upsell = persistedUpsellStep(order.upsell);
|
|
7965
|
+
if (Array.isArray(order.upsell_steps)) projected.upsell_steps = order.upsell_steps.map(persistedUpsellStep);
|
|
7966
|
+
return redactPersisted(projected);
|
|
7967
|
+
}
|
|
7968
|
+
|
|
7969
|
+
function persistedUpsellStep(step) {
|
|
7970
|
+
if (!step || typeof step !== "object") return step;
|
|
7971
|
+
const projected = { ...step };
|
|
7972
|
+
for (const field of ["offer_url", "final_url", "api_response_url"]) {
|
|
7973
|
+
if (Object.hasOwn(step, field)) projected[field] = redactUrlQuery(step[field]);
|
|
7974
|
+
}
|
|
7975
|
+
if (Object.hasOwn(step, "api_response_order_body")) projected.api_response_order_body = summarizeResponseBody(step.api_response_order_body);
|
|
7976
|
+
return projected;
|
|
7977
|
+
}
|
|
7978
|
+
|
|
7724
7979
|
function findPage(topologies, type) {
|
|
7725
7980
|
for (const topology of topologies || []) {
|
|
7726
7981
|
const page = (topology.pages || []).find((candidate) => candidate.page_type === type);
|
|
@@ -7929,6 +8184,10 @@ export const __qaBrowserTestHooks = Object.freeze({
|
|
|
7929
8184
|
createOrderCreationBudget,
|
|
7930
8185
|
dispatchTestOrderPlans,
|
|
7931
8186
|
recoverCreatedOrder,
|
|
8187
|
+
persistedTestOrder,
|
|
8188
|
+
sanitizedEvents,
|
|
8189
|
+
summarizeResponseBody,
|
|
8190
|
+
attachCreateResponseTap,
|
|
7932
8191
|
extractReceiptLines,
|
|
7933
8192
|
EXIT_INTENT_SURFACE_SELECTORS,
|
|
7934
8193
|
COUPON_INPUT_SELECTORS,
|