1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00
webvirtcloud/instances/tests/test_create_dialog_cpu.py
catborise ab5ed59ea1 feat(instances): a new VM is made on one form in three steps
The create page had three tabs (flavor, custom, template), each with its
own copy of the same create form; every flavor rendered one more copy in
a dialog, and fixes had to be made in each copy. All of them posted the
same form to the same job, differing only in where the disk comes from.

The page is now one form in sections: name and size, disk, networks and
an Advanced section. The disk section offers three sources as radio
cards (a new disk, existing volumes, a copy of a template); only the
chosen source's fields are shown, and the others are disabled, so they
are neither sent nor validated. A template's copy goes into the
template's pool unless another is picked. Flavors are presets that fill
vCPU, memory and disk size and pick a new disk; they are added and
deleted in a dialog outside the form. Every source uses the
per-interface network rows, so a new disk no longer requires a MAC:
libvirt gives an interface without one its address, as it did for the
other sources. A refused form comes back with its source, volumes,
template and networks, and the focus on its first error. Without a
storage pool or a network the page shows why instead of the form, and a
host that cannot be read is a message, not a 500.

The host dialog and the machine page share the form's look: hosts are
rows with their CPU and memory use, the machine page is a card with
Cancel/Next and the XML way is a collapsible section. All three steps
show the same step indicator, done steps linking back.

The create job refuses more than one disk source (the page sends one; a
crafted request could send two, and the job would have used the first).
A field with help text is described by its help and its error.

Frontend review fixes: the presets and the disclosure buttons show a
focus ring for keyboard users; a disclosure spans its card and its
chevron turns while open; a preset says it picks a new disk; numeric
fields bring a numeric keyboard and MAC fields no autocorrection; the
machine page's Next waits for a machine type. Tom Select's control
keeps the text colour of the select it replaces: its own fixed colour
made typed text invisible on dark Bootswatch themes, on every page that
uses it.
2026-10-23 23:26:28 +03:00

114 lines
5 KiB
Python

"""The new-VM dialog's CPU bars: the page carries each compute's statistics
URL and reads no CPU usage itself; its script fills the bars when the dialog
opens, two computes at a time, and ignores answers of an earlier opening."""
import json
import shutil
import subprocess
import unittest
from unittest.mock import PropertyMock, patch
from computes.models import Compute
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from lxml import html
RUN = """
const vm = require("vm");
function element() {
const attrs = {};
return {style: {}, textContent: "", attrs,
setAttribute: (k, v) => { attrs[k] = String(v); }, removeAttribute: (k) => { delete attrs[k]; }};
}
const cells = URLS.map((url) => {
const meter = element(), bar = element(), label = element();
return {dataset: {url}, meter, bar, label,
querySelector: (sel) => ({".progress": meter, ".progress-bar": bar})[sel] || label};
});
const handlers = {}, requests = [];
const dialog = {addEventListener: (name, fn) => { handlers[name] = fn; }, querySelectorAll: () => cells};
const page = {
document: {getElementById: () => dialog},
$: {getJSON: (url) => {
const r = {url, done: (f) => { r.ok = f; return r; }, fail: (f) => { r.err = f; return r; },
always: (f) => { r.end = f; return r; }};
requests.push(r);
return r;
}},
window: {location: {}},
};
vm.runInNewContext(SCRIPT, vm.createContext(page));
const state = () => cells.map((c) => [c.bar.style.width, c.meter.attrs["aria-valuenow"] || null, c.label.textContent]);
const answer = (i, data) => { const r = requests[i]; data === null ? r.err() : r.ok(data); r.end(); };
const out = {};
handlers["show.bs.modal"]();
out.loading = state();
out.first = requests.map((r) => r.url);
handlers["hide.bs.modal"]();
answer(0, {cpudata: 5});
answer(1, null);
out.hidden = [state(), requests.length];
handlers["show.bs.modal"]();
answer(2, {cpudata: 0});
answer(3, {cpudata: null});
out.filled = state();
handlers["hide.bs.modal"]();
handlers["show.bs.modal"]();
out.reopened = requests.length;
answer(4, {cpudata: 99});
answer(5, {cpudata: 41.6});
answer(6, null);
out.final = state();
out.all = requests.map((r) => r.url);
console.log(JSON.stringify(out));
"""
class CreateDialogCpuTestCase(TestCase):
def setUp(self):
self.client.force_login(get_user_model().objects.create_superuser("cd-admin", "cd@example.com", "pw"))
self.computes = [
Compute.objects.create(name=f"cd{i}", hostname=f"192.0.2.{i}", login="u", password="p", type=1)
for i in (1, 2, 3)
]
patches = [
patch.object(Compute, name, new_callable=PropertyMock, return_value=value)
for name, value in (("status", True), ("cpu_count", 1), ("ram_size", 1024), ("ram_usage", 0))
]
patches += [patch("instances.views.utils.refr"), patch("computes.models.wvmHostDetails")]
for p in patches:
self.details = p.start() # the last one: the host connection
self.addCleanup(p.stop)
def page(self):
response = self.client.get(reverse("instances:index"))
self.assertEqual(response.status_code, 200)
return response.content.decode()
def test_the_page_carries_the_statistics_urls_and_reads_no_cpu_usage(self):
page = self.page()
urls = html.fromstring(page).xpath("//*[contains(@class, 'cpu-usage ')]/@data-url")
self.assertEqual(urls, [reverse("compute_graph", args=[c.id]) for c in self.computes])
self.details.return_value.get_cpu_usage.assert_not_called()
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_the_dialog_fills_the_bars_two_at_a_time(self):
page = self.page()
script = next(s for s in html.fromstring(page).xpath("//script[not(@src)]/text()") if "show.bs.modal" in s)
urls = [reverse("compute_graph", args=[c.id]) for c in self.computes]
js = RUN.replace("SCRIPT", json.dumps(script)).replace("URLS", json.dumps(urls))
out = subprocess.run(["node", "-e", js], capture_output=True, text=True, timeout=30)
self.assertEqual(out.returncode, 0, out.stderr)
result = json.loads(out.stdout)
self.assertEqual(result["loading"], [["0%", None, "…"]] * 3)
self.assertEqual(result["first"], urls[:2])
# answers that come after the dialog closed change nothing and read no more
self.assertEqual(result["hidden"], [[["0%", None, "…"]] * 3, 2])
# 0 is a value, no value is a dash
self.assertEqual(result["filled"], [["0%", "0", "0%"], ["0%", None, "—"], ["0%", None, "…"]])
# reopened while the third read runs: one more read, two in all
self.assertEqual(result["reopened"], 6)
# the earlier opening's answer (99) is ignored; a failed read is a dash
self.assertEqual(result["final"], [["42%", "42", "42%"], ["0%", None, "—"], ["0%", None, "…"]])
self.assertEqual(result["all"], urls[:2] + urls + urls)