@nextcommerce/campaigns-os 1.43.1 → 1.46.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/AGENTS.md +9 -2
- package/CHANGELOG.md +1099 -5103
- package/README.md +34 -13
- package/agents/claude/CLAUDE.md +1 -1
- package/agents/codex/AGENTS.md +1 -1
- package/agents/copilot/copilot-instructions.md +1 -1
- package/agents/cursor/campaigns-os.mdc +1 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
- package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
- package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
- package/campaign-spec/dist/types.d.ts +2 -2
- package/contracts/agent-relevant-change-policy.v1.json +5 -0
- package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
- package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
- package/contracts/effects.v1.json +1184 -121
- package/contracts/orientation-reason-codes.v1.json +7 -0
- package/contracts/release-ledger.json +2190 -5260
- package/contracts/supported-surface.json +7 -4
- package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
- package/docs/brand-theme-bridge.md +81 -0
- package/docs/build-packet.md +222 -23
- package/docs/campaigns-os-build-flow.md +4 -3
- package/docs/design-source-package.md +162 -15
- package/docs/effects.md +66 -12
- package/docs/gateway-login.md +3 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +42 -2
- package/docs/polish-evidence.md +74 -0
- package/docs/progress-snapshots.md +10 -6
- package/docs/qa-and-test-orders.md +230 -20
- package/docs/release-ledger-authoring-guide.md +70 -8
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +2 -2
- package/docs/versioning.md +4 -1
- package/package.json +1 -1
- package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +7 -6
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +7 -6
- package/skills/next-campaigns-os/SKILL.md +7 -7
- package/skills/next-campaigns-os/references/session-intake.md +9 -3
- package/skills/next-campaigns-os-setup/SKILL.md +5 -5
- package/skills/next-campaigns-polish/SKILL.md +28 -9
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills.json +10 -10
- package/src/brand-theme.mjs +320 -20
- package/src/build-brief.mjs +6 -4
- package/src/built-script-syntax.mjs +480 -0
- package/src/built-site-scope.mjs +16 -4
- package/src/campaigns-api-key.mjs +99 -0
- package/src/cli-helpers.mjs +118 -0
- package/src/cli.mjs +1530 -7580
- package/src/commercial-parity.mjs +48 -2
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +898 -0
- package/src/deviation.mjs +13 -1
- package/src/diagnostic.mjs +6 -2
- package/src/directory-lock.mjs +270 -0
- package/src/doctor/checks.mjs +4654 -0
- package/src/doctor/inspect.mjs +678 -0
- package/src/doctor/next-step.mjs +731 -0
- package/src/doctor/source-provenance.mjs +184 -0
- package/src/install-invocation.mjs +29 -0
- package/src/invocation.mjs +183 -0
- package/src/live-campaign-refs.mjs +466 -0
- package/src/login.mjs +2 -2
- package/src/page-kit-store-profile.mjs +69 -12
- package/src/page-kit-sync.mjs +31 -12
- package/src/private-template-source.mjs +1 -1
- package/src/progress-node.mjs +9 -36
- package/src/proof-policy.mjs +1 -1
- package/src/qa-analytics-correctness.mjs +3 -0
- package/src/qa-binding-evidence.mjs +76 -11
- package/src/qa-browser.mjs +1316 -105
- package/src/qa-build-scope.mjs +47 -0
- package/src/qa-commercial-parity.mjs +48 -5
- package/src/qa-node.mjs +339 -19
- package/src/qa-test-order-topology.mjs +148 -0
- package/src/sdk-markup.mjs +72 -8
- package/src/source-html-intake.mjs +117 -1
- package/src/source-html-manifest.mjs +9 -2
- package/src/stage-ledger.mjs +28 -0
- package/src/stage-record.mjs +551 -0
- package/src/target-lock.mjs +54 -0
- package/src/template-brand-contract.mjs +17 -1
- package/src/upsell-selector-scope.mjs +112 -2
package/src/qa-node.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "./spec-source-identity.mjs";
|
|
2
|
-
import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion } from './qa-binding-evidence.mjs';
|
|
2
|
+
import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion, scriptParseAssertion } from './qa-binding-evidence.mjs';
|
|
3
3
|
import { shellToken } from "./shell-token.mjs";
|
|
4
|
+
import { applyQaBuildScope, specForQaScope } from "./qa-build-scope.mjs";
|
|
4
5
|
import { requiredActionText } from "./gate-actions.mjs";
|
|
5
6
|
import { parseOrderPathDepthFlag } from "./proof-policy.mjs";
|
|
6
7
|
import {
|
|
@@ -29,13 +30,22 @@ const PACKAGE_ROOT = installModeResolve(installModeDirname(installModeFileUrl(im
|
|
|
29
30
|
function cmd(verb, rest = "") {
|
|
30
31
|
return `${invocationPrefixFor(PACKAGE_ROOT)} ${verb}${rest ? ` ${rest}` : ""}`;
|
|
31
32
|
}
|
|
32
|
-
import { runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, validatedOrderCreationLimit } from "./qa-browser.mjs";
|
|
33
|
+
import { isSameAnalyticsCapturePage, runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, upsellActionCoverageWithoutOrders, validatedOrderCreationLimit } from "./qa-browser.mjs";
|
|
33
34
|
import { assessReceiptPurchase } from "./qa-analytics-correctness.mjs";
|
|
34
35
|
import { createVerdict, isFindingAssertion, QA_ASSERTION_FAMILY_VOCABULARY, SESSION_ENDING_DISPOSITIONS, SEVERITY, STATUS, validateVerdict } from "./qa-verdict.mjs";
|
|
35
36
|
import { normalizeSdkMetaName, lookupSdkIgnoredMetaTag } from "./sdk-meta-tags.mjs";
|
|
36
37
|
import { annotateQaAssertionCauses, formatCauseReportLines, formatCauseTag } from "./finding-cause.mjs";
|
|
37
38
|
import { promoteQaVerdict, writeQaSidecar } from "./qa-sidecar.mjs";
|
|
38
39
|
import { publishQaVerdict, qaPortalUrl, qaVerdictPublishBlock, QA_VERDICT_PUBLISHERS, skippedQaVerdictPublish } from "./qa-verdict-publish.mjs";
|
|
40
|
+
import {
|
|
41
|
+
isLocalServePacket,
|
|
42
|
+
LOCAL_PROOF_BUILD_ENVIRONMENT,
|
|
43
|
+
LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD,
|
|
44
|
+
LOCAL_PROOF_PARITY_FIELD,
|
|
45
|
+
recordedBuildEnvironment,
|
|
46
|
+
recordedProductionParity,
|
|
47
|
+
} from "./local-proof.mjs";
|
|
48
|
+
import { isLoopbackHostname } from "./remit.mjs";
|
|
39
49
|
import { publishStoredVerdict, qaPublishTextLines, QA_PUBLISH_EXIT_CODES } from "./qa-publish.mjs";
|
|
40
50
|
// Shared outgoing-edge resolver, so QA expectations and build-time wiring
|
|
41
51
|
// cannot drift on which declared routing field wins.
|
|
@@ -88,6 +98,18 @@ import {
|
|
|
88
98
|
unavailableCommercialCapture,
|
|
89
99
|
unavailableCommercialReport,
|
|
90
100
|
} from "./qa-commercial-parity.mjs";
|
|
101
|
+
import {
|
|
102
|
+
LIVE_REF_CODES,
|
|
103
|
+
campaignDriftMessage,
|
|
104
|
+
disabledLiveRead,
|
|
105
|
+
evaluateLiveCampaignRefs,
|
|
106
|
+
extractRenderedRefs,
|
|
107
|
+
liveRefsDisabled,
|
|
108
|
+
liveRefFindingMessage,
|
|
109
|
+
liveRefsNotRunMessage,
|
|
110
|
+
readLiveCampaignForPacket,
|
|
111
|
+
} from "./live-campaign-refs.mjs";
|
|
112
|
+
import { specPackageRefs, specShippingRefs } from "./doctor/checks.mjs";
|
|
91
113
|
|
|
92
114
|
// The producing runtime identity on every verdict. Read from package.json so
|
|
93
115
|
// a verdict names the release that made it; a literal here outlived three
|
|
@@ -100,7 +122,7 @@ const HELP = `campaigns-os qa — Node/npm spec-aware QA
|
|
|
100
122
|
Usage:
|
|
101
123
|
campaigns-os qa parity --fixture <parity-fixture.json> --scenario <scenario-id> [--base-url <override>] [--baseline <url>] [--parity-order-json <file>] [--no-post-verdict]
|
|
102
124
|
campaigns-os qa resolve --packet <campaign-runtime.build.json> [--base-url <url>] [--no-probe] [--probe-timeout-ms <ms>] [--json]
|
|
103
|
-
campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--output-dir <dir>] [--no-remit] [--json]
|
|
125
|
+
campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--output-dir <dir>] [--no-remit] [--no-live-refs] [--json]
|
|
104
126
|
campaigns-os qa policy set --packet <campaign-runtime.build.json> [--allowed-domains-confirmed true|false] [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--order-path-depth <off|common|full>] [--json]
|
|
105
127
|
campaigns-os qa waive --packet <campaign-runtime.build.json> --assertion analytics-correctness:purchase-fires --reason "<why>" [--waived-by <who>] [--report <assembly-report.json>] [--json]
|
|
106
128
|
campaigns-os qa promote --packet <campaign-runtime.build.json> --verdict <full-verdict.json> [--json] # project one explicit qa-output verdict to the committed .campaign-runtime/qa-verdict.json sidecar
|
|
@@ -120,7 +142,7 @@ Options:
|
|
|
120
142
|
Requires --base-url and --family. No Map ID / CampaignSpec needed.
|
|
121
143
|
--spec <path> Local exported CampaignSpec JSON for the non-packet Map ID flow.
|
|
122
144
|
Packet QA always uses packet.spec.local_path and rejects this override.
|
|
123
|
-
--proxy-base <url> Campaign Map proxy base for /api/spec, /api/price-preview, and verdict publishing.
|
|
145
|
+
--proxy-base <url> Campaign Map proxy base for /api/spec, /api/price-preview, /api/campaign, and verdict publishing.
|
|
124
146
|
--base-url <url> Deployed campaign root. Packet deploy URL is used when omitted.
|
|
125
147
|
Commercial pages are checked automatically against /api/price-preview;
|
|
126
148
|
no commercial sidecar or extra catalog flag is required.
|
|
@@ -156,6 +178,9 @@ Options:
|
|
|
156
178
|
Record is not stamped; a refusal still exits 2, a clean dry run exits 0
|
|
157
179
|
(--json: dry_run, would_publish, would_post).
|
|
158
180
|
--no-remit When an ambient run session is active, write the local Run Record but skip Run Telemetry remit.
|
|
181
|
+
--no-live-refs qa run: skip the one read-only GET of <proxy-base>/api/campaign that checks each
|
|
182
|
+
served page's shipping and package refs against the live campaign; the verdict
|
|
183
|
+
records the check not_run with reason disabled, never a pass.
|
|
159
184
|
--auth-cookie <cookie> Cookie header for protected previews.
|
|
160
185
|
--browser Run Playwright-rendered browser checks after static Node checks.
|
|
161
186
|
Requires one-time setup: campaigns-os qa install-browser
|
|
@@ -168,8 +193,11 @@ Options:
|
|
|
168
193
|
Create Playwright typed-card test orders through the tested checkout page.
|
|
169
194
|
Test cards bypass the gateway and create no transactions, so no permission
|
|
170
195
|
flags or packet policy are needed — just pick a mode. Default mode (bare
|
|
171
|
-
--test-order, or "common") runs
|
|
172
|
-
|
|
196
|
+
--test-order, or "common") runs every actual terminal path when they fit under
|
|
197
|
+
the cap (--max-test-orders, default 6). Above the cap it runs checkout,
|
|
198
|
+
first-offer accept/decline, and a deduplicated shortest real receipt path, then
|
|
199
|
+
adds one decline path per offer/downsell page not yet declined, up to the cap,
|
|
200
|
+
and names any page left out. "full" walks
|
|
173
201
|
every actual terminal path; cycles, missing routes, and reachable nonterminals
|
|
174
202
|
block before browser launch. The default cap is 6; overflow names the exact raise.
|
|
175
203
|
"tiers" is spec-driven: one strict-selection order per selector tier the
|
|
@@ -455,12 +483,22 @@ async function resolveQaInputs(args, {
|
|
|
455
483
|
hiddenEagerMediaGate,
|
|
456
484
|
});
|
|
457
485
|
const qaWaivers = resolveQaWaivers({ packetPath, report: checkpointPreflight?.runtimeReport });
|
|
486
|
+
const qaScope = applyQaBuildScope(topologies, {
|
|
487
|
+
packet, report: checkpointPreflight?.runtimeReport,
|
|
488
|
+
targetRepo: checkpointPreflight?.targetRepo, publicRouteSlug,
|
|
489
|
+
});
|
|
458
490
|
const brandContract = loadBrandContract(templateFamily);
|
|
491
|
+
const localServeAnalytics = resolveLocalServeAnalytics({
|
|
492
|
+
packet,
|
|
493
|
+
report: checkpointPreflight?.runtimeReport,
|
|
494
|
+
captureUrl: analyticsCaptureTarget.url || baseUrl,
|
|
495
|
+
});
|
|
459
496
|
return {
|
|
460
497
|
themeGate,
|
|
461
498
|
polishGate,
|
|
462
499
|
qaWaivers,
|
|
463
500
|
analyticsCaptureTarget,
|
|
501
|
+
localServeAnalytics,
|
|
464
502
|
brandContract: brandContract.contract,
|
|
465
503
|
brandContractStatus: brandContract.status,
|
|
466
504
|
packetPath,
|
|
@@ -481,7 +519,8 @@ async function resolveQaInputs(args, {
|
|
|
481
519
|
specHash,
|
|
482
520
|
templateFamily,
|
|
483
521
|
commerceStructureContract,
|
|
484
|
-
topologies,
|
|
522
|
+
topologies: qaScope.topologies,
|
|
523
|
+
excludedPages: qaScope.excludedPages,
|
|
485
524
|
checkpointGates: checkpointPreflight?.checkpointGates || nonPacketCheckpointGates(),
|
|
486
525
|
// The report the checkpoint gates were evaluated on, and the target repo
|
|
487
526
|
// whose default it may or may not be: what the printed remediation names.
|
|
@@ -2130,6 +2169,13 @@ async function runQa(args, options = {}) {
|
|
|
2130
2169
|
// Fail-fast before anything resolves or launches. The authoritative check
|
|
2131
2170
|
// lives on the creation budget itself, which every browser path builds.
|
|
2132
2171
|
refuseBadOrderCreationLimit(args);
|
|
2172
|
+
// Match dispatch: an active browser mode takes precedence over legacy API
|
|
2173
|
+
// diagnostics. Only the selected legacy path requires a cart and API mode.
|
|
2174
|
+
const browserMode = String(args["test-order"] || "off").toLowerCase();
|
|
2175
|
+
const legacyMode = String(args["legacy-api-test-order"] || "off").toLowerCase();
|
|
2176
|
+
if (browserMode === "off" && legacyMode !== "off") {
|
|
2177
|
+
refusing(() => legacyTestOrderInputs({ ...args, "test-order": legacyMode }));
|
|
2178
|
+
}
|
|
2133
2179
|
const resolved = await resolveQaInputs(args);
|
|
2134
2180
|
return runResolvedQa(args, resolved, options);
|
|
2135
2181
|
}
|
|
@@ -2137,7 +2183,7 @@ async function runQa(args, options = {}) {
|
|
|
2137
2183
|
// `runSessionActive` is threaded in from the CLI's single ambient-session read
|
|
2138
2184
|
// rather than re-discovered here, so the closeout command this run prints and
|
|
2139
2185
|
// the run_id the session will close under come from the same observation.
|
|
2140
|
-
async function runResolvedQa(args, resolved, { runSessionActive = false } = {}) {
|
|
2186
|
+
async function runResolvedQa(args, resolved, { runSessionActive = false, liveCampaign = undefined, liveCampaignFetch = globalThis.fetch } = {}) {
|
|
2141
2187
|
const startedAt = new Date().toISOString();
|
|
2142
2188
|
const runId = generateRunId();
|
|
2143
2189
|
const gate = resolved.themeGate;
|
|
@@ -2207,12 +2253,19 @@ async function runResolvedQa(args, resolved, { runSessionActive = false } = {})
|
|
|
2207
2253
|
|
|
2208
2254
|
const assertions = [
|
|
2209
2255
|
...checkpointAssertions,
|
|
2256
|
+
...(resolved.excludedPages || []).map(page => assertion({
|
|
2257
|
+
id: `build-scope:${page.page_id}`, family: "funnel-flow", page,
|
|
2258
|
+
status: STATUS.SKIPPED,
|
|
2259
|
+
expected: "Only built routes are preview-QA targets",
|
|
2260
|
+
actual: "out_of_build_scope",
|
|
2261
|
+
evidence: { reason: "out_of_build_scope" },
|
|
2262
|
+
})),
|
|
2210
2263
|
...(polishGate?.owned_checkpoint_only ? [] : [polishGateAssertion(polishGate)]),
|
|
2211
2264
|
themeGateAssertion(gate),
|
|
2212
2265
|
];
|
|
2213
2266
|
const contractAssertion = templateBrandContractAssertion(resolved);
|
|
2214
2267
|
if (contractAssertion) assertions.push(contractAssertion);
|
|
2215
|
-
const commercialPlanning = planCommercialParity(resolved.rawSpec || resolved.spec);
|
|
2268
|
+
const commercialPlanning = planCommercialParity(specForQaScope(resolved.rawSpec || resolved.spec, resolved.excludedPages));
|
|
2216
2269
|
const sourceLoader = createPageSourceLoader({ authCookie: args["auth-cookie"] });
|
|
2217
2270
|
const commercialIds = new Set(commercialPlanning.pages
|
|
2218
2271
|
.filter((page) => page?.id !== undefined && page?.id !== null)
|
|
@@ -2223,13 +2276,33 @@ async function runResolvedQa(args, resolved, { runSessionActive = false } = {})
|
|
|
2223
2276
|
const pages = resolved.topologies.flatMap(topology => topology.pages);
|
|
2224
2277
|
const pageResults = await mapConcurrent(pages, COMMERCIAL_QA_LIMITS.concurrency, page =>
|
|
2225
2278
|
runPageChecks(page, args, { sourceLoader, bindingExpected, bindingScriptLoader, captureCommercial: commercialIds.has(String(page.page_id)) }));
|
|
2279
|
+
const livePages = new Map();
|
|
2226
2280
|
for (const [index, page] of pages.entries()) {
|
|
2227
2281
|
const pageResult = pageResults[index];
|
|
2228
2282
|
assertions.push(...pageResult.assertions);
|
|
2283
|
+
if (pageResult.renderedRefs && !livePages.has(String(page.page_id))) {
|
|
2284
|
+
livePages.set(String(page.page_id), { ...page, page_id: String(page.page_id), ...pageResult.renderedRefs });
|
|
2285
|
+
}
|
|
2229
2286
|
if (commercialIds.has(String(page.page_id)) && pageResult.commercialCapture && !capturesByPageId.has(String(page.page_id))) {
|
|
2230
2287
|
capturesByPageId.set(String(page.page_id), pageResult.commercialCapture);
|
|
2231
2288
|
}
|
|
2232
2289
|
}
|
|
2290
|
+
// The live campaign read (#533): one GET of {proxy-base}/api/campaign under
|
|
2291
|
+
// the public campaign key, made only when a served page was read to compare;
|
|
2292
|
+
// --no-live-refs sends nothing and records the check not_run (`disabled`).
|
|
2293
|
+
const liveSpec = resolved.rawSpec || resolved.spec;
|
|
2294
|
+
const liveRead = liveRefsDisabled(args)
|
|
2295
|
+
? disabledLiveRead()
|
|
2296
|
+
: liveCampaign !== undefined || livePages.size === 0
|
|
2297
|
+
? liveCampaign
|
|
2298
|
+
: await readLiveCampaignForPacket({
|
|
2299
|
+
packet: resolved.packet,
|
|
2300
|
+
packetPath: resolved.packetPath || null,
|
|
2301
|
+
spec: liveSpec,
|
|
2302
|
+
fetchImpl: liveCampaignFetch,
|
|
2303
|
+
proxyBase: resolved.proxyBase,
|
|
2304
|
+
});
|
|
2305
|
+
assertions.push(...liveCampaignRefAssertions({ pages: [...livePages.values()], spec: liveSpec, liveCampaign: liveRead }));
|
|
2233
2306
|
if (args.browser === true) {
|
|
2234
2307
|
assertions.push(...await runBrowserChecks(resolved.topologies, args, {
|
|
2235
2308
|
brandContract: resolved.brandContract,
|
|
@@ -2297,13 +2370,17 @@ async function runAnalyticsOrderSequence({ args, resolved, runId, assertions },
|
|
|
2297
2370
|
} else if (analyticsLeg === "run") {
|
|
2298
2371
|
assertions.push(...await operations.runInventory(args, analyticsContract || {}, {
|
|
2299
2372
|
target: resolved.analyticsCaptureTarget,
|
|
2373
|
+
...analyticsCaptureScope(resolved),
|
|
2300
2374
|
}));
|
|
2301
2375
|
}
|
|
2302
2376
|
|
|
2303
|
-
// Analytics parity
|
|
2304
|
-
//
|
|
2377
|
+
// Analytics parity remains opt-in between root inventory and typed-card
|
|
2378
|
+
// receipt capture. #503: it gets the same partial-scope capture options.
|
|
2305
2379
|
if (stringArg(args["analytics-baseline"])) {
|
|
2306
|
-
assertions.push(...await operations.runParity(args, {
|
|
2380
|
+
assertions.push(...await operations.runParity(args, {
|
|
2381
|
+
target: resolved.analyticsCaptureTarget,
|
|
2382
|
+
...analyticsCaptureScope(resolved),
|
|
2383
|
+
}));
|
|
2307
2384
|
}
|
|
2308
2385
|
|
|
2309
2386
|
const result = await operations.runOrders({
|
|
@@ -2316,9 +2393,130 @@ async function runAnalyticsOrderSequence({ args, resolved, runId, assertions },
|
|
|
2316
2393
|
if (analyticsLeg === "run") {
|
|
2317
2394
|
assertions.push(operations.assessReceipt(result.receiptAnalytics, { waivers: resolved.qaWaivers }));
|
|
2318
2395
|
}
|
|
2396
|
+
applyLocalServeAnalyticsReview(assertions, resolved.localServeAnalytics);
|
|
2319
2397
|
return result.orders;
|
|
2320
2398
|
}
|
|
2321
2399
|
|
|
2400
|
+
// #483: local proof mode (deploy.target local-serve) renders the DEVELOPMENT
|
|
2401
|
+
// environment on purpose, and the starter templates gate every vendor loader
|
|
2402
|
+
// on it. A pixel that did not fire on that render is the render's design, not
|
|
2403
|
+
// the campaign's defect, so fire-dependent analytics checks become manual
|
|
2404
|
+
// review there instead of blockers. Only a run whose capture is actually
|
|
2405
|
+
// served from loopback qualifies: the same packet QA'd against the PR preview
|
|
2406
|
+
// (--base-url <preview>) is a production render and keeps its blockers, which
|
|
2407
|
+
// is the follow-up every downgraded assertion names.
|
|
2408
|
+
// The render itself must be on record, too: the exception applies only when
|
|
2409
|
+
// stages.assembly.evidence.build_environment says "development". A production
|
|
2410
|
+
// build served on localhost, or a build whose environment was never recorded,
|
|
2411
|
+
// keeps its blockers; the build-environment preflight only warns about those
|
|
2412
|
+
// states, so this gate cannot lean on it.
|
|
2413
|
+
// data-layer-purchase is deliberately not downgraded: it counts the SDK's own
|
|
2414
|
+
// dl_purchase, which the development render still pushes, so a miss on
|
|
2415
|
+
// localhost can be a real defect and keeps blocking.
|
|
2416
|
+
// A failure that is a capture or runner error, not "did not fire", is never
|
|
2417
|
+
// downgraded either: the environment gate explains a silent pixel, not an
|
|
2418
|
+
// unmeasured one. tag:* and oob:* are only emitted from a completed capture (a
|
|
2419
|
+
// failed capture is analytics-correctness:runner, which is outside this set);
|
|
2420
|
+
// purchase-fires names its unmeasured receipts in capture_error_plan_ids.
|
|
2421
|
+
const LOCAL_SERVE_ANALYTICS_REASON = "local_serve_development_render";
|
|
2422
|
+
const FIRE_DEPENDENT_ANALYTICS_ID = /^analytics-correctness:(?:tag:|oob:|purchase-fires(?::|$))/;
|
|
2423
|
+
|
|
2424
|
+
function isCaptureErrorFailure(item) {
|
|
2425
|
+
const evidence = item?.evidence;
|
|
2426
|
+
if (evidence && typeof evidence === "object" && evidence.error_code) return true;
|
|
2427
|
+
if (/^analytics-correctness:purchase-fires(?::|$)/.test(String(item?.id || ""))) {
|
|
2428
|
+
// Downgrade only a Purchase reading that positively records no capture
|
|
2429
|
+
// error; a missing list is an unknown, not a clean measurement.
|
|
2430
|
+
const errors = evidence?.capture_error_plan_ids;
|
|
2431
|
+
return !Array.isArray(errors) || errors.length > 0;
|
|
2432
|
+
}
|
|
2433
|
+
return false;
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
function isLoopbackUrl(value) {
|
|
2437
|
+
if (typeof value !== "string" || !value.trim()) return false;
|
|
2438
|
+
let hostname = null;
|
|
2439
|
+
try { hostname = new URL(value).hostname; } catch { hostname = null; }
|
|
2440
|
+
return !!hostname && isLoopbackHostname(hostname);
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
// #500: run-level eligibility is computed from the campaign-root URL, but the
|
|
2444
|
+
// page a failing check measured is not always that root. The #493 inventory
|
|
2445
|
+
// fallback can capture a built entry whose page.url is a remote production
|
|
2446
|
+
// preview, and a localhost root can redirect to a production host. So each
|
|
2447
|
+
// failing check is downgraded only when the page it measured is on record as
|
|
2448
|
+
// loopback:
|
|
2449
|
+
// - tag:* / oob:* — the check's own url and the inventory capture's
|
|
2450
|
+
// capture_page.url (both the URL requested) AND the capture's final_url
|
|
2451
|
+
// (page.url() after redirects and settling) must all be loopback;
|
|
2452
|
+
// - purchase-fires — every judged receipt's receipt_url (the order's final
|
|
2453
|
+
// page URL, recorded before analytics settle) AND its receipt_document_url
|
|
2454
|
+
// (page.url() read after the receipt analytics settled and were collected)
|
|
2455
|
+
// must be loopback, and there must be one. A receipt that redirects to a
|
|
2456
|
+
// hosted page during the settle window measured that host, not loopback.
|
|
2457
|
+
// A measured location that is missing or unparseable keeps the blocker.
|
|
2458
|
+
function localServeMeasuredOnLoopback(item, assertions) {
|
|
2459
|
+
const id = String(item?.id || "");
|
|
2460
|
+
if (/^analytics-correctness:purchase-fires(?::|$)/.test(id)) {
|
|
2461
|
+
const receipts = item?.evidence?.receipts;
|
|
2462
|
+
return Array.isArray(receipts) && receipts.length > 0
|
|
2463
|
+
&& receipts.every((receipt) => isLoopbackUrl(receipt?.receipt_url)
|
|
2464
|
+
&& isLoopbackUrl(receipt?.receipt_document_url));
|
|
2465
|
+
}
|
|
2466
|
+
const ownUrl = item?.evidence?.url ?? item?.url;
|
|
2467
|
+
if (!isLoopbackUrl(ownUrl)) return false;
|
|
2468
|
+
const capture = assertions.find((entry) => entry?.id === "analytics-correctness:capture" && entry.status === STATUS.PASS);
|
|
2469
|
+
const capturePage = capture?.evidence?.capture_page;
|
|
2470
|
+
return isLoopbackUrl(capturePage?.url) && isLoopbackUrl(capture?.evidence?.final_url);
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
function resolveLocalServeAnalytics({ packet, report, captureUrl }) {
|
|
2474
|
+
if (!isLocalServePacket(packet)) return null;
|
|
2475
|
+
if (!isLoopbackUrl(String(captureUrl ?? ""))) return null;
|
|
2476
|
+
if (recordedBuildEnvironment(report) !== LOCAL_PROOF_BUILD_ENVIRONMENT) return null;
|
|
2477
|
+
const parity = recordedProductionParity(report);
|
|
2478
|
+
return {
|
|
2479
|
+
deploy_target: "local-serve",
|
|
2480
|
+
build_environment: { field: LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD, value: LOCAL_PROOF_BUILD_ENVIRONMENT },
|
|
2481
|
+
production_parity: parity
|
|
2482
|
+
? {
|
|
2483
|
+
field: LOCAL_PROOF_PARITY_FIELD,
|
|
2484
|
+
status: typeof parity.status === "string" ? parity.status : null,
|
|
2485
|
+
checked_at: typeof parity.checked_at === "string" ? parity.checked_at : null,
|
|
2486
|
+
page_count: Number.isFinite(parity.page_count) ? parity.page_count : null,
|
|
2487
|
+
gated_hosts: [...new Set((Array.isArray(parity.pages) ? parity.pages : [])
|
|
2488
|
+
.flatMap((page) => (Array.isArray(page?.gated_hosts) ? page.gated_hosts : []))
|
|
2489
|
+
.filter((host) => typeof host === "string"))].sort(),
|
|
2490
|
+
}
|
|
2491
|
+
: { field: LOCAL_PROOF_PARITY_FIELD, status: "not_recorded" },
|
|
2492
|
+
};
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
function applyLocalServeAnalyticsReview(assertions, localServe) {
|
|
2496
|
+
if (!localServe) return;
|
|
2497
|
+
const parityPassed = localServe.production_parity?.status === "pass";
|
|
2498
|
+
for (const [index, item] of assertions.entries()) {
|
|
2499
|
+
if (item?.status !== STATUS.FAIL || !FIRE_DEPENDENT_ANALYTICS_ID.test(String(item.id || ""))) continue;
|
|
2500
|
+
if (isCaptureErrorFailure(item)) continue;
|
|
2501
|
+
if (!localServeMeasuredOnLoopback(item, assertions)) continue;
|
|
2502
|
+
assertions[index] = {
|
|
2503
|
+
...item,
|
|
2504
|
+
status: STATUS.MANUAL_REVIEW,
|
|
2505
|
+
severity: SEVERITY.WARN,
|
|
2506
|
+
actual: `${item.actual ?? "did not fire"}; local-serve development render gates vendor loaders out, so re-run against the PR preview with --base-url <preview-url>`,
|
|
2507
|
+
evidence: {
|
|
2508
|
+
...(item.evidence || {}),
|
|
2509
|
+
reason: LOCAL_SERVE_ANALYTICS_REASON,
|
|
2510
|
+
local_serve_status: STATUS.FAIL,
|
|
2511
|
+
build_environment: localServe.build_environment,
|
|
2512
|
+
follow_up: "Re-run qa run against the PR preview (a production render) with --base-url <preview-url>; that run gates these checks.",
|
|
2513
|
+
production_parity: localServe.production_parity,
|
|
2514
|
+
...(parityPassed ? {} : { production_parity_note: "No passing page-kit parity is recorded, so nothing yet shows the production render carries these loaders." }),
|
|
2515
|
+
},
|
|
2516
|
+
};
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2322
2520
|
async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, testOrders, commercial = null, runSessionActive = false, browser = null }) {
|
|
2323
2521
|
const entryUrls = deriveEntryUrls(resolved.topologies);
|
|
2324
2522
|
const pageUrls = derivePageUrls(resolved.topologies);
|
|
@@ -2447,6 +2645,38 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2447
2645
|
};
|
|
2448
2646
|
}
|
|
2449
2647
|
|
|
2648
|
+
// #493: a full build captures the campaign root. A partial build captures it
|
|
2649
|
+
// only when a built, in-scope page is served there; otherwise the root is
|
|
2650
|
+
// whatever the host answers (a directory index, a generic fallback) and must
|
|
2651
|
+
// not be measured. Either way the built entry pages (the same first in-scope
|
|
2652
|
+
// entry #482 selects) are the fallback when the root cannot be captured.
|
|
2653
|
+
// #503: "served there" is the page identity the capture leg deduplicates by
|
|
2654
|
+
// (isSameAnalyticsCapturePage): a query-routed page on the root's path
|
|
2655
|
+
// (`/campaign/?step=checkout`) is not the root, so it cannot put the root in
|
|
2656
|
+
// scope.
|
|
2657
|
+
function analyticsCaptureScope(resolved) {
|
|
2658
|
+
const rootUrl = rootCaptureUrl(resolved?.analyticsCaptureTarget?.url);
|
|
2659
|
+
const topologies = topologyList(resolved?.topologies);
|
|
2660
|
+
const partial = topologies.some((topology) => topology?.partial_build_scope)
|
|
2661
|
+
|| (Array.isArray(resolved?.excludedPages) && resolved.excludedPages.length > 0);
|
|
2662
|
+
const rootInScope = !rootUrl || !partial || topologies.some((topology) =>
|
|
2663
|
+
(Array.isArray(topology?.pages) ? topology.pages : []).some((page) =>
|
|
2664
|
+
typeof page?.url === "string" && isSameAnalyticsCapturePage(page.url, rootUrl)));
|
|
2665
|
+
return {
|
|
2666
|
+
rootInScope,
|
|
2667
|
+
fallbackTargets: deriveEntryUrls(resolved?.topologies),
|
|
2668
|
+
};
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
function rootCaptureUrl(value) {
|
|
2672
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
2673
|
+
try {
|
|
2674
|
+
return new URL(value).toString();
|
|
2675
|
+
} catch {
|
|
2676
|
+
return null;
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2450
2680
|
const ENTRY_PAGE_TYPES = new Set([
|
|
2451
2681
|
"entry",
|
|
2452
2682
|
"presell",
|
|
@@ -2464,7 +2694,9 @@ function deriveEntryUrls(topologies) {
|
|
|
2464
2694
|
for (const topology of topologyList(topologies)) {
|
|
2465
2695
|
const pages = Array.isArray(topology?.pages) ? topology.pages.filter((page) => page?.url) : [];
|
|
2466
2696
|
if (!pages.length) continue;
|
|
2467
|
-
|
|
2697
|
+
// #482: a partial build enters at its first in-scope page, which can be
|
|
2698
|
+
// an opted-in select or checkout rather than a later landing/presell.
|
|
2699
|
+
const page = topology.partial_build_scope ? pages[0] : pages.find(isEntryLikePage) || pages[0];
|
|
2468
2700
|
entries.push({
|
|
2469
2701
|
funnel_id: topology.funnel_id || "default",
|
|
2470
2702
|
funnel_name: topology.funnel_name || topology.funnel_id || "default",
|
|
@@ -2558,7 +2790,10 @@ async function runPageChecks(page, args, {
|
|
|
2558
2790
|
}
|
|
2559
2791
|
|
|
2560
2792
|
const source = await sourceLoader(page);
|
|
2561
|
-
|
|
2793
|
+
const scriptParseFailures = [];
|
|
2794
|
+
assertions.push(bindingAssertion(page, await observeBinding({ source, page, expected: bindingExpected, scriptLoader: bindingScriptLoader, parseFailures: scriptParseFailures })));
|
|
2795
|
+
const scriptParse = scriptParseAssertion(page, scriptParseFailures);
|
|
2796
|
+
if (scriptParse) assertions.push(assertion({ ...scriptParse, page }));
|
|
2562
2797
|
if (!source.ok) {
|
|
2563
2798
|
const isHttpStatus = source.error_code === "http_status";
|
|
2564
2799
|
assertions.push(assertion({
|
|
@@ -2687,7 +2922,77 @@ async function runPageChecks(page, args, {
|
|
|
2687
2922
|
}));
|
|
2688
2923
|
}
|
|
2689
2924
|
|
|
2690
|
-
|
|
2925
|
+
const rendered = extractRenderedRefs(html);
|
|
2926
|
+
return {
|
|
2927
|
+
assertions,
|
|
2928
|
+
commercialCapture,
|
|
2929
|
+
renderedRefs: { package_refs: [...rendered.package_refs], shipping_refs: [...rendered.shipping_refs] },
|
|
2930
|
+
};
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
// The live campaign ref check (#533) as QA assertions: the comparison doctor
|
|
2934
|
+
// runs over built pages, run here over the served pages this attempt read,
|
|
2935
|
+
// under the same codes. `liveCampaign` is a readLiveCampaign result the
|
|
2936
|
+
// caller made; absent or failed, the check is not_run with its reason.
|
|
2937
|
+
function liveCampaignRefAssertions({ pages, spec, liveCampaign }) {
|
|
2938
|
+
const campaignAssertion = (fields) => assertion({ family: "api-metadata", page: { page_id: "campaign" }, ...fields });
|
|
2939
|
+
if (!pages.length && liveCampaign?.reason_code !== "disabled") {
|
|
2940
|
+
return [campaignAssertion({
|
|
2941
|
+
id: "live-campaign-refs",
|
|
2942
|
+
status: STATUS.SKIPPED,
|
|
2943
|
+
expected: "every served page's shipping and package refs are served by the live campaign",
|
|
2944
|
+
actual: "not_run",
|
|
2945
|
+
evidence: { code: LIVE_REF_CODES.notRun, reason_code: "no_pages", reason: "No served page source was read to compare against the live campaign." },
|
|
2946
|
+
})];
|
|
2947
|
+
}
|
|
2948
|
+
const result = evaluateLiveCampaignRefs({
|
|
2949
|
+
pages,
|
|
2950
|
+
map: { package_refs: specPackageRefs(spec), shipping_refs: specShippingRefs(spec) },
|
|
2951
|
+
live: liveCampaign,
|
|
2952
|
+
});
|
|
2953
|
+
const keySource = liveCampaign?.key_source ? { key_source: liveCampaign.key_source } : {};
|
|
2954
|
+
if (result.status === "not_run") {
|
|
2955
|
+
// --no-live-refs: the verdict says how many served pages went unchecked.
|
|
2956
|
+
const eligible = result.reason_code === "disabled" ? { pages_eligible: result.checked_pages } : {};
|
|
2957
|
+
return [campaignAssertion({
|
|
2958
|
+
id: result.attempted ? LIVE_REF_CODES.notRun : "live-campaign-refs",
|
|
2959
|
+
status: result.attempted ? STATUS.WARN : STATUS.SKIPPED,
|
|
2960
|
+
...(result.attempted ? { severity: SEVERITY.WARN } : {}),
|
|
2961
|
+
expected: "every served page's shipping and package refs are served by the live campaign",
|
|
2962
|
+
actual: "not_run",
|
|
2963
|
+
evidence: { code: LIVE_REF_CODES.notRun, reason_code: result.reason_code, reason: result.attempted ? liveRefsNotRunMessage(result) : result.reason, ...eligible, ...keySource },
|
|
2964
|
+
})];
|
|
2965
|
+
}
|
|
2966
|
+
const out = result.page_findings.map((finding) => assertion({
|
|
2967
|
+
id: `${finding.code}:${finding.page_id}`,
|
|
2968
|
+
family: "api-metadata",
|
|
2969
|
+
page: pages.find((page) => String(page.page_id) === finding.page_id) || { page_id: finding.page_id },
|
|
2970
|
+
status: STATUS.FAIL,
|
|
2971
|
+
severity: SEVERITY.BLOCKER,
|
|
2972
|
+
expected: `every rendered ${finding.kind} ref is served by the live campaign`,
|
|
2973
|
+
actual: finding.refs.join(", "),
|
|
2974
|
+
evidence: { code: finding.code, refs: finding.refs, message: liveRefFindingMessage(finding) },
|
|
2975
|
+
}));
|
|
2976
|
+
if (result.drift) {
|
|
2977
|
+
out.push(campaignAssertion({
|
|
2978
|
+
id: LIVE_REF_CODES.drift,
|
|
2979
|
+
status: STATUS.WARN,
|
|
2980
|
+
severity: SEVERITY.WARN,
|
|
2981
|
+
expected: "the CampaignSpec lists the refs the live campaign serves",
|
|
2982
|
+
actual: "drift",
|
|
2983
|
+
evidence: { code: LIVE_REF_CODES.drift, drift: result.drift, message: campaignDriftMessage(result.drift) },
|
|
2984
|
+
}));
|
|
2985
|
+
}
|
|
2986
|
+
if (result.status === "pass") {
|
|
2987
|
+
out.push(campaignAssertion({
|
|
2988
|
+
id: "live-campaign-refs",
|
|
2989
|
+
status: STATUS.PASS,
|
|
2990
|
+
expected: "every served page's shipping and package refs are served by the live campaign",
|
|
2991
|
+
actual: `${result.checked_pages} page(s) checked`,
|
|
2992
|
+
evidence: { ...keySource },
|
|
2993
|
+
}));
|
|
2994
|
+
}
|
|
2995
|
+
return out;
|
|
2691
2996
|
}
|
|
2692
2997
|
|
|
2693
2998
|
async function maybeRunTestOrders(
|
|
@@ -2702,6 +3007,12 @@ async function maybeRunTestOrders(
|
|
|
2702
3007
|
const mode = String(args["test-order"] || "off").toLowerCase();
|
|
2703
3008
|
const legacyMode = String(args["legacy-api-test-order"] || "off").toLowerCase();
|
|
2704
3009
|
const emptyReceiptAnalytics = () => ({ plannedPlanIds: [], attempts: [] });
|
|
3010
|
+
if (!mode || mode === "off") {
|
|
3011
|
+
// No browser order clicks an upsell control, legacy API orders included,
|
|
3012
|
+
// so the coverage row says so instead of going missing (#530).
|
|
3013
|
+
const coverage = upsellActionCoverageWithoutOrders(resolved.topologies);
|
|
3014
|
+
if (coverage) assertions.push(coverage);
|
|
3015
|
+
}
|
|
2705
3016
|
if ((!mode || mode === "off") && (!legacyMode || legacyMode === "off")) {
|
|
2706
3017
|
return { orders: [], receiptAnalytics: emptyReceiptAnalytics() };
|
|
2707
3018
|
}
|
|
@@ -2728,15 +3039,12 @@ async function maybeRunLegacyApiTestOrders({ args, resolved, runId, assertions }
|
|
|
2728
3039
|
const apiKey = stringArg(args["api-key"]) || process.env.QA_CAMPAIGNS_API_KEY;
|
|
2729
3040
|
const apiBase = stringArg(args["campaigns-api-base"]) || process.env.CAMPAIGNS_API_BASE;
|
|
2730
3041
|
if (!apiKey || !apiBase) throw new Error("Legacy direct API test orders require --api-key/QA_CAMPAIGNS_API_KEY and --campaigns-api-base/CAMPAIGNS_API_BASE.");
|
|
2731
|
-
const cart =
|
|
2732
|
-
if (!cart.length) throw new Error("--test-order requires --cart package_id:quantity pairs.");
|
|
3042
|
+
const { cart, paths } = legacyTestOrderInputs(args);
|
|
2733
3043
|
const checkout = findPage(resolved.topologies, "checkout");
|
|
2734
3044
|
if (!checkout?.url) throw new Error("--test-order requires a checkout page URL.");
|
|
2735
3045
|
const upsell = findPage(resolved.topologies, "upsell");
|
|
2736
|
-
const paths = mode === "both" ? ["accept", "decline"] : [mode];
|
|
2737
3046
|
const orders = [];
|
|
2738
3047
|
for (const path of paths) {
|
|
2739
|
-
if (!["accept", "decline"].includes(path)) throw new Error(`Unknown --test-order mode: ${mode}`);
|
|
2740
3048
|
const create = await createTestOrder({ apiBase, apiKey, cart, runId, successUrl: checkout.expected_next_url || upsell?.url || checkout.url, spec: resolved.spec, args });
|
|
2741
3049
|
const verification = { expected_line_count: cart.length, actual_line_count: 0, diff: [], verified: false };
|
|
2742
3050
|
if (!create.ok) {
|
|
@@ -3604,6 +3912,14 @@ function decodeHtml(value) {
|
|
|
3604
3912
|
.replace(/>/g, ">");
|
|
3605
3913
|
}
|
|
3606
3914
|
|
|
3915
|
+
function legacyTestOrderInputs(args) {
|
|
3916
|
+
const cart = typeof args.cart === "string" ? parseCart(args.cart) : [];
|
|
3917
|
+
if (!cart.length) throw new Error("--test-order requires --cart package_id:quantity pairs.");
|
|
3918
|
+
const mode = String(args["test-order"] || "off").toLowerCase();
|
|
3919
|
+
if (!["accept", "decline", "both"].includes(mode)) throw new Error(`Unknown --test-order mode: ${mode}`);
|
|
3920
|
+
return { cart, paths: mode === "both" ? ["accept", "decline"] : [mode] };
|
|
3921
|
+
}
|
|
3922
|
+
|
|
3607
3923
|
function parseCart(value) {
|
|
3608
3924
|
if (!value) return [];
|
|
3609
3925
|
return String(value).split(",").map((part) => {
|
|
@@ -3649,6 +3965,9 @@ export const __qaNodeTestHooks = Object.freeze({
|
|
|
3649
3965
|
resolveQaInputs,
|
|
3650
3966
|
runResolvedQa,
|
|
3651
3967
|
runPageChecks,
|
|
3968
|
+
analyticsCaptureScope,
|
|
3969
|
+
resolveLocalServeAnalytics,
|
|
3970
|
+
applyLocalServeAnalyticsReview,
|
|
3652
3971
|
analyticsCorrectnessLegDecision,
|
|
3653
3972
|
analyticsCorrectnessDisabledAssertion,
|
|
3654
3973
|
runAnalyticsOrderSequence,
|
|
@@ -3683,6 +4002,7 @@ export const __qaNodeTestHooks = Object.freeze({
|
|
|
3683
4002
|
isRoutingMetaTag,
|
|
3684
4003
|
unsupportedSdkMetaHint,
|
|
3685
4004
|
reportCommercialRunnerError,
|
|
4005
|
+
liveCampaignRefAssertions,
|
|
3686
4006
|
browserSkippedByGate,
|
|
3687
4007
|
reportBrowserSkippedByGate,
|
|
3688
4008
|
gateClearingHint,
|