Compare commits

..

1 Commits

Author SHA1 Message Date
Maintainer Bruno
1cc94e8ffe feat(dev): enhance hot reload development setup 2025-06-04 16:56:22 +05:30
17 changed files with 331 additions and 326 deletions

44
.github/workflows/playwright.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Playwright E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e-test:
timeout-minutes: 60
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: v22.11.x
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb
npm ci --legacy-peer-deps
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
- name: Build libraries
run: |
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build:bruno-converters
npm run build:bruno-requests
- name: Run Playwright tests
run: |
xvfb-run npm run test:e2e
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

View File

@@ -91,43 +91,5 @@ jobs:
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
check_name: CLI Test Results
files: packages/bruno-tests/collection/junit.xml
comment_mode: always
e2e-test:
name: Playwright E2E Tests
timeout-minutes: 60
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: v22.11.x
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb
npm ci --legacy-peer-deps
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
- name: Build libraries
run: |
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build:bruno-converters
npm run build:bruno-requests
- name: Run Playwright tests
run: |
xvfb-run npm run test:e2e
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

View File

@@ -99,13 +99,14 @@ npm run dev
```
#### Customize Electron `userData` path
If `ELECTRON_USER_DATA_PATH` env-variable is present and its development mode, then `userData` path is modified accordingly.
If `ELECTRON_APP_NAME` env-variable is present and its development mode, then the `appName` and `userData` path is modified accordingly.
e.g.
```sh
ELECTRON_USER_DATA_PATH=$(realpath ~/Desktop/bruno-test) npm run dev:electron
ELECTRON_APP_NAME=bruno-dev npm run dev:electron
```
This will create a `bruno-test` folder on your Desktop and use it as the `userData` path.
> This doesn't change the name of the window or the names in lot of other places, only the name used by Electron internally.
### Troubleshooting

View File

@@ -1,5 +0,0 @@
import { test, expect } from '../../playwright';
test('Check if the logo on top left is visible', async ({ page }) => {
await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible();
});

View File

@@ -1,31 +0,0 @@
import { test, expect } from '../../playwright';
test('Create new collection and add a simple HTTP request', async ({ page, createTmpDir }) => {
await page.getByLabel('Create Collection').click();
await page.getByLabel('Name').click();
await page.getByLabel('Name').fill('test-collection');
await page.getByLabel('Name').press('Tab');
await page.getByLabel('Location').fill(await createTmpDir('test-collection'));
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByText('test-collection').click();
await page.getByLabel('Safe ModeBETA').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByPlaceholder('Request Name').fill('r1');
await page.getByPlaceholder('Request URL').click();
await page.getByPlaceholder('Request URL').fill('http://localhost:8081');
await page.getByRole('button', { name: 'Create' }).click();
await page.locator('pre').filter({ hasText: 'http://localhost:' }).click();
await page.locator('textarea').fill('/ping');
await page.locator('#send-request').getByRole('img').nth(2).click();
await expect(page.getByRole('main')).toContainText('200 OK');
await page.getByRole('tab', { name: 'GET r1' }).locator('circle').click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await page.getByText('GETr1').click();
await page.getByRole('button', { name: 'Clear response' }).click();
await page.locator('body').press('ControlOrMeta+Enter');
await expect(page.getByRole('main')).toContainText('200 OK');
});

View File

@@ -1,4 +0,0 @@
{
"maximized": true,
"lastOpenedCollections": ["{{projectRoot}}/packages/bruno-tests/collection"]
}

View File

@@ -1,49 +0,0 @@
import { test, expect } from '../../playwright';
test.describe.parallel('Run Testbench Requests', () => {
test('Run bruno-testbench in Developer Mode', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);
await page.getByText('bruno-testbench').click();
await page.getByLabel('Developer Mode(use only if').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('.environment-selector').nth(1).click();
await page.locator('.dropdown-item').getByText('Prod').click();
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const [totalRequests, passed, failed, skipped] = result
.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/)
.slice(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - 1);
});
test.fixme('Run bruno-testbench in Safe Mode', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);
await page.getByText('bruno-testbench').click();
await page.getByLabel('Safe ModeBETA').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('.environment-selector').nth(1).click();
await page.locator('.dropdown-item').getByText('Prod').click();
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const [totalRequests, passed, failed, skipped] = result
.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/)
.slice(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - 1);
});
});

View File

@@ -0,0 +1,5 @@
import { test, expect } from '../playwright';
test('test-app-start', async ({ page }) => {
await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible();
});

View File

@@ -40,6 +40,7 @@
"setup": "node ./scripts/setup.js",
"watch:converters": "npm run watch --workspace=packages/bruno-converters",
"dev": "concurrently --kill-others \"npm run dev:web\" \"npm run dev:electron\"",
"dev:watch": "node ./scripts/dev-hot-reload.js",
"dev:web": "npm run dev --workspace=packages/bruno-app",
"build:web": "npm run build --workspace=packages/bruno-app",
"prettier:web": "npm run prettier --workspace=packages/bruno-app",

View File

@@ -14,11 +14,16 @@ const { format } = require('url');
const { BrowserWindow, app, session, Menu, ipcMain } = require('electron');
const { setContentSecurityPolicy } = require('electron-util');
if (isDev && process.env.ELECTRON_USER_DATA_PATH) {
console.debug("`ELECTRON_USER_DATA_PATH` found, modifying `userData` path: \n"
+ `\t${app.getPath("userData")} -> ${process.env.ELECTRON_USER_DATA_PATH}`);
if (isDev && process.env.ELECTRON_APP_NAME) {
const appName = process.env.ELECTRON_APP_NAME;
const userDataPath = path.join(app.getPath("appData"), appName);
app.setPath('userData', process.env.ELECTRON_USER_DATA_PATH);
console.log("`ELECTRON_APP_NAME` found, overriding `appName` and `userData` path: \n"
+ `\t${app.getName()} -> ${appName}\n`
+ `\t${app.getPath("userData")} -> ${userDataPath}`);
app.setName(appName);
app.setPath("userData", userDataPath);
}
const menuTemplate = require('./app/menu-template');

View File

@@ -9,7 +9,8 @@
"package.json"
],
"scripts": {
"build": "rollup -c"
"build": "rollup -c",
"watch": "rollup -c -w"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.2",

View File

@@ -15,6 +15,7 @@
"test": "jest",
"prebuild": "npm run clean",
"build": "rollup -c",
"watch": "rollup -c -w",
"prepack": "npm run test && npm run build"
},
"devDependencies": {

View File

@@ -14,6 +14,7 @@
"clean": "rimraf dist",
"prebuild": "npm run clean",
"build": "rollup -c",
"watch": "rollup -c -w",
"prepack": "npm run test && npm run build"
},
"devDependencies": {

View File

@@ -1,11 +1,12 @@
import { defineConfig, devices } from '@playwright/test';
const reporter: any[] = [['list'], ['html']];
const reporter: string[][string] = [['list'], ['html']];
if (process.env.CI) {
reporter.push(['github']);
reporter.push(["github"]);
}
export default defineConfig({
testDir: './e2e-tests',
fullyParallel: false,
@@ -13,9 +14,8 @@ export default defineConfig({
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? undefined : 1,
reporter,
use: {
trace: process.env.CI ? 'on-first-retry' : 'on'
trace: 'on-first-retry'
},
projects: [
@@ -24,16 +24,9 @@ export default defineConfig({
}
],
webServer: [
{
command: 'npm run dev:web',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI
},
{
command: 'npm start --workspace=packages/bruno-tests',
url: 'http://localhost:8081/ping',
reuseExistingServer: !process.env.CI
}
]
webServer: {
command: 'npm run dev:web',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI
}
});

View File

@@ -7,11 +7,7 @@ exports.startApp = async () => {
const app = await electron.launch({ args: [electronAppPath] });
const context = await app.context();
app.process().stdout.on('data', (data) => {
process.stdout.write(data.toString().replace(/^(?=.)/gm, '[Electron] |'));
});
app.process().stderr.on('data', (error) => {
process.stderr.write(error.toString().replace(/^(?=.)/gm, '[Electron] |'));
});
app.process().stdout.on('data', (data) => console.log(data.toString()));
app.process().stderr.on('data', (error) => console.error(error.toString()));
return { app, context };
};

View File

@@ -1,179 +1,23 @@
import { test as baseTest, BrowserContext, ElectronApplication, Page } from '@playwright/test';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
import { test as baseTest, ElectronApplication, Page } from '@playwright/test';
const electronAppPath = path.join(__dirname, '../packages/bruno-electron');
export const test = baseTest.extend<
{
context: BrowserContext;
page: Page;
newPage: Page;
pageWithUserData: Page;
},
{
createTmpDir: (tag?: string) => Promise<string>;
launchElectronApp: (options?: { initUserDataPath?: string }) => Promise<ElectronApplication>;
electronApp: ElectronApplication;
reuseOrLaunchElectronApp: (options?: { initUserDataPath?: string }) => Promise<ElectronApplication>;
}
>({
createTmpDir: [
async ({}, use) => {
const dirs: string[] = [];
await use(async (tag?: string) => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `pw-${tag || ''}-`));
dirs.push(dir);
return dir;
});
await Promise.all(
dirs.map((dir) => fs.promises.rm(dir, { recursive: true, force: true, maxRetries: 10 }).catch((e) => e))
);
},
{ scope: 'worker' }
],
launchElectronApp: [
async ({ playwright, createTmpDir }, use, workerInfo) => {
const apps: ElectronApplication[] = [];
await use(async ({ initUserDataPath } = {}) => {
const userDataPath = await createTmpDir('electron-userdata');
if (initUserDataPath) {
const replacements = {
projectRoot: path.join(__dirname, '..')
};
for (const file of await fs.promises.readdir(initUserDataPath)) {
let content = await fs.promises.readFile(path.join(initUserDataPath, file), 'utf-8');
content = content.replace(/{{(\w+)}}/g, (_, key) => {
if (replacements[key]) {
return replacements[key];
} else {
throw new Error(`\tNo replacement for {{${key}}} in ${path.join(initUserDataPath, file)}`);
}
});
await fs.promises.writeFile(path.join(userDataPath, file), content, 'utf-8');
}
}
const app = await playwright._electron.launch({
args: [electronAppPath],
env: {
...process.env,
ELECTRON_USER_DATA_PATH: userDataPath,
}
});
const { workerIndex } = workerInfo;
app.process().stdout.on('data', (data) => {
process.stdout.write(data.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`));
});
app.process().stderr.on('data', (error) => {
process.stderr.write(error.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`));
});
apps.push(app);
return app;
});
for (const app of apps) {
await app.context().close();
await app.close();
}
},
{ scope: 'worker' }
],
const { startApp } = require('./electron.ts');
export const test = baseTest.extend<{ page: Page }, { electronApp: ElectronApplication }>({
electronApp: [
async ({ launchElectronApp }, use) => {
const app = await launchElectronApp();
await use(app);
async ({}, use) => {
const { app: electronApp, context } = await startApp();
await use(electronApp);
await context.close();
await electronApp.close();
},
{ scope: 'worker' }
],
context: async ({ electronApp }, use, testInfo) => {
const context = await electronApp.context();
const tracingOptions = (testInfo as any)._tracing.traceOptions();
if (tracingOptions) {
try {
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
} catch (e) {}
}
await use(context);
},
page: async ({ electronApp, context }, use, testInfo) => {
page: async ({ electronApp }, use) => {
const page = await electronApp.firstWindow();
const tracingOptions = (testInfo as any)._tracing.traceOptions();
if (tracingOptions) {
const tracePath = testInfo.outputPath(`trace-${testInfo.testId}.zip`);
await context.tracing.startChunk();
await use(page);
await context.tracing.stopChunk({ path: tracePath });
await testInfo.attach('trace', { path: tracePath });
} else {
await use(page);
}
},
newPage: async ({ launchElectronApp }, use, testInfo) => {
const app = await launchElectronApp();
const context = await app.context();
const page = await app.firstWindow();
const tracingOptions = (testInfo as any)._tracing.traceOptions();
if (tracingOptions) {
const tracePath = testInfo.outputPath(`trace-${testInfo.testId}.zip`);
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
await use(page);
await context.tracing.stop({ path: tracePath });
await testInfo.attach('trace', { path: tracePath });
} else {
await use(page);
}
},
reuseOrLaunchElectronApp: [
async ({ launchElectronApp }, use, testInfo) => {
const apps: Record<string, ElectronApplication> = {};
await use(async ({ initUserDataPath } = {}) => {
const key = initUserDataPath;
if (key && apps[key]) {
return apps[key];
}
const app = await launchElectronApp({ initUserDataPath });
apps[key] = app;
return app;
});
},
{ scope: 'worker' }
],
pageWithUserData: async ({ reuseOrLaunchElectronApp }, use, testInfo) => {
const testDir = path.dirname(testInfo.file);
const initUserDataPath = path.join(testDir, 'init-user-data');
const app = await reuseOrLaunchElectronApp(
(await fs.promises.stat(initUserDataPath).catch(() => false)) ? { initUserDataPath } : {}
);
const context = await app.context();
const page = await app.firstWindow();
const tracingOptions = (testInfo as any)._tracing.traceOptions();
if (tracingOptions) {
const tracePath = testInfo.outputPath(`trace-${testInfo.testId}.zip`);
try {
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
} catch (e) {}
await context.tracing.startChunk();
await use(page);
await context.tracing.stopChunk({ path: tracePath });
await testInfo.attach('trace', { path: tracePath });
} else {
await use(page);
}
await use(page);
await page.reload();
}
});
export * from '@playwright/test';
export * from '@playwright/test'

240
scripts/dev-hot-reload.js Normal file
View File

@@ -0,0 +1,240 @@
#!/usr/bin/env node
/**
# Bruno Development Script
#
# This script sets up and runs the Bruno development environment with hot-reloading.
# It manages concurrent processes for various packages and provides cleanup on exit.
#
# Usage:
# From the root of the project, run:
# node ./scripts/dev-hot-reload.js [options]
# or
# npm run dev:watch -- [options]
*/
const { execSync } = require('child_process');
const { readFileSync } = require('fs');
// Get major version from .nvmrc (e.g. v22.1.0 -> v22)
const NODE_VERSION = readFileSync('.nvmrc', 'utf8').trim().split('.')[0];
// Configuration
const CONFIG = {
NODE_VERSION,
ELECTRON_WATCH_PATHS: [
'packages/**/dist/',
'packages/bruno-electron/src/',
'packages/bruno-lang/src/',
'packages/bruno-lang/v2/src/',
'packages/bruno-js/src/',
'packages/bruno-schema/src/'
],
ELECTRON_START_DELAY: 10, // seconds
NODEMON_WATCH_DELAY: 1000 // milliseconds
};
const COLORS = {
red: '\x1b[0;31m',
green: '\x1b[0;32m',
yellow: '\x1b[1;33m',
blue: '\x1b[0;34m',
nc: '\x1b[0m' // No Color
};
const LOG_LEVELS = {
INFO: 'INFO',
WARN: 'WARN',
ERROR: 'ERROR',
DEBUG: 'DEBUG',
SUCCESS: 'SUCCESS'
};
function log(level, msg) {
let color = COLORS.nc;
switch (level) {
case LOG_LEVELS.INFO:
case LOG_LEVELS.SUCCESS: color = COLORS.green; break;
case LOG_LEVELS.WARN: color = COLORS.yellow; break;
case LOG_LEVELS.ERROR: color = COLORS.red; break;
case LOG_LEVELS.DEBUG: color = COLORS.blue; break;
}
const output = `${color}[${level}]${COLORS.nc} ${msg}`;
if (level === LOG_LEVELS.ERROR) {
console.error(output);
} else {
console.log(output);
}
}
// Show help documentation
function showHelp() {
console.log(`
Development Environment Setup for Bruno
Usage:
From the root of the project, run:
npm run dev:watch -- [options]
or
node scripts/dev-hot-reload.js [options]
Options:
-s, --setup Clean all node_modules folders and re-install dependencies before starting
-h, --help Show this help message
Examples:
# Start development environment
npm run dev:watch
# Start after cleaning node_modules
npm run dev:watch -- --setup
# Show this help
npm run dev:watch -- --help
`);
}
function commandExists(command) {
try {
execSync(`command -v ${command}`, { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
// Install global NPM package if not present
function ensureGlobalPackage(packageName) {
if (!commandExists(packageName)) {
log(LOG_LEVELS.INFO, `Installing ${packageName} globally...`);
execSync(`npm install -g ${packageName}`, { stdio: 'inherit' });
}
}
// Ensure correct node version
function ensureNodeVersion(requiredVersion) {
const currentVersion = process.version;
if (!currentVersion.includes(requiredVersion)) {
log(LOG_LEVELS.ERROR, `Node ${requiredVersion} is required but currently installed version is ${currentVersion}`);
log(LOG_LEVELS.ERROR, `Please install node ${requiredVersion} and try again.`);
log(LOG_LEVELS.ERROR, `You can run 'nvm install ${requiredVersion}' to install it, or 'nvm use ${requiredVersion}' if it's already installed.`);
process.exit(1);
}
}
function cleanNodeModules() {
log(LOG_LEVELS.INFO, 'Removing all node_modules directories...');
execSync('find . -name "node_modules" -type d -prune -exec rm -rf {} +', { stdio: 'inherit' });
log(LOG_LEVELS.SUCCESS, 'Node modules cleanup completed');
}
function reinstallDependencies() {
log(LOG_LEVELS.INFO, 'Re-installing dependencies...');
execSync('npm install --legacy-peer-deps', { stdio: 'inherit' });
log(LOG_LEVELS.SUCCESS, 'Dependencies re-installation completed');
}
// Setup development environment
function startDevelopment() {
log(LOG_LEVELS.INFO, 'Starting development servers...');
const concurrently = require('concurrently');
const watchPaths = CONFIG.ELECTRON_WATCH_PATHS.map(path => `--watch "${path}"`).join(' ');
// concurrently command objects: { command, name, prefixColor, env, cwd, ipc }
const commandObjects = [
{
command: 'npm run watch --workspace=packages/bruno-common',
name: 'common',
prefixColor: 'magenta'
},
{
command: 'npm run watch --workspace=packages/bruno-converters',
name: 'converters',
prefixColor: 'green'
},
{
command: 'npm run watch --workspace=packages/bruno-query',
name: 'query',
prefixColor: 'blue'
},
{
command: 'npm run watch --workspace=packages/bruno-graphql-docs',
name: 'graphql',
prefixColor: 'white'
},
{
command: 'npm run watch --workspace=packages/bruno-requests',
name: 'requests',
prefixColor: 'gray'
},
{
command: 'npm run dev:web',
name: 'react',
prefixColor: 'cyan'
},
{
command: `sleep ${CONFIG.ELECTRON_START_DELAY} && nodemon ${watchPaths} --ext js,jsx,ts,tsx --delay ${CONFIG.NODEMON_WATCH_DELAY}ms --exec "npm run dev --workspace=packages/bruno-electron"`,
name: 'electron',
prefixColor: 'yellow',
delay: CONFIG.ELECTRON_START_DELAY
}
];
const { result } = concurrently(commandObjects, {
prefix: '[{name}: {pid}]',
killOthers: ['failure', 'success'],
restartTries: 3,
restartDelay: 1000
});
result
.then(() => log(LOG_LEVELS.SUCCESS, 'All processes completed successfully'))
.catch(err => {
log(LOG_LEVELS.ERROR, 'Development environment failed to start');
console.error(err);
process.exit(1);
});
}
// Main function
(async function main() {
const args = process.argv.slice(2);
let runSetup = false;
// Parse command line arguments
for (const arg of args) {
if (arg === '-s' || arg === '--setup') {
runSetup = true;
} else if (arg === '-h' || arg === '--help') {
showHelp();
process.exit(0);
} else {
log(LOG_LEVELS.ERROR, `Unknown parameter: ${arg}`);
showHelp();
process.exit(1);
}
}
log(LOG_LEVELS.INFO, 'Initializing Bruno development environment...');
// Ensure required global packages and node version
ensureNodeVersion(CONFIG.NODE_VERSION);
ensureGlobalPackage('nodemon');
ensureGlobalPackage('concurrently');
// Run setup if requested
if (runSetup) {
cleanNodeModules();
reinstallDependencies();
}
// Start development environment
startDevelopment();
})().catch(err => {
log(LOG_LEVELS.ERROR, 'An error occurred:');
console.error(err);
process.exit(1);
});