@nextcommerce/campaigns-os 1.47.0 → 1.50.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 +307 -0
- package/README.md +30 -3
- package/agents/claude/CLAUDE.md +6 -3
- package/agents/codex/AGENTS.md +6 -4
- package/agents/copilot/copilot-instructions.md +3 -2
- package/agents/cursor/campaigns-os.mdc +3 -3
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +17 -17
- package/contracts/effects.v1.json +173 -0
- package/contracts/release-ledger.json +798 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/brand-theme-bridge.md +12 -6
- package/docs/build-packet.md +9 -4
- package/docs/campaign-build-brief.md +25 -28
- package/docs/local-setup.md +7 -2
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +70 -9
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +8 -3
- 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 +9 -4
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -4
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +6 -5
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills.json +10 -10
- package/src/brand-theme.mjs +13 -2
- package/src/cli.mjs +17 -12
- package/src/content-residue.mjs +18 -90
- package/src/doctor/checks.mjs +21 -29
- package/src/doctor/inspect.mjs +13 -3
- package/src/doctor/next-step.mjs +4 -0
- package/src/gate-actions.mjs +8 -0
- package/src/install-mode.mjs +0 -8
- package/src/invocation.mjs +3 -1
- package/src/local-preview-policy.mjs +92 -0
- package/src/page-kit-sdk-version.mjs +8 -1
- package/src/polish-node.mjs +26 -2
- package/src/progress-node.mjs +5 -1
- package/src/qa-binding-evidence.mjs +22 -1
- package/src/qa-browser.mjs +92 -14
- package/src/qa-node.mjs +43 -6
- package/src/readback.mjs +19 -10
- package/src/source-prep.mjs +1 -1
- package/src/stage-record.mjs +303 -22
- package/src/theme-gate.mjs +3 -3
package/src/gate-actions.mjs
CHANGED
|
@@ -62,6 +62,14 @@ export const HIDDEN_EAGER_MEDIA_ACTIONS = Object.freeze({
|
|
|
62
62
|
command: null,
|
|
63
63
|
description: localProofRebuildText(),
|
|
64
64
|
}),
|
|
65
|
+
// Every mapped page is template stock: capture has no design route, so
|
|
66
|
+
// neither a recapture nor an authority repair can produce the evidence.
|
|
67
|
+
map_design_route: Object.freeze({
|
|
68
|
+
id: "polish.hidden_eager_media.map_design_route",
|
|
69
|
+
kind: "manual",
|
|
70
|
+
command: null,
|
|
71
|
+
description: "Every mapped page is template stock, so polish capture has nothing to measure. Map at least one page to its design source HTML (source_html.pages[].path) and rerun intake, or prove the campaign on the local preview (deploy.target local-serve served from localhost), where this missing evidence is carried forward as a warning.",
|
|
72
|
+
}),
|
|
65
73
|
});
|
|
66
74
|
|
|
67
75
|
const PACKET_PLACEHOLDER = "--packet <packet>";
|
package/src/install-mode.mjs
CHANGED
|
@@ -282,11 +282,3 @@ export function invocationPrefixFor(root, pkg = null) {
|
|
|
282
282
|
prefixCache.set(root, prefix);
|
|
283
283
|
return prefix;
|
|
284
284
|
}
|
|
285
|
-
|
|
286
|
-
// Rewrites every command spelled with the canonical bare `campaigns-os <verb>`
|
|
287
|
-
// into the given prefix. Internal bookkeeping (deviation tracking, gate
|
|
288
|
-
// registries, tests) keeps the canonical spelling; only what is printed or
|
|
289
|
-
// emitted for an operator or agent to copy is rewritten. Skill names such as
|
|
290
|
-
// next-campaigns-os-setup, file names (campaigns-os.mjs), and already-prefixed
|
|
291
|
-
// forms (`npx --no-install campaigns-os`, `npm run campaigns-os --`) are left
|
|
292
|
-
// alone.
|
package/src/invocation.mjs
CHANGED
|
@@ -65,7 +65,7 @@ const COMMANDS = frozen({
|
|
|
65
65
|
theme: { subcommands: ["generate", "inspect", "waive"] },
|
|
66
66
|
checkpoint: { subcommands: ["waive"] },
|
|
67
67
|
polish: { subcommands: ["capture"] },
|
|
68
|
-
record: { subcommands: ["build", "polish", "setup"] },
|
|
68
|
+
record: { subcommands: ["build", "deploy", "polish", "setup", "theme"] },
|
|
69
69
|
"validate-assembly-report": {},
|
|
70
70
|
"install-agent-context": { dryRun: true },
|
|
71
71
|
"install-skills": { dryRun: true },
|
|
@@ -92,8 +92,10 @@ const SUBCOMMAND_OVERRIDES = frozen({
|
|
|
92
92
|
"spec derive": { dryRun: true },
|
|
93
93
|
"qa publish": { dryRun: true },
|
|
94
94
|
"record build": { dryRun: true },
|
|
95
|
+
"record deploy": { dryRun: true },
|
|
95
96
|
"record polish": { dryRun: true },
|
|
96
97
|
"record setup": { dryRun: true },
|
|
98
|
+
"record theme": { dryRun: true },
|
|
97
99
|
"qa run": { autoEnd: true },
|
|
98
100
|
"run start": { sweepRoot: "session" },
|
|
99
101
|
"run end": { sweepRoot: "session", dryRun: true },
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The local preview policy: one place that decides which missing evidence a
|
|
2
|
+
// localhost preview carries forward as a warning instead of a blocker.
|
|
3
|
+
//
|
|
4
|
+
// The local preview is a packet whose deploy target is `local-serve`, served
|
|
5
|
+
// from a loopback host. There a campaign must still prove its commerce (store
|
|
6
|
+
// and campaign binding, routes, SDK loading, prices, a typed-card order), but
|
|
7
|
+
// evidence that only design work or launch produces may be missing. Missing
|
|
8
|
+
// evidence is reported as missing, never as passed: a carried-forward gate is
|
|
9
|
+
// a warning in doctor, `next` does not stop on it, and QA records it as a
|
|
10
|
+
// `warn` row, so the verdict is at best `ready_with_exceptions`. A hosted
|
|
11
|
+
// preview or production packet never takes this path, and every check not
|
|
12
|
+
// named below keeps its meaning on every path.
|
|
13
|
+
//
|
|
14
|
+
// Doctor and QA apply this to the gates they evaluate; `next` reads doctor's
|
|
15
|
+
// gates. Recording a stage (`record polish`) and the waiver commands keep the
|
|
16
|
+
// strict gates.
|
|
17
|
+
import { isLocalServePacket } from "./local-proof.mjs";
|
|
18
|
+
import { isLoopbackHostname } from "./remit.mjs";
|
|
19
|
+
|
|
20
|
+
export const LOCAL_PREVIEW_POLICY = "local_preview";
|
|
21
|
+
export const CARRIED_FORWARD = "carried_forward";
|
|
22
|
+
|
|
23
|
+
// The checks carried forward, one by one. Each is evidence the campaign does
|
|
24
|
+
// not have yet, not evidence that something is wrong:
|
|
25
|
+
// - polish was never recorded for this build;
|
|
26
|
+
// - no page-load capture exists, either because polish capture never ran or
|
|
27
|
+
// because every mapped page is template stock and there is no design route
|
|
28
|
+
// to capture.
|
|
29
|
+
const CARRIED_POLISH_CODES = Object.freeze(new Set([
|
|
30
|
+
"polish.report_missing",
|
|
31
|
+
"polish.evidence_missing",
|
|
32
|
+
]));
|
|
33
|
+
export const NO_CAPTURABLE_ROUTES_CODE = "polish.hidden_eager_media.no_capturable_routes";
|
|
34
|
+
const MISSING_CAPTURE_CODE = "polish.hidden_eager_media.capture_malformed";
|
|
35
|
+
|
|
36
|
+
// With no generatable brand theme the starter template is the design, so its
|
|
37
|
+
// palette and chrome are expected on the local preview: residue findings are
|
|
38
|
+
// warnings there, as they are under a waived theme gate.
|
|
39
|
+
const STARTER_IS_THE_DESIGN_THEME_CODE = "theme_gate.nothing_generatable";
|
|
40
|
+
|
|
41
|
+
function loopbackOrAbsent(url) {
|
|
42
|
+
if (url === undefined || url === null || url === "") return true;
|
|
43
|
+
try {
|
|
44
|
+
return isLoopbackHostname(new URL(String(url)).hostname);
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isLocalPreview(packet, { baseUrl = null } = {}) {
|
|
51
|
+
return isLocalServePacket(packet)
|
|
52
|
+
&& loopbackOrAbsent(packet?.deploy?.preview_url)
|
|
53
|
+
&& loopbackOrAbsent(baseUrl);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function pageLoadRecorded(report) {
|
|
57
|
+
return report?.stages?.polish?.evidence?.visual_review?.page_load != null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function carried(gate) {
|
|
61
|
+
return {
|
|
62
|
+
...gate,
|
|
63
|
+
status: CARRIED_FORWARD,
|
|
64
|
+
carried_forward: { policy: LOCAL_PREVIEW_POLICY, from_status: gate.status, evidence: "missing" },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The hidden eager-media checkpoint, evaluated on its own.
|
|
69
|
+
export function applyLocalPreviewToCheckpoint(gate, { packet, report, baseUrl = null } = {}) {
|
|
70
|
+
if (gate?.status !== "blocked" || !isLocalPreview(packet, { baseUrl })) return gate;
|
|
71
|
+
const missing = gate.code === NO_CAPTURABLE_ROUTES_CODE
|
|
72
|
+
|| (gate.code === MISSING_CAPTURE_CODE && !pageLoadRecorded(report));
|
|
73
|
+
return missing ? carried(gate) : gate;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The polish gate folds the checkpoint in, so it is carried forward only when
|
|
77
|
+
// whatever it owns from the checkpoint was carried forward too.
|
|
78
|
+
export function applyLocalPreviewToPolishGate(gate, { packet, checkpointGate = null, baseUrl = null } = {}) {
|
|
79
|
+
if (gate?.status !== "blocked" || !isLocalPreview(packet, { baseUrl })) return gate;
|
|
80
|
+
const checkpointCarried = checkpointGate?.status === CARRIED_FORWARD;
|
|
81
|
+
if (gate.owned_checkpoint_status === "blocked" && !checkpointCarried) return gate;
|
|
82
|
+
if (gate.owned_checkpoint_only) return checkpointCarried ? carried(gate) : gate;
|
|
83
|
+
return CARRIED_POLISH_CODES.has(gate.code) ? carried(gate) : gate;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function starterResidueIsExpected(themeGate, { packet, baseUrl = null } = {}) {
|
|
87
|
+
return themeGate?.code === STARTER_IS_THE_DESIGN_THEME_CODE && isLocalPreview(packet, { baseUrl });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function carriedForwardMessage(gate) {
|
|
91
|
+
return `Carried forward on the local preview: ${gate.reason} This evidence is missing, not passed; a hosted preview or production run requires it.`;
|
|
92
|
+
}
|
|
@@ -92,6 +92,10 @@ export function evaluatePageKitSdkVersion({
|
|
|
92
92
|
waivers = [],
|
|
93
93
|
required = true,
|
|
94
94
|
now = new Date().toISOString(),
|
|
95
|
+
// { family, sdk_version } of the selected certified template family, when
|
|
96
|
+
// the caller knows it: the missing-pin reason names that verified SDK so the
|
|
97
|
+
// pin is not chosen by searching docs.
|
|
98
|
+
familyVerification = null,
|
|
95
99
|
} = {}) {
|
|
96
100
|
const pin = resolveSpecSdkPin(spec);
|
|
97
101
|
const { has_canonical: hasCanonical, has_alias: hasAlias } = pin;
|
|
@@ -139,7 +143,10 @@ export function evaluatePageKitSdkVersion({
|
|
|
139
143
|
scope: PAGE_KIT_SDK_VERSION_SCOPE,
|
|
140
144
|
status: "blocked",
|
|
141
145
|
code: "page_kit.sdk_version.spec_missing",
|
|
142
|
-
reason: "CampaignSpec is missing global_config.sdk_version (and the runtime.sdk_version alias); add an explicit released SDK pin before build or QA."
|
|
146
|
+
reason: "CampaignSpec is missing global_config.sdk_version (and the runtime.sdk_version alias); add an explicit released SDK pin before build or QA."
|
|
147
|
+
+ (isReleasedSdkVersion(familyVerification?.sdk_version) && typeof familyVerification?.family === "string"
|
|
148
|
+
? ` The "${familyVerification.family}" template family was last verified against ${familyVerification.sdk_version}.`
|
|
149
|
+
: ""),
|
|
143
150
|
waivable: false,
|
|
144
151
|
subject,
|
|
145
152
|
state: { spec_status: "missing" },
|
package/src/polish-node.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { campaignIdentitiesMatch, localSpecIdentityFields } from "./spec-source-identity.mjs";
|
|
2
|
+
import { NO_CAPTURABLE_ROUTES_CODE } from "./local-preview-policy.mjs";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import { HIDDEN_EAGER_MEDIA_ACTIONS } from "./gate-actions.mjs";
|
|
4
5
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -93,6 +94,8 @@ function mappedSpecRoute(value, pageId) {
|
|
|
93
94
|
return route;
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
const NO_CAPTURABLE_ROUTES_ERROR = "no_capturable_routes";
|
|
98
|
+
|
|
96
99
|
export function planPolishCapture({ packet, baseUrl } = {}) {
|
|
97
100
|
if (!isPlainObject(packet) || !Array.isArray(packet?.source_html?.pages) || packet.source_html.pages.length === 0) {
|
|
98
101
|
throw new Error("polish capture requires packet.source_html.pages mappings.");
|
|
@@ -129,7 +132,14 @@ export function planPolishCapture({ packet, baseUrl } = {}) {
|
|
|
129
132
|
});
|
|
130
133
|
}
|
|
131
134
|
|
|
132
|
-
if (routes.length === 0)
|
|
135
|
+
if (routes.length === 0) {
|
|
136
|
+
const error = new Error(
|
|
137
|
+
"polish capture has no mapped non-skipped routes to capture: every mapped page is template stock (skip_reason), so there is no design route to compare."
|
|
138
|
+
+ " On a local-serve preview, missing polish evidence is carried forward as a warning; run `next` for the next stage.",
|
|
139
|
+
);
|
|
140
|
+
error.code = NO_CAPTURABLE_ROUTES_ERROR;
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
133
143
|
routes.sort((a, b) => a.requested_route.localeCompare(b.requested_route) || a.page_id.localeCompare(b.page_id));
|
|
134
144
|
for (let index = 1; index < routes.length; index += 1) {
|
|
135
145
|
if (routes[index - 1].requested_route === routes[index].requested_route) {
|
|
@@ -189,6 +199,19 @@ function recordedAuthorityBlock({ packet, report, plan = null, now } = {}) {
|
|
|
189
199
|
};
|
|
190
200
|
}
|
|
191
201
|
|
|
202
|
+
// Every mapped page is template stock: there is nothing for polish capture to
|
|
203
|
+
// measure, which is missing evidence rather than a malformed packet. It stays
|
|
204
|
+
// a non-waivable block; the local preview policy carries it forward.
|
|
205
|
+
function noCapturableRoutesBlock({ packet, report, now } = {}) {
|
|
206
|
+
const block = recordedAuthorityBlock({ packet, report, now });
|
|
207
|
+
return {
|
|
208
|
+
...block,
|
|
209
|
+
code: NO_CAPTURABLE_ROUTES_CODE,
|
|
210
|
+
reason: "Every mapped page is template stock (skip_reason), so polish capture has no design route to capture and this build has no page-load evidence.",
|
|
211
|
+
required_actions: [HIDDEN_EAGER_MEDIA_ACTIONS.map_design_route],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
192
215
|
function recordedCheckpointNotApplicable() {
|
|
193
216
|
return {
|
|
194
217
|
id: HIDDEN_EAGER_MEDIA_SCOPE,
|
|
@@ -219,7 +242,8 @@ export function evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report, now
|
|
|
219
242
|
let plan;
|
|
220
243
|
try {
|
|
221
244
|
plan = planPolishCapture({ packet, baseUrl: "https://polish-capture.invalid" });
|
|
222
|
-
} catch {
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (error?.code === NO_CAPTURABLE_ROUTES_ERROR) return noCapturableRoutesBlock({ packet, report, now });
|
|
223
247
|
return recordedAuthorityBlock({ packet, report, now });
|
|
224
248
|
}
|
|
225
249
|
|
package/src/progress-node.mjs
CHANGED
|
@@ -58,7 +58,11 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
|
|
|
58
58
|
binding:reportBound&&id(verdict.run_id)&&id(verdict.run_id)===id(report?.stages?.qa?.verdict_run_id)&&build&&qaSource===build&&doctor?.derived?.build_output_fingerprint?.status==='pass'&&hash(verdict.spec_hash)===localHash?'matching':'unconfirmed',
|
|
59
59
|
publish_state:accepted(qaResult?.qa_verdict_publish?.state,['skipped','ok','failed']),
|
|
60
60
|
}:null;
|
|
61
|
-
|
|
61
|
+
// The snapshot schema has no carried-forward state: a gate the local preview
|
|
62
|
+
// policy carried forward does not apply on this path, so it is not_applicable
|
|
63
|
+
// here (never pass).
|
|
64
|
+
const gateState=status=>accepted(status==='carried_forward'?'not_applicable':status,['pass','blocked','waived','not_applicable']);
|
|
65
|
+
const gates=(Array.isArray(continuation?.gates)?continuation.gates:[]).slice(0,PROGRESS_GATE_LIMIT).map(gate=>({id:accepted(gate?.id,PROGRESS_GATE_IDS),state:PROGRESS_GATE_IDS.includes(gate?.id)?gateState(gate?.status):'unknown'}));
|
|
62
66
|
const actions=[...new Set((Array.isArray(continuation?.next_actions)?continuation.next_actions:[]).slice(0,64).map(action=>accepted(action?.id,PROGRESS_ACTION_IDS)))];
|
|
63
67
|
const stage=accepted(continuation?.stage,PROGRESS_CONTINUATIONS);
|
|
64
68
|
const preview=typeof packet?.deploy?.preview_url==='string'&&packet.deploy.preview_url?packet.deploy.preview_url:null;
|
|
@@ -5,7 +5,7 @@ import { HTML_NAMESPACE, baseInEffect, documentBases, endsUnclosed, frozenBaseUr
|
|
|
5
5
|
|
|
6
6
|
export const BINDING_SCHEMA = 'campaigns-os-page-binding/v0';
|
|
7
7
|
export const BINDING_LIMITS = Object.freeze({ scripts_per_page: 6, scripts_per_run: 24, script_bytes: 262144, timeout_ms: 5000 });
|
|
8
|
-
const SDK = /^https:\/\/cdn\.jsdelivr\.net\/gh\/NextCommerceCo\/campaign-cart@[^/]+\/(?:dist\/index\.js|public\/loader\.js)(?:\?[^#]*)?$/;
|
|
8
|
+
const SDK = /^https:\/\/cdn\.jsdelivr\.net\/gh\/NextCommerceCo\/campaign-cart@[^/]+\/(?:dist\/(?:index|loader)\.js|public\/loader\.js)(?:\?[^#]*)?$/;
|
|
9
9
|
const str = value => typeof value === 'string' && value.length ? value : null;
|
|
10
10
|
|
|
11
11
|
// The expected credential uses the existing producer resolver. Disagreement
|
|
@@ -188,6 +188,27 @@ export async function observeBinding({ source, page, expected, scriptLoader, par
|
|
|
188
188
|
return result(values[0] === expected.value ? 'match' : 'mismatch', 'credential_comparison');
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// The Campaign Cart SDK sends the page's raw key, with no scheme, as
|
|
192
|
+
// `Authorization` on every Campaigns API call, so the browser pass can see the
|
|
193
|
+
// key a page actually uses, whatever its scripts look like. The value is
|
|
194
|
+
// compared as sent: if the SDK ever adds a scheme, every page reads mismatch
|
|
195
|
+
// rather than passing. The API host is matched by its shape,
|
|
196
|
+
// campaigns.apps.<name>.com, without naming it.
|
|
197
|
+
const SDK_API_HOST = /^campaigns\.apps\.[a-z0-9-]+\.com$/;
|
|
198
|
+
export function isSdkApiRequest(url) {
|
|
199
|
+
try { const parsed = new URL(url); return parsed.protocol === 'https:' && SDK_API_HOST.test(parsed.hostname) && parsed.pathname.startsWith('/api/'); } catch { return false; }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The keys a page sent are compared here and dropped; only the outcome is
|
|
203
|
+
// kept. With nothing sent, or no single expected key, the static read stands
|
|
204
|
+
// (null).
|
|
205
|
+
export function sentBinding(sentKeys, expected) {
|
|
206
|
+
if (!sentKeys.length || expected?.conflict || !expected?.value) return null;
|
|
207
|
+
return { schema_version: BINDING_SCHEMA, observation: 'sdk_request',
|
|
208
|
+
outcome: sentKeys.every(value => value === expected.value) ? 'match' : 'mismatch',
|
|
209
|
+
reason: 'credential_comparison', source_kinds: ['sdk_request'], identity: 'not_verified' };
|
|
210
|
+
}
|
|
211
|
+
|
|
191
212
|
export function bindingAssertion(page, evidence) {
|
|
192
213
|
// No source URLs, values, hashes, masked fragments, or inferred App IDs.
|
|
193
214
|
return { id: `page-binding:${page.page_id}`, family: 'api-metadata', page: page.page_id,
|
package/src/qa-browser.mjs
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
import { ORDER_BUMP_PROBE_INPUT, orderBumpEvidenceScript } from "./qa-order-bump.mjs";
|
|
46
46
|
import { assessPurchaseDataLayer, expectedOrderReferences, purchaseDataLayerAssertion, purchaseDataLayerProbe } from "./qa-purchase-data-layer.mjs";
|
|
47
47
|
import { isBumpRow } from "./commercial-journey.mjs";
|
|
48
|
+
import { bindingAssertion, isSdkApiRequest, sentBinding } from "./qa-binding-evidence.mjs";
|
|
48
49
|
import {
|
|
49
50
|
RESIDUE_PAGE_TYPES,
|
|
50
51
|
demoAssetConfig,
|
|
@@ -962,6 +963,14 @@ async function runPageBrowserChecks(context, page, args, options = {}) {
|
|
|
962
963
|
failure: request.failure()?.errorText || "request failed",
|
|
963
964
|
});
|
|
964
965
|
});
|
|
966
|
+
// The key each SDK request carried. It stays in this function: sentBinding
|
|
967
|
+
// keeps only whether it matched.
|
|
968
|
+
const sentKeys = [];
|
|
969
|
+
const keyReads = [];
|
|
970
|
+
browserPage.on("request", (request) => {
|
|
971
|
+
if (!isSdkApiRequest(request.url())) return;
|
|
972
|
+
keyReads.push(request.headerValue("authorization").then((value) => { if (value !== null) sentKeys.push(value); }, () => {}));
|
|
973
|
+
});
|
|
965
974
|
|
|
966
975
|
// Measurements describe the existing sequence; readiness never shortens it.
|
|
967
976
|
const observationStarted = performance.now();
|
|
@@ -1017,6 +1026,9 @@ async function runPageBrowserChecks(context, page, args, options = {}) {
|
|
|
1017
1026
|
assertions.push(runtimeIssueAssertion(page, "browser-console-errors", actionableConsoleErrors));
|
|
1018
1027
|
}
|
|
1019
1028
|
observation.failed_requests_sampled_after_ms = elapsedMilliseconds(observationStarted);
|
|
1029
|
+
await Promise.all(keyReads);
|
|
1030
|
+
const sent = sentBinding(sentKeys, options.bindingExpected);
|
|
1031
|
+
if (sent) assertions.push(bindingAssertion(page, sent));
|
|
1020
1032
|
if (failedRequests.length) {
|
|
1021
1033
|
assertions.push(assertion({
|
|
1022
1034
|
id: `browser-request-failures:${page.page_id}`,
|
|
@@ -2822,7 +2834,22 @@ async function pricingVisibilityAssertions(browserPage, page, options = {}) {
|
|
|
2822
2834
|
selectors.length ? countVisiblePriceRows(browserPage, selectors) : 0,
|
|
2823
2835
|
countVisiblePriceRows(browserPage, totalSelectors),
|
|
2824
2836
|
]);
|
|
2825
|
-
|
|
2837
|
+
// Nothing priced is visible. When the checkout also has no package
|
|
2838
|
+
// selection of its own and the SDK reports an empty cart, the cart is
|
|
2839
|
+
// filled on an earlier page (a landing link carrying forcePackageId), and
|
|
2840
|
+
// opening the checkout directly shows a state no shopper reaches. The
|
|
2841
|
+
// test-order path enters that cart from the landing page instead.
|
|
2842
|
+
const emptyEntry = visibleCount === 0 && totalVisibleCount === 0
|
|
2843
|
+
? await Promise.all([
|
|
2844
|
+
browserPage.evaluate(() => (typeof window.next?.getCartCount === "function" ? window.next.getCartCount() : null)),
|
|
2845
|
+
browserPage.evaluate(checkoutSelectionSurfaceScript()),
|
|
2846
|
+
]).then(
|
|
2847
|
+
([cart_count, selection_surface]) => ({ cart_count, selection_surface }),
|
|
2848
|
+
// A failed probe leaves the row failed, and says why on the row.
|
|
2849
|
+
(error) => ({ probe_error: trim(error?.message || String(error)).slice(0, 200) }),
|
|
2850
|
+
)
|
|
2851
|
+
: null;
|
|
2852
|
+
return [checkoutPriceVisibilityAssertion({ page, selectors, visibleCount, totalSelectors, totalVisibleCount, emptyEntry })];
|
|
2826
2853
|
}
|
|
2827
2854
|
return [];
|
|
2828
2855
|
}
|
|
@@ -2896,25 +2923,37 @@ function upsellPriceVisibilityAssertion({ page, selectors, visibleCount }) {
|
|
|
2896
2923
|
// no `totalSelectors` ran the bundle-row check alone, and the row says so
|
|
2897
2924
|
// rather than reporting an empty selector list that reads like a check that
|
|
2898
2925
|
// ran and found nothing.
|
|
2899
|
-
|
|
2926
|
+
//
|
|
2927
|
+
// `emptyEntry` ({ cart_count, selection_surface }, or { probe_error }) is read
|
|
2928
|
+
// only when nothing priced is visible. An empty SDK cart (cart_count 0, never
|
|
2929
|
+
// null: null means no SDK was read) on a checkout with no package selection of
|
|
2930
|
+
// its own is not a price the shopper fails to see: the row is skipped with
|
|
2931
|
+
// that reason rather than failed, and only that row carries the probe values.
|
|
2932
|
+
function checkoutPriceVisibilityAssertion({ page, selectors, visibleCount, totalSelectors, totalVisibleCount = 0, emptyEntry = null }) {
|
|
2900
2933
|
const totalChecked = Array.isArray(totalSelectors);
|
|
2901
2934
|
const ok = visibleCount >= 1 || (totalChecked && totalVisibleCount >= 1);
|
|
2935
|
+
const enteredUpstream = !ok && emptyEntry?.cart_count === 0 && emptyEntry?.selection_surface?.count === 0;
|
|
2936
|
+
const counts = totalChecked
|
|
2937
|
+
? `${visibleCount} visible price row(s); ${totalVisibleCount} visible cart-summary total(s)`
|
|
2938
|
+
: `${visibleCount} visible price row(s)`;
|
|
2902
2939
|
return assertion({
|
|
2903
2940
|
id: "pricing.checkout_price_visible",
|
|
2904
2941
|
family: "pricing",
|
|
2905
2942
|
page,
|
|
2906
|
-
status: ok ? STATUS.PASS : STATUS.FAIL,
|
|
2907
|
-
severity: ok ? undefined : SEVERITY.WARN,
|
|
2943
|
+
status: ok ? STATUS.PASS : enteredUpstream ? STATUS.SKIPPED : STATUS.FAIL,
|
|
2944
|
+
severity: ok || enteredUpstream ? undefined : SEVERITY.WARN,
|
|
2908
2945
|
expected: totalChecked
|
|
2909
2946
|
? "at least one visible checkout bundle price row or a visible cart-summary total"
|
|
2910
2947
|
: "at least one visible checkout bundle price row",
|
|
2911
|
-
actual:
|
|
2912
|
-
? `${
|
|
2913
|
-
:
|
|
2948
|
+
actual: enteredUpstream
|
|
2949
|
+
? `${counts}: opened directly, the checkout has an empty cart and no package selection of its own; its cart is filled on an earlier page, and the test order enters it from there`
|
|
2950
|
+
: counts,
|
|
2914
2951
|
evidence: {
|
|
2915
2952
|
selectors,
|
|
2916
2953
|
visible_count: visibleCount,
|
|
2917
2954
|
...(totalChecked ? { total_selectors: totalSelectors, total_visible_count: totalVisibleCount } : {}),
|
|
2955
|
+
...(enteredUpstream ? { cart_count: emptyEntry.cart_count, checkout_selection_surface: emptyEntry.selection_surface } : {}),
|
|
2956
|
+
...(emptyEntry?.probe_error ? { empty_cart_probe_error: emptyEntry.probe_error } : {}),
|
|
2918
2957
|
page_url: page.url,
|
|
2919
2958
|
},
|
|
2920
2959
|
});
|
|
@@ -4962,10 +5001,7 @@ async function waitForCheckoutResult(page, events = null) {
|
|
|
4962
5001
|
if (outcomeUrl.test(String(safePageUrl(page) || ""))) break;
|
|
4963
5002
|
if (events) {
|
|
4964
5003
|
const rejected = rejectedOrderCreateResponse(events);
|
|
4965
|
-
if (rejected)
|
|
4966
|
-
const detail = typeof rejected.body?.detail === "string" ? `: ${trim(rejected.body.detail)}` : "";
|
|
4967
|
-
throw new Error(`order create rejected: HTTP ${rejected.status}${detail}`);
|
|
4968
|
-
}
|
|
5004
|
+
if (rejected) throw new Error(orderCreateRejectionMessage(rejected));
|
|
4969
5005
|
const failed = failedOrderCreateRequest(events);
|
|
4970
5006
|
if (failed) throw new Error(`order create request failed: ${failed.failure || "network failure"}`);
|
|
4971
5007
|
}
|
|
@@ -4979,6 +5015,23 @@ async function waitForCheckoutResult(page, events = null) {
|
|
|
4979
5015
|
// The MOST RECENT create response decides the outcome: SDKs/platforms retry
|
|
4980
5016
|
// transient create failures, so [..., 400, 201] means the retry succeeded and
|
|
4981
5017
|
// the earlier rejection is history, not the result.
|
|
5018
|
+
// The platform refuses an order whose customer, items and total match one it
|
|
5019
|
+
// accepted or is still processing in the last 30 minutes, and reports it in
|
|
5020
|
+
// `payment_details`. QA reuses one test customer, so two runs against the same
|
|
5021
|
+
// campaign at once, or a rerun after an attempt that died mid-submit, trip it.
|
|
5022
|
+
const DUPLICATE_ORDER_PATTERN = /duplicate order/i;
|
|
5023
|
+
const DUPLICATE_ORDER_REMEDY =
|
|
5024
|
+
"duplicate_order: the platform refused an order matching a recent one from the same test customer with the same items and total, " +
|
|
5025
|
+
"for up to 30 minutes. Concurrent QA runs that share a test customer collide. Re-run with a different --test-email-prefix " +
|
|
5026
|
+
"(or --test-email), or wait. The shipping address is not part of the match";
|
|
5027
|
+
|
|
5028
|
+
function orderCreateRejectionMessage(rejected) {
|
|
5029
|
+
const body = rejected?.body;
|
|
5030
|
+
const reason = [body?.detail, body?.payment_details].find((value) => typeof value === "string" && value.trim());
|
|
5031
|
+
const base = `order create rejected: HTTP ${rejected?.status}${reason ? `: ${trim(reason)}` : ""}`;
|
|
5032
|
+
return reason && DUPLICATE_ORDER_PATTERN.test(reason) ? `${base} (${DUPLICATE_ORDER_REMEDY})` : base;
|
|
5033
|
+
}
|
|
5034
|
+
|
|
4982
5035
|
function rejectedOrderCreateResponse(events) {
|
|
4983
5036
|
for (let index = events.responses.length - 1; index >= 0; index -= 1) {
|
|
4984
5037
|
const response = events.responses[index];
|
|
@@ -5444,6 +5497,22 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
|
|
|
5444
5497
|
const displayed = new Set(resolved.displayed_package_ids);
|
|
5445
5498
|
const summaryIds = new Set(resolved.summary_package_ids);
|
|
5446
5499
|
const matchedSummaryIds = new Set();
|
|
5500
|
+
// A checkout order bump persists with is_upsell: true (the platform's
|
|
5501
|
+
// reporting tag), yet the checkout summary displays it. Such a line charges
|
|
5502
|
+
// a displayed row; an is_upsell line the summary does not show is a
|
|
5503
|
+
// post-purchase upsell, out of scope here, never a stray charge.
|
|
5504
|
+
let bumpLineCount = 0;
|
|
5505
|
+
for (const line of (lines || []).filter((entry) => entry?.is_upsell)) {
|
|
5506
|
+
const resolution = events ? campaignPackageResolutionForLine(events, line, {
|
|
5507
|
+
selected_packages,
|
|
5508
|
+
preferred_refs: resolved.summary_package_ids,
|
|
5509
|
+
}) : null;
|
|
5510
|
+
const ref = resolution?.pkg?.ref_id == null ? null : String(resolution.pkg.ref_id);
|
|
5511
|
+
if (ref && summaryIds.has(ref)) {
|
|
5512
|
+
matchedSummaryIds.add(ref);
|
|
5513
|
+
bumpLineCount += 1;
|
|
5514
|
+
}
|
|
5515
|
+
}
|
|
5447
5516
|
const extra = [];
|
|
5448
5517
|
const unresolved = [];
|
|
5449
5518
|
const matchedQuantities = [];
|
|
@@ -5491,6 +5560,7 @@ function reconcileOrderAgainstDisplay({ lines = [], display = null, events = nul
|
|
|
5491
5560
|
displayed_package_ids: [...displayed],
|
|
5492
5561
|
summary_package_ids: [...summaryIds],
|
|
5493
5562
|
non_upsell_line_count: nonUpsellLines.length,
|
|
5563
|
+
...(bumpLineCount ? { order_bump_line_count: bumpLineCount } : {}),
|
|
5494
5564
|
extra,
|
|
5495
5565
|
missing,
|
|
5496
5566
|
matched_quantities: matchedQuantities,
|
|
@@ -5560,7 +5630,7 @@ function orderDisplayParityAssertion(page, planIdentifier, order) {
|
|
|
5560
5630
|
return assertion({
|
|
5561
5631
|
...base,
|
|
5562
5632
|
status: STATUS.PASS,
|
|
5563
|
-
actual: `${reconciliation.non_upsell_line_count} non-upsell line(s) reconciled against ${reconciliation.summary_package_ids.length} displayed package(s)`,
|
|
5633
|
+
actual: `${reconciliation.non_upsell_line_count} non-upsell line(s)${reconciliation.order_bump_line_count ? ` and ${reconciliation.order_bump_line_count} order bump line(s)` : ""} reconciled against ${reconciliation.summary_package_ids.length} displayed package(s)`,
|
|
5564
5634
|
evidence: reconciliation,
|
|
5565
5635
|
});
|
|
5566
5636
|
}
|
|
@@ -6393,7 +6463,7 @@ function sanitizedEvents(events) {
|
|
|
6393
6463
|
responses: events.responses.slice(-20).map((response) => ({
|
|
6394
6464
|
status: response.status,
|
|
6395
6465
|
url: response.url,
|
|
6396
|
-
body: summarizeResponseBody(response.body),
|
|
6466
|
+
body: summarizeResponseBody(response.body, { status: response.status }),
|
|
6397
6467
|
})),
|
|
6398
6468
|
failed: events.failed.slice(-20),
|
|
6399
6469
|
console: events.console.slice(-20),
|
|
@@ -6420,7 +6490,7 @@ function summarizeRequestPostData(value) {
|
|
|
6420
6490
|
}
|
|
6421
6491
|
}
|
|
6422
6492
|
|
|
6423
|
-
function summarizeResponseBody(body) {
|
|
6493
|
+
function summarizeResponseBody(body, { status = null } = {}) {
|
|
6424
6494
|
if (typeof body === "string") return trim(body).slice(0, 1000);
|
|
6425
6495
|
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
|
6426
6496
|
return {
|
|
@@ -6432,6 +6502,12 @@ function summarizeResponseBody(body) {
|
|
|
6432
6502
|
...(body.checkout_url ? { checkout_url: body.checkout_url } : {}),
|
|
6433
6503
|
...(Array.isArray(body.lines) ? { lines: extractReceiptLines(body) } : {}),
|
|
6434
6504
|
...(body.detail ? { detail: body.detail } : {}),
|
|
6505
|
+
// A rejected request names its reason here (for example the
|
|
6506
|
+
// duplicate-order refusal). Kept only on an error response, so a
|
|
6507
|
+
// successful create never carries payment_details into evidence.
|
|
6508
|
+
...(Number(status) >= 400 && typeof body.payment_details === "string"
|
|
6509
|
+
? { payment_details: trim(body.payment_details).slice(0, 300) }
|
|
6510
|
+
: {}),
|
|
6435
6511
|
};
|
|
6436
6512
|
}
|
|
6437
6513
|
|
|
@@ -7789,6 +7865,8 @@ export const __qaBrowserTestHooks = Object.freeze({
|
|
|
7789
7865
|
linePriceDeltaEvidence,
|
|
7790
7866
|
packageMatchesLine,
|
|
7791
7867
|
rejectedOrderCreateResponse,
|
|
7868
|
+
orderCreateRejectionMessage,
|
|
7869
|
+
summarizeResponseBody,
|
|
7792
7870
|
failedOrderCreateRequest,
|
|
7793
7871
|
extractOrderVouchers,
|
|
7794
7872
|
orderDiscountTotal,
|
package/src/qa-node.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "./spec-source-identity.mjs";
|
|
2
|
+
import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD, starterResidueIsExpected } from "./local-preview-policy.mjs";
|
|
2
3
|
import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion, scriptParseAssertion } from './qa-binding-evidence.mjs';
|
|
3
4
|
import { shellToken } from "./shell-token.mjs";
|
|
4
5
|
import { applyQaBuildScope, specForQaScope } from "./qa-build-scope.mjs";
|
|
@@ -481,6 +482,8 @@ async function resolveQaInputs(args, {
|
|
|
481
482
|
packetPath,
|
|
482
483
|
report: checkpointPreflight?.runtimeReport,
|
|
483
484
|
hiddenEagerMediaGate,
|
|
485
|
+
packet,
|
|
486
|
+
baseUrl: stringArg(args["base-url"]),
|
|
484
487
|
});
|
|
485
488
|
const qaWaivers = resolveQaWaivers({ packetPath, report: checkpointPreflight?.runtimeReport });
|
|
486
489
|
const qaScope = applyQaBuildScope(topologies, {
|
|
@@ -593,7 +596,10 @@ function resolvePacketCheckpointPreflight(args, {
|
|
|
593
596
|
waivers: report?.waivers,
|
|
594
597
|
required: true,
|
|
595
598
|
}),
|
|
596
|
-
|
|
599
|
+
applyLocalPreviewToCheckpoint(
|
|
600
|
+
evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
|
|
601
|
+
{ packet, report, baseUrl: stringArg(args["base-url"]) },
|
|
602
|
+
),
|
|
597
603
|
];
|
|
598
604
|
const runtimeReport = reportMatchesPacketIdentity(report, packet) ? report : null;
|
|
599
605
|
return {
|
|
@@ -692,6 +698,8 @@ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
|
|
|
692
698
|
packetPath: preflight.packetPath,
|
|
693
699
|
report: preflight.runtimeReport,
|
|
694
700
|
hiddenEagerMediaGate,
|
|
701
|
+
packet: preflight.packet,
|
|
702
|
+
baseUrl: stringArg(args["base-url"]),
|
|
695
703
|
});
|
|
696
704
|
return {
|
|
697
705
|
themeGate,
|
|
@@ -931,16 +939,18 @@ function resolvePolishGate({
|
|
|
931
939
|
packetPath,
|
|
932
940
|
report: reportOverride = undefined,
|
|
933
941
|
hiddenEagerMediaGate = undefined,
|
|
942
|
+
packet = null,
|
|
943
|
+
baseUrl = null,
|
|
934
944
|
}) {
|
|
935
945
|
const report = reportOverride === undefined
|
|
936
946
|
? loadRuntimeArtifact(packetPath, "assembly-report.json")
|
|
937
947
|
: reportOverride;
|
|
938
|
-
const gate = evaluatePolishGate({
|
|
948
|
+
const gate = applyLocalPreviewToPolishGate(evaluatePolishGate({
|
|
939
949
|
report,
|
|
940
950
|
required: true,
|
|
941
951
|
hiddenEagerMediaGate,
|
|
942
952
|
currentOutputFingerprint: currentBuiltOutputFingerprint(packetPath),
|
|
943
|
-
});
|
|
953
|
+
}), { packet, checkpointGate: hiddenEagerMediaGate, baseUrl });
|
|
944
954
|
gate.scope_source = report ? "assembly_report" : "missing_assembly_report";
|
|
945
955
|
return gate;
|
|
946
956
|
}
|
|
@@ -1044,6 +1054,18 @@ function polishGateAssertion(gate) {
|
|
|
1044
1054
|
},
|
|
1045
1055
|
});
|
|
1046
1056
|
}
|
|
1057
|
+
if (gate.status === CARRIED_FORWARD) {
|
|
1058
|
+
return assertion({
|
|
1059
|
+
id: gate.code,
|
|
1060
|
+
family: "polish_gate",
|
|
1061
|
+
page,
|
|
1062
|
+
status: STATUS.WARN,
|
|
1063
|
+
severity: SEVERITY.WARN,
|
|
1064
|
+
expected: "current structured Polish evidence produced by next-campaigns-polish",
|
|
1065
|
+
actual: carriedForwardMessage(gate),
|
|
1066
|
+
evidence: { ...evidence, reason: gate.reason, carried_forward: gate.carried_forward },
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1047
1069
|
if (gate.status === "not_applicable") {
|
|
1048
1070
|
return assertion({
|
|
1049
1071
|
id: gate.code,
|
|
@@ -1350,6 +1372,9 @@ function hiddenEagerMediaGateAssertion(gate) {
|
|
|
1350
1372
|
if (summary.status === "waived") {
|
|
1351
1373
|
return assertion({ ...common, status: STATUS.WARN, severity: SEVERITY.WARN, waiver: summary.waiver });
|
|
1352
1374
|
}
|
|
1375
|
+
if (summary.status === CARRIED_FORWARD) {
|
|
1376
|
+
return assertion({ ...common, status: STATUS.WARN, severity: SEVERITY.WARN, actual: `${summary.code}: ${carriedForwardMessage(summary)}` });
|
|
1377
|
+
}
|
|
1353
1378
|
if (summary.status === "not_applicable") {
|
|
1354
1379
|
return assertion({ ...common, status: STATUS.SKIPPED });
|
|
1355
1380
|
}
|
|
@@ -2304,11 +2329,23 @@ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCam
|
|
|
2304
2329
|
});
|
|
2305
2330
|
assertions.push(...liveCampaignRefAssertions({ pages: [...livePages.values()], spec: liveSpec, liveCampaign: liveRead }));
|
|
2306
2331
|
if (args.browser === true) {
|
|
2307
|
-
|
|
2332
|
+
const browserAssertions = await runBrowserChecks(resolved.topologies, args, {
|
|
2308
2333
|
brandContract: resolved.brandContract,
|
|
2309
|
-
|
|
2334
|
+
// With no generatable brand theme on the local preview, the starter
|
|
2335
|
+
// template is the design: its residue is a warning (local-preview-policy.mjs).
|
|
2336
|
+
residueSeverity: starterResidueIsExpected(gate, { packet: resolved.packet, baseUrl: resolved.baseUrl })
|
|
2337
|
+
? SEVERITY.WARN
|
|
2338
|
+
: residueSeverityForThemeGate(gate.status),
|
|
2310
2339
|
supportedPaymentMethods: supportedPaymentMethodsFromSpec(resolved.spec),
|
|
2311
|
-
|
|
2340
|
+
bindingExpected,
|
|
2341
|
+
});
|
|
2342
|
+
// A page-binding row from the browser is the key the SDK actually sent;
|
|
2343
|
+
// it takes the place of that page's static read.
|
|
2344
|
+
for (const observed of browserAssertions) {
|
|
2345
|
+
const at = observed.id.startsWith("page-binding:") ? assertions.findIndex((entry) => entry.id === observed.id) : -1;
|
|
2346
|
+
if (at >= 0) assertions[at] = observed;
|
|
2347
|
+
else assertions.push(observed);
|
|
2348
|
+
}
|
|
2312
2349
|
}
|
|
2313
2350
|
|
|
2314
2351
|
const testOrders = await runAnalyticsOrderSequence({ args, resolved, runId, assertions });
|