chore: Fixed tests

This commit is contained in:
Simeon Radivoev 2026-05-15 15:07:51 +03:00
parent 9141fb35d4
commit 5593985884
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
13 changed files with 139 additions and 66 deletions

1
.gitignore vendored
View file

@ -28,6 +28,7 @@ downloads
gameflow-deck.code-workspace gameflow-deck.code-workspace
.env.local .env.local
src/tests/mock-roms/db.sqlite src/tests/mock-roms/db.sqlite
src/tests/mock-roms/store
src/tests/mock-config src/tests/mock-config
bin bin
.config/flatpak/repo .config/flatpak/repo

View file

@ -116,6 +116,13 @@ export async function cleanup ()
cleannedUp = true; cleannedUp = true;
} }
/** Reset the cleanup flags. This is mainly used by tests since they run the same app. */
export async function resetCleanup ()
{
cleaningUp = false;
cleannedUp = false;
}
export async function reloadDatabase () export async function reloadDatabase ()
{ {
await ensureDir(config.get('downloadPath')); await ensureDir(config.get('downloadPath'));

View file

@ -454,18 +454,18 @@ export default new Elysia()
}, { }, {
params: z.object({ id: z.string(), source: z.string() }), params: z.object({ id: z.string(), source: z.string() }),
}) })
.post('/game/:source/:id/install', async ({ params: { id, source }, body: { downloadId } }) => .post('/game/:source/:id/install', async ({ params: { id, source }, body }) =>
{ {
if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob)) if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob))
{ {
return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, { downloadId })); return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, body));
} else } else
{ {
return status('Not Implemented'); return status('Not Implemented');
} }
}, { }, {
params: z.object({ id: z.string(), source: z.string() }), params: z.object({ id: z.string(), source: z.string() }),
body: z.object({ downloadId: z.string().optional() }), body: z.object({ downloadId: z.string().optional() }).optional(),
response: z.any() response: z.any()
}) })
.delete('/game/:source/:id/install', async ({ params: { id, source } }) => .delete('/game/:source/:id/install', async ({ params: { id, source } }) =>

View file

@ -6,8 +6,9 @@ import { runBunPackageCommand } from "../plugins/services";
import { PluginRegistry } from "@/shared/constants"; import { PluginRegistry } from "@/shared/constants";
import path from "node:path"; import path from "node:path";
import sdkPkg from '@simeonradivoev/gameflow-sdk/package.json'; import sdkPkg from '@simeonradivoev/gameflow-sdk/package.json';
import { IsPluginAllowed } from "@/bun/utils";
export default class UpdateStoreJob implements IJob<never, string> export default class EnsureStore implements IJob<never, string>
{ {
static id = "update-store" as const; static id = "update-store" as const;
static dataSchema = z.never(); static dataSchema = z.never();
@ -20,7 +21,7 @@ export default class UpdateStoreJob implements IJob<never, string>
this.storeVersion = process.env.STORE_VERSION ?? "^0.1.0"; this.storeVersion = process.env.STORE_VERSION ?? "^0.1.0";
} }
async start (context: JobContext<UpdateStoreJob, never, string>) async start (context: JobContext<EnsureStore, never, string>)
{ {
const storeFolder = getStoreRootFolder(); const storeFolder = getStoreRootFolder();
await ensureDir(storeFolder); await ensureDir(storeFolder);
@ -32,17 +33,23 @@ export default class UpdateStoreJob implements IJob<never, string>
const storePackage = await Bun.file(path.join(storeFolder, "package.json")).json(); const storePackage = await Bun.file(path.join(storeFolder, "package.json")).json();
if (!storePackage.dependencies?.[sdkPkg.name] || storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version) if (IsPluginAllowed(sdkPkg.name))
{ {
let response = await runBunPackageCommand(["add", `${sdkPkg.name}@${sdkPkg.version}`, "--registry", PluginRegistry, '--omit', 'peer']); if (!storePackage.dependencies?.[sdkPkg.name] || storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version)
console.log(response); {
} let response = await runBunPackageCommand(["add", `${sdkPkg.name}@${sdkPkg.version}`, "--registry", PluginRegistry, '--omit', 'peer']);
console.log(response);
}
// probably just means we couldn't find a version of the sdk, just install latest // probably just means we couldn't find a version of the sdk, just install latest
if (storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version) if (storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version)
{
let response = await runBunPackageCommand(["add", '--exact', `${sdkPkg.name}@latest`, "--registry", PluginRegistry, '--omit', 'peer']);
console.log(response);
}
} else
{ {
let response = await runBunPackageCommand(["add", '--exact', `${sdkPkg.name}@latest`, "--registry", PluginRegistry, '--omit', 'peer']); console.log("Ignoring SDK package");
console.log(response);
} }
if (process.env.CUSTOM_STORE_PATH) return; if (process.env.CUSTOM_STORE_PATH) return;

View file

@ -3,7 +3,7 @@ import z, { _ZodType } from "zod";
import { taskQueue } from "../app"; import { taskQueue } from "../app";
import { LoginJob } from "./login-job"; import { LoginJob } from "./login-job";
import TwitchLoginJob from "./twitch-login-job"; import TwitchLoginJob from "./twitch-login-job";
import UpdateStoreJob from "./update-store"; import EnsureStore from "./ensure-store";
import { EmulatorDownloadJob } from "./emulator-download-job"; import { EmulatorDownloadJob } from "./emulator-download-job";
import { getErrorMessage } from "@/bun/utils"; import { getErrorMessage } from "@/bun/utils";
import { BaseEvent, IJob } from "@simeonradivoev/gameflow-sdk/task-queue"; import { BaseEvent, IJob } from "@simeonradivoev/gameflow-sdk/task-queue";
@ -184,7 +184,7 @@ export const jobs = new Elysia({ prefix: '/api/jobs' })
.use(registerJob(LaunchGameJob)) .use(registerJob(LaunchGameJob))
.use(registerJob(LoginJob)) .use(registerJob(LoginJob))
.use(registerJob(TwitchLoginJob)) .use(registerJob(TwitchLoginJob))
.use(registerJob(UpdateStoreJob)) .use(registerJob(EnsureStore))
.use(registerJob(BiosDownloadJob)) .use(registerJob(BiosDownloadJob))
.use(registerJob(InstallJob)) .use(registerJob(InstallJob))
.use(registerJob(ReloadPluginsJob)) .use(registerJob(ReloadPluginsJob))

View file

@ -2,14 +2,14 @@ import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-s
import desc from './package.json'; import desc from './package.json';
import path, { } from 'node:path'; import path, { } from 'node:path';
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService"; import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService";
import { Glob, pathToFileURL, which } from "bun"; import { Glob, pathToFileURL, sleep, which } from "bun";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import * as emulatorSchema from '@schema/emulators'; import * as emulatorSchema from '@schema/emulators';
import { config, emulatorsDb, taskQueue } from "@/bun/api/app"; import { config, emulatorsDb, taskQueue } from "@/bun/api/app";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { getSourceGameDetailed } from "@/bun/api/games/services/utils"; import { getSourceGameDetailed } from "@/bun/api/games/services/utils";
import UpdateStoreJob from "@/bun/api/jobs/update-store"; import EnsureStore from "@/bun/api/jobs/ensure-store";
import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService";
import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services"; import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services";
import { DownloadInfo, FrontEndEmulatorDetailed, FrontEndGameTypeWithIds } from "@simeonradivoev/gameflow-sdk/shared"; import { DownloadInfo, FrontEndEmulatorDetailed, FrontEndGameTypeWithIds } from "@simeonradivoev/gameflow-sdk/shared";
@ -20,7 +20,7 @@ import StreamZip from "node-stream-zip";
import { path7za } from "7zip-bin"; import { path7za } from "7zip-bin";
import Seven from 'node-7z'; import Seven from 'node-7z';
export default class RommIntegration implements PluginType export default class StoreIntegration implements PluginType
{ {
eventsNames = [{ id: 'updateStore', title: "Update Store", description: "Update the Store Manifest", action: "Update" }]; eventsNames = [{ id: 'updateStore', title: "Update Store", description: "Update the Store Manifest", action: "Update" }];
@ -29,7 +29,7 @@ export default class RommIntegration implements PluginType
switch (e) switch (e)
{ {
case 'updateStore': case 'updateStore':
await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); await taskQueue.enqueue(EnsureStore.id, new EnsureStore());
return { reload: true }; return { reload: true };
} }
} }
@ -38,7 +38,7 @@ export default class RommIntegration implements PluginType
{ {
console.log("Store Directory is ", getStoreFolder()); console.log("Store Directory is ", getStoreFolder());
ctx.setProgress(0, "Updating Store"); ctx.setProgress(0, "Updating Store");
await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); await taskQueue.enqueue(EnsureStore.id, new EnsureStore());
} }
async load (ctx: PluginLoadingContextType) async load (ctx: PluginLoadingContextType)

View file

@ -14,10 +14,12 @@ import rclone from './builtin/other/com.simeonradivoev.gameflow.rclone/package.j
import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@simeonradivoev/gameflow-sdk"; import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@simeonradivoev/gameflow-sdk";
import path from 'node:path'; import path from 'node:path';
import { getStoreRootFolder } from "../store/services/gamesService"; import { getStoreRootFolder } from "../store/services/gamesService";
import { getUpdates } from "./services"; import { getUpdates, runBunPackageCommand } from "./services";
import { PluginSourceType } from "@simeonradivoev/gameflow-sdk/shared"; import { PluginSourceType } from "@simeonradivoev/gameflow-sdk/shared";
import { taskQueue } from "../app"; import { taskQueue } from "../app";
import UpdateStoreJob from "../jobs/update-store"; import EnsureStore from "../jobs/ensure-store";
import { PluginRegistry } from "@/shared/constants";
import { IsPluginAllowed } from "@/bun/utils";
type PluginEntry = PluginDescriptionType & { load: () => Promise<any>; }; type PluginEntry = PluginDescriptionType & { load: () => Promise<any>; };
@ -58,15 +60,9 @@ export async function unregisterPlugin (id: string, pluginManager: PluginManager
export async function registerPlugin (plugin: PluginEntry, source: PluginSourceType, pluginManager: PluginManager) export async function registerPlugin (plugin: PluginEntry, source: PluginSourceType, pluginManager: PluginManager)
{ {
if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(plugin.name)) if (!IsPluginAllowed(plugin.name))
{ {
console.log("Skipping", plugin.name, "missing in whitelist"); console.log("Skipping", plugin.name, "plugin not allowed");
return;
}
if (process.env.PLUGIN_BLACKLIST && process.env.PLUGIN_BLACKLIST.includes(plugin.name))
{
console.log("Skipping", plugin.name, "found in whitelist");
return; return;
} }
@ -101,39 +97,52 @@ export default async function register (pluginManager: PluginManager)
await Promise.all(plugins.map(p => registerPlugin(p, 'builtin', pluginManager))); await Promise.all(plugins.map(p => registerPlugin(p, 'builtin', pluginManager)));
const storePackageFilePath = path.join(getStoreRootFolder(), 'package.json'); if (IsPluginAllowed('@simeonradivoev/gameflow-store'))
if (!await Bun.file(storePackageFilePath).exists())
{ {
console.log("Store is missing. Updating it."); const storePackageFilePath = path.join(getStoreRootFolder(), 'package.json');
await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); if (!await Bun.file(storePackageFilePath).exists())
console.log("Store Updated");
}
const storePackage = await Bun.file(storePackageFilePath).json();
if (storePackage?.dependencies)
{
const storePlugins = await Promise.all(Object.keys(storePackage.dependencies).filter(p => !blacklist.has(p)).map(async p =>
{ {
return getPlugin(p, pluginManager); console.log("Store is missing. Updating it.");
})); await taskQueue.enqueue(EnsureStore.id, new EnsureStore());
console.log("Store Updated");
console.log("Checking for outdated packages");
const outdated = await getUpdates();
const validPlugins = storePlugins.filter(p => !!p);
if (outdated)
{
validPlugins.forEach(p =>
{
const newVersion = outdated[p.name];
if (newVersion)
{
console.log("Plugin", p.name, "has update", p.version, "=>", newVersion);
}
});
} }
const storePackage = await Bun.file(storePackageFilePath).json();
await Promise.all(validPlugins.map(p => registerPlugin(p, 'store', pluginManager))); if (storePackage?.dependencies)
{
const storePlugins = await Promise.all(Object.keys(storePackage.dependencies).filter(p => !blacklist.has(p)).map(async p =>
{
return getPlugin(p, pluginManager);
}));
console.log("Checking for outdated packages");
const outdated = await getUpdates();
const validPlugins = storePlugins.filter(p => !!p);
if (outdated)
{
for await (const plugin of validPlugins)
{
const newVersion = outdated[plugin.name];
if (newVersion)
{
console.log("Plugin", plugin.name, "has update", plugin.version, "=>", newVersion);
}
if (plugin.autoUpdate)
{
console.log("Auto Updating Plugin", plugin.name);
let response = await runBunPackageCommand(["add", `${plugin.name}@${newVersion}`, "--registry", PluginRegistry, '--omit', 'peer']);
console.log(response);
}
}
}
await Promise.all(validPlugins.map(p => registerPlugin(p, 'store', pluginManager)));
}
} else
{
console.log('Skipping Store Packages');
} }
} }

View file

@ -3,6 +3,7 @@ import os from 'node:os';
import { getStoreRootFolder } from '../store/services/gamesService'; import { getStoreRootFolder } from '../store/services/gamesService';
import { PluginDescriptionType } from '@simeonradivoev/gameflow-sdk'; import { PluginDescriptionType } from '@simeonradivoev/gameflow-sdk';
import { run } from 'npm-check-updates'; import { run } from 'npm-check-updates';
import { existsSync } from 'node:fs';
export function canDisable (description: PluginDescriptionType) export function canDisable (description: PluginDescriptionType)
{ {
@ -15,6 +16,7 @@ export function canDisable (description: PluginDescriptionType)
export async function getUpdates () export async function getUpdates ()
{ {
if (!existsSync(getStoreRootFolder())) return {};
const updated = await run({ packageManager: 'bun', peer: true, cwd: getStoreRootFolder(), jsonUpgraded: true, reject: ['@simeonradivoev/gameflow-sdk'] }); const updated = await run({ packageManager: 'bun', peer: true, cwd: getStoreRootFolder(), jsonUpgraded: true, reject: ['@simeonradivoev/gameflow-sdk'] });
return updated as Record<string, string>; return updated as Record<string, string>;
} }

View file

@ -188,16 +188,16 @@ export const store = new Elysia({ prefix: '/api/store' })
emulator.integrations = integrations; emulator.integrations = integrations;
return emulator; return emulator;
}, { params: z.object({ id: z.string() }) }) }, { params: z.object({ id: z.string() }) })
.post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) => .post('/install/emulator/:id/:source', async ({ params: { source, id }, body }) =>
{ {
if (taskQueue.hasActiveOfType(EmulatorDownloadJob)) if (taskQueue.hasActiveOfType(EmulatorDownloadJob))
{ {
return status("Conflict", "Installation already running"); return status("Conflict", "Installation already running");
} }
const job = new EmulatorDownloadJob(id, source, { isUpdate }); const job = new EmulatorDownloadJob(id, source, body);
return taskQueue.enqueue(EmulatorDownloadJob.id, job); return taskQueue.enqueue(EmulatorDownloadJob.id, job);
}, { }, {
body: z.object({ isUpdate: z.boolean().optional() }) body: z.object({ isUpdate: z.boolean().optional() }).optional()
}) })
.delete('/emulator/:id', async ({ params: { id } }) => .delete('/emulator/:id', async ({ params: { id } }) =>
{ {

View file

@ -186,3 +186,18 @@ export function isArchive (path: string)
{ {
return archiveRegex.test(path); return archiveRegex.test(path);
} }
export function IsPluginAllowed (id: string)
{
if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(id))
{
return false;
}
if (process.env.PLUGIN_BLACKLIST && process.env.PLUGIN_BLACKLIST.includes(id))
{
return false;
}
return true;
}

View file

@ -41,7 +41,8 @@ export const PluginDescriptionSchema = z.object({
peerDependencies: z.record(z.string(), z.string()).optional(), peerDependencies: z.record(z.string(), z.string()).optional(),
category: z.string().default("other"), category: z.string().default("other"),
main: z.string().describe("The main entry. It must export a default class implementing PluginType"), main: z.string().describe("The main entry. It must export a default class implementing PluginType"),
canDisable: z.boolean().default(true).optional().describe("Can the plugin be disabled or enabled by the user") canDisable: z.boolean().default(true).optional().describe("Can the plugin be disabled or enabled by the user"),
autoUpdate: z.boolean().optional().describe("Should the plugin auto update to latest version")
}); });
export const PluginSchema = z.object({ export const PluginSchema = z.object({

View file

@ -91,6 +91,11 @@ export class TaskQueue
return this.activeQueue.length > 0; return this.activeQueue.length > 0;
} }
public hasQueued ()
{
return this.queue && this.queue.length > 0;
}
public hasActiveOfType (type: any) public hasActiveOfType (type: any)
{ {
for (const entry of this.activeQueue) for (const entry of this.activeQueue)
@ -109,6 +114,30 @@ export class TaskQueue
return job?.promise.promise ?? Promise.resolve(); return job?.promise.promise ?? Promise.resolve();
} }
public waitForAll ()
{
return new Promise((resolve) =>
{
if (!this.hasActive())
{
resolve(true);
return;
}
const handleEnded = () =>
{
if (!this.hasActive() && !this.hasQueued())
{
resolve(true);
this.events?.removeListener('ended', handleEnded);
this.events?.removeListener('abort', handleEnded);
}
};
this.events?.on('ended', handleEnded);
this.events?.on('abort', handleEnded);
});
}
public cancelJob (id: string) public cancelJob (id: string)
{ {
const job = this.queue?.find(j => j.id === id) const job = this.queue?.find(j => j.id === id)

View file

@ -1,18 +1,20 @@
import { beforeAll, beforeEach, afterEach } from 'bun:test'; import { beforeAll, beforeEach, afterEach } from 'bun:test';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import * as app from '@/bun/api/app'; import * as app from '@/bun/api/app';
import { remove } from 'fs-extra'; import { ensureDir, remove } from 'fs-extra';
export async function LoadApp () export async function LoadApp ()
{ {
console.log("Loading App"); console.log("Loading App");
await app.load(); await app.load();
await app.taskQueue.waitForAll();
} }
export async function CleanupApp () export async function CleanupApp ()
{ {
console.log("Cleaning Up App"); console.log("Cleaning Up App");
await app.cleanup(); await app.cleanup();
await app.resetCleanup();
} }
beforeAll(async () => beforeAll(async () =>
@ -20,7 +22,7 @@ beforeAll(async () =>
process.env.CUSTOM_STORE_PATH = resolve('./src/tests/mock-store'); process.env.CUSTOM_STORE_PATH = resolve('./src/tests/mock-store');
process.env.CONFIG_CWD = resolve('./src/tests/mock-config'); process.env.CONFIG_CWD = resolve('./src/tests/mock-config');
process.env.DEFAULT_DOWNLOAD_PATH = resolve('./src/tests/mock-roms'); process.env.DEFAULT_DOWNLOAD_PATH = resolve('./src/tests/mock-roms');
process.env.PLUGIN_BLACKLIST = 'com.simeonradivoev.gameflow.rclone'; process.env.PLUGIN_BLACKLIST = 'com.simeonradivoev.gameflow.rclone,@simeonradivoev/gameflow-store,com.simeonradivoev.gameflow.romm,com.simeonradivoev.gameflow.igdb,@simeonradivoev/gameflow-sdk';
}); });
async function FileCleanup () async function FileCleanup ()