<?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; }}