@nextcommerce/campaigns-os 1.57.0 → 1.59.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 +60 -0
- package/campaign-spec/dist/checkout-flow.d.ts +7 -0
- package/campaign-spec/dist/checkout-flow.js +17 -1
- package/compatibility.json +1 -1
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +26 -27
- package/contracts/effects.v1.json +5 -5
- package/contracts/release-ledger.json +432 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/build-packet.md +22 -8
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +54 -12
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +2 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +2 -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 +6 -6
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +3 -3
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills.json +10 -10
- package/src/built-site-scope.mjs +91 -0
- package/src/cli.mjs +15 -23
- package/src/doctor/checks.mjs +73 -3
- package/src/doctor/next-step.mjs +3 -14
- package/src/polish-node.mjs +5 -4
- package/src/qa-browser.mjs +137 -2
- package/src/qa-cart-entry.mjs +105 -0
- package/src/qa-node.mjs +3 -1
- package/src/qa-verdict.mjs +24 -1
- package/src/sdk-attribute-index.mjs +8 -7
- package/src/source-html-intake.mjs +27 -18
- package/src/stage-record.mjs +200 -33
package/src/cli.mjs
CHANGED
|
@@ -343,7 +343,7 @@ Usage:
|
|
|
343
343
|
campaigns-os record build --packet <campaign-runtime.build.json> [--build-environment <development|production>] [--adapter-decision <key>=<value>[,<key>=<value>...]] [--context <json>] [--report <json>] [--dry-run] [--json] # after page-kit build: records scalar adapter decisions on the Assembly Report (all pairs in one flag; a repeated flag keeps only the last), stamps stages.assembly.build_fingerprint from doctor's derived.build_output_fingerprint.value (and the Design Source Package material fingerprint when present), and makes Polish required unless its evidence is bound to this output; --build-environment records stages.assembly.evidence.build_environment (local proof mode records development)
|
|
344
344
|
campaigns-os record polish --packet <campaign-runtime.build.json> --evidence <polish-evidence.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after polish capture: stages.polish from the file's status (completed, completed_with_warnings, blocked with blockers, or skipped with skip_reason), evidence and optional repair_loop_defect, bound to doctor's current fingerprint; a completed status is refused, writing nothing, unless the polish gate doctor evaluates would pass. --dry-run runs every check and writes nothing
|
|
345
345
|
campaigns-os record theme --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after the brand layer is linked and build is recorded: report.theme becomes applied with load_order after-next-core, css_path, commerce_pages and per-page evidence, only when each built commerce page that loads next-core.css loads brand-theme.css (or checkout-brand.css) after it and at least one does; a page loading neither is left out as the design's own markup; refused, writing nothing, otherwise. --dry-run runs every check and writes nothing
|
|
346
|
-
campaigns-os record deploy --packet <campaign-runtime.build.json> --base-url <served url> [--context <json>] [--report <json>] [--dry-run] [--json] # a
|
|
346
|
+
campaigns-os record deploy --packet <campaign-runtime.build.json> --base-url <served url> [--context <json>] [--report <json>] [--dry-run] [--json] # a served deploy after polish is recorded: GETs every built page under the URL (the campaign route root), then records deploy.preview_url on the packet and stages.deploy completed with the URL in outputs and the build fingerprint it probed (a later record build of other output makes deploy required again). A local-serve packet takes a loopback URL and each page must answer 2xx; any other deploy target takes an https URL (plain http only on a loopback host), each page must answer 200, and every script and stylesheet the built pages load from that origin must be served byte-identical (sha256) to the built output (HTML is not compared). Refused, writing nothing, when the URL breaks those rules or is not the route root, a page does not answer, a served asset differs or is missing, the build changed since it was recorded, or the theme gate is blocked. --dry-run runs every check, the requests included, and writes nothing
|
|
347
347
|
campaigns-os readback <target-repo-root> [--json] [--packet <path>] [--doctor <path>] [--context <path>] [--report <path>] [--qa-verdict <path>] [--findings <path>] # read-only projection of one run's emitted artifacts (packet, doctor output, build context, assembly report, QA verdict, findings export): artifact states, per-artifact freshness against the checkout's HEAD reflog, doctor warning grouping, skip cascades and cross-artifact divergences. Writes nothing, starts no process, touches no network, and records no lifecycle entry; --json emits one campaigns-os-readback/v2 object (docs/readback.md). Exit 2 for a missing target root or a Build Packet set freshness cannot single out.
|
|
348
348
|
campaigns-os readback --example [--json] # project the bundled synthetic sample; freshness is not computable for it by design
|
|
349
349
|
campaigns-os validate-assembly-report --report <json> [--json]
|
|
@@ -4465,7 +4465,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
|
|
|
4465
4465
|
errors,
|
|
4466
4466
|
warnings,
|
|
4467
4467
|
ready,
|
|
4468
|
-
prompt:
|
|
4468
|
+
prompt: `Pipeline complete. All stages in the assembly report are in a terminal status. To repeat build work, do the work and run \`${cmd("record")} build --packet <path>\`; this makes downstream stages owed as needed. Run \`${cmd("qa")} run\` for QA. For a hosted deploy, record the URL and stage outcome as the deploy prompt describes. Then call \`${cmd("next")}\` again. If a run session is active, finish it with \`${cmd("run")} end\` so the Run Record is assembled and the session closes.${report?.stages?.qa?.status === "completed_with_warnings" ? " QA passed with exceptions; report them to the operator without clearing or waiving them or changing markup just to make them pass." : ""}`,
|
|
4469
4469
|
});
|
|
4470
4470
|
}
|
|
4471
4471
|
stage = picked.stage;
|
|
@@ -4877,8 +4877,8 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
|
|
|
4877
4877
|
return actions;
|
|
4878
4878
|
}
|
|
4879
4879
|
if (result.stage === "polish" && polishGate?.carried_forward?.policy === "hosted_template_preview") {
|
|
4880
|
-
push("
|
|
4881
|
-
push("qa_run", "command", qaRunCommand(packetPath, packet.deploy.preview_url), "
|
|
4880
|
+
push("record_deploy", "command", `${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url ${shellToken(packet.deploy.preview_url)}`, "Record the hosted preview deploy; missing Polish evidence remains a warning.");
|
|
4881
|
+
push("qa_run", "command", qaRunCommand(packetPath, packet.deploy.preview_url), "Then run browser and typed-card QA against the hosted preview URL.");
|
|
4882
4882
|
return actions;
|
|
4883
4883
|
}
|
|
4884
4884
|
}
|
|
@@ -4934,20 +4934,13 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
|
|
|
4934
4934
|
: `Run the visual polish pass and ${cmd("polish")} capture, then record polish with ${cmd("record")} polish --packet ${shellToken(packetPath)} --evidence <polish-evidence.json>.`);
|
|
4935
4935
|
if (polishCheckpointGate?.status === "blocked") pushPolishCheckpointActions();
|
|
4936
4936
|
} else if (result.stage === "deploy") {
|
|
4937
|
-
if (
|
|
4938
|
-
push("next_qa", "command", `${cmd("next")} qa --packet ${shellToken(packetPath)}`, "Inspect the QA stage for this hosted preview without recording a hosted deploy.");
|
|
4939
|
-
push("qa_run", "command", qaRunCommand(packetPath, packet.deploy.preview_url), "Run browser and typed-card QA against the hosted preview; its missing Polish evidence remains a warning.");
|
|
4940
|
-
} else if (packet.deploy?.target === LOCAL_SERVE_DEPLOY_TARGET) {
|
|
4937
|
+
if (packet.deploy?.target === LOCAL_SERVE_DEPLOY_TARGET) {
|
|
4941
4938
|
const plan = localServePlan(packet);
|
|
4942
4939
|
push("deploy", "manual", null, `Serve the built ${plan.dir} output locally as the origin root (deploy.target is local-serve)${plan.rewrite ? ` — ${plan.rewrite}` : ""}, then run ${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url <served url>: it checks every built page answers and records deploy.preview_url and stages.deploy. Localhost on any port is a Development domain: SDK allowed, analytics suppressed.`);
|
|
4943
4940
|
} else {
|
|
4944
|
-
push("deploy", "manual", null, `Deploy _site/ output to ${packet.deploy?.target || "the deploy target"}, then record deploy.preview_url (
|
|
4945
|
-
}
|
|
4946
|
-
if (polishGate?.carried_forward?.policy === "hosted_template_preview" && packet.deploy?.preview_url) {
|
|
4947
|
-
push("advance", "command", `${cmd("next")} qa --packet ${shellToken(packetPath)}`, "Inspect QA for the hosted preview without a deploy record.");
|
|
4948
|
-
} else {
|
|
4949
|
-
push("advance", "command", `${cmd("next")} --packet ${shellToken(packetPath)} --json`, "Advance to QA once the deploy URL is recorded.");
|
|
4941
|
+
push("deploy", "manual", null, `Deploy _site/ output to ${packet.deploy?.target || "the deploy target"} with _site/ as the origin root, then run ${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url ${packet.deploy?.preview_url ? shellToken(packet.deploy.preview_url) : "<https preview url>"}: it checks every built page answers HTTP 200 and every script and stylesheet the pages load from that origin is served byte-identical to the build, and records deploy.preview_url and stages.deploy.`);
|
|
4950
4942
|
}
|
|
4943
|
+
push("advance", "command", `${cmd("next")} --packet ${shellToken(packetPath)} --json`, "Advance to QA once the deploy URL is recorded.");
|
|
4951
4944
|
} else if (result.stage === "qa") {
|
|
4952
4945
|
const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
|
|
4953
4946
|
push("install_browser", "command", `${cmd("qa")} install-browser`, "Install the Playwright browser once after install/update (npm run qa:install-browser from a checkout).");
|
|
@@ -5108,8 +5101,8 @@ Rules:
|
|
|
5108
5101
|
- For one-time prepurchase/order-bump packages outside the main bundles, default package_sync=false and show_line_total_price=false unless the spec explicitly requires quantity sync.
|
|
5109
5102
|
- Record spec-driven removals, especially unsupported payment methods, so polish does not reintroduce them.
|
|
5110
5103
|
- Replace demo refs; do not copy Olympus-style shipping_methods into shop-three-step.
|
|
5111
|
-
- Multi-step checkout paths run select -> checkout_step... -> checkout; only the checkout page takes payment. On a select page the swap-mode bundle selector (data-next-bundle-selector with data-next-selection-mode="swap") writes the cart and the SDK persists it between pages; a checkout button or link (href from the page's
|
|
5112
|
-
- Each checkout_step page's checkout form (<form data-next-checkout="form">) carries data-next-checkout-step="<its
|
|
5104
|
+
- Multi-step checkout paths run select -> checkout_step... -> checkout; only the checkout page takes payment. On a select page the swap-mode bundle selector (data-next-bundle-selector with data-next-selection-mode="swap") writes the cart and the SDK persists it between pages; a checkout button or link (href from the page's next_step) goes to the next page. Use forcePackageId only on a page with no selector, such as a landing call-to-action into a checkout that renders none.
|
|
5105
|
+
- Each checkout_step page's checkout form (<form data-next-checkout="form">) carries data-next-checkout-step="<its next_step>" and data-next-step-number="<step_number>" (Build Context page_kit frontmatter next_step and step_number); the checkout page's form carries neither. On select and checkout_step pages, frontmatter next_url is the Checkout's post-payment destination, the same meaning it has on the checkout page, and the layout renders it as next-success-url because the SDK reads that tag for express orders; next_step is the forward link. A landing page keeps next_url as its call-to-action target; if it has express checkout buttons, set its next-success-url to the Checkout's own next_url from the Build Context page_map, never to the landing page's next_url. Forward navigation uses the step form attribute or a plain link, never that meta tag. Page Kit frontmatter has no success_url key (success_url remains a CampaignSpec field on checkout pages).
|
|
5113
5106
|
- After page-kit build, inspect rendered _site output before handoff: each active page should have a body, Campaign Cart runtime markers, SDK meta tags from CampaignSpec sdk_hints.meta_tags, and no stale copied funnel attribution.
|
|
5114
5107
|
- Run page-kit build and SDK/template lint, then record build before polish: \`${cmd("record")} build --packet ${packetPath}${isLocalServePacket(packet) ? ` --build-environment ${LOCAL_PROOF_BUILD_ENVIRONMENT}` : ""} --adapter-decision <key>=<value>[,<key>=<value>...]\`. Record the value that is true for this build: for example, raw_html_conversion_status=completed once source HTML is converted, or not_required when there is none. Put all scalar adapter decisions in one comma-separated flag; a repeated flag keeps only the last. Use the allowed values in docs/build-packet.md. Record the nine recordable scalar decisions with the command, never by editing those fields in the Assembly Report. wrapper_policy is selected at intake with prepare-build --wrapper-policy or the source-html manifest's wrapper_policy option; the object-valued template_files_copied proof has no flag. It stamps stages.assembly.build_fingerprint with the fingerprint doctor computes from the built output (derived.build_output_fingerprint.value, sha256 over the sorted path+sha256 manifest of _site/<slug>/; doctor reports built_output.fingerprint_stale whenever the output on disk stops matching the recorded value), records report.design_source_package.material_fingerprint on stages.assembly.source_package_material_fingerprint when present, and sets stages.polish to "required" (required_by="build", required_for=["qa"]). Re-run it after every rebuild; never hand-edit these fields. Build must not mark stages.polish as completed/completed_with_warnings/skipped. If you applied a brand theme, run \`${cmd("record")} theme --packet ${packetPath}\` after record build: it reads each built commerce page's stylesheet links and records report.theme (status applied, load_order=after-next-core, css_path, commerce_pages, evidence), and refuses, writing nothing, when a page that loads next-core.css does not load the brand layer after it. Never hand-edit report.theme.
|
|
5115
5108
|
- Capture the machine-readable build summary as an artifact: \`${PAGE_KIT_BUILD_SUMMARY_CAPTURE_COMMAND}\` (requires next-campaign-page-kit >= 0.1.4). Doctor verifies it for per-page build errors and Page Kit shape warnings (NESTED_NO_PERMALINK, DUPLICATE_OUTPUT, MISSING_FRONTMATTER, LAYOUT_NOT_FOUND). If the installed page-kit predates --json, record that in the assembly report instead of skipping silently.${localProofPromptLines(packet, packetPath)}`;
|
|
@@ -5193,7 +5186,7 @@ function polishPrompt(packetPath, reportPath, packet, intent, polishCheckpointGa
|
|
|
5193
5186
|
return `Every mapped page is template stock, so there is no design route to capture. Missing Polish evidence is carried forward only on a hosted preview whose deploy.preview_url is a non-loopback http(s) URL different from deploy.production_url; this packet's is not. Set one with \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\`, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
|
|
5194
5187
|
}
|
|
5195
5188
|
return packet.deploy?.preview_url
|
|
5196
|
-
? `Every mapped page is template stock, so there is no design route to capture. Missing Polish evidence is carried forward on the hosted preview. Run \`${cmd("
|
|
5189
|
+
? `Every mapped page is template stock, so there is no design route to capture. Missing Polish evidence is carried forward on the hosted preview. Run \`${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url ${shellToken(packet.deploy.preview_url)}\`, then \`${qaRunCommand(packetPath, packet.deploy.preview_url)}\`. Production QA remains blocked without the evidence.`
|
|
5197
5190
|
: `Every mapped page is template stock, so there is no design route to capture. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\` to record the hosted preview URL, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
|
|
5198
5191
|
}
|
|
5199
5192
|
return `${campaignIntentPromptHeader(intent)}Use next-campaigns-polish for this built campaign.
|
|
@@ -5258,7 +5251,7 @@ function deployPrompt(packetPath, reportPath, packet, polishGate = null, polishC
|
|
|
5258
5251
|
if (packet.deploy?.target !== LOCAL_SERVE_DEPLOY_TARGET
|
|
5259
5252
|
&& polishCheckpointGate?.code === "polish.hidden_eager_media.no_capturable_routes"
|
|
5260
5253
|
&& !packet.deploy?.preview_url) {
|
|
5261
|
-
return `Every mapped page is template stock, and the hosted preview URL is not recorded. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\` after the preview is deployed, then \`${cmd("next")} --packet ${shellToken(packetPath)}
|
|
5254
|
+
return `Every mapped page is template stock, and the hosted preview URL is not recorded. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\` after the preview is deployed, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
|
|
5262
5255
|
}
|
|
5263
5256
|
const target = packet.deploy?.target || "unknown";
|
|
5264
5257
|
const liveUrlPath = packet.deploy?.live_url_path || packet.campaign?.live_url_path || campaignRouteRoot(packet) || "/<slug>/";
|
|
@@ -5282,7 +5275,7 @@ Once the server is up:
|
|
|
5282
5275
|
If the served build cannot be reached, set stages.deploy.status to "blocked" with a clear reason in outputs so the orchestration loop surfaces it rather than skipping past.`;
|
|
5283
5276
|
}
|
|
5284
5277
|
if (polishGate?.carried_forward?.policy === "hosted_template_preview" && packet.deploy?.preview_url) {
|
|
5285
|
-
return `The hosted preview URL is recorded at deploy.preview_url. Every mapped page is template stock, so missing Polish evidence is carried forward as a warning for this preview. Run \`${cmd("
|
|
5278
|
+
return `The hosted preview URL is recorded at deploy.preview_url. Every mapped page is template stock, so missing Polish evidence is carried forward as a warning for this preview. Run \`${cmd("record")} deploy --packet ${shellToken(packetPath)} --base-url ${shellToken(packet.deploy.preview_url)}\` to record the deploy, then \`${qaRunCommand(packetPath, packet.deploy.preview_url)}\` to test the preview. Production QA remains strict.`;
|
|
5286
5279
|
}
|
|
5287
5280
|
return `Deploy the built campaign to ${target}.
|
|
5288
5281
|
|
|
@@ -5295,10 +5288,9 @@ Read first:
|
|
|
5295
5288
|
Deploy is currently an out-of-band step: the page-kit build produces _site/ output; you (or your CI) ship it to ${target}. Use the deploy target's normal tooling (netlify deploy, wrangler pages deploy, vercel deploy, etc.).
|
|
5296
5289
|
|
|
5297
5290
|
After deploy succeeds:
|
|
5298
|
-
1.
|
|
5299
|
-
2.
|
|
5300
|
-
3.
|
|
5301
|
-
4. Run \`${cmd("next")} --packet ${packetPath}\` to advance to QA.
|
|
5291
|
+
1. Run \`${cmd("record")} deploy --packet ${packetPath} --base-url <https preview origin>${liveUrlPath}\`. It requests every built page under that URL (each must answer HTTP 200) and compares every script and stylesheet the built pages load from that origin with the built output by sha256 (HTML is not compared; hosts inject markup), then records the URL at deploy.preview_url and stages.deploy as completed with the URL in outputs and the build fingerprint it probed. It refuses, writing nothing, naming each page or asset that does not match.
|
|
5292
|
+
2. Verify the SDK initialises on the tested origin. Localhost on any port is globally available as a Campaigns App Development domain (analytics suppressed). Non-localhost preview/production hosts must be in the Campaigns App SDK origin allowlist before QA.
|
|
5293
|
+
3. Run \`${cmd("next")} --packet ${packetPath}\` to advance to QA.
|
|
5302
5294
|
|
|
5303
5295
|
If the deploy is blocked (non-localhost allowed-domain not yet added, CI permission missing, host-side outage), set stages.deploy.status to "blocked" with a clear reason in outputs so the orchestration loop surfaces it rather than skipping past.`;
|
|
5304
5296
|
}
|
package/src/doctor/checks.mjs
CHANGED
|
@@ -76,7 +76,7 @@ import {
|
|
|
76
76
|
resolveTemplateBrandContract,
|
|
77
77
|
} from "../private-template-source.mjs";
|
|
78
78
|
import { assessTemplateFreshness, defaultSdkSupportPolicy, renderTemplateFreshness } from "../template-freshness.mjs";
|
|
79
|
-
import { computeBuildFingerprint, resolveBuiltSiteScope } from "../built-site-scope.mjs";
|
|
79
|
+
import { BUILD_DRIFT_PATH_LIMIT, computeBuildFingerprint, describeBuildOutputDrift, resolveBuiltSiteScope } from "../built-site-scope.mjs";
|
|
80
80
|
import {
|
|
81
81
|
UPSELL_SELECTOR_SCOPE,
|
|
82
82
|
builtPageTypeOverRouteGuess,
|
|
@@ -1895,12 +1895,33 @@ export function validateBuildOutputFingerprint(packet, errors, warnings, ready,
|
|
|
1895
1895
|
);
|
|
1896
1896
|
return;
|
|
1897
1897
|
}
|
|
1898
|
+
const drift = describeBuildOutputDrift(buildState.report?.stages?.assembly, current);
|
|
1899
|
+
const capped = (paths) => paths.slice(0, BUILD_DRIFT_PATH_LIMIT);
|
|
1898
1900
|
addIssue(
|
|
1899
1901
|
assemblyComplete ? errors : warnings,
|
|
1900
1902
|
"built_output.fingerprint_stale",
|
|
1901
1903
|
`Built output under _site/${publicRouteSlug}/ no longer matches stages.assembly.build_fingerprint (recorded ${recorded}, current ${current.fingerprint}, ${current.file_count} file(s)). `
|
|
1904
|
+
+ `${drift.summary} `
|
|
1902
1905
|
+ "The output changed after build recorded it; re-run build (page-kit build, then record the current fingerprint) before polish or QA evidence can bind to it.",
|
|
1903
|
-
{
|
|
1906
|
+
{
|
|
1907
|
+
root: `_site/${publicRouteSlug}/`,
|
|
1908
|
+
recorded,
|
|
1909
|
+
current: current.fingerprint,
|
|
1910
|
+
file_count: current.file_count,
|
|
1911
|
+
assembly_complete: assemblyComplete,
|
|
1912
|
+
drift: drift.manifest === "recorded"
|
|
1913
|
+
? {
|
|
1914
|
+
manifest: "recorded",
|
|
1915
|
+
extra_count: drift.extra.length,
|
|
1916
|
+
missing_count: drift.missing.length,
|
|
1917
|
+
changed_count: drift.changed.length,
|
|
1918
|
+
extra: capped(drift.extra),
|
|
1919
|
+
missing: capped(drift.missing),
|
|
1920
|
+
changed: capped(drift.changed),
|
|
1921
|
+
conflict_copies: capped(drift.conflict_copies),
|
|
1922
|
+
}
|
|
1923
|
+
: { manifest: "unavailable" },
|
|
1924
|
+
}
|
|
1904
1925
|
);
|
|
1905
1926
|
}
|
|
1906
1927
|
|
|
@@ -3342,7 +3363,7 @@ function collectHardcodedPhoneMatches(scanRoots, storePhone) {
|
|
|
3342
3363
|
const matches = [];
|
|
3343
3364
|
for (const { label: surface, root } of scanRoots) {
|
|
3344
3365
|
for (const file of collectHtmlFiles(root)) {
|
|
3345
|
-
const content = maskMarketLintIgnoredRegions(readHtmlScanText(join(root, file.path)));
|
|
3366
|
+
const content = maskNonRenderedMarkup(maskMarketLintIgnoredRegions(readHtmlScanText(join(root, file.path))));
|
|
3346
3367
|
for (const match of content.matchAll(HARDCODED_PHONE_REGEX)) {
|
|
3347
3368
|
const found = normalizePhoneNumber(match[0]);
|
|
3348
3369
|
if (!found || found === expected) continue;
|
|
@@ -3370,6 +3391,18 @@ function maskMarketLintIgnoredRegions(content) {
|
|
|
3370
3391
|
.replace(ignoredTag, preserveNewlinesMask);
|
|
3371
3392
|
}
|
|
3372
3393
|
|
|
3394
|
+
// Markup a shopper never reads: HTML comments, <style> blocks, and CSS
|
|
3395
|
+
// comments (in style attributes too). Design exports leave long digit runs
|
|
3396
|
+
// there, such as a Figma frame name "Group 1000003339", which the phone
|
|
3397
|
+
// pattern would otherwise read as a number (campaigns-os#660). Masking keeps
|
|
3398
|
+
// offsets and newlines, so reported line numbers stay true.
|
|
3399
|
+
function maskNonRenderedMarkup(content) {
|
|
3400
|
+
return content
|
|
3401
|
+
.replace(/<!--[\s\S]*?-->/g, preserveNewlinesMask)
|
|
3402
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, preserveNewlinesMask)
|
|
3403
|
+
.replace(/\/\*[\s\S]*?\*\//g, preserveNewlinesMask);
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3373
3406
|
function preserveNewlinesMask(value) {
|
|
3374
3407
|
return value.replace(/[^\n]/g, " ");
|
|
3375
3408
|
}
|
|
@@ -4138,6 +4171,42 @@ function warnCheckoutPackageFamilyFit(specPages, family, catalog, warnings, repo
|
|
|
4138
4171
|
);
|
|
4139
4172
|
}
|
|
4140
4173
|
}
|
|
4174
|
+
// A family that puts package selection on its own `select` page (the catalog
|
|
4175
|
+
// names a selectStep surface) and carries no selector on checkout (no
|
|
4176
|
+
// mainSelector surface) can only fill the cart from a declared select page.
|
|
4177
|
+
// Without one on the path into a Checkout, QA resolves the landing as the
|
|
4178
|
+
// entry page, finds no add-to-cart control, and every order path fails after
|
|
4179
|
+
// the whole build (campaigns-os#658). Families whose checkout carries its own
|
|
4180
|
+
// selector, and families that fill the cart on the landing page, are not
|
|
4181
|
+
// affected.
|
|
4182
|
+
function warnMissingSelectStep(specPages, family, catalog, warnings) {
|
|
4183
|
+
const surfaces = catalog.families?.[family]?.canonicalSurfaces;
|
|
4184
|
+
if (!surfaces?.selectStep || surfaces.mainSelector) return;
|
|
4185
|
+
const byFunnel = new Map();
|
|
4186
|
+
for (const page of specPages) {
|
|
4187
|
+
if (!byFunnel.has(page.funnel_id)) byFunnel.set(page.funnel_id, []);
|
|
4188
|
+
byFunnel.get(page.funnel_id).push(page);
|
|
4189
|
+
}
|
|
4190
|
+
for (const [funnelId, pages] of byFunnel) {
|
|
4191
|
+
const reachedFromSelect = new Set();
|
|
4192
|
+
for (const select of pages.filter((page) => page.type === "select")) {
|
|
4193
|
+
const path = checkoutPathFrom(pages, select);
|
|
4194
|
+
if (path) reachedFromSelect.add(path[path.length - 1]);
|
|
4195
|
+
}
|
|
4196
|
+
for (const checkout of pages.filter((page) => page.type === "checkout")) {
|
|
4197
|
+
if (reachedFromSelect.has(checkout)) continue;
|
|
4198
|
+
// A split-test path built on another family is judged by that family.
|
|
4199
|
+
const pageFamily = checkout.sdk_hints?.template_family;
|
|
4200
|
+
if (isNonEmptyString(pageFamily) && pageFamily !== family) continue;
|
|
4201
|
+
addIssue(
|
|
4202
|
+
warnings,
|
|
4203
|
+
"template_contract.select_step_missing",
|
|
4204
|
+
`Template family "${family}" puts package selection on a select page, and its checkout carries no selector, but no select page leads to Checkout "${checkout.id}" in funnel "${funnelId}". QA will find no add-to-cart control on the entry page and every order path will fail. Fix: add a Select page between the entry page and checkout in Map Builder.`,
|
|
4205
|
+
{ funnel_id: funnelId, checkout_page_id: checkout.id, template_family: family },
|
|
4206
|
+
);
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
4209
|
+
}
|
|
4141
4210
|
function offerRefsFromEntries(entries) {
|
|
4142
4211
|
if (!Array.isArray(entries)) return [];
|
|
4143
4212
|
return entries
|
|
@@ -4323,6 +4392,7 @@ export function validateCommerceCatalog(packet, packetPath, spec, errors, warnin
|
|
|
4323
4392
|
// ADR-003 step 2: template-contract checks ported from the private doctor.
|
|
4324
4393
|
const specPages = activeSpecPages(spec);
|
|
4325
4394
|
warnCheckoutPackageFamilyFit(specPages, family, catalog, warnings, buildState.report);
|
|
4395
|
+
warnMissingSelectStep(specPages, family, catalog, warnings);
|
|
4326
4396
|
|
|
4327
4397
|
const mismatchedFamilies = specPages.filter(
|
|
4328
4398
|
(page) => isNonEmptyString(page.sdk_hints?.template_family) && page.sdk_hints.template_family !== family
|
package/src/doctor/next-step.mjs
CHANGED
|
@@ -477,12 +477,6 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
477
477
|
// A first local preview may carry missing Polish forward. A theme waiver
|
|
478
478
|
// or prior capture makes the recorded Polish stage owed again.
|
|
479
479
|
if (cliStage === "polish" && polishCarriedForwardForLadder(report, polishGate)) continue;
|
|
480
|
-
// A hosted all-template preview has no hosted deploy recorder. The
|
|
481
|
-
// recorded preview URL satisfies this one deploy handoff while the
|
|
482
|
-
// carried-forward gate identifies the exact preview-only shape.
|
|
483
|
-
if (cliStage === "deploy" && packet?.deploy?.preview_url
|
|
484
|
-
&& polishGate?.carried_forward?.policy === "hosted_template_preview"
|
|
485
|
-
&& polishCheckpointGate?.carried_forward?.policy === "hosted_template_preview") continue;
|
|
486
480
|
const reportKey = reportKeyForCliStage(cliStage);
|
|
487
481
|
const stage = report.stages[reportKey];
|
|
488
482
|
if (!stage) {
|
|
@@ -524,13 +518,10 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
524
518
|
}
|
|
525
519
|
}
|
|
526
520
|
|
|
527
|
-
const hostedPreview = Boolean(packet?.deploy?.preview_url && polishGate?.carried_forward?.policy === "hosted_template_preview");
|
|
528
521
|
return {
|
|
529
522
|
stage: "done",
|
|
530
|
-
outcome:
|
|
531
|
-
reason:
|
|
532
|
-
? "The recorded hosted preview satisfies deploy, and QA is terminal for the current build. Pipeline is complete."
|
|
533
|
-
: "All stages are in a terminal status (completed / completed_with_warnings / skipped). Pipeline is complete.",
|
|
523
|
+
outcome: "standard_closeout",
|
|
524
|
+
reason: "All stages are in a terminal status (completed / completed_with_warnings / skipped). Pipeline is complete.",
|
|
534
525
|
};
|
|
535
526
|
}
|
|
536
527
|
|
|
@@ -730,9 +721,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
|
|
|
730
721
|
? `${cmd("next")} --packet ${packetRef}`
|
|
731
722
|
: `${cmd("next")} ${picked.stage} --packet ${packetRef}`;
|
|
732
723
|
const fallbackAction = picked.stage === "done"
|
|
733
|
-
?
|
|
734
|
-
? `${picked.reason} Run ${cmd("next")} to confirm the closeout actions.`
|
|
735
|
-
: `All stages are recorded as terminal; run ${cmd("next")} to confirm the closeout actions.`)
|
|
724
|
+
? `All stages are recorded as terminal; run ${cmd("next")} to confirm the closeout actions.`
|
|
736
725
|
: `Run ${command}.`;
|
|
737
726
|
if (picked.stage === "done") {
|
|
738
727
|
actions.unshift(fallbackAction);
|
package/src/polish-node.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { NO_CAPTURABLE_ROUTES_CODE } from "./local-preview-policy.mjs";
|
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { HIDDEN_EAGER_MEDIA_ACTIONS } from "./gate-actions.mjs";
|
|
5
5
|
import { dirname, join, resolve } from "node:path";
|
|
6
|
-
import { computeBuildFingerprint } from "./built-site-scope.mjs";
|
|
6
|
+
import { computeBuildFingerprint, describeBuildOutputDrift } from "./built-site-scope.mjs";
|
|
7
7
|
import {
|
|
8
8
|
buildPageLoadCapture,
|
|
9
9
|
MAX_POLISH_CAPTURE_URL_LENGTH,
|
|
@@ -402,7 +402,7 @@ export function createPolishCaptureBinding({ packet, report, plan, packetPath, t
|
|
|
402
402
|
// an output that has drifted from what build recorded would be evidence
|
|
403
403
|
// about a build that no longer exists, and an output that changes during the
|
|
404
404
|
// browser pass fails the unchanged-binding assertion after it.
|
|
405
|
-
const outputFingerprint = boundOutputFingerprint(join(resolve(targetRepo), "_site", slug), slug, buildFingerprint);
|
|
405
|
+
const outputFingerprint = boundOutputFingerprint(join(resolve(targetRepo), "_site", slug), slug, buildFingerprint, report.stages.assembly);
|
|
406
406
|
const runId = nonemptyString(report.run_id);
|
|
407
407
|
const reportPacketPath = nonemptyString(report?.inputs?.packet_path);
|
|
408
408
|
if (!runId || !reportPacketPath) {
|
|
@@ -585,7 +585,7 @@ function uncapturedPages(packet, plan) {
|
|
|
585
585
|
// (permissions, a file vanishing mid-walk), or an output that no longer
|
|
586
586
|
// matches what build recorded. None of them may surface as an uncaught
|
|
587
587
|
// filesystem error, and none may bind as a null.
|
|
588
|
-
function boundOutputFingerprint(outputRoot, slug, buildFingerprint) {
|
|
588
|
+
function boundOutputFingerprint(outputRoot, slug, buildFingerprint, assembly = null) {
|
|
589
589
|
let current;
|
|
590
590
|
try {
|
|
591
591
|
current = computeBuildFingerprint(outputRoot);
|
|
@@ -604,7 +604,8 @@ function boundOutputFingerprint(outputRoot, slug, buildFingerprint) {
|
|
|
604
604
|
if (current.fingerprint !== buildFingerprint) {
|
|
605
605
|
throw new Error(
|
|
606
606
|
`polish capture refuses: built output under _site/${slug}/ no longer matches stages.assembly.build_fingerprint `
|
|
607
|
-
+ `(recorded ${buildFingerprint}, current ${current.fingerprint}).
|
|
607
|
+
+ `(recorded ${buildFingerprint}, current ${current.fingerprint}). ${describeBuildOutputDrift(assembly, current).summary} `
|
|
608
|
+
+ "Re-run build and record the current fingerprint first.",
|
|
608
609
|
);
|
|
609
610
|
}
|
|
610
611
|
return current.fingerprint;
|
package/src/qa-browser.mjs
CHANGED
|
@@ -52,6 +52,13 @@ import {
|
|
|
52
52
|
isCartEntryCode,
|
|
53
53
|
resolveCartEntryPage,
|
|
54
54
|
sdkCartSnapshotScript,
|
|
55
|
+
SLOT_VARIANT_DROPDOWN_ROW_SELECTOR,
|
|
56
|
+
SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR,
|
|
57
|
+
SLOT_VARIANT_FIELD_SELECTOR,
|
|
58
|
+
SLOT_VARIANT_SELECT_SELECTOR,
|
|
59
|
+
slotVariantKey,
|
|
60
|
+
slotVariantLabel,
|
|
61
|
+
slotVariantStepScript,
|
|
55
62
|
summarizeSelectionSurface,
|
|
56
63
|
UNDECLARED_ROUTE_ATTRIBUTES,
|
|
57
64
|
} from "./qa-cart-entry.mjs";
|
|
@@ -304,7 +311,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
304
311
|
const identifier = planId(plan);
|
|
305
312
|
const pageForPlan = (typeof plan === "object" && plan?.checkout_page?.url) ? plan.checkout_page : checkoutPage;
|
|
306
313
|
const firstAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
|
|
307
|
-
orders.push(firstAttempt.order);
|
|
314
|
+
orders.push(testOrderEntry(firstAttempt.order, identifier, 1));
|
|
308
315
|
orderPlans.push(plan);
|
|
309
316
|
// Every attempt this path actually SUBMITTED, in order. The confirmed
|
|
310
317
|
// creation count is read from these and never from the deciding result
|
|
@@ -364,7 +371,7 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
364
371
|
const rerunAttempt = await runSingle(context, pageForPlan, plan, args, runId, attemptOptions);
|
|
365
372
|
attemptsForPlan.push(rerunAttempt);
|
|
366
373
|
if (rerunAttempt.order) {
|
|
367
|
-
orders.push(rerunAttempt.order);
|
|
374
|
+
orders.push(testOrderEntry(rerunAttempt.order, identifier, 2));
|
|
368
375
|
orderPlans.push(plan);
|
|
369
376
|
}
|
|
370
377
|
if (rerunAttempt.budget_exhausted) {
|
|
@@ -500,6 +507,17 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
500
507
|
return { orders, assertions, receiptAnalytics, journeyAnalytics, creationBudget, qcResults };
|
|
501
508
|
}
|
|
502
509
|
|
|
510
|
+
// One test_orders[] entry per attempt, never one per planned path: a re-run
|
|
511
|
+
// places its own real order and the operator cleaning up test orders needs both.
|
|
512
|
+
// So each entry names the planned path it belongs to (`plan_id`, the same id the
|
|
513
|
+
// creation budget charges) and which attempt it was, 1-based, so a reader can
|
|
514
|
+
// tell 5 paths with one retry each from 10 paths (#661). A copy, so the attempt
|
|
515
|
+
// result the assertions read is left as the runner returned it.
|
|
516
|
+
function testOrderEntry(order, identifier, attempt) {
|
|
517
|
+
if (!order || typeof order !== "object") return order;
|
|
518
|
+
return { ...order, plan_id: order.plan_id ?? identifier, attempt };
|
|
519
|
+
}
|
|
520
|
+
|
|
503
521
|
// Analytics-parity leg: capture the live dataLayer event stream + GTM/pixel
|
|
504
522
|
// tag-fires on a baseline (legacy) URL and a candidate (migrated) URL, then
|
|
505
523
|
// diff them into parity assertions. Highest-value target is the thank-you /
|
|
@@ -4378,6 +4396,11 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4378
4396
|
cardSelection = choice.select_card ? await selectPackageCard(page, choice.select_card, []) : null;
|
|
4379
4397
|
control.package_id = choice.select_card?.packageId || await selectedBundleCardPackage(page);
|
|
4380
4398
|
}
|
|
4399
|
+
// Each bundle slot may need a variant (size, colour) before the page lets
|
|
4400
|
+
// Next through (campaigns-os#667). Fill every visible empty slot variant
|
|
4401
|
+
// select with its first in-stock option now, after the card that decides
|
|
4402
|
+
// the slots, and refuse by name when one has nothing to choose.
|
|
4403
|
+
const variantSelections = control.kind === "checkout_button" ? await fillSlotVariants(page) : [];
|
|
4381
4404
|
// The index is the control's position among what its own locator matches,
|
|
4382
4405
|
// so it replays with nth(); no selector is rebuilt from an attribute value.
|
|
4383
4406
|
const controlSelector = control.kind === "add_to_cart"
|
|
@@ -4414,6 +4437,7 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4414
4437
|
control_kind: control.kind,
|
|
4415
4438
|
package_id: control.package_id || null,
|
|
4416
4439
|
...(cardSelection ? { bundle_card_selection: cardSelection } : {}),
|
|
4440
|
+
...(variantSelections.length ? { variant_selections: variantSelections } : {}),
|
|
4417
4441
|
sdk_ready: sdkReady,
|
|
4418
4442
|
arrived_url: redactUrlQuery(safePageUrl(page)),
|
|
4419
4443
|
checkout_selection_surface: surface,
|
|
@@ -4422,6 +4446,117 @@ async function enterCartViaLanding({ page, checkoutPage, entryPage, selectedPack
|
|
|
4422
4446
|
};
|
|
4423
4447
|
}
|
|
4424
4448
|
|
|
4449
|
+
// Chooses a select page's slot variants one at a time (a choice can
|
|
4450
|
+
// re-render its slot) and returns the choices. A field showing a dropdown UI
|
|
4451
|
+
// is picked like a shopper, toggle then row, even when its select is
|
|
4452
|
+
// pre-filled, because a page may count a field as chosen only when a row was
|
|
4453
|
+
// clicked (campaigns-os#667). A select with no dropdown UI is filled only when
|
|
4454
|
+
// empty; a pre-filled native-only page returns [] untouched. Throws
|
|
4455
|
+
// ENTRY_VARIANT_UNFILLED, before any click on the checkout control, when a
|
|
4456
|
+
// field has no in-stock row or option, a pick cannot be clicked, or the page
|
|
4457
|
+
// keeps asking for more choices.
|
|
4458
|
+
const SLOT_VARIANT_FILL_LIMIT = 40;
|
|
4459
|
+
const SLOT_VARIANT_ROW_WAIT_MS = 2000;
|
|
4460
|
+
|
|
4461
|
+
// The slot variant field a pick names, found again by its slot identity
|
|
4462
|
+
// (data-next-bundle-id, data-next-slot-index, variant code) so a re-render
|
|
4463
|
+
// since the pick was planned does not leave a stale index; the planned index
|
|
4464
|
+
// is the fallback for a field that carries no slot index.
|
|
4465
|
+
async function slotVariantField(page, pick) {
|
|
4466
|
+
const fields = page.locator(SLOT_VARIANT_FIELD_SELECTOR);
|
|
4467
|
+
const index = await fields.evaluateAll((nodes, want) => {
|
|
4468
|
+
const clean = (value) => String(value || "").replace(/\s+/g, " ").trim();
|
|
4469
|
+
const found = nodes.findIndex((node) => {
|
|
4470
|
+
const slotIndex = Number.parseInt(node.getAttribute("data-next-slot-index") ?? "", 10);
|
|
4471
|
+
if (!Number.isFinite(slotIndex) || slotIndex + 1 !== want.slot) return false;
|
|
4472
|
+
if ((clean(node.getAttribute("data-next-bundle-id")) || null) !== want.bundle_id) return false;
|
|
4473
|
+
const select = node.querySelector("select");
|
|
4474
|
+
const code = clean(select?.getAttribute("data-next-variant-code") || select?.getAttribute("data-variant-code") || node.getAttribute("data-next-variant-code") || select?.name) || null;
|
|
4475
|
+
return code === want.variant_code;
|
|
4476
|
+
});
|
|
4477
|
+
return found >= 0 ? found : want.field_index;
|
|
4478
|
+
}, pick).catch(() => pick.field_index);
|
|
4479
|
+
return fields.nth(index);
|
|
4480
|
+
}
|
|
4481
|
+
|
|
4482
|
+
// The pick's row, matched by its value among the field's rows (no selector is
|
|
4483
|
+
// built from the value), once it is visible; null if it does not show in time.
|
|
4484
|
+
async function visibleSlotVariantRow(page, pick, timeoutMs) {
|
|
4485
|
+
const deadline = Date.now() + timeoutMs;
|
|
4486
|
+
for (;;) {
|
|
4487
|
+
const field = await slotVariantField(page, pick);
|
|
4488
|
+
const rows = field.locator(SLOT_VARIANT_DROPDOWN_ROW_SELECTOR);
|
|
4489
|
+
const index = await rows.evaluateAll((nodes, value) => nodes.findIndex((node) => {
|
|
4490
|
+
if (String(node.getAttribute("value") || "").replace(/\s+/g, " ").trim() !== value) return false;
|
|
4491
|
+
const rect = node.getBoundingClientRect();
|
|
4492
|
+
const style = getComputedStyle(node);
|
|
4493
|
+
return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
|
|
4494
|
+
}), pick.value).catch(() => -1);
|
|
4495
|
+
if (index >= 0) return rows.nth(index);
|
|
4496
|
+
if (Date.now() >= deadline) return null;
|
|
4497
|
+
await page.waitForTimeout(100);
|
|
4498
|
+
}
|
|
4499
|
+
}
|
|
4500
|
+
async function fillSlotVariants(page) {
|
|
4501
|
+
const selections = [];
|
|
4502
|
+
const picked = [];
|
|
4503
|
+
const step = () => page.evaluate(slotVariantStepScript(), {
|
|
4504
|
+
selector: SLOT_VARIANT_SELECT_SELECTOR,
|
|
4505
|
+
fieldSelector: SLOT_VARIANT_FIELD_SELECTOR,
|
|
4506
|
+
toggleSelector: SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR,
|
|
4507
|
+
rowSelector: SLOT_VARIANT_DROPDOWN_ROW_SELECTOR,
|
|
4508
|
+
picked,
|
|
4509
|
+
}).catch(() => ({ done: true }));
|
|
4510
|
+
for (let pass = 0; pass < SLOT_VARIANT_FILL_LIMIT; pass += 1) {
|
|
4511
|
+
const result = await step();
|
|
4512
|
+
if (result?.done) {
|
|
4513
|
+
if (selections.length) await page.waitForLoadState("networkidle", { timeout: 3000 }).catch(() => {});
|
|
4514
|
+
return selections;
|
|
4515
|
+
}
|
|
4516
|
+
if (result?.unfillable?.detail) {
|
|
4517
|
+
throw codedError(CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED, `${slotVariantLabel(result.unfillable)}: ${result.unfillable.detail}; the page would refuse Next`);
|
|
4518
|
+
}
|
|
4519
|
+
if (result?.unfillable) {
|
|
4520
|
+
throw codedError(
|
|
4521
|
+
CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
|
|
4522
|
+
`${slotVariantLabel(result.unfillable)} has no in-stock ${result.unfillable.via === "dropdown" ? "dropdown row" : "option"} to choose (${result.unfillable.options} ${result.unfillable.via === "dropdown" ? "row" : "option"}(s), all disabled or placeholder); the page would refuse Next`,
|
|
4523
|
+
);
|
|
4524
|
+
}
|
|
4525
|
+
const choice = result.pick || result.filled;
|
|
4526
|
+
if (result.pick) {
|
|
4527
|
+
try {
|
|
4528
|
+
const field = await slotVariantField(page, result.pick);
|
|
4529
|
+
await clickControl(field.locator(SLOT_VARIANT_DROPDOWN_TOGGLE_SELECTOR).filter({ visible: true }).first(), { timeout: 5000 });
|
|
4530
|
+
// The menu may open late or re-render on toggle: re-resolve the field
|
|
4531
|
+
// and the row by its value, and wait for that row to show.
|
|
4532
|
+
const row = await visibleSlotVariantRow(page, result.pick, SLOT_VARIANT_ROW_WAIT_MS);
|
|
4533
|
+
if (!row) {
|
|
4534
|
+
throw codedError(
|
|
4535
|
+
CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
|
|
4536
|
+
`${slotVariantLabel(choice)} dropdown row "${choice.value}" did not show within ${SLOT_VARIANT_ROW_WAIT_MS}ms of opening the dropdown; the page would refuse Next`,
|
|
4537
|
+
);
|
|
4538
|
+
}
|
|
4539
|
+
await clickControl(row, { timeout: 5000 });
|
|
4540
|
+
} catch (error) {
|
|
4541
|
+
if (error?.code === CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED) throw error;
|
|
4542
|
+
throw codedError(
|
|
4543
|
+
CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
|
|
4544
|
+
`${slotVariantLabel(choice)} dropdown row "${choice.value}" could not be clicked (${String(error?.message || error).split("\n")[0]}); the page would refuse Next`,
|
|
4545
|
+
);
|
|
4546
|
+
}
|
|
4547
|
+
picked.push(slotVariantKey(choice));
|
|
4548
|
+
}
|
|
4549
|
+
selections.push({ bundle_id: choice.bundle_id, slot: choice.slot, variant_code: choice.variant_code, value: choice.value, via: result.pick ? "dropdown" : "select" });
|
|
4550
|
+
await page.waitForTimeout(250);
|
|
4551
|
+
}
|
|
4552
|
+
const pending = await step();
|
|
4553
|
+
if (pending?.done) return selections;
|
|
4554
|
+
throw codedError(
|
|
4555
|
+
CART_ENTRY_CODES.ENTRY_VARIANT_UNFILLED,
|
|
4556
|
+
`${slotVariantLabel(pending.pick || pending.filled || pending.unfillable)} still needed a choice after ${SLOT_VARIANT_FILL_LIMIT} variant choices; the page keeps clearing them`,
|
|
4557
|
+
);
|
|
4558
|
+
}
|
|
4559
|
+
|
|
4425
4560
|
// The package of the bundle card a select page shows as selected, or null.
|
|
4426
4561
|
async function selectedBundleCardPackage(page) {
|
|
4427
4562
|
return page.evaluate((cardSelector) => {
|