145 lines
5.1 KiB
JavaScript
145 lines
5.1 KiB
JavaScript
|
|
"use strict";
|
||
|
|
|
||
|
|
const test = require("node:test");
|
||
|
|
const assert = require("node:assert/strict");
|
||
|
|
|
||
|
|
const { lowerSurfaceToHtml, neutraliseCommandControls } = require("../src/static-lowering.cjs");
|
||
|
|
|
||
|
|
function surfaceWith(children) {
|
||
|
|
return {
|
||
|
|
providerId: "erycina",
|
||
|
|
title: "Erycina Operator",
|
||
|
|
surface: {
|
||
|
|
schema: "gamecult.eve.surface.v1",
|
||
|
|
id: "erycina-operator-surface",
|
||
|
|
title: "Erycina Operator",
|
||
|
|
root: { id: "root", kind: "dashboard", props: { title: "Erycina" }, children },
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const panel = {
|
||
|
|
id: "readiness",
|
||
|
|
kind: "panel",
|
||
|
|
props: { title: "Readiness" },
|
||
|
|
// "metric" is the kind Erycina real surface uses for a labelled value.
|
||
|
|
// The renderer silently drops kinds it does not know, so a fixture that
|
||
|
|
// invents one tests nothing.
|
||
|
|
children: [{ id: "manifest", kind: "metric", props: { label: "Manifest", value: "present" } }],
|
||
|
|
};
|
||
|
|
|
||
|
|
const commandButton = {
|
||
|
|
id: "run-ingest",
|
||
|
|
kind: "control.button",
|
||
|
|
props: { label: "Run ingest", command: "erycina.ingest.run", target: "cultmesh://erycina/commands/ingest" },
|
||
|
|
};
|
||
|
|
|
||
|
|
test("a surface lowers to a standalone document", async () => {
|
||
|
|
const { html, title } = await lowerSurfaceToHtml(surfaceWith([panel]));
|
||
|
|
|
||
|
|
assert.equal(title, "Erycina Operator");
|
||
|
|
assert.match(html, /^<!doctype html>/);
|
||
|
|
assert.match(html, /<meta charset="utf-8">/);
|
||
|
|
assert.match(html, /<\/html>\s*$/);
|
||
|
|
// Content the graph declared actually reaches the page.
|
||
|
|
assert.match(html, /Readiness/);
|
||
|
|
assert.match(html, /Manifest/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("styles are inlined, so the page needs no second request", async () => {
|
||
|
|
const { html } = await lowerSurfaceToHtml(surfaceWith([panel]));
|
||
|
|
|
||
|
|
assert.match(html, /<style>/);
|
||
|
|
assert.doesNotMatch(html, /<link[^>]+stylesheet/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("command controls are rendered inert", async () => {
|
||
|
|
// The plan's negative check: a static page has no back-channel, so a control
|
||
|
|
// that looks operable is lying about one.
|
||
|
|
const { html, inertCount } = await lowerSurfaceToHtml(surfaceWith([panel, commandButton]));
|
||
|
|
|
||
|
|
assert.equal(inertCount, 1);
|
||
|
|
assert.match(html, /data-eve-static-inert="true"/);
|
||
|
|
assert.match(html, /disabled/);
|
||
|
|
assert.match(html, /eve-static-inert/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("an inert control keeps its label rather than vanishing", async () => {
|
||
|
|
// Removing it would misrepresent the surface. The operator should see that
|
||
|
|
// the capability exists and is simply not reachable from a snapshot.
|
||
|
|
const { html } = await lowerSurfaceToHtml(surfaceWith([commandButton]));
|
||
|
|
|
||
|
|
assert.match(html, /Run ingest/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("no command target survives into the static page as an operable route", async () => {
|
||
|
|
const { html } = await lowerSurfaceToHtml(surfaceWith([commandButton]));
|
||
|
|
|
||
|
|
// The button may name its command, but nothing may present a live endpoint
|
||
|
|
// the page could post to.
|
||
|
|
assert.doesNotMatch(html, /fetch\(/);
|
||
|
|
assert.doesNotMatch(html, /hermodr\/commands/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("a surface with no controls marks nothing inert", async () => {
|
||
|
|
const { inertCount } = await lowerSurfaceToHtml(surfaceWith([panel]));
|
||
|
|
assert.equal(inertCount, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("lowering the same surface twice gives the same bytes", async () => {
|
||
|
|
// Determinism is what makes the output diffable, and what would let a future
|
||
|
|
// conformance check compare static output against the live renderer.
|
||
|
|
const first = await lowerSurfaceToHtml(surfaceWith([panel, commandButton]));
|
||
|
|
const second = await lowerSurfaceToHtml(surfaceWith([panel, commandButton]));
|
||
|
|
|
||
|
|
assert.equal(first.html, second.html);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("the title falls back through the surface before the provider id", async () => {
|
||
|
|
const untitled = surfaceWith([panel]);
|
||
|
|
delete untitled.title;
|
||
|
|
const { title } = await lowerSurfaceToHtml(untitled);
|
||
|
|
assert.equal(title, "Erycina Operator");
|
||
|
|
|
||
|
|
const bare = surfaceWith([panel]);
|
||
|
|
delete bare.title;
|
||
|
|
delete bare.surface.title;
|
||
|
|
assert.equal((await lowerSurfaceToHtml(bare)).title, "erycina");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("an explicit title wins", async () => {
|
||
|
|
const { html, title } = await lowerSurfaceToHtml(surfaceWith([panel]), { title: "Velvet" });
|
||
|
|
assert.equal(title, "Velvet");
|
||
|
|
assert.match(html, /<title>Velvet<\/title>/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("a title carrying markup is escaped rather than injected", async () => {
|
||
|
|
const { html } = await lowerSurfaceToHtml(surfaceWith([panel]), { title: "<script>alert(1)</script>" });
|
||
|
|
|
||
|
|
assert.doesNotMatch(html, /<title><script>/);
|
||
|
|
assert.match(html, /<script>/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("lowering refuses a document that is not a surface", async () => {
|
||
|
|
await assert.rejects(() => lowerSurfaceToHtml(null), /requires a surface document/);
|
||
|
|
await assert.rejects(() => lowerSurfaceToHtml("nope"), /requires a surface document/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("neutralise marks every control it is given", () => {
|
||
|
|
const calls = [];
|
||
|
|
const fake = {
|
||
|
|
querySelectorAll: () => [makeControl(calls), makeControl(calls)],
|
||
|
|
};
|
||
|
|
const count = neutraliseCommandControls(fake, "because");
|
||
|
|
|
||
|
|
assert.equal(count, 2);
|
||
|
|
assert.ok(calls.filter((c) => c === "disabled").length === 2);
|
||
|
|
assert.ok(calls.filter((c) => c === "eve-static-inert").length === 2);
|
||
|
|
});
|
||
|
|
|
||
|
|
function makeControl(calls) {
|
||
|
|
return {
|
||
|
|
setAttribute(name) { calls.push(name); },
|
||
|
|
classList: { add(name) { calls.push(name); } },
|
||
|
|
};
|
||
|
|
}
|