mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-11 12:46:49 +00:00
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.
This commit is contained in:
parent
c1744402b4
commit
ab5ed59ea1
17 changed files with 991 additions and 1417 deletions
|
|
@ -15,20 +15,22 @@ 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
|
||||
# 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: []});
|
||||
this.classList = {toggle: (c, on) => on ? this.classes.add(c) : this.classes.delete(c)};
|
||||
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); }
|
||||
|
|
@ -37,10 +39,10 @@ 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 fields = {}, all = {};
|
||||
const field = sel => fields[sel] ??= new El();
|
||||
const rows = list => field(list).children;
|
||||
const document = {getElementById: id => templates[id], querySelector: field};
|
||||
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}; };
|
||||
|
|
@ -51,9 +53,27 @@ class TomSelect {
|
|||
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; }
|
||||
}
|
||||
"""
|
||||
|
||||
ROWS = ("make_pickers", "new_row", "add_disk_row", "add_network_row", "row_for", "remove_row", "sync_lists",
|
||||
"random_mac", "load_buses")
|
||||
|
||||
|
||||
def functions(page, *names):
|
||||
"""The page's script functions of these names, in this order."""
|
||||
return "".join(re.search(rf"\n function {name}\(.*?\n }}\n", page, 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):
|
||||
|
|
@ -64,18 +84,14 @@ class CreatePageScriptTests(TestCase):
|
|||
@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"]))
|
||||
out = node(f"const COMPUTE = {compute.id}; let asked; const $ = {{getJSON: url => {{ asked = url; }}}};"
|
||||
+ functions(page, "load_buses") + "load_buses({}, 'cdrom'); console.log(JSON.stringify(asked));")
|
||||
self.assertEqual(out, reverse("buses", args=[compute.id, "i686", "pc", "cdrom"]))
|
||||
|
||||
def test_the_custom_form_has_its_own_mac_field(self):
|
||||
def test_the_form_has_one_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"])
|
||||
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
|
||||
|
|
@ -91,6 +107,12 @@ class CreatePageScriptTests(TestCase):
|
|||
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."""
|
||||
_, page = self.page()
|
||||
return node(FAKE_DOM + "let images_control, networks_control;" + functions(page, *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("""
|
||||
|
|
@ -120,7 +142,7 @@ 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]));""")
|
||||
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")
|
||||
|
|
@ -129,109 +151,149 @@ console.log(JSON.stringify([field("#networks").value, field("#custom_mac").value
|
|||
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):
|
||||
def test_the_pickers_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"):
|
||||
# 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)
|
||||
|
||||
@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));
|
||||
|
||||
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(" ")]);
|
||||
"""
|
||||
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)
|
||||
|
||||
def run_script(self, test):
|
||||
_, response = render_create_page(self)
|
||||
page = response.content.decode()
|
||||
return node(self.HARNESS + functions(page, "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):
|
||||
_, response = render_create_page(self)
|
||||
page = response.content.decode()
|
||||
return node(FAKE_DOM + "let images_control, networks_control;" + functions(page, *ROWS)
|
||||
+ functions(page, "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):
|
||||
"""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};
|
||||
def run_restore(self, test, lists=LISTS):
|
||||
"""Run restore_submitted against the fake DOM, with loaders that
|
||||
answer when the test calls answer()."""
|
||||
_, response = render_create_page(self)
|
||||
page = response.content.decode()
|
||||
harness = FAKE_DOM + "let images_control, networks_control;" \
|
||||
+ functions(page, *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 get_cust_vols = (compute, pool) => later("volumes " + pool);
|
||||
const get_disk_bus_choices = (compute, bus, device) => later("buses " + device);
|
||||
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]);
|
||||
""" % 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)
|
||||
make_pickers();
|
||||
""" % json.dumps(json.dumps(lists))
|
||||
return node(harness + "(async () => {" + test + "})();")
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "node is not installed")
|
||||
def test_restoring_the_custom_form_rebuilds_its_rows(self):
|
||||
def test_the_lists_are_rebuilt_in_order(self):
|
||||
out = self.run_restore("""
|
||||
const restored = restore_custom_lists().then(() => log.push("restored"));
|
||||
restore_submitted();
|
||||
await answer();
|
||||
const before = disks();
|
||||
await answer(); await answer(); await restored;
|
||||
const typed_before_reply = field("#custom_mac").value;
|
||||
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("#custom_mac").value, random_requests: replies.length}));""")
|
||||
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", "restored"])
|
||||
"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")
|
||||
|
|
@ -242,7 +304,7 @@ console.log(JSON.stringify({log, before, after: disks(), networks: field("#netwo
|
|||
@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();
|
||||
restore_submitted();
|
||||
await answer();
|
||||
const bus = rows("#img-list")[0].part(".disk-bus");
|
||||
bus.options.push({value: "virtio"}); // the loaded list has no sata
|
||||
|
|
@ -252,3 +314,23 @@ console.log(JSON.stringify({volumes: images_control.added, bus: bus.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"})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue