Skip to content

Commit 5d73585

Browse files
committed
chore: setup tests and ci via alchemy
1 parent fb3ba13 commit 5d73585

8 files changed

Lines changed: 361 additions & 7 deletions

File tree

‎.github/workflows/tests.yml‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Generated by Leaf Alchemy from alchemy.yml — edits will be overwritten on the next alchemy run.
2+
# Remove this header to take ownership of this file.
3+
name: Run Tests
4+
5+
on: ["push","pull_request"]
6+
7+
jobs:
8+
tests:
9+
runs-on: ${{ matrix.os }}
10+
strategy:
11+
fail-fast: true
12+
matrix:
13+
os: ["ubuntu-latest","macos-latest","windows-latest"]
14+
php: ["8.2","8.3","8.4"]
15+
16+
name: PHP ${{ matrix.php }} - ${{ matrix.os }}
17+
18+
steps:
19+
- name: Checkout
20+
uses: actions/checkout@v7
21+
22+
- name: Setup PHP
23+
uses: shivammathur/setup-php@v2
24+
with:
25+
php-version: ${{ matrix.php }}
26+
extensions: json, zip
27+
tools: composer:v2
28+
coverage: xdebug
29+
30+
- name: Install PHP dependencies
31+
run: composer update --no-interaction --no-progress
32+
33+
- name: Run Tests
34+
run: composer run test -- --flags=coverage

‎alchemy.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@ app:
22
- bin
33
- src
44

5+
tests:
6+
engine: pest
7+
parallel: false # tests spawn real cli processes; keep them sequential
8+
paths:
9+
- tests
10+
files:
11+
- '*.test.php'
12+
513
lint:
14+
ignore_tests: true
615
preset: PSR12
716
rules:
817
single_quote: true
@@ -34,6 +43,7 @@ lint:
3443
actions:
3544
run:
3645
- lint
46+
- tests
3747
os:
3848
- ubuntu-latest
3949
- macos-latest

‎composer.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@
4545
"prefer-stable": true,
4646
"require-dev": {
4747
"friendsofphp/php-cs-fixer": "*",
48-
"leafs/alchemy": "^5.0"
48+
"leafs/alchemy": "^5.0",
49+
"pestphp/pest": "*"
4950
},
5051
"scripts": {
5152
"test": "@php vendor/bin/alchemy test",

‎src/ContextCommand.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,27 +239,32 @@ protected function extractRoutesFromSource(string $content): array
239239
case 'group':
240240
case 'mount':
241241
$routes[] = "GROUP {$firstArg} — routes below this line sit under the prefix";
242+
242243
break;
243244

244245
case 'match':
245246
// first arg is the method list, the path comes next
246247
if (preg_match('/^\s*([\'"])((?:\\\\.|(?!\1).)*)\1/', $tail, $pathMatch)) {
247248
$routes[] = strtoupper($firstArg) . " {$pathMatch[2]} → " . $this->describeHandler(substr($tail, strlen($pathMatch[0])));
248249
}
250+
249251
break;
250252

251253
case 'view':
252254
$routes[] = "GET {$firstArg} → " . $this->describeSecondString($tail, 'view');
255+
253256
break;
254257

255258
case 'redirect':
256259
$routes[] = "GET {$firstArg} → " . $this->describeSecondString($tail, 'redirect');
260+
257261
break;
258262

259263
case 'resource':
260264
case 'apiresource':
261265
$label = $method === 'resource' ? 'RESOURCE' : 'API RESOURCE';
262266
$routes[] = "{$label} {$firstArg} → " . $this->describeSecondString($tail, 'controller');
267+
263268
break;
264269

265270
default:

‎src/DeployCommand.php‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,16 @@ protected function handle(): int
4444

4545
protected function deployToFly(): int
4646
{
47-
if (sprout()->process('fly version')->run(function () {}) !== 0) {
47+
if (sprout()->process('fly version')->run(function () {
48+
}) !== 0) {
4849
$this->writeln('<error>The fly CLI is not installed.</error>');
4950
$this->writeln('Install it from https://fly.io/docs/flyctl/install/ then run this command again.');
5051

5152
return 1;
5253
}
5354

54-
if (sprout()->process('fly auth whoami')->run(function () {}) !== 0) {
55+
if (sprout()->process('fly auth whoami')->run(function () {
56+
}) !== 0) {
5557
$this->writeln('<error>You are not logged in to Fly.io.</error>');
5658
$this->writeln('Run <info>fly auth login</info> then run this command again.');
5759

@@ -105,7 +107,8 @@ function ($content) use ($appRegion, $appName) {
105107
// to go stale when a teammate clones the repo
106108
$appExists = sprout()
107109
->process('fly status --app ' . escapeshellarg($appName))
108-
->run(function () {}) === 0;
110+
->run(function () {
111+
}) === 0;
109112

110113
$exitCode = $appExists
111114
? sprout()
@@ -175,7 +178,8 @@ protected function deployToRender(): int
175178
$this->writeln(' 2. Open <info>https://dashboard.render.com</info> → New → Blueprint');
176179
$this->writeln(' 3. Connect this repository. Render reads render.yaml and sets everything up');
177180

178-
if (sprout()->process('git remote get-url origin')->run(function () {}) !== 0) {
181+
if (sprout()->process('git remote get-url origin')->run(function () {
182+
}) !== 0) {
179183
$this->writeln('');
180184
$this->writeln('<comment>This project has no git remote yet. Create a repository first, e.g. on https://github.com/new</comment>');
181185
}
@@ -256,7 +260,7 @@ protected function serveFromAppRoot(string $appDir)
256260

257261
\Leaf\FS\File::write($nginxConfig, function ($content) {
258262
return str_replace(
259-
"root /var/www/html/public;",
263+
'root /var/www/html/public;',
260264
"root /var/www/html;\n\n" .
261265
" # the app root is the docroot here, so keep app internals\n" .
262266
" # out of the browser's reach\n" .
@@ -265,7 +269,7 @@ protected function serveFromAppRoot(string $appDir)
265269
" }\n\n" .
266270
" location ~ ^/(composer\.(json|lock)|package(-lock)?\.json)$ {\n" .
267271
" deny all;\n" .
268-
" }",
272+
' }',
269273
$content
270274
);
271275
});

‎tests/Pest.php‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<?php
2+
3+
/*
4+
|--------------------------------------------------------------------------
5+
| Leaf CLI test helpers
6+
|--------------------------------------------------------------------------
7+
|
8+
| Tests run inside a throwaway sandbox with a fake `composer` on PATH,
9+
| so commands build their real shell strings without ever touching the
10+
| network or a real project.
11+
|
12+
*/
13+
14+
function sandboxSetup(): void
15+
{
16+
$GLOBALS['__cliTestCwd'] = getcwd();
17+
18+
$base = PHP_OS_FAMILY === 'Windows' ? sys_get_temp_dir() : '/tmp';
19+
$sandbox = $base . '/leaf-cli-test-' . substr(str_shuffle('abcdefghijklmnopqrstuvwxyz'), 0, 10);
20+
21+
mkdir("$sandbox/bin", 0777, true);
22+
file_put_contents("$sandbox/bin/composer", "#!/bin/bash\necho \"COMPOSER CALLED WITH: \$@\"\n");
23+
chmod("$sandbox/bin/composer", 0755);
24+
chdir($sandbox);
25+
26+
$GLOBALS['__cliSandbox'] = $sandbox;
27+
}
28+
29+
function sandboxTeardown(): void
30+
{
31+
chdir($GLOBALS['__cliTestCwd']);
32+
removeDirRecursive($GLOBALS['__cliSandbox']);
33+
}
34+
35+
function removeDirRecursive(string $dir): void
36+
{
37+
if (!is_dir($dir)) {
38+
return;
39+
}
40+
41+
foreach (scandir($dir) as $item) {
42+
if ($item === '.' || $item === '..') {
43+
continue;
44+
}
45+
46+
$path = "$dir/$item";
47+
is_dir($path) && !is_link($path) ? removeDirRecursive($path) : unlink($path);
48+
}
49+
50+
rmdir($dir);
51+
}
52+
53+
/**
54+
* Run the real leaf binary inside the current sandbox, with the fake
55+
* composer first on PATH.
56+
* @return array{0: int, 1: string} exit code and combined output
57+
*/
58+
function leaf(string $args): array
59+
{
60+
$bin = dirname(__DIR__) . '/bin/leaf';
61+
$path = $GLOBALS['__cliSandbox'] . '/bin:' . getenv('PATH');
62+
63+
exec('PATH=' . escapeshellarg($path) . ' ' . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($bin) . " $args 2>&1", $output, $exit);
64+
65+
return [$exit, implode("\n", $output)];
66+
}
67+
68+
/**
69+
* Write a minimal fixture project into the sandbox for `leaf context`
70+
* to scan: routes with every registration shape the parser claims to
71+
* understand, a model, a schema file, and env keys.
72+
*/
73+
function fixtureProject(): string
74+
{
75+
$root = getcwd();
76+
77+
mkdir("$root/app/routes", 0777, true);
78+
mkdir("$root/app/models", 0777, true);
79+
mkdir("$root/app/database", 0777, true);
80+
81+
file_put_contents("$root/composer.json", json_encode([
82+
'name' => 'fixture/app',
83+
'require' => ['leafs/leaf' => '^5.0', 'leafs/db' => '^5.0'],
84+
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
85+
86+
file_put_contents("$root/app/routes/index.php", <<<'PHP'
87+
<?php
88+
89+
app()->get('/', function () {
90+
response()->json(['ok' => true]);
91+
});
92+
93+
app()->get('/users/{id}', 'UsersController@show');
94+
95+
app()->post('/users', ['middleware' => 'auth.required', 'UsersController@store']);
96+
97+
app()->group('/admin', function () {
98+
app()->get('/dashboard', 'AdminController@index');
99+
});
100+
101+
app()->resource('/posts', 'PostsController');
102+
PHP);
103+
104+
file_put_contents("$root/app/models/User.php", "<?php\n\nclass User extends Model\n{\n}\n");
105+
file_put_contents("$root/app/database/users.yml", "users:\n id: id\n name: string\n");
106+
file_put_contents("$root/.env.example", "APP_KEY=\nDB_HOST=localhost\nDB_PASSWORD=\n");
107+
file_put_contents("$root/.env", "APP_KEY=secret-value-should-never-print\nDB_HOST=localhost\nDB_PASSWORD=supersecret\n");
108+
109+
return $root;
110+
}

‎tests/commands.test.php‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
beforeEach(function () {
4+
sandboxSetup();
5+
});
6+
7+
afterEach(function () {
8+
sandboxTeardown();
9+
});
10+
11+
test('install does not pass --dev unless asked', function () {
12+
file_put_contents(getcwd() . '/composer.json', '{"name":"t/t"}');
13+
14+
[, $output] = leaf('install fs');
15+
16+
// the regression that shipped: a stale flag parser sent every
17+
// install to require-dev
18+
expect($output)->toContain('COMPOSER CALLED WITH: require leafs/fs')
19+
->and($output)->not->toContain('--dev');
20+
});
21+
22+
test('install passes --dev when asked', function () {
23+
file_put_contents(getcwd() . '/composer.json', '{"name":"t/t"}');
24+
25+
[, $output] = leaf('install fs --dev');
26+
27+
expect($output)->toContain('require leafs/fs')
28+
->and($output)->toContain('--dev');
29+
});
30+
31+
test('install expands bare names to leafs/ and @ to a version constraint', function () {
32+
file_put_contents(getcwd() . '/composer.json', '{"name":"t/t"}');
33+
34+
[, $output] = leaf('install fs@5.0 symfony/yaml');
35+
36+
expect($output)->toContain('leafs/fs:5.0')
37+
->and($output)->toContain('symfony/yaml');
38+
});
39+
40+
test('install passes --ansi exactly once', function () {
41+
file_put_contents(getcwd() . '/composer.json', '{"name":"t/t"}');
42+
43+
[, $output] = leaf('install fs');
44+
45+
$composerLine = '';
46+
47+
foreach (explode("\n", $output) as $line) {
48+
if (strpos($line, 'COMPOSER CALLED WITH') !== false) {
49+
$composerLine = $line;
50+
}
51+
}
52+
53+
expect(substr_count($composerLine, '--ansi'))->toBe(1);
54+
});
55+
56+
test('create builds an unpinned create-project for mvc apps', function () {
57+
[, $output] = leaf('create my-app --mvc');
58+
59+
// the launch-day contract: no version pin, latest stable resolves
60+
expect($output)->toContain('create-project leafs/mvc')
61+
->and($output)->not->toContain('5.0-alpha')
62+
->and($output)->not->toContain('dev-');
63+
});
64+
65+
test('create builds an unpinned create-project for console apps', function () {
66+
[, $output] = leaf('create my-tool --console');
67+
68+
expect($output)->toContain('create-project leafs/seedling')
69+
->and($output)->not->toContain('5.0-alpha');
70+
});
71+
72+
test('up refuses to run outside a project and mentions beta', function () {
73+
[$exit, $output] = leaf('up');
74+
75+
expect($exit)->toBe(1)
76+
->and($output)->toContain('beta')
77+
->and($output)->toContain('github.com/leafsphp/cli/issues')
78+
->and($output)->toContain('No composer.json found');
79+
});

0 commit comments

Comments
 (0)