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");