@intentius/behold 0.7.0 → 0.9.0
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/AGENTS.md +94 -2
- package/README.md +133 -2
- package/demos.json +25 -1
- package/dist/cli.js +3176 -330
- package/example-argo-estate/README.md +71 -0
- package/example-argo-estate/app-a/chant.config.ts +18 -0
- package/example-argo-estate/app-a/manifests/app.yaml +54 -0
- package/example-argo-estate/app-a/package.json +13 -0
- package/example-argo-estate/app-a/src/app.ts +35 -0
- package/example-argo-estate/app-a/tsconfig.json +1 -0
- package/example-argo-estate/app-b/chant.config.ts +18 -0
- package/example-argo-estate/app-b/manifests/app.yaml +54 -0
- package/example-argo-estate/app-b/package.json +13 -0
- package/example-argo-estate/app-b/src/app.ts +34 -0
- package/example-argo-estate/app-b/tsconfig.json +1 -0
- package/example-argo-estate/control-plane/chant.config.ts +29 -0
- package/example-argo-estate/control-plane/package.json +13 -0
- package/example-argo-estate/control-plane/src/argo.ts +69 -0
- package/example-argo-estate/control-plane/tsconfig.json +1 -0
- package/example-argo-estate/package-lock.json +1949 -0
- package/example-argo-estate/package.json +10 -0
- package/example-carve/README.md +194 -0
- package/example-carve/app/chant.config.ts +6 -0
- package/example-carve/app/package-lock.json +1075 -0
- package/example-carve/app/package.json +13 -0
- package/example-carve/app/src/carved.ts +30 -0
- package/example-carve/app/tsconfig.json +1 -0
- package/example-carve/carve-report.json +872 -0
- package/example-carve/legacy-tf/cdn.tf +23 -0
- package/example-carve/legacy-tf/compute.tf +63 -0
- package/example-carve/legacy-tf/floci-override.tf.disabled +61 -0
- package/example-carve/legacy-tf/modules/cdn/main.tf +72 -0
- package/example-carve/legacy-tf/naming.tf +10 -0
- package/example-carve/legacy-tf/network.tf +119 -0
- package/example-carve/legacy-tf/observability.tf +18 -0
- package/example-carve/legacy-tf/outputs.tf +16 -0
- package/example-carve/legacy-tf/storage.tf +33 -0
- package/example-carve/legacy-tf/terraform.tfstate +602 -0
- package/example-carve/legacy-tf/versions.tf +40 -0
- package/example-flux-estate/README.md +22 -12
- package/example-flux-estate/app-a/package.json +2 -2
- package/example-flux-estate/app-a/src/app.ts +2 -1
- package/example-flux-estate/app-b/chant.config.ts +4 -3
- package/example-flux-estate/app-b/package.json +2 -2
- package/example-flux-estate/app-b/src/app.ts +5 -3
- package/example-flux-estate/control-plane/package.json +2 -2
- package/example-flux-estate/control-plane/src/flux.ts +10 -2
- package/example-flux-estate/package-lock.json +17 -17
- package/example-k8s/README.md +16 -2
- package/example-k8s/base/deployment.yaml +21 -0
- package/example-k8s/base/kustomization.yaml +3 -0
- package/example-k8s/base/service.yaml +12 -0
- package/example-k8s/chant.config.ts +10 -0
- package/example-k8s/overlays/dev/kustomization.yaml +6 -0
- package/example-k8s/package-lock.json +18 -18
- package/example-k8s/package.json +3 -3
- package/example-writes/package-lock.json +14 -14
- package/example-writes/package.json +3 -3
- package/package.json +9 -6
- package/web/app.js +1027 -42
- package/web/carve-steps.js +610 -0
- package/web/carve-steps.test.js +233 -0
- package/web/demos.js +71 -0
- package/web/demos.test.js +83 -0
- package/web/icons/cncf/argo.svg +1 -0
- package/web/icons/cncf/flux.svg +1 -0
- package/web/icons/cncf/helm.svg +1 -0
- package/web/icons/k8s/c-role.svg +1 -0
- package/web/icons/k8s/cm.svg +1 -0
- package/web/icons/k8s/crb.svg +1 -0
- package/web/icons/k8s/crd.svg +1 -0
- package/web/icons/k8s/cronjob.svg +1 -0
- package/web/icons/k8s/deploy.svg +1 -0
- package/web/icons/k8s/ds.svg +1 -0
- package/web/icons/k8s/ep.svg +1 -0
- package/web/icons/k8s/group.svg +1 -0
- package/web/icons/k8s/hpa.svg +1 -0
- package/web/icons/k8s/ing.svg +1 -0
- package/web/icons/k8s/job.svg +1 -0
- package/web/icons/k8s/limits.svg +1 -0
- package/web/icons/k8s/netpol.svg +1 -0
- package/web/icons/k8s/ns.svg +1 -0
- package/web/icons/k8s/pod.svg +1 -0
- package/web/icons/k8s/psp.svg +1 -0
- package/web/icons/k8s/pv.svg +1 -0
- package/web/icons/k8s/pvc.svg +1 -0
- package/web/icons/k8s/quota.svg +1 -0
- package/web/icons/k8s/rb.svg +1 -0
- package/web/icons/k8s/role.svg +1 -0
- package/web/icons/k8s/rs.svg +1 -0
- package/web/icons/k8s/sa.svg +1 -0
- package/web/icons/k8s/sc.svg +1 -0
- package/web/icons/k8s/secret.svg +1 -0
- package/web/icons/k8s/sts.svg +1 -0
- package/web/icons/k8s/svc.svg +1 -0
- package/web/icons/k8s/user.svg +1 -0
- package/web/icons/k8s/vol.svg +1 -0
- package/web/index.html +285 -85
- package/web/json-view.js +334 -0
- package/web/json-view.test.js +218 -0
- package/web/layout-store.js +278 -0
- package/web/layout-store.test.js +388 -0
- package/web/panel.js +28 -0
- package/web/theme.js +114 -6
package/web/json-view.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// #259 — the one place a JSON value becomes DOM.
|
|
2
|
+
//
|
|
3
|
+
// behold shows other people's payloads: a declared attribute, chant's observed
|
|
4
|
+
// live state, a drift pair, an op report, the body of an /api error. Every one
|
|
5
|
+
// of those used to reach the pane as `JSON.stringify(v)` — a single flat line
|
|
6
|
+
// that wraps four times and tells you nothing about its shape. This module is
|
|
7
|
+
// the replacement: 2-space pretty print, an expand/collapse affordance per
|
|
8
|
+
// object and array, and a per-subtree copy of the raw JSON.
|
|
9
|
+
//
|
|
10
|
+
// Two halves, deliberately separated:
|
|
11
|
+
//
|
|
12
|
+
// - the pure half (kindOf … collapsedByDefault) has no DOM in it and is unit
|
|
13
|
+
// tested in web/json-view.test.js, the way web/layout-store.js is (#245);
|
|
14
|
+
// - renderJson() builds the tree out of those functions and nothing else.
|
|
15
|
+
//
|
|
16
|
+
// Rules the pure half encodes, so they are testable rather than argued about:
|
|
17
|
+
//
|
|
18
|
+
// Key order is the order received. Object.keys is insertion order for string
|
|
19
|
+
// keys, and chant's payloads arrive from JSON.parse, so what you read is what
|
|
20
|
+
// the substrate sent — never sorted, because a re-sorted payload no longer
|
|
21
|
+
// matches the manifest you'd diff it against.
|
|
22
|
+
//
|
|
23
|
+
// Escaping is JSON's own. Strings render through JSON.stringify, so quotes,
|
|
24
|
+
// backslashes and control characters come out as the escapes a JSON reader
|
|
25
|
+
// expects; everything lands via textContent, so markup in a payload is text.
|
|
26
|
+
//
|
|
27
|
+
// Collapsed by default means deep OR large. Depth <= openDepth (1) is open,
|
|
28
|
+
// so the first tier of a payload reads at a glance; anything below that, and
|
|
29
|
+
// anything wider than `big` entries wherever it sits, starts folded.
|
|
30
|
+
//
|
|
31
|
+
// Styling is tokens only (#229/#253) — see the `.jsonv` block in index.html.
|
|
32
|
+
// No dependencies: the SPA is unbundled ES modules and stays that way.
|
|
33
|
+
|
|
34
|
+
/** Indent unit — literally two spaces, matched by `.jsonv-children`'s 2ch. */
|
|
35
|
+
export const INDENT = " ";
|
|
36
|
+
/** Containers at this depth or shallower start open (root is depth 0). */
|
|
37
|
+
export const OPEN_DEPTH = 1;
|
|
38
|
+
/** More entries than this and a container starts collapsed at any depth. */
|
|
39
|
+
export const BIG_ENTRIES = 12;
|
|
40
|
+
/** Characters of a string shown before the "+N chars" expander. */
|
|
41
|
+
export const MAX_STRING = 180;
|
|
42
|
+
|
|
43
|
+
const BRACKETS = { array: ["[", "]"], object: ["{", "}"] };
|
|
44
|
+
|
|
45
|
+
/** JSON's own type vocabulary, with arrays split out from objects. */
|
|
46
|
+
export function kindOf(v) {
|
|
47
|
+
if (v === null) return "null";
|
|
48
|
+
if (Array.isArray(v)) return "array";
|
|
49
|
+
const t = typeof v;
|
|
50
|
+
return t === "object" ? "object" : t; // string | number | boolean | undefined | bigint | function | symbol
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** An object or an array — the two things that get a collapse affordance. */
|
|
54
|
+
export function isContainer(v) {
|
|
55
|
+
const k = kindOf(v);
|
|
56
|
+
return k === "object" || k === "array";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `[key, value]` pairs in the order received (array indices as keys). */
|
|
60
|
+
export function entriesOf(v) {
|
|
61
|
+
if (Array.isArray(v)) return v.map((x, i) => [String(i), x]);
|
|
62
|
+
if (v && typeof v === "object") return Object.keys(v).map((k) => [k, v[k]]);
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function countOf(v) {
|
|
67
|
+
return entriesOf(v).length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What a collapsed container says about itself: `3 keys` / `5 items`. */
|
|
71
|
+
export function summaryOf(v) {
|
|
72
|
+
const n = countOf(v);
|
|
73
|
+
if (Array.isArray(v)) return n === 1 ? "1 item" : `${n} items`;
|
|
74
|
+
return n === 1 ? "1 key" : `${n} keys`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A string's INNER escaped form — JSON's escaping without the quotes. */
|
|
78
|
+
export function escapeString(s) {
|
|
79
|
+
return JSON.stringify(String(s)).slice(1, -1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* One scalar as JSON source text. Strings come back quoted and escaped;
|
|
84
|
+
* `undefined` (which JSON has no spelling for, but a payload sliced by the SPA
|
|
85
|
+
* can still hold) and non-finite numbers are named rather than silently turned
|
|
86
|
+
* into `null` the way JSON.stringify would.
|
|
87
|
+
*/
|
|
88
|
+
export function scalarText(v) {
|
|
89
|
+
switch (kindOf(v)) {
|
|
90
|
+
case "string":
|
|
91
|
+
return JSON.stringify(v);
|
|
92
|
+
case "null":
|
|
93
|
+
return "null";
|
|
94
|
+
case "undefined":
|
|
95
|
+
return "undefined";
|
|
96
|
+
case "bigint":
|
|
97
|
+
return String(v) + "n";
|
|
98
|
+
default:
|
|
99
|
+
return String(v); // number (incl. NaN/Infinity), boolean, function, symbol
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Exactly what the copy control writes: this subtree, 2-space pretty printed. */
|
|
104
|
+
export function rawJson(v) {
|
|
105
|
+
try {
|
|
106
|
+
const s = JSON.stringify(v, null, INDENT);
|
|
107
|
+
return s === undefined ? scalarText(v) : s;
|
|
108
|
+
} catch {
|
|
109
|
+
return scalarText(v); // a cycle, or a value that refuses to serialize
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Split a long string for the truncated view. Splits the RAW string, then the
|
|
114
|
+
* renderer escapes each half — escaping first could cut `é` in two. */
|
|
115
|
+
export function truncateString(s, max = MAX_STRING) {
|
|
116
|
+
const str = String(s);
|
|
117
|
+
if (str.length <= max) return { head: str, rest: "", truncated: false };
|
|
118
|
+
return { head: str.slice(0, max), rest: str.slice(max), truncated: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Does this container start folded? Deep or large — see the header note. */
|
|
122
|
+
export function collapsedByDefault(value, depth, opts = {}) {
|
|
123
|
+
if (!isContainer(value)) return false;
|
|
124
|
+
const n = countOf(value);
|
|
125
|
+
if (n === 0) return false; // `{}` has nothing to hide; it renders on one line
|
|
126
|
+
const openDepth = opts.openDepth ?? OPEN_DEPTH;
|
|
127
|
+
const big = opts.big ?? BIG_ENTRIES;
|
|
128
|
+
if (n > big) return true;
|
|
129
|
+
return depth > openDepth;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- the DOM half ---------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
/** Write to the system clipboard, with the pre-`navigator.clipboard` fallback
|
|
135
|
+
* a file:// static export may still need. Returns a promise either way. */
|
|
136
|
+
export function writeClipboard(text) {
|
|
137
|
+
if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text);
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const ta = document.createElement("textarea");
|
|
140
|
+
ta.value = text;
|
|
141
|
+
ta.style.cssText = "position:fixed;opacity:0;pointer-events:none";
|
|
142
|
+
document.body.appendChild(ta);
|
|
143
|
+
ta.select();
|
|
144
|
+
try {
|
|
145
|
+
document.execCommand("copy") ? resolve() : reject(new Error("copy rejected"));
|
|
146
|
+
} catch (e) {
|
|
147
|
+
reject(e);
|
|
148
|
+
} finally {
|
|
149
|
+
ta.remove();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function span(cls, text) {
|
|
155
|
+
const el = document.createElement("span");
|
|
156
|
+
el.className = cls;
|
|
157
|
+
if (text !== undefined) el.textContent = text;
|
|
158
|
+
return el;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A focusable, Enter/Space-activated control. Not a <button>: the inspect
|
|
162
|
+
* pane paints every <button> in it as an Adopt-style action, and a JSON tree
|
|
163
|
+
* full of those would read as a page of buttons. */
|
|
164
|
+
function control(cls, text, onActivate) {
|
|
165
|
+
const el = span(cls, text);
|
|
166
|
+
el.setAttribute("role", "button");
|
|
167
|
+
el.tabIndex = 0;
|
|
168
|
+
el.addEventListener("click", (e) => {
|
|
169
|
+
e.stopPropagation();
|
|
170
|
+
onActivate();
|
|
171
|
+
});
|
|
172
|
+
el.addEventListener("keydown", (e) => {
|
|
173
|
+
if (e.key !== "Enter" && e.key !== " " && e.key !== "Spacebar") return;
|
|
174
|
+
e.preventDefault();
|
|
175
|
+
e.stopPropagation();
|
|
176
|
+
onActivate();
|
|
177
|
+
});
|
|
178
|
+
return el;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function scalarNode(value, o) {
|
|
182
|
+
const wrap = span("jsonv-val jsonv-" + kindOf(value));
|
|
183
|
+
if (kindOf(value) !== "string") {
|
|
184
|
+
wrap.textContent = scalarText(value);
|
|
185
|
+
return wrap;
|
|
186
|
+
}
|
|
187
|
+
const { head, truncated } = truncateString(value, o.maxString);
|
|
188
|
+
if (!truncated) {
|
|
189
|
+
wrap.textContent = scalarText(value);
|
|
190
|
+
return wrap;
|
|
191
|
+
}
|
|
192
|
+
const text = span("jsonv-str-head", `"${escapeString(head)}…"`);
|
|
193
|
+
const more = control("jsonv-more", `+${value.length - head.length} chars`, () => {
|
|
194
|
+
text.textContent = scalarText(value);
|
|
195
|
+
more.remove();
|
|
196
|
+
});
|
|
197
|
+
more.title = "Show the whole string";
|
|
198
|
+
wrap.append(text, more);
|
|
199
|
+
return wrap;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function copyControl(value, o) {
|
|
203
|
+
const el = control("jsonv-copy", "copy", () => {
|
|
204
|
+
const text = rawJson(value);
|
|
205
|
+
Promise.resolve(o.copyText(text)).then(
|
|
206
|
+
() => flash(el, "copied", "1"),
|
|
207
|
+
() => flash(el, "copy failed", "0"),
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
el.title = "Copy this subtree as raw JSON";
|
|
211
|
+
// What the click WILL write, reachable from a test without a clipboard
|
|
212
|
+
// permission (smoke/ui-smoke.mjs reads it, then round-trips it through
|
|
213
|
+
// JSON.parse) — the same string the handler above hands to the clipboard.
|
|
214
|
+
el.__jsonPayload = () => rawJson(value);
|
|
215
|
+
return el;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function flash(el, text, ok) {
|
|
219
|
+
el.textContent = text;
|
|
220
|
+
el.dataset.copied = ok;
|
|
221
|
+
clearTimeout(el.__flash);
|
|
222
|
+
el.__flash = setTimeout(() => {
|
|
223
|
+
el.textContent = "copy";
|
|
224
|
+
delete el.dataset.copied;
|
|
225
|
+
}, 1500);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function buildNode(value, key, depth, o) {
|
|
229
|
+
const node = document.createElement("div");
|
|
230
|
+
node.className = "jsonv-node";
|
|
231
|
+
// The key this node hangs off, so a tree is addressable from outside — the
|
|
232
|
+
// smoke selects `[data-key="metadata"]` rather than matching rendered text.
|
|
233
|
+
if (key !== null) node.dataset.key = key;
|
|
234
|
+
const line = document.createElement("div");
|
|
235
|
+
line.className = "jsonv-line";
|
|
236
|
+
node.appendChild(line);
|
|
237
|
+
|
|
238
|
+
if (!isContainer(value)) {
|
|
239
|
+
if (key !== null) line.append(span("jsonv-key", quoteKey(key)), span("jsonv-punct", ": "));
|
|
240
|
+
line.appendChild(scalarNode(value, o));
|
|
241
|
+
return node;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const [open, close] = BRACKETS[kindOf(value)];
|
|
245
|
+
const n = countOf(value);
|
|
246
|
+
const kids = document.createElement("div");
|
|
247
|
+
kids.className = "jsonv-children";
|
|
248
|
+
const tail = document.createElement("div");
|
|
249
|
+
tail.className = "jsonv-tail";
|
|
250
|
+
tail.appendChild(span("jsonv-punct", close));
|
|
251
|
+
|
|
252
|
+
const head = document.createElement("span");
|
|
253
|
+
head.className = "jsonv-head";
|
|
254
|
+
const chevron = span("jsonv-toggle");
|
|
255
|
+
head.appendChild(chevron);
|
|
256
|
+
if (key !== null) head.append(span("jsonv-key", quoteKey(key)), span("jsonv-punct", ": "));
|
|
257
|
+
head.appendChild(span("jsonv-punct", open));
|
|
258
|
+
head.appendChild(span("jsonv-summary", n ? ` … ${summaryOf(value)} ` : ""));
|
|
259
|
+
head.appendChild(span("jsonv-punct jsonv-close-inline", close));
|
|
260
|
+
line.appendChild(head);
|
|
261
|
+
if (o.copy) line.appendChild(copyControl(value, o));
|
|
262
|
+
node.append(kids, tail);
|
|
263
|
+
|
|
264
|
+
// An empty container is its own summary — `{}` on one line, no affordance,
|
|
265
|
+
// nothing to focus.
|
|
266
|
+
if (!n) {
|
|
267
|
+
node.dataset.open = "0";
|
|
268
|
+
chevron.textContent = " ";
|
|
269
|
+
return node;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Children are built on first expand: a collapsed subtree costs no DOM, which
|
|
273
|
+
// is what makes a 400-key observed payload cheap to show at all.
|
|
274
|
+
let built = false;
|
|
275
|
+
const setOpen = (isOpen) => {
|
|
276
|
+
if (isOpen && !built) {
|
|
277
|
+
built = true;
|
|
278
|
+
for (const [k, v] of entriesOf(value)) kids.appendChild(buildNode(v, k, depth + 1, o));
|
|
279
|
+
}
|
|
280
|
+
node.dataset.open = isOpen ? "1" : "0";
|
|
281
|
+
head.setAttribute("aria-expanded", String(isOpen));
|
|
282
|
+
chevron.textContent = isOpen ? "▾" : "▸";
|
|
283
|
+
head.title = isOpen ? "Collapse" : `Expand — ${summaryOf(value)}`;
|
|
284
|
+
};
|
|
285
|
+
head.setAttribute("role", "button");
|
|
286
|
+
head.tabIndex = 0;
|
|
287
|
+
head.addEventListener("click", (e) => {
|
|
288
|
+
e.stopPropagation();
|
|
289
|
+
setOpen(node.dataset.open !== "1");
|
|
290
|
+
});
|
|
291
|
+
head.addEventListener("keydown", (e) => {
|
|
292
|
+
if (e.key !== "Enter" && e.key !== " " && e.key !== "Spacebar") return;
|
|
293
|
+
e.preventDefault();
|
|
294
|
+
e.stopPropagation();
|
|
295
|
+
setOpen(node.dataset.open !== "1");
|
|
296
|
+
});
|
|
297
|
+
setOpen(!collapsedByDefault(value, depth, o));
|
|
298
|
+
return node;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Array indices read as `0:` rather than `"0":` — they are positions, not keys. */
|
|
302
|
+
function quoteKey(key) {
|
|
303
|
+
return /^\d+$/.test(key) ? key : JSON.stringify(key);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Render one JSON value as a collapsible tree. Returns a detached element —
|
|
308
|
+
* the caller appends it wherever the flat string used to go.
|
|
309
|
+
*
|
|
310
|
+
* opts: { openDepth, big, maxString, copy, copyText }
|
|
311
|
+
*/
|
|
312
|
+
export function renderJson(value, opts = {}) {
|
|
313
|
+
const o = {
|
|
314
|
+
openDepth: OPEN_DEPTH,
|
|
315
|
+
big: BIG_ENTRIES,
|
|
316
|
+
maxString: MAX_STRING,
|
|
317
|
+
copy: true,
|
|
318
|
+
copyText: writeClipboard,
|
|
319
|
+
...opts,
|
|
320
|
+
};
|
|
321
|
+
const root = document.createElement("div");
|
|
322
|
+
root.className = "jsonv";
|
|
323
|
+
root.appendChild(buildNode(value, null, 0, o));
|
|
324
|
+
return root;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The call every site in app.js actually makes: a container becomes the tree,
|
|
329
|
+
* a scalar stays inline text. A collapsible one-line view of `3` helps nobody,
|
|
330
|
+
* and the panes this feeds are dominated by scalars.
|
|
331
|
+
*/
|
|
332
|
+
export function jsonCell(value, opts) {
|
|
333
|
+
return isContainer(value) ? renderJson(value, opts) : scalarText(value);
|
|
334
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// #259: the pure half of web/json-view.js, tested the way web/layout-store.js
|
|
2
|
+
// is (#245) — no DOM, no jsdom, no browser. Everything renderJson() decides
|
|
3
|
+
// (what a value reads as, what a collapsed subtree says about itself, whether
|
|
4
|
+
// it starts folded, exactly what the copy control writes) lives in a function
|
|
5
|
+
// that takes data and returns data, so it is checkable here; the DOM half —
|
|
6
|
+
// the toggle, the keyboard, the copy click — is smoke/ui-smoke.mjs's job.
|
|
7
|
+
import { describe, it, expect } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
BIG_ENTRIES,
|
|
10
|
+
INDENT,
|
|
11
|
+
MAX_STRING,
|
|
12
|
+
OPEN_DEPTH,
|
|
13
|
+
collapsedByDefault,
|
|
14
|
+
countOf,
|
|
15
|
+
entriesOf,
|
|
16
|
+
escapeString,
|
|
17
|
+
isContainer,
|
|
18
|
+
kindOf,
|
|
19
|
+
rawJson,
|
|
20
|
+
scalarText,
|
|
21
|
+
summaryOf,
|
|
22
|
+
truncateString,
|
|
23
|
+
} from "./json-view.js";
|
|
24
|
+
|
|
25
|
+
describe("kindOf — JSON's vocabulary, with arrays split from objects", () => {
|
|
26
|
+
it("names every JSON type", () => {
|
|
27
|
+
expect(kindOf(null)).toBe("null");
|
|
28
|
+
expect(kindOf([])).toBe("array");
|
|
29
|
+
expect(kindOf({})).toBe("object");
|
|
30
|
+
expect(kindOf("s")).toBe("string");
|
|
31
|
+
expect(kindOf(3)).toBe("number");
|
|
32
|
+
expect(kindOf(false)).toBe("boolean");
|
|
33
|
+
});
|
|
34
|
+
it("null is not an object — typeof would say otherwise, and the tree would try to expand it", () => {
|
|
35
|
+
expect(isContainer(null)).toBe(false);
|
|
36
|
+
expect(isContainer([])).toBe(true);
|
|
37
|
+
expect(isContainer({})).toBe(true);
|
|
38
|
+
expect(isContainer("{}")).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
it("undefined survives (a sliced payload can hold one; JSON has no spelling for it)", () => {
|
|
41
|
+
expect(kindOf(undefined)).toBe("undefined");
|
|
42
|
+
expect(scalarText(undefined)).toBe("undefined");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("entriesOf — key order is the order received, never sorted", () => {
|
|
47
|
+
it("keeps insertion order for objects", () => {
|
|
48
|
+
const o = { zeta: 1, alpha: 2, mid: 3 };
|
|
49
|
+
expect(entriesOf(o).map(([k]) => k)).toEqual(["zeta", "alpha", "mid"]);
|
|
50
|
+
});
|
|
51
|
+
it("keeps it after a JSON round trip — which is how every payload here arrives", () => {
|
|
52
|
+
const parsed = JSON.parse('{"status":"Running","spec":{},"apiVersion":"v1"}');
|
|
53
|
+
expect(entriesOf(parsed).map(([k]) => k)).toEqual(["status", "spec", "apiVersion"]);
|
|
54
|
+
});
|
|
55
|
+
it("indexes arrays by position", () => {
|
|
56
|
+
expect(entriesOf(["a", "b"])).toEqual([
|
|
57
|
+
["0", "a"],
|
|
58
|
+
["1", "b"],
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
it("a scalar has no entries", () => {
|
|
62
|
+
expect(entriesOf("nope")).toEqual([]);
|
|
63
|
+
expect(entriesOf(null)).toEqual([]);
|
|
64
|
+
expect(countOf(7)).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("summaryOf — what a collapsed container says about itself", () => {
|
|
69
|
+
it("counts keys and items, singular and plural", () => {
|
|
70
|
+
expect(summaryOf({ a: 1, b: 2 })).toBe("2 keys");
|
|
71
|
+
expect(summaryOf({ a: 1 })).toBe("1 key");
|
|
72
|
+
expect(summaryOf([1, 2, 3])).toBe("3 items");
|
|
73
|
+
expect(summaryOf([1])).toBe("1 item");
|
|
74
|
+
expect(summaryOf({})).toBe("0 keys");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("scalarText — one value as JSON source text", () => {
|
|
79
|
+
it("quotes and escapes strings, so a payload's quotes are visible as escapes", () => {
|
|
80
|
+
expect(scalarText('he said "hi"')).toBe('"he said \\"hi\\""');
|
|
81
|
+
expect(scalarText("C:\\tmp")).toBe('"C:\\\\tmp"');
|
|
82
|
+
expect(scalarText("line\nbreak")).toBe('"line\\nbreak"');
|
|
83
|
+
});
|
|
84
|
+
it("markup in a payload stays a string — nothing here builds HTML", () => {
|
|
85
|
+
// The renderer sets textContent, so this is belt and braces: the escaped
|
|
86
|
+
// form is what a JSON reader expects, and it is inert either way.
|
|
87
|
+
expect(scalarText("<script>alert(1)</script>")).toBe('"<script>alert(1)</script>"');
|
|
88
|
+
});
|
|
89
|
+
it("names the values JSON.stringify would quietly turn into null", () => {
|
|
90
|
+
expect(scalarText(NaN)).toBe("NaN");
|
|
91
|
+
expect(scalarText(Infinity)).toBe("Infinity");
|
|
92
|
+
expect(JSON.stringify(NaN)).toBe("null"); // …which is the thing being avoided
|
|
93
|
+
});
|
|
94
|
+
it("renders the rest as JSON source", () => {
|
|
95
|
+
expect(scalarText(null)).toBe("null");
|
|
96
|
+
expect(scalarText(true)).toBe("true");
|
|
97
|
+
expect(scalarText(0)).toBe("0");
|
|
98
|
+
expect(scalarText(-1.5)).toBe("-1.5");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("escapeString — JSON's escaping without the quotes (for the truncated form)", () => {
|
|
103
|
+
it("escapes the inner text only", () => {
|
|
104
|
+
expect(escapeString('a"b')).toBe('a\\"b');
|
|
105
|
+
expect(escapeString("tab\there")).toBe("tab\\there");
|
|
106
|
+
});
|
|
107
|
+
it("wrapping it in quotes reproduces scalarText", () => {
|
|
108
|
+
for (const s of ['q"q', "back\\slash", "nl\n", "plain"]) expect(`"${escapeString(s)}"`).toBe(scalarText(s));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("rawJson — exactly what the copy control writes", () => {
|
|
113
|
+
const payload = { kind: "Deployment", spec: { replicas: 3, ports: [80, 443] } };
|
|
114
|
+
|
|
115
|
+
it("is 2-space pretty printed", () => {
|
|
116
|
+
expect(INDENT).toBe(" ");
|
|
117
|
+
expect(rawJson(payload).split("\n")[1]).toBe(' "kind": "Deployment",');
|
|
118
|
+
expect(rawJson(payload)).toContain('\n "replicas": 3');
|
|
119
|
+
});
|
|
120
|
+
it("round-trips: what you paste back parses to what you copied", () => {
|
|
121
|
+
expect(JSON.parse(rawJson(payload))).toEqual(payload);
|
|
122
|
+
});
|
|
123
|
+
it("copies the SUBTREE, not the document — the point of a per-node copy", () => {
|
|
124
|
+
expect(JSON.parse(rawJson(payload.spec))).toEqual({ replicas: 3, ports: [80, 443] });
|
|
125
|
+
expect(rawJson(payload.spec)).not.toContain("Deployment");
|
|
126
|
+
});
|
|
127
|
+
it("copies a scalar as its own JSON source", () => {
|
|
128
|
+
expect(rawJson("just a string")).toBe('"just a string"');
|
|
129
|
+
expect(rawJson(42)).toBe("42");
|
|
130
|
+
expect(rawJson(null)).toBe("null");
|
|
131
|
+
});
|
|
132
|
+
it("survives what JSON.stringify refuses", () => {
|
|
133
|
+
const cycle = { name: "loop" };
|
|
134
|
+
cycle.self = cycle;
|
|
135
|
+
expect(() => rawJson(cycle)).not.toThrow();
|
|
136
|
+
expect(rawJson(undefined)).toBe("undefined");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("truncateString — long strings shrink, and give the rest back on demand", () => {
|
|
141
|
+
it("leaves anything within the budget alone", () => {
|
|
142
|
+
expect(truncateString("short", 10)).toEqual({ head: "short", rest: "", truncated: false });
|
|
143
|
+
expect(truncateString("x".repeat(MAX_STRING)).truncated).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
it("splits at the budget, losing nothing", () => {
|
|
146
|
+
const long = "x".repeat(MAX_STRING + 40);
|
|
147
|
+
const { head, rest, truncated } = truncateString(long);
|
|
148
|
+
expect(truncated).toBe(true);
|
|
149
|
+
expect(head.length).toBe(MAX_STRING);
|
|
150
|
+
expect(head + rest).toBe(long); // the expander re-joins these
|
|
151
|
+
});
|
|
152
|
+
it("splits the RAW string, so an escape is never cut in half", () => {
|
|
153
|
+
// Escaping first would split `\n` into a lone backslash. The raw split
|
|
154
|
+
// keeps the character whole and the renderer escapes each half after.
|
|
155
|
+
const { head } = truncateString("a\nb", 2);
|
|
156
|
+
expect(head).toBe("a\n");
|
|
157
|
+
expect(escapeString(head)).toBe("a\\n");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("collapsedByDefault — deep OR large, and nothing else", () => {
|
|
162
|
+
const small = { a: 1 };
|
|
163
|
+
const wide = Object.fromEntries(Array.from({ length: BIG_ENTRIES + 1 }, (_, i) => [`k${i}`, i]));
|
|
164
|
+
|
|
165
|
+
it("opens the root and the depth-1 tier", () => {
|
|
166
|
+
expect(OPEN_DEPTH).toBe(1);
|
|
167
|
+
expect(collapsedByDefault(small, 0)).toBe(false);
|
|
168
|
+
expect(collapsedByDefault(small, 1)).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
it("folds everything below it", () => {
|
|
171
|
+
expect(collapsedByDefault(small, 2)).toBe(true);
|
|
172
|
+
expect(collapsedByDefault(small, 5)).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
it("folds a large container wherever it sits, root included", () => {
|
|
175
|
+
expect(countOf(wide)).toBeGreaterThan(BIG_ENTRIES);
|
|
176
|
+
expect(collapsedByDefault(wide, 0)).toBe(true);
|
|
177
|
+
expect(collapsedByDefault(wide, 1)).toBe(true);
|
|
178
|
+
expect(collapsedByDefault(Array.from({ length: BIG_ENTRIES + 1 }, (_, i) => i), 0)).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
it("never folds an empty container — `{}` has nothing to hide", () => {
|
|
181
|
+
expect(collapsedByDefault({}, 9)).toBe(false);
|
|
182
|
+
expect(collapsedByDefault([], 9)).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
it("never folds a scalar", () => {
|
|
185
|
+
expect(collapsedByDefault("a string long enough to matter", 9)).toBe(false);
|
|
186
|
+
expect(collapsedByDefault(null, 9)).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
it("takes the caller's tiers — the /api error card opens nothing (openDepth -1)", () => {
|
|
189
|
+
expect(collapsedByDefault(small, 0, { openDepth: -1 })).toBe(true);
|
|
190
|
+
expect(collapsedByDefault(small, 4, { openDepth: 6 })).toBe(false);
|
|
191
|
+
expect(collapsedByDefault({ a: 1, b: 2 }, 0, { big: 1 })).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("a real observed payload, end to end through the pure half", () => {
|
|
196
|
+
// The shape /api/diff hands renderObserved: a k8s object's attributes.
|
|
197
|
+
const observed = {
|
|
198
|
+
type: "k8s::Deployment",
|
|
199
|
+
attributes: {
|
|
200
|
+
replicas: 3,
|
|
201
|
+
spec: { template: { metadata: { labels: { app: "api", tier: "web" } } } },
|
|
202
|
+
conditions: ["Available=True", "Progressing=True"],
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
it("the deep label map is folded, its grandparent is not", () => {
|
|
207
|
+
expect(collapsedByDefault(observed.attributes.spec, 0)).toBe(false); // a dd's own root
|
|
208
|
+
expect(collapsedByDefault(observed.attributes.spec.template, 1)).toBe(false);
|
|
209
|
+
expect(collapsedByDefault(observed.attributes.spec.template.metadata, 2)).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
it("copying the fold copies only the fold", () => {
|
|
212
|
+
expect(JSON.parse(rawJson(observed.attributes.spec.template.metadata))).toEqual({ labels: { app: "api", tier: "web" } });
|
|
213
|
+
});
|
|
214
|
+
it("the conditions array reads as items, in order", () => {
|
|
215
|
+
expect(summaryOf(observed.attributes.conditions)).toBe("2 items");
|
|
216
|
+
expect(entriesOf(observed.attributes.conditions)[0]).toEqual(["0", "Available=True"]);
|
|
217
|
+
});
|
|
218
|
+
});
|