diff --git a/scripts/e2e/parallels/macos-smoke.ts b/scripts/e2e/parallels/macos-smoke.ts index aee81d9a4fb..4af0c160aeb 100755 --- a/scripts/e2e/parallels/macos-smoke.ts +++ b/scripts/e2e/parallels/macos-smoke.ts @@ -1013,9 +1013,31 @@ sleep 1`, deadline=$((SECONDS + 120)) while [ $SECONDS -lt $deadline ]; do if curl -fsSL --connect-timeout 2 --max-time 5 http://127.0.0.1:18789/ >/tmp/openclaw-dashboard-smoke.html 2>/dev/null; then - grep -F 'OpenClaw Control' /tmp/openclaw-dashboard-smoke.html >/dev/null && - grep -F '' /tmp/openclaw-dashboard-smoke.html >/dev/null && - exit 0 + if grep -F 'OpenClaw Control' /tmp/openclaw-dashboard-smoke.html >/dev/null && + grep -F '' /tmp/openclaw-dashboard-smoke.html >/dev/null; then + asset_paths="$( + sed -nE 's/.*<(script|link)[^>]*(src|href)=["'"'"']([^"'"'"']+)["'"'"'].*/\3/p' /tmp/openclaw-dashboard-smoke.html | + grep -E '(^|/)assets/' | + grep -Ev '^(https?:)?//' | + sort -u + )" + if [ -n "$asset_paths" ]; then + assets_ok=1 + while IFS= read -r asset_path; do + [ -n "$asset_path" ] || continue + case "$asset_path" in + http://127.0.0.1:18789/*) asset_url="$asset_path" ;; + /*) asset_url="http://127.0.0.1:18789$asset_path" ;; + *) asset_url="http://127.0.0.1:18789/$asset_path" ;; + esac + curl -fsSL --connect-timeout 2 --max-time 5 "$asset_url" >/dev/null 2>/dev/null || + assets_ok=0 + done <OpenClaw Control"); + const app = html.includes(""); + return { app, ready: title && app, title }; +} + +export function resolveDashboardAssetUrls(dashboardUrl: string, html: string): string[] { + const baseUrl = new URL(dashboardUrl); + const assetUrls = new Set(); + const assetAttributePattern = /<(?:script|link)\b[^>]*(?:src|href)\s*=\s*(["'])([^"']+)\1/giu; + for (const match of html.matchAll(assetAttributePattern)) { + const rawUrl = match[2]?.trim(); + if (!rawUrl) { + continue; + } + const assetUrl = new URL(rawUrl, baseUrl); + if (assetUrl.origin === baseUrl.origin && assetUrl.pathname.includes("/assets/")) { + assetUrls.add(assetUrl.href); + } + } + return [...assetUrls].toSorted(); +} + +export async function verifyDashboardAssetUrls( + assetUrls: string[], + fetchAsset: typeof fetch = fetch, +): Promise<{ failures: string[]; ok: boolean }> { + if (assetUrls.length === 0) { + return { failures: ["no dashboard asset URLs found"], ok: false }; + } + const failures: string[] = []; + for (const assetUrl of assetUrls) { + try { + const response = await fetchAsset(assetUrl, { + signal: AbortSignal.timeout(CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS), + }); + await response.body?.cancel().catch(() => undefined); + if (!response.ok) { + failures.push(`${assetUrl} status=${response.status}`); + } + } catch (error) { + failures.push(`${assetUrl} ${formatError(error)}`); + } + } + return { failures, ok: failures.length === 0 }; +} + async function waitForDiscordMessage(params) { const deadline = Date.now() + 3 * 60 * 1000; while (Date.now() < deadline) { @@ -3437,18 +3488,22 @@ async function runDashboardSmoke(params) { signal: AbortSignal.timeout(CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS), }); const html = await readBoundedCrossOsResponseText(response); - if ( - response.ok && - html.includes("OpenClaw Control") && - html.includes("") - ) { + const markers = dashboardHtmlMarkerStatus(html); + const assetUrls = resolveDashboardAssetUrls(dashboardUrl, html); + if (response.ok && markers.ready) { + const assets = await verifyDashboardAssetUrls(assetUrls); + if (assets.ok) { + logStream.write( + `${new Date().toISOString()} dashboard-ready status=${response.status} assets=${assetUrls.length}\n`, + ); + return; + } logStream.write( - `${new Date().toISOString()} dashboard-ready status=${response.status}\n`, + `${new Date().toISOString()} dashboard-assets-not-ready status=${response.status} assets=${assetUrls.length} failures=${assets.failures.join(" | ")}\n`, ); - return; } logStream.write( - `${new Date().toISOString()} dashboard-not-ready status=${response.status} title=${html.includes("OpenClaw Control")} app=${html.includes("")}\n`, + `${new Date().toISOString()} dashboard-not-ready status=${response.status} title=${markers.title} app=${markers.app} assets=${assetUrls.length}\n`, ); } catch (error) { logStream.write( diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index fecf7424329..e596ac6ef5c 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -33,6 +33,7 @@ import { canConnectToLoopbackPort, buildDiscordSmokeGuildsConfig, buildRealUpdateEnv, + dashboardHtmlMarkerStatus, CROSS_OS_FETCH_BODY_MAX_CHARS, CROSS_OS_GATEWAY_READY_TIMEOUT_MS, CROSS_OS_GATEWAY_STATUS_COMMAND_TIMEOUT_MS, @@ -61,6 +62,7 @@ import { readInstalledVersion, readBoundedCrossOsResponseText, readRunnerOverrideEnv, + resolveDashboardAssetUrls, resolveCrossOsAgentTurnOptional, runCommand, resolveCommandSpawnInvocation, @@ -85,6 +87,7 @@ import { shouldSkipOptionalCrossOsAgentTurnError, shouldUseManagedGatewayForInstallerRuntime, shouldUseManagedGatewayService, + verifyDashboardAssetUrls, verifyDevUpdateStatus, verifyPackagedUpgradeUpdateResult, verifyWindowsPackagedUpgradeFallbackInstall, @@ -159,6 +162,40 @@ describe("scripts/openclaw-cross-os-release-checks", () => { expect(CROSS_OS_FETCH_BODY_MAX_CHARS).toBeGreaterThan(1024); }); + it("requires dashboard root markers and same-origin asset URLs", () => { + const html = [ + "OpenClaw Control", + "", + '', + '', + '', + ].join("\n"); + + expect(dashboardHtmlMarkerStatus(html)).toEqual({ app: true, ready: true, title: true }); + expect(resolveDashboardAssetUrls("http://127.0.0.1:18789/", html)).toEqual([ + "http://127.0.0.1:18789/assets/index.css", + "http://127.0.0.1:18789/assets/index.js", + ]); + }); + + it("fails dashboard readiness when assets are missing or unreachable", async () => { + await expect(verifyDashboardAssetUrls([])).resolves.toEqual({ + failures: ["no dashboard asset URLs found"], + ok: false, + }); + + const result = await verifyDashboardAssetUrls( + ["http://127.0.0.1:18789/assets/index.css", "http://127.0.0.1:18789/assets/index.js"], + async (url) => + new Response("", { + status: String(url).endsWith(".js") ? 404 : 200, + }), + ); + + expect(result.ok).toBe(false); + expect(result.failures).toEqual(["http://127.0.0.1:18789/assets/index.js status=404"]); + }); + it("keeps gateway RPC status probes patient enough for live release startup", () => { expect(CROSS_OS_GATEWAY_STATUS_RPC_TIMEOUT_MS).toBeGreaterThanOrEqual(30_000); expect(CROSS_OS_GATEWAY_STATUS_COMMAND_TIMEOUT_MS).toBeGreaterThan( diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index a793befe0da..ece0ff6d81e 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -806,6 +806,14 @@ if (isPrlctl) { expect(windows).toContain("OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS"); }); + it("requires macOS dashboard smoke to load built assets", () => { + const macos = readFileSync(TS_PATHS.macos, "utf8"); + + expect(macos).toContain("asset_paths="); + expect(macos).toContain("grep -E '(^|/)assets/'"); + expect(macos).toContain('curl -fsSL --connect-timeout 2 --max-time 5 "$asset_url"'); + }); + it("passes aggregate model overrides into each OS fresh lane", () => { const script = readFileSync(TS_PATHS.npmUpdate, "utf8");