@nextcommerce/campaigns-os 1.48.0 → 1.52.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +539 -0
  2. package/agents/claude/CLAUDE.md +6 -5
  3. package/agents/codex/AGENTS.md +6 -5
  4. package/agents/copilot/copilot-instructions.md +3 -3
  5. package/agents/cursor/campaigns-os.mdc +3 -3
  6. package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
  7. package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
  8. package/campaign-spec/dist/rules/design-source-shape.js +13 -3
  9. package/campaign-spec/dist/rules/sdk-version.js +2 -1
  10. package/compatibility.json +1 -1
  11. package/contracts/commerce-surface-catalog.json +26 -46
  12. package/contracts/effects.v1.json +254 -2
  13. package/contracts/release-ledger.json +1239 -0
  14. package/contracts/supported-surface.json +4 -4
  15. package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
  16. package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
  17. package/docs/brand-theme-bridge.md +12 -6
  18. package/docs/build-packet.md +101 -12
  19. package/docs/campaign-build-brief.md +25 -1
  20. package/docs/effects.md +6 -0
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +10 -0
  24. package/docs/qa-and-test-orders.md +66 -11
  25. package/docs/runtime-readiness.md +1 -1
  26. package/docs/sdk-storage-compatibility.md +1 -1
  27. package/docs/skills-revision.md +10 -10
  28. package/package.json +1 -1
  29. package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -0
  30. package/schemas/campaigns-os-qa-verdict.v0.schema.json +8 -3
  31. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  32. package/skills/campaign-readback-classification/SKILL.md +3 -3
  33. package/skills/campaign-run-evidence/SKILL.md +3 -3
  34. package/skills/contribution-intake/SKILL.md +3 -3
  35. package/skills/next-campaigns-build/SKILL.md +4 -4
  36. package/skills/next-campaigns-os/SKILL.md +4 -4
  37. package/skills/next-campaigns-os/references/session-intake.md +7 -3
  38. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  39. package/skills/next-campaigns-polish/SKILL.md +5 -4
  40. package/skills/next-campaigns-qa/SKILL.md +6 -5
  41. package/skills.json +10 -10
  42. package/src/adapter-decision-contract.mjs +1 -1
  43. package/src/brand-theme.mjs +25 -2
  44. package/src/build-brief.mjs +68 -21
  45. package/src/built-site-scope.mjs +39 -6
  46. package/src/built-smoke-qc.mjs +1117 -0
  47. package/src/campaign-identity.mjs +36 -2
  48. package/src/cart-placeholders.mjs +730 -0
  49. package/src/cli.mjs +320 -42
  50. package/src/commercial-journey.mjs +65 -4
  51. package/src/commercial-parity.mjs +6 -1
  52. package/src/doctor/checks.mjs +291 -24
  53. package/src/doctor/inspect.mjs +53 -2
  54. package/src/doctor/next-step.mjs +1 -1
  55. package/src/install-mode.mjs +0 -8
  56. package/src/invocation.mjs +5 -2
  57. package/src/local-preview-policy.mjs +1 -1
  58. package/src/local-proof.mjs +4 -1
  59. package/src/polish-browser.mjs +218 -1
  60. package/src/polish-capture.mjs +1 -1
  61. package/src/polish-media-weight.mjs +492 -0
  62. package/src/polish-node.mjs +96 -4
  63. package/src/progress-node.mjs +5 -1
  64. package/src/qa-binding-evidence.mjs +21 -0
  65. package/src/qa-browser.mjs +338 -97
  66. package/src/qa-content-params.mjs +889 -0
  67. package/src/qa-node.mjs +114 -14
  68. package/src/qa-order-bump.mjs +22 -1
  69. package/src/qa-policy-links.mjs +1019 -0
  70. package/src/qa-tracking-params.mjs +1389 -0
  71. package/src/qa-url-privacy.mjs +168 -0
  72. package/src/qc-accept.mjs +446 -0
  73. package/src/qc-check-registry.mjs +83 -0
  74. package/src/qc-results.mjs +1049 -0
  75. package/src/sdk-attribute-index.mjs +71 -0
  76. package/src/sdk-markup.mjs +2 -2
  77. package/src/sdk-storage-compatibility.mjs +63 -3
  78. package/src/source-prep.mjs +37 -7
  79. package/src/stage-record.mjs +356 -36
  80. package/src/theme-gate.mjs +3 -3
@@ -0,0 +1,889 @@
1
+ // Content parameters work at runtime: for each declared
2
+ // analytics.params.content entry and applicable page, `qa run --browser` loads
3
+ // the page twice, each time in a fresh browser context: once without the
4
+ // parameter (baseline) and once with `?<name>=n` (param_n). After the SDK's
5
+ // display pass it checks that the same elements whose data-next-hide or
6
+ // data-next-show refers to the parameter are visible at baseline and hidden
7
+ // with `n`. One row per (param, page): content_param:<page>:<name>, with the
8
+ // verdict assertion qc.content_param:<name>:<page> (family browser-runtime).
9
+ //
10
+ // This module holds the pure expression classifier, the target identity
11
+ // matcher, the result rules and the browser driver. The driver gets its
12
+ // browser contexts and its URL helper from qa-browser.mjs (runBrowserChecks),
13
+ // so the launch, viewport and auth policy stay there.
14
+ //
15
+ // Privacy: expression text is read in memory only. The observation keeps the
16
+ // sha256 of each expression (expr_sha256), its form id and prediction, a
17
+ // structural element_path ("body>main[0]>section[1]"), presence, visibility,
18
+ // stability, readiness outcomes and counts. No expression text, query string
19
+ // or URL is stored; the variant is the label "param_n".
20
+ //
21
+ // rederiveQcResult(observation) is the rule the QC readers call; the producer
22
+ // builds every row through it, so a stored row only stands when its stored
23
+ // observation re-derives to the same result.
24
+ //
25
+ // SDK pin. The supported forms and their predictions follow campaign-cart
26
+ // 12ba5d14: AttributeParser.parseCondition
27
+ // (src/core/base/attribute-parser.ts lines 244-371), the param evaluator
28
+ // (src/features/display/conditional-display/conditional-display.param-conditions.ts
29
+ // lines 10-171) and the show-over-hide choice
30
+ // (src/features/display/conditional-display/conditional-display.enhancer.ts
31
+ // lines 64-75 and 128-140). Both the parser's parseCondition and the param
32
+ // evaluator are unchanged at the v0.4.38 tag, the pin of the vendored
33
+ // attribute index (src/sdk-attribute-index.mjs); the only attribute-parser.ts
34
+ // difference between the two is element-type detection outside
35
+ // parseCondition. Readiness follows the same commit:
36
+ // body[data-next-sdk-loading="false"] (src/core/sdk-initializer/sdk-initializer.ts)
37
+ // or html.next-display-ready, then one macrotask and one animation frame.
38
+ //
39
+ // Every DOM read runs in an isolated world (see "Browser driver"), never in
40
+ // the page's own world.
41
+ import { createHash } from "node:crypto";
42
+
43
+ import { runWithDeadline } from "./deadline.mjs";
44
+ import { aggregateQcResults, buildQcResult, toQaAssertion } from "./qc-results.mjs";
45
+
46
+ export const CONTENT_PARAM_CHECK = "content_param";
47
+ const FAMILY = "browser-runtime";
48
+ const PARAM_VALUE = "n";
49
+
50
+ // Cost bound: at most 8 (param, page) pairs, two loads each, in sequence; per
51
+ // load, navigation up to 20 s and readiness up to 8 s; the whole leg adds at
52
+ // most 60 s.
53
+ export const CONTENT_PARAM_LIMITS = Object.freeze({
54
+ maxPairs: 8,
55
+ navigationMs: 20_000,
56
+ readinessMs: 8_000,
57
+ budgetMs: 60_000,
58
+ });
59
+ // What the leg's budget timer settles with when it fires.
60
+ const BUDGET_CUT = Symbol("budget cut");
61
+
62
+ const READINESS = Object.freeze(["ready", "readiness_timeout", "navigation_failed", "page_not_served"]);
63
+ const VARIANTS = Object.freeze(["baseline", "param_n"]);
64
+ const ATTRS = Object.freeze(["hide", "show"]);
65
+ const FORMS = Object.freeze(["presence", "equals", "not_equals", "has", "is", "unsupported"]);
66
+ const PREDICTIONS = Object.freeze(["hidden", "visible", "unknown"]);
67
+ const LIMITS = Object.freeze(["budget_exhausted", "browser_checks_not_requested"]);
68
+ const BUDGET_EXHAUSTED = "budget_exhausted";
69
+ const NOT_REQUESTED = "browser_checks_not_requested";
70
+ const EXPR_HASH = /^sha256:[a-f0-9]{64}$/;
71
+ const ABSENT = Object.freeze({ present: false, visible: false, stable: false });
72
+
73
+ const isPlainObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
74
+ const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
75
+ const isCount = (value) => Number.isSafeInteger(value) && value >= 0;
76
+
77
+ export const expressionHash = (text) => `sha256:${createHash("sha256").update(String(text)).digest("hex")}`;
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Declared pairs
81
+
82
+ // Every (param, page) pair to check, one per distinct (name, page), in
83
+ // declaration order: each analytics.params.content entry in turn (its name
84
+ // trimmed), then the pages its `pages` lists, or every page of the run's
85
+ // topologies when `pages` is absent. The doctor's static content-param check
86
+ // reads every entry the same way, so a name declared twice covers the pages
87
+ // of both entries. A listed page id that is not one of the run's pages still
88
+ // gets its pair, with no URL: it reads navigation_failed, never silence.
89
+ export function contentParamPairs(spec, topologies) {
90
+ const pages = [];
91
+ for (const topology of Array.isArray(topologies) ? topologies : []) {
92
+ for (const page of Array.isArray(topology?.pages) ? topology.pages : []) {
93
+ const id = page?.page_id == null ? "" : String(page.page_id);
94
+ if (!id || pages.some((known) => known.id === id)) continue;
95
+ pages.push({ id, url: page.url || null });
96
+ }
97
+ }
98
+ const content = spec?.analytics?.params?.content;
99
+ const pairs = [];
100
+ const seen = new Set();
101
+ for (const entry of Array.isArray(content) ? content : []) {
102
+ const name = typeof entry?.name === "string" ? entry.name.trim() : "";
103
+ if (!name) continue;
104
+ const ids = Array.isArray(entry.pages) ? entry.pages.map(String) : pages.map((page) => page.id);
105
+ for (const id of ids) {
106
+ const key = JSON.stringify([name, id]);
107
+ if (!id || seen.has(key)) continue;
108
+ seen.add(key);
109
+ pairs.push({ param: name, page: id, url: pages.find((page) => page.id === id)?.url ?? null });
110
+ }
111
+ }
112
+ return pairs;
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Expression classifier
117
+
118
+ // The reference scan and the classifier read an expression through one
119
+ // tokenizer, so every expression the classifier can classify for a param is
120
+ // one the scan lists. Tokens: identifiers (`[A-Za-z_][\w-]*`; the declared
121
+ // name is one token wherever an identifier could start, whatever characters
122
+ // it holds), quoted strings ('…', "…" or `…`, to the matching quote or the
123
+ // end), the operators ===, !==, ==, !=, >=, <=, && and ||, and any other
124
+ // character on its own. Whitespace (newlines and tabs included) separates
125
+ // tokens; each token keeps its offsets, so a form can require two tokens to
126
+ // touch.
127
+ const IDENT_CHAR = /[\w-]/;
128
+ const IDENT = /[A-Za-z_][\w-]*/y;
129
+ const OPERATORS = Object.freeze(["===", "!==", "==", "!=", ">=", "<=", "&&", "||"]);
130
+
131
+ function tokenize(expression, name) {
132
+ const text = String(expression ?? "");
133
+ const tokens = [];
134
+ let at = 0;
135
+ while (at < text.length) {
136
+ const char = text[at];
137
+ if (/\s/.test(char)) {
138
+ at += 1;
139
+ continue;
140
+ }
141
+ const start = at;
142
+ if (char === "'" || char === '"' || char === "`") {
143
+ const close = text.indexOf(char, at + 1);
144
+ const end = close === -1 ? text.length : close + 1;
145
+ tokens.push({ type: "string", quote: char, value: text.slice(at + 1, close === -1 ? end : close), closed: close !== -1, start, end });
146
+ at = end;
147
+ continue;
148
+ }
149
+ const identStart = at === 0 || !IDENT_CHAR.test(text[at - 1]);
150
+ if (name && identStart && text.startsWith(name, at) && !IDENT_CHAR.test(text[at + name.length] ?? "")) {
151
+ at += name.length;
152
+ tokens.push({ type: "ident", value: name, name: true, start, end: at });
153
+ continue;
154
+ }
155
+ IDENT.lastIndex = at;
156
+ const ident = IDENT.exec(text);
157
+ if (ident) {
158
+ at += ident[0].length;
159
+ tokens.push({ type: "ident", value: ident[0], name: false, start, end: at });
160
+ continue;
161
+ }
162
+ const operator = OPERATORS.find((candidate) => text.startsWith(candidate, at));
163
+ at += operator ? operator.length : 1;
164
+ tokens.push({ type: operator ? "operator" : "punct", value: operator ?? char, start, end: at });
165
+ }
166
+ return tokens;
167
+ }
168
+
169
+ const isRoot = (token) => token?.type === "ident" && (token.value === "param" || token.value === "params");
170
+ const isPunct = (token, value) => token?.type === "punct" && token.value === value;
171
+ const touching = (tokens, from, to) => tokens.slice(from, to).every((token, index) => token.end === tokens[from + index + 1].start);
172
+ // A backslash, a backquote, a bracket or a comment marker: no supported form
173
+ // holds one, wherever it stands.
174
+ const UNRECOGNISED_CHARS = /[\\`[]|\/\*|\*\/|\/\//;
175
+
176
+ // The supported form `expression` is for the param `name`, or null when it is
177
+ // not wholly one of them naming exactly `name`.
178
+ function recognisedForm(expression, name) {
179
+ const text = String(expression ?? "");
180
+ return UNRECOGNISED_CHARS.test(text) ? null : supportedForm(tokenize(text, name), name);
181
+ }
182
+
183
+ // Whether `expression` is wholly a supported form naming one param other
184
+ // than `name`. The candidates are its identifiers and quoted strings.
185
+ function namesOtherParam(expression, name) {
186
+ const candidates = new Set(tokenize(expression).filter((token) => token.type === "ident" || token.type === "string").map((token) => token.value));
187
+ candidates.delete(name);
188
+ return [...candidates].some((other) => other !== "" && recognisedForm(expression, other) !== null);
189
+ }
190
+
191
+ // Whether `expression` refers to the param `name`, by a closed list. It is a
192
+ // target expression only when the classifier wholly recognises it as a
193
+ // supported form naming exactly `name`. It is irrelevant to `name` only when
194
+ // it is wholly a supported form naming another param, or holds no `param`
195
+ // at all (any case, anywhere). Every other expression holding `param` (in a
196
+ // string, a comment, an escape or a template literal included) refers to
197
+ // every declared param and classifies as unsupported: a review member.
198
+ export function referencesParam(expression, name) {
199
+ const text = String(expression ?? "");
200
+ if (recognisedForm(text, name)) return true;
201
+ return /param/i.test(text) && !namesOtherParam(text, name);
202
+ }
203
+
204
+ // A string literal the parser reads as one value: single or double quotes,
205
+ // none of the characters it splits conditions on. An unquoted literal is an
206
+ // identifier, except the booleans, which are no string comparison.
207
+ function literalOf(token) {
208
+ if (token?.type === "string") return token.closed && token.quote !== "`" && !/['"=!<>&|()]/.test(token.value) ? token.value : null;
209
+ if (token?.type === "ident" && /^[A-Za-z_][\w-]*$/.test(token.value) && !["true", "false"].includes(token.value)) return token.value;
210
+ return null;
211
+ }
212
+ // The param's name as a function argument: bare, or in single or double
213
+ // quotes.
214
+ const namesParam = (token, name) => (token?.type === "ident" && token.name)
215
+ || (token?.type === "string" && token.closed && token.quote !== "`" && token.value === name);
216
+
217
+ // The form of one expression's tokens and the condition it reads with
218
+ // `?<name>=n`, or null when it is not a supported form. `param.<name>` and
219
+ // `param.<fn>(` are written without spaces; whitespace elsewhere is free.
220
+ function supportedForm(tokens, name) {
221
+ const member = tokens.length >= 3 && isRoot(tokens[0]) && isPunct(tokens[1], ".") && tokens[2].type === "ident" && tokens[2].name && touching(tokens, 0, 2);
222
+ if (member && tokens.length === 3) return { form: "presence", condition: true };
223
+ if (member && tokens.length === 5 && tokens[3].type === "operator") {
224
+ const literal = literalOf(tokens[4]);
225
+ if (literal === null) return null;
226
+ if (tokens[3].value === "==" || tokens[3].value === "===") return { form: "equals", condition: literal === PARAM_VALUE };
227
+ if (tokens[3].value === "!=") return { form: "not_equals", condition: literal !== PARAM_VALUE };
228
+ return null;
229
+ }
230
+ const call = tokens.length >= 6 && isRoot(tokens[0]) && isPunct(tokens[1], ".") && tokens[2].type === "ident" && isPunct(tokens[3], "(")
231
+ && touching(tokens, 0, 3) && namesParam(tokens[4], name) && isPunct(tokens.at(-1), ")");
232
+ if (!call) return null;
233
+ if (tokens.length === 6 && ["has", "exists"].includes(tokens[2].value)) return { form: "has", condition: true };
234
+ if (tokens.length === 8 && ["is", "equals"].includes(tokens[2].value) && isPunct(tokens[5], ",")) {
235
+ const literal = literalOf(tokens[6]);
236
+ return literal === null ? null : { form: "is", condition: literal === PARAM_VALUE };
237
+ }
238
+ return null;
239
+ }
240
+
241
+ // The form of one expression that refers to the param, and what it predicts
242
+ // for the element with `?<name>=n`: {form, prediction, mixed_cart}. Only the
243
+ // single-term forms below are supported; everything else (compound, `!`,
244
+ // parentheses, `!==`, `>=`, `<=`, numeric comparisons, more operators, a
245
+ // condition that also reads the cart) is `unsupported` with an `unknown`
246
+ // prediction.
247
+ export function classifyExpression(attr, expression, name) {
248
+ // Any `cart.` in the text, quoted or not, reads as a cart condition.
249
+ const mixedCart = /(?:^|[^\w.])cart\./.test(String(expression ?? ""));
250
+ const supported = recognisedForm(expression, name);
251
+ if (!supported || mixedCart) return { form: "unsupported", prediction: "unknown", mixed_cart: mixedCart };
252
+ // data-next-hide hides when its condition holds; data-next-show hides when
253
+ // its condition fails.
254
+ const hidden = attr === "hide" ? supported.condition : !supported.condition;
255
+ return { form: supported.form, prediction: hidden ? "hidden" : "visible", mixed_cart: false };
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Result rules
260
+
261
+ const isTarget = (reference) => reference.form !== "unsupported" && reference.prediction === "hidden" && reference.both_attributes !== true;
262
+ const reviewReason = (reference) => {
263
+ if (reference.both_attributes === true) return "show_overrides_hide";
264
+ if (reference.form === "unsupported") return reference.mixed_cart === true ? "mixed_param_cart" : "unsupported_expression";
265
+ return null;
266
+ };
267
+
268
+ function validReference(reference) {
269
+ if (!isPlainObject(reference)) return false;
270
+ if (!ATTRS.includes(reference.attr) || !EXPR_HASH.test(reference.expr_sha256 || "")) return false;
271
+ if (!FORMS.includes(reference.form) || !PREDICTIONS.includes(reference.prediction)) return false;
272
+ if ((reference.form === "unsupported") !== (reference.prediction === "unknown")) return false;
273
+ if (reference.element_path !== undefined && !isNonEmptyString(reference.element_path)) return false;
274
+ if (![undefined, true, false].includes(reference.both_attributes) || ![undefined, true, false].includes(reference.mixed_cart)) return false;
275
+ if (reference.mixed_cart === true && reference.form !== "unsupported") return false;
276
+ // A review member is keyed by its element, so it needs one.
277
+ return reviewReason(reference) === null || isNonEmptyString(reference.element_path);
278
+ }
279
+
280
+ const validRead = (read) => isPlainObject(read) && ["present", "visible", "stable"].every((field) => typeof read[field] === "boolean");
281
+
282
+ function validTarget(target) {
283
+ return isPlainObject(target)
284
+ && ATTRS.includes(target.attr)
285
+ && EXPR_HASH.test(target.expr_sha256 || "")
286
+ && isNonEmptyString(target.element_path)
287
+ && VARIANTS.every((variant) => validRead(target[variant]))
288
+ && (target.baseline.present || target.param_n.present);
289
+ }
290
+
291
+ // The observation as the rules read it, or null when it is not one they can.
292
+ function readObservation(observation) {
293
+ if (!isPlainObject(observation)) return null;
294
+ const { param, page, readiness, references, targets, counts } = observation;
295
+ if (!isNonEmptyString(param) || !isNonEmptyString(page)) return null;
296
+ if (!isPlainObject(readiness) || !Array.isArray(references) || !Array.isArray(targets) || !isPlainObject(counts)) return null;
297
+ const limit = observation.limit ?? null;
298
+ const pageCoverage = observation.page_coverage ?? null;
299
+ if (limit !== null && !LIMITS.includes(limit)) return null;
300
+ if (pageCoverage !== null && pageCoverage !== BUDGET_EXHAUSTED) return null;
301
+ if (limit !== null) {
302
+ // Nothing was loaded for a pair that was cut or not requested.
303
+ const empty = VARIANTS.every((variant) => readiness[variant] === null && counts[variant] === null);
304
+ if (!empty || references.length || targets.length || pageCoverage !== null) return null;
305
+ return { param, page, limit, pageCoverage, readiness, references, targets, counts };
306
+ }
307
+ if (!VARIANTS.every((variant) => READINESS.includes(readiness[variant]))) return null;
308
+ if (!VARIANTS.every((variant) => counts[variant] === null || isCount(counts[variant]))) return null;
309
+ if (!references.every(validReference) || !targets.every(validTarget)) return null;
310
+ // The target list and the target references describe the same elements:
311
+ // every target is a supported reference predicted hidden, and every such
312
+ // reference has its target listed. A reference without an element_path
313
+ // resolves by (attr, expr_sha256).
314
+ const targetRefs = references.filter(isTarget);
315
+ if (!targets.every((target) => targetRefs.some((reference) => resolvesTo(reference, target)))) return null;
316
+ if (!targetRefs.every((reference) => targets.some((target) => resolvesTo(reference, target)))) return null;
317
+ const identities = targets.map((target) => `${target.attr}|${target.expr_sha256}|${target.element_path}`);
318
+ if (new Set(identities).size !== identities.length) return null;
319
+ return { param, page, limit, pageCoverage, readiness, references, targets, counts };
320
+ }
321
+
322
+ const resolvesTo = (reference, target) => reference.attr === target.attr
323
+ && reference.expr_sha256 === target.expr_sha256
324
+ && (reference.element_path === undefined || reference.element_path === target.element_path);
325
+
326
+ // Whether each context's count accounts for its targets: no target present in
327
+ // a context goes uncounted, and no count exceeds the targets listed. Where
328
+ // every target is present in both contexts (the only way to pass), this makes
329
+ // each count equal the number of target entries.
330
+ const countsReconcile = (targets, counts) => VARIANTS.every((variant) => {
331
+ const present = targets.filter((target) => target[variant].present).length;
332
+ return present <= counts[variant] && counts[variant] <= targets.length;
333
+ });
334
+
335
+ const projectReference = (reference) => ({
336
+ attr: reference.attr,
337
+ expr_sha256: reference.expr_sha256,
338
+ form: reference.form,
339
+ prediction: reference.prediction,
340
+ element_path: reference.element_path ?? null,
341
+ both_attributes: reference.both_attributes === true,
342
+ mixed_cart: reference.mixed_cart === true,
343
+ });
344
+ const projectTarget = (target) => ({
345
+ attr: target.attr,
346
+ expr_sha256: target.expr_sha256,
347
+ element_path: target.element_path,
348
+ baseline: { present: target.baseline.present, visible: target.baseline.visible, stable: target.baseline.stable },
349
+ param_n: { present: target.param_n.present, visible: target.param_n.visible, stable: target.param_n.stable },
350
+ });
351
+ const identityOf = (entry) => JSON.stringify([entry.attr, entry.expr_sha256, entry.element_path ?? null, entry.form ?? null, entry.both_attributes ?? null]);
352
+ const byIdentity = (a, b) => identityOf(a).localeCompare(identityOf(b));
353
+
354
+ // One target's member: identity first (present and stable in both contexts),
355
+ // then visible at baseline, then hidden with `n`.
356
+ function targetMember(target, key) {
357
+ const matched = VARIANTS.every((variant) => target[variant].present && target[variant].stable);
358
+ if (!matched) return { key, result: "review", reason_code: "target_identity_unresolved" };
359
+ if (!target.baseline.visible) return { key, result: "review", reason_code: "already_hidden_at_baseline" };
360
+ if (target.param_n.visible) return { key, result: "warning", reason_code: "target_still_visible" };
361
+ return { key, result: "pass", reason_code: null };
362
+ }
363
+
364
+ function decide(read) {
365
+ const { readiness, references, targets, counts, limit } = read;
366
+ if (limit === NOT_REQUESTED) return { result: "excluded", reason_code: NOT_REQUESTED };
367
+ if (limit === BUDGET_EXHAUSTED) return { result: "unexercised", reason_code: BUDGET_EXHAUSTED };
368
+ for (const variant of VARIANTS) {
369
+ if (readiness[variant] !== "ready") return { result: "unexercised", reason_code: readiness[variant] };
370
+ }
371
+ // Both contexts are ready: from here on the DOM was read in both.
372
+ if (!VARIANTS.every((variant) => isCount(counts[variant]))) return null;
373
+ if (!references.length) return targets.length || counts.baseline || counts.param_n ? null : { result: "review", reason_code: "no_resolvable_target" };
374
+ if (counts.baseline !== counts.param_n) return { result: "review", reason_code: "target_count_changed" };
375
+ if (!countsReconcile(targets, counts)) return null;
376
+ const reviews = references.filter((reference) => reviewReason(reference) !== null);
377
+ if (!reviews.length && !references.some(isTarget)) {
378
+ // Supported handlers only, none of which hides anything with `n`.
379
+ return { result: "warning", reason_code: "not_toggled_by_n" };
380
+ }
381
+ // Member keys are "<attr>:<element_path>", unique per kind: a review
382
+ // reference at a target's attribute and path (its expression differs
383
+ // between the two contexts) is its own member, keyed with a ":review"
384
+ // suffix, so it is never folded into the target's. Within a kind, a shared
385
+ // key keeps the result that ranks first by the 1.0 precedence.
386
+ const members = new Map();
387
+ const add = (key, member) => {
388
+ const known = members.get(key);
389
+ if (!known || MEMBER_RANK.indexOf(member.result) < MEMBER_RANK.indexOf(known.result)) members.set(key, { key, ...member });
390
+ };
391
+ const targetKeys = new Set(targets.map((target) => `${target.attr}:${target.element_path}`));
392
+ for (const target of targets) {
393
+ const key = `${target.attr}:${target.element_path}`;
394
+ add(key, targetMember(target, key));
395
+ }
396
+ for (const reference of reviews) {
397
+ const key = `${reference.attr}:${reference.element_path}`;
398
+ add(targetKeys.has(key) ? `${key}:review` : key, { result: "review", reason_code: reviewReason(reference) });
399
+ }
400
+ return { members: [...members.values()] };
401
+ }
402
+
403
+ // The 1.0 aggregation precedence, used to merge members that share a key.
404
+ const MEMBER_RANK = Object.freeze(["warning", "review", "unexercised", "pass"]);
405
+
406
+ // Re-derives one row from its stored observation, or returns null when the
407
+ // observation is not one this rule can read (the reader then reports
408
+ // evidence_not_reproducible).
409
+ export function rederiveQcResult(observation) {
410
+ try {
411
+ return derive(observation);
412
+ } catch {
413
+ return null;
414
+ }
415
+ }
416
+
417
+ function derive(observation) {
418
+ const read = readObservation(observation);
419
+ if (!read) return null;
420
+ const decided = decide(read);
421
+ if (!decided) return null;
422
+ // A page where the budget cut another pair is a capped page: every result
423
+ // kept for it carries the page_coverage member, so it is never
424
+ // accept-eligible and never pass.
425
+ const capReason = read.pageCoverage;
426
+ let aggregate;
427
+ if (decided.members) {
428
+ aggregate = aggregateQcResults(decided.members, { capReason });
429
+ } else {
430
+ const members = capReason && decided.result !== "excluded" ? [{ key: "page_coverage", result: "unexercised", reason_code: capReason }] : [];
431
+ aggregate = { result: decided.result, reason_code: decided.reason_code, members, accept_eligible: decided.result === "warning" && !members.length };
432
+ }
433
+ const subject = { check: CONTENT_PARAM_CHECK, page: read.page, key: read.param };
434
+ const coverage = aggregate.result === "excluded"
435
+ ? { observed: 0, expected: null, limits: [] }
436
+ : decided.members || aggregate.result !== "unexercised"
437
+ ? { observed: 1, expected: 1, limits: capReason ? [capReason] : [] }
438
+ : { observed: 0, expected: 1, limits: [aggregate.reason_code] };
439
+ return {
440
+ check: CONTENT_PARAM_CHECK,
441
+ subject,
442
+ result: aggregate.result,
443
+ reason_code: aggregate.result === "pass" ? null : aggregate.reason_code,
444
+ members: aggregate.members,
445
+ accept_eligible: aggregate.accept_eligible,
446
+ coverage,
447
+ state: {
448
+ reason_code: aggregate.result === "pass" ? null : aggregate.reason_code,
449
+ readiness: { baseline: read.readiness.baseline, param_n: read.readiness.param_n },
450
+ counts: { baseline: read.counts.baseline, param_n: read.counts.param_n },
451
+ references: read.references.map(projectReference).sort(byIdentity),
452
+ targets: read.targets.map(projectTarget).sort(byIdentity),
453
+ limit: read.limit,
454
+ page_coverage: read.pageCoverage,
455
+ },
456
+ };
457
+ }
458
+
459
+ // ---------------------------------------------------------------------------
460
+ // Rows and assertions
461
+
462
+ export function contentParamQcRow(observation, { measuredAt = new Date().toISOString() } = {}) {
463
+ const derived = rederiveQcResult(observation);
464
+ if (!derived) return null;
465
+ return buildQcResult({
466
+ check: derived.check,
467
+ leg: "qa",
468
+ subject: derived.subject,
469
+ result: derived.result,
470
+ reason_code: derived.reason_code,
471
+ state: derived.state,
472
+ observation,
473
+ members: derived.members,
474
+ accept_eligible: derived.accept_eligible,
475
+ coverage: derived.coverage,
476
+ measured_at: measuredAt,
477
+ });
478
+ }
479
+
480
+ // The row's verdict assertion, under the id qc.content_param:<name>:<page>.
481
+ // It names its row in evidence.qc.result_id, which is how readers pair them.
482
+ export function contentParamQaAssertion(row) {
483
+ return { ...toQaAssertion(row, { family: FAMILY }), id: `qc.${CONTENT_PARAM_CHECK}:${row.subject.key}:${row.subject.page}` };
484
+ }
485
+
486
+ const unloadedObservation = (pair, limit, pageCoverage = null) => ({
487
+ param: pair.param,
488
+ page: pair.page,
489
+ readiness: { baseline: null, param_n: null },
490
+ references: [],
491
+ targets: [],
492
+ counts: { baseline: null, param_n: null },
493
+ limit,
494
+ page_coverage: pageCoverage,
495
+ });
496
+
497
+ // The run-scope rows of a QA run without browser checks: one
498
+ // excluded / browser_checks_not_requested row per declared (param, page).
499
+ export function contentParamNotRequestedRows(spec, topologies, { measuredAt = new Date().toISOString() } = {}) {
500
+ return contentParamPairs(spec, topologies)
501
+ .map((pair) => contentParamQcRow(unloadedObservation(pair, NOT_REQUESTED), { measuredAt }))
502
+ .filter(Boolean);
503
+ }
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // Browser driver
507
+
508
+ // Every DOM read the check makes runs in an isolated world of the page,
509
+ // reached through the Chrome DevTools Protocol. The page's own scripts share
510
+ // the DOM with that world but not its globals or prototypes, so a page that
511
+ // overrides getComputedStyle, getAttribute, querySelectorAll or any other DOM
512
+ // method cannot change what is read. The world's script is installed before
513
+ // navigation and runs at document start. Once the document is parsed it
514
+ // watches for the readiness signal and, when the signal appears, captures the
515
+ // live [data-next-hide] / [data-next-show] nodes and their attribute values
516
+ // (template content is not part of the document, so it is never listed).
517
+ // One macrotask and one animation frame later it re-reads those same nodes:
518
+ // a node is stable when it is still connected at the same path with the same
519
+ // attribute values, and visible per the contract's rule.
520
+ //
521
+ // Every reading carries the URL its document was loaded at (after any
522
+ // redirect) and the status of the response that document was served with
523
+ // (its navigation timing entry's responseStatus, the status a service worker
524
+ // answered with included), both taken at document start in the world of the
525
+ // document that is read. The driver uses a reading only when that URL has the
526
+ // requested origin and path and, with `?<name>=n`, carries `<name>=n` exactly
527
+ // once (the baseline: no `<name>`), and that status is known and below 400. A
528
+ // redirect that drops the parameter or lands on another path, a page that
529
+ // replaces itself with another document before it is read (the same URL
530
+ // included: the replacement reports its own status, never the one `goto`
531
+ // saw), or a document served with an error status, no status or status 0 is
532
+ // not the page that was asked for: the load reads page_not_served. The URL and
533
+ // status are compared in memory and never stored.
534
+ //
535
+ // The world's script carries its own copy of the element path rule: "body",
536
+ // then ">tag[index]" per step, index 0-based among same-tag element siblings
537
+ // (null for a detached element).
538
+ const WORLD_NAME = "campaigns-os-content-params";
539
+ const WORLD_KEY = "__campaignsOsContentParams";
540
+ // Reading covers the readiness wait, then one macrotask and one frame; this
541
+ // is the allowance for that frame and the protocol round trips.
542
+ const READ_MARGIN_MS = 1_000;
543
+
544
+ function installReader(key) {
545
+ const url = location.href;
546
+ const status = performance.getEntriesByType("navigation")[0]?.responseStatus ?? null;
547
+ const pathOf = (element) => {
548
+ const steps = [];
549
+ let node = element;
550
+ while (node && node.nodeType === 1 && node !== document.body && node !== document.documentElement) {
551
+ const parent = node.parentElement;
552
+ if (!parent) return null;
553
+ const index = Array.prototype.filter.call(parent.children, (sibling) => sibling.tagName === node.tagName).indexOf(node);
554
+ steps.unshift(`${node.tagName.toLowerCase()}[${index}]`);
555
+ node = parent;
556
+ }
557
+ return [node === document.body ? "body" : "html", ...steps].join(">");
558
+ };
559
+ const signalled = () => document.readyState !== "loading"
560
+ && (document.body?.getAttribute("data-next-sdk-loading") === "false"
561
+ || Boolean(document.documentElement?.classList.contains("next-display-ready")));
562
+ const readNode = ({ element, hide, show, path }) => {
563
+ const connected = element.isConnected;
564
+ const stable = connected
565
+ && pathOf(element) === path
566
+ && element.getAttribute("data-next-hide") === hide
567
+ && element.getAttribute("data-next-show") === show;
568
+ let visible = false;
569
+ if (connected) {
570
+ const style = getComputedStyle(element);
571
+ visible = style.display !== "none" && style.visibility !== "hidden" && element.getClientRects().length > 0;
572
+ }
573
+ return { hide, show, path, stable, visible };
574
+ };
575
+ let settle;
576
+ const reading = new Promise((resolve) => {
577
+ settle = resolve;
578
+ });
579
+ let captured = false;
580
+ const observer = new MutationObserver(() => check());
581
+ const check = () => {
582
+ if (captured || !signalled()) return;
583
+ captured = true;
584
+ observer.disconnect();
585
+ document.removeEventListener("DOMContentLoaded", check);
586
+ const nodes = Array.from(document.querySelectorAll("[data-next-hide], [data-next-show]"), (element) => ({
587
+ element,
588
+ hide: element.getAttribute("data-next-hide"),
589
+ show: element.getAttribute("data-next-show"),
590
+ path: pathOf(element),
591
+ }));
592
+ setTimeout(() => requestAnimationFrame(() => settle(nodes.map(readNode))), 0);
593
+ };
594
+ observer.observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "data-next-sdk-loading"] });
595
+ document.addEventListener("DOMContentLoaded", check);
596
+ check();
597
+ Object.defineProperty(globalThis, key, {
598
+ value: Object.freeze({
599
+ // The reading, or { ready: false } when the signal has not appeared
600
+ // within `timeoutMs`; either way with the document's URL and status.
601
+ read: (timeoutMs) => new Promise((resolve) => {
602
+ const timer = setTimeout(() => resolve({ url, status, ready: false, elements: [] }), timeoutMs);
603
+ reading.then((elements) => {
604
+ clearTimeout(timer);
605
+ resolve({ url, status, ready: true, elements });
606
+ });
607
+ }),
608
+ }),
609
+ });
610
+ }
611
+
612
+ const READER_SOURCE = `(${installReader})(${JSON.stringify(WORLD_KEY)});`;
613
+
614
+ // A protocol session on the page with the reader installed for every
615
+ // document it loads from here on.
616
+ async function openReader(context, page) {
617
+ const session = await context.newCDPSession(page);
618
+ await session.send("Page.enable");
619
+ await session.send("Page.addScriptToEvaluateOnNewDocument", { source: READER_SOURCE, worldName: WORLD_NAME });
620
+ return session;
621
+ }
622
+
623
+ // The reading of the main frame's current document, from the reader's world.
624
+ async function readDocument(session, timeoutMs) {
625
+ const { frameTree } = await session.send("Page.getFrameTree");
626
+ const { executionContextId } = await session.send("Page.createIsolatedWorld", { frameId: frameTree.frame.id, worldName: WORLD_NAME });
627
+ const { result, exceptionDetails } = await session.send("Runtime.evaluate", {
628
+ contextId: executionContextId,
629
+ expression: `globalThis[${JSON.stringify(WORLD_KEY)}].read(${Number(timeoutMs)})`,
630
+ awaitPromise: true,
631
+ returnByValue: true,
632
+ });
633
+ if (exceptionDetails) throw new Error("The document could not be read.");
634
+ return result?.value;
635
+ }
636
+
637
+ const isAttrValue = (value) => value === null || typeof value === "string";
638
+ // A reading as the driver uses it, or null when it is not a complete one.
639
+ function readElements(reading) {
640
+ if (!isPlainObject(reading) || reading.ready !== true || !Array.isArray(reading.elements)) return null;
641
+ const complete = reading.elements.every((element) => isPlainObject(element)
642
+ && isAttrValue(element.hide) && isAttrValue(element.show) && isAttrValue(element.path)
643
+ && typeof element.stable === "boolean" && typeof element.visible === "boolean");
644
+ return complete ? reading.elements : null;
645
+ }
646
+
647
+ // The param's references among the described elements of one context.
648
+ function contextReferences(described, name) {
649
+ const references = [];
650
+ described.forEach((element, index) => {
651
+ if (!element || typeof element.path !== "string") return;
652
+ // An element that carries both attributes (even an empty one) reads review:
653
+ // the SDK reads data-next-show when it is set and ignores data-next-hide.
654
+ const both = typeof element.show === "string" && typeof element.hide === "string";
655
+ for (const attr of ATTRS) {
656
+ const expression = element[attr];
657
+ if (!expression || !referencesParam(expression, name)) continue;
658
+ const { form, prediction, mixed_cart } = classifyExpression(attr, expression, name);
659
+ references.push({ index, attr, expr_sha256: expressionHash(expression), form, prediction, element_path: element.path, both_attributes: both, mixed_cart });
660
+ }
661
+ });
662
+ return references;
663
+ }
664
+
665
+ // Whether the document a reading came from is the one requested for
666
+ // `variant`: its URL has the requested origin and path, and carries `<name>=n`
667
+ // once (param_n) or no `<name>` (baseline).
668
+ function servesRequested(documentUrl, requestedUrl, name, variant) {
669
+ try {
670
+ const loaded = new URL(documentUrl);
671
+ const requested = new URL(requestedUrl);
672
+ if (loaded.origin !== requested.origin || loaded.pathname !== requested.pathname) return false;
673
+ const values = loaded.searchParams.getAll(name);
674
+ return variant === "param_n" ? values.length === 1 && values[0] === PARAM_VALUE : values.length === 0;
675
+ } catch {
676
+ return false;
677
+ }
678
+ }
679
+
680
+ // Whether the read document's own response status says it was served.
681
+ const servedStatus = (status) => Number.isInteger(status) && status > 0 && status < 400;
682
+
683
+ const withinDeadline = (operation, timeoutMs) => runWithDeadline(operation, { timeoutMs });
684
+
685
+ // Whether the leg's budget has ended: cut by the budget timer, or the clock
686
+ // already at or past the deadline (a late timer is no evidence of time left).
687
+ // There is time left only while the clock reads before the deadline.
688
+ function lapsed(run) {
689
+ if (!run.cancelled && run.now() >= run.deadline) run.cancelled = true;
690
+ return run.cancelled;
691
+ }
692
+
693
+ // Closes a context without waiting on it beyond the returned promise, which
694
+ // never rejects.
695
+ function closeQuietly(context) {
696
+ try {
697
+ return Promise.resolve(context.close()).catch(() => {});
698
+ } catch {
699
+ return Promise.resolve();
700
+ }
701
+ }
702
+
703
+ // One load in a fresh context. Never throws: every failure is a readiness
704
+ // outcome. `run.cancelled` is set when the budget ends; no new context opens
705
+ // after that, and the contexts still open are closed by the budget cut. The
706
+ // clock is checked after each awaited step, so no step starts past the budget.
707
+ async function loadVariant(url, name, variant, run) {
708
+ if (!url) return { readiness: "navigation_failed" };
709
+ if (lapsed(run)) return { readiness: null };
710
+ let context = null;
711
+ try {
712
+ context = await run.newContext();
713
+ run.open.add(context);
714
+ if (lapsed(run)) return { readiness: null };
715
+ const page = await context.newPage();
716
+ const session = await openReader(context, page);
717
+ if (lapsed(run)) return { readiness: null };
718
+ let response = null;
719
+ try {
720
+ response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: run.limits.navigationMs });
721
+ } catch {
722
+ return { readiness: lapsed(run) ? null : "navigation_failed" };
723
+ }
724
+ if (lapsed(run)) return { readiness: null };
725
+ const status = response?.status?.() ?? null;
726
+ if (!response || !Number.isFinite(status) || status >= 400) return { readiness: "page_not_served" };
727
+ // A reading from a document other than the one requested, or from a
728
+ // document whose own response was not served, is no reading of the
729
+ // requested page. A page that does not signal readiness in time,
730
+ // navigates away or stalls before it can be read, or returns an
731
+ // incomplete reading did not reach a readable ready state.
732
+ let reading = null;
733
+ try {
734
+ reading = await withinDeadline(() => readDocument(session, run.limits.readinessMs), run.limits.readinessMs + READ_MARGIN_MS);
735
+ } catch {
736
+ reading = null;
737
+ }
738
+ if (lapsed(run)) return { readiness: null };
739
+ if (isPlainObject(reading) && (!servesRequested(reading.url, url, name, variant) || !servedStatus(reading.status))) return { readiness: "page_not_served" };
740
+ const elements = readElements(reading);
741
+ if (!elements) return { readiness: "readiness_timeout" };
742
+ const references = contextReferences(elements, name);
743
+ const targets = references.filter(isTarget);
744
+ return {
745
+ readiness: "ready",
746
+ references,
747
+ reads: new Map(targets.map((target) => [identityOf(target), { present: true, visible: elements[target.index].visible, stable: elements[target.index].stable }])),
748
+ };
749
+ } catch {
750
+ return { readiness: lapsed(run) ? null : "navigation_failed" };
751
+ } finally {
752
+ if (context) {
753
+ run.open.delete(context);
754
+ await closeQuietly(context);
755
+ }
756
+ }
757
+ }
758
+
759
+ // The baseline URL carries no `?<name>=`; the variant sets it to `n`.
760
+ function baselineUrl(url, name) {
761
+ try {
762
+ const parsed = new URL(url);
763
+ if (!parsed.searchParams.has(name)) return url;
764
+ parsed.searchParams.delete(name);
765
+ return parsed.toString();
766
+ } catch {
767
+ return url;
768
+ }
769
+ }
770
+
771
+ // The observation of a pair whose two loads both completed.
772
+ function pairObservation(pair, baseline, paramN) {
773
+ const readiness = { baseline: baseline.readiness, param_n: paramN.readiness };
774
+ if (baseline.readiness !== "ready" || paramN.readiness !== "ready") {
775
+ return { param: pair.param, page: pair.page, readiness, references: [], targets: [], counts: { baseline: null, param_n: null }, limit: null, page_coverage: null };
776
+ }
777
+ const references = [];
778
+ const seen = new Set();
779
+ for (const reference of [...baseline.references, ...paramN.references]) {
780
+ const identity = identityOf(reference);
781
+ if (seen.has(identity)) continue;
782
+ seen.add(identity);
783
+ const { index: _index, ...stored } = reference;
784
+ references.push(stored);
785
+ }
786
+ const targets = references.filter(isTarget).map((reference) => ({
787
+ attr: reference.attr,
788
+ expr_sha256: reference.expr_sha256,
789
+ element_path: reference.element_path,
790
+ baseline: { ...(baseline.reads.get(identityOf(reference)) ?? ABSENT) },
791
+ param_n: { ...(paramN.reads.get(identityOf(reference)) ?? ABSENT) },
792
+ }));
793
+ return {
794
+ param: pair.param,
795
+ page: pair.page,
796
+ readiness,
797
+ references,
798
+ targets,
799
+ counts: { baseline: baseline.reads.size, param_n: paramN.reads.size },
800
+ limit: null,
801
+ page_coverage: null,
802
+ };
803
+ }
804
+
805
+ // Both loads of one pair inside what is left of the leg's budget. A pair the
806
+ // budget cuts (whether or not a load had started) reads budget_exhausted, and
807
+ // so does a pair that completes at or past the deadline, whatever its readings
808
+ // say. A pair starts only while the clock reads before the deadline. The pair
809
+ // races `timeUp`, the leg's budget timer: once it fires the pair is left as it
810
+ // is, never awaited further.
811
+ async function runPair(pair, run, { withQueryParam, timeUp }) {
812
+ if (lapsed(run)) return null;
813
+ const work = (async () => {
814
+ const baseline = await loadVariant(pair.url ? baselineUrl(pair.url, pair.param) : null, pair.param, "baseline", run);
815
+ const paramN = await loadVariant(pair.url ? withQueryParam(baselineUrl(pair.url, pair.param), pair.param, PARAM_VALUE) : null, pair.param, "param_n", run);
816
+ return { baseline, paramN };
817
+ })();
818
+ work.catch(() => {});
819
+ const outcome = await Promise.race([work, timeUp]);
820
+ if (outcome === BUDGET_CUT || lapsed(run)) return null;
821
+ const { baseline, paramN } = outcome;
822
+ if (baseline.readiness === null || paramN.readiness === null) return null;
823
+ // An observation the rules cannot read (its counts, targets and
824
+ // references do not reconcile) is no reading of a ready state, so it is
825
+ // kept as readiness_timeout in both contexts rather than dropped.
826
+ const observation = pairObservation(pair, baseline, paramN);
827
+ if (rederiveQcResult(observation)) return observation;
828
+ return { ...observation, readiness: { baseline: "readiness_timeout", param_n: "readiness_timeout" }, references: [], targets: [], counts: { baseline: null, param_n: null } };
829
+ }
830
+
831
+ // The limits a run uses: each field of `limits` that is a positive number
832
+ // (maxPairs: zero or more), else CONTENT_PARAM_LIMITS' field.
833
+ function runLimits(limits) {
834
+ return Object.fromEntries(Object.entries(CONTENT_PARAM_LIMITS).map(([field, fallback]) => {
835
+ const value = isPlainObject(limits) ? limits[field] : undefined;
836
+ const usable = Number.isFinite(value) && (field === "maxPairs" ? value >= 0 : value > 0);
837
+ return [field, usable ? value : fallback];
838
+ }));
839
+ }
840
+
841
+ // The content parameter leg of `qa run --browser`, run after the page checks.
842
+ // `newContext()` opens a fresh browser context with the page checks' options;
843
+ // `withQueryParam(url, key, value)` builds the variant URL. Returns the rows
844
+ // and their verdict assertions.
845
+ //
846
+ // The leg returns within its budget, cleanup included: one timer, armed for
847
+ // budgetMs when the leg starts, cuts the pair in progress. At the cut the open
848
+ // contexts are told to close and the leg returns at once; neither those closes
849
+ // nor the cut loads are awaited (their errors are swallowed). A load's own
850
+ // close, when it completes in time, is part of its pair's work.
851
+ export async function runContentParamChecks({ topologies, spec, newContext, withQueryParam, now = () => Date.now(), limits = CONTENT_PARAM_LIMITS, measuredAt = null } = {}) {
852
+ const pairs = contentParamPairs(spec, topologies);
853
+ if (!pairs.length) return { rows: [], assertions: [] };
854
+ const bounds = runLimits(limits);
855
+ const run = { newContext, open: new Set(), cancelled: false, deadline: now() + bounds.budgetMs, now, limits: bounds };
856
+ let timer = null;
857
+ const timeUp = new Promise((resolve) => {
858
+ timer = setTimeout(() => {
859
+ run.cancelled = true;
860
+ for (const context of run.open) closeQuietly(context);
861
+ resolve(BUDGET_CUT);
862
+ }, bounds.budgetMs);
863
+ });
864
+ const observations = [];
865
+ try {
866
+ for (const [index, pair] of pairs.entries()) {
867
+ let observation = null;
868
+ if (index < bounds.maxPairs) {
869
+ // Loads never throw (every failure is a readiness outcome); anything
870
+ // else leaves the pair unmeasured, which is never a pass.
871
+ try {
872
+ observation = await runPair(pair, run, { withQueryParam, timeUp });
873
+ } catch {
874
+ observation = null;
875
+ }
876
+ }
877
+ observations.push(observation ?? unloadedObservation(pair, BUDGET_EXHAUSTED));
878
+ }
879
+ } finally {
880
+ clearTimeout(timer);
881
+ }
882
+ const capped = new Set(observations.filter((observation) => observation.limit === BUDGET_EXHAUSTED).map((observation) => observation.page));
883
+ const at = measuredAt ?? new Date().toISOString();
884
+ const rows = observations
885
+ .map((observation) => (observation.limit === null && capped.has(observation.page) ? { ...observation, page_coverage: BUDGET_EXHAUSTED } : observation))
886
+ .map((observation) => contentParamQcRow(observation, { measuredAt: at }))
887
+ .filter(Boolean);
888
+ return { rows, assertions: rows.map(contentParamQaAssertion) };
889
+ }