$value) { if ($value === false) { putenv($key); continue; } putenv($key . '=' . $value); } } } function remove_edge_gateway_temp_path(string $path): void { if (is_file($path)) { unlink($path); return; } if (is_dir($path)) { rmdir($path); } } it('resolves edge-agent artifacts from a supported runtime layout', function (): void { $path = edge_gateway_agent_artifact_locator::resolve('agent.php', app_path()); $normalizedPath = str_replace('\\', '/', $path); expect($normalizedPath)->toEndWith('/resources/edge-gateway-agent/agent.php'); expect(is_file($path))->toBeTrue(); }); it('prioritizes router-owned artifacts before implicit generated build output', function (): void { with_edge_gateway_artifact_env(['EDGE_AGENT_ARTIFACT_DIR' => null], function (): void { $candidatePaths = array_map( static fn(string $path): string => str_replace('\\', '/', $path), edge_gateway_agent_artifact_locator::candidatePaths( 'agent.php', '/var/www/html', '/services/edge-agent/php-agent', '/opt/truckwash-edge-agent-artifacts' ) ); expect(array_slice($candidatePaths, 0, 5))->toBe([ '/var/www/html/resources/edge-gateway-agent/agent.php', '/edge-agent/build/install/agent.php', '/services/edge-agent/build/install/agent.php', '/var/edge-agent/build/install/agent.php', '/var/www/edge-agent/build/install/agent.php', ]); expect($candidatePaths)->toContain('/services/edge-agent/php-agent/agent.php'); expect($candidatePaths)->toContain('/opt/truckwash-edge-agent-artifacts/agent.php'); expect($candidatePaths)->toContain('/var/edge-agent/php-agent/agent.php'); expect($candidatePaths)->toContain('/var/www/edge-agent/php-agent/agent.php'); }); }); it('prioritizes EDGE_AGENT_ARTIFACT_DIR before generated edge-agent build output', function (): void { with_edge_gateway_artifact_env(['EDGE_AGENT_ARTIFACT_DIR' => '/tmp/custom-edge-artifacts'], function (): void { $candidatePaths = array_map( static fn(string $path): string => str_replace('\\', '/', $path), edge_gateway_agent_artifact_locator::candidatePaths( 'agent.php', '/var/www/html', '/services/edge-agent/php-agent', '/opt/truckwash-edge-agent-artifacts' ) ); expect(array_slice($candidatePaths, 0, 4))->toBe([ '/tmp/custom-edge-artifacts/agent.php', '/var/www/html/resources/edge-gateway-agent/agent.php', '/edge-agent/build/install/agent.php', '/services/edge-agent/build/install/agent.php', ]); expect($candidatePaths)->toContain('/var/edge-agent/build/install/agent.php'); }); }); it('serves a manifest with hashes for the resolved artifacts', function (): void { $service = new EdgeGatewayInstallServiceHarness(); $manifest = json_decode($service->readArtifact('manifest.json'), true); expect($manifest)->toBeArray() ->and($manifest['version'] ?? null)->toBe(edge_gateway_manager::DEFAULT_INSTALL_VERSION); $artifacts = []; foreach ((array)($manifest['artifacts'] ?? []) as $artifact) { if (is_array($artifact)) { $artifacts[(string)($artifact['name'] ?? '')] = $artifact; } } foreach ([ 'agent.php', 'lan-worker.php', 'auto-updater.php', 'docker-compose.gateway.yml', 'Dockerfile.edge-agent', 'Dockerfile.lan-worker', 'Dockerfile.auto-updater', 'gateway-launcher.sh', 'truckwash-edge-gateway-stack.service', 'truckwash-edge-agent.service', ] as $fileName) { $path = edge_gateway_agent_artifact_locator::resolve($fileName, app_path()); expect($artifacts)->toHaveKey($fileName) ->and($artifacts[$fileName]['sha256'] ?? null)->toBe(hash_file('sha256', $path)) ->and($artifacts[$fileName]['bytes'] ?? null)->toBe(filesize($path)); } }); it('reads install artifacts through the shared locator', function (): void { $service = new EdgeGatewayInstallServiceHarness(); $contents = $service->readArtifact('truckwash-edge-gateway-stack.service'); expect($contents)->toContain('ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up'); }); it('normalizes install artifact line endings for shell-safe streaming', function (): void { $tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'edge-gateway-crlf-' . bin2hex(random_bytes(6)); $artifactPath = $tempRoot . DIRECTORY_SEPARATOR . 'gateway-launcher.sh'; @mkdir($tempRoot, 0777, true); file_put_contents($artifactPath, "#!/usr/bin/env bash\r\nset -Eeuo pipefail\r\n"); try { with_edge_gateway_artifact_env(['EDGE_AGENT_ARTIFACT_DIR' => $tempRoot], function (): void { $service = new EdgeGatewayInstallServiceHarness(); $contents = $service->readArtifact('gateway-launcher.sh'); expect($contents)->toBe("#!/usr/bin/env bash\nset -Eeuo pipefail\n"); expect($contents)->not->toContain("\r"); }); } finally { remove_edge_gateway_temp_path($artifactPath); remove_edge_gateway_temp_path($tempRoot); } }); it('builds update payloads with checksums from resolved artifact paths', function (): void { $manager = new EdgeGatewayManagerArtifactHarness(); $payload = $manager->buildUpdateOperationRequest('2.0.0', 'stable'); expect($payload['artifactSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/agent.php'))); expect($payload['serviceUnitSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/truckwash-edge-agent.service'))); expect($payload['stackServiceUnitSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/truckwash-edge-gateway-stack.service'))); expect($payload['composeFileSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/docker-compose.gateway.yml'))); expect($payload['launcherScriptSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/gateway-launcher.sh'))); }); it('falls back to baked-in artifacts when the mount path is absent', function (): void { $tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'edge-gateway-baked-' . bin2hex(random_bytes(6)); $appPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'nginx' . DIRECTORY_SEPARATOR . 'app'; $missingMountPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'; $bakedPath = $tempRoot . DIRECTORY_SEPARATOR . 'baked-artifacts'; $bakedAgentPath = $bakedPath . DIRECTORY_SEPARATOR . 'agent.php'; @mkdir($appPath, 0777, true); @mkdir($bakedPath, 0777, true); file_put_contents($bakedAgentPath, "toBe(str_replace('\\', '/', $bakedAgentPath)); } finally { remove_edge_gateway_temp_path($bakedAgentPath); foreach ([ $bakedPath, $appPath, dirname($appPath), dirname(dirname($appPath)), dirname($missingMountPath), dirname(dirname($missingMountPath)), $tempRoot, ] as $directory) { remove_edge_gateway_temp_path($directory); } } }); it('explains the legacy dist mount mismatch when php artifacts are unavailable', function (): void { $tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'edge-gateway-artifacts-' . bin2hex(random_bytes(6)); $appPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'nginx' . DIRECTORY_SEPARATOR . 'app'; $legacyDistPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'dist'; @mkdir($appPath, 0777, true); @mkdir($legacyDistPath, 0777, true); file_put_contents($legacyDistPath . DIRECTORY_SEPARATOR . 'agent.mjs', '// legacy node artifact'); try { edge_gateway_agent_artifact_locator::resolve('agent.php', $appPath); test()->fail('Expected artifact resolution to fail when php-agent artifacts are missing.'); } catch (Exception $exception) { expect($exception->getMessage())->toContain('Missing edge agent artifact: agent.php.'); expect($exception->getMessage())->toContain('Deploy the router-owned artifacts under'); expect($exception->getMessage())->toContain('Legacy Node dist artifact found at'); expect($exception->getMessage())->toContain('Remove /services/edge-agent/dist and deploy the router resources instead of depending on the legacy mount.'); } finally { remove_edge_gateway_temp_path($legacyDistPath . DIRECTORY_SEPARATOR . 'agent.mjs'); foreach ([ $legacyDistPath, dirname($legacyDistPath), $appPath, dirname($appPath), dirname(dirname($appPath)), $tempRoot, ] as $directory) { remove_edge_gateway_temp_path($directory); } } });