@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
package/src/doctor/inspect.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { shellToken } from "../shell-token.mjs";
|
|
|
8
8
|
import { commitAssemblyReport, recordProducerStageOutcome } from "../stage-ledger.mjs";
|
|
9
9
|
import { annotateDoctorIssueCauses } from "../finding-cause.mjs";
|
|
10
10
|
import { DOCTOR_SIDECAR_SCHEMA } from "../doctor-sidecar.mjs";
|
|
11
|
+
import { recordQcResults } from "../qc-results.mjs";
|
|
11
12
|
import { resolveCampaignWorkspace } from "../campaign-workspace.mjs";
|
|
12
13
|
import { normalizePublicRouteSlug } from "../route-identity.mjs";
|
|
13
14
|
import { DEFAULT_PROXY_BASE } from "../spec-fetch.mjs";
|
|
@@ -19,6 +20,8 @@ import { UPSELL_SELECTOR_SCOPE, builtPageTypeOverRouteGuess } from "../upsell-se
|
|
|
19
20
|
import { CAMPAIGN_IDENTITY } from "../campaign-identity.mjs";
|
|
20
21
|
import { SDK_MARKUP } from "../sdk-markup.mjs";
|
|
21
22
|
import { SCRIPT_SYNTAX, collectBuiltScriptSyntaxInputs } from "../built-script-syntax.mjs";
|
|
23
|
+
import { CART_PLACEHOLDERS } from "../cart-placeholders.mjs";
|
|
24
|
+
import { SMOKE_QC } from "../built-smoke-qc.mjs";
|
|
22
25
|
import { stageIsTerminal } from "../orchestration-stage-contract.mjs";
|
|
23
26
|
import { evaluatePolishGate } from "../polish-gate.mjs";
|
|
24
27
|
import { evaluateRecordedHiddenEagerMediaCheckpoint } from "../polish-node.mjs";
|
|
@@ -42,6 +45,9 @@ import {
|
|
|
42
45
|
recordCampaignIdentityGate,
|
|
43
46
|
recordScriptSyntaxGate,
|
|
44
47
|
recordSdkMarkupGate,
|
|
48
|
+
recordCartPlaceholders,
|
|
49
|
+
collectCartPlaceholderPages,
|
|
50
|
+
recordSmokeQc,
|
|
45
51
|
summarizeCopyMatches,
|
|
46
52
|
resolveBrandContractOnce,
|
|
47
53
|
reportBrandContractDefectOnce,
|
|
@@ -123,7 +129,7 @@ export async function readDoctorLiveCampaign(args, { fetchImpl = globalThis.fetc
|
|
|
123
129
|
});
|
|
124
130
|
}
|
|
125
131
|
|
|
126
|
-
export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = undefined } = {}) {
|
|
132
|
+
export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = undefined, qcStandIns = undefined } = {}) {
|
|
127
133
|
// Non-packet mode (learnings L7): doctor a `campaign-build`'d page-kit
|
|
128
134
|
// campaign that has only a built _site/ and no full Build Packet. Resolves
|
|
129
135
|
// scope from the built output and runs the built-output residue/text/
|
|
@@ -141,6 +147,8 @@ export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = u
|
|
|
141
147
|
// A live campaign read (live-campaign-refs.mjs) the caller already made;
|
|
142
148
|
// absent, the live ref check is recorded not_run.
|
|
143
149
|
...(liveCampaign !== undefined ? { liveCampaign } : {}),
|
|
150
|
+
// In-process QC stand-in checks (tests only; see qc-results.mjs).
|
|
151
|
+
...(qcStandIns !== undefined ? { qcStandIns } : {}),
|
|
144
152
|
};
|
|
145
153
|
const result = runDoctor(packetPath, doctorOptions);
|
|
146
154
|
// Inspection and recording are separate operations. A laptop's untracked
|
|
@@ -232,6 +240,8 @@ export function doctorBuiltOutput(args) {
|
|
|
232
240
|
built_pages: scope.pages.map((page) => ({ page_id: page.page_id, type: page.page_type, route: page.route })),
|
|
233
241
|
doctor_checks: [],
|
|
234
242
|
checkpoint_gates: [],
|
|
243
|
+
// QC results: every result a QC check recomputed this run.
|
|
244
|
+
qc_results: [],
|
|
235
245
|
};
|
|
236
246
|
ready.push(`Resolved ${scope.html_count} built page(s) from ${relFromDir(targetRepo, scope.campaign_dir)} (slug "${scope.slug || "(site root)"}")`);
|
|
237
247
|
|
|
@@ -344,6 +354,39 @@ export function doctorBuiltOutput(args) {
|
|
|
344
354
|
});
|
|
345
355
|
derived.doctor_checks.push(SCRIPT_SYNTAX);
|
|
346
356
|
|
|
357
|
+
// Raw cart placeholders. Same placement, same reasons; a QC
|
|
358
|
+
// check whose warnings no blocker above withholds.
|
|
359
|
+
recordCartPlaceholders({
|
|
360
|
+
subject: {
|
|
361
|
+
public_route_slug: scope.slug || null,
|
|
362
|
+
site_root: relFromDir(targetRepo, scope.campaign_dir),
|
|
363
|
+
},
|
|
364
|
+
pages: collectCartPlaceholderPages(targetRepo, optionalString(args.slug)),
|
|
365
|
+
warnings,
|
|
366
|
+
ready,
|
|
367
|
+
derived,
|
|
368
|
+
});
|
|
369
|
+
derived.doctor_checks.push(CART_PLACEHOLDERS);
|
|
370
|
+
|
|
371
|
+
// Built-output smoke checks. Same placement, same reasons. This path has
|
|
372
|
+
// no Assembly Report and no deploy URL, so the build environment and the
|
|
373
|
+
// deploy base are unknown: the production-only rules and an absolute
|
|
374
|
+
// og:image read unexercised here.
|
|
375
|
+
recordSmokeQc({
|
|
376
|
+
subject: {
|
|
377
|
+
public_route_slug: scope.slug || null,
|
|
378
|
+
site_root: relFromDir(targetRepo, scope.campaign_dir),
|
|
379
|
+
},
|
|
380
|
+
targetRepo,
|
|
381
|
+
pages: collectCartPlaceholderPages(targetRepo, optionalString(args.slug)),
|
|
382
|
+
environment: "unknown",
|
|
383
|
+
deployBase: null,
|
|
384
|
+
warnings,
|
|
385
|
+
ready,
|
|
386
|
+
derived,
|
|
387
|
+
});
|
|
388
|
+
derived.doctor_checks.push(SMOKE_QC);
|
|
389
|
+
|
|
347
390
|
const synthesized = synthesizeMinimalBuildPacket({
|
|
348
391
|
schemaVersion: PACKET_SCHEMA,
|
|
349
392
|
targetRepo,
|
|
@@ -417,7 +460,7 @@ export function doctorPacket(packetPath, options = {}) {
|
|
|
417
460
|
return result;
|
|
418
461
|
}
|
|
419
462
|
|
|
420
|
-
function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath = undefined, outputBaseDir = null, liveCampaign = undefined } = {}) {
|
|
463
|
+
function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath = undefined, outputBaseDir = null, liveCampaign = undefined, qcStandIns = null } = {}) {
|
|
421
464
|
// The Build Context records where prepare-build wrote the report
|
|
422
465
|
// (--report-out). `next` follows that pointer when no --report is given;
|
|
423
466
|
// doctor reads the same report so its gates and its next block cannot
|
|
@@ -449,6 +492,8 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
|
|
|
449
492
|
spec_path: null,
|
|
450
493
|
doctor_checks: [],
|
|
451
494
|
checkpoint_gates: [],
|
|
495
|
+
// QC results: every result a QC check recomputed this run.
|
|
496
|
+
qc_results: [],
|
|
452
497
|
polish_checkpoint_gate: null,
|
|
453
498
|
// The prepare-build gate `next` acts on, stored like every other gate so
|
|
454
499
|
// the ladder consumes doctor's evaluation instead of computing its own.
|
|
@@ -548,6 +593,12 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
|
|
|
548
593
|
ready.push("Hidden eager-media checkpoint not applicable before completed assembly.");
|
|
549
594
|
}
|
|
550
595
|
|
|
596
|
+
// QC stand-in checks reach doctor only through this in-process option
|
|
597
|
+
// (tests); the shipped checks record through recordQcResults themselves.
|
|
598
|
+
for (const check of Array.isArray(qcStandIns?.doctor) ? qcStandIns.doctor : []) {
|
|
599
|
+
recordQcResults({ derived, warnings, results: check() });
|
|
600
|
+
}
|
|
601
|
+
|
|
551
602
|
// The same fully resolved prepare-build gate the `next` command evaluates:
|
|
552
603
|
// DSP-required packets, the recorded report path and the context/report
|
|
553
604
|
// binding checks. A weaker gate here would let doctor name setup or build
|
package/src/doctor/next-step.mjs
CHANGED
|
@@ -591,7 +591,7 @@ function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGat
|
|
|
591
591
|
actions.push("Confirm any rendered promo discount percentage claims against the build request, merchant notes, or CampaignSpec before launch.");
|
|
592
592
|
}
|
|
593
593
|
if (codes.has("template_contract.placeholder_text_residue")) {
|
|
594
|
-
actions.push("Replace literal template placeholder text (Lorem/Placeholder/TODO/Product Name) with CampaignSpec/design copy before QA; the browser residue gate blocks on these terms.");
|
|
594
|
+
actions.push("Replace literal template placeholder text (Lorem/Placeholder/TODO/Product Name/Benefit one…four) with CampaignSpec/design copy before QA; the browser residue gate blocks on these terms.");
|
|
595
595
|
}
|
|
596
596
|
if (codes.has("template_contract.demo_asset_residue")) {
|
|
597
597
|
actions.push("Re-skin template demo placeholder assets (spacer SVGs, repeated benefit icons, starter imagery) to the campaign's real assets before launch.");
|
package/src/invocation.mjs
CHANGED
|
@@ -63,7 +63,7 @@ const COMMANDS = frozen({
|
|
|
63
63
|
bundle: { subcommands: ["check"] },
|
|
64
64
|
standardize: {},
|
|
65
65
|
theme: { subcommands: ["generate", "inspect", "waive"] },
|
|
66
|
-
checkpoint: { subcommands: ["waive"] },
|
|
66
|
+
checkpoint: { subcommands: ["accept", "waive"] },
|
|
67
67
|
polish: { subcommands: ["capture"] },
|
|
68
68
|
record: { subcommands: ["build", "deploy", "polish", "setup", "theme"] },
|
|
69
69
|
"validate-assembly-report": {},
|
|
@@ -87,6 +87,7 @@ const SUBCOMMAND_OVERRIDES = frozen({
|
|
|
87
87
|
"tooling setup": { class: "inline" },
|
|
88
88
|
"sdk storage-check": { class: "inspection" },
|
|
89
89
|
"theme waive": { dryRun: true },
|
|
90
|
+
"checkpoint accept": { dryRun: true },
|
|
90
91
|
"checkpoint waive": { dryRun: true },
|
|
91
92
|
"page-kit sync": { dryRun: true },
|
|
92
93
|
"spec derive": { dryRun: true },
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
//
|
|
14
14
|
// Doctor and QA apply this to the gates they evaluate; `next` reads doctor's
|
|
15
15
|
// gates. Recording a stage (`record polish`) and the waiver commands keep the
|
|
16
|
-
// strict gates.
|
|
16
|
+
// strict gates; `record deploy` follows next past a carried-forward polish.
|
|
17
17
|
import { isLocalServePacket } from "./local-proof.mjs";
|
|
18
18
|
import { isLoopbackHostname } from "./remit.mjs";
|
|
19
19
|
|
package/src/local-proof.mjs
CHANGED
|
@@ -31,6 +31,9 @@ export const LOCAL_PROOF_BUILD_COMMAND = `CPK_ENV=${LOCAL_PROOF_BUILD_ENVIRONMEN
|
|
|
31
31
|
export const LOCAL_PROOF_PARITY_SCOPE = "local_proof.production_parity";
|
|
32
32
|
export const LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE = "local_proof.build_environment";
|
|
33
33
|
export const LOCAL_PROOF_PARITY_COMMAND = "campaigns-os page-kit parity --packet <packet>";
|
|
34
|
+
// The command that records the build stage as a development render: record
|
|
35
|
+
// build stamps the fingerprint and writes LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD.
|
|
36
|
+
export const LOCAL_PROOF_RECORD_BUILD_COMMAND = `campaigns-os record build --packet <packet> --build-environment ${LOCAL_PROOF_BUILD_ENVIRONMENT}`;
|
|
34
37
|
// Where the build stage records which environment it rendered, and where the
|
|
35
38
|
// parity command records its result. Both live under the assembly stage's
|
|
36
39
|
// free-form `evidence` object, which the hashed Assembly Report schema already
|
|
@@ -43,7 +46,7 @@ export const LOCAL_PROOF_NEVER_EDIT_RULE = "Never edit a generated include (anal
|
|
|
43
46
|
// a cross-origin http: dependency — the signature of a protocol-relative
|
|
44
47
|
// production loader served locally.
|
|
45
48
|
export function localProofRebuildText() {
|
|
46
|
-
return `The served build is a production build over plain HTTP: a cross-origin http: dependency failed to load, which is what a protocol-relative vendor loader (//host/...) does off an http://localhost origin. Rebuild in local proof mode — \`${LOCAL_PROOF_BUILD_COMMAND}\` — record ${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD}
|
|
49
|
+
return `The served build is a production build over plain HTTP: a cross-origin http: dependency failed to load, which is what a protocol-relative vendor loader (//host/...) does off an http://localhost origin. Rebuild in local proof mode — \`${LOCAL_PROOF_BUILD_COMMAND}\` — record it with \`${LOCAL_PROOF_RECORD_BUILD_COMMAND}\` (which sets ${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} to "${LOCAL_PROOF_BUILD_ENVIRONMENT}"), serve the development output, and recapture. ${LOCAL_PROOF_NEVER_EDIT_RULE}`;
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
export function isLocalServePacket(packet) {
|
package/src/polish-browser.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
singleResponseRecord,
|
|
18
18
|
} from "./polish-capture.mjs";
|
|
19
19
|
import { launchPackageChromium, PLAYWRIGHT_INSTALL_HINT } from "./browser-launch.mjs";
|
|
20
|
+
import { isProbeClock } from "./polish-media-weight.mjs";
|
|
20
21
|
import {
|
|
21
22
|
boundedPolishDeadline,
|
|
22
23
|
POLISH_BROWSER_CELL_DEADLINE_MS,
|
|
@@ -511,6 +512,215 @@ async function collectMediaElements(page) {
|
|
|
511
512
|
});
|
|
512
513
|
}
|
|
513
514
|
|
|
515
|
+
// The <img> reader the media-weight image probe runs after network
|
|
516
|
+
// observation closed, in an isolated world: every DOM method, getter and
|
|
517
|
+
// window property it uses is that world's own, so page scripts can neither
|
|
518
|
+
// change what it reads nor see that it ran. It walks the document in
|
|
519
|
+
// shadow-including tree order (an element, then its shadow tree, then its
|
|
520
|
+
// children), reaching open shadow roots through element.shadowRoot and
|
|
521
|
+
// closed ones through `closedRoots` (resolved into this world over CDP); it
|
|
522
|
+
// does not enter an iframe's document. It returns the <img> count,
|
|
523
|
+
// window.devicePixelRatio and, for the first `limits.images` <img>: the
|
|
524
|
+
// element path (the CSS child path from <body>, every step
|
|
525
|
+
// "tag:nth-of-type(n)", with a "#shadow-root" step after a shadow host),
|
|
526
|
+
// currentSrc (http(s) as is; any other scheme as the scheme alone), loading,
|
|
527
|
+
// complete, natural size, the rendered box in CSS px, computed object-fit and
|
|
528
|
+
// whether it is hidden (checkVisibility: it or a flat-tree ancestor is not
|
|
529
|
+
// displayed, or it is not visible). It reads only; it never scrolls and
|
|
530
|
+
// starts no load.
|
|
531
|
+
function readImageElements(limits, ...closedRoots) {
|
|
532
|
+
const closedRootOf = new Map(closedRoots.filter((root) => root?.host).map((root) => [root.host, root]));
|
|
533
|
+
const source = (value) => {
|
|
534
|
+
if (typeof value !== "string" || value === "") return { url: null, svg: false };
|
|
535
|
+
if (/^https?:/i.test(value)) return { url: value.length <= limits.urlLength ? value : "[url-too-long]", svg: false };
|
|
536
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value);
|
|
537
|
+
return { url: scheme ? `${scheme[1].toLowerCase()}:` : null, svg: /^data:image\/svg\+xml[;,]/i.test(value) };
|
|
538
|
+
};
|
|
539
|
+
const found = [];
|
|
540
|
+
let observedCount = 0;
|
|
541
|
+
// Work still to do, last first: an element to visit, or a parent (the
|
|
542
|
+
// document, an element or a shadow root) whose children are still to list.
|
|
543
|
+
const pending = [{ parent: document, path: "" }];
|
|
544
|
+
while (pending.length) {
|
|
545
|
+
const item = pending.pop();
|
|
546
|
+
if (item.element) {
|
|
547
|
+
if (item.element instanceof HTMLImageElement) {
|
|
548
|
+
observedCount += 1;
|
|
549
|
+
if (found.length < limits.images) found.push(item);
|
|
550
|
+
}
|
|
551
|
+
pending.push({ parent: item.element, path: item.path });
|
|
552
|
+
const shadowRoot = item.element.shadowRoot ?? closedRootOf.get(item.element);
|
|
553
|
+
if (shadowRoot) pending.push({ parent: shadowRoot, path: `${item.path}>#shadow-root` });
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
const children = [];
|
|
557
|
+
const counts = new Map();
|
|
558
|
+
for (let child = item.parent.firstElementChild; child; child = child.nextElementSibling) {
|
|
559
|
+
const index = (counts.get(child.tagName) ?? 0) + 1;
|
|
560
|
+
counts.set(child.tagName, index);
|
|
561
|
+
const tag = child.tagName.toLowerCase();
|
|
562
|
+
const path = child === document.body || child === document.documentElement
|
|
563
|
+
? tag
|
|
564
|
+
: `${item.path ? `${item.path}>` : ""}${tag}:nth-of-type(${index})`;
|
|
565
|
+
children.push({ element: child, path });
|
|
566
|
+
}
|
|
567
|
+
for (let index = children.length - 1; index >= 0; index -= 1) pending.push(children[index]);
|
|
568
|
+
}
|
|
569
|
+
const images = found.map(({ element: image, path }) => {
|
|
570
|
+
const src = source(image.currentSrc);
|
|
571
|
+
const style = getComputedStyle(image);
|
|
572
|
+
const bounds = image.getBoundingClientRect();
|
|
573
|
+
return {
|
|
574
|
+
element_path: path,
|
|
575
|
+
current_src: src.url,
|
|
576
|
+
svg_data: src.svg,
|
|
577
|
+
loading: image.loading,
|
|
578
|
+
complete: image.complete,
|
|
579
|
+
natural: [image.naturalWidth, image.naturalHeight],
|
|
580
|
+
rendered: [bounds.width, bounds.height],
|
|
581
|
+
object_fit: style.objectFit,
|
|
582
|
+
hidden: !image.checkVisibility({ visibilityProperty: true }),
|
|
583
|
+
};
|
|
584
|
+
});
|
|
585
|
+
return { dpr: window.devicePixelRatio, observed_count: observedCount, images };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// The backend node ids of the main document's closed shadow roots, from a
|
|
589
|
+
// DOM.getDocument tree read with pierce. Open roots the read reaches itself
|
|
590
|
+
// and user-agent roots are not the page's; an iframe's document is not
|
|
591
|
+
// entered.
|
|
592
|
+
function closedShadowRoots(root) {
|
|
593
|
+
const found = [];
|
|
594
|
+
const pending = [root];
|
|
595
|
+
while (pending.length) {
|
|
596
|
+
const node = pending.pop();
|
|
597
|
+
if (!node || typeof node !== "object") continue;
|
|
598
|
+
if (node.shadowRootType === "closed" && Number.isInteger(node.backendNodeId)) found.push(node.backendNodeId);
|
|
599
|
+
if (Array.isArray(node.shadowRoots)) pending.push(...node.shadowRoots);
|
|
600
|
+
if (Array.isArray(node.children)) pending.push(...node.children);
|
|
601
|
+
}
|
|
602
|
+
return found;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const BOUND_ENDED = Symbol("bound ended");
|
|
606
|
+
const realProbeClock = Object.freeze({
|
|
607
|
+
now: () => performance.now(),
|
|
608
|
+
sleep: (ms) => new Promise((resolve) => {
|
|
609
|
+
const timer = setTimeout(resolve, ms);
|
|
610
|
+
timer.unref?.();
|
|
611
|
+
}),
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
function imageRead(value, imageCap) {
|
|
615
|
+
return value && typeof value === "object" && Number.isInteger(value.observed_count) && Array.isArray(value.images)
|
|
616
|
+
&& value.images.length <= imageCap
|
|
617
|
+
? value
|
|
618
|
+
: null;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// The media-weight image probe, run after collector.finish so it cannot
|
|
622
|
+
// delay the network window or add to the ledger. Its steps, in order: an
|
|
623
|
+
// isolated world (Page.createIsolatedWorld); the node tree (DOM.getDocument,
|
|
624
|
+
// piercing shadow roots) and each closed shadow root resolved into that world
|
|
625
|
+
// (DOM.resolveNode); the one read (Runtime.callFunctionOn of
|
|
626
|
+
// readImageElements in that world), which is the only source of the listing,
|
|
627
|
+
// geometry, computed style and device pixel ratio; and the Page.getFrameTree
|
|
628
|
+
// re-read. The probe's bound is the smaller of the cell bound and the run
|
|
629
|
+
// budget left. Every step races one deadline on `clock` at that bound, and
|
|
630
|
+
// none starts once it has passed. spent_ms, on `clock` from the first step to
|
|
631
|
+
// the return, is what the run budget is charged. The probe is cancelled when
|
|
632
|
+
// its bound ends it, on its timer or on `clock` whichever is first, or when it
|
|
633
|
+
// returns: every CDP command, and every step's result, checks that first, so
|
|
634
|
+
// a step that settles late issues nothing more. A probe its bound ends reads
|
|
635
|
+
// probe_timeout, or probe_budget_exhausted where the run budget left was the
|
|
636
|
+
// smaller bound, never complete; it names its images only when its read had
|
|
637
|
+
// returned. A cell that starts once the run budget is spent starts no step
|
|
638
|
+
// and reads probe_budget_exhausted with no images. The re-read discards the
|
|
639
|
+
// probe as document_context_changed when the main frame has another id or
|
|
640
|
+
// loaderId than page_load's own re-read found; so does a step that failed
|
|
641
|
+
// (its execution context destroyed).
|
|
642
|
+
// Returns { status, dpr, images, spent_ms }.
|
|
643
|
+
async function probeImageElements(options) {
|
|
644
|
+
const token = { cancelled: false };
|
|
645
|
+
try {
|
|
646
|
+
return await runImageProbe(options, token);
|
|
647
|
+
} finally {
|
|
648
|
+
token.cancelled = true;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function runImageProbe({ session, mainFrame, documentContextChanged, probe }, token) {
|
|
653
|
+
const clock = isProbeClock(probe.clock) ? probe.clock : realProbeClock;
|
|
654
|
+
const limits = { images: probe.imageCap, urlLength: MAX_POLISH_CAPTURE_URL_LENGTH };
|
|
655
|
+
const started = clock.now();
|
|
656
|
+
const spent = () => Math.max(0, clock.now() - started);
|
|
657
|
+
let read = null;
|
|
658
|
+
const outcome = (status) => ({ status, dpr: read?.dpr ?? null, images: read?.images ?? [], spent_ms: spent() });
|
|
659
|
+
if (!(probe.remainingMs > 0)) return { ...outcome("probe_budget_exhausted"), spent_ms: 0 };
|
|
660
|
+
if (documentContextChanged || typeof mainFrame?.id !== "string") return outcome("document_context_changed");
|
|
661
|
+
const boundMs = Math.min(probe.cellBoundMs, probe.remainingMs);
|
|
662
|
+
const boundEnd = started + boundMs;
|
|
663
|
+
// The status of a probe its bound ended.
|
|
664
|
+
const boundEnded = probe.remainingMs < probe.cellBoundMs ? "probe_budget_exhausted" : "probe_timeout";
|
|
665
|
+
// The probe's one guard, run before every command it issues and before it
|
|
666
|
+
// acts on any step's result: the probe is cancelled once the token is, or
|
|
667
|
+
// once the probe clock reaches the bound. The clock is read even when no
|
|
668
|
+
// timer has fired, so a step that settles past the bound ahead of a queued
|
|
669
|
+
// timer issues nothing more; a passed bound cancels the token too.
|
|
670
|
+
const cancelled = () => {
|
|
671
|
+
if (!token.cancelled && clock.now() >= boundEnd) token.cancelled = true;
|
|
672
|
+
return token.cancelled;
|
|
673
|
+
};
|
|
674
|
+
const send = (method, params) => (cancelled()
|
|
675
|
+
? Promise.reject(new Error("The image probe was cancelled."))
|
|
676
|
+
: session.send(method, params));
|
|
677
|
+
const deadline = clock.sleep(boundMs).then(() => {
|
|
678
|
+
token.cancelled = true;
|
|
679
|
+
return BOUND_ENDED;
|
|
680
|
+
});
|
|
681
|
+
// One probe step: started only inside the bound, and raced against it. A
|
|
682
|
+
// step that settles past the bound reads BOUND_ENDED, not its result.
|
|
683
|
+
const step = async (start) => {
|
|
684
|
+
if (cancelled()) return BOUND_ENDED;
|
|
685
|
+
const settled = await Promise.race([Promise.resolve().then(start).then((value) => ({ value }), (error) => ({ error })), deadline]);
|
|
686
|
+
return cancelled() ? BOUND_ENDED : settled;
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
const world = await step(() => send("Page.createIsolatedWorld", {
|
|
690
|
+
frameId: mainFrame.id,
|
|
691
|
+
worldName: "campaigns-os-polish-image-probe",
|
|
692
|
+
grantUniveralAccess: false,
|
|
693
|
+
}));
|
|
694
|
+
if (world === BOUND_ENDED) return outcome(boundEnded);
|
|
695
|
+
const contextId = world.value?.executionContextId;
|
|
696
|
+
if (world.error || !Number.isInteger(contextId)) return outcome("document_context_changed");
|
|
697
|
+
const tree = await step(() => send("DOM.getDocument", { depth: -1, pierce: true }));
|
|
698
|
+
if (tree === BOUND_ENDED) return outcome(boundEnded);
|
|
699
|
+
if (tree.error) return outcome("document_context_changed");
|
|
700
|
+
const roots = await step(() => Promise.all(closedShadowRoots(tree.value?.root)
|
|
701
|
+
.map((backendNodeId) => send("DOM.resolveNode", { backendNodeId, executionContextId: contextId }))));
|
|
702
|
+
if (roots === BOUND_ENDED) return outcome(boundEnded);
|
|
703
|
+
const rootIds = roots.error ? null : roots.value.map((resolved) => resolved?.object?.objectId);
|
|
704
|
+
if (!rootIds || !rootIds.every((objectId) => typeof objectId === "string")) return outcome("document_context_changed");
|
|
705
|
+
const evaluated = await step(() => send("Runtime.callFunctionOn", {
|
|
706
|
+
functionDeclaration: readImageElements.toString(),
|
|
707
|
+
executionContextId: contextId,
|
|
708
|
+
arguments: [{ value: limits }, ...rootIds.map((objectId) => ({ objectId }))],
|
|
709
|
+
returnByValue: true,
|
|
710
|
+
}));
|
|
711
|
+
if (evaluated === BOUND_ENDED) return outcome(boundEnded);
|
|
712
|
+
if (evaluated.error || evaluated.value?.exceptionDetails) return outcome("document_context_changed");
|
|
713
|
+
read = imageRead(evaluated.value?.result?.value, probe.imageCap);
|
|
714
|
+
if (!read) return outcome("document_context_changed");
|
|
715
|
+
const reread = await step(() => send("Page.getFrameTree"));
|
|
716
|
+
if (reread === BOUND_ENDED) return outcome(boundEnded);
|
|
717
|
+
if (reread.error) return outcome("document_context_changed");
|
|
718
|
+
const frame = reread.value?.frameTree?.frame;
|
|
719
|
+
if (frame?.id !== mainFrame.id || frame?.loaderId !== mainFrame.loaderId) return outcome("document_context_changed");
|
|
720
|
+
if (read.observed_count > probe.imageCap) return outcome("image_cap_reached");
|
|
721
|
+
return cancelled() ? outcome(boundEnded) : outcome("complete");
|
|
722
|
+
}
|
|
723
|
+
|
|
514
724
|
function preferredResolvedValue(finalValue, initialValue) {
|
|
515
725
|
return typeof finalValue === "string" && finalValue !== "" ? finalValue : initialValue;
|
|
516
726
|
}
|
|
@@ -676,7 +886,10 @@ export async function createPolishBrowserAdapter({
|
|
|
676
886
|
let poisonCode = null;
|
|
677
887
|
let closePromise = null;
|
|
678
888
|
return {
|
|
679
|
-
|
|
889
|
+
// `imageProbe` ({ clock, remainingMs, cellBoundMs, imageCap }) opts the
|
|
890
|
+
// cell into the media-weight image probe; the observation then carries
|
|
891
|
+
// `imageProbe` beside the page-load fields.
|
|
892
|
+
async captureRoute({ url, viewport, signal, imageProbe: probeOptions = null } = {}) {
|
|
680
893
|
if (closed) throw new Error("Campaigns OS polish capture browser adapter is already closed.");
|
|
681
894
|
if (poisonCode === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) throw polishProducerTimeoutError();
|
|
682
895
|
if (poisonCode === POLISH_PRODUCER_CLEANUP_ERROR_CODE) throw polishProducerCleanupError();
|
|
@@ -791,12 +1004,16 @@ export async function createPolishBrowserAdapter({
|
|
|
791
1004
|
network.responseCollectionStatus = "failed";
|
|
792
1005
|
network.responses.push(captureProblemRecord("document_context_changed"));
|
|
793
1006
|
}
|
|
1007
|
+
const imageProbe = probeOptions && typeof probeOptions === "object"
|
|
1008
|
+
? await awaitActive(probeImageElements({ session, mainFrame, documentContextChanged, probe: probeOptions }))
|
|
1009
|
+
: null;
|
|
794
1010
|
return {
|
|
795
1011
|
finalDocumentUrl,
|
|
796
1012
|
responseCollectionStatus: network.responseCollectionStatus,
|
|
797
1013
|
networkidle,
|
|
798
1014
|
mediaElements,
|
|
799
1015
|
responses: network.responses,
|
|
1016
|
+
...(imageProbe ? { imageProbe } : {}),
|
|
800
1017
|
};
|
|
801
1018
|
}, {
|
|
802
1019
|
timeoutMs: boundedCellDeadlineMs,
|
package/src/polish-capture.mjs
CHANGED
|
@@ -228,7 +228,7 @@ export function buildPolishCaptureIntegrity(captureProjection) {
|
|
|
228
228
|
};
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
-
function resolvedResource(value, { baseUrl } = {}) {
|
|
231
|
+
export function resolvedResource(value, { baseUrl } = {}) {
|
|
232
232
|
const resolved = resolvedCaptureUrl(value, { baseUrl });
|
|
233
233
|
return {
|
|
234
234
|
...resolved,
|