@nextcommerce/campaigns-os 1.46.0 → 1.48.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 +347 -3
- package/README.md +31 -4
- package/agents/claude/CLAUDE.md +2 -0
- package/agents/codex/AGENTS.md +1 -0
- package/agents/copilot/copilot-instructions.md +1 -0
- package/agents/cursor/campaigns-os.mdc +1 -1
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +1204 -129
- package/contracts/effects.v1.json +3 -3
- package/contracts/release-ledger.json +803 -0
- package/contracts/supported-surface.json +2 -2
- package/contracts/template-brand-contract.shared-commerce.v0.json +3 -3
- package/docs/build-packet.md +23 -3
- package/docs/campaign-build-brief.md +25 -28
- package/docs/local-setup.md +13 -5
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +68 -3
- 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 +9 -4
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -4
- 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 +4 -4
- package/skills/next-campaigns-qa/SKILL.md +4 -4
- package/skills.json +10 -10
- package/src/built-script-syntax.mjs +116 -15
- package/src/cli.mjs +6 -3
- package/src/content-residue.mjs +18 -90
- package/src/diagnostic.mjs +2 -1
- package/src/doctor/checks.mjs +21 -30
- package/src/doctor/inspect.mjs +13 -3
- package/src/doctor/next-step.mjs +4 -0
- package/src/gate-actions.mjs +8 -0
- package/src/local-preview-policy.mjs +92 -0
- package/src/page-kit-sdk-version.mjs +8 -1
- package/src/polish-node.mjs +26 -2
- package/src/progress-node.mjs +5 -1
- package/src/qa-analytics-parity.mjs +37 -2
- package/src/qa-binding-evidence.mjs +5 -3
- package/src/qa-browser.mjs +136 -25
- package/src/qa-node.mjs +33 -4
- package/src/readback.mjs +19 -10
- package/src/sdk-markup.mjs +6 -45
- package/src/sdk-storage-compatibility.mjs +3 -2
- package/src/source-prep.mjs +1 -1
- package/src/tooling-setup.mjs +9 -0
package/src/qa-browser.mjs
CHANGED
|
@@ -428,11 +428,11 @@ async function dispatchTestOrderPlans({ context, plans, checkoutPage, args = {},
|
|
|
428
428
|
// with --analytics-baseline's legacy receipt, and identity resolution cannot
|
|
429
429
|
// derive a receipt page yet (receipt-aware capture is out of packet 01's
|
|
430
430
|
// scope). Absent that override, the candidate IS the resolved target.
|
|
431
|
-
function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl, capturePage = null, rootFallback = null }) {
|
|
431
|
+
function analyticsParityCaptureAssertions({ baseline, candidate, baselineUrl, candidateUrl, capturePage = null, rootFallback = null, candidatePage = null }) {
|
|
432
432
|
const baselinePublicUrl = redactUrlQuery(baselineUrl);
|
|
433
433
|
const candidatePublicUrl = redactUrlQuery(candidateUrl);
|
|
434
434
|
const analyticsPage = { page_id: "analytics", url: candidatePublicUrl || baselinePublicUrl || undefined };
|
|
435
|
-
const assertions = diffAnalyticsParity(baseline, candidate, { url: candidatePublicUrl });
|
|
435
|
+
const assertions = diffAnalyticsParity(baseline, candidate, { url: candidatePublicUrl, ...(candidatePage ? { candidatePage } : {}) });
|
|
436
436
|
assertions.unshift(assertion({
|
|
437
437
|
id: "analytics-parity:capture",
|
|
438
438
|
family: "analytics-parity",
|
|
@@ -540,9 +540,24 @@ async function captureAnalyticsParityInContext(context, baselineUrl, targetUrl,
|
|
|
540
540
|
candidateUrl: selected.url,
|
|
541
541
|
capturePage: selected.capturePage,
|
|
542
542
|
rootFallback: selected.rootFallback,
|
|
543
|
+
candidatePage: automaticParityCandidatePage(selected),
|
|
543
544
|
});
|
|
544
545
|
}
|
|
545
546
|
|
|
547
|
+
// #512: what the automatic candidate is, for the Purchase checks. The campaign
|
|
548
|
+
// root is never a receipt; a built entry is one only when its topology page
|
|
549
|
+
// type says so. A receipt loaded without an order fires no Purchase, so the
|
|
550
|
+
// leg does not go looking for the topology's receipt step: the operator pairs
|
|
551
|
+
// receipts with --analytics-candidate.
|
|
552
|
+
function automaticParityCandidatePage(selected) {
|
|
553
|
+
const pageType = selected.pageType || null;
|
|
554
|
+
return {
|
|
555
|
+
receipt: contractPageType({ page_type: pageType }) === "receipt",
|
|
556
|
+
source: selected.capturePage?.source || null,
|
|
557
|
+
page_type: pageType,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
546
561
|
// Analytics CORRECTNESS inventory leg: capture ONE page and assess only
|
|
547
562
|
// declared tags/pixels. Purchase is finalized later from the canonical
|
|
548
563
|
// typed-card order's recognized receipt; this inventory visit is never treated
|
|
@@ -716,6 +731,7 @@ function analyticsCaptureCandidates(url, options = {}) {
|
|
|
716
731
|
source: "built_entry",
|
|
717
732
|
page_id: entry.page_id || null,
|
|
718
733
|
funnel_id: entry.funnel_id || null,
|
|
734
|
+
page_type: entry.page_type || null,
|
|
719
735
|
// The query value is redacted from every record; this says the entry
|
|
720
736
|
// was told apart from the root by its query alone.
|
|
721
737
|
...(url && isQueryRoutedFrom(trim(entry.url), url) ? { query_routed: true } : {}),
|
|
@@ -761,6 +777,7 @@ async function captureFirstAnsweringAnalyticsPage(context, url, args, extraHosts
|
|
|
761
777
|
...queryRouted(candidate),
|
|
762
778
|
http_status: httpStatus ?? null,
|
|
763
779
|
},
|
|
780
|
+
pageType: candidate.page_type || null,
|
|
764
781
|
rootFallback: usedFallback ? rootFallback : null,
|
|
765
782
|
};
|
|
766
783
|
}
|
|
@@ -2805,7 +2822,22 @@ async function pricingVisibilityAssertions(browserPage, page, options = {}) {
|
|
|
2805
2822
|
selectors.length ? countVisiblePriceRows(browserPage, selectors) : 0,
|
|
2806
2823
|
countVisiblePriceRows(browserPage, totalSelectors),
|
|
2807
2824
|
]);
|
|
2808
|
-
|
|
2825
|
+
// Nothing priced is visible. When the checkout also has no package
|
|
2826
|
+
// selection of its own and the SDK reports an empty cart, the cart is
|
|
2827
|
+
// filled on an earlier page (a landing link carrying forcePackageId), and
|
|
2828
|
+
// opening the checkout directly shows a state no shopper reaches. The
|
|
2829
|
+
// test-order path enters that cart from the landing page instead.
|
|
2830
|
+
const emptyEntry = visibleCount === 0 && totalVisibleCount === 0
|
|
2831
|
+
? await Promise.all([
|
|
2832
|
+
browserPage.evaluate(() => (typeof window.next?.getCartCount === "function" ? window.next.getCartCount() : null)),
|
|
2833
|
+
browserPage.evaluate(checkoutSelectionSurfaceScript()),
|
|
2834
|
+
]).then(
|
|
2835
|
+
([cart_count, selection_surface]) => ({ cart_count, selection_surface }),
|
|
2836
|
+
// A failed probe leaves the row failed, and says why on the row.
|
|
2837
|
+
(error) => ({ probe_error: trim(error?.message || String(error)).slice(0, 200) }),
|
|
2838
|
+
)
|
|
2839
|
+
: null;
|
|
2840
|
+
return [checkoutPriceVisibilityAssertion({ page, selectors, visibleCount, totalSelectors, totalVisibleCount, emptyEntry })];
|
|
2809
2841
|
}
|
|
2810
2842
|
return [];
|
|
2811
2843
|
}
|
|
@@ -2879,25 +2911,37 @@ function upsellPriceVisibilityAssertion({ page, selectors, visibleCount }) {
|
|
|
2879
2911
|
// no `totalSelectors` ran the bundle-row check alone, and the row says so
|
|
2880
2912
|
// rather than reporting an empty selector list that reads like a check that
|
|
2881
2913
|
// ran and found nothing.
|
|
2882
|
-
|
|
2914
|
+
//
|
|
2915
|
+
// `emptyEntry` ({ cart_count, selection_surface }, or { probe_error }) is read
|
|
2916
|
+
// only when nothing priced is visible. An empty SDK cart (cart_count 0, never
|
|
2917
|
+
// null: null means no SDK was read) on a checkout with no package selection of
|
|
2918
|
+
// its own is not a price the shopper fails to see: the row is skipped with
|
|
2919
|
+
// that reason rather than failed, and only that row carries the probe values.
|
|
2920
|
+
function checkoutPriceVisibilityAssertion({ page, selectors, visibleCount, totalSelectors, totalVisibleCount = 0, emptyEntry = null }) {
|
|
2883
2921
|
const totalChecked = Array.isArray(totalSelectors);
|
|
2884
2922
|
const ok = visibleCount >= 1 || (totalChecked && totalVisibleCount >= 1);
|
|
2923
|
+
const enteredUpstream = !ok && emptyEntry?.cart_count === 0 && emptyEntry?.selection_surface?.count === 0;
|
|
2924
|
+
const counts = totalChecked
|
|
2925
|
+
? `${visibleCount} visible price row(s); ${totalVisibleCount} visible cart-summary total(s)`
|
|
2926
|
+
: `${visibleCount} visible price row(s)`;
|
|
2885
2927
|
return assertion({
|
|
2886
2928
|
id: "pricing.checkout_price_visible",
|
|
2887
2929
|
family: "pricing",
|
|
2888
2930
|
page,
|
|
2889
|
-
status: ok ? STATUS.PASS : STATUS.FAIL,
|
|
2890
|
-
severity: ok ? undefined : SEVERITY.WARN,
|
|
2931
|
+
status: ok ? STATUS.PASS : enteredUpstream ? STATUS.SKIPPED : STATUS.FAIL,
|
|
2932
|
+
severity: ok || enteredUpstream ? undefined : SEVERITY.WARN,
|
|
2891
2933
|
expected: totalChecked
|
|
2892
2934
|
? "at least one visible checkout bundle price row or a visible cart-summary total"
|
|
2893
2935
|
: "at least one visible checkout bundle price row",
|
|
2894
|
-
actual:
|
|
2895
|
-
? `${
|
|
2896
|
-
:
|
|
2936
|
+
actual: enteredUpstream
|
|
2937
|
+
? `${counts}: opened directly, the checkout has an empty cart and no package selection of its own; its cart is filled on an earlier page, and the test order enters it from there`
|
|
2938
|
+
: counts,
|
|
2897
2939
|
evidence: {
|
|
2898
2940
|
selectors,
|
|
2899
2941
|
visible_count: visibleCount,
|
|
2900
2942
|
...(totalChecked ? { total_selectors: totalSelectors, total_visible_count: totalVisibleCount } : {}),
|
|
2943
|
+
...(enteredUpstream ? { cart_count: emptyEntry.cart_count, checkout_selection_surface: emptyEntry.selection_surface } : {}),
|
|
2944
|
+
...(emptyEntry?.probe_error ? { empty_cart_probe_error: emptyEntry.probe_error } : {}),
|
|
2901
2945
|
page_url: page.url,
|
|
2902
2946
|
},
|
|
2903
2947
|
});
|
|
@@ -4945,10 +4989,7 @@ async function waitForCheckoutResult(page, events = null) {
|
|
|
4945
4989
|
if (outcomeUrl.test(String(safePageUrl(page) || ""))) break;
|
|
4946
4990
|
if (events) {
|
|
4947
4991
|
const rejected = rejectedOrderCreateResponse(events);
|
|
4948
|
-
if (rejected)
|
|
4949
|
-
const detail = typeof rejected.body?.detail === "string" ? `: ${trim(rejected.body.detail)}` : "";
|
|
4950
|
-
throw new Error(`order create rejected: HTTP ${rejected.status}${detail}`);
|
|
4951
|
-
}
|
|
4992
|
+
if (rejected) throw new Error(orderCreateRejectionMessage(rejected));
|
|
4952
4993
|
const failed = failedOrderCreateRequest(events);
|
|
4953
4994
|
if (failed) throw new Error(`order create request failed: ${failed.failure || "network failure"}`);
|
|
4954
4995
|
}
|
|
@@ -4962,6 +5003,23 @@ async function waitForCheckoutResult(page, events = null) {
|
|
|
4962
5003
|
// The MOST RECENT create response decides the outcome: SDKs/platforms retry
|
|
4963
5004
|
// transient create failures, so [..., 400, 201] means the retry succeeded and
|
|
4964
5005
|
// the earlier rejection is history, not the result.
|
|
5006
|
+
// The platform refuses an order whose customer, items and total match one it
|
|
5007
|
+
// accepted or is still processing in the last 30 minutes, and reports it in
|
|
5008
|
+
// `payment_details`. QA reuses one test customer, so two runs against the same
|
|
5009
|
+
// campaign at once, or a rerun after an attempt that died mid-submit, trip it.
|
|
5010
|
+
const DUPLICATE_ORDER_PATTERN = /duplicate order/i;
|
|
5011
|
+
const DUPLICATE_ORDER_REMEDY =
|
|
5012
|
+
"duplicate_order: the platform refused an order matching a recent one from the same test customer with the same items and total, " +
|
|
5013
|
+
"for up to 30 minutes. Concurrent QA runs that share a test customer collide. Re-run with a different --test-email-prefix " +
|
|
5014
|
+
"(or --test-email), or wait. The shipping address is not part of the match";
|
|
5015
|
+
|
|
5016
|
+
function orderCreateRejectionMessage(rejected) {
|
|
5017
|
+
const body = rejected?.body;
|
|
5018
|
+
const reason = [body?.detail, body?.payment_details].find((value) => typeof value === "string" && value.trim());
|
|
5019
|
+
const base = `order create rejected: HTTP ${rejected?.status}${reason ? `: ${trim(reason)}` : ""}`;
|
|
5020
|
+
return reason && DUPLICATE_ORDER_PATTERN.test(reason) ? `${base} (${DUPLICATE_ORDER_REMEDY})` : base;
|
|
5021
|
+
}
|
|
5022
|
+
|
|
4965
5023
|
function rejectedOrderCreateResponse(events) {
|
|
4966
5024
|
for (let index = events.responses.length - 1; index >= 0; index -= 1) {
|
|
4967
5025
|
const response = events.responses[index];
|
|
@@ -5171,10 +5229,18 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
|
|
|
5171
5229
|
// the browser's wall time at request start), and armedAt is read before the
|
|
5172
5230
|
// click is sent, so this click's request starts at or after it. A request
|
|
5173
5231
|
// with no known start time is not excluded: nothing shows it is stale.
|
|
5232
|
+
//
|
|
5233
|
+
// A redirected POST (307/308) is one mutation across several hops, and
|
|
5234
|
+
// Playwright gives each hop its own Request. The watch matches a hop by the
|
|
5235
|
+
// request that started its chain, and passes over a hop the browser follows,
|
|
5236
|
+
// so it resolves on the chain's final response: that is the one carrying the
|
|
5237
|
+
// order. A chain with no final response is not answered (#516).
|
|
5174
5238
|
const armedAt = Date.now();
|
|
5175
5239
|
const mutationPromise = path === "accept"
|
|
5176
5240
|
? page.waitForResponse((response) => {
|
|
5177
|
-
if (
|
|
5241
|
+
if (isFollowedRedirect(response)) return false;
|
|
5242
|
+
const root = responseRequest(response);
|
|
5243
|
+
if (!root || root.method() !== "POST" || !isOrderUpsellsUrl(root.url())) return false;
|
|
5178
5244
|
const startedAt = responseRequestStartedAt(response);
|
|
5179
5245
|
return startedAt === null || startedAt >= armedAt;
|
|
5180
5246
|
}, { timeout: UPSELL_MUTATION_TIMEOUT_MS + (perpetual ? 0 : UPSELL_CLICK_TIMEOUT_MS) }).catch(() => null)
|
|
@@ -5205,9 +5271,9 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
|
|
|
5205
5271
|
// waits for a later read-back before it judges the step.
|
|
5206
5272
|
...(bodyRead ? { api_response_body_read: { timed_out: bodyRead.timed_out, waited_ms: bodyRead.waited_ms, bound_ms: bodyRead.bound_ms } } : {}),
|
|
5207
5273
|
};
|
|
5208
|
-
// The request this click made
|
|
5209
|
-
// same order-upsells URL, so a late body
|
|
5210
|
-
// answers this request.
|
|
5274
|
+
// The request this click made (the root of its redirect chain). Another
|
|
5275
|
+
// step's upsell mutation posts to the same order-upsells URL, so a late body
|
|
5276
|
+
// is this step's only when it answers this request.
|
|
5211
5277
|
const mutationRequest = mutationResponse ? responseRequest(mutationResponse) : null;
|
|
5212
5278
|
if (mutationRequest) record[REQUEST_IDENTITY] = mutationRequest;
|
|
5213
5279
|
return record;
|
|
@@ -6191,21 +6257,52 @@ function mutationRespondedAt(response) {
|
|
|
6191
6257
|
// it). Playwright hands every listener the same Request object for one
|
|
6192
6258
|
// request, and a different one for each request, even to the same URL: an
|
|
6193
6259
|
// upsell step matches its own mutation's late body by this identity (#505).
|
|
6260
|
+
//
|
|
6261
|
+
// A redirect gives each hop a new Request, so the identity is the request
|
|
6262
|
+
// that started the chain (followed back through redirectedFrom()): every hop
|
|
6263
|
+
// of one redirected POST carries the same identity, and two POSTs never do
|
|
6264
|
+
// (#516).
|
|
6194
6265
|
const REQUEST_IDENTITY = Symbol("campaigns-os.request-identity");
|
|
6195
6266
|
|
|
6196
6267
|
function responseRequest(response) {
|
|
6197
6268
|
try {
|
|
6198
|
-
return response.request()
|
|
6269
|
+
return redirectChainRoot(response.request());
|
|
6199
6270
|
} catch {
|
|
6200
6271
|
return null;
|
|
6201
6272
|
}
|
|
6202
6273
|
}
|
|
6203
6274
|
|
|
6275
|
+
// Walks back to the chain's first request. A visited set ends the walk on any
|
|
6276
|
+
// chain, however long, so every hop of one chain reports the same root.
|
|
6277
|
+
function redirectChainRoot(request) {
|
|
6278
|
+
let root = request || null;
|
|
6279
|
+
const visited = new Set();
|
|
6280
|
+
while (root && typeof root.redirectedFrom === "function" && !visited.has(root)) {
|
|
6281
|
+
visited.add(root);
|
|
6282
|
+
const previous = root.redirectedFrom();
|
|
6283
|
+
if (!previous) break;
|
|
6284
|
+
root = previous;
|
|
6285
|
+
}
|
|
6286
|
+
return root;
|
|
6287
|
+
}
|
|
6288
|
+
|
|
6289
|
+
// A 3xx hop the browser follows: it names a Location, and the chain's answer
|
|
6290
|
+
// is a later hop's response. A 3xx with no Location is a final response.
|
|
6291
|
+
function isFollowedRedirect(response) {
|
|
6292
|
+
try {
|
|
6293
|
+
const status = response.status();
|
|
6294
|
+
return status >= 300 && status < 400 && Boolean(response.headers()?.location);
|
|
6295
|
+
} catch {
|
|
6296
|
+
return false;
|
|
6297
|
+
}
|
|
6298
|
+
}
|
|
6299
|
+
|
|
6204
6300
|
// When the browser started a response's request, in epoch milliseconds (the
|
|
6205
|
-
// same clock as Date.now()), or null when Playwright does not report it.
|
|
6301
|
+
// same clock as Date.now()), or null when Playwright does not report it. For a
|
|
6302
|
+
// redirected request this is when its chain's first hop started.
|
|
6206
6303
|
function responseRequestStartedAt(response) {
|
|
6207
6304
|
try {
|
|
6208
|
-
const startTime = response
|
|
6305
|
+
const startTime = responseRequest(response).timing().startTime;
|
|
6209
6306
|
return Number.isFinite(startTime) && startTime > 0 ? startTime : null;
|
|
6210
6307
|
} catch {
|
|
6211
6308
|
return null;
|
|
@@ -6228,11 +6325,16 @@ function captureCheckoutEvents(page) {
|
|
|
6228
6325
|
// polls for it. A bound here would record a slow but successful order body
|
|
6229
6326
|
// as null for good, and the order would read as not created.
|
|
6230
6327
|
page.on("response", async (response) => {
|
|
6231
|
-
|
|
6328
|
+
// A redirected chain is logged by where it started, so its final hop is
|
|
6329
|
+
// captured even when it answers from a URL this filter would not match
|
|
6330
|
+
// (#516): the late-body search finds it by request identity.
|
|
6331
|
+
const request = responseRequest(response);
|
|
6332
|
+
let chainUrl = null;
|
|
6333
|
+
try { chainUrl = request?.url() ?? null; } catch { /* identity only */ }
|
|
6334
|
+
if (!interesting.test(response.url()) && !(chainUrl && interesting.test(chainUrl))) return;
|
|
6232
6335
|
// Taken before the body read: an entry lands in the log when its body
|
|
6233
6336
|
// finishes, so its position says nothing about when it was requested.
|
|
6234
6337
|
const requestStartedAt = responseRequestStartedAt(response);
|
|
6235
|
-
const request = responseRequest(response);
|
|
6236
6338
|
const entry = {
|
|
6237
6339
|
status: response.status(),
|
|
6238
6340
|
url: response.url(),
|
|
@@ -6332,7 +6434,7 @@ function sanitizedEvents(events) {
|
|
|
6332
6434
|
responses: events.responses.slice(-20).map((response) => ({
|
|
6333
6435
|
status: response.status,
|
|
6334
6436
|
url: response.url,
|
|
6335
|
-
body: summarizeResponseBody(response.body),
|
|
6437
|
+
body: summarizeResponseBody(response.body, { status: response.status }),
|
|
6336
6438
|
})),
|
|
6337
6439
|
failed: events.failed.slice(-20),
|
|
6338
6440
|
console: events.console.slice(-20),
|
|
@@ -6359,7 +6461,7 @@ function summarizeRequestPostData(value) {
|
|
|
6359
6461
|
}
|
|
6360
6462
|
}
|
|
6361
6463
|
|
|
6362
|
-
function summarizeResponseBody(body) {
|
|
6464
|
+
function summarizeResponseBody(body, { status = null } = {}) {
|
|
6363
6465
|
if (typeof body === "string") return trim(body).slice(0, 1000);
|
|
6364
6466
|
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
|
6365
6467
|
return {
|
|
@@ -6371,6 +6473,12 @@ function summarizeResponseBody(body) {
|
|
|
6371
6473
|
...(body.checkout_url ? { checkout_url: body.checkout_url } : {}),
|
|
6372
6474
|
...(Array.isArray(body.lines) ? { lines: extractReceiptLines(body) } : {}),
|
|
6373
6475
|
...(body.detail ? { detail: body.detail } : {}),
|
|
6476
|
+
// A rejected request names its reason here (for example the
|
|
6477
|
+
// duplicate-order refusal). Kept only on an error response, so a
|
|
6478
|
+
// successful create never carries payment_details into evidence.
|
|
6479
|
+
...(Number(status) >= 400 && typeof body.payment_details === "string"
|
|
6480
|
+
? { payment_details: trim(body.payment_details).slice(0, 300) }
|
|
6481
|
+
: {}),
|
|
6374
6482
|
};
|
|
6375
6483
|
}
|
|
6376
6484
|
|
|
@@ -7369,7 +7477,8 @@ async function waitForLateUpsellEvidence(events, { responseIndexBefore, mutation
|
|
|
7369
7477
|
const response = fresh[index];
|
|
7370
7478
|
if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) continue;
|
|
7371
7479
|
if (!(response.status >= 200 && response.status < 300)) continue;
|
|
7372
|
-
|
|
7480
|
+
// A redirected mutation's final hop may answer from another URL; its
|
|
7481
|
+
// identity, the root of its chain, is what ties it to this step (#516).
|
|
7373
7482
|
if (mutationRequest && response[REQUEST_IDENTITY] === mutationRequest) return { source: "late_upsell_body", body: response.body };
|
|
7374
7483
|
}
|
|
7375
7484
|
for (let index = fresh.length - 1; index >= 0; index -= 1) {
|
|
@@ -7727,6 +7836,8 @@ export const __qaBrowserTestHooks = Object.freeze({
|
|
|
7727
7836
|
linePriceDeltaEvidence,
|
|
7728
7837
|
packageMatchesLine,
|
|
7729
7838
|
rejectedOrderCreateResponse,
|
|
7839
|
+
orderCreateRejectionMessage,
|
|
7840
|
+
summarizeResponseBody,
|
|
7730
7841
|
failedOrderCreateRequest,
|
|
7731
7842
|
extractOrderVouchers,
|
|
7732
7843
|
orderDiscountTotal,
|
package/src/qa-node.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "./spec-source-identity.mjs";
|
|
2
|
+
import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD, starterResidueIsExpected } from "./local-preview-policy.mjs";
|
|
2
3
|
import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion, scriptParseAssertion } from './qa-binding-evidence.mjs';
|
|
3
4
|
import { shellToken } from "./shell-token.mjs";
|
|
4
5
|
import { applyQaBuildScope, specForQaScope } from "./qa-build-scope.mjs";
|
|
@@ -481,6 +482,8 @@ async function resolveQaInputs(args, {
|
|
|
481
482
|
packetPath,
|
|
482
483
|
report: checkpointPreflight?.runtimeReport,
|
|
483
484
|
hiddenEagerMediaGate,
|
|
485
|
+
packet,
|
|
486
|
+
baseUrl: stringArg(args["base-url"]),
|
|
484
487
|
});
|
|
485
488
|
const qaWaivers = resolveQaWaivers({ packetPath, report: checkpointPreflight?.runtimeReport });
|
|
486
489
|
const qaScope = applyQaBuildScope(topologies, {
|
|
@@ -593,7 +596,10 @@ function resolvePacketCheckpointPreflight(args, {
|
|
|
593
596
|
waivers: report?.waivers,
|
|
594
597
|
required: true,
|
|
595
598
|
}),
|
|
596
|
-
|
|
599
|
+
applyLocalPreviewToCheckpoint(
|
|
600
|
+
evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
|
|
601
|
+
{ packet, report, baseUrl: stringArg(args["base-url"]) },
|
|
602
|
+
),
|
|
597
603
|
];
|
|
598
604
|
const runtimeReport = reportMatchesPacketIdentity(report, packet) ? report : null;
|
|
599
605
|
return {
|
|
@@ -692,6 +698,8 @@ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
|
|
|
692
698
|
packetPath: preflight.packetPath,
|
|
693
699
|
report: preflight.runtimeReport,
|
|
694
700
|
hiddenEagerMediaGate,
|
|
701
|
+
packet: preflight.packet,
|
|
702
|
+
baseUrl: stringArg(args["base-url"]),
|
|
695
703
|
});
|
|
696
704
|
return {
|
|
697
705
|
themeGate,
|
|
@@ -931,16 +939,18 @@ function resolvePolishGate({
|
|
|
931
939
|
packetPath,
|
|
932
940
|
report: reportOverride = undefined,
|
|
933
941
|
hiddenEagerMediaGate = undefined,
|
|
942
|
+
packet = null,
|
|
943
|
+
baseUrl = null,
|
|
934
944
|
}) {
|
|
935
945
|
const report = reportOverride === undefined
|
|
936
946
|
? loadRuntimeArtifact(packetPath, "assembly-report.json")
|
|
937
947
|
: reportOverride;
|
|
938
|
-
const gate = evaluatePolishGate({
|
|
948
|
+
const gate = applyLocalPreviewToPolishGate(evaluatePolishGate({
|
|
939
949
|
report,
|
|
940
950
|
required: true,
|
|
941
951
|
hiddenEagerMediaGate,
|
|
942
952
|
currentOutputFingerprint: currentBuiltOutputFingerprint(packetPath),
|
|
943
|
-
});
|
|
953
|
+
}), { packet, checkpointGate: hiddenEagerMediaGate, baseUrl });
|
|
944
954
|
gate.scope_source = report ? "assembly_report" : "missing_assembly_report";
|
|
945
955
|
return gate;
|
|
946
956
|
}
|
|
@@ -1044,6 +1054,18 @@ function polishGateAssertion(gate) {
|
|
|
1044
1054
|
},
|
|
1045
1055
|
});
|
|
1046
1056
|
}
|
|
1057
|
+
if (gate.status === CARRIED_FORWARD) {
|
|
1058
|
+
return assertion({
|
|
1059
|
+
id: gate.code,
|
|
1060
|
+
family: "polish_gate",
|
|
1061
|
+
page,
|
|
1062
|
+
status: STATUS.WARN,
|
|
1063
|
+
severity: SEVERITY.WARN,
|
|
1064
|
+
expected: "current structured Polish evidence produced by next-campaigns-polish",
|
|
1065
|
+
actual: carriedForwardMessage(gate),
|
|
1066
|
+
evidence: { ...evidence, reason: gate.reason, carried_forward: gate.carried_forward },
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1047
1069
|
if (gate.status === "not_applicable") {
|
|
1048
1070
|
return assertion({
|
|
1049
1071
|
id: gate.code,
|
|
@@ -1350,6 +1372,9 @@ function hiddenEagerMediaGateAssertion(gate) {
|
|
|
1350
1372
|
if (summary.status === "waived") {
|
|
1351
1373
|
return assertion({ ...common, status: STATUS.WARN, severity: SEVERITY.WARN, waiver: summary.waiver });
|
|
1352
1374
|
}
|
|
1375
|
+
if (summary.status === CARRIED_FORWARD) {
|
|
1376
|
+
return assertion({ ...common, status: STATUS.WARN, severity: SEVERITY.WARN, actual: `${summary.code}: ${carriedForwardMessage(summary)}` });
|
|
1377
|
+
}
|
|
1353
1378
|
if (summary.status === "not_applicable") {
|
|
1354
1379
|
return assertion({ ...common, status: STATUS.SKIPPED });
|
|
1355
1380
|
}
|
|
@@ -2306,7 +2331,11 @@ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCam
|
|
|
2306
2331
|
if (args.browser === true) {
|
|
2307
2332
|
assertions.push(...await runBrowserChecks(resolved.topologies, args, {
|
|
2308
2333
|
brandContract: resolved.brandContract,
|
|
2309
|
-
|
|
2334
|
+
// With no generatable brand theme on the local preview, the starter
|
|
2335
|
+
// template is the design: its residue is a warning (local-preview-policy.mjs).
|
|
2336
|
+
residueSeverity: starterResidueIsExpected(gate, { packet: resolved.packet, baseUrl: resolved.baseUrl })
|
|
2337
|
+
? SEVERITY.WARN
|
|
2338
|
+
: residueSeverityForThemeGate(gate.status),
|
|
2310
2339
|
supportedPaymentMethods: supportedPaymentMethodsFromSpec(resolved.spec),
|
|
2311
2340
|
}));
|
|
2312
2341
|
}
|
package/src/readback.mjs
CHANGED
|
@@ -1065,8 +1065,11 @@ function artifactData(views, key) {
|
|
|
1065
1065
|
}
|
|
1066
1066
|
|
|
1067
1067
|
/**
|
|
1068
|
-
* Bucket the QA verdict's assertions by recorded status
|
|
1068
|
+
* Bucket the QA verdict's assertions by recorded status: `{ fail, pass,
|
|
1069
|
+
* skipped, review, other }`.
|
|
1069
1070
|
*
|
|
1071
|
+
* `review` collects the verdict's `warn` and `manual_review` statuses: evidence
|
|
1072
|
+
* that still needs reading, which the disposition counts as an exception.
|
|
1070
1073
|
* `other` collects any status this readback does not project (it renders those
|
|
1071
1074
|
* rows as written rather than reclassifying them). Non-object entries are
|
|
1072
1075
|
* dropped: an assertion the readback cannot address by status is not an
|
|
@@ -1075,13 +1078,15 @@ function artifactData(views, key) {
|
|
|
1075
1078
|
* branch.
|
|
1076
1079
|
*/
|
|
1077
1080
|
export function partitionAssertions(views) {
|
|
1078
|
-
const buckets = { fail: [], pass: [], skipped: [], other: [] };
|
|
1081
|
+
const buckets = { fail: [], pass: [], skipped: [], review: [], other: [] };
|
|
1079
1082
|
const verdict = artifactData(views, "qa_verdict");
|
|
1080
1083
|
if (verdict === null) return buckets;
|
|
1081
1084
|
for (const assertion of verdict.assertions ?? []) {
|
|
1082
1085
|
if (!isPlainObject(assertion)) continue;
|
|
1083
1086
|
const status = assertion.status;
|
|
1084
|
-
const bucket = status === "fail" || status === "pass" || status === "skipped"
|
|
1087
|
+
const bucket = status === "fail" || status === "pass" || status === "skipped"
|
|
1088
|
+
? status
|
|
1089
|
+
: status === "warn" || status === "manual_review" ? "review" : "other";
|
|
1085
1090
|
buckets[bucket].push(assertion);
|
|
1086
1091
|
}
|
|
1087
1092
|
return buckets;
|
|
@@ -1414,15 +1419,17 @@ function renderVerdict(views, lines) {
|
|
|
1414
1419
|
lines.push(
|
|
1415
1420
|
`QA VERDICT [QA verdict; disposition: ${verdict.disposition} — Campaigns OS is the verdict authority]`,
|
|
1416
1421
|
);
|
|
1417
|
-
const { fail: failed, pass: passed, skipped, other } = partitionAssertions(views);
|
|
1422
|
+
const { fail: failed, pass: passed, skipped, review, other } = partitionAssertions(views);
|
|
1418
1423
|
let countLine = ` assertions: ${failed.length} fail, ${passed.length} pass, ${skipped.length} skipped`;
|
|
1424
|
+
if (review.length) countLine += `, ${review.length} warn or manual review`;
|
|
1419
1425
|
if (other.length) countLine += `, ${other.length} unrecognized status`;
|
|
1420
1426
|
lines.push(countLine);
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1427
|
+
// fail, warn and manual_review rows print what Campaigns OS recorded: the
|
|
1428
|
+
// row's actual value and any evidence problems.
|
|
1429
|
+
const renderRecordedRow = (assertion) => {
|
|
1430
|
+
const severityText = assertion.severity ? `, severity ${assertion.severity}` : "";
|
|
1424
1431
|
lines.push(
|
|
1425
|
-
`
|
|
1432
|
+
` ${assertion.status} ${recorded(assertion.id, "(no id)")} ` +
|
|
1426
1433
|
`(family ${recorded(assertion.family, "(no family)")}${severityText})`,
|
|
1427
1434
|
);
|
|
1428
1435
|
const actual = assertion.actual;
|
|
@@ -1433,7 +1440,9 @@ function renderVerdict(views, lines) {
|
|
|
1433
1440
|
lines.push(` recorded problems (${problems.length}):`);
|
|
1434
1441
|
for (const problem of problems) lines.push(` - ${problem}`);
|
|
1435
1442
|
}
|
|
1436
|
-
}
|
|
1443
|
+
};
|
|
1444
|
+
for (const assertion of failed) renderRecordedRow(assertion);
|
|
1445
|
+
for (const assertion of review) renderRecordedRow(assertion);
|
|
1437
1446
|
for (const assertion of passed) {
|
|
1438
1447
|
const family = assertion.family || assertion.id || "(no family)";
|
|
1439
1448
|
lines.push(` pass ${recorded(assertion.id, "(no id)")} (family ${family})`);
|
|
@@ -1442,7 +1451,7 @@ function renderVerdict(views, lines) {
|
|
|
1442
1451
|
lines.push(
|
|
1443
1452
|
` unrecognized status ${JSON.stringify(assertion.status ?? null)} ${recorded(assertion.id, "(no id)")} ` +
|
|
1444
1453
|
`(family ${assertion.family || "(no family)"}) — shown as written; this readback ` +
|
|
1445
|
-
"projects fail, pass, and
|
|
1454
|
+
"projects fail, pass, skipped, warn and manual_review statuses",
|
|
1446
1455
|
);
|
|
1447
1456
|
}
|
|
1448
1457
|
|
package/src/sdk-markup.mjs
CHANGED
|
@@ -40,12 +40,11 @@
|
|
|
40
40
|
// TEMPLATE_DOUBLE_BRACE `{{` inside an SDK-owned <template>. SDK tokens
|
|
41
41
|
// are single-brace and conditions are no-brace;
|
|
42
42
|
// a double brace renders literally.
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
// what the SDK reads; otherwise the route type.
|
|
43
|
+
//
|
|
44
|
+
// Not a finding: data-next-is-upsell="true" on a checkout order bump. The
|
|
45
|
+
// selected bump is a line item on the checkout order, tagged as an upsell so
|
|
46
|
+
// order reports show it apart from core items. That is the intended default;
|
|
47
|
+
// a bump include opts out with is_upsell: false.
|
|
49
48
|
//
|
|
50
49
|
// Info (advisory, one note per campaign, no code)
|
|
51
50
|
// unknown_attributes[] a data-next-* name the pinned SDK's attribute
|
|
@@ -70,9 +69,6 @@ import {
|
|
|
70
69
|
isIndexedSdkAttribute,
|
|
71
70
|
isKnownCheckoutFieldName,
|
|
72
71
|
} from "./sdk-attribute-index.mjs";
|
|
73
|
-
import { builtPageTypeMeta } from "./upsell-selector-scope.mjs";
|
|
74
|
-
|
|
75
|
-
const normalizedPageTypeValue = (type) => (type == null ? null : String(type).trim().toLowerCase());
|
|
76
72
|
|
|
77
73
|
export const SDK_MARKUP = "built_output.sdk_markup";
|
|
78
74
|
|
|
@@ -84,7 +80,6 @@ export const SDK_MARKUP_CODES = Object.freeze({
|
|
|
84
80
|
ORPHANED_UPSELL_ACTION: { code: `${SDK_MARKUP}.orphaned_upsell_action`, severity: "error" },
|
|
85
81
|
DOUBLE_SELECTED: { code: `${SDK_MARKUP}.double_selected`, severity: "warning" },
|
|
86
82
|
TEMPLATE_DOUBLE_BRACE: { code: `${SDK_MARKUP}.template_double_brace`, severity: "warning" },
|
|
87
|
-
CHECKOUT_BUMP_IS_UPSELL: { code: `${SDK_MARKUP}.checkout_bump_is_upsell`, severity: "warning" },
|
|
88
83
|
// Unknown data-next-* names are not a finding and carry no code: they are
|
|
89
84
|
// information on the gate (unknown_attributes[]) and one advisory ready line.
|
|
90
85
|
});
|
|
@@ -160,22 +155,11 @@ function describe(entry) {
|
|
|
160
155
|
return `${bits.join("")}>`;
|
|
161
156
|
}
|
|
162
157
|
|
|
163
|
-
function describeBump(entry) {
|
|
164
|
-
const id = entry.attrs.get("id");
|
|
165
|
-
const packageId = entry.attrs.get("data-next-package-id");
|
|
166
|
-
const bits = [`<${entry.tag}`];
|
|
167
|
-
if (id) bits.push(` id="${id}"`);
|
|
168
|
-
if (packageId) bits.push(` data-next-package-id="${packageId}"`);
|
|
169
|
-
return `${bits.join("")}>`;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
158
|
/**
|
|
173
159
|
* Scan one built page. Returns findings with { code_name, code, severity,
|
|
174
160
|
* page_id, file, message, detail } and the set of unknown data-next-* names.
|
|
175
|
-
* `page_type` is the route-inferred type, used only when the page declares no
|
|
176
|
-
* live, unambiguous next-page-type meta (builtPageTypeMeta).
|
|
177
161
|
*/
|
|
178
|
-
export function scanPageMarkup({ page_id, file = null, content = ""
|
|
162
|
+
export function scanPageMarkup({ page_id, file = null, content = "" }) {
|
|
179
163
|
const document = parse(String(content || ""));
|
|
180
164
|
const where = file || page_id;
|
|
181
165
|
const findings = [];
|
|
@@ -186,13 +170,10 @@ export function scanPageMarkup({ page_id, file = null, content = "", page_type =
|
|
|
186
170
|
const selectorIds = new Set(); // ids of elements that are themselves a selector
|
|
187
171
|
const templates = []; // { entry, sdkOwned }
|
|
188
172
|
const referencedTemplateIds = new Set();
|
|
189
|
-
const upsellFlagged = []; // entries carrying data-next-is-upsell="true"
|
|
190
173
|
|
|
191
174
|
walkElements(document, (entry) => {
|
|
192
175
|
const { tag, attrs: a, ancestors } = entry;
|
|
193
176
|
|
|
194
|
-
if ((a.get("data-next-is-upsell") || "").trim().toLowerCase() === "true") upsellFlagged.push(entry);
|
|
195
|
-
|
|
196
177
|
for (const [name] of a) {
|
|
197
178
|
if (name.startsWith("data-next-") && !isIndexedSdkAttribute(name)) unknown.add(name);
|
|
198
179
|
if (TEMPLATE_ID_ATTRIBUTES.test(name) && a.get(name)) referencedTemplateIds.add(a.get(name).trim());
|
|
@@ -307,26 +288,6 @@ export function scanPageMarkup({ page_id, file = null, content = "", page_type =
|
|
|
307
288
|
{ template_id: id || null, snippet }));
|
|
308
289
|
}
|
|
309
290
|
|
|
310
|
-
// One finding per page, naming every flagged element: the repair is the
|
|
311
|
-
// same for each (drop the flag from the bump include's markup; several
|
|
312
|
-
// starter includes write it unconditionally, so an is_upsell=false argument
|
|
313
|
-
// does not always clear it).
|
|
314
|
-
// The page type is read only when a flag is present, so a page without one
|
|
315
|
-
// is not parsed a second time.
|
|
316
|
-
const pageTypeMeta = upsellFlagged.length ? builtPageTypeMeta(content) : null;
|
|
317
|
-
// The live, unambiguous meta wins because it is what the SDK reads; the
|
|
318
|
-
// upsell gate's narrower oto-route rule (builtPageTypeOverRouteGuess) does
|
|
319
|
-
// not apply to a checkout bump.
|
|
320
|
-
const effectivePageType = upsellFlagged.length
|
|
321
|
-
? normalizedPageTypeValue(pageTypeMeta ?? page_type)
|
|
322
|
-
: null;
|
|
323
|
-
if (effectivePageType === "checkout") {
|
|
324
|
-
const elements = upsellFlagged.map(describeBump);
|
|
325
|
-
findings.push(finding("CHECKOUT_BUMP_IS_UPSELL", page_id, where,
|
|
326
|
-
`${elements.length > 1 ? `${elements.length} order bumps` : "An order bump"} on checkout page ${where} (${elements.join(", ")}) ${elements.length > 1 ? "carry" : "carries"} data-next-is-upsell="true". A checkout bump is a pre-purchase add-on; the flag puts it on the initial order as an upsell line. The flag comes from the bump include's markup, and several starter bump includes write it unconditionally, so remove data-next-is-upsell="true" from the include in this campaign unless the line really should be billed as an upsell.`,
|
|
327
|
-
{ page_type: effectivePageType, page_type_source: pageTypeMeta !== null ? "next-page-type" : "route", elements }));
|
|
328
|
-
}
|
|
329
|
-
|
|
330
291
|
return { page_id, file, findings, unknown_attributes: [...unknown].sort() };
|
|
331
292
|
}
|
|
332
293
|
|
|
@@ -57,8 +57,9 @@ export function readStorageManifest(path) {
|
|
|
57
57
|
version(value.sdkVersion);
|
|
58
58
|
version(value.supportedSdkVersions?.min);
|
|
59
59
|
version(value.supportedSdkVersions?.max);
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
// A release manifest may describe a supported range ending below its own release; it cannot vouch past itself.
|
|
61
|
+
if (compare(value.sdkVersion, value.supportedSdkVersions.max) < 0)
|
|
62
|
+
throw new Error('Manifest source SDK version is below its supported maximum.');
|
|
62
63
|
const unique = new Set();
|
|
63
64
|
if (compare(value.supportedSdkVersions.min, value.supportedSdkVersions.max) > 0)
|
|
64
65
|
throw new Error('Invalid manifest version range.');
|
package/src/source-prep.mjs
CHANGED
|
@@ -201,7 +201,7 @@ function describeFinding(code, pages, { wrapperPolicy }) {
|
|
|
201
201
|
const policyNote = wrapperPolicy === "preserve_document_wrappers"
|
|
202
202
|
? " The adapter contract records wrapper_policy \"preserve_document_wrappers\", so this is reported without blocking."
|
|
203
203
|
: "";
|
|
204
|
-
return `Mapped source HTML is a full browser document, not page-kit-ready source: ${listed}${more}. Strip <!doctype>, <html>, <head>, and <body> so the campaign layout can wrap the page, or record wrapper_policy "preserve_document_wrappers" as an explicit adapter decision.${policyNote} See ${docs}.`;
|
|
204
|
+
return `Mapped source HTML is a full browser document, not page-kit-ready source: ${listed}${more}. Strip <!doctype>, <html>, <head>, and <body> so the campaign layout can wrap the page, or, for a standalone page meant to stay whole, record wrapper_policy "preserve_document_wrappers" as an explicit adapter decision: re-run start or prepare-build with --wrapper-policy preserve_document_wrappers, or set "wrapper_policy" in the source-html manifest.${policyNote} See ${docs}.`;
|
|
205
205
|
}
|
|
206
206
|
if (code === SOURCE_PREP_FRONTMATTER_RESIDUE) {
|
|
207
207
|
const listed = sample.map((page) => {
|
package/src/tooling-setup.mjs
CHANGED
|
@@ -101,6 +101,13 @@ export function setupTooling(args, { packageRoot, installSkills, installAgentCon
|
|
|
101
101
|
if (!existsSync(join(target, "node_modules", "next-campaign-page-kit", "package.json"))) {
|
|
102
102
|
throw new Error("tooling setup: page-kit is declared but not installed; run npm ci in the selected project first.");
|
|
103
103
|
}
|
|
104
|
+
// Page-kit builds the deployed pages, so a dev-only declaration disappears
|
|
105
|
+
// from any host that installs with --omit=dev or NODE_ENV=production.
|
|
106
|
+
// The command names the installed version, so it can be pasted as-is.
|
|
107
|
+
const pageKitVersion = json(join(target, "node_modules", "next-campaign-page-kit", "package.json")).version;
|
|
108
|
+
const warnings = manifest.dependencies?.["next-campaign-page-kit"] ? [] : [
|
|
109
|
+
`next-campaign-page-kit is declared only in devDependencies, so builds that run npm ci --omit=dev or set NODE_ENV=production will not install it. Move it back with npm install --save-exact next-campaign-page-kit@${pageKitVersion}.`,
|
|
110
|
+
];
|
|
104
111
|
|
|
105
112
|
// Preflight every destination before any installer runs. Custom repository
|
|
106
113
|
// instructions are preserved; only one Claude import line is appended.
|
|
@@ -136,6 +143,7 @@ export function setupTooling(args, { packageRoot, installSkills, installAgentCon
|
|
|
136
143
|
context,
|
|
137
144
|
instructions: { path: instructions, action: !ready ? "not_run" : hasImport ? "unchanged" : "append_import" },
|
|
138
145
|
browser,
|
|
146
|
+
warnings,
|
|
139
147
|
next_action: contextFailed
|
|
140
148
|
? `Setup could not add the runtime ignore block (${context.gitignore.reason}). Fix .gitignore and rerun setup; skills and context files may already be installed.`
|
|
141
149
|
: args["dry-run"]
|
|
@@ -154,6 +162,7 @@ export function setupTextLines(result) {
|
|
|
154
162
|
`Skills revision: ${result.skills_revision}`,
|
|
155
163
|
`Browser: ${result.browser.status}`,
|
|
156
164
|
...(result.browser.note ? [result.browser.note] : []),
|
|
165
|
+
...(result.warnings ?? []).map((warning) => `Warning: ${warning}`),
|
|
157
166
|
result.next_action,
|
|
158
167
|
result.note,
|
|
159
168
|
];
|