chore: merge main into release/v4.5 (#12115)

This commit is contained in:
Andras Bacsai
2026-10-08 08:38:13 +02:00
committed by GitHub
21 changed files with 319 additions and 121 deletions
-65
View File
@@ -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
@@ -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"
+2 -2
View File
@@ -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
<laravel-boost-guidelines>
=== foundation rules ===
+4 -3
View File
@@ -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.
+27 -23
View File
@@ -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-<short-sha>`, `4.4-rc.1.<short-sha>`, 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.<short-sha>` 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.<short-sha>` | Exact `release/vX.Y` commit build |
| `X.Y.Z` | Exact stable release |
| `X.Y-rc.N` | Exact RC release |
| `sha-<commit>` | 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.
@@ -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;
@@ -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();
@@ -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();
@@ -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<array{value: string, label: string}>
*/
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());
+8 -8
View File
@@ -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();
+36
View File
@@ -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)) {
+1 -1
View File
@@ -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),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
"version": "4.4.2"
"version": "4.4.3"
},
"nightly": {
"version": "4.5-rc.1"
+1 -1
View File
@@ -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;
+1 -2
View File
@@ -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">
<span class="flex items-center gap-2.5 min-w-0">
<x-reicon name="search" class="menu-item-icon" />
<span class="menu-item-label truncate" :class="collapsed && 'lg:hidden'">Search<span
x-show="hasPageSearch" x-cloak class="text-[11px] opacity-70"> page &amp; global</span></span>
<span class="menu-item-label truncate" :class="collapsed && 'lg:hidden'">Search</span>
</span>
<kbd class="px-1.5 py-0.5 text-[11px] font-medium text-fg-faint bg-neutral-200 dark:bg-white/[0.06] rounded-md border border-transparent dark:border-white/5"
:class="collapsed && 'lg:hidden'" x-text="modKeyLabel + 'K'"></kbd>
@@ -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';
@@ -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',
+39
View File
@@ -0,0 +1,39 @@
<?php
it('decodes nested base64 container labels to label text', function () {
$labels = "traefik.enable=true\ntraefik.http.routers.web.rule=Host(`example.com`)";
expect(decodeBase64EncodedLabels(base64_encode(base64_encode($labels))))->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();
});
@@ -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')
@@ -0,0 +1,67 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Process;
use Symfony\Component\Process\Process as SymfonyProcess;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->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');
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
"version": "4.4.2"
"version": "4.4.3"
},
"nightly": {
"version": "4.5-rc.1"