// 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() {} }; global.window = { backgroundJobRunning: false, location: { reload() { window.reloaded = true; } } }; // 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 }; 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"); 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"); 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"); 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"); answer("/j/u", { ...running, id: "u", label: "Upload", state: "succeeded" }); assert.ok(el11.innerHTML.includes("Upload") && el11.innerHTML.includes('succeeded'), "the outcome shows once the job ends"); Date.now = realNow; console.log("ok");