mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
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.
254 lines
14 KiB
Python
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"])
|