diff --git a/.github/extensions/og-preview/README.md b/.github/extensions/og-preview/README.md
index fac3d789d..9cfc22913 100644
--- a/.github/extensions/og-preview/README.md
+++ b/.github/extensions/og-preview/README.md
@@ -40,6 +40,36 @@ port) that serves the static UI from `ui/` and a JSON API:
The target page is fetched and parsed server-side (no external dependencies),
which sidesteps browser CORS and lets it reach `localhost`.
+## Network access and isolation
+
+Select a localhost URL in the address form or through a canvas action to authorize
+that exact origin (scheme, hostname, and port). Links and resources discovered in
+the page cannot authorize another local origin. Public-to-local redirects are
+blocked, even if the local origin was previously selected.
+
+Private-network destinations beyond loopback require `OG_ALLOW_PRIVATE_NETWORK=1`
+in addition to explicit selection. This setting does not grant arbitrary private
+access to fetched pages. Selecting a different origin invalidates the previous
+browse capability; resources on additional local ports must be previewed separately.
+
+Every connection validates all DNS answers and pins the validated addresses to
+the request, including redirect hops. IPv4-mapped IPv6 addresses are checked
+against the same policy as IPv4. The original hostname remains in use for HTTP
+Host and TLS certificate verification.
+
+The host-provided canvas URL contains a private UI capability in its fragment.
+The renderer uses it for API calls and events; do not share that URL. Sandboxed
+pages receive a separate, revocable resource-only capability and cannot select
+origins or invoke session/issue actions. Public errors omit exception details.
+The browse frame continues to run scripts without `allow-same-origin`; module
+import rewriting is a preview transform, not an HTML sanitizer.
+
+Run the dependency-free regression suite with Node.js 24:
+
+```powershell
+node --test .github\extensions\og-preview\tests\*.test.mjs
+```
+
## Agent actions & tools
- **`open_og_preview`** `{ url?, instanceId? }` *(tool)* — open or focus the
diff --git a/.github/extensions/og-preview/extension.mjs b/.github/extensions/og-preview/extension.mjs
index 01083e909..077b10d31 100644
--- a/.github/extensions/og-preview/extension.mjs
+++ b/.github/extensions/og-preview/extension.mjs
@@ -15,7 +15,8 @@ import { extname } from "node:path";
import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension";
-import { fetchUrl, normalizeUrl } from "./lib/http-fetch.mjs";
+import { fetchUrl, normalizeUrl, authorizePreview } from "./lib/http-fetch.mjs";
+import { createAccess, requestAccess } from "./lib/request-access.mjs";
import { parseMetadata } from "./lib/parse-og.mjs";
import { checkAgentReadiness } from "./lib/agent-readiness.mjs";
@@ -74,9 +75,9 @@ async function serveAsset(res, name) {
}
/** Fetch a target URL and parse its OpenGraph metadata. */
-async function loadMetadata(rawUrl) {
+async function loadMetadata(rawUrl, policy) {
const target = normalizeUrl(rawUrl);
- const result = await fetchUrl(target);
+ const result = await fetchUrl(target, policy);
if (result.status >= 400) {
throw new Error(`Target responded with HTTP ${result.status}.`);
}
@@ -90,6 +91,14 @@ async function loadMetadata(rawUrl) {
return data;
}
+async function selectPreview(entry, url) {
+ const policy = await authorizePreview(url);
+ entry.policy = policy;
+ // Previously rendered content must not inherit a later local selection.
+ entry.browseToken = createAccess().browseToken;
+ return policy;
+}
+
function sendJson(res, status, obj) {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
@@ -353,7 +362,7 @@ function rewriteBrowseDoc(html, finalUrl, appOrigin) {
);
// Inline (no src) → rewrite import specifiers
- out = out.replace(/`;
@@ -439,7 +448,8 @@ function broadcast(entry, payload) {
async function handleRequest(entry, req, res) {
const reqUrl = new URL(req.url, "http://127.0.0.1");
- const path = reqUrl.pathname;
+ let path = reqUrl.pathname;
+ res.setHeader("Referrer-Policy", "no-referrer");
if (path === "/" || path === "/index.html") {
return serveAsset(res, "index.html");
@@ -448,6 +458,17 @@ async function handleRequest(entry, req, res) {
return serveAsset(res, path.slice(1));
}
+ const access = requestAccess(entry, req, reqUrl);
+ if (!access) return sendJson(res, 403, { error: "Preview access denied." });
+ path = access.path;
+ if (path !== "/api/open-session" && path !== "/api/create-issue" && req.method !== "GET") {
+ return sendJson(res, 405, { error: "Use GET." });
+ }
+ // A request keeps its authorization snapshot even when another selection
+ // replaces the active origin while a fetch/redirect is in flight.
+ let policy = entry.policy;
+ const proxyOrigin = new URL(entry.url).origin + `/browse/${entry.browseToken}`;
+
if (path === "/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
@@ -469,15 +490,19 @@ async function handleRequest(entry, req, res) {
// user out of the Browse tab on every in-page navigation.
const silent = reqUrl.searchParams.get("silent") === "1";
try {
- const data = await loadMetadata(u);
+ if (reqUrl.searchParams.get("select") === "1") {
+ policy = await selectPreview(entry, u);
+ }
+ const data = await loadMetadata(u, policy);
entry.currentUrl = data.requestedUrl;
sendJson(res, 200, data);
// Refresh the host panel title to the resolved URL (fire-and-forget;
// guarded against loops by syncTitle).
if (!silent) syncTitle(entry, data.requestedUrl).catch(() => {});
return;
- } catch (err) {
- return sendJson(res, 200, { error: err.message });
+ } catch {
+ log("OG Viewer: metadata request failed.", "warning");
+ return sendJson(res, 200, { error: "Couldn't load metadata. Check the URL and selected-origin access." });
}
}
@@ -486,10 +511,11 @@ async function handleRequest(entry, req, res) {
if (!u) return sendJson(res, 400, { error: "Missing 'u' query parameter." });
try {
const target = normalizeUrl(u);
- const report = await checkAgentReadiness(target);
+ const report = await checkAgentReadiness(target, policy);
return sendJson(res, 200, report);
- } catch (err) {
- return sendJson(res, 200, { error: err.message });
+ } catch {
+ log("OG Viewer: agent-readiness request failed.", "warning");
+ return sendJson(res, 200, { error: "Couldn't check agent readiness." });
}
}
@@ -500,7 +526,7 @@ async function handleRequest(entry, req, res) {
return res.end("Missing 'u'");
}
try {
- const img = await fetchUrl(u, { accept: "image/*,*/*;q=0.8", timeoutMs: 12000 });
+ const img = await fetchUrl(u, { ...policy, accept: "image/*,*/*;q=0.8", timeoutMs: 12000 });
res.statusCode = img.status >= 400 ? img.status : 200;
res.setHeader("Content-Type", img.contentType || "application/octet-stream");
res.setHeader("Cache-Control", "public, max-age=300");
@@ -517,6 +543,7 @@ async function handleRequest(entry, req, res) {
try {
const target = githubBlobToRaw(u);
const r = await fetchUrl(target, {
+ ...policy,
accept: "text/plain,text/markdown,application/json,text/*;q=0.9,*/*;q=0.5",
timeoutMs: 12000,
maxBytes: 1024 * 1024,
@@ -564,8 +591,9 @@ async function handleRequest(entry, req, res) {
truncated,
text,
});
- } catch (err) {
- return sendJson(res, 200, { error: err.message || "Couldn't load file preview." });
+ } catch {
+ log("OG Viewer: file preview failed.", "warning");
+ return sendJson(res, 200, { error: "Couldn't load file preview." });
}
}
@@ -575,17 +603,19 @@ async function handleRequest(entry, req, res) {
// them, and rewrites JS imports / CSS urls so the dependency graph stays inside
// the proxy. The path layout makes relative module imports resolve correctly.
if (path.startsWith("/api/proxy/")) {
- const target = proxyDecodePath(path, reqUrl.search);
+ const search = new URLSearchParams(reqUrl.search);
+ if (access.privileged) search.delete("key");
+ const target = proxyDecodePath(path, search.size ? `?${search}` : "");
if (!target) {
res.statusCode = 400;
return res.end("Bad proxy path");
}
- const appOrigin = "http://" + (req.headers.host || "127.0.0.1");
+ const appOrigin = proxyOrigin;
try {
- const r = await fetchUrl(target, { accept: "*/*", timeoutMs: 15000 });
+ const r = await fetchUrl(target, { ...policy, accept: "*/*", timeoutMs: 15000 });
const ct = r.contentType || "";
res.setHeader("Access-Control-Allow-Origin", "*");
- res.setHeader("Cache-Control", "public, max-age=300");
+ res.setHeader("Cache-Control", "no-store");
const realPath = (() => {
try {
return new URL(r.url).pathname;
@@ -617,10 +647,11 @@ async function handleRequest(entry, req, res) {
res.statusCode = r.status >= 400 ? r.status : 200;
res.setHeader("Content-Type", ct || "application/octet-stream");
return res.end(r.body);
- } catch (err) {
+ } catch {
+ log("OG Viewer: proxy resource request failed.", "warning");
res.statusCode = 502;
res.setHeader("Access-Control-Allow-Origin", "*");
- return res.end(String(err && err.message ? err.message : err));
+ return res.end("Couldn't load preview resource.");
}
}
@@ -632,6 +663,7 @@ async function handleRequest(entry, req, res) {
}
try {
const r = await fetchUrl(normalizeUrl(u), {
+ ...policy,
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
timeoutMs: 15000,
});
@@ -639,7 +671,7 @@ async function handleRequest(entry, req, res) {
const isHtml = !ct || /text\/html|application\/xhtml\+xml|\/xml|text\/plain/i.test(ct);
res.setHeader("Cache-Control", "no-store");
res.setHeader("Access-Control-Allow-Origin", "*");
- const appOrigin = "http://" + (req.headers.host || "127.0.0.1");
+ const appOrigin = proxyOrigin;
if (!isHtml) {
// Serve non-HTML targets (images, PDFs, …) verbatim so links to
// them still render inside the browse frame.
@@ -652,11 +684,12 @@ async function handleRequest(entry, req, res) {
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
return res.end(rewriteBrowseDoc(r.body.toString("utf8"), r.url, appOrigin));
- } catch (err) {
+ } catch {
+ log("OG Viewer: browse request failed.", "warning");
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-store");
- return res.end(browseErrorPage(u, err && err.message ? err.message : String(err)));
+ return res.end(browseErrorPage(u, "Check the URL and selected-origin access."));
}
}
@@ -667,8 +700,8 @@ async function handleRequest(entry, req, res) {
let body;
try {
body = await readJsonBody(req);
- } catch (err) {
- return sendJson(res, 400, { error: err.message });
+ } catch {
+ return sendJson(res, 400, { error: "Invalid JSON request body." });
}
const repo = typeof body.repo === "string" ? body.repo.trim() : "";
const pageUrl = typeof body.url === "string" ? body.url.trim() : "";
@@ -690,13 +723,14 @@ async function handleRequest(entry, req, res) {
return sendJson(res, 200, { ok: false, error: "Session bridge unavailable." });
}
// Fire the request into the host chat session; the agent acts on it.
- sessionRef.send(message).catch(() => {});
+ await sessionRef.send(message);
log(`OG Viewer: requested ${kind} for ${repo}.`);
return sendJson(res, 200, { ok: true });
- } catch (err) {
+ } catch {
+ log("OG Viewer: session action failed.", "warning");
return sendJson(res, 200, {
ok: false,
- error: err && err.message ? err.message : String(err),
+ error: "Couldn't send the session action.",
});
}
}
@@ -707,6 +741,7 @@ async function handleRequest(entry, req, res) {
async function startServer(instanceId, currentUrl) {
const entry = {
+ ...createAccess(),
instanceId,
server: null,
url: "",
@@ -714,10 +749,12 @@ async function startServer(instanceId, currentUrl) {
titleKey: currentUrl ? titleKey(currentUrl) : "",
clients: new Set(),
};
+ if (currentUrl) entry.policy = await authorizePreview(currentUrl);
const server = createServer((req, res) => {
- Promise.resolve(handleRequest(entry, req, res)).catch((err) => {
+ Promise.resolve(handleRequest(entry, req, res)).catch(() => {
+ log("OG Viewer: request failed.", "warning");
if (!res.headersSent) res.statusCode = 500;
- res.end(String(err && err.message ? err.message : err));
+ res.end("Preview request failed.");
});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
@@ -731,7 +768,8 @@ async function startServer(instanceId, currentUrl) {
function instanceUrl(entry) {
const base = entry.url;
- return entry.currentUrl ? `${base}?u=${encodeURIComponent(entry.currentUrl)}` : base;
+ const url = entry.currentUrl ? `${base}?u=${encodeURIComponent(entry.currentUrl)}` : base;
+ return `${url}#key=${entry.uiToken}`;
}
const ogCanvas = createCanvas({
@@ -767,7 +805,8 @@ const ogCanvas = createCanvas({
}
const url = ctx.input?.url;
if (!url) throw new CanvasError("invalid_input", "An 'url' value is required.");
- const data = await loadMetadata(url);
+ const policy = await selectPreview(entry, url);
+ const data = await loadMetadata(url, policy);
entry.currentUrl = data.requestedUrl;
broadcast(entry, { type: "load", url: data.requestedUrl });
syncTitle(entry, data.requestedUrl).catch(() => {});
@@ -787,7 +826,7 @@ const ogCanvas = createCanvas({
handler: async (ctx) => {
const url = ctx.input?.url;
if (!url) throw new CanvasError("invalid_input", "An 'url' value is required.");
- const data = await loadMetadata(url);
+ const data = await loadMetadata(url, await authorizePreview(url));
return {
requestedUrl: data.requestedUrl,
resolved: data.resolved,
@@ -803,6 +842,9 @@ const ogCanvas = createCanvas({
if (!entry) {
entry = await startServer(ctx.instanceId, inputUrl);
} else if (inputUrl) {
+ // Title refreshes use currentUrl and must not grant permissions to
+ // a redirect or a route reported by the sandboxed page.
+ if (inputUrl !== entry.currentUrl) await selectPreview(entry, inputUrl);
entry.currentUrl = inputUrl;
broadcast(entry, { type: "load", url: inputUrl });
}
diff --git a/.github/extensions/og-preview/lib/agent-readiness.mjs b/.github/extensions/og-preview/lib/agent-readiness.mjs
index 188a7e8e5..9f9f159ba 100644
--- a/.github/extensions/og-preview/lib/agent-readiness.mjs
+++ b/.github/extensions/og-preview/lib/agent-readiness.mjs
@@ -44,8 +44,8 @@ async function probe(url, opts = {}) {
bytes: r.body ? r.body.length : 0,
finalUrl: r.url,
};
- } catch (err) {
- return { ok: false, status: 0, error: String((err && err.message) || err) };
+ } catch {
+ return { ok: false, status: 0, error: "Probe unavailable or outside the authorized preview origin." };
}
}
@@ -94,33 +94,34 @@ async function dnsAid(host) {
return { ok: false };
}
-export async function checkAgentReadiness(rawUrl) {
+export async function checkAgentReadiness(rawUrl, policy = {}) {
const u = new URL(rawUrl);
const origin = u.origin;
const host = u.hostname;
const W = (p) => origin + p;
+ const check = (url, opts) => probe(url, { ...policy, ...opts });
const [
robots, sitemapXml, llms, llmsFull, mdNeg, page,
mcp1, mcp2, a2a1, a2a2, aiPlugin, skills1, skills2,
oauthPr, oauthAs, apiCatalog, aid,
] = await Promise.all([
- probe(W("/robots.txt"), { accept: "text/plain,*/*;q=0.8" }),
- probe(W("/sitemap.xml"), { accept: "application/xml,text/xml,*/*;q=0.8" }),
- probe(W("/llms.txt"), { accept: "text/markdown,text/plain,*/*;q=0.8" }),
- probe(W("/llms-full.txt"), { accept: "text/markdown,text/plain,*/*;q=0.8" }),
- probe(rawUrl, { accept: "text/markdown; q=1.0, text/x-markdown; q=0.9, text/plain; q=0.5" }),
- probe(rawUrl, { accept: "text/html,application/xhtml+xml" }),
- probe(W("/.well-known/mcp"), { accept: "application/json,*/*;q=0.8" }),
- probe(W("/.well-known/mcp.json"), { accept: "application/json,*/*;q=0.8" }),
- probe(W("/.well-known/agent.json"), { accept: "application/json,*/*;q=0.8" }),
- probe(W("/.well-known/agent-card.json"), { accept: "application/json,*/*;q=0.8" }),
- probe(W("/.well-known/ai-plugin.json"), { accept: "application/json,*/*;q=0.8" }),
- probe(W("/.well-known/agent-skills.json"), { accept: "application/json,*/*;q=0.8" }),
- probe(W("/.well-known/skills.json"), { accept: "application/json,*/*;q=0.8" }),
- probe(W("/.well-known/oauth-protected-resource"), { accept: "application/json,*/*;q=0.8" }),
- probe(W("/.well-known/oauth-authorization-server"), { accept: "application/json,*/*;q=0.8" }),
- probe(W("/.well-known/api-catalog"), { accept: "application/linkset+json,application/json,*/*;q=0.8" }),
+ check(W("/robots.txt"), { accept: "text/plain,*/*;q=0.8" }),
+ check(W("/sitemap.xml"), { accept: "application/xml,text/xml,*/*;q=0.8" }),
+ check(W("/llms.txt"), { accept: "text/markdown,text/plain,*/*;q=0.8" }),
+ check(W("/llms-full.txt"), { accept: "text/markdown,text/plain,*/*;q=0.8" }),
+ check(rawUrl, { accept: "text/markdown; q=1.0, text/x-markdown; q=0.9, text/plain; q=0.5" }),
+ check(rawUrl, { accept: "text/html,application/xhtml+xml" }),
+ check(W("/.well-known/mcp"), { accept: "application/json,*/*;q=0.8" }),
+ check(W("/.well-known/mcp.json"), { accept: "application/json,*/*;q=0.8" }),
+ check(W("/.well-known/agent.json"), { accept: "application/json,*/*;q=0.8" }),
+ check(W("/.well-known/agent-card.json"), { accept: "application/json,*/*;q=0.8" }),
+ check(W("/.well-known/ai-plugin.json"), { accept: "application/json,*/*;q=0.8" }),
+ check(W("/.well-known/agent-skills.json"), { accept: "application/json,*/*;q=0.8" }),
+ check(W("/.well-known/skills.json"), { accept: "application/json,*/*;q=0.8" }),
+ check(W("/.well-known/oauth-protected-resource"), { accept: "application/json,*/*;q=0.8" }),
+ check(W("/.well-known/oauth-authorization-server"), { accept: "application/json,*/*;q=0.8" }),
+ check(W("/.well-known/api-catalog"), { accept: "application/linkset+json,application/json,*/*;q=0.8" }),
dnsAid(host),
]);
diff --git a/.github/extensions/og-preview/lib/http-fetch.mjs b/.github/extensions/og-preview/lib/http-fetch.mjs
index d24a563a1..5370349eb 100644
--- a/.github/extensions/og-preview/lib/http-fetch.mjs
+++ b/.github/extensions/og-preview/lib/http-fetch.mjs
@@ -13,74 +13,81 @@ const USER_AGENT =
const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1|.*\.localhost)(:|\/|$)/i;
-// SSRF guard. The tool intentionally supports localhost, but every other
-// private / link-local / unique-local / carrier-grade-NAT range is denied by
-// default so the agent-callable actions and the loopback proxy can't be turned
-// into a request-forgery primitive against the developer's machine/network
-// (e.g. the 169.254.169.254 cloud metadata endpoint). Set
-// OG_ALLOW_PRIVATE_NETWORK=1 to opt in to private destinations beyond localhost.
+// Local destinations require explicit origin authorization. The environment
+// opt-in permits selecting private origins; it never authorizes discovered URLs.
const ALLOW_PRIVATE_NETWORK = /^(1|true|yes|on)$/i.test(
String(process.env.OG_ALLOW_PRIVATE_NETWORK || ""),
);
-function ipv4Allowed(ip, allowPrivate) {
- const o = ip.split(".").map((n) => Number(n));
- if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
- return false;
+function addressKind(ip) {
+ if (net.isIP(ip) === 6) {
+ const canonical = new URL(`http://[${ip}]/`).hostname.slice(1, -1);
+ const mapped = canonical.match(/^::ffff:([a-f0-9]+):([a-f0-9]+)$/);
+ if (mapped) {
+ const high = parseInt(mapped[1], 16);
+ const low = parseInt(mapped[2], 16);
+ return addressKind(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
+ }
+ if (canonical === "::1") return "loopback";
+ // Global unicast only; exclude transition and documentation ranges.
+ const [first, second = "0"] = canonical.split(":");
+ if (/^[23]/.test(canonical) &&
+ !(first === "2001" && (parseInt(second || "0", 16) < 512 || second === "db8")) &&
+ first !== "2002" && first !== "3fff") return "public";
+ return "private";
}
- const [a, b] = o;
- if (a === 127) return true; // loopback (localhost) — always allowed
- if (allowPrivate) return true;
- if (a === 0) return false; // "this" network
- if (a === 10) return false; // private
- if (a === 172 && b >= 16 && b <= 31) return false; // private
- if (a === 192 && b === 168) return false; // private
- if (a === 169 && b === 254) return false; // link-local + cloud metadata
- if (a === 100 && b >= 64 && b <= 127) return false; // carrier-grade NAT
- return true;
-}
-
-function ipv6Allowed(ip, allowPrivate) {
- const s = ip.toLowerCase();
- const mapped = s.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
- if (mapped) return ipv4Allowed(mapped[1], allowPrivate);
- if (s === "::1") return true; // loopback
- if (allowPrivate) return true;
- if (s === "::") return false; // unspecified
- if (/^fe[89ab]/.test(s)) return false; // fe80::/10 link-local
- if (/^f[cd]/.test(s)) return false; // fc00::/7 unique-local
- return true;
+ if (net.isIP(ip) !== 4) throw new Error("Invalid resolved address.");
+ const o = ip.split(".").map((n) => Number(n));
+ const [a, b, c] = o;
+ if (a === 127) return "loopback";
+ if (a === 0 || a === 10 || a >= 224 ||
+ (a === 172 && b >= 16 && b <= 31) ||
+ (a === 192 && (b === 168 || (b === 0 && (c === 0 || c === 2)))) ||
+ (a === 169 && b === 254) ||
+ (a === 100 && b >= 64 && b <= 127) ||
+ (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
+ (a === 203 && b === 0 && c === 113)) return "private";
+ return "public";
}
-function addressAllowed(ip, allowPrivate) {
- const v = net.isIP(ip);
- if (v === 4) return ipv4Allowed(ip, allowPrivate);
- if (v === 6) return ipv6Allowed(ip, allowPrivate);
- return false;
+function parseTarget(rawUrl) {
+ const parsed = new URL(rawUrl);
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ throw new Error("Only HTTP and HTTPS URLs are supported.");
+ }
+ if (parsed.username || parsed.password) throw new Error("URL credentials are not supported.");
+ return parsed;
}
-// Resolve a hostname to its addresses and confirm none land in a denied range.
-// Literal IPs are checked directly; explicit localhost names are always allowed.
-async function assertHostAllowed(hostname, allowPrivate) {
+async function resolveAddresses(hostname) {
const host = hostname.startsWith("[") ? hostname.slice(1, -1) : hostname;
- if (LOCAL_HOST_RE.test(host)) return; // localhost / *.localhost / 127.* / ::1
if (net.isIP(host)) {
- if (!addressAllowed(host, allowPrivate)) {
- throw new Error(`Blocked non-public address: ${host}`);
- }
- return;
- }
- let addrs;
- try {
- addrs = await dns.lookup(host, { all: true });
- } catch {
- throw new Error(`Could not resolve host: ${host}`);
+ return [{ address: host, family: net.isIP(host) }];
}
- for (const a of addrs) {
- if (!addressAllowed(a.address, allowPrivate)) {
- throw new Error(`Blocked non-public address for ${host}: ${a.address}`);
+ const addresses = await dns.lookup(host, { all: true });
+ if (!addresses.length) throw new Error("Host resolved to no addresses.");
+ for (const record of addresses) {
+ if (!net.isIP(record.address) || net.isIP(record.address) !== record.family) {
+ throw new Error("Invalid DNS result.");
}
}
+ return addresses;
+}
+
+/** Only trusted user/agent selection may call this, never discovered content. */
+export async function authorizePreview(rawUrl, allowPrivateNetwork = ALLOW_PRIVATE_NETWORK) {
+ const parsed = parseTarget(normalizeUrl(rawUrl));
+ const addresses = await resolveAddresses(parsed.hostname);
+ const kinds = new Set(addresses.map((a) => addressKind(a.address)));
+ if (kinds.size !== 1) throw new Error("Host resolves to mixed network scopes.");
+ const kind = kinds.values().next().value;
+ if (kind === "private" && !allowPrivateNetwork) {
+ throw new Error("Private-network previews require OG_ALLOW_PRIVATE_NETWORK.");
+ }
+ return {
+ authorizedOrigin: kind === "public" ? null : parsed.origin,
+ allowPrivateNetwork,
+ };
}
/**
@@ -90,9 +97,12 @@ async function assertHostAllowed(hostname, allowPrivate) {
export function normalizeUrl(input) {
const trimmed = String(input ?? "").trim();
if (!trimmed) throw new Error("No URL provided.");
- if (/^https?:\/\//i.test(trimmed)) return trimmed;
+ if (/^https?:\/\//i.test(trimmed)) return parseTarget(trimmed).href;
+ if (/^[a-z][a-z\d+.-]*:/i.test(trimmed) && !/^[^:/]+:\d+(?:[/?#]|$)/.test(trimmed)) {
+ throw new Error("Only HTTP and HTTPS URLs are supported.");
+ }
const scheme = LOCAL_HOST_RE.test(trimmed) ? "http://" : "https://";
- return scheme + trimmed;
+ return parseTarget(scheme + trimmed).href;
}
const MAX_BYTES = 6 * 1024 * 1024; // 6 MB safety cap
@@ -108,23 +118,32 @@ export function fetchUrl(rawUrl, options = {}) {
accept = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
maxBytes = MAX_BYTES,
allowPrivateNetwork = ALLOW_PRIVATE_NETWORK,
+ authorizedOrigin = null,
} = options;
return new Promise((resolve, reject) => {
let redirects = 0;
+ let localOrigin = authorizedOrigin;
const visit = async (urlStr) => {
let parsed;
try {
- parsed = new URL(urlStr);
+ parsed = parseTarget(urlStr);
} catch {
- return reject(new Error(`Invalid URL: ${urlStr}`));
- }
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
- return reject(new Error(`Unsupported protocol: ${parsed.protocol}`));
+ return reject(new Error("Invalid or unsupported URL."));
}
+ let addresses;
try {
- await assertHostAllowed(parsed.hostname, allowPrivateNetwork);
+ if (parsed.origin !== localOrigin) localOrigin = null;
+ addresses = await resolveAddresses(parsed.hostname);
+ for (const { address } of addresses) {
+ const kind = addressKind(address);
+ if (kind !== "public" &&
+ (parsed.origin !== localOrigin ||
+ (kind === "private" && !allowPrivateNetwork))) {
+ throw new Error("Destination is outside the authorized preview origin.");
+ }
+ }
} catch (err) {
return reject(err);
}
@@ -134,6 +153,17 @@ export function fetchUrl(rawUrl, options = {}) {
parsed,
{
method: "GET",
+ // Do not resolve again or reuse a connection validated for a
+ // different request. Keep the URL hostname for Host and TLS.
+ agent: false,
+ lookup: (_hostname, opts, callback) => {
+ const candidates = opts.family
+ ? addresses.filter((a) => a.family === opts.family)
+ : addresses;
+ if (!candidates.length) return callback(new Error("No validated address."));
+ if (opts.all) return callback(null, candidates);
+ callback(null, candidates[0].address, candidates[0].family);
+ },
headers: {
"User-Agent": USER_AGENT,
Accept: accept,
diff --git a/.github/extensions/og-preview/lib/request-access.mjs b/.github/extensions/og-preview/lib/request-access.mjs
new file mode 100644
index 000000000..8df6398ad
--- /dev/null
+++ b/.github/extensions/og-preview/lib/request-access.mjs
@@ -0,0 +1,25 @@
+import { randomBytes } from "node:crypto";
+
+export function createAccess() {
+ return {
+ uiToken: randomBytes(32).toString("hex"),
+ browseToken: randomBytes(32).toString("hex"),
+ policy: {},
+ };
+}
+
+/** Browse content can fetch resources, but cannot select origins or use tools. */
+export function requestAccess(entry, req, url) {
+ if (req.headers.host !== new URL(entry.url).host) return null;
+ const prefix = `/browse/${entry.browseToken}`;
+ if (url.pathname.startsWith(prefix + "/api/proxy")) {
+ const path = url.pathname.slice(prefix.length);
+ if (path === "/api/proxy" || path.startsWith("/api/proxy/")) {
+ return req.method === "GET" ? { path, privileged: false } : null;
+ }
+ }
+ if (url.searchParams.get("key") === entry.uiToken) {
+ return { path: url.pathname, privileged: true };
+ }
+ return null;
+}
diff --git a/.github/extensions/og-preview/tests/fixtures/sdk.mjs b/.github/extensions/og-preview/tests/fixtures/sdk.mjs
new file mode 100644
index 000000000..5e4fe06bf
--- /dev/null
+++ b/.github/extensions/og-preview/tests/fixtures/sdk.mjs
@@ -0,0 +1,12 @@
+export let registration;
+export const messages = [];
+export const createCanvas = (options) => options;
+export class CanvasError extends Error {}
+export async function joinSession(options) {
+ registration = options;
+ return {
+ log() {},
+ async send(message) { messages.push(message); },
+ rpc: { canvas: { async open() {} } },
+ };
+}
diff --git a/.github/extensions/og-preview/tests/http-fetch.test.mjs b/.github/extensions/og-preview/tests/http-fetch.test.mjs
new file mode 100644
index 000000000..3bb360b27
--- /dev/null
+++ b/.github/extensions/og-preview/tests/http-fetch.test.mjs
@@ -0,0 +1,155 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { EventEmitter } from "node:events";
+import http from "node:http";
+import https from "node:https";
+import dns from "node:dns/promises";
+import { authorizePreview, fetchUrl, normalizeUrl } from "../lib/http-fetch.mjs";
+
+function transport(t, library = http, responses = [{}]) {
+ const requests = [];
+ t.mock.method(library, "request", (url, options, receive) => {
+ const req = new EventEmitter();
+ req.setTimeout = () => {};
+ req.destroy = () => {};
+ req.end = () => {
+ const next = responses[requests.length - 1] || {};
+ const res = new EventEmitter();
+ res.statusCode = next.status || 200;
+ res.headers = next.headers || {};
+ res.resume = () => {};
+ receive(res);
+ queueMicrotask(() => {
+ res.emit("data", Buffer.from("ok"));
+ res.emit("end");
+ });
+ };
+ requests.push({ url, options });
+ return req;
+ });
+ return requests;
+}
+
+test("blocks private, mapped IPv6, transition, and loopback addresses without selection", async (t) => {
+ const requests = transport(t);
+ for (const host of [
+ "169.254.169.254", "10.0.0.1", "127.0.0.1", "0.0.0.0", "100.64.0.1",
+ "[::1]", "[fc00::1]", "[fe80::1]", "[::ffff:169.254.169.254]",
+ "[0:0:0:0:0:ffff:a00:1]", "[::ffff:7f00:1]", "[2002:a00:1::]",
+ "[2001::1]", "[64:ff9b::a00:1]",
+ ]) {
+ await assert.rejects(fetchUrl(`http://${host}/`, { allowPrivateNetwork: false }), /authorized/);
+ }
+ assert.equal(requests.length, 0);
+});
+
+test("environment/private opt-in alone never permits discovered private destinations", async (t) => {
+ const requests = transport(t);
+ await assert.rejects(fetchUrl("http://10.0.0.1/", { allowPrivateNetwork: true }), /authorized/);
+ assert.equal(requests.length, 0);
+});
+
+test("explicit loopback authorization is exact host, scheme and port", async (t) => {
+ const requests = transport(t);
+ const policy = await authorizePreview("http://127.0.0.1:4321/", false);
+ assert.equal((await fetchUrl("http://127.0.0.1:4321/page", policy)).status, 200);
+ for (const url of ["http://127.0.0.1:4322/", "http://[::1]:4321/", "https://127.0.0.1:4321/"]) {
+ await assert.rejects(fetchUrl(url, policy), /authorized/);
+ }
+ assert.equal(requests.length, 1);
+});
+
+test("private selection needs opt-in and authorizes only that origin", async (t) => {
+ const requests = transport(t);
+ await assert.rejects(authorizePreview("http://10.0.0.1/", false), /OG_ALLOW_PRIVATE_NETWORK/);
+ const policy = await authorizePreview("http://10.0.0.1/", true);
+ await fetchUrl("http://10.0.0.1/page", policy);
+ await assert.rejects(fetchUrl("http://10.0.0.2/", policy), /authorized/);
+ assert.equal(requests.length, 1);
+});
+
+test("public selection does not pre-authorize later DNS rebinding to loopback", async (t) => {
+ const requests = transport(t);
+ t.mock.method(dns, "lookup", async () => [{ address: "8.8.8.8", family: 4 }]);
+ const policy = await authorizePreview("http://preview.example/", false);
+ t.mock.method(dns, "lookup", async () => [{ address: "127.0.0.1", family: 4 }]);
+ await assert.rejects(fetchUrl("http://preview.example/", policy), /authorized/);
+ assert.equal(requests.length, 0);
+});
+
+test("pins validated DNS addresses while preserving the hostname for Host and TLS", async (t) => {
+ const lookups = t.mock.method(dns, "lookup", async () => [{ address: "8.8.8.8", family: 4 }]);
+ const requests = transport(t, https);
+ await fetchUrl("https://preview.example:443/path");
+ const { url, options } = requests[0];
+ assert.equal(url.hostname, "preview.example");
+ assert.equal(url.protocol, "https:");
+ assert.equal(options.agent, false);
+ assert.equal(options.rejectUnauthorized, undefined);
+ t.mock.method(dns, "lookup", async () => { throw new Error("Must not resolve again"); });
+ options.lookup("preview.example", { all: true }, (err, addresses) => {
+ assert.ifError(err);
+ assert.deepEqual(addresses, [{ address: "8.8.8.8", family: 4 }]);
+ });
+ options.lookup("preview.example", {}, (err, address, family) => {
+ assert.ifError(err);
+ assert.equal(address, "8.8.8.8");
+ assert.equal(family, 4);
+ });
+ assert.equal(lookups.mock.callCount(), 1);
+});
+
+test("checks every DNS result including localhost names and rejects empty answers", async (t) => {
+ const requests = transport(t);
+ for (const answers of [
+ [],
+ [{ address: "8.8.8.8", family: 4 }, { address: "10.0.0.1", family: 4 }],
+ [{ address: "10.0.0.1", family: 4 }],
+ [{ address: "not-an-ip", family: 4 }],
+ ]) {
+ t.mock.method(dns, "lookup", async () => answers);
+ await assert.rejects(fetchUrl("http://untrusted.localhost/"));
+ }
+ assert.equal(requests.length, 0);
+});
+
+test("public IPv4 and IPv6 mapped addresses remain usable", async (t) => {
+ const requests = transport(t);
+ for (const host of ["8.8.8.8", "[2606:4700:4700::1111]", "[::ffff:808:808]"]) {
+ await fetchUrl(`http://${host}/`);
+ }
+ assert.equal(requests.length, 3);
+});
+
+test("revalidates redirect targets and never follows public-to-local redirects", async (t) => {
+ const requests = transport(t, http, [{ status: 302, headers: { location: "http://127.0.0.1/" } }]);
+ await assert.rejects(fetchUrl("http://8.8.8.8/"), /authorized/);
+ assert.equal(requests.length, 1);
+});
+
+test("local redirects can remain on the selected origin but not switch ports", async (t) => {
+ const requests = transport(t, http, [
+ { status: 302, headers: { location: "/page" } },
+ { status: 302, headers: { location: "http://127.0.0.1:2/" } },
+ ]);
+ await assert.rejects(fetchUrl("http://127.0.0.1:1/", await authorizePreview("http://127.0.0.1:1/")), /authorized/);
+ assert.equal(requests.length, 2);
+});
+
+test("rejects unsupported schemes and embedded credentials before transport", async (t) => {
+ const requests = transport(t);
+ for (const url of ["file:///secret", "javascript:alert(1)", "ftp://8.8.8.8", "https://user:pass@8.8.8.8/"]) {
+ await assert.rejects(fetchUrl(url), /Invalid or unsupported/);
+ }
+ assert.equal(requests.length, 0);
+ assert.equal(normalizeUrl("localhost:4321"), "http://localhost:4321/");
+ assert.equal(normalizeUrl("aspire.dev"), "https://aspire.dev/");
+ assert.throws(() => normalizeUrl("file:///secret"), /Only HTTP/);
+ assert.throws(() => normalizeUrl("javascript:alert(1)"), /Only HTTP/);
+});
+
+test("a public resource cannot redirect back to an otherwise authorized local origin", async (t) => {
+ const requests = transport(t, http, [{ status: 302, headers: { location: "http://127.0.0.1/" } }]);
+ await assert.rejects(fetchUrl("http://8.8.8.8/", await authorizePreview("http://127.0.0.1/")), /authorized/);
+ assert.equal(requests.length, 1);
+});
diff --git a/.github/extensions/og-preview/tests/request-access.test.mjs b/.github/extensions/og-preview/tests/request-access.test.mjs
new file mode 100644
index 000000000..3d704ab17
--- /dev/null
+++ b/.github/extensions/og-preview/tests/request-access.test.mjs
@@ -0,0 +1,37 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { createAccess, requestAccess } from "../lib/request-access.mjs";
+
+test("requires the UI key and literal server host for privileged routes", () => {
+ const entry = { ...createAccess(), url: "http://127.0.0.1:4321/" };
+ const req = { method: "GET", headers: { host: "127.0.0.1:4321" } };
+ const url = new URL(`/api/fetch?key=${entry.uiToken}`, entry.url);
+ assert.deepEqual(requestAccess(entry, req, url), { path: "/api/fetch", privileged: true });
+ assert.equal(requestAccess(entry, { ...req, headers: { host: "rebound.example:4321" } }, url), null);
+ assert.equal(requestAccess(entry, req, new URL("/api/fetch", entry.url)), null);
+});
+
+test("browse keys only grant GET access to exact proxy routes", () => {
+ const entry = { ...createAccess(), url: "http://127.0.0.1:4321/" };
+ const req = { method: "GET", headers: { host: "127.0.0.1:4321" } };
+ const prefix = `/browse/${entry.browseToken}`;
+ for (const path of ["/api/proxy", "/api/proxy/http/example.com/path"]) {
+ const url = new URL(prefix + path, entry.url);
+ assert.deepEqual(requestAccess(entry, req, url), { path, privileged: false });
+ assert.equal(requestAccess(entry, { ...req, method: "POST" }, url), null);
+ }
+ for (const path of ["/api/proxyevil", "/api/fetch", "/events", "/api/create-issue"]) {
+ assert.equal(requestAccess(entry, req, new URL(prefix + path, entry.url)), null);
+ }
+});
+
+test("capabilities are distinct across roles and canvas instances", () => {
+ const first = createAccess();
+ const second = createAccess();
+ assert.equal(new Set([first.uiToken, first.browseToken, second.uiToken, second.browseToken]).size, 4);
+ assert.equal(first.uiToken.length, 64);
+ const entry = { ...first, url: "http://127.0.0.1:4321/" };
+ const req = { method: "GET", headers: { host: "127.0.0.1:4321" } };
+ assert.equal(requestAccess(entry, req, new URL(`/api/fetch?key=${first.browseToken}`, entry.url)), null);
+ assert.equal(requestAccess(entry, req, new URL(`/api/fetch?key=${second.uiToken}`, entry.url)), null);
+});
diff --git a/.github/extensions/og-preview/tests/server.test.mjs b/.github/extensions/og-preview/tests/server.test.mjs
new file mode 100644
index 000000000..601a39175
--- /dev/null
+++ b/.github/extensions/og-preview/tests/server.test.mjs
@@ -0,0 +1,209 @@
+import assert from "node:assert/strict";
+import { test, before, after } from "node:test";
+import { registerHooks } from "node:module";
+import { createServer } from "node:http";
+import { once } from "node:events";
+import { registration, messages } from "./fixtures/sdk.mjs";
+
+const hooks = registerHooks({
+ resolve(specifier, context, nextResolve) {
+ if (specifier === "@github/copilot-sdk/extension") {
+ return { url: new URL("./fixtures/sdk.mjs", import.meta.url).href, shortCircuit: true };
+ }
+ return nextResolve(specifier, context);
+ },
+});
+await import("../extension.mjs");
+hooks.deregister();
+
+let target;
+let targetUrl;
+let canvas;
+let canvasUrl;
+let key;
+let hits = 0;
+before(async () => {
+ target = createServer((req, res) => {
+ hits++;
+ res.setHeader("Content-Type", "text/html");
+ if (req.url === "/bad-type") {
+ res.setHeader("Content-Type", "application/private-path-sentinel");
+ }
+ if (req.url === "/redirect") {
+ res.writeHead(302, { Location: "http://127.0.0.1:1/" });
+ res.end();
+ return;
+ }
+ res.end(`
Local preview
+
+">
+
+
+Hello`);
+ });
+ target.listen(0, "127.0.0.1");
+ await once(target, "listening");
+ targetUrl = `http://127.0.0.1:${target.address().port}`;
+ canvas = registration.canvases[0];
+ const opened = await canvas.open({ instanceId: "test", input: { url: targetUrl } });
+ canvasUrl = new URL(opened.url);
+ key = new URLSearchParams(canvasUrl.hash.slice(1)).get("key");
+});
+after(async () => {
+ await canvas.onClose({ instanceId: "test" });
+ await new Promise((resolve) => target.close(resolve));
+});
+function api(path) {
+ const url = new URL(path, canvasUrl);
+ url.searchParams.set("key", key);
+ return url;
+}
+async function browse() {
+ const res = await fetch(api("/api/proxy?u=" + encodeURIComponent(targetUrl)));
+ return res.text();
+}
+function resourceUrl(html) {
+ const value = html.match(/http:\/\/127\.0\.0\.1:\d+\/browse\/[a-f0-9]+\/api\/proxy\/http\/[^" ]+\/external\.js/);
+ assert.ok(value, "resource URL has a browse-only capability");
+ return new URL(value[0]);
+}
+
+test("requires the private outer-UI capability on API routes and SSE", async () => {
+ const beforeHits = hits;
+ for (const path of ["/api/fetch?select=1&u=" + encodeURIComponent(targetUrl), "/events", "/api/proxy?u=" + encodeURIComponent(targetUrl)]) {
+ const response = await fetch(new URL(path, canvasUrl));
+ assert.equal(response.status, 403);
+ }
+ assert.equal(hits, beforeHits);
+});
+
+test("allows explicitly selected localhost metadata and same-origin resources", async () => {
+ const res = await fetch(api("/api/fetch?u=" + encodeURIComponent(targetUrl)));
+ assert.equal(res.status, 200);
+ assert.equal((await res.json()).resolved.title, "Local preview");
+ const html = await browse();
+ const resource = resourceUrl(html);
+ assert.equal((await fetch(resource)).status, 200);
+ assert.ok(!html.includes(key), "privileged token never enters fetched HTML");
+});
+
+test("rewrites mixed-case inline modules with quoted delimiters and closing whitespace", async () => {
+ const html = await browse();
+ assert.match(html, /import "http:\/\/127\.0\.0\.1:\d+\/browse\/[a-f0-9]+\/api\/proxy\/http\/127\.0\.0\.1:\d+\/module\.js"/);
+ assert.match(html, /const classic = "\/plain\.js"/);
+ assert.match(html, /import "http:\/\/127\.0\.0\.1:\d+\/browse\/[a-f0-9]+\/api\/proxy\/http\/127\.0\.0\.1:\d+\/end-attributes\.js"/);
+ assert.match(html, /import "http:\/\/127\.0\.0\.1:\d+\/browse\/[a-f0-9]+\/api\/proxy\/http\/127\.0\.0\.1:\d+\/end-slash\.js"/);
+});
+
+test("browse capability cannot authorize origins, send actions, or read events", async () => {
+ const resource = resourceUrl(await browse());
+ const prefix = resource.pathname.slice(0, resource.pathname.indexOf("/api/proxy"));
+ const beforeMessages = messages.length;
+ for (const path of ["/api/fetch?select=1&u=http://127.0.0.1:1", "/api/open-session", "/events"]) {
+ const res = await fetch(new URL(prefix + path, canvasUrl));
+ assert.equal(res.status, 403);
+ }
+ const res = await fetch(api("/api/create-issue"), {
+ method: "POST",
+ body: '{"repo":"microsoft/aspire.dev","prompt":"test"}',
+ });
+ assert.equal(res.status, 200);
+ assert.equal(messages.length, beforeMessages + 1);
+});
+
+test("revokes old browse capabilities when the outer UI explicitly selects a target", async () => {
+ const oldResource = resourceUrl(await browse());
+ const res = await fetch(api("/api/fetch?select=1&silent=1&u=" + encodeURIComponent(targetUrl)));
+ assert.equal((await res.json()).resolved.title, "Local preview");
+ assert.equal((await fetch(oldResource)).status, 403);
+ assert.equal((await fetch(resourceUrl(await browse()))).status, 200);
+});
+
+for (const route of ["document", "resource"]) {
+ test(`in-flight ${route} responses retain their revoked browse capability`, async () => {
+ const html = 'Delayed preview';
+ const started = Promise.withResolvers();
+ let heldResponse;
+ let pending;
+ const delayedTarget = createServer((req, res) => {
+ res.setHeader("Content-Type", "text/html");
+ if (req.url === "/delayed") {
+ heldResponse = res;
+ started.resolve();
+ return;
+ }
+ res.end(html);
+ });
+ delayedTarget.listen(0, "127.0.0.1");
+ await once(delayedTarget, "listening");
+ const origin = `http://127.0.0.1:${delayedTarget.address().port}`;
+ try {
+ const selection = await fetch(api("/api/fetch?select=1&silent=1&u=" + encodeURIComponent(origin)));
+ assert.equal((await selection.json()).resolved.title, "Delayed preview");
+ const initial = await fetch(api("/api/proxy?u=" + encodeURIComponent(origin)));
+ const oldResource = resourceUrl(await initial.text());
+ const delayedUrl = route === "document"
+ ? api("/api/proxy?u=" + encodeURIComponent(origin + "/delayed"))
+ : new URL(oldResource.href.replace("/external.js", "/delayed"));
+ pending = fetch(delayedUrl, { signal: AbortSignal.timeout(5000) }).then((res) => res.text());
+ await Promise.race([
+ started.promise,
+ pending.then(() => assert.fail("The upstream response must remain in flight.")),
+ ]);
+
+ const reselection = await fetch(api("/api/fetch?select=1&silent=1&u=" + encodeURIComponent(targetUrl)));
+ assert.equal((await reselection.json()).resolved.title, "Local preview");
+ const currentResource = resourceUrl(await browse());
+ heldResponse.end(html);
+ const delayedHtml = await pending;
+ assert.equal(resourceUrl(delayedHtml).href, oldResource.href);
+ const currentPrefix = currentResource.pathname.split("/api/proxy")[0];
+ assert.ok(!delayedHtml.includes(currentPrefix), "old content never receives the new capability");
+ assert.equal((await fetch(oldResource)).status, 403);
+ assert.equal((await fetch(currentResource)).status, 200);
+ } finally {
+ heldResponse?.end();
+ if (pending) await Promise.allSettled([pending]);
+ delayedTarget.closeAllConnections();
+ await new Promise((resolve) => delayedTarget.close(resolve));
+ }
+ });
+}
+
+test("returns fixed errors without paths, exception messages, or stack details", async () => {
+ const secret = "stack-path-sentinel";
+ for (const path of ["/api/fetch", "/api/raw", "/api/agent-readiness"]) {
+ const res = await fetch(api(`${path}?u=${encodeURIComponent("file:///" + secret)}`));
+ const body = await res.text();
+ assert.ok(!body.includes(secret));
+ assert.match(body, /error/);
+ }
+ const res = await fetch(api("/api/proxy?u=" + encodeURIComponent(targetUrl + "/redirect")));
+ const html = await res.text();
+ assert.match(html, /selected-origin access/);
+ assert.ok(!html.includes("Error:") && !html.includes(" at "));
+ const badType = await fetch(api("/api/fetch?u=" + encodeURIComponent(targetUrl + "/bad-type")));
+ const error = await badType.json();
+ assert.ok(error.error);
+ assert.ok(!error.error.includes("private-path-sentinel"));
+});
+
+test("rejects unrelated loopback origins for all fetch surfaces", async () => {
+ const url = encodeURIComponent("http://127.0.0.1:1/");
+ for (const path of ["/api/fetch", "/api/raw", "/api/img"]) {
+ const res = await fetch(api(`${path}?u=${url}`));
+ if (path === "/api/img") assert.equal(res.status, 502);
+ else assert.match(await res.text(), /error/);
+ }
+ const resource = resourceUrl(await browse());
+ resource.pathname = resource.pathname.replace(/\/http\/.*$/, "/http/127.0.0.1:1/");
+ assert.equal((await fetch(resource)).status, 502);
+});
+
+test("standalone get_metadata action explicitly authorizes localhost", async () => {
+ const action = canvas.actions.find((action) => action.name === "get_metadata");
+ const result = await action.handler({ input: { url: targetUrl } });
+ assert.equal(result.resolved.title, "Local preview");
+ const named = await action.handler({ input: { url: targetUrl.replace("127.0.0.1", "localhost") } });
+ assert.equal(named.resolved.title, "Local preview");
+});
diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js
index a50e935c0..48b603d61 100644
--- a/.github/extensions/og-preview/ui/app.js
+++ b/.github/extensions/og-preview/ui/app.js
@@ -1,5 +1,13 @@
"use strict";
+const previewKey = new URLSearchParams(location.hash.slice(1)).get("key") || "";
+function apiUrl(path) {
+ const url = new URL(path, location.origin);
+ if (url.origin !== location.origin) throw new Error("Invalid preview API origin.");
+ url.searchParams.set("key", previewKey);
+ return url.pathname + url.search;
+}
+
const TRANSPARENT =
"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
@@ -228,6 +236,9 @@ function withScheme(raw) {
let v = (raw || "").trim();
if (!v || v === "https://" || v === "http://") return "";
if (/^https?:\/\//i.test(v)) return v;
+ // Preserve explicit schemes so the fetch/navigation boundary can reject
+ // them, rather than disguising file: or data: input as an HTTPS hostname.
+ if (/^[a-z][a-z\d+.-]*:/i.test(v) && !/^[^:/]+:\d+(?:[/?#]|$)/.test(v)) return v;
v = v.replace(/^\/+/, "");
const isLocal = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|[^/]+\.local)(:|\/|$)/i.test(v);
return (isLocal ? "http://" : "https://") + v;
@@ -278,7 +289,7 @@ function makeImage(url, className) {
img.addEventListener("error", () => {
if (img.dataset.stage === "direct") {
img.dataset.stage = "proxy";
- img.src = "/api/img?u=" + encodeURIComponent(url);
+ img.src = apiUrl("/api/img?u=" + encodeURIComponent(url));
} else if (img.dataset.stage === "proxy") {
img.dataset.stage = "placeholder";
img.src = TRANSPARENT;
@@ -330,7 +341,7 @@ function showImgTip(url, x, y) {
imgTipImg.onerror = () => {
if (imgTipImg.dataset.stage === "direct") {
imgTipImg.dataset.stage = "proxy";
- imgTipImg.src = "/api/img?u=" + encodeURIComponent(real);
+ imgTipImg.src = apiUrl("/api/img?u=" + encodeURIComponent(real));
} else {
imgTipMeta.textContent = "Preview unavailable";
}
@@ -899,7 +910,7 @@ async function showCodeCard(url, node) {
let payload = codeCache.get(raw);
if (!payload) {
try {
- const res = await fetch("/api/raw?u=" + encodeURIComponent(raw));
+ const res = await fetch(apiUrl("/api/raw?u=" + encodeURIComponent(raw)));
payload = await res.json();
} catch {
payload = { error: "Couldn't load file." };
@@ -1621,7 +1632,7 @@ async function postAction(path, payload, btn, busyLabel, doneLabel) {
btn.classList.add("busy");
if (labelEl && busyLabel) labelEl.textContent = busyLabel;
try {
- const res = await fetch(path, {
+ const res = await fetch(apiUrl(path), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -1995,7 +2006,7 @@ async function refreshAgentReadiness(data) {
return;
}
try {
- const res = await fetch("/api/agent-readiness?u=" + encodeURIComponent(targetUrl));
+ const res = await fetch(apiUrl("/api/agent-readiness?u=" + encodeURIComponent(targetUrl)));
const ar = await res.json();
if (seq !== arSeq) return; // a newer load superseded this probe
if (!res.ok || ar.error) throw new Error(ar.error || `Request failed (${res.status})`);
@@ -2159,7 +2170,8 @@ async function load(rawUrl, opts) {
renderSkeleton();
try {
const res = await fetch(
- "/api/fetch?u=" + encodeURIComponent(url) + (silent ? "&silent=1" : ""),
+ apiUrl("/api/fetch?u=" + encodeURIComponent(url) +
+ (silent ? "&silent=1" : "") + (opts && opts.select ? "&select=1" : "")),
);
const data = await res.json();
if (!res.ok || data.error) {
@@ -2170,6 +2182,7 @@ async function load(rawUrl, opts) {
input.value = data.requestedUrl || url;
}
pendingBrowseUrl = data.requestedUrl || url;
+ if (opts && opts.select) browseFrameUrl = "";
if (!(opts && opts.skipBrowse)) syncBrowseFrame(pendingBrowseUrl);
document.title = data.requestedUrl
? `OG · ${(data.resolved && data.resolved.hostname) || data.requestedUrl}`
@@ -2195,7 +2208,7 @@ async function load(rawUrl, opts) {
$("#url-form").addEventListener("submit", (e) => {
e.preventDefault();
- load(input.value);
+ load(input.value, { select: true });
});
$("#refresh").addEventListener("click", () => {
if (browseActive()) {
@@ -2266,7 +2279,7 @@ const luckyGo = $("#lucky-go");
if (luckyGo) {
luckyGo.addEventListener("click", () => {
input.value = luckyTarget;
- load(luckyTarget);
+ load(luckyTarget, { select: true });
});
}
@@ -2287,7 +2300,7 @@ if (luckyEmpty) {
luckyEmpty.addEventListener("click", () => {
const url = pickLuckySite();
input.value = url;
- load(url);
+ load(url, { select: true });
});
}
@@ -2430,7 +2443,7 @@ async function navBrowseFrame(rawUrl) {
browsePanel.classList.add("has-browse");
const token = ++browseNavToken;
try {
- const res = await fetch("/api/proxy?u=" + encodeURIComponent(u));
+ const res = await fetch(apiUrl("/api/proxy?u=" + encodeURIComponent(u)));
const html = await res.text();
if (token !== browseNavToken) return; // a newer navigation superseded us
browseFrame.srcdoc = html;
@@ -2466,9 +2479,14 @@ $("#browse-open").addEventListener("click", () => {
const u = withScheme(input.value || pendingBrowseUrl);
if (!u) return;
try {
- window.open(u, "_blank", "noopener");
+ const target = new URL(u);
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
+ setStatus("error", "Only HTTP and HTTPS pages can be opened.");
+ return;
+ }
+ window.open(target.href, "_blank", "noopener");
} catch {
- /* host may block popups */
+ setStatus("error", "Couldn't open this URL.");
}
});
@@ -2477,6 +2495,7 @@ $("#browse-open").addEventListener("click", () => {
// top-level URL input, advance the embedded frame to the new page, and refresh
// every preview from it.
window.addEventListener("message", (e) => {
+ if (e.source !== browseFrame.contentWindow) return;
const m = e && e.data;
if (!m || m.source !== "og-browse" || m.type !== "nav" || !m.url) return;
if (!/^https?:\/\//i.test(m.url)) return; // ignore non-http targets (e.g. about:srcdoc)
@@ -2542,12 +2561,13 @@ try {
// Server-pushed loads (agent invoking the preview_url action).
try {
- const es = new EventSource("/events");
+ const es = new EventSource(apiUrl("/events"));
es.addEventListener("message", (e) => {
try {
const msg = JSON.parse(e.data);
if (msg && msg.type === "load" && msg.url) {
input.value = msg.url;
+ browseFrameUrl = "";
load(msg.url);
}
} catch {
diff --git a/.github/scripts/merge-main-into-release.sh b/.github/scripts/merge-main-into-release.sh
new file mode 100644
index 000000000..dca716029
--- /dev/null
+++ b/.github/scripts/merge-main-into-release.sh
@@ -0,0 +1,162 @@
+#!/usr/bin/env bash
+# Usage: merge-main-into-release.sh
+# Run from a clean release checkout. The caller owns fetching and pushing.
+set -euo pipefail
+
+MAIN_SHA="$(git rev-parse --verify "${1:?main ref required}^{commit}")"
+BRANCH="${2:?release branch required}"
+RELEASE_SHA="$(git rev-parse HEAD)"
+MERGE_BASE="$(git merge-base HEAD "$MAIN_SHA")"
+
+fail() {
+ echo "::error::$*" >&2
+ exit 1
+}
+
+[[ -z "$(git status --porcelain)" ]] || fail "Release checkout must be clean."
+if git rev-parse -q --verify MERGE_HEAD >/dev/null; then
+ fail "A merge is already in progress."
+fi
+
+report() {
+ printf '%s\n' "$@"
+ if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
+ printf '%s\n' "$@" >> "$GITHUB_STEP_SUMMARY"
+ fi
+}
+
+report "## Release sync: $BRANCH" \
+ "- Release SHA: \`$RELEASE_SHA\`" \
+ "- Main SHA: \`$MAIN_SHA\`" \
+ "- Merge base: \`$MERGE_BASE\`"
+
+ancestor_status=0
+git merge-base --is-ancestor "$MAIN_SHA" HEAD || ancestor_status=$?
+case "$ancestor_status" in
+ 0)
+ report "Main is already an ancestor of release; nothing to merge."
+ exit 0
+ ;;
+ 1) ;;
+ *) fail "Could not check main ancestry (exit $ancestor_status)." ;;
+esac
+
+generated_files=(
+ src/frontend/src/data/aspire-integrations.json
+ src/frontend/src/data/github-stats.json
+ src/frontend/src/data/samples.json
+ src/frontend/src/data/twoslash/aspire.d.ts
+)
+generated_dirs=(
+ src/frontend/src/data/pkgs
+ src/frontend/src/data/ts-modules
+ src/frontend/src/assets/samples
+)
+
+is_generated() {
+ local path
+ for path in "${generated_files[@]}"; do
+ [[ "$1" != "$path" ]] || return 0
+ done
+ for path in "${generated_dirs[@]}"; do
+ case "$1" in "$path"|"$path"/*) return 0 ;; esac
+ done
+ return 1
+}
+
+is_curated() {
+ case "$1" in
+ src/frontend/src/assets/samples/*) return 1 ;;
+ src/frontend/src/content/*|src/frontend/src/assets/*) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+
+keep_release() {
+ if git cat-file -e "$RELEASE_SHA:$1" 2>/dev/null; then
+ git checkout "$RELEASE_SHA" -- "$1"
+ else
+ git rm -q --force --ignore-unmatch -- "$1"
+ fi
+}
+
+conflicts_file="$(mktemp)"
+cleanup() {
+ local status=$?
+ if [[ "$status" -ne 0 ]] && git rev-parse -q --verify MERGE_HEAD >/dev/null; then
+ if ! git merge --abort; then
+ echo "::error::Could not abort the failed release merge." >&2
+ status=1
+ fi
+ fi
+ rm -f -- "$conflicts_file"
+ exit "$status"
+}
+trap cleanup EXIT
+
+merge_status=0
+git merge --no-commit --no-ff "$MAIN_SHA" || merge_status=$?
+if [[ "$merge_status" -gt 1 ]] || ! git rev-parse -q --verify MERGE_HEAD >/dev/null; then
+ fail "Git merge failed (exit $merge_status); main is not already integrated."
+fi
+
+git diff --name-only --diff-filter=U -z > "$conflicts_file"
+mapfile -d '' -t conflicts < "$conflicts_file"
+if [[ "$merge_status" -ne 0 && "${#conflicts[@]}" -eq 0 ]]; then
+ fail "Git merge failed without resolvable conflict entries (exit $merge_status)."
+fi
+
+unresolved=()
+for path in "${conflicts[@]}"; do
+ if is_generated "$path"; then
+ continue
+ elif is_curated "$path"; then
+ keep_release "$path"
+ else
+ unresolved+=("$path")
+ fi
+done
+
+if [[ "${#unresolved[@]}" -ne 0 ]]; then
+ report "" "### Conflicts requiring human review"
+ for path in "${unresolved[@]}"; do
+ report "- \`$path\`"
+ done
+ report "" \
+ "Create a repair branch from \`$BRANCH\`, merge main into it, and resolve these conflicts without discarding release-only work." \
+ "Land the repair PR with **Create a merge commit**, never squash or rebase. Copying content alone does not repair ancestry." \
+ "After landing, verify \`git merge-base --is-ancestor $MAIN_SHA origin/$BRANCH\`, then dispatch a fresh workflow run from main."
+ fail "Automated merge aborted: ${#unresolved[@]} conflicts require human review."
+fi
+
+# Only these producer-owned paths are mirrored. Other data is hand-authored.
+for path in "${generated_files[@]}" "${generated_dirs[@]}"; do
+ diff_status=0
+ git diff --quiet "$MAIN_SHA" -- "$path" || diff_status=$?
+ [[ "$diff_status" -le 1 ]] || fail "Could not compare generated path $path."
+ if [[ "$diff_status" -eq 0 && -z "$(git ls-files -u -- "$path")" ]]; then
+ continue
+ fi
+ git rm -r -q --force --ignore-unmatch -- "$path"
+ if git cat-file -e "$MAIN_SHA:$path" 2>/dev/null; then
+ git checkout "$MAIN_SHA" -- "$path"
+ fi
+done
+
+[[ -z "$(git ls-files -u)" ]] || fail "Unexpected unmerged entries remain."
+if ! markers="$(git diff --cached --check)"; then
+ [[ -n "$markers" ]] || fail "Could not check the merged diff."
+ if grep -q 'conflict marker' <<< "$markers"; then
+ fail "Conflict markers detected after auto-resolution."
+ fi
+ echo "::warning::Merged diff has whitespace warnings:"
+ printf '%s\n' "$markers"
+fi
+
+git commit --no-edit --trailer "Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>"
+git merge-base --is-ancestor "$MAIN_SHA" HEAD || fail "Merge did not preserve main ancestry; do not push."
+read -r commit first_parent second_parent extra <<< "$(git rev-list --parents -n 1 HEAD)"
+if [[ "$first_parent" != "$RELEASE_SHA" || "$second_parent" != "$MAIN_SHA" || -n "$extra" ]]; then
+ fail "Expected a two-parent release/main merge commit; do not push."
+fi
+report "" "Merged main into release with both parents preserved: \`$commit\`."
diff --git a/.github/scripts/test-merge-main-into-release.sh b/.github/scripts/test-merge-main-into-release.sh
new file mode 100644
index 000000000..cce4b78e0
--- /dev/null
+++ b/.github/scripts/test-merge-main-into-release.sh
@@ -0,0 +1,210 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+export GIT_CONFIG_NOSYSTEM=1
+export GIT_CONFIG_GLOBAL=/dev/null
+unset GITHUB_STEP_SUMMARY
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+HELPER="$SCRIPT_DIR/merge-main-into-release.sh"
+TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/release-sync-tests.XXXXXX")"
+trap 'cd "$SCRIPT_DIR"; rm -rf -- "$TEST_ROOT"' EXIT
+
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+
+write_file() {
+ mkdir -p -- "$(dirname -- "$1")"
+ printf '%s\n' "$2" > "$1"
+}
+
+commit_all() {
+ git add -A
+ git commit -qm "$1"
+}
+
+new_repo() {
+ mkdir "$TEST_ROOT/$1"
+ cd "$TEST_ROOT/$1"
+ git init -q --initial-branch=main
+ git config user.name "Release sync test"
+ git config user.email "release-sync@example.invalid"
+ git config commit.gpgsign false
+ git config core.autocrlf false
+ write_file shared.txt base
+ commit_all base
+ git switch -qc release/test
+}
+
+run_helper() {
+ if bash "$HELPER" main release/test > "$TEST_ROOT/output" 2>&1; then
+ return 0
+ else
+ cat "$TEST_ROOT/output" >&2
+ return 1
+ fi
+}
+
+expect_failure() {
+ if bash "$HELPER" main release/test > "$TEST_ROOT/output" 2>&1; then
+ fail "Expected merge failure."
+ fi
+ grep -q "$1" "$TEST_ROOT/output" || { cat "$TEST_ROOT/output"; fail "Missing expected diagnostic: $1"; }
+ [[ "$(git rev-parse HEAD)" == "$release_sha" ]] || fail "Failed merge changed release HEAD."
+ [[ -z "$(git status --porcelain)" ]] || fail "Failed merge left a dirty checkout."
+ if git rev-parse -q --verify MERGE_HEAD >/dev/null; then
+ fail "Failed merge was not aborted."
+ fi
+}
+
+assert_parents() {
+ [[ "$(git show -s --format=%P HEAD)" == "$release_sha $(git rev-parse main)" ]] || fail "Merge parents differ."
+ git merge-base --is-ancestor main HEAD || fail "Main ancestry was lost."
+}
+
+new_repo no-op
+release_sha="$(git rev-parse HEAD)"
+run_helper
+[[ "$(git rev-parse HEAD)" == "$release_sha" ]] || fail "No-op created a commit."
+grep -q "already an ancestor" "$TEST_ROOT/output" || fail "Missing no-op diagnostic."
+echo "PASS: ancestor no-op"
+
+new_repo clean-merge
+write_file release.txt release
+commit_all release
+release_sha="$(git rev-parse HEAD)"
+git switch -q main
+write_file main.txt main
+commit_all main
+git switch -q release/test
+run_helper
+assert_parents
+[[ -f main.txt && -f release.txt ]] || fail "Clean merge lost files."
+echo "PASS: clean merge preserves both parents"
+
+new_repo path-policies
+generated_files=(
+ src/frontend/src/data/aspire-integrations.json
+ src/frontend/src/data/github-stats.json
+ src/frontend/src/data/samples.json
+ src/frontend/src/data/twoslash/aspire.d.ts
+)
+generated_dirs=(
+ src/frontend/src/data/pkgs
+ src/frontend/src/data/ts-modules
+ src/frontend/src/assets/samples
+)
+curated=src/frontend/src/content/docs/curated.mdx
+deleted=src/frontend/src/content/docs/deleted.mdx
+asset=src/frontend/src/assets/dashboard.txt
+for path in "${generated_files[@]}" "$curated" "$deleted" "$asset"; do
+ write_file "$path" base
+done
+for path in "${generated_dirs[@]}"; do
+ write_file "$path/base-version.txt" base
+done
+commit_all shared-paths
+git switch -q main
+git merge -q --ff-only release/test
+for path in "${generated_files[@]}" "$curated" "$deleted" "$asset"; do
+ write_file "$path" main
+done
+git rm -q -f -- src/frontend/src/data/samples.json
+for path in "${generated_dirs[@]}"; do
+ git mv "$path/base-version.txt" "$path/main-version.txt"
+done
+write_file src/frontend/src/content/docs/new.mdx "nonconflicting main content"
+commit_all main-paths
+git switch -q release/test
+for path in "${generated_files[@]}" "$curated" "$asset"; do
+ write_file "$path" release
+done
+git rm -q -- "$deleted"
+for path in "${generated_dirs[@]}"; do
+ git mv "$path/base-version.txt" "$path/release-version.txt"
+done
+write_file src/frontend/src/data/hand-authored.json release
+commit_all release-paths
+release_sha="$(git rev-parse HEAD)"
+run_helper
+assert_parents
+git diff --exit-code main HEAD -- "${generated_files[@]}" "${generated_dirs[@]}"
+[[ "$(cat "$curated")" == release && "$(cat "$asset")" == release ]] || fail "Curated content was overwritten."
+[[ ! -e "$deleted" ]] || fail "Release-side deletion was lost."
+[[ -f src/frontend/src/content/docs/new.mdx ]] || fail "Nonconflicting content did not merge."
+[[ "$(cat src/frontend/src/data/hand-authored.json)" == release ]] || fail "Hand-authored data was overwritten."
+echo "PASS: generated mirroring, curated conflicts/deletions, nonconflicting content"
+
+new_repo unknown-conflict
+write_file shared.txt release
+commit_all release
+release_sha="$(git rev-parse HEAD)"
+git switch -q main
+write_file shared.txt main
+commit_all main
+git switch -q release/test
+export GITHUB_STEP_SUMMARY="$TEST_ROOT/summary"
+expect_failure "conflicts require human review"
+grep -q 'shared.txt' "$GITHUB_STEP_SUMMARY" || fail "Summary omitted conflict path."
+grep -q 'never squash or rebase' "$GITHUB_STEP_SUMMARY" || fail "Summary omitted ancestry guidance."
+unset GITHUB_STEP_SUMMARY
+echo "PASS: unknown conflict aborts with recovery instructions"
+
+new_repo fatal-merge
+release_sha="$(git rev-parse HEAD)"
+git switch -q main
+write_file main.txt main
+commit_all main
+git switch -q release/test
+# Refuse Git's index lock without introducing tracked or untracked changes.
+write_file .git/index.lock locked
+expect_failure "Git merge failed"
+[[ ! "$(cat "$TEST_ROOT/output")" == *"nothing to merge"* ]] || fail "Fatal error was reported as a no-op."
+rm -- .git/index.lock
+echo "PASS: fatal merge without MERGE_HEAD is not a no-op"
+
+new_repo conflict-markers
+release_sha="$(git rev-parse HEAD)"
+git switch -q main
+write_file marker.txt '<<<<<<< HEAD'
+commit_all markers
+git switch -q release/test
+expect_failure "Conflict markers detected"
+echo "PASS: conflict marker rejection"
+
+new_repo squash-ancestry
+git switch -q main
+write_file shared.txt first-main
+commit_all first-main
+git switch -q release/test
+git merge -q --squash main
+git commit -qm squash-repair
+if git merge-base --is-ancestor main HEAD; then
+ fail "Squash fixture unexpectedly preserved ancestry."
+fi
+write_file shared.txt release-after-squash
+commit_all release-after-squash
+release_sha="$(git rev-parse HEAD)"
+git switch -q main
+write_file shared.txt second-main
+commit_all second-main
+git switch -q release/test
+expect_failure "conflicts require human review"
+# Reconcile deliberately as a human would, retaining both parent histories.
+merge_status=0
+git merge --no-commit --no-ff main > "$TEST_ROOT/manual-merge" 2>&1 || merge_status=$?
+[[ "$merge_status" == 1 ]] || fail "Expected a manual repair conflict."
+write_file shared.txt reconciled
+commit_all real-repair
+git merge-base --is-ancestor main HEAD || fail "Real repair did not establish ancestry."
+git switch -q main
+write_file next.txt next
+commit_all next-main
+git switch -q release/test
+release_sha="$(git rev-parse HEAD)"
+run_helper
+assert_parents
+[[ "$(cat shared.txt)" == reconciled && -f next.txt ]] || fail "Next sync revisited repaired content."
+echo "PASS: real repair avoids repeating squash-era conflicts"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5b80614e2..070a50454 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -102,8 +102,19 @@ jobs:
if: ${{ needs.changes.outputs.apphost == 'true' }}
uses: ./.github/workflows/apphost-build.yml
+ release-sync-tests:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Test release merge policy and ancestry
+ shell: bash
+ run: |
+ bash -n .github/scripts/merge-main-into-release.sh
+ bash -n .github/scripts/test-merge-main-into-release.sh
+ bash .github/scripts/test-merge-main-into-release.sh
+
ci-gate:
- needs: [changes, frontend-build, apphost-build]
+ needs: [changes, frontend-build, apphost-build, release-sync-tests]
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
steps:
@@ -115,12 +126,19 @@ jobs:
APPHOST_CHANGED: ${{ needs.changes.outputs.apphost }}
FRONTEND_RESULT: ${{ needs['frontend-build'].result }}
APPHOST_RESULT: ${{ needs['apphost-build'].result }}
+ RELEASE_SYNC_RESULT: ${{ needs['release-sync-tests'].result }}
run: |
echo "changes result: $CHANGES_RESULT"
echo "frontend changed: $FRONTEND_CHANGED"
echo "frontend-build result: $FRONTEND_RESULT"
echo "apphost changed: $APPHOST_CHANGED"
echo "apphost-build result: $APPHOST_RESULT"
+ echo "release-sync-tests result: $RELEASE_SYNC_RESULT"
+
+ if [[ "$RELEASE_SYNC_RESULT" != "success" ]]; then
+ echo "The release sync tests must succeed."
+ exit 1
+ fi
if [[ "$CHANGES_RESULT" != "success" ]]; then
echo "The changes job must succeed."
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 000000000..0d5c0a0c9
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,54 @@
+name: CodeQL
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ schedule:
+ - cron: "23 7 * * 3"
+ workflow_dispatch:
+
+permissions:
+ actions: read
+ contents: read
+ packages: read
+ security-events: write
+
+jobs:
+ analyze:
+ name: Analyze (${{ matrix.language }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - language: javascript-typescript
+ build-mode: none
+ - language: csharp
+ build-mode: manual
+
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Setup .NET
+ if: matrix.language == 'csharp'
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
+ with:
+ global-json-file: global.json
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
+ with:
+ languages: ${{ matrix.language }}
+ build-mode: ${{ matrix.build-mode }}
+
+ - name: Build C# solution
+ if: matrix.language == 'csharp'
+ # CodeQL needs compiler extraction, not the Aspire orchestration bundle.
+ run: dotnet build Aspire.Dev.slnx --configuration Release -p:AspireUseCliBundle=false
+
+ - name: Perform CodeQL analysis
+ uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
+ with:
+ category: "/language:${{ matrix.language }}"
diff --git a/.github/workflows/og-preview-tests.yml b/.github/workflows/og-preview-tests.yml
new file mode 100644
index 000000000..19431e5e7
--- /dev/null
+++ b/.github/workflows/og-preview-tests.yml
@@ -0,0 +1,25 @@
+name: OG preview tests
+
+on:
+ pull_request:
+ paths:
+ - ".github/extensions/og-preview/**"
+ - ".github/workflows/og-preview-tests.yml"
+ push:
+ branches: [main]
+ paths:
+ - ".github/extensions/og-preview/**"
+ - ".github/workflows/og-preview-tests.yml"
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: "24.x"
+ - run: node --test .github/extensions/og-preview/tests/*.test.mjs
diff --git a/.github/workflows/update-release-branch.yml b/.github/workflows/update-release-branch.yml
index 7ec4b09be..5c6dcfcf3 100644
--- a/.github/workflows/update-release-branch.yml
+++ b/.github/workflows/update-release-branch.yml
@@ -84,130 +84,8 @@ jobs:
git fetch origin main
MAIN_SHA="$(git rev-parse origin/main)"
- # The release branch is a curated snapshot of the shipped site. Merging
- # main into it conflicts in three, and only three, well-understood ways:
- # 1. Generated data -> main is authoritative (mirror it).
- # 2. Curated content/imagery -> the release branch is authoritative.
- # 3. Anything else -> a human must decide; abort cleanly.
- #
- # (1) is the exact output set of the update-integration-data workflow
- # (its `allowed-files`); those paths are 100% machine-generated, so main
- # always wins and mirroring also clears the version-stamped rename/rename
- # orphans they routinely produce. (2) is site content plus documentation
- # imagery, which the release branch pins to release-specific versions (the
- # "Aspire 13.5 is available" banner, 13.5 dashboard screenshots, ...) and
- # must never regress. (3) is a genuine divergence we refuse to guess at.
-
- GEN_FILES="
- src/frontend/src/data/aspire-integrations.json
- src/frontend/src/data/github-stats.json
- src/frontend/src/data/samples.json
- src/frontend/src/data/twoslash/aspire.d.ts
- "
- GEN_DIRS="
- src/frontend/src/data/pkgs
- src/frontend/src/data/ts-modules
- src/frontend/src/assets/samples
- "
-
- is_generated() {
- case "$1" in
- src/frontend/src/data/aspire-integrations.json|\
- src/frontend/src/data/github-stats.json|\
- src/frontend/src/data/samples.json|\
- src/frontend/src/data/twoslash/aspire.d.ts|\
- src/frontend/src/data/pkgs/*|\
- src/frontend/src/data/ts-modules/*|\
- src/frontend/src/assets/samples/*) return 0 ;;
- *) return 1 ;;
- esac
- }
-
- # assets/samples/** is generated and is matched by is_generated first;
- # it is excluded here defensively in case the checks are reordered.
- is_curated() {
- case "$1" in
- src/frontend/src/assets/samples/*) return 1 ;;
- src/frontend/src/content/*) return 0 ;;
- src/frontend/src/assets/*) return 0 ;;
- *) return 1 ;;
- esac
- }
-
- # Keep the release branch's version, honoring a release-side deletion.
- keep_release() {
- if git cat-file -e "HEAD:$1" 2>/dev/null; then
- git checkout HEAD -- "$1"
- git add -- "$1"
- else
- git rm -q --force --ignore-unmatch -- "$1" >/dev/null
- fi
- }
-
- # A generated path needs syncing when it has a conflict stage or its
- # merged content differs from main.
- needs_mirror() {
- [ -n "$(git ls-files -u -- "$1")" ] && return 0
- git diff --quiet "$MAIN_SHA" -- "$1" 2>/dev/null || return 0
- return 1
- }
-
- git merge --no-commit --no-ff origin/main || true
-
- if ! git rev-parse -q --verify MERGE_HEAD >/dev/null; then
- echo "Already up to date with origin/main; nothing to merge."
- exit 0
- fi
-
- # Triage conflicts: generated ones are handled by the mirror step below;
- # keep-release for curated paths; flag anything else as unresolvable.
- unresolved=0
- while IFS= read -r f; do
- [ -z "$f" ] && continue
- if is_generated "$f"; then
- continue
- elif is_curated "$f"; then
- keep_release "$f"
- else
- echo "::error::Unresolvable merge conflict in ${f} (not a generated or curated path)."
- unresolved=1
- fi
- done < <(git diff --name-only --diff-filter=U)
-
- if [ "$unresolved" -ne 0 ]; then
- git merge --abort
- echo "::error::Automated merge of main into ${BRANCH} aborted; a human must resolve the conflicts above."
- exit 1
- fi
-
- # Force every generated producer path to match main exactly. This clears
- # remaining generated conflicts and drops stale version-stamped orphans,
- # without ever touching hand-authored files under data/.
- for p in $GEN_FILES $GEN_DIRS; do
- if git cat-file -e "$MAIN_SHA:$p" 2>/dev/null; then
- if needs_mirror "$p"; then
- git rm -r -q --force --ignore-unmatch -- "$p" >/dev/null 2>&1 || true
- git checkout "$MAIN_SHA" -- "$p"
- git add -A -- "$p"
- fi
- else
- git rm -r -q --force --ignore-unmatch -- "$p" >/dev/null 2>&1 || true
- fi
- done
-
- if [ -n "$(git ls-files -u)" ]; then
- git merge --abort
- echo "::error::Unexpected unmerged entries remain after auto-resolution; aborting."
- exit 1
- fi
-
- markers="$(git diff --cached --check || true)"
- if printf '%s' "$markers" | grep -q 'conflict marker'; then
- git merge --abort
- echo "::error::Conflict markers detected after auto-resolution; aborting."
- exit 1
- fi
-
- git commit --no-edit
- echo "Merged origin/main into ${BRANCH} with automated conflict resolution."
- git push
+ # Checkout is on release; always run the helper from the pinned main
+ # commit, including when release has not received the helper yet.
+ git show "$MAIN_SHA:.github/scripts/merge-main-into-release.sh" > "$RUNNER_TEMP/merge-main-into-release.sh"
+ bash "$RUNNER_TEMP/merge-main-into-release.sh" "$MAIN_SHA" "$BRANCH"
+ git push origin "HEAD:refs/heads/$BRANCH"
diff --git a/src/frontend/astro.config.mjs b/src/frontend/astro.config.mjs
index 73d95a3ff..4d9523fa8 100644
--- a/src/frontend/astro.config.mjs
+++ b/src/frontend/astro.config.mjs
@@ -3,7 +3,6 @@ import { defineConfig } from 'astro/config';
import { unified } from '@astrojs/markdown-remark';
import { sidebarTopics } from './config/sidebar/sidebar.topics.ts';
import { redirects } from './config/redirects.mjs';
-import { iconPacks } from './config/icon-packs.mjs';
import { locales } from './config/locales.ts';
import { headAttrs } from './config/head.attrs.ts';
import { socialConfig } from './config/socials.config.ts';
@@ -11,16 +10,15 @@ import { aspireProject } from './src/data/aspire-project.ts';
import { aspireVersionPlaceholdersIntegration } from './config/aspire-version-placeholders-integration.mjs';
import { remarkAspireVersionPlaceholders } from './config/remark-aspire-version-placeholders.mjs';
import { remarkTypeScriptFirstAppHostTabs } from './config/remark-typescript-first-apphost-tabs.mjs';
+import { remarkMermaid } from './config/remark-mermaid.mjs';
import catppuccin from '@catppuccin/starlight';
import lunaria from './config/lunaria-starlight.mjs';
-import mermaid from 'astro-mermaid';
import mdx from '@astrojs/mdx';
import starlightGitHubAlerts from 'starlight-github-alerts';
import starlightImageZoom from 'starlight-image-zoom';
import starlightKbd from 'starlight-kbd';
import starlightLinksValidator from 'starlight-links-validator';
import starlightLlmsTxt from 'starlight-llms-txt';
-import starlightScrollToTop from 'starlight-scroll-to-top';
import starlightSidebarTopics from 'starlight-sidebar-topics';
import starlightPageActions from 'starlight-page-actions';
import buildTiming from './config/build-timing.mjs';
@@ -62,13 +60,27 @@ export default defineConfig({
// Resolve filesystem-backed redirects before prerender modules are bundled.
__ASPIRE_REDIRECT_PATHS__: JSON.stringify(Object.keys(redirects)),
},
+ ...(staticHostUrl
+ ? {
+ server: {
+ proxy: {
+ // Bypass Astro's trailing-slash routing for JSON and SSE.
+ '^/api/live(?:/.*)?$': {
+ target: staticHostUrl,
+ changeOrigin: true,
+ secure: false,
+ },
+ },
+ },
+ }
+ : {}),
},
prefetch: true,
site: 'https://aspire.dev',
trailingSlash: 'always',
markdown: {
processor: unified({
- remarkPlugins: [remarkTypeScriptFirstAppHostTabs, remarkAspireVersionPlaceholders],
+ remarkPlugins: [remarkTypeScriptFirstAppHostTabs, remarkAspireVersionPlaceholders, remarkMermaid],
}),
},
redirects: redirects,
@@ -144,31 +156,6 @@ export default defineConfig({
'/hub/', '/hub/glossary/ats/', '/get-started/glossary/#polyglot',
],
}),
- starlightScrollToTop({
- // https://frostybee.github.io/starlight-scroll-to-top/svg-paths/
- svgPath: 'M4 16L12 8L20 16',
- showTooltip: true,
- threshold: 10,
- showOnHomepage: true,
- svgStrokeWidth: 4,
- tooltipText: {
- da: 'Rul op',
- de: 'Nach oben scrollen',
- en: 'Scroll to top',
- es: 'Ir arriba',
- fr: 'Retour en haut',
- hi: 'ऊपर स्क्रॉल करें',
- id: 'Gulir ke atas',
- it: 'Torna su',
- ja: 'トップへ戻る',
- ko: '맨 위로',
- 'pt-br': 'Voltar ao topo',
- ru: 'Наверх',
- tr: 'Başa dön',
- uk: 'Прокрутити вгору',
- 'zh-cn': '回到顶部',
- },
- }),
starlightGitHubAlerts(),
starlightLlmsTxt({
projectName: 'Aspire',
@@ -240,11 +227,6 @@ export default defineConfig({
],
},
}),
- mermaid({
- theme: 'forest',
- autoTheme: true,
- iconPacks,
- }),
mdx({
optimize: true,
gfm: true,
@@ -255,21 +237,4 @@ export default defineConfig({
build: {
concurrency: buildConcurrency,
},
- ...(staticHostUrl
- ? {
- vite: {
- server: {
- proxy: {
- // A regular-expression context bypasses Astro's trailing-slash
- // routing for both the JSON snapshot and SSE stream.
- '^/api/live(?:/.*)?$': {
- target: staticHostUrl,
- changeOrigin: true,
- secure: false,
- },
- },
- },
- },
- }
- : {}),
});
diff --git a/src/frontend/config/icon-packs.mjs b/src/frontend/config/icon-packs.mjs
index 3e852800a..10df0c11e 100644
--- a/src/frontend/config/icon-packs.mjs
+++ b/src/frontend/config/icon-packs.mjs
@@ -1,6 +1,5 @@
// Icon packs configuration for Mermaid diagrams.
-// astro-mermaid now serializes icon packs as name/url pairs, so custom packs
-// must be served from a static JSON endpoint instead of an inline loader.
+// Loaded lazily by src/scripts/mermaid.ts.
export const iconPacks = [
{
diff --git a/src/frontend/config/remark-mermaid.mjs b/src/frontend/config/remark-mermaid.mjs
new file mode 100644
index 000000000..77efff657
--- /dev/null
+++ b/src/frontend/config/remark-mermaid.mjs
@@ -0,0 +1,24 @@
+/** Turn opted-in fences into plain pre elements before code highlighting. */
+export function remarkMermaid() {
+ return (tree) => {
+ function visit(parent) {
+ for (const [index, node] of parent.children.entries()) {
+ if (node.type === 'code' && node.lang === 'mermaid') {
+ parent.children[index] = {
+ type: 'paragraph',
+ children: [],
+ data: {
+ hName: 'pre',
+ hProperties: { className: ['mermaid'] },
+ hChildren: [{ type: 'text', value: node.value }],
+ },
+ position: node.position,
+ };
+ } else if (Array.isArray(node.children)) {
+ visit(node);
+ }
+ }
+ }
+ visit(tree);
+ };
+}
diff --git a/src/frontend/package.json b/src/frontend/package.json
index 981891ff3..f22d80ea0 100644
--- a/src/frontend/package.json
+++ b/src/frontend/package.json
@@ -82,7 +82,6 @@
"astro": "^7.2.8",
"astro-contributors": "^0.9.0",
"astro-expressive-code": "^0.44.1",
- "astro-mermaid": "^2.1.0",
"astro-tooltips": "^0.6.2",
"hast-util-to-html": "^9.0.5",
"mdast-util-to-hast": "^13.2.1",
@@ -98,7 +97,6 @@
"starlight-llms-txt": "^0.11.0",
"starlight-page-actions": "^0.7.0",
"starlight-plugin-icons": "^1.1.6",
- "starlight-scroll-to-top": "^1.0.1",
"starlight-sidebar-topics": "^0.8.0"
},
"devDependencies": {
diff --git a/src/frontend/pnpm-lock.yaml b/src/frontend/pnpm-lock.yaml
index 278471734..d88ad5e24 100644
--- a/src/frontend/pnpm-lock.yaml
+++ b/src/frontend/pnpm-lock.yaml
@@ -107,9 +107,6 @@ importers:
astro-expressive-code:
specifier: ^0.44.1
version: 0.44.1(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))
- astro-mermaid:
- specifier: ^2.1.0
- version: 2.1.0(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(mermaid@11.16.1)
astro-tooltips:
specifier: ^0.6.2
version: 0.6.2
@@ -155,9 +152,6 @@ importers:
starlight-plugin-icons:
specifier: ^1.1.6
version: 1.1.6(patch_hash=ecdc00afa0722d3cdc77871f9debee551f67d1c02d82fea11c5aa41bf0f48a54)(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)(unocss@66.7.5(@unocss/astro@66.7.5(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(zod@4.4.3)
- starlight-scroll-to-top:
- specifier: ^1.0.1
- version: 1.0.1(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))
starlight-sidebar-topics:
specifier: ^0.8.0
version: 0.8.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))
@@ -1995,16 +1989,6 @@ packages:
peerDependencies:
astro: ^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0
- astro-mermaid@2.1.0:
- resolution: {integrity: sha512-fFRUN0BTZh+DZhDiLyblXoO26XqJ1Rr+qK3JGgSu7OBspKHDm59jkztg/aHsrdo1vO/tIq/+xhP/vgT8Mp92XA==}
- peerDependencies:
- '@mermaid-js/layout-elk': ^0.2.0
- astro: '>=4'
- mermaid: ^10.0.0 || ^11.0.0
- peerDependenciesMeta:
- '@mermaid-js/layout-elk':
- optional: true
-
astro-tooltips@0.6.2:
resolution: {integrity: sha512-I9uXbchctnRqbc0mnxKcBRfweMuql/U+619+MzNvq3kANc7xthOXj6cMNgAkTaXoHJLdFMKL3Fx6vB5cyiiRXg==}
@@ -3809,12 +3793,6 @@ packages:
unocss: '>=0.58.0'
zod: '>=3.22.0 || >=4.0.0'
- starlight-scroll-to-top@1.0.1:
- resolution: {integrity: sha512-OkSVwEQBzHkYPgBYL/O9xSiFn75j6HvrmjvdL8+3Kk0oxgt4sNmkOwhB0bGjB/T7M7SAJ4AFk1Ojza1HVxOeHQ==}
- engines: {node: '>=22.12.0'}
- peerDependencies:
- '@astrojs/starlight': '>=0.38.0'
-
starlight-sidebar-topics@0.8.0:
resolution: {integrity: sha512-hE8+w2vNL13h6cq3UJGl05milJQ3+1BSlhaV5V4a993YzYczU6uLAj6jfUDKQ2mmgkdVWp8/UNDF4Je/rQXNQw==}
engines: {node: '>=22.12.0'}
@@ -6102,14 +6080,6 @@ snapshots:
rehype-expressive-code: 0.44.1
url-extras: 0.1.0
- astro-mermaid@2.1.0(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(mermaid@11.16.1):
- dependencies:
- astro: 7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)
- import-meta-resolve: 4.2.0
- mdast-util-to-string: 4.0.0
- mermaid: 11.16.1
- unist-util-visit: 5.1.0
-
astro-tooltips@0.6.2:
dependencies:
tippy.js: 6.3.7
@@ -8641,10 +8611,6 @@ snapshots:
unocss: 66.7.5(@unocss/astro@66.7.5(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))
zod: 4.4.3
- starlight-scroll-to-top@1.0.1(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)):
- dependencies:
- '@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)
-
starlight-sidebar-topics@0.8.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)):
dependencies:
'@astrojs/starlight': 0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3)
diff --git a/src/frontend/public/scripts/analytics/1ds.js b/src/frontend/public/scripts/analytics/1ds.js
index 6c1ebc9fc..7829b16f7 100644
--- a/src/frontend/public/scripts/analytics/1ds.js
+++ b/src/frontend/public/scripts/analytics/1ds.js
@@ -2,7 +2,6 @@
(function () {
if (typeof location !== 'undefined' && location.origin !== 'https://aspire.dev') {
- console.debug('[1ds] Skipping load for origin:', location.origin);
return;
}
@@ -11,7 +10,6 @@
}
if (window.analytics && window.analytics.__initialized) {
- console.debug('[1ds] Already initialized, skipping.');
return;
}
@@ -21,6 +19,7 @@
{
instrumentationKey:
'1c6ad99c3e274af7881b9c3c78eed459-573e6b44-ab25-4e60-97ad-7b7f38f0243a-6923',
+ disablePageUnloadEvents: ['unload'],
channelConfiguration: { eventsLimitInMem: 50 },
propertyConfiguration: { env: 'PROD' },
webAnalyticsConfiguration: {
@@ -42,6 +41,6 @@
analytics.__initialized = true;
window.analytics = analytics;
} catch (err) {
- console.debug('[1ds] Failed to initialize Application Insights:', err);
+ console.warn('[1ds] Failed to initialize Application Insights:', err);
}
})();
diff --git a/src/frontend/public/scripts/analytics/track.js b/src/frontend/public/scripts/analytics/track.js
index 2f570a065..ade61441c 100644
--- a/src/frontend/public/scripts/analytics/track.js
+++ b/src/frontend/public/scripts/analytics/track.js
@@ -2,12 +2,10 @@
(function () {
if (!window.analytics || !window.analytics.__initialized) {
- console.debug('[track] Analytics not initialized, skipping event tracking setup.');
return;
}
if (window.analytics.__trackingBound) {
- console.debug('[track] Event tracking already bound, skipping.');
return;
}
@@ -42,11 +40,9 @@
try {
window.analytics.capturePageAction(target, overrides);
- console.debug('[track] Event tracked:', eventName, overrides);
} catch (err) {
- console.debug('[track] Failed to track event:', err);
+ console.warn('[track] Failed to track event:', err);
}
});
- console.debug('[track] Event tracking bound.');
})();
diff --git a/src/frontend/src/components/InstallCliModal.astro b/src/frontend/src/components/InstallCliModal.astro
index b69be4701..9e7622674 100644
--- a/src/frontend/src/components/InstallCliModal.astro
+++ b/src/frontend/src/components/InstallCliModal.astro
@@ -175,9 +175,15 @@ const channelOptions: CustomSelectOption[] = [
return 'release';
}
+ let modalListeners: AbortController | undefined;
+
function initializeModal() {
+ modalListeners?.abort();
+ modalListeners = undefined;
const modal = document.getElementById('install-cli-modal') as HTMLDialogElement;
if (!modal) return;
+ modalListeners = new AbortController();
+ const { signal } = modalListeners;
const versionSelect = modal.querySelector('#version-select') as HTMLSelectElement;
const openBtns = document.querySelectorAll('[data-open-install-modal]');
@@ -227,53 +233,44 @@ const channelOptions: CustomSelectOption[] = [
versionSelect.addEventListener('change', (e) => {
const quality = (e.target as HTMLSelectElement).value as 'release' | 'staging' | 'dev';
updateVersionDisplay(quality);
- });
+ }, { signal });
}
// Open modal and position it
openBtns.forEach((openBtn) => {
- if (openBtn.dataset.installModalBound === 'true') {
- return;
- }
-
- openBtn.dataset.installModalBound = 'true';
openBtn.addEventListener('click', (e) => {
e.preventDefault();
openModal(openBtn);
- });
+ }, { signal });
});
// Close modal handlers
- closeBtn?.addEventListener('click', () => modal.close());
+ closeBtn?.addEventListener('click', () => modal.close(), { signal });
// Close on backdrop click
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.close();
- });
+ }, { signal });
// Close on Escape key
modal.addEventListener('keydown', (e) => {
if (e.key === 'Escape') modal.close();
- });
+ }, { signal });
// Close modal if window is resized below mobile breakpoint
window.addEventListener('resize', () => {
if (window.innerWidth < 800 && modal.open) modal.close();
- });
+ }, { signal });
}
- // Handle install button clicks via event delegation (works across page navigations)
- document.addEventListener('click', (e) => {
- const target = e.target as HTMLElement;
- const openBtn = target.closest('[data-open-install-modal]');
- if (!openBtn) return;
-
- e.preventDefault();
+ document.addEventListener('astro:before-swap', () => {
+ modalListeners?.abort();
+ modalListeners = undefined;
});
// Initialize on page load
if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', initializeModal);
+ document.addEventListener('DOMContentLoaded', initializeModal, { once: true });
} else {
initializeModal();
}
diff --git a/src/frontend/src/components/PivotSelector.astro b/src/frontend/src/components/PivotSelector.astro
index 4c88adf41..68c50de48 100644
--- a/src/frontend/src/components/PivotSelector.astro
+++ b/src/frontend/src/components/PivotSelector.astro
@@ -23,8 +23,9 @@ const renderedOptions =
: options;
---
-
@@ -73,7 +74,7 @@ const renderedOptions =
stroke-linejoin="round">
-
+
-
diff --git a/src/frontend/src/components/ScrollToTop.astro b/src/frontend/src/components/ScrollToTop.astro
new file mode 100644
index 000000000..d74f1ae0c
--- /dev/null
+++ b/src/frontend/src/components/ScrollToTop.astro
@@ -0,0 +1,184 @@
+---
+const labels: Record = {
+ da: 'Rul op',
+ de: 'Nach oben scrollen',
+ en: 'Scroll to top',
+ es: 'Ir arriba',
+ fr: 'Retour en haut',
+ hi: 'ऊपर स्क्रॉल करें',
+ id: 'Gulir ke atas',
+ it: 'Torna su',
+ ja: 'トップへ戻る',
+ ko: '맨 위로',
+ 'pt-br': 'Voltar ao topo',
+ ru: 'Наверх',
+ tr: 'Başa dön',
+ uk: 'Прокрутити вгору',
+ 'zh-cn': '回到顶部',
+};
+const language = (Astro.locals.starlightRoute?.lang ?? 'en').toLowerCase();
+const label = labels[language] ?? labels[language.split('-')[0]] ?? labels.en;
+---
+
+
+
+
+
+
+
+
diff --git a/src/frontend/src/components/pivot-selector.ts b/src/frontend/src/components/pivot-selector.ts
new file mode 100644
index 000000000..4899d3b4e
--- /dev/null
+++ b/src/frontend/src/components/pivot-selector.ts
@@ -0,0 +1,294 @@
+import { getStoredPreference, setStoredPreference } from '@utils/browser-storage';
+
+const headingSelector = 'main h1, main h2, main h3, main h4, main h5, main h6';
+
+function findScrollAnchor() {
+ let nearest: HTMLElement | undefined;
+ let minDistance = Infinity;
+ for (const heading of document.querySelectorAll(headingSelector)) {
+ if (heading.offsetParent === null) continue;
+ const distance = -heading.getBoundingClientRect().top;
+ if (distance >= -100 && distance < minDistance) {
+ minDistance = distance;
+ nearest = heading;
+ }
+ }
+ return nearest
+ ? { id: nearest.id, text: nearest.textContent, offset: nearest.getBoundingClientRect().top }
+ : undefined;
+}
+
+function restoreScrollAnchor(anchor: ReturnType) {
+ if (!anchor) return;
+ let heading = document.getElementById(anchor.id);
+ if (!heading || heading.offsetParent === null) {
+ heading =
+ [...document.querySelectorAll(headingSelector)].find(
+ (candidate) => candidate.offsetParent !== null && candidate.textContent === anchor.text
+ ) ?? null;
+ }
+ if (heading) {
+ window.scrollTo({
+ top: window.scrollY + heading.getBoundingClientRect().top - anchor.offset,
+ behavior: 'instant',
+ });
+ }
+}
+
+export class PivotSelector extends HTMLElement {
+ private controller?: AbortController;
+
+ connectedCallback() {
+ if (this.controller) return;
+ this.controller = new AbortController();
+ const { signal } = this.controller;
+ let initialized = false;
+ const initialize = () => {
+ if (initialized) return;
+ initialized = true;
+ this.initialize(signal);
+ };
+ // A swapped element connects before Astro updates the URL and restores scroll.
+ document.addEventListener('astro:page-load', initialize, {
+ once: true,
+ signal,
+ });
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initialize, {
+ once: true,
+ signal,
+ });
+ }
+ document.addEventListener('astro:before-swap', () => this.dispose(), {
+ once: true,
+ signal,
+ });
+ }
+
+ disconnectedCallback() {
+ this.dispose();
+ }
+
+ private dispose() {
+ this.controller?.abort();
+ this.controller = undefined;
+ }
+
+ private initialize(signal: AbortSignal) {
+ const key = this.dataset.pivotKey;
+ const selector = this.querySelector('.pivot-selector');
+ if (!key || !selector) throw new Error('Pivot selector requires a key and option container.');
+ const buttons = [...selector.querySelectorAll('button[data-pivot-option]')];
+ const validOptions = buttons
+ .filter((button) => !button.disabled)
+ .map((button) => button.dataset.pivotOption);
+ const allOptions = buttons.map((button) => button.dataset.pivotOption);
+ const collapseButton = this.querySelector('.pivot-collapse-btn');
+ const expandButton = this.querySelector('.pivot-expand-btn');
+ let placeholder: HTMLDivElement | undefined;
+ let floatingFrame: number | undefined;
+ let restoreFrame: number | undefined;
+ let resizeTimer: number | undefined;
+ let autoCollapseTimer: number | undefined;
+ let scrollTimer: number | undefined;
+ let originalTop = 0;
+ let isFloating = false;
+ let isCollapsed = false;
+ let userExpanded = false;
+ let enableScrollCollapse = false;
+ let lastScrollTop = window.scrollY;
+
+ signal.addEventListener(
+ 'abort',
+ () => {
+ window.clearTimeout(resizeTimer);
+ window.clearTimeout(autoCollapseTimer);
+ window.clearTimeout(scrollTimer);
+ if (floatingFrame !== undefined) window.cancelAnimationFrame(floatingFrame);
+ if (restoreFrame !== undefined) window.cancelAnimationFrame(restoreFrame);
+ placeholder?.remove();
+ this.classList.remove('floating', 'collapsed');
+ delete selector.dataset.pivotInitialized;
+ },
+ { once: true }
+ );
+
+ const apply = (id: string, preserveScroll: boolean) => {
+ const anchor = preserveScroll ? findScrollAnchor() : undefined;
+ setStoredPreference(key, id);
+ // Keep destination Starlight tabs on the same AppHost language.
+ if (key === 'aspire-lang' && (id === 'csharp' || id === 'typescript')) {
+ setStoredPreference(
+ 'starlight-synced-tabs__aspire-lang',
+ id === 'csharp' ? 'C#' : 'TypeScript'
+ );
+ document.documentElement.dataset.apphostLang = id;
+ }
+ const url = new URL(window.location.href);
+ url.searchParams.set(key, id);
+ window.history.replaceState(window.history.state, '', url);
+
+ for (const root of document.querySelectorAll('aspire-pivot-selector')) {
+ if (root.dataset.pivotKey !== key) continue;
+ for (const button of root.querySelectorAll(
+ 'button[data-pivot-option]'
+ )) {
+ button.classList.toggle('active', button.dataset.pivotOption === id);
+ }
+ }
+ for (const block of document.querySelectorAll('[data-pivot-block]')) {
+ const ids = (block.dataset.pivotBlock ?? '').split(/[,;]/).map((value) => value.trim());
+ // Selectors with different option sets must not hide each other's content.
+ if (ids.some((value) => allOptions.includes(value))) {
+ block.style.display = ids.includes(id) ? '' : 'none';
+ }
+ }
+ if (restoreFrame !== undefined) window.cancelAnimationFrame(restoreFrame);
+ if (anchor) {
+ restoreFrame = window.requestAnimationFrame(() => {
+ restoreFrame = undefined;
+ restoreScrollAnchor(anchor);
+ });
+ }
+ };
+
+ for (const button of buttons) {
+ button.addEventListener(
+ 'click',
+ () => {
+ const id = button.dataset.pivotOption;
+ if (!button.disabled && id && validOptions.includes(id)) apply(id, true);
+ },
+ { signal }
+ );
+ }
+ const current = [
+ new URLSearchParams(window.location.search).get(key),
+ getStoredPreference(key),
+ validOptions[0],
+ ].find((id) => id && validOptions.includes(id));
+ if (current) apply(current, false);
+
+ const clearCollapseTimers = () => {
+ window.clearTimeout(autoCollapseTimer);
+ window.clearTimeout(scrollTimer);
+ autoCollapseTimer = undefined;
+ scrollTimer = undefined;
+ enableScrollCollapse = false;
+ };
+
+ const setCollapsed = (collapsed: boolean, expandedByUser = false) => {
+ clearCollapseTimers();
+ isCollapsed = collapsed;
+ userExpanded = expandedByUser;
+ this.classList.toggle('collapsed', collapsed);
+ if (expandedByUser) {
+ // Expansion remains open for five seconds, then the next scroll can collapse it.
+ autoCollapseTimer = window.setTimeout(() => {
+ autoCollapseTimer = undefined;
+ enableScrollCollapse = isFloating && !isCollapsed;
+ }, 5000);
+ }
+ };
+
+ const updateFloatingState = () => {
+ floatingFrame = undefined;
+ const rect = this.getBoundingClientRect();
+ const scrollTop = window.scrollY;
+ if (!isFloating) originalTop = scrollTop + rect.top;
+ const shouldFloat = scrollTop > originalTop;
+ if (shouldFloat === isFloating) return;
+ isFloating = shouldFloat;
+ if (isFloating) {
+ if (!placeholder) {
+ placeholder = document.createElement('div');
+ placeholder.className = 'pivot-placeholder';
+ placeholder.setAttribute('aria-hidden', 'true');
+ this.after(placeholder);
+ }
+ placeholder.style.height = `${rect.height}px`;
+ placeholder.style.marginBottom = getComputedStyle(this).marginBottom;
+ placeholder.style.display = 'block';
+ } else if (placeholder) {
+ placeholder.style.display = 'none';
+ }
+ this.classList.toggle('floating', isFloating);
+ setCollapsed(isFloating);
+ };
+
+ const scheduleFloatingUpdate = () => {
+ if (floatingFrame === undefined) {
+ floatingFrame = window.requestAnimationFrame(updateFloatingState);
+ }
+ };
+
+ const updateTitles = () => {
+ const isDesktop = window.matchMedia('(min-width: 72rem)').matches;
+ for (const [button, title] of [
+ [collapseButton, 'Collapse selector'],
+ [expandButton, 'Show selector'],
+ ] as const) {
+ if (isDesktop) button?.setAttribute('title', title);
+ else button?.removeAttribute('title');
+ }
+ };
+
+ collapseButton?.addEventListener(
+ 'click',
+ (event) => {
+ event.stopPropagation();
+ setCollapsed(true);
+ },
+ { signal }
+ );
+ expandButton?.addEventListener(
+ 'click',
+ (event) => {
+ event.stopPropagation();
+ setCollapsed(false, true);
+ },
+ { signal }
+ );
+
+ window.addEventListener(
+ 'scroll',
+ () => {
+ scheduleFloatingUpdate();
+ const scrollTop = window.scrollY;
+ if (
+ isFloating &&
+ !isCollapsed &&
+ userExpanded &&
+ enableScrollCollapse &&
+ Math.abs(scrollTop - lastScrollTop) > 10
+ ) {
+ window.clearTimeout(scrollTimer);
+ scrollTimer = window.setTimeout(() => {
+ scrollTimer = undefined;
+ setCollapsed(true);
+ }, 150);
+ }
+ lastScrollTop = scrollTop;
+ },
+ { passive: true, signal }
+ );
+ window.addEventListener(
+ 'resize',
+ () => {
+ updateTitles();
+ window.clearTimeout(resizeTimer);
+ resizeTimer = window.setTimeout(() => {
+ resizeTimer = undefined;
+ scheduleFloatingUpdate();
+ }, 150);
+ },
+ { signal }
+ );
+
+ updateTitles();
+ scheduleFloatingUpdate();
+ selector.dataset.pivotInitialized = 'true';
+ }
+}
+
+customElements.define('aspire-pivot-selector', PivotSelector);
diff --git a/src/frontend/src/components/starlight/Footer.astro b/src/frontend/src/components/starlight/Footer.astro
index eee9bdf7a..2cb26730b 100644
--- a/src/frontend/src/components/starlight/Footer.astro
+++ b/src/frontend/src/components/starlight/Footer.astro
@@ -7,6 +7,7 @@ import FooterLegal from '@components/FooterLegal.astro';
import FooterPreferences from '@components/FooterPreferences.astro';
import FooterResources from '@components/FooterResources.astro';
import FooterSocials from '@components/FooterSocials.astro';
+import ScrollToTop from '@components/ScrollToTop.astro';
import aspireLogo from '@assets/aspire-logo-256.svg';
import { isHomepage } from '@utils/helpers';
@@ -89,6 +90,8 @@ const commit = (import.meta.env.PUBLIC_GIT_COMMIT_ID || import.meta.env.GIT_COMM
+
+
-');
+ await route.fulfill({ response, body });
+ });
+ await page.locator('header a[href="/docs/"]:visible').evaluate((element) => {
+ element.setAttribute('href', '/docs/?deployment-test=1#_top');
+ });
+ await page.locator('header a[href^="/docs/?deployment-test=1"]:visible').click();
+ await expect(page).toHaveURL(/\/docs\/\?deployment-test=1#_top$/);
+ await expect
+ .poll(() => page.evaluate(() => Reflect.get(window, '__deploymentSession')))
+ .toBeUndefined();
+ expect(nativeDocuments).toBe(1);
+ expect(executions).toEqual([]);
+ await expect(page.locator('site-search button[data-open-modal]')).toBeEnabled();
+ await page.keyboard.press('Control+k');
+ await expect(page.locator('site-search dialog[open]')).toBeVisible();
+ await expect(page.locator('site-search input.pagefind-ui__search-input')).toBeVisible();
+ expect(errors).toEqual([]);
+ });
+}
+
+test('native Document PiP survives same-build navigation and closes on a deployment reload', async ({
+ page,
+ isMobile,
+}) => {
+ test.skip(isMobile, 'Native Document Picture-in-Picture requires a desktop browser.');
+ await page.goto('/');
+ await dismissCookieConsentIfVisible(page);
+ const supported = await page.evaluate(() => 'documentPictureInPicture' in window);
+ test.skip(!supported, 'This browser does not support native Document Picture-in-Picture.');
+
+ await page.evaluate(() => {
+ const button = document.createElement('button');
+ button.id = 'native-pip-test';
+ button.textContent = 'Open native PiP';
+ button.style.cssText = 'position:fixed;top:80px;left:0;z-index:2147483647';
+ button.addEventListener('click', () => {
+ const api = (
+ window as Window & {
+ documentPictureInPicture?: { requestWindow: () => Promise };
+ }
+ ).documentPictureInPicture;
+ if (!api) throw new Error('Native Document Picture-in-Picture is unavailable.');
+ void api.requestWindow().then((pip) => {
+ pip.document.body.textContent = 'Native PiP navigation test';
+ Reflect.set(window, '__nativePipTest', pip);
+ });
+ });
+ document.body.append(button);
+ });
+ const opened = page.context().waitForEvent('page');
+ await page.locator('#native-pip-test').click();
+ const pipPage = await opened;
+ await expect
+ .poll(() => page.evaluate(() => Boolean(Reflect.get(window, '__nativePipTest'))))
+ .toBe(true);
+ await navigateClient(page, () => page.locator('header a[href="/docs/"]:visible').click());
+ await expect(page).toHaveURL(/\/docs\/$/);
+ expect(pipPage.isClosed()).toBe(false);
+ await expect(pipPage.locator('body')).toHaveText('Native PiP navigation test');
+
+ await page.route(new URL('/', page.url()).href, async (route) => {
+ if (route.request().isNavigationRequest()) {
+ await route.continue();
+ return;
+ }
+ const response = await route.fetch();
+ const body = (await response.text()).replace(
+ /]*>/,
+ ''
+ );
+ await route.fulfill({ response, body });
+ });
+ const closed = pipPage.waitForEvent('close');
+ await page.locator('header a[href="/"]:visible').click();
+ await expect(page).toHaveURL((url) => url.pathname === '/');
+ await closed;
+ await expect
+ .poll(() => page.evaluate(() => Boolean(Reflect.get(window, '__nativePipTest'))))
+ .toBe(false);
+ expect(pipPage.isClosed()).toBe(true);
+});
diff --git a/src/frontend/tests/e2e/homepage.spec.ts b/src/frontend/tests/e2e/homepage.spec.ts
index a8f606964..54c7193a9 100644
--- a/src/frontend/tests/e2e/homepage.spec.ts
+++ b/src/frontend/tests/e2e/homepage.spec.ts
@@ -546,6 +546,9 @@ test('presents the application model as a live polyglot topology', async ({ page
await terminalWindow.scrollIntoViewIfNeeded();
await expect(story).toHaveAttribute('data-story-playing', 'true', { timeout: 10_000 });
await expect(story).toHaveAttribute('data-story-focus', 'stage', { timeout: 10_000 });
+ // Center the whole stage so clicking its tab does not scroll the terminal out of view.
+ await story.locator('[data-model-story-surface]').scrollIntoViewIfNeeded();
+ await expect(story).toHaveAttribute('data-story-viewport-active', '');
await topologyStage.click();
await expect(story).toHaveAttribute('data-story-playing', 'false');
await expect(story).toHaveAttribute('data-story-stage', 'topology');
@@ -1588,12 +1591,14 @@ test('keeps the environment frame stable while each topology changes', async ({
)
.toBeLessThan(0.1);
await expect.poll(async () => Math.max(...(await nodeOffsets()))).toBeLessThan(1);
- const centeredTransforms = await productionPanel
- .locator('.topology-node')
- .evaluateAll((nodes) => nodes.map((node) => getComputedStyle(node).transform));
- expect(
- enteringTransforms.some((transform, index) => transform !== centeredTransforms[index])
- ).toBe(true);
+ await expect
+ .poll(async () => {
+ const centeredTransforms = await productionPanel
+ .locator('.topology-node')
+ .evaluateAll((nodes) => nodes.map((node) => getComputedStyle(node).transform));
+ return enteringTransforms.some((transform, index) => transform !== centeredTransforms[index]);
+ })
+ .toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)).toBe(
false
diff --git a/src/frontend/tests/e2e/install-modal-navigation.spec.ts b/src/frontend/tests/e2e/install-modal-navigation.spec.ts
new file mode 100644
index 000000000..d8a92587d
--- /dev/null
+++ b/src/frontend/tests/e2e/install-modal-navigation.spec.ts
@@ -0,0 +1,51 @@
+import { expect, test } from '@playwright/test';
+import { dismissCookieConsentIfVisible } from '@tests/e2e/helpers';
+
+test('install modal owns one set of listeners and disposes outgoing controls', async ({
+ page, isMobile,
+}) => {
+ test.skip(isMobile, 'Touch layouts use the full installation page.');
+ const errors: string[] = [];
+ page.on('pageerror', error => errors.push(error.message));
+ await page.goto('/');
+ await dismissCookieConsentIfVisible(page);
+ await page.evaluate(() => {
+ const close = HTMLDialogElement.prototype.close;
+ Reflect.set(window, '__installCloseCalls', 0);
+ Reflect.set(window, '__installSession', true);
+ HTMLDialogElement.prototype.close = function(value) {
+ if (this.id === 'install-cli-modal') {
+ Reflect.set(window, '__installCloseCalls', Number(Reflect.get(window, '__installCloseCalls')) + 1);
+ }
+ close.call(this, value);
+ };
+ });
+
+ for (let visit = 0; visit < 3; visit++) {
+ await page.evaluate(() => {
+ document.dispatchEvent(new Event('astro:page-load'));
+ document.dispatchEvent(new Event('astro:page-load'));
+ });
+ await page.locator('header [data-open-install-modal]:visible').click();
+ const modal = page.locator('#install-cli-modal');
+ await expect(modal).toBeVisible();
+ const before = await page.evaluate(() => Reflect.get(window, '__installCloseCalls'));
+ await modal.locator('[data-close-modal]').click();
+ await expect(modal).toBeHidden();
+ expect(await page.evaluate(() => Reflect.get(window, '__installCloseCalls'))).toBe(before + 1);
+ await modal.evaluate(element => Reflect.set(window, '__outgoingInstallModal', element));
+
+ const destination = visit % 2 === 0 ? '/docs/' : '/';
+ await page.locator(`header a[href="${destination}"]:visible`).click();
+ await expect(page).toHaveURL(url => url.pathname === destination);
+ expect(await page.evaluate(() => Reflect.get(window, '__installSession'))).toBe(true);
+ const calls = await page.evaluate(() => Reflect.get(window, '__installCloseCalls'));
+ await page.evaluate(() => {
+ const outgoing = Reflect.get(window, '__outgoingInstallModal') as HTMLDialogElement;
+ outgoing.dispatchEvent(new Event('click'));
+ outgoing.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+ });
+ expect(await page.evaluate(() => Reflect.get(window, '__installCloseCalls'))).toBe(calls);
+ }
+ expect(errors).toEqual([]);
+});
diff --git a/src/frontend/tests/e2e/pivot-selector.spec.ts b/src/frontend/tests/e2e/pivot-selector.spec.ts
index 2d83f9cc4..9d4d9ee19 100644
--- a/src/frontend/tests/e2e/pivot-selector.spec.ts
+++ b/src/frontend/tests/e2e/pivot-selector.spec.ts
@@ -139,3 +139,185 @@ test('app host page restores pivot state from the lang query string', async ({ p
await expect(javaContent).toBeVisible();
await expect(nodeJsContent).toBeHidden();
});
+
+test('first-app pivots default to TypeScript and preserve history and shared preferences', async ({
+ page,
+}) => {
+ await page.goto('/get-started/first-app/?keep=1');
+ await dismissCookieConsentIfVisible(page);
+ const selector = page.locator('#pivot-selector-aspire-lang');
+ await expect(selector).toHaveAttribute('data-pivot-initialized', 'true');
+ await expect(selector.getByRole('button').first()).toHaveText('TypeScript');
+ await expect(selector.getByRole('button', { name: 'TypeScript' })).toHaveClass(/active/);
+ await expect(page.locator('[data-pivot-block="typescript"]').first()).toBeVisible();
+ await expect(page.locator('[data-pivot-block="csharp"]').first()).toBeHidden();
+
+ const index = await page.evaluate(() => {
+ const state = history.state as { index: number };
+ history.replaceState({ ...state, pivotTest: 'preserved' }, '');
+ return state.index;
+ });
+ expect(index).toEqual(expect.any(Number));
+ await selector.getByRole('button', { name: 'C#', exact: true }).focus();
+ await page.keyboard.press('Enter');
+ await expect(page).toHaveURL(/\?keep=1&aspire-lang=csharp$/);
+ await expect(page.locator('[data-pivot-block="csharp"]').first()).toBeVisible();
+ await expect(page.locator('[data-pivot-block="typescript"]').first()).toBeHidden();
+ await expect
+ .poll(() =>
+ page.evaluate(() => ({
+ state: history.state,
+ preference: localStorage.getItem('aspire-lang'),
+ tabs: localStorage.getItem('starlight-synced-tabs__aspire-lang'),
+ language: document.documentElement.dataset.apphostLang,
+ }))
+ )
+ .toMatchObject({
+ state: { index, pivotTest: 'preserved' },
+ preference: 'csharp',
+ tabs: 'C#',
+ language: 'csharp',
+ });
+
+ await page.goto('/get-started/first-app/?aspire-lang=typescript&keep=2');
+ await expect(selector.getByRole('button', { name: 'TypeScript' })).toHaveClass(/active/);
+ await expect
+ .poll(() => page.evaluate(() => localStorage.getItem('aspire-lang')))
+ .toBe('typescript');
+});
+
+test('floating pivot controls clear the TOC across its responsive breakpoint', async ({
+ page,
+}, testInfo) => {
+ test.skip(testInfo.project.name !== 'desktop-chromium');
+ await page.goto('/get-started/first-app/');
+ await dismissCookieConsentIfVisible(page);
+ const root = page.locator('aspire-pivot-selector[data-pivot-key="aspire-lang"]');
+ await expect(root.locator('.pivot-selector')).toHaveAttribute('data-pivot-initialized', 'true');
+ await root.getByRole('button', { name: 'C#', exact: true }).click();
+ const expand = root.getByRole('button', { name: 'Expand selector', exact: true });
+ const collapse = root.getByRole('button', { name: 'Collapse selector', exact: true });
+ const toc = page.locator('#starlight__on-this-page--mobile');
+
+ for (const width of [390, 834, 1152, 1440, 1599, 1600]) {
+ await page.setViewportSize({ width, height: 900 });
+ await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
+ await expect(root).not.toHaveClass(/floating/);
+ await page.evaluate(() => window.scrollTo({ top: 1200, behavior: 'instant' }));
+ await expect(root).toHaveClass(/floating/);
+ await expect(root).toHaveClass(/collapsed/);
+
+ if (width < 1600) {
+ await expect(toc).toBeVisible();
+ await expect
+ .poll(async () => {
+ const buttonBox = await expand.boundingBox();
+ const tocBox = await toc.boundingBox();
+ // The shared border can overlap by one pixel, but the control must clear the TOC.
+ return buttonBox && tocBox ? buttonBox.y + 1 >= tocBox.y + tocBox.height : false;
+ })
+ .toBe(true);
+ } else {
+ await expect(toc).toBeHidden();
+ }
+ await expand.click();
+ await expect(root).not.toHaveClass(/collapsed/);
+ await expect(root.getByRole('button', { name: 'C#', exact: true })).toHaveClass(/active/);
+ await collapse.click();
+ await expect(root).toHaveClass(/collapsed/);
+
+ if (width < 1600) {
+ await toc.click();
+ await expect(page.locator('#starlight__mobile-toc')).toHaveJSProperty('open', true);
+ await toc.press('Escape');
+ await expect(page.locator('#starlight__mobile-toc')).toHaveJSProperty('open', false);
+ }
+ }
+});
+
+for (const transition of ['native', 'fallback'] as const) {
+ test(`pivot lifecycle survives repeated visits and history with ${transition} swaps`, async ({
+ page, isMobile,
+ }) => {
+ test.skip(isMobile && transition === 'native',
+ 'Chromium touch emulation aborts native transitions; touch projects cover the swap fallback.');
+ test.setTimeout(120_000);
+ const warnings: string[] = [];
+ const errors: string[] = [];
+ page.on('console', (message) => {
+ if (message.type() === 'warning' && /pivot.*not found/i.test(message.text())) {
+ warnings.push(message.text());
+ }
+ });
+ page.on('pageerror', (error) => errors.push(error.message));
+ if (transition === 'fallback') {
+ await page.addInitScript(() => {
+ Object.defineProperty(document, 'startViewTransition', { value: undefined });
+ });
+ }
+ await page.goto('/docs/');
+ await dismissCookieConsentIfVisible(page);
+ const documentMarker = await page.evaluate(() => {
+ const marker = crypto.randomUUID();
+ Object.defineProperty(window, '__pivotNavigationMarker', { value: marker });
+ return marker;
+ });
+ const selector = page.locator('#pivot-selector-aspire-lang');
+ const root = page.locator('aspire-pivot-selector[data-pivot-key="aspire-lang"]');
+ const navigate = async (action: () => Promise) => {
+ await page.evaluate(() => {
+ Reflect.set(window, '__pivotPageLoaded', false);
+ document.addEventListener('astro:page-load', () => {
+ Reflect.set(window, '__pivotPageLoaded', true);
+ }, { once: true });
+ });
+ await action();
+ await expect.poll(() => page.evaluate(() => Reflect.get(window, '__pivotPageLoaded'))).toBe(true);
+ await expect(page.locator('html[data-astro-transition]')).toHaveCount(0);
+ };
+
+ for (let visit = 0; visit < 3; visit++) {
+ await navigate(() => page.locator('a[href="/get-started/first-app/"]:visible').first().click());
+ await expect(selector).toHaveAttribute('data-pivot-initialized', 'true');
+ await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
+ await selector.getByRole('button', { name: 'C#', exact: true }).click();
+ await expect(page).toHaveURL(/\/get-started\/first-app\/\?aspire-lang=csharp$/);
+ await expect
+ .poll(() => page.evaluate(() => history.state))
+ .toMatchObject({
+ index: expect.any(Number),
+ });
+ await page.evaluate(() => window.scrollTo({ top: 1200, behavior: 'instant' }));
+ await expect(root).toHaveClass(/floating/);
+ await expect(root).toHaveClass(/collapsed/);
+ await root.getByRole('button', { name: 'Expand selector', exact: true }).click();
+ await expect(root).not.toHaveClass(/collapsed/);
+
+ // Leave while the old selector still owns its five-second expansion timer.
+ await navigate(() => page.locator('header a[href="/"]:visible').click());
+ await expect(page).toHaveURL(url => url.pathname === '/');
+ await expect(root).toHaveCount(0);
+ await expect(page.locator('.pivot-placeholder')).toHaveCount(0);
+ await navigate(() => page.goBack());
+ await expect(selector).toHaveAttribute('data-pivot-initialized', 'true');
+ await expect(selector.locator('[data-pivot-option="csharp"]')).toHaveClass(/active/);
+ await expect
+ .poll(() => page.evaluate(() => history.state))
+ .toMatchObject({
+ index: expect.any(Number),
+ });
+ await navigate(() => page.goForward());
+ await expect(page).toHaveURL(url => url.pathname === '/');
+ await expect(root).toHaveCount(0);
+ await page.evaluate(() => {
+ window.dispatchEvent(new Event('scroll'));
+ window.dispatchEvent(new Event('resize'));
+ });
+ await expect
+ .poll(() => page.evaluate(() => Reflect.get(window, '__pivotNavigationMarker')))
+ .toBe(documentMarker);
+ }
+ expect(warnings).toEqual([]);
+ expect(errors).toEqual([]);
+ });
+}
diff --git a/src/frontend/tests/e2e/route-style-navigation.spec.ts b/src/frontend/tests/e2e/route-style-navigation.spec.ts
new file mode 100644
index 000000000..fafbfcd46
--- /dev/null
+++ b/src/frontend/tests/e2e/route-style-navigation.spec.ts
@@ -0,0 +1,313 @@
+import { expect, test, type Page } from '@playwright/test';
+
+const home = '/';
+const docs = '/docs/';
+const api = (language: string) => `/reference/api/${language}/`;
+const redis = (language: string) => `${api(language)}aspire.hosting.redis/redisresource/`;
+const routes = [home, docs, api('csharp'), api('typescript'), redis('csharp'), redis('typescript')];
+
+async function markNavigation(page: Page) {
+ await page.evaluate(() => {
+ Reflect.set(window, '__routeStyleLoaded', false);
+ document.addEventListener(
+ 'astro:page-load',
+ () => {
+ Reflect.set(window, '__routeStyleLoaded', true);
+ },
+ { once: true }
+ );
+ });
+}
+
+async function settled(page: Page) {
+ await expect
+ .poll(() => page.evaluate(() => Reflect.get(window, '__routeStyleLoaded')), {
+ timeout: 30_000,
+ })
+ .toBe(true);
+ await expect(page.locator('html[data-astro-transition]')).toHaveCount(0);
+ expect(await page.evaluate(() => Reflect.get(window, '__routeStyleSession'))).toBe(true);
+}
+
+async function navigate(page: Page, href: string) {
+ await markNavigation(page);
+ await page.evaluate((destination) => {
+ const anchor = document.createElement('a');
+ anchor.id = 'route-style-link';
+ anchor.href = destination;
+ anchor.textContent = 'Navigate';
+ anchor.style.cssText = 'position:fixed;top:80px;left:0;z-index:2147483647';
+ document.body.append(anchor);
+ }, href);
+ await page.locator('#route-style-link').click({ force: true });
+ const destination = new URL(href, page.url());
+ await expect(page).toHaveURL(
+ (url) =>
+ url.pathname === destination.pathname &&
+ [...destination.searchParams].every(([key, value]) => url.searchParams.get(key) === value),
+ { timeout: 30_000 }
+ );
+ await settled(page);
+}
+
+async function appearance(page: Page) {
+ await expect(page.locator('main h1')).toHaveCount(1);
+ await page.evaluate(async () => {
+ await document.fonts.ready;
+ window.scrollTo({ top: 0, behavior: 'instant' });
+ });
+ return page.evaluate(async () => {
+ const selectors = [
+ 'html',
+ 'body',
+ 'header.header',
+ 'header .header',
+ 'header .title-wrapper',
+ 'header .right-group',
+ 'header .right-group-mobile',
+ 'main h1',
+ 'main > .content-panel',
+ '.sl-markdown-content',
+ '.home-hero-story',
+ '.home-hero-sticky',
+ '.home-hero-copy',
+ '.home-hero-summary',
+ '.home-hero-actions',
+ '.home-hero-product',
+ '.api-hero',
+ '.api-hero-summary',
+ '.api-search-input',
+ '.api-kind-badge',
+ '.topics-sidebar',
+ ];
+ const properties = [
+ 'font-family',
+ 'font-size',
+ 'font-weight',
+ 'line-height',
+ 'letter-spacing',
+ 'color',
+ 'background-color',
+ 'display',
+ 'position',
+ 'padding',
+ 'margin-top',
+ 'margin-bottom',
+ 'width',
+ 'max-width',
+ 'grid-template-columns',
+ 'gap',
+ 'overflow-x',
+ 'word-break',
+ 'overflow-wrap',
+ ];
+ const styles = Object.fromEntries(
+ selectors.map((selector) => {
+ const element = document.querySelector(selector);
+ if (!element) return [selector, null];
+ element.getBoundingClientRect();
+ const computed = getComputedStyle(element);
+ return [
+ selector,
+ Object.fromEntries(
+ properties.map((property) => [property, computed.getPropertyValue(property)])
+ ),
+ ];
+ })
+ );
+ const root = document.documentElement;
+ const rootAttributes = Object.fromEntries(
+ [
+ 'lang',
+ 'dir',
+ 'data-theme',
+ 'data-has-sidebar',
+ 'data-has-hero',
+ 'data-apphost-lang',
+ 'data-home-js',
+ 'data-aspire-environment-state',
+ 'data-sidebar-collapsed',
+ 'data-topic-sidebar-collapsed',
+ 'data-api-sidebar-ready',
+ 'data-topic-sidebar-ready',
+ ].map((name) => [name, root.getAttribute(name)])
+ );
+ return {
+ styles,
+ rootAttributes,
+ rootClass: root.className,
+ stylesheets: Array.from(document.head.querySelectorAll('link[rel="stylesheet"]'), (link) =>
+ link.getAttribute('href')
+ ).sort(),
+ bodyClass: document.body.className,
+ bodyStyle: document.body.getAttribute('style'),
+ apiTitle: document.querySelector('main h1')?.classList.contains('api-page-title'),
+ searchCount: document.querySelectorAll('site-search').length,
+ actionsCount: document.querySelectorAll('.actions-container').length,
+ canonical: document.querySelector('link[rel="canonical"]')?.getAttribute('href'),
+ canonicalCount: document.querySelectorAll('link[rel="canonical"]').length,
+ titleCount: document.head.querySelectorAll('title').length,
+ };
+ });
+}
+
+for (const theme of ['light', 'dark']) {
+ test.describe(`${theme} route styles`, () => {
+ test.beforeEach(async ({ page, isMobile }) => {
+ await page.emulateMedia({ reducedMotion: 'reduce' });
+ await page.route('**/scripts/analytics/*.js', (route) =>
+ route.fulfill({ contentType: 'application/javascript', body: '' })
+ );
+ await page.route('**/wcp-consent.js', (route) =>
+ route.fulfill({ contentType: 'application/javascript', body: '' })
+ );
+ await page.addInitScript(
+ ({ theme, fallback }) => {
+ localStorage.setItem('starlight-theme', theme);
+ localStorage.setItem('aspireConsentRequired', 'false');
+ if (fallback)
+ Object.defineProperty(document, 'startViewTransition', {
+ configurable: true,
+ value: undefined,
+ });
+ },
+ { theme, fallback: isMobile }
+ );
+ });
+
+ test('fresh rendering matches API/search/Home, docs/Home, cross-language and history swaps', async ({
+ page,
+ }) => {
+ test.setTimeout(180000);
+ const baseline = new Map>>();
+ for (const route of routes) {
+ await page.goto(route);
+ await expect(page.locator('html')).toHaveAttribute('data-theme', theme);
+ baseline.set(route, await appearance(page));
+ }
+ const matchesFresh = async (route: string) => {
+ await expect
+ .poll(() => appearance(page), {
+ message: `${route} must match its fresh ${theme} rendering after a client swap`,
+ })
+ .toEqual(baseline.get(route));
+ };
+ await page.goto(api('csharp'));
+ await page.evaluate(() => Reflect.set(window, '__routeStyleSession', true));
+ await page.locator('#api-search-input').fill('RedisResource');
+ const result = page.locator(`#api-search-results a[href="${redis('csharp')}"]`).first();
+ await expect(result).toBeVisible();
+ await markNavigation(page);
+ await result.click();
+ await settled(page);
+ await matchesFresh(redis('csharp'));
+ await navigate(page, home);
+ await matchesFresh(home);
+ for (const route of [
+ docs,
+ home,
+ api('csharp'),
+ api('typescript'),
+ redis('typescript'),
+ redis('csharp'),
+ home,
+ ]) {
+ await navigate(page, route);
+ await matchesFresh(route);
+ }
+ await markNavigation(page);
+ await page.goBack();
+ await settled(page);
+ await matchesFresh(redis('csharp'));
+ await markNavigation(page);
+ await page.goForward();
+ await settled(page);
+ await matchesFresh(home);
+ });
+
+ test('restores saved language and route-specific collapsed sidebars, with URL language taking precedence', async ({
+ page,
+ }) => {
+ test.setTimeout(120000);
+ await page.addInitScript(() => {
+ localStorage.setItem('aspire-lang', 'csharp');
+ localStorage.setItem('api-sidebar-collapsed', '1');
+ localStorage.setItem('topic-sidebar-collapsed', '1');
+ });
+ await page.goto(api('csharp'));
+ await expect(page.locator('html')).toHaveAttribute('data-sidebar-collapsed', '');
+ await page.evaluate(() => Reflect.set(window, '__routeStyleSession', true));
+ for (const route of [home, '/get-started/app-host/', api('typescript'), api('csharp')]) {
+ await navigate(page, route);
+ const isApi = route.startsWith('/reference/api/');
+ const isDoc = route === '/get-started/app-host/';
+ await expect(page.locator('html')).toHaveAttribute('data-apphost-lang', 'csharp');
+ await expect(page.locator('html[data-sidebar-collapsed]')).toHaveCount(isApi ? 1 : 0);
+ await expect(page.locator('html[data-topic-sidebar-collapsed]')).toHaveCount(isDoc ? 1 : 0);
+ if (isApi || isDoc) {
+ const prefix = isApi ? 'sidebar' : 'topic-sidebar';
+ await expect(page.locator(`#${prefix}-expand-btn`)).toHaveAttribute(
+ 'aria-expanded',
+ 'false'
+ );
+ await expect(page.locator(`#${prefix}-collapse-btn`)).toHaveAttribute(
+ 'aria-hidden',
+ 'true'
+ );
+ }
+ }
+ await navigate(page, '/get-started/app-host/?aspire-lang=typescript');
+ await expect(page.locator('html')).toHaveAttribute('data-apphost-lang', 'typescript');
+ await navigate(page, home);
+ await expect(page.locator('html')).toHaveAttribute('data-apphost-lang', 'typescript');
+ });
+
+ test('header install affordance retains responsive visibility and dialog layout after swaps', async ({
+ page,
+ isMobile,
+ }) => {
+ test.setTimeout(120000);
+ await page.goto(home);
+ await page.evaluate(() => Reflect.set(window, '__routeStyleSession', true));
+ const dialog = page.locator('#install-cli-modal');
+ const trigger = page.locator('header [data-open-install-modal]:visible');
+ if (page.viewportSize()!.width < 800) {
+ // The compact header intentionally omits the install action.
+ for (const route of [api('csharp'), docs, home]) {
+ await expect(trigger).toHaveCount(0);
+ await expect(dialog).not.toBeVisible();
+ await navigate(page, route);
+ }
+ await expect(trigger).toHaveCount(0);
+ return;
+ }
+ const openAndMeasure = async () => {
+ if (isMobile) await trigger.tap();
+ else await trigger.click();
+ await expect(page.locator('dialog[open]')).toHaveCount(1);
+ await expect(dialog).toBeVisible();
+ return dialog.evaluate((element) => {
+ const style = getComputedStyle(element);
+ const rect = element.getBoundingClientRect();
+ return {
+ color: style.color,
+ background: style.backgroundColor,
+ font: style.font,
+ width: rect.width,
+ fits: rect.left >= 0 && rect.right <= window.innerWidth,
+ };
+ });
+ };
+ const baseline = await openAndMeasure();
+ expect(baseline.fits).toBe(true);
+ for (const route of [api('csharp'), docs, home]) {
+ const close = dialog.getByRole('button', { name: 'Close modal', exact: true });
+ if (isMobile) await close.tap();
+ else await close.click();
+ await expect(dialog).not.toBeVisible();
+ await navigate(page, route);
+ expect(await openAndMeasure()).toEqual(baseline);
+ }
+ });
+ });
+}
diff --git a/src/frontend/tests/e2e/site-search.spec.ts b/src/frontend/tests/e2e/site-search.spec.ts
index 46c1a7281..d7c4c9c17 100644
--- a/src/frontend/tests/e2e/site-search.spec.ts
+++ b/src/frontend/tests/e2e/site-search.spec.ts
@@ -146,6 +146,38 @@ test.describe('site search dialog', () => {
}
});
+ test('API search result navigation restores homepage hero typography', async ({ page }) => {
+ await page.setViewportSize({ width: 2000, height: 1001 });
+ await page.goto('/');
+ await dismissCookieConsentIfVisible(page);
+
+ const hero = page.getByRole('heading', {
+ level: 1,
+ name: 'Compose distributed apps in code.',
+ });
+ const expectedFontSize = await hero.evaluate((element) => getComputedStyle(element).fontSize);
+
+ await page.goto('/reference/api/csharp/');
+ await page.evaluate(() => {
+ const result = document.createElement('a');
+ result.className = 'pagefind-ui__result-link';
+ result.href = '/';
+ result.textContent = 'Home';
+ result.style.cssText =
+ 'position:fixed;inset-block-start:0;inset-inline-start:0;z-index:2147483647';
+ document.body.append(result);
+ });
+
+ await navigateClient(page, () =>
+ page.locator('.pagefind-ui__result-link', { hasText: 'Home' }).click()
+ );
+ await expect(page).toHaveURL((url) => url.pathname === '/');
+ await expect(hero).not.toHaveClass(/api-page-title/);
+ await expect.poll(() => hero.evaluate((element) => getComputedStyle(element).fontSize)).toBe(
+ expectedFontSize
+ );
+ });
+
test('navigation during the lazy UI import mounts only the current search instance', async ({
page,
}) => {
diff --git a/src/frontend/tests/e2e/site-ui-navigation.spec.ts b/src/frontend/tests/e2e/site-ui-navigation.spec.ts
new file mode 100644
index 000000000..e4ec9bd73
--- /dev/null
+++ b/src/frontend/tests/e2e/site-ui-navigation.spec.ts
@@ -0,0 +1,173 @@
+import { expect, test, type Page } from '@playwright/test';
+import { dismissCookieConsentIfVisible } from '@tests/e2e/helpers';
+
+async function navigateClient(page: Page, href: string) {
+ await page.evaluate((destination) => {
+ Reflect.set(window, '__dependencyPageLoaded', false);
+ document.addEventListener(
+ 'astro:page-load',
+ () => {
+ Reflect.set(window, '__dependencyPageLoaded', true);
+ },
+ { once: true }
+ );
+ const link = document.createElement('a');
+ link.id = 'dependency-navigation-link';
+ link.href = destination;
+ link.textContent = 'Navigate to dependency test page';
+ link.style.cssText = 'position:fixed;top:80px;left:0;z-index:2147483647';
+ document.body.append(link);
+ }, href);
+ await page.locator('#dependency-navigation-link').click();
+ await expect
+ .poll(() => page.evaluate(() => Reflect.get(window, '__dependencyPageLoaded')), {
+ timeout: 30000,
+ })
+ .toBe(true);
+ await expect(page.locator('html[data-astro-transition]')).toHaveCount(0);
+ expect(await page.evaluate(() => Reflect.get(window, '__dependencySession'))).toBe(true);
+}
+
+for (const fallback of [false, true]) {
+ test.describe(fallback ? 'site UI swap fallback' : 'site UI native transitions', () => {
+ test.beforeEach(async ({ page, isMobile }) => {
+ test.skip(
+ isMobile && !fallback,
+ 'Chromium touch emulation aborts native transitions; touch projects cover the swap fallback.'
+ );
+ // These runtime tests must never submit analytics events.
+ await page.route('**/scripts/analytics/*.js', (route) =>
+ route.fulfill({
+ contentType: 'application/javascript',
+ body: '',
+ })
+ );
+ await page.addInitScript((swap) => {
+ localStorage.setItem('starlight-theme', 'light');
+ if (swap) {
+ Object.defineProperty(document, 'startViewTransition', {
+ configurable: true,
+ value: undefined,
+ });
+ }
+ const renders = new WeakMap();
+ new MutationObserver((mutations) => {
+ for (const { target } of mutations) {
+ if (!(target instanceof Element) || !target.matches('pre.mermaid[data-processed]'))
+ continue;
+ const count = (renders.get(target) ?? 0) + 1;
+ renders.set(target, count);
+ target.setAttribute('data-test-render-count', String(count));
+ }
+ }).observe(document, {
+ subtree: true,
+ attributes: true,
+ attributeFilter: ['data-processed'],
+ });
+ }, fallback);
+ });
+
+ test('renders two diagrams once per page/theme across repeat visits and history', async ({
+ page,
+ }) => {
+ test.setTimeout(120000);
+ const errors: string[] = [];
+ page.on('pageerror', (error) => errors.push(error.message));
+ page.on('console', (message) => {
+ if (/\[(?:astro-)?mermaid\]/.test(message.text())) errors.push(message.text());
+ });
+ await page.goto('/docs/');
+ await dismissCookieConsentIfVisible(page);
+ await page.evaluate(() => Reflect.set(window, '__dependencySession', true));
+ const diagrams = page.locator('pre.mermaid');
+ const expectRenders = async (count: number) => {
+ await expect(diagrams).toHaveCount(2);
+ for (const diagram of await diagrams.all()) {
+ await expect(diagram.locator(':scope > svg')).toHaveCount(1, { timeout: 30000 });
+ await expect(diagram).toHaveAttribute('data-test-render-count', String(count));
+ }
+ };
+ for (let visit = 0; visit < 2; visit++) {
+ await navigateClient(page, '/get-started/first-app/');
+ await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
+ await expectRenders(1);
+ await page.evaluate(() => {
+ const html = document.documentElement;
+ const theme = html.getAttribute('data-theme')!;
+ html.removeAttribute('data-theme');
+ html.setAttribute('data-theme', theme);
+ });
+ await expectRenders(1);
+ for (const [theme, count] of [
+ ['dark', 2],
+ ['light', 3],
+ ] as const) {
+ await page.locator(`#footer-theme-toggle [data-theme-option="${theme}"]`).click();
+ await expect(page.locator('html')).toHaveAttribute('data-theme', theme);
+ await expectRenders(count);
+ }
+ await navigateClient(page, '/docs/');
+ await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
+ }
+ await page.goBack();
+ await expectRenders(1);
+ expect(await page.evaluate(() => Reflect.get(window, '__dependencySession'))).toBe(true);
+ expect(errors).toEqual([]);
+ });
+
+ test('activates scroll once per mouse, touch, Enter and Space after repeated swaps', async ({
+ page,
+ isMobile,
+ }) => {
+ test.setTimeout(120000);
+ await page.emulateMedia({ reducedMotion: 'reduce' });
+ await page.goto('/docs/');
+ await dismissCookieConsentIfVisible(page);
+ await page.evaluate(() => {
+ Reflect.set(window, '__dependencySession', true);
+ const scrolls: ScrollToOptions[] = [];
+ Reflect.set(window, '__dependencyScrolls', scrolls);
+ const scrollTo = window.scrollTo.bind(window);
+ window.scrollTo = (options?: ScrollToOptions | number, y?: number) => {
+ if (typeof options === 'number') scrollTo(options, y ?? 0);
+ else {
+ if (options?.top === 0) scrolls.push(options);
+ scrollTo(options);
+ }
+ };
+ });
+ for (let visit = 0; visit < 2; visit++) {
+ await navigateClient(page, '/get-started/app-host/');
+ const button = page.locator('#scroll-to-top-button');
+ await expect(button).toHaveCount(1);
+ for (const activation of ['pointer', 'Enter', 'Space']) {
+ await page.evaluate(() => window.scrollTo({ top: 800, behavior: 'instant' }));
+ await expect(button).toBeVisible();
+ await page.evaluate(() =>
+ Reflect.set(
+ window,
+ '__dependencyScrollCountBefore',
+ Reflect.get(window, '__dependencyScrolls').length
+ )
+ );
+ if (activation === 'pointer') {
+ if (isMobile) await button.tap();
+ else await button.click();
+ } else {
+ await button.focus();
+ await page.keyboard.press(activation);
+ }
+ await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0);
+ expect(
+ await page.evaluate(() => {
+ const scrolls: ScrollToOptions[] = Reflect.get(window, '__dependencyScrolls');
+ const before: number = Reflect.get(window, '__dependencyScrollCountBefore');
+ return scrolls.slice(before);
+ })
+ ).toEqual([{ top: 0, behavior: 'auto' }]);
+ }
+ await navigateClient(page, '/docs/');
+ }
+ });
+ });
+}
diff --git a/src/frontend/tests/unit/analytics-script-contracts.vitest.test.ts b/src/frontend/tests/unit/analytics-script-contracts.vitest.test.ts
index b28b01225..ef3c1804b 100644
--- a/src/frontend/tests/unit/analytics-script-contracts.vitest.test.ts
+++ b/src/frontend/tests/unit/analytics-script-contracts.vitest.test.ts
@@ -1,7 +1,8 @@
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { expect, test } from 'vitest';
+import { runInNewContext } from 'node:vm';
+import { expect, test, vi } from 'vitest';
const testsDir = path.dirname(fileURLToPath(import.meta.url));
const frontendRoot = path.resolve(testsDir, '..', '..');
@@ -41,3 +42,104 @@ test('analytics asset files contain javascript bootstrap code', () => {
expect(oneDsScript.trimStart().startsWith('<')).toBe(false);
expect(trackScript.trimStart().startsWith('<')).toBe(false);
});
+
+function analyticsFixture(origin = 'https://aspire.dev') {
+ type TrackedElement = {
+ tagName: string;
+ href?: string;
+ textContent: string;
+ attributes: { name: string; value: string }[];
+ getAttribute: (name: string) => string | null;
+ };
+ type TrackEvent = { target: { closest: (selector: string) => TrackedElement | null } };
+ const initialize = vi.fn<(config: unknown, extensions: unknown[]) => void>();
+ const capturePageAction = vi.fn<(target: TrackedElement, overrides: Record) => void>();
+ const addEventListener = vi.fn<(type: string, listener: (event: TrackEvent) => void) => void>();
+ const console = { debug: vi.fn(), warn: vi.fn() };
+ const context = {
+ location: { origin },
+ window: {},
+ document: { addEventListener },
+ console,
+ oneDS: {
+ ApplicationInsights: class {
+ initialize = initialize;
+ capturePageAction = capturePageAction;
+ },
+ },
+ };
+ const bootstrap = () => { runInNewContext(read('public/scripts/analytics/1ds.js'), context); };
+ const tracking = () => { runInNewContext(read('public/scripts/analytics/track.js'), context); };
+ return { initialize, capturePageAction, addEventListener, console, bootstrap, tracking };
+}
+
+test('analytics excludes unload without disabling other lifecycle or click capture', () => {
+ const fixture = analyticsFixture();
+ fixture.bootstrap();
+ fixture.bootstrap();
+ expect(fixture.initialize).toHaveBeenCalledOnce();
+ expect(fixture.initialize.mock.calls[0][0]).toMatchObject({
+ disablePageUnloadEvents: ['unload'],
+ webAnalyticsConfiguration: {
+ autoCapture: { onUnload: true, click: true, pageView: true },
+ },
+ });
+ expect(fixture.console.debug).not.toHaveBeenCalled();
+ expect(fixture.console.warn).not.toHaveBeenCalled();
+});
+
+test('analytics and tracking remain inactive on other origins', () => {
+ const fixture = analyticsFixture('http://localhost:4321');
+ fixture.bootstrap();
+ fixture.tracking();
+ expect(fixture.initialize).not.toHaveBeenCalled();
+ expect(fixture.addEventListener).not.toHaveBeenCalled();
+ expect(fixture.console.debug).not.toHaveBeenCalled();
+});
+
+test('tracking binds once, forwards nested clicks and stays quiet on success', () => {
+ const fixture = analyticsFixture();
+ fixture.tracking();
+ expect(fixture.addEventListener).not.toHaveBeenCalled();
+ fixture.bootstrap();
+ fixture.tracking();
+ fixture.tracking();
+ expect(fixture.addEventListener).toHaveBeenCalledOnce();
+ const [event, listener] = fixture.addEventListener.mock.calls[0];
+ expect(event).toBe('click');
+ const link = {
+ tagName: 'A',
+ href: 'https://aspire.dev/docs/',
+ textContent: ' Docs ',
+ attributes: [{ name: 'data-track-source-name', value: 'header' }],
+ getAttribute: () => 'docs-link',
+ };
+ listener({ target: { closest: () => link } });
+ expect(fixture.capturePageAction).toHaveBeenCalledExactlyOnceWith(link, {
+ name: 'docs-link', sourceName: 'header', href: link.href, text: 'Docs',
+ });
+ expect(fixture.console.debug).not.toHaveBeenCalled();
+ expect(fixture.console.warn).not.toHaveBeenCalled();
+});
+
+test('initialization and capture failures remain visible', () => {
+ const initialization = analyticsFixture();
+ initialization.initialize.mockImplementation(() => { throw new Error('initialization failed'); });
+ initialization.bootstrap();
+ expect(initialization.console.warn).toHaveBeenCalledWith(
+ '[1ds] Failed to initialize Application Insights:', expect.any(Error),
+ );
+
+ const tracking = analyticsFixture();
+ tracking.bootstrap();
+ tracking.tracking();
+ tracking.capturePageAction.mockImplementation(() => { throw new Error('capture failed'); });
+ tracking.addEventListener.mock.calls[0][1]({
+ target: { closest: () => ({
+ tagName: 'BUTTON', textContent: '', attributes: [], getAttribute: () => 'button',
+ }) },
+ });
+ expect(tracking.console.warn).toHaveBeenCalledWith(
+ '[track] Failed to track event:', expect.any(Error),
+ );
+});
diff --git a/src/frontend/tests/unit/api-markdown.vitest.test.ts b/src/frontend/tests/unit/api-markdown.vitest.test.ts
index 159015258..5b32213c8 100644
--- a/src/frontend/tests/unit/api-markdown.vitest.test.ts
+++ b/src/frontend/tests/unit/api-markdown.vitest.test.ts
@@ -6,7 +6,13 @@
-- route module imports and test props are intentionally dynamic in this harness
*/
import { describe, expect, it, vi } from 'vitest';
+import { createMarkdownProcessor } from '@astrojs/markdown-remark';
+import { selectAll } from 'hast-util-select';
+import { toHtml } from 'hast-util-to-html';
+import rehypeParse from 'rehype-parse';
+import { unified } from 'unified';
+import { escapeTableCell } from '@utils/api-markdown-shared';
import {
renderCSharpDocMarkdown,
renderCSharpMemberKindMarkdown,
@@ -236,6 +242,92 @@ describe('API markdown routes', () => {
});
describe('API markdown helpers', () => {
+ const tableProcessor = createMarkdownProcessor({ smartypants: false, syntaxHighlight: false });
+
+ async function renderCell(value: string) {
+ const processor = await tableProcessor;
+ const result = await processor.render(
+ `| Value | Sentinel |\n| --- | --- |\n| ${escapeTableCell(value)} | intact |`
+ );
+ const tree = unified().use(rehypeParse, { fragment: true }).parse(result.code);
+ expect(selectAll('tbody tr', tree)).toHaveLength(1);
+ const cells = selectAll('td', tree);
+ expect(cells).toHaveLength(2);
+ expect(toHtml(cells[1])).toBe('intact | ');
+ return toHtml(cells[0]);
+ }
+
+ it.each([0, 1, 2, 3, 4])('preserves %i literal backslashes before a pipe in a GFM cell', async (count) => {
+ const value = `left${'\\'.repeat(count)}|right`;
+ expect(await renderCell(value)).toBe(`${value} | `);
+ });
+
+ it('preserves ordinary inline code containing pipes', async () => {
+ expect(await renderCell('`left|middle|right`')).toBe('left|middle|right | ');
+ });
+
+ it.each(['\r\n', '\r', '\n'])('keeps %j line endings within a single table row', async (newline) => {
+ expect(await renderCell(`first${newline}second`)).toBe('first second | ');
+ });
+
+ it('preserves links, emphasis, code spans and literal paths in rendered table cells', async () => {
+ expect(await renderCell(
+ '[API | docs](/reference/api/) **important** `C:\\src\\app` and C:\\src\\app\\'
+ )).toBe(
+ 'API | docs important '
+ + 'C:\\src\\app and C:\\src\\app\\ | '
+ );
+ });
+
+ it('encodes repeated prose backslashes and pipes once, without changing plain code spans', async () => {
+ const value = 'a\\|b\\\\|c | `C:\\src\\app` | `left|right`';
+ expect(escapeTableCell(value)).toBe(
+ 'a\\\\\\|b\\\\\\\\\\|c \\| `C:\\src\\app` \\| `left\\|right`'
+ );
+ expect(await renderCell(value)).toBe(
+ 'a\\|b\\\\|c | C:\\src\\app | left|right | '
+ );
+ });
+
+ it('preserves code backslashes when a pipe occurs elsewhere in the same span', async () => {
+ expect(await renderCell('before\r\n`C:\\src | D:\\data` after\\|end')).toBe(
+ 'before
C:\\src | D:\\data after\\|end | '
+ );
+ });
+
+ it('preserves existing Markdown escapes instead of treating the cell as raw text', async () => {
+ expect(await renderCell('\\*literal\\* and \\[label\\] | `C:\\src\\app`')).toBe(
+ '*literal* and [label] | C:\\src\\app | '
+ );
+ });
+
+ it('preserves inline-code link labels and single-line code from API renderers', async () => {
+ expect(await renderCell('[`left|right`](/reference/api/) and `first second`')).toBe(
+ 'left|right and first second | '
+ );
+ });
+
+ it('preserves rich Markdown from the C# documentation table renderer', async () => {
+ const markdown = renderCSharpDocMarkdown([{
+ kind: 'list',
+ style: 'table',
+ items: [{
+ term: [{ kind: 'text', text: 'path\\|name' }],
+ description: [
+ { kind: 'href', text: 'API | docs', value: '/reference/api/' },
+ { kind: 'code', text: '%LocalAppData%\\Aspire\\BrowserData' },
+ ],
+ }],
+ }], { allTypes: [], base: '', packageName: 'Test.Package' });
+ const result = await (await tableProcessor).render(markdown);
+ const tree = unified().use(rehypeParse, { fragment: true }).parse(result.code);
+ expect(selectAll('tbody tr', tree)).toHaveLength(1);
+ expect(selectAll('td', tree).map((node) => toHtml(node))).toEqual([
+ 'path\\|name | ',
+ 'API | docs %LocalAppData%\\Aspire\\BrowserData | ',
+ ]);
+ });
+
it('normalizes note blockquotes to a single level', () => {
const markdown = renderCSharpDocMarkdown(
[
diff --git a/src/frontend/tests/unit/api-search-lifecycle.vitest.test.ts b/src/frontend/tests/unit/api-search-lifecycle.vitest.test.ts
index 5fd4016bb..f02cf811e 100644
--- a/src/frontend/tests/unit/api-search-lifecycle.vitest.test.ts
+++ b/src/frontend/tests/unit/api-search-lifecycle.vitest.test.ts
@@ -4,6 +4,9 @@ import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runInNewContext } from 'node:vm';
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript';
+import { selectAll } from 'hast-util-select';
+import rehypeParse from 'rehype-parse';
+import { unified } from 'unified';
import { readApiSearchIndex, registerApiSearch } from '@components/api-reference/search-lifecycle';
import * as searchStats from '@utils/ts-api-search-stats';
@@ -18,6 +21,32 @@ const surfaces = [
type MountSearch = (root: HTMLElement, signal: AbortSignal) => void;
+function readControllerScript(source: string): string {
+ const tree = unified().use(rehypeParse, { fragment: true }).parse(source);
+ const scripts = selectAll('script', tree).filter((node) =>
+ !('src' in node.properties) && !('is:inline' in node.properties)
+ && (!node.properties.type || node.properties.type === 'module'));
+ expect(scripts).toHaveLength(1);
+ return scripts[0].children.map((node) => node.type === 'text' ? node.value : '').join('');
+}
+
+describe('Astro controller script extraction', () => {
+ it.each(['script', 'ScRiPt'])('parses %s boundaries and quoted attributes, not script-like tags', (tag) => {
+ expect(readControllerScript(`
+ not a controller
+
+
+
+ <${tag} data-label="a > b">const value = "&";${tag} >
+ `)).toBe('const value = "&";');
+ });
+
+ it('rejects missing or ambiguous controllers', () => {
+ expect(() => readControllerScript('no')).toThrow();
+ expect(() => readControllerScript('')).toThrow();
+ });
+});
+
describe('API search navigation lifecycle', () => {
let events: EventTarget;
let roots: Map;
@@ -162,9 +191,14 @@ describe('API search navigation lifecycle', () => {
if (kind === 'type' || kind === 'item') segments.push(language === 'csharp' ? '[type]' : '[item]');
const filename = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..',
'src', 'pages', 'reference', 'api', ...segments, 'index.astro');
- const script = readFileSync(filename, 'utf8').match(/>', 'scriptnamescript'],
+ ['', 'section'],
+ ['<> & !!!', 'section'],
+ ])('allows only slug characters in plain text %j', (text, expected) => {
+ expect(slugifyHeading(text)).toBe(expected);
+ expect(slugifyHeading(text)).toMatch(/^[\p{Letter}\p{Number}-]+$/u);
+ });
+
+ it('preserves heading IDs when the caller extracts MDAST text before slugging', () => {
+ const text = headingPlainText([
+ { type: 'html', value: '' },
+ { type: 'text', value: 'Running ' },
+ { type: 'strong', children: [{ type: 'text', value: 'the ' }] },
+ { type: 'link', url: '/app/', children: [{ type: 'inlineCode', value: 'AppHost' }] },
+ { type: 'html', value: '' },
+ ]);
+ expect(text).toBe('Running the AppHost');
+ expect(slugifyHeading(text)).toBe('running-the-apphost');
+ });
});
describe('toSentenceCase', () => {
diff --git a/src/frontend/tests/unit/search.vitest.test.ts b/src/frontend/tests/unit/search.vitest.test.ts
new file mode 100644
index 000000000..b6af7b639
--- /dev/null
+++ b/src/frontend/tests/unit/search.vitest.test.ts
@@ -0,0 +1,108 @@
+import type { RootContent } from 'hast';
+import rehypeParse from 'rehype-parse';
+import { unified } from 'unified';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { SearchResponse } from '@scripts/search';
+
+const pagefind = vi.hoisted(() => ({ search: vi.fn() }));
+vi.mock('https://aspire.dev/pagefind/pagefind.js', () => pagefind);
+
+function textContent(node: RootContent): string {
+ if (node.type === 'text') return node.value;
+ return 'children' in node ? node.children.map(textContent).join('') : '';
+}
+
+describe('WebMCP search excerpts', () => {
+ const parse = unified().use(rehypeParse, { fragment: true });
+ const createElement = vi.fn((tag: string) => {
+ expect(tag).toBe('template');
+ // Model detached template content with a real HTML parser, not a tag regex.
+ const content = { textContent: '' };
+ return {
+ content,
+ set innerHTML(html: string) {
+ content.textContent = parse.parse(html).children.map(textContent).join('');
+ },
+ };
+ });
+
+ beforeEach(() => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ vi.stubGlobal('window', { location: new URL('https://aspire.dev/') });
+ vi.stubGlobal('document', { createElement });
+ pagefind.search.mockResolvedValue({ results: [] });
+ });
+
+ afterEach(() => vi.unstubAllGlobals());
+
+ function hit(excerpt?: string, title?: string) {
+ return {
+ data: vi.fn().mockResolvedValue({ url: '/get-started/', meta: { title }, excerpt }),
+ };
+ }
+
+ it.each([
+ [' Aspire Redis & cache ', 'Aspire Redis & cache'],
+ ['match <T> | \', 'match | \\'],
+ ['beforenested match after', 'beforenested match after'],
+ ['x < y and unfinished', 'x < y and unfinished'],
+ ['
text',
+ 'globalThis.injected = truetext'],
+ ['<img src=x onerror="fail()">', '
'],
+ [undefined, ''],
+ ])('extracts text from the inert HTML excerpt %j', async (excerpt, expected) => {
+ pagefind.search.mockResolvedValue({ results: [hit(excerpt, 'Getting started')] });
+ const { searchAspireDocs } = await import('@scripts/search');
+ expect(await searchAspireDocs(' redis ', 10)).toEqual({
+ results: [{ title: 'Getting started', url: '/get-started/', excerpt: expected }],
+ });
+ expect(pagefind.search).toHaveBeenCalledWith('redis');
+ expect(createElement).toHaveBeenCalledExactlyOnceWith('template');
+ });
+
+ it('preserves result limits, title fallback, and empty queries/results', async () => {
+ const hits = [hit('one'), hit('two'), hit('three')];
+ pagefind.search.mockResolvedValue({ results: hits });
+ const { searchAspireDocs } = await import('@scripts/search');
+ const response = await searchAspireDocs('redis', 2);
+ expect(response.results).toHaveLength(2);
+ expect(response.results[0].title).toBe('/get-started/');
+ expect(hits[2].data).not.toHaveBeenCalled();
+ expect((await searchAspireDocs('redis', 0)).results).toHaveLength(1);
+ pagefind.search.mockClear();
+ expect(await searchAspireDocs(' \n ', 10)).toEqual({ results: [] });
+ expect(pagefind.search).not.toHaveBeenCalled();
+ pagefind.search.mockResolvedValue({ results: [] });
+ expect(await searchAspireDocs('no matches', 10)).toEqual({ results: [] });
+ });
+
+ it('preserves the unavailable response without a browser environment', async () => {
+ vi.stubGlobal('window', undefined);
+ const { searchAspireDocs } = await import('@scripts/search');
+ expect(await searchAspireDocs('redis', 10)).toEqual({
+ results: [],
+ unavailable: true,
+ reason: 'Pagefind is not available in this environment.',
+ });
+ });
+
+ it('keeps decoded excerpts inside the unchanged WebMCP JSON-text envelope', async () => {
+ const registerTool = vi.fn<(tool: {
+ execute: (input: unknown) => Promise;
+ }) => void>();
+ vi.stubGlobal('navigator', { modelContext: { registerTool } });
+ const excerpt = '
& "quoted"';
+ pagefind.search.mockResolvedValue({
+ results: [hit('<img src=x onerror="fail()"> & "quoted"')],
+ });
+ await import('@scripts/webmcp');
+ const response: SearchResponse = {
+ results: [{ title: '/get-started/', url: '/get-started/', excerpt }],
+ };
+ expect(await registerTool.mock.calls[0][0].execute({ query: 'redis' })).toEqual({
+ content: [{ type: 'text', text: JSON.stringify(response) }],
+ isError: false,
+ });
+ });
+});
diff --git a/src/frontend/tests/unit/site-ui-runtime.vitest.test.ts b/src/frontend/tests/unit/site-ui-runtime.vitest.test.ts
new file mode 100644
index 000000000..34fd4b06a
--- /dev/null
+++ b/src/frontend/tests/unit/site-ui-runtime.vitest.test.ts
@@ -0,0 +1,564 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { getEventListeners } from 'node:events';
+import { readFileSync } from 'node:fs';
+import { runInNewContext } from 'node:vm';
+import { ModuleKind, ScriptTarget, transpileModule } from 'typescript';
+import type { AsyncIconLoader } from 'mermaid';
+
+const mermaidSource = readFileSync(
+ new URL('../../src/scripts/mermaid.ts', import.meta.url),
+ 'utf8'
+);
+const scrollComponent = readFileSync(
+ new URL('../../src/components/ScrollToTop.astro', import.meta.url),
+ 'utf8'
+);
+const extractScript = (component: string) =>
+ component.match(/')).toBe('const ready = true;');
+});
+
+class Element extends EventTarget {
+ id = '';
+ lang = 'en';
+ type = '';
+ ariaLabel = '';
+ textContent = '';
+ innerHTML = '';
+ scrollHeight = 2000;
+ style: Record = {};
+ attributes = new Map();
+ dataset: Record = {};
+ classes = new Set();
+ classList = {
+ add: (...names: string[]) => names.forEach((name) => this.classes.add(name)),
+ remove: (...names: string[]) => names.forEach((name) => this.classes.delete(name)),
+ contains: (name: string) => this.classes.has(name),
+ toggle: (name: string, force = !this.classes.has(name)) => {
+ if (force) this.classes.add(name);
+ else this.classes.delete(name);
+ return force;
+ },
+ };
+ parentNode: Element | null = null;
+ children: Element[] = [];
+ root = false;
+ constructor(readonly tagName = 'div') {
+ super();
+ }
+ get parentElement() {
+ return this.parentNode;
+ }
+ get isConnected(): boolean {
+ return this.root || Boolean(this.parentNode?.isConnected);
+ }
+ setAttribute(name: string, value: string) {
+ this.attributes.set(name, value);
+ if (name.startsWith('data-')) this.dataset[name.slice(5)] = value;
+ }
+ getAttribute(name: string) {
+ return name.startsWith('data-')
+ ? (this.dataset[name.slice(5)] ?? null)
+ : (this.attributes.get(name) ?? null);
+ }
+ hasAttribute(name: string) {
+ return this.getAttribute(name) !== null;
+ }
+ removeAttribute(name: string) {
+ this.attributes.delete(name);
+ if (name.startsWith('data-')) delete this.dataset[name.slice(5)];
+ }
+ append(child: Element) {
+ child.remove();
+ this.appendChild(child);
+ }
+ appendChild(child: T) {
+ child.parentNode = this;
+ this.children.push(child);
+ return child;
+ }
+ removeChild(child: Element) {
+ this.children = this.children.filter((element) => element !== child);
+ child.parentNode = null;
+ }
+ remove() {
+ this.parentNode?.removeChild(this);
+ }
+ contains(element: Element): boolean {
+ return this === element || this.children.some((child) => child.contains(element));
+ }
+ all(): Element[] {
+ return this.children.flatMap((child) => [child, ...child.all()]);
+ }
+ querySelectorAll(selector: string) {
+ return this.all().filter((element) => {
+ if (selector.startsWith('#')) return element.id === selector.slice(1);
+ if (selector === 'pre.mermaid') {
+ return element.tagName === 'pre' && element.classList.contains('mermaid');
+ }
+ if (selector.startsWith('.')) return element.classList.contains(selector.slice(1));
+ return element.tagName === selector;
+ });
+ }
+ querySelector(selector: string): Element | null {
+ return this.querySelectorAll(selector)[0] ?? null;
+ }
+}
+
+class Document extends EventTarget {
+ documentElement = new Element('html');
+ head = this.documentElement.appendChild(new Element('head'));
+ body = this.documentElement.appendChild(new Element('body'));
+ readyState = 'complete';
+ constructor() {
+ super();
+ this.documentElement.root = true;
+ this.documentElement.setAttribute('data-theme', 'light');
+ }
+ createElement(tag: string) {
+ return new Element(tag);
+ }
+ querySelectorAll(selector: string) {
+ return this.documentElement.querySelectorAll(selector);
+ }
+ querySelector(selector: string) {
+ return this.documentElement.querySelector(selector);
+ }
+ getElementById(id: string) {
+ return this.querySelector(`#${id}`);
+ }
+ emit(name: string) {
+ this.dispatchEvent(new Event(name));
+ }
+ replaceBody(sources: string[] = []) {
+ this.body.remove();
+ this.body = this.documentElement.appendChild(new Element('body'));
+ return sources.map((source) => {
+ const diagram = this.body.appendChild(new Element('pre'));
+ diagram.classList.add('mermaid');
+ diagram.textContent = source;
+ return diagram;
+ });
+ }
+}
+
+function observers() {
+ const instances: Observer[] = [];
+ class Observer {
+ targets = new Set();
+ constructor(readonly callback: () => void) {
+ instances.push(this);
+ }
+ observe(target: Element) {
+ this.targets.add(target);
+ }
+ disconnect() {
+ this.targets.clear();
+ }
+ }
+ return {
+ Observer,
+ instances,
+ notify: (target: Element) => {
+ instances
+ .filter((observer) => observer.targets.has(target))
+ .forEach((observer) => observer.callback());
+ },
+ };
+}
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((yes, no) => {
+ resolve = yes;
+ reject = no;
+ });
+ return { promise, resolve, reject };
+}
+
+const flush = () => new Promise((resolve) => setImmediate(resolve));
+
+async function mermaidRuntime({
+ sources = ['graph TD; A-->B', 'graph TD; C-->D'],
+ loading = false,
+} = {}) {
+ const { iconPacks } = await import('../../config/icon-packs.mjs');
+ const icons = { prefix: 'test', icons: { app: { body: '' } } };
+ const fetch = vi
+ .fn()
+ .mockImplementation(() => Promise.resolve(new Response(JSON.stringify(icons))));
+ const document = new Document();
+ const diagrams = document.replaceBody(sources);
+ if (loading) document.readyState = 'loading';
+ const mutation = observers();
+ const logger = { log: vi.fn(), error: vi.fn() };
+ const mermaid = {
+ initialize: vi.fn<(config: { theme: string }) => void>(),
+ registerIconPacks: vi.fn<(packs: AsyncIconLoader[]) => void>(),
+ render: vi
+ .fn<(id: string, source: string) => Promise<{ svg: string }>>()
+ .mockImplementation((_id, source) => Promise.resolve({ svg: `` })),
+ };
+ const imported = deferred<{ default: typeof mermaid }>();
+ const importMermaid = vi.fn(() => imported.promise);
+ // Replace only the module boundary; execute the actual site runtime.
+ runInNewContext(compile(mermaidSource).replace("import('mermaid')", 'importMermaid()'), {
+ document,
+ MutationObserver: mutation.Observer,
+ console: logger,
+ queueMicrotask,
+ importMermaid,
+ iconPacks,
+ fetch,
+ Error,
+ });
+ if (!loading) document.emit('astro:page-load');
+ return {
+ document,
+ diagrams,
+ mutation,
+ mermaid,
+ imported,
+ importMermaid,
+ logger,
+ icons,
+ iconPacks,
+ fetch,
+ theme(value: string, target = document.documentElement) {
+ target.setAttribute('data-theme', value);
+ mutation.notify(target);
+ },
+ swap(sources: string[]) {
+ document.emit('astro:before-swap');
+ const diagrams = document.replaceBody(sources);
+ document.emit('astro:after-swap');
+ document.emit('astro:page-load');
+ return diagrams;
+ },
+ };
+}
+
+describe('site-owned Mermaid runtime', () => {
+ it('coalesces readiness, page-load and same-theme notifications before and during rendering', async () => {
+ const fixture = await mermaidRuntime({ loading: true });
+ const { document, mermaid, imported, diagrams, importMermaid, logger } = fixture;
+ expect(importMermaid).not.toHaveBeenCalled();
+ document.emit('DOMContentLoaded');
+ document.emit('astro:page-load');
+ fixture.theme('light');
+ await flush();
+ expect(importMermaid).toHaveBeenCalledOnce();
+ const render = deferred<{ svg: string }>();
+ mermaid.render.mockReturnValueOnce(render.promise);
+ imported.resolve({ default: mermaid });
+ await flush();
+ document.emit('astro:after-swap');
+ document.emit('astro:page-load');
+ fixture.theme('light');
+ await flush();
+ expect(mermaid.render).toHaveBeenCalledOnce();
+ render.resolve({ svg: '' });
+ await flush();
+ expect(mermaid.render).toHaveBeenCalledTimes(2);
+ expect(mermaid.initialize).toHaveBeenCalledTimes(1);
+ expect(diagrams.map((diagram) => diagram.getAttribute('data-processed'))).toEqual([
+ 'true',
+ 'true',
+ ]);
+ expect(mermaid.registerIconPacks).toHaveBeenCalledOnce();
+ expect(mermaid.registerIconPacks.mock.calls[0][0].map((pack) => pack.name)).toEqual(
+ fixture.iconPacks.map((pack) => pack.name)
+ );
+ expect(logger.log).not.toHaveBeenCalled();
+ expect(logger.error).not.toHaveBeenCalled();
+ });
+
+ it('keeps imports lazy on empty pages and renders two diagrams once on each return visit', async () => {
+ const fixture = await mermaidRuntime({ sources: [] });
+ await flush();
+ expect(fixture.importMermaid).not.toHaveBeenCalled();
+ fixture.imported.resolve({ default: fixture.mermaid });
+ for (let visit = 1; visit <= 3; visit++) {
+ fixture.swap(['graph TD; A-->B', 'graph TD; C-->D']);
+ await flush();
+ expect(fixture.mermaid.render).toHaveBeenCalledTimes(visit * 2);
+ fixture.swap([]);
+ await flush();
+ }
+ expect(fixture.importMermaid).toHaveBeenCalledOnce();
+ });
+
+ it('ignores equivalent attribute churn and rerenders only for effective theme changes', async () => {
+ const fixture = await mermaidRuntime();
+ fixture.imported.resolve({ default: fixture.mermaid });
+ await flush();
+ fixture.document.documentElement.removeAttribute('data-theme');
+ fixture.theme('light');
+ fixture.theme('dark', fixture.document.body);
+ await flush();
+ expect(fixture.mermaid.render).toHaveBeenCalledTimes(2);
+ fixture.theme('dark');
+ await flush();
+ expect(fixture.mermaid.render).toHaveBeenCalledTimes(4);
+ expect(fixture.mermaid.initialize.mock.calls.map(([config]) => config.theme)).toEqual([
+ 'default',
+ 'dark',
+ ]);
+ expect(fixture.mermaid.render.mock.calls.map(([, source]) => source)).toEqual([
+ 'graph TD; A-->B',
+ 'graph TD; C-->D',
+ 'graph TD; A-->B',
+ 'graph TD; C-->D',
+ ]);
+ });
+
+ it('serializes global theme configuration and discards superseded in-flight SVGs', async () => {
+ const fixture = await mermaidRuntime({ sources: ['graph TD; A-->B'] });
+ const render = deferred<{ svg: string }>();
+ fixture.mermaid.render.mockReturnValueOnce(render.promise);
+ fixture.imported.resolve({ default: fixture.mermaid });
+ await flush();
+ fixture.theme('dark');
+ fixture.theme('light');
+ fixture.theme('dark');
+ await flush();
+ expect(fixture.mermaid.initialize).toHaveBeenCalledOnce();
+ expect(fixture.mermaid.render).toHaveBeenCalledOnce();
+ const latest = deferred<{ svg: string }>();
+ fixture.mermaid.render.mockReturnValueOnce(latest.promise);
+ render.resolve({ svg: '' });
+ await flush();
+ expect(fixture.diagrams[0].innerHTML).toBe('');
+ expect(fixture.mermaid.initialize.mock.calls.map(([config]) => config.theme)).toEqual([
+ 'default',
+ 'dark',
+ ]);
+ latest.resolve({ svg: '' });
+ await flush();
+ expect(fixture.diagrams[0].innerHTML).toBe('');
+ });
+
+ it('abandons imports owned by a replaced page and observes the new body, not the old body', async () => {
+ const fixture = await mermaidRuntime();
+ await flush();
+ const oldBody = fixture.document.body;
+ const next = fixture.swap(['graph TD; New-->Page']);
+ fixture.imported.resolve({ default: fixture.mermaid });
+ await flush();
+ expect(fixture.mermaid.render).toHaveBeenCalledExactlyOnceWith(
+ expect.any(String),
+ 'graph TD; New-->Page'
+ );
+ expect(fixture.diagrams.every((diagram) => diagram.innerHTML === '')).toBe(true);
+ expect(fixture.mutation.instances[0].targets.has(oldBody)).toBe(false);
+ fixture.theme('dark');
+ await flush();
+ expect(fixture.mermaid.render).toHaveBeenCalledTimes(2);
+ expect(next[0].getAttribute('data-processed')).toBe('true');
+ });
+
+ it.each(['resolve', 'reject'] as const)(
+ 'does not write stale %s completions after a swap',
+ async (outcome) => {
+ const fixture = await mermaidRuntime();
+ const render = deferred<{ svg: string }>();
+ fixture.mermaid.render.mockReturnValueOnce(render.promise);
+ fixture.imported.resolve({ default: fixture.mermaid });
+ await flush();
+ const next = fixture.swap(['graph TD; New-->Page']);
+ await flush();
+ expect(fixture.mermaid.initialize).toHaveBeenCalledOnce();
+ if (outcome === 'resolve') render.resolve({ svg: '' });
+ else render.reject(new Error('obsolete render failed'));
+ await flush();
+ expect(fixture.diagrams[0].innerHTML).toBe('');
+ expect(fixture.diagrams[0].children).toHaveLength(0);
+ expect(fixture.diagrams[0].hasAttribute('data-processed')).toBe(false);
+ expect(next[0].innerHTML).toContain('New-->Page');
+ expect(fixture.mermaid.render).toHaveBeenCalledTimes(2);
+ expect(fixture.logger.error).toHaveBeenCalledTimes(outcome === 'reject' ? 1 : 0);
+ }
+ );
+
+ it('keeps current work alive when navigation preparation is canceled', async () => {
+ const fixture = await mermaidRuntime();
+ fixture.document.emit('astro:before-preparation');
+ fixture.imported.resolve({ default: fixture.mermaid });
+ await flush();
+ expect(fixture.mermaid.render).toHaveBeenCalledTimes(2);
+ fixture.theme('dark');
+ await flush();
+ expect(fixture.mermaid.render).toHaveBeenCalledTimes(4);
+ });
+
+ it('preserves visible escaped errors and source without retrying an unchanged failed diagram', async () => {
+ const fixture = await mermaidRuntime({ sources: ['invalid '] });
+ fixture.mermaid.render.mockRejectedValueOnce(new Error('
'));
+ fixture.imported.resolve({ default: fixture.mermaid });
+ await flush();
+ const [diagram] = fixture.diagrams;
+ expect(fixture.logger.error).toHaveBeenCalledOnce();
+ expect(diagram.getAttribute('data-diagram')).toBe('invalid ');
+ expect(diagram.textContent).toBe('Unable to render diagram:
');
+ expect(diagram.classList.contains('mermaid-error')).toBe(true);
+ expect(diagram.innerHTML).toBe('');
+ fixture.document.emit('astro:page-load');
+ await flush();
+ expect(fixture.mermaid.render).toHaveBeenCalledOnce();
+ fixture.theme('dark');
+ await flush();
+ expect(fixture.mermaid.render.mock.calls[1][1]).toBe('invalid ');
+ });
+
+ it('logs import failures, clears the failed import promise and retries on the next page load', async () => {
+ const fixture = await mermaidRuntime();
+ fixture.imported.reject(new Error('import failed'));
+ await flush();
+ expect(fixture.logger.error).toHaveBeenCalled();
+ fixture.importMermaid.mockResolvedValueOnce({ default: fixture.mermaid });
+ fixture.document.emit('astro:page-load');
+ await flush();
+ expect(fixture.importMermaid).toHaveBeenCalledTimes(2);
+ expect(fixture.mermaid.render).toHaveBeenCalledTimes(2);
+ });
+
+ it('loads configured icon packs lazily and reports failed responses', async () => {
+ const fixture = await mermaidRuntime();
+ fixture.imported.resolve({ default: fixture.mermaid });
+ await flush();
+ expect(fixture.fetch).not.toHaveBeenCalled();
+ const pack = fixture.mermaid.registerIconPacks.mock.calls[0][0][0];
+ await expect(pack.loader()).resolves.toEqual(fixture.icons);
+ expect(fixture.fetch).toHaveBeenCalledWith(fixture.iconPacks[0].url);
+ fixture.fetch.mockResolvedValueOnce(new Response('', { status: 503 }));
+ await expect(pack.loader()).rejects.toThrow('HTTP 503');
+ });
+});
+
+function scrollRuntime({ reducedMotion = false } = {}) {
+ vi.useFakeTimers();
+ const document = new Document();
+ const window = Object.assign(new EventTarget(), {
+ scrollY: 500,
+ innerHeight: 900,
+ innerWidth: 1440,
+ outerWidth: 1440,
+ scrollTo: vi.fn(),
+ matchMedia: () => ({ matches: reducedMotion }),
+ requestAnimationFrame: (callback: () => void) => setTimeout(callback, 16),
+ cancelAnimationFrame: clearTimeout,
+ });
+ type ScrollElement = Element & { connectedCallback(): void; disconnectedCallback(): void };
+ let Constructor: (new () => ScrollElement) | undefined;
+ runInNewContext(compile(scrollSource!), {
+ document,
+ window,
+ HTMLElement: Element,
+ AbortController,
+ customElements: {
+ define: (_name: string, value: new () => ScrollElement) => {
+ Constructor = value;
+ },
+ },
+ });
+ if (!Constructor) throw new Error('Scroll-to-top custom element was not registered.');
+ const root = document.body.appendChild(new Constructor());
+ const button = root.appendChild(new Element('button'));
+ button.id = 'scroll-to-top-button';
+ button.type = 'button';
+ root.connectedCallback();
+ return { document, window, root, button: () => button };
+}
+
+describe('site-owned scroll-to-top control', () => {
+ afterEach(() => vi.useRealTimers());
+
+ it('mounts once per connection without document readiness or keyboard handlers', () => {
+ const fixture = scrollRuntime();
+ fixture.root.connectedCallback();
+ fixture.document.emit('DOMContentLoaded');
+ fixture.document.emit('astro:page-load');
+ expect(vi.getTimerCount()).toBe(1);
+ vi.runAllTimers();
+ const button = fixture.button();
+ fixture.document.emit('astro:page-load');
+ vi.runAllTimers();
+ expect(fixture.button()).toBe(button);
+ expect(fixture.document.querySelectorAll('#scroll-to-top-button')).toHaveLength(1);
+ expect(getEventListeners(fixture.document, 'keydown')).toHaveLength(0);
+ expect(getEventListeners(fixture.document, 'DOMContentLoaded')).toHaveLength(0);
+ });
+
+ it.each([false, true])(
+ 'uses only native click activation (reduced motion: %s)',
+ (reducedMotion) => {
+ const fixture = scrollRuntime({ reducedMotion });
+ vi.runAllTimers();
+ const button = fixture.button();
+ expect(button.type).toBe('button');
+ for (const name of ['touchstart', 'touchend', 'keydown']) {
+ expect(getEventListeners(button, name)).toHaveLength(0);
+ }
+ const click = new Event('click', { cancelable: true });
+ button.dispatchEvent(click);
+ expect(click.defaultPrevented).toBe(false);
+ expect(fixture.window.scrollTo).toHaveBeenCalledExactlyOnceWith({
+ top: 0,
+ behavior: reducedMotion ? 'auto' : 'smooth',
+ });
+ expect(scrollComponent).toContain('button:focus-visible');
+ }
+ );
+
+ it('disposes element/window handlers and pending frames when disconnected', () => {
+ const fixture = scrollRuntime();
+ fixture.root.disconnectedCallback();
+ expect(vi.getTimerCount()).toBe(0);
+ vi.runAllTimers();
+ for (let visit = 0; visit < 3; visit++) {
+ fixture.root.connectedCallback();
+ vi.runAllTimers();
+ expect(getEventListeners(fixture.document, 'keydown')).toHaveLength(0);
+ expect(getEventListeners(fixture.window, 'scroll')).toHaveLength(1);
+ fixture.window.dispatchEvent(new Event('scroll'));
+ expect(vi.getTimerCount()).toBe(1);
+ fixture.root.disconnectedCallback();
+ expect(vi.getTimerCount()).toBe(0);
+ expect(getEventListeners(fixture.document, 'keydown')).toHaveLength(0);
+ expect(getEventListeners(fixture.window, 'scroll')).toHaveLength(0);
+ expect(getEventListeners(fixture.window, 'resize')).toHaveLength(0);
+ expect(getEventListeners(fixture.button(), 'click')).toHaveLength(0);
+ }
+ });
+
+ it('survives canceled preparation and updates scroll/zoom visibility in one frame', () => {
+ const fixture = scrollRuntime();
+ vi.runAllTimers();
+ const button = fixture.button();
+ fixture.document.emit('astro:before-preparation');
+ expect(fixture.button()).toBe(button);
+ expect(getEventListeners(fixture.document, 'keydown')).toHaveLength(0);
+ fixture.window.outerWidth = 6000;
+ fixture.window.dispatchEvent(new Event('resize'));
+ vi.runAllTimers();
+ expect(button.classList.contains('visible')).toBe(false);
+ fixture.window.outerWidth = 1440;
+ fixture.window.dispatchEvent(new Event('resize'));
+ vi.runAllTimers();
+ expect(button.classList.contains('visible')).toBe(true);
+ fixture.window.dispatchEvent(new Event('scroll'));
+ fixture.window.scrollY = 0;
+ fixture.window.dispatchEvent(new Event('scroll'));
+ vi.runAllTimers();
+ expect(button.classList.contains('visible')).toBe(false);
+ });
+});
diff --git a/src/frontend/tests/unit/update-integrations.vitest.test.ts b/src/frontend/tests/unit/update-integrations.vitest.test.ts
index e53478e95..6ef2b88a0 100644
--- a/src/frontend/tests/unit/update-integrations.vitest.test.ts
+++ b/src/frontend/tests/unit/update-integrations.vitest.test.ts
@@ -33,6 +33,15 @@ function docsPageExists(
);
}
+describe('integration catalog integrity', () => {
+ test('contains each NuGet package ID only once, ignoring case', () => {
+ const packageIds = aspireIntegrations.map(({ title }) => title.toLowerCase());
+ const duplicateIds = packageIds.filter((id, index) => packageIds.indexOf(id) !== index);
+
+ expect(duplicateIds).toEqual([]);
+ });
+});
+
describe('update-integrations icon handling', () => {
test('uses the package version for official Aspire packages from nuget.org', () => {
expect(
diff --git a/src/statichost/StaticHost/Live/LiveEndpoints.cs b/src/statichost/StaticHost/Live/LiveEndpoints.cs
index 96d88de12..98a0ac86c 100644
--- a/src/statichost/StaticHost/Live/LiveEndpoints.cs
+++ b/src/statichost/StaticHost/Live/LiveEndpoints.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
@@ -203,7 +204,8 @@ private static async Task TwitchWebhook(
// replayed indefinitely.
if (!TwitchWebhookHandler.IsFresh(timestamp, time.GetUtcNow(), TimeSpan.FromMinutes(10)))
{
- logger.LogWarning("Twitch webhook timestamp {Timestamp} is stale or unparseable; rejecting.", timestamp);
+ logger.LogWarning("Twitch webhook timestamp {Timestamp} (sanitized) is stale or unparseable; rejecting.",
+ TwitchWebhookHandler.SanitizeDiagnosticValue(timestamp));
return Results.Unauthorized();
}
@@ -226,15 +228,16 @@ private static async Task TwitchWebhook(
if (acquisition.Status == TwitchMessageAcquisitionStatus.Completed)
{
- logger.LogDebug("Twitch webhook replay ignored for {MessageId}.", messageId);
+ logger.LogDebug("Twitch webhook replay ignored for message {MessageId}.",
+ TwitchWebhookHandler.SanitizeDiagnosticValue(messageId));
return Results.Ok();
}
if (acquisition.Status == TwitchMessageAcquisitionStatus.Processing)
{
logger.LogDebug(
- "Twitch webhook {MessageId} is already being processed; asking Twitch to retry.",
- messageId);
+ "Twitch webhook message {MessageId} is already being processed; asking Twitch to retry.",
+ TwitchWebhookHandler.SanitizeDiagnosticValue(messageId));
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
@@ -276,6 +279,7 @@ private static async Task TwitchWebhook(
private static async Task YouTubeVerify(
HttpContext context,
IYouTubeWebSubSubscriptionState subscriptions,
+ IOptions options,
TimeProvider time,
ILoggerFactory loggerFactory)
{
@@ -291,6 +295,19 @@ private static async Task YouTubeVerify(
var verifyToken = query["hub.verify_token"].ToString();
var logger = loggerFactory.CreateLogger("StaticHost.Live.YouTube.WebSub");
+ if (string.Equals(mode, "denied", StringComparison.Ordinal))
+ {
+ var channelId = options.Value.YouTube.ChannelId;
+ bool? matchesConfiguredTopic = string.IsNullOrEmpty(channelId)
+ ? null
+ : string.Equals(topic, YouTubeWebSubSubscriptionTransitions.TopicFor(channelId), StringComparison.Ordinal);
+ YouTubeDiagnostics.LogUntrustedDenial(
+ logger, query["hub.reason"].ToString(), !string.IsNullOrEmpty(topic), matchesConfiguredTopic);
+ return string.IsNullOrEmpty(topic)
+ ? Results.BadRequest("Invalid WebSub denial report.")
+ : Results.Ok();
+ }
+
if (!int.TryParse(query["hub.lease_seconds"], out var leaseSeconds) ||
string.IsNullOrEmpty(challenge))
{
@@ -304,7 +321,8 @@ private static async Task YouTubeVerify(
verifyToken,
leaseSeconds))
{
- logger.LogWarning("Rejected malformed YouTube WebSub {Mode} verification for {Topic}.", mode, topic);
+ LogRejectedVerification(
+ logger, mode, topic, options.Value.YouTube.ChannelId, malformed: true);
return Results.NotFound();
}
@@ -330,15 +348,36 @@ private static async Task YouTubeVerify(
if (!confirmed)
{
- logger.LogWarning("Rejected unexpected YouTube WebSub {Mode} verification for {Topic}.", mode, topic);
+ LogRejectedVerification(
+ logger, mode, topic, options.Value.YouTube.ChannelId, malformed: false);
return Results.NotFound();
}
- logger.LogInformation("YouTube WebSub subscription verified for {Topic}; granted lease {LeaseSeconds}s.",
- topic, leaseSeconds);
+ logger.LogInformation(
+ "YouTube {Operation} acknowledged; callback lease {LeaseSeconds}s. A matching retry does not extend the lease or reset subscription backoff.",
+ "WebSubVerification", leaseSeconds);
return Results.Text(challenge, "text/plain");
}
+ private static void LogRejectedVerification(
+ ILogger logger, string mode, string topic, string channelId, bool malformed)
+ {
+ var modeClassification = mode switch
+ {
+ "subscribe" => "Subscribe",
+ "unsubscribe" => "Unsubscribe",
+ _ => "Unknown",
+ };
+ bool? matchesConfiguredTopic = string.IsNullOrEmpty(channelId)
+ ? null
+ : string.Equals(topic, YouTubeWebSubSubscriptionTransitions.TopicFor(channelId), StringComparison.Ordinal);
+ logger.LogWarning(
+ "YouTube {Operation} rejected: {RejectionReason}; mode {ModeClassification}, " +
+ "topic present {TopicPresent}, matches configured topic {MatchesConfiguredTopic}.",
+ "WebSubVerification", malformed ? "Malformed" : "Unexpected", modeClassification,
+ !string.IsNullOrEmpty(topic), matchesConfiguredTopic);
+ }
+
private static async Task YouTubeWebhook(
HttpContext context,
IOptions options,
@@ -365,8 +404,8 @@ private static async Task YouTubeWebhook(
var signature = context.Request.Headers["X-Hub-Signature"].ToString();
if (!YouTubeWebhookHandler.IsValidSignature(youtube.WebhookSecret, bodyBytes, signature))
{
- logger.LogWarning("YouTube WebSub signature mismatch.");
- return Results.Unauthorized();
+ logger.LogWarning("YouTube WebSub signature missing or invalid; acknowledging and discarding the notification without processing.");
+ return Results.Ok();
}
if (env.IsDevelopment() && options.Value.EnableDevEndpoint && !youtube.IsConfigured)
@@ -408,16 +447,21 @@ internal static async Task ConfirmYouTubeLiveStatusAsync(
var channelId = youtube.ChannelId;
if (string.IsNullOrWhiteSpace(channelId))
{
- channelId = await ytClient.ResolveChannelIdAsync(youtube.ChannelHandle, cancellationToken).ConfigureAwait(false);
+ channelId = await ConfirmOperationAsync("NotificationChannelResolution", YouTubeDiagnostics.ChannelsEndpoint,
+ () => ytClient.ResolveChannelIdAsync(youtube.ChannelHandle, cancellationToken)).ConfigureAwait(false);
}
if (string.IsNullOrWhiteSpace(channelId))
{
- logger.LogWarning("Could not resolve YouTube channel id for webhook confirmation.");
+ logger.LogWarning("YouTube {Operation} returned no channel; notification confirmation is unavailable, not confirmed offline.",
+ "NotificationChannelResolution");
return;
}
- var live = await ytClient.GetCurrentLiveAsync(channelId, cancellationToken).ConfigureAwait(false);
+ var live = await ConfirmOperationAsync("NotificationConfirmation", YouTubeDiagnostics.SearchEndpoint,
+ () => ytClient.GetCurrentLiveAsync(channelId, cancellationToken)).ConfigureAwait(false);
+ logger.LogInformation("YouTube {Operation} succeeded at {CheckedAt}; observed live {ObservedLive}.",
+ "NotificationConfirmation", DateTimeOffset.UtcNow, live.Live);
await broadcaster.UpdateAsync(
new LiveStatusUpdate
{
@@ -428,6 +472,22 @@ await broadcaster.UpdateAsync(
youtube.OfflineConfirmationCount),
},
cancellationToken).ConfigureAwait(false);
+
+ async Task ConfirmOperationAsync(string operation, string endpoint, Func> action)
+ {
+ var started = Stopwatch.GetTimestamp();
+ try
+ {
+ return await action().ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
+ catch (Exception exception)
+ {
+ YouTubeDiagnostics.LogFailure(logger, exception, operation, endpoint,
+ elapsedMs: Stopwatch.GetElapsedTime(started).TotalMilliseconds);
+ throw;
+ }
+ }
}
// --- Dev-only -----------------------------------------------------------
diff --git a/src/statichost/StaticHost/Live/LiveStatusOptions.cs b/src/statichost/StaticHost/Live/LiveStatusOptions.cs
index 381ffe530..adb9868f1 100644
--- a/src/statichost/StaticHost/Live/LiveStatusOptions.cs
+++ b/src/statichost/StaticHost/Live/LiveStatusOptions.cs
@@ -109,9 +109,9 @@ public sealed class YouTubeOptions
public int PollingIntervalSeconds { get; set; } = 120;
///
- /// How often to run the quota-expensive search.list discovery request
- /// while offline. Defaults to 30 minutes, which stays below the standard
- /// YouTube Data API daily quota.
+ /// How often to run search.list discovery while offline.
+ /// Defaults to 30 minutes; this limits scheduled searches, but does not
+ /// enforce the project's daily Search Queries quota across all callers.
///
[Range(30 * 60, 24 * 60 * 60)]
public int DiscoveryPollingIntervalSeconds { get; set; } = 30 * 60;
diff --git a/src/statichost/StaticHost/Live/LiveStatusServiceCollectionExtensions.cs b/src/statichost/StaticHost/Live/LiveStatusServiceCollectionExtensions.cs
index cbfb9fc95..751f5345c 100644
--- a/src/statichost/StaticHost/Live/LiveStatusServiceCollectionExtensions.cs
+++ b/src/statichost/StaticHost/Live/LiveStatusServiceCollectionExtensions.cs
@@ -47,11 +47,16 @@ public static TBuilder AddLiveStatus(this TBuilder builder)
.AddStandardResilienceHandler();
builder.Services.AddHttpClient(TwitchAppTokenProvider.HttpClientName)
.AddStandardResilienceHandler();
- builder.Services.AddHttpClient(YouTubeClient.HttpClientName)
+ builder.Services.AddHttpClient(YouTubeClient.HttpClientName,
+ client => client.MaxResponseContentBufferSize = YouTubeClient.ResponseBufferLimit)
.AddStandardResilienceHandler();
// Subscription POST retries are scheduled in Redis, not inside the HTTP request.
builder.Services.AddHttpClient(YouTubeClient.PubSubHttpClientName,
- client => client.Timeout = TimeSpan.FromSeconds(30));
+ client =>
+ {
+ client.Timeout = TimeSpan.FromSeconds(30);
+ client.MaxResponseContentBufferSize = YouTubeClient.ResponseBufferLimit;
+ });
builder.Services.AddSingleton();
builder.Services.AddSingleton();
diff --git a/src/statichost/StaticHost/Live/README.md b/src/statichost/StaticHost/Live/README.md
index d4ed37d3f..345c60124 100644
--- a/src/statichost/StaticHost/Live/README.md
+++ b/src/statichost/StaticHost/Live/README.md
@@ -51,18 +51,160 @@ restart the retry sequence. Successful verification resets the backoff;
acceptance of the subscription POST alone does not. A channel change starts a
fresh sequence.
-Each failed attempt logs one concise warning for expected HTTP errors or
-timeouts, including the failure count and next retry deadline. Exception
-details and hub error response bodies are available at Debug; unexpected
-exceptions remain Error-level with their stack traces. Successful verification
-is logged at Information. Shutdown or leadership cancellation does not count
-as an upstream failure.
+Each failed attempt logs one warning for expected HTTP errors or timeouts,
+including the failure count and next retry deadline. Unexpected failures are
+Error-level, with a bounded `SafeStackTrace` rendered from stack frames alone,
+without exception messages, argument values, or source file paths. Expected
+HTTP and timeout warnings omit stack traces. Diagnostics intentionally omit
+raw exception messages, URLs with query strings, and response bodies, which
+can contain credentials. HTTP acceptance is logged separately from successful callback
+verification: only verification establishes or renews the lease and resets
+backoff. Shutdown or leadership cancellation does not count as an upstream
+failure.
The live and idle polling intervals remain unchanged during subscription
backoff. Google documents feed notifications for uploads and video
title/description updates, not guaranteed broadcast start/stop events, so
fallback polling is still necessary.
+### YouTube operational diagnostics
+
+In the Aspire dashboard, open **Structured logs**, select the `aspiredev`
+resource, and filter for `YouTube` (the `StaticHost.Live.YouTube` categories).
+Information, Warning, and Error events are sufficient; enabling Debug or
+logging HTTP request bodies is not required.
+
+| `Operation` | Meaning |
+| --- | --- |
+| `ChannelResolution` | Resolve the configured handle through `channels.list`. A missing channel means detection is unavailable, not offline. |
+| `OfflineDiscovery` | Interval-limited `search.list` check for a broadcast while no live video is known; not a daily quota guard. |
+| `KnownVideoStatus` | Low-cost `videos.list` check of the currently known live video. |
+| `WebSubSubscribe` | Subscription POST accepted or failed; acceptance does **not** prove callback verification. |
+| `WebSubVerification` | A matching callback was acknowledged, with its `LeaseSeconds`; matching retries do not extend the original lease or reset backoff. |
+| `WebSubDenialReport` | An untrusted, unauthenticated `hub.mode=denied` report; not proof that Google denied a subscription. No state or polling changes. |
+| `NotificationChannelResolution` / `NotificationConfirmation` | Resolve the channel or run the confirming search after a signed notification. |
+| `BackgroundTick` | A failure outside the provider calls, such as state coordination. |
+
+Failed provider operations include the query-free outbound `Endpoint`,
+`ElapsedMs` measured with a monotonic clock, numeric `StatusCode`,
+standard `StatusReason` (not an untrusted reason phrase), `FailureType`,
+`IsTimeout`, `HttpRequestError`, `SocketError`, and `InnerFailureType` where
+available. Google JSON errors add bounded `ProviderReason` and `ProviderDomain`
+codes, for example `quotaExceeded` / `youtube.quota` or `accessNotConfigured` /
+`usageLimits`. These distinguish quota exhaustion from API configuration,
+network, and hub failures.
+
+The duration covers the client operation, including any existing Data API
+resilience retries and response parsing, but not the later Redis backoff write.
+For example, `WebSubSubscribe` with endpoint
+`pubsubhubbub.appspot.com/subscribe` and status 503 identifies an outbound hub
+failure, not an inbound website 503. Duration and status alone cannot establish
+the underlying provider cause.
+
+HTTP acceptance is logged before recording the sent request in Redis, so a
+subsequent persistence failure does not hide the provider's successful response.
+
+`ProviderDetail` contains only a fixed classification, such as temporary
+unavailability, transient error, invalid topic, or callback verification failure. Unknown or
+malformed bodies are omitted; `BodyTruncated` indicates the diagnostic read
+exceeded 4,096 characters. No raw HTML, provider message, API key, webhook
+secret, verification token, authorization header, or subscription form is
+logged by these diagnostics. Subscription failures also expose `FailureCount`
+and `RetryAt`. A valid provider `Retry-After` header is recorded as typed
+`RetryAfterSeconds` or `RetryAfterDate`, never as raw header text. These fields
+are diagnostic only and do not change the persisted retry schedule. For
+example, a 503 with `Transient error; please try again later` and
+`Retry-After: 120` reports a transient error and 120 seconds; it does not stop
+polling or prove why a broadcast was missed.
+
+Both named YouTube HTTP clients cap response buffering at 1 MiB, including
+responses without a Content-Length header. The existing request timeouts still
+cover buffering. Responses exceeding that limit fail before diagnostic body
+classification; they are logged as request failures, not offline observations.
+The 4,096-character classification limit applies within that response-size cap.
+
+When `WebhookSecret` is configured, the callback acknowledges notifications with missing, invalid, or mismatched
+signatures with HTTP 200, as required by PubSubHubbub authenticated content
+distribution, but discards them before parsing, state updates, coordination,
+or confirmation queuing. A warning explicitly records the discard. HTTP 200
+does not mean that a notification was authenticated or processed. Development
+overrides still require both a valid signature and the dev command secret.
+If `WebhookSecret` is empty, the callback instead returns HTTP 503 before
+signature validation and logs the missing configuration.
+
+A denial GET requires `hub.topic`, but not a challenge, lease, or verification
+token. The static callback cannot authenticate these reports, so it logs only
+topic presence, a nullable comparison with the configured channel's topic,
+reason presence/length, and a bounded fixed classification. If only a channel
+handle is configured, topic correlation is unavailable. Even a matching topic
+does not authenticate the sender. Denials never confirm, revoke, clear, or
+otherwise change subscription state, live status, backoff, or polling.
+Subscribe verification still requires the matching `hub.verify_token`,
+challenge, and valid lease; repeated matching verifications retain the original
+renewal deadline.
+
+Rejected verification callbacks log a fixed `RejectionReason` (`Malformed` or
+`Unexpected`), a `ModeClassification` (`Subscribe`, `Unsubscribe`, or `Unknown`),
+`TopicPresent`, and nullable `MatchesConfiguredTopic`. Neither submitted modes
+nor topics enter the formatted message or structured fields. Topic correlation
+is diagnostic only, not callback authorization.
+
+Twitch callback diagnostics retain rejected timestamps, unknown message types,
+and replay/in-progress message IDs as sanitized fields: values are bounded to
+128 characters (including an ellipsis when truncated), and characters outside
+ASCII letters, digits, `_`, `.`, `:`,
+`+`, and `-` are replaced with `_`. This preserves ordinary RFC3339 timestamps
+and message-type names without letting line separators or control characters
+forge log entries. Revocations retain the subscription ID, type, and status using
+the same sanitizer, but omit the full body, transport URLs, and any other payload
+fields. Invalid revocation JSON still returns the original acknowledgment and
+logs that details were unavailable. Signature validation, freshness checks,
+replay coordination, challenge responses, and HTTP statuses are unchanged.
+
+Successful discovery logs `LastSuccessfulDiscoveryAt`, `LastDiscoveryLive`,
+and `NextDiscoveryAt`. The worker includes its last successful discovery time
+and result in subsequent failure logs, so operators can distinguish a
+successful offline observation from unavailable detection. These are
+process-local diagnostic values, not new Redis or public snapshot fields;
+they start empty after a restart, channel change, or before the first successful
+discovery.
+The reset happens before resolving changed channel settings, so a failed
+resolution cannot report a previous channel's successful discovery.
+A later successful check advances the timestamp, indicating polling recovery.
+Known-video and notification checks log `CheckedAt` and `ObservedLive`.
+These observations precede the guarded state update: an offline observation
+does not bypass the configured two-check confirmation rule.
+
+Without a useful notification, a new broadcast may take up to the configured
+30-minute discovery interval (plus request/tick delays) to be detected.
+Failures can extend that delay. Google's current
+[quota guide](https://developers.google.com/youtube/v3/determine_quota_cost) and
+[`search.list` reference](https://developers.google.com/youtube/v3/docs/search/list)
+describe a separate Search Queries bucket: one unit per search call, with a
+default limit of 100 calls per day. Other endpoints used here (`channels.list`
+and `videos.list`) each cost one unit in the general bucket, whose default daily
+allocation is 10,000 units. Actual project limits and usage must be checked in
+Google Cloud; daily quotas reset at midnight Pacific Time.
+
+The default idle schedule permits approximately 48 scheduled searches per
+24 hours for a continuously running leader before retries. Known-video checks
+can make 720 calls per 24 hours while continuously live. A Pacific calendar day
+can be 23 or 25 hours at daylight-saving transitions.
+
+These intervals are **not project-wide daily quota enforcement**. Notification
+confirmation searches use a separate Redis gate that admits a confirmation
+every 30 seconds, not the discovery interval. Data API resilience retries,
+restarts/leader changes (the next discovery timestamp is process-local), and
+other clients sharing the Google Cloud project can add usage. A budget must
+account for each outbound attempt across these paths and all replicas, not just
+successful worker ticks. The current implementation does not maintain such a
+shared daily budget.
+
+The WebSub subscription POST does not use a Data API key. Its Retry-After and
+subscription backoff are separate from Data API quota accounting; the observed
+hub 503 does not establish quota exhaustion. These diagnostic changes do not
+alter polling intervals, retry policies, or leader coordination.
+
## Configuration
Bind from the `Live` section of configuration. In unconfigured local runs,
@@ -311,6 +453,13 @@ dotnet test .\tests\Aspire.Dev.AppHost.Tests\Aspire.Dev.AppHost.Tests.csproj --c
dotnet test .\tests\StaticHost.Tests\StaticHost.Tests.csproj --configuration Release --filter "Category!=RedisIntegration"
```
+For the focused YouTube diagnostics, callback authorization, and polling regressions, explicitly keep
+frontend build scripts and package installation disabled:
+
+```powershell
+dotnet test .\tests\StaticHost.Tests\StaticHost.Tests.csproj --configuration Release --filter "(FullyQualifiedName~YouTube|FullyQualifiedName~LiveEndpointsTests)&Category!=RedisIntegration" -p:ShouldRunBuildScript=false -p:ShouldRunNpmInstall=false
+```
+
The real-Redis group has the xUnit trait `Category=RedisIntegration` and requires
a running container runtime, such as Docker. Following the
[Aspire testing pattern](https://aspire.dev/testing/overview/), its shared fixture
diff --git a/src/statichost/StaticHost/Live/Twitch/TwitchWebhookHandler.cs b/src/statichost/StaticHost/Live/Twitch/TwitchWebhookHandler.cs
index 68dd2424b..8b6b604d0 100644
--- a/src/statichost/StaticHost/Live/Twitch/TwitchWebhookHandler.cs
+++ b/src/statichost/StaticHost/Live/Twitch/TwitchWebhookHandler.cs
@@ -2,6 +2,7 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
+using System.Text.RegularExpressions;
namespace StaticHost.Live.Twitch;
@@ -56,6 +57,42 @@ public static bool IsFresh(string timestamp, DateTimeOffset now, TimeSpan maxAge
return age <= maxAge && age >= TimeSpan.FromMinutes(-1);
}
+ internal static string SanitizeDiagnosticValue(string value)
+ {
+ const int maxLength = 128;
+ var bounded = value.Length > maxLength ? value[..(maxLength - 3)] + "..." : value;
+ return Regex.Replace(bounded, "[^a-zA-Z0-9_.:+-]", "_");
+ }
+
+ private static void LogRevocation(string bodyJson, ILogger logger)
+ {
+ try
+ {
+ using var document = JsonDocument.Parse(bodyJson);
+ if (document.RootElement.ValueKind != JsonValueKind.Object ||
+ !document.RootElement.TryGetProperty("subscription", out var subscription) ||
+ subscription.ValueKind != JsonValueKind.Object)
+ {
+ logger.LogWarning("Twitch EventSub subscription revoked; subscription details unavailable.");
+ return;
+ }
+
+ // Retain the subscription state, not transport URLs or other payload data.
+ logger.LogWarning(
+ "Twitch EventSub subscription revoked (Id: {SubscriptionId}, Type: {SubscriptionType}, Status: {SubscriptionStatus}).",
+ LogField("id"), LogField("type"), LogField("status"));
+
+ string LogField(string name) =>
+ subscription.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
+ ? SanitizeDiagnosticValue(value.GetString()!)
+ : "";
+ }
+ catch (JsonException)
+ {
+ logger.LogWarning("Twitch EventSub subscription revoked; payload is not valid JSON.");
+ }
+ }
+
///
/// Branches on Twitch-Eventsub-Message-Type:
///
@@ -121,10 +158,11 @@ await broadcaster.UpdateAsync(
return Results.Ok();
}
case "revocation":
- logger.LogWarning("Twitch EventSub subscription revoked: {Body}", bodyJson);
+ LogRevocation(bodyJson, logger);
return Results.NoContent();
default:
- logger.LogDebug("Twitch webhook of unknown message-type {Type}", messageType);
+ logger.LogDebug("Twitch webhook of unknown message type {MessageType} (sanitized).",
+ SanitizeDiagnosticValue(messageType));
return Results.Ok();
}
}
diff --git a/src/statichost/StaticHost/Live/YouTube/YouTubeClient.cs b/src/statichost/StaticHost/Live/YouTube/YouTubeClient.cs
index 106715fea..153ee6036 100644
--- a/src/statichost/StaticHost/Live/YouTube/YouTubeClient.cs
+++ b/src/statichost/StaticHost/Live/YouTube/YouTubeClient.cs
@@ -11,6 +11,8 @@ public sealed class YouTubeClient(
IOptionsMonitor options,
ILogger logger) : IYouTubeClient
{
+ internal const int ResponseBufferLimit = 1024 * 1024;
+
/// Name of the registered for the Data API.
public const string HttpClientName = "youtube";
@@ -34,7 +36,8 @@ private HttpClient ApiClient()
var url = $"channels?part=id&forHandle={Uri.EscapeDataString(clean)}&key={Uri.EscapeDataString(apiKey)}";
using var response = await ApiClient().GetAsync(url, cancellationToken).ConfigureAwait(false);
- response.EnsureSuccessStatusCode();
+ await YouTubeDiagnostics.EnsureSuccessAsync(response, cancellationToken,
+ apiKey, options.CurrentValue.YouTube.WebhookSecret).ConfigureAwait(false);
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
@@ -56,7 +59,8 @@ public async Task GetCurrentLiveAsync(string channelId, Cance
var url = $"search?part=id&channelId={Uri.EscapeDataString(channelId)}&eventType=live&type=video&key={Uri.EscapeDataString(apiKey)}";
using var response = await ApiClient().GetAsync(url, cancellationToken).ConfigureAwait(false);
- response.EnsureSuccessStatusCode();
+ await YouTubeDiagnostics.EnsureSuccessAsync(response, cancellationToken,
+ apiKey, options.CurrentValue.YouTube.WebhookSecret).ConfigureAwait(false);
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
@@ -80,7 +84,8 @@ public async Task GetVideoLiveStatusAsync(string videoId, Can
var url = $"videos?part=liveStreamingDetails&id={Uri.EscapeDataString(videoId)}&key={Uri.EscapeDataString(apiKey)}";
using var response = await ApiClient().GetAsync(url, cancellationToken).ConfigureAwait(false);
- response.EnsureSuccessStatusCode();
+ await YouTubeDiagnostics.EnsureSuccessAsync(response, cancellationToken,
+ apiKey, options.CurrentValue.YouTube.WebhookSecret).ConfigureAwait(false);
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
@@ -129,9 +134,9 @@ public async Task SubscribeAsync(
using var response = await c.PostAsync("subscribe", form, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
- var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
- logger.LogDebug("YouTube WebSub subscribe failed: {Status} {Body}", response.StatusCode, body);
- response.EnsureSuccessStatusCode();
+ logger.LogDebug("YouTube WebSub hub returned HTTP {StatusCode}.", (int)response.StatusCode);
+ await YouTubeDiagnostics.EnsureSuccessAsync(response, cancellationToken,
+ options.CurrentValue.YouTube.ApiKey, secret, verifyToken).ConfigureAwait(false);
}
}
}
diff --git a/src/statichost/StaticHost/Live/YouTube/YouTubeDiagnostics.cs b/src/statichost/StaticHost/Live/YouTube/YouTubeDiagnostics.cs
new file mode 100644
index 000000000..36073c282
--- /dev/null
+++ b/src/statichost/StaticHost/Live/YouTube/YouTubeDiagnostics.cs
@@ -0,0 +1,187 @@
+using System.Diagnostics;
+using System.Net.Sockets;
+using System.Text.Json;
+using Microsoft.AspNetCore.WebUtilities;
+using Polly.Timeout;
+
+namespace StaticHost.Live.YouTube;
+
+internal static class YouTubeDiagnostics
+{
+ internal const string ChannelsEndpoint = "www.googleapis.com/youtube/v3/channels";
+ internal const string SearchEndpoint = "www.googleapis.com/youtube/v3/search";
+ internal const string VideosEndpoint = "www.googleapis.com/youtube/v3/videos";
+ internal const string SubscribeEndpoint = "pubsubhubbub.appspot.com/subscribe";
+ private const string DetailsKey = "YouTube.ResponseDiagnostics";
+ private const string LoggedKey = "YouTube.FailureLogged";
+ private const int BodyLimit = 4096;
+
+ private sealed record ResponseDetails(string? Reason, string? Domain, string Detail, bool Truncated)
+ {
+ public double? RetryAfterSeconds { get; init; }
+ public DateTimeOffset? RetryAfterDate { get; init; }
+ }
+
+ internal static async Task EnsureSuccessAsync(
+ HttpResponseMessage response, CancellationToken cancellationToken, params string[] secrets)
+ {
+ if (response.IsSuccessStatusCode) return;
+
+ ResponseDetails details;
+ try
+ {
+ using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ using var reader = new StreamReader(stream);
+ var buffer = new char[BodyLimit + 1];
+ var count = await reader.ReadBlockAsync(buffer.AsMemory(), cancellationToken).ConfigureAwait(false);
+ details = DescribeBody(new string(buffer, 0, Math.Min(count, BodyLimit)), count > BodyLimit, secrets);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
+ catch (Exception)
+ {
+ // Diagnostic decoding must never replace the original HTTP failure.
+ details = new(null, null, "Response body could not be read; body omitted", false);
+ }
+
+ var retryAfter = response.Headers.RetryAfter;
+ details = details with
+ {
+ RetryAfterSeconds = retryAfter?.Delta?.TotalSeconds,
+ RetryAfterDate = retryAfter?.Date,
+ };
+
+ try
+ {
+ response.EnsureSuccessStatusCode();
+ }
+ catch (HttpRequestException exception)
+ {
+ exception.Data[DetailsKey] = details;
+ throw;
+ }
+ }
+
+ private static ResponseDetails DescribeBody(string body, bool truncated, string[] secrets)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(body);
+ if (doc.RootElement.ValueKind == JsonValueKind.Object &&
+ doc.RootElement.TryGetProperty("error", out var error) &&
+ error.ValueKind == JsonValueKind.Object &&
+ error.TryGetProperty("errors", out var errors) &&
+ errors.ValueKind == JsonValueKind.Array && errors.GetArrayLength() > 0 &&
+ errors[0].ValueKind == JsonValueKind.Object)
+ {
+ return new(
+ ReadCode(errors[0], "reason", secrets),
+ ReadCode(errors[0], "domain", secrets),
+ "Google structured error; message omitted", truncated);
+ }
+ }
+ catch (JsonException) { }
+
+ return new(null, null, ClassifyProviderText(body), truncated);
+ }
+
+ internal static void LogUntrustedDenial(
+ ILogger logger, string reason, bool topicPresent, bool? matchesConfiguredTopic)
+ {
+ var truncated = reason.Length > BodyLimit;
+ logger.LogWarning(
+ "YouTube {Operation}: untrusted, unauthenticated denial report; not proof of a hub decision. " +
+ "Topic present {TopicPresent}, matches configured topic {MatchesConfiguredTopic}; " +
+ "reason present {ReasonPresent}, length {ReasonLength}, detail {ProviderDetail}, truncated {BodyTruncated}. " +
+ "Subscription state and live-status polling are unchanged.",
+ "WebSubDenialReport", topicPresent, matchesConfiguredTopic, reason.Length > 0, reason.Length,
+ ClassifyProviderText(truncated ? reason[..BodyLimit] : reason), truncated);
+ }
+
+ private static string ClassifyProviderText(string text)
+ {
+ // Only fixed classifications leave this boundary, never echoed HTML, URLs,
+ // callback values or arbitrary provider messages.
+ return text.Contains("temporarily unavailable", StringComparison.OrdinalIgnoreCase)
+ ? "Provider reports temporary unavailability"
+ : text.Contains("transient error", StringComparison.OrdinalIgnoreCase)
+ ? "Provider reports a transient error"
+ : text.Contains("invalid topic", StringComparison.OrdinalIgnoreCase)
+ ? "Provider reports an invalid topic"
+ : text.Contains("verification failed", StringComparison.OrdinalIgnoreCase)
+ ? "Provider reports callback verification failure"
+ : "Unrecognized provider response; body omitted";
+ }
+
+ private static string? ReadCode(JsonElement error, string name, string[] secrets)
+ {
+ if (!error.TryGetProperty(name, out var property) || property.ValueKind != JsonValueKind.String)
+ return null;
+ var value = property.GetString();
+ if (string.IsNullOrEmpty(value) || value.Length > 64 ||
+ value.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not '.' and not '_' and not '-') ||
+ secrets.Any(secret => !string.IsNullOrEmpty(secret) && value.Contains(secret, StringComparison.Ordinal)))
+ return null;
+ return value;
+ }
+
+ internal static void LogFailure(
+ ILogger logger, Exception exception, string operation, string endpoint,
+ YouTubeWebSubRetryState? retry = null,
+ DateTimeOffset? lastSuccessfulDiscoveryAt = null, bool? lastDiscoveryLive = null,
+ bool skipIfLogged = false, double? elapsedMs = null)
+ {
+ if (skipIfLogged && exception.Data[LoggedKey] is true)
+ {
+ exception.Data.Remove(LoggedKey);
+ return;
+ }
+ exception.Data[LoggedKey] = true;
+ var details = exception.Data[DetailsKey] as ResponseDetails;
+ var http = exception as HttpRequestException;
+ var status = http?.StatusCode is { } code ? (int?)code : null;
+ var timeout = exception is OperationCanceledException or TimeoutException or TimeoutRejectedException;
+ SocketException? socket = null;
+ var inner = exception.InnerException;
+ for (var depth = 0; inner is not null && depth < 4; depth++, inner = inner.InnerException)
+ {
+ socket ??= inner as SocketException;
+ timeout |= inner is TimeoutException or TimeoutRejectedException;
+ }
+ var expected = http is not null || timeout || exception is JsonException;
+ var message =
+ "YouTube {Operation} failed at {Endpoint} after {ElapsedMs} ms: {FailureType}, HTTP {StatusCode} {StatusReason}; " +
+ "provider reason {ProviderReason}, domain {ProviderDomain}, detail {ProviderDetail}, truncated {BodyTruncated}; " +
+ "provider Retry-After seconds {RetryAfterSeconds}, date {RetryAfterDate}; " +
+ "timeout {IsTimeout}, network {HttpRequestError}, socket {SocketError}, inner failure {InnerFailureType}. " +
+ "Last successful discovery {LastSuccessfulDiscoveryAt}, live {LastDiscoveryLive}. Failure is not an offline observation.";
+ List