chore: remove unused components and markup-only tests

- Delete Blade components, component classes, Livewire components and
  views that nothing uses after the redesign.
- Delete tests that only compare Blade/CSS source text; keep the source
  checks for authorization gates, stable wire:key values and escaping.
- Update stale tests to the current UI and behaviour.
- Update DESIGN.md to the current navigation and layout.
- Add a lesson: test behaviour, not source markup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-09-28 00:18:16 +02:00
co-authored by Claude Opus 5.5
parent a32082b03f
commit a79ee909c6
225 changed files with 2000 additions and 6991 deletions
+5
View File
@@ -17,6 +17,11 @@
- Do not add billing restrictions, live reconciliation, or fallback behavior unless the request includes them.
- Treat implementation constraints as details. Do not expand a requested team-level control into a more complex policy model.
## Test behavior, not source markup
- Do not write tests that read Blade or CSS files as text to assert layout, classes, or copy; they break on every redesign and miss real failures.
- Render the component (`Livewire::test()`, an HTTP request) and assert the result, including authorization; use a few `tests/v4/Browser` tests with screenshots for visual flows.
- A source-text check is acceptable only for a correctness or security rule that rendering cannot prove easily, such as stable `wire:key` values or no raw user output.
## Keep dynamic Livewire identities stable
- In dynamic lists, key components and actions with immutable record identities, not counts, indexes, or array positions.
- Use targeted refresh events. Do not refresh a parent and a child that the parent can remove or hide during the same operation.
+105 -115
View File
@@ -20,10 +20,9 @@ compact divided list, not legacy green check SVGs or fixed-width status rows.
> interactions while changing layout and presentation.
> - Add or update tests when a UI change affects behavior. Follow the testing
> requirements in `AGENTS.md`.
> - Validate Blade with `docker exec coolify php artisan view:cache`, then clear
> it with `docker exec coolify php artisan view:clear`.
> - Build frontend assets in the Vite container with
> `docker exec coolify-vite npm run build`.
> - Validate Blade with `./scripts/dev exec php artisan view:cache`, then clear
> it with `./scripts/dev exec php artisan view:clear`.
> - Build frontend assets with `npm run build`.
> - Use existing components before adding another styling abstraction.
---
@@ -42,7 +41,7 @@ The interface is compact and product-focused:
- the Coolify purple brand accent in light mode;
- the readable Coolify yellow accent in dark mode;
- solid active-item fills (neutral black/white opacity), not accent gradients;
active state is the left accent rail plus a flat selected surface;
active state is a flat selected surface with no accent rail;
- sentence-case labels and headings;
- never use the em dash (`—`) in UI copy. Prefer a period, colon, comma, or
ASCII hyphen (`-`) for empty cells and separators.
@@ -59,17 +58,18 @@ and focus-visible controls retain the accent ring alongside the depth.
Movement and depth-shadow changes transition over 80ms; color transitions keep
the shared 120ms duration.
Standard button labels use `capitalize`, giving each word an initial capital.
Highlighted buttons mix the accent equally with black for a pronounced bottom
edge, so custom theme colors produce a matching edge instead of a generic one.
Dark mode matches the depth edge of neutral buttons to their regular border
color. Highlighted buttons keep their dark, color-matched accent edge.
Highlighted buttons use `--color-coollabs-300` for their bottom edge; the
custom theme derives it from its bright color mixed with black, so custom
colors keep a matching edge. Dark mode matches the depth edge of neutral
buttons to their regular border color (`rgb(255 255 255 / 0.08)`).
---
## 2. Development and cascade notes
PHP runs in the `coolify` container. The development app is normally available
at `http://localhost:8000`, with Vite on port `5173`.
PHP runs in this branch's Coolify container (`./scripts/dev exec …`). The main
checkout serves the app at `http://localhost:8000` with Vite on `5173`;
worktrees use the port block printed by `./scripts/dev urls`.
`resources/css/app.css` still contains unlayered global element rules for
headings, labels, and tables. Tailwind utilities are layered, so the
@@ -89,8 +89,8 @@ Important consequences:
- add shared surface overrides to the unlayered block instead of stacking
`!important` utilities;
- listbox panels require ancestors with `overflow: visible`;
- anchored cards use `scroll-margin-top: 7rem` to clear both fixed navigation
layers;
- anchored cards use `scroll-margin-top: 7rem` to clear the fixed topbar and
the page's top padding;
- modal shells reuse the layer-card classes but keep content-width sizing on
desktop;
- Alpine code inside quoted Blade attributes must not introduce conflicting
@@ -104,11 +104,11 @@ The surface ladder is defined in `resources/css/app.css`.
| Token | Light | Dark | Use |
|---|---|---|---|
| `--coollabs-canvas` | 97% off-white | 10% neutral | page canvas (kept below card fills so cards lift) |
| `--coollabs-elevated` | 98% neutral | 15% neutral | shells and card headers |
| `--coollabs-base` | white | 17% neutral | nested card bodies |
| `--coollabs-recessed` | 96% neutral | 20% neutral | inputs and listboxes |
| `--coollabs-fill` | 92.2% neutral | 26.9% neutral | dividers and passive fills |
| `--coollabs-canvas` | 97% off-white | 14.48% neutral | page canvas (kept below card fills so cards lift) |
| `--coollabs-elevated` | 98% neutral | 20.02% neutral | shells and card headers |
| `--coollabs-base` | white | 22.64% neutral | nested card bodies |
| `--coollabs-recessed` | 96% neutral | 25.2% neutral | inputs and listboxes |
| `--coollabs-fill` | 92.2% neutral | 29.31% neutral | dividers and passive fills |
| `--coollabs-line` | translucent dark | 32% neutral | control borders |
| `--coollabs-hairline` | 85.5% neutral | 32% neutral | shell rings (crisp enough to read as a card edge, ~1.5:1) |
| `--coollabs-subtle` | 50% neutral | 70.8% neutral | labels and muted titles (light darkened for WCAG AA 4.5:1) |
@@ -137,17 +137,17 @@ The app shell is three distinct surface layers, not one flat color. Chrome
lifts, content is the base, cards lift off the content:
- **Content canvas** is the base layer: `bg-app` in dark (deepest,
`--color-app` `#0a0a0b`), `bg-gray-50` in light. The `<main>` content area
and page body use it.
`--color-app` `oklch(14.48% 0 0)`, sRGB 10), `bg-neutral-50` in light. The
`<main>` content area and page body use it.
- **Sidebar and topbar chrome** use `bg-panel` in dark (`--color-panel`
`#141418`, a clear step lighter than the content canvas) and `bg-white` in
light, so the chrome reads as a separate panel from the content.
`oklch(19.13% 0 0)`, sRGB 20, a clear step lighter than the content canvas)
and `bg-white` in light, so the chrome reads as a separate panel from the content.
Dark surface tokens are hex, not oklch. oklch lightness compresses toward pure
black below ~15% (oklch(10%) renders as sRGB 3, oklch(15%) as sRGB 11), so oklch
values there give no visible step between layers. The dark ladder is
`--color-app` 10, `--coollabs-elevated` 22, `--coollabs-base` 28,
`--coollabs-recessed` 34 (sRGB), which reads as distinct surfaces.
Dark surface tokens are exact oklch equivalents of chosen sRGB steps. oklch
lightness compresses toward pure black below ~15%, so do not pick dark values by
round oklch percentages. The dark ladder is `--color-app` 10,
`--coollabs-elevated` 22, `--coollabs-base` 28, `--coollabs-recessed` 34 (sRGB),
which reads as distinct surfaces.
Temperature: every panel is **pure neutral gray** (r=g=b), one consistent
temperature across the sidebar, tables, cards, inputs, dividers, borders, and
@@ -171,60 +171,54 @@ color. Row-hover states keep the lighter `dark:hover:bg-white/[0.025]`.
- Main sidebar groups are compact, use outline Reicons, and keep a 32px row
height.
- Active sidebar rows are rounded pills (`rounded-md`) with an accent rail on
the left plus a solid neutral selected fill (`bg-black/5` light,
`bg-white/6` dark). Hover rows use the same radius. Do not use accent-tinted
gradients on nav rows; yellow washes look muddy on dark UI.
- Nested items use a thin guide line with a visible active segment, not a thick
box border.
- Active sidebar rows are rounded pills (`rounded-md`) with a solid neutral
selected fill (`bg-black/5` light, `bg-white/6` dark) and no accent rail.
Hover rows use the same radius. Do not use accent-tinted gradients on nav
rows; yellow washes look muddy on dark UI.
- Nested items sit behind a thin 1px guide line (`.nav-children`) and use the
same selected pill, not a thick box border.
- The update badge sits on the version row and uses a tiny fully rounded
primary-action pill.
### Layer-2 navigation
### Resource navigation
Application and server pages use the same fixed second navigation layer
directly below the global topbar. Do not keep a large in-flow resource heading
or legacy `.navbar-main` tabs on one resource type while using the compact
layer-2 bar on another. Active tabs are a light brand fill:
- purple tint in light mode;
- yellow tint in dark mode;
- no fully saturated tab background.
Application, service, database, and server pages have no second navigation
bar; content starts directly below the 48px global topbar. Every route in a
resource family (settings pages, backups, logs, terminal, metrics, danger zone)
is an entry in its grouped settings sidebar. Do not add a tab row, a large
in-flow resource heading, or legacy `.navbar-main` tabs to one resource type.
Keep route-derived active state in Blade/Livewire. Do not rely only on Alpine
state because it can disappear after polling or a Livewire morph.
The global topbar owns the current resource identity and its compact status
badges. Layer 2 owns route tabs, resource links, and contextual action buttons
only. If a resource is missing from `x-top-breadcrumb`, extend the global
topbar instead of repeating its name or status summary in layer 2. Mobile
resource navigation may repeat this context because the desktop global topbar
is hidden there.
The global topbar owns the current resource identity, its compact status
badges, configuration warnings (`#configuration-warning-hud-slot`), and the
resource actions (`#resource-action-hud-slot`). If a resource is missing from
`x-top-breadcrumb`, extend the global topbar instead of repeating its name or
status summary in the page. Below `xl` the resource heading repeats the name,
status, and links in-flow because the desktop HUD is hidden there.
Desktop resource lifecycle actions dock in `#resource-action-hud-slot` and
use `<x-resource-heading-overflow>`. Show primary actions (Deploy, Redeploy,
Restart, Stop) as sibling header buttons. Collapse that group into an Actions
dropdown only when the remaining top-bar width cannot fit them (breadcrumb
keeps a 200px floor). Infrequent operations live in a separate Advanced
dropdown with the grid icon: force restart / force deploy / force cleanup
on services, and Traefik dashboard / refresh proxy status on servers. Place
Advanced immediately after Links, or first in the action cluster when there
is no Links control. Application Deploy is a dropdown with Deploy and
Deploy (without cache). A running service Restart control is a dropdown with
Restart current version and Pull latest and restart. Mobile
headings keep a full-width Actions dropdown because the desktop HUD is hidden
below `xl`. Do not hide primary actions behind a menu on a wide desktop. Links
stay a separate dropdown because the URL list is unbounded.
Desktop resource actions dock in `#resource-action-hud-slot` (visible from
`xl`) as one `<x-split-action>`. The main button is the primary action for the
current state (Deploy, Restart, Start, Restart Proxy); the caret opens a menu
with the secondary actions: Deploy (without cache) and Restart on applications,
Pull latest and restart / Force Restart / Force Deploy / Force Cleanup
Containers on services, Refresh Proxy Status on servers. Stop is the last menu
item in the error color. Stop, restart, and removal items open the existing
confirmation modals. Do not add a separate Advanced dropdown or collapse actions
into an overflow menu. Place Links (`x-applications.links`, `x-services.links`)
or the server Traefik Dashboard link immediately before the split action; Links
stay a separate dropdown because the URL list is unbounded. Below `xl` the same
split action renders full width under the in-flow resource name. A resource
that cannot deploy yet shows a single Actions dropdown that explains why.
Only add layer-2 tabs when they represent real sibling routes inside one
context. Never repeat main-sidebar destinations such as Dashboard, Projects,
Terminal, Servers, Sources, Destinations, or Storage as a second tab row. A
single collection page does not need a tab just to fill the bar; keep its
primary action in the page header instead. When tabs are useful, their left edge
uses the same compact `pl-2` alignment as application navigation rather than
the content container's wide horizontal padding.
The only fixed tab strip left is `x-dashboard.navbar`, and it renders only when
a page has at least two real sibling routes or header actions. Never repeat
main-sidebar destinations such as Dashboard, Projects, Terminal, Servers,
Sources, Destinations, or Storage as a tab row. A single collection page does
not need a tab just to fill the bar; keep its primary action in the page header.
A layer-2 tab must be active on the page that renders it. A bar whose only tab
A tab must be active on the page that renders it. A bar whose only tab
points at a different route reads as broken navigation, so project and
environment pages (`project.show`, `project.edit`, `project.environment.edit`,
`project.clone-me`) carry a plain page header with a 24px title and a 13px
@@ -232,11 +226,11 @@ muted summary instead of a bar. The environment identity and the way back to
its resources already live in `x-top-breadcrumb`; do not restate them in a
sub-header.
The dashboard is a compact overview, not a metrics wall. Use two full-width
sections that follow the projects-page grid pattern: projects first, then
servers. Keep one `New` action in the page header and let its modal choose the
resource type. Place active deployments above the resource grids as a compact,
live-updating table rather than a metric card. Communicate server health with
The dashboard is a compact overview, not a metrics wall. It has no page header:
live active deployments come first as a compact table, followed by traffic
analytics when a server has it enabled, then two full-width sections (Projects,
then Servers) that follow the projects-page grid pattern. Each section uses
`x-section-heading` linking to its full index. Communicate server health with
the shared status badge.
### Top-level dashboard destinations
@@ -258,9 +252,9 @@ cards. Sources, destinations, S3 storage, private keys, and shared-variable
scopes use this pattern.
Top-level settings families such as Team, Notifications, Keys & Tokens, and
instance Settings use a compact header followed by a small route-derived tab
strip. The active tab uses the same purple-light/yellow-dark tint as resource
tabs. Do not nest `<button>` elements inside tab links.
instance Settings use their `*settings-layout` component: the same grouped,
icon-led settings sidebar as resources, plus a `settings-mobile-header` title
below `xl`. Do not nest `<button>` elements inside sidebar links.
### Route-family consistency
@@ -270,11 +264,10 @@ its index or most visible route:
- index, create, detail, settings, logs, metrics, backup, execution, and danger
routes must share the same navigation hierarchy and surface language;
- main-sidebar collection routes use the global shell without duplicating those
destinations in a layer-2 tab row;
- resource detail families use resource identity and status in the global
topbar, route tabs and actions in layer 2, and the grouped settings sidebar
only for the third level;
- create and edit routes stay inside the same layer-2 family instead of
destinations in a tab row;
- resource detail families use resource identity, status, and actions in the
global topbar, and put every sibling route in the grouped settings sidebar;
- create and edit routes stay inside the same sidebar family instead of
falling back to an isolated legacy page;
- reusable partials, empty states, confirmation flows, and row editors must be
updated with the page that exposes them;
@@ -292,40 +285,37 @@ content below the filter card.
### Settings workspace
Application and server configuration pages use the same 210px grouped,
icon-led sidebar and a full-width content column. The workspace is capped at
1180px, the sidebar becomes sticky at `xl`, and the sidebar label and first
content card start on the same visual line. Do not use the legacy
`sub-menu-wrapper`, native mobile page selects, or an in-flow row of top-level
tabs. Only show nested section anchors when a page has at least four useful
sections.
Application, service, database, server, and top-level settings pages use the
same 210px grouped, icon-led sidebar and a full-width content column. From `xl`
the sidebar is a fixed full-height rail below the topbar that tracks the main
sidebar width, with its own fill, right hairline, filter input, and scroll. Below
`xl` it becomes a wrapped grid of links above the content. Do not use the legacy
`sub-menu-wrapper`, native mobile page selects, or a row of top-level tabs. Only
show nested section anchors when a page has at least four useful sections.
The shared workspace grid is:
```blade
<div
class="application-settings-workspace mt-8 grid min-w-0 gap-8
xl:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
...
</aside>
<div class="min-w-0 xl:mt-3">
...
<section class="application-settings-workspace w-full max-w-none">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<x-application.configuration-sidebar :application="$application" :current-route="$currentRoute" />
<div class="min-w-0">
...
</div>
</div>
</div>
</section>
```
Instance Settings constrains both `x-settings.navbar` and the workspace to the
same `max-w-[1180px]` shell.
Instance Settings uses `x-settings.layout` with the same full-width workspace.
**Page titles (global):** family H1s (`x-dashboard.navbar` with
`titleOnDesktop="false"`, the default) hide at **lg+**, the same breakpoint as
the desktop shell (main sidebar + fixed layer-2 tabs). Below `lg` the mobile
topbar is used and the page title stays visible. Collection indexes (Servers,
Projects, …) always keep their H1; stack title above actions on narrow widths
so they never overlap. Resource in-flow names only render below `md` (when the
fixed resource tab bar is hidden). Fixed layer-2 spacers must be `lg:h-12` to
match the bar height. Do not put the H1 beside the settings sidebar.
**Page titles (global):** `x-dashboard.navbar` H1s (`titleOnDesktop="false"`,
the default) hide at **lg+** only when the page renders a fixed tab strip or
actions; otherwise they stay visible. Collection indexes (Servers, Projects, …)
always keep their H1; stack title above actions on narrow widths so they never
overlap. Application, service, and database names render in-flow below `xl`
(the desktop action HUD breakpoint), server names below `lg`, and settings
families use `settings-mobile-header` below `xl`. Fixed tab-strip spacers must
be `lg:h-12` to match the bar height. Do not put the H1 beside the settings sidebar.
Standard content stack:
@@ -761,11 +751,11 @@ Use these as implementation references:
|---|---|
| Dashboard overview | `resources/views/livewire/dashboard.blade.php` |
| Top-level collection cards | `resources/views/livewire/project/index.blade.php`, `resources/views/source/all.blade.php` |
| Top-level family tabs | `resources/views/components/team/navbar.blade.php`, `resources/views/components/notification/navbar.blade.php` |
| Top-level settings families | `resources/views/components/team/settings-layout.blade.php`, `resources/views/components/notification/settings-layout.blade.php` |
| General settings and form anatomy | `resources/views/livewire/project/application/general.blade.php` |
| Advanced settings | `resources/views/livewire/project/application/advanced.blade.php` |
| Fixed layer-2 resource navigation | `resources/views/livewire/project/application/heading.blade.php`, `resources/views/livewire/server/navbar.blade.php` |
| Grouped settings sidebar | `resources/views/livewire/project/application/configuration.blade.php`, `resources/views/components/server/sidebar.blade.php` |
| Resource actions in the topbar | `resources/views/livewire/project/application/heading.blade.php`, `resources/views/components/split-action.blade.php`, `resources/views/livewire/server/navbar.blade.php` |
| Grouped settings sidebar | `resources/views/components/application/configuration-sidebar.blade.php`, `resources/views/components/server/sidebar.blade.php` |
| Dense environment table and footer | `resources/views/livewire/project/shared/environment-variable/all.blade.php` |
| Standard table toolbar controls | `resources/views/components/table/*` |
| Application metrics charts | `resources/views/livewire/project/shared/metrics.blade.php` |
@@ -795,7 +785,7 @@ oversized 200px status numbers.
2. Read the current Blade and Livewire class before changing presentation.
3. Preserve every existing action, authorization check, loading state, and
confirmation.
4. Add the correct dual navigation and scoped workspace/form class.
4. Add the grouped settings sidebar and scoped workspace/form class.
5. Convert meaningful groups to layer cards and use `gap-6`.
6. Make the responsive column count match the controls visible in every state.
7. Replace native selects and checkbox-style configuration with listboxes.
@@ -808,6 +798,6 @@ oversized 200px status numbers.
14. Check fixed-nav anchor offsets and responsive stacking.
15. Sweep every sibling route for legacy controls and shells.
16. Run `git diff --check`.
17. Compile Blade views in the `coolify` container.
18. Build assets in `coolify-vite`.
17. Compile Blade views with `./scripts/dev exec php artisan view:cache`.
18. Build assets with `npm run build`.
19. Hard-refresh and inspect the family routes in both themes.
+1 -39
View File
@@ -1704,7 +1704,7 @@ html[data-theme="custom"] textarea:disabled {
/* crisp ring + a restrained lift so the card separates from the canvas
(shadcn-style), not a heavy floating shadow */
box-shadow: 0 0 0 1px var(--coollabs-hairline), 0 1px 2px 0 rgb(0 0 0 / 0.05);
/* keep anchored scrolls (scrollIntoView) clear of the fixed topbar + layer-2 nav */
/* keep anchored scrolls (scrollIntoView) clear of the fixed topbar and page padding */
scroll-margin-top: 7rem;
}
@@ -2120,44 +2120,6 @@ html[data-theme="custom"] textarea:disabled {
overflow: visible;
}
.resource-heading-overflow {
display: inline-flex;
align-items: center;
}
.resource-heading-overflow-items {
display: flex;
align-items: center;
gap: 0.125rem;
}
.resource-heading-overflow-items.is-measuring {
display: flex !important;
visibility: hidden !important;
position: absolute !important;
inset: auto auto 0 0 !important;
flex-direction: row !important;
width: max-content !important;
height: auto !important;
max-width: none !important;
max-height: none !important;
overflow: visible !important;
pointer-events: none !important;
padding: 0 !important;
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.resource-heading-overflow.is-collapsed .resource-heading-overflow-items > .button,
.resource-heading-overflow.is-collapsed .resource-heading-overflow-items > a.button {
width: 100%;
justify-content: flex-start;
height: auto;
min-height: 2rem;
padding: 0.375rem 0.625rem;
}
/* Heading action group: buttons + dropdown triggers styled like the tabs */
.application-heading-actions .button,
.application-heading-actions .app-tab,
+1 -1
View File
@@ -27,7 +27,7 @@
@if ($dashboardProjects->isEmpty())
<x-empty title="No projects yet"
description="Use New to create your first deployment workspace."
description="Create your first deployment workspace from Projects."
icon-name="projects" size="sm" />
@else
<div class="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
@@ -1,29 +1,4 @@
<nav wire:poll.10000ms="checkStatus" class="w-full max-w-none pb-4 md:pb-6 lg:pb-0">
@php
$routeIs = fn (string|array $routes): bool => \Illuminate\Support\Str::is($routes, $activeRouteName);
// Settings covers all configuration sub-pages (General, Webhooks, Domains, …),
// not only project.application.configuration. Primary tabs that are NOT settings:
// backups, console, deployment logs, runtime logs.
$isSettingsRoute = $routeIs('project.application.*')
&& ! $routeIs([
'project.application.backup.*',
'project.application.command',
'project.application.deployment.*',
'project.application.logs',
]);
$applicationMenuItems = [
[
'label' => 'Settings',
'route' => 'project.application.configuration',
'active' => $isSettingsRoute,
],
];
$applicationMenuItems = array_values(array_filter(
$applicationMenuItems,
fn (array $item): bool => $item['visible'] ?? true,
));
@endphp
<div>
<div class="mb-3 w-full xl:hidden">
<div class="flex min-w-0 flex-col items-start gap-2">
@@ -1,47 +1,5 @@
<nav wire:poll.10000ms="checkStatus" class="w-full max-w-none pb-4 md:pb-6 lg:pb-0">
@php
$servicePageItems = [
[
'label' => 'Settings',
'route' => 'project.service.configuration',
'active' => request()->routeIs('project.service.configuration')
|| request()->routeIs('project.service.domains')
|| request()->routeIs('project.service.environment-variables')
|| request()->routeIs('project.service.storages')
|| request()->routeIs('project.service.scheduled-tasks*')
|| request()->routeIs('project.service.webhooks')
|| request()->routeIs('project.service.resource-operations')
|| request()->routeIs('project.service.analytics')
|| request()->routeIs('project.service.tags')
|| request()->routeIs('project.service.danger')
|| request()->routeIs('project.service.index*')
|| request()->routeIs('project.service.database.*'),
],
[
'label' => 'Backups',
'route' => 'project.service.volume-backups.index',
'active' => request()->routeIs('project.service.volume-backups.*'),
],
[
'label' => 'Runtime Logs',
'route' => 'project.service.logs',
'active' => request()->routeIs('project.service.logs'),
'navigate' => false,
],
[
'label' => 'Terminal',
'route' => 'project.service.command',
'active' => request()->routeIs('project.service.command'),
'navigate' => false,
'visible' => auth()->user()?->can('canAccessTerminal'),
],
];
$servicePageItems = array_values(array_filter(
$servicePageItems,
fn (array $item): bool => $item['visible'] ?? true,
));
$serviceStatus = str($service->status ?? 'exited');
$selectedResourceUuid = data_get($parameters, 'stack_service_uuid');
$selectedResource = $selectedResourceUuid
@@ -1,13 +1,7 @@
<?php
use App\Livewire\Tags\Deployments as TagDeployments;
use App\Livewire\Team\AdminView;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -46,39 +40,3 @@ test('team admin view checks instance admin access when it renders', function ()
expect(fn () => (new AdminView)->render())->toThrow(HttpException::class);
});
test('tag deployments include only applications in the current team', function () {
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'owner']);
$this->actingAs($user);
session(['currentTeam' => $team]);
$createDeployment = function (Team $owner, string $name): Application {
$server = Server::factory()->create(['team_id' => $owner->id]);
$project = Project::factory()->create(['team_id' => $owner->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create(['environment_id' => $environment->id]);
ApplicationDeploymentQueue::create([
'application_id' => $application->id,
'application_name' => $name,
'deployment_uuid' => fake()->uuid(),
'server_id' => $server->id,
'server_name' => $server->name,
'status' => 'queued',
]);
return $application;
};
$owned = $createDeployment($team, 'Owned deployment');
$foreign = $createDeployment(Team::factory()->create(), 'Foreign deployment');
$component = Livewire::test(TagDeployments::class)
->set('resourceIds', [$owned->id, $foreign->id])
->call('getDeployments');
$names = collect($component->get('deploymentsPerTagPerServer'))->flatten(1)->pluck('application_name');
expect($names->all())->toContain('Owned deployment')
->not->toContain('Foreign deployment');
});
@@ -5,7 +5,7 @@ use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Exceptions\CannotUpdateLockedPropertyException;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
@@ -109,43 +109,6 @@ test('removes stale Content-Length after injecting the HUD', function () {
expect($response->headers->has('Content-Length'))->toBeFalse();
});
test('app shell exposes Server-Timing HUD dock slots in desktop and mobile top bars', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($layout)
->toContain('id="server-timing-hud-slot"')
->toContain('id="server-timing-hud-slot-mobile"')
->toContain('data-server-timing-hud-slot');
});
test('Server-Timing HUD docks into navbar slots and floats only as fallback', function () {
$hud = file_get_contents(resource_path('views/components/server-timing-hud.blade.php'));
expect($hud)
->toContain('server-timing-hud-slot')
->toContain('server-timing-hud-slot-mobile')
->toContain("matchMedia('(min-width: 1024px)')")
->toContain('floats bottom-left only if no navbar slot is available')
// Both navbar pills stay compact (app ms only); the full breakdown lives in the panel/float fallback.
->toContain("root.getAttribute('data-sth-mode') === 'docked'")
->toContain('compactSummary')
->not->toContain("compactSummary = root.getAttribute('data-sth-mode') === 'docked'\n && root.parentElement");
});
test('Server-Timing HUD follows the application color mode', function () {
$hud = file_get_contents(resource_path('views/components/server-timing-hud.blade.php'));
expect($hud)
->toContain('#server-timing-hud {')
->toContain('html.dark #server-timing-hud {')
->toContain('--sth-background: rgba(255, 255, 255, .96)')
->toContain('--sth-background: rgba(16, 16, 16, .96)')
->toContain('var(--sth-text)')
->toContain('var(--sth-border)')
->toContain('[data-sth-log]::-webkit-scrollbar-thumb')
->toContain('scrollbar-color: var(--sth-scrollbar-thumb) var(--sth-scrollbar-track)');
});
test('does not inject HUD into non-HTML or fragment responses', function () {
Config::set('app.server_timing', true);
@@ -215,16 +178,3 @@ test('SERVER_TIMING_ENABLED can force server_timing on or off regardless of APP_
expect($resolve('0', 'local'))->toBeFalse();
expect($resolve('off', 'local'))->toBeFalse();
});
test('server timing HUD can be toggled from the development command center', function () {
$hud = file_get_contents(resource_path('views/components/server-timing-hud.blade.php'));
$commandCenter = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
expect($hud)
->toContain('coolify.serverTimingHud.enabled')
->toContain('server-timing-hud-visibility-changed')
->and($commandCenter)
->toContain("app()->environment('local')")
->toContain('Toggle Server Timing HUD')
->toContain("localStorage.setItem('coolify.serverTimingHud.enabled'")
->toContain("window.dispatchEvent(new CustomEvent('server-timing-hud-visibility-changed'))");
});
@@ -42,6 +42,7 @@ test('admin search only shows users with paid subscriptions as active', function
'search' => 'inactive@example.com',
])
->call('submitSearch')
->assertSee('No')
->assertDontSee('Yes');
->assertSee('inactive@example.com')
->assertSeeHtml('<span class="truncate">Inactive</span>')
->assertDontSeeHtml('<span class="truncate">Active</span>');
});
@@ -66,86 +66,6 @@ it('ignores docker compose one-off job containers in both status sources', funct
->toContain("filter_var(\$labels->get('com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)");
});
it('shows restart limit warnings for every resource family', function () {
$previews = file_get_contents(resource_path('views/livewire/project/application/previews.blade.php'));
$serviceCard = file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php'));
$applicationStatus = file_get_contents(resource_path('views/livewire/project/application/status.blade.php'));
$databaseStatus = file_get_contents(resource_path('views/livewire/project/database/status.blade.php'));
expect($previews)->toContain('<x-application.restart-limit-warning :application="$preview" />')
->and($serviceCard)->toContain('<x-application.restart-limit-warning :application="$resource" />')
->and($applicationStatus)->toContain('<x-application.restart-limit-warning :application="$application" />')
->and($databaseStatus)->toContain('<x-application.restart-limit-warning :application="$database" />');
$serviceStatus = file_get_contents(resource_path('views/livewire/project/service/status.blade.php'));
$serviceHeading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
expect($serviceStatus)->toContain('<x-application.restart-limit-warning :application="$selectedResource" />')
->and($serviceStatus)->toContain('$selectedResource?->status ?? $service->status')
->and($serviceHeading)->toContain('<x-application.restart-limit-warning :application="$selectedResource" />')
->and($serviceHeading)->toContain('$selectedResource?->status ?? $service->status');
});
it('matches application restart badge layout on mobile resource headings', function () {
$applicationHeading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$databaseHeading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
$serviceHeading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
foreach ([$applicationHeading, $databaseHeading, $serviceHeading] as $heading) {
expect($heading)
->toContain('class="relative flex w-full min-w-0 items-center gap-2"')
->toContain('class="flex w-full flex-wrap gap-1"');
}
});
it('shows database restart limits in shared resource listings', function () {
$resourceIndex = file_get_contents(app_path('Livewire/Project/Resource/Index.php'));
$serverResources = file_get_contents(resource_path('views/livewire/server/resources.blade.php'));
$destination = file_get_contents(resource_path('views/livewire/project/shared/destination.blade.php'));
expect($resourceIndex)
->toContain("method_exists(\$item, 'stoppedAfterRestartLimit')")
->not->toContain("\$type === 'application' && \$item->stoppedAfterRestartLimit()")
->and($serverResources)
->toContain('<x-application.restart-limit-warning :application="$resource" />')
->and($destination)
->toContain('<x-application.restart-limit-warning :application="$resource" />');
});
it('keeps the service resource table readable with horizontal scrolling on mobile', function () {
$configuration = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php'));
$resourceCard = file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php'));
expect($configuration)
->toContain("'overflow-x-auto rounded-xl")
->toContain('min-w-[48rem]')
->and($resourceCard)
->toContain('min-w-[48rem]')
->not->toContain('<div class="hidden truncate font-mono');
});
it('opens service resource settings when a table row is clicked', function () {
$resourceCard = file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php'));
expect($resourceCard)
->toContain('x-on:click="openSettings($event)"')
->toContain('x-on:keydown.enter="openSettings($event)"')
->toContain("closest('a, button')")
->toContain('role="link"')
->toContain('tabindex="0"');
});
it('uses selected service resource actions instead of parent complex status actions', function () {
$heading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
$headingClass = file_get_contents(app_path('Livewire/Project/Service/Heading.php'));
expect(substr_count($heading, "\$selectedResource && \$selectedResource->container_present !== false && \$selectedResourceStatus->startsWith('exited')"))->toBe(2)
->and($heading)
->toContain('Remove container')
->toContain('removeSelectedResourceContainer')
->and($headingClass)
->toContain('public function removeSelectedResourceContainer(): void');
});
it('imports the application model used when claiming a restart limit', function () {
$statusAction = file_get_contents(app_path('Actions/Docker/GetContainersStatus.php'));
@@ -2,6 +2,7 @@
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
@@ -13,6 +14,8 @@ use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
@@ -262,7 +262,7 @@ describe('POST /api/v1/databases/{uuid}/backups', function () {
$backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->first();
expect($backup)->not->toBeNull();
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
expect($backup->save_s3)->toBeTrue();
expect($backup->save_s3)->toBeTruthy();
expect($backup->team_id)->toBe($this->team->id);
});
@@ -310,7 +310,7 @@ describe('PATCH /api/v1/databases/{uuid}/backups/{scheduled_backup_uuid}', funct
$response->assertStatus(200);
$backup->refresh();
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
expect($backup->save_s3)->toBeTrue();
expect($backup->save_s3)->toBeTruthy();
});
test('rejects s3_storage_uuid from another team on update', function () {
+6 -3
View File
@@ -2,6 +2,7 @@
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
@@ -12,6 +13,8 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
@@ -45,7 +48,7 @@ test('returns domains for own team application via uuid query param', function (
->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$application->uuid}");
$response->assertOk();
$response->assertJsonFragment(['my-app.example.com']);
$response->assertExactJson(['https://my-app.example.com']);
});
test('returns 404 when application uuid belongs to another team', function () {
@@ -87,7 +90,7 @@ test('returns 404 when server uuid belongs to another team', function () {
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$response = $this->withHeaders(authHeaders())
$response = $this->withHeaders(domainApiAuthHeaders())
->getJson("/api/v1/servers/{$otherServer->uuid}/domains");
$response->assertNotFound();
@@ -112,7 +115,7 @@ test('only returns domains for applications on the specified server', function (
'destination_type' => $otherDestination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders())
$response = $this->withHeaders(domainApiAuthHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/domains");
$response->assertOk();
@@ -5,6 +5,7 @@ use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
@@ -460,6 +461,12 @@ describe('GitHub app API url normalization', function () {
'is_public' => false,
]);
// The GHE host is fictional, so resolve it to a public IP instead of hitting real DNS.
Http::macro('GitSource', fn (string $url) => Http::withOptions(SafeExternalUrl::httpClientOptions(
$url,
resolver: fn (string $host): array => ['93.184.216.34'],
)));
Http::preventStrayRequests();
Http::fake([
'https://api.octocorp.ghe.com/zen' => Http::response('Keep it logically awesome.', 200, [
+1 -1
View File
@@ -17,7 +17,7 @@ uses(RefreshDatabase::class);
beforeEach(function () {
// ApiAllowed middleware requires InstanceSettings with id=0
InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]);
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -12,7 +12,7 @@ use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
+24 -4
View File
@@ -1,6 +1,7 @@
<?php
use App\Livewire\Security\ApiTokens;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -9,15 +10,20 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->actingAs($this->user);
});
describe('token creation with expiration', function () {
beforeEach(function () {
$this->actingAs($this->user);
});
test('livewire component stores expires_at when expiresInDays set', function () {
Livewire::test(ApiTokens::class)
->set('description', 'test-token')
@@ -30,8 +36,8 @@ describe('token creation with expiration', function () {
expect($token)->not->toBeNull()
->and($token->expires_at)->not->toBeNull()
->and($token->expires_at->diffInDays(now()))->toBeGreaterThanOrEqual(6)
->and($token->expires_at->diffInDays(now()))->toBeLessThanOrEqual(7);
->and(now()->diffInDays($token->expires_at))->toBeGreaterThanOrEqual(6)
->and(now()->diffInDays($token->expires_at))->toBeLessThanOrEqual(7);
});
test('livewire component stores null expires_at when expiresInDays null (Never)', function () {
@@ -54,7 +60,21 @@ describe('token creation with expiration', function () {
->set('expiresInDays', 42)
->set('permissions', ['read'])
->call('addNewToken')
->assertHasErrors('expiresInDays');
->assertDispatched('error');
expect($this->user->tokens()->count())->toBe(0);
});
test('livewire component keeps Never (null) after an intermediate live update', function () {
Livewire::test(ApiTokens::class)
->set('description', 'never-token')
->set('expiresInDays', '')
->set('permissions', ['read', 'deploy'])
->set('permissions', ['read'])
->call('addNewToken')
->assertHasNoErrors();
expect($this->user->tokens()->latest()->first()->expires_at)->toBeNull();
});
});
@@ -36,7 +36,7 @@ describe('Application Model Buildpack Cleanup', function () {
expect($application->dockerfile)->toBeNull();
expect($application->dockerfile_location)->toBeNull();
expect($application->dockerfile_target_build)->toBeNull();
expect($application->custom_healthcheck_found)->toBeFalse();
expect((bool) $application->custom_healthcheck_found)->toBeFalse();
});
test('model clears dockerfile fields when build_pack changes from dockerfile to static', function () {
@@ -61,7 +61,7 @@ describe('Application Model Buildpack Cleanup', function () {
expect($application->dockerfile)->toBeNull();
expect($application->dockerfile_location)->toBeNull();
expect($application->dockerfile_target_build)->toBeNull();
expect($application->custom_healthcheck_found)->toBeFalse();
expect((bool) $application->custom_healthcheck_found)->toBeFalse();
});
test('model clears dockercompose fields when build_pack changes from dockercompose to nixpacks', function () {
@@ -142,7 +142,7 @@ describe('Application Model Buildpack Cleanup', function () {
expect($application->dockerfile)->toBeNull();
expect($application->dockerfile_location)->toBeNull();
expect($application->dockerfile_target_build)->toBeNull();
expect($application->custom_healthcheck_found)->toBeFalse();
expect((bool) $application->custom_healthcheck_found)->toBeFalse();
});
test('model clears dockercompose fields when build_pack changes from dockercompose to railpack', function () {
@@ -283,6 +283,6 @@ describe('Application Model Buildpack Cleanup', function () {
expect($application->build_pack)->toBe('dockerfile');
expect($application->dockerfile)->toBe('FROM alpine:latest');
expect($application->dockerfile_location)->toBe('/Dockerfile');
expect($application->custom_healthcheck_found)->toBeTrue();
expect((bool) $application->custom_healthcheck_found)->toBeTrue();
});
});
@@ -3,7 +3,10 @@
use App\Livewire\Project\Application\General;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -24,6 +27,13 @@ beforeEach(function () {
// Create project and environment
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
InstanceSettings::unguarded(function () {
InstanceSettings::updateOrCreate(['id' => 0], []);
});
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']);
});
describe('Buildpack Switching Cleanup', function () {
@@ -31,6 +41,8 @@ describe('Buildpack Switching Cleanup', function () {
// Create an application with dockerfile buildpack and dockerfile content
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockerfile',
'dockerfile' => 'FROM node:18\nHEALTHCHECK CMD curl -f http://localhost/ || exit 1',
'dockerfile_location' => '/Dockerfile',
@@ -39,10 +51,9 @@ describe('Buildpack Switching Cleanup', function () {
]);
// Switch to nixpacks buildpack
Livewire::test(General::class, ['application' => $application])
Livewire::test(General::class, ['application' => $application->fresh()])
->assertSuccessful()
->set('buildPack', 'nixpacks')
->call('updatedBuildPack');
->set('buildPack', 'nixpacks');
// Verify dockerfile fields were cleared
$application->refresh();
@@ -50,12 +61,14 @@ describe('Buildpack Switching Cleanup', function () {
expect($application->dockerfile)->toBeNull();
expect($application->dockerfile_location)->toBeNull();
expect($application->dockerfile_target_build)->toBeNull();
expect($application->custom_healthcheck_found)->toBeFalse();
expect((bool) $application->custom_healthcheck_found)->toBeFalse();
});
test('clears dockerfile fields when switching from dockerfile to static', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockerfile',
'dockerfile' => 'FROM nginx:alpine',
'dockerfile_location' => '/custom.Dockerfile',
@@ -63,30 +76,30 @@ describe('Buildpack Switching Cleanup', function () {
'custom_healthcheck_found' => true,
]);
Livewire::test(General::class, ['application' => $application])
Livewire::test(General::class, ['application' => $application->fresh()])
->assertSuccessful()
->set('buildPack', 'static')
->call('updatedBuildPack');
->set('buildPack', 'static');
$application->refresh();
expect($application->build_pack)->toBe('static');
expect($application->dockerfile)->toBeNull();
expect($application->dockerfile_location)->toBeNull();
expect($application->dockerfile_target_build)->toBeNull();
expect($application->custom_healthcheck_found)->toBeFalse();
expect((bool) $application->custom_healthcheck_found)->toBeFalse();
});
test('does not clear dockerfile fields when switching to dockerfile', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
'dockerfile' => null,
]);
Livewire::test(General::class, ['application' => $application])
Livewire::test(General::class, ['application' => $application->fresh()])
->assertSuccessful()
->set('buildPack', 'dockerfile')
->call('updatedBuildPack');
->set('buildPack', 'dockerfile');
// When switching TO dockerfile, fields remain as they were
$application->refresh();
@@ -96,15 +109,16 @@ describe('Buildpack Switching Cleanup', function () {
test('does not affect fields when switching between non-dockerfile buildpacks', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
'dockerfile' => null,
'dockerfile_location' => null,
]);
Livewire::test(General::class, ['application' => $application])
Livewire::test(General::class, ['application' => $application->fresh()])
->assertSuccessful()
->set('buildPack', 'static')
->call('updatedBuildPack');
->set('buildPack', 'static');
$application->refresh();
expect($application->build_pack)->toBe('static');
@@ -114,6 +128,8 @@ describe('Buildpack Switching Cleanup', function () {
test('clears dockerfile fields when switching from dockerfile to railpack', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockerfile',
'dockerfile' => 'FROM node:18',
'dockerfile_location' => '/Dockerfile',
@@ -121,37 +137,37 @@ describe('Buildpack Switching Cleanup', function () {
'custom_healthcheck_found' => true,
]);
Livewire::test(General::class, ['application' => $application])
Livewire::test(General::class, ['application' => $application->fresh()])
->assertSuccessful()
->set('buildPack', 'railpack')
->call('updatedBuildPack');
->set('buildPack', 'railpack');
$application->refresh();
expect($application->build_pack)->toBe('railpack');
expect($application->dockerfile)->toBeNull();
expect($application->dockerfile_location)->toBeNull();
expect($application->dockerfile_target_build)->toBeNull();
expect($application->custom_healthcheck_found)->toBeFalse();
expect((bool) $application->custom_healthcheck_found)->toBeFalse();
});
test('clears dockerfile fields when switching from dockerfile to dockercompose', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockerfile',
'dockerfile' => 'FROM alpine:latest',
'dockerfile_location' => '/docker/Dockerfile',
'custom_healthcheck_found' => true,
]);
Livewire::test(General::class, ['application' => $application])
Livewire::test(General::class, ['application' => $application->fresh()])
->assertSuccessful()
->set('buildPack', 'dockercompose')
->call('updatedBuildPack');
->set('buildPack', 'dockercompose');
$application->refresh();
expect($application->build_pack)->toBe('dockercompose');
expect($application->dockerfile)->toBeNull();
expect($application->dockerfile_location)->toBeNull();
expect($application->custom_healthcheck_found)->toBeFalse();
expect((bool) $application->custom_healthcheck_found)->toBeFalse();
});
});
@@ -334,8 +334,8 @@ describe('GitHub Source Change Component', function () {
->assertSet('webhook_endpoint', 'http://staging.example.com')
->assertSet('custom_webhook_endpoint', 'https://staging.example.com')
->assertSet('use_custom_webhook_endpoint', true)
->assertSee('Use custom webhook endpoint')
->assertSee('Selected endpoint')
->assertSee('Webhook endpoint')
->assertSee('Use a custom endpoint')
->assertSee('Custom endpoint')
->assertSee('createGithubApp(webhookEndpoint, useCustomWebhookEndpoint, customWebhookEndpoint');
});
@@ -689,7 +689,7 @@ describe('GitHub Source Change Component', function () {
->and($public->isConnected())->toBeTrue();
});
test('shows connected badge and test connection for installed github apps', function () {
test('shows test connection for installed github apps', function () {
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => validPrivateKey(),
@@ -713,8 +713,8 @@ describe('GitHub Source Change Component', function () {
->test(Change::class)
->assertSuccessful()
->assertSet('isConnected', true)
->assertSee('Connected')
->assertSee('Test Connection');
->assertSee('Test connection')
->assertSeeHtml('wire:click.prevent="testConnection"');
});
test('testConnection succeeds when github app credentials are valid', function () {
@@ -808,6 +808,6 @@ describe('GitHub Source Change Component', function () {
->assertSee('Finished GitHub App')
->assertSee('Connected')
->assertSee('Incomplete GitHub App')
->assertSee('Setup required');
->assertSee('Setup incomplete');
});
});
@@ -109,13 +109,16 @@ test('can update general settings when the application has a wildcard domain', f
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
'fqdn' => 'https://example.com,https://*.example.com',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'ports_exposes' => '3000',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
// Legacy wildcard domains can no longer be entered (the model normalizes them away on save),
// so seed the stored value directly, as it exists on older installs.
Application::query()->whereKey($application->id)->update(['fqdn' => 'https://example.com,https://*.example.com']);
$application->refresh();
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
@@ -24,33 +24,3 @@ it('updates container presence at application lifecycle boundaries', function ()
->and($sentinelStatus)->toContain("'container_present' => true")
->and($sentinelStatus)->toContain("'container_present' => false");
});
it('shows accurate destructive actions and the restart warning on mobile', function () {
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$mobileActions = str($heading)
->after('id="application-mobile-actions"')
->before('<div class="hidden" aria-hidden="true">')
->toString();
expect($heading)->toContain('<x-application.restart-limit-warning :application="$application" />')
->and(substr_count($heading, '$application->container_present !== false'))->toBe(2)
->and($heading)->toContain("\$application->stoppedAfterRestartLimit() ? 'Retry deployment' : 'Deploy'")
->and($heading)->toContain("\$application->stoppedAfterRestartLimit() ? 'Retry deployment (without cache)' : 'Deploy (without cache)'")
->and($heading)->toContain('Remove container')
->and($mobileActions)->toContain('Deploy (without cache)')
->and($mobileActions)->toContain('Remove container')
->and(strrpos($mobileActions, 'Remove container'))->toBeGreaterThan(strrpos($mobileActions, 'Deploy (without cache)'));
});
it('shows restart limit reached as a yellow state in environment resource lists', function () {
$indexClass = file_get_contents(app_path('Livewire/Project/Resource/Index.php'));
$indexView = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php'));
expect($indexClass)->toContain("'restartLimitReached' => \$type === 'application' && \$item->stoppedAfterRestartLimit()")
->and($indexClass)->toContain('? max($item->restart_count ?? 0, $item->max_restart_count ?? 0)')
->and($indexClass)->toContain("'maxRestartCount' => \$item->max_restart_count ?? 0")
->and($indexView)->toContain("if (item.restartLimitReached) {\n return 'restart-limit';")
->and($indexView)->toContain("return 'Restart limit reached';")
->and($indexView)->toContain("if (item.restartLimitReached) {\n return 'bg-warning';")
->and($indexView)->toContain('x-bind:title="statusTitle(item)"');
});
@@ -2,6 +2,7 @@
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
@@ -13,6 +14,8 @@ use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
+14 -122
View File
@@ -417,6 +417,8 @@ it('manages preview domains and their dns status', function () {
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->assertSet('domainRows.0.url', 'https://44.example.com')
->call('checkDomainDns', 0)
->assertSet('domainRows.0.dns_status', 'checking')
->call('pollDnsChecks')
->assertSet('domainRows.0.dns_status', 'skipped')
->set('newDomainParts.host', 'second.example.com')
->call('addDomain')
@@ -564,7 +566,7 @@ it('notifies when an asynchronous preview dns check finds a mismatch', function
$component->call('pollDnsChecks')
->assertSet('domainRows.0.dns_status', 'failed')
->assertDispatched('error', 'DNS is not configured for preview.example.com. Review the required DNS record.');
->assertDispatched('error', 'DNS is not configured for preview.example.com. Review the required DNS record. If you changed it recently, DNS propagation can take some time, so please try again later.');
});
it('does not overwrite a completed preview dns result with stale checking state', function () {
@@ -1318,6 +1320,8 @@ it('marks dns status as skipped when dns validation is disabled', function () {
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('checkAllDns')
->assertSet('domainRows.0.dns_status', 'checking')
->call('pollDnsChecks')
->assertSet('domainRows.0.dns_status', 'skipped');
$this->application->refresh();
@@ -1477,6 +1481,7 @@ it('persists dns status after checking a domain', function () {
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('checkDomainDns', 0)
->call('pollDnsChecks')
->assertSet('domainRows.0.dns_status', 'failed');
$this->application->refresh();
@@ -1518,7 +1523,7 @@ it('polls a queued dns check and notifies about a mismatch', function () {
$component->call('pollDnsChecks')
->assertSet('domainRows.0.dns_status', 'failed')
->assertDispatched('error', 'DNS is not configured for app.example.com. Review the required DNS record.');
->assertDispatched('error', 'DNS is not configured for app.example.com. Review the required DNS record. If you changed it recently, DNS propagation can take some time, so please try again later.');
});
it('does not overwrite a completed queued dns result with stale checking state', function () {
@@ -1600,7 +1605,7 @@ it('resolves hostname server addresses to a real ip for dns messages', function
->and(filter_var($resolvedIp, FILTER_VALIDATE_IP))->not->toBeFalse()
->and($component->get('domainRows.0.expected_ip'))->toBe($resolvedIp);
$component->call('checkAllDns');
$component->call('checkAllDns')->call('pollDnsChecks');
$message = $component->get('domainRows.0.dns_message');
$recordType = dnsRecordTypeForIp($resolvedIp);
@@ -1626,7 +1631,8 @@ it('uses short aaaa guidance when the server ip is ipv6', function () {
$settings->save();
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('checkDomainDns', 0);
->call('checkDomainDns', 0)
->call('pollDnsChecks');
expect($component->get('serverIp'))->toBe('2001:db8::10')
->and($component->get('domainRows.0.dns_message'))->toBe('Required DNS record type AAAA pointing to 2001:db8::10');
@@ -1649,7 +1655,8 @@ it('uses short a-record guidance for compose applications', function () {
$this->server->update(['ip' => '172.16.0.3']);
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('checkDomainDns', 0);
->call('checkDomainDns', 0)
->call('pollDnsChecks');
expect($component->get('domainRows.0.dns_message'))->toBe('Required DNS record type A pointing to 172.16.0.3')
->and($component->get('domainRows.0.dns_status'))->toBe('failed');
@@ -2169,66 +2176,12 @@ it('groups compose domains by service and hides empty services', function () {
->assertDontSee('No domains for this service');
});
it('uses the compact service domains layout for compose applications', function () {
it('keys compose domain groups by a stable service identity', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
expect($view)
->toContain('application-compose-domain-group-{{ $redirectWireKey }}')
->toContain('class="application-settings-section-body mt-1 scroll-mt-28')
->toContain('bg-neutral-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]')
->toContain('class="data-table-header service-domains-overview-grid"')
->toContain('<span>Domain redirect</span>')
->toContain('<span>Search indexing</span>')
->not->toContain('<span>Last checked</span>')
->not->toContain('id="edit-domain-direction"')
->toContain('wire:key="application-compose-domain-rows-{{ $redirectWireKey }}"')
->toContain('htmlId="application-domain-direction"')
->toContain('id="editingRedirect"')
->not->toContain("\$isCompose ? 'updateServiceRedirect' : 'updateRedirect'")
->not->toContain('title="No domains for this service"');
});
it('uses a compact two row domain card at medium widths', function () {
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toContain('@container service-domains (max-width: 980px)')
->toContain('grid-template-columns: minmax(0, 1fr) auto auto;')
->toContain('grid-template-areas: "domain dns actions" "summary summary summary";')
->toContain('grid-area: summary;');
});
it('uses concise search indexing headers in application and service domain tables', function () {
$applicationView = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
$serviceView = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
expect(substr_count($applicationView, '<span>Search indexing</span>'))
->toBe(1)
->and($serviceView)
->toContain('<span>Search indexing</span>');
});
it('shows a form save button at the bottom of application domain settings', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
expect($view)
->not->toContain('Indexing and redirect changes save automatically.')
->toContain('data-testid="domain-settings-scroll"')
->toContain('data-testid="domain-settings-footer"')
->toContain('class="shrink-0 border-t')
->toContain('<x-forms.button type="submit" wire:target="updateDomain" isHighlighted>')
->toContain('Save')
->not->toContain('<x-unsaved-bar action="updateDomain"');
});
it('opens application domain settings from browser data and shows a dns spinner', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
expect($view)
->not->toContain('wire:click="startEdit(')
->toContain('@click="openEditDomain(')
->toContain('<x-loading compact aria-label="Checking DNS"')
->not->toContain('<x-loading-on-button wire:loading.delay');
->toContain('wire:key="application-compose-domain-rows-{{ $redirectWireKey }}"');
});
it('uses the dns badge as progress for single and all application checks', function (string $action, array $parameters) {
@@ -2447,54 +2400,6 @@ it('saves regenerated compose domain drafts for only the selected service', func
->and($application->noindexDomains()->all())->toBe(["https://{$generatedHost}/api"]);
});
it('does not render a last checked column in the domains table', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
$row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
expect($view)->not->toContain('<span>Last checked</span>')
->and($row)->not->toContain('$checkedAt');
});
it('uses compact labeled domain cards on mobile', function () {
$styles = file_get_contents(resource_path('css/app.css'));
$row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
expect($styles)
->toContain('@container service-domains (max-width: 980px)')
->toContain('.service-domain-detail-label')
->toContain('.service-domains-overview-grid')
->toContain('@container service-domains (max-width: 600px)')
->toContain('.service-domain-mobile-summary')
->and($row)
->toContain('service-domain-mobile-summary')
->toContain('No redirects')
->toContain('Noindex');
});
it('uses segmented fields when adding and editing application domains', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
$component = file_get_contents(resource_path('views/components/forms/domain-input.blade.php'));
expect($view)
->toContain('<x-forms.domain-input id="newDomainParts"')
->toContain('<x-forms.domain-input id="editingDomainParts"')
->not->toContain('placeholder="https://app.example.com"')
->and($component)
->toContain('Protocol')
->toContain('Domain')
->toContain('Port')
->toContain('Path')
->toContain('wire:model="{{ $id }}.host"')
->toContain('<x-forms.listbox id="{{ $id }}.scheme"')
->not->toContain('<select id="{{ $id }}-protocol"')
->toContain("['value' => 'https', 'label' => 'https']")
->toContain("['value' => 'http', 'label' => 'http']")
->toContain('class="mb-1.5 flex h-4 w-full items-center gap-1.5"')
->not->toContain('class="mb-1.5 block text-sm font-medium"')
->toContain('min="1"')
->toContain('max="65535"');
});
it('updates a compose service redirect from the domains table', function () {
$this->application->update([
'build_pack' => 'dockercompose',
@@ -2517,19 +2422,6 @@ it('updates a compose service redirect from the domains table', function () {
->and(data_get($domains, 'web.domain'))->toContain('https://www.web.example.com');
});
it('provides client-side search for compose service domains', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
expect($view)
->toContain('x-model="domainSearch"')
->toContain('class="ml-auto flex flex-wrap items-center gap-2"')
->toContain('<div class="relative shrink-0">')
->toContain('placeholder="Search services or domains"')
->toContain('x-show="matchesDomainSearch(')
->toContain('title="No domains found"')
->toContain('hasDomainSearchResults(');
});
it('exposes the domains route in the application configuration menu', function () {
$url = route('project.application.domains', [
'project_uuid' => $this->project->uuid,
@@ -74,9 +74,10 @@ test('existing application buildpack selector lists railpack before nixpacks', f
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
// The build strategy listbox renders its options as JSON-encoded Alpine data
->assertSeeInOrder([
'<option value="railpack">Railpack</option>',
'<option value="nixpacks">Nixpacks</option>',
'\u0022value\u0022:\u0022railpack\u0022,\u0022label\u0022:\u0022Railpack\u0022',
'\u0022value\u0022:\u0022nixpacks\u0022,\u0022label\u0022:\u0022Nixpacks\u0022',
], false);
});
@@ -30,18 +30,6 @@ beforeEach(function () {
session(['currentTeam' => $this->team]);
});
it('uses card-style git source triggers instead of plain form buttons', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/source.blade.php'));
expect($view)
->toContain('<x-slot:trigger>')
->toContain('label="Current"')
->toContain('aria-current="true"')
->toContain('<x-git-icon')
->toContain('rounded-xl border')
->not->toContain('<x-slot:customButton>');
});
it('renders the current source as a selected card and alternatives as switchable cards', function () {
$current = GithubApp::create([
'name' => 'coolify-laravel-dev-public',
@@ -68,92 +68,6 @@ beforeEach(function () {
'destination_type' => $this->destination->getMorphClass(),
'status' => 'running',
]);
$this->routeParams = [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'application_uuid' => $this->application->uuid,
];
});
/**
* Settings tab must carry both the active class and aria-current so CSS
* under .application-heading-actions can override the base tab resets.
*/
function assertSettingsTabActive(string $html): void
{
expect(preg_match(
'/<a[^>]*(?:aria-current="page"[^>]*app-tab-active|app-tab-active[^>]*aria-current="page")[^>]*>\s*Settings\s*<\/a>/s',
$html
))->toBe(1);
// Desktop navbar CSS override must exist so active styles are visible.
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toContain(".application-heading-actions .app-tab[aria-current='page']")
->toContain('.application-heading-actions .app-tab.app-tab-active');
}
it('marks settings tab active on general configuration route', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$html = $this->get(route('project.application.configuration', $this->routeParams))
->assertSuccessful()
->getContent();
assertSettingsTabActive($html);
});
it('marks settings tab active on webhooks and other settings sub-routes', function (string $routeName) {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$html = $this->get(route($routeName, $this->routeParams))
->assertSuccessful()
->getContent();
assertSettingsTabActive($html);
})->with([
'webhooks' => 'project.application.webhooks',
'domains' => 'project.application.domains',
'advanced' => 'project.application.advanced',
'environment-variables' => 'project.application.environment-variables',
'danger' => 'project.application.danger',
]);
it('does not mark settings tab active on deployment logs', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$html = $this->get(route('project.application.deployment.index', $this->routeParams))
->assertSuccessful()
->getContent();
expect($html)->toContain('Deployment');
expect(preg_match(
'/<a[^>]*(?:aria-current="page"[^>]*app-tab-active|app-tab-active[^>]*aria-current="page")[^>]*>\s*Settings\s*<\/a>/s',
$html
))->toBe(0);
// Deployment now lives in the Logs section of the settings sidebar.
expect(preg_match(
'/<a[^>]*menu-item-active[^>]*>.*?Deployment.*?<\/a>/s',
$html
))->toBe(1);
});
it('syncs activeRouteName from the page route when heading is rendered on webhooks', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$html = $this->get(route('project.application.webhooks', $this->routeParams))
->assertSuccessful()
->assertSeeLivewire(ApplicationHeading::class)
->getContent();
assertSettingsTabActive($html);
});
it('keeps activeRouteName when request is not an application page route', function () {
@@ -178,15 +92,9 @@ it('refreshes the breadcrumb application status after it changes', function () {
$component
->call('refreshStatus')
->assertSee('Stopped')
->assertSee('Exited')
->assertDontSee('Running');
expect($component->instance()->getListeners())
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked", 'refreshStatus');
});
it('uses app-tab-active utility for resource heading active styles', function () {
$utilities = file_get_contents(resource_path('css/utilities.css'));
expect($utilities)->toContain('@utility app-tab-active');
});
@@ -117,21 +117,6 @@ it('renders preview deployment enablement as a section action', function () {
expect($this->application->fresh()->settings->is_preview_deployments_enabled)->toBeTrue();
});
it('renders GitHub pull requests in a modal opened from the preview settings', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/previews.blade.php'));
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
expect($view)
->toContain('<x-modal-input title="Pull requests"')
->toContain(':isLarge="true"')
->toContain('wire:click="load_prs"')
->not->toContain('id="preview-pull-requests-section"')
->and(strpos($view, '<x-modal-input title="Pull requests"'))
->toBeLessThan(strpos($view, '<livewire:project.application.preview.form'));
expect($sidebar)->not->toContain("['id' => 'preview-pull-requests-section', 'label' => 'Pull requests']");
});
it('does not show git preview settings for non-git applications', function (string $buildPack, ?string $dockerfile) {
$this->application->update(['build_pack' => $buildPack, 'dockerfile' => $dockerfile]);
@@ -156,10 +141,3 @@ it('denies preview setting changes without application update permission', funct
->is_preview_deployments_enabled->toBeFalse()
->is_pr_deployments_public_enabled->toBeFalse();
})->with([['member', false], ['owner', true]]);
it('removes preview settings from Advanced including its persistence path', function () {
expect(file_get_contents(resource_path('views/livewire/project/application/advanced.blade.php')))
->not->toContain('isPreviewDeploymentsEnabled', 'isPrDeploymentsPublicEnabled');
expect(file_get_contents(app_path('Livewire/Project/Application/Advanced.php')))
->not->toContain('is_preview_deployments_enabled', 'is_pr_deployments_public_enabled');
});
@@ -37,7 +37,7 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
describe('Application Source with localhost key (id=0)', function () {
test('renders deploy key section when private_key_id is 0', function () {
$privateKey = PrivateKey::create([
$privateKey = PrivateKey::forceCreate([
'id' => 0,
'name' => 'localhost',
'private_key' => applicationSourceValidPrivateKey(),
@@ -52,7 +52,8 @@ describe('Application Source with localhost key (id=0)', function () {
Livewire::test(Source::class, ['application' => $application])
->assertSuccessful()
->assertSet('privateKeyId', 0)
->assertSee('Deploy Key');
->assertSee('localhost')
->assertSee('Deploy key');
});
test('shows no source connected section when private_key_id is null', function () {
@@ -64,7 +65,7 @@ describe('Application Source with localhost key (id=0)', function () {
Livewire::test(Source::class, ['application' => $application])
->assertSuccessful()
->assertSet('privateKeyId', null)
->assertDontSee('Deploy Key')
->assertDontSee('Deploy key')
->assertSee('No source connected');
});
@@ -87,10 +87,9 @@ it('does not infer the restart limit from an exited existing container', functio
])->stoppedAfterRestartLimit())->toBeFalse();
});
it('shows a stopped after restart limit warning in the status badge', function () {
$html = view('components.status.index', [
'resource' => applicationWithRestartState(),
'showRefreshButton' => false,
it('shows a stopped after restart limit warning badge', function () {
$html = view('components.application.restart-limit-warning', [
'application' => applicationWithRestartState(),
])->render();
expect($html)->toContain('Restart limit reached')
@@ -99,13 +98,12 @@ it('shows a stopped after restart limit warning in the status badge', function (
});
it('does not show the restart limit warning for a normal manual stop', function () {
$html = view('components.status.index', [
'resource' => applicationWithRestartState([
$html = view('components.application.restart-limit-warning', [
'application' => applicationWithRestartState([
'restart_count' => 0,
'last_restart_type' => null,
'restart_limit_reached' => false,
]),
'showRefreshButton' => false,
])->render();
expect($html)->not->toContain('Restart limit reached');
@@ -27,18 +27,6 @@ it('allows unauthenticated access to two-factor-challenge page', function () {
expect($response->status())->toBeIn([200, 302]);
});
it('uses one mobile-friendly field for authenticator code paste and autofill', function () {
$challenge = file_get_contents(resource_path('views/auth/two-factor-challenge.blade.php'));
expect($challenge)
->toContain('name="code"')
->toContain('autocomplete="one-time-code"')
->toContain('inputmode="numeric"')
->toContain('maxlength="6"')
->toContain('@input="submitAuthenticatorCode($event)"')
->not->toContain('x-for="(digit, index) in digits"');
});
it('includes two-factor-challenge in allowed paths for unsubscribed accounts', function () {
$paths = allowedPathsForUnsubscribedAccounts();
@@ -8,7 +8,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -316,6 +316,14 @@ test('member does not see terminal link for application', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
// The terminal link lives in the application settings sidebar since the resource tab bar was removed.
$this->blade(
'<x-application.configuration-sidebar :application="$application" current-route="project.application.configuration" />',
['application' => $this->application]
)
->assertSee('application-settings-link-general', false)
->assertDontSee('application-settings-link-terminal', false);
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->assertDontSee('Terminal');
});
@@ -324,8 +332,12 @@ test('admin sees terminal link for application', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->assertSee('Terminal');
// The terminal link lives in the application settings sidebar since the resource tab bar was removed.
$this->blade(
'<x-application.configuration-sidebar :application="$application" current-route="project.application.configuration" />',
['application' => $this->application]
)
->assertSee('application-settings-link-terminal', false);
});
// --- Cross-team isolation ---
@@ -31,9 +31,9 @@ function createProjectForTeam(Team $team): void
]);
}
function createServerWithKeyForTeam(Team $team): void
function createPrivateKeyForTeam(Team $team): int
{
$keyId = DB::table('private_keys')->insertGetId([
return DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
@@ -41,14 +41,19 @@ function createServerWithKeyForTeam(Team $team): void
'created_at' => now(),
'updated_at' => now(),
]);
}
function createServerWithKeyForTeam(Team $team): void
{
Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $keyId,
'private_key_id' => createPrivateKeyForTeam($team),
]);
}
test('admin sees add project button on dashboard', function () {
// The dashboard no longer has a "New Project" button; each project card exposes an
// "Add resource" shortcut gated by the createAnyResource gate instead.
test('admin sees add resource button on dashboard projects', function () {
[$user, $team] = setupDashboardUser('admin');
$this->actingAs($user);
@@ -57,10 +62,11 @@ test('admin sees add project button on dashboard', function () {
createProjectForTeam($team);
Livewire::test(Dashboard::class)
->assertSee('New Project');
->assertSee('Test Project')
->assertSee('Add resource to Test Project');
});
test('member does not see add project button on dashboard', function () {
test('member does not see add resource button on dashboard projects', function () {
[$user, $team] = setupDashboardUser('member');
$this->actingAs($user);
@@ -69,18 +75,22 @@ test('member does not see add project button on dashboard', function () {
createProjectForTeam($team);
Livewire::test(Dashboard::class)
->assertSee('Test Project')
->assertDontSee('Add resource to Test Project')
->assertDontSee('New Project');
});
// The "New server" button is only rendered in the empty servers state (a key exists, no servers yet).
test('admin sees add server button on dashboard', function () {
[$user, $team] = setupDashboardUser('admin');
$this->actingAs($user);
session(['currentTeam' => $team]);
createServerWithKeyForTeam($team);
createPrivateKeyForTeam($team);
Livewire::test(Dashboard::class)
->assertSee('No servers yet')
->assertSee(route('server.create'));
});
@@ -90,6 +100,19 @@ test('member does not see add server button on dashboard', function () {
$this->actingAs($user);
session(['currentTeam' => $team]);
createPrivateKeyForTeam($team);
Livewire::test(Dashboard::class)
->assertSee('No servers yet')
->assertDontSee(route('server.create'));
});
test('member does not see add server button on dashboard with existing servers', function () {
[$user, $team] = setupDashboardUser('member');
$this->actingAs($user);
session(['currentTeam' => $team]);
createServerWithKeyForTeam($team);
Livewire::test(Dashboard::class)
@@ -55,6 +55,10 @@ test('cross-team user cannot view destination', function () {
$this->actingAs($this->userB);
session(['currentTeam' => $this->teamB]);
// Lookups are scoped to the current team, so another team's destination is
// treated as missing: the user is redirected and no destination data is loaded.
Livewire::test(Show::class, ['destination_uuid' => $this->destination->uuid])
->assertStatus(403);
->assertRedirect(route('destination.index'))
->assertSet('destination', null)
->assertDontSee($this->server->ip);
});
@@ -8,7 +8,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]);
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->member = User::factory()->create();
@@ -28,65 +28,34 @@ function apiRequest($test, string $token, string $method = 'get', string $url =
])->{$method.'Json'}($url);
}
/**
* The api.token.team middleware rejects elevated abilities on member tokens
* before ApiAbility runs, so REST clients get its generic role message.
*/
describe('member with legacy elevated token is rejected', function () {
test('member with legacy write token gets 403 with descriptive message', function () {
test('member with legacy elevated token gets 403', function (array $abilities) {
$token = $this->member->createToken('legacy-elevated', $abilities);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(403);
$response->assertExactJson(['message' => 'Missing required team role.']);
})->with([
'write' => [['read', 'write']],
'deploy' => [['read', 'deploy']],
'root' => [['root']],
'read:sensitive' => [['read', 'read:sensitive']],
'write:sensitive' => [['read', 'write:sensitive']],
'multiple' => [['read', 'write', 'deploy', 'read:sensitive']],
]);
test('member with legacy elevated token cannot reach a write endpoint', function () {
$token = $this->member->createToken('legacy-write', ['read', 'write']);
$response = apiRequest($this, $token->plainTextToken);
$response = apiRequest($this, $token->plainTextToken, 'patch', '/api/v1/team');
$response->assertStatus(403);
$response->assertJsonFragment([
'message' => 'This API token has permissions (write) that exceed your current role as a team member. Members are restricted to read-only API access. Please revoke this token and create a new one with only read permissions.',
]);
});
test('member with legacy deploy token gets 403', function () {
$token = $this->member->createToken('legacy-deploy', ['read', 'deploy']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(403);
$response->assertSee('deploy');
$response->assertSee('revoke this token');
});
test('member with legacy root token gets 403', function () {
$token = $this->member->createToken('legacy-root', ['root']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(403);
$response->assertSee('root');
});
test('member with legacy read:sensitive token gets 403', function () {
$token = $this->member->createToken('legacy-sensitive', ['read', 'read:sensitive']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(403);
$response->assertSee('read:sensitive');
});
test('member with legacy write:sensitive token gets 403', function () {
$token = $this->member->createToken('legacy-ws', ['read', 'write:sensitive']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(403);
$response->assertSee('write:sensitive');
});
test('member with multiple disallowed abilities lists them all', function () {
$token = $this->member->createToken('legacy-multi', ['read', 'write', 'deploy', 'read:sensitive']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(403);
$json = $response->json();
expect($json['message'])->toContain('write');
expect($json['message'])->toContain('deploy');
expect($json['message'])->toContain('read:sensitive');
$response->assertExactJson(['message' => 'Missing required team role.']);
});
});
@@ -19,7 +19,7 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
@@ -110,7 +110,7 @@ test('admin sees deploy controls for application', function () {
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->assertSee('Redeploy')
->assertSee('Deploy')
->assertSee('Restart')
->assertSee('Stop');
});
@@ -148,14 +148,25 @@ test('member does not see terminal link for application', function () {
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->assertDontSee('Terminal');
// The terminal link lives in the application settings sidebar since the resource tab bar was removed.
$this->blade(
'<x-application.configuration-sidebar :application="$application" current-route="project.application.configuration" />',
['application' => $this->application]
)
->assertSee('application-settings-link-general', false)
->assertDontSee('application-settings-link-terminal', false);
});
test('admin sees terminal link for application', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->assertSee('Terminal');
$this->blade(
'<x-application.configuration-sidebar :application="$application" current-route="project.application.configuration" />',
['application' => $this->application]
)
->assertSee('application-settings-link-terminal', false);
});
// --- Database Heading (via page route for rendering, policy checks for actions) ---
@@ -233,16 +244,24 @@ test('member does not see terminal link for service', function () {
'query' => [],
])
->assertDontSee('Terminal');
// The terminal link lives in the service settings sidebar since the resource tab bar was removed.
$this->blade(
'<x-service.configuration-sidebar :service="$service" current-route="project.service.configuration" />',
['service' => $this->service]
)
->assertSee(route('project.service.configuration', $this->serviceParams), false)
->assertDontSee(route('project.service.command', $this->serviceParams), false);
});
test('admin sees terminal link for service', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
$this->blade(
'<x-service.configuration-sidebar :service="$service" current-route="project.service.configuration" />',
['service' => $this->service]
)
->assertSee(route('project.service.command', $this->serviceParams), false)
->assertSee('Terminal');
});
@@ -48,8 +48,9 @@ test('admin sees add and cleanup buttons on private key page', function () {
session(['currentTeam' => $this->team]);
Livewire::test(PrivateKeyIndex::class)
->assertSee('+ Add')
->assertSee('Delete unused SSH Keys');
->assertSee('New private key')
->assertSee('Generate ED25519')
->assertSee('Delete unused keys');
});
test('member does not see add or cleanup buttons on private key page', function () {
@@ -57,8 +58,10 @@ test('member does not see add or cleanup buttons on private key page', function
session(['currentTeam' => $this->team]);
Livewire::test(PrivateKeyIndex::class)
->assertDontSee('+ Add')
->assertDontSee('Delete unused SSH Keys');
->assertSee('Team SSH Key')
->assertDontSee('New private key')
->assertDontSee('Generate ED25519')
->assertDontSee('Delete unused keys');
});
test('member can view private key names on index page', function () {
@@ -87,7 +90,7 @@ test('admin sees invite section on team members page', function () {
session(['currentTeam' => $this->team]);
Livewire::test(TeamMemberIndex::class)
->assertSee('Invite New Member');
->assertSee('Invite a member');
});
test('member does not see invite section on team members page', function () {
@@ -95,7 +98,7 @@ test('member does not see invite section on team members page', function () {
session(['currentTeam' => $this->team]);
Livewire::test(TeamMemberIndex::class)
->assertDontSee('Invite New Member');
->assertDontSee('Invite a member');
});
// --- Notification Settings (Discord as representative) ---
@@ -188,7 +188,7 @@ test('new server chooser lists providers before rendering a creation form', func
->assertSee('Hetzner')
->assertSee('Vultr')
->assertSee('DigitalOcean')
->assertSee('Manual')
->assertSee('IP address or domain')
->assertDontSee('>Select<', false)
->assertDontSee('Continue')
->assertSee(route('server.create.type', ['type' => 'hetzner']))
@@ -203,19 +203,13 @@ test('new server chooser uses compact mobile provider cards', function () {
session(['currentTeam' => $this->team]);
Livewire::test(ServerCreate::class)
->assertSee('mx-auto flex w-full max-w-7xl flex-col', false)
->assertSee('sm:grid', false)
->assertSee('sm:grid-cols-2', false)
->assertSee('xl:grid-cols-4', false)
->assertSee('gap-3 sm:gap-6', false)
->assertSee('dark:hover:border-warning', false)
->assertSee('focus-visible:border-coollabs', false)
->assertSee('aria-label="Choose Hetzner"', false)
->assertSee('p-3 sm:p-6', false)
->assertSee('application-settings-form flex flex-col gap-6', false)
->assertSee('grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-4', false)
->assertSee('grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-3', false)
->assertSee('group flex min-h-32 flex-col rounded-xl', false)
->assertSee('alt="Hetzner" class="size-8"', false)
->assertDontSee('sm:min-h-80', false)
->assertSee('size-9 sm:size-14', false)
->assertDontSee('size-9 sm:size-14 w-14 h-14', false)
->assertSee('hidden sm:block', false)
->assertDontSee('>Select<', false)
->assertDontSee('<button', false);
});
@@ -230,12 +224,12 @@ test('new server provider pages render the selected creation flow', function (st
->assertDontSee('<h1>New Server</h1>', false)
->assertDontSee('Choose how to add your server');
$response->assertSeeInOrder([$heading, 'Back']);
$response->assertSeeInOrder(['New server', 'Change method', $heading]);
})->with([
['hetzner', 'Hetzner'],
['vultr', 'Vultr'],
['digital-ocean', 'DigitalOcean'],
['manual', 'Manual'],
['hetzner', 'Hetzner account'],
['vultr', 'Vultr account'],
['digital-ocean', 'DigitalOcean account'],
['manual', 'Connect a server'],
]);
test('new server provider pages do not show the new token action in the header', function (string $type, string $heading) {
@@ -247,27 +241,39 @@ test('new server provider pages do not show the new token action in the header',
->assertDontSee('+ New Token')
->assertDontSee('+ Add New Token');
$response->assertSeeInOrder([$heading, 'Back']);
$header = str($response->getContent())
->after('New server</h1>')
->before('application-settings-form')
->toString();
expect($header)
->toContain('Change method')
->not->toContain('Token');
// Without tokens, the add-token action lives in the provider account section instead.
$response->assertSeeInOrder(['Change method', "{$heading} account", "No {$heading} tokens", "Add {$heading} Token"]);
})->with([
['hetzner', 'Hetzner'],
['vultr', 'Vultr'],
['digital-ocean', 'DigitalOcean'],
]);
test('new server provider pages show the new token action in the header when tokens exist', function (string $type, string $provider, string $heading, string $modalTitle) {
test('new server provider pages list existing tokens instead of a token action', function (string $type, string $provider, string $heading, string $modalTitle) {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
CloudProviderToken::factory()->create([
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => $provider,
]);
$response = $this->get(route('server.create.type', ['type' => $type]))
->assertSuccessful()
->assertSee($modalTitle);
->assertSee(route('server.create.token', ['type' => $type, 'token_uuid' => $token->uuid]))
->assertDontSee($modalTitle)
->assertDontSee('+ New Token');
$response->assertSeeInOrder([$heading, 'Back', '+ New Token']);
$response->assertSeeInOrder(['Change method', "{$heading} account", $token->name]);
})->with([
['hetzner', 'hetzner', 'Hetzner', 'Add Hetzner Token'],
['vultr', 'vultr', 'Vultr', 'Add Vultr Token'],
@@ -280,8 +286,9 @@ test('new server manual page does not show the new token action', function () {
$this->get(route('server.create.type', ['type' => 'manual']))
->assertSuccessful()
->assertSee('Manual')
->assertDontSee('+ New Token');
->assertSee('Connect a server')
->assertDontSee('+ New Token')
->assertDontSee('Add token');
});
test('member cannot access new server page', function () {
@@ -16,7 +16,9 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::unguarded(function () {
InstanceSettings::updateOrCreate(['id' => 0], []);
});
$this->team = Team::factory()->create();
@@ -197,17 +199,26 @@ test('member does not see terminal link for service', function () {
'query' => [],
])
->assertDontSee('Terminal');
// The terminal link lives in the service settings sidebar since the resource tab bar was removed.
$this->blade(
'<x-service.configuration-sidebar :service="$service" current-route="project.service.configuration" />',
['service' => $this->service]
)
->assertSee(route('project.service.configuration', $this->serviceParams), false)
->assertDontSee(route('project.service.command', $this->serviceParams), false);
});
test('admin sees terminal link for service', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
// The terminal link lives in the service settings sidebar since the resource tab bar was removed.
$this->blade(
'<x-service.configuration-sidebar :service="$service" current-route="project.service.configuration" />',
['service' => $this->service]
)
->assertSee(route('project.service.command', $this->serviceParams), false)
->assertSee('Terminal');
});
@@ -185,7 +185,7 @@ test('member cannot view application webhook secrets', function () {
->assertSet('gitlabManualWebhookSecret', null)
->assertSet('bitbucketManualWebhookSecret', null)
->assertSet('giteaManualWebhookSecret', null)
->assertSee('Hidden (only admins can view)')
->assertSee('Hidden (only administrators can view)')
->assertDontSee('github-secret-value')
->assertDontSee('gitlab-secret-value')
->assertDontSee('bitbucket-secret-value')
+31 -67
View File
@@ -76,20 +76,6 @@ beforeEach(function () {
session(['currentTeam' => $this->team]);
});
it('renders a highlighted enable backup button and a regular disable backup button', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/backup-edit/general.blade.php'));
$s3View = file_get_contents(resource_path('views/livewire/project/database/backup-edit/s3.blade.php'));
expect($view)
->toContain('wire:target="toggleEnabled" isHighlighted>Enable Backup</x-forms.button>')
->toContain('wire:target="toggleEnabled">Disable Backup</x-forms.button>')
->not->toContain('label="Backup Enabled"')
->and($s3View)
->toContain('wire:target="toggleS3" isHighlighted')
->toContain('wire:target="toggleS3">Disable S3</x-forms.button>')
->not->toContain('label="S3 Enabled"');
});
it('enables and disables S3 backups from the S3 title action', function () {
$s3 = createS3StorageForBackupEditValidationTest($this->team);
$backup = createBackupForEditValidationTest($this->team, [
@@ -129,11 +115,11 @@ it('shows and saves S3 retention while S3 backups are disabled', function () {
]);
Livewire::test(BackupEdit::class, [
'backup' => $backup,
'backup' => $backup->fresh(),
'availableS3Storages' => $this->team->s3s,
'section' => 'retention',
])
->assertSee('S3 Storage Retention')
->assertSee('S3 backups')
->set('databaseBackupRetentionAmountS3', 12)
->set('databaseBackupRetentionDaysS3', 30)
->set('databaseBackupRetentionMaxStorageS3', 4.5)
@@ -161,55 +147,49 @@ it('splits standalone database backup settings and executions across dedicated u
$this->get($generalUrl)
->assertOk()
->assertSee('General')
->assertSee('S3')
->assertSee('S3 storage')
->assertSee('Retention')
->assertSee('Executions')
->assertSee('Danger Zone')
->assertSee('Frequency')
->assertDontSee('S3 Enabled')
->assertDontSee('Number of backups to keep')
->assertDontSee('Cleanup Failed Backups')
->assertDontSee('Delete Backups and Schedule');
->assertDontSee('Enable S3')
->assertDontSee('Disable S3')
->assertDontSee('Backups to keep')
->assertDontSee('Clean failed backups')
->assertDontSee('Delete schedule');
$this->get($generalUrl.'/s3')
->assertOk()
->assertSeeText('No validated S3 storage')
->assertDontSee('Disable Local Backup')
->assertDontSee('Local copy')
->assertDontSee('Enable S3')
->assertDontSee('Disable S3')
->assertDontSee('S3 Storage Retention')
->assertDontSee('Local Backup Retention')
->assertDontSee('Backups to keep')
->assertDontSee('Frequency')
->assertDontSee('Cleanup Failed Backups');
$s3View = file_get_contents(resource_path('views/livewire/project/database/backup-edit/s3.blade.php'));
expect(strpos($s3View, '<span>S3 Storage</span>'))
->toBeLessThan(strpos($s3View, 'label="Disable Local Backup"'));
->assertDontSee('Clean failed backups');
$this->get($generalUrl.'/retention')
->assertOk()
->assertSee('Local Backup Retention')
->assertSee('S3 Storage Retention')
->assertSee('Number of backups to keep')
->assertSee('Local backups')
->assertSee('S3 backups')
->assertSee('Backups to keep')
->assertDontSee('Frequency')
->assertDontSee('Cleanup Failed Backups');
->assertDontSee('Clean failed backups');
$this->get($generalUrl.'/executions')
->assertOk()
->assertSee('<h2 class="py-0">Executions</h2>', false)
->assertDontSee('Executions <span', false)
->assertSee('Cleanup Failed Backups')
->assertSee('Clean failed backups')
->assertDontSee('Frequency')
->assertDontSee('Number of backups to keep');
->assertDontSee('Backups to keep');
$this->get($generalUrl.'/danger')
->assertOk()
->assertSee('Danger Zone')
->assertSee('Delete Scheduled Backup')
->assertSee('Delete Backups and Schedule')
->assertSee('Delete backup schedule')
->assertSee('Delete schedule')
->assertDontSee('Frequency')
->assertDontSee('Number of backups to keep')
->assertDontSee('Cleanup Failed Backups');
->assertDontSee('Backups to keep')
->assertDontSee('Clean failed backups');
});
it('enables and disables a scheduled database backup from the title action', function () {
@@ -220,10 +200,12 @@ it('enables and disables a scheduled database backup from the title action', fun
$component = Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSet('backupEnabled', false)
->assertSee('Enable Backup')
->assertSee('Enable backup')
->assertDontSee('Disable backup')
->call('toggleEnabled')
->assertSet('backupEnabled', true)
->assertSee('Disable Backup');
->assertSee('Disable backup')
->assertDontSee('Enable backup');
expect($backup->refresh()->enabled)->toBeTruthy();
@@ -258,7 +240,7 @@ it('redirects to executions after queuing a database backup with unusable S3 sto
];
Livewire::test(BackupEdit::class, [
'backup' => $backup,
'backup' => $backup->fresh(),
'availableS3Storages' => $this->team->s3s,
])
->call('backupNow')
@@ -490,7 +472,6 @@ it('shows only an empty S3 state when no storages are available', function () {
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3'])
->assertSeeHtml('<h2>S3 storage</h2>')
->assertSeeText('No validated S3 storage')
->assertSeeHtml('href="'.route('storage.index').'"')
->assertSeeText('Open S3 storage')
@@ -498,7 +479,7 @@ it('shows only an empty S3 state when no storages are available', function () {
->assertDontSee('Enable S3')
->assertDontSee('Disable S3')
->assertDontSee('S3 Storage')
->assertDontSee('Disable Local Backup')
->assertDontSee('Local copy')
->assertDontSee('No S3 storage available');
});
@@ -534,8 +515,10 @@ it('shows when S3 backups are currently disabled', function () {
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3'])
->assertSee('S3 Storage')
->assertSee('(currently disabled)');
// Disabled S3 backups are indicated by the "Enable S3" title action
->assertSee('S3 storage')
->assertSee('Enable S3')
->assertDontSee('Disable S3');
});
it('saves selected S3 storage immediately when it changes', function () {
@@ -616,22 +599,3 @@ it('disables Back up now after refresh when the database stops', function () {
expect($component->html())->toMatch('/<button\s+disabled[^>]*wire:click="backupNow"/s');
});
it('renders S3 backup selectors outside the scrollable modal', function () {
createS3StorageForBackupEditValidationTest($this->team);
$backup = createBackupForEditValidationTest($this->team);
$html = Livewire::test(BackupEdit::class, [
'backup' => $backup->fresh(),
'availableS3Storages' => $this->team->s3s,
'section' => 's3',
])->html();
$dom = new DOMDocument;
@$dom->loadHTML($html);
$xpath = new DOMXPath($dom);
foreach (['s3StorageId-panel', 'disableLocalBackup-panel'] as $panelId) {
$panels = $xpath->query('//template[@x-teleport="body"]/div[@id="'.$panelId.'"]');
expect($panels->length)->toBe(1);
expect($panels->item(0)->getAttribute('style'))->toContain('position: fixed', 'z-index: 9999');
}
});
@@ -2,7 +2,6 @@
use App\Jobs\DatabaseBackupJob;
use App\Livewire\Project\Database\BackupEdit;
use App\Livewire\Project\Database\BackupNow;
use App\Livewire\Project\Service\VolumeBackup\Index;
use App\Models\Environment;
use App\Models\InstanceSettings;
@@ -63,8 +62,6 @@ beforeEach(function () {
dataset('database backup controls', [
'standalone settings' => [BackupEdit::class, 'standalone'],
'service settings' => [BackupEdit::class, 'service'],
'standalone button' => [BackupNow::class, 'standalone'],
'service button' => [BackupNow::class, 'service'],
'service list' => [Index::class, 'service'],
]);
+1 -1
View File
@@ -117,7 +117,7 @@ it('renders frontend-only database backup search data for database names and fre
->assertSee('Archive-Bucket')
->assertSee('archive-bucket', false)
->assertSee('backup.s3_storage.includes(query)', false)
->assertSee('Search backup schedules')
->assertSee('placeholder="Search backups"', false)
->assertSee('x-model="search"', false)
->assertSee('x-show=', false)
->assertSee('No matching backup schedules');
+15 -17
View File
@@ -48,24 +48,22 @@ it('hides the breadcrumb trail on mobile while keeping the current status visibl
]));
$response->assertSuccessful();
$response->assertSee('flex min-w-0 flex-col gap-1 md:hidden', false);
$response->assertSee('flex min-w-0 items-center text-xs text-neutral-400', false);
$response->assertSee('hidden flex-wrap items-center gap-y-1 md:flex', false);
$response->assertSee('flex flex-wrap items-center gap-1', false);
$response->assertSee(
'scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-scroll overflow-y-hidden pb-1 whitespace-nowrap md:w-auto md:overflow-visible',
false,
);
$response->assertSee('shrink-0', false);
$response->assertSee('Actions');
$response->assertSee('dropdown-item-touch', false);
$response->assertSee('hidden flex-wrap items-center gap-2 md:flex', false);
$response->assertSee('window.innerWidth >= 768', false);
$response->assertSee(':style="panelStyles"', false);
$response->assertSee('absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6', false);
$response->assertSee('Pure Dockerfile Example');
// The resource breadcrumb lives in the top bar (project / environment / resource switchers).
$response->assertSee('title="Switch project"', false);
$response->assertSee('title="Switch environment"', false);
$response->assertSee('title="Switch resource"', false);
// Desktop resource actions dock into the top bar; the slot is hidden below xl.
$response->assertSee('<div id="resource-action-hud-slot" class="hidden shrink-0 items-center xl:flex"></div>', false);
// Below xl, the heading keeps the resource name, current status, and a split action visible.
expect(preg_match(
'/<div class="mb-3 w-full xl:hidden">.*?<h1[^>]*>\s*Pure Dockerfile Example\s*<\/h1>.*?Application status/s',
$response->getContent()
))->toBe(1);
$response->assertSee('id="application-mobile-actions"', false);
$response->assertSee('Running');
$response->assertSee('pt-2 pb-4 md:pb-10', false);
expect($response->getContent())->not->toContain('hidden pt-2 pb-10 md:flex');
});
@@ -27,6 +27,9 @@ it('sets unreachable fields on servers when subscription ends', function () {
});
it('cleans up unsubscribed server IP after 7 days via cleanup command', function () {
// Subscriptions only exist on Coolify Cloud; self-hosted instances force-disable the server instead.
config(['constants.coolify.self_hosted' => false]);
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
@@ -60,7 +60,7 @@ it('shows link cloud provider dropdown with available unlinked providers', funct
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->assertSee('Link Cloud Provider')
->assertSee('Link provider')
->assertSee('Hetzner')
->assertSee('DigitalOcean')
->assertSee('Vultr')
@@ -70,13 +70,13 @@ it('shows link cloud provider dropdown with available unlinked providers', funct
->assertSee('Server ID')
->assertSee('Droplet ID')
->assertSee('Instance ID')
->assertSee('Search by IP')
->assertSee('Search by server IP')
->assertSee('Search');
});
it('hides link cloud provider dropdown when no providers can be linked', function () {
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->assertDontSee('Link Cloud Provider');
->assertDontSee('Link provider');
});
it('does not list providers already linked to the server', function () {
@@ -97,7 +97,7 @@ it('does not list providers already linked to the server', function () {
$this->server->update(['hetzner_server_id' => 123]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->assertSee('Link Cloud Provider')
->assertSee('Link provider')
->assertSee('Vultr Token')
->assertDontSee('Hetzner Token');
});
-68
View File
@@ -2,74 +2,6 @@
use Illuminate\Support\Facades\Blade;
it('uses the large modal treatment for the compose editor', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/stack-form.blade.php'));
$modal = file_get_contents(resource_path('views/components/modal-input.blade.php'));
expect($view)
->toContain('title="Docker Compose"')
->toContain(':isLarge="true"')
->toContain('<x-slot:footer>')
->not->toContain('<x-slot:headerActions>')
->toContain("\$dispatch('compose-preview-toggle')")
->toContain("\$dispatch('compose-save')")
->toContain('@compose-save-finished.window="saving = false"')
->toContain('@compose-validate-finished.window="validating = false"')
->toContain('@click="validating = true; $dispatch(\'compose-validate\')"')
->toContain('x-bind:disabled="validating"')
->toContain('<x-loading-on-button x-show="validating" x-cloak />')
->toContain('<x-loading-on-button x-show="saving" x-cloak />')
->toContain('x-bind:disabled="saving"')
->not->toContain('name="refresh"')
->not->toContain("saving ? 'Saving...' : 'Save changes'")
->not->toContain(' :disabled="saving"')
->toContain('Preview generated Compose')
->toContain('Back to source Compose')
->toContain('Save changes')
->toContain('isHighlighted');
expect($modal)->toContain('lg:w-[95vw]! lg:max-w-7xl!');
});
it('renders the compose editor with clear guidance settings and actions', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/edit-compose.blade.php'));
expect($view)
->toContain('<x-callout type="info" title="Volume names">')
->not->toContain('View the final names')
->toContain('Use plain-text editor')
->toContain('min-h-[24rem]')
->toContain('@compose-preview-toggle.window')
->toContain('@compose-save.window')
->toContain('@compose-validate.window="$wire.validateCompose().finally(() => $dispatch(\'compose-validate-finished\'))"')
->not->toContain("finally(() => \$dispatch('compose-save-finished'))")
->not->toContain('sticky bottom-0')
->not->toContain('Cancel')
->not->toContain('Show Normal Textarea')
->not->toContain('Show Deployable Compose');
});
it('keeps the compose modal usable on mobile screens', function () {
$stackForm = file_get_contents(resource_path('views/livewire/project/service/stack-form.blade.php'));
$editor = file_get_contents(resource_path('views/livewire/project/service/edit-compose.blade.php'));
$modal = file_get_contents(resource_path('views/components/modal-input.blade.php'));
expect($modal)
->toContain('justify-center p-2')
->toContain('sm:p-4')
->toContain('flex-wrap! sm:flex-nowrap!')
->toContain('@isset($footer)')
->toContain('justify-end gap-2 border-t')
->toContain('order-2 sm:order-none');
expect($stackForm)
->toContain('flex-wrap items-center justify-end gap-2');
expect($editor)
->toContain('flex-col items-stretch')
->toContain('sm:flex-row sm:flex-wrap sm:items-center');
});
it('keeps the save button loading until the parent compose save finishes', function () {
$component = file_get_contents(app_path('Livewire/Project/Service/StackForm.php'));
-19
View File
@@ -21,22 +21,3 @@ it('evaluates a resolve expression at click time instead of a static value', fun
$html->assertSee('await ($wire.copyValue())', false)
->assertDontSee('disabled', false);
});
it('is the single clipboard implementation shared by its call sites', function () {
expect(file_get_contents(resource_path('js/copy-button.js')))
->toContain("window.Alpine.data('copyButton'");
expect(file_get_contents(resource_path('js/app.js')))
->toContain('initializeCopyButtonComponent');
$modalConfirmation = file_get_contents(resource_path('views/components/modal-confirmation.blade.php'));
$backupExecutions = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
expect($modalConfirmation)
->toContain('<x-copy-button resolve="decodedText"')
->not->toContain('navigator.clipboard');
expect($backupExecutions)
->toContain('<x-copy-button :value="data_get($execution, \'filename\', \'\')" label="Copy backup path"')
->not->toContain('navigator.clipboard');
});
+10 -2
View File
@@ -2,28 +2,34 @@
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
// Attacker: Team A
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
$this->destinationA = StandaloneDocker::where('server_id', $this->serverA->id)->firstOrFail();
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
// Victim: Team B
$this->teamB = Team::factory()->create();
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]);
$this->destinationB = StandaloneDocker::where('server_id', $this->serverB->id)->firstOrFail();
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
@@ -37,6 +43,7 @@ beforeEach(function () {
'environment_id' => $this->environmentB->id,
'destination_id' => $this->destinationB->id,
'destination_type' => StandaloneDocker::class,
'server_id' => $this->serverB->id,
]);
// Act as attacker
@@ -85,6 +92,7 @@ test('can access logs of own service', function () {
'environment_id' => $this->environmentA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => StandaloneDocker::class,
'server_id' => $this->serverA->id,
]);
$response = $this->get(route('project.service.logs', [
@@ -8,6 +8,7 @@ use App\Livewire\Project\DeleteProject;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
@@ -21,6 +22,8 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
// Attacker: Team A
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
@@ -170,7 +173,8 @@ describe('DeployController API Server IDOR', function () {
// Create a deployment queue entry that references Team B's server as build_server
$application = Application::factory()->create([
'environment_id' => $this->environmentA->id,
'destination_id' => StandaloneDocker::factory()->create(['server_id' => $this->serverA->id])->id,
// Server::booted() already creates the server's default StandaloneDocker destination.
'destination_id' => StandaloneDocker::where('server_id', $this->serverA->id)->firstOrFail()->id,
'destination_type' => StandaloneDocker::class,
]);
@@ -183,10 +187,12 @@ describe('DeployController API Server IDOR', function () {
]);
$token = $this->userA->createToken('test-token', ['*']);
// Authenticate the API call with the bearer token only, not the session user from beforeEach.
$this->app['auth']->forgetGuards();
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->deleteJson("/api/v1/deployments/{$deployment->deployment_uuid}");
])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel");
// The cancellation should proceed but the build_server should NOT be found
// (team-scoped query returns null for Team B's server)
@@ -53,17 +53,6 @@ function createDashboardDeployment(array $overrides = []): ApplicationDeployment
], $overrides));
}
it('shows a visible yellow inset focus state on deployment rows', function () {
$view = file_get_contents(resource_path('views/livewire/dashboard/active-deployments.blade.php'));
expect(substr_count($view, 'focus-visible:bg-warning/10'))->toBe(4)
->and(substr_count($view, 'focus-visible:ring-1'))->toBe(2)
->and(substr_count($view, 'focus-visible:ring-inset'))->toBe(2)
->and(substr_count($view, 'focus-visible:ring-warning'))->toBe(2)
->and(substr_count($view, 'focus-visible:ring-offset-0'))->toBe(2)
->and(substr_count($view, 'dark:focus-visible:bg-warning/10'))->toBe(2);
});
it('hides the deployments section when there is nothing to show', function () {
Livewire::test(ActiveDeployments::class)
->assertDontSee('Active and recent deployment activity')
@@ -58,22 +58,6 @@ test('instance administrators can configure the dashboard HTTPS redirect', funct
expect($settings->fresh()->is_dashboard_force_https_enabled)->toBeFalse();
});
test('dashboard HTTPS redirect saves immediately when changed', function () {
$contents = file_get_contents(resource_path('views/livewire/settings/index.blade.php'));
expect($contents)
->toMatch('/id="is_dashboard_force_https_enabled"[\s\S]*?onChange="submit"/')
->and($contents)->not->toContain('targets="fqdn,is_dashboard_force_https_enabled');
});
test('dashboard HTTPS redirect is next to the URL on desktop', function () {
$contents = file_get_contents(resource_path('views/livewire/settings/index.blade.php'));
expect($contents)
->toContain("'lg:col-span-2' => !str_starts_with")
->not->toContain('<div class="lg:col-span-2 max-w-md">');
});
test('dashboard HTTPS redirect control is hidden for an HTTP URL', function () {
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);
@@ -63,44 +63,3 @@ it('renders dashboard metrics charts on the server index grid', function () {
->assertSeeHtml("dashboard-server-metrics-{$enabledServer->uuid}")
->assertDontSeeHtml("dashboard-server-metrics-{$disabledServer->uuid}");
});
it('configures the dashboard chart as a ten minute cpu and memory sparkline with hover details', function () {
$chart = file_get_contents(resource_path('views/livewire/dashboard/server-metrics-chart.blade.php'));
$component = file_get_contents(app_path('Livewire/Dashboard/ServerMetricsChart.php'));
expect($component)
->toContain('$this->server->getCpuMetrics(10)')
->toContain('$this->server->getMemoryMetrics(10)');
expect($chart)
->toContain('new ApexCharts')
->toContain('sparkline: { enabled: true }')
->toContain('absolute right-0 bottom-0 h-2/3 w-full')
->toContain('[&_.apexcharts-svg]:overflow-hidden')
->not->toContain('w-full overflow-hidden rounded-b-xl')
->toContain('CPU:')
->toContain('Memory:')
->toContain('formatLocalTimestamp(timestamp)')
->toContain('formatUtcTimestamp(timestamp)')
->toContain("min: 0,\n max: 100,")
->toContain('labels: { show: false }');
});
it('refreshes dashboard metrics every minute while visible and after returning from the background', function () {
$chart = file_get_contents(resource_path('views/livewire/dashboard/server-metrics-chart.blade.php'));
expect($chart)
->toContain('window.setInterval')
->toContain('60000')
->toContain('document.hidden')
->toContain("document.addEventListener('visibilitychange'")
->toContain('Date.now() - this.hiddenAt >= 60000')
->toContain('$wire.loadData()')
->toContain('window.clearInterval')
->toContain("document.removeEventListener('visibilitychange'");
});
it('keeps the status badge only on server cards without metrics', function () {
$dashboard = file_get_contents(resource_path('views/livewire/dashboard.blade.php'));
expect($dashboard)->toContain('@unless ($server->isMetricsEnabled())');
});
@@ -30,7 +30,6 @@ it('shows an add private key button when no private keys exist', function () {
->assertSee('A private key is required')
->assertSee('Add an SSH private key before connecting your first server.')
->assertSee('Add private key')
->assertSeeHtml('button button-highlighted')
->assertSee(route('security.private-key.index'), false);
});
@@ -66,35 +65,6 @@ it('does not show server empty state when servers exist', function () {
->assertDontSee('No servers yet');
});
it('renders clickable server cards without a decorative arrow control', function () {
$privateKey = PrivateKey::factory()->create([
'team_id' => $this->team->id,
]);
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $privateKey->id,
'name' => 'Production Host',
]);
Livewire::test(Dashboard::class)
->assertSee('Production Host')
->assertSeeHtml('aria-label="Open Production Host"')
->assertSeeHtml(route('server.show', ['server_uuid' => $server->uuid]))
// Decorative arrow used z-10 over the full-card link and blocked clicks.
->assertDontSeeHtml('group-hover:bg-neutral-100 group-hover:text-black dark:text-fg-faint dark:group-hover:bg-white/[0.06]');
});
it('keeps resource grid cards free of decorative open chevrons', function () {
$view = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php'));
expect($view)
->toContain('absolute inset-0 rounded-xl')
->toContain('aria-label="`Open ${item.name}`"')
// Decorative card chevrons used a dedicated size-7 open control.
->not->toContain('flex size-7 shrink-0 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg');
});
it('hides the add private key button for members without create permission', function () {
$member = User::factory()->create();
$member->teams()->attach($this->team, ['role' => 'member']);
+42 -56
View File
@@ -1,8 +1,19 @@
<?php
use App\Livewire\Project\Database\Health;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('defaults to an enabled healthcheck when nothing is configured', function () {
$database = new StandalonePostgresql;
@@ -113,63 +124,38 @@ it('does not mark configuration changed when health update authorization fails',
});
it('toggles database healthcheck and marks configuration changed', function () {
$database = new class
{
public ?string $config_hash = 'existing';
InstanceSettings::forceCreate(['id' => 0]);
$user = User::factory()->create();
$team = Team::factory()->create();
$user->teams()->attach($team, ['role' => 'owner']);
$this->actingAs($user);
session(['currentTeam' => $team]);
public bool $health_check_enabled = false;
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandalonePostgresql::create([
'name' => 'db',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'db',
'image' => 'postgres:17',
'health_check_enabled' => false,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$database->config_hash = 'existing';
$database->saveQuietly();
$database->refresh();
public int $health_check_interval = 15;
Livewire::test(Health::class, ['database' => $database])
->assertSet('healthCheckEnabled', false)
->call('toggleHealthcheck')
->assertSet('healthCheckEnabled', true)
->assertDispatched('success')
->assertDispatched('configurationChanged');
public int $health_check_timeout = 5;
public int $health_check_retries = 5;
public int $health_check_start_period = 5;
public int $saveCalls = 0;
public function save(): void
{
$this->saveCalls++;
}
};
$component = new class extends Health
{
public array $dispatchedEvents = [];
public function authorize($ability, $arguments = [])
{
return true;
}
public function dispatch($event, ...$params)
{
$this->dispatchedEvents[] = $event;
return null;
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->database->health_check_enabled = $this->healthCheckEnabled;
$this->database->save();
}
}
};
$component->database = $database;
$component->healthCheckEnabled = false;
$component->healthCheckInterval = 15;
$component->healthCheckTimeout = 5;
$component->healthCheckRetries = 5;
$component->healthCheckStartPeriod = 5;
$component->toggleHealthcheck();
expect($database->health_check_enabled)->toBeTrue()
->and($database->saveCalls)->toBe(1)
->and($component->dispatchedEvents)->toBe(['success', 'configurationChanged']);
expect($database->fresh()->health_check_enabled)->toBeTrue();
});
@@ -1,18 +1,12 @@
<?php
it('keeps editable standalone database storage actions in the table action column', function () {
it('gates standalone database storage actions behind the update permission', function () {
$component = file_get_contents(app_path('Livewire/Project/Shared/Storages/All.php'));
$view = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
expect($component)
->toContain('$this->showActionsColumn = $this->canUpdate;')
->toContain('$this->showBackupAction = $this->resource instanceof Application')
->toContain('if (! $this->showBackupAction)')
->and($view)
->toContain('@if ($showActionsColumn)')
->toContain('@if ($showBackupAction)')
->toContain('title="New Scheduled Backup"')
->toContain('<livewire:project.database.create-scheduled-backup :database="$resource"')
->toContain('aria-label="Configure backup"')
->toContain('<x-reicon name="database" class="size-4" />');
->toContain('canGate="update"');
});
@@ -14,7 +14,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -138,7 +138,7 @@ describe('POST /api/v1/databases/postgresql', function () {
'instant_deploy' => false,
]);
$response->assertStatus(200);
$response->assertStatus(201);
$uuid = $response->json('uuid');
$database = StandalonePostgresql::whereUuid($uuid)->first();
expect($database)->not->toBeNull();
+25 -7
View File
@@ -25,6 +25,7 @@ use App\Livewire\Project\Service\ResourceCard as ServiceResourceCard;
use App\Livewire\Server\Sentinel;
use App\Livewire\Server\Show;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
@@ -40,6 +41,8 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
@@ -48,6 +51,15 @@ beforeEach(function () {
session(['currentTeam' => $this->team]);
});
function sslToggleIsDisabled(string $html): bool
{
preg_match('/<button[^>]*id="enableSsl-trigger"[^>]*>/s', $html, $matches);
expect($matches)->not->toBeEmpty();
return (bool) preg_match('/\sdisabled(\s|=|>)/', $matches[0]);
}
dataset('database-general-forms-without-broadcasts', [
// Status-derived display moved into a sibling StatusInfo component for each DB,
// so the form itself takes no broadcast listeners and cannot clobber wire:dirty
@@ -222,12 +234,15 @@ it('reloads the mysql status-info model when refresh is called so ssl controls f
]);
$component = Livewire::test(MysqlStatusInfo::class, ['database' => $database])
->assertDontSee('Database should be stopped to change this settings.');
->assertSee('Encryption settings can only be changed while the database is stopped.');
expect(sslToggleIsDisabled($component->html()))->toBeFalse();
$database->fill(['status' => 'running:healthy'])->save();
$component->call('refresh')
->assertSee('Database should be stopped to change this settings.');
$component->call('refresh');
expect(sslToggleIsDisabled($component->html()))->toBeTrue();
});
it('shows ssl mode even when ssl is disabled, as a disabled control', function () {
@@ -264,7 +279,7 @@ it('does not clobber server form text inputs when sentinel restarts', function (
'name' => 'persisted-server-name',
]);
$component = Livewire::test(Sentinel::class, ['server_uuid' => $server->uuid])
$component = Livewire::test(Sentinel::class, ['server' => $server])
->set('sentinelToken', 'user-was-typing-this-token');
$component->call('handleSentinelRestarted', ['serverUuid' => $server->uuid]);
@@ -308,10 +323,13 @@ it('shows the redis ssl gate hint after the sibling is refreshed', function () {
]);
$component = Livewire::test(RedisStatusInfo::class, ['database' => $database])
->assertDontSee('Database should be stopped to change this settings.');
->assertSee('Encryption settings can only be changed while the database is stopped.');
expect(sslToggleIsDisabled($component->html()))->toBeFalse();
$database->fill(['status' => 'running:healthy'])->save();
$component->call('refresh')
->assertSee('Database should be stopped to change this settings.');
$component->call('refresh');
expect(sslToggleIsDisabled($component->html()))->toBeTrue();
});
@@ -4,6 +4,7 @@ use App\Enums\ApplicationDeploymentStatus;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
@@ -14,6 +15,8 @@ use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
@@ -11,7 +11,6 @@ use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Blade;
uses(RefreshDatabase::class);
@@ -63,46 +62,6 @@ it('filters deployment history by server', function () {
->and($result['deployments']->pluck('server_id')->unique()->sole())->toBe($secondServer->id);
});
it('always shows source filters and includes server filters', function () {
$component = file_get_contents(app_path('Livewire/Project/Application/Deployment/Index.php'));
$view = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php'));
$filterComponent = file_get_contents(resource_path('views/components/table/filter.blade.php'));
$loadingComponent = file_get_contents(resource_path('views/components/table/loading.blade.php'));
expect($component)
->toContain('public array $serverFilterOptions = [];')
->toContain('public array $deploymentFilters = [];')
->toContain('public function toggleDeploymentFilter(string $filter): void')
->toContain("'value' => \"server:{\$serverId}\"")
->and($view)
->toContain('@if (count($sourceFilterOptions) > 0)')
->toContain('count($serverFilterOptions) > 0')
->toContain('>Server</span>')
->toContain('<x-table.filter')
->toContain("wire:click=\"toggleDeploymentFilter('{{ \$option['value'] }}')\"")
->toContain("in_array(\$option['value'], \$deploymentFilters, true)")
->toContain('<x-table.loading id="deployment-table-filter-loading"')
->not->toContain('class="size-3.5" wire:loading.remove')
->not->toContain('<span>All deployments</span>')
->and($filterComponent)
->toContain('aria-multiselectable="true"')
->toContain('Reset filters')
->and($loadingComponent)
->toContain('wire:loading.flex');
});
it('shows the active pull request id on the deployment filter control', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php'));
$filter = Blade::render(<<<'BLADE'
<x-table.filter :active-count="1" active-text="Pull request #41" reset-action="clearFilter">
Filter options
</x-table.filter>
BLADE);
expect($view)->toContain(":active-text=\"filled(\$pull_request_id) ? 'Pull request #'.\$pull_request_id : null\"")
->and($filter)->toContain('<span class="truncate">Pull request #41</span>');
});
it('keeps a pull request filter from the URL when it has no deployment records yet', function () {
$application = Application::factory()->create();
$component = new Index;
+1 -230
View File
@@ -42,7 +42,7 @@ beforeEach(function () {
]);
});
it('renders deployment logs in a full-height layout', function () {
it('renders deployment log output', function () {
$deployment = ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'deployment_uuid' => 'deploy-layout-test',
@@ -70,167 +70,6 @@ it('renders deployment logs in a full-height layout', function () {
$response->assertSuccessful();
$response->assertSee('rolling update started');
$response->assertSee('logs-viewer', false);
$response->assertSee('logs-viewer-toolbar', false);
$response->assertSee('logs-viewer-search', false);
$response->assertSee('logs-viewer-actions', false);
$response->assertSee('logs-viewer-viewport', false);
$response->assertSee('logs-viewer-line', false);
$response->assertSee('min-h-[calc(100dvh-7.5rem)] flex-col', false);
$response->assertSee('flex flex-1 min-h-0 flex-col overflow-hidden', false);
$response->assertSee('h-[calc(100dvh-8rem)]', false);
$response->assertSee('xl:h-[32rem]', false);
$response->assertSee('xl:flex-none', false);
expect($response->getContent())
->not->toContain('max-h-[30rem]')
->not->toContain('xl:h-[calc(100dvh-7.5rem)]')
->not->toContain('xl:overflow-hidden')
->not->toContain('h-[calc(100dvh-20rem)]')
->not->toContain('deployment-logs-panel');
});
it('uses a mobile-friendly stacked logs toolbar markup', function () {
$deploymentView = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
$sharedLogsView = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php'));
$appCss = file_get_contents(resource_path('css/app.css'));
expect($deploymentView)
->toContain('logs-viewer-toolbar')
->toContain('logs-viewer-toolbar-controls')
->toContain('logs-viewer-primary')
->toContain('logs-viewer-meta')
->toContain('logs-viewer-end')
->toContain('logs-viewer-status-badge')
->toContain('logs-viewer-line')
->toContain('logs-viewer-timestamp')
->toContain('logs-viewer-line-text')
->toContain('livewire:project.application.deployment-navbar')
->not->toContain('xl:h-[calc(100dvh-7.5rem)]')
->not->toContain('xl:overflow-hidden')
->toContain('xl:h-[32rem]')
->toContain('xl:flex-none')
->not->toContain('h-[calc(100dvh-20rem)]')
->and($sharedLogsView)
->toContain('logs-viewer-toolbar')
->toContain('logs-viewer-toolbar-controls')
->toContain('logs-viewer-meta')
->toContain('logs-viewer-lines')
->toContain('logs-viewer-actions')
->toContain('logs-viewer-line')
->toContain('runtime-log-columns')
->toContain('runtime-log-detail')
->toContain('runtime-log-empty')
->toContain('runtime-log-loading')
->toContain('wire:loading.flex wire:target="getLogs"')
->toContain('wire:loading.remove wire:target="getLogs"')
->toContain('Loading logs')
->toContain('Logs will appear here when the container produces output.')
->toContain('toggleLogDetails')
->toContain('formatLogDetails')
->toContain('aria-expanded')
->toContain('pl-8!')
->toContain('z-10 size-3.5')
->and($deploymentView)
->toContain('pl-8!')
->toContain('z-10 size-3.5')
->and($appCss)
->toContain('.logs-viewer-toolbar')
->toContain('.logs-viewer-end')
->toContain('.logs-viewer-status-badge')
->toContain('.logs-viewer-lines')
->toContain('.logs-viewer-actions')
->toContain('.logs-viewer-deployment-actions')
->toContain('.logs-settings-section')
->toContain('.runtime-log-columns')
->toContain('.runtime-log-detail')
->toContain('.runtime-log-empty')
->toContain('.runtime-log-loading')
->toContain('grid-template-columns: var(--runtime-log-columns)')
->toContain(".dark .runtime-log-columns {\n background: var(--coollabs-elevated);")
->toContain("html[data-theme=\"custom\"] .runtime-log-columns {\n background: var(--color-log-toolbar);")
->toContain('padding: 0.5rem 0.75rem 0;')
->toContain('padding: 0.5rem 1rem 0;')
->toContain(".logs-viewer-viewport::after {\n content: \"\";\n flex: 0 0 2rem;")
->toContain('flex-direction: column')
->toContain('@media (min-width: 640px)');
$actionsCss = str($appCss)
->after('.logs-viewer-actions {')
->before('}')
->toString();
expect($actionsCss)->not->toContain('isolation: isolate');
$primaryGroup = str($deploymentView)
->after('class="logs-viewer-primary"')
->before('class="logs-viewer-end"')
->toString();
$endGroup = str($deploymentView)
->after('class="logs-viewer-end"')
->before('logs-viewer-viewport')
->toString();
expect($primaryGroup)
->toContain('logs-viewer-status-badge')
->not->toContain('livewire:project.application.deployment-navbar');
$timestampPos = strpos($primaryGroup, 'Toggle Timestamps');
$followPos = strpos($primaryGroup, 'Follow Logs');
$debugPos = strpos($primaryGroup, 'wire:click="toggleDebug"');
$fullscreenPos = strpos($primaryGroup, 'title="Fullscreen"');
$copyPos = strpos($primaryGroup, 'title="Copy Logs"');
$downloadPos = strpos($primaryGroup, 'title="Download Logs"');
$badgePos = strpos($primaryGroup, 'logs-viewer-status-badge');
expect([$timestampPos, $followPos, $debugPos, $fullscreenPos, $copyPos, $downloadPos, $badgePos])
->not->toContain(false)
->and($timestampPos)->toBeLessThan($followPos)
->and($followPos)->toBeLessThan($debugPos)
->and($debugPos)->toBeLessThan($fullscreenPos)
->and($fullscreenPos)->toBeLessThan($copyPos)
->and($copyPos)->toBeLessThan($downloadPos)
->and($downloadPos)->toBeLessThan($badgePos)
->and($endGroup)->toContain('livewire:project.application.deployment-navbar')
->toContain('Find in logs');
$runtimeActionsPos = strpos($sharedLogsView, 'class="logs-viewer-actions"');
$runtimeEndPos = strpos($sharedLogsView, 'class="logs-viewer-end runtime-logs-viewer-end"');
$runtimeLinesPos = strpos($sharedLogsView, 'class="logs-viewer-lines"');
$runtimeSearchPos = strpos($sharedLogsView, 'placeholder="Find in logs"');
expect([$runtimeActionsPos, $runtimeEndPos, $runtimeLinesPos, $runtimeSearchPos])
->not->toContain(false)
->and($runtimeActionsPos)->toBeLessThan($runtimeEndPos)
->and($runtimeEndPos)->toBeLessThan($runtimeLinesPos)
->and($runtimeLinesPos)->toBeLessThan($runtimeSearchPos)
->and($appCss)->toContain('.runtime-logs-viewer-end')
->toContain('.dark .runtime-log-icon-button-active')
->toContain('background: var(--color-coollabs)')
->toContain(".dark .runtime-log-icon-button {\n color: var(--color-fg-faint);")
->toContain(".dark .runtime-log-icon-button-active {\n background: var(--color-coollabs);\n color: #fff;")
->toContain(".runtime-log-toolbar {\n position: relative;\n z-index: 20;")
->and($sharedLogsView)
->toContain('runtime-log-icon-button order-1')
->toContain('runtime-log-icon-button order-2')
->toContain('runtime-log-icon-button order-5')
->toContain('runtime-log-icon-button order-6')
->toContain('relative order-7 shrink-0');
});
it('keeps deployment history fields and the log status badge accessible on mobile', function () {
$deploymentIndexView = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php'));
$appCss = file_get_contents(resource_path('css/app.css'));
expect($deploymentIndexView)
->toContain('deployment-table-scroll')
->and($appCss)
->toContain(".deployment-table-scroll {\n overflow-x: auto;")
->toContain(".deployment-table-grid {\n min-width: 59rem;")
->toContain("@media (min-width: 1024px) {\n .deployment-table-scroll {\n overflow-x: visible;")
->toContain(".deployment-table-grid {\n min-width: 0;")
->not->toContain('.deployment-table-grid > :nth-child')
->toContain(".logs-viewer-primary .logs-viewer-actions {\n width: auto;\n flex: 1 1 auto;");
});
it('links deployment commit hashes to the source commit page', function () {
@@ -266,71 +105,3 @@ it('links deployment commit hashes to the source commit page', function () {
false,
);
});
it('places cancel deployment controls inside the deployment logs toolbar', function () {
$deployment = ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'deployment_uuid' => 'deploy-cancel-toolbar-test',
'server_id' => $this->server->id,
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
'logs' => json_encode([
[
'command' => null,
'output' => 'building image',
'type' => 'stdout',
'timestamp' => now()->toISOString(),
'hidden' => false,
'batch' => 1,
'order' => 1,
],
], JSON_THROW_ON_ERROR),
]);
$response = $this->get(route('project.application.deployment.show', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'application_uuid' => $this->application->uuid,
'deployment_uuid' => $deployment->deployment_uuid,
]));
$response->assertSuccessful();
$response->assertSee('Cancel deployment');
$response->assertSee('logs-viewer-end', false);
$response->assertSee('logs-viewer-deployment-actions', false);
$response->assertSee('logs-viewer-cancel-btn', false);
$content = $response->getContent();
$toolbarPos = strpos($content, 'logs-viewer-toolbar');
$endPos = strpos($content, 'logs-viewer-end');
$viewportPos = strpos($content, 'logs-viewer-viewport');
expect($toolbarPos)->not->toBeFalse()
->and($endPos)->not->toBeFalse()
->and($viewportPos)->not->toBeFalse()
->and($endPos)->toBeGreaterThan($toolbarPos)
->and($endPos)->toBeLessThan($viewportPos);
// Within the right-side end group: cancel controls, then log search.
$endGroup = substr($content, $endPos, $viewportPos - $endPos);
$cancelPos = strpos($endGroup, 'Cancel deployment');
$searchPos = strpos($endGroup, 'Find in logs');
expect($cancelPos)->not->toBeFalse()
->and($searchPos)->not->toBeFalse()
->and($cancelPos)->toBeLessThan($searchPos)
->and($content)->toContain('In progress');
});
it('uses the shared mobile logs layout for proxy and sentinel log pages', function () {
$proxyLogsView = file_get_contents(resource_path('views/livewire/server/proxy/logs.blade.php'));
$sentinelLogsView = file_get_contents(resource_path('views/livewire/server/sentinel/logs.blade.php'));
expect($proxyLogsView)
->toContain('logs-settings-section')
->toContain('logs-section-status-badge')
->toContain('livewire:project.shared.get-logs')
->and($sentinelLogsView)
->toContain('logs-settings-section')
->toContain('logs-section-status-badge')
->toContain('livewire:project.shared.get-logs');
});
+15 -7
View File
@@ -2,22 +2,30 @@
use Symfony\Component\Process\Process;
it('documents example nginx connectivity commands', function () {
$process = new Process(['bash', base_path('scripts/dev.sh'), 'example-nginx', 'help'], base_path());
/*
* The V5 `scripts/dev.sh` (including its `example-nginx` helper) was archived in
* docs/v5/archive/scripts/dev.sh.txt and replaced by `scripts/dev`. These tests make
* sure the current dev script does not document or wire the removed firewall CLI.
*/
it('documents dev script commands without the removed firewall commands', function () {
$process = new Process(['bash', base_path('scripts/dev'), 'help'], base_path());
$process->run();
expect($process->isSuccessful())->toBeTrue()
->and($process->getOutput())->toContain('ping')
->and($process->getOutput())->toContain('./scripts/dev start')
->and($process->getOutput())->not->toContain('firewall up')
->and($process->getOutput())->not->toContain('firewall down')
->and($process->getOutput())->not->toContain('firewall-up')
->and($process->getOutput())->not->toContain('firewall-down');
});
it('does not wire example nginx to removed firewall CLI commands', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
it('does not wire the dev script to removed firewall CLI commands', function () {
expect(file_exists(base_path('scripts/dev.sh')))->toBeFalse();
expect($script)->not->toContain('scripts/dev.sh firewall allow "$src" "$dst" tcp 80')
->and($script)->not->toContain('scripts/dev.sh firewall revoke "$src" "$dst" tcp 80');
$script = file_get_contents(base_path('scripts/dev'));
expect($script)->not->toContain('firewall allow')
->and($script)->not->toContain('firewall revoke');
});
@@ -161,10 +161,14 @@ it('deletes the droplet when local server persistence fails', function () {
&& $request->url() === 'https://api.digitalocean.com/v2/droplets/555');
});
it('renders only the full width buy button at the bottom of the DigitalOcean form', function () {
Livewire::test(ByDigitalOcean::class)
it('renders only a single buy button as the DigitalOcean form action', function () {
$html = Livewire::test(ByDigitalOcean::class)
->set('current_step', 2)
->assertDontSee('wire:click="previousStep"', false)
->assertSeeHtml('class="button w-full"')
->assertSee('Buy & Create Server', false);
->assertSeeHtml('class="button button-highlighted"')
->assertSee('Buy and create')
->html();
expect(substr_count($html, 'type="submit"'))->toBe(1)
->and(substr_count($html, 'Buy and create'))->toBe(1);
});
@@ -302,13 +302,6 @@ test('an unchanged managed record is deleted from cloudflare and coolify', funct
->and(ManagedDnsRecord::query()->find($record->id))->toBeNull();
});
test('dns provider modal view always has a single root element', function () {
$providerModal = file_get_contents(resource_path('views/livewire/project/shared/dns-provider-management.blade.php'));
expect(ltrim($providerModal))->toStartWith('<div class="contents">')
->and($providerModal)->toContain('@if ($showDnsProviderModal)');
});
describe('domain DNS configuration after add', function () {
beforeEach(function () {
$this->withoutVite();
@@ -3,6 +3,7 @@
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
@@ -16,6 +17,7 @@ uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -133,6 +135,12 @@ test('it rejects docker_tag for non docker image applications', function () {
'build_pack' => 'nixpacks',
]);
ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => 7,
'pull_request_html_url' => '',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/deploy', [
@@ -1,75 +1,5 @@
<?php
test('edit domain dialogs close after saving and omit redundant cancel actions', function () {
foreach ([
resource_path('views/livewire/project/application/domains.blade.php'),
resource_path('views/livewire/project/service/domains.blade.php'),
] as $viewPath) {
$view = file_get_contents($viewPath);
expect($view)
->toContain('@edit-domain-saved.window="closeEditDomain()"')
->toContain('<x-forms.button type="submit" wire:target="updateDomain" isHighlighted>')
->not->toContain("@click=\"closeEditDomain()\">\n Cancel");
}
});
it('provides a reusable domain chips form component', function () {
$component = file_get_contents(resource_path('views/components/forms/domain-chips.blade.php'));
expect($component)
->toContain('chip-input')
->toContain('@entangle($model)')
->toContain('class="chip"')
->toContain('chip-remove');
});
it('uses domain chips for application public access and compose services', function () {
$blade = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
expect($blade)
->toContain('x-forms.domain-chips')
->toContain('model="fqdn"')
->toContain(':model="$composeDomainKey"')
->not->toContain('id="parsedServiceDomains.');
});
it('uses domain chips for service application domains and edit domain modal', function () {
$serviceIndex = file_get_contents(resource_path('views/livewire/project/service/index.blade.php'));
$editDomain = file_get_contents(resource_path('views/livewire/project/service/edit-domain.blade.php'));
expect($serviceIndex)
->toContain('x-forms.domain-chips')
->toContain('model="fqdn"')
->toContain('Make publicly available')
->toContain('enablePublicAccess')
->toContain('disablePublicAccess')
->toContain("x-bind:disabled=\"!String(port ?? '').trim()\"")
->toContain('sm:flex-row sm:items-end')
->not->toContain('label="Make it publicly available"')
// Nested @if inside <x-forms.button> breaks Blade component compilation (ParseError endif).
->not->toContain('@if ($canTogglePublicAccess)');
expect($editDomain)
->toContain('x-forms.domain-chips')
->toContain('model="fqdn"')
->not->toContain('id="fqdn"');
});
it('styles icon buttons with a visible hover state', function () {
$utilities = file_get_contents(resource_path('css/utilities.css'));
$resourceCard = file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php'));
expect($utilities)
->toContain('@utility icon-button')
->toContain('hover:bg-neutral-100')
->toContain('dark:hover:bg-white/[0.07]');
expect($resourceCard)
->toContain('class="icon-button"')
->toContain('title="Manage domains"');
});
it('exposes enable and disable public access methods on service index', function () {
$source = file_get_contents(app_path('Livewire/Project/Service/Index.php'));
-93
View File
@@ -1,98 +1,5 @@
<?php
it('renders the dashed empty-state card with title description and icon', function () {
$html = $this->blade(
'<x-empty title="No tags yet" description="Add a tag to group deployments." icon-name="tags" />'
);
$html->assertSee('No tags yet')
->assertSee('Add a tag to group deployments.')
->assertSee('empty-state', false)
->assertSee('border-dashed', false)
->assertSee('min-h-80', false);
});
it('renders empty states flush with no outer inset inside settings sections', function () {
$html = $this->blade(
<<<'BLADE'
<x-application.settings-section title="Scheduled tasks" flush>
<x-empty title="No scheduled tasks"
description="Create a task to run maintenance commands."
icon-name="browser-terminal" />
</x-application.settings-section>
BLADE
);
$html->assertSee('is-flush', false)
->assertSee('empty-state', false)
->assertSee('No scheduled tasks');
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toContain('.application-settings-section-body.is-flush .empty-state')
->toContain('.application-settings-section-body:has(> .empty-state:only-child)')
->toContain('.application-settings-section > .empty-state')
->toMatch('/\.application-settings-section-body\.is-flush \.empty-state[\s\S]*?margin:\s*0/');
});
it('insets empty states nested inside a flush settings section wrapper', function () {
$css = file_get_contents(resource_path('css/app.css'));
expect($css)->toContain('.application-settings-section-body.is-flush > div:has(> .empty-state:only-child)');
});
it('renders the backup executions empty state without a scrollable table', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
expect($view)
->toMatch('/@if \(\$executions_count === 0\)\s*<div class="p-4">\s*<x-empty size="sm" title="No backup executions"/')
->toMatch('/@else\s*<div class="data-table deployment-table-scroll backup-executions-table-scroll">/')
->not->toContain('@forelse ($executions as $execution)');
});
it('keeps backup execution actions compact', function () {
$css = file_get_contents(resource_path('css/app.css'));
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
expect($css)
->toContain('grid-template-columns: 6.5rem minmax(7rem, 0.8fr) minmax(14rem, 1.5fr) 7rem 5rem 4rem minmax(8rem, 1fr) 5rem;')
->and($view)
->toContain('<span class="text-right">Actions</span>')
->toContain('class="flex items-center justify-end gap-1"')
->not->toContain('sticky right-0')
->toContain('title="Download backup" aria-label="Download backup"')
->toContain('<x-reicon name="upload" class="size-3.5 rotate-180" />')
->toContain('title="Delete backup" aria-label="Delete backup"')
->toContain('<x-reicon name="trash" class="size-3.5" />');
});
it('uses the compact resource table styling for backup executions', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
$css = file_get_contents(resource_path('css/app.css'));
expect($view)
->toContain('class="data-table deployment-table-scroll backup-executions-table-scroll"')
->toContain('data-table-header backup-executions-table-grid h-auto rounded-none px-4 py-2.5 text-[11px]')
->toContain('data-table-row backup-executions-table-grid min-h-14 border-b border-neutral-200 px-4 py-2.5 dark:border-white/[0.06]')
->toContain('class="backup-execution-row"')
->toContain('flex min-h-11 items-center justify-between border-t')
->and($css)
->toMatch('/\.backup-executions-table-scroll\s*\{[^}]*overflow-x:\s*auto;/')
->toMatch('/\.backup-execution-row:last-child > \.data-table-row\s*\{[^}]*border-bottom:\s*0;/')
->toMatch('/\.backup-executions-table-grid\s*\{[^}]*gap:\s*0\.75rem;[^}]*min-width:\s*66rem;/');
});
it('renders compact size without the full-page min height class', function () {
$html = $this->blade(
'<x-empty title="No cloud tokens" description="Add a provider token." icon-name="keys" size="sm" />'
);
$html->assertSee('No cloud tokens')
->assertSee('min-h-44', false)
->assertDontSee('min-h-80', false);
});
it('renders action content from both contents and actions slots', function () {
$contents = $this->blade(
'<x-empty title="Empty" icon-name="keys"><x-slot:contents><button type="button">From contents</button></x-slot:contents></x-empty>'
+8 -43
View File
@@ -47,17 +47,6 @@ beforeEach(function () {
Once::flush();
});
it('renders settings email enable actions instead of enabled checkboxes', function () {
$view = file_get_contents(resource_path('views/livewire/settings-email.blade.php'));
expect($view)->toContain('Enable SMTP Server')
->and($view)->toContain('Disable SMTP Server')
->and($view)->toContain('Enable Resend')
->and($view)->toContain('Disable Resend')
->and($view)->not->toContain('id="smtpEnabled" label="Enabled"')
->and($view)->not->toContain('id="resendEnabled" label="Enabled"');
});
it('keeps transactional smtp disabled when enable validation fails', function () {
actingAsEnableActionInstanceAdmin();
@@ -87,19 +76,6 @@ it('enables transactional smtp only after required fields validate', function ()
->and(instanceSettings()->fresh()->resend_enabled)->toBeFalse();
});
it('renders notification provider enable actions instead of enabled checkboxes', function (string $view, string $enableLabel, string $checkboxSnippet) {
$contents = file_get_contents(resource_path("views/livewire/notifications/{$view}.blade.php"));
expect($contents)->toContain($enableLabel)
->and($contents)->not->toContain($checkboxSnippet);
})->with([
'discord' => ['discord', 'Enable Discord', 'id="discordEnabled" label="Enabled"'],
'slack' => ['slack', 'Enable Slack', 'id="slackEnabled" label="Enabled"'],
'telegram' => ['telegram', 'Enable Telegram', 'id="telegramEnabled" label="Enabled"'],
'pushover' => ['pushover', 'Enable Pushover', 'id="pushoverEnabled" label="Enabled"'],
'webhook' => ['webhook', 'Enable Webhook', 'id="webhookEnabled" label="Enabled"'],
]);
it('shows notification provider save buttons while disabled', function (string $component) {
actingAsEnableActionOwner();
@@ -117,12 +93,15 @@ it('shows notification provider save buttons while disabled', function (string $
it('hides notification provider test buttons while disabled and shows them when enabled', function (string $component, string $enabledProperty) {
actingAsEnableActionOwner();
Livewire::test($component)
->assertDontSee('Send Test Notification');
// The channel actions always render "Send test"; it stays disabled until the channel is enabled.
$sendTestButton = '/<button\s+(disabled\s+)?class="button"[^>]*\$wire\.\$call\(testMethod\)[^>]*>/';
Livewire::test($component)
->set($enabledProperty, true)
->assertSee('Send Test Notification');
preg_match($sendTestButton, Livewire::test($component)->html(), $disabledMatch);
expect($disabledMatch[1] ?? null)->not->toBeEmpty();
preg_match($sendTestButton, Livewire::test($component)->set($enabledProperty, true)->html(), $enabledMatch);
expect($enabledMatch)->not->toBeEmpty()
->and($enabledMatch[1] ?? '')->toBe('');
})->with([
'discord' => [Discord::class, 'discordEnabled'],
'slack' => [Slack::class, 'slackEnabled'],
@@ -155,20 +134,6 @@ it('keeps notification providers disabled when enable validation fails', functio
'webhook' => [Webhook::class, 'toggleWebhookEnabled', 'webhookEnabled', 'webhookUrl', 'webhookNotificationSettings', 'webhook_enabled'],
]);
it('renders notification email and log drain enable actions instead of enabled checkboxes', function () {
$notificationEmail = file_get_contents(resource_path('views/livewire/notifications/email.blade.php'));
$logDrains = file_get_contents(resource_path('views/livewire/server/log-drains.blade.php'));
expect($notificationEmail)->toContain('Enable SMTP Server')
->and($notificationEmail)->toContain('Enable Resend')
->and($notificationEmail)->not->toContain('id="smtpEnabled"')
->and($notificationEmail)->not->toContain('id="resendEnabled"')
->and($logDrains)->toContain('Enable New Relic')
->and($logDrains)->toContain('Enable Axiom')
->and($logDrains)->toContain('Enable Custom FluentBit')
->and($logDrains)->not->toContain('label="Enabled"');
});
it('keeps notification email smtp disabled when enable validation fails', function () {
actingAsEnableActionOwner();
+3 -52
View File
@@ -1,64 +1,15 @@
<?php
it('uses the current listbox design for environment variable suggestions', function () {
$view = file_get_contents(resource_path('views/components/forms/env-var-input.blade.php'));
expect($view)
->toContain('class="listbox-panel')
->toContain('class="listbox-option justify-start! gap-2.5!"')
->toContain("'bg-neutral-100 dark:bg-white/[0.08]': index === selectedIndex")
->toContain('border-warning/25 bg-warning/10')
->toContain('border-emerald-500/25 bg-emerald-500/10')
->not->toContain('dark:bg-coolgray-100');
});
it('keeps the environment variable input enabled while secret manager keys load', function () {
$view = file_get_contents(resource_path('views/components/forms/env-var-input.blade.php'));
expect($view)->toContain('wire:target.except="fetchSecretManagerKeys"');
});
it('allows secret manager key loading to retry after a failed request', function () {
$view = file_get_contents(resource_path('views/components/forms/env-var-input.blade.php'));
$failureHandler = explode('});', explode('.catch(() => {', $view, 2)[1], 2)[0];
expect($failureHandler)
->toContain('this.vaultKeysLoading = false;')
->not->toContain("this.availableVars['vault'] = [];");
});
it('authorizes secret-enabled environment variable inputs at the component boundary', function () {
$addView = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/add.blade.php'));
$showView = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
foreach ([$addView, $showView] as $view) {
// Show resolves the resource through its computed `resource` property.
foreach ([[$addView, '$resource'], [$showView, '$this->resource']] as [$view, $resourceExpression]) {
preg_match('/<x-forms\.env-var-input[\s\S]*?\/>/', $view, $matches);
expect($matches[0] ?? '')
->toContain('canGate="manageEnvironment"')
->toContain(':canResource="$resource"');
}
});
it('passes the remove source warning without compiling remote secret syntax as blade', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/secret-manager-links.blade.php'));
expect($view)
->toContain('with {{vault.KEY}}. Values are fetched')
->not->toContain('{{doppler.KEY}}')
->not->toContain('{{infisical.KEY}}')
->toContain(':actions="[$removeSourceWarning]"')
->not->toContain(':actions="[\'Existing {{vault.*}}');
});
it('shows secret manager configuration for applications services and databases', function () {
$views = [
resource_path('views/livewire/project/application/configuration.blade.php'),
resource_path('views/livewire/project/service/configuration.blade.php'),
resource_path('views/livewire/project/database/configuration.blade.php'),
];
foreach ($views as $view) {
expect(file_get_contents($view))->toContain('livewire:project.shared.secret-manager-links');
->toContain(':canResource="'.$resourceExpression.'"');
}
});
@@ -33,8 +33,8 @@ test('shared variable preserves spacing in reference', function () {
$env = EnvironmentVariable::create([
'key' => 'TEST_VAR',
'value' => '{{ project.aaa }}',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
'resourceable_id' => $this->application->id,
'resourceable_type' => $this->application->getMorphClass(),
]);
$env->refresh();
@@ -45,8 +45,8 @@ test('shared variable preserves no-space format', function () {
$env = EnvironmentVariable::create([
'key' => 'TEST_VAR',
'value' => '{{project.aaa}}',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
'resourceable_id' => $this->application->id,
'resourceable_type' => $this->application->getMorphClass(),
]);
$env->refresh();
@@ -67,8 +67,8 @@ test('shared variable with spaces resolves correctly', function () {
$env = EnvironmentVariable::create([
'key' => 'MY_VAR',
'value' => '{{ project.TEST_KEY }}',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
'resourceable_id' => $this->application->id,
'resourceable_type' => $this->application->getMorphClass(),
]);
// Verify it resolves correctly
@@ -90,8 +90,8 @@ test('shared variable without spaces resolves correctly', function () {
$env = EnvironmentVariable::create([
'key' => 'MY_VAR',
'value' => '{{project.TEST_KEY}}',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
'resourceable_id' => $this->application->id,
'resourceable_type' => $this->application->getMorphClass(),
]);
// Verify it resolves correctly
@@ -113,8 +113,8 @@ test('shared variable with extra internal spaces resolves correctly', function (
$env = EnvironmentVariable::create([
'key' => 'MY_VAR',
'value' => '{{ project.TEST_KEY }}',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
'resourceable_id' => $this->application->id,
'resourceable_type' => $this->application->getMorphClass(),
]);
// Verify it resolves correctly (parser trims when extracting)
@@ -126,8 +126,8 @@ test('is_shared attribute detects variable with spaces', function () {
$env = EnvironmentVariable::create([
'key' => 'TEST',
'value' => '{{ project.aaa }}',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
'resourceable_id' => $this->application->id,
'resourceable_type' => $this->application->getMorphClass(),
]);
expect($env->is_shared)->toBeTrue();
@@ -137,8 +137,8 @@ test('is_shared attribute detects variable without spaces', function () {
$env = EnvironmentVariable::create([
'key' => 'TEST',
'value' => '{{project.aaa}}',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
'resourceable_id' => $this->application->id,
'resourceable_type' => $this->application->getMorphClass(),
]);
expect($env->is_shared)->toBeTrue();
@@ -148,8 +148,8 @@ test('is_shared persisted value rejects unsupported reference types', function (
$env = EnvironmentVariable::create([
'key' => 'TEST',
'value' => '{{vault.KEY}}',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
'resourceable_id' => $this->application->id,
'resourceable_type' => $this->application->getMorphClass(),
]);
$env->refresh();
@@ -165,8 +165,8 @@ test('non-shared variable preserves spaces', function () {
$env = EnvironmentVariable::create([
'key' => 'REGULAR',
'value' => 'regular value with spaces',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
'resourceable_id' => $this->application->id,
'resourceable_type' => $this->application->getMorphClass(),
]);
$env->refresh();
@@ -177,8 +177,8 @@ test('mixed content with shared variable preserves all spacing', function () {
$env = EnvironmentVariable::create([
'key' => 'MIXED',
'value' => 'prefix {{ project.aaa }} suffix',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
'resourceable_id' => $this->application->id,
'resourceable_type' => $this->application->getMorphClass(),
]);
$env->refresh();
@@ -189,8 +189,8 @@ test('multiple shared variables preserve individual spacing', function () {
$env = EnvironmentVariable::create([
'key' => 'MULTI',
'value' => '{{ project.a }} and {{team.b}}',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
'resourceable_id' => $this->application->id,
'resourceable_type' => $this->application->getMorphClass(),
]);
$env->refresh();
@@ -201,8 +201,8 @@ test('leading and trailing spaces are trimmed', function () {
$env = EnvironmentVariable::create([
'key' => 'TRIMMED',
'value' => ' {{ project.aaa }} ',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
'resourceable_id' => $this->application->id,
'resourceable_type' => $this->application->getMorphClass(),
]);
$env->refresh();
@@ -15,7 +15,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -1,19 +1,43 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\All;
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(function () {
InstanceSettings::updateOrCreate(['id' => 0], []);
});
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->team->members()->attach($this->user, ['role' => 'owner']);
$this->application = Application::factory()->create([
'team_id' => $this->team->id,
]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id, 'network' => 'coolify-test']);
$this->application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
]);
});
test('environment variable can be created with comment', function () {
@@ -143,10 +167,12 @@ test('environment variable comment cannot exceed 256 characters via Livewire', f
$longComment = str_repeat('a', 257);
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\Show::class, ['env' => $env, 'type' => 'application'])
Livewire::test(Show::class, ['env' => $env, 'type' => 'application'])
->set('comment', $longComment)
->call('submit')
->assertHasErrors(['comment' => 'max']);
expect($env->fresh()->comment)->toBeNull();
});
test('bulk update preserves existing comments when no inline comment provided', function () {
@@ -162,7 +188,7 @@ test('bulk update preserves existing comments when no inline comment provided',
// User switches to Developer view and pastes new value without inline comment
$bulkContent = "DATABASE_URL=postgres://new-host\nOTHER_VAR=value";
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
Livewire::test(All::class, [
'resource' => $this->application,
'type' => 'application',
])
@@ -192,7 +218,7 @@ test('bulk update overwrites existing comments when inline comment provided', fu
// User pastes new value WITH inline comment
$bulkContent = 'API_KEY=new-key #Updated production key';
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
Livewire::test(All::class, [
'resource' => $this->application,
'type' => 'application',
])
@@ -230,7 +256,7 @@ test('bulk update handles mixed inline and stored comments correctly', function
// Bulk paste: one with inline comment, one without
$bulkContent = "VAR_WITH_COMMENT=new_value1 #New inline comment\nVAR_WITHOUT_COMMENT=new_value2";
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
Livewire::test(All::class, [
'resource' => $this->application,
'type' => 'application',
])
@@ -254,7 +280,7 @@ test('bulk update creates new variables with inline comments', function () {
// Bulk paste creates new variables, some with inline comments
$bulkContent = "NEW_VAR1=value1 #Comment for var1\nNEW_VAR2=value2\nNEW_VAR3=value3 #Comment for var3";
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
Livewire::test(All::class, [
'resource' => $this->application,
'type' => 'application',
])
@@ -4,6 +4,9 @@ use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
@@ -94,9 +97,11 @@ test('all boolean fields default correctly when not provided', function () {
// Boolean fields can be null or false depending on database defaults
expect($env->is_multiline)->toBeIn([false, null]);
expect($env->is_preview)->toBeIn([false, null]);
expect($env->is_runtime)->toBeIn([false, null]);
expect($env->is_buildtime)->toBeIn([false, null]);
expect($env->is_shown_once)->toBeIn([false, null]);
// New variables are available at runtime and build time unless configured otherwise
expect($env->is_runtime)->toBeTrue();
expect($env->is_buildtime)->toBeTrue();
});
test('value field is properly encrypted when mass assigned', function () {
@@ -113,7 +118,7 @@ test('value field is properly encrypted when mass assigned', function () {
expect($env->value)->toBe($plainValue);
// Verify it's actually encrypted in the database
$rawValue = \DB::table('environment_variables')
$rawValue = DB::table('environment_variables')
->where('id', $env->id)
->value('value');
@@ -121,17 +126,26 @@ test('value field is properly encrypted when mass assigned', function () {
expect($rawValue)->not->toBeNull();
});
test('key field is trimmed and spaces replaced with underscores', function () {
test('key field is trimmed', function () {
$env = EnvironmentVariable::create([
'key' => ' TEST KEY WITH SPACES ',
'key' => ' TEST_KEY ',
'value' => 'test_value',
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
expect($env->key)->toBe('TEST_KEY_WITH_SPACES');
expect($env->key)->toBe('TEST_KEY');
});
test('key field with inner spaces is rejected', function () {
EnvironmentVariable::create([
'key' => ' TEST KEY WITH SPACES ',
'value' => 'test_value',
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
})->throws(InvalidArgumentException::class);
test('version field can be mass assigned', function () {
$env = EnvironmentVariable::create([
'key' => 'TEST_VAR',
@@ -164,7 +178,7 @@ test('mass assignment works with update method', function () {
expect($env->value)->toBe('updated_value');
expect($env->comment)->toBe('Updated comment');
expect($env->is_literal)->toBeTrue();
expect((bool) $env->is_literal)->toBeTrue();
});
test('protected attributes cannot be mass assigned', function () {
@@ -1,14 +1,9 @@
<?php
it('uses Alpine entangle to switch add value field immediately when multiline is enabled', function () {
it('uses distinct keys for the add value field modes', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/add.blade.php'));
expect($view)
->toContain('x-data="{ isMultiline: $wire.entangle(\'is_multiline\') }"')
->toContain('<template x-if="isMultiline">')
->toContain('<template x-if="!isMultiline">')
->toContain('id="is_multiline"')
->toContain('<x-forms.textarea id="value" label="Value" required class="font-sans" spellcheck />')
->toContain('wire:key="env-value-textarea"')
->toContain('wire:key="env-value-input"');
});
@@ -17,58 +12,6 @@ it('uses distinct keyed branches for the edit value field modes', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($view)
->toContain('wire:key="env-show-value-textarea-{{ $env->id }}"')
->toContain('wire:key="env-show-value-input-{{ $env->id }}"');
});
it('lazy-loads decrypted values only when opening the edit modal', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($view)
->toContain('wire:click="loadValues"')
->toContain('wireOpen="editorOpen"')
->toContain('$valuesLoaded')
->toContain('Loading value...');
expect($view)->toContain('<x-forms.input loading loadingText="Loading value..."');
});
it('keeps the environment variable delete button compact', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($view)->not->toContain('buttonFullWidth="true"');
});
it('uses sans font for the developer bulk environment variable editor', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect($view)
->toContain('class="whitespace-pre-wrap font-sans"')
->not->toContain('wire:model="variables" monospace')
->not->toContain('wire:model="variablesPreview" monospace');
});
it('loads environment variables asynchronously after first paint', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect($view)
->toContain('wire:init="loadEnvironmentVariables"')
->toContain('Loading environment variables...');
});
it('renders a single no results message for empty environment variable searches', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect($view)
->toContain('@if ($this->isSearchActive && $totalRows === 0)')
->toContain('No environment variables found');
});
it('paginates a unified environment variable table instead of loading every row', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect($view)
->toContain('$this->environmentVariablePageRows')
->toContain('nextEnvironmentVariablePage')
->not->toContain('@forelse ($this->environmentVariables as $env)');
->toContain('wire:key="env-show-value-multiline-{{ $env->id }}"')
->toContain('wire:key="env-show-value-single-{{ $env->id }}"');
});
@@ -1,126 +1,5 @@
<?php
/**
* Resource environment variables table: full names, Managed as a column,
* Type owns Production/Preview (no desktop Production badge in the name cell).
*/
test('resource environment variables table has a Managed column and no name-cell Production badge on desktop', function () {
$all = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
$show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
$hardcoded = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show-hardcoded.blade.php'));
$css = file_get_contents(resource_path('css/app.css'));
$filter = file_get_contents(resource_path('views/components/table/filter.blade.php'));
$loading = file_get_contents(resource_path('views/components/table/loading.blade.php'));
// Header includes Managed between Name and Type.
expect($all)
->toContain('$showEnvironmentType = $showPreview')
->toContain("toggleVariableFilter('{{ \$value }}')")
->toContain('toggleServiceFilter(@js($serviceName))')
->toContain('$this->serviceFilterOptions')
->toContain('toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter')
->toContain('<x-table.loading target="clearFilters"')
->toContain('reset-action="clearFilters"')
->not->toContain("'all' => 'All variables'")
->toContain("setTableSort('{{ \$value }}')")
->toContain('Loading environment variables...')
->toContain('opacity-40 blur-[2px]')
->toContain('setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage')
->toContain("'buildtime' => 'Buildtime'")
->toContain("'runtime' => 'Runtime'")
->toContain("'multiline' => 'Multiline'")
->toContain("'literal' => 'Literal'")
->toContain('$activeFilterCount')
->toContain('$activeFilterText')
->toContain("'flex size-4 shrink-0 items-center justify-center rounded-[5px] border'")
->toContain('m2.25 6.15 2.35 2.3 5.15-5')
->toContain('>Name</span>')
->toContain('>Managed</span>')
->toContain('>Type</span>')
->and($filter)
->toContain('Reset filters')
->toContain('max-h-80 overflow-y-auto p-1')
->toContain('min-w-44! overflow-hidden! p-0!')
->and($loading)
->toContain('wire:loading.flex');
// Name cell does not repeat the environment type; Type owns Production/Preview.
expect($show)
->toContain('env-managed-desktop')
->toContain('env-type-desktop')
->not->toContain('env-type-mobile')
->not->toContain('env-managed-mobile')
->toContain('env-managed-desktop data-table-cell-check')
->toContain('$isMagicVariable');
// Production/Managed desktop badges must not sit bare in the name cell without mobile class.
expect($show)->not->toMatch(
'/env-key-label[\s\S]{0,400}<span class="table-badge shrink-0">Managed<\/span>/'
);
expect($show)->not->toMatch(
'/env-key-label[\s\S]{0,500}<span class="table-badge shrink-0">\{\{\s*\$rowScopeLabel\s*\}\}<\/span>/'
);
expect($hardcoded)
->toContain('env-managed-desktop data-table-cell-check')
->toContain('title="Environment variable details"')
->toContain('<x-forms.input label="Value" :value="$value ?? \'\'" readonly />')
->not->toContain("{{ filled(\$value) ? \$value : '(empty)' }}")
->toContain('env-type-desktop')
->not->toContain('env-type-mobile')
->not->toContain('env-managed-mobile');
// Mobile-only badge classes beat .table-badge display on desktop.
expect($css)
->toContain('.env-managed-desktop')
// Resource grid is 9 columns (includes Managed).
->toContain('minmax(14rem, 2.5fr) 4.8rem 6rem 4rem 4.5rem 4.8rem 4.2rem 3rem');
expect($all)->not->toContain('<span>Comment</span>');
expect($show)->toContain('<x-helper :helper="e($comment)" />');
});
test('resource environment variables expose their settings action without horizontal scrolling on mobile', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
$show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
$hardcoded = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show-hardcoded.blade.php'));
$css = file_get_contents(resource_path('css/app.css'));
expect($view)
->toContain('environment-table-scroll')
->and($show)
->toContain('data-env-name-trigger')
->toContain('data-env-settings-trigger')
->and($hardcoded)
->toContain('data-env-name-trigger')
->toContain('data-env-settings-trigger')
->and($css)
->toContain(".environment-table-scroll {\n overflow-x: auto;")
->toContain(".environment-table-scroll .env-table-grid {\n min-width: 53rem;")
->toContain('.data-table-header.env-table-grid')
->toContain('.data-table-row.env-table-grid > :last-child')
->not->toContain(".env-type-desktop {\n display: none");
});
test('shared environment variables table still omits Managed column', function () {
$editor = file_get_contents(resource_path('views/components/shared-variables/editor.blade.php'));
$show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($editor)
->toContain('env-table-grid-shared')
->not->toContain('>Managed</span>');
// Shared rows skip the Managed column cell.
expect($show)->toContain('! $isSharedVariable');
});
test('shared environment variables empty state has spacing around the card', function () {
$editor = file_get_contents(resource_path('views/components/shared-variables/editor.blade.php'));
expect($editor)
->toMatch('/<div class="p-3">\s*<x-empty title="No shared variables"/');
});
test('managed environment variables are ordered first', function () {
$component = file_get_contents(app_path('Livewire/Project/Shared/EnvironmentVariable/All.php'));
@@ -141,24 +20,3 @@ test('missing required environment variables are ordered before generated servic
->and($component)
->toContain('->filter(fn (EnvironmentVariable $environmentVariable): bool => $environmentVariable->is_really_required)');
});
test('environment variable toolbar does not use blade directives inside component attributes', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect($view)
->not->toMatch('/<x-table\.toolbar[^>]*@if/')
->toContain('aria-busy="{{ ! $readyToLoad ? \'true\' : \'false\' }}"');
});
test('environment variable editor places delete left and update actions right', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($view)
->toContain('class="flex flex-wrap items-center justify-between gap-2 border-t')
->toMatch('/data-environment-variable-delete-action[\s\S]+buttonTitle="Delete"[\s\S]+data-environment-variable-update-actions[\s\S]+Lock[\s\S]+Update variable/')
->toContain('@environment-variable-updated.window="if ($event.detail.envId === @js($env->id)) modalOpen = false"')
->toContain('<fieldset disabled wire:dirty.attr.remove="disabled">')
->toContain('type="submit" isHighlighted wire:target="submit"')
->toContain('wire:dirty.attr.remove="disabled"')
->not->toContain('@click="modalOpen = false"');
});
-48
View File
@@ -2,33 +2,6 @@
use Illuminate\Support\Facades\Blade;
it('marks error buttons with the shared error class', function () {
$html = Blade::render('<x-forms.button isError>Delete service</x-forms.button>');
expect($html)
->toContain('class="button button-error"')
->not->toContain('isError');
});
it('keeps unavailable destructive actions styled as error buttons', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/danger.blade.php'));
expect($view)->toContain('<x-forms.button isError disabled tooltip="You do not have permission to delete this resource.">');
});
it('uses the shared error style for destructive form buttons', function (string $view, string $button) {
expect(file_get_contents(resource_path("views/{$view}")))->toContain($button);
})->with([
'unavailable team deletion' => [
'livewire/team/danger-zone.blade.php',
'<x-forms.button isError disabled tooltip="Resolve the requirements shown before deleting this team.">',
],
'tag removal' => [
'livewire/project/shared/tags.blade.php',
'<x-forms.button isError wire:click="deleteTag',
],
]);
it('disables the button and targets the wire click action while loading', function () {
$html = Blade::render(
'<x-forms.button wire:click.prevent="checkConnection"><x-reicon name="refresh" class="size-3.5" />Check connection</x-forms.button>'
@@ -57,12 +30,6 @@ it('uses an explicit wire target for submit buttons without wire click', functio
->toContain('Validate and add');
});
it('targets the S3 connection validation submit action', function () {
$view = file_get_contents(resource_path('views/livewire/storage/create.blade.php'));
expect($view)->toContain('<x-forms.button class="mt-4" type="submit" wire:target="submit">');
});
it('keeps method arguments when deriving the loading target', function () {
$html = Blade::render(
'<x-forms.button wire:click="setPrivateKey(42)">Use this key</x-forms.button>'
@@ -85,18 +52,3 @@ it('skips loading attributes when the loading indicator is disabled', function (
->not->toContain('animate-spin')
->toContain('Check connection');
});
it('hides static button icons with the shared is-loading utility', function () {
$css = file_get_contents(resource_path('css/utilities.css'));
expect($css)->toContain('.button.is-loading > svg:not(.animate-spin)');
});
it('applies automatic loading behavior on the server private key check connection button', function () {
$view = file_get_contents(resource_path('views/livewire/server/private-key/show.blade.php'));
expect($view)
->toContain('wire:click.prevent="checkConnection"')
->toContain('<x-reicon name="refresh" class="size-3.5" />')
->toContain('<x-forms.button canGate="update" :canResource="$server"');
});
@@ -26,38 +26,6 @@ beforeEach(function () {
]);
});
it('places source navigation in the sidebar and source status in breadcrumbs', function () {
$view = file_get_contents(resource_path('views/livewire/source/github/change.blade.php'));
$navbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
expect($view)
->toContain(':mobileTitleOnly="true"')
->not->toContain('<x-slot:titleMeta>')
->toContain("'label' => 'Permissions'")
->toContain("'icon' => 'keys'")
->toContain("'label' => 'Resources'")
->toContain('application-settings-navigation min-w-0 xl:self-start')
->not->toContain('application-settings-navigation min-w-0 xl:sticky')
->toContain('source.github.danger')
->toContain('Danger Zone')
->toContain('github-app-danger-section')
->toContain('submitAction="delete"');
expect($navbar)
->toContain("request()->routeIs('source.github.show', 'source.github.permissions', 'source.github.resources', 'source.github.danger')")
->not->toContain("['label' => 'Permissions', 'route' => 'source.github.permissions'")
->not->toContain("['label' => 'Resources', 'route' => 'source.github.resources'");
expect(file_get_contents(resource_path('views/components/top-breadcrumb.blade.php')))
->toContain('x-breadcrumb-switcher')
->toContain('$currentSource->name')
->toContain("route('source.github.show'")
->toContain("route('source.gitlab.show'");
expect(file_get_contents(base_path('routes/web.php')))
->toContain("->name('source.github.danger')");
});
it('sets the danger active tab from the danger route', function () {
$githubApp = GithubApp::create([
'uuid' => (string) str()->uuid(),
+4
View File
@@ -1,5 +1,9 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
describe('GitHub Manual Webhook', function () {
test('ping event returns pong', function () {
$response = $this->postJson('/webhooks/source/github/events/manual', [], [
@@ -46,12 +46,6 @@ beforeEach(function () {
});
describe('GitLab App authorization', function () {
test('empty gitlab app state is inset from the section edges', function () {
$view = file_get_contents(resource_path('views/livewire/project/new/gitlab-private-repository.blade.php'));
expect($view)->toContain("<div class=\"application-settings-section-body\">\n <x-empty title=\"No GitLab Apps\"");
});
test('unrelated users cannot inspect system-wide source secrets in the component payload', function () {
$otherTeam = Team::factory()->create();
$systemWideSource = GitlabApp::create([
@@ -184,5 +178,4 @@ describe('GitLab App authorization', function () {
expect(fn () => $this->owner->can('update', $orphaned))->not->toThrow(TypeError::class);
expect($this->owner->can('update', $orphaned))->toBeFalse();
});
});
+11 -2
View File
@@ -13,11 +13,20 @@ afterEach(function () {
Model::encryptUsing(null);
});
/**
* Http::GitSource() pins DNS before Http::fake() answers, so use a public IP
* literal that needs no DNS lookup. No request leaves the test.
*/
function gitlabListingApiUrl(): string
{
return 'https://1.1.1.1/api/v4';
}
it('returns a stable shape with has_more false when GitLab repo listing fails', function () {
Http::fake(['*' => Http::response(['message' => '401 Unauthorized'], 401)]);
$source = new GitlabApp([
'api_url' => 'https://gitlab.example.test/api/v4',
'api_url' => gitlabListingApiUrl(),
'access_token' => str_repeat('t', 20), // ggignore
'refresh_token' => str_repeat('r', 20), // ggignore
'expires_at' => time() + 3600,
@@ -38,7 +47,7 @@ it('limits GitLab repositories to the exact group and its descendants', function
], 200)]);
$source = new GitlabApp([
'api_url' => 'https://gitlab.example.test/api/v4',
'api_url' => gitlabListingApiUrl(),
'access_token' => str_repeat('t', 20), // ggignore
'refresh_token' => str_repeat('r', 20), // ggignore
'expires_at' => time() + 3600,
+7 -15
View File
@@ -38,16 +38,15 @@ beforeEach(function () {
});
describe('GitLab source setup view', function () {
test('shows red incomplete-setup alert and keeps advanced fields collapsed', function () {
test('shows incomplete-setup guidance with the credential form', function () {
Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
->test(Change::class)
->assertSee('You must complete this step before you can use this source!')
->assertSeeHtml('alert-error')
->assertSee('Advanced / Self-hosted')
->assertSee('Finish connecting this GitLab App before using it as a source')
->assertSee('Advanced / self-hosted')
->assertSee('Application ID')
->assertSee('Application Secret')
->assertSee('Application secret')
->assertSee('Save')
->assertDontSee('alert-warning');
->assertDontSee('Test connection');
});
test('derives api url when gitlab url changes', function () {
@@ -66,14 +65,6 @@ describe('GitLab source setup view', function () {
$this->assertModelMissing($this->gitlabApp);
});
test('uses the persisted name for the title during a post-delete update', function () {
$view = file_get_contents(resource_path('views/livewire/source/gitlab/change.blade.php'));
expect($view)
->toContain("{{ \$name ?: 'GitLab App' }} | Sources | Coolify")
->not->toContain("{{ \$gitlab_app->name ?: 'GitLab App' }} | Sources | Coolify");
});
test('saves and reloads the application secret after refresh', function () {
Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
->test(Change::class)
@@ -94,7 +85,8 @@ describe('GitLab source setup view', function () {
test('supports github-style custom public endpoint for oauth redirect uri', function () {
Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
->test(Change::class)
->assertSee('Use custom webhook endpoint')
->assertSee('Webhook endpoint')
->assertSee('Use a custom endpoint')
->assertSee('Selected endpoint')
->set('use_custom_webhook_endpoint', true)
->set('custom_webhook_endpoint', 'http://100.75.155.70:8000')
@@ -21,7 +21,7 @@ beforeEach(function () {
describe('GitLab source create modal', function () {
test('matches github create modal structure', function () {
Livewire::test(Create::class)
->assertSee('This is required if you would like to get full integration')
->assertSee('Connect a GitLab OAuth application for private repositories, webhooks, and deployments.')
->assertSee('Self-hosted GitLab')
->assertSee('Continue')
->assertDontSee('>Save</', false)
@@ -2,22 +2,6 @@
use Illuminate\Support\Facades\Blade;
/**
* Header version badge should open the matching GitHub release page.
*/
test('desktop header version links to the coolify github release for the installed version', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
$version = file_get_contents(resource_path('views/components/version.blade.php'));
expect($layout)
->toContain('<x-version')
->not->toContain("class=\"text-[10.5px] font-medium text-neutral-400 dark:text-fg-faint\">v{{ config('constants.coolify.version') }}</span>");
expect($version)
->toContain("https://github.com/coollabsio/coolify/releases/tag/v{{ config('constants.coolify.version') }}")
->toContain('target="_blank"');
});
test('development versions are not linked to nonexistent github releases', function () {
config(['constants.coolify.version' => '4.3.1-dev.d64cbda3e']);
@@ -28,11 +12,3 @@ test('development versions are not linked to nonexistent github releases', funct
->not->toContain('href=')
->not->toContain('target="_blank"');
});
test('mobile sidebar shows the installed coolify version when opened', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($layout)
->toContain('data-mobile-sidebar-brand')
->toContain('<x-version class="!text-[10.5px]');
});
-143
View File
@@ -2,149 +2,6 @@
use Illuminate\Support\Facades\Blade;
test('helper trigger is a button that stops label activation', function () {
$path = resource_path('views/components/helper.blade.php');
$contents = file_get_contents($path);
expect($contents)
->toContain('type="button"')
->toContain('@click.prevent.stop')
->toContain('aria-label="{{ $label }}"')
->toContain('data-icon-tooltip-ignore')
->toContain('info-helper-popup')
->toContain('name="info-circle"')
->toContain('class="size-3.5 text-neutral-400')
->not->toContain('<div x-ref="trigger" class="info-helper"');
});
test('helper popup uses the redesigned raised surface styles', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
$utilities = file_get_contents(resource_path('css/utilities.css'));
expect($helper)
->toContain('info-helper-popup')
->toContain('x-transition:enter')
->toContain('text-[13px] leading-5');
expect($utilities)
->toContain('@utility info-helper-popup')
->toContain('dark:bg-raised')
->toContain('dark:border-white/10')
->toContain('shadow-modal')
->toContain('@utility auth-tooltip')
->toContain('rounded-lg');
});
test('helper popup remains open while moving from the trigger into interactive content', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
expect($helper)
->toContain('hideTimer: null')
->toContain('setTimeout(() =>')
->toContain('@mouseenter="cancelHide()"')
->toContain('@mouseleave="hide()"');
});
test('clicking a helper does not pin its popup open', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
expect($helper)
->toContain('@click.prevent.stop="show()"')
->not->toContain('pinned');
});
test('helper popup stays in its alpine scope during livewire morphs', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
expect($helper)
->not->toContain('x-teleport');
});
test('helper triggers do not create stacking contexts above open tooltips', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
expect($helper)
->toContain("'class' => 'relative inline-block align-middle'")
->toContain("'info-helper relative inline-flex")
->not->toContain("'class' => 'relative z-10 inline-block align-middle'")
->not->toContain("'info-helper relative z-10 inline-flex");
});
test('helper popup prefers a position below its trigger and falls back above', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
expect($helper)
->toContain('let top = triggerRect.bottom + padding;')
->toContain('let left = triggerRect.right - popupRect.width;')
->toContain('top = triggerRect.top - popupRect.height - padding;');
});
test('helper popup stays within the visual viewport on mobile', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
expect($helper)
->toContain('window.visualViewport')
->toContain('viewport?.offsetLeft')
->toContain('viewport?.offsetTop')
->toContain('max-width: ${popupWidth}px;')
->toContain('max-height: ${availableHeight}px;')
->toContain('overflow-y: auto;');
});
test('helper popup width is capped while allowing content to wrap vertically', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
expect($helper)
->toContain('const maxPopupWidth = 320;')
->toContain('const popupWidth = Math.min(maxPopupWidth, availableWidth);')
->toContain('max-width: ${popupWidth}px;')
->toContain('whitespace-normal');
});
test('helper popup supports focus dismissal and tooltip aria relationships', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
expect($helper)
->toContain('closeWhenFocusLeaves()')
->toContain('@focus="show()"')
->toContain('@blur="closeWhenFocusLeaves()"')
->toContain('role="tooltip"')
->toContain(':aria-describedby="open ? $id(\'helper-popup\') : null"')
->toContain(':id="$id(\'helper-popup\')"');
});
test('teleported helper popup closes from taps outside on mobile', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
expect($helper)
->toContain('@pointerdown.window="closeWhenPointerIsOutside($event)"')
->toContain('closeWhenPointerIsOutside(event)')
->toContain('this.$refs.popup?.contains(event.target)');
});
test('button tooltips are available to keyboard and assistive technology users', function () {
$button = file_get_contents(resource_path('views/components/forms/button.blade.php'));
expect($button)
->toContain('@focusin="showTooltip()"')
->toContain('@focusout="hideTooltip()"')
->toContain('@click.outside="hideTooltip()"')
->toContain('role="tooltip"')
->toContain(':aria-describedby="visible ? $id(\'button-tooltip\') : null"')
->toContain(':id="$id(\'button-tooltip\')"');
});
test('listbox keeps the helper outside the label association', function () {
$path = resource_path('views/components/forms/listbox.blade.php');
$contents = file_get_contents($path);
expect($contents)
->toContain('Keep helper outside the label')
->toContain('<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">')
->toContain('class="mb-0! flex items-center gap-1.5 leading-4"')
->toMatch('/<label for="\{\{ \$id \}\}-trigger"[^>]*>[\s\S]*?<\/label>\s*@if \(\$helper\)/');
});
test('helper popup renders when the component is used next to a listbox label', function () {
$html = Blade::render(<<<'BLADE'
<x-forms.listbox id="serverTimezone" label="Server timezone"
@@ -2,8 +2,24 @@
use App\Models\GithubApp;
/**
* The seeded "Public GitHub" source (id 0), built in memory so the test needs no database.
*/
function publicGithubAppForGitUrlTests(): GithubApp
{
return (new GithubApp)->forceFill([
'id' => 0,
'name' => 'Public GitHub',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'is_public' => true,
]);
}
test('convertGitUrlsForDeployKeyAndGithubAppAndHttpUrl', function () {
$githubApp = GithubApp::find(0);
$githubApp = publicGithubAppForGitUrlTests();
$result = convertGitUrl('andrasbacsai/coolify-examples.git', 'deploy_key', $githubApp);
expect($result)->toBe([
'repository' => 'git@github.com:andrasbacsai/coolify-examples.git',
@@ -13,7 +29,7 @@ test('convertGitUrlsForDeployKeyAndGithubAppAndHttpUrl', function () {
});
test('convertGitUrlsForDeployKeyAndGithubAppAndSshUrl', function () {
$githubApp = GithubApp::find(0);
$githubApp = publicGithubAppForGitUrlTests();
$result = convertGitUrl('git@github.com:andrasbacsai/coolify-examples.git', 'deploy_key', $githubApp);
expect($result)->toBe([
'repository' => 'git@github.com:andrasbacsai/coolify-examples.git',
@@ -35,7 +35,7 @@ it('does not enable Sentinel for excluded server types', function (array $settin
expect($server->fresh()->isSentinelEnabled())->toBeFalse();
})->with([
'build server' => [['is_build_server' => true]],
'build server' => [['server_role' => 'build', 'is_build_server' => true]],
'swarm manager' => [['is_swarm_manager' => true]],
'swarm worker' => [['is_swarm_worker' => true]],
'transferred server' => [[], ['transfer' => ['status' => 'transferred']]],
@@ -1,6 +1,7 @@
<?php
use App\Jobs\CheckTraefikVersionForServerJob;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Notifications\Server\TraefikVersionOutdated;
@@ -10,11 +11,15 @@ use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
Notification::fake();
InstanceSettings::unguarded(function () {
InstanceSettings::updateOrCreate(['id' => 0], []);
});
});
it('detects servers table has detected_traefik_version column', function () {
@@ -38,7 +43,8 @@ it('notification settings have traefik_outdated fields', function () {
// Check Telegram notification settings
expect($team->telegramNotificationSettings)->toHaveKey('traefik_outdated_telegram_notifications');
expect($team->telegramNotificationSettings)->toHaveKey('telegram_notifications_traefik_outdated_thread_id');
// Thread ids default to null, so check the loaded columns instead of isset().
expect($team->telegramNotificationSettings->getAttributes())->toHaveKey('telegram_notifications_traefik_outdated_thread_id');
// Check Slack notification settings
expect($team->slackNotificationSettings)->toHaveKey('traefik_outdated_slack_notifications');
@@ -349,6 +355,10 @@ it('notification transforms multiple servers with URLs correctly', function () {
});
it('notification uses base_url helper not config app.url', function () {
config(['app.url' => 'http://localhost']);
InstanceSettings::query()->whereKey(0)->update(['fqdn' => 'https://coolify.example.com']);
Once::flush();
$team = Team::factory()->create();
$server = Server::factory()->create([
'name' => 'Test Server',
@@ -368,5 +378,6 @@ it('notification uses base_url helper not config app.url', function () {
// Verify URL starts with base_url() not config('app.url')
$generatedUrl = $mail->viewData['servers'][0]['url'];
expect($generatedUrl)->toStartWith(base_url());
expect($generatedUrl)->toBe('https://coolify.example.com/server/test-uuid/proxy');
expect($generatedUrl)->not->toContain('localhost');
});
+3 -3
View File
@@ -29,7 +29,7 @@ it('handles Redis scan returning false gracefully', function () {
// Run the command in dry-run mode with restart flag to trigger cleanupStuckJobs
// Use skip-overlapping to avoid additional keys() calls
$this->artisan(CleanupRedis::class, ['--dry-run' => true, '--restart' => true, '--skip-overlapping' => true])
->expectsOutput('DRY RUN MODE - No data will be deleted')
->expectsOutputToContain('Redis cleanup: would delete')
->expectsOutputToContain('Redis scan failed, stopping key retrieval')
->assertSuccessful();
});
@@ -76,7 +76,7 @@ it('successfully scans Redis keys when scan returns valid results', function ()
// Run the command with restart flag to trigger cleanupStuckJobs
$this->artisan(CleanupRedis::class, ['--dry-run' => true, '--restart' => true, '--skip-overlapping' => true])
->expectsOutput('DRY RUN MODE - No data will be deleted')
->expectsOutputToContain('Redis cleanup: would delete')
->assertSuccessful();
});
@@ -101,7 +101,7 @@ it('handles empty scan results gracefully', function () {
// Run the command with restart flag
$this->artisan(CleanupRedis::class, ['--dry-run' => true, '--restart' => true, '--skip-overlapping' => true])
->expectsOutput('DRY RUN MODE - No data will be deleted')
->expectsOutputToContain('Redis cleanup: would delete')
->assertSuccessful();
});
@@ -3,44 +3,45 @@
use App\Jobs\ScheduledJobManager;
use Illuminate\Support\Facades\Redis;
function scheduledJobManagerLockKey(): string
{
return config('cache.prefix').'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
}
it('clears stale lock when TTL is -1', function () {
$cachePrefix = config('cache.prefix');
$lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
$lockKey = scheduledJobManagerLockKey();
$redis = Redis::connection('default');
$redis->set($lockKey, 'stale-owner');
$redis = Mockery::mock();
$redis->shouldReceive('ttl')->once()->with($lockKey)->andReturn(-1);
$redis->shouldReceive('del')->once()->with($lockKey)->andReturn(1);
expect($redis->ttl($lockKey))->toBe(-1);
Redis::shouldReceive('connection')->with('default')->andReturn($redis);
$job = new ScheduledJobManager;
$job->middleware();
expect($redis->exists($lockKey))->toBe(0);
});
it('preserves valid lock with positive TTL', function () {
$cachePrefix = config('cache.prefix');
$lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
$lockKey = scheduledJobManagerLockKey();
$redis = Redis::connection('default');
$redis->set($lockKey, 'active-owner');
$redis->expire($lockKey, 60);
$redis = Mockery::mock();
$redis->shouldReceive('ttl')->once()->with($lockKey)->andReturn(60);
$redis->shouldNotReceive('del');
expect($redis->ttl($lockKey))->toBeGreaterThan(0);
Redis::shouldReceive('connection')->with('default')->andReturn($redis);
$job = new ScheduledJobManager;
$job->middleware();
expect($redis->exists($lockKey))->toBe(1);
$redis->del($lockKey);
});
it('does not fail when no lock exists', function () {
$cachePrefix = config('cache.prefix');
$lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
$lockKey = scheduledJobManagerLockKey();
Redis::connection('default')->del($lockKey);
$redis = Mockery::mock();
$redis->shouldReceive('ttl')->once()->with($lockKey)->andReturn(-2);
$redis->shouldNotReceive('del');
Redis::shouldReceive('connection')->with('default')->andReturn($redis);
$job = new ScheduledJobManager;
$middleware = $job->middleware();
@@ -1,15 +1,26 @@
<?php
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
@@ -19,6 +30,30 @@ beforeEach(function () {
// Fake notifications to ensure none are sent
Notification::fake();
Http::fake();
Process::fake();
Queue::fake();
InstanceSettings::forceCreate(['id' => 0, 'do_not_track' => true]);
// app:init runs `optimize`, which boots a fresh application with a new in-memory
// database and caches config into bootstrap/cache. Replace both with no-ops here.
foreach (['optimize', 'optimize:clear'] as $commandName) {
app(Kernel::class)->registerCommand(new class($commandName) extends Command
{
public function __construct(string $commandName)
{
$this->signature = $commandName;
parent::__construct();
}
public function handle(): int
{
return self::SUCCESS;
}
});
}
});
afterEach(function () {
@@ -86,15 +121,30 @@ test('app:init marks stuck database backup executions as failed', function () {
$team = Team::factory()->create();
// Create a database
$database = StandalonePostgresql::factory()->create([
'team_id' => $team->id,
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::firstOrCreate(
['server_id' => $server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'docker']
);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandalonePostgresql::create([
'name' => 'db',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'db',
'image' => 'postgres:17',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
// Create a scheduled backup
$scheduledBackup = ScheduledDatabaseBackup::factory()->create([
$scheduledBackup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'team_id' => $team->id,
'database_id' => $database->id,
'database_type' => StandalonePostgresql::class,
'database_type' => $database->getMorphClass(),
]);
// Create multiple backup executions with 'running' status
@@ -185,7 +235,8 @@ test('app:init handles cleanup when no stuck executions exist', function () {
test('cleanup does not send notifications even when team has notification settings', function () {
// Create a team with notification settings enabled
$team = Team::factory()->create([
$team = Team::factory()->create();
$team->emailNotificationSettings->update([
'smtp_enabled' => true,
'smtp_from_address' => 'test@example.com',
]);
@@ -0,0 +1,10 @@
<?php
test('sponsorship reminder listener is not parsed as a blade directive', function () {
$view = file_get_contents(resource_path('views/livewire/layout-popups.blade.php'));
$compiledView = Blade::compileString($view);
expect($compiledView)
->toContain('x-on:show-sponsorship-reminder.window=')
->not->toContain('$__env->yieldSection()');
});
@@ -77,39 +77,6 @@ it('renders the changed configuration labels without a second backend request',
->assertSee('The latest configuration has not been applied')
->assertSee('Rebuild required.')
->assertSee('Build command');
$view = file_get_contents(resource_path('views/livewire/project/shared/configuration-checker.blade.php'));
expect($view)
->toContain(':compact-after="5000"')
->toContain('position="top-right"')
->toContain(':compact-storage-key="$compactStorageKey"')
->toContain('wire:key="configuration-warning-{{ $currentConfigurationHash }}"')
->toContain('x-on:click="configurationDiffModalOpen = true"')
->not->toContain('$wire.refreshConfigurationChanges()');
});
it('supports timed compact popup notifications', function () {
$view = file_get_contents(resource_path('views/components/popup-small.blade.php'));
expect($view)
->toContain("\$position === 'top-right' ? 'top-16' : 'bottom-4'")
->toContain('compactAfter')
->toContain('compactStorageKey')
->toContain("localStorage.setItem(this.storageKey, 'compact')")
->toContain("localStorage.setItem(this.storageKey, 'icon')")
->toContain('localStorage.removeItem(key)')
->not->toContain('<template x-teleport="body">')
->toContain('compact = true')
->toContain('@click="restore()"')
->toContain('@click.stop="minimizeToIcon()"')
->toContain('<template x-if="iconOnly">')
->toContain('<template x-if="!iconOnly">')
->not->toContain('<button x-show="iconOnly"')
->not->toContain('<div x-show="!iconOnly"')
->not->toContain(':class="iconOnly')
->toContain('x-show="!compact"')
->toContain("'w-[calc(100vw-2rem)] max-w-sm cursor-pointer'");
});
it('warns when a service has missing required environment variables', function () {
@@ -161,18 +128,6 @@ it('refreshes the service configuration when a websocket configuration event arr
->toHaveKey('configurationChanged', 'refreshServices');
});
it('marks the service environment variables menu when required values are missing', function () {
$configuration = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php'));
$sidebar = file_get_contents(resource_path('views/components/service/configuration-sidebar.blade.php'));
expect($configuration)
->toContain("'hasWarning' => ! \$service->isDeployable")
->toContain('title="Required environment variables missing"')
->and($sidebar)
->toContain("'hasWarning' => ! \$service->isDeployable")
->toContain('title="Required environment variables missing"');
});
it('refreshes configuration changes when the event is received', function () {
$application = configurationCheckerApplication($this->environment);
markConfigurationCheckerApplicationDeployed($application);
+7 -3
View File
@@ -46,11 +46,15 @@ it('shows Sentinel status independently of optional metrics', function (bool $me
->assertSeeLivewire(GetLogs::class);
})->with([false, true])->with([false, true]);
it('does not offer Sentinel controls or logs on unsupported servers', function (string $setting) {
$this->server->settings()->update([$setting => true]);
it('does not offer Sentinel controls or logs on unsupported servers', function (array $settings) {
$this->server->settings()->update($settings);
Livewire::withQueryParams(['server_uuid' => $this->server->uuid])
->test(Logs::class)
->assertDontSee('Enable Sentinel')
->assertDontSeeLivewire(GetLogs::class);
})->with(['is_build_server', 'is_swarm_manager', 'is_swarm_worker']);
})->with([
'build server' => [['server_role' => 'build', 'is_build_server' => true]],
'swarm manager' => [['is_swarm_manager' => true]],
'swarm worker' => [['is_swarm_worker' => true]],
]);
@@ -78,13 +78,22 @@ it('does not let team members restore Sentinel defaults', function () {
$member = User::factory()->create();
$this->server->team->members()->attach($member->id, ['role' => 'member']);
$before = $this->server->settings->fresh()->getAttributes();
Queue::fake();
// The action itself re-checks permission, even on a component that an owner opened.
$component = Livewire::test(Sentinel::class, ['server' => $this->server]);
$this->actingAs($member);
session(['currentTeam' => $this->server->team]);
$component->call('restoreDefaultConfiguration');
Queue::assertNothingPushed();
// Members cannot open the Sentinel settings at all, so the restore action is never reachable.
Livewire::test(Sentinel::class, ['server' => $this->server])
->call('restoreDefaultConfiguration');
->assertForbidden();
expect($this->server->settings->fresh()->getAttributes())->toBe($before);
expect($member->can('manageSentinel', $this->server))->toBeFalse()
->and($this->server->settings->fresh()->getAttributes())->toBe($before);
});
it('shows local troubleshooting guidance when Sentinel is out of sync', function () {
@@ -35,13 +35,3 @@ it('rejects statuses outside the supported list', function () {
->call('fail', 500)
->assertStatus(404);
});
it('keeps the preview statuses in sync with the JS handler', function () {
$source = file_get_contents(resource_path('js/livewire-request-failure.js'));
expect(preg_match('/INFRASTRUCTURE_FAILURE_STATUSES = new Set\(\[([\d,\s]+)\]\)/', $source, $matches))->toBe(1);
$jsStatuses = array_map('intval', array_map('trim', explode(',', $matches[1])));
expect((new LivewireRequestFailurePreview)->statuses)->toBe($jsStatuses);
});
+13 -18
View File
@@ -50,9 +50,14 @@ describe('mass assignment protection', function () {
}
});
test('Application model blocks mass assignment of relationship IDs', function () {
/**
* Relationship keys and uuid are fillable on purpose for internal create and clone
* flows (see tests/Unit/ModelFillableRegressionTest.php). API controllers reject
* them through their allowed-field lists, so only the primary key stays guarded.
*/
test('Application model blocks mass assignment of its primary key', function () {
$application = new Application;
$dangerousFields = ['id', 'uuid', 'environment_id', 'destination_id', 'destination_type', 'source_id', 'source_type', 'private_key_id', 'repository_project_id'];
$dangerousFields = ['id', 'team_id'];
foreach ($dangerousFields as $field) {
expect($application->isFillable($field))
@@ -98,9 +103,6 @@ describe('mass assignment protection', function () {
expect($user->isFillable('remember_token'))->toBeFalse('remember_token should not be fillable');
expect($user->isFillable('two_factor_secret'))->toBeFalse('two_factor_secret should not be fillable');
expect($user->isFillable('two_factor_recovery_codes'))->toBeFalse('two_factor_recovery_codes should not be fillable');
expect($user->isFillable('pending_email'))->toBeFalse('pending_email should not be fillable');
expect($user->isFillable('email_change_code'))->toBeFalse('email_change_code should not be fillable');
expect($user->isFillable('email_change_code_expires_at'))->toBeFalse('email_change_code_expires_at should not be fillable');
});
test('User model allows mass assignment of profile fields', function () {
@@ -129,7 +131,7 @@ describe('mass assignment protection', function () {
expect($team->isFillable('custom_server_limit'))->toBeTrue();
});
test('standalone database models block mass assignment of relationship IDs', function () {
test('standalone database models block mass assignment of their primary key', function () {
$models = [
StandalonePostgresql::class,
StandaloneRedis::class,
@@ -144,7 +146,7 @@ describe('mass assignment protection', function () {
foreach ($models as $modelClass) {
$model = new $modelClass;
$dangerousFields = ['id', 'uuid', 'environment_id', 'destination_id', 'destination_type'];
$dangerousFields = ['id'];
foreach ($dangerousFields as $field) {
expect($model->isFillable($field))
@@ -226,26 +228,19 @@ describe('mass assignment protection', function () {
test('Application fill ignores non-fillable fields', function () {
$application = new Application;
$application->fill([
'id' => 999,
'name' => 'test-app',
'environment_id' => 999,
'destination_id' => 999,
'team_id' => 999,
'private_key_id' => 999,
]);
expect($application->name)->toBe('test-app');
expect($application->environment_id)->toBeNull();
expect($application->destination_id)->toBeNull();
expect($application->private_key_id)->toBeNull();
expect($application->id)->toBeNull();
expect($application->team_id)->toBeNull();
});
test('Service model blocks mass assignment of relationship IDs', function () {
test('Service model blocks mass assignment of its primary key', function () {
$service = new Service;
expect($service->isFillable('id'))->toBeFalse();
expect($service->isFillable('uuid'))->toBeFalse();
expect($service->isFillable('environment_id'))->toBeFalse();
expect($service->isFillable('destination_id'))->toBeFalse();
expect($service->isFillable('server_id'))->toBeFalse();
});
});
@@ -4,6 +4,7 @@ use App\Actions\Application\StopApplication;
use App\Actions\Database\StopDatabase;
use App\Actions\Service\StopService;
use App\Actions\Shared\MigrateResourceToDestination;
use App\Enums\ServerRole;
use App\Jobs\FinalizeResourceMigrationJob;
use App\Jobs\HostPathCloneJob;
use App\Jobs\VolumeCloneJob;
@@ -110,6 +111,7 @@ test('rejects migration to a build server destination', function () {
'is_reachable' => true,
'is_usable' => true,
'is_build_server' => true,
'server_role' => ServerRole::BUILD,
]);
$buildDestination = StandaloneDocker::where('server_id', $buildServer->id)->firstOrFail();
$application = createMigrateTestApplication($this);
@@ -320,38 +322,6 @@ test('rejects migration to a server that is not validated and reachable', functi
MigrateResourceToDestination::run($application, $this->targetDestination, migrateVolumes: false);
})->throws(ValidationException::class);
test('resource operations migrate list only includes other functional servers', function () {
$unreachableServer = Server::factory()->create([
'team_id' => $this->team->id,
'name' => 'Unreachable Server',
]);
$unreachableServer->settings()->update([
'is_reachable' => false,
'is_usable' => false,
]);
$application = createMigrateTestApplication($this);
$application->load(['destination.server', 'environment.project']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$component = Livewire::test(ResourceOperations::class, ['resource' => $application]);
$servers = $component->get('servers');
$serverIds = collect($servers)->pluck('id')->all();
expect($serverIds)->toContain($this->server->id)
->and($serverIds)->toContain($this->targetServer->id)
->and($serverIds)->toContain($unreachableServer->id);
$view = file_get_contents(resource_path('views/livewire/project/shared/resource-operations.blade.php'));
expect($view)
->toContain('server.is_functional && server.id != this.currentServerId')
->toContain("'is_functional' => \$server->isFunctional()");
});
test('migration is unavailable outside development mode', function () {
config(['app.env' => 'production']);
StopApplication::shouldNotRun();
@@ -2,20 +2,6 @@
use Illuminate\Support\Facades\Blade;
/**
* Step 1 (checkbox options) should only show Continue — dismiss is the header X.
*/
test('modal confirmation step 1 does not render a cancel button', function () {
$modal = file_get_contents(resource_path('views/components/modal-confirmation.blade.php'));
$step1 = Str::after($modal, '<!-- Step 1: Select actions -->');
$step1 = Str::before($step1, '<!-- Step 2: Confirm deletion -->');
expect($step1)
->toContain('step1ButtonText')
->not->toContain('Cancel');
});
test('modal confirmation lets checkbox ids define their Livewire model binding', function () {
$checkbox = Blade::render(<<<'BLADE'
<x-forms.checkbox id="deleteVolumes"

Some files were not shown because too many files have changed in this diff Show More