test: Added download test and made app more testable in general
fix: Store Downloads not properly working on steam deck fix: Removed linux shortcuts implementation
This commit is contained in:
parent
58d3c31c56
commit
8a0be8c913
26 changed files with 422 additions and 210 deletions
6
bun.lock
6
bun.lock
|
|
@ -49,6 +49,7 @@
|
|||
"@tanstack/react-router-ssr-query": "^1.157.17",
|
||||
"@tanstack/router-plugin": "^1.157.16",
|
||||
"@tanstack/zod-adapter": "^1.162.4",
|
||||
"@types/adm-zip": "^0.5.8",
|
||||
"@types/bun": "latest",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/ini": "^4.1.1",
|
||||
|
|
@ -58,6 +59,7 @@
|
|||
"@types/react-dom": "^19.2.3",
|
||||
"@types/unzip-stream": "^0.3.4",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"adm-zip": "^0.5.16",
|
||||
"animate.css": "^4.1.1",
|
||||
"app-builder-bin": "^5.0.0-alpha.13",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
|
|
@ -584,6 +586,8 @@
|
|||
|
||||
"@trysound/sax": ["@trysound/sax@0.2.0", "", {}, "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA=="],
|
||||
|
||||
"@types/adm-zip": ["@types/adm-zip@0.5.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
|
@ -636,6 +640,8 @@
|
|||
|
||||
"add-stream": ["add-stream@1.0.0", "", {}, "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ=="],
|
||||
|
||||
"adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="],
|
||||
|
||||
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@
|
|||
"@tanstack/react-router-ssr-query": "^1.157.17",
|
||||
"@tanstack/router-plugin": "^1.157.16",
|
||||
"@tanstack/zod-adapter": "^1.162.4",
|
||||
"@types/adm-zip": "^0.5.8",
|
||||
"@types/bun": "latest",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/ini": "^4.1.1",
|
||||
|
|
@ -96,6 +97,7 @@
|
|||
"@types/react-dom": "^19.2.3",
|
||||
"@types/unzip-stream": "^0.3.4",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"adm-zip": "^0.5.16",
|
||||
"animate.css": "^4.1.1",
|
||||
"app-builder-bin": "^5.0.0-alpha.13",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { CookieJar } from 'tough-cookie';
|
|||
import FileCookieStore from 'tough-cookie-file-store';
|
||||
import path from 'node:path';
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import Conf from "conf";
|
||||
import projectPackage from '~/package.json';
|
||||
import { SettingsSchema, SettingsType } from "@shared/constants";
|
||||
|
|
@ -23,60 +23,88 @@ import { getStoreFolder } from "./store/services/gamesService";
|
|||
import { PluginManager } from "./plugins/plugin-manager";
|
||||
import registerPlugins from "./plugins/register-plugins";
|
||||
import controls from './controls/controls';
|
||||
import { RunAPIServer } from "./rpc";
|
||||
import { RunBunServer } from "../server";
|
||||
|
||||
export const config = new Conf<SettingsType>({
|
||||
projectName: projectPackage.name,
|
||||
projectSuffix: 'bun',
|
||||
cwd: process.env.CONFIG_CWD,
|
||||
schema: Object.fromEntries(Object.entries(SettingsSchema.shape).map(([key, schema]) => [key, schema.toJSONSchema() as any])) as any,
|
||||
defaults: SettingsSchema.parse({
|
||||
downloadPath: path.join(os.homedir(), "gameflow"),
|
||||
windowSize: { width: 1280, height: 800 }
|
||||
}),
|
||||
});
|
||||
export const customEmulators = new Conf<Record<string, string>>({
|
||||
projectName: projectPackage.name,
|
||||
projectSuffix: 'bun',
|
||||
cwd: process.env.CONFIG_CWD,
|
||||
configName: 'custom-emulators',
|
||||
rootSchema: {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Config Path Located At: ", config.path);
|
||||
console.log("Custom Emulator Paths Located At: ", customEmulators.path);
|
||||
console.log("App Directory is ", process.env.APPDIR);
|
||||
console.log("Store Directory is ", getStoreFolder());
|
||||
|
||||
const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
|
||||
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
|
||||
export const jar = new CookieJar(fileCookieStore);
|
||||
export let config: Conf<SettingsType>;
|
||||
export let customEmulators: Conf<Record<string, string>>;
|
||||
export let fileCookieStore: FileCookieStore;
|
||||
export let jar: CookieJar;
|
||||
let sqlite: Database;
|
||||
export const cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite');
|
||||
export let cachePath: string;
|
||||
let cacheSqlite: Database;
|
||||
export let db: DrizzleSqliteDODatabase<typeof schema>;
|
||||
export let cache: DrizzleSqliteDODatabase<typeof cacheSchema>;
|
||||
await reloadDatabase();
|
||||
const emulatorsSqlite = new Database(appPath(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`), { readonly: true });
|
||||
export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
|
||||
export const taskQueue = new TaskQueue();
|
||||
config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v }));
|
||||
export const plugins = new PluginManager();
|
||||
registerPlugins(plugins);
|
||||
export const events = new EventEmitter<AppEventMap>();
|
||||
config.onDidChange('downloadPath', () => reloadDatabase());
|
||||
taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
|
||||
await controls();
|
||||
let emulatorsSqlite: Database;
|
||||
export let emulatorsDb: BunSQLiteDatabase<typeof emulatorSchema> & { $client: Database; };
|
||||
export let taskQueue: TaskQueue;
|
||||
export let plugins: PluginManager;
|
||||
export let events: EventEmitter<AppEventMap>;
|
||||
let controlsHandle: { cleanup: () => void; };
|
||||
let api: any;
|
||||
let bunServer: { stop: () => void; } | undefined;
|
||||
|
||||
export async function load ()
|
||||
{
|
||||
config = new Conf<SettingsType>({
|
||||
projectName: projectPackage.name,
|
||||
projectSuffix: 'bun',
|
||||
cwd: process.env.CONFIG_CWD,
|
||||
schema: Object.fromEntries(Object.entries(SettingsSchema.shape).map(([key, schema]) => [key, schema.toJSONSchema() as any])) as any,
|
||||
defaults: SettingsSchema.parse({
|
||||
downloadPath: process.env.DEFAULT_DOWNLOAD_PATH ?? path.join(os.homedir(), "gameflow"),
|
||||
windowSize: { width: 1280, height: 800 }
|
||||
}),
|
||||
});
|
||||
customEmulators = new Conf<Record<string, string>>({
|
||||
projectName: projectPackage.name,
|
||||
projectSuffix: 'bun',
|
||||
cwd: process.env.CONFIG_CWD,
|
||||
configName: 'custom-emulators',
|
||||
rootSchema: {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Config Path Located At: ", config.path);
|
||||
console.log("Custom Emulator Paths Located At: ", customEmulators.path);
|
||||
console.log("App Directory is ", process.env.APPDIR);
|
||||
console.log("Store Directory is ", getStoreFolder());
|
||||
|
||||
cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite');
|
||||
fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
|
||||
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
|
||||
jar = new CookieJar(fileCookieStore);
|
||||
taskQueue = new TaskQueue();
|
||||
events = new EventEmitter<AppEventMap>();
|
||||
emulatorsSqlite = new Database(appPath(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`), { readonly: true });
|
||||
emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
|
||||
await reloadDatabase();
|
||||
plugins = new PluginManager();
|
||||
await registerPlugins(plugins);
|
||||
api = await RunAPIServer();
|
||||
controlsHandle = await controls();
|
||||
if (!process.env.PUBLIC_ACCESS) bunServer = await RunBunServer();
|
||||
|
||||
config.onDidChange('downloadPath', () => reloadDatabase());
|
||||
config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v }));
|
||||
taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
|
||||
}
|
||||
|
||||
export async function cleanup ()
|
||||
{
|
||||
console.log("Cleaning Up");
|
||||
bunServer?.stop();
|
||||
await api.apiServer.stop(true);
|
||||
await api.cleanup();
|
||||
await taskQueue.close();
|
||||
controlsHandle.cleanup();
|
||||
sqlite.close();
|
||||
emulatorsSqlite.close();
|
||||
console.log("Finished Cleaning Up");
|
||||
}
|
||||
|
||||
export async function reloadDatabase ()
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default async function Initialize ()
|
|||
}
|
||||
}
|
||||
|
||||
setInterval(() =>
|
||||
const loop = setInterval(() =>
|
||||
{
|
||||
for (const pad of manager.getGamepads())
|
||||
{
|
||||
|
|
@ -56,4 +56,11 @@ export default async function Initialize ()
|
|||
endPressed = false;
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return {
|
||||
cleanup: () =>
|
||||
{
|
||||
clearInterval(loop);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -19,10 +19,6 @@ export class Gamepad
|
|||
{
|
||||
const { GamepadWindows } = await import("./windows");
|
||||
this.backend = new GamepadWindows(this.index);
|
||||
} else
|
||||
{
|
||||
const { GamepadLinux } = await import("./linux");
|
||||
this.backend = new GamepadLinux(this.index);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,87 +1,23 @@
|
|||
import { IGamepadBackend, GamepadState, ButtonName } from "./types";
|
||||
import { openSync, readSync, closeSync, readdirSync } from "fs";
|
||||
import { IGamepadBackend, GamepadState } from "./types";
|
||||
|
||||
export class GamepadLinux implements IGamepadBackend
|
||||
{
|
||||
private fd: number;
|
||||
private buttons: boolean[];
|
||||
private axes: number[];
|
||||
private buttonsCount = 16;
|
||||
private axesCount = 4;
|
||||
|
||||
constructor(index = 0)
|
||||
{
|
||||
const devices = readdirSync("/dev/input").filter(f => f.startsWith("js"));
|
||||
if (!devices[index]) throw new Error("No gamepad found");
|
||||
const path = `/dev/input/${devices[index]}`;
|
||||
this.fd = openSync(path, "r");
|
||||
|
||||
this.buttons = Array(this.buttonsCount).fill(false);
|
||||
this.axes = Array(this.axesCount).fill(0);
|
||||
}
|
||||
|
||||
update (): GamepadState | null
|
||||
{
|
||||
const buf = Buffer.alloc(8);
|
||||
let bytesRead;
|
||||
try
|
||||
{
|
||||
bytesRead = readSync(this.fd, buf, 0, 8, null);
|
||||
} catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (bytesRead !== 8) return null;
|
||||
|
||||
const [time, value, type, number] = [
|
||||
buf.readUInt32LE(0),
|
||||
buf.readInt16LE(4),
|
||||
buf[6],
|
||||
buf[7],
|
||||
];
|
||||
|
||||
if (type === 1) this.buttons[number] = value !== 0;
|
||||
else if (type === 2 && number < 4) this.axes[number] = value / 32767;
|
||||
|
||||
const btnMap: Record<ButtonName, boolean> = {
|
||||
A: this.buttons[0] ?? false,
|
||||
B: this.buttons[1] ?? false,
|
||||
X: this.buttons[2] ?? false,
|
||||
Y: this.buttons[3] ?? false,
|
||||
UP: this.buttons[4] ?? false,
|
||||
DOWN: this.buttons[5] ?? false,
|
||||
LEFT: this.buttons[6] ?? false,
|
||||
RIGHT: this.buttons[7] ?? false,
|
||||
LB: this.buttons[8] ?? false,
|
||||
RB: this.buttons[9] ?? false,
|
||||
START: this.buttons[10] ?? false,
|
||||
SELECT: this.buttons[11] ?? false,
|
||||
L3: this.buttons[12] ?? false,
|
||||
R3: this.buttons[13] ?? false,
|
||||
};
|
||||
|
||||
return {
|
||||
buttons: btnMap,
|
||||
leftStick: { x: this.axes[0] ?? 0, y: this.axes[1] ?? 0 },
|
||||
rightStick: { x: this.axes[2] ?? 0, y: this.axes[3] ?? 0 },
|
||||
triggers: { left: 0, right: 0 },
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
isConnected ()
|
||||
{
|
||||
try
|
||||
{
|
||||
readSync(this.fd, Buffer.alloc(1), 0, 1, null);
|
||||
return true;
|
||||
} catch
|
||||
{
|
||||
return false; // file disappeared or read failed
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
close ()
|
||||
{
|
||||
closeSync(this.fd);
|
||||
}
|
||||
}
|
||||
|
|
@ -348,13 +348,7 @@ export default new Elysia()
|
|||
{
|
||||
if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob))
|
||||
{
|
||||
if (source === 'romm' || source === 'store')
|
||||
{
|
||||
taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source));
|
||||
return status(200);
|
||||
}
|
||||
|
||||
return status('Not Implemented');
|
||||
return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source));
|
||||
} else
|
||||
{
|
||||
return status('Not Implemented');
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export class BiosDownloadJob implements IJob<z.infer<typeof BiosDownloadJob.data
|
|||
this.dryRun = init?.dryRun ?? false;
|
||||
}
|
||||
|
||||
async start (context: JobContext<IJob<never, "download">, never, "download">)
|
||||
async start (context: JobContext<IJob<z.infer<typeof BiosDownloadJob.dataSchema>, "download">, z.infer<typeof BiosDownloadJob.dataSchema>, "download">)
|
||||
{
|
||||
const emulator = await getStoreEmulatorPackage(this.emulator);
|
||||
if (!emulator) throw new Error("Could Not Find Emulator");
|
||||
|
|
|
|||
|
|
@ -40,25 +40,27 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
|
|||
if (!validDownloads) throw new Error(`Now downloads in ${this.emulatorPackage.name} for platform ${process.platform}:${process.arch}`);
|
||||
|
||||
const validDownload = validDownloads.find(d => d.type === this.downloadSource);
|
||||
if (!validDownload || !validDownload.path) throw new Error(`Download type ${this.downloadSource} not found`);
|
||||
if (!validDownload) throw new Error(`Download type ${this.downloadSource} not found`);
|
||||
|
||||
console.log("Trying To Download from ", `https://api.github.com/repos/${validDownload.path}/releases/latest`);
|
||||
const latestRelease = await getOrCachedGithubRelease(validDownload.path);
|
||||
const glob = new Glob(validDownload.pattern);
|
||||
const validAsset = latestRelease.assets.find(a => glob.match(a.name));
|
||||
if (!validAsset) throw new Error("Could Not Find Valid Asset");
|
||||
const downloadUrl = validAsset.browser_download_url;
|
||||
const emulatorsFolder = path.join(config.get('downloadPath'), "emulators", this.emulator);
|
||||
|
||||
const isArchive = validAsset.content_type === 'application/x-7z-compressed' || validAsset.name.endsWith('.7z') || validAsset.content_type === 'application/zip' || validAsset.name.endsWith('.zip');
|
||||
|
||||
const isAppImage = validAsset.name.endsWith(".AppImage");
|
||||
|
||||
if (!isArchive && !isAppImage)
|
||||
let downloadUrl: URL;
|
||||
if (validDownload.type === 'github')
|
||||
{
|
||||
throw new Error("Invalid Download Type");
|
||||
console.log("Trying To Download from ", `https://api.github.com/repos/${validDownload.path}/releases/latest`);
|
||||
const latestRelease = await getOrCachedGithubRelease(validDownload.path);
|
||||
const glob = new Glob(validDownload.pattern);
|
||||
const validAsset = latestRelease.assets.find(a => glob.match(a.name));
|
||||
if (!validAsset) throw new Error("Could Not Find Valid Asset");
|
||||
downloadUrl = new URL(validAsset.browser_download_url);
|
||||
} else if (validDownload.type === 'direct')
|
||||
{
|
||||
downloadUrl = new URL(validDownload.url);
|
||||
} else
|
||||
{
|
||||
throw new Error("Download Type Unsupported");
|
||||
}
|
||||
|
||||
const emulatorsFolder = path.join(config.get('downloadPath'), "emulators", this.emulator);
|
||||
|
||||
if (this.dryRun)
|
||||
{
|
||||
await simulateProgress(p => context.setProgress(p, "download"), context.abortSignal);
|
||||
|
|
@ -67,7 +69,7 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
|
|||
{
|
||||
const tmpFolder = path.join(config.get("downloadPath"), ".tmp");
|
||||
const downloader = new Downloader(this.emulator,
|
||||
[{ url: new URL(downloadUrl), file_name: path.basename(downloadUrl), file_path: this.emulator }],
|
||||
[{ url: new URL(downloadUrl), file_name: path.basename(downloadUrl.pathname), file_path: this.emulator }],
|
||||
tmpFolder,
|
||||
{
|
||||
signal: context.abortSignal,
|
||||
|
|
@ -80,6 +82,14 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
|
|||
const destinationPaths = await downloader.start();
|
||||
if (destinationPaths)
|
||||
{
|
||||
const isArchive = destinationPaths[0].endsWith('.7z') || destinationPaths[0].endsWith('.zip');
|
||||
const isAppImage = destinationPaths[0].endsWith(".AppImage");
|
||||
|
||||
if (!isArchive && !isAppImage)
|
||||
{
|
||||
throw new Error("Invalid Download Type");
|
||||
}
|
||||
|
||||
if (isArchive)
|
||||
{
|
||||
if (destinationPaths[0])
|
||||
|
|
|
|||
|
|
@ -106,15 +106,23 @@ export class InstallJob implements IJob<never, InstallJobStates>
|
|||
{
|
||||
let progress = 0;
|
||||
const progressDelta = 1 / downloadedFiles.length;
|
||||
for (const path of downloadedFiles)
|
||||
for (const filePath of downloadedFiles)
|
||||
{
|
||||
const extractPath = info.extract_path;
|
||||
const extractPath = path.join(config.get('downloadPath'), info.extract_path);
|
||||
await new Promise((resolve, reject) =>
|
||||
{
|
||||
const seven = Seven.extractFull(path, extractPath, { $bin: process.env.ZIP7_PATH, $progress: true });
|
||||
seven.on('progress', p => cx.setProgress(progress + p.percent * progressDelta, "extract"));
|
||||
const seven = Seven.extractFull(filePath, extractPath, { $bin: process.env.ZIP7_PATH, $progress: true });
|
||||
seven.on('progress', p =>
|
||||
{
|
||||
cx.setProgress(progress + p.percent * progressDelta, "extract");
|
||||
});
|
||||
|
||||
seven.on('error', e => reject(e));
|
||||
seven.on('end', () => resolve(true));
|
||||
seven.on('end', async () =>
|
||||
{
|
||||
await fs.rm(filePath);
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
progress += progressDelta * 100;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
|||
this.gameSourceId = sourceId;
|
||||
}
|
||||
|
||||
async start (context: JobContext<IJob<ActiveGameType, "playing">, ActiveGameType, "playing">)
|
||||
async start (context: JobContext<IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing">, z.infer<typeof LaunchGameJob.dataSchema>, "playing">)
|
||||
{
|
||||
const localGame = await db.query.games.findFirst({
|
||||
where: eq(appSchema.games.id, this.gameId), columns: {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { host } from "../utils/host";
|
|||
import { jobs } from "./jobs/jobs";
|
||||
import plugins from "./plugins/plugins";
|
||||
|
||||
const api = new Elysia({ serve: {} })
|
||||
const api = new Elysia()
|
||||
.use([cors(), clients, settings, system, store, jobs, plugins]);
|
||||
|
||||
export type RommAPIType = typeof clients;
|
||||
|
|
@ -19,18 +19,30 @@ export type StoreAPIType = typeof store;
|
|||
export type JobsAPIType = typeof jobs;
|
||||
export type PluginsAPIType = typeof plugins;
|
||||
|
||||
export function RunAPIServer ()
|
||||
export async function RunAPIServer ()
|
||||
{
|
||||
console.log("Launching API Server on port ", RPC_PORT);
|
||||
return {
|
||||
apiServer: api.listen({
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
{
|
||||
const timeout = setTimeout(() => reject(new Error("Server startup timed out")), 5000);
|
||||
|
||||
api.listen({
|
||||
port: RPC_PORT,
|
||||
hostname: host,
|
||||
...(host && host !== 'localhost' && { hostname: host }),
|
||||
development: process.env.NODE_ENV === 'development'
|
||||
}),
|
||||
}, s =>
|
||||
{
|
||||
clearTimeout(timeout);
|
||||
console.log("Launching API Server on", s.url.href);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
await api.modules;
|
||||
return {
|
||||
apiServer: api,
|
||||
async cleanup ()
|
||||
{
|
||||
|
||||
await api.stop();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -5,39 +5,41 @@ import z from 'zod';
|
|||
|
||||
export class TaskQueue
|
||||
{
|
||||
private activeQueue: { context: JobContext<IJob<any, string>, any, string>, promise?: Promise<void>; }[] = [];
|
||||
private queue?: { context: JobContext<IJob<any, string>, any, string>, promise?: Promise<void>; }[] = [];
|
||||
private activeQueue: JobContext<IJob<any, string>, any, string>[] = [];
|
||||
private queue?: JobContext<IJob<any, string>, any, string>[] = [];
|
||||
private events?: EventEmitter<EventsList> = new EventEmitter<EventsList>();
|
||||
|
||||
public enqueue<TData, TState extends string, T extends IJob<TData, TState>> (id: string, job: T)
|
||||
public enqueue<T> (id: string, job: T): T extends IJob<infer TData, infer TState extends string>
|
||||
? Promise<TData>
|
||||
: never
|
||||
{
|
||||
this.disposeSafeguard();
|
||||
if (!this.queue || !this.events) throw new Error("Queue disposed");
|
||||
const context = new JobContext(id, this.events, job);
|
||||
this.queue.push({ context });
|
||||
const context = new JobContext<any, any, any>(id, this.events, job);
|
||||
this.queue.push(context as any);
|
||||
this.events?.emit('queued', { id: context.id, job: context });
|
||||
return this.processQueue();
|
||||
this.processQueue();
|
||||
return context.promise.promise as any;
|
||||
}
|
||||
|
||||
private processQueue ()
|
||||
{
|
||||
if (!this.queue) return Promise.resolve();
|
||||
|
||||
const next = this.queue.filter(j => !j.context.job.group || !this.activeQueue.some(a => a.context.job.group === j.context.job.group)).map((job, i) => ({ i, job }));
|
||||
const next = this.queue.filter(j => !j.job.group || !this.activeQueue.some(a => a.job.group === j.job.group)).map((job, i) => ({ i, job }));
|
||||
|
||||
next.reverse().forEach(({ i }) => this.queue!.splice(i, 1));
|
||||
|
||||
next.forEach(job =>
|
||||
{
|
||||
const promise = job.job.context.start();
|
||||
job.job.promise = promise;
|
||||
job.job.start();
|
||||
this.activeQueue.push(job.job);
|
||||
promise.finally(() =>
|
||||
job.job.promise.promise.finally(() =>
|
||||
{
|
||||
const index = this.activeQueue.indexOf(job.job);
|
||||
this.activeQueue.splice(index, 1);
|
||||
// We need to call it after it has been removed from the queue, so that the has active of type doesn't return true
|
||||
this.events?.emit('ended', { id: job.job.context.id, job: job.job.context });
|
||||
this.events?.emit('ended', { id: job.job.id, job: job.job });
|
||||
setTimeout(() => this.processQueue(), 0);
|
||||
});
|
||||
});
|
||||
|
|
@ -57,7 +59,7 @@ export class TaskQueue
|
|||
{
|
||||
for (const entry of this.activeQueue)
|
||||
{
|
||||
if (entry.context.job instanceof type)
|
||||
if (entry.job instanceof type)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
|
@ -67,19 +69,25 @@ export class TaskQueue
|
|||
|
||||
public waitForJob (id: string): Promise<void>
|
||||
{
|
||||
const job = this.queue?.find(j => j.context.id === id) ?? this.activeQueue?.find(j => j.context.id === id);
|
||||
return job?.promise ?? Promise.resolve();
|
||||
const job = this.queue?.find(j => j.id === id) ?? this.activeQueue?.find(j => j.id === id);
|
||||
return job?.promise.promise ?? Promise.resolve();
|
||||
}
|
||||
|
||||
|
||||
public findJob<const TData, const TState extends string, const T extends IJob<TData, TState>> (id: string, type: new (...args: any[]) => T): IPublicJob<TData, TState, T> | undefined
|
||||
public findJob<T> (
|
||||
id: string,
|
||||
type: new (...args: any[]) => T
|
||||
): T extends IJob<infer TData, infer TState extends string>
|
||||
? IPublicJob<TData, TState, T> | undefined
|
||||
: undefined
|
||||
{
|
||||
const job = this.queue?.find(j => j.context.id === id) ?? this.activeQueue?.find(j => j.context.id === id);
|
||||
if (job?.context.job instanceof type)
|
||||
const job = this.queue?.find(j => j.id === id)
|
||||
?? this.activeQueue?.find(j => j.id === id);
|
||||
|
||||
if (job?.job instanceof type)
|
||||
{
|
||||
return job?.context;
|
||||
return job as any;
|
||||
}
|
||||
return undefined;
|
||||
return undefined as any;
|
||||
}
|
||||
|
||||
public on<E extends keyof EventsList> (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void
|
||||
|
|
@ -96,7 +104,7 @@ export class TaskQueue
|
|||
public async close ()
|
||||
{
|
||||
this.queue = [];
|
||||
this.activeQueue.forEach(c => c.context.abort());
|
||||
this.activeQueue.forEach(c => c.abort());
|
||||
return Promise.all(this.activeQueue.map(c => c.promise));
|
||||
}
|
||||
}
|
||||
|
|
@ -181,6 +189,7 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
|
|||
private error?: any;
|
||||
private events: EventEmitter<EventsList>;
|
||||
private abortController: AbortController;
|
||||
private m_promise: PromiseWithResolvers<TData | undefined>;
|
||||
private readonly m_job: T;
|
||||
|
||||
constructor(id: string, events: EventEmitter<EventsList>, job: T)
|
||||
|
|
@ -194,9 +203,10 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
|
|||
this.events.emit('abort', { id: this.m_id, reason: this.abortController.signal.reason, job: this } satisfies AbortEvent);
|
||||
});
|
||||
this.events = events;
|
||||
this.m_promise = Promise.withResolvers();
|
||||
}
|
||||
|
||||
public async start (): Promise<void>
|
||||
public async start ()
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -204,6 +214,7 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
|
|||
await this.m_job.start(this);
|
||||
this.completed = true;
|
||||
this.events.emit('completed', { id: this.m_id, job: this });
|
||||
this.m_promise.resolve(this.m_job.exposeData?.());
|
||||
|
||||
} catch (error)
|
||||
{
|
||||
|
|
@ -214,6 +225,7 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
|
|||
|
||||
this.events.emit('error', { id: this.m_id, job: this, error });
|
||||
this.error = error;
|
||||
this.m_promise.reject(error);
|
||||
} finally
|
||||
{
|
||||
this.running = false;
|
||||
|
|
@ -233,6 +245,8 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
|
|||
|
||||
public get job () { return this.m_job; }
|
||||
|
||||
public get promise () { return this.m_promise; }
|
||||
|
||||
public get abortSignal () { return this.abortController.signal; }
|
||||
|
||||
public get progress () { return this.m_progress; }
|
||||
|
|
|
|||
|
|
@ -1,30 +1,18 @@
|
|||
import { RunBunServer } from './server';
|
||||
import { RunAPIServer } from './api/rpc';
|
||||
|
||||
import * as app from './api/app';
|
||||
import init from './browser';
|
||||
import { dirname } from 'pathe';
|
||||
import { createInterface } from 'readline';
|
||||
import { isSteamDeckGameMode } from './utils';
|
||||
|
||||
const api = RunAPIServer();
|
||||
let bunServer: { stop: () => void; } | undefined;
|
||||
|
||||
if (!process.env.PUBLIC_ACCESS)
|
||||
{
|
||||
bunServer = await RunBunServer();
|
||||
}
|
||||
|
||||
async function cleanup ()
|
||||
{
|
||||
console.log("Cleaning Up");
|
||||
await app.cleanup();
|
||||
bunServer?.stop();
|
||||
await api.apiServer.stop(true);
|
||||
await api.cleanup();
|
||||
console.log("Finished Cleaning Up");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await app.load();
|
||||
|
||||
if (process.env.HEADLESS)
|
||||
{
|
||||
const rl = createInterface({ input: process.stdin });
|
||||
|
|
|
|||
|
|
@ -161,9 +161,9 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
|||
}
|
||||
mainButton = <ActionButton
|
||||
key={status ?? 'unknown'}
|
||||
disabled={installMut.isPending}
|
||||
onAction={() =>
|
||||
{
|
||||
if (installMut.isPending) return;
|
||||
switch (status)
|
||||
{
|
||||
case 'present':
|
||||
|
|
|
|||
|
|
@ -464,7 +464,7 @@ const assets = new Set<string>([
|
|||
]);
|
||||
|
||||
// Store basePath resolved from Vite config
|
||||
const BASE_PATH = "/";
|
||||
const BASE_PATH = "./";
|
||||
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Treaty, treaty } from "@elysiajs/eden";
|
||||
import { JobsAPIType, PluginsAPIType, RommAPIType, SettingsAPIType, StoreAPIType, SystemAPIType } from "../../bun/api/rpc";
|
||||
import type { JobsAPIType, PluginsAPIType, RommAPIType, SettingsAPIType, StoreAPIType, SystemAPIType } from "../../bun/api/rpc";
|
||||
import { RPC_URL } from "../../shared/constants";
|
||||
|
||||
const options: Treaty.Config = {
|
||||
|
|
|
|||
|
|
@ -109,8 +109,9 @@ export const installMutation = (source: string, id: string) => mutationOptions({
|
|||
mutationKey: ['install', source, id],
|
||||
mutationFn: async () =>
|
||||
{
|
||||
const { error } = await rommApi.api.romm.game({ source })({ id }).install.post();
|
||||
const { data, error } = await rommApi.api.romm.game({ source })({ id }).install.post();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
export const cancelInstallMutation = (source: string, id: string) => mutationOptions({
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import
|
|||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { RefObject, useEffect, useState } from "react";
|
||||
import { focusQueue, Router } from "..";
|
||||
import { scrollIntoViewHandler } from "./utils";
|
||||
|
||||
init({
|
||||
shouldFocusDOMNode: false,
|
||||
|
|
|
|||
|
|
@ -98,12 +98,17 @@ export const EmulatorPackageSchema = z.object({
|
|||
type: z.enum(['emulator']),
|
||||
os: z.array(z.enum(['darwin', 'linux', 'win32', 'android'])),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
downloads: z.record(z.string(), z.array(z.object({
|
||||
type: z.string(),
|
||||
url: z.url().optional(),
|
||||
pattern: z.string(),
|
||||
path: z.string().optional()
|
||||
}))).optional(),
|
||||
downloads: z.record(z.string(), z.array(z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('github'),
|
||||
pattern: z.string(),
|
||||
path: z.string()
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('direct'),
|
||||
url: z.url(),
|
||||
})
|
||||
]))).optional(),
|
||||
systems: z.array(z.string()),
|
||||
bios: z.literal(["required", "optional"]).optional()
|
||||
});
|
||||
|
|
|
|||
20
src/tests/client.ts
Normal file
20
src/tests/client.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { JobsAPIType, PluginsAPIType, RommAPIType, SettingsAPIType, StoreAPIType, SystemAPIType } from "@/bun/api/rpc";
|
||||
import { Treaty, treaty } from '@elysiajs/eden';
|
||||
import { RPC_URL } from '@/shared/constants';
|
||||
|
||||
const host = "localhost";
|
||||
const options: Treaty.Config = {
|
||||
keepDomain: true,
|
||||
fetch: {
|
||||
credentials: 'include',
|
||||
}
|
||||
};
|
||||
|
||||
export const client = {
|
||||
rommApi: treaty<RommAPIType>(RPC_URL(host), options),
|
||||
settingsApi: treaty<SettingsAPIType>(RPC_URL(host), options),
|
||||
systemApi: treaty<SystemAPIType>(RPC_URL(host), options),
|
||||
storeApi: treaty<StoreAPIType>(RPC_URL(host), options),
|
||||
jobsApi: treaty<JobsAPIType>(RPC_URL(host), options),
|
||||
pluginsApi: treaty<PluginsAPIType>(RPC_URL(host), options),
|
||||
};
|
||||
162
src/tests/downloads.test.ts
Normal file
162
src/tests/downloads.test.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { expect, test, describe, beforeEach, afterAll, beforeAll, jest } from 'bun:test';
|
||||
import { client } from './client';
|
||||
import * as app from '@/bun/api/app';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
|
||||
describe("Download Tests", () =>
|
||||
{
|
||||
let server: Bun.Server<unknown>;
|
||||
beforeAll(async () =>
|
||||
{
|
||||
server = server = Bun.serve({
|
||||
routes: {
|
||||
'/download/single_file.txt': new Response("Test File", {
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
"Content-Disposition": 'attachment; filename="Test File.txt"',
|
||||
}
|
||||
}),
|
||||
'/download/single_file_2.txt': new Response("Test File 2", {
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
"Content-Disposition": 'attachment; filename="Test File.txt"',
|
||||
}
|
||||
}),
|
||||
"/download/zip_file_with_single_file.zip": (req) =>
|
||||
{
|
||||
const url = new URL(req.url);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile(path.join(url.searchParams.get('root') ?? '', "Unzip Test File.txt"), Buffer.from("hello world"));
|
||||
|
||||
return new Response(zip.toBuffer(), {
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": 'attachment; filename="zip_file_with_single_file.zip"',
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() =>
|
||||
{
|
||||
server.stop();
|
||||
});
|
||||
|
||||
test("Download Single Non Archive File", async () =>
|
||||
{
|
||||
const mock = jest.fn();
|
||||
app.plugins.hooks.games.fetchDownloads.tap('test2', mock);
|
||||
app.plugins.hooks.games.fetchDownloads.tapPromise('test', async ({ source, id }) =>
|
||||
{
|
||||
if (source !== 'test') return;
|
||||
return {
|
||||
files: [{ file_name: "Test File.txt", file_path: 'test/files', url: new URL(`${server.url.href}download/single_file.txt`) }],
|
||||
coverUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/SIPI_Jelly_Beans_4.1.07.tiff/lossy-page1-256px-SIPI_Jelly_Beans_4.1.07.tiff.jpg",
|
||||
name: "Test Game",
|
||||
screenshotUrls: [],
|
||||
system_slug: 'ps2',
|
||||
source_id: "0"
|
||||
};
|
||||
});
|
||||
|
||||
const res = await client.rommApi.api.romm.game({ source: 'test' })({ id: '0' }).install.post();
|
||||
if (res.error) throw res.error;
|
||||
expect(mock).toHaveBeenCalled();
|
||||
expect(await fs.exists(path.join(app.config.get('downloadPath'), 'test/files/Test File.txt'))).toBeTrue();
|
||||
expect(res.response.ok).toBeTrue();
|
||||
});
|
||||
|
||||
test("Download Multiple Non Archive Files", async () =>
|
||||
{
|
||||
const mock = jest.fn();
|
||||
app.plugins.hooks.games.fetchDownloads.tap('test2', mock);
|
||||
app.plugins.hooks.games.fetchDownloads.tapPromise('test', async ({ source, id }) =>
|
||||
{
|
||||
if (source !== 'test') return;
|
||||
return {
|
||||
files: [
|
||||
{ file_name: "Test File.txt", file_path: 'test/files', url: new URL(`${server.url.href}download/single_file.txt`) },
|
||||
{ file_name: "Test File 2.txt", file_path: 'test/files', url: new URL(`${server.url.href}download/single_file_2.txt`) }],
|
||||
coverUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/SIPI_Jelly_Beans_4.1.07.tiff/lossy-page1-256px-SIPI_Jelly_Beans_4.1.07.tiff.jpg",
|
||||
name: "Test Game",
|
||||
screenshotUrls: [],
|
||||
system_slug: 'ps2',
|
||||
source_id: "0"
|
||||
};
|
||||
});
|
||||
|
||||
const res = await client.rommApi.api.romm.game({ source: 'test' })({ id: '0' }).install.post();
|
||||
if (res.error) throw res.error;
|
||||
expect(mock).toHaveBeenCalled();
|
||||
expect(await fs.exists(path.join(app.config.get('downloadPath'), 'test/files/Test File.txt'))).toBeTrue();
|
||||
expect(await fs.exists(path.join(app.config.get('downloadPath'), 'test/files/Test File 2.txt'))).toBeTrue();
|
||||
expect(res.response.ok).toBeTrue();
|
||||
});
|
||||
|
||||
test("Download Single File Archived", async () =>
|
||||
{
|
||||
const mock = jest.fn();
|
||||
app.plugins.hooks.games.fetchDownloads.tap('test2', mock);
|
||||
app.plugins.hooks.games.fetchDownloads.tapPromise('test', async ({ source, id }) =>
|
||||
{
|
||||
if (source !== 'test') return;
|
||||
return {
|
||||
files: [
|
||||
{ file_name: "zip_file_with_single_file.zip", file_path: 'test', url: new URL(`${server.url.href}download/zip_file_with_single_file.zip`) }],
|
||||
coverUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/SIPI_Jelly_Beans_4.1.07.tiff/lossy-page1-256px-SIPI_Jelly_Beans_4.1.07.tiff.jpg",
|
||||
name: "Test Game",
|
||||
screenshotUrls: [],
|
||||
system_slug: 'ps2',
|
||||
source_id: "0",
|
||||
extract_path: 'test/files'
|
||||
};
|
||||
});
|
||||
|
||||
const res = await client.rommApi.api.romm.game({ source: 'test' })({ id: '0' }).install.post();
|
||||
if (res.error) throw res.error;
|
||||
expect(mock).toHaveBeenCalled();
|
||||
expect(await fs.exists(path.join(app.config.get('downloadPath'), 'test/files/Unzip Test File.txt'))).toBeTrue();
|
||||
expect(res.response.ok).toBeTrue();
|
||||
});
|
||||
|
||||
test("Download Emulator Archive With 1 root Sub Folder", async () =>
|
||||
{
|
||||
const mockEmulator = {
|
||||
name: "TEST",
|
||||
description: "Test Mock emlator",
|
||||
homepage: "http://localhost",
|
||||
logo: "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/SIPI_Jelly_Beans_4.1.07.tiff/lossy-page1-256px-SIPI_Jelly_Beans_4.1.07.tiff.jpg",
|
||||
downloads: {
|
||||
"linux:x64": [
|
||||
{
|
||||
type: "direct",
|
||||
url: `${server.url.href}download/zip_file_with_single_file.zip?root=test`
|
||||
}
|
||||
]
|
||||
},
|
||||
keywords: [
|
||||
"test"
|
||||
],
|
||||
aliases: {},
|
||||
type: "emulator",
|
||||
systems: [
|
||||
"ps2"
|
||||
],
|
||||
os: [
|
||||
"win32",
|
||||
"linux"
|
||||
]
|
||||
};
|
||||
|
||||
await Bun.write('./src/tests/mock-store/buckets/emulators/TEST.json', JSON.stringify(mockEmulator));
|
||||
|
||||
const deleteRes = await client.storeApi.api.store.install.emulator({ id: "TEST" })({ source: 'direct' }).post();
|
||||
if (deleteRes.error) throw deleteRes.error;
|
||||
expect(await fs.exists(path.join(app.config.get('downloadPath'), 'emulators/TEST/Unzip Test File.txt'))).toBeTrue();
|
||||
expect(deleteRes.response.ok).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
import { expect, test } from 'bun:test';
|
||||
import { resolve } from 'node:path';
|
||||
import './preload';
|
||||
import path, { resolve } from 'node:path';
|
||||
import * as app from '@/bun/api/app';
|
||||
|
||||
test("uses custom emulator", async () =>
|
||||
{
|
||||
const { customEmulators } = await import('@/bun/api/app');
|
||||
customEmulators.set('PCSX2', resolve("./src/tests/mock-roms/mock-emulator.exe"));
|
||||
app.customEmulators.set('PCSX2', resolve("./src/tests/mock-roms/mock-emulator.exe"));
|
||||
|
||||
const { getValidLaunchCommands: getLaunchCommands } = await import('@/bun/api/games/services/launchGameService');
|
||||
const commands = await getLaunchCommands({
|
||||
|
|
@ -13,6 +12,9 @@ test("uses custom emulator", async () =>
|
|||
gamePath: './mock-rom.iso'
|
||||
});
|
||||
|
||||
await Bun.write(path.join(app.config.get('downloadPath'), 'mock-rom.iso'), "This is a mock Rom");
|
||||
await Bun.write(path.join(app.config.get('downloadPath'), 'mock-emulator.exe'), "This is a mock Emulator");
|
||||
|
||||
expect(commands)
|
||||
.toSatisfy((d) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,11 +1,33 @@
|
|||
import { beforeAll } from 'bun:test';
|
||||
import { afterAll, beforeAll, beforeEach, afterEach } from 'bun:test';
|
||||
import { resolve } from 'node:path';
|
||||
import * as app from '@/bun/api/app';
|
||||
import { remove } from 'fs-extra';
|
||||
|
||||
export async function LoadApp ()
|
||||
{
|
||||
console.log("Loading App");
|
||||
await app.load();
|
||||
}
|
||||
|
||||
export async function CleanupApp ()
|
||||
{
|
||||
console.log("Cleaning Up App");
|
||||
app.cleanup();
|
||||
}
|
||||
|
||||
beforeAll(async () =>
|
||||
{
|
||||
process.env.CUSTOM_STORE_PATH = resolve('./src/tests/mock-store');
|
||||
process.env.CONFIG_CWD = resolve('./src/tests/mock-config');
|
||||
|
||||
const { config } = await import('@/bun/api/app');
|
||||
config.set('downloadPath', resolve('./src/tests/mock-roms'));
|
||||
process.env.DEFAULT_DOWNLOAD_PATH = resolve('./src/tests/mock-roms');
|
||||
});
|
||||
|
||||
afterEach(async () =>
|
||||
{
|
||||
await remove(resolve('./src/tests/mock-config'));
|
||||
await remove(resolve('./src/tests/mock-roms'));
|
||||
await remove(resolve('./src/tests/mock-store'));
|
||||
});
|
||||
|
||||
beforeEach(LoadApp, { timeout: 30000 });
|
||||
afterEach(CleanupApp);
|
||||
Loading…
Add table
Add a link
Reference in a new issue