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:
Henry
2026-09-24 19:28:50 +08:00
committed by GitHub
parent 6e0c81df18
commit dca3ee3484
22 changed files with 681 additions and 84 deletions
+2 -1
View File
@@ -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" />);
+76 -49
View File
@@ -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 -3
View File
@@ -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();
+5
View File
@@ -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.",
+5
View File
@@ -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àś.",
+5
View File
@@ -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.",
+5
View File
@@ -22,6 +22,11 @@
"invalidReleaseDescription": "活动版本未通过验证。请查看版本或编辑画布。",
"unavailable": "画布不可用",
"unavailableDescription": "活动画布版本不可用。",
"runtimeFailed": "画布运行时启动失败",
"runtimeFailedDescription": "画布运行时启动失败。",
"runtimeFailedDocumentErrorDescription": "画布应用加载失败。请重试。",
"runtimeFailedContextUnavailableDescription": "画布应用无法连接到 Kandev 运行时。请重试或联系画布作者。",
"runtimeFailedTimeoutDescription": "画布应用启动超时。请重试。",
"archived": "画布已归档",
"archivedDescription": "请在工作区设置中恢复此画布,然后再次打开。",
"loadFailed": "加载画布失败。",
+5
View File
@@ -22,6 +22,11 @@
"invalidReleaseDescription": "活動版本未通過驗證。請查看版本或編輯畫布。",
"unavailable": "畫布不可用",
"unavailableDescription": "活動畫布版本不可用。",
"runtimeFailed": "畫布執行時啓動失敗",
"runtimeFailedDescription": "畫布執行時啓動失敗。",
"runtimeFailedDocumentErrorDescription": "畫布應用載入失敗。請重試。",
"runtimeFailedContextUnavailableDescription": "畫布應用無法連接到 Kandev 執行時。請重試或聯絡畫布作者。",
"runtimeFailedTimeoutDescription": "畫布應用啓動超時。請重試。",
"archived": "畫布已歸檔",
"archivedDescription": "請在工作區設定中恢復此畫布,然後再次開啟。",
"loadFailed": "載入畫布失敗。",
+5
View File
@@ -22,6 +22,11 @@
"invalidReleaseDescription": "活動版本未通過驗證。請檢視版本或編輯畫布。",
"unavailable": "畫布不可用",
"unavailableDescription": "活動畫布版本不可用。",
"runtimeFailed": "畫布執行時啟動失敗",
"runtimeFailedDescription": "畫布執行時啟動失敗。",
"runtimeFailedDocumentErrorDescription": "畫布應用載入失敗。請重試。",
"runtimeFailedContextUnavailableDescription": "畫布應用無法連線到 Kandev 執行時。請重試或聯絡畫布作者。",
"runtimeFailedTimeoutDescription": "畫布應用啟動超時。請重試。",
"archived": "畫布已歸檔",
"archivedDescription": "請在工作區設定中恢復此畫布,然後再次開啟。",
"loadFailed": "載入畫布失敗。",