// operations/tests/js/job_panel.test.js // node job_panel.test.js static/js/poll.js operations/static/operations/job_panel.js const fs = require("fs"); const assert = require("assert"); let now = 0, timers = [], nextId = 1; global.setTimeout = (fn, ms) => { const id = nextId++; timers.push({ id, at: now + (ms || 0), fn }); return id; }; global.clearTimeout = (id) => { timers = timers.filter((t) => t.id !== id); }; function advance(ms) { const end = now + ms; for (;;) { timers.sort((a, b) => a.at - b.at); const t = timers[0]; if (!t || t.at > end) break; timers.shift(); now = t.at; t.fn(); } now = end; } const realNow = Date.now; Date.now = () => 1700000000000 + now; global.document = { hidden: false, addEventListener() {}, activeElement: null, querySelector: () => null }; global.window = { backgroundJobRunning: false, location: { reload() { window.reloaded = true; }, assign(url) { window.opened = url; } } }; // requests settle only when the test says so; callbacks run in registration order let open = []; function req(url) { const cbs = []; const r = { url, settled: false, always(f) { cbs.push(["always", f]); return r; }, done(f) { cbs.push(["done", f]); return r; }, fail(f) { cbs.push(["fail", f]); return r; }, settle(ok, value) { r.settled = true; cbs.forEach(([t, f]) => { if (t === "always" || t === (ok ? "done" : "fail")) f(value); }); } }; open.push(r); return r; } // a failed request passes a jqXHR-like {status: N} function answer(url, value, ok = true) { const r = open.find((x) => x.url === url && !x.settled); assert.ok(r, "no open request to " + url); r.settle(ok, value); } global.$ = { getJSON: req, ajax: (o) => req(o.url) }; let listeners = {}; const el = { innerHTML: "", addEventListener(ev, f) { listeners[ev] = f; } }; eval(fs.readFileSync(process.argv[2], "utf8")); eval(fs.readFileSync(process.argv[3], "utf8")); const urls = { resource: "/r", job: (id) => "/j/" + id, cancel: (id) => "/c/" + id }; // the labels and texts the page gives (job_panel_script), here as in English const STATES = { queued: "queued", running: "running", cancelling: "cancelling", succeeded: "succeeded", failed: "failed", cancelled: "cancelled", uncertain: "uncertain" }; const TEXT = { cancel: "Cancel", open: "Open", refresh: "Refresh", dismiss: "Dismiss", finishing: "Finishing\u2026", not_removed: "Files not removed:", short_operation: "A short operation is in progress.", worker_unavailable: "Worker unavailable, last seen", never: "never", cancel_failed: "Cancel failed (HTTP %(status)s).", cancel_unreachable: "Cancel failed: the server could not be reached." }; const seen = new Date(1700000000000).toISOString(); const running = { id: "a", label: "Delete snapshot", state: "running", phase: "Merging", progress_done: 1, progress_total: 4, cancellable: false, result: {}, error: "" }; const empty = { jobs: [], others: [], short_operation: false, worker_last_seen: seen }; jobPanel(el, urls, "tok", undefined, STATES, TEXT); advance(0); answer("/r", { ...empty, jobs: [running] }); assert.ok(el.innerHTML.includes("Delete snapshot") && el.innerHTML.includes("25%"), "shows the job and real progress"); // one colour per state (operations/tests/test_views.py checks the same map in the template filter) assert.deepStrictEqual(JOB_STATE_COLORS, { queued: "secondary", running: "primary", cancelling: "info", succeeded: "success", failed: "danger", cancelled: "secondary", uncertain: "warning" }); assert.ok(el.innerHTML.includes('class="alert alert-primary') && el.innerHTML.includes('running') && el.innerHTML.includes('Merging'), "state badge and muted phase"); assert.strictEqual(window.backgroundJobRunning, true); assert.ok(!el.innerHTML.includes("Finishing"), "a number below 99 %"); // from 99 % the bar is full, striped, animated and says Finishing advance(3000); answer("/r", { ...empty, jobs: [{ ...running, progress_done: 99, progress_total: 100 }] }); assert.ok(el.innerHTML.includes("progress-bar-striped progress-bar-animated"), "striped and animated"); assert.ok(el.innerHTML.includes("Finishing\u2026") && el.innerHTML.includes('aria-valuenow="100"') && el.innerHTML.includes("width:100%") && !el.innerHTML.includes("99%"), "full width, no number"); advance(3000); answer("/r", { ...empty, jobs: [{ ...running, state: "cancelling", progress_done: 99, progress_total: 100 }] }); assert.ok(!el.innerHTML.includes("Finishing") && el.innerHTML.includes("99%"), "only a running job is finishing"); advance(3000); answer("/r", { ...empty, jobs: [running] }); assert.ok(!el.innerHTML.includes("Finishing"), "back to a number"); // the next poll goes out 3 s after the answer advance(3000); answer("/r", { ...empty, jobs: [running] }); // the job finishes between polls: the panel asks for it by id, keeps the flag meanwhile advance(3000); answer("/r", empty); assert.strictEqual(window.backgroundJobRunning, true, "still true while the outcome is fetched"); answer("/j/a", { status: 0 }, false); // a network error: asked again on the next poll advance(3000); answer("/r", empty); answer("/j/a", { ...running, state: "succeeded", result: { left_behind: ["/pool/a.one"], warning: "not " } }); assert.ok(el.innerHTML.includes('
  • /pool/a.one
  • '), "shows the warning from the result"); assert.ok(el.innerHTML.includes('
    not <thawed>
    '), "shows the result's warning escaped"); assert.ok(el.innerHTML.includes('class="alert alert-success') && el.innerHTML.includes('succeeded') && el.innerHTML.includes('
    '), "an enabled cancel button"); click9({ target: { dataset: { cancel: "q" } } }); assert.ok(el9.innerHTML.includes('data-cancel="q" disabled>'), "disabled while the request runs"); answer("/c/q", { status: 400, responseJSON: { detail: "This job cannot be cancelled once it has started" } }, false); assert.ok(el9.innerHTML.includes("This job cannot be cancelled once it has started") && el9.innerHTML.includes('class="alert alert-danger'), "the server's reason shows"); assert.ok(open.some((r) => r.url === "/r" && !r.settled), "the list is asked again at once"); answer("/r", { ...empty, jobs: [{ ...queued, state: "running", cancellable: false }] }); assert.ok(el9.innerHTML.includes("cannot be cancelled") && !el9.innerHTML.includes("data-cancel"), "the reason stays after the refresh"); click9({ target: { dataset: { dismissError: "1" } } }); assert.ok(!el9.innerHTML.includes("cannot be cancelled"), "the reason can be dismissed"); advance(3000); answer("/r", { ...empty, jobs: [queued] }); click9({ target: { dataset: { cancel: "q" } } }); answer("/c/q", { status: 0 }, false); assert.ok(el9.innerHTML.includes("could not be reached"), "a network error shows"); // track(id): the panel follows a job the page just started and asks for the list at once open = []; timers = []; window.backgroundJobRunning = false; const el10 = { innerHTML: "", addEventListener() {} }; const panel = jobPanel(el10, urls, "tok", undefined, STATES, TEXT); advance(0); answer("/r", empty); assert.strictEqual(window.backgroundJobRunning, false); panel.track("t"); assert.strictEqual(window.backgroundJobRunning, true, "a tracked job holds the page's reload"); assert.ok(open.some((r) => r.url === "/r" && !r.settled), "the list is asked for at once"); answer("/r", empty); answer("/j/t", { ...running, id: "t", label: "Upload", state: "cancelled" }); assert.ok(el10.innerHTML.includes("Upload") && el10.innerHTML.includes('cancelled'), "the tracked job's outcome shows"); // a list answer older than the job, then its detail still active: the panel keeps following it open = []; timers = []; const el11 = { innerHTML: "", addEventListener() {} }; const panel11 = jobPanel(el11, urls, "tok", undefined, STATES, TEXT); advance(0); answer("/r", empty); panel11.track("u"); answer("/r", empty); answer("/j/u", { ...running, id: "u", label: "Upload" }); assert.ok(!open.some((r) => !r.settled && r.url === "/j/u"), "an active detail is not asked for again at once"); assert.strictEqual(window.backgroundJobRunning, true, "still followed"); advance(3000); answer("/r", empty); assert.strictEqual(open.filter((r) => !r.settled && r.url === "/j/u").length, 1, "asked again once per poll"); window.reloaded = false; answer("/j/u", { ...running, id: "u", label: "Upload", state: "succeeded" }); assert.ok(window.reloaded, "a job that succeeds without a warning reloads the page: it shows the job's effect"); assert.ok(!el11.innerHTML.includes('succeeded'), "not shown as well"); // a job that made a VM opens its page; a warning keeps the outcome, with Open; only a path of this site open = []; timers = []; window.reloaded = false; window.opened = null; const elv = { innerHTML: "", addEventListener() {} }; jobPanel(elv, urls, "tok", ["v"], STATES, TEXT); advance(0); answer("/r", empty); answer("/j/v", { ...running, id: "v", label: "Clone VM", state: "succeeded", result: { open: "/instances/9/" } }); assert.ok(window.opened === "/instances/9/" && !window.reloaded, "opens the clone's page"); for (const [result, shown] of [[{ open: "/instances/9/", warning: "not started" }, true], [{ open: "//evil.example.com/" }, false], [{ open: "javascript:alert(1)" }, false]]) { open = []; timers = []; window.reloaded = false; window.opened = null; const elw = { innerHTML: "", addEventListener() {} }; jobPanel(elw, urls, "tok", ["w"], STATES, TEXT); advance(0); answer("/r", empty); answer("/j/w", { ...running, id: "w", state: "succeeded", result }); assert.strictEqual(window.opened, null, JSON.stringify(result)); assert.strictEqual(elw.innerHTML.includes('href="/instances/9/">Open'), shown, JSON.stringify(result)); } // a dialog open or a field in use: the outcome waits in the panel, with Refresh for (const [label, doc] of [["dialog", { querySelector: (s) => s === ".modal.show" ? {} : null }], ["field", { activeElement: { matches: (s) => s === "input, select, textarea" } }]]) { open = []; timers = []; window.reloaded = false; Object.assign(document, { activeElement: null, querySelector: () => null }, doc); const elm = { innerHTML: "", addEventListener() {} }; jobPanel(elm, urls, "tok", ["m"], STATES, TEXT); advance(0); answer("/r", empty); answer("/j/m", { ...running, id: "m", label: "Clone", state: "succeeded" }); assert.ok(!window.reloaded && elm.innerHTML.includes('succeeded') && elm.innerHTML.includes("data-refresh"), "with a " + label + ": the outcome with Refresh, no reload"); } Object.assign(document, { activeElement: null, querySelector: () => null }); // an XML edit not saved: no reload, as for the page's status poll open = []; timers = []; window.reloaded = false; window.xmlEditUnsaved = () => true; const elx = { innerHTML: "", addEventListener() {} }; jobPanel(elx, urls, "tok", ["x"], STATES, TEXT); advance(0); answer("/r", empty); answer("/j/x", { ...running, id: "x", state: "succeeded" }); assert.ok(!window.reloaded && elx.innerHTML.includes("data-refresh"), "an unsaved XML edit keeps the page"); delete window.xmlEditUnsaved; // another job's outcome shown or still awaited: a quiet success does not wipe it open = []; timers = []; window.reloaded = false; const elo = { innerHTML: "", addEventListener() {} }; jobPanel(elo, urls, "tok", ["f", "s", "w"], STATES, TEXT); advance(0); answer("/r", empty); answer("/j/f", { ...running, id: "f", label: "Migrate", state: "failed", error: "no route" }); answer("/j/s", { ...running, id: "s", label: "Clone", state: "succeeded" }); assert.ok(!window.reloaded && elo.innerHTML.includes("no route") && elo.innerHTML.includes("Clone"), "the failure stays, the success shows beside it"); answer("/j/w", { ...running, id: "w", label: "Snapshot", state: "succeeded" }); assert.ok(!window.reloaded, "still not while an outcome is shown"); open = []; timers = []; window.reloaded = false; const elp = { innerHTML: "", addEventListener() {} }; jobPanel(elp, urls, "tok", ["s2", "f2"], STATES, TEXT); advance(0); answer("/r", empty); answer("/j/s2", { ...running, id: "s2", state: "succeeded" }); assert.ok(!window.reloaded, "not while another outcome is awaited"); answer("/j/f2", { ...running, id: "f2", state: "failed", error: "refused" }); assert.ok(elp.innerHTML.includes("refused"), "which then shows"); // another job still running: its outcome would be lost to a reload open = []; timers = []; window.reloaded = false; const elr = { innerHTML: "", addEventListener() {} }; jobPanel(elr, urls, "tok", ["s3"], STATES, TEXT); advance(0); answer("/r", { ...empty, jobs: [{ ...running, id: "r3" }] }); answer("/j/s3", { ...running, id: "s3", state: "succeeded" }); assert.ok(!window.reloaded, "not while another job runs"); Date.now = realNow; console.log("ok"); // the page's language: the badge, the buttons and the panel's texts come from its settings { const elt = { innerHTML: "", addEventListener() {} }; const states = { ...STATES, running: "çalışıyor" }; jobPanel(elt, urls, "tok", undefined, states, { ...TEXT, cancel: "İptal" }); advance(0); answer("/r", { ...empty, jobs: [{ ...running, id: "t", cancellable: true }] }); assert.ok(elt.innerHTML.includes('çalışıyor') && elt.innerHTML.includes(">İptal"), "labels and texts of the page's language"); }