Added unit tests and adjusted some syntax

pull/652/head
n0nag0n 3 days ago
parent dcb7ff9687
commit 6742770cc1

@ -1,20 +1,44 @@
<?php <?php
namespace app\commands;
declare(strict_types=1);
namespace flight\commands;
use Ahc\Cli\Input\Command; use Ahc\Cli\Input\Command;
/**
* @property-read ?string $credsFile
* @property-read ?string $baseDir
*/
class AiGenerateInstructionsCommand extends Command class AiGenerateInstructionsCommand extends Command
{ {
/**
* Constructor for the AiGenerateInstructionsCommand class.
*
* Initializes a new instance of the command.
*/
public function __construct() public function __construct()
{ {
parent::__construct('ai:generate-instructions', 'Generate project-specific AI coding instructions'); parent::__construct('ai:generate-instructions', 'Generate project-specific AI coding instructions');
$this->option('--creds-file', 'Path to .runway-creds.json file', null, '');
$this->option('--base-dir', 'Project base directory (for testing or custom use)', null, '');
} }
/**
* Executes the command logic for generating AI instructions.
*
* This method is called to perform the main functionality of the
* AiGenerateInstructionsCommand. It should contain the steps required
* to generate and output instructions using AI, based on the command's
* configuration and input.
*
* @return int
*/
public function execute() public function execute()
{ {
$io = $this->app()->io(); $io = $this->app()->io();
$baseDir = getcwd() . DIRECTORY_SEPARATOR; $baseDir = $this->baseDir ? rtrim($this->baseDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR : getcwd() . DIRECTORY_SEPARATOR;
$runwayCredsFile = $baseDir . '.runway-creds.json'; $runwayCredsFile = $this->credsFile ?: $baseDir . '.runway-creds.json';
// Check for runway creds // Check for runway creds
if (!file_exists($runwayCredsFile)) { if (!file_exists($runwayCredsFile)) {
@ -55,11 +79,13 @@ class AiGenerateInstructionsCommand extends Command
foreach ($userDetails as $k => $v) { foreach ($userDetails as $k => $v) {
$detailsText .= "$k: $v\n"; $detailsText .= "$k: $v\n";
} }
$prompt = "" . $prompt = <<<EOT
"You are an AI coding assistant. Update the following project instructions for this FlightPHP project based on the latest user answers. " . You are an AI coding assistant. Update the following project instructions for this FlightPHP project based on the latest user answers. Only output the new instructions, no extra commentary.
"Only output the new instructions, no extra commentary.\n" . User answers:
"User answers:\n$detailsText\n" . $detailsText
"Current instructions:\n$context\n"; Current instructions:
$context
EOT;
// Read LLM creds // Read LLM creds
$creds = json_decode(file_get_contents($runwayCredsFile), true); $creds = json_decode(file_get_contents($runwayCredsFile), true);
@ -82,20 +108,13 @@ class AiGenerateInstructionsCommand extends Command
]; ];
$jsonData = json_encode($data); $jsonData = json_encode($data);
// add info line that this may take a few minutes // add info line that this may take a few minutes
$io->info('Generating AI instructions, this may take a few minutes...', true); $io->info('Generating AI instructions, this may take a few minutes...', true);
$ch = curl_init($baseUrl . '/v1/chat/completions'); $result = $this->callLlmApi($baseUrl, $headers, $jsonData, $io);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); if ($result === false) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonData);
$result = curl_exec($ch);
if (curl_errno($ch)) {
$io->error('Failed to call LLM API: ' . curl_error($ch), true);
return 1; return 1;
} }
curl_close($ch);
$response = json_decode($result, true); $response = json_decode($result, true);
$instructions = $response['choices'][0]['message']['content'] ?? ''; $instructions = $response['choices'][0]['message']['content'] ?? '';
if (!$instructions) { if (!$instructions) {
@ -108,13 +127,42 @@ class AiGenerateInstructionsCommand extends Command
if (!is_dir($baseDir . '.github')) { if (!is_dir($baseDir . '.github')) {
mkdir($baseDir . '.github', 0755, true); mkdir($baseDir . '.github', 0755, true);
} }
if (!is_dir($baseDir . '.cursor/rules')) { if (!is_dir($baseDir . '.cursor/rules')) {
mkdir($baseDir . '.cursor/rules', 0755, true); mkdir($baseDir . '.cursor/rules', 0755, true);
} }
file_put_contents($baseDir . '.github/copilot-instructions.md', $instructions); file_put_contents($baseDir . '.github/copilot-instructions.md', $instructions);
file_put_contents($baseDir . '.cursor/rules/project-overview.mdc', $instructions); file_put_contents($baseDir . '.cursor/rules/project-overview.mdc', $instructions);
file_put_contents($baseDir . '.windsurfrules', $instructions); file_put_contents($baseDir . '.windsurfrules', $instructions);
$io->ok('AI instructions updated successfully.', true); $io->ok('AI instructions updated successfully.', true);
return 0; return 0;
} }
/**
* Make the LLM API call using curl
*
* @param string $baseUrl
* @param array<int,string> $headers
* @param string $jsonData
* @param object $io
*
* @return string|false
*
* @codeCoverageIgnore
*/
protected function callLlmApi($baseUrl, $headers, $jsonData, $io)
{
$ch = curl_init($baseUrl . '/v1/chat/completions');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonData);
$result = curl_exec($ch);
if (curl_errno($ch)) {
$io->error('Failed to call LLM API: ' . curl_error($ch), true);
curl_close($ch);
return false;
}
curl_close($ch);
return $result;
}
} }

@ -1,138 +1,137 @@
<?php <?php
namespace app\commands;
use Ahc\Cli\Input\Option; declare(strict_types=1);
use Ahc\Cli\Output\Writer;
use Ahc\Cli\IO\Interactor; namespace flight\commands;
use Ahc\Cli\Input\Command; use Ahc\Cli\Input\Command;
/**
* @property-read ?string $gitignoreFile
* @property-read ?string $credsFile
*/
class AiInitCommand extends Command class AiInitCommand extends Command
{ {
/**
* Constructor for the AiInitCommand class.
*
* Initializes the command instance and sets up any required dependencies.
*/
public function __construct() public function __construct()
{ {
parent::__construct('ai:init', 'Initialize LLM API credentials and settings'); parent::__construct('ai:init', 'Initialize LLM API credentials and settings');
$this
->option('--gitignore-file', 'Path to .gitignore file', null, '')
->option('--creds-file', 'Path to .runway-creds.json file', null, '');
} }
/** /**
* Executes the function * Executes the function
* *
* @return void * @return int
*/ */
public function execute() public function execute()
{ {
$io = $this->app()->io(); $io = $this->app()->io();
$io->info('Welcome to AI Init!', true); $io->info('Welcome to AI Init!', true);
// if runway creds already exist, prompt to overwrite $baseDir = getcwd() . DIRECTORY_SEPARATOR;
$baseDir = getcwd() . DIRECTORY_SEPARATOR; $runwayCredsFile = $this->credsFile ?: $baseDir . '.runway-creds.json';
$runwayCredsFile = $baseDir . '.runway-creds.json'; $gitignoreFile = $this->gitignoreFile ?: $baseDir . '.gitignore';
// make sure the .runway-creds.json file is not already present
if (file_exists($runwayCredsFile)) {
$io->error('.runway-creds.json file already exists. Please remove it before running this command.', true);
// prompt to overwrite
$overwrite = $io->confirm('Do you want to overwrite the existing .runway-creds.json file?', 'n');
if ($overwrite === false) {
$io->info('Exiting without changes.', true);
return 0;
}
}
// Prompt for API provider with validation
do {
$api = $io->prompt('Which LLM API do you want to use? (openai, grok, claude) [openai]', 'openai');
$api = strtolower(trim($api));
if (!in_array($api, ['openai', 'grok', 'claude'], true)) {
$io->error('Invalid API provider. Please enter one of: openai, grok, claude.', true);
$api = '';
}
} while (empty($api));
// Prompt for base URL with validation
do {
switch($api) {
case 'openai':
$defaultBaseUrl = 'https://api.openai.com';
break;
case 'grok':
$defaultBaseUrl = 'https://api.x.ai';
break;
case 'claude':
$defaultBaseUrl = 'https://api.anthropic.com';
break;
default:
$defaultBaseUrl = '';
}
$baseUrl = $io->prompt('Enter the base URL for the LLM API', $defaultBaseUrl);
$baseUrl = trim($baseUrl);
if (empty($baseUrl) || !filter_var($baseUrl, FILTER_VALIDATE_URL)) {
$io->error('Base URL cannot be empty and must be a valid URL.', true);
$baseUrl = '';
}
} while (empty($baseUrl));
// Validate API key input // make sure the .runway-creds.json file is not already present
do { if (file_exists($runwayCredsFile)) {
$apiKey = $io->prompt('Enter your API key for ' . $api); $io->error('.runway-creds.json file already exists. Please remove it before running this command.', true);
if (empty(trim($apiKey))) { // prompt to overwrite
$io->error('API key cannot be empty. Please enter a valid API key.', true); $overwrite = $io->confirm('Do you want to overwrite the existing .runway-creds.json file?', 'n');
if ($overwrite === false) {
$io->info('Exiting without changes.', true);
return 0;
} }
} while (empty(trim($apiKey))); }
// Prompt for API provider with validation
$allowedApis = [
'1' => 'openai',
'2' => 'grok',
'3' => 'claude'
];
$apiChoice = strtolower(trim($io->choice('Which LLM API do you want to use?', $allowedApis, '1')));
$api = $allowedApis[$apiChoice] ?? 'openai';
// Prompt for base URL with validation
switch ($api) {
case 'openai':
$defaultBaseUrl = 'https://api.openai.com';
break;
case 'grok':
$defaultBaseUrl = 'https://api.x.ai';
break;
case 'claude':
$defaultBaseUrl = 'https://api.anthropic.com';
break;
}
$baseUrl = trim($io->prompt('Enter the base URL for the LLM API', $defaultBaseUrl));
if (empty($baseUrl) || !filter_var($baseUrl, FILTER_VALIDATE_URL)) {
$io->error('Base URL cannot be empty and must be a valid URL.', true);
return 1;
}
// Validate API key input
$apiKey = trim($io->prompt('Enter your API key for ' . $api));
if (empty($apiKey)) {
$io->error('API key cannot be empty. Please enter a valid API key.', true);
return 1;
}
// Validate model input // Validate model input
do { switch ($api) {
switch($api) { case 'openai':
case 'openai': $defaultModel = 'gpt-4o';
$defaultModel = 'gpt-4o'; break;
break; case 'grok':
case 'grok': $defaultModel = 'grok-3-beta';
$defaultModel = 'grok-3-beta'; break;
break; case 'claude':
case 'claude': $defaultModel = 'claude-3-opus';
$defaultModel = 'claude-3-opus'; break;
break; }
default: $model = trim($io->prompt('Enter the model name you want to use (e.g. gpt-4, claude-3-opus, etc)', $defaultModel));
$defaultModel = '';
}
$model = $io->prompt('Enter the model name you want to use (e.g. gpt-4, claude-3-opus, etc)', $defaultModel);
if (empty(trim($model))) {
$io->error('Model name cannot be empty. Please enter a valid model name.', true);
}
} while (empty(trim($model)));
$creds = [ $creds = [
'provider' => $api, 'provider' => $api,
'api_key' => $apiKey, 'api_key' => $apiKey,
'model' => $model, 'model' => $model,
'base_url' => $baseUrl, 'base_url' => $baseUrl,
]; ];
$json = json_encode($creds, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); $json = json_encode($creds, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$file = $runwayCredsFile; $file = $runwayCredsFile;
if (file_put_contents($file, $json) === false) { file_put_contents($file, $json);
$io->error('Failed to write credentials to ' . $file, true);
return 1; // change permissions to 600
} chmod($file, 0600);
$io->ok('Credentials saved to ' . $file, true); $io->ok('Credentials saved to ' . $file, true);
// run a check to make sure that the creds file is in the .gitignore file // run a check to make sure that the creds file is in the .gitignore file
$gitignoreFile = $baseDir . '.gitignore'; // use $gitignoreFile instead of hardcoded path
if (!file_exists($gitignoreFile)) { if (!file_exists($gitignoreFile)) {
// create the .gitignore file if it doesn't exist // create the .gitignore file if it doesn't exist
file_put_contents($gitignoreFile, ".runway-creds.json\n"); file_put_contents($gitignoreFile, basename($runwayCredsFile) . "\n");
$io->info('.gitignore file created and .runway-creds.json added to it.', true); $io->info(basename($gitignoreFile) . ' file created and ' . basename($runwayCredsFile) . ' added to it.', true);
} else { } else {
// check if the .runway-creds.json file is already in the .gitignore file // check if the creds file is already in the .gitignore file
$gitignoreContents = file_get_contents($gitignoreFile); $gitignoreContents = file_get_contents($gitignoreFile);
if (strpos($gitignoreContents, '.runway-creds.json') === false) { if (strpos($gitignoreContents, basename($runwayCredsFile)) === false) {
// add the .runway-creds.json file to the .gitignore file // add the creds file to the .gitignore file
file_put_contents($gitignoreFile, "\n.runway-creds.json\n", FILE_APPEND); file_put_contents($gitignoreFile, "\n" . basename($runwayCredsFile) . "\n", FILE_APPEND);
$io->info('.runway-creds.json added to .gitignore file.', true); $io->info(basename($runwayCredsFile) . ' added to ' . basename($gitignoreFile) . ' file.', true);
} else { } else {
$io->info('.runway-creds.json is already in the .gitignore file.', true); $io->info(basename($runwayCredsFile) . ' is already in the ' . basename($gitignoreFile) . ' file.', true);
} }
} }
return 0; return 0;
} }

@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace tests\commands;
use Ahc\Cli\Application;
use Ahc\Cli\IO\Interactor;
use flight\commands\AiGenerateInstructionsCommand;
use PHPUnit\Framework\TestCase;
class AiGenerateInstructionsCommandTest extends TestCase
{
protected static $in;
protected static $ou;
protected $baseDir;
protected $runwayCredsFile;
public function setUp(): void
{
self::$in = __DIR__ . DIRECTORY_SEPARATOR . 'input.test' . uniqid('', true) . '.txt';
self::$ou = __DIR__ . DIRECTORY_SEPARATOR . 'output.test' . uniqid('', true) . '.txt';
file_put_contents(self::$in, '');
file_put_contents(self::$ou, '');
$this->baseDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'flightphp-test-basedir-' . uniqid('', true) . DIRECTORY_SEPARATOR;
if (!is_dir($this->baseDir)) {
mkdir($this->baseDir, 0777, true);
}
$this->runwayCredsFile = $this->baseDir . 'dummy-creds.json';
if (file_exists($this->runwayCredsFile)) {
unlink($this->runwayCredsFile);
}
@unlink($this->baseDir . '.github/copilot-instructions.md');
@unlink($this->baseDir . '.cursor/rules/project-overview.mdc');
@unlink($this->baseDir . '.windsurfrules');
@rmdir($this->baseDir . '.github');
@rmdir($this->baseDir . '.cursor/rules');
@rmdir($this->baseDir . '.cursor');
}
public function tearDown(): void
{
if (file_exists(self::$in)) {
unlink(self::$in);
}
if (file_exists(self::$ou)) {
unlink(self::$ou);
}
if (file_exists($this->runwayCredsFile)) {
unlink($this->runwayCredsFile);
}
@unlink($this->baseDir . '.github/copilot-instructions.md');
@unlink($this->baseDir . '.cursor/rules/project-overview.mdc');
@unlink($this->baseDir . '.windsurfrules');
@rmdir($this->baseDir . '.github');
@rmdir($this->baseDir . '.cursor/rules');
@rmdir($this->baseDir . '.cursor');
if (is_dir($this->baseDir . '.cursor/rules')) {
@rmdir($this->baseDir . '.cursor/rules');
}
if (is_dir($this->baseDir . '.cursor')) {
@rmdir($this->baseDir . '.cursor');
}
if (is_dir($this->baseDir . '.github')) {
@rmdir($this->baseDir . '.github');
}
if (is_dir($this->baseDir)) {
@rmdir($this->baseDir);
}
}
protected function newApp($command): Application
{
$app = new Application('test', '0.0.1', function ($exitCode) {
return $exitCode;
});
$app->io(new Interactor(self::$in, self::$ou));
$app->add($command);
return $app;
}
protected function setInput(array $lines): void
{
file_put_contents(self::$in, implode("\n", $lines) . "\n");
}
public function testFailsIfCredsFileMissing()
{
$this->setInput([
'desc', 'none', 'latte', 'y', 'y', 'none', 'Docker', '1', 'n', 'no'
]);
$cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
->onlyMethods(['callLlmApi'])
->getMock();
$app = $this->newApp($cmd);
$result = $app->handle([
'runway', 'ai:generate-instructions',
'--creds-file=' . $this->runwayCredsFile,
'--base-dir=' . $this->baseDir
]);
$this->assertSame(1, $result);
$this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md');
}
public function testWritesInstructionsToFiles()
{
$creds = [
'api_key' => 'key',
'model' => 'gpt-4o',
'base_url' => 'https://api.openai.com',
];
file_put_contents($this->runwayCredsFile, json_encode($creds));
$this->setInput([
'desc', 'mysql', 'latte', 'y', 'y', 'flight/lib', 'Docker', '2', 'y', 'context info'
]);
$mockInstructions = "# Project Instructions\n\nUse MySQL, Latte, Docker.";
$cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
->onlyMethods(['callLlmApi'])
->getMock();
$cmd->expects($this->once())
->method('callLlmApi')
->willReturn(json_encode([
'choices' => [
['message' => ['content' => $mockInstructions]]
]
]));
$app = $this->newApp($cmd);
$result = $app->handle([
'runway', 'ai:generate-instructions',
'--creds-file=' . $this->runwayCredsFile,
'--base-dir=' . $this->baseDir
]);
$this->assertSame(0, $result);
$this->assertFileExists($this->baseDir . '.github/copilot-instructions.md');
$this->assertFileExists($this->baseDir . '.cursor/rules/project-overview.mdc');
$this->assertFileExists($this->baseDir . '.windsurfrules');
$this->assertStringContainsString('MySQL', file_get_contents($this->baseDir . '.github/copilot-instructions.md'));
$this->assertStringContainsString('MySQL', file_get_contents($this->baseDir . '.cursor/rules/project-overview.mdc'));
$this->assertStringContainsString('MySQL', file_get_contents($this->baseDir . '.windsurfrules'));
}
public function testNoInstructionsReturnedFromLlm()
{
$creds = [
'api_key' => 'key',
'model' => 'gpt-4o',
'base_url' => 'https://api.openai.com',
];
file_put_contents($this->runwayCredsFile, json_encode($creds));
$this->setInput([
'desc', 'mysql', 'latte', 'y', 'y', 'flight/lib', 'Docker', '2', 'y', 'context info'
]);
$cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
->onlyMethods(['callLlmApi'])
->getMock();
$cmd->expects($this->once())
->method('callLlmApi')
->willReturn(json_encode([
'choices' => [
['message' => ['content' => '']]
]
]));
$app = $this->newApp($cmd);
$result = $app->handle([
'runway', 'ai:generate-instructions',
'--creds-file=' . $this->runwayCredsFile,
'--base-dir=' . $this->baseDir
]);
$this->assertSame(1, $result);
$this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md');
}
public function testLlmApiCallFails()
{
$creds = [
'api_key' => 'key',
'model' => 'gpt-4o',
'base_url' => 'https://api.openai.com',
];
file_put_contents($this->runwayCredsFile, json_encode($creds));
$this->setInput([
'desc', 'mysql', 'latte', 'y', 'y', 'flight/lib', 'Docker', '2', 'y', 'context info'
]);
$cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
->onlyMethods(['callLlmApi'])
->getMock();
$cmd->expects($this->once())
->method('callLlmApi')
->willReturn(false);
$app = $this->newApp($cmd);
$result = $app->handle([
'runway', 'ai:generate-instructions',
'--creds-file=' . $this->runwayCredsFile,
'--base-dir=' . $this->baseDir
]);
$this->assertSame(1, $result);
$this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md');
}
}

@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace tests\commands;
use Ahc\Cli\Application;
use Ahc\Cli\IO\Interactor;
use flight\commands\AiInitCommand;
use PHPUnit\Framework\TestCase;
class AiInitCommandTest extends TestCase
{
protected static $in;
protected static $ou;
protected $baseDir;
protected $runwayCredsFile;
protected $gitignoreFile;
public function setUp(): void
{
self::$in = __DIR__ . DIRECTORY_SEPARATOR . 'input.test' . uniqid('', true) . '.txt';
self::$ou = __DIR__ . DIRECTORY_SEPARATOR . 'output.test' . uniqid('', true) . '.txt';
file_put_contents(self::$in, '');
file_put_contents(self::$ou, '');
$this->baseDir = getcwd() . DIRECTORY_SEPARATOR;
$this->runwayCredsFile = __DIR__ . DIRECTORY_SEPARATOR . 'dummy-creds-' . uniqid('', true) . '.json';
$this->gitignoreFile = __DIR__ . DIRECTORY_SEPARATOR . 'dummy-gitignore-' . uniqid('', true);
if (file_exists($this->runwayCredsFile)) {
unlink($this->runwayCredsFile);
}
if (file_exists($this->gitignoreFile)) {
unlink($this->gitignoreFile);
}
}
public function tearDown(): void
{
if (file_exists(self::$in)) {
unlink(self::$in);
}
if (file_exists(self::$ou)) {
unlink(self::$ou);
}
if (file_exists($this->runwayCredsFile)) {
if (is_dir($this->runwayCredsFile)) {
rmdir($this->runwayCredsFile);
} else {
unlink($this->runwayCredsFile);
}
}
if (file_exists($this->gitignoreFile)) {
unlink($this->gitignoreFile);
}
}
protected function newApp(): Application
{
$app = new Application('test', '0.0.1', function ($exitCode) {
return $exitCode;
});
$app->io(new Interactor(self::$in, self::$ou));
return $app;
}
protected function setInput(array $lines): void
{
file_put_contents(self::$in, implode("\n", $lines) . "\n");
}
public function testInitCreatesCredsAndGitignore()
{
$this->setInput([
'1', // provider
'', // accept default base url
'test-key', // api key
'', // accept default model
]);
$app = $this->newApp();
$app->add(new AiInitCommand());
$result = $app->handle([
'runway', 'ai:init',
'--creds-file=' . $this->runwayCredsFile,
'--gitignore-file=' . $this->gitignoreFile
]);
$this->assertSame(0, $result);
$this->assertFileExists($this->runwayCredsFile);
$creds = json_decode(file_get_contents($this->runwayCredsFile), true);
$this->assertSame('openai', $creds['provider']);
$this->assertSame('test-key', $creds['api_key']);
$this->assertSame('gpt-4o', $creds['model']);
$this->assertSame('https://api.openai.com', $creds['base_url']);
$this->assertFileExists($this->gitignoreFile);
$this->assertStringContainsString(basename($this->runwayCredsFile), file_get_contents($this->gitignoreFile));
}
public function testInitWithExistingCredsNoOverwrite()
{
file_put_contents($this->runwayCredsFile, '{}');
$this->setInput([
'n', // do not overwrite
]);
$app = $this->newApp();
$app->add(new AiInitCommand());
$result = $app->handle([
'runway', 'ai:init',
'--creds-file=' . $this->runwayCredsFile,
'--gitignore-file=' . $this->gitignoreFile
]);
$this->assertSame(0, $result);
$this->assertSame('{}', file_get_contents($this->runwayCredsFile));
}
public function testInitWithExistingCredsOverwrite()
{
file_put_contents($this->runwayCredsFile, '{}');
$this->setInput([
'y', // overwrite
'2', // provider
'', // accept default base url
'grok-key', // api key
'', // accept default model
]);
$app = $this->newApp();
$app->add(new AiInitCommand());
$result = $app->handle([
'runway', 'ai:init',
'--creds-file=' . $this->runwayCredsFile,
'--gitignore-file=' . $this->gitignoreFile
]);
$this->assertSame(0, $result);
$creds = json_decode(file_get_contents($this->runwayCredsFile), true);
$this->assertSame('grok', $creds['provider']);
$this->assertSame('grok-key', $creds['api_key']);
$this->assertSame('grok-3-beta', $creds['model']);
$this->assertSame('https://api.x.ai', $creds['base_url']);
}
public function testEmptyApiKeyPromptsAgain()
{
$this->setInput([
'1',
'', // accept default base url
'', // empty api key, should error and exit
]);
$app = $this->newApp();
$app->add(new AiInitCommand());
$result = $app->handle([
'runway', 'ai:init',
'--creds-file=' . $this->runwayCredsFile,
'--gitignore-file=' . $this->gitignoreFile
]);
$this->assertSame(1, $result);
$this->assertFileDoesNotExist($this->runwayCredsFile);
}
public function testEmptyModelPrompts()
{
$this->setInput([
'1',
'',
'key',
'', // accept default model (should use default)
]);
$app = $this->newApp();
$app->add(new AiInitCommand());
$result = $app->handle([
'runway', 'ai:init',
'--creds-file=' . $this->runwayCredsFile,
'--gitignore-file=' . $this->gitignoreFile
]);
$this->assertSame(0, $result);
$creds = json_decode(file_get_contents($this->runwayCredsFile), true);
$this->assertSame('gpt-4o', $creds['model']);
}
public function testGitignoreAlreadyHasCreds()
{
file_put_contents($this->gitignoreFile, basename($this->runwayCredsFile) . "\n");
$this->setInput([
'1',
'',
'key',
'',
]);
$app = $this->newApp();
$app->add(new AiInitCommand());
$result = $app->handle([
'runway', 'ai:init',
'--creds-file=' . $this->runwayCredsFile,
'--gitignore-file=' . $this->gitignoreFile
]);
$this->assertSame(0, $result);
$this->assertFileExists($this->gitignoreFile);
$lines = file($this->gitignoreFile, FILE_IGNORE_NEW_LINES);
$this->assertContains(basename($this->runwayCredsFile), $lines);
$this->assertCount(1, array_filter($lines, function ($l) {
return trim($l) === basename($this->runwayCredsFile);
}));
}
public function testInitWithClaudeProvider()
{
$this->setInput([
'3', // provider (claude)
'', // accept default base url
'claude-key', // api key
'', // accept default model
]);
$app = $this->newApp();
$app->add(new AiInitCommand());
$result = $app->handle([
'runway', 'ai:init',
'--creds-file=' . $this->runwayCredsFile,
'--gitignore-file=' . $this->gitignoreFile
]);
$this->assertSame(0, $result);
$creds = json_decode(file_get_contents($this->runwayCredsFile), true);
$this->assertSame('claude', $creds['provider']);
$this->assertSame('claude-key', $creds['api_key']);
$this->assertSame('claude-3-opus', $creds['model']);
$this->assertSame('https://api.anthropic.com', $creds['base_url']);
}
public function testAddsCredsFileToExistingGitignoreIfMissing()
{
// .gitignore exists but does not contain creds file
file_put_contents($this->gitignoreFile, "vendor\nnode_modules\n.DS_Store\n");
$this->setInput([
'1', // provider
'', // accept default base url
'test-key', // api key
'', // accept default model
]);
$app = $this->newApp();
$app->add(new AiInitCommand());
$result = $app->handle([
'runway', 'ai:init',
'--creds-file=' . $this->runwayCredsFile,
'--gitignore-file=' . $this->gitignoreFile
]);
$this->assertSame(0, $result);
$lines = file($this->gitignoreFile, FILE_IGNORE_NEW_LINES);
$this->assertContains(basename($this->runwayCredsFile), $lines);
$this->assertCount(1, array_filter($lines, function ($l) {
return trim($l) === basename($this->runwayCredsFile);
}));
}
public function testInvalidBaseUrlFails()
{
$this->setInput([
'1', // provider
'not-a-valid-url', // invalid base url
]);
$app = $this->newApp();
$app->add(new AiInitCommand());
$result = $app->handle([
'runway', 'ai:init',
'--creds-file=' . $this->runwayCredsFile,
'--gitignore-file=' . $this->gitignoreFile
]);
$this->assertSame(1, $result);
$this->assertFileDoesNotExist($this->runwayCredsFile);
}
}

@ -98,9 +98,9 @@ Flight::group('', function () {
Flight::render('template.phtml', ['name' => $name]); Flight::render('template.phtml', ['name' => $name]);
}); });
Flight::route('/template-data/@data', function ($data) { Flight::route('/template-data/@data', function ($data) {
Flight::render('template.phtml', ['data' => $data]); Flight::render('template.phtml', ['data' => $data]);
}); });
// Test 8: Throw an error // Test 8: Throw an error
Flight::route('/error', function () { Flight::route('/error', function () {

Loading…
Cancel
Save