Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1087,6 +1087,9 @@ <h2 id="failureTitle" data-i18n="failure.title">Something failed</h2>
</div>
</div>

<!-- First, so its handlers are installed before main.js is evaluated and
can report a failure to parse, resolve an import, or wire the UI. -->
<script type="module" src="/js/boot-diagnostics.js"></script>
<script type="module" src="/js/main.js"></script>
<script type="module" src="/js/ui-chrome.js"></script>
</body>
Expand Down
130 changes: 130 additions & 0 deletions static/js/boot-diagnostics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Catch and show a failure that happens while the app is wiring itself up.
//
// main.js wires the UI in a long synchronous run of wireX() calls and only
// registers its own error handlers near the end of the module. A throw anywhere
// in that run therefore does two things at once: every listener after it never
// attaches, and the handlers that would have reported it never register either.
// What the user gets is an app that hovers and lets them type, because that is
// the engine's own behaviour, but whose buttons and file drop do nothing,
// because nothing is listening. Nothing is written anywhere (#618, found via
// #617).
//
// This module exists to be the thing that still works when that happens, so it
// follows three rules:
//
// 1. It is loaded BEFORE main.js in index.html. Module scripts execute in
// document order, so its handlers are installed before main.js is
// evaluated and are in place for anything main.js does, including a
// failure to parse or to resolve an import.
// 2. It imports nothing. An import is a second thing that can fail, and it
// would fail in exactly the circumstances this module needs to survive.
// 3. It styles itself inline. It cannot rely on a stylesheet having loaded,
// and CSP allows inline style (`style-src 'self' 'unsafe-inline'`) while
// forbidding inline script, which is also why this is a file rather than a
// <script> block in the HTML.
//
// The strings here are deliberately English and do not go through i18n, which
// is the one place in the UI where that is correct: i18n.js is itself a module
// that can fail to load, and a banner that renders nothing when translation is
// broken defeats the entire point. An untranslated explanation beats a silent
// dead app.

(function () {
const BANNER_ID = "boot-failure";
let reported = false;

function detailFrom(event) {
if (event.type === "unhandledrejection") {
const reason = event.reason;
if (reason instanceof Error) return `${reason.message}\n${reason.stack || ""}`;
return String(reason);
}
// A resource that failed to load (a module that 404s, say) fires an error
// event on the element itself with no message, rather than a script error.
const target = event.target;
if (target && target !== window && (target.src || target.href)) {
return `Failed to load: ${target.src || target.href}`;
}
const where = event.filename ? `\n${event.filename}:${event.lineno}:${event.colno}` : "";
const stack = event.error && event.error.stack ? `\n${event.error.stack}` : "";
return `${event.message || "Unknown error"}${where}${stack}`;
}

function show(detail) {
// Only the first failure is shown. A broken wiring run usually produces
// several, and the first is the one that caused the rest.
if (reported) return;
reported = true;

const build = () => {
if (document.getElementById(BANNER_ID)) return;

const bar = document.createElement("div");
bar.id = BANNER_ID;
bar.setAttribute("role", "alert");
bar.style.cssText = [
"position:fixed", "inset:0 0 auto 0", "z-index:2147483647",
"background:#3b1111", "color:#f6e9e9", "border-bottom:1px solid #d65a4a",
"padding:14px 16px", "font:13px/1.5 system-ui,sans-serif",
"max-height:50vh", "overflow:auto", "box-shadow:0 4px 24px rgba(0,0,0,0.4)",
].join(";");

const title = document.createElement("strong");
title.textContent = "StemDeck did not finish starting up.";
title.style.cssText = "display:block;font-size:14px;margin-bottom:6px";

const lead = document.createElement("div");
lead.textContent =
"Part of the interface never loaded, so buttons and file drop will not "
+ "respond. Hovering and typing still work because the browser handles "
+ "those itself. The cause is below. Please include it if you report this.";
lead.style.cssText = "margin-bottom:10px;max-width:80ch";

const pre = document.createElement("pre");
pre.textContent = detail;
// user-select is explicit because the app sets it to none in places, and
// this text is the whole reason the banner exists.
pre.style.cssText = [
"margin:0 0 10px", "padding:8px 10px", "background:#2a0d0d",
"border:1px solid #5c2222", "border-radius:6px", "white-space:pre-wrap",
"word-break:break-word", "user-select:text", "-webkit-user-select:text",
"font:12px/1.45 ui-monospace,Consolas,monospace",
].join(";");

const hint = document.createElement("div");
hint.textContent =
"The full backend log is at /api/logs.zip, and the raw backend output at "
+ "/api/logs/backend. Both help identify whether a file failed to load.";
hint.style.cssText = "opacity:0.85;margin-bottom:10px";

const dismiss = document.createElement("button");
dismiss.type = "button";
dismiss.textContent = "Dismiss";
dismiss.style.cssText = [
"background:#5c2222", "color:#f6e9e9", "border:1px solid #d65a4a",
"border-radius:6px", "padding:5px 12px", "cursor:pointer", "font:inherit",
].join(";");
dismiss.addEventListener("click", () => bar.remove());

bar.append(title, lead, pre, hint, dismiss);
document.body.prepend(bar);
};

if (document.body) build();
else document.addEventListener("DOMContentLoaded", build, { once: true });
}

function handle(event) {
const detail = detailFrom(event);
// Still log it. In a browser the console is the faster route, and this runs
// before main.js installs its own logging.
console.error("[boot]", detail);
show(detail);
}

// Capture phase, because a resource load failure fires on the element and
// does not bubble. A bubbling-phase listener on window never sees a module
// that 404s, which is one of the two causes this needs to tell apart.
window.addEventListener("error", handle, true);
window.addEventListener("unhandledrejection", handle);
})();
83 changes: 83 additions & 0 deletions tests/e2e/boot-failure.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// A failure while the app wires itself up must be visible and quotable.
//
// Before boot-diagnostics.js existed, both cases below produced an app that
// hovered and accepted typing but whose buttons did nothing, with nothing
// logged anywhere, because main.js registers its own error handlers only after
// the wiring run that failed (#618, reported as #617).
//
// Both tests fail without static/js/boot-diagnostics.js loaded first.

import { test, expect } from "@playwright/test";

const BANNER = "#boot-failure";

test("a module that fails to load is reported in the UI", async ({ page }) => {
// The missing-file case: a JS file quarantined by antivirus or lost in a
// partial unzip. main.js cannot resolve the import and never evaluates, so
// nothing it would have registered exists.
await page.route("**/js/catalog.js", (route) => route.fulfill({ status: 404, body: "" }));

await page.goto("/");

const banner = page.locator(BANNER);
await expect(banner).toBeVisible();
await expect(banner).toContainText("StemDeck did not finish starting up");

// The banner names main.js, not catalog.js, and that is the honest limit of
// what the page can know: the module loader fetches dependencies itself, so a
// dependency that 404s produces no per-file error event. What surfaces is the
// entry module whose graph failed to resolve.
//
// Which file is missing is answerable, just not from here: the 404 is a
// uvicorn access line in backend.log. That is why the banner points at
// /api/logs/backend rather than pretending to know.
await expect(banner).toContainText("Failed to load");
await expect(banner).toContainText("main.js");
await expect(banner).toContainText("/api/logs/backend");
});

test("a throw during wiring is reported in the UI", async ({ page }) => {
// The other cause with the same symptom: every file loads, and one of the
// wireX() calls throws.
await page.route("**/js/main.js", (route) =>
route.fulfill({
status: 200,
contentType: "application/javascript",
body: 'throw new Error("wiring blew up");',
}),
);

await page.goto("/");

const banner = page.locator(BANNER);
await expect(banner).toBeVisible();
await expect(banner).toContainText("wiring blew up");
});

test("the detail can be selected, and the banner can be dismissed", async ({ page }) => {
// The text exists to be copied into a bug report, and the app sets
// user-select: none in places, so the banner sets it back explicitly.
await page.route("**/js/main.js", (route) =>
route.fulfill({
status: 200,
contentType: "application/javascript",
body: 'throw new Error("selectable please");',
}),
);

await page.goto("/");

const pre = page.locator(`${BANNER} pre`);
await expect(pre).toHaveCSS("user-select", "text");

await page.locator(`${BANNER} button`).click();
await expect(page.locator(BANNER)).toHaveCount(0);
});

test("a healthy page shows no banner", async ({ page }) => {
// Guards the obvious regression: a banner that appears when nothing is wrong
// would be worse than no banner at all.
await page.goto("/");
await expect(page.locator("#url")).toBeVisible();
await expect(page.locator(BANNER)).toHaveCount(0);
});
Loading