haltest 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/bin/haltest.js +195 -0
  2. package/package.json +34 -0
  3. package/src/index.js +218 -0
package/bin/haltest.js ADDED
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * HalTest CLI Launcher — bin/haltest.js
5
+ *
6
+ * Starts the HalTest backend server, runs pre-requisite checks,
7
+ * auto-opens the browser, and handles graceful shutdown.
8
+ */
9
+
10
+ import { spawn } from 'child_process';
11
+ import net from 'net';
12
+ import path from 'path';
13
+ import { fileURLToPath } from 'url';
14
+
15
+ // ── Resolve paths ──────────────────────────────────────────────────────────
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ // bin/ is one level inside apps/cli → go up two levels to reach the monorepo root
18
+ const MONOREPO_ROOT = path.resolve(__dirname, '..', '..', '..');
19
+ const BACKEND_ENTRY = path.join(MONOREPO_ROOT, 'apps', 'backend', 'app.js');
20
+
21
+ const PORT = parseInt(process.env.PORT || '2001', 10);
22
+ const APP_URL = `http://localhost:${PORT}/app/`;
23
+ const OLLAMA_PORT = 11434;
24
+
25
+ // ── ANSI helpers ───────────────────────────────────────────────────────────
26
+ const c = {
27
+ reset: '\x1b[0m',
28
+ bold: '\x1b[1m',
29
+ dim: '\x1b[2m',
30
+ cyan: '\x1b[36m',
31
+ green: '\x1b[32m',
32
+ yellow: '\x1b[33m',
33
+ red: '\x1b[31m',
34
+ magenta: '\x1b[35m',
35
+ blue: '\x1b[34m',
36
+ white: '\x1b[97m',
37
+ };
38
+ const style = (color, text) => `${color}${text}${c.reset}`;
39
+
40
+ // ── Banner ─────────────────────────────────────────────────────────────────
41
+ function printBanner() {
42
+ console.log('');
43
+ console.log(style(c.cyan + c.bold, ' ██╗ ██╗ █████╗ ██╗ ████████╗███████╗███████╗████████╗'));
44
+ console.log(style(c.cyan + c.bold, ' ██║ ██║██╔══██╗██║ ╚══██╔══╝██╔════╝██╔════╝╚══██╔══╝'));
45
+ console.log(style(c.cyan + c.bold, ' ███████║███████║██║ ██║ █████╗ ███████╗ ██║ '));
46
+ console.log(style(c.cyan + c.bold, ' ██╔══██║██╔══██║██║ ██║ ██╔══╝ ╚════██║ ██║ '));
47
+ console.log(style(c.cyan + c.bold, ' ██║ ██║██║ ██║███████╗██║ ███████╗███████║ ██║ '));
48
+ console.log(style(c.cyan + c.bold, ' ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚══════╝╚══════╝ ╚═╝ '));
49
+ console.log('');
50
+ console.log(style(c.white + c.bold, ' Browser Automation & Orchestration Platform'));
51
+ console.log(style(c.dim + c.white, ' ─────────────────────────────────────────────────────────'));
52
+ console.log('');
53
+ }
54
+
55
+ // ── Port check helper ──────────────────────────────────────────────────────
56
+ function isPortListening(port) {
57
+ return new Promise((resolve) => {
58
+ const socket = net.createConnection({ port, host: '127.0.0.1' });
59
+ socket.once('connect', () => { socket.destroy(); resolve(true); });
60
+ socket.once('error', () => { socket.destroy(); resolve(false); });
61
+ socket.setTimeout(500, () => { socket.destroy(); resolve(false); });
62
+ });
63
+ }
64
+
65
+ // ── Pre-requisite checks ───────────────────────────────────────────────────
66
+ async function runPreRequisiteChecks() {
67
+ console.log(style(c.bold, ' 🔍 Running pre-requisite checks...\n'));
68
+
69
+ // Check Ollama
70
+ const ollamaOk = await isPortListening(OLLAMA_PORT);
71
+ if (ollamaOk) {
72
+ console.log(style(c.green, ` ✅ Ollama — detected on port ${OLLAMA_PORT}. Local AI is active.`));
73
+ } else {
74
+ console.log(
75
+ style(c.yellow, ` ⚠️ Ollama — not detected on port ${OLLAMA_PORT}.`) +
76
+ style(c.dim, ' Local AI features will be disabled until Ollama is started.')
77
+ );
78
+ console.log(
79
+ style(c.dim, ` Run: `) +
80
+ style(c.white, 'ollama serve') +
81
+ style(c.dim, ' in a separate terminal to enable AI features.')
82
+ );
83
+ }
84
+
85
+ // Check Playwright (probe for the chromium executable)
86
+ try {
87
+ const { chromium } = await import('playwright');
88
+ const execPath = chromium.executablePath();
89
+ // executablePath() throws if not installed
90
+ void execPath;
91
+ console.log(style(c.green, ' ✅ Playwright — Chromium browser found.'));
92
+ } catch {
93
+ console.log(
94
+ style(c.yellow, ' ⚠️ Playwright — Chromium not found.') +
95
+ style(c.dim, ' Run: npx playwright install chromium')
96
+ );
97
+ }
98
+
99
+ console.log('');
100
+ }
101
+
102
+ // ── Open browser (dynamic import for ESM compatibility) ────────────────────
103
+ async function openBrowser(url) {
104
+ try {
105
+ const { default: open } = await import('open');
106
+ await open(url);
107
+ } catch {
108
+ console.log(style(c.dim, ` ℹ️ Could not auto-open browser. Navigate to: ${url}`));
109
+ }
110
+ }
111
+
112
+ // ── Main ───────────────────────────────────────────────────────────────────
113
+ printBanner();
114
+
115
+ await runPreRequisiteChecks();
116
+
117
+ console.log(style(c.bold, ` 🚀 Starting HalTest Server...`));
118
+ console.log(style(c.dim, ` Entry: ${BACKEND_ENTRY}`));
119
+ console.log('');
120
+
121
+ // Spawn the backend server
122
+ const server = spawn('node', [BACKEND_ENTRY], {
123
+ cwd: MONOREPO_ROOT,
124
+ stdio: ['ignore', 'pipe', 'pipe'],
125
+ env: { ...process.env, NODE_ENV: process.env.NODE_ENV || 'production' },
126
+ });
127
+
128
+ let browserOpened = false;
129
+
130
+ // Forward stdout — detect ready signal
131
+ server.stdout.on('data', (data) => {
132
+ const text = data.toString();
133
+ process.stdout.write(text);
134
+
135
+ // Detect the backend "ready" line
136
+ if (!browserOpened && text.includes('HaltTest Server is Up')) {
137
+ browserOpened = true;
138
+ console.log('');
139
+ console.log(style(c.green + c.bold, ` ✅ Server is up! Opening browser at ${APP_URL}`));
140
+ console.log(style(c.dim, ' Press Ctrl+C to stop the server.\n'));
141
+ openBrowser(APP_URL).catch(() => { });
142
+ }
143
+ });
144
+
145
+ // Forward stderr
146
+ server.stderr.on('data', (data) => {
147
+ process.stderr.write(data);
148
+ });
149
+
150
+ // Handle unexpected server exit
151
+ server.on('exit', (code, signal) => {
152
+ if (signal) {
153
+ // Killed by us during shutdown — silent
154
+ return;
155
+ }
156
+ if (code !== 0) {
157
+ console.error(style(c.red, `\n ❌ Server exited with code ${code}.`));
158
+ process.exit(code ?? 1);
159
+ }
160
+ });
161
+
162
+ server.on('error', (err) => {
163
+ console.error(style(c.red, `\n ❌ Failed to start server: ${err.message}`));
164
+ console.error(style(c.dim, ` Make sure you're inside the HalTest monorepo directory.`));
165
+ process.exit(1);
166
+ });
167
+
168
+ // ── Graceful Shutdown ──────────────────────────────────────────────────────
169
+ function shutdown(signal) {
170
+ console.log('');
171
+ console.log(style(c.magenta + c.bold, `\n 👋 Received ${signal}. Shutting down HalTest gracefully...`));
172
+
173
+ if (!server.killed) {
174
+ server.kill('SIGTERM');
175
+
176
+ // Force-kill after 5 s if the child doesn't exit
177
+ const forceKill = setTimeout(() => {
178
+ if (!server.killed) {
179
+ server.kill('SIGKILL');
180
+ }
181
+ }, 5000);
182
+
183
+ server.on('exit', () => {
184
+ clearTimeout(forceKill);
185
+ console.log(style(c.green, ' ✅ Server stopped cleanly. Goodbye!\n'));
186
+ process.exit(0);
187
+ });
188
+ } else {
189
+ console.log(style(c.green, ' ✅ Server already stopped. Goodbye!\n'));
190
+ process.exit(0);
191
+ }
192
+ }
193
+
194
+ process.on('SIGINT', () => shutdown('SIGINT'));
195
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "haltest",
3
+ "version": "1.0.0",
4
+ "description": "CLI launcher for the HalTest browser automation & orchestration platform",
5
+ "main": "bin/haltest.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "haltest": "./bin/haltest.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src"
13
+ ],
14
+ "scripts": {
15
+ "start": "node bin/haltest.js",
16
+ "dev": "node bin/haltest.js",
17
+ "cli": "node src/index.js"
18
+ },
19
+ "dependencies": {
20
+ "axios": "^1.7.9",
21
+ "chalk": "^5.4.1",
22
+ "commander": "^13.1.0",
23
+ "dotenv": "^16.4.7",
24
+ "form-data": "^4.0.1",
25
+ "open": "^10.1.0",
26
+ "ora": "^8.2.0",
27
+ "socket.io-client": "^4.8.3",
28
+ "terminal-image": "^4.2.0"
29
+ },
30
+ "engines": {
31
+ "node": ">=20.0.0"
32
+ },
33
+ "private": false
34
+ }
package/src/index.js ADDED
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from "commander";
4
+ import chalk from "chalk";
5
+ import ora from "ora";
6
+ import axios from "axios";
7
+ import dotenv from "dotenv";
8
+ import { fileURLToPath } from "url";
9
+ import path from "path";
10
+ import { io } from "socket.io-client";
11
+ import fs from "fs/promises";
12
+
13
+ // Load .env
14
+ dotenv.config();
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ // Use baseUrl but fallback to localhost:2001
18
+ const API_URL = (
19
+ process.env.HALTEST_API_URL || "http://localhost:2001/api"
20
+ ).replace(/\/$/, "");
21
+ const SOCKET_URL = API_URL.replace("/api", "");
22
+
23
+ const program = new Command();
24
+
25
+ program
26
+ .name("haltest")
27
+ .description("CLI for HAL-TEST automation framework")
28
+ .version("1.0.0");
29
+
30
+ // --- STATUS COMMAND ---
31
+ program
32
+ .command("status")
33
+ .description("Check the status of the HAL-TEST server")
34
+ .action(async () => {
35
+ const spinner = ora("Connecting to HAL-TEST server...").start();
36
+ try {
37
+ const response = await axios.get(`${API_URL}/status`);
38
+ spinner.succeed(chalk.green("HAL-TEST Server is ONLINE"));
39
+ console.log(chalk.gray(`URL: ${API_URL}`));
40
+ console.log(
41
+ chalk.cyan(`Environment: ${response.data.environment || "N/A"}`),
42
+ );
43
+ } catch (error) {
44
+ spinner.fail(chalk.red("HAL-TEST Server is OFFLINE"));
45
+ console.error(chalk.gray(`Tried: ${API_URL}`));
46
+ console.error(chalk.red(error.message));
47
+ }
48
+ });
49
+
50
+ // --- LIST COMMAND ---
51
+ program
52
+ .command("list")
53
+ .description("List all available flows")
54
+ .action(async () => {
55
+ const spinner = ora("Fetching flows...").start();
56
+ try {
57
+ const projectsRes = await axios.get(`${API_URL}/projects`);
58
+ spinner.succeed(chalk.blue("Available Flows:"));
59
+
60
+ projectsRes.data.forEach((project) => {
61
+ console.log(
62
+ `\n📂 Project: ${chalk.bold(project.name)} ${chalk.gray(`(${project.id})`)}`,
63
+ );
64
+ project.flows?.forEach((flow) => {
65
+ console.log(
66
+ ` - ${chalk.cyan(flow.name)} ${chalk.gray(`ID: ${flow.id}`)}`,
67
+ );
68
+ });
69
+ });
70
+ } catch (error) {
71
+ spinner.fail(chalk.red("Failed to fetch flows"));
72
+ console.error(chalk.red(error.message));
73
+ }
74
+ });
75
+
76
+ // --- RUN COMMAND ---
77
+ program
78
+ .command("run <flowId>")
79
+ .description("Execute a flow and stream real-time logs")
80
+ .option("-p, --project <projectId>", "Project ID", "default-project-1")
81
+ .option(
82
+ "-h, --headed",
83
+ "Run with visible browser (overrides headless)",
84
+ false,
85
+ )
86
+ .option("-o, --output <path>", "Save execution report to file (JSON)")
87
+ .action(async (flowId, options) => {
88
+ const { project: projectId, headed, output } = options;
89
+
90
+ console.log(
91
+ chalk.bold.blue(`\n🚀 Initializing execution for flow: ${flowId}`),
92
+ );
93
+ const spinner = ora("Handshaking with server...").start();
94
+
95
+ // 1. Connect to Socket for logs
96
+ const socket = io(SOCKET_URL);
97
+ let currentRunId = null;
98
+
99
+ socket.on("connect", () => {
100
+ spinner.text = "Connected to socket, waiting for execution start...";
101
+ });
102
+
103
+ socket.on("execution-log", (data) => {
104
+ const time = chalk.gray(
105
+ `[${new Date(data.timestamp).toLocaleTimeString()}]`,
106
+ );
107
+ const node = data.nodeId
108
+ ? chalk.magenta(`[${data.nodeId.split("-")[0]}]`)
109
+ : "";
110
+
111
+ let msg = data.message;
112
+ if (data.type === "error") msg = chalk.red(msg);
113
+ else if (data.type === "success") msg = chalk.green(msg);
114
+ else if (data.type === "warning") msg = chalk.yellow(msg);
115
+
116
+ console.log(`${time} ${node} ${msg}`);
117
+ });
118
+
119
+ socket.on("execution-status", (data) => {
120
+ if (data.status === "failed") {
121
+ console.log(
122
+ chalk.red(
123
+ `\n❌ Node [${data.stepId}] failed: ${data.error || "Unknown error"}`,
124
+ ),
125
+ );
126
+ }
127
+ });
128
+
129
+ // AUTO-TERMINATE LOGIC
130
+ socket.on("flow-finished", async (data) => {
131
+ if (data.runId !== currentRunId) return;
132
+
133
+ console.log(chalk.bold("\n" + "=".repeat(40)));
134
+ console.log(
135
+ chalk.bold(`🏁 EXECUTION FINISHED: ${data.status.toUpperCase()}`),
136
+ );
137
+ console.log(chalk.bold("=".repeat(40) + "\n"));
138
+
139
+ const reportSpinner = ora("Generating final report...").start();
140
+
141
+ try {
142
+ // Fetch full report from API
143
+ const runRes = await axios.get(`${API_URL}/runs/${currentRunId}`);
144
+ const runData = runRes.data.data;
145
+ reportSpinner.succeed("Report generated");
146
+
147
+ // Print summary table-like info
148
+ console.log(chalk.cyan(`Run ID: `) + runData.id);
149
+ console.log(
150
+ chalk.cyan(`Duration: `) +
151
+ `${(runData.duration_ms / 1000).toFixed(2)}s`,
152
+ );
153
+ console.log(chalk.cyan(`Steps: `) + runData.steps?.length);
154
+
155
+ const errors =
156
+ runData.steps?.filter((s) => s.status === "failed") || [];
157
+ if (errors.length > 0) {
158
+ console.log(chalk.red(`Errors: `) + errors.length);
159
+ errors.forEach((e) => {
160
+ console.log(
161
+ chalk.red(` ! [${e.node_id}] ${e.node_type}: ${e.error}`),
162
+ );
163
+ });
164
+ } else {
165
+ console.log(chalk.green(`Errors: 0`));
166
+ }
167
+
168
+ // Save to file if requested
169
+ if (output) {
170
+ await fs.writeFile(output, JSON.stringify(runData, null, 2));
171
+ console.log(chalk.green(`\n💾 Report saved to: ${output}`));
172
+ }
173
+ } catch (err) {
174
+ reportSpinner.fail("Failed to fetch run details");
175
+ console.error(chalk.red(err.message));
176
+ }
177
+
178
+ socket.disconnect();
179
+ process.exit(data.status === "completed" ? 0 : 1);
180
+ });
181
+
182
+ try {
183
+ // 2. Trigger the run
184
+ const response = await axios.post(`${API_URL}/runs/start`, {
185
+ flowId,
186
+ projectId,
187
+ overrides: {
188
+ headless: !headed,
189
+ },
190
+ });
191
+
192
+ currentRunId = response.data.runId;
193
+ spinner.succeed(
194
+ chalk.green(`Execution started! Run ID: ${currentRunId}`),
195
+ );
196
+ console.log(chalk.gray("--- Streaming Logs ---\n"));
197
+ } catch (error) {
198
+ spinner.fail(chalk.red("Execution failed to start"));
199
+ socket.disconnect();
200
+ if (error.response) {
201
+ const backendError =
202
+ error.response.data.message ||
203
+ error.response.data.error ||
204
+ error.response.statusText;
205
+ console.error(chalk.red(`Error: ${backendError}`));
206
+ } else {
207
+ console.error(chalk.red(error.message));
208
+ }
209
+ process.exit(1);
210
+ }
211
+ });
212
+
213
+ // Show help if no command is provided
214
+ if (!process.argv.slice(2).length) {
215
+ program.outputHelp();
216
+ } else {
217
+ program.parse();
218
+ }