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_page_js.py
catborise ce7c772aae fix(instances): the image, network and template selects of the create page have names
Three selects of the VM create page had no accessible name. TomSelect
points the label of a select it enhances at its own input, so the
network select lost its label and the image select never had one; the
template select sits beside the template pool's label with none of its
own. Each now has an aria-label (Images, Networks, Template), which
TomSelect also copies to its input for the image select.
2026-10-20 21:24:41 +03:00

254 lines
14 KiB
Python

"""Scripts of the VM create page, rendered on libvirt's test driver and run
in Node against stand-ins."""
import json
import re
import shutil
import subprocess
import unittest
from unittest.mock import patch
from django.test import TestCase
from django.urls import reverse
from instances.forms import NewVMForm
from instances.tests.create_page import render_create_page
from lxml import html
# Just enough DOM for the custom form's rows: elements reach their parts by
# selector, a row template clones a fresh row, and writing HTML throws (rows
# are built from text and nodes only). A random-MAC request waits in replies.
FAKE_DOM = """
class El {
constructor(parts = {}) {
Object.assign(this, {parts, dataset: {}, value: "", name: "", textContent: "", children: [], listeners: {},
classes: new Set(), options: []});
this.classList = {toggle: (c, on) => on ? this.classes.add(c) : this.classes.delete(c)};
}
set innerHTML(v) { throw new Error("HTML written: " + v); }
part(sel) { return this.parts[sel]; }
querySelector(sel) { return this.parts[sel]; }
addEventListener(event, f) { this.listeners[event] = f; }
append(child) { child.parent = this; this.children.push(child); }
add(option) { this.options.push(option); }
remove() { this.parent.children.splice(this.parent.children.indexOf(this), 1); }
}
const row_template = names => ({content: {firstElementChild: {cloneNode: () =>
new El(Object.fromEntries(names.map(n => [n, new El()])))}}});
const templates = {"disk-row": row_template([".row-label", ".disk-device", ".disk-bus", ".row-item", ".row-remove"]),
"network-row": row_template(["input", ".row-label", ".row-item", ".row-remove"])};
const fields = {};
const field = sel => fields[sel] ??= new El();
const rows = list => field(list).children;
const document = {getElementById: id => templates[id], querySelector: field};
const replies = [];
const $ = Object.assign(() => ({ready: f => f()}), {getJSON: (url, f) => { replies.push(f); return {}; }});
const Option = function (text, value) { return {text, value}; };
// TomSelect: records its added options and items, and calls the page's
// callbacks as TomSelect does (a silent addItem still calls onItemAdd)
class TomSelect {
constructor(sel, settings) { this.settings = settings; this.added = []; }
addOption(o) { this.added.push("option " + o.value); }
addItem(v) { this.added.push("item " + v); this.settings.onItemAdd(v); }
removeItem(v) { this.settings.onItemRemove(v); }
}
"""
class CreatePageScriptTests(TestCase):
def page(self):
compute, response = render_create_page(self)
self.assertEqual(response.status_code, 200)
return compute, response.content.decode()
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_the_disk_bus_list_is_asked_for_the_page_architecture_and_machine(self):
compute, page = self.page()
function = re.search(r"function get_disk_bus_choices\(.*?\n }\n", page, re.S).group(0)
script = "let asked; const $ = Object.assign(() => ({find: () => ({remove() {}}), append() {}, val() {}}), " \
"{getJSON: url => { asked = url; }, each() {}});\n" + function + \
f"get_disk_bus_choices({compute.id}, {{}}, 'cdrom'); console.log(asked);"
result = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), reverse("buses", args=[compute.id, "i686", "pc", "cdrom"]))
def test_the_custom_form_has_its_own_mac_field(self):
_, page = self.page()
custom = html.fromstring(page).xpath("//form[@aria-label='Create custom instance form']")[0]
self.assertEqual(custom.xpath(".//input[@name='mac']/@id"), ["custom_mac"])
def test_the_row_templates_hold_what_the_rows_use(self):
# the test driver reports no disk devices or buses
with patch("vrtManager.connection.wvmConnect.get_disk_device_types", return_value=["disk", "cdrom"]), \
patch("vrtManager.connection.wvmConnect.get_disk_bus_types", return_value=["sata", "virtio"]):
_, page = self.page()
disk = html.fromstring(page).get_element_by_id("disk-row")
self.assertEqual(disk.xpath(".//select[contains(@class, 'disk-device')]/option/@value"), ["disk", "cdrom"])
self.assertEqual(disk.xpath(".//select[contains(@class, 'disk-bus')]/option[@selected]/@value"), ["virtio"])
network = html.fromstring(page).get_element_by_id("network-row")
self.assertTrue(network.xpath(".//input[@required]"))
for row in (disk, network):
for part in ("row-label", "row-item", "row-remove"):
self.assertTrue(row.xpath(f".//*[contains(@class, '{part}')]"), part)
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_adding_a_disk_keeps_the_others(self):
out = self.run_rows("""
images_control.addItem("a");
const a = rows("#img-list")[0];
a.part(".disk-device").value = "cdrom"; a.part(".disk-bus").value = "sata";
images_control.addItem("b");
console.log(JSON.stringify([a.part(".disk-device").value, a.part(".disk-bus").value, field("#images").value,
rows("#img-list").flatMap(r => [r.part(".disk-device").name, r.part(".disk-bus").name])]));""")
self.assertEqual(out, ["cdrom", "sata", "a,b", ["device0", "bus0", "device1", "bus1"]])
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_removing_a_disk_renumbers_the_rest(self):
out = self.run_rows("""
images_control.addItem("a"); images_control.addItem("b"); images_control.addItem("c");
images_control.removeItem("b");
const c = rows("#img-list")[1];
console.log(JSON.stringify([field("#images").value, c.part(".disk-device").name, c.part(".disk-bus").name,
c.part(".row-label").textContent]));""")
self.assertEqual(out, ["a,c", "device1", "bus1", "hdd1"])
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_adding_a_network_keeps_typed_macs(self):
out = self.run_rows("""
networks_control.addItem("default");
const first = rows("#net-list")[0];
first.part("input").value = "52:54:00:00:00:01"; first.part("input").listeners.input();
networks_control.addItem("isolated");
replies[0]({mac: "52:54:00:aa:aa:aa"}); replies[1]({mac: "52:54:00:bb:bb:bb"});
console.log(JSON.stringify([field("#networks").value, field("#custom_mac").value]));""")
self.assertEqual(out, ["default,isolated", "52:54:00:00:00:01,52:54:00:bb:bb:bb"])
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_a_hostile_volume_name_is_text(self):
name = "x'\"><img src=x>"
out = self.run_rows(f"console.log(JSON.stringify(add_disk_row({json.dumps(name)}).part('.row-item').textContent));")
self.assertEqual(out, name)
def run_rows(self, test):
"""Run the custom form's row script against a fake DOM, then test;
returns what test printed, parsed as JSON."""
_, page = self.page()
functions = re.search(r" var images_control, networks_control;.*?\n function random_mac\(.*?\n }\n",
page, re.S).group(0)
result = subprocess.run(["node", "-e", FAKE_DOM + functions + test], capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
return json.loads(result.stdout)
def test_the_selects_without_a_label_of_their_own_are_named(self):
# TomSelect points the label of a select it enhances at its own
# input; the template select sits beside the template pool's label
_, page = self.page()
page = html.fromstring(page)
for select in ("image-control", "network-control", "template"):
with self.subTest(select=select):
self.assertTrue(page.get_element_by_id(select).get("aria-label"))
def test_a_mac_per_nic_fits_the_form(self):
macs = ",".join(f"52:54:00:00:00:{n:02x}" for n in range(8))
self.assertEqual(NewVMForm.base_fields["mac"].clean(macs), macs)
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_the_page_reopens_the_submitted_form(self):
_, page = self.page()
functions = re.search(r"function reopen_submitted_form\(.*?\n \$\(reopen_submitted_form\);", page, re.S).group(0)
harness = """
const shown = [];
// the forms' holders: two tab panes and a flavor's dialog, each with an
// "Advanced" section that holds a refused field; a dialog's "shown"
// listeners run when it is shown, as in Bootstrap
const holder = (id, kind) => ({id, listeners: [], classList: {contains: c => c === kind},
querySelectorAll: () => [{name: "#" + id + " .collapse", querySelector: () => ({})}],
addEventListener(event, f) { this.listeners.push(f); }});
const holders = {custom: holder("addCustom", "tab-pane"), template: holder("add-from-temp", "tab-pane"),
flavor7: holder("addVMflavor7", "modal")};
const document = {body: {dataset: {submittedForm: %s}}, getElementById: () => null, querySelector: sel => {
const id = (sel.match(/data-form-id="([^"]*)"/) || [])[1];
if (id !== undefined) return holders[id] || null;
return sel.startsWith("form[") ? null : {name: sel}; // no refused template form here
}};
const bootstrap = {Tab: {getOrCreateInstance: e => ({show: () => shown.push("tab " + e.name)})},
Modal: {getOrCreateInstance: e => ({show: () => { shown.push("modal #" + e.id); e.listeners.forEach(f => f()); }})},
Collapse: {getOrCreateInstance: e => ({show: () => shown.push("collapse " + e.name)})}};
const $ = Object.assign(() => ({val() {}}), {when: () => ({then: f => f()})});
const focusFirstError = () => shown.push("focus");
%s
reopen_submitted_form(); console.log(JSON.stringify(shown));
"""
for submitted, expected in (
("custom", ["collapse #addCustom .collapse", 'tab [data-bs-target="#addCustom"]', "focus"]),
("template", ["collapse #add-from-temp .collapse", 'tab [data-bs-target="#add-from-temp"]', "focus"]),
("flavor7", ["collapse #addVMflavor7 .collapse", "modal #addVMflavor7", "focus"]),
("", ["focus"]),
):
with self.subTest(submitted=submitted):
result = subprocess.run(["node", "-e", harness % (json.dumps(submitted), functions.replace("$(reopen_submitted_form);", ""))],
capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), expected)
LISTS = {"pool": "default",
"disks": [{"volume": "a.qcow2", "device": "cdrom", "bus": "sata"},
{"volume": "b.qcow2", "device": "disk", "bus": "virtio"}],
"networks": [{"name": "default", "mac": "52:54:00:00:00:01"}, {"name": "isolated", "mac": ""}]}
def run_restore(self, test):
"""Run restore_custom_lists on LISTS against the fake DOM, with
loaders that answer when the test calls answer(); returns what the
test printed, parsed as JSON."""
_, page = self.page()
rows = re.search(r" var images_control, networks_control;.*?\n function random_mac\(.*?\n }\n", page, re.S)
restore = re.search(r" // The custom form's lists as submitted.*?\n function select_value\(.*?\n }\n", page, re.S)
harness = FAKE_DOM + rows.group(0) + restore.group(0) + """
templates["custom-lists"] = {textContent: %s};
$.when = (...loading) => Promise.all(loading);
const log = [], answers = [];
const later = name => { log.push(name); return new Promise(done => answers.push(() => { log.push("loaded " + name); done(); })); };
const get_cust_vols = (compute, pool) => later("volumes " + pool);
const get_disk_bus_choices = (compute, bus, device) => later("buses " + device);
const settle = () => new Promise(done => setTimeout(done));
const answer = async () => { await settle(); answers.shift()(); await settle(); };
const disks = () => rows("#img-list").map(r => [r.dataset.item, r.part(".disk-device").value, r.part(".disk-bus").value]);
""" % json.dumps(json.dumps(self.LISTS))
result = subprocess.run(["node", "-e", harness + "(async () => {" + test + "})();"],
capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
return json.loads(result.stdout)
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_restoring_the_custom_form_rebuilds_its_rows(self):
out = self.run_restore("""
const restored = restore_custom_lists().then(() => log.push("restored"));
await answer();
const before = disks();
await answer(); await answer(); await restored;
const typed_before_reply = field("#custom_mac").value;
replies[0]({mac: "52:54:00:00:00:bb"});
console.log(JSON.stringify({log, before, after: disks(), networks: field("#networks").value,
typed_before_reply, macs: field("#custom_mac").value, random_requests: replies.length}));""")
self.assertEqual(out["log"], ["volumes default", "loaded volumes default", "buses cdrom", "buses disk",
"loaded buses cdrom", "loaded buses disk", "restored"])
self.assertEqual(out["before"], [["a.qcow2", "cdrom", ""], ["b.qcow2", "disk", ""]])
self.assertEqual(out["after"], [["a.qcow2", "cdrom", "sata"], ["b.qcow2", "disk", "virtio"]])
self.assertEqual(out["networks"], "default,isolated")
self.assertEqual(out["typed_before_reply"], "52:54:00:00:00:01,")
self.assertEqual(out["macs"], "52:54:00:00:00:01,52:54:00:00:00:bb")
self.assertEqual(out["random_requests"], 1)
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_a_value_gone_from_the_lists_is_kept(self):
out = self.run_restore("""
const restored = restore_custom_lists();
await answer();
const bus = rows("#img-list")[0].part(".disk-bus");
bus.options.push({value: "virtio"}); // the loaded list has no sata
await answer(); await answer(); await restored;
console.log(JSON.stringify({volumes: images_control.added, bus: bus.value,
options: bus.options.map(o => o.value)}));""")
self.assertEqual(out["volumes"], ["option a.qcow2", "item a.qcow2", "option b.qcow2", "item b.qcow2"])
self.assertEqual(out["bus"], "sata")
self.assertEqual(out["options"], ["virtio", "sata"])