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
260 changes: 260 additions & 0 deletions desktop/main/composables/game.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing trailing newline — CI format check will reject this file

CLAUDE.md requires every edited file to be formatted immediately, and Prettier enforces a final newline. All three new files in this PR (game.test.ts, vitest.config.ts, and dependency-pinning.test.ts) are missing the trailing newline, so pnpm --filter drop format:check will fail in CI.

Context Used: CLAUDE.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: desktop/main/composables/game.test.ts
Line: 260

Comment:
**Missing trailing newline — CI format check will reject this file**

`CLAUDE.md` requires every edited file to be formatted immediately, and Prettier enforces a final newline. All three new files in this PR (`game.test.ts`, `vitest.config.ts`, and `dependency-pinning.test.ts`) are missing the trailing newline, so `pnpm --filter drop format:check` will fail in CI.

**Context Used:** CLAUDE.md ([source](https://app.greptile.com/heretek-ai/github/BillyOutlast/drop/-/custom-context?memory=990afeb5-70bf-42e6-b1b6-9a31e6269b3f))

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

7 changes: 5 additions & 2 deletions desktop/main/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand All @@ -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"
}
}
17 changes: 17 additions & 0 deletions desktop/main/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
Comment thread
BillyOutlast marked this conversation as resolved.
},
test: {
environment: "node",
globals: true,
include: ["**/*.test.ts"],
},
});
83 changes: 83 additions & 0 deletions server/test/unit/dependency-pinning.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
devDependencies?: Record<string, string>;
};

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);

Check failure on line 47 in server/test/unit/dependency-pinning.test.ts

View workflow job for this annotation

GitHub Actions / Test

test/unit/dependency-pinning.test.ts > dependency version pinning > 'libraries/base'/package.json pins vue and vue-router (no 'latest')

AssertionError: libraries/base/package.json: expected "vue" to be pinned, but found "latest": expected true to be false // Object.is equality - Expected + Received - false + true ❯ test/unit/dependency-pinning.test.ts:47:11

Check failure on line 47 in server/test/unit/dependency-pinning.test.ts

View workflow job for this annotation

GitHub Actions / Test

test/unit/dependency-pinning.test.ts > dependency version pinning > 'server'/package.json pins vue and vue-router (no 'latest')

AssertionError: server/package.json: expected "vue" to be pinned, but found "latest": expected true to be false // Object.is equality - Expected + Received - false + true ❯ test/unit/dependency-pinning.test.ts:47:11

Check failure on line 47 in server/test/unit/dependency-pinning.test.ts

View workflow job for this annotation

GitHub Actions / Test + Coverage

test/unit/dependency-pinning.test.ts > dependency version pinning > 'libraries/base'/package.json pins vue and vue-router (no 'latest')

AssertionError: libraries/base/package.json: expected "vue" to be pinned, but found "latest": expected true to be false // Object.is equality - Expected + Received - false + true ❯ test/unit/dependency-pinning.test.ts:47:11

Check failure on line 47 in server/test/unit/dependency-pinning.test.ts

View workflow job for this annotation

GitHub Actions / Test + Coverage

test/unit/dependency-pinning.test.ts > dependency version pinning > 'server'/package.json pins vue and vue-router (no 'latest')

AssertionError: server/package.json: expected "vue" to be pinned, but found "latest": expected true to be false // Object.is equality - Expected + Received - false + true ❯ test/unit/dependency-pinning.test.ts:47:11
}
},
);

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([]);

Check failure on line 62 in server/test/unit/dependency-pinning.test.ts

View workflow job for this annotation

GitHub Actions / Test

test/unit/dependency-pinning.test.ts > dependency version pinning > 'libraries/base'/package.json has no floating ('latest' or '*') dependency versions

AssertionError: expected [ '@nuxt/eslint@latest', 'vue@latest' ] to deeply equal [] - Expected + Received - [] + [ + "@nuxt/eslint@latest", + "vue@latest", + ] ❯ test/unit/dependency-pinning.test.ts:62:24

Check failure on line 62 in server/test/unit/dependency-pinning.test.ts

View workflow job for this annotation

GitHub Actions / Test

test/unit/dependency-pinning.test.ts > dependency version pinning > 'server'/package.json has no floating ('latest' or '*') dependency versions

AssertionError: expected [ 'vue@latest', 'vue-router@latest' ] to deeply equal [] - Expected + Received - [] + [ + "vue@latest", + "vue-router@latest", + ] ❯ test/unit/dependency-pinning.test.ts:62:24

Check failure on line 62 in server/test/unit/dependency-pinning.test.ts

View workflow job for this annotation

GitHub Actions / Test + Coverage

test/unit/dependency-pinning.test.ts > dependency version pinning > 'libraries/base'/package.json has no floating ('latest' or '*') dependency versions

AssertionError: expected [ '@nuxt/eslint@latest', 'vue@latest' ] to deeply equal [] - Expected + Received - [] + [ + "@nuxt/eslint@latest", + "vue@latest", + ] ❯ test/unit/dependency-pinning.test.ts:62:24

Check failure on line 62 in server/test/unit/dependency-pinning.test.ts

View workflow job for this annotation

GitHub Actions / Test + Coverage

test/unit/dependency-pinning.test.ts > dependency version pinning > 'server'/package.json has no floating ('latest' or '*') dependency versions

AssertionError: expected [ 'vue@latest', 'vue-router@latest' ] to deeply equal [] - Expected + Received - [] + [ + "vue@latest", + "vue-router@latest", + ] ❯ test/unit/dependency-pinning.test.ts:62:24
},
);

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);

Check failure on line 81 in server/test/unit/dependency-pinning.test.ts

View workflow job for this annotation

GitHub Actions / Test

test/unit/dependency-pinning.test.ts > dependency version pinning > server and desktop/main resolve vue-router to the exact same version

AssertionError: expected 'latest' to be '4.5.1' // Object.is equality Expected: "4.5.1" Received: "latest" ❯ test/unit/dependency-pinning.test.ts:81:27

Check failure on line 81 in server/test/unit/dependency-pinning.test.ts

View workflow job for this annotation

GitHub Actions / Test + Coverage

test/unit/dependency-pinning.test.ts > dependency version pinning > server and desktop/main resolve vue-router to the exact same version

AssertionError: expected 'latest' to be '4.5.1' // Object.is equality Expected: "4.5.1" Received: "latest" ❯ test/unit/dependency-pinning.test.ts:81:27
});
Comment on lines +37 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Tests will fail immediately — server/package.json still uses "latest"

The dependency-pinning tests assert that server/package.json has no floating versions for vue and vue-router, but the server workspace was never remediated: it still carries "vue": "latest" and "vue-router": "latest" (lines 77–78 of server/package.json). All three parameterised test cases will fail on first run — the two "no 'latest'" cases will catch both packages, and the version-parity check will fail because desktop/main is now pinned to "4.5.1" while the server remains on "latest".

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/test/unit/dependency-pinning.test.ts
Line: 37-82

Comment:
**Tests will fail immediately — `server/package.json` still uses `"latest"`**

The dependency-pinning tests assert that `server/package.json` has no floating versions for `vue` and `vue-router`, but the server workspace was never remediated: it still carries `"vue": "latest"` and `"vue-router": "latest"` (lines 77–78 of `server/package.json`). All three parameterised test cases will fail on first run — the two "no 'latest'" cases will catch both packages, and the version-parity check will fail because `desktop/main` is now pinned to `"4.5.1"` while the server remains on `"latest"`.

How can I resolve this? If you propose a fix, please make it concise.

});
Loading