@intentius/behold 0.29.0 → 0.29.2
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 +17 -4
- package/dist/cli.js +281 -125
- package/package.json +1 -1
- package/web/app.js +348 -51
- package/web/embed.js +6 -3
- package/web/embed.test.js +15 -1
- package/web/index.html +7 -0
- package/web/kiosk-runs.js +485 -0
- package/web/kiosk-runs.test.js +98 -0
- package/web/kiosk.html +98 -29
- package/web/kiosk.js +177 -83
- package/web/kiosk.test.js +65 -0
- package/web/terragucci-change.js +148 -3
- package/web/terragucci-change.test.js +99 -0
- package/web/view-url.js +24 -2
- package/web/view-url.test.js +23 -0
package/web/terragucci-change.js
CHANGED
|
@@ -105,12 +105,18 @@ export function readTag(e) {
|
|
|
105
105
|
return `reads ${e.to} · ${e.composed !== undefined ? "in the composed run" : e.outputs}${e.unknown.length ? ` · ${e.unknown.length} unknown` : ""}`;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* The checkout's card id for a reader's data block, for each kind of block
|
|
110
|
+
* that reads another root. The run view names no data block (#569): there the
|
|
111
|
+
* block named after the root it reads is tried, and the caller falls back to
|
|
112
|
+
* the reader's box when the checkout has none.
|
|
113
|
+
*/
|
|
109
114
|
export function readCardIds(answer, e) {
|
|
110
|
-
|
|
115
|
+
const data = e.data || (e.stage === "apply" ? e.to : null);
|
|
116
|
+
if (!data || !answer || !answer.roots) return [];
|
|
111
117
|
const r = answer.roots.find((x) => x.path === e.from);
|
|
112
118
|
if (!r) return [];
|
|
113
|
-
return [`${r.member}/${r.name}/data.terraform_remote_state.${
|
|
119
|
+
return [`${r.member}/${r.name}/data.terraform_remote_state.${data}`, `${r.member}/${r.name}/data.terraform_estate_outputs.${data}`];
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
/** "5 create · 1 update", no-ops and zeros left out; "no changes" when nothing moves. */
|
|
@@ -421,3 +427,142 @@ export function changeSection(answer, progress, now, ui) {
|
|
|
421
427
|
}
|
|
422
428
|
return box;
|
|
423
429
|
}
|
|
430
|
+
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
// #569: the change drawn on the view itself, so the graph says what the panel
|
|
433
|
+
// says: each root's wave and its state on its box, each read between roots as
|
|
434
|
+
// a labelled line, and which run all of it comes from.
|
|
435
|
+
|
|
436
|
+
/** The colour a state is drawn in: one word per channel, mapped to a theme token in app.js. */
|
|
437
|
+
export const STATE_TONE = {
|
|
438
|
+
planned: "plan",
|
|
439
|
+
"not-started": "idle",
|
|
440
|
+
waiting: "wait",
|
|
441
|
+
applying: "busy",
|
|
442
|
+
applied: "ok",
|
|
443
|
+
refused: "bad",
|
|
444
|
+
failed: "bad",
|
|
445
|
+
outside: "idle",
|
|
446
|
+
unchanged: "idle",
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* #569 review: whether a root of a run-view wave plans a change. A root its
|
|
451
|
+
* wave planned without a change (`changed`, the roots whose plan changes a
|
|
452
|
+
* resource or an output) is never shown waiting, applying or failed: nothing
|
|
453
|
+
* of it is approved or applied. A composed run's member says for itself.
|
|
454
|
+
* Unknown (true) until the wave has planned, or on a run view without
|
|
455
|
+
* `changed`.
|
|
456
|
+
*/
|
|
457
|
+
export function rootChanges(w, root) {
|
|
458
|
+
const m = w.composed && w.composed.members ? w.composed.members.find((x) => x.root === root) : null;
|
|
459
|
+
if (m && m.apply) return m.apply !== "no-changes";
|
|
460
|
+
const planned = w.state !== "not-started" && (!!w.totals || !!w.digest);
|
|
461
|
+
if (!planned || !Array.isArray(w.changed)) return true;
|
|
462
|
+
return w.changed.includes(root);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The roots of a wave that plan a change, in the wave's order (all of them while that isn't known). */
|
|
466
|
+
export function changingRoots(w, roots) {
|
|
467
|
+
return roots.filter((r) => rootChanges(w, r));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** A member of a composed run, once it ended, as a root state. */
|
|
471
|
+
const MEMBER_STATE = { applied: "applied", partial: "failed", failed: "failed", "not-started": "not-started", "no-changes": "applied" };
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Each root's wave and state, for its box on the view. `roots` maps a root's
|
|
475
|
+
* path to `{ wave, state, tone, words, facts }`: "wave 2 · waiting for an
|
|
476
|
+
* approval", with the facts under it (the plan's digest it waits on, its
|
|
477
|
+
* changes, that it plans again after an earlier wave). A root the change
|
|
478
|
+
* doesn't plan, or the run doesn't apply, is `outside`. A root in a composed
|
|
479
|
+
* run that failed part way takes its own part's outcome: what applied says
|
|
480
|
+
* applied.
|
|
481
|
+
*/
|
|
482
|
+
export function rootStates(answer, progress) {
|
|
483
|
+
const stage = changeStage(answer, progress);
|
|
484
|
+
const roots = {};
|
|
485
|
+
const known = (answer && answer.roots ? answer.roots.map((r) => r.path) : []).filter(Boolean);
|
|
486
|
+
if (stage === "pull-request") {
|
|
487
|
+
const pr = answer.pull_request;
|
|
488
|
+
for (const w of pr.waves) {
|
|
489
|
+
const c = w.composed;
|
|
490
|
+
const members = c ? c.members.map((m) => m.root) : [];
|
|
491
|
+
for (const root of [...new Set([...w.roots, ...members])]) {
|
|
492
|
+
// #569 review: a root the pull request plans without a change, and that
|
|
493
|
+
// reads nothing unknown, shows no changes rather than a wave's plan.
|
|
494
|
+
const blast = pr.blast && Array.isArray(pr.blast.roots) ? pr.blast.roots : null;
|
|
495
|
+
const readsUnknown = (pr.reads || []).some((r) => r.root === root && r.unknown && r.unknown.length) || !!(c && c.held_by_unknown && c.held_by_unknown.includes(root));
|
|
496
|
+
if (blast && !blast.includes(root) && !readsUnknown) {
|
|
497
|
+
roots[root] = { wave: w.number, state: "unchanged", tone: STATE_TONE.unchanged, words: `wave ${w.number} · no changes`, facts: [] };
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const facts = [];
|
|
501
|
+
if (w.replans_after && w.replans_after.length) facts.push(`plans again once wave ${list(w.replans_after)} applies`);
|
|
502
|
+
else if (w.review_digest || w.set_digest) facts.push(`plan ${short(w.review_digest || w.set_digest)}`);
|
|
503
|
+
if (c && c.held_by_unknown && c.held_by_unknown.includes(root)) facts.push("reads a value unknown when planned");
|
|
504
|
+
if (w.waits) facts.push("will wait for an approval");
|
|
505
|
+
roots[root] = { wave: w.number, state: "planned", tone: STATE_TONE.planned, words: `wave ${w.number} · planned${c ? ", composed" : ""}`, facts };
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
for (const root of known) if (!roots[root]) roots[root] = { wave: null, state: "outside", tone: STATE_TONE.outside, words: "not in this change", facts: [] };
|
|
509
|
+
return { stage, roots };
|
|
510
|
+
}
|
|
511
|
+
if (stage === "apply") {
|
|
512
|
+
const composedOf = new Map(progress.reads.filter((r) => r.composed).map((r) => [r.root, r.composed.wave]));
|
|
513
|
+
for (const w of progress.states) {
|
|
514
|
+
const c = w.composed;
|
|
515
|
+
const inRun = [...composedOf].filter(([, n]) => n === w.number).map(([r]) => r);
|
|
516
|
+
for (const root of [...new Set([...w.roots, ...(c ? c.roots : []), ...inRun])]) {
|
|
517
|
+
if (!rootChanges(w, root)) {
|
|
518
|
+
roots[root] = { wave: w.number, state: "unchanged", tone: STATE_TONE.unchanged, words: `wave ${w.number} · no changes`, facts: [] };
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
const m = c && c.members ? c.members.find((x) => x.root === root) : null;
|
|
522
|
+
const state = m && m.apply ? MEMBER_STATE[m.apply] || w.state : w.state;
|
|
523
|
+
const facts = [];
|
|
524
|
+
if (state === "waiting" && w.digest) facts.push(`approve plan ${short(w.digest)}`);
|
|
525
|
+
if (state === "waiting" && c && c.unknown && c.unknown.includes(root)) facts.push("read a value unknown when planned");
|
|
526
|
+
const totals = totalsLine(w.totals);
|
|
527
|
+
const together = new Set([...w.roots, ...(c ? c.roots : []), ...inRun]).size > 1;
|
|
528
|
+
const composedHere = !!c && (c.roots.includes(root) || inRun.includes(root));
|
|
529
|
+
if (m && m.apply) facts.push(memberApplyLine(m).replace(`${root}: `, "")); // this root's own part, not the wave's
|
|
530
|
+
else if (c && !composedHere) {
|
|
531
|
+
// Beside a composed run in its wave: the wave's figures are the run's, not this root's.
|
|
532
|
+
} else if (totals && state !== "not-started") facts.push(totals === "no changes" ? `${together ? "the wave: " : ""}no changes` : `${together ? "the wave " : ""}${state === "applied" ? "applied" : state === "failed" || state === "refused" ? "planned" : "plans"} ${totals}`);
|
|
533
|
+
if (state === "failed" || state === "refused") {
|
|
534
|
+
const before = progress.states.filter((x) => x.number < w.number && x.state === "applied").map((x) => x.number);
|
|
535
|
+
if (before.length) facts.push(`wave ${list(before)} stays applied`);
|
|
536
|
+
}
|
|
537
|
+
const words = `wave ${w.number} · ${STATE_WORDS[state] || state}${composedHere ? ", composed" : ""}`;
|
|
538
|
+
roots[root] = { wave: w.number, state, tone: STATE_TONE[state] || "idle", words, facts };
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
for (const root of known) if (!roots[root]) roots[root] = { wave: null, state: "outside", tone: STATE_TONE.outside, words: "not in this run", facts: [] };
|
|
542
|
+
return { stage, roots };
|
|
543
|
+
}
|
|
544
|
+
return { stage, roots };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** The short label on a read's line: "reads network's planned http_proxy_port, unknown until network applies". */
|
|
548
|
+
export function readLabel(e) {
|
|
549
|
+
if (e.composed !== undefined) {
|
|
550
|
+
return `reads ${e.to} in wave ${e.composed}'s composed run${e.unknown.length ? `: ${list(e.unknown)} known once ${e.to}'s part applies` : ""}`;
|
|
551
|
+
}
|
|
552
|
+
if (e.outputs === "planned") return e.unknown.length ? `reads ${list(e.unknown)} from ${e.to}: unknown until ${e.to} applies` : `reads ${e.to}'s planned outputs`;
|
|
553
|
+
return `reads ${e.to}'s applied state`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Which run the view's marks come from, dated: "the plan of pull request #201, 03:11 UTC". */
|
|
557
|
+
export function viewCaption(answer, progress, now = new Date()) {
|
|
558
|
+
const stage = changeStage(answer, progress);
|
|
559
|
+
if (stage === "pull-request") {
|
|
560
|
+
const r = answer.pull_request.run;
|
|
561
|
+
return `the plan of pull request #${r.pull_request} at ${clock(r.finished, now)}: not merged yet, nothing applied`;
|
|
562
|
+
}
|
|
563
|
+
if (stage === "apply") {
|
|
564
|
+
const r = progress.run;
|
|
565
|
+
return `the run of merge ${r.commit.slice(0, 8)}, as of ${clock(r.updated, now)}`;
|
|
566
|
+
}
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readCardIds, readLabel, rootStates, viewCaption } from "./terragucci-change.js";
|
|
3
|
+
|
|
4
|
+
const roots = ["app", "edge", "network", "other"].map((name) => ({ member: "demo", name, path: name }));
|
|
5
|
+
const prAnswer = {
|
|
6
|
+
roots,
|
|
7
|
+
pull_request: {
|
|
8
|
+
run: { pull_request: "201", finished: "2026-10-15T03:11:13.605Z", commit: "c9dfc649c704" },
|
|
9
|
+
reads: [{ root: "app", upstream: "network", data: "network", outputs: "planned", unknown: ["http_proxy_port"] }],
|
|
10
|
+
roots: ["app", "edge", "network"],
|
|
11
|
+
waves: [
|
|
12
|
+
{ number: 1, roots: ["network"], state: "planned", review_digest: "jcs1-sha256:f8851fb619215c0eab", waits: false },
|
|
13
|
+
{ number: 2, roots: ["app"], state: "planned", review_digest: null, waits: true, replans_after: [1] },
|
|
14
|
+
{ number: 3, roots: ["edge"], state: "planned", review_digest: null, waits: true, replans_after: [2] },
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
const run = (states, reads) => ({ run: { present: true, commit: "6b9f7e255f6f", updated: "2026-10-15T03:20:00.000Z" }, states, reads });
|
|
19
|
+
|
|
20
|
+
describe("rootStates (#569)", () => {
|
|
21
|
+
it("puts each planned wave on its roots, and says which root the change leaves out", () => {
|
|
22
|
+
const { stage, roots: r } = rootStates(prAnswer, null);
|
|
23
|
+
expect(stage).toBe("pull-request");
|
|
24
|
+
expect(r.network).toMatchObject({ wave: 1, state: "planned", tone: "plan", words: "wave 1 · planned", facts: ["plan f8851fb61921"] });
|
|
25
|
+
expect(r.app.facts).toEqual(["plans again once wave 1 applies", "will wait for an approval"]);
|
|
26
|
+
expect(r.other).toMatchObject({ state: "outside", words: "not in this change" });
|
|
27
|
+
});
|
|
28
|
+
it("shows a failed wave's roots failed and the waves before it applied", () => {
|
|
29
|
+
const p = run(
|
|
30
|
+
[
|
|
31
|
+
{ number: 1, state: "applied", roots: ["network", "other"], reads: [], totals: { update: 1 } },
|
|
32
|
+
{ number: 2, state: "failed", roots: ["app"], reads: [1], totals: { create: 1, update: 1 } },
|
|
33
|
+
{ number: 3, state: "not-started", roots: ["edge"], reads: [2] },
|
|
34
|
+
],
|
|
35
|
+
[],
|
|
36
|
+
);
|
|
37
|
+
const { roots: r } = rootStates(prAnswer, p);
|
|
38
|
+
expect(r.network).toMatchObject({ state: "applied", tone: "ok", facts: ["the wave applied 1 update"] });
|
|
39
|
+
expect(r.app).toMatchObject({ state: "failed", tone: "bad", words: "wave 2 · failed", facts: ["planned 1 create · 1 update", "wave 1 stays applied"] });
|
|
40
|
+
expect(r.edge).toMatchObject({ state: "not-started", tone: "idle", facts: [] });
|
|
41
|
+
});
|
|
42
|
+
it("names the plan a waiting wave waits on", () => {
|
|
43
|
+
const p = run([{ number: 2, state: "waiting", roots: ["app"], reads: [1], digest: "jcs1-sha256:83b0533bb8085dfad2" }], []);
|
|
44
|
+
expect(rootStates(prAnswer, p).roots.app).toMatchObject({ state: "waiting", tone: "wait", facts: ["approve plan 83b0533bb808"] });
|
|
45
|
+
});
|
|
46
|
+
it("gives each root of a composed run that failed part way its own outcome", () => {
|
|
47
|
+
const p = run(
|
|
48
|
+
[{ number: 1, state: "failed", roots: ["network"], reads: [], composed: { roots: ["network", "app"], members: [{ root: "network", module: "module.network", apply: "applied", state: "written" }, { root: "app", module: "module.app", apply: "failed", state: "written" }] } }],
|
|
49
|
+
[{ root: "app", wave: 2, reads: ["network"], composed: { wave: 1, own_wave: 2, module: "module.app" } }],
|
|
50
|
+
);
|
|
51
|
+
const { roots: r } = rootStates(prAnswer, p);
|
|
52
|
+
expect(r.network).toMatchObject({ wave: 1, state: "applied", words: "wave 1 · applied, composed", facts: ["applied; state written"] });
|
|
53
|
+
expect(r.app).toMatchObject({ wave: 1, state: "failed" });
|
|
54
|
+
expect(r.edge.state).toBe("outside");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("rootStates, a root that plans no change (#569 review)", () => {
|
|
59
|
+
it("never shows a root its wave plans no change in as waiting", () => {
|
|
60
|
+
const p = run([{ number: 1, state: "waiting", roots: ["network", "other"], reads: [], digest: "jcs1-sha256:365a4f0d54d7aa", totals: { create: 5 }, changed: ["network"] }], []);
|
|
61
|
+
const { roots: r } = rootStates(prAnswer, p);
|
|
62
|
+
expect(r.network.state).toBe("waiting");
|
|
63
|
+
expect(r.other).toMatchObject({ state: "unchanged", tone: "idle", words: "wave 1 · no changes", facts: [] });
|
|
64
|
+
});
|
|
65
|
+
it("takes a composed member's own no-changes over the wave's state", () => {
|
|
66
|
+
const p = run([{ number: 1, state: "failed", roots: ["network"], reads: [], composed: { roots: ["network", "edge"], members: [{ root: "network", module: "m", apply: "failed" }, { root: "edge", module: "e", apply: "no-changes" }] } }], []);
|
|
67
|
+
expect(rootStates(prAnswer, p).roots.edge.state).toBe("unchanged");
|
|
68
|
+
});
|
|
69
|
+
it("shows a planned root outside the blast radius that reads nothing unknown as no changes", () => {
|
|
70
|
+
const a = { ...prAnswer, pull_request: { ...prAnswer.pull_request, blast: { roots: ["app", "network"], downstream: [] } } };
|
|
71
|
+
const { roots: r } = rootStates(a, null);
|
|
72
|
+
expect(r.edge.state).toBe("unchanged");
|
|
73
|
+
expect(r.app.state).toBe("planned");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("readLabel (#569)", () => {
|
|
78
|
+
it("says what is read and when it is known", () => {
|
|
79
|
+
expect(readLabel({ from: "app", to: "network", outputs: "planned", unknown: ["http_proxy_port"] })).toBe("reads http_proxy_port from network: unknown until network applies");
|
|
80
|
+
expect(readLabel({ from: "app", to: "network", outputs: "planned", unknown: [] })).toBe("reads network's planned outputs");
|
|
81
|
+
expect(readLabel({ from: "app", to: "network", outputs: "applied", unknown: [] })).toBe("reads network's applied state");
|
|
82
|
+
expect(readLabel({ from: "app", to: "network", outputs: "planned", unknown: ["subnet_id"], composed: 1 })).toBe("reads network in wave 1's composed run: subnet_id known once network's part applies");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("readCardIds (#569)", () => {
|
|
87
|
+
it("tries the block named after the root read when the run view names none", () => {
|
|
88
|
+
expect(readCardIds({ roots }, { from: "edge", to: "app", data: null, stage: "apply" })[0]).toBe("demo/edge/data.terraform_remote_state.app");
|
|
89
|
+
expect(readCardIds({ roots }, { from: "edge", to: "app", data: null, stage: "pull-request" })).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("viewCaption (#569)", () => {
|
|
94
|
+
it("says which run the marks come from", () => {
|
|
95
|
+
const now = new Date("2026-10-15T03:30:00Z");
|
|
96
|
+
expect(viewCaption(prAnswer, null, now)).toBe("the plan of pull request #201 at 03:11 UTC: not merged yet, nothing applied");
|
|
97
|
+
expect(viewCaption(prAnswer, run([{ number: 1, state: "applied", roots: ["network"], reads: [] }], []), now)).toBe("the run of merge 6b9f7e25, as of 03:20 UTC");
|
|
98
|
+
});
|
|
99
|
+
});
|
package/web/view-url.js
CHANGED
|
@@ -10,8 +10,13 @@
|
|
|
10
10
|
/** The view's keys, in the order they are written back. Anything else in the
|
|
11
11
|
* query (embed, theme, host, #476) is left where it is. `gates` is the env the
|
|
12
12
|
* gate strip reads when it isn't the graph's (#477); it is the view's because
|
|
13
|
-
* a host can move it with `behold:view`, and a reload has to keep that.
|
|
14
|
-
|
|
13
|
+
* a host can move it with `behold:view`, and a reload has to keep that.
|
|
14
|
+
* `focus` (#569) names the boxes the view brings forward, comma-separated
|
|
15
|
+
* (a terragucci repo's roots): the rest are drawn dimmed, nothing is hidden.
|
|
16
|
+
* `run` (#569) pins which terragucci run the view draws: a commit's run view,
|
|
17
|
+
* `none` (no run view: the pull request's plan) or `base` (nothing in
|
|
18
|
+
* flight); absent, the newest. */
|
|
19
|
+
export const VIEW_KEYS = ["member", "zoom", "env", "tier", "lens", "node", "radial", "gates", "focus", "run"];
|
|
15
20
|
|
|
16
21
|
/** An env name as the server takes one (src/workspace-gates.ts isEnvName). */
|
|
17
22
|
export function isEnvName(env) {
|
|
@@ -43,6 +48,10 @@ export function readViewQuery(search) {
|
|
|
43
48
|
want.env = v || null;
|
|
44
49
|
continue;
|
|
45
50
|
}
|
|
51
|
+
if (k === "focus" || k === "run") {
|
|
52
|
+
want[k] = v || null;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
46
55
|
if (v) want[k] = v;
|
|
47
56
|
}
|
|
48
57
|
return want;
|
|
@@ -71,6 +80,8 @@ export function writeViewQuery(search, state, defaults = {}) {
|
|
|
71
80
|
if (s.node) q.set("node", s.node);
|
|
72
81
|
if (s.radial) q.set("radial", "1");
|
|
73
82
|
if (s.gates) q.set("gates", s.gates);
|
|
83
|
+
if (s.focus) q.set("focus", s.focus);
|
|
84
|
+
if (s.run) q.set("run", s.run);
|
|
74
85
|
const out = q.toString();
|
|
75
86
|
return out ? `?${out}` : "";
|
|
76
87
|
}
|
|
@@ -112,6 +123,17 @@ export function settleView(want, offer) {
|
|
|
112
123
|
if (want.gates === null || isEnvName(want.gates)) apply.gates = want.gates;
|
|
113
124
|
else refused.push({ key: "gates", value: want.gates, reason: "an env name is letters, digits, '.', '_' and '-'" });
|
|
114
125
|
}
|
|
126
|
+
if ("focus" in want) {
|
|
127
|
+
// null: nothing brought forward. Otherwise box names, comma-separated.
|
|
128
|
+
const names = want.focus === null ? [] : String(want.focus).split(",").map((x) => x.trim()).filter(Boolean);
|
|
129
|
+
if (names.every((n) => /^[A-Za-z0-9][A-Za-z0-9._\/-]*$/.test(n))) apply.focus = names.length ? names.join(",") : null;
|
|
130
|
+
else refused.push({ key: "focus", value: want.focus, reason: "focus is box names, comma-separated" });
|
|
131
|
+
}
|
|
132
|
+
if ("run" in want) {
|
|
133
|
+
// null: the newest run, as without the key.
|
|
134
|
+
if (want.run === null || /^([0-9a-f]{7,40}|none|base)$/.test(String(want.run))) apply.run = want.run;
|
|
135
|
+
else refused.push({ key: "run", value: want.run, reason: "run is a commit sha, none or base" });
|
|
136
|
+
}
|
|
115
137
|
if (want.member) apply.member = want.member;
|
|
116
138
|
if (want.node) apply.node = want.node;
|
|
117
139
|
return { apply, refused };
|
package/web/view-url.test.js
CHANGED
|
@@ -107,3 +107,26 @@ describe("settlePlace (#475)", () => {
|
|
|
107
107
|
expect(refusalLine([])).toBe("");
|
|
108
108
|
});
|
|
109
109
|
});
|
|
110
|
+
|
|
111
|
+
describe("focus (#569)", () => {
|
|
112
|
+
it("reads, writes and settles a list of box names", () => {
|
|
113
|
+
expect(readViewQuery("?focus=app,edge")).toEqual({ focus: "app,edge" });
|
|
114
|
+
expect(readViewQuery("?focus=")).toEqual({ focus: null });
|
|
115
|
+
expect(writeViewQuery("?embed=1", { focus: "app,edge" })).toBe("?embed=1&focus=app%2Cedge");
|
|
116
|
+
expect(settleView({ focus: " app , edge " }, {}).apply).toEqual({ focus: "app,edge" });
|
|
117
|
+
expect(settleView({ focus: null }, {}).apply).toEqual({ focus: null });
|
|
118
|
+
});
|
|
119
|
+
it("refuses a focus that isn't box names", () => {
|
|
120
|
+
expect(settleView({ focus: "app,<b>" }, {}).refused[0].key).toBe("focus");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("run (#569)", () => {
|
|
125
|
+
it("takes a commit, none or base, and null for the newest", () => {
|
|
126
|
+
expect(readViewQuery("?run=abc1234")).toEqual({ run: "abc1234" });
|
|
127
|
+
expect(settleView({ run: "base" }, {}).apply).toEqual({ run: "base" });
|
|
128
|
+
expect(settleView({ run: null }, {}).apply).toEqual({ run: null });
|
|
129
|
+
expect(settleView({ run: "main" }, {}).refused[0].key).toBe("run");
|
|
130
|
+
expect(writeViewQuery("", { run: "none" })).toBe("?run=none");
|
|
131
|
+
});
|
|
132
|
+
});
|