Accedi
IT
games/xencenter-xenadmin-xcp-jumphp / tests / XenCenterTest.php · commit fb1b7c9
<?phpnamespace Tests;use App\Form\AddServerForm;use App\Form\ConfirmForm;use App\Form\MainForm;use App\Form\NameForm;use App\Form\NewVmForm;use App\Model\Connection;use App\Xapi\FakeXapi;use JumPHP\Kernel\Engine;use JumPHP\Testing\GameTestCase;/** Tests that use the window as a person would, against the demo pool (FakeXapi). */final class XenCenterTest extends GameTestCase{    private MainForm $form;    /** The window with no server saved (the session is one for the whole process: emptied first). */    private function fresh(array $params = []): MainForm    {        Engine::get()->storage->delete('servers.json');        $this->game->start(MainForm::class);        /** @var MainForm $form */        $form = $this->game->scene();        $form->session->reset();        $this->game->start(MainForm::class, $params);        $this->form = $form;        return $form;    }    private function connectDemo(): Connection    {        $form = $this->fresh(['connectDemo' => true]);        $this->game->run(10);        $c = $form->session->connections[0];        self::assertTrue($c->isConnected(), 'the demo pool connects: ' . $c->error);        // the tree is refreshed at most every 0.2 s: its nodes are there before a test clicks one        $this->until(fn () => $this->form->tree->node((string)$c->cache->master()) !== null, 60);        return $c;    }    private function vm(Connection $c, string $name): string    {        foreach ($c->cache->all('vm') as $ref => $vm) {            if ($vm['name_label'] === $name) {                return $ref;            }        }        self::fail("no VM $name");    }    /** Waits until a condition holds (a task ends, an event arrives). */    private function until(callable $condition, int $frames = 600): void    {        for ($i = 0; $i < $frames && !$condition(); $i++) {            $this->game->run(1);        }        self::assertTrue((bool)$condition(), 'still waiting after ' . $frames . ' frames');    }    public function testTheDemoPoolShowsItsServersAndVmsInTheTree(): void    {        $c = $this->connectDemo();        $ids = $this->form->tree->visibleIds();        self::assertContains((string)$c->cache->poolRef(), $ids);        foreach ($c->cache->hosts() as $ref => $host) {            self::assertContains($ref, $ids, $host['name_label'] . ' under the pool');        }        self::assertContains($this->vm($c, 'web-01'), $ids);        self::assertNotContains($this->vm($c, 'Debian Bookworm 12'), $ids, 'templates hidden by default');        self::assertSame('Demo pool', $this->form->banner->text, 'the pool is selected once connected');        $this->game->shot('pool');    }    public function testTheDemoPoolCanBeRemovedFromTheFileMenuAndStaysGone(): void    {        $this->connectDemo();        $file = array_column(array_filter(\App\View\Commands::menu('File', null, '', 'root', ['mode' => 'infrastructure', 'templates' => false, 'networks' => false]), 'is_array'), 'enabled', 'id');        self::assertTrue($file['server.removeDemo'], 'File > Remove the Demo Pool is offered while the demo is there');        $this->form->command('server.removeDemo');        $this->game->run(1);        $dialog = $this->game->scene();        self::assertInstanceOf(ConfirmForm::class, $dialog);        self::assertStringContainsString('Remove the demo pool', $dialog->message->text);        $dialog->click($dialog->ok);        $this->game->run(2);        self::assertSame([], $this->form->session->connections);        self::assertFalse(\App\View\Commands::hasDemo());        self::assertSame([], Engine::get()->storage->readJson('servers.json'), 'saved without it: it does not come back at the next start');    }    public function testAConnectedPoolOffersRemoveFromXenCenterInItsRightClickMenu(): void    {        $c = $this->connectDemo();        $ids = array_column(array_filter(\App\View\Commands::context($c, (string)$c->cache->poolRef(), 'pool'), 'is_array'), 'id');        self::assertContains('server.remove', $ids);    }    public function testClickingAVmShowsItsGeneralTabAndItsDisks(): void    {        $c = $this->connectDemo();        $vm = $this->vm($c, 'db-primary');        $this->form->tree->select($vm);        $this->game->run(2);        self::assertSame('db-primary', $this->form->banner->text);        self::assertSame('Running', $this->form->sheet->value('General', 'Power state:'));        $this->form->tabs->selectTab('Storage');        $this->game->run(2);        self::assertTrue($this->form->grid->visible);        self::assertSame('db-primary 0', $this->form->grid->rows[0]['cells'][1]);        $this->game->shot('storage');    }    public function testShuttingDownAVmAsksFirstThenTheVmHalts(): void    {        $c = $this->connectDemo();        $vm = $this->vm($c, 'web-01');        $this->form->tree->select($vm);        $this->game->run(2);        self::assertTrue($this->form->shutDown->enabled);        $this->form->click($this->form->shutDown);        $this->game->run(1);        $dialog = $this->game->scene();        self::assertInstanceOf(ConfirmForm::class, $dialog);        self::assertStringContainsString("shut down VM 'web-01'", $dialog->message->text);        $this->game->shot('confirm');        $dialog->click($dialog->ok);        $this->game->run(1);        self::assertSame($this->form, $this->game->scene());        $this->until(fn () => $c->cache->field($vm, 'power_state') === 'Halted');        $this->game->run(20);        self::assertTrue($this->form->startResume->enabled, 'Start is possible now');        self::assertSame('Completed', $this->form->pages()->events(null, $vm)->rows[0]['cells'][2]);    }    public function testStartingAHaltedVmFromTheRightClickMenu(): void    {        $c = $this->connectDemo();        $vm = $this->vm($c, 'backup-srv');        $this->form->tree->select($vm);        $this->game->run(2);        $this->form->onTreeMenu($this->form->tree, 300, 300);        $items = array_column(array_filter($this->form->tree->popup->items, 'is_array'), 'enabled', 'text');        self::assertTrue($items['Start']);        self::assertFalse($items['Shut Down']);        $this->game->run(1);        self::assertNotNull($this->form->tree->popup, 'the menu stays open');        $this->game->shot('menu');        $this->form->tree->close();        $this->form->command('vm.start');        $this->until(fn () => $c->cache->field($vm, 'power_state') === 'Running');    }    public function testASnapshotIsTakenAndListedInTheSnapshotsTab(): void    {        $c = $this->connectDemo();        $vm = $this->vm($c, 'gitlab');        $before = count($c->cache->snapshotsOf($vm));        $this->form->tree->select($vm);        $this->form->tabs->selectTab('Snapshots');        $this->game->run(2);        $this->form->command('vm.snapshot');        $this->game->run(1);        $dialog = $this->game->scene();        self::assertInstanceOf(NameForm::class, $dialog);        $dialog->name->text = 'before the upgrade to 17';        $dialog->click($dialog->ok);        $this->until(fn () => count($c->cache->snapshotsOf($vm)) === $before + 1);        $this->game->run(20);        self::assertSame('Snapshots', $this->form->tabs->value());        self::assertContains('before the upgrade to 17', array_map(fn ($r) => $r['cells'][0], $this->form->grid->rows), json_encode(array_column($this->form->grid->rows, 'cells')));        $this->game->shot('snapshots');    }    public function testANewVmIsCreatedFromATemplateAndStarts(): void    {        $c = $this->connectDemo();        $this->form->command('vm.new');        $this->game->run(1);        $dialog = $this->game->scene();        self::assertInstanceOf(NewVmForm::class, $dialog);        $dialog->template->selected = array_search('Debian Bookworm 12', $dialog->template->items, true);        $dialog->onTemplateChange($dialog->template, (int)$dialog->template->selected, '');        $dialog->name->text = 'new-debian';        $dialog->memory->text = '1024';        $dialog->vcpus->text = '2';        $this->game->shot('new-vm');        $dialog->click($dialog->create);        $this->until(function () use ($c): bool {            foreach ($c->cache->vms() as $vm) {                if ($vm['name_label'] === 'new-debian' && $vm['power_state'] === 'Running') {                    return true;                }            }            return false;        }, 1200);        $vm = $this->vm($c, 'new-debian');        self::assertSame(1024 * 1048576, (int)$c->cache->field($vm, 'memory_static_max'));        self::assertCount(1, $c->cache->vifsOf($vm), 'its network card');        self::assertCount(1, $c->cache->disksOf($vm), 'its disk, provisioned');    }    public function testAVmMigratesToTheOtherServer(): void    {        $c = $this->connectDemo();        $vm = $this->vm($c, 'web-01');        $from = (string)$c->cache->field($vm, 'resident_on');        $to = array_values(array_diff(array_keys($c->cache->hosts()), [$from]))[0];        $this->form->command('vm.migrate:' . $to, $vm);        $this->until(fn () => $c->cache->field($vm, 'resident_on') === $to);        $this->game->run(20);        self::assertContains($vm, array_column(array_map(fn ($n) => ['id' => $n['id']], $this->form->tree->node($to)['children']), 'id'), 'the VM is under its new server');    }    public function testAWrongPasswordIsShownAndTheServerStaysDisconnected(): void    {        $form = $this->fresh();        $form->command('server.add');        $this->game->run(1);        $dialog = $this->game->scene();        self::assertInstanceOf(AddServerForm::class, $dialog);        $dialog->click($dialog->add);        self::assertSame('Enter the address of the server.', $dialog->error->text);        $dialog->close(['profile' => new \App\Model\ServerProfile('xcp-demo.local', 'root', 'wrong', 'demo')]);        $this->game->run(10);        $c = $form->session->connections[0];        self::assertSame(Connection::FAILED, $c->state);        self::assertStringContainsString('Authentication failed', $c->error);        self::assertSame('disconnected', \App\View\Pages::kind($c, 'conn:0'));        $this->game->shot('failed');    }    public function testAServerAddedIsSavedWithItsPasswordSealedAndComesBackAtTheNextStart(): void    {        $form = $this->fresh();        $form->command('server.add');        $this->game->run(1);        $dialog = $this->game->scene();        self::assertInstanceOf(AddServerForm::class, $dialog);        self::assertTrue($dialog->remember->checked, 'the password is remembered unless the user says not');        $dialog->close(['profile' => new \App\Model\ServerProfile('xcp-demo.local', FakeXapi::USER, FakeXapi::PASSWORD, 'demo', 0, true, 'root', 22, '', 'My pool')]);        $this->game->run(10);        $saved = Engine::get()->storage->read('servers.json');        $row = json_decode((string)$saved, true)[0];        self::assertSame('My pool', $row['friendlyName']);        self::assertTrue(\App\Model\Keyring::isSealed($row['secret']), 'never in clear');        self::assertArrayNotHasKey('password', $row);        // the next start: the file read again, the password opened, the server reconnected        $form->session->reset();        (new \ReflectionProperty($form->session, 'loaded'))->setValue($form->session, false);        $this->game->start(MainForm::class);        $this->game->run(10);        $c = $form->session->connections[0];        self::assertSame('My pool', $c->profile->friendlyName);        self::assertSame(FakeXapi::PASSWORD, $c->profile->password);        self::assertTrue($c->isConnected(), 'reconnected at start: ' . $c->error);    }    public function testShowTemplatesIsRememberedForTheNextStart(): void    {        $form = $this->fresh();        $form->command('view.templates');        self::assertTrue((bool)Engine::get()->options->get('show_templates'));        $this->game->start(MainForm::class);        self::assertTrue($form->showTemplates);        $form->command('view.templates');        self::assertFalse((bool)Engine::get()->options->get('show_templates'));    }    public function testTheObjectsViewGroupsByType(): void    {        $this->connectDemo();        $this->form->command('view.objects');        $this->game->run(2);        $ids = $this->form->tree->visibleIds();        foreach (['type:pool', 'type:host', 'type:vm', 'type:template', 'type:sr', 'type:network'] as $group) {            self::assertContains($group, $ids);        }        $this->form->command('view.notifications');        $this->game->run(2);        self::assertSame(['notif:alerts', 'notif:events'], $this->form->tree->visibleIds());        self::assertNotEmpty($this->form->grid->rows, 'the alerts of the pool');        $this->game->shot('alerts');    }    public function testTheSearchTabShowsLiveFigures(): void    {        $c = $this->connectDemo();        $this->form->tree->select((string)$c->cache->poolRef());        $this->form->tabs->selectTab('Search');        self::assertSame('Search', $this->form->tabs->value());        $this->until(fn () => $this->form->rrd !== [], 300);        $this->game->run(2);        $row = null;        foreach ($this->form->grid->rows as $r) {            if (trim((string)$r['cells'][0]) === 'db-primary') {                $row = $r;            }        }        self::assertNotNull($row);        self::assertIsArray($row['cells'][1], 'a CPU bar');        $this->game->shot('search');    }    public function testTheConsoleShowsTheVmScreenAndTypesIntoIt(): void    {        $c = $this->connectDemo();        $vm = $this->vm($c, 'db-primary');        $this->form->tree->select($vm);        $this->form->tabs->selectTab('Console');        $this->game->run(2);        self::assertTrue($this->form->console->visible);        $rfb = $this->form->console();        self::assertNotNull($rfb);        $this->until(fn () => $rfb->state === \App\Vnc\RfbClient::READY, 120);        self::assertSame([640, 400], [$rfb->fb->width, $rfb->fb->height]);        $this->until(fn () => $rfb->fb->pixel(8, 20) !== [0, 0, 0] || $rfb->fb->pixel(10, 21) !== [0, 0, 0] || $this->lit($rfb->fb, 1), 60);        // a click gives the keyboard to the console; what is typed goes to the VM        $this->form->click($this->form->vnc);        self::assertSame($this->form->vnc, $this->form->focused());        $this->form->type('root');        $this->form->key(\JumPHP\Gui\Pointer::RETURN);        $this->form->vnc->typeText("secret\nuname -a\n");        $this->game->run(30);        self::assertTrue($this->lit($rfb->fb, 7), 'the shell answered on the 8th text row');        self::assertSame($rfb, $this->form->console(), 'the console stays open while the pool changes');        $this->game->shot('console');        // another tab closes it        $this->form->tabs->selectTab('General');        $this->game->run(2);        self::assertNull($this->form->console());        self::assertSame(\App\Vnc\RfbClient::CLOSED, $rfb->state);    }    public function testAHaltedVmHasNoConsoleButAStartButton(): void    {        $c = $this->connectDemo();        $this->form->tree->select($this->vm($c, 'backup-srv'));        $this->form->tabs->selectTab('Console');        $this->game->run(2);        self::assertFalse($this->form->console->visible);        self::assertStringContainsString('only available while the VM is running', $this->form->noteText->text);        self::assertNull($this->form->console());    }    public function testTheLogsTabListsEventsAndAlertsNewestFirstAndDismissesTheRightLine(): void    {        $c = $this->connectDemo();        $vm = $this->vm($c, 'backup-srv');        $this->form->command('vm.start', $vm);        $this->until(fn () => $c->cache->field($vm, 'power_state') === 'Running');        $this->game->run(20);        $this->form->tree->select((string)$c->cache->poolRef());        $this->form->tabs->selectTab('Logs');        $this->game->run(20);        $grid = $this->form->grid;        self::assertSame(['Message', 'Location', 'Status', 'Date'], array_column($grid->columns, 'title'));        $times = array_column($grid->rows, 'at');        $sorted = $times;        rsort($sorted);        self::assertSame($sorted, $times, 'newest first, events and alerts mixed');        self::assertSame("Starting VM 'backup-srv'", $grid->rows[0]['cells'][0], 'what was just done comes first');        self::assertMatchesRegularExpression('/^[A-Z][a-z]{2} \d+, \d{4} \d\d:\d\d:\d\d$/', $grid->rows[0]['cells'][3], 'one date format');        $this->game->shot('logs-pool');        // Dismiss on an alert removes that alert, and no event        $alert = null;        foreach ($grid->rows as $row) {            if (str_starts_with($row['id'], 'msg:')) {                $alert = $row['id'];                break;            }        }        self::assertNotNull($alert);        $events = count($this->form->session->log);        $alerts = count($c->cache->messages());        $grid->select($alert);        $this->game->run(1);        $buttons = [];        foreach ($this->form->actions->children as $button) {            $buttons[$button->props['text']] = $button->props['enabled'];        }        self::assertSame(['Cancel' => false, 'Dismiss' => true, 'Dismiss all' => true], $buttons, 'an alert cannot be cancelled');        $this->form->click($this->form->widget('action:log.dismiss'));        $this->until(fn () => count($c->cache->messages()) === $alerts - 1, 120);        self::assertSame($events, count($this->form->session->log), 'no event was removed');        $this->game->run(20);        self::assertNotContains($alert, array_column($grid->rows, 'id'));        // the VM's own logs: its events only; Dismiss all clears them without touching the pool's alerts        $this->form->tree->select($vm);        $this->form->tabs->selectTab('Logs');        $this->game->run(20);        self::assertSame(["Starting VM 'backup-srv'"], array_map(fn ($r) => $r['cells'][0], $grid->rows));        $this->form->command('log.dismissAll');        $this->game->run(20);        self::assertSame([], $grid->rows);        self::assertSame($alerts - 1, count($c->cache->messages()), 'the alerts of the pool stay');        $this->game->shot('logs-vm');    }    public function testTheLogsTabKeepsEachMessageOnOneLineEvenAsActionsPileUp(): void    {        $c = $this->connectDemo();        $this->form->tree->select((string)$c->cache->poolRef());        $this->form->tabs->selectTab('Logs');        $this->game->run(5);        foreach ($c->cache->vms() as $ref => $vm) {            if ($vm['power_state'] === 'Running') {                $this->form->command('vm.suspend', $ref, true);            }        }        $this->until(fn () => count(array_filter($this->form->session->log, fn ($e) => $e->status === \App\Model\LogEntry::RUNNING)) === 0, 900);        $this->game->run(20);        $rows = $this->form->grid->rows;        self::assertGreaterThan(8, count($rows), 'the actions and the alerts of the pool');        $messages = array_map(fn ($r) => \App\Gui\DataGrid::oneLine((string)$r['cells'][0]), $rows);        self::assertContains('CPU usage at 95% (alarm above 90% for 60 s)', $messages, "XAPI's alarm in words");        foreach ($rows as $r) {            self::assertStringNotContainsString('<variable>', (string)$r['cells'][0]);        }        $dates = array_map(fn ($r) => $r['at'], $rows);        $sorted = $dates;        rsort($sorted);        self::assertSame($sorted, $dates, 'newest first');        $this->game->shot('logs');    }    public function testAConsoleOnAnotherHostIsReachedWithTheNamingTheUserTyped(): void    {        // a pool reached through a VPN: host01.example.prod -> the master, whose XAPI name is host01.example.net        self::assertSame('host02.example.prod', \App\Model\Connection::sameNaming('host01.example.prod', ['host01'], ['host02']));        self::assertNull(\App\Model\Connection::sameNaming('10.20.30.11', ['host01'], ['host02']), 'an address: nothing to guess');        self::assertNull(\App\Model\Connection::sameNaming('pool.example.prod', ['host01'], ['host02']), 'a name of another kind');        $c = $this->connectDemo();        $master = (string)$c->cache->master();        $vmOnMaster = $vmElsewhere = null;        foreach ($c->cache->vms() as $ref => $vm) {            if ($vm['power_state'] === 'Running') {                $vm['resident_on'] === $master ? $vmOnMaster ??= $ref : $vmElsewhere ??= $ref;            }        }        self::assertSame('xcp-demo.local', $c->consoleTargets((string)$vmOnMaster)[0], 'on the master: the typed address first');        $targets = $c->consoleTargets((string)$vmElsewhere);        self::assertSame('192.168.1.12', $targets[0], "elsewhere: XAPI's location first (no naming to reuse with an address)");        self::assertContains('xcp-demo.local', $targets, 'the typed address last');    }    public function testAnAlarmBodyReadsAsASentence(): void    {        self::assertSame('Free memory at 512 MB (alarm below 1.0 GB for 60 s)', \App\View\Pages::messageText('ALARM',            "value: 524288\nconfig:\n<variable>\n<name value=\"memory_free_kib\"/>\n<alarm_trigger_level value=\"1048576\"/>\n<alarm_trigger_period value=\"60\"/>\n</variable>"));        self::assertSame('Line one line two', \App\View\Pages::messageText('X', "Line one\n\tline two\n"));        self::assertSame('a b', \App\Gui\DataGrid::oneLine("a\r\n\tb "));    }    public function testAConsoleOfAnotherHostIsReachedAtTheUsersNamingNotOnlyAtXapisAddress(): void    {        // a pool reached through a VPN: the user typed host01.example.prod, XAPI only knows public addresses        self::assertSame('xcp-ng-02.lab.prod', \App\Model\Connection::sameNaming('xcp-ng-01.lab.prod', ['xcp-ng-01'], ['xcp-ng-02']));        self::assertNull(\App\Model\Connection::sameNaming('10.20.30.11', ['xcp-ng-01'], ['xcp-ng-02']), 'an address: no naming to copy');        self::assertNull(\App\Model\Connection::sameNaming('pool.lab', ['xcp-ng-01'], ['xcp-ng-02']), 'another naming');        $c = $this->connectDemo();        $c->profile->host = 'xcp-ng-01.lab.prod';        $onMaster = $this->vm($c, 'db-primary');        $onOther = $this->vm($c, 'db-replica');        self::assertSame('xcp-ng-01.lab.prod', $c->consoleTargets($onMaster)[0], 'on the master: the address the user typed');        $targets = $c->consoleTargets($onOther);        self::assertSame('xcp-ng-02.lab.prod', $targets[0], 'another host: its name in the user\'s naming first');        self::assertContains('192.168.1.12', $targets, "then XAPI's address of that host");        self::assertSame('xcp-ng-01.lab.prod', end($targets), 'the typed address last');    }    /** A text row of the console holds some light pixels. */    private function lit(\App\Vnc\Framebuffer $fb, int $row): bool    {        for ($y = $row * 16; $y < $row * 16 + 16; $y += 2) {            for ($x = 0; $x < 400; $x += 3) {                if ($fb->pixel($x, $y)[0] > 100) {                    return true;                }            }        }        return false;    }}