diff --git a/apps/cyberstorm-remix/public/cyberstorm-static/scripts/beta-switch.js b/apps/cyberstorm-remix/public/cyberstorm-static/scripts/beta-switch.js index ee575756a..2d28ecdfb 100644 --- a/apps/cyberstorm-remix/public/cyberstorm-static/scripts/beta-switch.js +++ b/apps/cyberstorm-remix/public/cyberstorm-static/scripts/beta-switch.js @@ -1,39 +1,3 @@ -const legacyProd = { - protocol: "https://", - hostname: "thunderstore.io", - port: "", - tld: "io", -}; -const betaProd = { - protocol: "https://", - hostname: "new.thunderstore.io", - port: "", - tld: "io", -}; -const legacyQA = { - protocol: "https://", - hostname: "thunderstore.dev", - port: "", - tld: "dev", -}; -const betaQA = { - protocol: "https://", - hostname: "new.thunderstore.dev", - port: "", - tld: "dev", -}; -const legacyDev = { - protocol: "http://", - hostname: "thunderstore.temp", - port: "", - tld: "temp", -}; -const betaDev = { - protocol: "http://", - hostname: "new.thunderstore.temp", - port: "", - tld: "temp", -}; async function checkBetaRedirect(legacy, beta, goToBetaRoR2) { const legacyOnlyPages = [ "/settings", @@ -124,25 +88,79 @@ async function insertSwitchButton(legacy, beta) { } } } -const legacy = window.location.hostname.endsWith(legacyProd.tld) - ? legacyProd - : window.location.hostname.endsWith(legacyQA.tld) - ? legacyQA - : legacyDev; -const beta = window.location.hostname.endsWith(betaProd.tld) - ? betaProd - : window.location.hostname.endsWith(betaQA.tld) - ? betaQA - : betaDev; -async function insertSwitchButtonListener() { - insertSwitchButton(legacy, beta); - document.removeEventListener("DOMContentLoaded", insertSwitchButtonListener); +function hasBrowserGlobals() { + return typeof window !== "undefined" && typeof document !== "undefined"; } -if ( - document.readyState === "complete" || - document.readyState === "interactive" -) { - insertSwitchButton(legacy, beta); -} else { - document.addEventListener("DOMContentLoaded", insertSwitchButtonListener); +export function initBetaSwitch() { + if (!hasBrowserGlobals()) { + return; + } + const globalWindow = window; + const initFlag = "__thunderstore_beta_switch_initialized__"; + if (globalWindow[initFlag]) { + return; + } + globalWindow[initFlag] = true; + const legacyProd = { + protocol: "https://", + hostname: "thunderstore.io", + port: "", + tld: "io", + }; + const betaProd = { + protocol: "https://", + hostname: "new.thunderstore.io", + port: "", + tld: "io", + }; + const legacyQA = { + protocol: "https://", + hostname: "thunderstore.dev", + port: "", + tld: "dev", + }; + const betaQA = { + protocol: "https://", + hostname: "new.thunderstore.dev", + port: "", + tld: "dev", + }; + const legacyDev = { + protocol: "http://", + hostname: "thunderstore.localhost", + port: "", + tld: "localhost", + }; + const betaDev = { + protocol: "http://", + hostname: "new.thunderstore.localhost", + port: "", + tld: "localhost", + }; + const legacy = window.location.hostname.endsWith(legacyProd.tld) + ? legacyProd + : window.location.hostname.endsWith(legacyQA.tld) + ? legacyQA + : legacyDev; + const beta = window.location.hostname.endsWith(betaProd.tld) + ? betaProd + : window.location.hostname.endsWith(betaQA.tld) + ? betaQA + : betaDev; + async function insertSwitchButtonListener() { + insertSwitchButton(legacy, beta); + document.removeEventListener( + "DOMContentLoaded", + insertSwitchButtonListener + ); + } + if ( + document.readyState === "complete" || + document.readyState === "interactive" + ) { + insertSwitchButton(legacy, beta); + } else { + document.addEventListener("DOMContentLoaded", insertSwitchButtonListener); + } } +initBetaSwitch(); diff --git a/packages/beta-switch/src/__tests__/beta-switch.test.ts b/packages/beta-switch/src/__tests__/beta-switch.test.ts new file mode 100644 index 000000000..e49b31edd --- /dev/null +++ b/packages/beta-switch/src/__tests__/beta-switch.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from "vitest"; + +type TestWindow = { + location: { + hostname: string; + pathname: string; + assign: (url: string) => void; + }; +}; + +type TestElement = { + tag: string; + attributes: Map; + setAttribute: (key: string, value: string) => void; + onclick?: (() => void) | null; + innerHTML: string; + cloneNode: (deep?: boolean) => TestElement; +}; + +type TestDocument = { + readyState: "complete" | "interactive" | "loading"; + createElement: (tag: string) => TestElement; + addEventListener: (type: string, listener: () => void) => void; + removeEventListener: (type: string, listener: () => void) => void; + querySelector: ( + selector: string + ) => { appendChild: (child: TestElement) => void } | null; +}; + +describe("@thunderstore/beta-switch", () => { + it("can be imported without window/document (SSR-safe)", async () => { + // Ensure SSR-like environment + const g = globalThis as unknown as { window?: unknown; document?: unknown }; + delete g.window; + delete g.document; + + await expect(import("../index")).resolves.toBeTruthy(); + }); + + it("inserts switch button into #nimbusBeta when container exists", async () => { + const appended: TestElement[] = []; + + const desktopContainer = { + appendChild: (child: TestElement) => appended.push(child), + }; + + const createElement = (tag: string) => { + const element: TestElement = { + tag, + attributes: new Map(), + setAttribute: (k: string, v: string) => element.attributes.set(k, v), + onclick: undefined, + innerHTML: "", + cloneNode: () => ({ + ...element, + attributes: new Map(element.attributes), + }), + }; + return element; + }; + + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + + const g = globalThis as unknown as { window?: unknown; document?: unknown }; + + const w: TestWindow = { + location: { + hostname: "thunderstore.temp", + pathname: "/", + assign: vi.fn(), + }, + }; + + const d: TestDocument = { + readyState: "complete", + createElement, + addEventListener: + addEventListener as unknown as TestDocument["addEventListener"], + removeEventListener: + removeEventListener as unknown as TestDocument["removeEventListener"], + querySelector: (selector: string) => + selector === "#nimbusBeta" ? desktopContainer : null, + }; + + g.window = w; + g.document = d; + + const mod: typeof import("../index") = await import("../index"); + mod.initBetaSwitch(); + + expect(appended.length).toBe(1); + expect(appended[0].tag).toBe("button"); + }); +}); diff --git a/packages/beta-switch/src/index.ts b/packages/beta-switch/src/index.ts index b0522789f..5602cf97f 100644 --- a/packages/beta-switch/src/index.ts +++ b/packages/beta-switch/src/index.ts @@ -7,48 +7,6 @@ type UrlStructure = { tld: string; }; -const legacyProd: UrlStructure = { - protocol: "https://", - hostname: "thunderstore.io", - port: "", - tld: "io", -}; - -const betaProd: UrlStructure = { - protocol: "https://", - hostname: "new.thunderstore.io", - port: "", - tld: "io", -}; - -const legacyQA: UrlStructure = { - protocol: "https://", - hostname: "thunderstore.dev", - port: "", - tld: "dev", -}; - -const betaQA: UrlStructure = { - protocol: "https://", - hostname: "new.thunderstore.dev", - port: "", - tld: "dev", -}; - -const legacyDev: UrlStructure = { - protocol: "http://", - hostname: "thunderstore.temp", - port: "", - tld: "temp", -}; - -const betaDev: UrlStructure = { - protocol: "http://", - hostname: "new.thunderstore.temp", - port: "", - tld: "temp", -}; - async function checkBetaRedirect( legacy: UrlStructure, beta: UrlStructure, @@ -178,28 +136,92 @@ async function insertSwitchButton(legacy: UrlStructure, beta: UrlStructure) { } } -const legacy = window.location.hostname.endsWith(legacyProd.tld) - ? legacyProd - : window.location.hostname.endsWith(legacyQA.tld) - ? legacyQA - : legacyDev; -const beta = window.location.hostname.endsWith(betaProd.tld) - ? betaProd - : window.location.hostname.endsWith(betaQA.tld) - ? betaQA - : betaDev; - -async function insertSwitchButtonListener() { - insertSwitchButton(legacy, beta); - document.removeEventListener("DOMContentLoaded", insertSwitchButtonListener); +function hasBrowserGlobals(): boolean { + return typeof window !== "undefined" && typeof document !== "undefined"; } -// Run above code -if ( - document.readyState === "complete" || - document.readyState === "interactive" -) { - insertSwitchButton(legacy, beta); -} else { - document.addEventListener("DOMContentLoaded", insertSwitchButtonListener); +export function initBetaSwitch() { + if (!hasBrowserGlobals()) { + return; + } + + const globalWindow = window as unknown as Record; + const initFlag = "__thunderstore_beta_switch_initialized__"; + if (globalWindow[initFlag]) { + return; + } + globalWindow[initFlag] = true; + + const legacyProd: UrlStructure = { + protocol: "https://", + hostname: "thunderstore.io", + port: "", + tld: "io", + }; + + const betaProd: UrlStructure = { + protocol: "https://", + hostname: "new.thunderstore.io", + port: "", + tld: "io", + }; + + const legacyQA: UrlStructure = { + protocol: "https://", + hostname: "thunderstore.dev", + port: "", + tld: "dev", + }; + + const betaQA: UrlStructure = { + protocol: "https://", + hostname: "new.thunderstore.dev", + port: "", + tld: "dev", + }; + + const legacyDev: UrlStructure = { + protocol: "http://", + hostname: "thunderstore.localhost", + port: "", + tld: "localhost", + }; + + const betaDev: UrlStructure = { + protocol: "http://", + hostname: "new.thunderstore.localhost", + port: "", + tld: "localhost", + }; + + const legacy = window.location.hostname.endsWith(legacyProd.tld) + ? legacyProd + : window.location.hostname.endsWith(legacyQA.tld) + ? legacyQA + : legacyDev; + const beta = window.location.hostname.endsWith(betaProd.tld) + ? betaProd + : window.location.hostname.endsWith(betaQA.tld) + ? betaQA + : betaDev; + + async function insertSwitchButtonListener() { + insertSwitchButton(legacy, beta); + document.removeEventListener( + "DOMContentLoaded", + insertSwitchButtonListener + ); + } + + if ( + document.readyState === "complete" || + document.readyState === "interactive" + ) { + insertSwitchButton(legacy, beta); + } else { + document.addEventListener("DOMContentLoaded", insertSwitchButtonListener); + } } + +// Backwards compatible: importing the module in the browser initializes it. +initBetaSwitch(); diff --git a/packages/beta-switch/vitest.config.ts b/packages/beta-switch/vitest.config.ts new file mode 100644 index 000000000..423140a57 --- /dev/null +++ b/packages/beta-switch/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineProject } from "vitest/config"; + +const packageRoot = new URL("./", import.meta.url).pathname; + +export default defineProject({ + root: packageRoot, + test: { + include: ["src/**/__tests__/**/*.test.ts"], + exclude: ["dist/**/*"], + environment: "node", + browser: { + enabled: false, + }, + }, +});