@taprootio/site-authoring 0.8.3 → 0.8.5
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 +2 -2
- package/src/api.js +35 -0
- package/src/cli.js +126 -5
- package/src/constants.js +8 -1
- package/src/content/components.js +18 -2
- package/src/content/tiptap-prosemirror.ts +23 -2
- 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 +110 -11
- package/src/reference-help.js +122 -4
- 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/verbs/delivery-check.js +185 -0
- package/src/verbs/deploy.js +72 -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
|
@@ -252,6 +252,8 @@ const IMAGE_BANNER = Object.freeze({
|
|
|
252
252
|
// the site header; `viewport` fills exactly that space (TR00413).
|
|
253
253
|
heightMode: enumOf("ratio", "viewport"),
|
|
254
254
|
contentPosition: enumOf("bottom-start", "bottom", "bottom-end", "center", "top-start", "top", "top-end"),
|
|
255
|
+
contentGutter: enumOf("frame", "page"),
|
|
256
|
+
followingSpacing: enumOf("standard", "compact"),
|
|
255
257
|
scrim: enumOf("auto", "none", "flat", "top", "bottom", "left", "right", "radial"),
|
|
256
258
|
scrimStrength: enumOf("soft", "medium", "strong"),
|
|
257
259
|
bannerScheme: enumOf("auto", "light", "dark"),
|
|
@@ -260,7 +262,7 @@ const IMAGE_BANNER = Object.freeze({
|
|
|
260
262
|
// A built-in or application-registered Espalier texture name.
|
|
261
263
|
texture: textureName(),
|
|
262
264
|
textureScale: enumOf("fine", "medium", "coarse"),
|
|
263
|
-
imageMotion: enumOf("none", "slow-zoom", "drift-start", "drift-end"),
|
|
265
|
+
imageMotion: enumOf("none", "slow-zoom", "drift-start", "drift-end", "parallax-subtle", "parallax"),
|
|
264
266
|
});
|
|
265
267
|
|
|
266
268
|
const SPACER = Object.freeze({
|
|
@@ -371,6 +373,8 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
371
373
|
"Identify the speaker with authorName; use authorTitle when it gives useful context.",
|
|
372
374
|
"Author images need descriptive alt text unless they are purely decorative.",
|
|
373
375
|
"Keep carousel intervals long enough for the full quotation to be read.",
|
|
376
|
+
"Set carousel:true for directional previous/next arrows, dot navigation and a play/pause control. Navigation or keyboard focus pauses rotation; reduced-motion users get manual navigation without sliding or automatic rotation.",
|
|
377
|
+
"Use real attributed reviews, or clearly label fictional demo reviews. Never imply invented testimonials are genuine customer endorsements.",
|
|
374
378
|
],
|
|
375
379
|
{
|
|
376
380
|
items: [{
|
|
@@ -494,6 +498,8 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
494
498
|
compactRatio: "3/2",
|
|
495
499
|
heightMode: "ratio",
|
|
496
500
|
contentPosition: "center",
|
|
501
|
+
contentGutter: "frame",
|
|
502
|
+
followingSpacing: "standard",
|
|
497
503
|
scrim: "auto",
|
|
498
504
|
scrimStrength: "medium",
|
|
499
505
|
bannerScheme: "auto",
|
|
@@ -505,6 +511,16 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
505
511
|
[
|
|
506
512
|
"Set altText to describe meaningful banner imagery; leave it empty only when the image is decorative.",
|
|
507
513
|
"Choose a scrim and scheme that keep overlay text readable across the image.",
|
|
514
|
+
"For a full-bleed photograph with text aligned to the surrounding page well, set contentGutter to page. followingSpacing: compact reduces the top padding of an immediately following section so a bottom banner heading reads with its supporting paragraph. Use a banner-only contentPadding: none section followed by a standard text section; keep their theme contexts consistent. Defaults preserve frame-edge text and standard section spacing.",
|
|
515
|
+
"Compose around the actual photograph: identify the meaningful subject and a quiet area for copy. Do not cover faces, product details, or high-detail edges. A focal point controls cropping, not text placement; inspect wide and compact crops separately. Keep the subject's face, ears, and other identifying details inside the compact frame, including at both ends of parallax travel. Adjust focus toward the subject rather than reusing a generic center; reduce motion or choose a roomier crop if preserving the subject requires it.",
|
|
516
|
+
"Use contentPosition to place copy in that quiet area, not automatically in the center. Keep one dominant message and nearby supporting copy/actions; if the photo has no safe text area, use a split hero or a separate text section instead of forcing an overlay.",
|
|
517
|
+
"Use the rule of thirds as a composition guide: consider the subject near one third and a compact text group in quiet space on the opposite side. Golden-ratio proportions are optional alternatives, not a proven engagement formula. Neither guide overrides legibility, the actual subject, or the compact crop. Current contentPosition values are edge/center anchors, not exact third or golden-ratio coordinates; do not invent unsupported positioning properties.",
|
|
518
|
+
"Check contrast behind the actual letters at both motion endpoints and both themes: WCAG AA requires 4.5:1 for ordinary text and 3:1 for qualifying large text. A text shadow alone is not a contrast guarantee. Keep text and actions stationary; recheck at mobile widths and enlarged text.",
|
|
519
|
+
"Prefer parallax-subtle or parallax when motion is useful: motion follows the reader's scrolling and stops when they stop. Infinite ambient loops are an explicit art-direction choice, not the default recommendation.",
|
|
520
|
+
"imageMotion is optional and defaults to none. slow-zoom is a cinematic 18% zoom over 6 seconds, alternating; drift-start and drift-end travel horizontally over 8 seconds with a 14% safety crop.",
|
|
521
|
+
"These retuned ambient presets affect existing published banners when their shared runtime updates, without a per-site republish. Preview existing sites before rolling out the runtime.",
|
|
522
|
+
"parallax-subtle and parallax are scroll-linked image motion, not autoplay: scroll to see them. Published pages prefer CSS view timelines and use a platform-owned JavaScript fallback in unsupported browsers, including Firefox; authors need no custom code. Both paths respect reduced motion. They travel vertically by +/-4% and +/-8% of image height with 12% and 22% overscan. Keep focal subjects away from edges; overlay text remains stationary in its frame.",
|
|
523
|
+
"All image motion is disabled for prefers-reduced-motion. Without native view timelines and JavaScript, parallax remains static. Choose one motion per banner; never put essential text in the raster image.",
|
|
508
524
|
],
|
|
509
525
|
{
|
|
510
526
|
image: null,
|
|
@@ -516,7 +532,7 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
516
532
|
scrim: "bottom",
|
|
517
533
|
scrimStrength: "medium",
|
|
518
534
|
texture: "paper",
|
|
519
|
-
imageMotion: "
|
|
535
|
+
imageMotion: "parallax-subtle",
|
|
520
536
|
},
|
|
521
537
|
),
|
|
522
538
|
});
|
|
@@ -183,16 +183,19 @@ function renderFreeFormRootNodes(
|
|
|
183
183
|
): string {
|
|
184
184
|
const rendered: string[] = [];
|
|
185
185
|
let flow: ProseMirrorNode[] = [];
|
|
186
|
+
let compactFollowing = false;
|
|
186
187
|
const flushFlow = () => {
|
|
187
188
|
if (flow.length === 0) return;
|
|
188
|
-
rendered.push(renderSection(flow, {}, "implicit", options, "root"));
|
|
189
|
+
rendered.push(renderSection(flow, {}, "implicit", options, "root", compactFollowing));
|
|
190
|
+
compactFollowing = false;
|
|
189
191
|
flow = [];
|
|
190
192
|
};
|
|
191
193
|
|
|
192
194
|
for (const node of nodes) {
|
|
193
195
|
if (node.type === freeFormSectionRegistry.section.nodeType || isRootBandComponent(node)) {
|
|
194
196
|
flushFlow();
|
|
195
|
-
rendered.push(renderNode(node, options, "root"));
|
|
197
|
+
rendered.push(renderNode(node, options, "root", compactFollowing));
|
|
198
|
+
compactFollowing = joinsFollowingSection(node);
|
|
196
199
|
continue;
|
|
197
200
|
}
|
|
198
201
|
flow.push(node);
|
|
@@ -201,6 +204,20 @@ function renderFreeFormRootNodes(
|
|
|
201
204
|
return rendered.join("");
|
|
202
205
|
}
|
|
203
206
|
|
|
207
|
+
function joinsFollowingSection(node: ProseMirrorNode): boolean {
|
|
208
|
+
if (node.type === "section") {
|
|
209
|
+
return node.attrs?.contentPadding === "none" && node.content?.length === 1
|
|
210
|
+
&& joinsFollowingSection(node.content[0]);
|
|
211
|
+
}
|
|
212
|
+
if (node.type !== "componentBlock" || node.attrs?.componentType !== "image-banner") return false;
|
|
213
|
+
try {
|
|
214
|
+
const data = JSON.parse(stringAttr(node.attrs?.componentData) ?? "{}");
|
|
215
|
+
return data?.followingSpacing === "compact";
|
|
216
|
+
} catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
204
221
|
function isRootBandComponent(node: ProseMirrorNode): boolean {
|
|
205
222
|
if (node.type !== "componentBlock") return false;
|
|
206
223
|
const componentType = stringAttr(node.attrs?.componentType);
|
|
@@ -215,6 +232,7 @@ function renderNode(
|
|
|
215
232
|
node: ProseMirrorNode,
|
|
216
233
|
options: RenderProseMirrorOptions,
|
|
217
234
|
placement: RenderPlacement,
|
|
235
|
+
compactFollowing = false,
|
|
218
236
|
): string {
|
|
219
237
|
switch (node.type) {
|
|
220
238
|
case "doc":
|
|
@@ -272,6 +290,7 @@ function renderNode(
|
|
|
272
290
|
"explicit",
|
|
273
291
|
options,
|
|
274
292
|
placement === "root" ? "section" : "nested",
|
|
293
|
+
compactFollowing,
|
|
275
294
|
);
|
|
276
295
|
case "rawHtml": {
|
|
277
296
|
const html = stringAttr(node.attrs?.html) ?? "";
|
|
@@ -685,6 +704,7 @@ function renderSection(
|
|
|
685
704
|
kind: "explicit" | "implicit",
|
|
686
705
|
options: RenderProseMirrorOptions,
|
|
687
706
|
contentPlacement: RenderPlacement,
|
|
707
|
+
compactFollowing = false,
|
|
688
708
|
): string {
|
|
689
709
|
const definitions = freeFormSectionRegistry.section.attrs;
|
|
690
710
|
const contentPadding = attrs.contentPadding === "none" ? "none" : definitions.contentPadding.default;
|
|
@@ -748,6 +768,7 @@ function renderSection(
|
|
|
748
768
|
{
|
|
749
769
|
"data-taproot-section": kind,
|
|
750
770
|
"data-content-padding": contentPadding,
|
|
771
|
+
"data-following-spacing": compactFollowing && contentPadding === "standard" ? "compact" : undefined,
|
|
751
772
|
"data-surface": surface,
|
|
752
773
|
"data-entrance": entrance,
|
|
753
774
|
"data-entrance-stagger": entranceStagger,
|
|
@@ -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
|
+
}
|