diff --git a/modules/manager/updatePanels.test.mts b/modules/manager/updatePanels.test.mts new file mode 100644 index 0000000..07298f9 --- /dev/null +++ b/modules/manager/updatePanels.test.mts @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2026 spicetify + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { registerHooks } from "node:module"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import * as React from "react"; +import { act, create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer"; +import { transformSync } from "rolldown/experimental"; + +import type { DaemonInfo } from "./autoUpdate.ts"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const client: { modules?: unknown; daemon?: unknown; snackbar?: unknown } = {}; +Object.assign(globalThis, { managerPanelsTest: { client } }); +const exposure = ` +export * as React from ${JSON.stringify(import.meta.resolve("react"))}; +export const client = globalThis.managerPanelsTest.client; +`; +const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "/modules/stdlib/mod.ts") { + return { url: `data:text/javascript,${encodeURIComponent(exposure)}`, shortCircuit: true }; + } + if (specifier === "/modules/stdlib/lib/primitives.js") { + const primitives = "export const TextInput = () => null;"; + return { url: `data:text/javascript,${encodeURIComponent(primitives)}`, shortCircuit: true }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url.startsWith("file:") && url.endsWith(".tsx")) { + const filename = fileURLToPath(url); + const result = transformSync(filename, readFileSync(filename, "utf8"), { jsx: { runtime: "automatic" } }); + assert.equal(result.errors.length, 0); + return { format: "module", source: result.code, shortCircuit: true }; + } + return nextLoad(url, context); + }, +}); +const { SpicetifySelfUpdate } = await import("./selfUpdate.tsx"); +const { ManagerPage } = await import("./page.tsx"); +hooks.deregister(); + +const APPLIED = "3.0.0-beta.21"; +const INSTALLED = "3.0.0-beta.22"; +const PROMPT = `Spicetify ${INSTALLED} is installed. Apply to use it in Spotify.`; +const TOGGLE = "Install Spicetify updates automatically"; + +const text = (node: ReactTestInstance | string): string => + typeof node === "string" ? node : node.children.map(text).join(""); + +const toggle = (root: ReactTestInstance) => + root.findAll((node) => node.type === "label" && text(node).includes(TOGGLE)); + +const prompt = (root: ReactTestInstance) => root.findAll((node) => node.type === "p" && text(node) === PROMPT); + +// The section headed "Updates", whichever panel rendered it. +const updatesSection = (root: ReactTestInstance) => { + const sections = root.findAll( + (node) => node.type === "section" && node.findAll((h) => h.type === "h2" && text(h) === "Updates").length > 0, + ); + assert.equal(sections.length, 1); + return sections[0]!; +}; + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +const renderers: ReactTestRenderer[] = []; +async function render(element: React.ReactElement) { + let tree!: ReactTestRenderer; + await act(async () => { + tree = create(element); + }); + await act(flush); + renderers.push(tree); + return tree.root; +} + +afterEach(async () => { + await act(async () => renderers.splice(0).forEach((tree) => tree.unmount())); +}); + +const selfUpdateProps = (daemonInfo: DaemonInfo | null, setAutoUpdate?: (on: boolean) => Promise) => ({ + daemon: { apply: async () => {}, ...(setAutoUpdate ? { setAutoUpdate } : {}) }, + daemonInfo, + setDaemonInfo: () => {}, + cliVersion: APPLIED, + busy: false, + onAction: () => {}, +}); + +describe("SpicetifySelfUpdate", () => { + it("offers the toggle and the apply prompt when the daemon reports both", async () => { + const root = await render( + React.createElement( + SpicetifySelfUpdate, + selfUpdateProps({ version: INSTALLED, autoUpdate: true }, async () => {}), + ), + ); + assert.equal(toggle(root).length, 1); + assert.equal(prompt(root).length, 1); + }); + + it("leaves the toggle out when the daemon predates the setting", async () => { + const root = await render( + React.createElement(SpicetifySelfUpdate, selfUpdateProps({ version: INSTALLED, autoUpdate: null })), + ); + assert.equal(toggle(root).length, 0); + assert.equal(prompt(root).length, 1, "the apply prompt only needs the version"); + }); +}); + +describe("Manager update panels", () => { + const originalFetch = globalThis.fetch; + let manifest: Record; + + beforeEach(() => { + manifest = { cliVersion: APPLIED, spotifyVersion: "1.2.96.518", supportedSpotify: "1.2.96.518" }; + Object.assign(globalThis, { + Spicetify: { Modules: { manifest, list: () => [] } }, + fetch: async () => ({ ok: false }), + }); + }); + + afterEach(() => { + Object.assign(globalThis, { fetch: originalFetch }); + delete (globalThis as { Spicetify?: unknown }).Spicetify; + delete client.daemon; + }); + + const daemon = (info: DaemonInfo, withSetting: boolean) => ({ + available: async () => true, + daemonInfo: async () => info, + updateAndApplySupported: async () => true, + apply: async () => {}, + blockUpdates: async () => {}, + unblockUpdates: async () => {}, + managedSpotify: { status: async () => null, check: async () => ({ kind: "current" }) }, + ...(withSetting ? { setAutoUpdate: async () => {} } : {}), + }); + + for (const panel of ["regular", "managed Linux"] as const) { + const setup = () => { + if (panel === "managed Linux") manifest.managedSpotify = "stable"; + }; + + it(`the ${panel} panel shows the toggle and the apply prompt`, async () => { + setup(); + client.daemon = daemon({ version: INSTALLED, autoUpdate: true }, true); + const section = updatesSection(await render(React.createElement(ManagerPage))); + assert.equal( + section.findAll((node) => text(node).startsWith("Linux stable") && node.type === "span").length, + panel === "managed Linux" ? 1 : 0, + ); + assert.equal(toggle(section).length, 1); + assert.equal(prompt(section).length, 1); + }); + + it(`the ${panel} panel leaves the toggle out when the daemon predates the setting`, async () => { + setup(); + client.daemon = daemon({ version: INSTALLED, autoUpdate: null }, false); + const section = updatesSection(await render(React.createElement(ManagerPage))); + assert.equal(toggle(section).length, 0); + assert.equal(prompt(section).length, 1); + }); + } +}); diff --git a/package.json b/package.json index 858d065..a1e2038 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@types/pngjs": "^6.0.5", "@types/react": "^18", "@types/react-dom": "^18", + "@types/react-test-renderer": "^18", "happy-dom": "^20.11.1", "jszip": "3.10.1", "oxfmt": "^0.60.0", @@ -32,6 +33,7 @@ "pngjs": "^7.0.0", "react": "18.3.1", "react-dom": "18.3.1", + "react-test-renderer": "18.3.1", "rolldown": "^1.2.0", "rxjs": "^7.8.2", "sass-embedded": "^1.80.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3304a83..0772e58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@types/react-dom': specifier: ^18 version: 18.3.7(@types/react@18.3.31) + '@types/react-test-renderer': + specifier: ^18 + version: 18.3.1 happy-dom: specifier: ^20.11.1 version: 20.11.1 @@ -47,6 +50,9 @@ importers: react-dom: specifier: 18.3.1 version: 18.3.1(react@18.3.1) + react-test-renderer: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) rolldown: specifier: ^1.2.0 version: 1.2.0 @@ -499,6 +505,9 @@ packages: peerDependencies: '@types/react': ^18.0.0 + '@types/react-test-renderer@18.3.1': + resolution: {integrity: sha512-vAhnk0tG2eGa37lkU9+s5SoroCsRI08xnsWFiAXOuPH2jqzMbcXvKExXViPi1P5fIklDeCvXqyrdmipFaSkZrA==} + '@types/react@18.3.31': resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} @@ -697,6 +706,10 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + oxfmt@0.60.0: resolution: {integrity: sha512-fViX6i+gJuZWY+jI/fnR6WRbRj70GZ9RlCd30MygJrHTUNc4DxvKHWw8vBjMjffv3PgU5qWDR0AzmojQByqaZA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -746,6 +759,19 @@ packages: peerDependencies: react: ^18.3.1 + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-shallow-renderer@16.15.0: + resolution: {integrity: sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + + react-test-renderer@18.3.1: + resolution: {integrity: sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==} + peerDependencies: + react: ^18.3.1 + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -1216,6 +1242,10 @@ snapshots: dependencies: '@types/react': 18.3.31 + '@types/react-test-renderer@18.3.1': + dependencies: + '@types/react': 18.3.31 + '@types/react@18.3.31': dependencies: '@types/prop-types': 15.7.15 @@ -1358,6 +1388,8 @@ snapshots: node-addon-api@7.1.1: optional: true + object-assign@4.1.1: {} + oxfmt@0.60.0: dependencies: tinypool: 2.1.0 @@ -1423,6 +1455,21 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-is@18.3.1: {} + + react-shallow-renderer@16.15.0(react@18.3.1): + dependencies: + object-assign: 4.1.1 + react: 18.3.1 + react-is: 18.3.1 + + react-test-renderer@18.3.1(react@18.3.1): + dependencies: + react: 18.3.1 + react-is: 18.3.1 + react-shallow-renderer: 16.15.0(react@18.3.1) + scheduler: 0.23.2 + react@18.3.1: dependencies: loose-envify: 1.4.0