mirror of
https://github.com/nicolargo/glances.git
synced 2026-09-15 23:02:52 -04:00
204 lines
7.8 KiB
JavaScript
204 lines
7.8 KiB
JavaScript
import { test, beforeEach } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
getJson,
|
|
validate,
|
|
resolveConfig,
|
|
resolveArgs,
|
|
resolvePluginNames,
|
|
fetchAll,
|
|
DEFAULT_REFRESH_SECONDS,
|
|
DEFAULT_THEME,
|
|
} from "../../glances/outputs/static/js/v5/api.js";
|
|
|
|
function stubFetch(routes) {
|
|
globalThis.fetch = async (path) => {
|
|
const r = routes[path];
|
|
if (!r) throw new TypeError("network error");
|
|
return {
|
|
ok: r.status === undefined || (r.status >= 200 && r.status < 300),
|
|
status: r.status ?? 200,
|
|
json: async () => r.body,
|
|
};
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
delete globalThis.fetch;
|
|
});
|
|
|
|
test("getJson returns the parsed body on 200", async () => {
|
|
stubFetch({ "api/5/mem": { body: { percent: 1 } } });
|
|
assert.deepEqual(await getJson("api/5/mem"), { percent: 1 });
|
|
});
|
|
|
|
test("getJson throws with the path and status on a non-OK response", async () => {
|
|
stubFetch({ "api/5/mem": { status: 500, body: { detail: "boom" } } });
|
|
await assert.rejects(() => getJson("api/5/mem"), /api\/5\/mem: HTTP 500/);
|
|
});
|
|
|
|
test("validate rejects a 200 carrying the wrong shape", () => {
|
|
// The failure that started this: fetch does not reject on 4xx/5xx, and a
|
|
// FastAPI error body is valid JSON. Without a shape check it renders as
|
|
// data -- G9-1's page reported a plausible, wrong plugin count.
|
|
const spec = { shape: "scalar", required: ["percent", "total"] };
|
|
assert.throws(() => validate({ detail: "boom" }, spec), /missing|shape/i);
|
|
});
|
|
|
|
test("validate accepts a well-formed scalar payload", () => {
|
|
const spec = { shape: "scalar", required: ["percent"] };
|
|
const payload = { percent: 52.5, _levels: {} };
|
|
assert.equal(validate(payload, spec), payload);
|
|
});
|
|
|
|
test("validate requires a data array for a collection", () => {
|
|
const spec = { shape: "collection", required: ["interface_name"] };
|
|
assert.throws(() => validate({ detail: "boom" }, spec), /shape/i);
|
|
assert.throws(() => validate({ data: {} }, spec), /shape/i);
|
|
const ok = { data: [{ interface_name: "eth0" }] };
|
|
assert.equal(validate(ok, spec), ok);
|
|
});
|
|
|
|
test("validate accepts an empty collection", () => {
|
|
// A plugin with nothing to show (no container running) is not an error.
|
|
const spec = { shape: "collection", required: ["interface_name"] };
|
|
const ok = { data: [] };
|
|
assert.equal(validate(ok, spec), ok);
|
|
});
|
|
|
|
test("validate passes a cycle-0 null through untouched", () => {
|
|
// /api/5/<plugin> answers `200 null` before the first cycle (G9-1
|
|
// contract). That is a loading state, not a shape error.
|
|
const spec = { shape: "scalar", required: ["percent"] };
|
|
assert.equal(validate(null, spec), null);
|
|
});
|
|
|
|
|
|
|
|
|
|
test("resolveConfig reads [global] refresh and [outputs] theme from one fetch", async () => {
|
|
stubFetch({ "api/5/config": { body: { global: { refresh: 5 }, outputs: { theme: "light" } } } });
|
|
assert.deepEqual(await resolveConfig(), { refreshSeconds: 5, theme: "light" });
|
|
});
|
|
|
|
test("resolveConfig falls back to defaults when config is unreachable", async () => {
|
|
stubFetch({});
|
|
assert.deepEqual(await resolveConfig(), {
|
|
refreshSeconds: DEFAULT_REFRESH_SECONDS,
|
|
theme: DEFAULT_THEME,
|
|
});
|
|
});
|
|
|
|
test("resolveConfig falls back to the default theme when config has no theme key", async () => {
|
|
stubFetch({ "api/5/config": { body: { global: { refresh: 5 } } } });
|
|
assert.equal((await resolveConfig()).theme, DEFAULT_THEME);
|
|
});
|
|
|
|
test("resolveConfig falls back to the default theme on an unrecognised value", async () => {
|
|
// A config typo must not be written through to data-theme -- fall back
|
|
// rather than ship an unstyled page.
|
|
stubFetch({ "api/5/config": { body: { outputs: { theme: "solarized" } } } });
|
|
assert.equal((await resolveConfig()).theme, DEFAULT_THEME);
|
|
});
|
|
|
|
test("fetchAll issues exactly one request", async () => {
|
|
let calls = 0;
|
|
globalThis.fetch = async () => {
|
|
calls += 1;
|
|
return { ok: true, status: 200, json: async () => ({ mem: { percent: 1 }, network: { data: [] } }) };
|
|
};
|
|
await fetchAll([
|
|
{ name: "mem", spec: { shape: "scalar", required: ["percent"] } },
|
|
{ name: "network", spec: { shape: "collection", required: [] } },
|
|
]);
|
|
assert.equal(calls, 1);
|
|
});
|
|
|
|
test("a plugin absent from /all is loading, not an error", async () => {
|
|
// /api/5/all omits a plugin that has not published yet (scheduler cycle 0).
|
|
// That is the same state the per-plugin `200 null` used to signal.
|
|
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ mem: { percent: 1 } }) });
|
|
const { results, errors } = await fetchAll([
|
|
{ name: "mem", spec: { shape: "scalar", required: ["percent"] } },
|
|
{ name: "network", spec: { shape: "collection", required: [] } },
|
|
]);
|
|
assert.deepEqual(results.mem, { percent: 1 });
|
|
assert.equal(results.network, null);
|
|
assert.equal(errors.network, undefined);
|
|
});
|
|
|
|
test("a wrong-shaped slice errors only its own plugin", async () => {
|
|
globalThis.fetch = async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ mem: { percent: 1 }, network: { detail: "boom" } }),
|
|
});
|
|
const { results, errors } = await fetchAll([
|
|
{ name: "mem", spec: { shape: "scalar", required: ["percent"] } },
|
|
{ name: "network", spec: { shape: "collection", required: [] } },
|
|
]);
|
|
assert.deepEqual(results.mem, { percent: 1 });
|
|
assert.match(errors.network, /shape/i);
|
|
assert.equal(errors.mem, undefined);
|
|
});
|
|
|
|
test("an unreachable /all errors every plugin", async () => {
|
|
// The honest cost of one request: one failure blanks the page. Pinned so
|
|
// the trade is visible rather than discovered.
|
|
globalThis.fetch = async () => ({ ok: false, status: 503, json: async () => ({}) });
|
|
const { results, errors } = await fetchAll([
|
|
{ name: "mem", spec: { shape: "scalar", required: ["percent"] } },
|
|
]);
|
|
assert.equal(results.mem, undefined);
|
|
assert.match(errors.mem, /HTTP 503/);
|
|
});
|
|
|
|
test("resolveArgs returns the args object and fetches once", async () => {
|
|
let calls = 0;
|
|
globalThis.fetch = async () => {
|
|
calls += 1;
|
|
return { ok: true, status: 200, json: async () => ({ meangpu: true, fahrenheit: false }) };
|
|
};
|
|
assert.deepEqual(await resolveArgs(), { meangpu: true, fahrenheit: false });
|
|
// CLI arguments cannot change while the server runs, so a second call
|
|
// must not hit the network.
|
|
await resolveArgs();
|
|
assert.equal(calls, 1);
|
|
});
|
|
|
|
test("a non-object /all envelope errors every plugin instead of rejecting", async () => {
|
|
// getJson guarantees only "HTTP 2xx and parseable JSON". `null`, a number,
|
|
// a string and an array are all parseable JSON -- and `"mem" in null`
|
|
// throws a TypeError that escapes fetchAll, breaking its documented
|
|
// {results, errors} contract. AppShell's tick() has a `finally` but no
|
|
// `catch`, so that rejection propagates out of mounted() and the poll
|
|
// interval is never scheduled: the page freezes on "loading…" forever.
|
|
const specs = [
|
|
{ name: "mem", spec: { shape: "scalar", required: ["percent"] } },
|
|
{ name: "network", spec: { shape: "collection", required: [] } },
|
|
];
|
|
for (const body of [null, [{ mem: 1 }], "boom", 42]) {
|
|
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => body });
|
|
const { results, errors } = await fetchAll(specs);
|
|
assert.deepEqual(results, {}, `body ${JSON.stringify(body)} should yield no results`);
|
|
assert.match(errors.mem, /shape/i);
|
|
assert.match(errors.network, /shape/i);
|
|
}
|
|
});
|
|
|
|
test("resolvePluginNames returns the server's plugin list", async () => {
|
|
stubFetch({ "api/5/pluginslist": { body: ["cpu", "mem"] } });
|
|
assert.deepEqual(await resolvePluginNames(), ["cpu", "mem"]);
|
|
});
|
|
|
|
test("resolvePluginNames returns null when the list cannot be read", async () => {
|
|
stubFetch({ "api/5/pluginslist": { status: 500, body: { detail: "boom" } } });
|
|
assert.equal(await resolvePluginNames(), null);
|
|
// A 200 carrying the wrong shape is not a list either.
|
|
stubFetch({ "api/5/pluginslist": { body: { detail: "nope" } } });
|
|
assert.equal(await resolvePluginNames(), null);
|
|
// Network failure: stubFetch throws for an unknown route.
|
|
stubFetch({});
|
|
assert.equal(await resolvePluginNames(), null);
|
|
});
|