@nextcommerce/campaigns-os 1.46.0 → 1.47.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 +153 -3
- package/README.md +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 +338 -0
- package/contracts/supported-surface.json +2 -2
- package/contracts/template-brand-contract.shared-commerce.v0.json +3 -3
- package/docs/build-packet.md +22 -2
- package/docs/local-setup.md +7 -4
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +19 -1
- 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 +4 -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 +3 -3
- package/skills/next-campaigns-qa/SKILL.md +4 -4
- package/skills.json +10 -10
- package/src/built-script-syntax.mjs +116 -15
- package/src/diagnostic.mjs +2 -1
- package/src/doctor/checks.mjs +0 -1
- package/src/qa-analytics-parity.mjs +37 -2
- package/src/qa-binding-evidence.mjs +4 -2
- package/src/qa-browser.mjs +74 -12
- package/src/sdk-markup.mjs +6 -45
- package/src/sdk-storage-compatibility.mjs +3 -2
- 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
|
}
|
|
@@ -5171,10 +5188,18 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
|
|
|
5171
5188
|
// the browser's wall time at request start), and armedAt is read before the
|
|
5172
5189
|
// click is sent, so this click's request starts at or after it. A request
|
|
5173
5190
|
// with no known start time is not excluded: nothing shows it is stale.
|
|
5191
|
+
//
|
|
5192
|
+
// A redirected POST (307/308) is one mutation across several hops, and
|
|
5193
|
+
// Playwright gives each hop its own Request. The watch matches a hop by the
|
|
5194
|
+
// request that started its chain, and passes over a hop the browser follows,
|
|
5195
|
+
// so it resolves on the chain's final response: that is the one carrying the
|
|
5196
|
+
// order. A chain with no final response is not answered (#516).
|
|
5174
5197
|
const armedAt = Date.now();
|
|
5175
5198
|
const mutationPromise = path === "accept"
|
|
5176
5199
|
? page.waitForResponse((response) => {
|
|
5177
|
-
if (
|
|
5200
|
+
if (isFollowedRedirect(response)) return false;
|
|
5201
|
+
const root = responseRequest(response);
|
|
5202
|
+
if (!root || root.method() !== "POST" || !isOrderUpsellsUrl(root.url())) return false;
|
|
5178
5203
|
const startedAt = responseRequestStartedAt(response);
|
|
5179
5204
|
return startedAt === null || startedAt >= armedAt;
|
|
5180
5205
|
}, { timeout: UPSELL_MUTATION_TIMEOUT_MS + (perpetual ? 0 : UPSELL_CLICK_TIMEOUT_MS) }).catch(() => null)
|
|
@@ -5205,9 +5230,9 @@ async function clickUpsellPath(page, path, { trace = null } = {}) {
|
|
|
5205
5230
|
// waits for a later read-back before it judges the step.
|
|
5206
5231
|
...(bodyRead ? { api_response_body_read: { timed_out: bodyRead.timed_out, waited_ms: bodyRead.waited_ms, bound_ms: bodyRead.bound_ms } } : {}),
|
|
5207
5232
|
};
|
|
5208
|
-
// The request this click made
|
|
5209
|
-
// same order-upsells URL, so a late body
|
|
5210
|
-
// answers this request.
|
|
5233
|
+
// The request this click made (the root of its redirect chain). Another
|
|
5234
|
+
// step's upsell mutation posts to the same order-upsells URL, so a late body
|
|
5235
|
+
// is this step's only when it answers this request.
|
|
5211
5236
|
const mutationRequest = mutationResponse ? responseRequest(mutationResponse) : null;
|
|
5212
5237
|
if (mutationRequest) record[REQUEST_IDENTITY] = mutationRequest;
|
|
5213
5238
|
return record;
|
|
@@ -6191,21 +6216,52 @@ function mutationRespondedAt(response) {
|
|
|
6191
6216
|
// it). Playwright hands every listener the same Request object for one
|
|
6192
6217
|
// request, and a different one for each request, even to the same URL: an
|
|
6193
6218
|
// upsell step matches its own mutation's late body by this identity (#505).
|
|
6219
|
+
//
|
|
6220
|
+
// A redirect gives each hop a new Request, so the identity is the request
|
|
6221
|
+
// that started the chain (followed back through redirectedFrom()): every hop
|
|
6222
|
+
// of one redirected POST carries the same identity, and two POSTs never do
|
|
6223
|
+
// (#516).
|
|
6194
6224
|
const REQUEST_IDENTITY = Symbol("campaigns-os.request-identity");
|
|
6195
6225
|
|
|
6196
6226
|
function responseRequest(response) {
|
|
6197
6227
|
try {
|
|
6198
|
-
return response.request()
|
|
6228
|
+
return redirectChainRoot(response.request());
|
|
6199
6229
|
} catch {
|
|
6200
6230
|
return null;
|
|
6201
6231
|
}
|
|
6202
6232
|
}
|
|
6203
6233
|
|
|
6234
|
+
// Walks back to the chain's first request. A visited set ends the walk on any
|
|
6235
|
+
// chain, however long, so every hop of one chain reports the same root.
|
|
6236
|
+
function redirectChainRoot(request) {
|
|
6237
|
+
let root = request || null;
|
|
6238
|
+
const visited = new Set();
|
|
6239
|
+
while (root && typeof root.redirectedFrom === "function" && !visited.has(root)) {
|
|
6240
|
+
visited.add(root);
|
|
6241
|
+
const previous = root.redirectedFrom();
|
|
6242
|
+
if (!previous) break;
|
|
6243
|
+
root = previous;
|
|
6244
|
+
}
|
|
6245
|
+
return root;
|
|
6246
|
+
}
|
|
6247
|
+
|
|
6248
|
+
// A 3xx hop the browser follows: it names a Location, and the chain's answer
|
|
6249
|
+
// is a later hop's response. A 3xx with no Location is a final response.
|
|
6250
|
+
function isFollowedRedirect(response) {
|
|
6251
|
+
try {
|
|
6252
|
+
const status = response.status();
|
|
6253
|
+
return status >= 300 && status < 400 && Boolean(response.headers()?.location);
|
|
6254
|
+
} catch {
|
|
6255
|
+
return false;
|
|
6256
|
+
}
|
|
6257
|
+
}
|
|
6258
|
+
|
|
6204
6259
|
// 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.
|
|
6260
|
+
// same clock as Date.now()), or null when Playwright does not report it. For a
|
|
6261
|
+
// redirected request this is when its chain's first hop started.
|
|
6206
6262
|
function responseRequestStartedAt(response) {
|
|
6207
6263
|
try {
|
|
6208
|
-
const startTime = response
|
|
6264
|
+
const startTime = responseRequest(response).timing().startTime;
|
|
6209
6265
|
return Number.isFinite(startTime) && startTime > 0 ? startTime : null;
|
|
6210
6266
|
} catch {
|
|
6211
6267
|
return null;
|
|
@@ -6228,11 +6284,16 @@ function captureCheckoutEvents(page) {
|
|
|
6228
6284
|
// polls for it. A bound here would record a slow but successful order body
|
|
6229
6285
|
// as null for good, and the order would read as not created.
|
|
6230
6286
|
page.on("response", async (response) => {
|
|
6231
|
-
|
|
6287
|
+
// A redirected chain is logged by where it started, so its final hop is
|
|
6288
|
+
// captured even when it answers from a URL this filter would not match
|
|
6289
|
+
// (#516): the late-body search finds it by request identity.
|
|
6290
|
+
const request = responseRequest(response);
|
|
6291
|
+
let chainUrl = null;
|
|
6292
|
+
try { chainUrl = request?.url() ?? null; } catch { /* identity only */ }
|
|
6293
|
+
if (!interesting.test(response.url()) && !(chainUrl && interesting.test(chainUrl))) return;
|
|
6232
6294
|
// Taken before the body read: an entry lands in the log when its body
|
|
6233
6295
|
// finishes, so its position says nothing about when it was requested.
|
|
6234
6296
|
const requestStartedAt = responseRequestStartedAt(response);
|
|
6235
|
-
const request = responseRequest(response);
|
|
6236
6297
|
const entry = {
|
|
6237
6298
|
status: response.status(),
|
|
6238
6299
|
url: response.url(),
|
|
@@ -7369,7 +7430,8 @@ async function waitForLateUpsellEvidence(events, { responseIndexBefore, mutation
|
|
|
7369
7430
|
const response = fresh[index];
|
|
7370
7431
|
if (!response.body || typeof response.body !== "object" || Array.isArray(response.body)) continue;
|
|
7371
7432
|
if (!(response.status >= 200 && response.status < 300)) continue;
|
|
7372
|
-
|
|
7433
|
+
// A redirected mutation's final hop may answer from another URL; its
|
|
7434
|
+
// identity, the root of its chain, is what ties it to this step (#516).
|
|
7373
7435
|
if (mutationRequest && response[REQUEST_IDENTITY] === mutationRequest) return { source: "late_upsell_body", body: response.body };
|
|
7374
7436
|
}
|
|
7375
7437
|
for (let index = fresh.length - 1; index >= 0; index -= 1) {
|
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/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
|
];
|