@crewhaus/hangar-ui 0.5.7 → 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -8
- package/assets/hangar.css +175 -0
- package/assets/js/api.js +2 -0
- package/assets/js/app.js +7 -1
- package/assets/js/dom.js +30 -0
- package/assets/js/router.js +9 -0
- package/assets/js/routes.js +66 -2
- package/assets/js/util.js +1 -0
- package/assets/js/views/advisor.js +631 -0
- package/assets/js/views/library.js +139 -11
- package/dist/embed-suite.d.ts +1 -0
- package/dist/embed-suite.js +289 -0
- package/dist/index.js +2 -0
- package/package.json +2 -2
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { api } from "../api.js";
|
|
9
|
-
import { asOf, clear, copyBtn, dot, el, emptyState, skeleton, toast } from "../dom.js";
|
|
9
|
+
import { asOf, clear, copyBtn, dot, el, emptyState, skeleton, toast, withTip } from "../dom.js";
|
|
10
10
|
import { hrefHarness } from "../router.js";
|
|
11
11
|
import { shapeAccent, shapeLabel } from "../shapes.js";
|
|
12
12
|
import { procStatePill } from "../supervision.js";
|
|
@@ -27,7 +27,8 @@ import { renderOnboarding, shouldOnboard } from "./onboarding.js";
|
|
|
27
27
|
const state = {
|
|
28
28
|
sortKey: "name",
|
|
29
29
|
sortDir: "asc",
|
|
30
|
-
|
|
30
|
+
// {kind:"all"} | {kind:"group",name} | {kind:"smart",id} | {kind:"hidden"}
|
|
31
|
+
filter: { kind: "all" },
|
|
31
32
|
query: "",
|
|
32
33
|
};
|
|
33
34
|
|
|
@@ -122,13 +123,19 @@ function normalizeGroups(payload, rows) {
|
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
function applyFilter(rows, nowMs) {
|
|
125
|
-
|
|
126
|
+
// Hidden rows are curation, not data loss: they stay registered but stay
|
|
127
|
+
// OUT of every view except the explicit Hidden bucket — "don't show all
|
|
128
|
+
// harnesses unless added".
|
|
129
|
+
let out =
|
|
130
|
+
state.filter.kind === "hidden"
|
|
131
|
+
? rows.filter((r) => r.hidden === true)
|
|
132
|
+
: rows.filter((r) => r.hidden !== true);
|
|
126
133
|
if (state.filter.kind === "group") {
|
|
127
134
|
const name = state.filter.name;
|
|
128
135
|
out = out.filter((r) => r.groups.includes(name));
|
|
129
136
|
} else if (state.filter.kind === "smart") {
|
|
130
137
|
const smart = deriveSmartGroups(rows, nowMs).find((g) => g.id === state.filter.id);
|
|
131
|
-
out = smart ? smart.rows : [];
|
|
138
|
+
out = smart ? smart.rows.filter((r) => r.hidden !== true) : [];
|
|
132
139
|
}
|
|
133
140
|
if (state.query !== "") {
|
|
134
141
|
const q = state.query.toLowerCase();
|
|
@@ -200,16 +207,28 @@ function renderRail(rows, groups, nowMs, redraw, reload) {
|
|
|
200
207
|
);
|
|
201
208
|
return btn;
|
|
202
209
|
};
|
|
210
|
+
const hiddenCount = rows.filter((r) => r.hidden === true).length;
|
|
203
211
|
rail.appendChild(
|
|
204
|
-
item("All harnesses", rows.length, state.filter.kind === "all", null, () => {
|
|
212
|
+
item("All harnesses", rows.length - hiddenCount, state.filter.kind === "all", null, () => {
|
|
205
213
|
state.filter = { kind: "all" };
|
|
206
214
|
redraw();
|
|
207
215
|
}),
|
|
208
216
|
);
|
|
217
|
+
// The curated-out entries get their own bucket rather than vanishing:
|
|
218
|
+
// hiding is reversible, and a bucket with a count is how you find them.
|
|
219
|
+
if (hiddenCount > 0) {
|
|
220
|
+
rail.appendChild(
|
|
221
|
+
item("Hidden", hiddenCount, state.filter.kind === "hidden", null, () => {
|
|
222
|
+
state.filter = { kind: "hidden" };
|
|
223
|
+
redraw();
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
209
227
|
|
|
228
|
+
const shown = rows.filter((r) => r.hidden !== true);
|
|
210
229
|
if (groups.length > 0) rail.appendChild(el("div", { class: "rail-head", text: "Groups" }));
|
|
211
230
|
for (const g of groups) {
|
|
212
|
-
const count =
|
|
231
|
+
const count = shown.filter((r) => r.groups.includes(g.name)).length;
|
|
213
232
|
const active = state.filter.kind === "group" && state.filter.name === g.name;
|
|
214
233
|
rail.appendChild(
|
|
215
234
|
item(g.name, count, active, g.color || null, () => {
|
|
@@ -229,7 +248,7 @@ function renderRail(rows, groups, nowMs, redraw, reload) {
|
|
|
229
248
|
rail.appendChild(newBtn);
|
|
230
249
|
|
|
231
250
|
rail.appendChild(el("div", { class: "rail-head", text: "Smart groups" }));
|
|
232
|
-
for (const g of deriveSmartGroups(
|
|
251
|
+
for (const g of deriveSmartGroups(shown, nowMs)) {
|
|
233
252
|
const active = state.filter.kind === "smart" && state.filter.id === g.id;
|
|
234
253
|
rail.appendChild(
|
|
235
254
|
item(g.label, g.rows.length, active, null, () => {
|
|
@@ -307,10 +326,104 @@ function renderToolbar(reload) {
|
|
|
307
326
|
dirInput.focus();
|
|
308
327
|
};
|
|
309
328
|
|
|
310
|
-
const
|
|
329
|
+
const findBtn = withTip(
|
|
330
|
+
el("button", { class: "btn", type: "button", text: "Find harnesses…" }),
|
|
331
|
+
"Discovery WITHOUT registration: walks the scan roots and lists the harnesses that are not yet added, each one an Add away — the Library only shows what you add.",
|
|
332
|
+
);
|
|
333
|
+
const scanBtn = withTip(
|
|
334
|
+
el("button", { class: "btn btn-ghost", type: "button", text: "Scan (add all)" }),
|
|
335
|
+
"The bulk fallback: registers EVERY harness found under the scan roots. Prefer Find harnesses to pick which ones join the Library.",
|
|
336
|
+
);
|
|
311
337
|
const rootBtn = el("button", { class: "btn", type: "button", text: "Add scan root…" });
|
|
312
|
-
const addBtn =
|
|
313
|
-
|
|
338
|
+
const addBtn = withTip(
|
|
339
|
+
el("button", { class: "btn btn-primary", type: "button", text: "Add harness…" }),
|
|
340
|
+
"Register one harness by its directory — the default gesture.",
|
|
341
|
+
);
|
|
342
|
+
const bar = el("div", { class: "toolbar" }, [search, addBtn, findBtn, rootBtn, scanBtn]);
|
|
343
|
+
|
|
344
|
+
// The find panel: candidates from GET /api/registry/discover, each with an
|
|
345
|
+
// Add. Only one open at a time, sharing the openedForm slot with the two
|
|
346
|
+
// dir forms.
|
|
347
|
+
findBtn.addEventListener("click", async () => {
|
|
348
|
+
if (openedForm !== null) openedForm.remove();
|
|
349
|
+
findBtn.disabled = true;
|
|
350
|
+
let found = null;
|
|
351
|
+
try {
|
|
352
|
+
found = await api.discover();
|
|
353
|
+
} catch (err) {
|
|
354
|
+
toast(`Find failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
355
|
+
} finally {
|
|
356
|
+
findBtn.disabled = false;
|
|
357
|
+
}
|
|
358
|
+
if (found === null) return;
|
|
359
|
+
const panel = el("div", { class: "card" });
|
|
360
|
+
const candidates = Array.isArray(found.candidates) ? found.candidates : [];
|
|
361
|
+
if (found.roots === 0) {
|
|
362
|
+
toast("No scan roots configured — add one and discovery can search this machine", "info");
|
|
363
|
+
panel.remove();
|
|
364
|
+
rootBtn.click();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
panel.appendChild(
|
|
368
|
+
el("div", { class: "rollup" }, [
|
|
369
|
+
el("strong", {
|
|
370
|
+
text: `Found ${candidates.length} unregistered harness${candidates.length === 1 ? "" : "es"}`,
|
|
371
|
+
}),
|
|
372
|
+
el("span", {
|
|
373
|
+
class: "muted",
|
|
374
|
+
text: ` under ${found.roots} scan root${found.roots === 1 ? "" : "s"} (${Number(found.alreadyRegistered ?? 0)} already added)`,
|
|
375
|
+
}),
|
|
376
|
+
el("button", {
|
|
377
|
+
class: "btn btn-ghost",
|
|
378
|
+
type: "button",
|
|
379
|
+
text: "Close",
|
|
380
|
+
onClick: () => panel.remove(),
|
|
381
|
+
}),
|
|
382
|
+
]),
|
|
383
|
+
);
|
|
384
|
+
if (candidates.length === 0) {
|
|
385
|
+
panel.appendChild(emptyState(String(found.note ?? "Nothing new under the scan roots")));
|
|
386
|
+
}
|
|
387
|
+
for (const candidate of candidates) {
|
|
388
|
+
const addOne = el("button", { class: "btn btn-primary", type: "button", text: "Add" });
|
|
389
|
+
addOne.addEventListener("click", () => {
|
|
390
|
+
addOne.disabled = true;
|
|
391
|
+
tryWrite("Add harness", () => api.addHarness(candidate.dir), reload);
|
|
392
|
+
});
|
|
393
|
+
panel.appendChild(
|
|
394
|
+
el("div", { class: "find-row" }, [
|
|
395
|
+
el("strong", { text: String(candidate.specName ?? "") }),
|
|
396
|
+
el("span", { class: "chip", text: String(candidate.target ?? "unknown") }),
|
|
397
|
+
el("span", {
|
|
398
|
+
class: "mono muted",
|
|
399
|
+
title: String(candidate.dir ?? ""),
|
|
400
|
+
text: String(candidate.dir ?? ""),
|
|
401
|
+
}),
|
|
402
|
+
addOne,
|
|
403
|
+
]),
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
if (candidates.length > 1) {
|
|
407
|
+
const addAll = el("button", {
|
|
408
|
+
class: "btn",
|
|
409
|
+
type: "button",
|
|
410
|
+
text: `Add all ${candidates.length}`,
|
|
411
|
+
});
|
|
412
|
+
addAll.addEventListener("click", () => {
|
|
413
|
+
addAll.disabled = true;
|
|
414
|
+
tryWrite(
|
|
415
|
+
"Add all",
|
|
416
|
+
async () => {
|
|
417
|
+
for (const candidate of candidates) await api.addHarness(candidate.dir);
|
|
418
|
+
},
|
|
419
|
+
reload,
|
|
420
|
+
);
|
|
421
|
+
});
|
|
422
|
+
panel.appendChild(el("div", { class: "missing-actions" }, [addAll]));
|
|
423
|
+
}
|
|
424
|
+
bar.after(panel);
|
|
425
|
+
openedForm = panel;
|
|
426
|
+
});
|
|
314
427
|
|
|
315
428
|
scanBtn.addEventListener("click", async () => {
|
|
316
429
|
scanBtn.disabled = true;
|
|
@@ -567,9 +680,21 @@ function editorRow(row, groups, reload) {
|
|
|
567
680
|
cb.checked = row.groups.includes(g.name);
|
|
568
681
|
return { name: g.name, cb };
|
|
569
682
|
});
|
|
683
|
+
const hiddenCb = el("input", { type: "checkbox", "aria-label": "hide from the library" });
|
|
684
|
+
hiddenCb.checked = row.hidden === true;
|
|
570
685
|
const form = el("form", { class: "row-editor" }, [
|
|
571
686
|
el("label", { class: "field" }, [el("span", { text: "Tags" }), tags]),
|
|
572
687
|
el("label", { class: "field" }, [el("span", { text: "Notes" }), notes]),
|
|
688
|
+
el("div", { class: "field" }, [
|
|
689
|
+
el("span", { text: "Visibility" }),
|
|
690
|
+
withTip(
|
|
691
|
+
el("label", { class: "check" }, [
|
|
692
|
+
hiddenCb,
|
|
693
|
+
el("span", { text: "Hidden — keep registered, out of the default view" }),
|
|
694
|
+
]),
|
|
695
|
+
"Hiding is curation, not removal: state, groups and history survive, and the Hidden bucket in the rail lists it until you bring it back.",
|
|
696
|
+
),
|
|
697
|
+
]),
|
|
573
698
|
el("div", { class: "field" }, [
|
|
574
699
|
el("span", { text: groups.length > 0 ? "Groups" : "Groups (none defined yet)" }),
|
|
575
700
|
el(
|
|
@@ -589,13 +714,16 @@ function editorRow(row, groups, reload) {
|
|
|
589
714
|
.map((t) => t.trim())
|
|
590
715
|
.filter((t) => t !== "");
|
|
591
716
|
const groupList = checks.filter((c) => c.cb.checked).map((c) => c.name);
|
|
592
|
-
// The server's registry writes are per-field PUTs; save all
|
|
717
|
+
// The server's registry writes are per-field PUTs; save all four.
|
|
593
718
|
tryWrite(
|
|
594
719
|
"Save",
|
|
595
720
|
async () => {
|
|
596
721
|
await api.setTags(row.id, tagList);
|
|
597
722
|
await api.setNotes(row.id, notes.value);
|
|
598
723
|
await api.setGroups(row.id, groupList);
|
|
724
|
+
if (hiddenCb.checked !== (row.hidden === true)) {
|
|
725
|
+
await api.setHidden(row.id, hiddenCb.checked);
|
|
726
|
+
}
|
|
599
727
|
},
|
|
600
728
|
reload,
|
|
601
729
|
);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The embed-map completeness + asset-hygiene suite.
|
|
3
|
+
*
|
|
4
|
+
* DELIBERATELY NOT NAMED `*.test.ts`: this suite must run in its OWN
|
|
5
|
+
* process (`bun test ./src/embed-suite.ts`, the second half of the package
|
|
6
|
+
* test script). Importing `./index` loads every asset file as TEXT
|
|
7
|
+
* (`with { type: "text" }`), and bun's module registry keys a module by
|
|
8
|
+
* path alone — the import attribute is not part of the key — so in a
|
|
9
|
+
* process that ALSO imports an asset as an ES module (every view test),
|
|
10
|
+
* whichever load happens first poisons the other: the ESM side dies with
|
|
11
|
+
* "Export not found", or this side with "Missing 'default' export",
|
|
12
|
+
* depending on nothing but file order. Two processes, two registries, no
|
|
13
|
+
* collision — and `bun test src` skips this file because the name is off
|
|
14
|
+
* the test glob.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, test } from "bun:test";
|
|
17
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { CONTENT_TYPES, contentTypeFor, hangarAssets } from "./index";
|
|
20
|
+
const ASSETS_DIR = join(import.meta.dir, "..", "assets");
|
|
21
|
+
/** Walk assets/ and return every file's path relative to it (posix slashes). */
|
|
22
|
+
function walkAssets(dir = ASSETS_DIR, prefix = "") {
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const name of readdirSync(dir).sort()) {
|
|
25
|
+
const full = join(dir, name);
|
|
26
|
+
const rel = prefix === "" ? name : `${prefix}/${name}`;
|
|
27
|
+
if (statSync(full).isDirectory())
|
|
28
|
+
out.push(...walkAssets(full, rel));
|
|
29
|
+
else
|
|
30
|
+
out.push(rel);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/** The serve path an asset file is expected to appear under. */
|
|
35
|
+
function servePathFor(rel) {
|
|
36
|
+
return rel === "index.html" ? "/" : `/assets/${rel}`;
|
|
37
|
+
}
|
|
38
|
+
describe("embed map completeness", () => {
|
|
39
|
+
const files = walkAssets();
|
|
40
|
+
test("assets tree is non-trivial", () => {
|
|
41
|
+
expect(files.length).toBeGreaterThanOrEqual(10);
|
|
42
|
+
expect(files).toContain("index.html");
|
|
43
|
+
expect(files).toContain("hangar.css");
|
|
44
|
+
expect(files).toContain("js/app.js");
|
|
45
|
+
});
|
|
46
|
+
test("every M2 screen ships as an embedded module with the js content type", () => {
|
|
47
|
+
const m2 = [
|
|
48
|
+
"/assets/js/supervision.js",
|
|
49
|
+
"/assets/js/views/proc.js",
|
|
50
|
+
"/assets/js/views/runs.js",
|
|
51
|
+
"/assets/js/views/control.js",
|
|
52
|
+
"/assets/js/views/schedulers.js",
|
|
53
|
+
"/assets/js/views/approvals.js",
|
|
54
|
+
"/assets/js/views/review.js",
|
|
55
|
+
"/assets/js/views/inbox.js",
|
|
56
|
+
"/assets/js/views/activity.js",
|
|
57
|
+
"/assets/js/views/jobs.js",
|
|
58
|
+
"/assets/js/views/deploy.js",
|
|
59
|
+
];
|
|
60
|
+
for (const key of m2) {
|
|
61
|
+
const entry = hangarAssets[key];
|
|
62
|
+
expect(`${key}:${entry !== undefined}`).toBe(`${key}:true`);
|
|
63
|
+
expect(`${key}:${entry?.contentType}`).toBe(`${key}:text/javascript; charset=utf-8`);
|
|
64
|
+
expect(`${key}:${(entry?.body.length ?? 0) > 0}`).toBe(`${key}:true`);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
test("every M3 area ships a view module (one per implementer area)", () => {
|
|
68
|
+
const m3 = [
|
|
69
|
+
"/assets/js/views/spec-edit.js",
|
|
70
|
+
"/assets/js/views/memory-fabric.js",
|
|
71
|
+
"/assets/js/views/evals-lab.js",
|
|
72
|
+
"/assets/js/views/data.js",
|
|
73
|
+
"/assets/js/views/feedback.js",
|
|
74
|
+
"/assets/js/views/creds.js",
|
|
75
|
+
"/assets/js/views/channels.js",
|
|
76
|
+
"/assets/js/views/security.js",
|
|
77
|
+
"/assets/js/views/thredz.js",
|
|
78
|
+
"/assets/js/views/inspect.js",
|
|
79
|
+
"/assets/js/views/runtime.js",
|
|
80
|
+
];
|
|
81
|
+
for (const key of m3) {
|
|
82
|
+
const entry = hangarAssets[key];
|
|
83
|
+
expect(`${key}:${entry !== undefined}`).toBe(`${key}:true`);
|
|
84
|
+
expect(`${key}:${entry?.contentType}`).toBe(`${key}:text/javascript; charset=utf-8`);
|
|
85
|
+
expect(`${key}:${(entry?.body.length ?? 0) > 0}`).toBe(`${key}:true`);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
test("every file under assets/ is embedded under its serve path with exact bytes", () => {
|
|
89
|
+
for (const rel of files) {
|
|
90
|
+
const key = servePathFor(rel);
|
|
91
|
+
const entry = hangarAssets[key];
|
|
92
|
+
expect(entry).toBeDefined();
|
|
93
|
+
const onDisk = readFileSync(join(ASSETS_DIR, rel), "utf8");
|
|
94
|
+
expect(entry?.body).toBe(onDisk);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
test("the map holds nothing that is not on disk (no stale keys)", () => {
|
|
98
|
+
const expected = files.map(servePathFor).sort();
|
|
99
|
+
expect(Object.keys(hangarAssets).sort()).toEqual(expected);
|
|
100
|
+
});
|
|
101
|
+
test("content types are right per extension", () => {
|
|
102
|
+
expect(hangarAssets["/"]?.contentType).toBe("text/html; charset=utf-8");
|
|
103
|
+
expect(hangarAssets["/assets/hangar.css"]?.contentType).toBe("text/css; charset=utf-8");
|
|
104
|
+
for (const [key, entry] of Object.entries(hangarAssets)) {
|
|
105
|
+
if (key.endsWith(".js"))
|
|
106
|
+
expect(entry.contentType).toBe("text/javascript; charset=utf-8");
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
test("every body is non-empty", () => {
|
|
110
|
+
for (const entry of Object.values(hangarAssets)) {
|
|
111
|
+
expect(entry.body.length).toBeGreaterThan(0);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
describe("contentTypeFor", () => {
|
|
116
|
+
test("known extensions", () => {
|
|
117
|
+
expect(contentTypeFor("/assets/js/app.js")).toBe("text/javascript; charset=utf-8");
|
|
118
|
+
expect(contentTypeFor("x.css")).toBe("text/css; charset=utf-8");
|
|
119
|
+
expect(contentTypeFor("index.html")).toBe("text/html; charset=utf-8");
|
|
120
|
+
expect(contentTypeFor("a.svg")).toBe("image/svg+xml");
|
|
121
|
+
});
|
|
122
|
+
test("unknown/none falls back to octet-stream", () => {
|
|
123
|
+
expect(contentTypeFor("noext")).toBe("application/octet-stream");
|
|
124
|
+
expect(contentTypeFor("weird.wasm")).toBe("application/octet-stream");
|
|
125
|
+
});
|
|
126
|
+
test("CONTENT_TYPES covers every embedded extension", () => {
|
|
127
|
+
for (const key of Object.keys(hangarAssets)) {
|
|
128
|
+
if (key === "/")
|
|
129
|
+
continue;
|
|
130
|
+
const dot = key.lastIndexOf(".");
|
|
131
|
+
expect(dot).toBeGreaterThan(0);
|
|
132
|
+
expect(CONTENT_TYPES[key.slice(dot)]).toBeDefined();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
describe("shell wiring", () => {
|
|
137
|
+
test("index.html references the stylesheet and the module entry by serve path", () => {
|
|
138
|
+
const html = hangarAssets["/"]?.body ?? "";
|
|
139
|
+
expect(html).toContain('href="/assets/hangar.css"');
|
|
140
|
+
expect(html).toContain('src="/assets/js/app.js"');
|
|
141
|
+
expect(html).toContain('type="module"');
|
|
142
|
+
});
|
|
143
|
+
test("index.html defaults to the dark theme", () => {
|
|
144
|
+
expect(hangarAssets["/"]?.body ?? "").toContain('data-theme="dark"');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
describe("js module hygiene", () => {
|
|
148
|
+
const jsEntries = Object.entries(hangarAssets).filter(([key]) => key.endsWith(".js"));
|
|
149
|
+
test("every js asset parses as a valid module (zero-build means no compile step to catch this)", () => {
|
|
150
|
+
const transpiler = new Bun.Transpiler({ loader: "js" });
|
|
151
|
+
for (const [key, entry] of jsEntries) {
|
|
152
|
+
let failed = "";
|
|
153
|
+
try {
|
|
154
|
+
transpiler.transformSync(entry.body);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
failed = `${key}: ${err instanceof Error ? err.message : String(err)}`;
|
|
158
|
+
}
|
|
159
|
+
expect(failed).toBe("");
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
test("no innerHTML anywhere (injection-safe DOM building only)", () => {
|
|
163
|
+
for (const [key, entry] of jsEntries) {
|
|
164
|
+
expect(`${key}:${entry.body.includes("innerHTML")}`).toBe(`${key}:false`);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
test("no markup-string assignment of any kind (the ban is wider than innerHTML)", () => {
|
|
168
|
+
// Built from parts so this test file cannot itself trip a scanner.
|
|
169
|
+
const banned = [
|
|
170
|
+
"outer" + "HTML",
|
|
171
|
+
"insertAdjacent" + "HTML",
|
|
172
|
+
"document." + "write",
|
|
173
|
+
"createContextual" + "Fragment",
|
|
174
|
+
];
|
|
175
|
+
for (const [key, entry] of jsEntries) {
|
|
176
|
+
for (const needle of banned) {
|
|
177
|
+
expect(`${key}:${needle}:${entry.body.includes(needle)}`).toBe(`${key}:${needle}:false`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
test("srcdoc appears in exactly one module, and only alongside a sandbox", () => {
|
|
182
|
+
// M4 (HM-179) introduces the ONE deliberate exception to the
|
|
183
|
+
// markup-string ban: a plugin pane's document is handed to an iframe via
|
|
184
|
+
// `srcdoc`. That is safe precisely because the string never joins THIS
|
|
185
|
+
// document — the frame gets an opaque origin (no `allow-same-origin`)
|
|
186
|
+
// and a CSP. Both halves are load-bearing, so the exception is pinned to
|
|
187
|
+
// one file and to the presence of the sandbox attribute beside it; a
|
|
188
|
+
// second module reaching for `srcdoc` fails here.
|
|
189
|
+
const users = jsEntries
|
|
190
|
+
.filter(([, entry]) => entry.body.includes("srcdoc"))
|
|
191
|
+
.map(([key]) => key);
|
|
192
|
+
expect(users).toEqual(["/assets/js/views/panes.js"]);
|
|
193
|
+
const panes = hangarAssets["/assets/js/views/panes.js"]?.body ?? "";
|
|
194
|
+
expect(panes).toContain("sandbox:");
|
|
195
|
+
// …and it is the ONLY module allowed to build an iframe at all: the
|
|
196
|
+
// sandbox is what makes the exception safe, so a frame appearing
|
|
197
|
+
// elsewhere would be one nobody had to think about.
|
|
198
|
+
const frames = jsEntries
|
|
199
|
+
.filter(([, entry]) => entry.body.includes('el("iframe"'))
|
|
200
|
+
.map(([key]) => key);
|
|
201
|
+
expect(frames).toEqual(["/assets/js/views/panes.js"]);
|
|
202
|
+
// The sandbox token list itself (never `allow-same-origin` alongside
|
|
203
|
+
// `allow-scripts`) is proven behaviourally in `m4-views.test.ts`, where
|
|
204
|
+
// `paneSandbox` is called with a payload that tries to widen it.
|
|
205
|
+
});
|
|
206
|
+
test("no module hardcodes a `harness scan-root` verb (the CLI has none)", () => {
|
|
207
|
+
// The console's CLI twins are its trust affordance: a command shown
|
|
208
|
+
// beside a button has to be one a terminal accepts. `crewhaus harness`
|
|
209
|
+
// takes list|show|add|remove|relocate|group|tag|pin|scan|preflight — a
|
|
210
|
+
// scan root is added by `harness scan --root <dir>`, which remembers the
|
|
211
|
+
// root before it walks it. A twin nobody can paste teaches the opposite
|
|
212
|
+
// of what the discipline is for, and the first-boot screen is where an
|
|
213
|
+
// operator learns whether to believe them.
|
|
214
|
+
for (const [key, entry] of jsEntries) {
|
|
215
|
+
expect(`${key}:${entry.body.includes("scan-root add")}`).toBe(`${key}:false`);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
test("the evaluating notifications GET has exactly one caller, and the app runs it", () => {
|
|
219
|
+
// `GET /api/notifications` IS the rules evaluation (HM-183: the manager
|
|
220
|
+
// runs no timer). Every delivery it returns is added to the server's
|
|
221
|
+
// dedupe set and never returned again — so a second caller does not read
|
|
222
|
+
// the same state, it CONSUMES it, and the toast for whatever it ate can
|
|
223
|
+
// never appear again in that manager process. One caller, therefore, and
|
|
224
|
+
// every screen that needs the state goes through it.
|
|
225
|
+
const callers = jsEntries
|
|
226
|
+
.filter(([, entry]) => entry.body.includes("api.notifications("))
|
|
227
|
+
.map(([key]) => key);
|
|
228
|
+
expect(callers).toEqual(["/assets/js/notify.js"]);
|
|
229
|
+
// …and the app must actually START that loop: a poll nobody runs is the
|
|
230
|
+
// boot-time read this whole arrangement exists to replace.
|
|
231
|
+
expect(hangarAssets["/assets/js/app.js"]?.body ?? "").toContain("notifications.start()");
|
|
232
|
+
});
|
|
233
|
+
test("every relative import resolves to another embedded asset (import-graph closure)", () => {
|
|
234
|
+
const importRe = /import\s+[^"']*?["']([^"']+)["']/g;
|
|
235
|
+
for (const [key, entry] of jsEntries) {
|
|
236
|
+
const dir = key.slice(0, key.lastIndexOf("/"));
|
|
237
|
+
for (const match of entry.body.matchAll(importRe)) {
|
|
238
|
+
const spec = match[1] ?? "";
|
|
239
|
+
expect(spec.startsWith("./") || spec.startsWith("../")).toBe(true);
|
|
240
|
+
const resolved = resolvePosix(dir, spec);
|
|
241
|
+
expect(hangarAssets[resolved]).toBeDefined();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
test("no orphaned module is shipped (every js asset is reachable from the shell entry)", () => {
|
|
246
|
+
// R-20: `pending.js` — the stub-era "not built yet" screen — outlived its
|
|
247
|
+
// last importer and was still embedded, served over HTTP and compiled
|
|
248
|
+
// into the single-binary CLI, with a docblock telling readers that
|
|
249
|
+
// eleven live screens "have no data behind them yet". A dead module in a
|
|
250
|
+
// zero-build tree has nothing to catch it: the transpiler, the
|
|
251
|
+
// completeness check and the import-graph closure above all pass on code
|
|
252
|
+
// nobody imports. Reachability is the check that does not.
|
|
253
|
+
const entry = "/assets/js/app.js";
|
|
254
|
+
const importRe = /import\s+[^"']*?["']([^"']+)["']/g;
|
|
255
|
+
const seen = new Set([entry]);
|
|
256
|
+
const queue = [entry];
|
|
257
|
+
while (queue.length > 0) {
|
|
258
|
+
const key = queue.pop();
|
|
259
|
+
const body = hangarAssets[key]?.body ?? "";
|
|
260
|
+
const dir = key.slice(0, key.lastIndexOf("/"));
|
|
261
|
+
for (const match of body.matchAll(importRe)) {
|
|
262
|
+
const resolved = resolvePosix(dir, match[1] ?? "");
|
|
263
|
+
if (seen.has(resolved))
|
|
264
|
+
continue;
|
|
265
|
+
seen.add(resolved);
|
|
266
|
+
queue.push(resolved);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const orphans = jsEntries.map(([key]) => key).filter((key) => !seen.has(key));
|
|
270
|
+
expect(orphans).toEqual([]);
|
|
271
|
+
});
|
|
272
|
+
test("browser modules never import bun/node builtins", () => {
|
|
273
|
+
for (const [key, entry] of jsEntries) {
|
|
274
|
+
expect(`${key}:${/from\s+["'](?:bun|node:)/.test(entry.body)}`).toBe(`${key}:false`);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
function resolvePosix(fromDir, spec) {
|
|
279
|
+
const parts = fromDir.split("/").filter((p) => p !== "");
|
|
280
|
+
for (const seg of spec.split("/")) {
|
|
281
|
+
if (seg === "" || seg === ".")
|
|
282
|
+
continue;
|
|
283
|
+
if (seg === "..")
|
|
284
|
+
parts.pop();
|
|
285
|
+
else
|
|
286
|
+
parts.push(seg);
|
|
287
|
+
}
|
|
288
|
+
return `/${parts.join("/")}`;
|
|
289
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -37,6 +37,7 @@ import shapesJs from "../assets/js/shapes.js" with { type: "text" };
|
|
|
37
37
|
import supervisionJs from "../assets/js/supervision.js" with { type: "text" };
|
|
38
38
|
import utilJs from "../assets/js/util.js" with { type: "text" };
|
|
39
39
|
import activityViewJs from "../assets/js/views/activity.js" with { type: "text" };
|
|
40
|
+
import advisorViewJs from "../assets/js/views/advisor.js" with { type: "text" };
|
|
40
41
|
import approvalsViewJs from "../assets/js/views/approvals.js" with { type: "text" };
|
|
41
42
|
import channelsViewJs from "../assets/js/views/channels.js" with { type: "text" };
|
|
42
43
|
import controlViewJs from "../assets/js/views/control.js" with { type: "text" };
|
|
@@ -110,6 +111,7 @@ export const hangarAssets = {
|
|
|
110
111
|
"/assets/js/supervision.js": asset(supervisionJs, "supervision.js"),
|
|
111
112
|
"/assets/js/util.js": asset(utilJs, "util.js"),
|
|
112
113
|
"/assets/js/views/activity.js": asset(activityViewJs, "activity.js"),
|
|
114
|
+
"/assets/js/views/advisor.js": asset(advisorViewJs, "advisor.js"),
|
|
113
115
|
"/assets/js/views/approvals.js": asset(approvalsViewJs, "approvals.js"),
|
|
114
116
|
"/assets/js/views/channels.js": asset(channelsViewJs, "channels.js"),
|
|
115
117
|
"/assets/js/views/control.js": asset(controlViewJs, "control.js"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/hangar-ui",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The Hangar web console's static UI — zero-build, dependency-free browser ES modules embedded as text assets for the single-binary CLI to hand to the hangar server",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"scripts": {
|
|
15
|
-
"test": "bun test src"
|
|
15
|
+
"test": "bun test src && bun test ./src/embed-suite.ts"
|
|
16
16
|
},
|
|
17
17
|
"license": "Apache-2.0",
|
|
18
18
|
"author": {
|