fix(canvas): surface the real cause of a canvas runtime startup failure (#3903)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { WebAppFrame } from "./web-app-frame";
|
||||
import type { WebAppStartupFailureReason } from "./web-app-startup";
|
||||
|
||||
export type CanvasPageProps = {
|
||||
runtimeUrl?: string | null;
|
||||
title: string;
|
||||
onLoad?: () => void;
|
||||
onError?: () => void;
|
||||
onError?: (reason: WebAppStartupFailureReason) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,11 @@ vi.mock("@/components/theme/app-theme", () => ({
|
||||
useTheme: () => theme,
|
||||
}));
|
||||
|
||||
function acknowledge(frame: HTMLElement, result: "ready" | "failed" = "ready") {
|
||||
function acknowledge(
|
||||
frame: HTMLElement,
|
||||
result: "ready" | "failed" = "ready",
|
||||
code: "document_error" | "context_unavailable" = "document_error",
|
||||
) {
|
||||
const probe = (frame as HTMLIFrameElement).contentWindow;
|
||||
const message = (
|
||||
probe as unknown as { postMessage: ReturnType<typeof vi.fn> }
|
||||
@@ -27,7 +31,7 @@ function acknowledge(frame: HTMLElement, result: "ready" | "failed" = "ready") {
|
||||
version: WEB_APP_STARTUP_VERSION,
|
||||
nonce: message.nonce,
|
||||
result,
|
||||
...(result === "failed" ? { code: "document_error" } : {}),
|
||||
...(result === "failed" ? { code } : {}),
|
||||
},
|
||||
source: probe as unknown as Window,
|
||||
}),
|
||||
@@ -107,7 +111,7 @@ describe("WebAppFrame updates and failure handling", () => {
|
||||
expect(screen.getByTitle("Canvas")).toBe(frame);
|
||||
});
|
||||
|
||||
it("ignores wrong-frame results and becomes unavailable at the deadline", async () => {
|
||||
it("ignores wrong-frame results and reports a timeout reason at the deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
const onError = vi.fn();
|
||||
render(<WebAppFrame runtimeUrl="/runtime/one/" title="Canvas" onError={onError} />);
|
||||
@@ -137,6 +141,7 @@ describe("WebAppFrame updates and failure handling", () => {
|
||||
expect(screen.getByTestId("web-app-frame").getAttribute("data-frame-state")).toBe("loading");
|
||||
act(() => vi.advanceTimersByTime(15_000));
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
expect(onError).toHaveBeenCalledWith("timeout");
|
||||
expect(screen.getByTestId("web-app-frame").getAttribute("data-frame-state")).toBe(
|
||||
"unavailable",
|
||||
);
|
||||
@@ -144,6 +149,24 @@ describe("WebAppFrame updates and failure handling", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([["document_error"], ["context_unavailable"]] as const)(
|
||||
"passes the guest-reported %s code to onError",
|
||||
async (code) => {
|
||||
const onError = vi.fn();
|
||||
render(<WebAppFrame runtimeUrl="/runtime/one/" title="Canvas" onError={onError} />);
|
||||
const frame = screen.getByTitle("Canvas");
|
||||
const postMessage = vi.fn();
|
||||
Object.defineProperty(frame, "contentWindow", {
|
||||
configurable: true,
|
||||
value: { postMessage },
|
||||
});
|
||||
fireEvent.load(frame);
|
||||
acknowledge(frame, "failed", code);
|
||||
await waitFor(() => expect(onError).toHaveBeenCalledOnce());
|
||||
expect(onError).toHaveBeenCalledWith(code);
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the phone safe-area inset and renders no iframe without a capability", () => {
|
||||
responsive.isMobile = true;
|
||||
const { rerender } = render(<WebAppFrame title="Canvas" />);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
createWebAppStartupProbe,
|
||||
parseWebAppStartupResult,
|
||||
WEB_APP_STARTUP_TIMEOUT_MS,
|
||||
type WebAppStartupFailureReason,
|
||||
} from "./web-app-startup";
|
||||
|
||||
type WebAppFrameProps = {
|
||||
@@ -20,7 +21,7 @@ type WebAppFrameProps = {
|
||||
title: string;
|
||||
className?: string;
|
||||
onLoad?: () => void;
|
||||
onError?: () => void;
|
||||
onError?: (reason: WebAppStartupFailureReason) => void;
|
||||
};
|
||||
|
||||
type FrameState = "loading" | "ready" | "unavailable";
|
||||
@@ -32,9 +33,71 @@ type WebAppFrameLifecycleProps = {
|
||||
iframeRef: IframeReference;
|
||||
sendAppearance: () => void;
|
||||
onLoad?: () => void;
|
||||
onError?: () => void;
|
||||
onError?: (reason: WebAppStartupFailureReason) => void;
|
||||
};
|
||||
|
||||
type WebAppStartupOutcome =
|
||||
| { result: "ready" }
|
||||
| { result: "failed"; reason: WebAppStartupFailureReason };
|
||||
|
||||
type StartupAttempt = {
|
||||
nonce: string;
|
||||
frameWindow: Window | null;
|
||||
settled: boolean;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
function useWebAppStartupMessageListener({
|
||||
runtimeUrl,
|
||||
iframeRef,
|
||||
attemptRef,
|
||||
finishAttempt,
|
||||
}: {
|
||||
runtimeUrl?: string | null;
|
||||
iframeRef: IframeReference;
|
||||
attemptRef: { current: StartupAttempt | null };
|
||||
finishAttempt: (outcome: WebAppStartupOutcome) => void;
|
||||
}) {
|
||||
useLayoutEffect(() => {
|
||||
if (!runtimeUrl) return;
|
||||
const attempt: StartupAttempt = {
|
||||
nonce: createWebAppStartupNonce(),
|
||||
frameWindow: null,
|
||||
settled: false,
|
||||
timer: null,
|
||||
};
|
||||
attemptRef.current = attempt;
|
||||
attempt.timer = setTimeout(
|
||||
() => finishAttempt({ result: "failed", reason: "timeout" }),
|
||||
WEB_APP_STARTUP_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
const handleStartupMessage = (event: MessageEvent<unknown>) => {
|
||||
if (attemptRef.current !== attempt || attempt.settled) return;
|
||||
const currentFrameWindow = iframeRef.current?.contentWindow;
|
||||
if (
|
||||
!currentFrameWindow ||
|
||||
attempt.frameWindow !== currentFrameWindow ||
|
||||
event.source !== currentFrameWindow
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const result = parseWebAppStartupResult(event.data, attempt.nonce);
|
||||
if (!result) return;
|
||||
finishAttempt(
|
||||
result.result === "ready" ? { result: "ready" } : { result: "failed", reason: result.code },
|
||||
);
|
||||
};
|
||||
window.addEventListener("message", handleStartupMessage);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleStartupMessage);
|
||||
if (attempt.timer !== null) clearTimeout(attempt.timer);
|
||||
attempt.timer = null;
|
||||
if (attemptRef.current === attempt) attemptRef.current = null;
|
||||
};
|
||||
}, [finishAttempt, iframeRef, runtimeUrl]);
|
||||
}
|
||||
|
||||
function useWebAppFrameLifecycle({
|
||||
runtimeUrl,
|
||||
iframeRef,
|
||||
@@ -47,12 +110,7 @@ function useWebAppFrameLifecycle({
|
||||
const onLoadRef = useRef(onLoad);
|
||||
const onErrorRef = useRef(onError);
|
||||
const sendAppearanceRef = useRef(sendAppearance);
|
||||
const attemptRef = useRef<{
|
||||
nonce: string;
|
||||
frameWindow: Window | null;
|
||||
settled: boolean;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
} | null>(null);
|
||||
const attemptRef = useRef<StartupAttempt | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
onLoadRef.current = onLoad;
|
||||
@@ -70,7 +128,7 @@ function useWebAppFrameLifecycle({
|
||||
if (frameReadyRef.current) sendAppearance();
|
||||
}, [sendAppearance]);
|
||||
|
||||
const finishAttempt = useCallback((result: "ready" | "failed") => {
|
||||
const finishAttempt = useCallback((outcome: WebAppStartupOutcome) => {
|
||||
const attempt = attemptRef.current;
|
||||
if (!attempt || attempt.settled) return;
|
||||
attempt.settled = true;
|
||||
@@ -78,10 +136,10 @@ function useWebAppFrameLifecycle({
|
||||
clearTimeout(attempt.timer);
|
||||
attempt.timer = null;
|
||||
}
|
||||
if (result === "failed") {
|
||||
if (outcome.result === "failed") {
|
||||
frameReadyRef.current = false;
|
||||
setFrameState("unavailable");
|
||||
onErrorRef.current?.();
|
||||
onErrorRef.current?.(outcome.reason);
|
||||
return;
|
||||
}
|
||||
frameReadyRef.current = true;
|
||||
@@ -93,39 +151,7 @@ function useWebAppFrameLifecycle({
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!runtimeUrl) return;
|
||||
const attempt = {
|
||||
nonce: createWebAppStartupNonce(),
|
||||
frameWindow: null as Window | null,
|
||||
settled: false,
|
||||
timer: null as ReturnType<typeof setTimeout> | null,
|
||||
};
|
||||
attemptRef.current = attempt;
|
||||
attempt.timer = setTimeout(() => finishAttempt("failed"), WEB_APP_STARTUP_TIMEOUT_MS);
|
||||
|
||||
const handleStartupMessage = (event: MessageEvent<unknown>) => {
|
||||
if (attemptRef.current !== attempt || attempt.settled) return;
|
||||
const currentFrameWindow = iframeRef.current?.contentWindow;
|
||||
if (
|
||||
!currentFrameWindow ||
|
||||
attempt.frameWindow !== currentFrameWindow ||
|
||||
event.source !== currentFrameWindow
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const result = parseWebAppStartupResult(event.data, attempt.nonce);
|
||||
if (!result) return;
|
||||
finishAttempt(result.result);
|
||||
};
|
||||
window.addEventListener("message", handleStartupMessage);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleStartupMessage);
|
||||
if (attempt.timer !== null) clearTimeout(attempt.timer);
|
||||
attempt.timer = null;
|
||||
if (attemptRef.current === attempt) attemptRef.current = null;
|
||||
};
|
||||
}, [finishAttempt, iframeRef, runtimeUrl]);
|
||||
useWebAppStartupMessageListener({ runtimeUrl, iframeRef, attemptRef, finishAttempt });
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
const attempt = attemptRef.current;
|
||||
@@ -136,9 +162,7 @@ function useWebAppFrameLifecycle({
|
||||
frameWindow.postMessage(createWebAppStartupProbe(attempt.nonce), "*");
|
||||
}, [iframeRef]);
|
||||
|
||||
const handleError = useCallback(() => finishAttempt("failed"), [finishAttempt]);
|
||||
|
||||
return { frameState, handleLoad, handleError };
|
||||
return { frameState, handleLoad };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,7 +182,7 @@ export function WebAppFrame({ runtimeUrl, title, className, onLoad, onError }: W
|
||||
target.postMessage(resolveWebAppAppearance(document, resolvedTheme), "*");
|
||||
}, [resolvedTheme]);
|
||||
|
||||
const { frameState, handleLoad, handleError } = useWebAppFrameLifecycle({
|
||||
const { frameState, handleLoad } = useWebAppFrameLifecycle({
|
||||
runtimeUrl,
|
||||
iframeRef,
|
||||
sendAppearance,
|
||||
@@ -179,6 +203,10 @@ export function WebAppFrame({ runtimeUrl, title, className, onLoad, onError }: W
|
||||
aria-busy={frameState === "loading"}
|
||||
>
|
||||
{runtimeUrl && frameState !== "unavailable" ? (
|
||||
// No onError here: React never attaches a DOM "error" listener for iframe/object/embed
|
||||
// (it only listens for "load"), so a document-load failure is unobservable from the
|
||||
// frame element itself. It surfaces only through the guest's own postMessage handshake
|
||||
// (the "document_error" reason) or the startup timeout.
|
||||
<iframe
|
||||
key={runtimeUrl}
|
||||
title={title}
|
||||
@@ -189,7 +217,6 @@ export function WebAppFrame({ runtimeUrl, title, className, onLoad, onError }: W
|
||||
loading="eager"
|
||||
className="block h-full min-h-0 w-full min-w-0 flex-1 border-0"
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
) : null}
|
||||
{frameState !== "ready" && (
|
||||
|
||||
@@ -15,14 +15,26 @@ export type WebAppStartupProbe = {
|
||||
nonce: string;
|
||||
};
|
||||
|
||||
export type WebAppStartupResult = {
|
||||
type WebAppStartupResultBase = {
|
||||
type: typeof WEB_APP_STARTUP_RESULT_TYPE;
|
||||
version: typeof WEB_APP_STARTUP_VERSION;
|
||||
nonce: string;
|
||||
result: "ready" | "failed";
|
||||
code?: "document_error" | "context_unavailable";
|
||||
};
|
||||
|
||||
export type WebAppStartupResult =
|
||||
| (WebAppStartupResultBase & { result: "ready" })
|
||||
| (WebAppStartupResultBase & {
|
||||
result: "failed";
|
||||
code: "document_error" | "context_unavailable";
|
||||
});
|
||||
|
||||
/**
|
||||
* Every cause the host can distinguish for a failed startup: the two codes
|
||||
* the guest bootstrap reports over the wire, plus a host-observed deadline
|
||||
* with no code at all.
|
||||
*/
|
||||
export type WebAppStartupFailureReason = "document_error" | "context_unavailable" | "timeout";
|
||||
|
||||
export function createWebAppStartupNonce(): string {
|
||||
return generateUUID();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DropdownMenu, DropdownMenuContent } from "@kandev/ui/dropdown-menu";
|
||||
import type { Canvas } from "@/lib/api/domains/canvas-api";
|
||||
|
||||
const CANVAS_UNAVAILABLE_TITLE = "Canvas unavailable";
|
||||
const CANVAS_HOST_STATE_TEST_ID = "canvas-host-state";
|
||||
|
||||
const COPY: Record<string, string> = {
|
||||
"canvases:editCanvas": "Edit canvas",
|
||||
"canvases:releasesAndPermissions": "Releases and permissions",
|
||||
@@ -28,8 +31,16 @@ const COPY: Record<string, string> = {
|
||||
"canvases:shareCanvasDescription":
|
||||
"Prepare a verified bundle or source archive for review and sharing.",
|
||||
"canvases:ready": "Ready",
|
||||
"canvases:unavailable": "Canvas unavailable",
|
||||
"canvases:unavailable": CANVAS_UNAVAILABLE_TITLE,
|
||||
"canvases:unavailableDescription": "The canvas runtime is unavailable.",
|
||||
"canvases:runtimeFailed": "Canvas runtime failed to start",
|
||||
"canvases:runtimeFailedDescription": "The canvas runtime did not start correctly.",
|
||||
"canvases:runtimeFailedDocumentErrorDescription":
|
||||
"The canvas application failed to load. Try again or contact the canvas author.",
|
||||
"canvases:runtimeFailedContextUnavailableDescription":
|
||||
"The canvas application could not reach the Kandev runtime. Try again or contact the canvas author.",
|
||||
"canvases:runtimeFailedTimeoutDescription":
|
||||
"The canvas application did not start in time. Try again.",
|
||||
"canvases:retry": "Try again",
|
||||
};
|
||||
|
||||
@@ -229,14 +240,53 @@ describe("canvas host chrome", () => {
|
||||
expect(screen.getByTestId("canvas-host-header").textContent).toContain("Task canvas");
|
||||
expect(screen.getByTestId("canvas-host-header").textContent).toContain("Release actions");
|
||||
expect(screen.getByTestId("canvas-data-scope").textContent).toBe("Workspace data");
|
||||
expect(screen.getByTestId("canvas-host-state").textContent).toContain("Canvas unavailable");
|
||||
expect(screen.getByTestId(CANVAS_HOST_STATE_TEST_ID).textContent).toContain(
|
||||
CANVAS_UNAVAILABLE_TITLE,
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getByTestId("canvas-host-state-panel")
|
||||
.contains(screen.getByTestId("canvas-host-state")),
|
||||
.contains(screen.getByTestId(CANVAS_HOST_STATE_TEST_ID)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("names the runtime as the cause, not the release, for each startup-failure reason", () => {
|
||||
const cases = [
|
||||
[
|
||||
"document_error",
|
||||
"The canvas application failed to load. Try again or contact the canvas author.",
|
||||
],
|
||||
[
|
||||
"context_unavailable",
|
||||
"The canvas application could not reach the Kandev runtime. Try again or contact the canvas author.",
|
||||
],
|
||||
["timeout", "The canvas application did not start in time. Try again."],
|
||||
] as const;
|
||||
|
||||
for (const [reason, description] of cases) {
|
||||
const { unmount } = render(
|
||||
<CanvasHostStatePanel
|
||||
state="runtime_failed"
|
||||
runtimeFailureReason={reason}
|
||||
error={null}
|
||||
onRetry={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId(CANVAS_HOST_STATE_TEST_ID).textContent).toBe(
|
||||
"Canvas runtime failed to start",
|
||||
);
|
||||
expect(screen.getByText(description)).toBeTruthy();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a genuinely unavailable release distinct from a runtime-startup failure", () => {
|
||||
render(<CanvasHostStatePanel state="unavailable" error={null} onRetry={vi.fn()} />);
|
||||
expect(screen.getByTestId(CANVAS_HOST_STATE_TEST_ID).textContent).toBe(
|
||||
CANVAS_UNAVAILABLE_TITLE,
|
||||
);
|
||||
});
|
||||
|
||||
it("announces readiness politely without adding a visible status toolbar", () => {
|
||||
render(
|
||||
<CanvasHostBody
|
||||
@@ -255,6 +305,29 @@ describe("canvas host chrome", () => {
|
||||
expect(announcement.textContent).toContain("Ready");
|
||||
expect(announcement.parentElement?.getAttribute("role")).toBe("status");
|
||||
expect(announcement.parentElement?.getAttribute("aria-live")).toBe("polite");
|
||||
expect(screen.getByTestId("canvas-host-route").textContent).not.toContain("Canvas unavailable");
|
||||
expect(screen.getByTestId("canvas-host-route").textContent).not.toContain(
|
||||
CANVAS_UNAVAILABLE_TITLE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvas host body prop forwarding", () => {
|
||||
it("forwards the startup-failure reason from the body to the state panel", () => {
|
||||
render(
|
||||
<CanvasHostBody
|
||||
canvasId="canvas-1"
|
||||
title="Task canvas"
|
||||
state="runtime_failed"
|
||||
runtimeUrl={null}
|
||||
error={null}
|
||||
runtimeFailureReason="timeout"
|
||||
onRuntimeReady={vi.fn()}
|
||||
onRuntimeError={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("The canvas application did not start in time. Try again."),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Button } from "@kandev/ui/button";
|
||||
import { MobilePickerSheet } from "@/components/task/mobile/mobile-picker-sheet";
|
||||
import { PanelHeaderBarSplit } from "@/components/task/panel-primitives";
|
||||
import { CanvasPage } from "@/components/plugins/canvas-page";
|
||||
import type { WebAppStartupFailureReason } from "@/components/plugins/web-app-startup";
|
||||
import {
|
||||
canvasCanEnableWorkspaceData,
|
||||
canvasHref,
|
||||
@@ -40,8 +41,15 @@ export type CanvasHostState =
|
||||
| "offline"
|
||||
| "invalid_release"
|
||||
| "unavailable"
|
||||
| "runtime_failed"
|
||||
| "archived";
|
||||
|
||||
const RUNTIME_FAILED_DESCRIPTIONS: Record<WebAppStartupFailureReason, string> = {
|
||||
document_error: "canvases:runtimeFailedDocumentErrorDescription",
|
||||
context_unavailable: "canvases:runtimeFailedContextUnavailableDescription",
|
||||
timeout: "canvases:runtimeFailedTimeoutDescription",
|
||||
};
|
||||
|
||||
const STATE_COPY: Record<CanvasHostState, { title: string; description: string }> = {
|
||||
loading_metadata: {
|
||||
title: "canvases:loadingCanvas",
|
||||
@@ -66,6 +74,10 @@ const STATE_COPY: Record<CanvasHostState, { title: string; description: string }
|
||||
description: "canvases:invalidReleaseDescription",
|
||||
},
|
||||
unavailable: { title: "canvases:unavailable", description: "canvases:unavailableDescription" },
|
||||
runtime_failed: {
|
||||
title: "canvases:runtimeFailed",
|
||||
description: "canvases:runtimeFailedDescription",
|
||||
},
|
||||
archived: { title: "canvases:archived", description: "canvases:archivedDescription" },
|
||||
};
|
||||
|
||||
@@ -94,6 +106,7 @@ export function CanvasHostBody({
|
||||
state,
|
||||
runtimeUrl,
|
||||
error,
|
||||
runtimeFailureReason,
|
||||
onRuntimeReady,
|
||||
onRuntimeError,
|
||||
onRetry,
|
||||
@@ -103,8 +116,9 @@ export function CanvasHostBody({
|
||||
state: CanvasHostState;
|
||||
runtimeUrl: string | null;
|
||||
error: string | null;
|
||||
runtimeFailureReason?: WebAppStartupFailureReason | null;
|
||||
onRuntimeReady: () => void;
|
||||
onRuntimeError: () => void;
|
||||
onRuntimeError: (reason: WebAppStartupFailureReason) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -134,7 +148,12 @@ export function CanvasHostBody({
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<CanvasHostStatePanel state={state} error={error} onRetry={onRetry} />
|
||||
<CanvasHostStatePanel
|
||||
state={state}
|
||||
error={error}
|
||||
runtimeFailureReason={runtimeFailureReason}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -194,14 +213,20 @@ export function CanvasHostHeader({
|
||||
export function CanvasHostStatePanel({
|
||||
state,
|
||||
error,
|
||||
runtimeFailureReason,
|
||||
onRetry,
|
||||
}: {
|
||||
state: CanvasHostState;
|
||||
error: string | null;
|
||||
runtimeFailureReason?: WebAppStartupFailureReason | null;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const copy = STATE_COPY[state];
|
||||
const descriptionKey =
|
||||
state === "runtime_failed" && runtimeFailureReason
|
||||
? RUNTIME_FAILED_DESCRIPTIONS[runtimeFailureReason]
|
||||
: copy.description;
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 items-center justify-center p-6 text-center"
|
||||
@@ -211,7 +236,7 @@ export function CanvasHostStatePanel({
|
||||
<h2 className="text-lg font-semibold" data-testid="canvas-host-state">
|
||||
{t(copy.title)}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">{error || t(copy.description)}</p>
|
||||
<p className="text-sm text-muted-foreground">{error || t(descriptionKey)}</p>
|
||||
{state !== "loading_metadata" && state !== "loading_runtime" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
MobileCanvasActions,
|
||||
type CanvasHostState,
|
||||
} from "./canvas-host-components";
|
||||
import type { WebAppStartupFailureReason } from "@/components/plugins/web-app-startup";
|
||||
import { canvasDataScope, type Canvas } from "@/lib/api/domains/canvas-api";
|
||||
|
||||
type CanvasHostRouteViewProps = {
|
||||
@@ -29,6 +30,7 @@ type CanvasHostRouteViewProps = {
|
||||
runtimeUrl: string | null;
|
||||
state: CanvasHostState;
|
||||
error: string | null;
|
||||
runtimeFailureReason: WebAppStartupFailureReason | null;
|
||||
menuOpen: boolean;
|
||||
promotionOpen: boolean;
|
||||
workspaceDataOpen: boolean;
|
||||
@@ -50,7 +52,7 @@ type CanvasHostRouteViewProps = {
|
||||
onRename: () => void;
|
||||
onSelectCanvas: (canvas: Canvas) => void;
|
||||
onRuntimeReady: () => void;
|
||||
onRuntimeError: () => void;
|
||||
onRuntimeError: (reason: WebAppStartupFailureReason) => void;
|
||||
onRetry: () => void;
|
||||
onPromotionCompleted: () => void;
|
||||
onChanged: () => void;
|
||||
@@ -116,12 +118,20 @@ function CanvasHostRouteBody({
|
||||
state,
|
||||
runtimeUrl,
|
||||
error,
|
||||
runtimeFailureReason,
|
||||
onRuntimeReady,
|
||||
onRuntimeError,
|
||||
onRetry,
|
||||
}: Pick<
|
||||
CanvasHostRouteViewProps,
|
||||
"canvasId" | "runtimeUrl" | "state" | "error" | "onRuntimeReady" | "onRuntimeError" | "onRetry"
|
||||
| "canvasId"
|
||||
| "runtimeUrl"
|
||||
| "state"
|
||||
| "error"
|
||||
| "runtimeFailureReason"
|
||||
| "onRuntimeReady"
|
||||
| "onRuntimeError"
|
||||
| "onRetry"
|
||||
> & { title: string }) {
|
||||
return (
|
||||
<CanvasHostBody
|
||||
@@ -130,6 +140,7 @@ function CanvasHostRouteBody({
|
||||
state={state}
|
||||
runtimeUrl={runtimeUrl}
|
||||
error={error}
|
||||
runtimeFailureReason={runtimeFailureReason}
|
||||
onRuntimeReady={onRuntimeReady}
|
||||
onRuntimeError={onRuntimeError}
|
||||
onRetry={onRetry}
|
||||
@@ -262,6 +273,7 @@ export function CanvasHostRouteView(props: CanvasHostRouteViewProps) {
|
||||
state={props.state}
|
||||
runtimeUrl={props.runtimeUrl}
|
||||
error={props.error}
|
||||
runtimeFailureReason={props.runtimeFailureReason}
|
||||
onRuntimeReady={props.onRuntimeReady}
|
||||
onRuntimeError={props.onRuntimeError}
|
||||
onRetry={props.onRetry}
|
||||
|
||||
@@ -76,12 +76,18 @@ vi.mock("@/components/page-shell", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/plugins/canvas-page", () => ({
|
||||
CanvasPage: ({ runtimeUrl, onError }: { runtimeUrl?: string; onError?: () => void }) => (
|
||||
CanvasPage: ({
|
||||
runtimeUrl,
|
||||
onError,
|
||||
}: {
|
||||
runtimeUrl?: string;
|
||||
onError?: (reason: string) => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={FRAME_TEST_ID}
|
||||
data-runtime-url={runtimeUrl ?? ""}
|
||||
onClick={onError}
|
||||
onClick={() => onError?.("context_unavailable")}
|
||||
>
|
||||
frame
|
||||
</button>
|
||||
@@ -172,8 +178,11 @@ describe("CanvasHostRoute runtime recovery", () => {
|
||||
fireEvent.click(screen.getByTestId(FRAME_TEST_ID));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("canvas-host-state").textContent).toContain("unavailable"),
|
||||
expect(screen.getByTestId("canvas-host-state").textContent).toContain(
|
||||
"Canvas runtime failed to start",
|
||||
),
|
||||
);
|
||||
expect(screen.getByTestId("canvas-host-state").textContent).not.toContain("Canvas unavailable");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -212,7 +221,9 @@ describe("CanvasHostRoute runtime recovery", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CanvasHostRoute chrome and lifecycle", () => {
|
||||
it("passes raw desktop overflow items to the standalone page shell", async () => {
|
||||
mockRenderPageShellOverflow.value = true;
|
||||
render(<CanvasHostRoute canvasId="canvas-1" />);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
canvasPresentationUserId,
|
||||
recordCanvasPresentation,
|
||||
} from "@/lib/canvas-presentation-storage";
|
||||
import type { WebAppStartupFailureReason } from "@/components/plugins/web-app-startup";
|
||||
import { useCanvasHostCanvases } from "./canvas-host-picker";
|
||||
import { type CanvasHostState } from "./canvas-host-components";
|
||||
import { CanvasHostRouteView } from "./canvas-host-route-view";
|
||||
@@ -323,11 +324,19 @@ function useCanvasHost(canvasId: string) {
|
||||
setState((current) => (current === "loading_runtime" ? "ready" : current));
|
||||
}, []);
|
||||
|
||||
const markRuntimeUnavailable = useCallback(() => {
|
||||
clearRuntimeRenewal();
|
||||
setRuntimeUrl(null);
|
||||
setState("unavailable");
|
||||
}, [clearRuntimeRenewal]);
|
||||
const [runtimeFailureReason, setRuntimeFailureReason] =
|
||||
useState<WebAppStartupFailureReason | null>(null);
|
||||
|
||||
const markRuntimeUnavailable = useCallback(
|
||||
(reason: WebAppStartupFailureReason) => {
|
||||
clearRuntimeRenewal();
|
||||
setRuntimeUrl(null);
|
||||
setError(null);
|
||||
setRuntimeFailureReason(reason);
|
||||
setState("runtime_failed");
|
||||
},
|
||||
[clearRuntimeRenewal],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
renewRuntimeRef.current = renewRuntime;
|
||||
@@ -354,6 +363,7 @@ function useCanvasHost(canvasId: string) {
|
||||
runtimeUrl,
|
||||
state,
|
||||
error,
|
||||
runtimeFailureReason,
|
||||
lifecycleRevision,
|
||||
load,
|
||||
refresh,
|
||||
@@ -420,6 +430,7 @@ export function CanvasHostRoute({
|
||||
runtimeUrl,
|
||||
state,
|
||||
error,
|
||||
runtimeFailureReason,
|
||||
load,
|
||||
refresh,
|
||||
markRuntimeReady,
|
||||
@@ -464,6 +475,7 @@ export function CanvasHostRoute({
|
||||
runtimeUrl={runtimeUrl}
|
||||
state={state}
|
||||
error={error}
|
||||
runtimeFailureReason={runtimeFailureReason}
|
||||
menuOpen={menuOpen}
|
||||
promotionOpen={promotionOpen}
|
||||
workspaceDataOpen={workspaceDataOpen}
|
||||
|
||||
@@ -92,7 +92,7 @@ test.describe("authenticated same-origin canvas runtime", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the existing recoverable failure when a proxy ignores no-transform", async ({
|
||||
test("surfaces a runtime startup failure when a proxy ignores no-transform", async ({
|
||||
browser,
|
||||
testPage,
|
||||
apiClient,
|
||||
@@ -114,9 +114,10 @@ test.describe("authenticated same-origin canvas runtime", () => {
|
||||
const opened = await openAuthenticatedCanvas(browser, proxy, canvasId);
|
||||
context = opened.context;
|
||||
|
||||
await expect(opened.page.getByTestId("canvas-host-state")).toHaveText("Canvas unavailable", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(opened.page.getByTestId("canvas-host-state")).toHaveText(
|
||||
"Canvas runtime failed to start",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(opened.page.getByTestId("web-app-frame")).toHaveCount(0);
|
||||
await expect(
|
||||
opened.page.getByRole("button", { name: "Try again", exact: true }),
|
||||
@@ -214,9 +215,10 @@ test.describe("authenticated same-origin canvas runtime", () => {
|
||||
|
||||
await proxy.clearAuthentication(context);
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("canvas-host-state")).toHaveText("Canvas unavailable", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("canvas-host-state")).toHaveText(
|
||||
"Canvas runtime failed to start",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByTestId("web-app-frame")).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Try again", exact: true })).toBeVisible();
|
||||
|
||||
|
||||
@@ -99,9 +99,10 @@ test.describe("authenticated same-origin canvas runtime on mobile", () => {
|
||||
|
||||
await proxy.clearAuthentication(context);
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("canvas-host-state")).toHaveText("Canvas unavailable", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("canvas-host-state")).toHaveText(
|
||||
"Canvas runtime failed to start",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByTestId("web-app-frame")).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Try again", exact: true })).toBeVisible();
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
"invalidReleaseDescription": "The active release failed validation. Review releases or edit the canvas.",
|
||||
"unavailable": "Canvas unavailable",
|
||||
"unavailableDescription": "The active canvas release is not available.",
|
||||
"runtimeFailed": "Canvas runtime failed to start",
|
||||
"runtimeFailedDescription": "The canvas runtime failed to start.",
|
||||
"runtimeFailedDocumentErrorDescription": "The canvas application failed to load. Try again.",
|
||||
"runtimeFailedContextUnavailableDescription": "The canvas application could not reach the Kandev runtime. Try again or contact the canvas author.",
|
||||
"runtimeFailedTimeoutDescription": "The canvas application took too long to start. Try again.",
|
||||
"archived": "Canvas archived",
|
||||
"archivedDescription": "Restore this canvas from workspace settings to open it again.",
|
||||
"loadFailed": "Failed to load the canvas.",
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
"invalidReleaseDescription": "アクティブなリリースが検証に失敗しました。リリースを確認するか、キャンバスを編集してください。",
|
||||
"unavailable": "キャンバスを利用できません",
|
||||
"unavailableDescription": "アクティブなキャンバスリリースを利用できません。",
|
||||
"runtimeFailed": "キャンバスランタイムの起動に失敗しました",
|
||||
"runtimeFailedDescription": "キャンバスランタイムの起動に失敗しました。",
|
||||
"runtimeFailedDocumentErrorDescription": "キャンバスアプリケーションの読み込みに失敗しました。再度お試しください。",
|
||||
"runtimeFailedContextUnavailableDescription": "キャンバスアプリケーションが Kandev ランタイムに接続できませんでした。再度お試しいただくか、キャンバスの作成者に連絡してください。",
|
||||
"runtimeFailedTimeoutDescription": "キャンバスアプリケーションの起動に時間がかかりすぎました。再度お試しください。",
|
||||
"archived": "キャンバスはアーカイブ済みです",
|
||||
"archivedDescription": "再度開くには、ワークスペース設定からこのキャンバスを復元してください。",
|
||||
"loadFailed": "キャンバスの読み込みに失敗しました。",
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
"invalidReleaseDescription": "Ţĥē àćţĩvē ŕēĺēàśē ƒàĩĺēď vàĺĩďàţĩōń. Ŕēvĩēŵ ŕēĺēàśēś ōŕ ēďĩţ ţĥē ćàńvàś.",
|
||||
"unavailable": "Ćàńvàś ũńàvàĩĺàƀĺē",
|
||||
"unavailableDescription": "Ţĥē àćţĩvē ćàńvàś ŕēĺēàśē ĩś ńōţ àvàĩĺàƀĺē.",
|
||||
"runtimeFailed": "Ćàńvàś ŕũńţĩḿē ƒàĩĺēď ţō śţàŕţ",
|
||||
"runtimeFailedDescription": "Ţĥē ćàńvàś ŕũńţĩḿē ƒàĩĺēď ţō śţàŕţ.",
|
||||
"runtimeFailedDocumentErrorDescription": "Ţĥē ćàńvàś àƥƥĺĩćàţĩōń ƒàĩĺēď ţō ĺōàď. Ţŕŷ àĝàĩń.",
|
||||
"runtimeFailedContextUnavailableDescription": "Ţĥē ćàńvàś àƥƥĺĩćàţĩōń ćōũĺď ńōţ ŕēàćĥ ţĥē Ķàńďēv ŕũńţĩḿē. Ţŕŷ àĝàĩń ōŕ ćōńţàćţ ţĥē ćàńvàś àũţĥōŕ.",
|
||||
"runtimeFailedTimeoutDescription": "Ţĥē ćàńvàś àƥƥĺĩćàţĩōń ţōōķ ţōō ĺōńĝ ţō śţàŕţ. Ţŕŷ àĝàĩń.",
|
||||
"archived": "Ćàńvàś àŕćĥĩvēď",
|
||||
"archivedDescription": "Ŕēśţōŕē ţĥĩś ćàńvàś ƒŕōḿ ŵōŕķśƥàćē śēţţĩńĝś ţō ōƥēń ĩţ àĝàĩń.",
|
||||
"loadFailed": "Ƒàĩĺēď ţō ĺōàď ţĥē ćàńvàś.",
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
"invalidReleaseDescription": "A versão ativa falhou a validação. Reveja as versões ou edite o canvas.",
|
||||
"unavailable": "Canvas indisponível",
|
||||
"unavailableDescription": "A versão ativa do canvas não está disponível.",
|
||||
"runtimeFailed": "Falha ao iniciar o motor de execução do canvas",
|
||||
"runtimeFailedDescription": "O motor de execução do canvas falhou ao iniciar.",
|
||||
"runtimeFailedDocumentErrorDescription": "A aplicação do canvas falhou ao carregar. Tente novamente.",
|
||||
"runtimeFailedContextUnavailableDescription": "A aplicação do canvas não conseguiu alcançar o runtime do Kandev. Tente novamente ou contacte o autor do canvas.",
|
||||
"runtimeFailedTimeoutDescription": "A aplicação do canvas demorou demasiado tempo a iniciar. Tente novamente.",
|
||||
"archived": "Canvas arquivado",
|
||||
"archivedDescription": "Restaure este canvas nas definições do workspace para o abrir novamente.",
|
||||
"loadFailed": "Não foi possível carregar o canvas.",
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
"invalidReleaseDescription": "活动版本未通过验证。请查看版本或编辑画布。",
|
||||
"unavailable": "画布不可用",
|
||||
"unavailableDescription": "活动画布版本不可用。",
|
||||
"runtimeFailed": "画布运行时启动失败",
|
||||
"runtimeFailedDescription": "画布运行时启动失败。",
|
||||
"runtimeFailedDocumentErrorDescription": "画布应用加载失败。请重试。",
|
||||
"runtimeFailedContextUnavailableDescription": "画布应用无法连接到 Kandev 运行时。请重试或联系画布作者。",
|
||||
"runtimeFailedTimeoutDescription": "画布应用启动超时。请重试。",
|
||||
"archived": "画布已归档",
|
||||
"archivedDescription": "请在工作区设置中恢复此画布,然后再次打开。",
|
||||
"loadFailed": "加载画布失败。",
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
"invalidReleaseDescription": "活動版本未通過驗證。請查看版本或編輯畫布。",
|
||||
"unavailable": "畫布不可用",
|
||||
"unavailableDescription": "活動畫布版本不可用。",
|
||||
"runtimeFailed": "畫布執行時啓動失敗",
|
||||
"runtimeFailedDescription": "畫布執行時啓動失敗。",
|
||||
"runtimeFailedDocumentErrorDescription": "畫布應用載入失敗。請重試。",
|
||||
"runtimeFailedContextUnavailableDescription": "畫布應用無法連接到 Kandev 執行時。請重試或聯絡畫布作者。",
|
||||
"runtimeFailedTimeoutDescription": "畫布應用啓動超時。請重試。",
|
||||
"archived": "畫布已歸檔",
|
||||
"archivedDescription": "請在工作區設定中恢復此畫布,然後再次開啟。",
|
||||
"loadFailed": "載入畫布失敗。",
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
"invalidReleaseDescription": "活動版本未通過驗證。請檢視版本或編輯畫布。",
|
||||
"unavailable": "畫布不可用",
|
||||
"unavailableDescription": "活動畫布版本不可用。",
|
||||
"runtimeFailed": "畫布執行時啟動失敗",
|
||||
"runtimeFailedDescription": "畫布執行時啟動失敗。",
|
||||
"runtimeFailedDocumentErrorDescription": "畫布應用載入失敗。請重試。",
|
||||
"runtimeFailedContextUnavailableDescription": "畫布應用無法連線到 Kandev 執行時。請重試或聯絡畫布作者。",
|
||||
"runtimeFailedTimeoutDescription": "畫布應用啟動超時。請重試。",
|
||||
"archived": "畫布已歸檔",
|
||||
"archivedDescription": "請在工作區設定中恢復此畫布,然後再次開啟。",
|
||||
"loadFailed": "載入畫布失敗。",
|
||||
|
||||
Reference in New Issue
Block a user