@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/README.md +85 -3
- package/examples/riverbend-wellness/settings/taproot-styles.json +2 -188
- package/package.json +1 -1
- package/src/api.js +71 -2
- package/src/cli.js +126 -5
- package/src/constants.js +8 -1
- package/src/delivery-browser.js +276 -0
- package/src/delivery-check.js +821 -0
- package/src/errors.js +12 -1
- package/src/presentation-reference.js +98 -10
- package/src/reference-help.js +118 -1
- package/src/settings-catalog.js +14 -1
- package/src/staging-check.js +9 -0
- package/src/theme-projection.js +213 -0
- package/src/theme-validation.js +64 -4
- package/src/transport.js +20 -2
- package/src/verbs/delivery-check.js +185 -0
- package/src/verbs/deploy.js +88 -3
- package/src/verbs/index.js +6 -0
- package/src/verbs/preview-revoke.js +2 -2
- package/src/verbs/staging-review.js +29 -0
- package/src/verbs/theme-push.js +3 -3
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
|
|
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(
|
|
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.
|
|
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
|
+
}
|