Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions .harness.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,18 @@
"laravel/prompts"
],
"allow_commands": [
{ "command": "new", "reason": "the installer is named for the tool, like laravel new; renaming it would change the public API of the CLI" },
{ "command": "self-update", "reason": "the command is named for the tool, like composer self-update; renaming it would change the public API of the CLI" },
{ "command": "version", "reason": "the command is named for the tool; renaming it would change the public API of the CLI" }
{
"command": "new",
"reason": "the installer is named for the tool, like laravel new; renaming it would change the public API of the CLI"
},
{
"command": "self-update",
"reason": "the command is named for the tool, like composer self-update; renaming it would change the public API of the CLI"
},
{
"command": "version",
"reason": "the command is named for the tool; renaming it would change the public API of the CLI"
}
],
"ratchet": {
"phpstan_level": 8,
Expand Down
169 changes: 169 additions & 0 deletions tests/Support/FileOverlay.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<?php

declare(strict_types=1);

namespace Pollora\Cli\Tests\Support;

/**
* Lets a test decide what a few well-known files look like to the code under
* test, without touching the real file system.
*
* It replaces the "file" stream wrapper for the duration of a test: paths
* ending with a registered suffix are served from memory (or hidden, or made
* unreadable); every other path falls through to the native wrapper.
* Always call uninstall() when the test is over.
*/
final class FileOverlay
{
/** @var resource|null */
public $context;

/** @var array<string, string|false|null> suffix => content, false = unreadable, null = absent */
private static array $files = [];

/** @var resource|null */
private $handle;

private string $data = '';

private int $position = 0;

private bool $virtual = false;

/**
* @param array<string, string|false|null> $files suffix => content; false = exists but cannot be read, null = does not exist
*/
public static function install(array $files): void
{
self::$files = $files;
stream_wrapper_unregister('file');
stream_wrapper_register('file', self::class);
}

public static function uninstall(): void
{
self::$files = [];
// Safe to call twice: the native wrapper is simply restored.
@stream_wrapper_unregister('file');
stream_wrapper_restore('file');
}

/**
* @return array{0: bool, 1: string|false|null}
*/
private function match(string $path): array
{
foreach (self::$files as $suffix => $content) {
if (str_ends_with($path, $suffix)) {
return [true, $content];
}
}

return [false, null];
}

private function native(callable $call): mixed
{
stream_wrapper_restore('file');
// A missing file is an ordinary answer here, not a warning.
set_error_handler(static fn (): bool => true);

try {
return $call();
} finally {
restore_error_handler();
stream_wrapper_unregister('file');
stream_wrapper_register('file', self::class);
}
}

public function stream_open(string $path, string $mode): bool
{
[$matched, $content] = $this->match($path);

if ($matched) {
if (! is_string($content)) {
return false;
}

$this->virtual = true;
$this->data = $content;

return true;
}

$handle = $this->native(static fn (): mixed => fopen($path, $mode));

if (! is_resource($handle)) {
return false;
}

$this->handle = $handle;

return true;
}

public function stream_read(int $count): string|false
{
if ($this->virtual) {
$chunk = substr($this->data, $this->position, $count);
$this->position += strlen($chunk);

return $chunk;
}

return fread($this->handle, $count); // @phpstan-ignore argument.type
}

public function stream_write(string $data): int
{
return $this->virtual ? 0 : (int) fwrite($this->handle, $data); // @phpstan-ignore argument.type
}

public function stream_eof(): bool
{
return $this->virtual ? $this->position >= strlen($this->data) : feof($this->handle); // @phpstan-ignore argument.type
}

public function stream_close(): void
{
if (is_resource($this->handle)) {
fclose($this->handle);
}
}

/**
* @return array<string, int>|false
*/
public function stream_stat(): array|false
{
if ($this->virtual) {
return ['mode' => 0100644, 'size' => strlen($this->data)];
}

return fstat($this->handle); // @phpstan-ignore argument.type
}

public function stream_set_option(): bool
{
return false;
}

/**
* @return array<string, int>|false
*/
public function url_stat(string $path): array|false
{
[$matched, $content] = $this->match($path);

if ($matched) {
if ($content === null) {
return false;
}

return ['mode' => 0100644, 'size' => is_string($content) ? strlen($content) : 0];
}

return $this->native(static fn (): array|false => stat($path));
}
}
155 changes: 155 additions & 0 deletions tests/Support/Sandbox.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php

declare(strict_types=1);

namespace Pollora\Cli\Tests\Support;

/**
* A throw-away working area for tests that run external programs.
*
* It owns a temporary directory holding a "work" folder (the current directory
* during the test) and a "bin" folder of fake executables placed first in PATH,
* so no real composer, ddev, git, php or network call is ever made.
*/
final class Sandbox
{
public readonly string $root;

public readonly string $work;

public readonly string $bin;

public readonly string $log;

private readonly string|false $originalCwd;

/** @var array<string, string|false> */
private array $originalEnv = [];

/** @var array<string, mixed> */
private array $originalServer = [];

public function __construct()
{
$this->root = realpath(sys_get_temp_dir()).'/pollora-cli-'.bin2hex(random_bytes(6));
$this->work = $this->root.'/work';
$this->bin = $this->root.'/bin';
$this->log = $this->root.'/calls.log';

mkdir($this->work, 0755, true);
mkdir($this->bin, 0755, true);
touch($this->log);

$this->originalCwd = getcwd();
$this->env('FAKE_LOG', $this->log);
$this->env('FAKE_FAIL_ON', '');
$this->env('FAKE_NO_INSTALL', '');
$this->env('HTTPS_PROXY', 'http://127.0.0.1:1');
$this->env('HOME', $this->root.'/home');
$this->env('PATH', $this->bin.':'.getenv('PATH'));

chdir($this->work);
}

public function env(string $name, string $value): void
{
if (! array_key_exists($name, $this->originalEnv)) {
$this->originalEnv[$name] = getenv($name);
}

if (! array_key_exists($name, $this->originalServer)) {
$this->originalServer[$name] = $_SERVER[$name] ?? null;
}

// Symfony Process only passes on the variables that $_SERVER also knows.
putenv($name.'='.$value);
$_SERVER[$name] = $value;
}

/**
* Restrict PATH to the fake bin folder plus the system essentials.
*/
public function onlyFakePath(): void
{
$this->env('PATH', $this->bin.':/usr/bin:/bin');
}

public function script(string $name, string $body): void
{
$path = $this->bin.'/'.$name;
file_put_contents($path, "#!/bin/sh\n".$body."\n");
chmod($path, 0755);
}

/**
* Fake `composer`, `ddev`, `php` and `git` that record their arguments.
*/
public function fakeTools(): void
{
$skeleton = <<<'SH'
mkdir -p "$1/vendor"
touch "$1/artisan"
echo '{}' > "$1/composer.json"
[ -n "$FAKE_NO_ENV" ] || printf 'APP_KEY=\n#DB_HOST=127.0.0.1\n# DB_PASSWORD=\nAPP_URL=http://localhost\n' > "$1/.env.example"
SH;

$guard = <<<'SH'
echo "$(basename "$0") $*" >> "$FAKE_LOG"
if [ -n "$FAKE_FAIL_ON" ]; then
case "$*" in *"$FAKE_FAIL_ON"*) exit 1;; esac
fi
SH;

$this->script('composer', $guard."\n".'if [ "$1" = create-project ] && [ -z "$FAKE_NO_INSTALL" ]; then set -- "$3"; '.$skeleton."\nfi\nexit 0");
$this->script('ddev', $guard."\n".'if [ "$1" = composer ] && [ "$2" = create-project ] && [ -z "$FAKE_NO_INSTALL" ]; then set -- "."; '.$skeleton."\nfi\nexit 0");
$this->script('php', $guard."\nexit 0");
$this->script('git', $guard."\nexit 0");
}

public function calls(): string
{
return (string) file_get_contents($this->log);
}

public function restore(): void
{
if ($this->originalCwd !== false) {
chdir($this->originalCwd);
}

foreach ($this->originalEnv as $name => $value) {
putenv($value === false ? $name : $name.'='.$value);
}

foreach ($this->originalServer as $name => $value) {
if ($value === null) {
unset($_SERVER[$name]);
} else {
$_SERVER[$name] = $value;
}
}

self::removeTree($this->root);
}

public static function removeTree(string $path): void
{
if (is_link($path) || is_file($path)) {
unlink($path);

return;
}

if (! is_dir($path)) {
return;
}

foreach (scandir($path) ?: [] as $entry) {
if ($entry !== '.' && $entry !== '..') {
self::removeTree($path.'/'.$entry);
}
}

rmdir($path);
}
}
Loading
Loading