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'); Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_starting')); }); it('names the step the deployment is on, in the operator’s 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'); Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('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 failure’s step as this update’s 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(); Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_starting_queued')) ->assertDontSee(__('admin_settings.update_phase.migrate')); }); 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 agent’s failure in the operator’s 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'); Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_starting_queued')) ->assertDontSee(__('admin_settings.update_starting')); }); 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 operator’s 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('