fix(services): live journal poll + command-palette server search
Journal was a one-time snapshot mislabeled "journalctl -f": it loaded the last N lines once and never refreshed, so it was neither live nor complete. - FleetService: `journalctl --show-cursor` + new journalSince() reads only entries after the stored journald cursor (no gaps, no dup appends), capped to a bounded live tail; parseJournal split into splitJournalCursor + parseJournalLines. - Services\Index: pollJournal() (wire:poll.5s) appends new entries since the cursor and caps retained rows at 300 (fetch ceiling == display ceiling). - Honest labels: drop the misleading "-f"; badge stays "live" (now true). Command palette (Strg/⌘ K) can now jump to a server: the fleet feeds the Alpine cmdk x-data; servers surface only while searching, matched on name OR IP, and selecting one navigates to /servers/<uuid>. Cursor is validated (^[a-z0-9;=]+$) and single-quoted before the remote shell — no injection (Codex-reviewed: no P1, no security findings). Removes stock ExampleTest (asserted 200 on an auth-gated route → always 302). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation
parent
bdec68f661
commit
44d4046526
|
|
@ -15,6 +15,9 @@ class Index extends Component
|
|||
{
|
||||
use WithFleetContext;
|
||||
|
||||
/** Cap on retained journal rows so the live poll never grows the payload unbounded. */
|
||||
private const JOURNAL_MAX = 300;
|
||||
|
||||
/** Active server name (from fleet context). */
|
||||
public string $server = '—';
|
||||
|
||||
|
|
@ -31,6 +34,9 @@ class Index extends Component
|
|||
/** @var array<int, array{time:string,unit:string,level:string,text:string}> */
|
||||
public array $journal = [];
|
||||
|
||||
/** journald cursor to resume the live poll from (null until the first read). */
|
||||
public ?string $journalCursor = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->server = $this->activeServer()?->name ?? '—';
|
||||
|
|
@ -46,6 +52,7 @@ class Index extends Component
|
|||
$data = $fleet->systemd($active);
|
||||
$this->services = $data['services'];
|
||||
$this->journal = $data['journal'];
|
||||
$this->journalCursor = $data['cursor'] ?? null;
|
||||
$this->connected = true;
|
||||
} catch (Throwable) {
|
||||
$this->connected = false;
|
||||
|
|
@ -55,6 +62,40 @@ class Index extends Component
|
|||
$this->ready = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live journal: poll for entries since the last cursor and APPEND them, so no line
|
||||
* is dropped between ticks (a true `journalctl -f` stream needs a PTY — deferred to
|
||||
* the web terminal). Caps the retained rows and survives a transient SSH hiccup.
|
||||
*/
|
||||
public function pollJournal(FleetService $fleet): void
|
||||
{
|
||||
if (! $this->ready || ! $this->connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
$active = $this->activeServer();
|
||||
|
||||
if (! $active || ! $active->credential_exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch ceiling == display ceiling: never under-fill, never carry more than we show.
|
||||
$data = $fleet->journalSince($active, $this->journalCursor, self::JOURNAL_MAX);
|
||||
} catch (Throwable) {
|
||||
return; // keep the current view; retry on the next tick
|
||||
}
|
||||
|
||||
if ($data['journal'] !== []) {
|
||||
$this->journal = array_slice(
|
||||
array_merge($this->journal, $data['journal']),
|
||||
-self::JOURNAL_MAX
|
||||
);
|
||||
}
|
||||
|
||||
$this->journalCursor = $data['cursor'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Service control (R5): opens the wire-elements/modal confirm dialog. The
|
||||
* modal writes the AuditEvent and re-dispatches `serviceConfirmed`, which
|
||||
|
|
|
|||
|
|
@ -315,14 +315,14 @@ class FleetService
|
|||
|
||||
// ── systemd services + journal (Services page) ──────────────────────
|
||||
|
||||
/** @return array{services:array<int,array>,journal:array<int,array>} */
|
||||
/** @return array{services:array<int,array>,journal:array<int,array>,cursor:?string} */
|
||||
public function systemd(Server $server, int $journalLines = 25): array
|
||||
{
|
||||
$ssh = $this->client($server);
|
||||
$cmd = 'export LC_ALL=C; '
|
||||
.'echo '.self::MARK.'units===; systemctl list-units --type=service --all --plain --no-legend --no-pager; '
|
||||
.'echo '.self::MARK.'files===; systemctl list-unit-files --type=service --no-legend --no-pager; '
|
||||
.'echo '.self::MARK.'journal===; '.$this->sudoFn($server).'priv journalctl -n '.(int) $journalLines.' --no-pager -o short-iso 2>&1';
|
||||
.'echo '.self::MARK.'journal===; '.$this->sudoFn($server).'priv journalctl -n '.(int) $journalLines.' --no-pager -o short-iso --show-cursor 2>&1';
|
||||
|
||||
try {
|
||||
$s = $this->sections($ssh->exec($cmd));
|
||||
|
|
@ -330,9 +330,56 @@ class FleetService
|
|||
$ssh->disconnect();
|
||||
}
|
||||
|
||||
[$body, $cursor] = $this->splitJournalCursor($s['journal'] ?? '');
|
||||
$journal = $this->parseJournalLines($body);
|
||||
if ($journal === []) {
|
||||
$journal[] = ['time' => '—', 'unit' => 'journal', 'level' => 'warn', 'text' => __('backend.journal_needs_privileges')];
|
||||
}
|
||||
|
||||
return [
|
||||
'services' => $this->parseUnits($s['units'] ?? '', $s['files'] ?? ''),
|
||||
'journal' => $this->parseJournal($s['journal'] ?? ''),
|
||||
'journal' => $journal,
|
||||
'cursor' => $cursor,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental journal read for the live poll: only entries AFTER $cursor, plus
|
||||
* the new cursor to resume from. Without a valid cursor (first call) it returns
|
||||
* the last $tail entries like systemd(). Empty result = nothing new since the
|
||||
* cursor (NOT a permission problem), so no placeholder row is injected.
|
||||
*
|
||||
* This is a bounded LIVE TAIL, not a complete log shipper: `-n $tail` keeps the
|
||||
* NEWEST $tail entries since the cursor, so a single tick that bursts past $tail
|
||||
* lines shows the most recent ones (what "live" means) and drops the older overflow
|
||||
* — which the capped on-screen window would evict anyway. Set $tail to the display
|
||||
* ceiling so the visible window is always fully filled. Replaying a full backlog is
|
||||
* the deferred web terminal's job, not this poll's.
|
||||
*
|
||||
* @return array{journal:array<int,array>,cursor:?string}
|
||||
*/
|
||||
public function journalSince(Server $server, ?string $cursor, int $tail = 300): array
|
||||
{
|
||||
// Journald cursors are `key=hex` segments joined by `;` — reject anything else
|
||||
// before it reaches the shell, then single-quote it.
|
||||
$valid = is_string($cursor) && preg_match('/^[a-z0-9;=]+$/i', $cursor) === 1;
|
||||
$selector = $valid ? "--after-cursor='".$cursor."'" : '';
|
||||
|
||||
$ssh = $this->client($server);
|
||||
$cmd = 'export LC_ALL=C; '.$this->sudoFn($server)
|
||||
.'priv journalctl '.$selector.' -n '.(int) $tail.' --no-pager -o short-iso --show-cursor 2>&1';
|
||||
|
||||
try {
|
||||
$out = $ssh->exec($cmd);
|
||||
} finally {
|
||||
$ssh->disconnect();
|
||||
}
|
||||
|
||||
[$body, $next] = $this->splitJournalCursor($out);
|
||||
|
||||
return [
|
||||
'journal' => $this->parseJournalLines($body),
|
||||
'cursor' => $next ?? $cursor, // keep the old cursor when nothing new arrived
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -760,7 +807,29 @@ class FleetService
|
|||
return $out;
|
||||
}
|
||||
|
||||
private function parseJournal(string $body): array
|
||||
/**
|
||||
* Pull the trailing `-- cursor: X` line (journalctl --show-cursor) off the body.
|
||||
*
|
||||
* @return array{0:string,1:?string} [body without the cursor line, cursor or null]
|
||||
*/
|
||||
private function splitJournalCursor(string $body): array
|
||||
{
|
||||
$cursor = null;
|
||||
$kept = [];
|
||||
foreach ($this->lines($body) as $line) {
|
||||
if (preg_match('/^-- cursor:\s*(\S+)/', $line, $m)) {
|
||||
$cursor = $m[1];
|
||||
|
||||
continue;
|
||||
}
|
||||
$kept[] = $line;
|
||||
}
|
||||
|
||||
return [implode("\n", $kept), $cursor];
|
||||
}
|
||||
|
||||
/** Parse journalctl `short-iso` lines into structured rows. No placeholder on empty. */
|
||||
private function parseJournalLines(string $body): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($this->lines($body) as $line) {
|
||||
|
|
@ -782,10 +851,6 @@ class FleetService
|
|||
];
|
||||
}
|
||||
|
||||
if ($out === []) {
|
||||
$out[] = ['time' => '—', 'unit' => 'journal', 'level' => 'warn', 'text' => __('backend.journal_needs_privileges')];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ return [
|
|||
|
||||
// Journal panel
|
||||
'journal_title' => 'Journal',
|
||||
'journal_subtitle' => 'journalctl -f · :server',
|
||||
'journal_subtitle' => 'journalctl · :server',
|
||||
'journal_live' => 'live',
|
||||
'journal_follow' => 'Folge Journal · :count Zeilen',
|
||||
'journal_follow' => 'Live · automatische Aktualisierung · :count Zeilen',
|
||||
|
||||
// Confirm modal — operations
|
||||
'confirm_start_heading' => 'Dienst starten',
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ return [
|
|||
|
||||
// Journal panel
|
||||
'journal_title' => 'Journal',
|
||||
'journal_subtitle' => 'journalctl -f · :server',
|
||||
'journal_subtitle' => 'journalctl · :server',
|
||||
'journal_live' => 'live',
|
||||
'journal_follow' => 'Following journal · :count lines',
|
||||
'journal_follow' => 'Live · auto-refresh · :count lines',
|
||||
|
||||
// Confirm modal — operations
|
||||
'confirm_start_heading' => 'Start service',
|
||||
|
|
|
|||
|
|
@ -87,9 +87,10 @@ document.addEventListener('alpine:init', () => {
|
|||
// e=Einstellungen, y=sYstem — d/s already taken) and stays in JS.
|
||||
const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions' };
|
||||
|
||||
window.Alpine.data('cmdk', (nav = [], actions = []) => ({
|
||||
window.Alpine.data('cmdk', (nav = [], actions = [], servers = []) => ({
|
||||
nav,
|
||||
actions,
|
||||
servers,
|
||||
open: false,
|
||||
help: false,
|
||||
query: '',
|
||||
|
|
@ -115,8 +116,12 @@ document.addEventListener('alpine:init', () => {
|
|||
|
||||
get items() {
|
||||
const q = this.query.trim().toLowerCase();
|
||||
const all = [...this.nav, ...this.actions];
|
||||
return q === '' ? all : all.filter((i) => i.label.toLowerCase().includes(q));
|
||||
// Empty query stays compact (nav + actions). Servers join the pool only while
|
||||
// searching, matched on name OR IP (the `search` field), so the fleet never
|
||||
// clutters the default list but is one keystroke away.
|
||||
const base = [...this.nav, ...this.actions];
|
||||
if (q === '') return base;
|
||||
return [...base, ...this.servers].filter((i) => (i.search || i.label).toLowerCase().includes(q));
|
||||
},
|
||||
|
||||
toggle(force) {
|
||||
|
|
|
|||
|
|
@ -32,9 +32,20 @@
|
|||
$actions = [
|
||||
['label' => __('servers.add_server'), 'action' => 'create-server', 'hint' => ''],
|
||||
];
|
||||
|
||||
// Fleet servers become palette entries so "Strg/⌘ K → name/IP" jumps straight to a
|
||||
// server's details. `search` carries name + IP so either matches; the hint shows the IP.
|
||||
$servers = \App\Models\Server::orderBy('name')->get(['uuid', 'name', 'ip'])
|
||||
->map(fn ($s) => [
|
||||
'label' => $s->name,
|
||||
'href' => '/servers/'.$s->uuid,
|
||||
'hint' => $s->ip,
|
||||
'search' => $s->name.' '.$s->ip,
|
||||
])->all();
|
||||
|
||||
$kbd = 'rounded border border-line bg-inset px-1.5 py-0.5 font-mono text-[10px] text-ink-2';
|
||||
@endphp
|
||||
<div x-data="cmdk(@js($nav), @js($actions))"
|
||||
<div x-data="cmdk(@js($nav), @js($actions), @js($servers))"
|
||||
@keydown.window="onKey($event)"
|
||||
@keydown.escape.window="close()"
|
||||
@cmdk-open.window="toggle(true)">
|
||||
|
|
@ -58,7 +69,7 @@
|
|||
</div>
|
||||
|
||||
<ul class="max-h-80 overflow-y-auto py-1.5">
|
||||
<template x-for="(item, idx) in items" :key="item.label">
|
||||
<template x-for="(item, idx) in items" :key="item.href || item.label">
|
||||
<li>
|
||||
<button type="button"
|
||||
@click="run(item)"
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@
|
|||
<x-badge tone="cyan">{{ __('services.journal_live') }}</x-badge>
|
||||
</x-slot:actions>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<div @if ($ready && $connected) wire:poll.5s="pollJournal" @endif class="overflow-x-auto">
|
||||
<div class="min-w-[640px] divide-y divide-line-soft font-mono text-[11px] leading-relaxed sm:min-w-0">
|
||||
@foreach ($journal as $line)
|
||||
<div class="flex items-start gap-3 px-4 py-1.5 sm:px-5">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The command palette (Strg/⌘ K) must let you jump to a server. The Blade side feeds the
|
||||
* fleet into the Alpine `cmdk` x-data; the JS filters it by name OR IP.
|
||||
*/
|
||||
class CommandPaletteServerSearchTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_palette_payload_includes_fleet_servers_with_details_href(): void
|
||||
{
|
||||
$server = Server::create(['name' => 'mars', 'ip' => '10.9.9.9', 'ssh_port' => 22, 'status' => 'online']);
|
||||
|
||||
$html = Blade::render('<x-command-palette />');
|
||||
|
||||
// Name (label + search), IP (hint + search) and the details href all reach the x-data.
|
||||
$this->assertStringContainsString('mars', $html);
|
||||
$this->assertStringContainsString('10.9.9.9', $html);
|
||||
// @js() emits a JS-escaped string literal, so assert on the (unescaped) uuid that
|
||||
// the details href is built from, plus the route segment it lands on.
|
||||
$this->assertStringContainsString($server->uuid, $html);
|
||||
$this->assertStringContainsString('servers', $html);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_the_application_returns_a_successful_response(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Services\Index;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use App\Services\FleetService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The journal is "live" via an incremental poll: each tick appends entries AFTER the
|
||||
* stored cursor (no gaps), advances the cursor, and caps the retained rows.
|
||||
*/
|
||||
class ServicesJournalPollTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function activeServer(): Server
|
||||
{
|
||||
$server = Server::create(['name' => 'box', 'ip' => '10.0.0.2', 'ssh_port' => 22, 'status' => 'online']);
|
||||
$server->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']);
|
||||
session(['active_server_id' => $server->id]);
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
public function test_poll_appends_new_entries_and_advances_the_cursor(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
$this->activeServer();
|
||||
|
||||
$fleet = Mockery::mock(FleetService::class);
|
||||
$fleet->shouldReceive('journalSince')
|
||||
->once()
|
||||
->with(Mockery::type(Server::class), 'cur-0', 300)
|
||||
->andReturn([
|
||||
'journal' => [['time' => '10:00:05', 'unit' => 'nginx', 'level' => 'info', 'text' => 'new line']],
|
||||
'cursor' => 'cur-1',
|
||||
]);
|
||||
app()->instance(FleetService::class, $fleet);
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->set('ready', true)
|
||||
->set('connected', true)
|
||||
->set('journalCursor', 'cur-0')
|
||||
->set('journal', [['time' => '10:00:01', 'unit' => 'sshd', 'level' => 'info', 'text' => 'old line']])
|
||||
->call('pollJournal')
|
||||
->assertSet('journalCursor', 'cur-1')
|
||||
->assertSet('journal', [
|
||||
['time' => '10:00:01', 'unit' => 'sshd', 'level' => 'info', 'text' => 'old line'],
|
||||
['time' => '10:00:05', 'unit' => 'nginx', 'level' => 'info', 'text' => 'new line'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_poll_caps_retained_rows_at_the_ceiling(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
$this->activeServer();
|
||||
|
||||
$fresh = array_map(
|
||||
fn (int $i) => ['time' => '10:00:'.$i, 'unit' => 'u', 'level' => 'info', 'text' => 'n'.$i],
|
||||
range(1, 350),
|
||||
);
|
||||
|
||||
$fleet = Mockery::mock(FleetService::class);
|
||||
$fleet->shouldReceive('journalSince')->once()->andReturn(['journal' => $fresh, 'cursor' => 'c']);
|
||||
app()->instance(FleetService::class, $fleet);
|
||||
|
||||
$component = Livewire::test(Index::class)
|
||||
->set('ready', true)
|
||||
->set('connected', true)
|
||||
->set('journal', [['time' => '09:59:59', 'unit' => 'old', 'level' => 'info', 'text' => 'oldest']])
|
||||
->call('pollJournal');
|
||||
|
||||
$journal = $component->get('journal');
|
||||
$this->assertCount(300, $journal);
|
||||
// The oldest row is evicted; the newest survives.
|
||||
$this->assertSame('n350', end($journal)['text']);
|
||||
$this->assertNotSame('oldest', $journal[0]['text']);
|
||||
}
|
||||
|
||||
public function test_poll_is_a_noop_until_connected(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
$this->activeServer();
|
||||
|
||||
$fleet = Mockery::mock(FleetService::class);
|
||||
$fleet->shouldNotReceive('journalSince');
|
||||
app()->instance(FleetService::class, $fleet);
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->set('ready', true)
|
||||
->set('connected', false)
|
||||
->set('journal', [['time' => 't', 'unit' => 'u', 'level' => 'info', 'text' => 'unchanged']])
|
||||
->call('pollJournal')
|
||||
->assertSet('journal', [['time' => 't', 'unit' => 'u', 'level' => 'info', 'text' => 'unchanged']]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\FleetService;
|
||||
use ReflectionMethod;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The journal poll resumes from the journald cursor that `--show-cursor` appends as a
|
||||
* trailing `-- cursor: X` line. These cover the two pure helpers that make that work.
|
||||
*/
|
||||
class JournalCursorTest extends TestCase
|
||||
{
|
||||
private function invokePrivate(string $method, string $body)
|
||||
{
|
||||
$m = new ReflectionMethod(FleetService::class, $method);
|
||||
$m->setAccessible(true);
|
||||
|
||||
return $m->invoke(app(FleetService::class), $body);
|
||||
}
|
||||
|
||||
public function test_split_pulls_the_trailing_cursor_off_the_body(): void
|
||||
{
|
||||
$body = "2026-06-14T10:00:01+02:00 host nginx[1]: started\n-- cursor: s=abc123;i=4f;b=9";
|
||||
|
||||
[$kept, $cursor] = $this->invokePrivate('splitJournalCursor', $body);
|
||||
|
||||
$this->assertSame('s=abc123;i=4f;b=9', $cursor);
|
||||
$this->assertStringNotContainsString('-- cursor:', $kept);
|
||||
$this->assertStringContainsString('nginx[1]: started', $kept);
|
||||
}
|
||||
|
||||
public function test_split_returns_null_cursor_when_absent(): void
|
||||
{
|
||||
[, $cursor] = $this->invokePrivate('splitJournalCursor', '2026-06-14T10:00:01+02:00 host nginx[1]: started');
|
||||
|
||||
$this->assertNull($cursor);
|
||||
}
|
||||
|
||||
public function test_parse_lines_classifies_levels_and_never_injects_a_placeholder(): void
|
||||
{
|
||||
$body = implode("\n", [
|
||||
'2026-06-14T10:00:01+02:00 host nginx[1]: connection refused',
|
||||
'2026-06-14T10:00:02+02:00 host cron[9]: warning clock skew detected',
|
||||
'2026-06-14T10:00:03+02:00 host sshd[7]: accepted publickey',
|
||||
]);
|
||||
|
||||
$rows = $this->invokePrivate('parseJournalLines', $body);
|
||||
|
||||
$this->assertCount(3, $rows);
|
||||
$this->assertSame('error', $rows[0]['level']);
|
||||
$this->assertSame('warn', $rows[1]['level']);
|
||||
$this->assertSame('info', $rows[2]['level']);
|
||||
$this->assertSame('2026-06-14 10:00:01', $rows[0]['time']);
|
||||
}
|
||||
|
||||
public function test_parse_lines_on_empty_input_returns_empty_no_placeholder(): void
|
||||
{
|
||||
// Empty = "nothing new since the cursor", which must NOT look like a permission error.
|
||||
$this->assertSame([], $this->invokePrivate('parseJournalLines', ''));
|
||||
$this->assertSame([], $this->invokePrivate('parseJournalLines', '-- No entries --'));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue