diff --git a/runner/apps/authoring/src/App.tsx b/runner/apps/authoring/src/App.tsx index 0430abd11..261888e95 100644 --- a/runner/apps/authoring/src/App.tsx +++ b/runner/apps/authoring/src/App.tsx @@ -531,6 +531,15 @@ function Authoring({ // Bumped whenever the whole workspace is replaced (example switch or fork) so // the runtime remounts even when the framework is unchanged. const [mountGen, setMountGen] = useState(0); + // Bumped by the error card's "Restart preview". Its own counter rather than `mountGen`: + // that one doubles as `EditorShell`'s `workspaceKey`, and a retry is not a new + // workspace — sharing it would close the user's open tabs to fix the preview. + const [retryGen, setRetryGen] = useState(0); + // Whether the current failure is one a remount could clear. False for the refusals the + // mount effect makes *before* building a runtime — an unsupported version, a starter + // below its core floor. Those depend on the version picker, not on the preview, and + // offering to restart them would promise something the button cannot deliver. + const [retryable, setRetryable] = useState(true); const [iframeEl, setIframeEl] = useState(null); // Where the running preview lives, as reported by mount(). Tier 2 gives the @@ -963,6 +972,7 @@ function Authoring({ if (!v.ok) { setStatus("error"); setErrorMessage(v.message); + setRetryable(false); return; } // Per-starter floor: these starters were authored against a core API that @@ -980,8 +990,10 @@ function Authoring({ setErrorMessage( `Could not load this example for Handsontable ${version}. Try another version.`, ); + setRetryable(false); return; } + setRetryable(true); setStatus("booting"); setBootLog(""); setSyncing(false); @@ -1031,8 +1043,22 @@ function Authoring({ runtime.dispose(); if (runtimeRef.current === runtime) runtimeRef.current = null; }; - // mountGen forces a remount when files are replaced (example switch or fork/edit load). - }, [iframeEl, entry, version, mountGen, sourceLoaded, docsNotFound, docsRuntimeBlocked, versionPending, docsPath]); + // mountGen forces a remount when files are replaced (example switch or fork/edit load); + // retryGen when the user asks for one from the error card. + }, [iframeEl, entry, version, mountGen, retryGen, sourceLoaded, docsNotFound, docsRuntimeBlocked, versionPending, docsPath]); + + /** "Restart preview" — mount a fresh runtime from the current (edited) sources. + * + * The way out of a failure the code has already outlived. Tier 1 recovers on its own + * now (the bundler's next clean compile re-emits ready), but Tier 2 cannot: a boot + * failure exits the container's dev server, and streaming the fixed file into a + * container with no dev server changes nothing. Only a new session re-runs it. */ + const retryPreview = useCallback(() => { + setStatus("booting"); + setErrorMessage(null); + setBootLog(""); + setRetryGen((g) => g + 1); + }, []); const onEdit = useCallback( (path: string, contents: string) => { @@ -1358,6 +1384,10 @@ function Authoring({ errorMessage={errorMessage} bootLog={bootLog} containerBoot={entry.engine === "container"} + // Withheld while a docs bucket/path is unresolved (the mount effect refuses to run + // at all in that state) and for pre-mount version refusals: in both cases the + // button would restart nothing. + onRetry={docsRuntimeBlocked || !retryable ? undefined : retryPreview} syncing={syncing} refreshing={refreshing} version={version} diff --git a/runner/e2e/preview-recovery.spec.ts b/runner/e2e/preview-recovery.spec.ts new file mode 100644 index 000000000..f48ce5dee --- /dev/null +++ b/runner/e2e/preview-recovery.spec.ts @@ -0,0 +1,215 @@ +import { test, expect } from "@playwright/test"; + +// The preview must be able to come back. A runtime error in edited code puts the +// pane into `error` (the "The preview could not start" card); fixing the code has +// to clear it again. It did not: `SandpackRuntime.emitReady()` fired at most once +// per mount, so the bundler's next clean `done` — the honest "your fix compiled +// and ran" signal — was swallowed, and the error card outlived the error. The only +// way out was an example switch or a version change, both of which remount. +// +// Live — needs the external Sandpack bundler; opt-in via E2E_LIVE=1, like the +// other render checks. + +/** The visible editor. Hidden panes stay mounted (T12), so an unscoped + * `.cm-content` would match every open file and resolve in DOM order. */ +const activeEditor = (page: import("@playwright/test").Page) => + page.locator('[data-pane-active="true"] .cm-content'); + +const previewStatus = (page: import("@playwright/test").Page) => + page.locator('[aria-label="Preview"]'); + +// Deterministic — no `E2E_LIVE=1`: the session POST is stubbed to a refusal, so the +// error card appears whether or not a real API worker (and container pool) happens to be +// reachable from wherever the suite runs. Without the stub this test passes only when +// nothing answers `/api` — which is true in CI and false on a developer's machine with +// the local worker up. +// +// Tier 2 has no self-healing equivalent of the Tier-1 fix below. A boot failure means +// the container's boot script exited, so its dev server is gone: the fixed file streams +// into a container with nothing left to serve it, and `reload()` (row-2 refresh) bails +// early because the iframe was never pointed. A remount is the only way back, and this +// button is the only thing that asks for one. +test("a failed preview offers a restart that remounts the runtime", async ({ page }) => { + const sessionPosts: string[] = []; + await page.route("**/api/session", async (route) => { + if (route.request().method() !== "POST") return route.fallback(); + sessionPosts.push(route.request().url()); + await route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ error: "no container slots" }), + }); + }); + + // `react-js` is a container starter; `vue`, despite the name, is Tier 1 (see + // catalog.json) and boots in-browser with no session at all. + await page.goto("/?example=react-js"); + await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "error", { + timeout: 60_000, + }); + const restart = page.getByRole("button", { name: "Restart preview" }); + await expect(restart).toBeVisible(); + + const before = sessionPosts.length; + expect(before, "the first mount attempted a session").toBeGreaterThan(0); + await restart.click(); + // A fresh session POST is the observable proof the runtime remounted; asserting on the + // status attribute alone would pass on a button that did nothing, since the retry ends + // in the same error here (there is still no worker). + await expect(async () => { + expect(sessionPosts.length).toBeGreaterThan(before); + }).toPass({ timeout: 30_000 }); + await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "error", { + timeout: 60_000, + }); +}); + +test("live: fixing a runtime error clears the preview error card", async ({ page }) => { + test.skip(process.env.E2E_LIVE !== "1", "set E2E_LIVE=1 to run live-render checks"); + test.setTimeout(180_000); + + await page.goto("/?example=react"); + await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "ready", { + timeout: 120_000, + }); + await expect(page.frameLocator("iframe").first().locator(".handsontable td").first()).toBeVisible({ + timeout: 90_000, + }); + + // Break it the way a user does: a reference to something that doesn't exist. + // Compiles clean, throws when the module is evaluated. + const editor = activeEditor(page); + await editor.click(); + await page.keyboard.press("ControlOrMeta+End"); + await page.keyboard.type("\nconsole.log(alignHeadersTypo);"); + + await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "error", { + timeout: 60_000, + }); + await expect(page.getByText("The preview could not start")).toBeVisible(); + + // Undo the edit — the fix. Select the broken line and remove it, newline and all. + await page.keyboard.press("Shift+Home"); + await page.keyboard.press("Backspace"); + await page.keyboard.press("Backspace"); + + await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "ready", { + timeout: 60_000, + }); + await expect(page.getByText("The preview could not start")).toHaveCount(0); + // Attribute alone would pass on a blanked pane; the grid has to be back. + await expect(page.frameLocator("iframe").first().locator(".handsontable td").first()).toBeVisible({ + timeout: 60_000, + }); +}); + +// The counterpart to the test above: an unsupported version is refused before any runtime +// is built, so a remount would re-run the same refusal. The card has no action, and the +// version picker (still live behind it) is where the fix actually is. +test("a version the runner refuses gets no restart button", async ({ page }) => { + await page.goto("/?example=react&v=99.99.99"); + await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "error", { + timeout: 30_000, + }); + await expect(page.getByText("The preview could not start")).toBeVisible(); + await expect(page.getByRole("button", { name: "Restart preview" })).toHaveCount(0); +}); + +// A compile the bundler sees as "no module changed" resets the preview document without +// re-evaluating anything: a blank frame, `done` with no error, nothing in the console. +// Two paths hit it. This is the one reachable by typing: break a line (the transpile +// throws, so nothing is pushed and the good render stays), then undo the break — the +// recomputed sandbox is byte-identical to what the bundler already has, and pushing it +// blanked a preview that was correct. Not pushing is the fix. +test("live: breaking and un-breaking a line leaves the grid alone", async ({ page }) => { + test.skip(process.env.E2E_LIVE !== "1", "set E2E_LIVE=1 to run live-render checks"); + test.setTimeout(180_000); + + const grid = page.frameLocator("iframe").first().locator(".handsontable td").first(); + await page.goto("/?example=react"); + await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "ready", { + timeout: 120_000, + }); + await expect(grid).toBeVisible({ timeout: 90_000 }); + + // Drop the comma after a `colHeaders` entry: a syntax error, so the transpile fails and + // the bundler is never told. The grid on screen is the last good render. + const commaAt = await page.evaluate(`(() => { + const view = document.querySelector('.cm-content').cmTile.view; + const at = view.state.doc.toString().indexOf("'Company name',") + "'Company name'".length; + view.dispatch({ changes: { from: at, to: at + 1, insert: "" } }); + return at; + })()`); + await page.waitForTimeout(4000); + await expect(grid).toBeVisible(); + + // Put it back. The file is now byte-identical to the one that rendered. + await page.evaluate( + `document.querySelector('.cm-content').cmTile.view.dispatch({ changes: { from: ${commaAt}, insert: "," } })`, + ); + await page.waitForTimeout(8000); + await expect(grid).toBeVisible(); + await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "ready"); +}); + +// The other path to the same no-change compile: the row-2 refresh button pushes the +// current sources unchanged, which blanked the preview outright. `reload()` now stamps the +// entry *and* the example module so the bundler has a real diff to act on — stamping the +// HTML shell alone was measured to leave it blank, since a parcel sandbox boots from HTML +// but the module is what has to re-evaluate. +test("live: the refresh button re-runs the sandbox instead of blanking it", async ({ page }) => { + test.skip(process.env.E2E_LIVE !== "1", "set E2E_LIVE=1 to run live-render checks"); + test.setTimeout(180_000); + + const preview = page.frameLocator("iframe").first(); + await page.goto("/?example=react"); + await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "ready", { + timeout: 120_000, + }); + await expect(preview.locator(".handsontable td").first()).toBeVisible({ timeout: 90_000 }); + + await page.getByRole("button", { name: "Reload the preview" }).click(); + await expect(preview.locator(".handsontable td").first()).toBeVisible({ timeout: 60_000 }); + // Re-evaluating the entry must not stack a second grid on the page (the DEV-2129 class), + // and the plugin registry has to survive it — `getPlugin()` returning undefined after a + // refresh is exactly how that bug presented, with a grid still on screen. + await expect(preview.locator(".ht-root-wrapper")).toHaveCount(1); + await preview.locator(".handsontable td").first().click({ button: "right" }); + await expect(preview.locator(".htContextMenu").first()).toBeVisible({ timeout: 15_000 }); +}); + +// The reported Vue case (`VueCompilerError` with a template code frame). It reads like a +// second engine but is not: `vue` is a sandpack starter, so this is the same swallowed- +// `done` bug — through a *compile* error rather than a runtime one. Worth its own test +// because the two arrive differently: a compile failure never evaluates the module, so +// only the `done{compilatonError:true}` + `show-error` pair is seen. +test("live: fixing a Vue template error clears the preview error card", async ({ page }) => { + test.skip(process.env.E2E_LIVE !== "1", "set E2E_LIVE=1 to run live-render checks"); + test.setTimeout(180_000); + + await page.goto("/?example=vue"); + await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "ready", { + timeout: 120_000, + }); + await expect(page.frameLocator("iframe").first().locator(".handsontable td").first()).toBeVisible({ + timeout: 90_000, + }); + + // A second root `