@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.
- package/CHANGELOG.md +539 -0
- package/agents/claude/CLAUDE.md +6 -5
- package/agents/codex/AGENTS.md +6 -5
- package/agents/copilot/copilot-instructions.md +3 -3
- package/agents/cursor/campaigns-os.mdc +3 -3
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
- package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
- package/campaign-spec/dist/rules/design-source-shape.js +13 -3
- package/campaign-spec/dist/rules/sdk-version.js +2 -1
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +26 -46
- package/contracts/effects.v1.json +254 -2
- package/contracts/release-ledger.json +1239 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
- package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
- package/docs/brand-theme-bridge.md +12 -6
- package/docs/build-packet.md +101 -12
- package/docs/campaign-build-brief.md +25 -1
- package/docs/effects.md +6 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +10 -0
- package/docs/qa-and-test-orders.md +66 -11
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/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 +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +4 -4
- package/skills/next-campaigns-os/SKILL.md +4 -4
- package/skills/next-campaigns-os/references/session-intake.md +7 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +5 -4
- package/skills/next-campaigns-qa/SKILL.md +6 -5
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/brand-theme.mjs +25 -2
- package/src/build-brief.mjs +68 -21
- package/src/built-site-scope.mjs +39 -6
- package/src/built-smoke-qc.mjs +1117 -0
- package/src/campaign-identity.mjs +36 -2
- package/src/cart-placeholders.mjs +730 -0
- package/src/cli.mjs +320 -42
- package/src/commercial-journey.mjs +65 -4
- package/src/commercial-parity.mjs +6 -1
- package/src/doctor/checks.mjs +291 -24
- package/src/doctor/inspect.mjs +53 -2
- package/src/doctor/next-step.mjs +1 -1
- package/src/install-mode.mjs +0 -8
- package/src/invocation.mjs +5 -2
- package/src/local-preview-policy.mjs +1 -1
- package/src/local-proof.mjs +4 -1
- package/src/polish-browser.mjs +218 -1
- package/src/polish-capture.mjs +1 -1
- package/src/polish-media-weight.mjs +492 -0
- package/src/polish-node.mjs +96 -4
- package/src/progress-node.mjs +5 -1
- package/src/qa-binding-evidence.mjs +21 -0
- package/src/qa-browser.mjs +338 -97
- package/src/qa-content-params.mjs +889 -0
- package/src/qa-node.mjs +114 -14
- package/src/qa-order-bump.mjs +22 -1
- package/src/qa-policy-links.mjs +1019 -0
- package/src/qa-tracking-params.mjs +1389 -0
- package/src/qa-url-privacy.mjs +168 -0
- package/src/qc-accept.mjs +446 -0
- package/src/qc-check-registry.mjs +83 -0
- package/src/qc-results.mjs +1049 -0
- package/src/sdk-attribute-index.mjs +71 -0
- package/src/sdk-markup.mjs +2 -2
- package/src/sdk-storage-compatibility.mjs +63 -3
- package/src/source-prep.mjs +37 -7
- package/src/stage-record.mjs +356 -36
- package/src/theme-gate.mjs +3 -3
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
// Raw cart placeholders (built_output.cart_placeholders).
|
|
2
|
+
//
|
|
3
|
+
// Flags a known Campaign Cart template placeholder ({item.name}, {subtotal},
|
|
4
|
+
// {package.name} ...) that sits in live built HTML outside every SDK-owned
|
|
5
|
+
// scope, where the shopper sees it as raw text. A sibling doctor gate, kept
|
|
6
|
+
// out of sdk_markup (which withholds warnings while its blockers stand) and
|
|
7
|
+
// out of validateBuiltHtmlStructure (which escalates warnings to errors), so
|
|
8
|
+
// a placeholder is always a warning and never a blocker.
|
|
9
|
+
//
|
|
10
|
+
// Ported from the public starter-template lint:
|
|
11
|
+
// NextCommerceCo/campaign-cart-starter-templates@1bfa99b:
|
|
12
|
+
// scripts/lib/sdk-template-token-lint.mjs:24-60 (findLiveSdkTemplateTokens)
|
|
13
|
+
// Differences from the starter: it reads built HTML instead of Liquid source;
|
|
14
|
+
// it knows every renderer namespace (item, line, discount, property, package,
|
|
15
|
+
// bundle, toggle); live item lists, their declared row templates and the
|
|
16
|
+
// SDK-substituted quantity tokens are owned; only text nodes and the
|
|
17
|
+
// attributes that render or are announced as text are scanned; brace strings
|
|
18
|
+
// that are not known placeholders are review results; and every page reads
|
|
19
|
+
// its SDK pin from its own loader, with page, size, candidate and result caps.
|
|
20
|
+
//
|
|
21
|
+
// Results (one per page and token or shape; doctor issue code
|
|
22
|
+
// built_output.cart_placeholders.<reason_code>):
|
|
23
|
+
// warning live_token known placeholder, unowned
|
|
24
|
+
// review unknown_brace unknown brace shape, unowned
|
|
25
|
+
// review template_selector_unresolved unowned candidate on a page
|
|
26
|
+
// whose item template selector
|
|
27
|
+
// is not an #id
|
|
28
|
+
// pass (page) parsed in full, no unowned
|
|
29
|
+
// candidate, verified pin
|
|
30
|
+
// unexercised (page) sdk_pin_unverified, sdk_pin_unknown, page_unreadable
|
|
31
|
+
// (also nesting deeper than a browser parser builds),
|
|
32
|
+
// page_too_large, page_cap_reached, candidate_cap_reached,
|
|
33
|
+
// finding_cap_reached
|
|
34
|
+
// A page that reached a cap or could not be read never passes, and every
|
|
35
|
+
// result kept on a capped page carries the capped-page member, so none of
|
|
36
|
+
// them is accept-eligible. Unknown brace strings are kept only as a sha256.
|
|
37
|
+
//
|
|
38
|
+
// Pure: callers hand in built HTML. Both doctor entry points drive it.
|
|
39
|
+
|
|
40
|
+
import { createHash } from "node:crypto";
|
|
41
|
+
|
|
42
|
+
import { defaultTreeAdapter, parse } from "parse5";
|
|
43
|
+
|
|
44
|
+
import { aggregateQcResults, buildQcResult } from "./qc-results.mjs";
|
|
45
|
+
import { SDK_TEMPLATE_PLACEHOLDERS, SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS } from "./sdk-attribute-index.mjs";
|
|
46
|
+
import { TEMPLATE_CONTAINER_ATTRIBUTES, TEMPLATE_ID_ATTRIBUTES } from "./sdk-markup.mjs";
|
|
47
|
+
|
|
48
|
+
export const CART_PLACEHOLDERS = "built_output.cart_placeholders";
|
|
49
|
+
export const CART_PLACEHOLDERS_CHECK = "cart_placeholders";
|
|
50
|
+
export const CART_PLACEHOLDERS_PAGE_KEY = "page";
|
|
51
|
+
|
|
52
|
+
// Shared doctor bounds.
|
|
53
|
+
export const CART_PLACEHOLDERS_LIMITS = Object.freeze({
|
|
54
|
+
pages: 500,
|
|
55
|
+
page_bytes: 5 * 1024 * 1024,
|
|
56
|
+
candidates: 2000,
|
|
57
|
+
results: 50,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export const CART_PLACEHOLDERS_REASONS = Object.freeze({
|
|
61
|
+
LIVE_TOKEN: "live_token",
|
|
62
|
+
UNKNOWN_BRACE: "unknown_brace",
|
|
63
|
+
TEMPLATE_SELECTOR_UNRESOLVED: "template_selector_unresolved",
|
|
64
|
+
SDK_PIN_UNVERIFIED: "sdk_pin_unverified",
|
|
65
|
+
SDK_PIN_UNKNOWN: "sdk_pin_unknown",
|
|
66
|
+
PAGE_UNREADABLE: "page_unreadable",
|
|
67
|
+
PAGE_TOO_LARGE: "page_too_large",
|
|
68
|
+
PAGE_CAP_REACHED: "page_cap_reached",
|
|
69
|
+
CANDIDATE_CAP_REACHED: "candidate_cap_reached",
|
|
70
|
+
FINDING_CAP_REACHED: "finding_cap_reached",
|
|
71
|
+
});
|
|
72
|
+
const R = CART_PLACEHOLDERS_REASONS;
|
|
73
|
+
|
|
74
|
+
// The token grammar:
|
|
75
|
+
// known a bare cart-summary var or live token ({subtotal}, {quantity},
|
|
76
|
+
// {step}), a {qty} form ({qty}, {qty*2}, {qty+1}, {qty-1}), or
|
|
77
|
+
// {<namespace>.<field>[.<field>...]} at any depth in one of the
|
|
78
|
+
// seven namespaces (every namespaced renderer takes any key path)
|
|
79
|
+
// unknown {name} or {name.field} that is not known
|
|
80
|
+
// Any other brace string is not a candidate. A field is any run of
|
|
81
|
+
// characters other than a brace or the `.` separator, whitespace included:
|
|
82
|
+
// the renderers read every key as /\{([^}]+)\}/ and the package, bundle and
|
|
83
|
+
// toggle keys come from arbitrary JSON keys ({toggle.first-name},
|
|
84
|
+
// {package.product title}).
|
|
85
|
+
// Every single-brace pair is a candidate whatever surrounds it ({item.name}},
|
|
86
|
+
// }{item.name}, {{item.name}, {{{item.name}}, {{{item.name}}}); only the
|
|
87
|
+
// inner pair of an exactly balanced `{{...}}` (one `{` before, one `}` after,
|
|
88
|
+
// and no further brace next to either) is not, being left to sdk_markup's
|
|
89
|
+
// double-brace check.
|
|
90
|
+
const QTY_FORMS = SDK_TEMPLATE_PLACEHOLDERS.quantity_text.qty_forms;
|
|
91
|
+
const FIELD = "[^{}.]+";
|
|
92
|
+
const CANDIDATE = new RegExp(`\\{([A-Za-z_][A-Za-z0-9_]*(?:\\.${FIELD})*|${QTY_FORMS})\\}`, "g");
|
|
93
|
+
const NAMESPACED = new RegExp(`^([A-Za-z_][A-Za-z0-9_]*)(?:\\.${FIELD})+$`);
|
|
94
|
+
const UNKNOWN_SHAPE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)?$/;
|
|
95
|
+
const QTY = new RegExp(`^(?:${QTY_FORMS})$`);
|
|
96
|
+
|
|
97
|
+
const KNOWN_BARE = new Set([...SDK_TEMPLATE_PLACEHOLDERS.cart_summary_vars, ...SDK_TEMPLATE_PLACEHOLDERS.live_tokens]);
|
|
98
|
+
const KNOWN_NAMESPACES = new Set(SDK_TEMPLATE_PLACEHOLDERS.namespaces);
|
|
99
|
+
const VERIFIED_PINS = new Set(SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS);
|
|
100
|
+
|
|
101
|
+
// Attributes whose values render or are announced as text. `value` counts
|
|
102
|
+
// only on a button-like input.
|
|
103
|
+
const TEXT_ATTRIBUTES = new Set(["alt", "title", "placeholder", "aria-label"]);
|
|
104
|
+
const BUTTON_INPUT_TYPES = new Set(["button", "submit", "reset"]);
|
|
105
|
+
// Never painted, never scanned.
|
|
106
|
+
const UNSCANNED_ELEMENTS = new Set(["script", "style"]);
|
|
107
|
+
|
|
108
|
+
// The ownership attributes. An element owns only when its attribute value is
|
|
109
|
+
// exactly one of `values` (case and whitespace included: INCREASE or
|
|
110
|
+
// " increase " owns nothing); with no `values` the SDK selects the attribute
|
|
111
|
+
// by presence ([data-next-remove-item]), so any value owns. The row template
|
|
112
|
+
// an item list reads is resolved separately (indexLiveDocument).
|
|
113
|
+
const P = SDK_TEMPLATE_PLACEHOLDERS;
|
|
114
|
+
const ITEM_TEMPLATE_ID = "data-item-template-id";
|
|
115
|
+
const ITEM_LISTS = P.item_list_containers.map((attribute) => ({ attribute }));
|
|
116
|
+
const owns = (attrs, { attribute, values }) => attrs.has(attribute) && (values == null || values.includes(attrs.get(attribute)));
|
|
117
|
+
const ownsItemList = (attrs) => ITEM_LISTS.some((ownership) => owns(attrs, ownership));
|
|
118
|
+
|
|
119
|
+
// The loader is read only from a <script> a browser runs as a classic or
|
|
120
|
+
// module script (HTML "prepare the script element"): an HTML-namespace
|
|
121
|
+
// <script> outside <template>, <noscript>, SVG and MathML whose type, ASCII
|
|
122
|
+
// whitespace stripped, is absent or empty, a JavaScript MIME type essence or
|
|
123
|
+
// `module` (ASCII case-insensitive). A classic script with `nomodule` does
|
|
124
|
+
// not run, nor does one with both `for` and `event` unless `for` is `window`
|
|
125
|
+
// and `event` is `onload` or `onload()` (whitespace stripped, ASCII
|
|
126
|
+
// case-insensitive). Anything else (application/json, text/plain, importmap,
|
|
127
|
+
// speculationrules, an SVG <script> ...) is no loader.
|
|
128
|
+
const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
|
|
129
|
+
const SCRIPT_BARRIERS = new Set(["template", "noscript"]);
|
|
130
|
+
// MIME Sniffing, "JavaScript MIME type".
|
|
131
|
+
const JAVASCRIPT_MIME_TYPES = new Set([
|
|
132
|
+
"application/ecmascript",
|
|
133
|
+
"application/javascript",
|
|
134
|
+
"application/x-ecmascript",
|
|
135
|
+
"application/x-javascript",
|
|
136
|
+
"text/ecmascript",
|
|
137
|
+
"text/javascript",
|
|
138
|
+
"text/javascript1.0",
|
|
139
|
+
"text/javascript1.1",
|
|
140
|
+
"text/javascript1.2",
|
|
141
|
+
"text/javascript1.3",
|
|
142
|
+
"text/javascript1.4",
|
|
143
|
+
"text/javascript1.5",
|
|
144
|
+
"text/jscript",
|
|
145
|
+
"text/livescript",
|
|
146
|
+
"text/x-ecmascript",
|
|
147
|
+
"text/x-javascript",
|
|
148
|
+
]);
|
|
149
|
+
const ASCII_WHITESPACE_EDGES = /^[\t\n\f\r ]+|[\t\n\f\r ]+$/g;
|
|
150
|
+
const asciiLowercase = (value) => value.replace(/[A-Z]/g, (c) => c.toLowerCase());
|
|
151
|
+
|
|
152
|
+
// The loader is a script fetched over http(s) whose URL path ends in
|
|
153
|
+
// `campaign-cart[@<spec>]/dist/loader.js`: the package segment is the whole
|
|
154
|
+
// path segment (`not-campaign-cart@...` is another package), and an npm scope
|
|
155
|
+
// in front of it, if any, is the SDK's own. Its version is exact only as
|
|
156
|
+
// `<major>.<minor>.<patch>`, with or without a leading `v`.
|
|
157
|
+
const LOADER_PACKAGE = /^campaign-cart(?:@(.*))?$/;
|
|
158
|
+
const LOADER_SCOPES = new Set(["@nextcommerce"]);
|
|
159
|
+
const LOADER_PROTOCOLS = new Set(["http:", "https:"]);
|
|
160
|
+
const EXACT_VERSION = /^v?((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/;
|
|
161
|
+
|
|
162
|
+
// Nesting deeper than a browser's HTML parser builds (Blink stops nesting at
|
|
163
|
+
// 512) is not the DOM a shopper gets, so such a page is not read.
|
|
164
|
+
export const MAX_ELEMENT_DEPTH = 512;
|
|
165
|
+
|
|
166
|
+
export function isKnownCartPlaceholder(name) {
|
|
167
|
+
const value = String(name ?? "");
|
|
168
|
+
if (KNOWN_BARE.has(value) || QTY.test(value)) return true;
|
|
169
|
+
const namespaced = value.match(NAMESPACED);
|
|
170
|
+
return Boolean(namespaced) && KNOWN_NAMESPACES.has(namespaced[1]);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const isCandidate = (name) => isKnownCartPlaceholder(name) || UNKNOWN_SHAPE.test(name);
|
|
174
|
+
|
|
175
|
+
// Whether the brace pair at `index` is the inner pair of an exactly balanced
|
|
176
|
+
// `{{...}}`: one `{` right before it, one `}` right after it, and no other
|
|
177
|
+
// brace of either kind beyond those.
|
|
178
|
+
const isBrace = (char) => char === "{" || char === "}";
|
|
179
|
+
const isDoubleBraced = (text, index, length) =>
|
|
180
|
+
text[index - 1] === "{" && text[index + length] === "}" && !isBrace(text[index - 2]) && !isBrace(text[index + length + 1]);
|
|
181
|
+
|
|
182
|
+
// The page's own URL is not known here; an http(s) stand-in takes its place.
|
|
183
|
+
const PAGE_URL = "https://base.invalid/";
|
|
184
|
+
|
|
185
|
+
// The document base URL (HTML "document base URL"): the frozen base URL of
|
|
186
|
+
// the first HTML <base> in tree order that has an href attribute (template
|
|
187
|
+
// content is not in the tree), else the page URL. That href resolves against
|
|
188
|
+
// the page URL; one that fails to parse, or names a data: or javascript: URL,
|
|
189
|
+
// leaves the page URL. A later <base>, or one without href, is not read.
|
|
190
|
+
export function documentBaseUrl(document) {
|
|
191
|
+
const base = liveElements(document).find((node) => node.namespaceURI === HTML_NAMESPACE && node.tagName === "base" && attrsOf(node).has("href"));
|
|
192
|
+
if (!base) return PAGE_URL;
|
|
193
|
+
let url;
|
|
194
|
+
try {
|
|
195
|
+
url = new URL(attrsOf(base).get("href"), PAGE_URL);
|
|
196
|
+
} catch {
|
|
197
|
+
return PAGE_URL;
|
|
198
|
+
}
|
|
199
|
+
return url.protocol === "data:" || url.protocol === "javascript:" ? PAGE_URL : url.href;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Whether a <script src> is the Campaign Cart loader: `{ version }` (the
|
|
203
|
+
// exact version, or null when it names none) or null when it is not. The URL
|
|
204
|
+
// resolves as the browser resolves it, against the document base URL, so a
|
|
205
|
+
// relative or protocol-relative src lands where the page's <base> puts it, an
|
|
206
|
+
// src that cannot resolve against that base is no loader, and an opaque one
|
|
207
|
+
// (data:, javascript:, blob: ...) keeps its own scheme and is never the
|
|
208
|
+
// loader. Path segments are compared decoded.
|
|
209
|
+
export function campaignCartLoader(src, baseUrl = PAGE_URL) {
|
|
210
|
+
let url;
|
|
211
|
+
try {
|
|
212
|
+
url = new URL(String(src), baseUrl);
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
if (!LOADER_PROTOCOLS.has(url.protocol)) return null;
|
|
217
|
+
const segments = url.pathname.split("/").map((segment) => {
|
|
218
|
+
try {
|
|
219
|
+
return decodeURIComponent(segment);
|
|
220
|
+
} catch {
|
|
221
|
+
return segment;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
const [pkg, dist, file] = segments.slice(-3);
|
|
225
|
+
if (segments.length < 4 || dist !== "dist" || file !== "loader.js") return null;
|
|
226
|
+
const match = pkg.match(LOADER_PACKAGE);
|
|
227
|
+
if (!match) return null;
|
|
228
|
+
const scope = segments[segments.length - 4];
|
|
229
|
+
if (scope.startsWith("@") && !LOADER_SCOPES.has(scope)) return null;
|
|
230
|
+
const exact = match[1] == null ? null : match[1].match(EXACT_VERSION);
|
|
231
|
+
return { version: exact ? exact[1] : null };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Every element under `root` in document order (template content excluded,
|
|
235
|
+
// it is not live DOM), without recursion.
|
|
236
|
+
function liveElements(root) {
|
|
237
|
+
const out = [];
|
|
238
|
+
const stack = [...(root.childNodes || [])].reverse();
|
|
239
|
+
while (stack.length) {
|
|
240
|
+
const node = stack.pop();
|
|
241
|
+
if (!node.tagName) continue;
|
|
242
|
+
out.push(node);
|
|
243
|
+
if (node.tagName !== "template") for (let i = (node.childNodes || []).length - 1; i >= 0; i -= 1) stack.push(node.childNodes[i]);
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
class TooDeep extends Error {}
|
|
249
|
+
|
|
250
|
+
// parse5 with a tree adapter that stops the parse once an element would sit
|
|
251
|
+
// deeper than MAX_ELEMENT_DEPTH (template content counts from its
|
|
252
|
+
// <template>). parse5's own cost grows with the square of the nesting, so the
|
|
253
|
+
// bound has to hold during the parse, not after it. Its error is one
|
|
254
|
+
// isPageReadFailure reads as a page that cannot be read.
|
|
255
|
+
export function parseBounded(html) {
|
|
256
|
+
const templateOf = new WeakMap();
|
|
257
|
+
const check = (parent, node) => {
|
|
258
|
+
if (!node.tagName) return;
|
|
259
|
+
let depth = 1;
|
|
260
|
+
for (let at = parent; at; at = at.parentNode || templateOf.get(at)) {
|
|
261
|
+
if (at.tagName) depth += 1;
|
|
262
|
+
if (depth > MAX_ELEMENT_DEPTH) throw new TooDeep();
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
const treeAdapter = {
|
|
266
|
+
...defaultTreeAdapter,
|
|
267
|
+
appendChild(parent, node) {
|
|
268
|
+
check(parent, node);
|
|
269
|
+
defaultTreeAdapter.appendChild(parent, node);
|
|
270
|
+
},
|
|
271
|
+
insertBefore(parent, node, reference) {
|
|
272
|
+
check(parent, node);
|
|
273
|
+
defaultTreeAdapter.insertBefore(parent, node, reference);
|
|
274
|
+
},
|
|
275
|
+
setTemplateContent(template, content) {
|
|
276
|
+
templateOf.set(content, template);
|
|
277
|
+
defaultTreeAdapter.setTemplateContent(template, content);
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
return parse(html, { sourceCodeLocationInfo: true, treeAdapter });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// The deepest element nesting, template content included, without recursion.
|
|
284
|
+
function elementDepth(root) {
|
|
285
|
+
let max = 0;
|
|
286
|
+
const stack = [[root, 0]];
|
|
287
|
+
while (stack.length) {
|
|
288
|
+
const [node, depth] = stack.pop();
|
|
289
|
+
if (depth > max) max = depth;
|
|
290
|
+
for (const child of childrenOf(node)) if (child.tagName) stack.push([child, depth + 1]);
|
|
291
|
+
}
|
|
292
|
+
return max;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function cartPlaceholderShapeKey(text) {
|
|
296
|
+
return `sha256:${createHash("sha256").update(String(text)).digest("hex")}`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const attrsOf = (node) => {
|
|
300
|
+
const map = new Map();
|
|
301
|
+
for (const attr of node.attrs || []) map.set(attr.name.toLowerCase(), attr.value);
|
|
302
|
+
return map;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const childrenOf = (node) => (node.tagName === "template" && node.content ? node.content.childNodes : node.childNodes) || [];
|
|
306
|
+
|
|
307
|
+
// "classic", "module" or null: how a browser runs a <script> with these
|
|
308
|
+
// attributes, from its type (or, with no type, its language).
|
|
309
|
+
export function scriptKind(attrs) {
|
|
310
|
+
const type = attrs.get("type");
|
|
311
|
+
const language = attrs.get("language");
|
|
312
|
+
let kind;
|
|
313
|
+
if (type === "" || (type == null && (language == null || language === ""))) kind = "text/javascript";
|
|
314
|
+
else if (type != null) kind = type.replace(ASCII_WHITESPACE_EDGES, "");
|
|
315
|
+
else kind = `text/${language}`;
|
|
316
|
+
kind = asciiLowercase(kind);
|
|
317
|
+
if (JAVASCRIPT_MIME_TYPES.has(kind)) return attrs.has("nomodule") || !classicForEventRuns(attrs) ? null : "classic";
|
|
318
|
+
return kind === "module" ? "module" : null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// HTML "prepare the script element": a classic script with both `for` and
|
|
322
|
+
// `event` runs only as the window's onload handler.
|
|
323
|
+
function classicForEventRuns(attrs) {
|
|
324
|
+
if (!attrs.has("for") || !attrs.has("event")) return true;
|
|
325
|
+
const stripped = (name) => asciiLowercase(attrs.get(name).replace(ASCII_WHITESPACE_EDGES, ""));
|
|
326
|
+
return stripped("for") === "window" && (stripped("event") === "onload" || stripped("event") === "onload()");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// The <script> elements a browser runs, in document order, without
|
|
330
|
+
// recursion: never below a <template>, a <noscript> or a non-HTML element.
|
|
331
|
+
function executableScripts(document) {
|
|
332
|
+
const out = [];
|
|
333
|
+
const stack = [...(document.childNodes || [])].reverse();
|
|
334
|
+
while (stack.length) {
|
|
335
|
+
const node = stack.pop();
|
|
336
|
+
if (!node.tagName || node.namespaceURI !== HTML_NAMESPACE) continue;
|
|
337
|
+
if (node.tagName === "script") {
|
|
338
|
+
if (scriptKind(attrsOf(node))) out.push(node);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (SCRIPT_BARRIERS.has(node.tagName)) continue;
|
|
342
|
+
for (let i = (node.childNodes || []).length - 1; i >= 0; i -= 1) stack.push(node.childNodes[i]);
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// The page's SDK pin, read from its own loader <script src> only, among the
|
|
348
|
+
// scripts a browser runs, each src resolved against the document base URL.
|
|
349
|
+
// No loader, any loader with no exact version (none, @latest, a range), or
|
|
350
|
+
// two loaders that disagree give no pin.
|
|
351
|
+
export function readLoaderPin(document) {
|
|
352
|
+
const baseUrl = documentBaseUrl(document);
|
|
353
|
+
const versions = new Set();
|
|
354
|
+
let unpinned = false;
|
|
355
|
+
for (const element of executableScripts(document)) {
|
|
356
|
+
const src = attrsOf(element).get("src");
|
|
357
|
+
const loader = typeof src === "string" ? campaignCartLoader(src, baseUrl) : null;
|
|
358
|
+
if (!loader) continue;
|
|
359
|
+
if (loader.version) versions.add(loader.version);
|
|
360
|
+
else unpinned = true;
|
|
361
|
+
}
|
|
362
|
+
if (unpinned || versions.size !== 1) return null;
|
|
363
|
+
return [...versions][0];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// A selector resolved statically: `#` and a CSS identifier (CSS Syntax 3
|
|
367
|
+
// §4.3: non-ASCII code points and escapes included, so `#résumé` and
|
|
368
|
+
// `#\31 row` name the ids `résumé` and `1row`), exact. Returns the id, or
|
|
369
|
+
// null for anything else: a padded selector, `#1row` (no valid selector; the
|
|
370
|
+
// SDK's querySelector throws), or any other selector form.
|
|
371
|
+
const CSS_HEX_DIGIT = /^[0-9A-Fa-f]$/;
|
|
372
|
+
const CSS_WHITESPACE = new Set([" ", "\t", "\n"]);
|
|
373
|
+
const isIdentStart = (char) => char != null && (/^[A-Za-z_]$/.test(char) || char.codePointAt(0) >= 0x80);
|
|
374
|
+
const isIdentChar = (char) => isIdentStart(char) || (char != null && /^[0-9-]$/.test(char));
|
|
375
|
+
const isValidEscape = (first, second) => first === "\\" && second !== "\n";
|
|
376
|
+
|
|
377
|
+
function idOfSelector(selector) {
|
|
378
|
+
// CSS input preprocessing: CR, FF and CR LF are newlines, NUL is U+FFFD.
|
|
379
|
+
const chars = [...String(selector).replace(/\r\n?|\f/g, "\n").replaceAll("\0", "�")];
|
|
380
|
+
if (chars[0] !== "#") return null;
|
|
381
|
+
const [a, b, c] = chars.slice(1, 4);
|
|
382
|
+
const startsIdent = a === "-" ? isIdentStart(b) || b === "-" || isValidEscape(b, c) : isIdentStart(a) || isValidEscape(a, b);
|
|
383
|
+
if (!startsIdent) return null;
|
|
384
|
+
let id = "";
|
|
385
|
+
let at = 1;
|
|
386
|
+
while (at < chars.length) {
|
|
387
|
+
const char = chars[at];
|
|
388
|
+
if (isIdentChar(char)) {
|
|
389
|
+
id += char;
|
|
390
|
+
at += 1;
|
|
391
|
+
} else if (isValidEscape(char, chars[at + 1])) {
|
|
392
|
+
at += 1;
|
|
393
|
+
if (at === chars.length) {
|
|
394
|
+
id += "�";
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
if (!CSS_HEX_DIGIT.test(chars[at])) {
|
|
398
|
+
id += chars[at];
|
|
399
|
+
at += 1;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
let hex = "";
|
|
403
|
+
while (hex.length < 6 && at < chars.length && CSS_HEX_DIGIT.test(chars[at])) hex += chars[at++];
|
|
404
|
+
if (CSS_WHITESPACE.has(chars[at])) at += 1;
|
|
405
|
+
const code = Number.parseInt(hex, 16);
|
|
406
|
+
id += code === 0 || (code >= 0xd800 && code <= 0xdfff) || code > 0x10ffff ? "�" : String.fromCodePoint(code);
|
|
407
|
+
} else {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return id;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// The live element ids, first in document order wins (getElementById), and
|
|
415
|
+
// the row template each item list reads, as the SDK picks it
|
|
416
|
+
// (cart-item-list.enhancer.ts:21-35, order-item-list.enhancer.ts:26-36): a
|
|
417
|
+
// non-empty data-item-template-id names it by id and the selector is never
|
|
418
|
+
// read; otherwise a non-empty data-item-template-selector names it.
|
|
419
|
+
// Template content is not live DOM.
|
|
420
|
+
function indexLiveDocument(document) {
|
|
421
|
+
const ids = new Map();
|
|
422
|
+
const templateIds = new Map();
|
|
423
|
+
const rowTemplates = [];
|
|
424
|
+
for (const element of liveElements(document)) {
|
|
425
|
+
const attrs = attrsOf(element);
|
|
426
|
+
const id = attrs.get("id");
|
|
427
|
+
if (id && !ids.has(id)) ids.set(id, element);
|
|
428
|
+
for (const [name, value] of attrs) {
|
|
429
|
+
if (TEMPLATE_ID_ATTRIBUTES.test(name) && value && !templateIds.has(value)) templateIds.set(value, name);
|
|
430
|
+
}
|
|
431
|
+
if (!ownsItemList(attrs)) continue;
|
|
432
|
+
if (attrs.get(ITEM_TEMPLATE_ID)) rowTemplates.push({ id: attrs.get(ITEM_TEMPLATE_ID) });
|
|
433
|
+
else if (attrs.get(P.item_template_selector)) rowTemplates.push({ selector: attrs.get(P.item_template_selector) });
|
|
434
|
+
}
|
|
435
|
+
const owned = new Set();
|
|
436
|
+
let unresolved = false;
|
|
437
|
+
for (const { id, selector } of rowTemplates) {
|
|
438
|
+
const selectorId = selector == null ? null : idOfSelector(selector);
|
|
439
|
+
if (selector != null && selectorId == null) {
|
|
440
|
+
unresolved = true;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
const target = ids.get(id ?? selectorId);
|
|
444
|
+
if (target) owned.add(target);
|
|
445
|
+
}
|
|
446
|
+
const idTemplates = new Map();
|
|
447
|
+
for (const [id, attribute] of templateIds) {
|
|
448
|
+
const target = ids.get(id);
|
|
449
|
+
if (target) idTemplates.set(target, attribute);
|
|
450
|
+
}
|
|
451
|
+
return { rowTemplates: owned, selectorUnresolved: unresolved, idTemplates };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// The SDK template owner nearest an unowned occurrence, for the detail: a
|
|
455
|
+
// container whose direct <template> the SDK clones, or an element a
|
|
456
|
+
// *-template-id attribute names. The placeholder belongs inside that template.
|
|
457
|
+
function sdkOwnerOf(attrs, node, idTemplates) {
|
|
458
|
+
const container = TEMPLATE_CONTAINER_ATTRIBUTES.find((name) => attrs.has(name));
|
|
459
|
+
if (container) return container;
|
|
460
|
+
return idTemplates.get(node) || null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
class CandidateCapReached extends Error {}
|
|
464
|
+
|
|
465
|
+
// Scans one parsed page, handing every candidate in a rendered location to
|
|
466
|
+
// `onCandidate` in document order, marked owned or not. Iterative, so page
|
|
467
|
+
// depth never reaches the call stack.
|
|
468
|
+
function scanPage(document, { rowTemplates, idTemplates }, onCandidate) {
|
|
469
|
+
const emit = (text, where, line, ctx, elementPath) => {
|
|
470
|
+
for (const match of text.matchAll(CANDIDATE)) {
|
|
471
|
+
const name = match[1];
|
|
472
|
+
if (!isCandidate(name) || isDoubleBraced(text, match.index, match[0].length)) continue;
|
|
473
|
+
const owned = ctx.template || ctx.itemList || ctx.allowed.has(name) || (ctx.quantityText && QTY.test(name));
|
|
474
|
+
const prefix = text.slice(0, match.index);
|
|
475
|
+
const lineOf = line == null ? null : line + (where === "text" ? prefix.split("\n").length - 1 : 0);
|
|
476
|
+
onCandidate({ text: match[0], name, owned, where, line: lineOf, element_path: elementPath, sdk_owner: ctx.owner });
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
// The scope an element sets up. `self` is the part that covers the
|
|
481
|
+
// element's own attributes too: only a <template> and the row template an
|
|
482
|
+
// item list reads. An item list (innerHTML), quantity text (textContent), a
|
|
483
|
+
// quantity control and a remove-item button (innerHTML) rewrite only what
|
|
484
|
+
// is inside them, so their tokens are owned inside them, never in their own
|
|
485
|
+
// attributes.
|
|
486
|
+
const scopes = (ctx, node, tag, attrs) => {
|
|
487
|
+
const self = { ...ctx };
|
|
488
|
+
if (tag === "template") self.template = true;
|
|
489
|
+
if (rowTemplates.has(node)) self.itemList = true;
|
|
490
|
+
const inner = { ...self };
|
|
491
|
+
if (ownsItemList(attrs)) inner.itemList = true;
|
|
492
|
+
if (owns(attrs, P.quantity_text)) inner.quantityText = true;
|
|
493
|
+
const allowed = [];
|
|
494
|
+
if (owns(attrs, P.quantity_control)) allowed.push(...P.quantity_control.tokens);
|
|
495
|
+
if (owns(attrs, P.remove_item)) allowed.push(...P.remove_item.tokens);
|
|
496
|
+
if (allowed.length) inner.allowed = new Set([...ctx.allowed, ...allowed]);
|
|
497
|
+
inner.owner = sdkOwnerOf(attrs, node, idTemplates) || ctx.owner;
|
|
498
|
+
return { self, inner };
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// Frames are pushed in reverse so they pop in document order.
|
|
502
|
+
const pushChildren = (stack, node, ctx, path) => {
|
|
503
|
+
const counts = new Map();
|
|
504
|
+
const frames = [];
|
|
505
|
+
for (const child of childrenOf(node)) {
|
|
506
|
+
if (child.nodeName === "#text") {
|
|
507
|
+
frames.push({ node: child, ctx, path });
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (!child.tagName) continue; // comments, doctype
|
|
511
|
+
const tag = child.tagName.toLowerCase();
|
|
512
|
+
const index = (counts.get(tag) || 0) + 1;
|
|
513
|
+
counts.set(tag, index);
|
|
514
|
+
frames.push({ node: child, ctx, path: `${path ? `${path}/` : ""}${tag}[${index}]`, tag });
|
|
515
|
+
}
|
|
516
|
+
for (let i = frames.length - 1; i >= 0; i -= 1) stack.push(frames[i]);
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const stack = [];
|
|
520
|
+
pushChildren(stack, document, { template: false, itemList: false, quantityText: false, allowed: new Set(), owner: null }, "");
|
|
521
|
+
while (stack.length) {
|
|
522
|
+
const { node, ctx, path, tag } = stack.pop();
|
|
523
|
+
if (!tag) {
|
|
524
|
+
emit(node.value || "", "text", node.sourceCodeLocation?.startLine ?? null, ctx, path);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (UNSCANNED_ELEMENTS.has(tag)) continue;
|
|
528
|
+
const attrs = attrsOf(node);
|
|
529
|
+
const { self, inner } = scopes(ctx, node, tag, attrs);
|
|
530
|
+
const type = String(attrs.get("type") || "").trim().toLowerCase();
|
|
531
|
+
for (const attr of node.attrs || []) {
|
|
532
|
+
const name = attr.name.toLowerCase();
|
|
533
|
+
const rendered = TEXT_ATTRIBUTES.has(name) || (name === "value" && tag === "input" && BUTTON_INPUT_TYPES.has(type));
|
|
534
|
+
if (!rendered) continue;
|
|
535
|
+
emit(attr.value || "", `attr:${name}`, node.sourceCodeLocation?.attrs?.[attr.name]?.startLine ?? node.sourceCodeLocation?.startLine ?? null, self, path);
|
|
536
|
+
}
|
|
537
|
+
pushChildren(stack, node, inner, path);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// The file-system error codes that mean a page cannot be read.
|
|
542
|
+
const READ_ERROR_CODES = new Set([
|
|
543
|
+
"EACCES", "EAGAIN", "EBADF", "EBUSY", "EIO", "EISDIR", "ELOOP", "EMFILE", "ENAMETOOLONG",
|
|
544
|
+
"ENFILE", "ENODEV", "ENOENT", "ENOTDIR", "ENXIO", "EOVERFLOW", "EPERM", "ESTALE", "ETIMEDOUT",
|
|
545
|
+
"ERR_FS_FILE_TOO_LARGE", "ERR_STRING_TOO_LONG",
|
|
546
|
+
]);
|
|
547
|
+
// The engine's own size and depth bounds (V8 messages).
|
|
548
|
+
const BOUND_RANGE_ERRORS = /^(?:Maximum call stack size exceeded|Invalid string length)$/;
|
|
549
|
+
|
|
550
|
+
// Whether an error is a file-system read failure: one of READ_ERROR_CODES.
|
|
551
|
+
// What probes the file system for the page collection catches only these.
|
|
552
|
+
export function isFileReadFailure(error) {
|
|
553
|
+
return typeof error?.code === "string" && READ_ERROR_CODES.has(error.code);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Whether an error means the page cannot be read or parsed: a file-system
|
|
557
|
+
// error, nesting past MAX_ELEMENT_DEPTH, or the engine's size or depth bound.
|
|
558
|
+
// Anything else (a TypeError, a ReferenceError, an assertion) is a defect and
|
|
559
|
+
// is not one of these.
|
|
560
|
+
export function isPageReadFailure(error) {
|
|
561
|
+
if (error instanceof TooDeep) return true;
|
|
562
|
+
if (error instanceof RangeError) return BOUND_RANGE_ERRORS.test(error.message);
|
|
563
|
+
return isFileReadFailure(error);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// One page's raw observation: the pin, and either a page-level outcome or the
|
|
567
|
+
// unowned candidates grouped per token or shape, in first-seen order. A read
|
|
568
|
+
// or parse failure reads the page unreadable; any other error is a defect and
|
|
569
|
+
// throws.
|
|
570
|
+
function observePage(page) {
|
|
571
|
+
try {
|
|
572
|
+
return observeReadablePage(page);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
if (!isPageReadFailure(error)) throw error;
|
|
575
|
+
return { pin: null, outcome: R.PAGE_UNREADABLE };
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function observeReadablePage(page) {
|
|
580
|
+
if (page.unreadable) return { pin: null, outcome: R.PAGE_UNREADABLE };
|
|
581
|
+
// A page over the size cap may come without its HTML.
|
|
582
|
+
const bytes = Number.isFinite(page.bytes) ? page.bytes : typeof page.content === "string" ? Buffer.byteLength(page.content, "utf8") : null;
|
|
583
|
+
if (bytes != null && bytes > CART_PLACEHOLDERS_LIMITS.page_bytes) return { pin: null, outcome: R.PAGE_TOO_LARGE, bytes };
|
|
584
|
+
if (typeof page.content !== "string") return { pin: null, outcome: R.PAGE_UNREADABLE };
|
|
585
|
+
|
|
586
|
+
let document;
|
|
587
|
+
try {
|
|
588
|
+
document = parseBounded(page.content);
|
|
589
|
+
} catch (error) {
|
|
590
|
+
if (!isPageReadFailure(error)) throw error;
|
|
591
|
+
return { pin: null, outcome: R.PAGE_UNREADABLE, bytes };
|
|
592
|
+
}
|
|
593
|
+
// A backstop for nesting the parser reached by moving nodes.
|
|
594
|
+
if (elementDepth(document) > MAX_ELEMENT_DEPTH) return { pin: null, outcome: R.PAGE_UNREADABLE, bytes };
|
|
595
|
+
const pin = readLoaderPin(document);
|
|
596
|
+
const index = indexLiveDocument(document);
|
|
597
|
+
const groups = new Map();
|
|
598
|
+
const caps = [];
|
|
599
|
+
const unowned = [];
|
|
600
|
+
let candidates = 0;
|
|
601
|
+
// Past the candidate cap the walk stops; what it examined is kept. A page
|
|
602
|
+
// the walk cannot hold (an engine bound) is not read.
|
|
603
|
+
try {
|
|
604
|
+
scanPage(document, index, (candidate) => {
|
|
605
|
+
if (candidates === CART_PLACEHOLDERS_LIMITS.candidates) throw new CandidateCapReached();
|
|
606
|
+
candidates += 1;
|
|
607
|
+
if (!candidate.owned) unowned.push(candidate);
|
|
608
|
+
});
|
|
609
|
+
} catch (error) {
|
|
610
|
+
if (isPageReadFailure(error)) return { pin: null, outcome: R.PAGE_UNREADABLE, bytes };
|
|
611
|
+
if (!(error instanceof CandidateCapReached)) throw error;
|
|
612
|
+
caps.push(R.CANDIDATE_CAP_REACHED);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
for (const candidate of unowned) {
|
|
616
|
+
const known = isKnownCartPlaceholder(candidate.name);
|
|
617
|
+
const key = known ? candidate.text : cartPlaceholderShapeKey(candidate.text);
|
|
618
|
+
if (!groups.has(key)) {
|
|
619
|
+
if (groups.size >= CART_PLACEHOLDERS_LIMITS.results) {
|
|
620
|
+
if (!caps.includes(R.FINDING_CAP_REACHED)) caps.push(R.FINDING_CAP_REACHED);
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
const reason = index.selectorUnresolved ? R.TEMPLATE_SELECTOR_UNRESOLVED : known ? R.LIVE_TOKEN : R.UNKNOWN_BRACE;
|
|
624
|
+
groups.set(key, { key, known, token: known ? candidate.text : null, shape_sha256: known ? null : key, reason, occurrences: [] });
|
|
625
|
+
}
|
|
626
|
+
groups.get(key).occurrences.push({ element_path: candidate.element_path, where: candidate.where, line: candidate.line, sdk_owner: candidate.sdk_owner });
|
|
627
|
+
}
|
|
628
|
+
return { pin, bytes, candidates, caps, groups: [...groups.values()] };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const capMembersFor = (reasons) => reasons.flatMap((reason) => aggregateQcResults([], { capReason: reason }).members);
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Evaluate the raw cart placeholder check.
|
|
635
|
+
*
|
|
636
|
+
* @param {{
|
|
637
|
+
* subject?: object,
|
|
638
|
+
* pages: Array<{ file: string, content?: string, bytes?: number, unreadable?: boolean }>,
|
|
639
|
+
* measuredAt?: string,
|
|
640
|
+
* }} input `pages` in a stable order; `file` is the page path relative to
|
|
641
|
+
* the doctor target. Pages past the page cap may omit `content`. `subject`
|
|
642
|
+
* names the scanned site for the caller; every result carries its own
|
|
643
|
+
* {check, page, key} subject.
|
|
644
|
+
* @returns {object[]} QC results (src/qc-results.mjs buildQcResult).
|
|
645
|
+
*/
|
|
646
|
+
export function evaluateCartPlaceholders({ subject = null, pages = [], measuredAt = new Date().toISOString() } = {}) {
|
|
647
|
+
const results = [];
|
|
648
|
+
const check = CART_PLACEHOLDERS_CHECK;
|
|
649
|
+
const row = ({ page, key, result, reason_code = null, pin = null, observation, occurrences = [], members = [], coverage }) => buildQcResult({
|
|
650
|
+
check,
|
|
651
|
+
leg: "doctor",
|
|
652
|
+
subject: { check, page, key },
|
|
653
|
+
result,
|
|
654
|
+
reason_code,
|
|
655
|
+
state: {
|
|
656
|
+
reason_code: result === "pass" ? null : reason_code,
|
|
657
|
+
sdk_pin: pin,
|
|
658
|
+
occurrences: occurrences.map(({ element_path, where }) => ({ element_path, where })),
|
|
659
|
+
members,
|
|
660
|
+
},
|
|
661
|
+
observation,
|
|
662
|
+
members,
|
|
663
|
+
coverage,
|
|
664
|
+
measured_at: measuredAt,
|
|
665
|
+
});
|
|
666
|
+
const pageObservation = (page, pin, extra = {}) => ({
|
|
667
|
+
page,
|
|
668
|
+
sdk_pin: pin,
|
|
669
|
+
sdk_pin_source: pin ? "loader" : null,
|
|
670
|
+
token: null,
|
|
671
|
+
shape_sha256: null,
|
|
672
|
+
known: false,
|
|
673
|
+
occurrences: [],
|
|
674
|
+
...extra,
|
|
675
|
+
});
|
|
676
|
+
const pageRow = (page, result, reasonCode, pin, extra, limits = reasonCode && result === "unexercised" ? [reasonCode] : []) => row({
|
|
677
|
+
page,
|
|
678
|
+
key: CART_PLACEHOLDERS_PAGE_KEY,
|
|
679
|
+
result,
|
|
680
|
+
reason_code: reasonCode,
|
|
681
|
+
pin,
|
|
682
|
+
observation: pageObservation(page, pin, extra),
|
|
683
|
+
coverage: limits.length ? { observed: 0, expected: 1, limits } : { observed: 1, expected: 1, limits: [] },
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
(Array.isArray(pages) ? pages : []).forEach((page, index) => {
|
|
687
|
+
const file = String(page?.file ?? "");
|
|
688
|
+
if (index >= CART_PLACEHOLDERS_LIMITS.pages) {
|
|
689
|
+
results.push(pageRow(file, "unexercised", R.PAGE_CAP_REACHED, null, {}));
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const observed = observePage(page || {});
|
|
693
|
+
if (observed.outcome) {
|
|
694
|
+
results.push(pageRow(file, "unexercised", observed.outcome, observed.pin, observed.bytes == null ? {} : { bytes: observed.bytes }));
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const { pin, caps, groups } = observed;
|
|
698
|
+
for (const group of groups) {
|
|
699
|
+
const result = group.reason === R.LIVE_TOKEN ? "warning" : "review";
|
|
700
|
+
results.push(row({
|
|
701
|
+
page: file,
|
|
702
|
+
key: group.key,
|
|
703
|
+
result,
|
|
704
|
+
reason_code: group.reason,
|
|
705
|
+
pin,
|
|
706
|
+
occurrences: group.occurrences,
|
|
707
|
+
members: capMembersFor(caps),
|
|
708
|
+
observation: {
|
|
709
|
+
page: file,
|
|
710
|
+
sdk_pin: pin,
|
|
711
|
+
sdk_pin_source: pin ? "loader" : null,
|
|
712
|
+
token: group.token,
|
|
713
|
+
shape_sha256: group.shape_sha256,
|
|
714
|
+
known: group.known,
|
|
715
|
+
occurrences: group.occurrences,
|
|
716
|
+
},
|
|
717
|
+
coverage: caps.length ? { observed: observed.candidates, expected: null, limits: [...caps] } : undefined,
|
|
718
|
+
}));
|
|
719
|
+
}
|
|
720
|
+
const extra = { bytes: observed.bytes, candidates: observed.candidates };
|
|
721
|
+
if (caps.length) {
|
|
722
|
+
results.push(pageRow(file, "unexercised", caps[0], pin, extra, [...caps]));
|
|
723
|
+
} else if (!groups.length) {
|
|
724
|
+
if (pin == null) results.push(pageRow(file, "unexercised", R.SDK_PIN_UNKNOWN, pin, extra));
|
|
725
|
+
else if (!VERIFIED_PINS.has(pin)) results.push(pageRow(file, "unexercised", R.SDK_PIN_UNVERIFIED, pin, extra));
|
|
726
|
+
else results.push(pageRow(file, "pass", null, pin, extra));
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
return results;
|
|
730
|
+
}
|