@nextcommerce/campaigns-os 1.50.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 +426 -0
- package/agents/claude/CLAUDE.md +2 -2
- package/agents/codex/AGENTS.md +1 -1
- package/agents/copilot/copilot-instructions.md +1 -1
- package/agents/cursor/campaigns-os.mdc +1 -1
- package/campaign-spec/dist/rules/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 +81 -2
- package/contracts/release-ledger.json +906 -0
- package/contracts/supported-surface.json +2 -2
- package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
- package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
- package/docs/build-packet.md +93 -9
- 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 +45 -4
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +3 -3
- 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 +3 -3
- 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 +12 -0
- 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 +310 -34
- 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/invocation.mjs +2 -1
- 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-browser.mjs +308 -96
- package/src/qa-content-params.mjs +889 -0
- package/src/qa-node.mjs +104 -12
- 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 +56 -17
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
// Policy links resolve: for each configured campaign.store_* policy URL,
|
|
2
|
+
// `qa run --browser` reports two rows, both family browser-runtime:
|
|
3
|
+
// - presence (policy.presence:campaign:<field>, assertion
|
|
4
|
+
// qc.policy.presence:<field>): a rendered anchor on the visited pages points
|
|
5
|
+
// to the configured URL;
|
|
6
|
+
// - availability (policy.availability:campaign:<field>, assertion
|
|
7
|
+
// qc.policy.availability:<field>): a bounded, header-only GET chain reaches
|
|
8
|
+
// an HTML page.
|
|
9
|
+
//
|
|
10
|
+
// This module holds the URL normalization and classes, the presence counts,
|
|
11
|
+
// the availability probe, the result rules and the anchor reader. The page
|
|
12
|
+
// visits stay in qa-browser.mjs (runPageBrowserChecks), which calls
|
|
13
|
+
// readPageAnchors once per visited page; runBrowserChecks then calls
|
|
14
|
+
// runPolicyLinkChecks once, after the page checks.
|
|
15
|
+
//
|
|
16
|
+
// Privacy: link text is compared inside the page and never leaves it; presence
|
|
17
|
+
// keeps counts. A query is compared in memory and kept only as query_sha256
|
|
18
|
+
// (the sha256 of its sorted key=value pairs). Every stored URL is
|
|
19
|
+
// origin+path, written as the persisted-verdict projection
|
|
20
|
+
// (src/qa-url-privacy.mjs) leaves it, so a row and its verdict assertion carry
|
|
21
|
+
// the same observation. That projection can merge distinct paths (an encoded
|
|
22
|
+
// "?" in a path is redacted), so no rule compares stored URLs: the configured
|
|
23
|
+
// value (configured_identity) and each hop (chain_identity) also keep sha256
|
|
24
|
+
// hashes of the raw URL, path and host, and the rules compare those. A row's
|
|
25
|
+
// accept state carries the path and host hashes (the forms the pass rule
|
|
26
|
+
// compares), so an accept lapses when a raw URL changes behind the same stored
|
|
27
|
+
// text, but not for a change the pass rule treats as the same destination.
|
|
28
|
+
//
|
|
29
|
+
// Time: one run budget (createPolicyLinkBudget) bounds the time the policy
|
|
30
|
+
// link checks add to a QA run. Every anchor read and the probes take their
|
|
31
|
+
// deadline from it, nothing starts once it is spent, and what it cut short
|
|
32
|
+
// reads unexercised.
|
|
33
|
+
//
|
|
34
|
+
// rederiveQcResult(observation) is the rule the QC readers call; the producer
|
|
35
|
+
// builds every row through it, and the availability outcome is a function of
|
|
36
|
+
// the stored request chain, so a stored outcome only stands when the chain
|
|
37
|
+
// re-derives to it.
|
|
38
|
+
import { createHash } from "node:crypto";
|
|
39
|
+
|
|
40
|
+
import { runWithDeadline } from "./deadline.mjs";
|
|
41
|
+
import { buildQcResult, toQaAssertion } from "./qc-results.mjs";
|
|
42
|
+
import { REDACTED_QUERY, TRUNCATED, redactPersisted } from "./qa-url-privacy.mjs";
|
|
43
|
+
|
|
44
|
+
export const POLICY_PRESENCE_CHECK = "policy.presence";
|
|
45
|
+
export const POLICY_AVAILABILITY_CHECK = "policy.availability";
|
|
46
|
+
const CHECKS = Object.freeze([POLICY_PRESENCE_CHECK, POLICY_AVAILABILITY_CHECK]);
|
|
47
|
+
const FAMILY = "browser-runtime";
|
|
48
|
+
const PAGE = "campaign";
|
|
49
|
+
|
|
50
|
+
// The CampaignSpec policy fields, in the order rows are written. store_url is
|
|
51
|
+
// not a policy.
|
|
52
|
+
export const POLICY_LINK_FIELDS = Object.freeze(["store_terms", "store_privacy", "store_returns", "store_shipping", "store_contact"]);
|
|
53
|
+
|
|
54
|
+
// Cost bound: at most 5 distinct URLs per run (one per field at most), all
|
|
55
|
+
// probed at once; per URL up to 6 requests (1 + 5 redirects), 5 s per request
|
|
56
|
+
// (its body cancel included) and a 15 s deadline; at most 30 requests. The
|
|
57
|
+
// anchor reads and the probes together add at most addedMs to the run.
|
|
58
|
+
export const POLICY_LINK_LIMITS = Object.freeze({
|
|
59
|
+
maxUrls: 5,
|
|
60
|
+
maxRedirects: 5,
|
|
61
|
+
requestTimeoutMs: 5_000,
|
|
62
|
+
deadlineMs: 15_000,
|
|
63
|
+
addedMs: 20_000,
|
|
64
|
+
});
|
|
65
|
+
// The anchor read of one page, after its load settled.
|
|
66
|
+
const ANCHOR_READ_MS = 5_000;
|
|
67
|
+
// A page with more anchors than this is not read (its read would be partial).
|
|
68
|
+
const MAX_ANCHORS = 10_000;
|
|
69
|
+
// Anchor text is compared in full, inside the page. A page with an anchor text
|
|
70
|
+
// longer than this many characters is not read (its read would be partial).
|
|
71
|
+
const MAX_TEXT = 1_048_576;
|
|
72
|
+
// What the probe waits for a canceled body to settle, within its request's
|
|
73
|
+
// deadline.
|
|
74
|
+
const CANCEL_SETTLE_MS = 1_000;
|
|
75
|
+
|
|
76
|
+
const SCHEMES = Object.freeze(["http", "https", "mailto", "other", "invalid"]);
|
|
77
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
78
|
+
const NOT_REQUESTED = "browser_checks_not_requested";
|
|
79
|
+
const INVALID = "configured_url_invalid";
|
|
80
|
+
const NON_HTTP = "non_http_destination";
|
|
81
|
+
const NETWORK = "network_unavailable";
|
|
82
|
+
const PAGES_NOT_READ = "pages_not_read";
|
|
83
|
+
const HTML_TYPES = Object.freeze(["text/html", "application/xhtml+xml"]);
|
|
84
|
+
|
|
85
|
+
// Availability outcome → result.
|
|
86
|
+
const AVAILABILITY_RESULTS = Object.freeze({
|
|
87
|
+
pass: "pass",
|
|
88
|
+
not_found: "warning",
|
|
89
|
+
server_error: "warning",
|
|
90
|
+
redirected_to_root: "warning",
|
|
91
|
+
redirected_elsewhere: "review",
|
|
92
|
+
redirect_loop: "review",
|
|
93
|
+
redirect_limit: "review",
|
|
94
|
+
auth_required: "review",
|
|
95
|
+
rate_limited: "review",
|
|
96
|
+
non_html_response: "review",
|
|
97
|
+
unexpected_status: "review",
|
|
98
|
+
[INVALID]: "review",
|
|
99
|
+
[NETWORK]: "unexercised",
|
|
100
|
+
[NON_HTTP]: "excluded",
|
|
101
|
+
[NOT_REQUESTED]: "excluded",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// The English wording hint per field, matched case-insensitively at the start
|
|
105
|
+
// of a word of the anchor text.
|
|
106
|
+
const HINTS = Object.freeze({
|
|
107
|
+
store_terms: ["terms"],
|
|
108
|
+
store_privacy: ["privacy"],
|
|
109
|
+
store_returns: ["refund", "return"],
|
|
110
|
+
store_shipping: ["shipping"],
|
|
111
|
+
store_contact: ["contact"],
|
|
112
|
+
});
|
|
113
|
+
// Each pattern's source; the anchor read tests it inside the page, flag "i".
|
|
114
|
+
const HINT_SOURCES = Object.freeze(Object.fromEntries(Object.entries(HINTS).map(([field, words]) => [field, `\\b(?:${words.join("|")})`])));
|
|
115
|
+
|
|
116
|
+
const SHA = /^sha256:[a-f0-9]{64}$/;
|
|
117
|
+
const CONTENT_TYPE = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/;
|
|
118
|
+
const isPlainObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
119
|
+
const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
|
|
120
|
+
const isCount = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
121
|
+
const sha256 = (text) => `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
|
122
|
+
// A stored string is written as the persisted-verdict projection
|
|
123
|
+
// (redactPersisted, which the verdict applies when it is assembled) leaves it.
|
|
124
|
+
const persistable = (text) => redactPersisted(text);
|
|
125
|
+
const isPersistable = (text) => typeof text === "string" && persistable(text) === text;
|
|
126
|
+
// Every stored object has a closed key list, defined next to the code that
|
|
127
|
+
// writes it. The producer writes each object through record(), so it holds
|
|
128
|
+
// exactly those keys; the reader refuses one that does not (hasExactKeys).
|
|
129
|
+
const record = (keys, values) => Object.fromEntries(keys.map((key) => [key, values[key]]));
|
|
130
|
+
const hasExactKeys = (value, keys) => isPlainObject(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Clock and run budget
|
|
134
|
+
|
|
135
|
+
// The clock every deadline reads: a monotonic now() in milliseconds and the
|
|
136
|
+
// timer pair runWithDeadline arms. Tests inject their own.
|
|
137
|
+
export const SYSTEM_CLOCK = Object.freeze({
|
|
138
|
+
now: () => performance.now(),
|
|
139
|
+
setTimer: (callback, ms) => setTimeout(callback, ms),
|
|
140
|
+
clearTimer: (handle) => clearTimeout(handle),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// The run budget of the policy link checks: at most `addedMs` of time added to
|
|
144
|
+
// the QA run (contract §1.4 Cost: "Added QA wall clock is at most 20 s").
|
|
145
|
+
// Created once, when the checks start, and passed to every step. A step is
|
|
146
|
+
// one anchor read, or the probes of all URLs at once; it runs through step(),
|
|
147
|
+
// which hands it `endsAt` (the clock time the budget runs out) and charges
|
|
148
|
+
// its elapsed time to the budget. The time between steps (the page visits) is
|
|
149
|
+
// not added time and is not charged. A step that finds the budget spent does
|
|
150
|
+
// nothing.
|
|
151
|
+
export function createPolicyLinkBudget({ addedMs = POLICY_LINK_LIMITS.addedMs, clock = SYSTEM_CLOCK } = {}) {
|
|
152
|
+
let spent = 0;
|
|
153
|
+
return {
|
|
154
|
+
clock,
|
|
155
|
+
async step(operation) {
|
|
156
|
+
const started = clock.now();
|
|
157
|
+
try {
|
|
158
|
+
return await operation(started + (addedMs - spent));
|
|
159
|
+
} finally {
|
|
160
|
+
spent += clock.now() - started;
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Runs `operation` until the clock reaches `endsAt` (see runWithDeadline).
|
|
167
|
+
const untilClock = (clock, endsAt, operation, options = {}) => runWithDeadline(operation, {
|
|
168
|
+
...options,
|
|
169
|
+
timeoutMs: endsAt - clock.now(),
|
|
170
|
+
setTimer: clock.setTimer,
|
|
171
|
+
clearTimer: clock.clearTimer,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Normalization and URL classes
|
|
176
|
+
|
|
177
|
+
// The query as a sorted key=value multiset, or null when it has no pair.
|
|
178
|
+
function queryKey(url) {
|
|
179
|
+
const pairs = [...url.searchParams].map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).sort();
|
|
180
|
+
return pairs.length ? pairs.join("&") : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const trimSlash = (path) => (path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path);
|
|
184
|
+
const safeDecode = (text) => {
|
|
185
|
+
try {
|
|
186
|
+
return decodeURIComponent(text);
|
|
187
|
+
} catch {
|
|
188
|
+
return text;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// The hashes of one raw http(s) URL that the availability rules compare:
|
|
193
|
+
// url_sha256 its exact origin+path (with query_sha256, a hop's identity for
|
|
194
|
+
// loops), path_sha256 its path with the trailing slash ignored, host_sha256
|
|
195
|
+
// its host without a leading "www." (the pass allowances). Stored as an http(s)
|
|
196
|
+
// configured value's configured_identity and as a hop's chain_identity entry.
|
|
197
|
+
const IDENTITY_KEYS = Object.freeze(["url_sha256", "path_sha256", "host_sha256"]);
|
|
198
|
+
const sameIdentity = (a, b) => IDENTITY_KEYS.every((key) => a[key] === b[key]);
|
|
199
|
+
// The hashes an accept state carries: path and host only, so a trailing slash
|
|
200
|
+
// or a leading "www." leaves the state as it was.
|
|
201
|
+
const STATE_IDENTITY_KEYS = Object.freeze(["path_sha256", "host_sha256"]);
|
|
202
|
+
const stateIdentity = (ids) => (ids === null ? null : record(STATE_IDENTITY_KEYS, ids));
|
|
203
|
+
const urlIdentity = (protocol, host, pathname) => record(IDENTITY_KEYS, {
|
|
204
|
+
url_sha256: sha256(`${protocol}//${host}${pathname}`),
|
|
205
|
+
path_sha256: sha256(trimSlash(pathname)),
|
|
206
|
+
host_sha256: sha256(host.replace(/^www\./, "")),
|
|
207
|
+
});
|
|
208
|
+
const ROOT_PATH_SHA = sha256("/");
|
|
209
|
+
|
|
210
|
+
// The projection's marker as the URL parser writes it in a path (its angle
|
|
211
|
+
// brackets percent-encoded). A stored path the projection redacted ends with
|
|
212
|
+
// the marker; parsed again, that suffix is read as the marker.
|
|
213
|
+
const ENCODED_REDACTED_QUERY = encodeURI(REDACTED_QUERY);
|
|
214
|
+
const withMarker = (text) => (text.endsWith(ENCODED_REDACTED_QUERY) ? `${text.slice(0, -ENCODED_REDACTED_QUERY.length)}${REDACTED_QUERY}` : text);
|
|
215
|
+
|
|
216
|
+
// The stored value of a URL whose projection does not read back as itself:
|
|
217
|
+
// its scheme (and, where the scheme needs one, its host) with the marker.
|
|
218
|
+
function placeholderOf(url) {
|
|
219
|
+
if (url.protocol === "mailto:") return `mailto:${REDACTED_QUERY}`;
|
|
220
|
+
const opaque = `${url.protocol}${REDACTED_QUERY}`;
|
|
221
|
+
return URL.canParse(opaque) ? opaque : `${url.protocol}//${url.host}/${REDACTED_QUERY}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The stored value of a URL whose placeholder does not read back as itself
|
|
225
|
+
// either (its scheme or host is too long for the projection's bound): a fixed
|
|
226
|
+
// value of its class, with the marker.
|
|
227
|
+
const BOUNDED_PLACEHOLDERS = Object.freeze({
|
|
228
|
+
http: `http://host-redacted.invalid/${REDACTED_QUERY}`,
|
|
229
|
+
https: `https://host-redacted.invalid/${REDACTED_QUERY}`,
|
|
230
|
+
mailto: `mailto:${REDACTED_QUERY}`,
|
|
231
|
+
other: `scheme-redacted:${REDACTED_QUERY}`,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// One URL as the rules compare it, in one pass (see parseTarget).
|
|
235
|
+
function classifyTarget(value) {
|
|
236
|
+
if (typeof value !== "string" || !value.trim()) return { scheme: "invalid" };
|
|
237
|
+
let url;
|
|
238
|
+
try {
|
|
239
|
+
url = new URL(value.trim());
|
|
240
|
+
} catch {
|
|
241
|
+
return { scheme: "invalid" };
|
|
242
|
+
}
|
|
243
|
+
const protocol = url.protocol.toLowerCase();
|
|
244
|
+
if (protocol === "http:" || protocol === "https:") {
|
|
245
|
+
if (!url.hostname) return { scheme: "invalid" };
|
|
246
|
+
url.hash = "";
|
|
247
|
+
url.username = "";
|
|
248
|
+
url.password = "";
|
|
249
|
+
const query = queryKey(url);
|
|
250
|
+
const originPath = `${protocol}//${url.host}${url.pathname}`;
|
|
251
|
+
return {
|
|
252
|
+
scheme: protocol.slice(0, -1),
|
|
253
|
+
stored: persistable(withMarker(originPath)),
|
|
254
|
+
placeholder: placeholderOf(url),
|
|
255
|
+
query,
|
|
256
|
+
querySha: query == null ? null : sha256(query),
|
|
257
|
+
ids: urlIdentity(protocol, url.host, url.pathname),
|
|
258
|
+
match: `${protocol}//${url.host}${trimSlash(url.pathname)}`,
|
|
259
|
+
host: url.host,
|
|
260
|
+
request: url.href,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if (protocol === "mailto:") {
|
|
264
|
+
const address = safeDecode(url.pathname).trim().toLowerCase();
|
|
265
|
+
return { scheme: "mailto", stored: persistable(`mailto:${address}`), placeholder: placeholderOf(url), query: null, querySha: null, match: `mailto:${address}` };
|
|
266
|
+
}
|
|
267
|
+
const bare = url.href.split(/[?#]/)[0].replace(/^([a-z][a-z0-9+.-]*:\/\/)[^@/]*@/i, "$1");
|
|
268
|
+
return { scheme: "other", stored: persistable(withMarker(bare)), placeholder: placeholderOf(url), query: null, querySha: null, match: bare };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// One URL as the rules compare it. `scheme` is the class; `stored` is the
|
|
272
|
+
// persisted value; for http(s), `ids` are the hashes of the raw URL
|
|
273
|
+
// (urlIdentity), `match` the presence identity (trailing slash ignored, query
|
|
274
|
+
// kept) and `request` the URL a probe fetches (no fragment, no userinfo). The
|
|
275
|
+
// query text never leaves this object.
|
|
276
|
+
//
|
|
277
|
+
// Invariant: every stored value parseTarget returns is a fixed point of both
|
|
278
|
+
// parseTarget and the persisted-verdict projection, so a stored identity
|
|
279
|
+
// persisted in a verdict reads back as itself (readIdentity holds every
|
|
280
|
+
// stored identity to that). The projection is one when, classified again, it
|
|
281
|
+
// is the same class with the same stored value. When it is not (the
|
|
282
|
+
// persisted-verdict projection replaced it whole or cut it, or it no longer
|
|
283
|
+
// classifies as a URL of its class), the placeholder is stored:
|
|
284
|
+
// it depends only on the scheme and host it names, so it parses back to
|
|
285
|
+
// itself, and it is one when the projection leaves it unchanged. A
|
|
286
|
+
// placeholder longer than the projection's bound is not, and the bounded
|
|
287
|
+
// placeholder of its class is stored instead. Decisions never read the stored
|
|
288
|
+
// value: they compare `ids` and `match`, taken from the raw URL.
|
|
289
|
+
function parseTarget(value) {
|
|
290
|
+
const { placeholder, ...target } = classifyTarget(value);
|
|
291
|
+
if (target.scheme === "invalid") return target;
|
|
292
|
+
const again = classifyTarget(target.stored);
|
|
293
|
+
if (again.scheme === target.scheme && again.stored === target.stored) return target;
|
|
294
|
+
return { ...target, stored: persistable(placeholder) === placeholder ? placeholder : BOUNDED_PLACEHOLDERS[target.scheme] };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const sameTarget = (a, b) => Boolean(a?.match) && a.match === b.match && a.query === b.query;
|
|
298
|
+
const samePath = (a, b) => Boolean(a?.match) && a.match === b.match;
|
|
299
|
+
|
|
300
|
+
// The configured policy fields of a spec: every non-empty string value (the
|
|
301
|
+
// fields the QC handoff treats as applicable).
|
|
302
|
+
export function configuredPolicyFields(spec) {
|
|
303
|
+
const campaign = isPlainObject(spec?.campaign) ? spec.campaign : {};
|
|
304
|
+
return POLICY_LINK_FIELDS.filter((field) => isNonEmptyString(campaign[field])).map((field) => ({ field, value: campaign[field] }));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export const hasPolicyLinkFields = (spec) => configuredPolicyFields(spec).length > 0;
|
|
308
|
+
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// Presence
|
|
311
|
+
|
|
312
|
+
const normalText = (text) => String(text ?? "").replace(/\s+/g, " ").trim();
|
|
313
|
+
|
|
314
|
+
// The labels footer_links declares for the configured URL.
|
|
315
|
+
function declaredLabels(spec, target) {
|
|
316
|
+
const links = Array.isArray(spec?.campaign?.footer_links) ? spec.campaign.footer_links : [];
|
|
317
|
+
const labels = [];
|
|
318
|
+
for (const link of links) {
|
|
319
|
+
if (!isPlainObject(link) || !isNonEmptyString(link.label) || typeof link.url !== "string") continue;
|
|
320
|
+
if (sameTarget(parseTarget(link.url), target)) labels.push(normalText(link.label));
|
|
321
|
+
}
|
|
322
|
+
return labels;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// The declared labels of every configured field, by field: what the anchor
|
|
326
|
+
// read compares each anchor's text with.
|
|
327
|
+
function declaredLabelsByField(spec) {
|
|
328
|
+
const labels = {};
|
|
329
|
+
for (const { field, value } of configuredPolicyFields(spec)) {
|
|
330
|
+
const target = parseTarget(value);
|
|
331
|
+
if (target.scheme !== "invalid") labels[field] = declaredLabels(spec, target);
|
|
332
|
+
}
|
|
333
|
+
return labels;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// The stored presence block: the counts presenceCounts writes.
|
|
337
|
+
const PRESENCE_KEYS = Object.freeze(["pages_expected", "pages_read", "pages_with_match", "path_match_query_differs", "label_declared", "label_anchor_mismatch_pages", "hint_anchor_elsewhere"]);
|
|
338
|
+
|
|
339
|
+
// The presence counts of one configured field over the visited pages
|
|
340
|
+
// ({read, anchors}; each anchor {href, label_fields, hint_fields} as
|
|
341
|
+
// readPageAnchors gives it). Only pages whose anchors were read are compared.
|
|
342
|
+
export function presenceCounts({ field, target, pages, labels = [] }) {
|
|
343
|
+
const counts = {
|
|
344
|
+
pages_expected: pages.length,
|
|
345
|
+
pages_read: pages.filter((page) => page.read === true).length,
|
|
346
|
+
pages_with_match: 0,
|
|
347
|
+
path_match_query_differs: 0,
|
|
348
|
+
label_declared: labels.length > 0,
|
|
349
|
+
label_anchor_mismatch_pages: 0,
|
|
350
|
+
hint_anchor_elsewhere: 0,
|
|
351
|
+
};
|
|
352
|
+
if (target.scheme === "invalid") return record(PRESENCE_KEYS, { ...counts, label_declared: false });
|
|
353
|
+
for (const page of pages) {
|
|
354
|
+
if (page.read !== true) continue;
|
|
355
|
+
let matched = false;
|
|
356
|
+
let queryDiffers = false;
|
|
357
|
+
let mislabelled = false;
|
|
358
|
+
for (const anchor of page.anchors) {
|
|
359
|
+
const points = parseTarget(anchor.href);
|
|
360
|
+
if (sameTarget(points, target)) {
|
|
361
|
+
matched = true;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if ((target.scheme === "http" || target.scheme === "https") && samePath(points, target)) queryDiffers = true;
|
|
365
|
+
if (labels.length && anchor.label_fields.includes(field)) mislabelled = true;
|
|
366
|
+
if (anchor.hint_fields.includes(field)) counts.hint_anchor_elsewhere += 1;
|
|
367
|
+
}
|
|
368
|
+
if (matched) counts.pages_with_match += 1;
|
|
369
|
+
if (queryDiffers) counts.path_match_query_differs += 1;
|
|
370
|
+
if (mislabelled) counts.label_anchor_mismatch_pages += 1;
|
|
371
|
+
}
|
|
372
|
+
return record(PRESENCE_KEYS, counts);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// Availability outcome (shared by the probe and the reader)
|
|
377
|
+
|
|
378
|
+
const isHttpStored = (text) => {
|
|
379
|
+
if (!isPersistable(text)) return false;
|
|
380
|
+
try {
|
|
381
|
+
const url = new URL(text);
|
|
382
|
+
return (url.protocol === "http:" || url.protocol === "https:") && !url.search && !url.hash;
|
|
383
|
+
} catch {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
const validQuery = (value) => value === null || SHA.test(value);
|
|
388
|
+
const validHop = (hop) => hasExactKeys(hop, HOP_KEYS) && isHttpStored(hop.url) && validQuery(hop.query_sha256) && Number.isSafeInteger(hop.status) && hop.status >= 100 && hop.status <= 599;
|
|
389
|
+
// The raw-URL hashes kept for a stored URL, in an object of exactly `keys`.
|
|
390
|
+
//
|
|
391
|
+
// Rule: the hashes agree with every part of the raw URL the stored URL still
|
|
392
|
+
// shows, whatever the projection redacted. The projection never cuts a host,
|
|
393
|
+
// so host_sha256 is always the hash of the stored host (only a bounded
|
|
394
|
+
// placeholder shows no host). When the stored path is shown whole (neither
|
|
395
|
+
// redacted nor truncated), the stored URL is the raw origin+path, so
|
|
396
|
+
// url_sha256 (scheme, host and path) and path_sha256 are its hashes too.
|
|
397
|
+
// Otherwise the scheme is held by sameStoredUrl: requests with one url_sha256
|
|
398
|
+
// have one stored URL.
|
|
399
|
+
function validIdentity(ids, stored, keys = IDENTITY_KEYS) {
|
|
400
|
+
if (!hasExactKeys(ids, keys) || !IDENTITY_KEYS.every((key) => SHA.test(ids[key]))) return false;
|
|
401
|
+
const url = new URL(stored);
|
|
402
|
+
if (stored === BOUNDED_PLACEHOLDERS[url.protocol.slice(0, -1)]) return true;
|
|
403
|
+
const recomputed = urlIdentity(url.protocol, url.host, url.pathname);
|
|
404
|
+
const shown = stored.includes(REDACTED_QUERY) || stored.endsWith(TRUNCATED) ? ["host_sha256"] : IDENTITY_KEYS;
|
|
405
|
+
return shown.every((key) => ids[key] === recomputed[key]);
|
|
406
|
+
}
|
|
407
|
+
// Whether every pair [stored URL, its hashes] with the same url_sha256 has the
|
|
408
|
+
// same stored URL and hashes, as the stored value and the hashes are both
|
|
409
|
+
// taken from the raw origin+path alone.
|
|
410
|
+
function sameStoredUrl(entries) {
|
|
411
|
+
const byUrl = new Map();
|
|
412
|
+
for (const [stored, ids] of entries) {
|
|
413
|
+
const shown = JSON.stringify([stored, ids.path_sha256, ids.host_sha256]);
|
|
414
|
+
if ((byUrl.get(ids.url_sha256) ?? shown) !== shown) return false;
|
|
415
|
+
byUrl.set(ids.url_sha256, shown);
|
|
416
|
+
}
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
// A request's identity: its exact raw origin+path and its query.
|
|
420
|
+
const requestKey = (ids, querySha) => `${ids.url_sha256} ${querySha}`;
|
|
421
|
+
|
|
422
|
+
// What a stored http(s) URL shows of its destination: its host without a
|
|
423
|
+
// leading "www." and its path with the trailing slash ignored, or, when the
|
|
424
|
+
// projection cut the path (redacted or truncated), the start of the raw path
|
|
425
|
+
// it still shows (`cut`). null when it shows no destination (a bounded
|
|
426
|
+
// placeholder); false when it is not written as its own origin+path.
|
|
427
|
+
function shownDestination(stored) {
|
|
428
|
+
const url = new URL(stored);
|
|
429
|
+
if (stored === BOUNDED_PLACEHOLDERS[url.protocol.slice(0, -1)]) return null;
|
|
430
|
+
const origin = `${url.protocol}//${url.host}`;
|
|
431
|
+
if (!stored.startsWith(origin)) return false;
|
|
432
|
+
const marker = [REDACTED_QUERY, TRUNCATED].find((end) => stored.endsWith(end));
|
|
433
|
+
const path = stored.slice(origin.length, marker ? -marker.length : undefined);
|
|
434
|
+
return { host: url.host.replace(/^www\./, ""), path: marker ? path : trimSlash(path), cut: Boolean(marker) };
|
|
435
|
+
}
|
|
436
|
+
// Whether two shown paths can be one path with the trailing slash ignored: a
|
|
437
|
+
// cut path is the start of a raw path that is the other path or the other
|
|
438
|
+
// path with a slash added.
|
|
439
|
+
function shownPathsAgree(a, b) {
|
|
440
|
+
if (!a.cut && !b.cut) return a.path === b.path;
|
|
441
|
+
if (!b.cut) return `${b.path}/`.startsWith(a.path);
|
|
442
|
+
if (!a.cut) return `${a.path}/`.startsWith(b.path);
|
|
443
|
+
return a.path.startsWith(b.path) || b.path.startsWith(a.path);
|
|
444
|
+
}
|
|
445
|
+
const shownAgree = (a, b, compare) => a !== false && b !== false && (a === null || b === null || compare(a, b));
|
|
446
|
+
// Whether two stored URLs can be one destination under the pass allowances. A
|
|
447
|
+
// placeholder is one destination only with another placeholder.
|
|
448
|
+
const sameShownDestination = (a, b) => {
|
|
449
|
+
const [x, y] = [shownDestination(a), shownDestination(b)];
|
|
450
|
+
return (x === null) === (y === null) && shownAgree(x, y, (p, q) => p.host === q.host && shownPathsAgree(p, q));
|
|
451
|
+
};
|
|
452
|
+
// Whether a stored URL can be at the root path.
|
|
453
|
+
const showsRoot = (stored) => shownAgree(shownDestination(stored), { path: "/", cut: false }, shownPathsAgree);
|
|
454
|
+
|
|
455
|
+
// Whether the final URL is the configured one, allowing only a scheme, a
|
|
456
|
+
// leading "www." or a trailing-slash difference. Both are compared through the
|
|
457
|
+
// hashes of their raw URLs.
|
|
458
|
+
//
|
|
459
|
+
// Rule: an outcome that says two requests are one destination stands only
|
|
460
|
+
// when their stored URLs say so too, under exactly the allowances the
|
|
461
|
+
// decision makes; a stored chain where they do not re-derives to nothing.
|
|
462
|
+
// pass: the stored final and configured URLs agree but for the scheme, a
|
|
463
|
+
// leading "www." and a trailing slash (sameShownDestination), a bounded
|
|
464
|
+
// placeholder agreeing only with another placeholder; a redacted or
|
|
465
|
+
// truncated path is compared up to its cut. redirected_to_root: the stored
|
|
466
|
+
// final URL shows the root path (showsRoot). redirect_loop: the two requests have one url_sha256, so
|
|
467
|
+
// one stored URL (sameStoredUrl). The producer always writes agreeing stored
|
|
468
|
+
// URLs, as equal raw URLs give equal stored URLs.
|
|
469
|
+
function passesAs(final, configured) {
|
|
470
|
+
return final.query_sha256 === configured.query_sha256
|
|
471
|
+
&& final.ids.host_sha256 === configured.ids.host_sha256
|
|
472
|
+
&& final.ids.path_sha256 === configured.ids.path_sha256;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function finalOutcome(final, contentType, configured) {
|
|
476
|
+
const status = final.status;
|
|
477
|
+
if (status >= 200 && status < 300) {
|
|
478
|
+
if (!passesAs(final, configured)) {
|
|
479
|
+
if (final.ids.path_sha256 !== ROOT_PATH_SHA || configured.ids.path_sha256 === ROOT_PATH_SHA) return "redirected_elsewhere";
|
|
480
|
+
return showsRoot(final.url) ? "redirected_to_root" : null;
|
|
481
|
+
}
|
|
482
|
+
if (!sameShownDestination(final.url, configured.url)) return null;
|
|
483
|
+
return HTML_TYPES.includes(contentType) ? "pass" : "non_html_response";
|
|
484
|
+
}
|
|
485
|
+
if (status === 404 || status === 410) return "not_found";
|
|
486
|
+
if (status >= 500) return "server_error";
|
|
487
|
+
if (status === 401 || status === 403) return "auth_required";
|
|
488
|
+
if (status === 429) return "rate_limited";
|
|
489
|
+
return "unexpected_status";
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// The outcome a stored availability block re-derives to, or null when the
|
|
493
|
+
// block is not one a probe of the configured value can have written.
|
|
494
|
+
function availabilityOutcome({ scheme, configured, configured_query_sha256: configuredQuery, configured_identity: configuredIds, run_scope: runScope }, block, { maxRedirects = POLICY_LINK_LIMITS.maxRedirects } = {}) {
|
|
495
|
+
if (!hasExactKeys(block, BLOCK_KEYS) || !Array.isArray(block.chain)) return null;
|
|
496
|
+
const { chain, chain_identity: ids, next_hop: nextHop } = block;
|
|
497
|
+
if (!Array.isArray(ids)) return null;
|
|
498
|
+
const empty = chain.length === 0 && ids.length === 0 && block.final === null && block.final_query_sha256 === null && block.status === null && block.content_type === null && nextHop === null;
|
|
499
|
+
if (runScope === NOT_REQUESTED) return empty ? NOT_REQUESTED : null;
|
|
500
|
+
if (runScope != null) return null;
|
|
501
|
+
if (scheme === "invalid") return empty ? INVALID : null;
|
|
502
|
+
if (scheme === "mailto" || scheme === "other") return empty ? NON_HTTP : null;
|
|
503
|
+
if (!chain.length) return empty ? NETWORK : null;
|
|
504
|
+
if (chain.length > maxRedirects + 1 || !chain.every(validHop)) return null;
|
|
505
|
+
if (ids.length !== chain.length || !ids.every((hopIds, index) => validIdentity(hopIds, chain[index].url))) return null;
|
|
506
|
+
const requests = chain.map((hop, index) => [hop.url, ids[index]]);
|
|
507
|
+
if (!sameStoredUrl(requests)) return null;
|
|
508
|
+
// The first request is the configured URL, its hashes included; every hop
|
|
509
|
+
// but the last is a redirect; no request repeats (a repeat ends the chain as
|
|
510
|
+
// a loop instead).
|
|
511
|
+
if (chain[0].url !== configured || chain[0].query_sha256 !== configuredQuery || !sameIdentity(ids[0], configuredIds)) return null;
|
|
512
|
+
if (!chain.slice(0, -1).every((hop) => REDIRECT_STATUSES.has(hop.status))) return null;
|
|
513
|
+
const keys = chain.map((hop, index) => requestKey(ids[index], hop.query_sha256));
|
|
514
|
+
if (new Set(keys).size !== keys.length) return null;
|
|
515
|
+
const last = chain.at(-1);
|
|
516
|
+
if (block.final !== last.url || block.final_query_sha256 !== last.query_sha256 || block.status !== last.status) return null;
|
|
517
|
+
if (block.content_type !== null && !CONTENT_TYPE.test(block.content_type)) return null;
|
|
518
|
+
if (REDIRECT_STATUSES.has(last.status) && nextHop !== null) {
|
|
519
|
+
if (!isPlainObject(nextHop) || !isHttpStored(nextHop.url) || !validQuery(nextHop.query_sha256) || !validIdentity(nextHop, nextHop.url, NEXT_HOP_KEYS)) return null;
|
|
520
|
+
if (!sameStoredUrl([...requests, [nextHop.url, nextHop]])) return null;
|
|
521
|
+
if (keys.includes(requestKey(nextHop, nextHop.query_sha256))) return "redirect_loop";
|
|
522
|
+
if (chain.length === maxRedirects + 1) return "redirect_limit";
|
|
523
|
+
// The redirect was due but its request got no response in time.
|
|
524
|
+
return NETWORK;
|
|
525
|
+
}
|
|
526
|
+
if (nextHop !== null) return null;
|
|
527
|
+
return finalOutcome({ ...last, ids: ids.at(-1) }, block.content_type, { ...chain[0], ids: ids[0] });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
// Availability probe
|
|
532
|
+
|
|
533
|
+
// The stored availability block, as probePolicyUrl writes it.
|
|
534
|
+
const BLOCK_KEYS = Object.freeze(["chain", "chain_identity", "final", "final_query_sha256", "status", "content_type", "outcome", "next_hop"]);
|
|
535
|
+
const emptyBlock = (outcome) => record(BLOCK_KEYS, { chain: [], chain_identity: [], final: null, final_query_sha256: null, status: null, content_type: null, outcome, next_hop: null });
|
|
536
|
+
|
|
537
|
+
const contentTypeOf = (value) => {
|
|
538
|
+
const type = String(value ?? "").split(";")[0].trim().toLowerCase();
|
|
539
|
+
return CONTENT_TYPE.test(type) ? type : null;
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const cancelBody = (response) => {
|
|
543
|
+
try {
|
|
544
|
+
const canceled = response?.body?.cancel?.();
|
|
545
|
+
if (canceled && typeof canceled.then === "function") return canceled.then(() => {}, () => {});
|
|
546
|
+
} catch {
|
|
547
|
+
// A body that cannot be canceled has nothing more to settle.
|
|
548
|
+
}
|
|
549
|
+
return null;
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
// One GET with redirect "manual": the status and headers, then the body is
|
|
553
|
+
// canceled without reading it. The whole request, its body cancel included,
|
|
554
|
+
// ends by `endsAt` on `clock`: it rejects when it has not, whether or not
|
|
555
|
+
// `fetchImpl` honours the abort signal. A cancel that has not settled after
|
|
556
|
+
// CANCEL_SETTLE_MS (and before `endsAt`) is left to finish on its own.
|
|
557
|
+
async function headersOnly(fetchImpl, url, { endsAt, clock }) {
|
|
558
|
+
const controller = new AbortController();
|
|
559
|
+
const pending = Promise.resolve().then(() => fetchImpl(url, {
|
|
560
|
+
method: "GET",
|
|
561
|
+
redirect: "manual",
|
|
562
|
+
signal: controller.signal,
|
|
563
|
+
headers: { accept: "text/html,application/xhtml+xml;q=0.9,*/*;q=0.1" },
|
|
564
|
+
}));
|
|
565
|
+
try {
|
|
566
|
+
return await untilClock(clock, endsAt, async () => {
|
|
567
|
+
const response = await pending;
|
|
568
|
+
const answer = {
|
|
569
|
+
status: response.status,
|
|
570
|
+
location: response.headers?.get?.("location") ?? null,
|
|
571
|
+
contentType: contentTypeOf(response.headers?.get?.("content-type")),
|
|
572
|
+
};
|
|
573
|
+
const canceled = cancelBody(response);
|
|
574
|
+
if (canceled) await untilClock(clock, clock.now() + CANCEL_SETTLE_MS, () => canceled).catch(() => {});
|
|
575
|
+
return answer;
|
|
576
|
+
}, { onTimeout: () => controller.abort(), label: "probePolicyUrl" });
|
|
577
|
+
} catch (error) {
|
|
578
|
+
// A response that arrives after the deadline is canceled unread.
|
|
579
|
+
pending.then(cancelBody, () => {});
|
|
580
|
+
throw error;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// The next request of a redirect, or null when its Location is missing or
|
|
585
|
+
// is not an http(s) URL.
|
|
586
|
+
function redirectTarget(location, from) {
|
|
587
|
+
if (typeof location !== "string" || !location.trim()) return null;
|
|
588
|
+
let next;
|
|
589
|
+
try {
|
|
590
|
+
next = new URL(location.trim(), from);
|
|
591
|
+
} catch {
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
if (next.protocol !== "http:" && next.protocol !== "https:") return null;
|
|
595
|
+
const target = parseTarget(next.href);
|
|
596
|
+
return target.scheme === "http" || target.scheme === "https" ? target : null;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// A stored chain hop, and the stored next hop of a redirect that ended the
|
|
600
|
+
// chain (with its raw-URL hashes).
|
|
601
|
+
const HOP_KEYS = Object.freeze(["url", "query_sha256", "status"]);
|
|
602
|
+
const NEXT_HOP_KEYS = Object.freeze(["url", "query_sha256", ...IDENTITY_KEYS]);
|
|
603
|
+
const hopOf = (target, status) => record(HOP_KEYS, { url: target.stored, query_sha256: target.querySha, status });
|
|
604
|
+
const nextHopOf = (target) => record(NEXT_HOP_KEYS, { url: target.stored, query_sha256: target.querySha, ...target.ids });
|
|
605
|
+
|
|
606
|
+
// Probes one configured value: GET with redirect "manual", following up to
|
|
607
|
+
// `maxRedirects` redirects (off-origin included). Each request ends by the
|
|
608
|
+
// earliest of its own `timeoutMs`, the chain's `deadlineMs` and `endsAt` (the
|
|
609
|
+
// run budget's end), all on `clock`; nothing is requested once one of them is
|
|
610
|
+
// reached, and a response is accepted only when it, its body cancel included,
|
|
611
|
+
// completed before its request's end. Resolves to the availability block
|
|
612
|
+
// {chain, chain_identity, final, final_query_sha256, status, content_type,
|
|
613
|
+
// outcome, next_hop}; never rejects. A value that is not an absolute http(s)
|
|
614
|
+
// URL sends nothing. `fetchImpl` defaults to the global fetch at call time.
|
|
615
|
+
export async function probePolicyUrl(url, {
|
|
616
|
+
maxRedirects = POLICY_LINK_LIMITS.maxRedirects,
|
|
617
|
+
timeoutMs = POLICY_LINK_LIMITS.requestTimeoutMs,
|
|
618
|
+
deadlineMs = POLICY_LINK_LIMITS.deadlineMs,
|
|
619
|
+
fetchImpl = globalThis.fetch,
|
|
620
|
+
clock = SYSTEM_CLOCK,
|
|
621
|
+
endsAt = Infinity,
|
|
622
|
+
} = {}) {
|
|
623
|
+
const configured = parseTarget(url);
|
|
624
|
+
if (configured.scheme === "invalid") return emptyBlock(INVALID);
|
|
625
|
+
if (configured.scheme === "mailto" || configured.scheme === "other") return emptyBlock(NON_HTTP);
|
|
626
|
+
const deadlineAt = Math.min(clock.now() + deadlineMs, endsAt);
|
|
627
|
+
const chain = [];
|
|
628
|
+
const chainIdentity = [];
|
|
629
|
+
let contentType = null;
|
|
630
|
+
let nextHop = null;
|
|
631
|
+
let current = configured;
|
|
632
|
+
const finish = () => {
|
|
633
|
+
const last = chain.at(-1) ?? null;
|
|
634
|
+
const block = record(BLOCK_KEYS, {
|
|
635
|
+
chain,
|
|
636
|
+
chain_identity: chainIdentity,
|
|
637
|
+
final: last?.url ?? null,
|
|
638
|
+
final_query_sha256: last?.query_sha256 ?? null,
|
|
639
|
+
status: last?.status ?? null,
|
|
640
|
+
content_type: last ? contentType : null,
|
|
641
|
+
outcome: null,
|
|
642
|
+
next_hop: nextHop,
|
|
643
|
+
});
|
|
644
|
+
const observed = { scheme: configured.scheme, configured: configured.stored, configured_query_sha256: configured.querySha, configured_identity: configured.ids };
|
|
645
|
+
block.outcome = availabilityOutcome(observed, block, { maxRedirects });
|
|
646
|
+
// A chain the rules cannot read is kept as unanswered: never a pass.
|
|
647
|
+
return block.outcome ? block : emptyBlock(NETWORK);
|
|
648
|
+
};
|
|
649
|
+
const seen = new Set();
|
|
650
|
+
for (;;) {
|
|
651
|
+
const requestEndsAt = Math.min(clock.now() + timeoutMs, deadlineAt);
|
|
652
|
+
if (!(requestEndsAt > clock.now())) return finish();
|
|
653
|
+
let answer;
|
|
654
|
+
try {
|
|
655
|
+
answer = await headersOnly(fetchImpl, current.request, { endsAt: requestEndsAt, clock });
|
|
656
|
+
} catch {
|
|
657
|
+
return finish();
|
|
658
|
+
}
|
|
659
|
+
// A response whose processing ended at or after its deadline is not one
|
|
660
|
+
// the probe got in time.
|
|
661
|
+
if (!(clock.now() < requestEndsAt)) return finish();
|
|
662
|
+
chain.push(hopOf(current, answer.status));
|
|
663
|
+
chainIdentity.push(current.ids);
|
|
664
|
+
seen.add(requestKey(current.ids, current.querySha));
|
|
665
|
+
contentType = answer.contentType;
|
|
666
|
+
nextHop = null;
|
|
667
|
+
if (!REDIRECT_STATUSES.has(answer.status)) return finish();
|
|
668
|
+
const next = redirectTarget(answer.location, current.request);
|
|
669
|
+
if (!next) return finish();
|
|
670
|
+
nextHop = nextHopOf(next);
|
|
671
|
+
// A repeat is found in memory (exact raw URLs, query included), before
|
|
672
|
+
// the repeated URL is requested again.
|
|
673
|
+
if (seen.has(requestKey(next.ids, next.querySha))) return finish();
|
|
674
|
+
if (chain.length > maxRedirects) return finish();
|
|
675
|
+
current = next;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ---------------------------------------------------------------------------
|
|
680
|
+
// Result rules
|
|
681
|
+
|
|
682
|
+
const PRESENCE_COUNTS = Object.freeze(PRESENCE_KEYS.filter((key) => key !== "label_declared"));
|
|
683
|
+
|
|
684
|
+
// The identity fields shared by both checks, or null when they are not
|
|
685
|
+
// consistent with the scheme: a stored configured value is one the producer
|
|
686
|
+
// writes for its class, so classified again it is that class and that stored
|
|
687
|
+
// value, byte for byte.
|
|
688
|
+
function readIdentity(observation) {
|
|
689
|
+
if (!isPlainObject(observation)) return null;
|
|
690
|
+
const { check, field, configured, configured_query_sha256: configuredQuery, configured_identity: configuredIds, scheme } = observation;
|
|
691
|
+
if (!CHECKS.includes(check) || !POLICY_LINK_FIELDS.includes(field) || !SCHEMES.includes(scheme)) return null;
|
|
692
|
+
const scoped = Object.hasOwn(observation, "run_scope");
|
|
693
|
+
if (!hasExactKeys(observation, observationKeys(check, scoped))) return null;
|
|
694
|
+
if (!validQuery(configuredQuery)) return null;
|
|
695
|
+
const runScope = scoped ? observation.run_scope : null;
|
|
696
|
+
if (scoped && runScope !== NOT_REQUESTED) return null;
|
|
697
|
+
if (scheme === "invalid") {
|
|
698
|
+
if (configured !== null || configuredQuery !== null || configuredIds !== null) return null;
|
|
699
|
+
} else {
|
|
700
|
+
const target = parseTarget(configured);
|
|
701
|
+
if (target.scheme !== scheme || target.stored !== configured) return null;
|
|
702
|
+
// Only an http(s) value keeps a query (as its hash) and the hashes of its
|
|
703
|
+
// raw URL, which agree with what its stored value shows.
|
|
704
|
+
if (scheme === "http" || scheme === "https") {
|
|
705
|
+
if (!validIdentity(configuredIds, configured)) return null;
|
|
706
|
+
} else if (configuredQuery !== null || configuredIds !== null) {
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return { check, field, configured, configured_query_sha256: configuredQuery, configured_identity: configuredIds, scheme, run_scope: runScope };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function readPresence(presence, scheme) {
|
|
714
|
+
if (!hasExactKeys(presence, PRESENCE_KEYS) || !PRESENCE_COUNTS.every((key) => isCount(presence[key])) || typeof presence.label_declared !== "boolean") return null;
|
|
715
|
+
const { pages_expected: expected, pages_read: read } = presence;
|
|
716
|
+
if (read > expected) return null;
|
|
717
|
+
if (["pages_with_match", "path_match_query_differs", "label_anchor_mismatch_pages"].some((key) => presence[key] > read)) return null;
|
|
718
|
+
if (presence.label_anchor_mismatch_pages > 0 && !presence.label_declared) return null;
|
|
719
|
+
if (!(scheme === "http" || scheme === "https") && presence.path_match_query_differs !== 0) return null;
|
|
720
|
+
if (read === 0 && presence.hint_anchor_elsewhere !== 0) return null;
|
|
721
|
+
return presence;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function decidePresence(identity, presence) {
|
|
725
|
+
if (identity.run_scope === NOT_REQUESTED) return { result: "excluded", reason_code: NOT_REQUESTED, read: false };
|
|
726
|
+
if (identity.scheme === "invalid") return { result: "review", reason_code: INVALID, read: false };
|
|
727
|
+
// Presence is decided only when every expected page had its anchors read.
|
|
728
|
+
if (!(presence.pages_expected > 0 && presence.pages_read === presence.pages_expected)) return { result: "unexercised", reason_code: PAGES_NOT_READ, read: true };
|
|
729
|
+
if (presence.label_anchor_mismatch_pages > 0) return { result: "warning", reason_code: "destination_mismatch", read: true };
|
|
730
|
+
if (presence.pages_with_match === 0 && presence.path_match_query_differs > 0) return { result: "warning", reason_code: "policy_link_query_differs", read: true };
|
|
731
|
+
if (presence.pages_with_match === 0) return { result: "warning", reason_code: "policy_link_absent", read: true };
|
|
732
|
+
if (presence.hint_anchor_elsewhere > 0) return { result: "review", reason_code: "possible_policy_link_mismatch", read: true };
|
|
733
|
+
return { result: "pass", reason_code: null, read: true };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function derivePresence(identity, observation) {
|
|
737
|
+
const presence = readPresence(observation.presence, identity.scheme);
|
|
738
|
+
if (!presence) return null;
|
|
739
|
+
const decided = decidePresence(identity, presence);
|
|
740
|
+
const coverage = decided.read
|
|
741
|
+
? { observed: presence.pages_read, expected: presence.pages_expected, limits: decided.result === "unexercised" ? [PAGES_NOT_READ] : [] }
|
|
742
|
+
: { observed: 0, expected: null, limits: [] };
|
|
743
|
+
return {
|
|
744
|
+
decided,
|
|
745
|
+
coverage,
|
|
746
|
+
state: {
|
|
747
|
+
reason_code: decided.reason_code,
|
|
748
|
+
configured: identity.configured,
|
|
749
|
+
configured_query_sha256: identity.configured_query_sha256,
|
|
750
|
+
configured_identity: stateIdentity(identity.configured_identity),
|
|
751
|
+
pages_expected: presence.pages_expected,
|
|
752
|
+
pages_read: presence.pages_read,
|
|
753
|
+
pages_with_match: presence.pages_with_match,
|
|
754
|
+
path_match_query_differs: presence.path_match_query_differs,
|
|
755
|
+
label_anchor_mismatch_pages: presence.label_anchor_mismatch_pages,
|
|
756
|
+
hint_anchor_elsewhere: presence.hint_anchor_elsewhere,
|
|
757
|
+
},
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function deriveAvailability(identity, observation) {
|
|
762
|
+
const block = observation.availability;
|
|
763
|
+
const outcome = availabilityOutcome(identity, block);
|
|
764
|
+
// The stored outcome stands only when the chain re-derives to it.
|
|
765
|
+
if (!outcome || block.outcome !== outcome) return null;
|
|
766
|
+
const result = AVAILABILITY_RESULTS[outcome];
|
|
767
|
+
const reasonCode = outcome === "pass" ? null : outcome;
|
|
768
|
+
const coverage = result === "excluded" || outcome === INVALID
|
|
769
|
+
? { observed: 0, expected: null, limits: [] }
|
|
770
|
+
: outcome === NETWORK
|
|
771
|
+
? { observed: 0, expected: 1, limits: [NETWORK] }
|
|
772
|
+
: { observed: 1, expected: 1, limits: [] };
|
|
773
|
+
return {
|
|
774
|
+
decided: { result, reason_code: reasonCode },
|
|
775
|
+
coverage,
|
|
776
|
+
state: {
|
|
777
|
+
reason_code: reasonCode,
|
|
778
|
+
configured: identity.configured,
|
|
779
|
+
configured_query_sha256: identity.configured_query_sha256,
|
|
780
|
+
configured_identity: stateIdentity(identity.configured_identity),
|
|
781
|
+
chain: block.chain.map(({ url, query_sha256: querySha, status }) => ({ url, query_sha256: querySha, status })),
|
|
782
|
+
chain_identity: block.chain_identity.map(stateIdentity),
|
|
783
|
+
final: block.final,
|
|
784
|
+
final_query_sha256: block.final_query_sha256,
|
|
785
|
+
status: block.status,
|
|
786
|
+
content_type: block.content_type,
|
|
787
|
+
next_hop_identity: stateIdentity(block.next_hop),
|
|
788
|
+
},
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Re-derives one row from its stored observation, or returns null when the
|
|
793
|
+
// observation is not one this rule can read (the reader then reports
|
|
794
|
+
// evidence_not_reproducible).
|
|
795
|
+
export function rederiveQcResult(observation) {
|
|
796
|
+
try {
|
|
797
|
+
const identity = readIdentity(observation);
|
|
798
|
+
if (!identity) return null;
|
|
799
|
+
const derived = identity.check === POLICY_PRESENCE_CHECK ? derivePresence(identity, observation) : deriveAvailability(identity, observation);
|
|
800
|
+
if (!derived) return null;
|
|
801
|
+
const { result, reason_code: reasonCode } = derived.decided;
|
|
802
|
+
return {
|
|
803
|
+
check: identity.check,
|
|
804
|
+
subject: { check: identity.check, page: PAGE, key: identity.field },
|
|
805
|
+
result,
|
|
806
|
+
reason_code: reasonCode,
|
|
807
|
+
members: [],
|
|
808
|
+
accept_eligible: result === "warning",
|
|
809
|
+
coverage: derived.coverage,
|
|
810
|
+
state: derived.state,
|
|
811
|
+
};
|
|
812
|
+
} catch {
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// ---------------------------------------------------------------------------
|
|
818
|
+
// Rows and assertions
|
|
819
|
+
|
|
820
|
+
export function policyLinkQcRow(observation, { measuredAt = new Date().toISOString() } = {}) {
|
|
821
|
+
const derived = rederiveQcResult(observation);
|
|
822
|
+
if (!derived) return null;
|
|
823
|
+
return buildQcResult({
|
|
824
|
+
check: derived.check,
|
|
825
|
+
leg: "qa",
|
|
826
|
+
subject: derived.subject,
|
|
827
|
+
result: derived.result,
|
|
828
|
+
reason_code: derived.reason_code,
|
|
829
|
+
state: derived.state,
|
|
830
|
+
observation,
|
|
831
|
+
members: derived.members,
|
|
832
|
+
accept_eligible: derived.accept_eligible,
|
|
833
|
+
coverage: derived.coverage,
|
|
834
|
+
measured_at: measuredAt,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// The row's verdict assertion, under the id qc.<check>:<field>. It names its
|
|
839
|
+
// row in evidence.qc.result_id, which is how readers pair them.
|
|
840
|
+
export function policyLinkQaAssertion(row) {
|
|
841
|
+
return { ...toQaAssertion(row, { family: FAMILY }), id: `qc.${row.check}:${row.subject.key}` };
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// The stored observation of one row: the field's identity, the check's block
|
|
845
|
+
// and, for a run-scope row only, run_scope.
|
|
846
|
+
const observationKeys = (check, scoped) => [
|
|
847
|
+
"check", "field", "configured", "configured_query_sha256", "configured_identity", "scheme",
|
|
848
|
+
check === POLICY_PRESENCE_CHECK ? "presence" : "availability",
|
|
849
|
+
...(scoped ? ["run_scope"] : []),
|
|
850
|
+
];
|
|
851
|
+
const identityOf = (check, field, target) => ({
|
|
852
|
+
check,
|
|
853
|
+
field,
|
|
854
|
+
configured: target.scheme === "invalid" ? null : target.stored,
|
|
855
|
+
configured_query_sha256: target.scheme === "invalid" ? null : target.querySha,
|
|
856
|
+
configured_identity: target.ids ?? null,
|
|
857
|
+
scheme: target.scheme,
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
const emptyPresence = (pagesExpected) => record(PRESENCE_KEYS, {
|
|
861
|
+
pages_expected: pagesExpected,
|
|
862
|
+
pages_read: 0,
|
|
863
|
+
pages_with_match: 0,
|
|
864
|
+
path_match_query_differs: 0,
|
|
865
|
+
label_declared: false,
|
|
866
|
+
label_anchor_mismatch_pages: 0,
|
|
867
|
+
hint_anchor_elsewhere: 0,
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
// Both rows of one field. An observation the rules cannot read (which the
|
|
871
|
+
// producer never writes) is kept unread: unexercised, never a pass.
|
|
872
|
+
function fieldRows({ field, target, presence, availability, runScope = null }, measuredAt) {
|
|
873
|
+
const observationOf = (check, block) => record(observationKeys(check, Boolean(runScope)), { ...identityOf(check, field, target), ...block, run_scope: runScope });
|
|
874
|
+
const presenceObservation = observationOf(POLICY_PRESENCE_CHECK, { presence });
|
|
875
|
+
const availabilityObservation = observationOf(POLICY_AVAILABILITY_CHECK, { availability });
|
|
876
|
+
const presenceRow = policyLinkQcRow(presenceObservation, { measuredAt })
|
|
877
|
+
?? policyLinkQcRow({ ...presenceObservation, presence: emptyPresence(presence.pages_expected) }, { measuredAt });
|
|
878
|
+
const availabilityRow = policyLinkQcRow(availabilityObservation, { measuredAt })
|
|
879
|
+
?? policyLinkQcRow({ ...availabilityObservation, availability: emptyBlock(target.scheme === "http" || target.scheme === "https" ? NETWORK : availability.outcome) }, { measuredAt });
|
|
880
|
+
return [presenceRow, availabilityRow].filter(Boolean);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const topologyPageCount = (topologies) => (Array.isArray(topologies) ? topologies : [])
|
|
884
|
+
.reduce((total, topology) => total + (Array.isArray(topology?.pages) ? topology.pages.length : 0), 0);
|
|
885
|
+
|
|
886
|
+
// The run-scope rows of a QA run without browser checks: presence and
|
|
887
|
+
// availability excluded / browser_checks_not_requested for every configured
|
|
888
|
+
// field. Nothing is read and no request is made.
|
|
889
|
+
export function policyLinkNotRequestedRows(spec, topologies, { measuredAt = new Date().toISOString() } = {}) {
|
|
890
|
+
const pagesExpected = topologyPageCount(topologies);
|
|
891
|
+
return configuredPolicyFields(spec).flatMap(({ field, value }) => fieldRows({
|
|
892
|
+
field,
|
|
893
|
+
target: parseTarget(value),
|
|
894
|
+
presence: emptyPresence(pagesExpected),
|
|
895
|
+
availability: emptyBlock(NOT_REQUESTED),
|
|
896
|
+
runScope: NOT_REQUESTED,
|
|
897
|
+
}, measuredAt));
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const isFieldList = (value) => Array.isArray(value) && value.every((field) => POLICY_LINK_FIELDS.includes(field));
|
|
901
|
+
const isReadAnchor = (anchor) => isPlainObject(anchor) && typeof anchor.href === "string" && isFieldList(anchor.label_fields) && isFieldList(anchor.hint_fields);
|
|
902
|
+
|
|
903
|
+
// The policy link leg of `qa run --browser`, run after the page checks.
|
|
904
|
+
// `pages` holds one {read, anchors} entry per page visit the page checks
|
|
905
|
+
// attempted (readPageAnchors). Each distinct configured http(s) URL is probed
|
|
906
|
+
// once, all at once, as one step of `budget` (the run budget the anchor reads
|
|
907
|
+
// drew on). Returns the rows and their verdict assertions.
|
|
908
|
+
export async function runPolicyLinkChecks({ spec, pages = [], fetchImpl, measuredAt = null, budget = createPolicyLinkBudget() } = {}) {
|
|
909
|
+
const fields = configuredPolicyFields(spec).map(({ field, value }) => ({ field, target: parseTarget(value) }));
|
|
910
|
+
if (!fields.length) return { rows: [], assertions: [] };
|
|
911
|
+
const visits = (Array.isArray(pages) ? pages : []).map((page) => (page?.read === true && Array.isArray(page.anchors) && page.anchors.every(isReadAnchor) ? { read: true, anchors: page.anchors } : { read: false, anchors: [] }));
|
|
912
|
+
// There are at most five policy fields, so at most POLICY_LINK_LIMITS.maxUrls
|
|
913
|
+
// distinct URLs.
|
|
914
|
+
const urls = [];
|
|
915
|
+
for (const { target } of fields) {
|
|
916
|
+
if ((target.scheme === "http" || target.scheme === "https") && !urls.includes(target.request) && urls.length < POLICY_LINK_LIMITS.maxUrls) urls.push(target.request);
|
|
917
|
+
}
|
|
918
|
+
// A probe the budget leaves no time for sends nothing and reads
|
|
919
|
+
// network_unavailable (contract §1.4 Cost).
|
|
920
|
+
const blocks = new Map(urls.length ? await budget.step((endsAt) => Promise.all(urls.map(async (url) => [url, await probePolicyUrl(url, {
|
|
921
|
+
...(fetchImpl ? { fetchImpl } : {}),
|
|
922
|
+
clock: budget.clock,
|
|
923
|
+
endsAt,
|
|
924
|
+
})]))) : []);
|
|
925
|
+
const at = measuredAt ?? new Date().toISOString();
|
|
926
|
+
const rows = fields.flatMap(({ field, target }) => {
|
|
927
|
+
const availability = target.scheme === "http" || target.scheme === "https"
|
|
928
|
+
? structuredClone(blocks.get(target.request) ?? emptyBlock(NETWORK))
|
|
929
|
+
: emptyBlock(target.scheme === "invalid" ? INVALID : NON_HTTP);
|
|
930
|
+
const presence = presenceCounts({ field, target, pages: visits, labels: target.scheme === "invalid" ? [] : declaredLabels(spec, target) });
|
|
931
|
+
return fieldRows({ field, target, presence, availability }, at);
|
|
932
|
+
});
|
|
933
|
+
return { rows, assertions: rows.map(policyLinkQaAssertion) };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// ---------------------------------------------------------------------------
|
|
937
|
+
// Anchor reader
|
|
938
|
+
|
|
939
|
+
// The page's rendered anchors are read in an isolated world of the main
|
|
940
|
+
// frame, reached through the Chrome DevTools Protocol: the page's own scripts
|
|
941
|
+
// share the DOM with that world but not its globals or prototypes, so a page
|
|
942
|
+
// that overrides querySelectorAll, href or textContent cannot change what is
|
|
943
|
+
// read. Each a[href] gives its href as the browser resolves it against
|
|
944
|
+
// document.baseURI. Its text is compared in that world, in full, and never
|
|
945
|
+
// leaves it: the read keeps the fields whose declared label equals the text
|
|
946
|
+
// (whitespace collapsed, as declaredLabels normalizes a label) and the fields
|
|
947
|
+
// whose wording hint it holds. A page whose read would be partial (too many
|
|
948
|
+
// anchors, or a text over MAX_TEXT) is not read.
|
|
949
|
+
const WORLD_NAME = "campaigns-os-policy-links";
|
|
950
|
+
|
|
951
|
+
function anchorReadExpression(maxAnchors, maxText, labels, hints) {
|
|
952
|
+
const anchors = document.querySelectorAll("a[href]");
|
|
953
|
+
if (anchors.length > maxAnchors) return { complete: false, anchors: [] };
|
|
954
|
+
const patterns = Object.keys(hints).map((field) => [field, new RegExp(hints[field], "i")]);
|
|
955
|
+
const read = [];
|
|
956
|
+
for (const anchor of Array.from(anchors)) {
|
|
957
|
+
let href = anchor.href;
|
|
958
|
+
if (typeof href !== "string") {
|
|
959
|
+
try {
|
|
960
|
+
href = new URL(anchor.getAttribute("href"), document.baseURI).href;
|
|
961
|
+
} catch {
|
|
962
|
+
href = String(anchor.getAttribute("href") ?? "");
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
const text = String(anchor.textContent ?? "");
|
|
966
|
+
if (text.length > maxText) return { complete: false, anchors: [] };
|
|
967
|
+
const normal = text.replace(/\s+/g, " ").trim();
|
|
968
|
+
read.push({
|
|
969
|
+
href,
|
|
970
|
+
label_fields: Object.keys(labels).filter((field) => labels[field].includes(normal)),
|
|
971
|
+
hint_fields: patterns.filter(([, pattern]) => pattern.test(text)).map(([field]) => field),
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
return { complete: true, anchors: read };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const anchorReadSource = (spec) => `(${anchorReadExpression})(${MAX_ANCHORS}, ${MAX_TEXT}, ${JSON.stringify(declaredLabelsByField(spec))}, ${JSON.stringify(HINT_SOURCES)})`;
|
|
978
|
+
|
|
979
|
+
// The anchors of the page's current document, or null when they could not be
|
|
980
|
+
// read in full (the page then counts as not read). The read is one step of
|
|
981
|
+
// `budget` and ends by the earlier of ANCHOR_READ_MS and the budget's end; it
|
|
982
|
+
// does not start once the budget is spent, and its anchors are kept only when
|
|
983
|
+
// it completed before its end. Never throws.
|
|
984
|
+
export async function readPageAnchors(context, browserPage, { spec = null, budget = createPolicyLinkBudget() } = {}) {
|
|
985
|
+
const { clock } = budget;
|
|
986
|
+
return budget.step(async (endsAt) => {
|
|
987
|
+
const readEndsAt = Math.min(clock.now() + ANCHOR_READ_MS, endsAt);
|
|
988
|
+
if (!(readEndsAt > clock.now())) return null;
|
|
989
|
+
let session = null;
|
|
990
|
+
let ended = false;
|
|
991
|
+
try {
|
|
992
|
+
const anchors = await untilClock(clock, readEndsAt, async () => {
|
|
993
|
+
const opened = await context.newCDPSession(browserPage);
|
|
994
|
+
if (ended) {
|
|
995
|
+
Promise.resolve().then(() => opened.detach()).catch(() => {});
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
session = opened;
|
|
999
|
+
const { frameTree } = await session.send("Page.getFrameTree");
|
|
1000
|
+
const { executionContextId } = await session.send("Page.createIsolatedWorld", { frameId: frameTree.frame.id, worldName: WORLD_NAME });
|
|
1001
|
+
const { result, exceptionDetails } = await session.send("Runtime.evaluate", { contextId: executionContextId, expression: anchorReadSource(spec), returnByValue: true });
|
|
1002
|
+
const value = exceptionDetails ? null : result?.value;
|
|
1003
|
+
if (!isPlainObject(value) || value.complete !== true || !Array.isArray(value.anchors)) return null;
|
|
1004
|
+
return value.anchors.every(isReadAnchor) ? value.anchors : null;
|
|
1005
|
+
}, { onTimeout: () => { ended = true; }, label: "readPageAnchors" });
|
|
1006
|
+
// A read that completed at or after its end (its own deadline or the
|
|
1007
|
+
// budget's end) is not one the read got in time, even when no timer
|
|
1008
|
+
// fired.
|
|
1009
|
+
return clock.now() < readEndsAt ? anchors : null;
|
|
1010
|
+
} catch {
|
|
1011
|
+
return null;
|
|
1012
|
+
} finally {
|
|
1013
|
+
ended = true;
|
|
1014
|
+
// The session is closed in the background, so a detach that stalls
|
|
1015
|
+
// adds no time.
|
|
1016
|
+
if (session) Promise.resolve().then(() => session.detach()).catch(() => {});
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
}
|