diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml new file mode 100644 index 000000000..820bb0159 --- /dev/null +++ b/.github/workflows/postman.yml @@ -0,0 +1,25 @@ +name: API Contract (Postman) + +# Boots a full Fleetbase stack (published image) and runs the FleetOps Postman +# collection against the live API. Delegates to the reusable workflow in +# fleetbase/fleetbase. Requires org secrets POSTMAN_API_KEY + _GITHUB_AUTH_TOKEN +# (inherited); no-ops until POSTMAN_API_KEY is set. +# TODO: change @dev-v0.7.53 to @main once that branch is merged. + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + contract: + uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@dev-v0.7.53 + with: + collections: "Fleetbase API" + build-from-source: false + secrets: inherit diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index 964eeb394..a47be23db 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -46,9 +46,24 @@ jobs: - name: Generate Coverage Baseline run: XDEBUG_MODE=coverage composer coverage:baseline + # Asserts the gate against the report the step above just wrote. + # `composer coverage:check` would regenerate it first, which means running + # the whole suite under coverage a second time. + - name: Enforce Coverage Gate + run: php scripts/coverage-summary.php coverage/clover.xml --fail-under=100 + - name: Upload Coverage Baseline uses: actions/upload-artifact@v4 with: name: fleetops-coverage-clover path: coverage/clover.xml if-no-files-found: error + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage/clover.xml + disable_search: true + flags: backend + fail_ci_if_error: false diff --git a/README.md b/README.md index ec787e208..f0d631c05 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ + + + diff --git a/addon/components/customer/form.hbs b/addon/components/customer/form.hbs index 67c0679b8..4dd63de78 100644 --- a/addon/components/customer/form.hbs +++ b/addon/components/customer/form.hbs @@ -99,55 +99,6 @@ - - - - - - - - - - {{model.name}} - - - {{n-a model.email}} - - - - {{n-a model.phone}} - - - - - - {{#if @resource.user}} - - - - - - - - - - - - {{/if}} - - - diff --git a/addon/components/entity/card.hbs b/addon/components/entity/card.hbs index ccd63de5a..9defaf2d1 100644 --- a/addon/components/entity/card.hbs +++ b/addon/components/entity/card.hbs @@ -15,6 +15,7 @@ + {{#if (has-block "footer")}} diff --git a/addon/components/modals/order-label.hbs b/addon/components/modals/order-label.hbs index 68126b76d..94f53f815 100644 --- a/addon/components/modals/order-label.hbs +++ b/addon/components/modals/order-label.hbs @@ -1,6 +1,6 @@ - + {{t "modals.order-label.loading"}} diff --git a/addon/components/place/form.hbs b/addon/components/place/form.hbs index 2075045a3..e65da7281 100644 --- a/addon/components/place/form.hbs +++ b/addon/components/place/form.hbs @@ -37,7 +37,7 @@ - + diff --git a/addon/services/entity-actions.js b/addon/services/entity-actions.js index 29bacc098..318667f34 100644 --- a/addon/services/entity-actions.js +++ b/addon/services/entity-actions.js @@ -1,4 +1,6 @@ import ResourceActionService from '@fleetbase/ember-core/services/resource-action'; +import { action } from '@ember/object'; +import { debug } from '@ember/debug'; export default class EntityActionsService extends ResourceActionService { constructor() { @@ -38,4 +40,34 @@ export default class EntityActionsService extends ResourceActionService { }); }, }; + + @action async viewLabel(entity) { + // render dialog to display label within + this.modalsManager.show(`modals/order-label`, { + title: this.intl.t('order.fields.entity-label'), + modalClass: 'modal-xl', + acceptButtonText: this.intl.t('common.done'), + hideDeclineButton: true, + subject: entity, + }); + + try { + // load the pdf label from base64 + // eslint-disable-next-line no-undef + const fileReader = new FileReader(); + const { data: pdfStream } = await this.fetch.get(`orders/label/${entity.public_id}?format=base64`); + // eslint-disable-next-line no-undef + const base64 = await fetch(`data:application/pdf;base64,${pdfStream}`); + const blob = await base64.blob(); + // load into file reader + fileReader.onload = (event) => { + const data = event.target.result; + this.modalsManager.setOption('data', data); + }; + fileReader.readAsDataURL(blob); + } catch (err) { + this.notifications.error(this.intl.t('order.prompts.failed-to-load-entity-label')); + debug('Error loading entity label data: ' + err.message); + } + } } diff --git a/addon/styles/fleetops-engine.css b/addon/styles/fleetops-engine.css index 3e05fd5cd..18171b94c 100644 --- a/addon/styles/fleetops-engine.css +++ b/addon/styles/fleetops-engine.css @@ -117,7 +117,6 @@ nav.next-sidebar { } /** places management css mods */ - .next-content-overlay.place-panel > .next-content-overlay-panel-container > .next-content-overlay-panel .next-content-overlay-panel-header, .next-content-overlay.place-form-panel > .next-content-overlay-panel-container > .next-content-overlay-panel .next-content-overlay-panel-header { display: grid; diff --git a/composer.json b/composer.json index c836d4b80..98804e52a 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/fleetops-api", - "version": "0.6.58", + "version": "0.6.59", "description": "Fleet & Transport Management Extension for Fleetbase", "keywords": [ "fleetbase-extension", @@ -22,7 +22,7 @@ ], "require": { "php": "^8.0", - "barryvdh/laravel-dompdf": "^2.0", + "barryvdh/laravel-dompdf": "^3.1", "brick/geo": "0.7.2", "cknow/laravel-money": "^7.1", "fleetbase/core-api": "*", @@ -90,11 +90,15 @@ "@test:coverage:clover", "@coverage:summary" ], + "coverage:check": [ + "@test:coverage:clover", + "php scripts/coverage-summary.php coverage/clover.xml --fail-under=100" + ], "coverage:summary": "php scripts/coverage-summary.php coverage/clover.xml", "lint": "php-cs-fixer fix -v", "test:lint": "php-cs-fixer fix -v --dry-run", "test:coverage": "php scripts/coverage-runner.php --coverage --coverage-text --colors=always", - "test:coverage:clover": "mkdir -p coverage && php scripts/coverage-runner.php --coverage-clover=coverage/clover.xml --colors=always", + "test:coverage:clover": "mkdir -p coverage && php scripts/coverage-file-runner.php --coverage-clover=coverage/clover.xml --colors=always", "test:types": "phpstan analyse --ansi --memory-limit=4G", "test:unit": "php scripts/pest-file-runner.php --colors=always", "test": [ diff --git a/extension.json b/extension.json index 51a06501a..af9d83e6e 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Fleet-Ops", - "version": "0.6.58", + "version": "0.6.59", "description": "Fleet & Transport Management Extension for Fleetbase", "repository": "https://github.com/fleetbase/fleetops", "license": "AGPL-3.0-or-later", diff --git a/package.json b/package.json index 51089688d..c1593597d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/fleetops-engine", - "version": "0.6.58", + "version": "0.6.59", "description": "Fleet & Transport Management Extension for Fleetbase", "fleetbase": { "route": "fleet-ops" diff --git a/scripts/coverage-file-runner.php b/scripts/coverage-file-runner.php new file mode 100644 index 000000000..da7395e46 --- /dev/null +++ b/scripts/coverage-file-runner.php @@ -0,0 +1,240 @@ +isFile() && $fileInfo->getExtension() === 'php') { + $files[] = $fileInfo->getPathname(); + } + } + } +} + +$files = array_values(array_unique($files)); +sort($files); + +if ($files === []) { + fwrite(STDERR, "Unable to find Pest test files for coverage.\n"); + exit(1); +} + +$coverageDir = dirname($cloverPath); +if (!is_dir($coverageDir) && !mkdir($coverageDir, 0777, true) && !is_dir($coverageDir)) { + fwrite(STDERR, "Unable to create coverage directory: {$coverageDir}\n"); + exit(1); +} + +$tmpDir = $coverageDir . '/.coverage-file-runner'; +if (is_dir($tmpDir)) { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($tmpDir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $fileInfo) { + $fileInfo->isDir() ? rmdir($fileInfo->getPathname()) : unlink($fileInfo->getPathname()); + } +} elseif (!mkdir($tmpDir, 0777, true) && !is_dir($tmpDir)) { + fwrite(STDERR, "Unable to create temporary coverage directory: {$tmpDir}\n"); + exit(1); +} + +$timeout = (float) (getenv('PEST_FILE_TIMEOUT') ?: 120); +$timeoutBinary = trim((string) shell_exec('command -v timeout')); +$memoryLimit = getenv('FLEETOPS_COVERAGE_MEMORY_LIMIT') ?: '-1'; +$coverageFiles = []; + +// Opt-in: keep going after a file fails instead of aborting the whole run. +// +// By default a non-zero file exits immediately, which means every later file is +// skipped, the merge below never runs, and no Clover report is written — the +// previous report is silently left in place and reads as current. With this set, +// failures are collected and reported, the report is still produced, and the +// process still exits non-zero so a failure cannot be mistaken for success. +$continueOnFailure = filter_var(getenv('FLEETOPS_COVERAGE_CONTINUE_ON_FAILURE') ?: '', FILTER_VALIDATE_BOOLEAN); +$failedFiles = []; + +foreach ($files as $index => $file) { + $relativeFile = str_replace(getcwd() . '/', '', $file); + $coverageFile = $tmpDir . '/' . str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '.cov'; + + fwrite(STDOUT, "::group::{$relativeFile} coverage\n"); + + $command = array_merge([ + PHP_BINARY, + '-d', + 'memory_limit=' . $memoryLimit, + $pestRunner, + ], $pestArgs, [ + '--coverage-php=' . $coverageFile, + $file, + ]); + + if ($timeoutBinary !== '') { + $command = array_merge([$timeoutBinary, "{$timeout}s"], $command); + } + + fwrite(STDOUT, '$ ' . implode(' ', array_map('escapeshellarg', $command)) . "\n"); + passthru(implode(' ', array_map('escapeshellarg', $command)), $exitCode); + fwrite(STDOUT, "::endgroup::\n"); + + if ($exitCode === 124) { + fwrite(STDERR, "\nTimed out after {$timeout} seconds while running {$relativeFile}.\n"); + + if (!$continueOnFailure) { + exit(1); + } + + $failedFiles[] = $relativeFile . ' (timed out)'; + + continue; + } + + if ($exitCode !== 0) { + fwrite(STDERR, "\nPest coverage failed for {$relativeFile} with exit code {$exitCode}.\n"); + + if (!$continueOnFailure) { + exit($exitCode); + } + + $failedFiles[] = $relativeFile; + + // A failing file may still have written partial coverage before it died + if (is_file($coverageFile)) { + $coverageFiles[] = $coverageFile; + } + + continue; + } + + $coverageFiles[] = $coverageFile; +} + +$merged = null; +foreach ($coverageFiles as $coverageFile) { + $coverage = include $coverageFile; + + if (!$coverage instanceof CodeCoverage) { + fwrite(STDERR, "Invalid coverage artifact: {$coverageFile}\n"); + exit(1); + } + + if ($merged === null) { + $merged = $coverage; + continue; + } + + $merged->merge($coverage); +} + +if (!$merged instanceof CodeCoverage) { + fwrite(STDERR, "No coverage data was produced.\n"); + exit(1); +} + +(new Clover())->process($merged, $cloverPath); +fwrite(STDOUT, "Wrote Clover coverage to {$cloverPath}\n"); + +if ($failedFiles !== []) { + fwrite(STDERR, "\n" . count($failedFiles) . " test file(s) failed:\n"); + + foreach ($failedFiles as $failedFile) { + fwrite(STDERR, " - {$failedFile}\n"); + } + + fwrite(STDERR, "\nCoverage above excludes anything those files would have covered.\n"); + + exit(1); +} diff --git a/scripts/coverage-runner.php b/scripts/coverage-runner.php index 3f630c696..a2d3a0145 100644 --- a/scripts/coverage-runner.php +++ b/scripts/coverage-runner.php @@ -43,7 +43,8 @@ $args[] = 'server/tests'; } -$command = array_merge([PHP_BINARY, $pestRunner], $args); +$memoryLimit = getenv('FLEETOPS_COVERAGE_MEMORY_LIMIT') ?: '-1'; +$command = array_merge([PHP_BINARY, '-d', 'memory_limit=' . $memoryLimit, $pestRunner], $args); $escapedCommand = implode(' ', array_map('escapeshellarg', $command)); passthru($escapedCommand, $exitCode); diff --git a/scripts/coverage-summary.php b/scripts/coverage-summary.php index 09cda8782..6968e4827 100644 --- a/scripts/coverage-summary.php +++ b/scripts/coverage-summary.php @@ -2,7 +2,20 @@ declare(strict_types=1); -$cloverPath = $argv[1] ?? 'coverage/clover.xml'; +$cloverPath = 'coverage/clover.xml'; +$failUnder = null; + +foreach (array_slice($argv, 1) as $arg) { + if (str_starts_with($arg, '--fail-under=')) { + $failUnder = (float) substr($arg, strlen('--fail-under=')); + + continue; + } + + if ($arg !== '') { + $cloverPath = $arg; + } +} if (!is_file($cloverPath)) { fwrite(STDERR, "Coverage file not found: {$cloverPath}\n"); @@ -26,6 +39,27 @@ function intMetric(SimpleXMLElement $node, string $name): int return (int) ($node->metrics[$name] ?? 0); } +function hasMetric(SimpleXMLElement $node, string $name): bool +{ + return isset($node->metrics[$name]); +} + +function deriveCoveredClasses(SimpleXMLElement $project): int +{ + $coveredClasses = 0; + + foreach ($project->xpath('.//class') ?: [] as $class) { + $methods = intMetric($class, 'methods'); + $coveredMethods = intMetric($class, 'coveredmethods'); + + if ($methods > 0 && $coveredMethods >= $methods) { + $coveredClasses++; + } + } + + return $coveredClasses; +} + $project = $xml->project; $metrics = $project->metrics; @@ -34,7 +68,7 @@ function intMetric(SimpleXMLElement $node, string $name): int $methods = (int) ($metrics['methods'] ?? 0); $coveredMethods = (int) ($metrics['coveredmethods'] ?? 0); $classes = (int) ($metrics['classes'] ?? 0); -$coveredClasses = (int) ($metrics['coveredclasses'] ?? 0); +$coveredClasses = hasMetric($project, 'coveredclasses') ? (int) $metrics['coveredclasses'] : deriveCoveredClasses($project); $files = []; $directories = []; @@ -108,3 +142,8 @@ function intMetric(SimpleXMLElement $node, string $name): int $relativePath = preg_replace('#^' . preg_quote(getcwd(), '#') . '/?#', '', $file['path']); printf(" %6.2f%% %5d/%-5d %s\n", $file['percent'], $file['covered'], $file['statements'], $relativePath ?: $file['path']); } + +if ($failUnder !== null && coveragePercent($coveredStatements, $statements) < $failUnder) { + fwrite(STDERR, sprintf("\nCoverage %.2f%% is below the required %.2f%% line threshold.\n", coveragePercent($coveredStatements, $statements), $failUnder)); + exit(1); +} diff --git a/scripts/pest-bootstrap.php b/scripts/pest-bootstrap.php index 7864ff12c..f047e69a8 100644 --- a/scripts/pest-bootstrap.php +++ b/scripts/pest-bootstrap.php @@ -15,8 +15,31 @@ } if (!function_exists('config')) { - function config(?string $key = null, mixed $default = null): mixed + function config(string|array|null $key = null, mixed $default = null): mixed { + if (class_exists('Illuminate\Container\Container')) { + $app = Illuminate\Container\Container::getInstance(); + + if ($app->bound('config')) { + $config = $app->make('config'); + + if ($key === null) { + return $config; + } + + // Laravel's helper doubles as a setter when handed an array + if (is_array($key)) { + foreach ($key as $configKey => $configValue) { + $config->set($configKey, $configValue); + } + + return null; + } + + return $config->get($key, $default); + } + } + return $default; } } @@ -25,6 +48,106 @@ function config(?string $key = null, mixed $default = null): mixed $app = Illuminate\Container\Container::getInstance(); Illuminate\Support\Facades\Facade::setFacadeApplication($app); + if (!$app->bound('config') && class_exists('Illuminate\Config\Repository')) { + $app->singleton('config', fn () => new Illuminate\Config\Repository([ + 'fleetops' => [], + 'services' => [], + 'telematics' => [], + ])); + } + + if (!$app->bound(Illuminate\Contracts\Routing\ResponseFactory::class)) { + $responseFactory = new class { + public function json(mixed $data = [], int $status = 200): mixed + { + if (class_exists('Illuminate\Http\JsonResponse')) { + return new Illuminate\Http\JsonResponse($data, $status); + } + + return new class($data, $status) { + public function __construct(public mixed $data, public int $status) + { + } + + public function getStatusCode(): int + { + return $this->status; + } + }; + } + + public function make(mixed $content = '', int $status = 200, array $headers = []): mixed + { + if (class_exists('Illuminate\\Http\\Response')) { + return new Illuminate\Http\Response($content, $status, $headers); + } + + return new class($content, $status) { + public function __construct(public mixed $content, public int $status) + { + } + + public function getContent(): mixed + { + return $this->content; + } + + public function getStatusCode(): int + { + return $this->status; + } + }; + } + + public function error(mixed $error = null, int $status = 500): mixed + { + return $this->json(['error' => $error], $status); + } + + public function apiError(mixed $error = null, int $statusCode = 400, ?array $data = []): mixed + { + if ($error instanceof Illuminate\Support\MessageBag) { + $error = $error->all(); + } + + return $this->json(['error' => $error] + ($data ?? []), $statusCode); + } + }; + + $app->instance(Illuminate\Contracts\Routing\ResponseFactory::class, $responseFactory); + $app->instance('Illuminate\Contracts\Routing\ResponseFactory', $responseFactory); + $app->instance('response', $responseFactory); + } + + if (!$app->bound('db')) { + // Unbound 'db' resolutions recurse the container until memory is + // exhausted when model boot paths reach the DB facade — proxy to the + // Eloquent connection resolver instead. Fixture instance bindings + // override this fallback. + $app->singleton('db', function () { + return new class { + public function connection($name = null) + { + return Illuminate\Database\Eloquent\Model::getConnectionResolver() + ? Illuminate\Database\Eloquent\Model::resolveConnection($name) + : null; + } + + public function raw($value) + { + return new Illuminate\Database\Query\Expression($value); + } + + public function __call($method, $arguments) + { + $connection = $this->connection(); + + return $connection ? $connection->{$method}(...$arguments) : null; + } + }; + }); + } + if (!$app->bound('http') && class_exists('Illuminate\Http\Client\Factory')) { $app->singleton('http', fn () => new Illuminate\Http\Client\Factory()); } @@ -64,6 +187,16 @@ function app(?string $abstract = null, array $parameters = []): mixed if (!function_exists('request')) { function request(?string $key = null, mixed $default = null): mixed { + if (class_exists('Illuminate\Container\Container')) { + $container = Illuminate\Container\Container::getInstance(); + + if ($container->bound('request')) { + $request = $container->make('request'); + + return $key === null ? $request : $request->input($key, $default); + } + } + $request = class_exists('Illuminate\Http\Request') ? Illuminate\Http\Request::create('/') : new stdClass(); return $key === null ? $request : $default; @@ -81,7 +214,32 @@ public function json(mixed $data = [], int $status = 200): mixed } return new class($data, $status) { - public function __construct(public mixed $data, public int $status) {} + public function __construct(public mixed $data, public int $status) + { + } + + public function getStatusCode(): int + { + return $this->status; + } + }; + } + + public function make(mixed $content = '', int $status = 200, array $headers = []): mixed + { + if (class_exists('Illuminate\\Http\\Response')) { + return new Illuminate\Http\Response($content, $status, $headers); + } + + return new class($content, $status) { + public function __construct(public mixed $content, public int $status) + { + } + + public function getContent(): mixed + { + return $this->content; + } public function getStatusCode(): int { @@ -94,6 +252,15 @@ public function error(mixed $error = null, int $status = 500): mixed { return $this->json(['error' => $error], $status); } + + public function apiError(mixed $error = null, int $statusCode = 400, ?array $data = []): mixed + { + if ($error instanceof Illuminate\Support\MessageBag) { + $error = $error->all(); + } + + return $this->json(['error' => $error] + ($data ?? []), $statusCode); + } }; } } @@ -120,12 +287,63 @@ function now($tz = null): Illuminate\Support\Carbon } } +if (!class_exists('Illuminate\Validation\ValidationException')) { + // Laravel's real constructor takes the failing validator and an optional + // response, not a message. Both shapes are accepted so + // `new ValidationException($validator, $response)` works alongside the + // `withMessages()` string form. + eval('namespace Illuminate\Validation; class ValidationException extends \Exception { public array $messages = []; public $validator; public $response; public function __construct($validator = null, $response = null) { parent::__construct(is_string($validator) ? $validator : "The given data was invalid."); $this->response = $response; if (!is_string($validator) && is_object($validator)) { $this->validator = $validator; if (method_exists($validator, "errors")) { $errors = $validator->errors(); $this->messages = is_object($errors) && method_exists($errors, "messages") ? $errors->messages() : (array) $errors; } } } public static function withMessages(array $messages): self { $exception = new self("The given data was invalid."); $exception->messages = $messages; return $exception; } public function errors(): array { return $this->messages; } public function getResponse() { return $this->response; } }'); +} + +if (class_exists('Illuminate\Http\Request') && method_exists('Illuminate\Http\Request', 'macro')) { + if (!method_exists('Illuminate\Http\Request', 'array')) { + Illuminate\Http\Request::macro('array', function (string $key, array $default = []): array { + $value = $this->input($key, $default); + + return is_array($value) ? $value : $default; + }); + } + + if (!method_exists('Illuminate\Http\Request', 'validate')) { + Illuminate\Http\Request::macro('validate', function (array $rules, ...$parameters): array { + if (app()->bound('validator')) { + $validator = app('validator')->make($this->all(), $rules); + if (is_object($validator) && method_exists($validator, 'fails') && $validator->fails()) { + $messages = method_exists($validator, 'errors') ? $validator->errors()->toArray() : ['error' => ['Validation failed.']]; + throw Illuminate\Validation\ValidationException::withMessages($messages); + } + } + + return $this->all(); + }); + } +} + if (!trait_exists('Illuminate\Foundation\Auth\Access\AuthorizesRequests')) { eval('namespace Illuminate\Foundation\Auth\Access; trait AuthorizesRequests {}'); } +if (!class_exists('Fleetbase\TestSupport\PendingDispatch')) { + eval('namespace Fleetbase\TestSupport; class PendingDispatch { public function __call($name, $arguments) { return $this; } public function __toString(): string { return \'\'; } }'); +} + +if (!class_exists('Fleetbase\TestSupport\DispatchRecorder')) { + eval('namespace Fleetbase\TestSupport; class DispatchRecorder { public static array $dispatched = []; public static function record(string $job, array $arguments): void { self::$dispatched[] = [\'job\' => $job, \'arguments\' => $arguments]; } }'); +} + if (!trait_exists('Illuminate\Foundation\Bus\Dispatchable')) { - eval('namespace Illuminate\Foundation\Bus; trait Dispatchable {}'); + eval('namespace Illuminate\Foundation\Bus; trait Dispatchable { + public static function dispatch(...$arguments) { \Fleetbase\TestSupport\DispatchRecorder::record(static::class, $arguments); return new \Fleetbase\TestSupport\PendingDispatch(); } + public static function dispatchIf($boolean, ...$arguments) { if ($boolean) { \Fleetbase\TestSupport\DispatchRecorder::record(static::class, $arguments); } return new \Fleetbase\TestSupport\PendingDispatch(); } + public static function dispatchUnless($boolean, ...$arguments) { if (!$boolean) { \Fleetbase\TestSupport\DispatchRecorder::record(static::class, $arguments); } return new \Fleetbase\TestSupport\PendingDispatch(); } + public static function dispatchSync(...$arguments) { \Fleetbase\TestSupport\DispatchRecorder::record(static::class, $arguments); return null; } + public static function dispatchAfterResponse(...$arguments) { \Fleetbase\TestSupport\DispatchRecorder::record(static::class, $arguments); return null; } + public static function dispatchNow(...$arguments) { \Fleetbase\TestSupport\DispatchRecorder::record(static::class, $arguments); return null; } + }'); +} + +if (!trait_exists('Illuminate\Foundation\Events\Dispatchable')) { + eval('namespace Illuminate\Foundation\Events; trait Dispatchable {}'); } if (!trait_exists('Illuminate\Foundation\Bus\DispatchesJobs')) { @@ -136,10 +354,26 @@ function now($tz = null): Illuminate\Support\Carbon eval('namespace Illuminate\Foundation\Validation; trait ValidatesRequests {}'); } +if (!trait_exists('Fleetbase\Traits\HasApiModelCache')) { + eval('namespace Fleetbase\Traits; trait HasApiModelCache {}'); +} + +if (!trait_exists('Fleetbase\Traits\HasCustomFields')) { + eval('namespace Fleetbase\Traits; trait HasCustomFields {}'); +} + if (!class_exists('Illuminate\Foundation\Http\FormRequest') && class_exists('Illuminate\Http\Request')) { eval('namespace Illuminate\Foundation\Http; class FormRequest extends \Illuminate\Http\Request { public function authorize(): bool { return true; } public function rules(): array { return []; } public function responseWithErrors(\Illuminate\Contracts\Validation\Validator $validator) { return $validator; } }'); } +if (!class_exists('Illuminate\Foundation\Auth\User') && class_exists('Illuminate\Database\Eloquent\Model')) { + eval('namespace Illuminate\Foundation\Auth; class User extends \Illuminate\Database\Eloquent\Model {}'); +} + +if (!class_exists('Fleetbase\Models\ScheduleItem') && class_exists('Fleetbase\Models\Model')) { + eval('namespace Fleetbase\Models; class ScheduleItem extends Model {}'); +} + if (!interface_exists('Fleetbase\Ai\Contracts\AIContextCapabilityInterface')) { eval('namespace Fleetbase\Ai\Contracts; interface AIContextCapabilityInterface {}'); } diff --git a/scripts/pest-file-runner.php b/scripts/pest-file-runner.php index 77015db43..47636c4bc 100644 --- a/scripts/pest-file-runner.php +++ b/scripts/pest-file-runner.php @@ -29,7 +29,19 @@ } $testsPath = getcwd() . '/server/tests'; -$files = glob($testsPath . '/*.php') ?: []; +$files = []; + +$iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($testsPath, FilesystemIterator::SKIP_DOTS) +); + +foreach ($iterator as $fileInfo) { + if ($fileInfo->isFile() && $fileInfo->getExtension() === 'php') { + $files[] = $fileInfo->getPathname(); + } +} + +$files = array_values(array_unique($files)); sort($files); if ($files === []) { diff --git a/scripts/pest-runner.php b/scripts/pest-runner.php index dd59292e7..bac6583b3 100644 --- a/scripts/pest-runner.php +++ b/scripts/pest-runner.php @@ -24,8 +24,24 @@ $serverVendor = getcwd() . '/server_vendor'; $vendor = getcwd() . '/vendor'; + +// Pest hardcodes its autoloader at ../../../vendor/autoload.php (pestphp/pest#920), +// so it needs a `vendor` entry even though this package installs to server_vendor. +// Create the symlink only for the duration of this run and remove it afterwards, so it +// never persists to collide with other tooling — notably the console's Ember build, +// whose addon `vendor/` convention breaks when a dev-linked package exposes this PHP +// server_vendor symlink there. +$createdVendorSymlink = false; if (!file_exists($vendor) && is_dir($serverVendor) && function_exists('symlink')) { - @symlink($serverVendor, $vendor); + $createdVendorSymlink = @symlink($serverVendor, $vendor); +} + +if ($createdVendorSymlink) { + register_shutdown_function(static function () use ($vendor): void { + if (is_link($vendor)) { + @unlink($vendor); + } + }); } $bootstrap = getcwd() . '/scripts/pest-bootstrap.php'; @@ -55,6 +71,8 @@ '-d', 'error_reporting=8191', '-d', + 'memory_limit=' . ini_get('memory_limit'), + '-d', 'auto_prepend_file=' . $bootstrap, $pest, ], $args); diff --git a/server/resources/views/labels/entity-label.php b/server/resources/views/labels/entity-label.php index 98bf6e63d..ac62da5f7 100644 --- a/server/resources/views/labels/entity-label.php +++ b/server/resources/views/labels/entity-label.php @@ -8,7 +8,7 @@ - = $company->name ?? ($waypoint->internal_id ?? $waypoint->public_id) ?> Waypoint Label + = $company->name ?? ($entity->internal_id ?? $entity->public_id) ?> Item Label