1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00
webvirtcloud/instances/tests/test_create_page_js.py
catborise e143fe353a refactor(instances): the VM create pages' scripts load from static files
The create wizard's two pages lose their inline scripts and handlers:
create_machine.js (machine types of the chosen architecture, Next, the
XML editor) and create_instance.js (volume and network pickers, disk
source, presets and flavors, row lists, a refused form restored). Their
URLs and messages are data attributes of #select_chipset, #next and
#create-vm-form; URL parts use __name__ placeholders, so the volume list
URL is now the routed one with its trailing slash. The XML form shows
the please-wait dialog through data-please-wait. The page script tests
read the static files.
2026-11-02 09:26:39 +03:00

348 lines
19 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.conf import settings
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 page's scripts: 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: [], attrs: {}});
this.classList = {toggle: (c, on) => on ? this.classes.add(c) : this.classes.delete(c),
replace: (a, b) => this.classes.delete(a) && !!this.classes.add(b)};
}
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; }
setAttribute(k, v) { this.attrs[k] = String(v); }
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 = {}, all = {};
const field = sel => fields[sel] ??= new El();
const rows = list => field(list).children;
const document = {getElementById: id => templates[id], querySelector: field, querySelectorAll: sel => all[sel] ?? []};
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); }
enable() { this.enabled = true; }
disable() { this.enabled = false; }
}
"""
# the page's script, with the form's data in a stand-in #create-vm-form
SCRIPT = (settings.BASE_DIR / "static/js/create_instance.js").read_text()
ROWS = ("form_data", "make_pickers", "new_row", "add_disk_row", "add_network_row", "row_for", "remove_row", "sync_lists",
"random_mac", "load_buses")
def functions(*names):
"""The page script's functions of these names, in this order."""
return "".join(re.search(rf"\nfunction {name}\(.*?\n}}\n", SCRIPT, re.S).group(0) for name in names)
def node(script):
"""Run script in Node; returns what it printed, parsed as JSON."""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=30)
if result.returncode:
raise AssertionError(result.stderr)
return json.loads(result.stdout)
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()
buses_url = html.fromstring(page).get_element_by_id("create-vm-form").get("data-buses-url")
out = node(f"const document = {{querySelector: () => ({{dataset: {{busesUrl: {json.dumps(buses_url)}}}}})}};"
"let asked; const $ = {getJSON: url => { asked = url; }};"
+ functions("form_data", "load_list", "load_buses")
+ "load_buses({replaceChildren() {}}, 'cdrom'); console.log(JSON.stringify(asked));")
self.assertEqual(out, reverse("buses", args=[compute.id, "i686", "pc", "cdrom"]))
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_a_list_is_empty_while_it_loads_and_a_newer_load_aborts_the_older(self):
out = node("const document = {querySelector: () => ({dataset: {busesUrl: '/__disk__/'}})}; const requests = [];"
"const $ = {getJSON: (url, fill) => { const r = {fill, abort() { r.aborted = true; }}; requests.push(r); return r; }};"
"const Option = function (text) { this.text = text; };"
"const bus = {children: ['ide'], replaceChildren(...items) { this.children = items; }};"
+ functions("form_data", "load_list", "load_buses")
+ "load_buses(bus, 'disk'); const loading = bus.children; load_buses(bus, 'cdrom');"
"requests[1].fill({bus: ['sata']});"
"console.log(JSON.stringify({loading, aborted: requests.map(r => !!r.aborted), filled: bus.children.map(o => o.text)}));")
self.assertEqual(out, {"loading": [], "aborted": [True, False], "filled": ["sata"]})
def test_the_form_has_one_mac_field(self):
_, page = self.page()
form = html.fromstring(page).get_element_by_id("create-vm-form")
self.assertEqual(form.xpath(".//input[@name='mac']/@id"), ["vm_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)
def run_rows(self, test):
"""Run the page's row script against the fake DOM, then test."""
return node(FAKE_DOM + "let images_control, networks_control;" + functions(*ROWS)
+ "make_pickers();" + test)
@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("#vm_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 test_the_pickers_are_named(self):
# TomSelect points the label of a select it enhances at its own
# input, so the select is named by aria-label too
page = html.fromstring(self.page()[1])
for select in ("image-control", "network-control"):
with self.subTest(select=select):
self.assertTrue(page.xpath(f"//label[@for='{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)
class DiskSourceAndPresetScriptTests(TestCase):
"""The disk source choice enables only its own fields; a preset fills the
sizes, picks a new disk and is announced."""
HARNESS = FAKE_DOM + """
let images_control = new TomSelect("#image-control", {});
const fieldsets = ["new", "existing", "template"].map(s => Object.assign(new El(), {id: "source-" + s}));
all[".disk-source"] = fieldsets;
const presets = [["small", "2", "2048", "40"], ["large", "4", "8192", "80"]].map(function ([label, vcpu, memory, disk]) {
const button = new El();
Object.assign(button.dataset, {label, vcpu, memory, disk});
button.classes.add("text-body");
return button;
});
all[".preset"] = presets;
const sources = () => fieldsets.map(f => [f.id, !!f.hidden, !!f.disabled]);
const pressed = () => presets.map(b => [b.attrs["aria-pressed"], [...b.classes].join(" ")]);
"""
def run_script(self, test):
return node(self.HARNESS + functions("show_source", "apply_preset", "clear_presets") + test)
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_only_the_chosen_source_is_shown_and_sent(self):
out = self.run_script("""
show_source("existing"); const existing = [sources(), images_control.enabled];
show_source("template"); const template = [sources(), images_control.enabled];
console.log(JSON.stringify({existing, template}));""")
self.assertEqual(out["existing"], [[["source-new", True, True], ["source-existing", False, False],
["source-template", True, True]], True])
self.assertEqual(out["template"], [[["source-new", True, True], ["source-existing", True, True],
["source-template", False, False]], False])
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_a_preset_fills_the_sizes_and_picks_a_new_disk(self):
out = self.run_script("""
show_source("template");
apply_preset(presets[1]);
const applied = {sizes: ["#vm_vcpu", "#vm_memory", "#vm_hdd_size"].map(s => field(s).value),
radio: field('input[name="disk_source"][value="new"]').checked, sources: sources(), pressed: pressed(),
status: field("#preset-status").textContent};
clear_presets();
console.log(JSON.stringify({applied, cleared: pressed()}));""")
applied = out["applied"]
self.assertEqual(applied["sizes"], ["4", "8192", "80"])
self.assertTrue(applied["radio"])
self.assertEqual(applied["sources"][0], ["source-new", False, False])
self.assertEqual(applied["pressed"], [["false", "text-body"], ["true", "btn-primary"]])
self.assertEqual(applied["status"], "large: 4 vCPU, 8192 MiB, new 80 GiB disk")
self.assertEqual(out["cleared"], [["false", "text-body"], ["false", "text-body"]])
class NewFormScriptTests(TestCase):
"""A new form starts with one interface; the template's copy goes into the
template's pool unless the user picks another."""
def script(self, test):
return node(FAKE_DOM + "let images_control, networks_control;" + functions(*ROWS)
+ functions("add_first_network", "select_value", "template_pool_chosen") + "make_pickers();" + test)
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_a_new_form_starts_on_default_if_the_host_has_it(self):
for networks, first in ((["isolated", "default"], "default"), (["isolated", "lab"], "isolated"), ([], None)):
with self.subTest(networks=networks):
out = self.script(f"networks_control.options = Object.fromEntries({json.dumps(networks)}.map(n => [n, {{}}]));"
"add_first_network(); console.log(JSON.stringify(rows('#net-list').map(r => r.dataset.item)));")
self.assertEqual(out, [first] if first else [])
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_the_copy_goes_into_the_template_pool(self):
out = self.script("""
const load_templates = pool => {};
field("#template_storage").options = [{value: "default"}, {value: "templates"}];
template_pool_chosen("templates");
console.log(JSON.stringify(field("#template_storage").value));""")
self.assertEqual(out, "templates")
class RestoreScriptTests(TestCase):
"""A refused form's lists and template come back, loaded in order, then
the focus goes to the first error."""
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, lists=LISTS):
"""Run restore_submitted against the fake DOM, with loaders that
answer when the test calls answer()."""
harness = FAKE_DOM + "let images_control, networks_control;" \
+ functions(*ROWS[:-1], "select_value", "restore_submitted") + """
templates["submitted-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 load_volumes = pool => later("volumes " + pool);
const load_buses = (bus, device) => later("buses " + device);
const load_templates = pool => later("templates " + pool);
const bootstrap = {Collapse: {getOrCreateInstance: () => ({show: () => log.push("open advanced")})}};
let focused;
const restored = new Promise(done => { focused = done; });
const focusFirstError = () => { log.push("focus"); focused(); };
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]);
make_pickers();
""" % json.dumps(json.dumps(lists))
return node(harness + "(async () => {" + test + "})();")
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_the_lists_are_rebuilt_in_order(self):
out = self.run_restore("""
restore_submitted();
await answer();
const before = disks();
await answer(); await answer(); await restored;
const typed_before_reply = field("#vm_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("#vm_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", "focus"])
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("""
restore_submitted();
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"])
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_an_advanced_section_with_an_error_opens_before_the_focus(self):
out = self.run_restore("""
field("#advanced").parts[".is-invalid"] = new El();
restore_submitted(); await restored;
console.log(JSON.stringify(log));""", lists={"pool": "", "disks": [], "networks": []})
self.assertEqual(out, ["open advanced", "focus"])
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_the_template_comes_back_after_its_pool_loads(self):
out = self.run_restore("""
Object.assign(field("#create-vm-form").dataset, {templatePool: "pool-a", template: "base.img"});
restore_submitted();
const pool = field("#template_pool").value;
await answer(); await restored;
console.log(JSON.stringify({log, pool, template: field("#template").value}));""",
lists={"pool": "", "disks": [], "networks": []})
self.assertEqual(out, {"log": ["templates pool-a", "loaded templates pool-a", "focus"],
"pool": "pool-a", "template": "base.img"})