Skip to content
Open
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
49 changes: 49 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"lint": "bun run --parallel \"oxlint\" \"oxfmt --check\"",
"prepare": "husky",
"release": "bumpp --commit --tag --push",
"start": "vite build && electrobun dev"
"start": "vite build && electrobun dev",
"test": "bun run vitest run"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
Expand Down Expand Up @@ -63,7 +64,8 @@
"oxlint-tsgolint": "^0.17.0",
"tailwindcss": "^4.2.1",
"typescript": "^5.7.2",
"vite": "^8.0.0"
"vite": "^8.0.0",
"vitest": "^4.1.0"
},
"lint-staged": {
"*": "oxfmt --no-error-on-unmatched-pattern",
Expand Down
242 changes: 242 additions & 0 deletions src/bun/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { existsSync, mkdirSync, readFileSync, rmSync } from "fs";
import { join } from "path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";

// vi.hoisted runs before ESM imports are resolved.
// Use plain require() — no "typeof import()" type assertions, which can confuse
// Vitest's AST hoisting transform.
const tmpDir = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any
const path = require("path") as any;
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any
const os = require("os") as any;
return path.join(os.tmpdir(), `storyforge-test-${process.pid}`) as string;
});

vi.mock("electrobun/bun", () => ({
Utils: { paths: { appData: tmpDir } },
}));

// Mock top-level electrobun so the controller can be imported without a native runtime
vi.mock("electrobun", () => ({
default: { Updater: {} },
Utils: { openExternal: vi.fn(), quit: vi.fn(), showNotification: vi.fn() },
}));

// Mock src/bun/index.ts (imported by the controller as "..")
vi.mock("../index", () => ({
mainWindow: {
webview: { rpc: { send: vi.fn() } },
minimize: vi.fn(),
maximize: vi.fn(),
unmaximize: vi.fn(),
isMaximized: vi.fn(() => false),
},
}));

import { utilsController } from "../controllers/utils";
import {
getInstallationsPath,
getModsCachePath,
getStreamMode,
getVersionsPath,
getPlatform,
slugify,
} from "../utils";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

const configPath = join(tmpDir, "storyforge", "config.json");

// Write via Bun.write() (stateless helper) rather than the singleton configFile,
// so we avoid any BunFile instance-level caching between tests.
async function writeConfig(data: Record<string, unknown>) {
mkdirSync(join(tmpDir, "storyforge"), { recursive: true });
await Bun.write(configPath, JSON.stringify(data));
}

beforeAll(() => {
mkdirSync(join(tmpDir, "storyforge"), { recursive: true });
});

beforeEach(() => {
if (existsSync(configPath)) rmSync(configPath);
});

// ---------------------------------------------------------------------------
// getStreamMode
// ---------------------------------------------------------------------------

describe("getStreamMode", () => {
it("returns false by default when no config file exists", async () => {
expect(await getStreamMode()).toBe(false);
});

it("returns true when config has streamMode: true", async () => {
await writeConfig({ streamMode: true });
expect(await getStreamMode()).toBe(true);
});

it("returns false when streamMode is not a boolean", async () => {
await writeConfig({ streamMode: "yes" });
expect(await getStreamMode()).toBe(false);
});
});

// ---------------------------------------------------------------------------
// getVersionsPath
// ---------------------------------------------------------------------------

describe("getVersionsPath", () => {
it("returns default path under appData when no relevant config is saved", async () => {
await writeConfig({}); // empty config — no versionPath key
expect(await getVersionsPath()).toBe(join(tmpDir, "storyforge", "versions"));
});

it("returns custom path saved in config", async () => {
await writeConfig({ versionPath: "/custom/versions" });
expect(await getVersionsPath()).toBe("/custom/versions");
});
});

// ---------------------------------------------------------------------------
// getInstallationsPath
// ---------------------------------------------------------------------------

describe("getInstallationsPath", () => {
it("returns default path under appData when no relevant config is saved", async () => {
await writeConfig({}); // empty config — no installationsPath key
expect(await getInstallationsPath()).toBe(join(tmpDir, "storyforge", "installations"));
});

it("returns custom path saved in config", async () => {
await writeConfig({ installationsPath: "/custom/installations" });
expect(await getInstallationsPath()).toBe("/custom/installations");
});
});

// ---------------------------------------------------------------------------
// getModsCachePath
// ---------------------------------------------------------------------------

describe("getModsCachePath", () => {
it("returns default path under appData when no relevant config is saved", async () => {
await writeConfig({}); // empty config — no modsCachePath key
expect(await getModsCachePath()).toBe(join(tmpDir, "storyforge", "mods_cache"));
});

it("returns custom path saved in config", async () => {
await writeConfig({ modsCachePath: "/custom/mods" });
expect(await getModsCachePath()).toBe("/custom/mods");
});
});

// ---------------------------------------------------------------------------
// setConfig / getConfig (controller)
// ---------------------------------------------------------------------------

describe("setConfig", () => {
it("writes all fields to the config file", async () => {
await utilsController.setConfig({
streamMode: true,
versionPath: "/my/versions",
installationsPath: "/my/installations",
modsCachePath: "/my/mods",
});

const saved = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
expect(saved.streamMode).toBe(true);
expect(saved.versionPath).toBe("/my/versions");
expect(saved.installationsPath).toBe("/my/installations");
expect(saved.modsCachePath).toBe("/my/mods");
});

it("merges partial updates without clobbering existing fields", async () => {
await writeConfig({ streamMode: true, versionPath: "/my/versions" });

await utilsController.setConfig({ installationsPath: "/new/installations" });

const saved = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
expect(saved.streamMode).toBe(true); // unchanged
expect(saved.versionPath).toBe("/my/versions"); // unchanged
expect(saved.installationsPath).toBe("/new/installations"); // updated
});
});

describe("getConfig", () => {
it("reflects values written by setConfig", async () => {
await utilsController.setConfig({
streamMode: true,
versionPath: "/v",
installationsPath: "/i",
modsCachePath: "/m",
});

const cfg = await utilsController.getConfig();
expect(cfg.streamMode).toBe(true);
expect(cfg.versionPath).toBe("/v");
expect(cfg.installationsPath).toBe("/i");
expect(cfg.modsCachePath).toBe("/m");
});

it("returns defaults when no config has been saved", async () => {
const cfg = await utilsController.getConfig();
expect(cfg.streamMode).toBe(false);
expect(cfg.versionPath).toBe(join(tmpDir, "storyforge", "versions"));
expect(cfg.installationsPath).toBe(join(tmpDir, "storyforge", "installations"));
expect(cfg.modsCachePath).toBe(join(tmpDir, "storyforge", "mods_cache"));
});
});

// ---------------------------------------------------------------------------
// slugify (pure function)
// ---------------------------------------------------------------------------

describe("slugify", () => {
it("lowercases and converts spaces to hyphens", () => {
expect(slugify("My World")).toBe("my-world");
});

it("removes special characters", () => {
expect(slugify("Hello, World!")).toBe("hello-world");
});

it("collapses multiple hyphens into one", () => {
expect(slugify("foo---bar")).toBe("foo-bar");
});

it("strips leading and trailing hyphens", () => {
expect(slugify(" My Installation ")).toBe("my-installation");
});

it("returns 'default' for empty string", () => {
expect(slugify("")).toBe("default");
});

it("returns 'default' when all characters are stripped", () => {
expect(slugify("!!!")).toBe("default");
});
});

// ---------------------------------------------------------------------------
// getPlatform (pure function)
// ---------------------------------------------------------------------------

describe("getPlatform", () => {
it("returns 'mac' on darwin", () => {
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
expect(getPlatform()).toBe("mac");
});

it("returns 'windows' on win32", () => {
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
expect(getPlatform()).toBe("windows");
});

it("returns 'linux' on linux", () => {
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
expect(getPlatform()).toBe("linux");
});
});
37 changes: 36 additions & 1 deletion src/bun/controllers/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import Electrobun, { Utils } from "electrobun";
import { InferRPCSchema } from "@/shared/helper";
import { mainWindow } from "..";
import { getStreamMode } from "../utils";
import {
getConfigFile,
getInstallationsPath,
getModsCachePath,
getStreamMode,
getVersionsPath,
} from "../utils";

export const utilsController = {
getVersion: async (): Promise<string> => {
Expand Down Expand Up @@ -33,6 +39,35 @@ export const utilsController = {
getStreamMode: async (): Promise<boolean> => {
return await getStreamMode();
},
getConfig: async (): Promise<{
streamMode: boolean;
versionPath: string;
installationsPath: string;
modsCachePath: string;
}> => {
const streamMode = await getStreamMode();
const versionPath = await getVersionsPath();
const installationsPath = await getInstallationsPath();
const modsCachePath = await getModsCachePath();
return { streamMode, versionPath, installationsPath, modsCachePath };
},
setConfig: async (config: {
streamMode?: boolean;
versionPath?: string;
installationsPath?: string;
modsCachePath?: string;
}): Promise<void> => {
const configFile = getConfigFile();
const exists = await configFile.exists();
const configText = exists ? await configFile.text() : "{}";
const current = Bun.JSON5.parse(configText.trim() || "{}") as Record<string, unknown>;
if (config.streamMode !== undefined) current.streamMode = config.streamMode;
if (config.versionPath !== undefined) current.versionPath = config.versionPath;
if (config.installationsPath !== undefined)
current.installationsPath = config.installationsPath;
if (config.modsCachePath !== undefined) current.modsCachePath = config.modsCachePath;
await configFile.write(JSON.stringify(current, null, 2));
},
openLink: ({ url }: { url: string }): void => {
Utils.openExternal(url);
},
Expand Down
16 changes: 12 additions & 4 deletions src/bun/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ import { mkdirSync } from "fs";
import { join } from "path";
import { Utils } from "electrobun/bun";

export const configFile = Bun.file(join(Utils.paths.appData, "storyforge", "config.json"));
export const oldSettingsFile = Bun.file(
join(Utils.paths.appData, "storyforge", "store", "settings.json"),
);
export function getConfigFile(): ReturnType<typeof Bun.file> {
return Bun.file(join(Utils.paths.appData, "storyforge", "config.json"));
}

function getOldSettingsFile(): ReturnType<typeof Bun.file> {
return Bun.file(join(Utils.paths.appData, "storyforge", "store", "settings.json"));
}

export async function getOldSettings() {
const oldSettingsFile = getOldSettingsFile();
if (await oldSettingsFile.exists()) {
const oldConfigText = await oldSettingsFile.text();
const oldConfig = Bun.JSON5.parse(oldConfigText);
Expand All @@ -23,6 +27,7 @@ export async function getOldSettings() {
}

export async function getStreamMode(): Promise<boolean> {
const configFile = getConfigFile();
if (!(await configFile.exists())) {
const oldSettings = await getOldSettings();
if (oldSettings) {
Expand Down Expand Up @@ -59,6 +64,7 @@ export async function getStreamMode(): Promise<boolean> {
}

export async function getModsCachePath(): Promise<string> {
const configFile = getConfigFile();
if (!(await configFile.exists())) {
mkdirSync(join(Utils.paths.appData, "storyforge"), { recursive: true });
await configFile.write(
Expand All @@ -82,6 +88,7 @@ export async function getModsCachePath(): Promise<string> {
}

export async function getVersionsPath(): Promise<string> {
const configFile = getConfigFile();
if (!(await configFile.exists())) {
const oldSettings = await getOldSettings();
if (oldSettings) {
Expand Down Expand Up @@ -123,6 +130,7 @@ export async function getVersionsPath(): Promise<string> {
}

export async function getInstallationsPath(): Promise<string> {
const configFile = getConfigFile();
if (!(await configFile.exists())) {
const oldSettings = await getOldSettings();
if (oldSettings) {
Expand Down
Loading