diff --git a/packages/core/src/studioPreviewMark.test.ts b/packages/core/src/studioPreviewMark.test.ts new file mode 100644 index 0000000000..4a04d7e3db --- /dev/null +++ b/packages/core/src/studioPreviewMark.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { refusedPreviewMessage, refusedPreviewStatus } from "./studioPreviewMark"; + +describe("refusedPreviewStatus", () => { + it.each([404, 410, 500])("reads %i back out of the refused page's message", (status) => { + expect(refusedPreviewStatus(refusedPreviewMessage(status))).toBe(status); + }); + + it.each([ + "Composition timeline not found after 8s", + "it took too long to load", + "x The preview page could not be loaded (HTTP 404).", + ])("finds no status in %j", (cause) => { + expect(refusedPreviewStatus(cause)).toBeNull(); + }); +}); diff --git a/packages/core/src/studioPreviewMark.ts b/packages/core/src/studioPreviewMark.ts index 56ca0697db..2899d0edfc 100644 --- a/packages/core/src/studioPreviewMark.ts +++ b/packages/core/src/studioPreviewMark.ts @@ -3,6 +3,13 @@ export const STUDIO_PREVIEW_CAPTURE_MODE = "capture"; export const STUDIO_PREVIEW_UPCOMING_ATTR = "data-hf-upcoming"; export const STUDIO_PREVIEW_LAZY_ATTR = "data-hf-preview-lazy"; export const STUDIO_PREVIEW_ERRORS = "__hfPreviewErrors"; + +export const refusedPreviewMessage = (status: number) => + `The preview page could not be loaded (HTTP ${status}).`; +export function refusedPreviewStatus(message: string): number | null { + const status = /^The preview page could not be loaded \(HTTP (\d{3})\)\.$/.exec(message)?.[1]; + return status ? Number(status) : null; +} export const PREVIEW_RASTER_ATTR = "data-hf-preview-raster"; export const RUNTIME_STAMPED_ATTR = "data-hf-autostamped"; export const CLIP_HIDDEN_ATTR = "data-hf-clip-hidden"; diff --git a/packages/studio-server/src/helpers/previewErrorPage.ts b/packages/studio-server/src/helpers/previewErrorPage.ts index cdb643f96f..b2f59c1c4f 100644 --- a/packages/studio-server/src/helpers/previewErrorPage.ts +++ b/packages/studio-server/src/helpers/previewErrorPage.ts @@ -1,6 +1,9 @@ -import { STUDIO_PREVIEW_ERRORS } from "@hyperframes/core/studio-preview-mark"; +import { + refusedPreviewMessage, + STUDIO_PREVIEW_ERRORS, +} from "@hyperframes/core/studio-preview-mark"; export function previewErrorPage(status: number): string { - const message = `The preview page could not be loaded (HTTP ${status}).`; + const message = refusedPreviewMessage(status); return `
${message}
`; } diff --git a/packages/studio/src/player/hooks/useShadowPreviewReload.ts b/packages/studio/src/player/hooks/useShadowPreviewReload.ts index 91cd9c7c67..bb93ec87bd 100644 --- a/packages/studio/src/player/hooks/useShadowPreviewReload.ts +++ b/packages/studio/src/player/hooks/useShadowPreviewReload.ts @@ -8,6 +8,7 @@ import { isStudioManualEditGestureLiveIn, studioManualEditSavesIn, } from "../../components/editor/manualEditsDom"; +import { refusedPreviewStatus } from "@hyperframes/core/studio-preview-mark"; import { logReload } from "../../utils/reloadDebug"; import { useTimelineSyncCallbacks, @@ -131,7 +132,9 @@ export function useShadowPreviewReload({ setPreviewReloading(false); const message = `The preview did not reload (${cause}). It was still waiting for ${waitingFor}. The previous preview is still showing.`; logReload("shadow-failed", { cause, waitingFor }); - console.error(`[studio] ${message}`); + const refused = refusedPreviewStatus(cause); + const pageMissingNow = refused !== null && refused < 500; + (pageMissingNow ? console.warn : console.error)(`[studio] ${message}`); onReloadFailedRef.current?.(message); }, [stopPendingShadow, isRefreshingRef, pendingSeekRef], diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts index 873ead27ea..be8ec26e9f 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts @@ -5,6 +5,7 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; import { SHADOW_READY_BUDGETS, SHADOW_READY_TIMEOUT_MS } from "./useShadowPreviewReload"; +import { refusedPreviewMessage } from "@hyperframes/core/studio-preview-mark"; import { usePlayerStore } from "../store/playerStore"; import { isPreviewChanging } from "../previewReloading"; import { NLEProvider, useNLEContext, type NLEContextValue } from "../../components/nle/NLEContext"; @@ -639,6 +640,26 @@ describe("shadow reload readiness and failure", () => { unmount(root); }); + // An agent's edit moves the page away for a few saves: each reload is refused until it is back. + it.each([ + [404, "warn"], + [500, "error"], + ] as const)("reports a reload the server refused with %i as a console %s", (status, level) => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const onPreviewReloadFailed = vi.fn(); + const { getApi, live, gen, root } = beginReload({ onPreviewReloadFailed }); + + act(() => getApi().onShadowError(gen, refusedPreviewMessage(status))); + + expect(getApi().iframeRef.current).toBe(live); + expect(onPreviewReloadFailed).toHaveBeenCalledOnce(); + const said = (spy: typeof warn) => + spy.mock.calls.some(([text]) => String(text).includes(`(HTTP ${status})`)); + expect([said(warn), said(error)]).toEqual([level === "warn", level === "error"]); + unmount(root); + }); + it("runs no load side effects for a superseded shadow", () => { const { getApi, gen, root } = beginReload(); usePlayerStore.getState().setCurrentTime(7);