diff --git a/.github/workflows/sync-main-to-next.yml b/.github/workflows/sync-main-to-next.yml deleted file mode 100644 index cc53b0ca88..0000000000 --- a/.github/workflows/sync-main-to-next.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Sync main to next - -on: - schedule: - - cron: '*/10 * * * *' - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -concurrency: - group: sync-main-to-next - cancel-in-progress: false - -jobs: - sync: - name: Merge main into next - runs-on: ubuntu-latest - steps: - - name: Checkout next - uses: actions/checkout@v5 - with: - ref: next - fetch-depth: 0 - - - name: Merge main into next - env: - GH_TOKEN: ${{ github.token }} - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git fetch origin main next - - if git merge --no-edit origin/main; then - git push origin HEAD:next - exit 0 - fi - - conflicts=$(git diff --name-only --diff-filter=U) - git merge --abort - - if [ -z "$conflicts" ]; then - echo 'The merge failed without conflicts, so no pull request was created.' - exit 1 - fi - - sync_branch='automation/sync-main-to-next' - existing_pr=$(gh pr list --base next --head "$sync_branch" --state open --json url --jq '.[0].url') - if [ -n "$existing_pr" ]; then - # The conflict was already reported when this pull request was opened. - # Stay green until it is merged instead of failing every scheduled run. - echo "::notice title=main to next sync blocked::main still conflicts with next; resolve the open pull request: $existing_pr" - exit 0 - fi - - git push --force origin origin/main:"refs/heads/$sync_branch" - gh pr create \ - --base next \ - --head "$sync_branch" \ - --title 'chore: merge main into next' \ - --body 'This pull request was created automatically because main could not be merged into next without conflicts. Resolve conflicts on this temporary branch; never update main with next.' - - echo '::error title=main to next sync blocked::main could not be merged into next without conflicts; a pull request was opened to resolve them.' - exit 1 diff --git a/.github/workflows/sync-main-to-release.yml b/.github/workflows/sync-main-to-release.yml new file mode 100644 index 0000000000..6ea7ea0768 --- /dev/null +++ b/.github/workflows/sync-main-to-release.yml @@ -0,0 +1,81 @@ +name: Sync main to release branches + +on: + schedule: + - cron: '*/10 * * * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: sync-main-to-release + cancel-in-progress: false + +jobs: + sync: + name: Merge main into release branches + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Merge main into every release/v* branch + env: + GH_TOKEN: ${{ github.token }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git fetch origin main '+refs/heads/release/v*:refs/remotes/origin/release/v*' + + branches=$(git for-each-ref --format='%(refname:lstrip=3)' 'refs/remotes/origin/release/v*') + if [ -z "$branches" ]; then + echo 'No release/v* branch exists, so there is nothing to sync.' + exit 0 + fi + + failed=0 + for branch in $branches; do + echo "::group::Sync main into $branch" + git checkout --quiet -B "$branch" "origin/$branch" + + if git merge --no-edit origin/main; then + git push origin "HEAD:refs/heads/$branch" + echo '::endgroup::' + continue + fi + + conflicts=$(git diff --name-only --diff-filter=U) + git merge --abort + echo '::endgroup::' + + if [ -z "$conflicts" ]; then + echo "::error title=main to $branch sync failed::The merge failed without conflicts, so no pull request was created." + failed=1 + continue + fi + + sync_branch="automation/sync-main-to-${branch//\//-}" + existing_pr=$(gh pr list --base "$branch" --head "$sync_branch" --state open --json url --jq '.[0].url') + if [ -n "$existing_pr" ]; then + # The conflict was already reported when this pull request was opened. + # Stay green until it is merged instead of failing every scheduled run. + echo "::notice title=main to $branch sync blocked::main still conflicts with $branch; resolve the open pull request: $existing_pr" + continue + fi + + git push --force origin origin/main:"refs/heads/$sync_branch" + gh pr create \ + --base "$branch" \ + --head "$sync_branch" \ + --title "chore: merge main into $branch" \ + --body "This pull request was created automatically because main could not be merged into $branch without conflicts. Resolve conflicts on this temporary branch; never update main with $branch." + + echo "::error title=main to $branch sync blocked::main could not be merged into $branch without conflicts; a pull request was opened to resolve them." + failed=1 + done + + exit "$failed" diff --git a/AGENTS.md b/AGENTS.md index fa5c428160..a57f2c2c4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -235,8 +235,8 @@ The persistent-storage implementation is the reference pattern: `Project\Service ## Git Workflow - Production branch: `main` -- Development branch: `next` -- Fix PRs should target the current production branch; feature PRs should target `next` +- Development branch: `release/v4.x`, where `x` is the next minor release (for example, `release/v4.5` while production is `4.4.x`). Do not use `next`. +- Fix PRs should target the current production branch; feature PRs should target the current `release/v4.x` branch === foundation rules === diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 626f6d7b93..977b90919a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -193,11 +193,12 @@ Choose the branch based on the type of change: | --- | --- | --- | | Fixes and small improvements | `main` | `main` | | Security fixes | `main` | `main` | -| New features and larger changes | `next` | `next` | +| New features and larger changes | `release/v4.x` | `release/v4.x` | - For a fix, branch from `main` and target `main`. -- For a feature, branch from `next` and target `next`. -- If a fix is discovered while developing a feature, submit it separately to `main`. Maintainers will merge `main` into `next` so the fix is included there too. +- For a feature, branch from `release/v4.x` and target `release/v4.x`. +- `x` is the number of the next minor release. For example, when the production version is `4.4.x`, use `release/v4.5`. There is no `next` branch. +- If a fix is discovered while developing a feature, submit it separately to `main`. Maintainers will merge `main` into `release/v4.x` so the fix is included there too. - Pull requests targeting the wrong branch may be closed or asked to retarget. diff --git a/RELEASE.md b/RELEASE.md index 3733392a48..15cc64527d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -5,52 +5,54 @@ | Branch | Purpose | | --- | --- | | `main` | Latest production source | -| `next` | Feature integration and RC releases | -| `feature/*` | New features based on and merged into `next` | +| `release/v4.x` | Feature integration and RC releases for the next minor release, such as `release/v4.5` | +| `feature/*` | New features based on and merged into the current `release/v4.x` branch | | `hotfix/X.Y.Z` | Production fixes based on `main` | +`x` is the number of the next minor release. When `4.4.x` is the production version, the release branch is `release/v4.5`. Create a new `release/v4.x` branch from `main` after each stable minor release. Do not use a long-lived `next` branch. + Release workflows never edit or commit versions. Stable versions come from `config/constants.php`; RC versions come from `coolify.nightly.version` in `versions.json` and `other/nightly/versions.json`. ## Where changes go - Fixes, security updates, and small improvements target `main`. -- New features and larger changes target `next`. -- Merge `main` into `next` regularly so every production fix is included in the next release. -- Do not merge `next` into `main` until an RC is approved for a stable release. +- New features and larger changes target the current `release/v4.x` branch. +- **Sync main to release branches** merges `main` into every `release/v*` branch every 10 minutes, so every production fix is included in the next release. If a merge conflicts, it opens a `chore: merge main into release/vX.Y` pull request to resolve. +- Do not merge `release/v4.x` into `main` until an RC is approved for a stable release. ## Feature and RC flow ```text -feature/* → next → RC +feature/* → release/v4.x → RC ``` -1. Merge feature branches into `next`. -2. Set `coolify.nightly.version` in both version files to the intended RC, such as `4.4-rc.1`. -3. Regular `next` builds publish `sha-`, `4.4-rc.1.`, and the moving `next` tag. They never publish the exact `4.4-rc.1` tag. -4. Create a reviewed draft GitHub Release named `v4.4-rc.1` and mark it as a prerelease. -5. Run **Release Coolify RC** manually from `next` and enter `v4.4-rc.1`. -6. The workflow validates the draft and configured nightly version, builds the exact RC, publishes `4.4-rc.1`, updates `next`, and publishes the draft prerelease. +1. Merge feature branches into `release/v4.x`. +2. Set `coolify.nightly.version` in both version files to the intended RC, such as `4.5-rc.1`. +3. Every push to `release/v4.x` runs **Build Coolify Release Branch**. It calculates the version from the branch name and publishes `4.5-dev.` and the moving `4.5-dev` tag. It never publishes an RC or stable tag. +4. Create a reviewed draft GitHub Release named `v4.5-rc.1` and mark it as a prerelease. +5. Run **Release Coolify RC** manually from `release/v4.x` and enter `v4.5-rc.1`. +6. The workflow validates the draft and configured nightly version, builds the exact RC, publishes `4.5-rc.1`, updates the `next` image tag, and publishes the draft prerelease. 7. Advance `coolify.nightly.version` to the next intended RC version. ## Stable release flow ```text -next → main → stable release +release/v4.x → main → stable release ``` -1. Temporarily stop merging features into `next`. -2. Change the version on `next` from the approved RC to the stable version, such as `4.4.0`. -3. Merge `next` into `main`. -4. Create a reviewed draft GitHub Release named `v4.4.0`. +1. Temporarily stop merging features into `release/v4.x`. +2. Change the version on `release/v4.x` from the approved RC to the stable version, such as `4.5.0`. +3. Merge `release/v4.x` into `main`. +4. Create a reviewed draft GitHub Release named `v4.5.0`. 5. Run the stable release workflow from `main`. -6. The workflow rebuilds the exact stable version, publishes `4.4.0` and `latest`, then publishes the draft. +6. The workflow rebuilds the exact stable version, publishes `4.5.0` and `latest`, then publishes the draft. 7. Update the CDN only after the release is approved. -8. Advance `next` to the next development version. +8. Create the next release branch from `main`, such as `release/v4.6`, and set its development version. Delete the old release branch. ## Hotfix flow ```text -main → hotfix/X.Y.Z → main → next +main → hotfix/X.Y.Z → main → release/v4.x ``` 1. Create `hotfix/X.Y.Z` from `main` when a patch needs an integration branch. A single fix may use a normal branch from `main` instead. @@ -59,7 +61,7 @@ main → hotfix/X.Y.Z → main → next 4. Merge the fix into `main`. 5. Create a reviewed draft GitHub Release named `vX.Y.Z`. 6. Run the stable release workflow from `main`. -7. Merge `main` into `next`, resolve the version in favor of the next intended RC, and delete the hotfix branch if one was used. +7. Merge `main` into `release/v4.x`, resolve the version in favor of the next intended RC, and delete the hotfix branch if one was used. 8. Update the CDN only after the release is approved. ## Image tags @@ -67,9 +69,11 @@ main → hotfix/X.Y.Z → main → next | Tag | Meaning | | --- | --- | | `latest` | Latest stable release | -| `next` | Latest successful `next` build | +| `next` | Latest successful `next` build (legacy branch) | +| `X.Y-dev` | Latest successful `release/vX.Y` build | +| `X.Y-dev.` | Exact `release/vX.Y` commit build | | `X.Y.Z` | Exact stable release | | `X.Y-rc.N` | Exact RC release | | `sha-` | Exact commit build | -Git tags use the `v` prefix, such as `v4.4.0`. Docker image tags do not. +Git tags use the `v` prefix, such as `v4.5.0`. Docker image tags do not. diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 8dd63f85d7..a0edd8cb60 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -4964,8 +4964,8 @@ class ApplicationsController extends Controller ], ], 422); } - $customLabels = base64_decode($request->custom_labels); - if (mb_detect_encoding($customLabels, 'UTF-8', true) === false) { + $customLabels = decodeBase64EncodedLabels($request->custom_labels); + if ($customLabels === null) { return response()->json([ 'message' => 'Validation failed.', 'errors' => [ @@ -4973,6 +4973,7 @@ class ApplicationsController extends Controller ], ], 422); } + $request->offsetSet('custom_labels', base64_encode($customLabels)); } if ($request->has('domains') && $server->isProxyShouldRun()) { $uuid = $request->uuid; diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 6946d9a687..6af782d1cc 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -84,7 +84,7 @@ class GithubPrivateRepositoryDeployKey extends Component public function mount() { if (isDev()) { - $this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/v4.x'; + $this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/main'; } $this->parameters = get_route_parameters(); $this->query = request()->query(); diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index a031c50c00..a44aa050d9 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -89,7 +89,7 @@ class PublicGitRepository extends Component public function mount() { if (isDev()) { - $this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/v4.x'; + $this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/main'; $this->port = 3000; } $this->parameters = get_route_parameters(); diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index 4eb05ad0ad..d8ce2e03fc 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -31,6 +31,13 @@ class ExecuteContainerCommand extends Component public bool $containersLoaded = false; + /** + * Container picker entries. Alpine reads them through entangle, because containers load after the first render. + * + * @var list + */ + public array $containerOptions = []; + protected $rules = [ 'server' => 'required', 'container' => 'required', @@ -149,6 +156,10 @@ class ExecuteContainerCommand extends Component $this->containers = $this->containers->sortBy(function ($container) { return data_get($container, 'container.Names'); }); + $this->containerOptions = $this->containers->map(fn (array $container) => [ + 'value' => $this->containerTarget($container), + 'label' => data_get($container, 'container.Names').' · '.data_get($container, 'server.name'), + ])->values()->all(); if ($this->containers->count() === 1) { $this->selected_container = $this->containerTarget($this->containers->first()); diff --git a/app/Models/Application.php b/app/Models/Application.php index d69c7f3ad5..3b0ac88ce5 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -2441,18 +2441,18 @@ class Application extends BaseModel public function parseContainerLabels(?ApplicationPreview $preview = null) { - $customLabels = data_get($this, 'custom_labels'); - if (! $customLabels) { + $storedLabels = data_get($this, 'custom_labels'); + if (! $storedLabels) { return; } - if (base64_encode(base64_decode($customLabels, true)) !== $customLabels) { - $this->custom_labels = str($customLabels)->replace(',', "\n"); - $this->custom_labels = base64_encode($customLabels); - } - $customLabels = base64_decode($this->custom_labels); - if (mb_detect_encoding($customLabels, 'UTF-8', true) === false) { + + $customLabels = decodeBase64EncodedLabels($storedLabels); + if ($customLabels === null && ! isBase64Encoded($storedLabels)) { + $customLabels = $storedLabels; + } elseif ($customLabels === null) { $customLabels = str(implode('|coolify|', generateLabelsApplication($this, $preview)))->replace('|coolify|', "\n"); } + $this->custom_labels = base64_encode($customLabels); $this->save(); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index ae4d28b4bb..42abc2dcb0 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -2723,6 +2723,42 @@ function isBase64Encoded($strValue) { return base64_encode(base64_decode($strValue, true)) === $strValue; } + +function decodeBase64EncodedLabels(string $value): ?string +{ + if (! isBase64Encoded($value)) { + return null; + } + + $decoded = base64_decode($value, true); + if ($decoded === false) { + return null; + } + $labels = $decoded; + + while ($decoded !== '' && isBase64Encoded($decoded)) { + $next = base64_decode($decoded, true); + if ($next === false) { + break; + } + $decoded = $next; + if (mb_detect_encoding($decoded, 'UTF-8', true) !== false) { + $lines = preg_split('/\r\n|\n|\r/', $decoded); + if ($lines === false) { + break; + } + $containsOnlyLabels = collect($lines) + ->filter(fn (string $line) => $line !== '') + ->every(fn (string $line) => str_contains($line, '=') && ! str_starts_with($line, '=')); + + if ($containsOnlyLabels) { + $labels = $decoded; + } + } + } + + return mb_detect_encoding($labels, 'UTF-8', true) === false ? null : $labels; +} function customApiValidator(Collection|array $item, array $rules, array $messages = []) { if (is_array($item)) { diff --git a/config/constants.php b/config/constants.php index 657d393ab6..28b36469e2 100644 --- a/config/constants.php +++ b/config/constants.php @@ -5,7 +5,7 @@ $sshCommandTimeout = (int) env('SSH_COMMAND_TIMEOUT', 3600); return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.4.2', + 'version' => env('COOLIFY_VERSION') ?: '4.4.3', 'helper_version' => '1.0.18', 'railpack_version' => '0.40.1', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 3f4323c7ea..40f297c2a1 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.4.2" + "version": "4.4.3" }, "nightly": { "version": "4.5-rc.1" diff --git a/resources/css/app.css b/resources/css/app.css index 407b1d9874..9423c24430 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2317,7 +2317,7 @@ html[data-theme="custom"] textarea:disabled { gap: 0.5rem; width: 100%; min-width: 0; - height: 2.25rem; + height: 2rem; padding: 0 0.625rem 0 0.75rem; overflow: hidden; border-radius: 8px; diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index ba045f303c..13ae6277f4 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -65,8 +65,7 @@ class="menu-item justify-between !bg-neutral-100 dark:!bg-white/[0.04] hover:!bg-neutral-200 dark:hover:!bg-white/[0.07] !text-fg-faint"> - Search page & global + Search diff --git a/resources/views/livewire/project/shared/execute-container-command.blade.php b/resources/views/livewire/project/shared/execute-container-command.blade.php index ef179a4ec9..2d974c0bb1 100644 --- a/resources/views/livewire/project/shared/execute-container-command.blade.php +++ b/resources/views/livewire/project/shared/execute-container-command.blade.php @@ -33,10 +33,6 @@ $consoleThemeKeys = collect($consoleThemes)->pluck('key')->values(); $consoleThemeNames = collect($consoleThemes)->pluck('name', 'key'); $consoleThemeAccents = collect($consoleThemes)->pluck('accent', 'key'); - $containerOptions = $containers->map(fn ($container) => [ - 'value' => data_get($container, 'server.uuid').':'.data_get($container, 'container.Names'), - 'label' => data_get($container, 'container.Names').' · '.data_get($container, 'server.name'), - ])->values(); @endphp @if (in_array($type, ['application', 'database', 'service', 'server'], true)) @@ -78,7 +74,7 @@ containerOpen: false, targetChosen: @js($selected_container !== 'default'), selectedContainer: @entangle('selected_container').live, - containerOptions: @js($containerOptions), + containerOptions: @entangle('containerOptions'), init() { const savedTheme = localStorage.getItem('coolify-console-theme'); this.consoleTheme = this.themeKeys.includes(savedTheme) ? savedTheme : 'system'; diff --git a/tests/Feature/Api/ApplicationSettingsApiTest.php b/tests/Feature/Api/ApplicationSettingsApiTest.php index daa60ea77f..14cf1e1c1a 100644 --- a/tests/Feature/Api/ApplicationSettingsApiTest.php +++ b/tests/Feature/Api/ApplicationSettingsApiTest.php @@ -267,6 +267,34 @@ test('http basic auth updates preserve user-managed labels', function () { expect(base64_decode($this->application->fresh()->custom_labels))->toBe('sentinel-label=true'); }); +test('nested base64 custom labels are stored in canonical form', function () { + $labels = "traefik.enable=true\ntraefik.http.routers.web.rule=Host(`example.com`)"; + + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'custom_labels' => base64_encode(base64_encode($labels)), + ]) + ->assertOk(); + + expect($this->application->fresh()->custom_labels)->toBe(base64_encode($labels)); +}); + +test('deployment parsing repairs historically nested custom labels', function () { + $labels = "traefik.enable=true\ntraefik.http.routers.web.rule=Host(`example.com`)"; + $this->application->update(['custom_labels' => base64_encode(base64_encode($labels))]); + + expect($this->application->parseContainerLabels())->toBe($labels) + ->and($this->application->fresh()->custom_labels)->toBe(base64_encode($labels)); +}); + +test('deployment parsing keeps commas in raw custom label values', function () { + $labels = "traefik.enable=true\ntraefik.http.routers.web.middlewares=gzip,redirect-to-https"; + $this->application->update(['custom_labels' => $labels]); + + expect($this->application->parseContainerLabels())->toBe($labels) + ->and($this->application->fresh()->custom_labels)->toBe(base64_encode($labels)); +}); + test('http basic auth hash settings are saved and applied to managed labels', function () { $this->application->update([ 'fqdn' => 'https://app.example.com', diff --git a/tests/Unit/CustomLabelEncodingTest.php b/tests/Unit/CustomLabelEncodingTest.php new file mode 100644 index 0000000000..b871c336c1 --- /dev/null +++ b/tests/Unit/CustomLabelEncodingTest.php @@ -0,0 +1,39 @@ +toBe($labels); +}); + +it('decodes single encoded container labels without changing them', function () { + $labels = "traefik.enable=true\ncom.example.version=1"; + + expect(decodeBase64EncodedLabels(base64_encode($labels)))->toBe($labels); +}); + +it('rejects values that are not valid base64', function () { + expect(decodeBase64EncodedLabels('traefik.enable=true'))->toBeNull(); +}); + +it('handles an empty encoded value', function () { + expect(decodeBase64EncodedLabels(''))->toBe(''); +}); + +it('does not decode label text that happens to be valid base64', function () { + expect(decodeBase64EncodedLabels(base64_encode('foo=')))->toBe('foo='); +}); + +it('decodes labels through an intermediate layer that is not label text', function () { + $labels = 'traefik.enable=true'; + + expect(decodeBase64EncodedLabels(base64_encode(base64_encode(base64_encode($labels)))))->toBe($labels); +}); + +it('preserves label text when a further decoded layer is not UTF-8', function () { + expect(decodeBase64EncodedLabels(base64_encode(base64_encode('foo='))))->toBe('foo='); +}); + +it('rejects encoded non UTF-8 text', function () { + expect(decodeBase64EncodedLabels(base64_encode("\xff")))->toBeNull(); +}); diff --git a/tests/v4/Browser/CommandPaletteSettingsSearchTest.php b/tests/v4/Browser/CommandPaletteSettingsSearchTest.php index 75654a1840..09460cc3be 100644 --- a/tests/v4/Browser/CommandPaletteSettingsSearchTest.php +++ b/tests/v4/Browser/CommandPaletteSettingsSearchTest.php @@ -21,7 +21,7 @@ it('shows the settings of the open resource in the command palette and switches $page = visit(applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->application)); $page->assertSee('Palette App') ->assertMissing('[aria-label="Filter settings"]') - ->assertVisible('button:visible:has-text("Search page & global")'); + ->assertVisible('button:visible[title^="Search this page"]'); $page->script("window.dispatchEvent(new Event('open-global-search'))"); @@ -49,7 +49,7 @@ it('does not show the scope switch on pages without a settings sidebar', functio loginAndSkipBoarding(); $page = visit('/dashboard'); - $page->assertDontSee('page & global'); + $page->assertMissing('button[title^="Search this page"]'); $page->script("window.dispatchEvent(new Event('open-global-search'))"); $page->assertVisible('.command-palette-input') @@ -65,7 +65,7 @@ it('focuses the search input when the palette is opened from the mobile menu', f $page->assertSee('Palette App') ->click('button:visible:has-text("Open sidebar")') - ->assertSee('Search page & global') + ->assertVisible('button:visible[title^="Search this page"]') ->screenshot(filename: 'command-palette-mobile-menu') ->click('button:visible:has-text("Search")') ->assertVisible('.command-palette-input') @@ -112,7 +112,7 @@ it('searches the sidebar pages of other settings pages', function (string $pageT loginAndSkipBoarding(); $page = visit($url); - $page->assertVisible('button:visible:has-text("Search page & global")'); + $page->assertVisible('button:visible[title^="Search this page"]'); $page->script("window.dispatchEvent(new Event('open-global-search'))"); $page->assertVisible('.command-palette-scope') @@ -135,7 +135,7 @@ it('searches the sections of a page without a settings sidebar and scrolls to th loginAndSkipBoarding(); $page = visit('/profile'); - $page->assertVisible('button:visible:has-text("Search page & global")'); + $page->assertVisible('button:visible[title^="Search this page"]'); $page->script("window.dispatchEvent(new Event('open-global-search'))"); $page->assertVisible('.command-palette-scope') diff --git a/tests/v4/Browser/TerminalContainerPickerTest.php b/tests/v4/Browser/TerminalContainerPickerTest.php new file mode 100644 index 0000000000..d1a49338cd --- /dev/null +++ b/tests/v4/Browser/TerminalContainerPickerTest.php @@ -0,0 +1,67 @@ +set('app.maintenance.store', 'array'); + config()->set('constants.ssh.mux_enabled', false); + + $stack = seedBrowserResourceStack(); + $stack['server']->settings->update(['is_terminal_enabled' => true]); + $this->application = createBrowserApplication($stack, ['uuid' => 'app-terminal-picker']); + $this->terminalPath = "/project/{$stack['project']->uuid}/environment/{$stack['environment']->uuid}/application/{$this->application->uuid}/terminal"; + Cache::flush(); + + // An open WebSocket that accepts the connection and waits (it only reacts to a client frame). + $this->terminalServer = new SymfonyProcess(['node', base_path('tests/Fixtures/terminal-websocket-rejection-server.mjs'), 'reject-token']); + $this->terminalServer->start(); + $this->terminalServer->waitUntil(fn (string $type, string $output) => str_contains($output, "\n")); + config()->set('constants.terminal.protocol', 'ws'); + config()->set('constants.terminal.host', '127.0.0.1'); + config()->set('constants.terminal.port', trim($this->terminalServer->getOutput())); +}); + +afterEach(function () { + $this->terminalServer?->stop(0); +}); + +it('lists the lazily loaded containers when the user must choose one', function () { + Process::fake(function ($process) { + $command = (string) $process->command; + + if (str_contains($command, 'docker ps -a')) { + return Process::result(output: collect(['app-terminal-picker-web', 'app-terminal-picker-worker']) + ->map(fn (string $name) => json_encode(['ID' => md5($name), 'Names' => $name, 'State' => 'running', 'Labels' => ''])) + ->implode("\n")); + } + + // The chosen container stops before the session starts, so no WebSocket session is needed. + if (str_contains($command, 'docker inspect')) { + return Process::result(output: json_encode(['State' => ['Status' => 'exited']])); + } + + return Process::result(); + }); + + $page = visit('/login') + ->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertSee('Dashboard') + ->navigate($this->terminalPath) + ->assertSee('Start a terminal session') + ->assertSee('app-terminal-picker-web') + ->assertSee('app-terminal-picker-worker') + ->screenshot(filename: 'terminal-container-picker-list') + ->click('[data-terminal-target-picker="launcher"] button:nth-of-type(2)') + ->assertSee('The container is not running.') + ->assertDontSee('Start a terminal session') + ->assertSee('app-terminal-picker-worker · localhost'); + + $page->screenshot(filename: 'terminal-container-picker-selected'); +}); diff --git a/versions.json b/versions.json index 3f4323c7ea..40f297c2a1 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.4.2" + "version": "4.4.3" }, "nightly": { "version": "4.5-rc.1"