Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e766337e9c | ||
|
|
07a4427a0b | ||
|
|
680ae4ca08 | ||
|
|
e6e1785413 | ||
|
|
bf1e137c6c | ||
|
|
6674f33be9 | ||
|
|
7742d6f89f | ||
|
|
6d830ae98b | ||
|
|
19bd41375e | ||
|
|
cf0d457d1c | ||
|
|
83ca7a251b | ||
|
|
41caa76b28 | ||
|
|
45bc12a2f5 | ||
|
|
093012c8f7 | ||
|
|
c5bdb62cb6 |
@@ -22,6 +22,7 @@ win:
|
||||
endpoint: https://eus.codesigning.azure.net
|
||||
certificateProfileName: ImEXRPS
|
||||
codeSigningAccountName: ImEX
|
||||
publisherName: ImEX Systems Inc.
|
||||
nsis:
|
||||
artifactName: imex-partner-${env.ARTIFACT_SUFFIX}${arch}.${ext}
|
||||
shortcutName: ${productName}
|
||||
@@ -50,14 +51,6 @@ mac:
|
||||
- x64
|
||||
dmg:
|
||||
artifactName: imex-partner-${env.ARTIFACT_SUFFIX}${arch}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- snap
|
||||
- deb
|
||||
maintainer: electronjs.org
|
||||
category: Utility
|
||||
desktop: scripts/imex-shop-partner.desktop
|
||||
appImage:
|
||||
artifactName: imex-partner-${env.ARTIFACT_SUFFIX}${arch}.${ext}
|
||||
npmRebuild: false
|
||||
|
||||
@@ -22,6 +22,7 @@ win:
|
||||
endpoint: https://eus.codesigning.azure.net
|
||||
certificateProfileName: ImEXRPS
|
||||
codeSigningAccountName: ImEX
|
||||
publisherName: ImEX Systems Inc.
|
||||
nsis:
|
||||
artifactName: rome-partner-${env.ARTIFACT_SUFFIX}${arch}.${ext}
|
||||
shortcutName: ${productName}
|
||||
@@ -51,14 +52,6 @@ mac:
|
||||
- x64
|
||||
dmg:
|
||||
artifactName: rome-partner-${env.ARTIFACT_SUFFIX}${arch}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- snap
|
||||
- deb
|
||||
maintainer: electronjs.org
|
||||
category: Utility
|
||||
desktop: scripts/rome-shop-partner.desktop
|
||||
appImage:
|
||||
artifactName: rome-partner-${env.ARTIFACT_SUFFIX}${arch}.${ext}
|
||||
npmRebuild: false
|
||||
|
||||
@@ -6,10 +6,18 @@ import react from "@vitejs/plugin-react";
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [
|
||||
externalizeDepsPlugin(),
|
||||
externalizeDepsPlugin({
|
||||
exclude: ["electron-store"],
|
||||
}),
|
||||
sentryVitePlugin({
|
||||
org: "imex",
|
||||
project: "imex-partner",
|
||||
sourcemaps: {
|
||||
filesToDeleteAfterUpload: ["**.js.map"],
|
||||
},
|
||||
release: {
|
||||
name: `bodyshop-desktop@${process.env.npm_package_version}`,
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
@@ -32,6 +40,12 @@ export default defineConfig({
|
||||
sentryVitePlugin({
|
||||
org: "imex",
|
||||
project: "imex-partner",
|
||||
sourcemaps: {
|
||||
filesToDeleteAfterUpload: ["**.js.map"],
|
||||
},
|
||||
release: {
|
||||
name: `bodyshop-desktop@${process.env.npm_package_version}`,
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
|
||||
4934
package-lock.json
generated
4934
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
85
package.json
85
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bodyshop-desktop",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.7",
|
||||
"description": "Shop Management System Partner",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Convenient Brands, LLC",
|
||||
@@ -27,66 +27,65 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.13.6",
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@sentry/electron": "^6.5.0",
|
||||
"@sentry/vite-plugin": "^3.3.1",
|
||||
"axios": "^1.9.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"electron-log": "^5.3.3",
|
||||
"electron-store": "^8.2.0",
|
||||
"@sentry/electron": "^7.2.0",
|
||||
"@sentry/vite-plugin": "^4.5.0",
|
||||
"axios": "^1.12.2",
|
||||
"dayjs": "^1.11.18",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"winax": "^3.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.0.0",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@reduxjs/toolkit": "^2.6.1",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^22.14.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@reduxjs/toolkit": "^2.9.1",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/node": "^24.9.1",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"antd": "^5.24.6",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"antd": "^5.27.6",
|
||||
"archiver": "^7.0.1",
|
||||
"chokidar": "^4.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"cross-env": "^10.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"dbffile": "^1.12.0",
|
||||
"electron": "^35.1.5",
|
||||
"electron-builder": "^25.1.8",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-vite": "^3.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"electron": "^38.3.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-store": "^11.0.2",
|
||||
"electron-vite": "^4.0.1",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"eslint-plugin-react-hooks": "^7.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"express": "^5.1.0",
|
||||
"firebase": "^11.6.0",
|
||||
"graphql": "^16.10.0",
|
||||
"graphql-request": "^7.1.2",
|
||||
"i18next": "^24.2.3",
|
||||
"firebase": "^12.4.0",
|
||||
"graphql": "^16.11.0",
|
||||
"graphql-request": "^7.3.1",
|
||||
"i18next": "^25.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
"node-cron": "^3.0.3",
|
||||
"playwright": "^1.51.1",
|
||||
"prettier": "^3.5.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"playwright": "^1.56.1",
|
||||
"prettier": "^3.6.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-error-boundary": "^6.0.0",
|
||||
"react-i18next": "^16.1.4",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.5.0",
|
||||
"react-router": "^7.9.4",
|
||||
"redux-logger": "^3.0.6",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "6.2.6",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "7.1.11",
|
||||
"xml2js": "^0.6.2",
|
||||
"xmlbuilder2": "^3.1.1"
|
||||
"xmlbuilder2": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { GraphQLClient, RequestMiddleware } from "graphql-request";
|
||||
import errorTypeCheck from "../../util/errorTypeCheck.js";
|
||||
import ipcTypes from "../../util/ipcTypes.json";
|
||||
import store from "../store/store.js";
|
||||
import getMainWindow from "../../util/getMainWindow.js";
|
||||
|
||||
const requestMiddleware: RequestMiddleware = async (request) => {
|
||||
const token = await getTokenFromRenderer();
|
||||
@@ -32,9 +33,9 @@ const client: GraphQLClient = new GraphQLClient(
|
||||
export async function getTokenFromRenderer(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0]; //TODO: Filter to only main window once a proper key has been set.
|
||||
const mainWindow = getMainWindow();
|
||||
//TODO: Verify that this will work if the app is minimized/closed.
|
||||
mainWindow.webContents.send(ipcTypes.toRenderer.user.getToken);
|
||||
mainWindow?.webContents.send(ipcTypes.toRenderer.user.getToken);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
"Unable to send request to renderer process for token",
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
setupKeepAliveTask,
|
||||
} from "./setup-keep-alive-task";
|
||||
import ensureWindowOnScreen from "./util/ensureWindowOnScreen";
|
||||
import ongoingMemoryDump, { dumpMemoryStatsToFile } from "../util/memUsage";
|
||||
|
||||
const appIconToUse =
|
||||
import.meta.env.VITE_COMPANY === "IMEX" ? imexAppIcon : romeAppIcon;
|
||||
@@ -45,6 +46,13 @@ Sentry.init({
|
||||
});
|
||||
|
||||
log.initialize();
|
||||
|
||||
// Configure log format to include process ID
|
||||
log.transports.file.format =
|
||||
"[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [PID:{processId}] {text}";
|
||||
log.transports.console.format =
|
||||
"[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [PID:{processId}] {text}";
|
||||
log.transports.file.maxSize = 50 * 1024 * 1024; // 50 MB
|
||||
const isMac: boolean = process.platform === "darwin";
|
||||
const protocol: string = "imexmedia";
|
||||
let isAppQuitting = false; //Needed on Mac as an override to allow us to fully quit the app.
|
||||
@@ -53,6 +61,14 @@ let isKeepAliveLaunch = false; // Track if launched via keep-alive
|
||||
const localServer = new LocalServer();
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!gotTheLock) {
|
||||
log.warn(
|
||||
"Another instance is already running and could not obtain mutex lock. Exiting this instance.",
|
||||
);
|
||||
isAppQuitting = true;
|
||||
app.quit(); // Quit the app if another instance is already running
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
// Create the browser window.
|
||||
const { width, height, x, y } = store.get("app.windowBounds") as {
|
||||
@@ -252,6 +268,27 @@ function createWindow(): void {
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Enable Memory Logging",
|
||||
checked: store.get("settings.enableMemDebug") as boolean,
|
||||
type: "checkbox",
|
||||
click: (): void => {
|
||||
const currentSetting = store.get(
|
||||
"settings.enableMemDebug",
|
||||
) as boolean;
|
||||
store.set("settings.enableMemDebug", !currentSetting);
|
||||
log.info("Enable Memory Logging set to", !currentSetting);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Dump Memory Stats Now",
|
||||
click: (): void => {
|
||||
dumpMemoryStatsToFile();
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
// {
|
||||
// label: "Decode Hardcoded Estimate",
|
||||
// click: (): void => {
|
||||
@@ -429,9 +466,6 @@ function createWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (!gotTheLock) {
|
||||
app.quit(); // Quit the app if another instance is already running
|
||||
}
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
@@ -439,6 +473,8 @@ app.whenReady().then(async () => {
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
log.debug("App is ready, initializing shortcuts and protocol handlers.");
|
||||
|
||||
if (platform.isWindows) {
|
||||
app.setAppUserModelId("Shop Partner");
|
||||
}
|
||||
@@ -470,21 +506,6 @@ app.whenReady().then(async () => {
|
||||
log.warn("Failed to register protocol handler.");
|
||||
}
|
||||
|
||||
// Add this event handler for second instance
|
||||
app.on("second-instance", (_event: Electron.Event, argv: string[]) => {
|
||||
const url = argv.find((arg) => arg.startsWith(`${protocol}://`));
|
||||
if (url) {
|
||||
if (url.startsWith(`${protocol}://keep-alive`)) {
|
||||
log.info("Keep-alive protocol received, app is already running.");
|
||||
// Do nothing if already running
|
||||
return;
|
||||
} else {
|
||||
openInExplorer(url);
|
||||
}
|
||||
}
|
||||
// No action taken if no URL is provided
|
||||
});
|
||||
|
||||
//Dynamically load ipcMain handlers once ready.
|
||||
try {
|
||||
const { initializeCronTasks } = await import("./ipc/ipcMainConfig");
|
||||
@@ -546,19 +567,19 @@ app.whenReady().then(async () => {
|
||||
|
||||
autoUpdater.on("checking-for-update", () => {
|
||||
log.info("Checking for update...");
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||
const mainWindow = getMainWindow();
|
||||
mainWindow?.webContents.send(ipcTypes.toRenderer.updates.checking);
|
||||
});
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
log.info("Update available.", info);
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||
const mainWindow = getMainWindow();
|
||||
mainWindow?.webContents.send(ipcTypes.toRenderer.updates.available, info);
|
||||
});
|
||||
autoUpdater.on("download-progress", (progress) => {
|
||||
log.info(`Download speed: ${progress.bytesPerSecond}`);
|
||||
log.info(`Downloaded ${progress.percent}%`);
|
||||
log.info(`Total downloaded ${progress.transferred}/${progress.total}`);
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||
const mainWindow = getMainWindow();
|
||||
mainWindow?.webContents.send(
|
||||
ipcTypes.toRenderer.updates.downloading,
|
||||
progress,
|
||||
@@ -566,7 +587,7 @@ app.whenReady().then(async () => {
|
||||
});
|
||||
autoUpdater.on("update-downloaded", (info) => {
|
||||
log.info("Update downloaded", info);
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||
const mainWindow = getMainWindow();
|
||||
mainWindow?.webContents.send(ipcTypes.toRenderer.updates.downloaded, info);
|
||||
});
|
||||
|
||||
@@ -577,7 +598,8 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
|
||||
//The update itself will run when the bodyshop record is queried to know what release channel to use.
|
||||
createWindow();
|
||||
openMainWindow();
|
||||
ongoingMemoryDump();
|
||||
|
||||
app.on("activate", function () {
|
||||
openMainWindow();
|
||||
@@ -595,6 +617,24 @@ app.on("open-url", (event: Electron.Event, url: string) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Add this event handler for second instance
|
||||
app.on("second-instance", (_event: Electron.Event, argv: string[]) => {
|
||||
const url = argv.find((arg) => arg.startsWith(`${protocol}://`));
|
||||
if (url) {
|
||||
if (url.startsWith(`${protocol}://keep-alive`)) {
|
||||
log.info(
|
||||
"Keep-alive protocol received, app is already running. Nothing to do.",
|
||||
);
|
||||
// Do nothing if already running
|
||||
return;
|
||||
} else {
|
||||
log.info("Received Media URL: ", url);
|
||||
openInExplorer(url);
|
||||
}
|
||||
}
|
||||
// No action taken if no URL is provided
|
||||
});
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
@@ -604,8 +644,7 @@ app.on("window-all-closed", () => {
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", (props) => {
|
||||
console.log(props);
|
||||
app.on("before-quit", () => {
|
||||
preQuitMethods();
|
||||
});
|
||||
|
||||
@@ -635,7 +674,7 @@ function preQuitMethods(): void {
|
||||
}
|
||||
|
||||
function openMainWindow(): void {
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
} else {
|
||||
|
||||
@@ -14,7 +14,7 @@ const handlePartsPriceChangeRequest = async (
|
||||
): Promise<void> => {
|
||||
//Route handler here only.
|
||||
|
||||
const { job } = req.body as { job: PpcJob };
|
||||
const job = req.body as PpcJob;
|
||||
try {
|
||||
await generatePartsPriceChange(job);
|
||||
res.status(200).json({ success: true });
|
||||
|
||||
@@ -9,6 +9,7 @@ const store = new Store({
|
||||
emsOutFilePath: null,
|
||||
qbFilePath: "",
|
||||
runWatcherOnStartup: true,
|
||||
enableMemDebug: false,
|
||||
polling: {
|
||||
enabled: false,
|
||||
interval: 30000,
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { setReleaseChannel } from "../ipc/ipcMainHandler.user";
|
||||
|
||||
let continuousUpdatesTriggered = false;
|
||||
|
||||
async function checkForAppUpdatesContinuously(): Promise<void> {
|
||||
checkForAppUpdates();
|
||||
setInterval(
|
||||
() => {
|
||||
checkForAppUpdatesContinuously();
|
||||
},
|
||||
1000 * 60 * 30,
|
||||
);
|
||||
if (!continuousUpdatesTriggered) {
|
||||
continuousUpdatesTriggered = true;
|
||||
checkForAppUpdates();
|
||||
setInterval(
|
||||
() => {
|
||||
checkForAppUpdates();
|
||||
},
|
||||
1000 * 60 * 30,
|
||||
);
|
||||
}
|
||||
}
|
||||
async function checkForAppUpdates(): Promise<void> {
|
||||
await setReleaseChannel();
|
||||
autoUpdater.checkForUpdatesAndNotify({
|
||||
title: "Shop Partner Update",
|
||||
body: "A new version of Shop Partner is available. Click to update.",
|
||||
});
|
||||
autoUpdater.checkForUpdates();
|
||||
}
|
||||
|
||||
export { checkForAppUpdates, checkForAppUpdatesContinuously };
|
||||
|
||||
@@ -7,6 +7,7 @@ import errorTypeCheck from "../../util/errorTypeCheck";
|
||||
import ipcTypes from "../../util/ipcTypes.json";
|
||||
import ImportJob from "../decoder/decoder";
|
||||
import store from "../store/store";
|
||||
import getMainWindow from "../../util/getMainWindow";
|
||||
let watcher: FSWatcher | null;
|
||||
|
||||
async function StartWatcher(): Promise<boolean> {
|
||||
@@ -107,23 +108,23 @@ function addWatcherPath(path: string | string[]): void {
|
||||
|
||||
function onWatcherReady(): void {
|
||||
if (watcher) {
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0]; //TODO: Filter to only main window once a proper key has been set.
|
||||
const mainWindow = getMainWindow();
|
||||
new Notification({
|
||||
title: "Watcher Started",
|
||||
body: "Newly exported estimates will be automatically uploaded.",
|
||||
}).show();
|
||||
log.info("Confirmed watched paths:", watcher.getWatched());
|
||||
mainWindow.webContents.send(ipcTypes.toRenderer.watcher.started);
|
||||
mainWindow?.webContents.send(ipcTypes.toRenderer.watcher.started);
|
||||
}
|
||||
}
|
||||
|
||||
async function StopWatcher(): Promise<boolean> {
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0]; //TODO: Filter to only main window once a proper key has been set.
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
if (watcher) {
|
||||
await watcher.close();
|
||||
log.info("Watcher stopped.");
|
||||
mainWindow.webContents.send(ipcTypes.toRenderer.watcher.stopped);
|
||||
mainWindow?.webContents.send(ipcTypes.toRenderer.watcher.stopped);
|
||||
|
||||
new Notification({
|
||||
title: "Watcher Stopped",
|
||||
|
||||
@@ -1,71 +1,98 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import ipcTypes from '../../../../../util/ipcTypes.json';
|
||||
import { PaintScaleConfig, PaintScaleType } from '../../../../../util/types/paintScale';
|
||||
import { useState, useEffect } from "react";
|
||||
import ipcTypes from "../../../../../util/ipcTypes.json";
|
||||
import {
|
||||
PaintScaleConfig,
|
||||
PaintScaleType,
|
||||
} from "../../../../../util/types/paintScale";
|
||||
import { message } from "antd";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ConfigType = 'input' | 'output';
|
||||
type ConfigType = "input" | "output";
|
||||
|
||||
export const usePaintScaleConfig = (configType: ConfigType) => {
|
||||
const [paintScaleConfigs, setPaintScaleConfigs] = useState<PaintScaleConfig[]>([]);
|
||||
const [paintScaleConfigs, setPaintScaleConfigs] = useState<
|
||||
PaintScaleConfig[]
|
||||
>([]);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Get the appropriate IPC methods based on config type
|
||||
const getConfigsMethod = configType === 'input'
|
||||
const getConfigsMethod =
|
||||
configType === "input"
|
||||
? ipcTypes.toMain.settings.paintScale.getInputConfigs
|
||||
: ipcTypes.toMain.settings.paintScale.getOutputConfigs;
|
||||
|
||||
const setConfigsMethod = configType === 'input'
|
||||
const setConfigsMethod =
|
||||
configType === "input"
|
||||
? ipcTypes.toMain.settings.paintScale.setInputConfigs
|
||||
: ipcTypes.toMain.settings.paintScale.setOutputConfigs;
|
||||
|
||||
const setPathMethod = configType === 'input'
|
||||
const setPathMethod =
|
||||
configType === "input"
|
||||
? ipcTypes.toMain.settings.paintScale.setInputPath
|
||||
: ipcTypes.toMain.settings.paintScale.setOutputPath;
|
||||
|
||||
// Load paint scale configs on mount
|
||||
useEffect(() => {
|
||||
window.electron.ipcRenderer
|
||||
.invoke(getConfigsMethod)
|
||||
.then((configs: PaintScaleConfig[]) => {
|
||||
// Ensure all configs have a pollingInterval and type (for backward compatibility)
|
||||
const updatedConfigs = configs.map(config => ({
|
||||
...config,
|
||||
pollingInterval: config.pollingInterval || 1440, // Default to 1440 seconds
|
||||
type: config.type || PaintScaleType.PPG, // Default type if missing
|
||||
}));
|
||||
setPaintScaleConfigs(updatedConfigs || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to load paint scale ${configType} configs:`, error);
|
||||
});
|
||||
.invoke(getConfigsMethod)
|
||||
.then((configs: PaintScaleConfig[]) => {
|
||||
// Ensure all configs have a pollingInterval and type (for backward compatibility)
|
||||
const defaultPolling = configType === "input" ? 1440 : 60;
|
||||
const updatedConfigs = configs.map((config) => ({
|
||||
...config,
|
||||
pollingInterval: config.pollingInterval || defaultPolling, // Default to 1440 for input, 60 for output
|
||||
type: config.type || PaintScaleType.PPG, // Default type if missing
|
||||
}));
|
||||
setPaintScaleConfigs(updatedConfigs || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to load paint scale ${configType} configs:`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
}, [getConfigsMethod]);
|
||||
|
||||
// Save configs to store and notify main process of config changes
|
||||
const saveConfigs = (configs: PaintScaleConfig[]) => {
|
||||
window.electron.ipcRenderer
|
||||
.invoke(setConfigsMethod, configs)
|
||||
.then(() => {
|
||||
// Notify main process to update cron job
|
||||
if (configType === 'input') {
|
||||
window.electron.ipcRenderer.send(ipcTypes.toMain.settings.paintScale.updateInputCron, configs);
|
||||
} else if (configType === 'output') {
|
||||
window.electron.ipcRenderer.send(ipcTypes.toMain.settings.paintScale.updateOutputCron, configs);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to save paint scale ${configType} configs:`, error);
|
||||
});
|
||||
.invoke(setConfigsMethod, configs)
|
||||
.then(() => {
|
||||
// Notify main process to update cron job
|
||||
if (configType === "input") {
|
||||
window.electron.ipcRenderer.send(
|
||||
ipcTypes.toMain.settings.paintScale.updateInputCron,
|
||||
configs,
|
||||
);
|
||||
} else if (configType === "output") {
|
||||
window.electron.ipcRenderer.send(
|
||||
ipcTypes.toMain.settings.paintScale.updateOutputCron,
|
||||
configs,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to save paint scale ${configType} configs:`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// New helper to check if a path is unique across input and output configs
|
||||
const checkPathUnique = async (newPath: string): Promise<boolean> => {
|
||||
try {
|
||||
const inputConfigs: PaintScaleConfig[] = await window.electron.ipcRenderer.invoke(ipcTypes.toMain.settings.paintScale.getInputConfigs);
|
||||
const outputConfigs: PaintScaleConfig[] = await window.electron.ipcRenderer.invoke(ipcTypes.toMain.settings.paintScale.getOutputConfigs);
|
||||
const inputConfigs: PaintScaleConfig[] =
|
||||
await window.electron.ipcRenderer.invoke(
|
||||
ipcTypes.toMain.settings.paintScale.getInputConfigs,
|
||||
);
|
||||
const outputConfigs: PaintScaleConfig[] =
|
||||
await window.electron.ipcRenderer.invoke(
|
||||
ipcTypes.toMain.settings.paintScale.getOutputConfigs,
|
||||
);
|
||||
const allConfigs = [...inputConfigs, ...outputConfigs];
|
||||
// Allow updating the current config even if its current value equals newPath.
|
||||
return !allConfigs.some(config => config.path === newPath);
|
||||
return !allConfigs.some((config) => config.path === newPath);
|
||||
} catch (error) {
|
||||
console.error("Failed to check unique path:", error);
|
||||
return false;
|
||||
@@ -74,10 +101,11 @@ export const usePaintScaleConfig = (configType: ConfigType) => {
|
||||
|
||||
// Handle adding a new paint scale config
|
||||
const handleAddConfig = (type: PaintScaleType) => {
|
||||
const defaultPolling = configType === "input" ? 1440 : 60;
|
||||
const newConfig: PaintScaleConfig = {
|
||||
id: Date.now().toString(),
|
||||
type,
|
||||
pollingInterval: 1440, // Default to 1440 seconds
|
||||
pollingInterval: defaultPolling, // Default to 1440 for input, 60 for output
|
||||
};
|
||||
const updatedConfigs = [...paintScaleConfigs, newConfig];
|
||||
setPaintScaleConfigs(updatedConfigs);
|
||||
@@ -86,7 +114,9 @@ export const usePaintScaleConfig = (configType: ConfigType) => {
|
||||
|
||||
// Handle removing a config
|
||||
const handleRemoveConfig = (id: string) => {
|
||||
const updatedConfigs = paintScaleConfigs.filter((config) => config.id !== id);
|
||||
const updatedConfigs = paintScaleConfigs.filter(
|
||||
(config) => config.id !== id,
|
||||
);
|
||||
setPaintScaleConfigs(updatedConfigs);
|
||||
saveConfigs(updatedConfigs);
|
||||
};
|
||||
@@ -94,7 +124,10 @@ export const usePaintScaleConfig = (configType: ConfigType) => {
|
||||
// Handle path selection (modified to check directory uniqueness)
|
||||
const handlePathChange = async (id: string) => {
|
||||
try {
|
||||
const path: string | null = await window.electron.ipcRenderer.invoke(setPathMethod, id);
|
||||
const path: string | null = await window.electron.ipcRenderer.invoke(
|
||||
setPathMethod,
|
||||
id,
|
||||
);
|
||||
if (path) {
|
||||
const isUnique = await checkPathUnique(path);
|
||||
if (!isUnique) {
|
||||
@@ -115,7 +148,7 @@ export const usePaintScaleConfig = (configType: ConfigType) => {
|
||||
// Handle polling interval change
|
||||
const handlePollingIntervalChange = (id: string, pollingInterval: number) => {
|
||||
const updatedConfigs = paintScaleConfigs.map((config) =>
|
||||
config.id === id ? { ...config, pollingInterval } : config,
|
||||
config.id === id ? { ...config, pollingInterval } : config,
|
||||
);
|
||||
setPaintScaleConfigs(updatedConfigs);
|
||||
saveConfigs(updatedConfigs);
|
||||
|
||||
@@ -35,7 +35,7 @@ const SettingsPaintScaleInputPaths = (): JSX.Element => {
|
||||
handleRemoveConfig,
|
||||
handlePathChange,
|
||||
handlePollingIntervalChange,
|
||||
} = usePaintScaleConfig("input");
|
||||
} = usePaintScaleConfig("output");
|
||||
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [selectedType, setSelectedType] = useState<PaintScaleType | null>(null);
|
||||
|
||||
@@ -33,7 +33,7 @@ const SettingsPaintScaleOutputPaths = (): JSX.Element => {
|
||||
handleRemoveConfig,
|
||||
handlePathChange,
|
||||
handlePollingIntervalChange,
|
||||
} = usePaintScaleConfig("output");
|
||||
} = usePaintScaleConfig("input");
|
||||
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [selectedType, setSelectedType] = useState<PaintScaleType | null>(null);
|
||||
|
||||
5
src/util/getMainWindow.ts
Normal file
5
src/util/getMainWindow.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { BrowserWindow } from "electron";
|
||||
|
||||
export default function getMainWindow(): BrowserWindow | null {
|
||||
return BrowserWindow.getAllWindows()[0] || null;
|
||||
}
|
||||
309
src/util/memUsage.ts
Normal file
309
src/util/memUsage.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { BrowserWindow } from "electron";
|
||||
import log from "electron-log/main";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import Store from "../main/store/store";
|
||||
/**
|
||||
* Human-readable memory/cpu/resource snapshot.
|
||||
*/
|
||||
export type MemoryUsageStats = {
|
||||
timestamp: string;
|
||||
label?: string;
|
||||
uptimeSeconds: number;
|
||||
pid: number;
|
||||
memory: {
|
||||
rss: number;
|
||||
heapTotal: number;
|
||||
heapUsed: number;
|
||||
external: number;
|
||||
arrayBuffers?: number;
|
||||
};
|
||||
memoryPretty: {
|
||||
rss: string;
|
||||
heapTotal: string;
|
||||
heapUsed: string;
|
||||
external: string;
|
||||
arrayBuffers?: string;
|
||||
};
|
||||
os: {
|
||||
totalMem: number;
|
||||
freeMem: number;
|
||||
freeMemPercent: number;
|
||||
};
|
||||
cpuUsage?: NodeJS.CpuUsage;
|
||||
resourceUsage?: NodeJS.ResourceUsage;
|
||||
heapSpaces?: Array<import("v8").HeapSpaceInfo>;
|
||||
heapSnapshotFile?: string;
|
||||
custom?: Record<string, unknown>;
|
||||
};
|
||||
// (merged into top import)
|
||||
|
||||
/**
|
||||
* Options for dumpMemoryStats.
|
||||
*/
|
||||
export type DumpOptions = {
|
||||
/**
|
||||
* Call global.gc() before sampling if available (requires node run with --expose-gc).
|
||||
*/
|
||||
runGc?: boolean;
|
||||
/**
|
||||
* Optional label to include in the returned snapshot.
|
||||
*/
|
||||
label?: string;
|
||||
includeHeapSpaces?: boolean;
|
||||
writeHeapSnapshot?: boolean;
|
||||
heapSnapshotDir?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert bytes to a compact human readable string.
|
||||
*/
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!isFinite(bytes)) return String(bytes);
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let i = 0;
|
||||
let val = bytes;
|
||||
while (val >= 1024 && i < units.length - 1) {
|
||||
val /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${val.toFixed(2)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously produce a memory / cpu / os snapshot.
|
||||
*
|
||||
* Example:
|
||||
* const stats = await dumpMemoryStats({ runGc: true, label: 'before-heavy-task' });
|
||||
*/
|
||||
export async function dumpMemoryStats(
|
||||
options: DumpOptions = {},
|
||||
): Promise<MemoryUsageStats> {
|
||||
const {
|
||||
runGc = false,
|
||||
label,
|
||||
includeHeapSpaces = true,
|
||||
writeHeapSnapshot = true,
|
||||
heapSnapshotDir,
|
||||
} = options;
|
||||
|
||||
// Allow GC if requested and available to get a cleaner snapshot
|
||||
if (runGc && typeof (global as any).gc === "function") {
|
||||
try {
|
||||
(global as any).gc();
|
||||
} catch {
|
||||
// ignore GC errors
|
||||
}
|
||||
}
|
||||
|
||||
// Let the event loop settle a tick so GC can complete if run
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const mem = process.memoryUsage();
|
||||
const totalMem = os.totalmem();
|
||||
const freeMem = os.freemem();
|
||||
|
||||
const stats: MemoryUsageStats = {
|
||||
timestamp: new Date().toISOString(),
|
||||
label,
|
||||
uptimeSeconds: Math.floor(process.uptime()),
|
||||
pid: process.pid,
|
||||
memory: {
|
||||
rss: mem.rss,
|
||||
heapTotal: mem.heapTotal,
|
||||
heapUsed: mem.heapUsed,
|
||||
external: mem.external,
|
||||
arrayBuffers: mem.arrayBuffers,
|
||||
},
|
||||
memoryPretty: {
|
||||
rss: formatBytes(mem.rss),
|
||||
heapTotal: formatBytes(mem.heapTotal),
|
||||
heapUsed: formatBytes(mem.heapUsed),
|
||||
external: formatBytes(mem.external),
|
||||
arrayBuffers:
|
||||
mem.arrayBuffers !== undefined
|
||||
? formatBytes(mem.arrayBuffers)
|
||||
: undefined,
|
||||
},
|
||||
os: {
|
||||
totalMem,
|
||||
freeMem,
|
||||
freeMemPercent: Math.round((freeMem / totalMem) * 10000) / 100,
|
||||
},
|
||||
cpuUsage: process.cpuUsage ? process.cpuUsage() : undefined,
|
||||
resourceUsage:
|
||||
typeof process.resourceUsage === "function"
|
||||
? process.resourceUsage()
|
||||
: undefined,
|
||||
custom: {
|
||||
numBrowserWindows: BrowserWindow.getAllWindows().length,
|
||||
},
|
||||
};
|
||||
|
||||
if (includeHeapSpaces) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const v8: typeof import("v8") = require("v8");
|
||||
if (typeof v8.getHeapSpaceStatistics === "function") {
|
||||
stats.heapSpaces = v8.getHeapSpaceStatistics();
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("Failed to get heap space stats", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (writeHeapSnapshot) {
|
||||
try {
|
||||
if (!runGc && typeof (global as any).gc === "function") {
|
||||
try {
|
||||
(global as any).gc();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const v8: typeof import("v8") = require("v8");
|
||||
if (typeof v8.writeHeapSnapshot === "function") {
|
||||
const baseDir =
|
||||
heapSnapshotDir || path.dirname(log.transports.file.getFile().path);
|
||||
|
||||
const dir = path.join(baseDir, "heap-snapshots");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const fileName = `heap-${Date.now()}-${process.pid}.heapsnapshot`;
|
||||
const filePath = path.join(dir, fileName);
|
||||
const snapshotPath = v8.writeHeapSnapshot(filePath);
|
||||
stats.heapSnapshotFile = snapshotPath;
|
||||
} else {
|
||||
log.warn("v8.writeHeapSnapshot not available");
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("Failed to write heap snapshot", err);
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
const memLogger = log.create({ logId: "mem-stat" });
|
||||
memLogger.transports.file.resolvePathFn = () => {
|
||||
const filePath = path.join(
|
||||
path.dirname(log.transports.file.getFile().path),
|
||||
"memory-stats.log",
|
||||
);
|
||||
return filePath;
|
||||
};
|
||||
// Configure memory logger format to include process ID
|
||||
memLogger.transports.file.format =
|
||||
"[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [PID:{processId}] {text}";
|
||||
memLogger.transports.console.format =
|
||||
"[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [PID:{processId}] {text}";
|
||||
|
||||
export async function dumpMemoryStatsToFile() {
|
||||
try {
|
||||
const stats = await dumpMemoryStats({ includeHeapSpaces: false });
|
||||
memLogger.debug("[MemStat]:", stats);
|
||||
} catch (error) {
|
||||
log.warn("Unexpected error while writing memory stats log", error);
|
||||
}
|
||||
}
|
||||
|
||||
function ongoingMemoryDump() {
|
||||
console.log(
|
||||
`Memory logging set to ${Store.get("settings.enableMemDebug")}. Log file at ${memLogger.transports.file.getFile().path}`,
|
||||
);
|
||||
|
||||
setInterval(
|
||||
async () => {
|
||||
// Also write each snapshot to a dedicated memory stats log file as JSON lines.
|
||||
try {
|
||||
const loggingEnabled = Store.get("settings.enableMemDebug");
|
||||
log.debug(
|
||||
"Checking if memory stats logging is enabled.",
|
||||
loggingEnabled,
|
||||
);
|
||||
if (loggingEnabled) {
|
||||
// Enforce heap snapshot folder size limit (< 1GB) before writing a new snapshot.
|
||||
const MAX_DIR_BYTES = 5 * 1024 * 1024 * 1024; // 5GB
|
||||
const TARGET_REDUCED_BYTES = Math.floor(MAX_DIR_BYTES * 0.9); // prune down to 90%
|
||||
const baseDir = path.dirname(log.transports.file.getFile().path);
|
||||
const heapDir = path.join(baseDir, "heap-snapshots");
|
||||
try {
|
||||
fs.mkdirSync(heapDir, { recursive: true });
|
||||
const files = fs
|
||||
.readdirSync(heapDir)
|
||||
.filter((f) => f.endsWith(".heapsnapshot"));
|
||||
let totalSize = 0;
|
||||
const fileStats: Array<{
|
||||
file: string;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
}> = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const stat = fs.statSync(path.join(heapDir, file));
|
||||
if (stat.isFile()) {
|
||||
totalSize += stat.size;
|
||||
fileStats.push({
|
||||
file,
|
||||
size: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn("Failed to stat heap snapshot file", file, e);
|
||||
}
|
||||
}
|
||||
if (totalSize > MAX_DIR_BYTES) {
|
||||
// Sort oldest first and delete until below TARGET_REDUCED_BYTES.
|
||||
fileStats.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
||||
let bytesAfter = totalSize;
|
||||
for (const info of fileStats) {
|
||||
if (bytesAfter <= TARGET_REDUCED_BYTES) break;
|
||||
try {
|
||||
fs.unlinkSync(path.join(heapDir, info.file));
|
||||
bytesAfter -= info.size;
|
||||
log.warn(
|
||||
`Pruned heap snapshot '${info.file}' (${formatBytes(info.size)}) to reduce directory size. New size: ${formatBytes(bytesAfter)}.`,
|
||||
);
|
||||
} catch (errDel) {
|
||||
log.warn(
|
||||
"Failed to delete heap snapshot file",
|
||||
info.file,
|
||||
errDel,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (bytesAfter > MAX_DIR_BYTES) {
|
||||
// Still above hard cap; skip writing a new snapshot this cycle.
|
||||
log.warn(
|
||||
`Heap snapshot directory still above hard cap (${formatBytes(bytesAfter)} > ${formatBytes(MAX_DIR_BYTES)}). Skipping new heap snapshot this cycle.`,
|
||||
);
|
||||
const stats = await dumpMemoryStats({
|
||||
includeHeapSpaces: false,
|
||||
writeHeapSnapshot: false,
|
||||
});
|
||||
memLogger.debug("[MemStat]:", stats);
|
||||
return; // skip remainder; we already logged stats without snapshot.
|
||||
}
|
||||
}
|
||||
} catch (dirErr) {
|
||||
log.warn(
|
||||
"Unexpected error while enforcing heap snapshot directory size limit",
|
||||
dirErr,
|
||||
);
|
||||
// Continue; failure to enforce limit should not stop memory stats.
|
||||
}
|
||||
// Directory is within allowed bounds (or pruning succeeded); proceed normally.
|
||||
const stats = await dumpMemoryStats({ includeHeapSpaces: false });
|
||||
memLogger.debug("[MemStat]:", stats);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("Unexpected error while writing memory stats log", err);
|
||||
}
|
||||
},
|
||||
15 * 60 * 1000,
|
||||
); // every 15 minutes
|
||||
}
|
||||
|
||||
export default ongoingMemoryDump;
|
||||
62
tests/heapPrune.test.ts
Normal file
62
tests/heapPrune.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// We import the module after setting up a temporary log path by monkey patching electron-log.
|
||||
// Since the project primarily uses Playwright for tests, we leverage its expect assertion library.
|
||||
|
||||
// NOTE: This is a lightweight test that simulates the pruning logic indirectly by invoking the exported ongoingMemoryDump
|
||||
// function and creating artificial heap snapshot files exceeding the threshold.
|
||||
|
||||
// Because ongoingMemoryDump sets an interval, we invoke its internal logic by importing the file and manually calling dumpMemoryStats.
|
||||
// For simplicity and to avoid altering production code for testability, we replicate the size enforcement logic here and assert behavior.
|
||||
|
||||
function createDummySnapshots(dir: string, count: number, sizeBytes: number) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (let i = 0; i < count; i++) {
|
||||
const file = path.join(dir, `dummy-${i}.heapsnapshot`);
|
||||
const fd = fs.openSync(file, "w");
|
||||
// Write sizeBytes of zeros
|
||||
const buf = Buffer.alloc(1024 * 1024, 0); // 1MB chunk
|
||||
let written = 0;
|
||||
while (written < sizeBytes) {
|
||||
fs.writeSync(fd, buf, 0, Math.min(buf.length, sizeBytes - written));
|
||||
written += Math.min(buf.length, sizeBytes - written);
|
||||
}
|
||||
fs.closeSync(fd);
|
||||
// Stagger mtime for deterministic pruning ordering
|
||||
const mtime = new Date(Date.now() - (count - i) * 1000);
|
||||
fs.utimesSync(file, mtime, mtime);
|
||||
}
|
||||
}
|
||||
|
||||
test("heap snapshot directory pruning reduces size below simulated hard cap", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(process.cwd(), "heap-test-"));
|
||||
const heapDir = path.join(baseDir, "heap-snapshots");
|
||||
// Simulate oversize: 15 files of 5MB each = 75MB
|
||||
createDummySnapshots(heapDir, 15, 5 * 1024 * 1024);
|
||||
// Use smaller cap to keep test resource usage low.
|
||||
const MAX_DIR_BYTES = 50 * 1024 * 1024; // 50MB simulated cap
|
||||
const TARGET_REDUCED_BYTES = Math.floor(MAX_DIR_BYTES * 0.9);
|
||||
const files = fs
|
||||
.readdirSync(heapDir)
|
||||
.filter((f) => f.endsWith(".heapsnapshot"));
|
||||
let totalSize = 0;
|
||||
const fileStats: Array<{ file: string; size: number; mtimeMs: number }> = [];
|
||||
for (const file of files) {
|
||||
const stat = fs.statSync(path.join(heapDir, file));
|
||||
totalSize += stat.size;
|
||||
fileStats.push({ file, size: stat.size, mtimeMs: stat.mtimeMs });
|
||||
}
|
||||
expect(totalSize).toBeGreaterThan(MAX_DIR_BYTES);
|
||||
fileStats.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
||||
let bytesAfter = totalSize;
|
||||
for (const info of fileStats) {
|
||||
if (bytesAfter <= TARGET_REDUCED_BYTES) break;
|
||||
fs.unlinkSync(path.join(heapDir, info.file));
|
||||
bytesAfter -= info.size;
|
||||
}
|
||||
expect(bytesAfter).toBeLessThanOrEqual(TARGET_REDUCED_BYTES);
|
||||
// Cleanup
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
});
|
||||
Reference in New Issue
Block a user