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
13 changes: 6 additions & 7 deletions src/renderer/src/contexts/NotificationsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@
removeUpdateErrorListener()
removeUpdateDownloadedListener()
}
}, [])

Check warning on line 120 in src/renderer/src/contexts/NotificationsContext.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has missing dependencies: 'addNotification' and 't'. Either include them or remove the dependency array

const addNotification = (body: string, type: NotificationTypes, options?: NotificationOptions): void => {
const id = crypto.randomUUID()
Expand All @@ -139,12 +139,11 @@
return <NotificationsContext.Provider value={{ notifications, addNotification, removeNotification }}>{children}</NotificationsContext.Provider>
}

const useNotificationsContext = (): NotificationsContextType => {
const context = useContext(NotificationsContext)
if (!context) {
throw new Error("useNotificationsContext must be used within an NotificationsProvider")
}
return context
}
/**
* The context ships the no-op `defaultValue` above, so a call from outside a
* provider gets that and never nothing. There is no absent case to guard, which
* is why this one has no throw where the task and config hooks have one.
*/
const useNotificationsContext = (): NotificationsContextType => useContext(NotificationsContext)

export { NotificationsProvider, useNotificationsContext }
14 changes: 11 additions & 3 deletions tests/ipc/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process"
import { existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { afterEach, beforeEach, describe, it } from "vitest"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import * as tar from "tar"
import { path7za } from "7zip-bin"

Expand Down Expand Up @@ -50,6 +50,7 @@ beforeEach(() => {

afterEach(() => {
rmSync(workspace, { recursive: true, force: true })
vi.unstubAllEnvs()
})

describe("contentRoot", () => {
Expand Down Expand Up @@ -199,13 +200,20 @@ describe("runExtraction on a gzipped tar", () => {
})

it("leaves no temporary workspace behind", async () => {
const before = readdirSync(tmpdir()).filter((entry) => entry.startsWith("vs-launcher-extract-")).length
// Same reasoning as the Inno extraction's own cleanup test: the machine-wide
// temp root holds other runs' staging folders, so this one gets a root of its
// own and what is left in it at the end belongs to this call.
const temporaryRoot = workspacePath("temp-root")
mkdirSync(temporaryRoot)
vi.stubEnv("TMPDIR", temporaryRoot)
vi.stubEnv("TMP", temporaryRoot)
vi.stubEnv("TEMP", temporaryRoot)
writeTree(workspacePath("source"), { vintagestory: { Vintagestory: "elf" } })
const archivePath = await makeTarGz("clean.tar.gz", workspacePath("source"))

await runExtraction({ filePath: archivePath, outputPath: workspacePath("target"), deleteArchive: false, sevenZipBin })

assert.equal(readdirSync(tmpdir()).filter((entry) => entry.startsWith("vs-launcher-extract-")).length, before)
assert.deepEqual(readdirSync(temporaryRoot), [])
})
})

Expand Down
15 changes: 12 additions & 3 deletions tests/ipc/innoExtraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createHash } from "node:crypto"
import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { afterEach, beforeEach, describe, it } from "vitest"
import { afterEach, beforeEach, describe, it, vi } from "vitest"

import { runInnoExtraction } from "../../src/ipc/workers/innoExtraction"

Expand Down Expand Up @@ -37,6 +37,7 @@ beforeEach(() => {

afterEach(() => {
rmSync(workspace, { recursive: true, force: true })
vi.unstubAllEnvs()
})

describe("runInnoExtraction", () => {
Expand Down Expand Up @@ -117,12 +118,20 @@ describe("runInnoExtraction", () => {
})

it("leaves no temporary folder behind, whatever happened", async () => {
const before = readdirSync(tmpdir()).filter((name) => name.startsWith("riftlauncher-inno-")).length
// Staging goes wherever the temp root points, and the machine-wide one is
// shared with everything else running: counting folders by name there counts
// the ones other runs are still using. These two calls get a temp root to
// themselves, so whatever is left in it at the end is theirs.
const temporaryRoot = workspacePath("temp-root")
mkdirSync(temporaryRoot)
vi.stubEnv("TMPDIR", temporaryRoot)
vi.stubEnv("TMP", temporaryRoot)
vi.stubEnv("TEMP", temporaryRoot)

await runInnoExtraction({ filePath: installerFrom("valid.bin"), outputPath: workspacePath("target"), deleteInstaller: false })
await runInnoExtraction({ filePath: installerFrom("wrong-digest.bin"), outputPath: workspacePath("other"), deleteInstaller: false })

assert.equal(readdirSync(tmpdir()).filter((name) => name.startsWith("riftlauncher-inno-")).length, before)
assert.deepEqual(readdirSync(temporaryRoot), [])
})
})

Expand Down
21 changes: 3 additions & 18 deletions tests/renderer-dom/configContextSlices.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { describe, expect, it, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import { screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"

import { CONFIG_ACTIONS, useAccountList, useConfigDispatch, useCustomIcons, useFavMods, useGameVersions, useInstallations, useSettingsConfig } from "@renderer/features/config/contexts/ConfigContext"

import { createMockConfig, installMockWindowApi } from "./helpers/windowApi"
import { renderWithProviders } from "./helpers/render"
import { expectHookThrowsOutsideProvider, renderWithProviders } from "./helpers/render"

function anInstallation(overrides: Partial<InstallationType> = {}): InstallationType {
return {
Expand Down Expand Up @@ -153,21 +153,6 @@ describe("config slice contexts", () => {
})

it("throws by name when a slice hook is used outside the provider", () => {
function Orphan(): JSX.Element {
useInstallations()
return <p>never rendered</p>
}

// React re-reports the throw through console.error and a window error
// event; both are silenced so a passing run stays readable.
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})
const swallow = (event: ErrorEvent): void => event.preventDefault()
window.addEventListener("error", swallow)
try {
expect(() => render(<Orphan />)).toThrow(/useInstallations must be used within a ConfigProvider/)
} finally {
window.removeEventListener("error", swallow)
consoleError.mockRestore()
}
expectHookThrowsOutsideProvider(useInstallations, /useInstallations must be used within a ConfigProvider/)
})
})
35 changes: 34 additions & 1 deletion tests/renderer-dom/helpers/render.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ReactElement, ReactNode } from "react"
import { render, type RenderOptions, type RenderResult } from "@testing-library/react"
import { render, renderHook, type RenderOptions, type RenderResult } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { expect, onTestFinished, vi } from "vitest"

import { NotificationsProvider } from "@renderer/contexts/NotificationsContext"
import { ConfigProvider } from "@renderer/features/config/contexts/ConfigContext"
Expand Down Expand Up @@ -41,3 +42,35 @@ export function renderWithProviders(ui: ReactElement, { route = "/", ...renderOp

return render(ui, { wrapper: Wrapper, ...renderOptions })
}

/**
* Checks that `useHook` throws a message matching `expected` when it is called
* with no provider above it. Every hook guarded that way is tested through here.
*
* The throw is caught inside the component on purpose, and every one of these
* must be written that way. A render throw that escapes reaches React's
* development-only replay, which rethrows it through a synthetic DOM event so
* devtools can see it; jsdom turns that into an uncancelled window "error"
* event, and the jsdom environment re-emits such an event as an uncaught
* exception, which fails the whole run and pins the failure on whichever file
* happened to be running at the time. React also prints "The above error
* occurred in ..." for every render throw it handles itself, so the console
* check at the end is what fails first if an edit ever lets one escape again.
*/
export function expectHookThrowsOutsideProvider(useHook: () => unknown, expected: RegExp): void {
const consoleError = vi.spyOn(console, "error")
onTestFinished(() => consoleError.mockRestore())

let thrown: unknown
renderHook(() => {
try {
useHook()
} catch (error) {
thrown = error
}
})

expect(thrown).toBeInstanceOf(Error)
expect((thrown as Error).message).toMatch(expected)
expect(consoleError).not.toHaveBeenCalled()
}
28 changes: 3 additions & 25 deletions tests/renderer-dom/taskManagerFlows.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { ReactElement, ReactNode } from "react"
import { describe, expect, it, onTestFinished, vi } from "vitest"
import { describe, expect, it, vi } from "vitest"
import { act, renderHook, waitFor } from "@testing-library/react"
import type { RenderHookResult } from "@testing-library/react"

import { NotificationsProvider, useNotificationsContext } from "@renderer/contexts/NotificationsContext"
import { ACTIONS, TaskProvider, taskReducer, useTaskContext } from "@renderer/contexts/TaskManagerContext"
import type { TaskNotificationsMode, TaskType } from "@renderer/contexts/TaskManagerContext"

import { expectHookThrowsOutsideProvider } from "./helpers/render"
import { installMockWindowApi } from "./helpers/windowApi"

// Registers the i18n instance useTranslation() reads inside TaskProvider and
Expand Down Expand Up @@ -79,30 +80,7 @@ describe("taskReducer", () => {

describe("useTaskContext", () => {
it("throws when used outside a TaskProvider", () => {
// The throw is caught inside the component on purpose. A render throw
// that escapes reaches React's dev-only replay, which rethrows it
// through a synthetic DOM event so devtools can see it; jsdom turns that
// into an uncancelled window "error" event, and Vitest's jsdom
// environment re-emits such an event as an uncaught exception that fails
// the whole run and gets pinned on whichever file happened to be running.
const consoleError = vi.spyOn(console, "error")
onTestFinished(() => consoleError.mockRestore())

let thrown: unknown
renderHook(() => {
try {
useTaskContext()
} catch (error) {
thrown = error
}
})

expect(thrown).toBeInstanceOf(Error)
expect((thrown as Error).message).toMatch(/must be used within an TaskProvider/)
// React logs "The above error occurred in ..." for every render throw it
// has to handle itself, so this fails right here if the throw is ever let
// out of the component again.
expect(consoleError).not.toHaveBeenCalled()
expectHookThrowsOutsideProvider(useTaskContext, /must be used within an TaskProvider/)
})
})

Expand Down
Loading