1
0
mirror of https://github.com/nicoverbruggen/phpmon.git synced 2026-03-27 14:30:08 +01:00

♻️ All unit tests pass w/ DI container

This commit is contained in:
2025-10-16 14:03:16 +02:00
parent 79a23a2af2
commit 5b63211746
49 changed files with 349 additions and 206 deletions

View File

@@ -0,0 +1,25 @@
{
"configurations" : [
{
"id" : "CDAF5C24-6A14-4AD7-B204-61734226DBF0",
"name" : "Configuration 1",
"options" : {
}
}
],
"defaultOptions" : {
},
"testTargets" : [
{
"parallelizable" : false,
"target" : {
"containerPath" : "container:PHP Monitor.xcodeproj",
"identifier" : "C4F7807825D7F84B000DBC97",
"name" : "Unit Tests"
}
}
],
"version" : 1
}

View File

@@ -9,13 +9,11 @@
import XCTest
class FeatureTestCase: XCTestCase {
// TODO: make fake filesystem accessible via test case
public func assertFileSystemHas(
_ path: String,
file: StaticString = #filePath,
line: UInt = #line,
fs: TestableFileSystem
in fs: TestableFileSystem
) {
XCTAssertTrue(fs.files.keys.contains(path), file: file, line: line)
}
@@ -24,7 +22,7 @@ class FeatureTestCase: XCTestCase {
_ path: String,
file: StaticString = #filePath,
line: UInt = #line,
fs: TestableFileSystem
in fs: TestableFileSystem
) {
XCTAssertFalse(fs.files.keys.contains(path), file: file, line: line)
}
@@ -34,7 +32,7 @@ class FeatureTestCase: XCTestCase {
contents: String,
file: StaticString = #filePath,
line: UInt = #line,
fs: TestableFileSystem
in fs: TestableFileSystem
) {
XCTAssertEqual(contents, fs.files[path]?.content, file: file, line: line)
}

View File

@@ -9,39 +9,36 @@
import XCTest
final class InternalSwitcherTest: FeatureTestCase {
public func testDefaultPhpFpmPoolIsMoved() async {
ActiveFileSystem.useTestable([
let c = Container.fake(files: [
"/opt/homebrew/etc/php/8.1/php-fpm.d/www.conf": .fake(.text)
])
]), fs = c.filesystem as! TestableFileSystem
let outcome = await InternalSwitcher().disableDefaultPhpFpmPool("8.1")
let outcome = await InternalSwitcher(container: c).disableDefaultPhpFpmPool("8.1")
XCTAssertTrue(outcome)
assertFileSystemHas("/opt/homebrew/etc/php/8.1/php-fpm.d/www.conf.disabled-by-phpmon")
assertFileSystemDoesNotHave("/opt/homebrew/etc/php/8.1/php-fpm.d/www.conf")
assertFileSystemHas("/opt/homebrew/etc/php/8.1/php-fpm.d/www.conf.disabled-by-phpmon", in: fs)
assertFileSystemDoesNotHave("/opt/homebrew/etc/php/8.1/php-fpm.d/www.conf", in: fs)
}
public func testExistingDisabledByPhpMonFileIsRemoved() async {
ActiveFileSystem.useTestable([
let c = Container.fake(files: [
"/opt/homebrew/etc/php/8.1/php-fpm.d/www.conf": .fake(.text, "system generated"),
"/opt/homebrew/etc/php/8.1/php-fpm.d/www.conf.disabled-by-phpmon": .fake(.text, "phpmon generated")
])
]), fs = c.filesystem as! TestableFileSystem
assertFileHasContents(
"/opt/homebrew/etc/php/8.1/php-fpm.d/www.conf.disabled-by-phpmon",
contents: "phpmon generated"
)
assertFileHasContents("/opt/homebrew/etc/php/8.1/php-fpm.d/www.conf.disabled-by-phpmon",
contents: "phpmon generated", in: fs)
let outcome = await InternalSwitcher().disableDefaultPhpFpmPool("8.1")
let outcome = await InternalSwitcher(container: c).disableDefaultPhpFpmPool("8.1")
XCTAssertTrue(outcome)
assertFileSystemHas("/opt/homebrew/etc/php/8.1/php-fpm.d/www.conf.disabled-by-phpmon")
assertFileSystemDoesNotHave("/opt/homebrew/etc/php/8.1/php-fpm.d/www.conf")
assertFileSystemHas("/opt/homebrew/etc/php/8.1/php-fpm.d/www.conf.disabled-by-phpmon", in: fs)
assertFileSystemDoesNotHave("/opt/homebrew/etc/php/8.1/php-fpm.d/www.conf", in: fs)
assertFileHasContents(
"/opt/homebrew/etc/php/8.1/php-fpm.d/www.conf.disabled-by-phpmon",
contents: "system generated"
contents: "system generated", in: fs
)
}

View File

@@ -10,8 +10,10 @@ import Testing
struct CommandTest {
@Test func determinePhpVersion() {
let version = Command.execute(
path: Paths.php,
let container = Container.real()
let version = container.command.execute(
path: container.paths.php,
arguments: ["-v"],
trimNewlines: false
)

View File

@@ -9,8 +9,14 @@
import Testing
struct BytePhpPreferenceTest {
var container: Container
init () async throws {
container = Container.real()
}
@Test func can_extract_memory_value() throws {
let pref = BytePhpPreference(key: "memory_limit")
let pref = BytePhpPreference(container, key: "memory_limit")
#expect(pref.internalValue == "512M")
#expect(pref.unit == .megabyte)

View File

@@ -11,9 +11,14 @@ import Foundation
@Suite(.serialized)
struct CaskFileParserTest {
var container: Container
init() async throws {
ActiveShell.useSystem()
container = Container.real()
}
var Shell: ShellProtocol {
return container.shell
}
// MARK: - Test Files
@@ -22,7 +27,7 @@ struct CaskFileParserTest {
}
@Test func can_extract_fields_from_cask_file() async throws {
guard let caskFile = await CaskFile.from(url: CaskFileParserTest.exampleFilePath) else {
guard let caskFile = await CaskFile.from(container, url: CaskFileParserTest.exampleFilePath) else {
Issue.record("The CaskFile could not be parsed, check the log for more info")
return
}
@@ -48,7 +53,7 @@ struct CaskFileParserTest {
@Test func can_extract_fields_from_remote_cask_file() async throws {
let url = URL(string: "https://raw.githubusercontent.com/nicoverbruggen/homebrew-cask/master/Casks/phpmon.rb")!
guard let caskFile = await CaskFile.from(url: url) else {
guard let caskFile = await CaskFile.from(container, url: url) else {
Issue.record("The remote CaskFile could not be parsed, check the log for more info")
return
}

View File

@@ -10,28 +10,32 @@ import Testing
import Foundation
struct ExtensionEnumeratorTest {
var container: Container
init() async throws {
ActiveFileSystem.useTestable([
"\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.1.rb": .fake(.text, "<test>"),
"\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.2.rb": .fake(.text, "<test>"),
"\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.3.rb": .fake(.text, "<test>"),
"\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.4.rb": .fake(.text, "<test>")
let paths = Paths(container: Container.fake())
container = Container.fake(files: [
"\(paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.1.rb": .fake(.text, "<test>"),
"\(paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.2.rb": .fake(.text, "<test>"),
"\(paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.3.rb": .fake(.text, "<test>"),
"\(paths.tapPath)/shivammathur/homebrew-extensions/Formula/xdebug@8.4.rb": .fake(.text, "<test>")
])
}
@Test func can_read_formulae() throws {
let directory = "\(Paths.tapPath)/shivammathur/homebrew-extensions/Formula"
let files = try FileSystem.getShallowContentsOfDirectory(directory)
let directory = "\(container.paths.tapPath)/shivammathur/homebrew-extensions/Formula"
let files = try container.filesystem.getShallowContentsOfDirectory(directory)
#expect(Set(files) == Set(["xdebug@8.1.rb", "xdebug@8.2.rb", "xdebug@8.3.rb", "xdebug@8.4.rb"]))
}
@Test func can_parse_formulae_based_on_syntax() throws {
let formulae = BrewTapFormulae.from(tap: "shivammathur/homebrew-extensions")
let formulae = BrewTapFormulae.from(container, tap: "shivammathur/homebrew-extensions")
#expect(formulae["8.1"] == [BrewPhpExtension(path: "/", name: "xdebug", phpVersion: "8.1")])
#expect(formulae["8.2"] == [BrewPhpExtension(path: "/", name: "xdebug", phpVersion: "8.2")])
#expect(formulae["8.3"] == [BrewPhpExtension(path: "/", name: "xdebug", phpVersion: "8.3")])
#expect(formulae["8.4"] == [BrewPhpExtension(path: "/", name: "xdebug", phpVersion: "8.4")])
#expect(formulae["8.1"] == [BrewPhpExtension(container, path: "/", name: "xdebug", phpVersion: "8.1")])
#expect(formulae["8.2"] == [BrewPhpExtension(container, path: "/", name: "xdebug", phpVersion: "8.2")])
#expect(formulae["8.3"] == [BrewPhpExtension(container, path: "/", name: "xdebug", phpVersion: "8.3")])
#expect(formulae["8.4"] == [BrewPhpExtension(container, path: "/", name: "xdebug", phpVersion: "8.4")])
}
}

View File

@@ -53,12 +53,12 @@ struct HomebrewPackageTest {
/// or the JSON API of the Homebrew output may have changed.
@Test(.disabled("Uses system command; enable at your own risk"))
func can_parse_services_json_from_cli_output() async throws {
ActiveShell.useSystem()
let container = Container.real()
let services = try! JSONDecoder().decode(
[HomebrewService].self,
from: await Shell.pipe(
"sudo \(Paths.brew) services info --all --json"
from: await container.shell.pipe(
"sudo \(container.paths.brew) services info --all --json"
).out.data(using: .utf8)!
).filter({ service in
return ["php", "nginx", "dnsmasq"].contains(service.name)
@@ -76,12 +76,11 @@ struct HomebrewPackageTest {
/// or the JSON API of the Homebrew output may have changed.
@Test(.disabled("Uses system command; enable at your own risk"))
func can_load_extension_json_from_cli_output() async throws {
ActiveShell.useSystem()
let container = Container.real()
let package = try! JSONDecoder().decode(
[HomebrewPackage].self,
from: await Shell.pipe("\(Paths.brew) info php --json").out.data(using: .utf8)!
from: await container.shell.pipe("\(container.paths.brew) info php --json").out.data(using: .utf8)!
).first!
#expect(package.full_name == "php")

View File

@@ -10,42 +10,45 @@ import Testing
import Foundation
struct HomebrewUpgradableTest {
var container: Container
init() throws {
container = Container.fake(
shell: [
"/opt/homebrew/bin/brew update >/dev/null && /opt/homebrew/bin/brew outdated --json --formulae"
: .instant(try! String(contentsOf: Self.outdatedFileUrl)),
"/opt/homebrew/bin/php --ini | grep -E -o '(/[^ ]+\\.ini)'"
: .instant("/opt/homebrew/etc/php/8.2/conf.d/php-memory-limits.ini"),
"/opt/homebrew/opt/php@8.1.16/bin/php --ini | grep -E -o '(/[^ ]+\\.ini)'"
: .instant("/opt/homebrew/etc/php/8.1/conf.d/php-memory-limits.ini"),
"/opt/homebrew/opt/php@8.2.3/bin/php --ini | grep -E -o '(/[^ ]+\\.ini)'"
: .instant("/opt/homebrew/etc/php/8.2/conf.d/php-memory-limits.ini"),
"/opt/homebrew/opt/php@7.4.11/bin/php --ini | grep -E -o '(/[^ ]+\\.ini)'"
: .instant("/opt/homebrew/etc/php/7.4/conf.d/php-memory-limits.ini")
],
files: [
"/opt/homebrew/etc/php/8.2/conf.d/php-memory-limits.ini": .fake(.text),
"/opt/homebrew/etc/php/8.1/conf.d/php-memory-limits.ini": .fake(.text),
"/opt/homebrew/etc/php/7.4/conf.d/php-memory-limits.ini": .fake(.text)
]
)
}
static var outdatedFileUrl: URL {
return TestBundle.url(forResource: "brew-outdated", withExtension: "json")!
}
@Test func upgradable_php_versions_can_be_determined() async throws {
// Do not execute production cli commands
ActiveShell.useTestable([
"/opt/homebrew/bin/brew update >/dev/null && /opt/homebrew/bin/brew outdated --json --formulae"
: .instant(try! String(contentsOf: Self.outdatedFileUrl)),
"/opt/homebrew/bin/php --ini | grep -E -o '(/[^ ]+\\.ini)'"
: .instant("/opt/homebrew/etc/php/8.2/conf.d/php-memory-limits.ini"),
"/opt/homebrew/opt/php@8.1.16/bin/php --ini | grep -E -o '(/[^ ]+\\.ini)'"
: .instant("/opt/homebrew/etc/php/8.1/conf.d/php-memory-limits.ini"),
"/opt/homebrew/opt/php@8.2.3/bin/php --ini | grep -E -o '(/[^ ]+\\.ini)'"
: .instant("/opt/homebrew/etc/php/8.2/conf.d/php-memory-limits.ini"),
"/opt/homebrew/opt/php@7.4.11/bin/php --ini | grep -E -o '(/[^ ]+\\.ini)'"
: .instant("/opt/homebrew/etc/php/7.4/conf.d/php-memory-limits.ini")
])
// Do not read our production files
ActiveFileSystem.useTestable([
"/opt/homebrew/etc/php/8.2/conf.d/php-memory-limits.ini": .fake(.text),
"/opt/homebrew/etc/php/8.1/conf.d/php-memory-limits.ini": .fake(.text),
"/opt/homebrew/etc/php/7.4/conf.d/php-memory-limits.ini": .fake(.text)
])
// This config file assumes our PHP alias (`php`) is v8.2
PhpEnvironments.brewPhpAlias = "8.2"
let env = App.shared.container.phpEnvs
let env = container.phpEnvs!
env.cachedPhpInstallations = [
"8.1": PhpInstallation("8.1.16"),
"8.2": PhpInstallation("8.2.3"),
"7.4": PhpInstallation("7.4.11")
"8.1": PhpInstallation(container, "8.1.16"),
"8.2": PhpInstallation(container, "8.2.3"),
"7.4": PhpInstallation(container, "7.4.11")
]
let data = await BrewPhpFormulaeHandler().loadPhpVersions(loadOutdated: true)
let data = await BrewPhpFormulaeHandler(container: container)
.loadPhpVersions(loadOutdated: true)
#expect(true == data.contains(where: { formula in
formula.installedVersion == "8.1.16" && formula.upgradeVersion == "8.1.17"

View File

@@ -10,6 +10,11 @@ import Testing
import Foundation
struct NginxConfigurationTest {
var container: Container
init () async throws {
container = Container.real()
}
// MARK: - Test Files
@@ -36,33 +41,33 @@ struct NginxConfigurationTest {
// MARK: - Tests
@Test func can_determine_site_name_and_tld() throws {
#expect("nginx-site" == NginxConfigurationFile.from(filePath: Self.regularUrl.path)?.domain)
#expect("test" == NginxConfigurationFile.from(filePath: Self.regularUrl.path)?.tld)
#expect("nginx-site" == NginxConfigurationFile.from(container, filePath: Self.regularUrl.path)?.domain)
#expect("test" == NginxConfigurationFile.from(container, filePath: Self.regularUrl.path)?.tld)
}
@Test func can_determine_isolation() throws {
#expect(nil == NginxConfigurationFile.from(filePath: Self.regularUrl.path)?.isolatedVersion)
#expect("8.1" == NginxConfigurationFile.from(filePath: Self.isolatedUrl.path)?.isolatedVersion)
#expect(nil == NginxConfigurationFile.from(container, filePath: Self.regularUrl.path)?.isolatedVersion)
#expect("8.1" == NginxConfigurationFile.from(container, filePath: Self.isolatedUrl.path)?.isolatedVersion)
}
@Test func can_determine_proxy() throws {
let proxied = NginxConfigurationFile.from(filePath: Self.proxyUrl.path)!
let proxied = NginxConfigurationFile.from(container, filePath: Self.proxyUrl.path)!
#expect(proxied.contents.contains("# valet stub: proxy.valet.conf"))
#expect("http://127.0.0.1:90" == proxied.proxy)
let normal = NginxConfigurationFile.from(filePath: Self.regularUrl.path)!
let normal = NginxConfigurationFile.from(container, filePath: Self.regularUrl.path)!
#expect(false == normal.contents.contains("# valet stub: proxy.valet.conf"))
#expect(nil == normal.proxy)
}
@Test func can_determine_secured_proxy() throws {
let proxied = NginxConfigurationFile.from(filePath: Self.secureProxyUrl.path)!
let proxied = NginxConfigurationFile.from(container, filePath: Self.secureProxyUrl.path)!
#expect(proxied.contents.contains("# valet stub: secure.proxy.valet.conf"))
#expect("http://127.0.0.1:90" == proxied.proxy)
}
@Test func can_determine_proxy_with_custom_tld() throws {
let proxied = NginxConfigurationFile.from(filePath: Self.customTldProxyUrl.path)!
let proxied = NginxConfigurationFile.from(container, filePath: Self.customTldProxyUrl.path)!
#expect(proxied.contents.contains("# valet stub: secure.proxy.valet.conf"))
#expect("http://localhost:8080" == proxied.proxy)
}

View File

@@ -11,21 +11,25 @@ import Foundation
@Suite(.serialized)
class PhpConfigurationFileTest {
var container: Container
init() {
self.container = Container.real()
}
static var phpIniFileUrl: URL {
return TestBundle.url(forResource: "php", withExtension: "ini")!
}
@Test func can_load_extension() throws {
ActiveFileSystem.useSystem()
let iniFile = PhpConfigurationFile.from(filePath: Self.phpIniFileUrl.path)
let iniFile = PhpConfigurationFile.from(container, filePath: Self.phpIniFileUrl.path)
#expect(iniFile != nil)
#expect(!iniFile!.extensions.isEmpty)
}
@Test func can_check_key_existence() throws {
print(Self.phpIniFileUrl.path)
let iniFile = PhpConfigurationFile.from(filePath: Self.phpIniFileUrl.path)!
let iniFile = PhpConfigurationFile.from(container, filePath: Self.phpIniFileUrl.path)!
#expect(iniFile.has(key: "error_reporting"))
#expect(iniFile.has(key: "display_errors"))
@@ -33,7 +37,7 @@ class PhpConfigurationFileTest {
}
@Test func can_check_key_value() throws {
let iniFile = PhpConfigurationFile.from(filePath: Self.phpIniFileUrl.path)!
let iniFile = PhpConfigurationFile.from(container, filePath: Self.phpIniFileUrl.path)!
#expect(iniFile.get(for: "error_reporting") != nil)
#expect(iniFile.get(for: "error_reporting") == "E_ALL")
@@ -46,8 +50,7 @@ class PhpConfigurationFileTest {
let destination = Utility
.copyToTemporaryFile(resourceName: "php", fileExtension: "ini")!
let configurationFile = PhpConfigurationFile
.from(filePath: destination.path)!
let configurationFile = PhpConfigurationFile.from(container, filePath: destination.path)!
// 0. Verify the original value
#expect(configurationFile.get(for: "error_reporting") == "E_ALL")

View File

@@ -11,8 +11,10 @@ import Foundation
@Suite(.serialized)
struct PhpExtensionTest {
var container: Container
init () async throws {
ActiveShell.useSystem()
container = Container.real()
}
static var phpIniFileUrl: URL {
@@ -20,13 +22,13 @@ struct PhpExtensionTest {
}
@Test func can_load_extension() throws {
let extensions = PhpExtension.from(filePath: Self.phpIniFileUrl.path)
let extensions = PhpExtension.from(container, filePath: Self.phpIniFileUrl.path)
#expect(!extensions.isEmpty)
}
@Test func extension_name_is_correct() throws {
let extensions = PhpExtension.from(filePath: Self.phpIniFileUrl.path)
let extensions = PhpExtension.from(container, filePath: Self.phpIniFileUrl.path)
let extensionNames = extensions.map { (ext) -> String in
return ext.name
@@ -45,7 +47,7 @@ struct PhpExtensionTest {
}
@Test func extension_status_is_correct() throws {
let extensions = PhpExtension.from(filePath: Self.phpIniFileUrl.path)
let extensions = PhpExtension.from(container, filePath: Self.phpIniFileUrl.path)
// xdebug should be enabled
#expect(extensions[0].enabled == true)
@@ -56,7 +58,7 @@ struct PhpExtensionTest {
@Test func toggle_works_as_expected() async throws {
let destination = Utility.copyToTemporaryFile(resourceName: "php", fileExtension: "ini")!
let extensions = PhpExtension.from(filePath: destination.path)
let extensions = PhpExtension.from(container, filePath: destination.path)
#expect(extensions.count == 6)
// Try to disable xdebug (should be detected first)!
@@ -71,6 +73,6 @@ struct PhpExtensionTest {
#expect(file.contains("; zend_extension=\"xdebug.so\""))
// Make sure if we load the data again, it's disabled
#expect(PhpExtension.from(filePath: destination.path).first!.enabled == false)
#expect(PhpExtension.from(container, filePath: destination.path).first!.enabled == false)
}
}

View File

@@ -74,7 +74,7 @@ struct RealFileSystemTest {
"\(temporaryDirectory)/brew/etc/lib/c"
)
let contents = try! FileSystem.getShallowContentsOfDirectory("\(temporaryDirectory)/brew/etc/lib/c")
let contents = try! filesystem.getShallowContentsOfDirectory("\(temporaryDirectory)/brew/etc/lib/c")
#expect([] == contents)
}

View File

@@ -11,8 +11,9 @@ import Foundation
@Suite(.serialized)
struct TestableFileSystemTest {
var container: Container
init() throws {
ActiveFileSystem.useTestable([
container = Container.fake(files: [
"/home/user/bin/foo": .fake(.binary),
"/home/user/docs": .fake(.symlink, "/home/user/documents"),
"/home/user/documents/script.sh": .fake(.text, "echo 'cool';"),
@@ -22,6 +23,10 @@ struct TestableFileSystemTest {
])
}
var FileSystem: FileSystemProtocol {
return container.filesystem
}
@Test func testable_fs_is_in_use() {
#expect(FileSystem is TestableFileSystem)
}

View File

@@ -11,10 +11,15 @@ import Foundation
@Suite(.serialized)
struct RealShellTest {
var container: Container
init() async throws {
// Reset to the default shell
ActiveShell.useSystem()
container = Container.real()
}
var Shell: ShellProtocol {
return container.shell
}
@Test func system_shell_is_default() async {

View File

@@ -63,8 +63,8 @@ struct TestableShellTest {
}
@Test func fake_shell_has_path() {
ActiveShell.useTestable([:])
let container = Container.fake()
#expect(Shell.PATH == "/usr/local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin")
#expect(container.shell.PATH == "/usr/local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin")
}
}

View File

@@ -11,6 +11,8 @@ import Foundation
struct TestableConfigurationTest {
@Test func configuration_can_be_saved_as_json() async {
let container = Container.real()
// WORKING
var configuration = TestableConfigurations.working
@@ -39,8 +41,8 @@ struct TestableConfigurationTest {
)
// Verify that the files were written to disk
#expect(FileSystem.fileExists(NSHomeDirectory() + "/.phpmon_fconf_working.json"))
#expect(FileSystem.fileExists(NSHomeDirectory() + "/.phpmon_fconf_working_no_valet.json"))
#expect(FileSystem.fileExists(NSHomeDirectory() + "/.phpmon_fconf_broken.json"))
#expect(container.filesystem.fileExists(NSHomeDirectory() + "/.phpmon_fconf_working.json"))
#expect(container.filesystem.fileExists(NSHomeDirectory() + "/.phpmon_fconf_working_no_valet.json"))
#expect(container.filesystem.fileExists(NSHomeDirectory() + "/.phpmon_fconf_broken.json"))
}
}

View File

@@ -9,9 +9,10 @@
import XCTest
class PhpVersionDetectionTest: XCTestCase {
func test_can_detect_valid_php_versions() async throws {
let outcome = await App.shared.container.phpEnvs.extractPhpVersions(
let container = Container.real()
let outcome = await container.phpEnvs.extractPhpVersions(
from: [
"", // empty lines should be omitted
"php@8.0",