From 2a8eefab6622c654a69b8efecb306c621a33c37e Mon Sep 17 00:00:00 2001 From: castrojo Date: Tue, 8 Sep 2026 15:09:13 +0000 Subject: [PATCH] fix(gnome-extensions): render missing and unavailable extension data states - GnomeExtensions.tsx now models loading/found/not-found/unavailable states explicitly instead of a single nullable extension, so a requested id absent from the array renders a message instead of staying at 'Loading...' forever. - Guard against the documented { unavailable: true, stateReason } payload before calling .find() on it, which previously would throw. - fetch-gnome-extensions.js now follows the repo's data-pipeline rule (AGENTS.md: never fail the build) by writing that unavailable payload and exiting 0 instead of process.exit(1) when every extension fetch fails. Closes projectbluefin/documentation#1095 Assisted-by: Claude Sonnet 5 via GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: castrojo --- scripts/fetch-gnome-extensions.js | 27 +++++++++-- scripts/fetch-gnome-extensions.test.js | 8 ++++ src/components/GnomeExtensions.tsx | 63 ++++++++++++++++++++++---- 3 files changed, 85 insertions(+), 13 deletions(-) diff --git a/scripts/fetch-gnome-extensions.js b/scripts/fetch-gnome-extensions.js index ad61fffc9..4ee6a70e1 100644 --- a/scripts/fetch-gnome-extensions.js +++ b/scripts/fetch-gnome-extensions.js @@ -125,6 +125,23 @@ async function fetchExtensionData(pk) { return buildExtensionRecord(pk, data, localScreenshot); } +/** + * Per the repo's data-pipeline rules (AGENTS.md), a fetch script must never + * fail the build: no throw, no non-zero exit, no silently empty file. On + * error it writes this explicit unavailable payload instead, so consumers + * (e.g. GnomeExtensions.tsx) can render a visible reason rather than hang at + * "Loading...". + */ +function unavailablePayload(reason) { + return { unavailable: true, stateReason: reason }; +} + +function writeUnavailable(reason) { + console.warn(`fetch-gnome-extensions: ${reason} — writing unavailable payload`); + fs.mkdirSync(path.dirname(OUTPUT_JSON), { recursive: true }); + fs.writeFileSync(OUTPUT_JSON, JSON.stringify(unavailablePayload(reason), null, 2)); +} + async function main() { if (!isStale(OUTPUT_JSON)) return; @@ -140,8 +157,8 @@ async function main() { } if (extensions.length === 0) { - console.error("All extension fetches failed — aborting."); - process.exit(1); + writeUnavailable("All GNOME extension fetches failed"); + return; } if (extensions.length < EXTENSION_IDS.length) { console.warn(`Warning: only ${extensions.length}/${EXTENSION_IDS.length} extensions fetched.`); @@ -152,10 +169,14 @@ async function main() { } if (require.main === module) { - main().catch((e) => { console.error(e); process.exit(1); }); + main().catch((e) => { + console.error(e); + writeUnavailable(`GNOME extension data could not be generated: ${e.message}`); + }); } module.exports = { buildExtensionRecord, isStale, + unavailablePayload, }; diff --git a/scripts/fetch-gnome-extensions.test.js b/scripts/fetch-gnome-extensions.test.js index e33bbea6a..0ab3f92bf 100644 --- a/scripts/fetch-gnome-extensions.test.js +++ b/scripts/fetch-gnome-extensions.test.js @@ -5,6 +5,7 @@ const path = require("path"); const { buildExtensionRecord, isStale, + unavailablePayload, } = require("./fetch-gnome-extensions.js"); test("buildExtensionRecord normalizes remote GNOME extension fields", () => { @@ -41,3 +42,10 @@ test("isStale returns true when the cache file does not exist", () => { true, ); }); + +test("unavailablePayload emits the documented unavailable-object shape", () => { + assert.deepEqual(unavailablePayload("All GNOME extension fetches failed"), { + unavailable: true, + stateReason: "All GNOME extension fetches failed", + }); +}); diff --git a/src/components/GnomeExtensions.tsx b/src/components/GnomeExtensions.tsx index c906e6da4..3fc5bf40a 100644 --- a/src/components/GnomeExtensions.tsx +++ b/src/components/GnomeExtensions.tsx @@ -15,44 +15,87 @@ interface ExtensionData { donateUrl: string | null; } +/** + * scripts/fetch-gnome-extensions.js never fails the build: on error it writes + * `{ unavailable: true, stateReason }` instead of the extension array (see + * AGENTS.md → Data pipelines). This must be checked before treating the + * payload as an array, or a plain `.find()` throws. + */ +interface UnavailablePayload { + unavailable: true; + stateReason: string; +} + +type ExtensionsResponse = ExtensionData[] | UnavailablePayload; + +function isUnavailablePayload( + data: ExtensionsResponse, +): data is UnavailablePayload { + return !Array.isArray(data) && data?.unavailable === true; +} + interface GnomeExtensionsProps { extensionId: number; } +type LoadState = + | { status: "loading" } + | { status: "found"; extension: ExtensionData } + | { status: "not-found" } + | { status: "unavailable"; reason: string }; + const GnomeExtensions: React.FC = ({ extensionId }) => { - const [extension, setExtension] = useState(null); + const [state, setState] = useState({ status: "loading" }); const [imageError, setImageError] = useState(false); - const [loadError, setLoadError] = useState(false); useEffect(() => { fetch("/data/gnome-extensions.json") .then((response) => response.json()) - .then((data: ExtensionData[]) => { - const ext = data.find((item) => item.id === extensionId); - if (ext) { - setExtension(ext); + .then((data: ExtensionsResponse) => { + if (isUnavailablePayload(data)) { + setState({ status: "unavailable", reason: data.stateReason }); + return; } + const ext = data.find((item) => item.id === extensionId); + setState(ext ? { status: "found", extension: ext } : { status: "not-found" }); }) .catch((error) => { console.error("Error loading extension metadata:", error); - setLoadError(true); + setState({ + status: "unavailable", + reason: "Extension data could not be loaded.", + }); }); }, [extensionId]); - if (loadError) { + if (state.status === "unavailable") { return (
-

Extension data unavailable.

+

{state.reason}

); } - if (!extension) { + if (state.status === "not-found") { + return ( +
+
+

+ Extension data unavailable. +

+
+
+ ); + } + + if (state.status === "loading") { return
Loading...
; } + const extension = state.extension; + const thumbnailUrl = extension.screenshot || extension.remoteScreenshot; // Truncate to first line, then cap at 150 chars if still too long const firstLine = (extension.description ?? "").split("\n")[0];