-
Notifications
You must be signed in to change notification settings - Fork 1
CodeRabbit Generated Unit Tests: Add Generated Unit Tests for PR Changes #44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| }); | ||
| }); | ||
| 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, | ||
| }, | ||
|
BillyOutlast marked this conversation as resolved.
|
||
| }, | ||
| test: { | ||
| environment: "node", | ||
| globals: true, | ||
| include: ["**/*.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
|
||
| } | ||
| }, | ||
| ); | ||
|
|
||
| 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
|
||
| }, | ||
| ); | ||
|
|
||
| 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
|
||
| }); | ||
|
Comment on lines
+37
to
+82
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The dependency-pinning tests assert that Prompt To Fix With AIThis 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. |
||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CLAUDE.mdrequires 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, anddependency-pinning.test.ts) are missing the trailing newline, sopnpm --filter drop format:checkwill fail in CI.Context Used: CLAUDE.md (source)
Prompt To Fix With AI
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!