CluPilotCloud/tests/Feature/Admin/UpdateButtonTest.php

1008 lines
44 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
use App\Livewire\Admin\Settings as AdminSettings;
use App\Models\User;
use App\Services\Deployment\UpdateChannel;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
use Livewire\Livewire;
/**
* Updating the installation from the console.
*
* The panel cannot run the update — it is www-data inside the container the
* update restarts. It leaves a request for the host-side agent instead. These
* tests cover the panel's half: what it reports, what it refuses, and what it
* must never claim.
*/
beforeEach(function () {
File::deleteDirectory(storage_path('app/deploy'));
});
it('does not offer to update when the agent has never reported in', function () {
// Without an agent the button would do nothing at all, and a button that
// does nothing is worse than a missing one.
$state = app(UpdateChannel::class)->state();
expect($state['agent_seen'])->toBeFalse()
->and($state['behind'])->toBeNull()
->and($state['available'])->toBeFalse();
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_no_agent'));
});
it('never reports "up to date" when it simply does not know', function () {
// Null and zero are different answers. Showing the first as the second is
// how an installation sits three versions behind looking healthy.
expect(app(UpdateChannel::class)->state()['available'])->toBeFalse();
// Asserted on the state, not on the markup: "Aktuell" is also a substring
// of "Aktuelles Passwort" elsewhere on the same page.
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_unknown'))
->assertViewHas('update', fn (array $u) => $u['behind'] === null && $u['available'] === false);
});
it('reports an available update once the agent has looked', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 3]);
$state = app(UpdateChannel::class)->state();
expect($state['available'])->toBeTrue()->and($state['behind'])->toBe(3);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_available', ['n' => 3]));
});
it('leaves a request the agent can find', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->call('requestUpdate')
->assertHasNoErrors();
$request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true);
expect($request['requested_by'])->toBeString()->not->toBeEmpty()
->and($request['requested_at'])->toBeString();
});
it('does not queue a second run when the button is pressed twice', function () {
$channel = app(UpdateChannel::class);
expect($channel->request('someone@example.com'))->toBeTrue()
// Impatience, not intent — and two overlapping runs of update.sh fight
// over the checkout.
->and($channel->request('someone@example.com'))->toBeFalse();
});
it('forgets a request nobody ever collected', function () {
// Written while the agent was down. Firing it days later, whenever the
// agent happens to come back, is not what anyone asked for.
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-request.json'), json_encode([
'requested_at' => Carbon::now()->subHours(3)->toIso8601String(),
'requested_by' => 'someone@example.com',
]));
$channel = app(UpdateChannel::class);
expect($channel->pendingRequest())->toBeNull()
->and($channel->state()['running'])->toBeFalse()
// And a fresh request is accepted again rather than blocked forever.
->and($channel->request('someone@example.com'))->toBeTrue();
});
it('needs the capability, not merely an operator account', function () {
Livewire::actingAs(operator('Support'), 'operator')
->test(AdminSettings::class)
->call('requestUpdate')
->assertForbidden();
});
it('survives a status file that is corrupt', function () {
// This is the page an operator opens when something is already wrong. It
// must not be the second thing that is broken.
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-status.json'), '{ this is not json');
$state = app(UpdateChannel::class)->state();
expect($state['agent_seen'])->toBeFalse();
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertOk();
});
it('says a queued update is starting, and never counts down to it', function () {
// There was a live countdown here to the agent's next tick. It ran
// backwards a few seconds and snapped back to a full minute, over and
// over, and it did so on every server whose systemd timer is longer than
// the interval the agent reports — the target was recomputed on each poll
// and had already gone by, so the "honest upper bound" fallback moved it
// forward again every time. Reported exactly that way.
//
// Nothing counts down now. The agent is woken when the request lands.
writeStatus([
'state' => 'idle',
'checked_at' => now()->toIso8601String(),
'check_interval_minutes' => 1,
'behind' => 1,
'request_watch' => true,
]);
app(UpdateChannel::class)->request('owner@example.com');
$state = app(UpdateChannel::class)->state();
expect($state['running'])->toBeTrue()
->and($state)->not->toHaveKey('next_check_at')
// `instant` is what selects the sentence. Asserted here rather than on
// the settings component: the sentence moved into the full-page overlay
// in layouts/admin, because it was being shown in BOTH and an operator
// saw one panel appear and a second cover it seconds later.
->and($state['instant'])->toBeTrue();
});
it('names the step the deployment is on, in the operators language', function () {
// The step comes from the file update.sh rewrites as it goes, NOT from the
// status document: the agent writes that once and then blocks for the whole
// run, so a phase taken from there is frozen at the first step forever.
writeStatus([
'state' => 'running',
'checked_at' => now()->toIso8601String(),
'started_at' => now()->subMinutes(2)->toIso8601String(),
'behind' => 1,
]);
writePhase('migrate');
// Through the endpoint the overlay actually reads. That is the whole path
// now: no Livewire, no component state, no assets — because the thing being
// reported on restarts the containers serving the page.
$this->actingAs(operator('Owner'), 'operator')
->getJson(route('admin.update.state'))
->assertOk()
->assertJsonPath('step', __('admin_settings.update_step', [
'step' => __('admin_settings.update_phase.migrate'),
]));
});
it('follows the step file as the deployment moves on', function () {
writeStatus(['state' => 'running', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
writePhase('composer');
expect(app(UpdateChannel::class)->state()['phase'])->toBe(__('admin_settings.update_phase.composer'));
writePhase('restart');
expect(app(UpdateChannel::class)->state()['phase'])->toBe(__('admin_settings.update_phase.restart'));
});
it('does not print a raw phase key it has no translation for', function () {
// A newer deployment script against older translations. Better to say
// nothing than to put `update_phase.something` in front of an operator.
writeStatus([
'state' => 'running',
'checked_at' => now()->toIso8601String(),
'behind' => 1,
]);
writePhase('something_new');
expect(app(UpdateChannel::class)->state()['phase'])->toBeNull();
});
it('does not present the last failures step as this updates progress', function () {
// A failed run leaves its phase file in place on purpose — that is how the
// failing step gets named. Queueing the next update must not then show it
// as already mid-migration, hiding the only thing that is actually known:
// when it will start.
Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00'));
writeStatus([
'state' => 'idle',
'checked_at' => now()->toIso8601String(),
'check_interval_minutes' => 5,
'behind' => 1,
]);
writePhase('migrate');
app(UpdateChannel::class)->request('owner@example.com');
$state = app(UpdateChannel::class)->state();
expect($state['running'])->toBeTrue()
->and($state['phase'])->toBeNull()
// Queued, not instant — which is the sentence the overlay picks.
->and($state['instant'])->toBeFalse();
// And the endpoint the overlay reads says nothing about a step either.
$this->actingAs(operator('Owner'), 'operator')
->getJson(route('admin.update.state'))
->assertJsonPath('step', null);
});
it('ignores a step written before the run that is going on now', function () {
// A manual `bash deploy/update.sh` on the shell leaves a phase file the
// agent never cleared.
Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00'));
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(
storage_path('app/deploy/update-phase'),
"assets\t".now()->subHour()->toIso8601String()."\n",
);
writeStatus([
'state' => 'running',
'checked_at' => now()->toIso8601String(),
'started_at' => now()->subMinute()->toIso8601String(),
'behind' => 1,
]);
expect(app(UpdateChannel::class)->state()['phase'])->toBeNull();
});
it('promises no start time at all, on the exact configuration that used to loop', function () {
// The reported case, reproduced: the agent reports a one-minute interval
// (the value in the repo) while the host's systemd timer is still five, so
// checked_at + interval is in the past for four minutes out of every five.
// The old code answered "now + interval" afresh on every poll, which is a
// countdown that resets forever instead of reaching zero.
//
// There is nothing left to get wrong: no time is promised anywhere.
Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00'));
writeStatus([
'state' => 'idle',
'checked_at' => now()->subMinutes(4)->toIso8601String(),
'check_interval_minutes' => 1,
'behind' => 1,
]);
app(UpdateChannel::class)->request('owner@example.com');
expect(app(UpdateChannel::class)->state())->not->toHaveKey('next_check_at');
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
// No clock, and — the part that actually broke — no element for a
// browser-side timer to keep resetting.
->assertDontSee('x-data="countdown', escape: false);
});
it('names the step a failed update stopped at', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
writeLastRun([
'state' => 'failed',
'started_at' => now()->subMinutes(3)->toIso8601String(),
'finished_at' => now()->toIso8601String(),
'phase' => 'migrate',
'error' => 'update_failed',
'exit_code' => 1,
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_failed_at', [
'step' => __('admin_settings.update_phase.migrate'),
]));
});
function writeStatus(array $status): void
{
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-status.json'), json_encode($status));
}
/** What deploy/update.sh writes as it moves from step to step. */
function writePhase(string $key): void
{
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-phase'), $key."\t".now()->toIso8601String()."\n");
}
function writeLastRun(array $run): void
{
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-last-run.json'), json_encode($run));
}
it('stops trusting an agent that has gone quiet', function () {
// A status file written once by an agent since stopped would otherwise
// leave the button enabled forever, writing requests nobody collects.
writeStatus(['state' => 'idle', 'checked_at' => now()->subHour()->toIso8601String(), 'behind' => 2]);
$state = app(UpdateChannel::class)->state();
expect($state['agent_seen'])->toBeFalse()
->and($state['behind'])->toBeNull()
->and($state['available'])->toBeFalse();
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_no_agent'));
});
it('shows the agents failure in the operators language', function () {
// The agent is a shell script and is not translated; it reports a code.
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'update_failed', 'exit_code' => 3]);
expect(app(UpdateChannel::class)->state()['last_error'])
->toBe(__('admin_settings.update_error.update_failed', ['code' => '3']));
// An unknown code is passed through rather than swallowed: a message
// nobody has worded yet still beats silence while an update is failing.
writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'something_new']);
expect(app(UpdateChannel::class)->state()['last_error'])->toBe('something_new');
});
it('still shows a failed update after the next routine check', function () {
// The check runs on a timer. Writing both into one file meant a failure
// was usually gone before anyone looked at it.
writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'update_failed', 'exit_code' => 2]);
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);
$state = app(UpdateChannel::class)->state();
expect($state['last_state'])->toBe('failed')
->and($state['last_error'])->toBe(__('admin_settings.update_error.update_failed', ['code' => '2']));
});
it('refuses a second request while the agent is mid-run', function () {
// The agent deletes the request before it starts, so between that moment
// and the end of the run there is nothing "pending" to block on.
writeStatus(['state' => 'running', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
$channel = app(UpdateChannel::class);
expect($channel->isRunning())->toBeTrue()
->and($channel->request('someone@example.com'))->toBeFalse();
expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse();
});
/**
* "Nach Aktualisierungen suchen" — check only, never apply.
*
* The request carries a `kind` so the agent can tell the two apart. These
* cover the panel's half: a check must never read as "running" (no
* maintenance mode, no full-screen overlay for a question that was only "is
* there something new"), and the one request slot is still shared, so
* pressing either button while the other is outstanding is refused the same
* way pressing the same one twice already was.
*/
it('writes a distinct request kind for a check, not a flag on the existing one', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->call('requestCheck')
->assertHasNoErrors();
$request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true);
expect($request['kind'])->toBe('check')
->and($request['requested_by'])->toBeString()->not->toBeEmpty();
});
it('does not treat a pending check as "running" — no maintenance overlay for a question', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);
$channel = app(UpdateChannel::class);
expect($channel->requestCheck('owner@example.com'))->toBeTrue();
$state = $channel->state();
expect($state['checking'])->toBeTrue()
->and($state['running'])->toBeFalse();
});
it('does treat a pending run as "running", not "checking"', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
$channel = app(UpdateChannel::class);
expect($channel->request('owner@example.com'))->toBeTrue();
$state = $channel->state();
expect($state['running'])->toBeTrue()
->and($state['checking'])->toBeFalse();
});
it('does not queue a second check when the button is pressed twice', function () {
$channel = app(UpdateChannel::class);
expect($channel->requestCheck('someone@example.com'))->toBeTrue()
->and($channel->requestCheck('someone@example.com'))->toBeFalse();
});
it('blocks a check while a run is pending, and a run while a check is pending', function () {
$channel = app(UpdateChannel::class);
expect($channel->request('someone@example.com'))->toBeTrue()
->and($channel->requestCheck('someone@example.com'))->toBeFalse();
File::delete(storage_path('app/deploy/update-request.json'));
expect($channel->requestCheck('someone@example.com'))->toBeTrue()
->and($channel->request('someone@example.com'))->toBeFalse();
});
it('needs the capability to check, not merely an operator account', function () {
Livewire::actingAs(operator('Support'), 'operator')
->test(AdminSettings::class)
->call('requestCheck')
->assertForbidden();
});
it('confirms a check was requested, worded for a check rather than an update', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->call('requestCheck')
->assertDispatched('notify', message: __('admin_settings.update_check_requested'));
});
/**
* A check is a question, and a question does not get a countdown.
*
* Reported verbatim: "ich suche nur nach neuen Updates, was ist das da für ein
* Timer, den ich nicht gestartet habe?" The check had been routed through the
* same timer as a real update, so the card showed the only thing it had —
* "Startet in 3:44" — for an operator who had asked for nothing to start.
*
* The agent is now woken the moment the request lands (the path unit in
* deploy/install-agent.sh) and reports whether that trigger exists, because a
* server that has not re-run the installer still waits for the timer and must
* not be told otherwise.
*/
it('shows a pending check its badge and nothing else — no time, no clock', function () {
// Even on a host with no path unit, where the answer really does wait for
// the timer. A check starts nothing, so there is nothing to announce; the
// badge saying it is looking is the whole of what can honestly be said.
writeStatus([
'state' => 'idle',
'checked_at' => now()->toIso8601String(),
'check_interval_minutes' => 5,
'behind' => 0,
'request_watch' => false,
]);
app(UpdateChannel::class)->requestCheck('owner@example.com');
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_checking'))
->assertDontSee(__('admin_settings.update_starting'))
->assertDontSee(__('admin_settings.update_starting_queued'));
});
it('tells a queued update apart from an immediate one, without either naming a time', function () {
// The one thing 'instant' still decides: whether the console may say the
// update starts NOW, or has to admit it starts when the agent gets to it.
// Neither answer contains a number.
writeStatus([
'state' => 'idle',
'checked_at' => now()->toIso8601String(),
'check_interval_minutes' => 5,
'behind' => 1,
'request_watch' => false,
]);
app(UpdateChannel::class)->request('owner@example.com');
// On the state, which is what the overlay in layouts/admin picks its
// sentence from. The settings card no longer says any of this — it said it
// at the same time as the overlay, in a smaller box, and an operator saw
// the page arrange itself twice.
expect(app(UpdateChannel::class)->state()['instant'])->toBeFalse();
});
it('does not believe a stale agent about being woken on demand', function () {
// The field says what was installed when the agent last ran. From an agent
// that has since stopped it is not a promise anybody can keep.
writeStatus([
'state' => 'idle',
'checked_at' => now()->subHour()->toIso8601String(),
'behind' => 0,
'request_watch' => true,
]);
expect(app(UpdateChannel::class)->state()['instant'])->toBeFalse();
});
it('keeps the panel, the agent and the installer in step about the on-demand trigger', function () {
// Three files, one contract: the installer creates the path unit, the agent
// reports whether it is active, the panel decides from that whether it may
// say "now". If they drift, the console promises an instant answer on a
// server that still waits five minutes for it — which is the exact failure
// this replaced.
$installer = File::get(base_path('deploy/install-agent.sh'));
$agent = File::get(base_path('deploy/update-agent.sh'));
expect($installer)->toContain('/etc/systemd/system/clupilot-update-agent.path')
->and($installer)->toContain('PathChanged=')
->and($installer)->toContain('systemctl enable --now clupilot-update-agent.path')
->and($agent)->toContain('is-active --quiet clupilot-update-agent.path')
->and($agent)->toContain('"request_watch"');
});
/**
* An update is a released version, never the head of a branch.
*
* The console used to offer "Jetzt aktualisieren" unconditionally, because
* `behind` counted commits on main and was a reading a minute old — refusing on
* a stale reading cost the deployment. That whole problem belonged to following
* a branch. Nothing is installed now unless a tag exists whose version is
* higher than the one deployed, and pressing the check button answers within a
* second, so a stale reading is no longer something to design around.
*/
it('refuses to queue an update when nothing newer has been released', function () {
writeStatus([
'checked_at' => now()->toIso8601String(),
'state' => 'idle',
'behind' => 0,
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_only_releases'))
->call('requestUpdate')
->assertDispatched('notify', message: __('admin_settings.update_nothing_released'));
// Not merely a disabled button: a Livewire action is a public endpoint, and
// a queued run for nothing costs a maintenance window.
expect(app(UpdateChannel::class)->pendingRequest())->toBeNull();
});
it('offers the update, by version, once a release above the installed one exists', function () {
writeStatus([
'checked_at' => now()->toIso8601String(),
'state' => 'idle',
'behind' => 1,
'target_release' => 'v1.1.0',
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
// The number somebody can decide about, not "1 Aktualisierung".
->assertSee(__('admin_settings.update_available_release', ['version' => '1.1.0']))
->assertDontSee(__('admin_settings.update_only_releases'))
->call('requestUpdate')
->assertDispatched('notify', message: __('admin_settings.update_requested'));
expect(app(UpdateChannel::class)->pendingRequest())->not->toBeNull();
});
it('does not name a release the agent never reported', function () {
// An older agent writes no target_release. The count is all there is, and
// inventing a version number from it would be worse than the count.
writeStatus([
'checked_at' => now()->toIso8601String(),
'state' => 'idle',
'behind' => 2,
]);
expect(app(UpdateChannel::class)->state()['target_release'])->toBeNull();
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_available', ['n' => 2]));
});
it('decides what is newer by version and installs only a tag', function () {
// The agent's half of the same rule, which no PHP test can reach: it asks
// one question regardless of what the checkout is attached to, compares by
// version rather than by branch position, and hands update.sh a RELEASE
// rather than a BRANCH.
$agent = File::get(base_path('deploy/update-agent.sh'));
expect($agent)->toContain('version_gt')
->and($agent)->toContain('sort -V')
->and($agent)->toContain('RELEASE="$TARGET_RELEASE"')
// No branch left to fall back onto, which is what made a commit on main
// installable in the first place.
->and($agent)->not->toContain('BRANCH="$BRANCH" "$ROOT/deploy/update.sh"');
});
it('offers both actions together whenever the agent is reachable and idle', function () {
// Two separate buttons now, not one whose label changed with the
// reading: looking and applying are different intentions.
writeStatus([
'checked_at' => now()->toIso8601String(),
'state' => 'idle',
'behind' => 0,
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_check'))
->assertSee(__('admin_settings.update_now'));
});
it('still refuses when there is no agent to pick the request up', function () {
// The one case where either button really would go nowhere: nothing is
// running that would ever read the request file.
writeStatus([
'checked_at' => now()->subDay()->toIso8601String(),
'state' => 'idle',
'behind' => 0,
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_unknown'))
->assertDontSee(__('admin_settings.update_check'))
->assertDontSee(__('admin_settings.update_now'));
});
it('carries the watcher onto every console page, not only settings', function () {
// wire:poll cannot see the run through to the end, because the run
// restarts the containers answering the poll — so the watcher (and the
// maintenance overlay it drives) lives in the layout, not this one page,
// and has to be there however an operator reaches the console.
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.overview'))
->assertOk()
->assertSee('updateWatcher(', escape: false);
});
it('binds the watcher exactly once when the settings page is open', function () {
// The settings card used to bind its own copy as well as the layout now
// doing so for every page — two pollers racing each other against the
// same endpoint.
writeStatus([
'checked_at' => now()->toIso8601String(),
'state' => 'idle',
'behind' => 2,
]);
$html = $this->actingAs(operator('Owner'), 'operator')
->get(route('admin.settings'))
->assertOk()
->getContent();
expect(substr_count($html, 'updateWatcher('))->toBe(1);
});
it('answers the watcher without Livewire in the way', function () {
writeStatus(['checked_at' => now()->toIso8601String(), 'state' => 'idle', 'behind' => 0]);
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.update.state'))
->assertOk()
->assertJsonStructure(['running', 'commit', 'behind', 'last_finished_at', 'step', 'running_since', 'log'])
->assertHeader('Cache-Control', 'no-store, private');
});
it('carries the current step in the watcher JSON, already in the operators language', function () {
// The overlay only ever displays this string — it must arrive ready to
// print, the same sentence the settings card itself renders.
writeStatus([
'state' => 'running',
'checked_at' => now()->toIso8601String(),
'started_at' => now()->subMinute()->toIso8601String(),
'behind' => 1,
]);
writePhase('migrate');
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.update.state'))
->assertOk()
->assertJson(['step' => __('admin_settings.update_step', [
'step' => __('admin_settings.update_phase.migrate'),
])]);
});
it('carries no step in the watcher JSON before the deployment has announced one', function () {
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.update.state'))
->assertOk()
->assertJson(['step' => null]);
});
it('carries how long a run has been going in the watcher JSON, already worded', function () {
// Ready to print, the same sentence update_since renders on the settings
// card — the overlay never assembles this from a raw timestamp either.
writeStatus([
'state' => 'running',
'checked_at' => now()->toIso8601String(),
'started_at' => now()->subMinutes(2)->toIso8601String(),
'behind' => 1,
]);
writePhase('migrate');
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.update.state'))
->assertOk()
->assertJson(['running_since' => __('admin_settings.update_since', [
'when' => now()->subMinutes(2)->diffForHumans(),
])]);
});
it('carries the tail of the run log in the watcher JSON once a run has actually started', function () {
writeStatus([
'state' => 'running',
'checked_at' => now()->toIso8601String(),
'started_at' => now()->subMinute()->toIso8601String(),
'behind' => 1,
]);
writePhase('migrate');
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-last-run.log'), "line one\nline two\n");
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.update.state'))
->assertOk()
->assertJson(['log' => "line one\nline two"]);
});
it('carries no running-since or log while only queued, not yet actually running', function () {
// Otherwise this would be the PREVIOUS run's leftovers, presented as the
// progress of a run that has not begun.
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-last-run.log'), "an earlier run\n");
app(UpdateChannel::class)->request('owner@example.com');
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.update.state'))
->assertOk()
->assertJson(['running_since' => null, 'log' => null])
// Nothing for the overlay to count down to, either.
->assertJsonMissing(['next_check_at' => null]);
});
it('does not answer the watcher for somebody who is not an operator', function () {
$this->actingAs(User::factory()->create())
->get(route('admin.update.state'))
->assertForbidden();
});
it('shows one design while updating, not two swapping mid-run', function () {
// Reported as "the first screen must not be there". It was the console's
// own overlay in the app's light tokens; then the containers went down and
// the 503 page took over with a completely different face. Two designs for
// one event, replacing each other mid-update.
//
// Both render the same partial now, so there is nothing left that can
// disagree.
$console = Illuminate\Support\Facades\File::get(resource_path('views/layouts/admin.blade.php'));
$maintenance = Illuminate\Support\Facades\File::get(resource_path('views/errors/503.blade.php'));
expect($console)->toContain("partials.updating-panel")
->and($maintenance)->toContain("partials.updating-panel")
// And nothing left behind that would render a second look.
->and($console)->not->toContain('update_overlay_title')
->and($maintenance)->not->toContain('<style>');
});
it('keeps the deployment step in the overlay, and nothing else from the run', function () {
// The panel is shared with a page customers see, so the operator detail is
// behind a flag rather than always present — but the STEP has to still be
// there, because "läuft gerade" on its own is what sends somebody to the
// shell.
//
// The elapsed time and the deployment's raw output are deliberately gone.
// Neither is the question anybody staring at a maintenance screen has: the
// log was rsync listing every font file it copied, and reading it meant a
// file read on every console page load whether or not anything was
// running. The settings page still shows it, where somebody looks for it.
$panel = Illuminate\Support\Facades\File::get(resource_path('views/partials/updating-panel.blade.php'));
expect($panel)->toContain('x-text="step"')
->not->toContain('x-text="runningSince"')
->not->toContain('x-text="log"');
});
it('shows that something is happening without asking anybody to read', function () {
// The one question a person on this page has is whether anything is moving
// at all. The bar answers it in CSS — this page is served while the
// application is down, so it must not depend on JavaScript having loaded or
// on the server answering another request — and it is indeterminate on
// purpose: a deployment has no honest percentage, and a bar that claims one
// always stalls at 90 %.
$panel = Illuminate\Support\Facades\File::get(resource_path('views/partials/updating-panel.blade.php'));
expect($panel)->toContain('cpu-bar')
->toContain('@keyframes cpu-slide')
// Everyone sees it, not only the console: a customer on the 503 is
// asking the same question.
->and(str_contains(
Illuminate\Support\Str::before($panel, "@if (\$detail ?? false)"),
'cpu-bar" aria-hidden="true"',
))->toBeTrue()
// And it survives somebody who has switched motion off.
->and($panel)->toContain('prefers-reduced-motion');
});
it('has no dark mode of its own, in a product that has none', function () {
// Reported from a phone: the update screen — and only the update screen —
// came up dark blue. It carried a prefers-color-scheme block nothing else
// in this product has, so it was the one surface that flipped, and it
// flipped into Tailwind's slate ramp rather than the product's warm
// neutrals.
// Comments stripped first. The panel documents at length what it used to
// get wrong, naming the exact values — and a scan over the raw file reads
// that explanation as the offence it warns about. Twice now this file has
// had to learn the same thing: measure the thing, not the note about it.
$panel = preg_replace(
'/\{\{--.*?--\}\}/s',
'',
Illuminate\Support\Facades\File::get(resource_path('views/partials/updating-panel.blade.php')),
);
expect($panel)->not->toContain('prefers-color-scheme')
// The slate values it used. Named, so a paste of the same ramp is
// caught rather than merely discouraged by a comment.
->not->toContain('#0f172a')
->not->toContain('#64748b')
->not->toContain('#e2e8f0')
->not->toContain('#0b0f19')
// And the tokens it should be using instead.
->toContain('#f6f6f8')
->toContain('#17171c')
->toContain('#6e6e7a');
});
it('shows the step in one place, not in two of different sizes', function () {
// The settings card grew its own little "läuft gerade" block the instant a
// run started, and up to three seconds later the full-page overlay covered
// it saying the same thing. Reported as "first a small window, then the big
// one".
$card = Illuminate\Support\Facades\File::get(resource_path('views/livewire/admin/settings.blade.php'));
$layout = Illuminate\Support\Facades\File::get(resource_path('views/layouts/admin.blade.php'));
expect($card)->not->toContain("admin_settings.update_offline_hint")
->and($layout)->toContain('admin_settings.update_offline_hint');
});
it('opens the overlay on the click, not on the next poll', function () {
// Three seconds is long enough to see the page arrange itself twice.
$settings = Illuminate\Support\Facades\File::get(app_path('Livewire/Admin/Settings.php'));
$layout = Illuminate\Support\Facades\File::get(resource_path('views/layouts/admin.blade.php'));
expect($settings)->toContain("dispatch('update-started')")
->and($layout)->toContain('@update-started.window="wasRunning = true"');
});
it('does not cover the console because a button was pressed over nothing', function () {
// The overlay opens on the event, so the event must only fire once a run is
// actually pending. A second press, which the channel refuses, must not
// black out the page again.
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
$component = Livewire::actingAs(operator('Owner'), 'operator')->test(AdminSettings::class);
$component->call('requestUpdate')->assertDispatched('update-started');
$component->call('requestUpdate')->assertNotDispatched('update-started');
});
it('does not read the agents start-up gap as the end of the run', function () {
// The reported loop, in the one place it can be reproduced: the agent
// consumes the request file before it resolves the release, and writes
// `state: running` only once it has decided to go ahead. In between, the
// endpoint honestly answers "nothing is running" — and the watcher read
// that as "finished" and reloaded, so the overlay opened on the click,
// vanished a poll later, and the 503 arrived after it.
$watcher = Illuminate\Support\Facades\File::get(resource_path('js/app.js'));
// The overlay may only close once the SERVER has confirmed a run and then
// stopped reporting it.
expect($watcher)->toContain('this.wasRunning && this.serverConfirmed && !state.running')
// And it may not wait forever: the agent refuses a request with nothing
// to install by writing nothing at all.
->and($watcher)->toContain('this.unconfirmedPolls > 20');
});
it('reproduces the gap the console used to reload on', function () {
// The state the endpoint reports between the two agent steps: the request
// has been consumed, the status still says idle. `running` is false here
// and that is correct — the fix is on the reader, not on this value.
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
app(UpdateChannel::class)->request('owner@example.com');
expect(app(UpdateChannel::class)->state()['running'])->toBeTrue();
// The agent takes the request…
Illuminate\Support\Facades\File::delete(storage_path('app/deploy/update-request.json'));
// …and has not written its status yet.
expect(app(UpdateChannel::class)->state()['running'])->toBeFalse();
});
it('does not hold the console behind the panel when the application comes back broken', function () {
// Reported together with a 500: after the update every request failed, the
// watcher treated each failure as "still restarting", and the overlay
// stayed up over a console nobody could reach to find out why.
//
// A restart is seconds. Two minutes of nothing is an installation that came
// back broken, and then the operator needs the page.
$watcher = Illuminate\Support\Facades\File::get(resource_path('js/app.js'));
expect($watcher)->toContain('this.failedPolls > 40')
->toContain('this.stuck = true')
// A successful answer clears it: one bad minute must not leave a
// "something is wrong" notice sitting there for the rest of the run.
->toContain('this.failedPolls = 0');
});
it('offers the way out only once it is true, and only to an operator', function () {
// An always-present close button would be a lie: the deployment does not
// stop because somebody dismissed a panel. And the 503 page has no Alpine
// at all, so the affordance lives behind the same operator flag as the step
// and the log.
$panel = Illuminate\Support\Facades\File::get(resource_path('views/partials/updating-panel.blade.php'));
expect($panel)->toContain('x-show="stuck"')
->toContain('@click="dismiss()"');
// Two operator-only blocks, both gated by the same flag: the deployment
// detail and this. The 503 page passes no `detail`, so a customer sees
// neither the step nor a button suggesting they can call the update off.
expect(substr_count($panel, "@if (\$detail ?? false)"))->toBe(2);
});
it('runs the workers as the same user as the web process', function () {
// Blade compiles a view and then touch()es the compiled file to the
// source's mtime. touch() with an explicit time needs ownership — so a view
// compiled by a ROOT worker can never be refreshed by the web process
// again, and every request for it answers 500 with "touch(): Utime failed".
// A compose service without `user:` runs as root, and the queue is what
// renders mails.
$compose = Illuminate\Support\Facades\File::get(base_path('docker-compose.yml'));
foreach (['queue', 'reverb', 'scheduler'] as $service) {
// From the service key to the next one at the same indentation. An
// earlier version cut at the first "\n ", which is the end of the
// block's own first line.
preg_match('/\n '.$service.':\n(.*?)(?=\n \S|\z)/s', $compose, $match);
expect($match[1] ?? '')->toContain('user: "www-data"');
}
// And the deployment repairs whatever a root process left behind, at the
// end of the run as well as at the start.
expect(substr_count(
Illuminate\Support\Facades\File::get(base_path('deploy/update.sh')),
"\nnormalise_ownership\n",
))->toBe(2);
});
it('keeps answering its own status while the application is down', function () {
// The deployment's status is the one thing that must be readable DURING
// the deployment. Behind maintenance mode it answered 503 for the whole
// run, so the overlay had nothing to show but "still waiting" and could
// never name the step it was on.
expect(Illuminate\Support\Facades\File::get(base_path('bootstrap/app.php')))
->toContain("preventRequestsDuringMaintenance(except: [")
->toContain("'update/state'")
->toContain("'*/update/state'");
});
it('does not let a Livewire request during maintenance replace the page', function () {
// Reported with the network panel open: `update` and `state` both 503, and
// Livewire's default on a failed request is to render the response body —
// so a wire:poll that happened to fire mid-deployment swapped the console
// for Laravel's 503 page. Overlay, then error page, then reload.
//
// 503 is not a fault here. It IS the event being watched.
$app = Illuminate\Support\Facades\File::get(resource_path('js/app.js'));
expect($app)->toContain('if (status === 503) {')
// And the poll that used to fire it stops for the duration.
->and(Illuminate\Support\Facades\File::get(resource_path('views/livewire/admin/settings.blade.php')))
->toContain("@if (! \$update['running']) wire:poll");
});