@taprootio/site-authoring 0.8.4 → 0.8.6

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/src/cli.js CHANGED
@@ -15,7 +15,9 @@ import {
15
15
  SURFACE_DOCS_PRESENTATION,
16
16
  SURFACE_STANDARD,
17
17
  VERB_APPROVE,
18
+ VERB_DELIVERY_CHECK,
18
19
  VERB_DEPLOY,
20
+ VERB_STAGING_REVIEW,
19
21
  VERB_ENV,
20
22
  VERB_FOOTER_PUSH,
21
23
  VERB_HELP,
@@ -290,7 +292,11 @@ const VERBS = Object.freeze([
290
292
  tokens: ["theme", "push"],
291
293
  summary: "Validate and push the workspace's complete theme and appearance settings.",
292
294
  note: "Run pull first. Theme JSON stays decoded in the workspace; this command validates the complete light/dark"
293
- + " pair and encodes it only at the API boundary. Image settings reference site-owned image IDs from media upload.",
295
+ + " pair and encodes it only at the API boundary. Image settings reference site-owned image IDs from media upload. "
296
+ + "pull writes each scheme's complete effective theme — the stored theme resolved over the same defaults every "
297
+ + "consumer renders — so a fresh workspace validates as pulled. semanticMappings holds only authored pins, listed "
298
+ + "in explicitMappingTokens; every other token compiles from roles at render time, so never copy default "
299
+ + "mappings into a theme: a pinned token shadows its role.",
294
300
  },
295
301
  {
296
302
  name: VERB_FOOTER_PUSH,
@@ -345,6 +351,10 @@ const VERBS = Object.freeze([
345
351
  tokens: ["deploy"],
346
352
  summary: "Deploy to staging, or promote staging to production.",
347
353
  target: true,
354
+ note: "deploy --staging returns a single-use review handoff in stagingPreview.url, on a managed Docs site too, "
355
+ + "where it stages settings only: open the URL once, then switch the site's theme toggle to review both "
356
+ + "schemes before deploy --production. If no handoff could be minted, stagingPreview.reason names why and "
357
+ + "stagingPreview.recovery the next step; 'staging review' mints another.",
348
358
  },
349
359
  {
350
360
  name: VERB_PREVIEW_PAGE,
@@ -357,7 +367,8 @@ const VERBS = Object.freeze([
357
367
  note: "Select the persisted draft by page path (as recorded by pull) or canonical lowercase UUID. "
358
368
  + "The homepage is recorded with an empty path, so address it as '/'. "
359
369
  + "Preview before approving: approve consumes the draft, so a page that has been approved has no draft "
360
- + "left to render and answers preview.no_draft. Review it on staging after a deploy instead. "
370
+ + "left to render and answers preview.no_draft. Review it on staging after a deploy instead. A preview "
371
+ + "captured before approval stays revocable afterwards: preview revoke addresses the snapshot itself. "
361
372
  + "The handoff URL in the result is single-use and expires two minutes after it is minted: opening it "
362
373
  + "consumes it, and a reused, shared, or bookmarked preview URL answers Not found. Run preview page again "
363
374
  + "for another. "
@@ -374,6 +385,39 @@ const VERBS = Object.freeze([
374
385
  json: true,
375
386
  note: "Supply the canonical lowercase page UUID followed by the snapshot UUID returned by preview page.",
376
387
  },
388
+ {
389
+ name: VERB_DELIVERY_CHECK,
390
+ surface: SURFACE_DOCS_PRESENTATION,
391
+ // Deployments reads the deployment log and mints the staging handoff the
392
+ // check consumes; the public target itself needs no credential.
393
+ capabilities: [CAPABILITY_DEPLOYMENTS],
394
+ tokens: ["delivery", "check"],
395
+ summary: "Verify what a visitor receives after a deployment completed: routes, assets, runtime, browser.",
396
+ target: true,
397
+ deliveryOptions: true,
398
+ note: "A completed deployment job is not a verified delivery. This read-only check reads the target the way a "
399
+ + "visitor does and reports each dimension separately: the latest completed deployment for the target, HTTP "
400
+ + "delivery of the authored routes (from the workspace manifest, bounded), the favicon, images, module "
401
+ + "preloads and site bundle they reference, internal link targets, accidental local-only references, and the "
402
+ + "declared runtime (major pointer, fallback copy, entry module and capability modules), plus a browser "
403
+ + "dimension that runs only when Playwright is installed and is reported as unchecked otherwise. --staging "
404
+ + "resolves the acknowledged staging host and authorizes one preview session for the run (--url is refused "
405
+ + "there); --production needs --url https://<published-domain>/ because a site-authoring key cannot read "
406
+ + "hosting configuration. Only assets on the site's own origin and the runtime the page declares are "
407
+ + "fetched. --wait "
408
+ + "<seconds> (at most 120) observes everything once more after that pause for edge propagation. --no-browser skips "
409
+ + "the browser dimension. Nothing is purged, republished or rolled back.",
410
+ },
411
+ {
412
+ name: VERB_STAGING_REVIEW,
413
+ surface: SURFACE_DOCS_PRESENTATION,
414
+ capabilities: [CAPABILITY_DEPLOYMENTS],
415
+ tokens: ["staging", "review"],
416
+ summary: "Mint a fresh single-use review handoff for the site's staging host.",
417
+ note: "The handoff URL is reported only in the final JSON as stagingPreview.url, is single-use, and expires two "
418
+ + "minutes after it is minted; keep it private. Open it once, then use the site's theme toggle to review both "
419
+ + "schemes. This is the recovery when deploy --staging could not mint a handoff, on a managed Docs site too.",
420
+ },
377
421
  {
378
422
  name: VERB_STATUS,
379
423
  surface: SURFACE_DOCS_PRESENTATION,
@@ -454,6 +498,12 @@ whoami, and env need no configuration and no site. The offline help family is
454
498
  human-readable by default; add --json for stable reference data. Exit codes: 0
455
499
  success, 1 failure, 2 usage fault.
456
500
 
501
+ Install globally (npm install --global @taprootio/site-authoring) or run the
502
+ current release without installing: npx --yes @taprootio/site-authoring@latest
503
+ <verb>. An unversioned npx keeps whatever copy it cached, so always name
504
+ @latest; check what is running with --version, and compare it with the
505
+ release status reports as cliRelease.
506
+
457
507
  Troubleshooting: a command that fails with field=CliUpgradeRequired (or
458
508
  refusal=cli_outdated) means this CLI is behind the latest published release,
459
509
  which is the only release Taproot accepts. Nothing is wrong with the
@@ -499,10 +549,17 @@ function verbHelp(verb) {
499
549
  ? " <page-id> <snapshot-id>"
500
550
  : verb.positionals === "environmentSelector"
501
551
  ? " [production | local | <url>]"
552
+ : verb.positionals === "siteSelector"
553
+ ? " <site-name-or-id>"
502
554
  : verb.positionals
503
555
  ? ` [${verb.positionals === "paths" ? "path" : "page-path"}...]`
504
556
  : "";
505
- const targetOption = verb.target
557
+ const deliveryOptions = verb.deliveryOptions
558
+ ? `\n --url <origin> The https origin to check; required for --production, refused for --staging.
559
+ --wait <seconds> Re-check failures once after this pause (0-120) for edge propagation.
560
+ --no-browser Skip the browser dimension even when Playwright is installed.`
561
+ : "";
562
+ const targetOption = verb.target && !verb.deliveryOptions
506
563
  ? `\n --staging Deploy the staged site to staging.
507
564
  --production Promote the completed staging deployment to production.
508
565
  --allow-failed-preview Explicitly override the matching candidate's failed preview.`
@@ -571,12 +628,16 @@ function verbHelp(verb) {
571
628
  --help Show this help.
572
629
  --version Show the package version.`
573
630
  : COMMON_OPTIONS;
631
+ const deliveryTargetOption = verb.deliveryOptions
632
+ ? `\n --staging Check the staging target.
633
+ --production Check the production target.${deliveryOptions}`
634
+ : "";
574
635
  return `Usage: ${prefix} ${verb.tokens.join(" ")}${positionalUsage}${targetUsage} [options]
575
636
 
576
637
  ${verb.summary}
577
638
  ${boundary}
578
639
 
579
- ${options}${targetOption}${rawHtmlOption}${jsonOption}${nameOption}${note}
640
+ ${options}${targetOption}${deliveryTargetOption}${rawHtmlOption}${jsonOption}${nameOption}${note}
580
641
  `;
581
642
  }
582
643
 
@@ -640,6 +701,8 @@ function parseReferenceArguments(arguments_) {
640
701
  || topic === "footer"
641
702
  || topic === "fixture"
642
703
  || topic === "redirects"
704
+ || topic === "walkthrough"
705
+ || topic === "delivery"
643
706
  )
644
707
  && (subject !== undefined || extra.length > 0)
645
708
  ) {
@@ -707,6 +770,8 @@ function referenceResult(parsed) {
707
770
  case "media":
708
771
  case "preview":
709
772
  case "fixture":
773
+ case "walkthrough":
774
+ case "delivery":
710
775
  return {
711
776
  ...result,
712
777
  topic: "workflow",
@@ -746,6 +811,36 @@ function parseConfigOption(arguments_, index) {
746
811
  * (trimmed, 1-100 characters) belongs to the verb, which a programmatic caller
747
812
  * reaches without passing through this parser at all.
748
813
  */
814
+ function parseUrlOption(arguments_, index) {
815
+ const candidate = arguments_[index + 1];
816
+ let url;
817
+ try {
818
+ url = typeof candidate === "string" ? new URL(candidate) : undefined;
819
+ } catch {
820
+ url = undefined;
821
+ }
822
+ if (
823
+ !url || url.protocol !== "https:" || url.username || url.password || url.search || url.hash
824
+ || (url.pathname !== "/" && url.pathname !== "") || hasAsciiControl(candidate)
825
+ ) {
826
+ throw usageError("cli.url_option", "--url requires one https origin such as https://example.com/.", {
827
+ field: "url",
828
+ });
829
+ }
830
+ return `${url.origin}/`;
831
+ }
832
+
833
+ function parseWaitOption(arguments_, index) {
834
+ const candidate = arguments_[index + 1];
835
+ const seconds = typeof candidate === "string" && /^\d{1,3}$/u.test(candidate) ? Number(candidate) : undefined;
836
+ if (seconds === undefined || seconds > 120) {
837
+ throw usageError("cli.wait_option", "--wait requires a whole number of seconds from 0 to 120.", {
838
+ field: "propagationWaitSeconds",
839
+ });
840
+ }
841
+ return seconds;
842
+ }
843
+
749
844
  function parseNameOption(arguments_, index) {
750
845
  const candidate = arguments_[index + 1];
751
846
  if (
@@ -812,6 +907,9 @@ function parseArguments(arguments_) {
812
907
  let init = false;
813
908
  let quiet = false;
814
909
  let deployTarget;
910
+ let deliveryUrl;
911
+ let propagationWaitSeconds;
912
+ let browser;
815
913
  let allowFailedPreview = false;
816
914
  let allowRawHtml = false;
817
915
  let json = false;
@@ -848,6 +946,23 @@ function parseArguments(arguments_) {
848
946
  deployTarget = candidate;
849
947
  continue;
850
948
  }
949
+ if (verb.deliveryOptions && argument === "--url") {
950
+ if (deliveryUrl !== undefined) throw usageError("cli.duplicate_option", "--url may be supplied only once.");
951
+ deliveryUrl = parseUrlOption(rest, index);
952
+ index += 1;
953
+ continue;
954
+ }
955
+ if (verb.deliveryOptions && argument === "--wait") {
956
+ if (propagationWaitSeconds !== undefined) throw usageError("cli.duplicate_option", "--wait may be supplied only once.");
957
+ propagationWaitSeconds = parseWaitOption(rest, index);
958
+ index += 1;
959
+ continue;
960
+ }
961
+ if (verb.deliveryOptions && argument === "--no-browser") {
962
+ if (browser === false) throw usageError("cli.duplicate_option", "--no-browser may be supplied only once.");
963
+ browser = false;
964
+ continue;
965
+ }
851
966
  if (verb.target && argument === "--allow-failed-preview") {
852
967
  if (allowFailedPreview) {
853
968
  throw usageError("cli.duplicate_option", "--allow-failed-preview may be supplied only once.");
@@ -893,7 +1008,10 @@ function parseArguments(arguments_) {
893
1008
  throw usageError("cli.unexpected_argument", "The command contains an unexpected argument.");
894
1009
  }
895
1010
  if (verb.target && deployTarget === undefined) {
896
- throw usageError("cli.deploy_target", "deploy requires exactly one of --staging or --production.");
1011
+ throw usageError(
1012
+ "cli.deploy_target",
1013
+ `${verb.tokens.join(" ")} requires exactly one of --staging or --production.`,
1014
+ );
897
1015
  }
898
1016
  if (
899
1017
  verb.positionals === "fixturePath"
@@ -984,6 +1102,9 @@ function parseArguments(arguments_) {
984
1102
  quiet,
985
1103
  deployTarget,
986
1104
  allowFailedPreview,
1105
+ ...(deliveryUrl === undefined ? {} : { deliveryUrl }),
1106
+ ...(propagationWaitSeconds === undefined ? {} : { propagationWaitSeconds }),
1107
+ ...(browser === undefined ? {} : { browser }),
987
1108
  init,
988
1109
  allowRawHtml,
989
1110
  keyName,
package/src/constants.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const CLI_NAME = "@taprootio/site-authoring";
2
- export const CLI_VERSION = "0.8.4";
2
+ export const CLI_VERSION = "0.8.6";
3
3
  export const CLI_BINARY_NAME = "taproot-site";
4
4
  export const RESULT_SCHEMA_VERSION = 1;
5
5
  export const CONFIG_FILE_NAME = "taproot-site.json";
@@ -93,6 +93,8 @@ export const VERB_DEPLOY = "deploy";
93
93
  export const VERB_PREVIEW_PAGE = "preview page";
94
94
  export const VERB_PREVIEW_REVOKE = "preview revoke";
95
95
  export const VERB_STATUS = "status";
96
+ export const VERB_DELIVERY_CHECK = "delivery check";
97
+ export const VERB_STAGING_REVIEW = "staging review";
96
98
  export const VERB_SITES = "sites";
97
99
  export const VERB_USE = "use";
98
100
  export const VERB_WHOAMI = "whoami";
@@ -199,6 +201,11 @@ export const HTTP_TOO_MANY_REQUESTS = 429;
199
201
  export const GRPC_UNAUTHENTICATED = 16;
200
202
  export const HTTP_UNAUTHORIZED = 401;
201
203
 
204
+ // gRPC `NotFound`, and its HTTP mapping. Preview revocation classifies on it:
205
+ // the page or snapshot is not available to this caller on this site.
206
+ export const GRPC_NOT_FOUND = 5;
207
+ export const HTTP_NOT_FOUND = 404;
208
+
202
209
  export const LIMITS = Object.freeze({
203
210
  configBytes: 16 * 1024,
204
211
  configPathBytes: 4 * 1024,
@@ -0,0 +1,276 @@
1
+ import { sanitizeDiagnostic } from "./errors.js";
2
+ import { withholdCredentialLocation } from "./staging-check.js";
3
+
4
+ /**
5
+ * The optional browser dimension of delivery verification (TR00824).
6
+ *
7
+ * HTTP evidence cannot say what a visitor's browser loaded: the shared runtime
8
+ * comes from a mutable major pointer that a returning browser may hold in its
9
+ * cache. This probe drives a real browser at the target twice — a fresh
10
+ * context, then a second navigation in the same context so the browser's own
11
+ * cache participates — and reports which runtime entry each load evaluated,
12
+ * whether the declared capability elements were defined, and whether the
13
+ * second load actually served the runtime entry from cache.
14
+ *
15
+ * Playwright is deliberately not a dependency of this package: basic CLI use
16
+ * must not carry a browser. The probe imports it only if the operator has
17
+ * installed it where this package's imports resolve (a global install of both
18
+ * packages, run through the installed command), and reports the dimension as
19
+ * unchecked with that recovery otherwise.
20
+ *
21
+ * Playwright's own request routing is not used: it disables the HTTP cache,
22
+ * which is the very thing the returning load measures. Instead a DevTools
23
+ * session on the browser itself — not on the page, because a page session
24
+ * sees only its own target and authored script can open a popup — intercepts
25
+ * every document request in the browser (the navigation, each redirect hop,
26
+ * a script-driven navigation, a frame, a popup) at the request stage, before
27
+ * it is transmitted, and fails any that would leave the target origin; the
28
+ * subresource cache is untouched. A page session reports whether the runtime
29
+ * entry came from the memory or disk cache. Without the browser session the
30
+ * boundary cannot be enforced, so the dimension is unchecked rather than
31
+ * probed without it. Every URL the probe reports passes through the
32
+ * credential withholding the staging check uses, so a handoff-bearing
33
+ * redirect never reaches the result. A staging cookie is attached only to the
34
+ * staging origin it was minted for.
35
+ */
36
+
37
+ // Node resolves `playwright` from the CLI's own dependency chain: a copy run
38
+ // through `npx` lives in npm's cache and cannot see a global Playwright, so
39
+ // the recovery installs the CLI and Playwright side by side and reruns
40
+ // through the installed `taproot-site` command rather than `npx`.
41
+ export const BROWSER_INSTALL_HINT = "npm install --global @taprootio/site-authoring@latest playwright && npx playwright install chromium, then rerun with the installed taproot-site command (not npx)";
42
+ const SETTLE_MILLISECONDS = 20_000;
43
+ const EVIDENCE_CHARACTERS = 512;
44
+ const BLOCKED_NAVIGATION = /ERR_BLOCKED_BY_CLIENT|ERR_ABORTED/u;
45
+
46
+ async function defaultImportPlaywright() {
47
+ return await import("playwright");
48
+ }
49
+
50
+ /** A URL or entry name as evidence: credentials withheld, controls removed, bounded. */
51
+ function evidence(value) {
52
+ return [...withholdCredentialLocation(typeof value === "string" ? value : "")].slice(0, EVIDENCE_CHARACTERS).join("");
53
+ }
54
+
55
+ async function observe(page, capabilities) {
56
+ return await page.evaluate((declared) => {
57
+ const root = document.querySelector("esp-root");
58
+ const entries = performance.getEntriesByType("resource")
59
+ .map((entry) => entry.name)
60
+ .filter((name) => /taproot-shared-runtime[^/]*\.js(?:$|\?)/u.test(name));
61
+ return {
62
+ rootDefined: customElements.get("esp-root") !== undefined,
63
+ themeReady: root?.hasAttribute("data-theme-ready") ?? false,
64
+ loadingHeld: root?.hasAttribute("data-taproot-loading") ?? false,
65
+ runtimeEntries: entries.slice(0, 4),
66
+ capabilitiesDefined: declared.filter((tag) => customElements.get(tag) !== undefined).length,
67
+ title: document.title,
68
+ };
69
+ }, capabilities);
70
+ }
71
+
72
+ const NOTHING_OBSERVED = Object.freeze({ rootDefined: false, themeReady: false, loadingHeld: false, runtimeEntries: [], capabilitiesDefined: 0 });
73
+
74
+ /** Every URL the navigation passed through, from the first request to the final document. */
75
+ function redirectChain(response) {
76
+ const hops = [];
77
+ let request = response?.request?.();
78
+ while (request && hops.length < 16) {
79
+ hops.unshift(request.url());
80
+ request = request.redirectedFrom?.();
81
+ }
82
+ return hops;
83
+ }
84
+
85
+ function sameOrigin(url, origin) {
86
+ try {
87
+ return new URL(url).origin === origin;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * The DevTools sessions that enforce the navigation boundary (on the browser,
95
+ * so every target is covered) and observe cache reuse (on the page). Undefined
96
+ * when the browser offers no such sessions.
97
+ */
98
+ async function startDevTools(browser, context, page, { origin, expectedEntryUrl }) {
99
+ if (typeof browser.newBrowserCDPSession !== "function" || typeof context.newCDPSession !== "function") return undefined;
100
+ let boundary;
101
+ let session;
102
+ try {
103
+ boundary = await browser.newBrowserCDPSession();
104
+ session = await context.newCDPSession(page);
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ const state = { blocked: [], requests: new Map(), fromCache: undefined };
109
+ boundary.on("Fetch.requestPaused", (event) => {
110
+ const url = event?.request?.url ?? "";
111
+ if (sameOrigin(url, origin)) {
112
+ boundary.send("Fetch.continueRequest", { requestId: event.requestId }).catch(() => undefined);
113
+ } else {
114
+ if (state.blocked.length < 8) state.blocked.push(url);
115
+ boundary.send("Fetch.failRequest", { requestId: event.requestId, errorReason: "BlockedByClient" }).catch(() => undefined);
116
+ }
117
+ });
118
+ session.on("Network.requestWillBeSent", (event) => {
119
+ if (event?.request?.url === expectedEntryUrl) state.requests.set(event.requestId, event.request.url);
120
+ });
121
+ // A memory-cache hit is announced by requestServedFromCache; only a disk
122
+ // (or prefetch) cache hit is visible on the response itself.
123
+ session.on("Network.requestServedFromCache", (event) => {
124
+ if (state.requests.has(event?.requestId)) state.fromCache = true;
125
+ });
126
+ session.on("Network.responseReceived", (event) => {
127
+ if (event?.response?.url !== expectedEntryUrl || state.fromCache === true) return;
128
+ state.fromCache = Boolean(event.response.fromDiskCache || event.response.fromPrefetchCache);
129
+ });
130
+ await session.send("Network.enable");
131
+ await boundary.send("Fetch.enable", { patterns: [{ urlPattern: "*", resourceType: "Document", requestStage: "Request" }] });
132
+ return {
133
+ state,
134
+ reset() {
135
+ state.blocked = [];
136
+ state.requests.clear();
137
+ state.fromCache = undefined;
138
+ },
139
+ };
140
+ }
141
+
142
+ async function load(page, url, capabilities, origin, devtools) {
143
+ devtools.reset();
144
+ let response;
145
+ let blockedNavigation = false;
146
+ try {
147
+ response = await page.goto(url, { waitUntil: "domcontentloaded" });
148
+ } catch (error) {
149
+ // The boundary failed a document request before it was sent; the
150
+ // navigation error is the expected outcome, not a probe failure.
151
+ if (devtools.state.blocked.length === 0 && !BLOCKED_NAVIGATION.test(String(error?.message ?? ""))) throw error;
152
+ blockedNavigation = true;
153
+ }
154
+ if (!blockedNavigation) {
155
+ await page.waitForFunction(
156
+ () => {
157
+ const root = document.querySelector("esp-root");
158
+ return root !== null && !root.hasAttribute("data-taproot-loading");
159
+ },
160
+ undefined,
161
+ { timeout: SETTLE_MILLISECONDS },
162
+ ).catch(() => undefined);
163
+ }
164
+ const hops = blockedNavigation ? [] : [...new Set([...redirectChain(response), page.url()])];
165
+ const strayed = [...new Set([...devtools.state.blocked, ...hops.filter((hop) => !sameOrigin(hop, origin))])];
166
+ const observed = blockedNavigation ? NOTHING_OBSERVED : await observe(page, capabilities);
167
+ return { observed, strayed, finalUrl: blockedNavigation ? "" : page.url() };
168
+ }
169
+
170
+ function judge({ observed, strayed, finalUrl }, expectedEntryUrl, capabilityCount) {
171
+ const loadedEntry = observed.runtimeEntries.find((name) => name === expectedEntryUrl)
172
+ ?? observed.runtimeEntries[0]
173
+ ?? "";
174
+ return {
175
+ loadedEntry: evidence(loadedEntry),
176
+ expectedEntry: observed.runtimeEntries.includes(expectedEntryUrl),
177
+ rootDefined: observed.rootDefined,
178
+ themeReady: observed.themeReady,
179
+ capabilitiesDefined: observed.capabilitiesDefined,
180
+ capabilitiesDeclared: capabilityCount,
181
+ finalUrl: evidence(finalUrl),
182
+ ...(strayed.length > 0 ? { offOriginNavigation: strayed.slice(0, 4).map(evidence) } : {}),
183
+ ok: strayed.length === 0 && observed.rootDefined && observed.themeReady
184
+ && observed.runtimeEntries.includes(expectedEntryUrl)
185
+ && observed.capabilitiesDefined === capabilityCount,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * @param options.baseUrl The delivery origin.
191
+ * @param options.expectedEntryUrl The runtime entry the major pointer names right now.
192
+ * @param options.capabilities Custom-element tags the home page declares.
193
+ * @param options.cookie Optional staging cookie, sent only to `baseUrl`'s host.
194
+ * @param options.importPlaywright Test seam; defaults to a dynamic import.
195
+ */
196
+ export async function probeDeliveryWithBrowser({
197
+ baseUrl,
198
+ expectedEntryUrl,
199
+ capabilities = [],
200
+ cookie,
201
+ importPlaywright = defaultImportPlaywright,
202
+ }) {
203
+ let playwright;
204
+ try {
205
+ playwright = await importPlaywright();
206
+ } catch {
207
+ return {
208
+ status: "unchecked",
209
+ reason: "playwright_unavailable",
210
+ install: BROWSER_INSTALL_HINT,
211
+ note: "Fresh and returning browser behaviour was not verified; install the CLI and Playwright together and rerun through the installed taproot-site command to check it.",
212
+ };
213
+ }
214
+ const origin = new URL(baseUrl).origin;
215
+ let browser;
216
+ try {
217
+ browser = await playwright.chromium.launch({ headless: true });
218
+ const context = await browser.newContext();
219
+ if (cookie) {
220
+ const [name, value] = cookie.split("=", 2);
221
+ // A __Host- cookie carries no Domain attribute by definition; the URL
222
+ // form binds it to the staging origin without one.
223
+ await context.addCookies([{ name, value, url: baseUrl, secure: true, httpOnly: true, sameSite: "Lax" }]);
224
+ }
225
+ const page = await context.newPage();
226
+ const devtools = await startDevTools(browser, context, page, { origin, expectedEntryUrl });
227
+ if (!devtools) {
228
+ await context.close();
229
+ return {
230
+ status: "unchecked",
231
+ reason: "navigation_boundary_unavailable",
232
+ note: "The browser offers no DevTools sessions, so document requests could not be held to the site origin; nothing was loaded.",
233
+ };
234
+ }
235
+ const fresh = judge(await load(page, baseUrl, capabilities, origin, devtools), expectedEntryUrl, capabilities.length);
236
+ if (fresh.offOriginNavigation) {
237
+ // A target that leaves its origin is not probed again.
238
+ await context.close();
239
+ return {
240
+ status: "checked",
241
+ fresh,
242
+ returning: { skipped: true, reason: "fresh_load_left_origin", ok: false },
243
+ staleRuntimeObserved: false,
244
+ returningCache: { status: "unchecked", reason: "fresh_load_left_origin" },
245
+ ok: false,
246
+ };
247
+ }
248
+ // The same context navigates again: its HTTP cache now holds whatever the
249
+ // first load fetched, which is exactly what a returning visitor has.
250
+ const returning = judge(await load(page, baseUrl, capabilities, origin, devtools), expectedEntryUrl, capabilities.length);
251
+ const cacheObserved = devtools.state.fromCache;
252
+ await context.close();
253
+ return {
254
+ status: "checked",
255
+ fresh,
256
+ returning: {
257
+ ...returning,
258
+ runtimeFromCache: cacheObserved === undefined ? "unchecked" : cacheObserved,
259
+ },
260
+ staleRuntimeObserved: fresh.ok && !returning.expectedEntry,
261
+ returningCache: cacheObserved === undefined
262
+ ? { status: "unchecked", reason: "cache_observation_unavailable" }
263
+ : { status: "checked", servedFromCache: cacheObserved },
264
+ ok: fresh.ok && returning.ok,
265
+ };
266
+ } catch (error) {
267
+ return {
268
+ status: "failed",
269
+ reason: "browser_probe_failed",
270
+ error: sanitizeDiagnostic(error instanceof Error ? error.name : "Error", "Error"),
271
+ note: "The browser dimension could not be completed and is not verified.",
272
+ };
273
+ } finally {
274
+ await browser?.close().catch(() => undefined);
275
+ }
276
+ }