diff --git a/desktop/main/composables/game.test.ts b/desktop/main/composables/game.test.ts new file mode 100644 index 000000000..0a28d1481 --- /dev/null +++ b/desktop/main/composables/game.test.ts @@ -0,0 +1,260 @@ +/** + * Unit tests for the `game` composable. + * + * `useGame`/`parseStatus` rely on Nuxt's auto-imported `ref` (never imported + * explicitly in the source file) and on the Tauri `invoke`/`listen` APIs. + * Outside of a Nuxt runtime we have to stub `ref` as a global and mock the + * Tauri modules ourselves. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ref } from "vue"; +import { parseStatus, useGame } from "./game"; +import { InstalledType, type Game, type GameStatus, type GameVersion, type RawGameStatus } from "../types"; + +const mockInvoke = vi.fn(); +const mockListen = vi.fn(); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => mockInvoke(...args), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: (...args: unknown[]) => mockListen(...args), +})); + +// `ref` is normally supplied by Nuxt's auto-import compiler magic. Stub it on +// the global object so the composable (which references it as a bare +// identifier) resolves correctly when imported directly in a test. +vi.stubGlobal("ref", ref); + +function makeGame(id: string): Game { + return { + id, + type: "Game", + mName: `Game ${id}`, + mShortDescription: "short", + mDescription: "description", + mIconObjectId: "icon", + mBannerObjectId: "banner", + mCoverObjectId: "cover", + mImageLibraryObjectIds: [], + mImageCarouselObjectIds: [], + }; +} + +function makeInstalledStatus(versionId: string): GameStatus { + return { + type: "Installed", + install_type: { type: InstalledType.Installed }, + version_id: versionId, + install_dir: "/tmp/game", + update_available: false, + }; +} + +function makeVersion(): GameVersion { + return { + userConfiguration: { + launchTemplate: "", + overrideProtonPath: "", + overrideHandler: undefined, + enableUpdates: true, + }, + setups: [], + launches: [], + }; +} + +describe("parseStatus", () => { + it("returns the primary status when present", () => { + const status: RawGameStatus = [{ type: "Downloading" }, null]; + expect(parseStatus(status)).toEqual({ type: "Downloading" }); + }); + + it("falls back to the secondary status when the primary is null", () => { + const status: RawGameStatus = [null, { type: "Queued" }]; + expect(parseStatus(status)).toEqual({ type: "Queued" }); + }); + + it("prefers the primary status when both entries are present", () => { + const status: RawGameStatus = [{ type: "Running" }, { type: "Queued" }]; + expect(parseStatus(status)).toEqual({ type: "Running" }); + }); + + it("throws when both entries are null", () => { + const status: RawGameStatus = [null, null]; + expect(() => parseStatus(status)).toThrow("No game status"); + }); + + it("includes the JSON-stringified status in the thrown error message", () => { + const status: RawGameStatus = [null, null]; + expect(() => parseStatus(status)).toThrow(JSON.stringify(status)); + }); +}); + +describe("useGame", () => { + beforeEach(() => { + mockInvoke.mockReset(); + mockListen.mockReset(); + mockListen.mockResolvedValue(() => {}); + }); + + it("fetches game data via invoke with the requested gameId", async () => { + const gameId = "game-fetch-1"; + const game = makeGame(gameId); + mockInvoke.mockResolvedValueOnce({ + game, + status: [{ type: "Downloading" }, null] as RawGameStatus, + }); + + const result = await useGame(gameId); + + expect(mockInvoke).toHaveBeenCalledTimes(1); + expect(mockInvoke).toHaveBeenCalledWith("fetch_game", { gameId }); + expect(result.game).toEqual(game); + expect(result.status.value).toEqual({ type: "Downloading" }); + expect(result.version.value).toBeUndefined(); + }); + + it("populates the version ref when the fetch response includes one", async () => { + const gameId = "game-fetch-with-version"; + const version = makeVersion(); + mockInvoke.mockResolvedValueOnce({ + game: makeGame(gameId), + status: [makeInstalledStatus("v1"), null] as RawGameStatus, + version, + }); + + const result = await useGame(gameId); + + expect(result.version.value).toEqual(version); + }); + + it("caches the game registry entry and does not re-invoke for a known gameId", async () => { + const gameId = "game-cache-1"; + mockInvoke.mockResolvedValueOnce({ + game: makeGame(gameId), + status: [{ type: "Queued" }, null] as RawGameStatus, + }); + + await useGame(gameId); + mockInvoke.mockClear(); + + const second = await useGame(gameId); + + expect(mockInvoke).not.toHaveBeenCalled(); + expect(second.status.value).toEqual({ type: "Queued" }); + }); + + it("registers exactly one listener per gameId, even across repeated calls", async () => { + const gameId = "game-listen-once"; + mockInvoke.mockResolvedValue({ + game: makeGame(gameId), + status: [{ type: "Queued" }, null] as RawGameStatus, + }); + + await useGame(gameId); + await useGame(gameId); + + expect(mockListen).toHaveBeenCalledTimes(1); + expect(mockListen).toHaveBeenCalledWith( + `update_game/${gameId}`, + expect.any(Function), + ); + }); + + it("uses a distinct event channel per gameId", async () => { + const gameIdA = "game-channel-a"; + const gameIdB = "game-channel-b"; + mockInvoke.mockResolvedValue({ + game: makeGame(gameIdA), + status: [{ type: "Queued" }, null] as RawGameStatus, + }); + + await useGame(gameIdA); + await useGame(gameIdB); + + expect(mockListen).toHaveBeenNthCalledWith( + 1, + `update_game/${gameIdA}`, + expect.any(Function), + ); + expect(mockListen).toHaveBeenNthCalledWith( + 2, + `update_game/${gameIdB}`, + expect.any(Function), + ); + }); + + it("updates the status ref when the Tauri event handler fires", async () => { + const gameId = "game-update-status"; + let capturedHandler: ((event: { payload: unknown }) => void) | undefined; + mockListen.mockImplementationOnce((_channel: string, handler: (event: { payload: unknown }) => void) => { + capturedHandler = handler; + return Promise.resolve(() => {}); + }); + mockInvoke.mockResolvedValueOnce({ + game: makeGame(gameId), + status: [{ type: "Queued" }, null] as RawGameStatus, + }); + + const { status } = await useGame(gameId); + expect(status.value).toEqual({ type: "Queued" }); + + capturedHandler!({ + payload: { status: [{ type: "Downloading" }, null] as RawGameStatus }, + }); + + expect(status.value).toEqual({ type: "Downloading" }); + }); + + it("updates the version ref when the event payload includes a version", async () => { + const gameId = "game-update-version"; + let capturedHandler: ((event: { payload: unknown }) => void) | undefined; + mockListen.mockImplementationOnce((_channel: string, handler: (event: { payload: unknown }) => void) => { + capturedHandler = handler; + return Promise.resolve(() => {}); + }); + mockInvoke.mockResolvedValueOnce({ + game: makeGame(gameId), + status: [{ type: "Queued" }, null] as RawGameStatus, + }); + + const { version } = await useGame(gameId); + expect(version.value).toBeUndefined(); + + const newVersion = makeVersion(); + capturedHandler!({ + payload: { status: [makeInstalledStatus("v2"), null], version: newVersion }, + }); + + expect(version.value).toEqual(newVersion); + }); + + it("retains the previous version when the event payload omits one (documented behavior)", async () => { + const gameId = "game-retain-version"; + const initialVersion = makeVersion(); + let capturedHandler: ((event: { payload: unknown }) => void) | undefined; + mockListen.mockImplementationOnce((_channel: string, handler: (event: { payload: unknown }) => void) => { + capturedHandler = handler; + return Promise.resolve(() => {}); + }); + mockInvoke.mockResolvedValueOnce({ + game: makeGame(gameId), + status: [makeInstalledStatus("v1"), null] as RawGameStatus, + version: initialVersion, + }); + + const { version } = await useGame(gameId); + expect(version.value).toEqual(initialVersion); + + // No `version` field on this payload (e.g. game was uninstalled) — the + // composable intentionally keeps the last known version rather than + // clearing it. + capturedHandler!({ + payload: { status: [{ type: "Uninstalling" }, null] as RawGameStatus }, + }); + + expect(version.value).toEqual(initialVersion); + }); +}); \ No newline at end of file diff --git a/desktop/main/package.json b/desktop/main/package.json index 282196d31..6b61bbbdd 100644 --- a/desktop/main/package.json +++ b/desktop/main/package.json @@ -11,7 +11,8 @@ "typecheck": "nuxt typecheck", "format:check": "prettier --check .", "format:fix": "prettier --write .", - "lint": "prettier --check ." + "lint": "prettier --check .", + "test": "vitest run" }, "dependencies": { "@headlessui/vue": "^1.7.23", @@ -27,7 +28,7 @@ "micromark": "^4.0.1", "nuxt": "^4.4.8", "scss": "^0.2.4", - "vue-router": "latest", + "vue-router": "4.5.1", "vuedraggable": "^4.1.0" }, "devDependencies": { @@ -39,6 +40,8 @@ "sass-embedded": "^1.79.4", "tailwindcss": "^3.4.13", "typescript": "^5.8.3", + "vitest": "^4.1.10", + "vue": "3.5.17", "vue-tsc": "^2.2.10" } } diff --git a/desktop/main/vitest.config.ts b/desktop/main/vitest.config.ts new file mode 100644 index 000000000..2cb071b69 --- /dev/null +++ b/desktop/main/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; + +const rootDir = fileURLToPath(new URL(".", import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + "~": rootDir, + }, + }, + test: { + environment: "node", + globals: true, + include: ["**/*.test.ts"], + }, +}); \ No newline at end of file diff --git a/server/test/unit/dependency-pinning.test.ts b/server/test/unit/dependency-pinning.test.ts new file mode 100644 index 000000000..78345486b --- /dev/null +++ b/server/test/unit/dependency-pinning.test.ts @@ -0,0 +1,83 @@ +// Regression guard for CONF-1/CONF-2 (remediation-plan.md): `vue` and +// `vue-router` were pinned to lockfile-resolved versions after being found +// on `"latest"`, which let an unreviewed major bump silently break builds. +// This test asserts the fix holds and that no workspace re-introduces a +// floating ("latest" / "*") version for any dependency. + +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Resolve from the actual test file location (not process.cwd()), matching +// the pattern used in test/unit/plugins/init-order.test.ts. +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../../../"); + +type PackageJson = { + dependencies?: Record; + devDependencies?: Record; +}; + +function readPackageJson(relativePath: string): PackageJson { + const raw = readFileSync(join(repoRoot, relativePath), "utf-8"); + return JSON.parse(raw) as PackageJson; +} + +const FLOATING_VERSIONS = new Set(["latest", "*"]); + +const workspaces: Array<{ name: string; path: string }> = [ + { name: "server", path: "server/package.json" }, + { name: "desktop/main", path: "desktop/main/package.json" }, + { name: "libraries/base", path: "libraries/base/package.json" }, +]; + +describe("dependency version pinning", () => { + it.each(workspaces)( + "$name/package.json pins vue and vue-router (no 'latest')", + ({ path }) => { + const pkg = readPackageJson(path); + const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }; + + for (const name of ["vue", "vue-router"] as const) { + const version = allDeps[name]; + if (version === undefined) continue; // not every workspace depends on both + expect( + FLOATING_VERSIONS.has(version), + `${path}: expected "${name}" to be pinned, but found "${version}"`, + ).toBe(false); + } + }, + ); + + it.each(workspaces)( + "$name/package.json has no floating ('latest' or '*') dependency versions", + ({ path }) => { + const pkg = readPackageJson(path); + const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }; + + const floating = Object.entries(allDeps) + .filter(([, version]) => FLOATING_VERSIONS.has(version)) + .map(([name, version]) => `${name}@${version}`); + + expect(floating).toEqual([]); + }, + ); + + it("server and desktop/main resolve vue-router to the exact same version", () => { + // vue-router version drift between the two Nuxt apps in this monorepo + // is easy to miss and was part of the same remediation pass. + const server = readPackageJson("server/package.json"); + const desktopMain = readPackageJson("desktop/main/package.json"); + + const serverVersion = + server.dependencies?.["vue-router"] ?? + server.devDependencies?.["vue-router"]; + const desktopVersion = + desktopMain.dependencies?.["vue-router"] ?? + desktopMain.devDependencies?.["vue-router"]; + + expect(serverVersion).toBeDefined(); + expect(desktopVersion).toBeDefined(); + expect(serverVersion).toBe(desktopVersion); + }); +}); \ No newline at end of file