import { spawn, ChildProcess } from 'child_process';
import path from 'path';
import fs from 'fs/promises';
import { ParserGodot4, ErrorObject } from './parser_godot4';
export interface TestResult {
pass: boolean;
log: string;
first_error?: ErrorObject;
}
export interface GameResult {
pid: number;
started: boolean;
}
export class GodotShell {
private projectRoot: string;
private parser: ParserGodot4;
private sentinelDir: string;
private errorCallback?: (error: ErrorObject) => void;
constructor(projectRoot: string) {
this.projectRoot = projectRoot;
this.parser = new ParserGodot4();
this.sentinelDir = path.join(process.env.HOME || '', '.sentinel');
}
public onError(callback: (error: ErrorObject) => void): void {
this.errorCallback = callback;
}
public async runTests(): Promise<TestResult> {
const logPath = path.join(this.sentinelDir, 'last_run.log');
try {
// Run gdUnit4 headless tests
const output = await this.executeCommand([
'godot',
'--headless',
'-s', 'res://addons/gdUnit4/bin/gdUnit4.gd',
'-gexit',
'--verbose'
], this.projectRoot);
// Write log to file
await fs.writeFile(logPath, output);
// Parse first error
const firstError = this.parser.parseFirstError(output);
const pass = !firstError && !output.toLowerCase().includes('failed');
if (firstError && this.errorCallback) {
this.errorCallback(firstError);
}
return {
pass,
log: output,
first_error: firstError
};
} catch (error) {
const errorLog = error instanceof Error ? error.message : String(error);
await fs.writeFile(logPath, errorLog);
return {
pass: false,
log: errorLog,
first_error: this.parser.parseFirstError(errorLog)
};
}
}
public async runGame(opts?: { scene?: string }): Promise<GameResult> {
try {
const args = ['godot'];
if (opts?.scene) {
args.push('--scene', opts.scene);
}
const child = spawn(args[0], args.slice(1), {
cwd: this.projectRoot,
detached: true,
stdio: ['ignore', 'pipe', 'pipe']
});
if (child.stdout && child.stderr) {
// Monitor output for errors
child.stdout.on('data', (data) => {
this.monitorOutput(data.toString());
});
child.stderr.on('data', (data) => {
this.monitorOutput(data.toString());
});
}
// Give the process a moment to start
await new Promise(resolve => setTimeout(resolve, 1000));
return {
pid: child.pid || 0,
started: !child.killed
};
} catch (error) {
return {
pid: 0,
started: false
};
}
}
private async executeCommand(args: string[], cwd: string): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(args[0], args.slice(1), { cwd });
let stdout = '';
let stderr = '';
if (child.stdout) {
child.stdout.on('data', (data) => {
stdout += data.toString();
});
}
if (child.stderr) {
child.stderr.on('data', (data) => {
stderr += data.toString();
});
}
child.on('close', (code) => {
const output = stdout + stderr;
if (code === 0 || output.includes('All tests passed')) {
resolve(output);
} else {
reject(new Error(output || `Process exited with code ${code}`));
}
});
child.on('error', reject);
});
}
private monitorOutput(output: string): void {
const error = this.parser.parseFirstError(output);
if (error && this.errorCallback) {
this.errorCallback(error);
}
}
public async getLastLog(): Promise<string> {
try {
const logPath = path.join(this.sentinelDir, 'last_run.log');
return await fs.readFile(logPath, 'utf8');
} catch (error) {
return '';
}
}
}