feat(orchestrator): S3 generated SVG faces whose mouths move while speaking
Requirement 001 phase 6, S3:
- faces.js derives a face from the agent's stable key (its session id,
or name:<name> without a session), so a rename keeps the face: shape
4, colour 7, eyes 5, brows 4, mouth 5, hair or headwear 6, accessory 5,
84,000 combinations. It emits constant SVG tags and attributes only.
- The emoji avatar is gone: popup, Orbit and Chord nodes, the radio
view, stream and palette draw the SVG face (the 3D views keep their
marker).
- While the radio reads an agent's message, its faces carry the class
speaking and the mouth runs the talk keyframes; a silent face does
not move, and prefers-reduced-motion stops the mouth.
Evidence: make test 673 OK, ruff and markdownlint clean
(/tmp/s3-make-test-1.log); e2e 20 OK in Chromium and Firefox, none
skipped (/tmp/s3-e2e-3.log); screenshots
/tmp/orchestrator-phase4-shots/*-speaking.png; MEDUSA 13 files, 2
findings ("PLA" in "templates", the UI call openAgent) accepted after
an independent skeptic that found no markup sink or selector injection
in the face code.
🎯 Fix: 94 %
💥 Break: 3 %
💸 Cost: 100 %
⏱️ Time: –
🛡️ Security: 100 %
🏆 Score: 91
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d89fb616b8
commit
4d88296e47
13 files changed
+262
-34
No files matched your search
@@ -603,9 +603,9 @@ flowchart TD
|
|||||||
|
|
||||||
### Phase 6: faces (S3)
|
### Phase 6: faces (S3)
|
||||||
|
|
||||||
- [ ] The face generator yields at least 1000 distinct attribute
|
- [x] The face generator yields at least 1000 distinct attribute
|
||||||
combinations and the same face for the same agent id (unit test).
|
combinations and the same face for the same agent id (unit test).
|
||||||
- [ ] A speaking agent's face animates its mouth; a silent one does not
|
- [x] A speaking agent's face animates its mouth; a silent one does not
|
||||||
(DOM test on the class while the radio plays).
|
(DOM test on the class while the radio plays).
|
||||||
|
|
||||||
### Phase 6: night commands (S4)
|
### Phase 6: night commands (S4)
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
const Faces = (() => {
|
||||||
|
"use strict";
|
||||||
|
const SIZE = 40;
|
||||||
|
const INK = "#2b2622";
|
||||||
|
const dot = (cx, cy, rx, ry, fill) => ["ellipse", {cx, cy, rx, ry, fill}];
|
||||||
|
const line = (d, width) => ["path", {d, fill: "none", stroke: INK, "stroke-width": width, "stroke-linecap": "round", "stroke-linejoin": "round"}];
|
||||||
|
const blob = (d, fill) => ["path", {d, fill}];
|
||||||
|
const sclera = (cx) => ["ellipse", {cx, cy: 20, rx: 3.2, ry: 3, fill: "#fff", stroke: INK, "stroke-width": 0.8}];
|
||||||
|
const cheek = (cx) => ["ellipse", {cx, cy: 25, rx: 2.4, ry: 1.5, fill: "#f08a8a", opacity: 0.6}];
|
||||||
|
const SHAPES = [
|
||||||
|
["ellipse", {cx: 20, cy: 22, rx: 13, ry: 13}],
|
||||||
|
["ellipse", {cx: 20, cy: 23, rx: 11, ry: 14}],
|
||||||
|
["rect", {x: 7, y: 9, width: 26, height: 26, rx: 8}],
|
||||||
|
["ellipse", {cx: 20, cy: 22, rx: 15, ry: 12}],
|
||||||
|
];
|
||||||
|
const COLOURS = ["#f6d5b8", "#e3ac85", "#b97a55", "#7d4f33", "#f4cf5d", "#9fd4c7", "#c2b2f2"];
|
||||||
|
const EYES = [
|
||||||
|
[dot(15, 20, 1.8, 1.8, INK), dot(25, 20, 1.8, 1.8, INK)],
|
||||||
|
[dot(15, 20, 1.4, 2.5, INK), dot(25, 20, 1.4, 2.5, INK)],
|
||||||
|
[line("M12.5 21q2.5-3 5 0M22.5 21q2.5-3 5 0", 1.6)],
|
||||||
|
[line("M12.5 20.5h5M22.5 20.5h5", 1.6)],
|
||||||
|
[sclera(15), sclera(25), dot(15.6, 20.4, 1.4, 1.4, INK), dot(25.6, 20.4, 1.4, 1.4, INK)],
|
||||||
|
];
|
||||||
|
const BROWS = [
|
||||||
|
[],
|
||||||
|
[line("M12 15.5h6M22 15.5h6", 1.4)],
|
||||||
|
[line("M12 16q3-2.5 6 0M22 16q3-2.5 6 0", 1.4)],
|
||||||
|
[line("M12 14.5l6 1.5M28 14.5l-6 1.5", 1.4)],
|
||||||
|
];
|
||||||
|
const MOUTHS = [
|
||||||
|
blob("M14 27q6 6 12 0z", INK),
|
||||||
|
dot(20, 28, 2.2, 2, INK),
|
||||||
|
blob("M13 26q7 7 14 0q-7 2-14 0z", INK),
|
||||||
|
["rect", {x: 15, y: 27.2, width: 10, height: 1.8, rx: 0.9, fill: INK}],
|
||||||
|
blob("M15 28q5 3 10-2q-5 4.5-10 2z", INK),
|
||||||
|
];
|
||||||
|
const HAIR = [
|
||||||
|
[],
|
||||||
|
[blob("M7 19q0-12 13-12t13 12q-5-6-13-6t-13 6z", "#3b2a20")],
|
||||||
|
[blob("M9 15l2-8 3 5 3-7 3 6 3-6 3 7 3-5 2 8q-11-4-22 0z", "#d9a441")],
|
||||||
|
[blob("M8 14q12-14 24 0z", "#3f6fb5"), ["rect", {x: 17, y: 12.5, width: 19, height: 2.6, rx: 1.3, fill: "#2f568f"}]],
|
||||||
|
[blob("M8 13q12-17 24 0z", "#c0504d"), ["rect", {x: 7, y: 11.5, width: 26, height: 3, rx: 1.5, fill: "#9c3d3a"}], dot(20, 4.6, 2.6, 2.6, "#f1f1f1")],
|
||||||
|
[dot(20, 6.5, 4.5, 4, "#6b3f2a"), blob("M8 18q1-11 12-11t12 11q-4-5-12-5t-12 5z", "#6b3f2a")],
|
||||||
|
];
|
||||||
|
const EXTRAS = [
|
||||||
|
[],
|
||||||
|
[["path", {d: "M11 20a4 3.2 0 1 0 8 0a4 3.2 0 1 0-8 0M21 20a4 3.2 0 1 0 8 0a4 3.2 0 1 0-8 0M19 20h2", fill: "none", stroke: INK, "stroke-width": 1.1}]],
|
||||||
|
[cheek(12.5), cheek(27.5)],
|
||||||
|
[["rect", {x: 4.5, y: 18, width: 3.5, height: 8, rx: 1.75, fill: INK}], line("M6.5 25.5q1 5 7.5 4.5", 1.3)],
|
||||||
|
[12.5, 14.5, 12, 27.5, 25.5, 28].map((cx, index) => dot(cx, 24 + (index % 3) * 1.1, 0.75, 0.75, "#8a5a3c")),
|
||||||
|
];
|
||||||
|
const ATTRIBUTES = {shape: SHAPES, colour: COLOURS, eyes: EYES, brows: BROWS, mouth: MOUTHS, hair: HAIR, accessory: EXTRAS};
|
||||||
|
const COUNTS = Object.fromEntries(Object.entries(ATTRIBUTES).map(([name, list]) => [name, list.length]));
|
||||||
|
const COMBINATIONS = Object.values(COUNTS).reduce((product, count) => product * count, 1);
|
||||||
|
|
||||||
|
function hash(text) {
|
||||||
|
let value = 2166136261;
|
||||||
|
for (const char of String(text)) {
|
||||||
|
value = Math.imul(value ^ char.codePointAt(0), 16777619);
|
||||||
|
}
|
||||||
|
value = Math.imul(value ^ (value >>> 16), 0x85ebca6b);
|
||||||
|
value = Math.imul(value ^ (value >>> 13), 0xc2b2ae35);
|
||||||
|
return (value ^ (value >>> 16)) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param id - the agent's stable key: its session id, or name:<name> for an agent without a session.
|
||||||
|
* @returns {pick, parts}: pick holds the variant index of every attribute in ATTRIBUTES order; parts lists the
|
||||||
|
* [tag, attributes] SVG children of a 0 0 SIZE SIZE view box from back to front, the mouth carrying class "mouth".
|
||||||
|
*/
|
||||||
|
function of(id) {
|
||||||
|
let rest = hash(id);
|
||||||
|
const pick = {};
|
||||||
|
for (const [name, count] of Object.entries(COUNTS)) {
|
||||||
|
pick[name] = rest % count;
|
||||||
|
rest = Math.floor(rest / count);
|
||||||
|
}
|
||||||
|
const [tag, shape] = SHAPES[pick.shape];
|
||||||
|
const [mouth, lips] = MOUTHS[pick.mouth];
|
||||||
|
const parts = [
|
||||||
|
[tag, {...shape, fill: COLOURS[pick.colour], stroke: INK, "stroke-width": 0.8, "stroke-opacity": 0.4}],
|
||||||
|
...HAIR[pick.hair],
|
||||||
|
...EYES[pick.eyes],
|
||||||
|
...BROWS[pick.brows],
|
||||||
|
[mouth, {...lips, class: "mouth"}],
|
||||||
|
...EXTRAS[pick.accessory],
|
||||||
|
];
|
||||||
|
return {pick, parts};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {SIZE, COUNTS, COMBINATIONS, of};
|
||||||
|
})();
|
||||||
|
if (typeof module === "object" && module.exports) {
|
||||||
|
module.exports = Faces;
|
||||||
|
}
|
||||||
@@ -242,9 +242,6 @@ svg .node.k-chair circle { fill: var(--accent-soft); stroke: var(--accent); stro
|
|||||||
svg .node.k-subagent circle { fill: var(--violet-soft); stroke: var(--violet); }
|
svg .node.k-subagent circle { fill: var(--violet-soft); stroke: var(--violet); }
|
||||||
svg .node.claimed circle { stroke: var(--ok); stroke-width: 2.2; }
|
svg .node.claimed circle { stroke: var(--ok); stroke-width: 2.2; }
|
||||||
svg .label { fill: var(--text); font-size: var(--fs-sm); font-weight: 560; paint-order: stroke; stroke: var(--panel); stroke-width: 4px; stroke-linejoin: round; }
|
svg .label { fill: var(--text); font-size: var(--fs-sm); font-weight: 560; paint-order: stroke; stroke: var(--panel); stroke-width: 4px; stroke-linejoin: round; }
|
||||||
svg .initial { fill: var(--muted); font-size: var(--fs-sm); font-weight: 700; pointer-events: none; }
|
|
||||||
svg .k-operator .initial { fill: var(--on-accent); font-size: var(--fs-xl); }
|
|
||||||
svg .k-chair .initial { fill: var(--accent); font-size: var(--fs-lg); }
|
|
||||||
svg .group-arc { fill: none; stroke: var(--line-strong); stroke-width: 7; stroke-linecap: round; }
|
svg .group-arc { fill: none; stroke: var(--line-strong); stroke-width: 7; stroke-linecap: round; }
|
||||||
svg .group-arc.k-operator, svg .group-arc.k-chair { stroke: var(--accent); }
|
svg .group-arc.k-operator, svg .group-arc.k-chair { stroke: var(--accent); }
|
||||||
svg .tick { stroke: var(--c); stroke-linecap: round; opacity: .7; }
|
svg .tick { stroke: var(--c); stroke-linecap: round; opacity: .7; }
|
||||||
@@ -281,8 +278,9 @@ svg .lane-mark.tone-info { fill: var(--info); stroke: var(--info); }
|
|||||||
svg .lane-mark.tone-neutral { fill: var(--muted); stroke: var(--muted); }
|
svg .lane-mark.tone-neutral { fill: var(--muted); stroke: var(--muted); }
|
||||||
input[type="datetime-local"] { padding: 3px 6px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel); font-size: var(--fs-sm); }
|
input[type="datetime-local"] { padding: 3px 6px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel); font-size: var(--fs-sm); }
|
||||||
.custom { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
.custom { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||||
.avatar { display: inline-grid; place-items: center; flex: none; width: 26px; height: 26px; border-radius: 50%; background: var(--sunken); border: 1px solid var(--line); font-size: var(--fs-lg); line-height: 1; }
|
.avatar { display: inline-grid; place-items: center; flex: none; width: 26px; height: 26px; overflow: hidden; border-radius: 50%; background: var(--sunken); border: 1px solid var(--line); }
|
||||||
.avatar.big { width: 52px; height: 52px; font-size: var(--fs-3xl); }
|
.avatar.big { width: 52px; height: 52px; }
|
||||||
|
.avatar svg { display: block; width: 100%; height: 100%; }
|
||||||
.linkish { padding: 0; border: 0; background: none; color: inherit; font-weight: 650; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
|
.linkish { padding: 0; border: 0; background: none; color: inherit; font-weight: 650; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
|
||||||
.linkish:hover { color: var(--accent); text-decoration: underline; }
|
.linkish:hover { color: var(--accent); text-decoration: underline; }
|
||||||
.popup { position: fixed; inset: 0; z-index: 25; width: auto; height: auto; max-width: none; max-height: none; margin: 0; padding: 0; overflow: hidden; border: 0; background: transparent; color: var(--text); }
|
.popup { position: fixed; inset: 0; z-index: 25; width: auto; height: auto; max-width: none; max-height: none; margin: 0; padding: 0; overflow: hidden; border: 0; background: transparent; color: var(--text); }
|
||||||
@@ -453,6 +451,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
|||||||
{% include "nav.js" %}
|
{% include "nav.js" %}
|
||||||
|
|
||||||
{% include "popup.js" %}
|
{% include "popup.js" %}
|
||||||
|
|
||||||
|
{% include "faces.js" %}
|
||||||
</script>
|
</script>
|
||||||
<script nonce="{{ nonce }}">
|
<script nonce="{{ nonce }}">
|
||||||
{# Jinja drops each file's last newline and trim_blocks the one after the tag: the blank lines keep consecutive modules on separate lines #}
|
{# Jinja drops each file's last newline and trim_blocks the one after the tag: the blank lines keep consecutive modules on separate lines #}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
const DAY = 86400;
|
const DAY = 86400;
|
||||||
const HUMAN = "Operator";
|
const HUMAN = "Operator";
|
||||||
const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "all"], [-1, "custom"]];
|
const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "all"], [-1, "custom"]];
|
||||||
const EMOJI = ["🦊", "🐙", "🦉", "🐝", "🐢", "🦄", "🐳", "🦋", "🐧", "🦁", "🐼", "🐨", "🦜", "🐬", "🦀", "🐸", "🦔", "🦩", "🐞", "🌵", "🍀", "🌻", "🍄", "🌙", "⭐", "🔥", "🌊", "⚡", "🎈", "🎲", "🧭", "🚀", "🪐", "🔭", "🧩", "🎯", "🪁", "🧪", "🔮", "🗿", "🌋", "🍉", "🍋", "🥨", "🐋", "🦚", "🐌", "🌈"];
|
|
||||||
const PARTS = [["user", "User"], ["assistant", "Assistant"], ["tool_call", "Tool call"], ["tool_result", "Tool result"], ["system", "System"]];
|
const PARTS = [["user", "User"], ["assistant", "Assistant"], ["tool_call", "Tool call"], ["tool_result", "Tool result"], ["system", "System"]];
|
||||||
const PART_TONES = {user: "info", assistant: "ok", tool_call: "violet", tool_result: "teal", system: "neutral"};
|
const PART_TONES = {user: "info", assistant: "ok", tool_call: "violet", tool_result: "teal", system: "neutral"};
|
||||||
const KIND_TONES = {radio: "teal", review: "info", ask: "warn", decision: "violet", operator: "bad", register: "neutral", rename: "info", cron: "teal", rule: "ok", approval: "ok", run: "info", transcript: "neutral"};
|
const KIND_TONES = {radio: "teal", review: "info", ask: "warn", decision: "violet", operator: "bad", register: "neutral", rename: "info", cron: "teal", rule: "ok", approval: "ok", run: "info", transcript: "neutral"};
|
||||||
|
|||||||
@@ -68,14 +68,13 @@
|
|||||||
updated.textContent = `${ui.offline ? "No connection · " : ""}${live() ? "Live" : "As of"} ${absolute.format(stampOf(live() ? state.now : state.until))}`;
|
updated.textContent = `${ui.offline ? "No connection · " : ""}${live() ? "Live" : "As of"} ${absolute.format(stampOf(live() ? state.now : state.until))}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function avatar(name) {
|
const faceParts = (key) => Faces.of(key).parts.map(([tag, attrs]) => s(tag, attrs));
|
||||||
let hash = 2166136261;
|
const talking = (key) => Boolean(speech.current && speech.current.who === key);
|
||||||
for (const char of String(name)) {
|
const face = (who, big) => {
|
||||||
hash = Math.imul(hash ^ char.codePointAt(0), 16777619) >>> 0;
|
const key = agentKey(who);
|
||||||
}
|
return h("span", {class: `avatar${big ? " big" : ""}${talking(key) ? " speaking" : ""}`, "data-face": key, "aria-hidden": "true"},
|
||||||
return EMOJI[hash % EMOJI.length];
|
s("svg", {class: "face-art", viewBox: `0 0 ${Faces.SIZE} ${Faces.SIZE}`, focusable: "false"}, faceParts(key)));
|
||||||
}
|
};
|
||||||
const face = (name, big) => h("span", {class: big ? "avatar big" : "avatar", "aria-hidden": "true"}, avatar(name));
|
|
||||||
const person = (name) => (name && name !== "-" && name !== "all" && name !== HUMAN ? h("button", {type: "button", class: "linkish", title: `Open ${name}`, on: {click: () => openAgent(name)}}, name) : dash(name));
|
const person = (name) => (name && name !== "-" && name !== "all" && name !== HUMAN ? h("button", {type: "button", class: "linkish", title: `Open ${name}`, on: {click: () => openAgent(name)}}, name) : dash(name));
|
||||||
const people = () => state.people || {};
|
const people = () => state.people || {};
|
||||||
const labelOf = (key) => (people()[key] || {name: String(key).replace(/^name:/, "")}).name;
|
const labelOf = (key) => (people()[key] || {name: String(key).replace(/^name:/, "")}).name;
|
||||||
|
|||||||
@@ -137,7 +137,7 @@
|
|||||||
},
|
},
|
||||||
s("title", {}, `${node.label} · ${NODE_KINDS[node.kind]}${node.parent ? ` of ${labelOf(node.parent)}` : ""}${tasks}`),
|
s("title", {}, `${node.label} · ${NODE_KINDS[node.kind]}${node.parent ? ` of ${labelOf(node.parent)}` : ""}${tasks}`),
|
||||||
s("circle", {r}),
|
s("circle", {r}),
|
||||||
node.kind === "subagent" ? null : s("text", {class: "initial", "text-anchor": "middle", dy: "0.35em"}, node.label.slice(0, 1).toUpperCase()),
|
node.kind === "subagent" ? null : s("g", {class: "face-art", transform: `translate(${-r} ${-r}) scale(${(2 * r) / Faces.SIZE})`}, faceParts(node.id)),
|
||||||
caption(node, at, r, gap));
|
caption(node, at, r, gap));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
...["agents", "artifacts", "tasks"].flatMap((screen) => Nav.viewsOf(screen).map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))),
|
...["agents", "artifacts", "tasks"].flatMap((screen) => Nav.viewsOf(screen).map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))),
|
||||||
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Category", run: () => useView(id)})),
|
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Category", run: () => useView(id)})),
|
||||||
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "saved view", run: () => useSaved(view)})),
|
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "saved view", run: () => useSaved(view)})),
|
||||||
...agentNames().map((name) => ({label: `${avatar(name)} ${name}`, hint: "Agent", run: () => openAgent(name)})),
|
...agentNames().map((name) => ({label: name, hint: "Agent", face: name, run: () => openAgent(name)})),
|
||||||
{label: "Radio to all …", hint: "Newsletter, Broadcast, Pan-Pan, Security, Mayday", run: openToAll},
|
{label: "Radio to all …", hint: "Newsletter, Broadcast, Pan-Pan, Security, Mayday", run: openToAll},
|
||||||
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Day/Night", run: () => confirmAct(id)})),
|
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Day/Night", run: () => confirmAct(id)})),
|
||||||
{label: "Priority …", hint: "agent order in the hierarchy", run: openAgentOrder},
|
{label: "Priority …", hint: "agent order in the hierarchy", run: openAgentOrder},
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
drawPalette();
|
drawPalette();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}}, h("span", {}, item.label), h("span", {class: "muted"}, item.hint))));
|
}}, h("span", {class: item.face ? "who" : null}, item.face ? face(item.face, false) : null, item.label), h("span", {class: "muted"}, item.hint))));
|
||||||
const current = palette.list.children[palette.index];
|
const current = palette.list.children[palette.index];
|
||||||
if (current) {
|
if (current) {
|
||||||
current.scrollIntoView({block: "nearest"});
|
current.scrollIntoView({block: "nearest"});
|
||||||
|
|||||||
@@ -57,18 +57,21 @@
|
|||||||
|
|
||||||
function speakerChips() {
|
function speakerChips() {
|
||||||
return Object.entries(people()).sort(([, a], [, b]) => a.now.localeCompare(b.now)).map(([key, entry]) => h("li", {class: "speaker", "data-key": key, title: entry.now},
|
return Object.entries(people()).sort(([, a], [, b]) => a.now.localeCompare(b.now)).map(([key, entry]) => h("li", {class: "speaker", "data-key": key, title: entry.now},
|
||||||
h("span", {class: "speaker-face"}, face(entry.now, false)), h("span", {class: "speaker-name"}, entry.now)));
|
h("span", {class: "speaker-face"}, face(key, false)), h("span", {class: "speaker-name"}, entry.now)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawSpeaker() {
|
function drawSpeaker() {
|
||||||
const now = speech.current && speech.current.who ? speech.current : null;
|
const now = speech.current && speech.current.who ? speech.current : null;
|
||||||
|
for (const art of document.querySelectorAll(".avatar[data-face]")) {
|
||||||
|
art.classList.toggle("speaking", Boolean(now && art.dataset.face === now.who));
|
||||||
|
}
|
||||||
if (radioDom) {
|
if (radioDom) {
|
||||||
for (const chip of radioDom.speakers.querySelectorAll(".speaker")) {
|
for (const chip of radioDom.speakers.querySelectorAll(".speaker")) {
|
||||||
chip.classList.toggle("speaking", Boolean(now && chip.dataset.key === now.who));
|
chip.classList.toggle("speaking", Boolean(now && chip.dataset.key === now.who));
|
||||||
}
|
}
|
||||||
radioDom.caption.hidden = !now;
|
radioDom.caption.hidden = !now;
|
||||||
radioDom.caption.replaceChildren(...(now ? [
|
radioDom.caption.replaceChildren(...(now ? [
|
||||||
h("span", {class: "speaker-face"}, face(nameOf(now.who), false)),
|
h("span", {class: "speaker-face"}, face(now.who, false)),
|
||||||
h("strong", {}, `${nameOf(now.who)} speaking`),
|
h("strong", {}, `${nameOf(now.who)} speaking`),
|
||||||
h("span", {class: "caption-line", title: now.line}, now.line),
|
h("span", {class: "caption-line", title: now.line}, now.line),
|
||||||
iconBtn("bi-stop-fill", "Stop speaking", "btn small ghost", cancelSpeech),
|
iconBtn("bi-stop-fill", "Stop speaking", "btn small ghost", cancelSpeech),
|
||||||
|
|||||||
@@ -19,8 +19,11 @@
|
|||||||
.caption-line { flex: 1; min-width: 0; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
|
.caption-line { flex: 1; min-width: 0; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||||
svg .node.speaking circle { stroke: var(--ok); stroke-width: 4; animation: speaking-node 1.3s ease-in-out infinite; }
|
svg .node.speaking circle { stroke: var(--ok); stroke-width: 4; animation: speaking-node 1.3s ease-in-out infinite; }
|
||||||
@keyframes speaking-node { 50% { stroke-width: 10; stroke-opacity: .45; } }
|
@keyframes speaking-node { 50% { stroke-width: 10; stroke-opacity: .45; } }
|
||||||
|
.face-art .mouth { transform-box: fill-box; transform-origin: center; }
|
||||||
|
.speaking .face-art .mouth { animation: talk .3s ease-in-out infinite alternate; }
|
||||||
|
@keyframes talk { to { transform: scale(1.15, 2.2); } }
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.speaker.speaking .speaker-face::after, .radio-caption .speaker-face::after, svg .node.speaking circle { animation: none; }
|
.speaker.speaking .speaker-face::after, .radio-caption .speaker-face::after, svg .node.speaking circle, .speaking .face-art .mouth { animation: none; }
|
||||||
}
|
}
|
||||||
.stations { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
|
.stations { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
|
||||||
.station { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto auto; align-items: center; gap: var(--sp-2); min-width: 0; padding: 3px 3px 3px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--sunken); }
|
.station { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto auto; align-items: center; gap: var(--sp-2); min-width: 0; padding: 3px 3px 3px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--sunken); }
|
||||||
|
|||||||
@@ -243,7 +243,9 @@ POPUP_ORBIT = """(async () => {@HELPERS@@POPUPS@
|
|||||||
const flat = !tall();
|
const flat = !tall();
|
||||||
click(pick());
|
click(pick());
|
||||||
await sleep(400);
|
await sleep(400);
|
||||||
const shown = {...opened(), ...look(pick()), sections: all("h3", box).map((head) => head.firstChild.textContent), composer: Boolean(box.querySelector("form textarea")), offices: text(box.querySelector(".popup-body p"))};
|
const art = box.querySelector(".popup-head .avatar.big");
|
||||||
|
const face = art ? {key: art.dataset.face, svg: Boolean(art.querySelector("svg.face-art .mouth")), text: art.textContent} : null;
|
||||||
|
const shown = {...opened(), ...look(pick()), sections: all("h3", box).map((head) => head.firstChild.textContent), composer: Boolean(box.querySelector("form textarea")), offices: text(box.querySelector(".popup-body p")), face};
|
||||||
const gone = await away(pick);
|
const gone = await away(pick);
|
||||||
click(pick());
|
click(pick());
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
@@ -491,12 +493,18 @@ SPEAKING = """
|
|||||||
return node && !node.hidden ? node : null;
|
return node && !node.hidden ? node : null;
|
||||||
};
|
};
|
||||||
const ring = (chip) => (chip ? getComputedStyle(chip.querySelector(".speaker-face"), "::after").animationName : null);
|
const ring = (chip) => (chip ? getComputedStyle(chip.querySelector(".speaker-face"), "::after").animationName : null);
|
||||||
|
const mouth = (art) => (art ? [art.classList.contains("speaking"), getComputedStyle(art.querySelector(".mouth")).animationName] : null);
|
||||||
|
const faces = () => ({
|
||||||
|
speaking: mouth(document.querySelector('#main .speakers .avatar[data-face="s2"]')),
|
||||||
|
silent: mouth(document.querySelector('#main .speakers .avatar[data-face="s1"]')),
|
||||||
|
keys: [...new Set(all("#main .avatar.speaking").map((art) => art.dataset.face))],
|
||||||
|
});
|
||||||
const cue = async (word) => {
|
const cue = async (word) => {
|
||||||
await until(() => document.querySelector("#main .speakers .speaker"), 8000);
|
await until(() => document.querySelector("#main .speakers .speaker"), 8000);
|
||||||
await fetch("/api/tts", {method: "POST", headers: {"X-Kvb-Orchestrator": "1", "Content-Type": "application/json"}, body: JSON.stringify({text: word, voice: ""}), cache: "no-store"});
|
await fetch("/api/tts", {method: "POST", headers: {"X-Kvb-Orchestrator": "1", "Content-Type": "application/json"}, body: JSON.stringify({text: word, voice: ""}), cache: "no-store"});
|
||||||
const found = await until(caption, 25000);
|
const found = await until(caption, 25000);
|
||||||
const chip = document.querySelector("#main .speaker.speaking");
|
const chip = document.querySelector("#main .speaker.speaking");
|
||||||
return found ? {who: text(found.querySelector("strong")), line: text(found.querySelector(".caption-line")), chip: chip ? chip.dataset.key : null, ring: ring(chip)} : {error: "no caption", toasts: all("#toasts .toast").map(text)};
|
return found ? {who: text(found.querySelector("strong")), line: text(found.querySelector(".caption-line")), chip: chip ? chip.dataset.key : null, ring: ring(chip), faces: faces()} : {error: "no caption", toasts: all("#toasts .toast").map(text)};
|
||||||
};
|
};
|
||||||
"""
|
"""
|
||||||
SPEAK = """(async () => {@HELPERS@@SPEAKING@
|
SPEAK = """(async () => {@HELPERS@@SPEAKING@
|
||||||
@@ -505,14 +513,18 @@ SPEAK = """(async () => {@HELPERS@@SPEAKING@
|
|||||||
SPEAK_GRAPH = """(async () => {@HELPERS@@SPEAKING@
|
SPEAK_GRAPH = """(async () => {@HELPERS@@SPEAKING@
|
||||||
location.hash = "#agents/orbit";
|
location.hash = "#agents/orbit";
|
||||||
const node = await until(() => document.querySelector("#main svg .node.speaking"), 6000);
|
const node = await until(() => document.querySelector("#main svg .node.speaking"), 6000);
|
||||||
return {node: node ? node.getAttribute("aria-label") : null, ring: node ? getComputedStyle(node.querySelector("circle")).animationName : null};
|
const silent = document.querySelector('#main svg .node[data-pick="s1"] .mouth');
|
||||||
|
return {
|
||||||
|
node: node ? node.getAttribute("aria-label") : null, ring: node ? getComputedStyle(node.querySelector("circle")).animationName : null,
|
||||||
|
mouth: node ? getComputedStyle(node.querySelector(".mouth")).animationName : null, silent: silent ? getComputedStyle(silent).animationName : null,
|
||||||
|
};
|
||||||
})()""".replace("@HELPERS@", HELPERS).replace("@SPEAKING@", SPEAKING)
|
})()""".replace("@HELPERS@", HELPERS).replace("@SPEAKING@", SPEAKING)
|
||||||
SPEAK_END = """(async () => {@HELPERS@@SPEAKING@
|
SPEAK_END = """(async () => {@HELPERS@@SPEAKING@
|
||||||
const ended = Boolean(await until(() => !document.querySelector("#main svg .node") || !document.querySelector("#main svg .node.speaking"), 20000));
|
const ended = Boolean(await until(() => !document.querySelector("#main svg .node") || !document.querySelector("#main svg .node.speaking"), 20000));
|
||||||
location.hash = "#stream/radio";
|
location.hash = "#stream/radio";
|
||||||
await until(() => document.querySelector("#main .radio-caption"), 6000);
|
await until(() => document.querySelector("#main .radio-caption"), 6000);
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
return {ended, caption: Boolean(caption()), chips: all("#main .speaker.speaking").length};
|
return {ended, caption: Boolean(caption()), chips: all("#main .speaker.speaking").length, faces: all("#main .avatar.speaking").length};
|
||||||
})()""".replace("@HELPERS@", HELPERS).replace("@SPEAKING@", SPEAKING)
|
})()""".replace("@HELPERS@", HELPERS).replace("@SPEAKING@", SPEAKING)
|
||||||
COMPOSE = """(async () => {@HELPERS@
|
COMPOSE = """(async () => {@HELPERS@
|
||||||
const form = await until(() => document.querySelector("#main form.composer"), 8000);
|
const form = await until(() => document.querySelector("#main form.composer"), 8000);
|
||||||
@@ -908,6 +920,7 @@ class TestOrchestratorE2E(HubCase):
|
|||||||
self.assertNotIn("error", artifact)
|
self.assertNotIn("error", artifact)
|
||||||
self.assertTrue(agent["flat"], "#agents/orbit scrolls")
|
self.assertTrue(agent["flat"], "#agents/orbit scrolls")
|
||||||
self.popped(agent, "Stage")
|
self.popped(agent, "Stage")
|
||||||
|
self.assertEqual(agent["shown"]["face"], {"key": "s2", "svg": True, "text": ""})
|
||||||
self.assertEqual(agent["shown"]["sections"], ["Tasks", "Claims", "Subagents", "History"])
|
self.assertEqual(agent["shown"]["sections"], ["Tasks", "Claims", "Subagents", "History"])
|
||||||
self.assertTrue(agent["shown"]["composer"])
|
self.assertTrue(agent["shown"]["composer"])
|
||||||
self.assertRegex(agent["shown"]["offices"], r"^Offices: .*identity/oidc")
|
self.assertRegex(agent["shown"]["offices"], r"^Offices: .*identity/oidc")
|
||||||
@@ -1079,11 +1092,32 @@ class TestOrchestratorE2E(HubCase):
|
|||||||
(OUT / "chromium-speaking.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
(OUT / "chromium-speaking.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||||
self.assertEqual(data["errors"], [])
|
self.assertEqual(data["errors"], [])
|
||||||
speaking, graph, ended, calm = data["results"]
|
speaking, graph, ended, calm = data["results"]
|
||||||
self.assertEqual((speaking["who"], speaking["line"], speaking["chip"], speaking["ring"]), ("Stage speaking", "Disk almost full (e2e-cue-1)", "s2", "speaking"), speaking)
|
self.talks(speaking, graph)
|
||||||
self.assertEqual((graph["node"] or "").split(",")[0], "Stage", graph)
|
self.assertEqual(ended, {"ended": True, "caption": False, "chips": 0, "faces": 0})
|
||||||
self.assertEqual(graph["ring"], "speaking-node")
|
|
||||||
self.assertEqual(ended, {"ended": True, "caption": False, "chips": 0})
|
|
||||||
self.assertEqual((calm["who"], calm["line"], calm["ring"]), ("Stage speaking", "Disk almost full (e2e-cue-2)", "none"), calm)
|
self.assertEqual((calm["who"], calm["line"], calm["ring"]), ("Stage speaking", "Disk almost full (e2e-cue-2)", "none"), calm)
|
||||||
|
self.assertEqual(calm["faces"]["speaking"], [True, "none"], calm)
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
|
||||||
|
def test_firefox_shows_who_speaks_and_moves_only_the_speakers_mouth(self):
|
||||||
|
self.speaks(("e2e-cue-1",))
|
||||||
|
steps = [
|
||||||
|
{"width": 1366, "height": 768, "url": f"{self.base}#stream/radio", "wait": 900, "probe": SPEAK.replace("@CUE@", "e2e-cue-1"), "shot": str(OUT / "firefox-1366-radio-speaking.png"), "gesture": True},
|
||||||
|
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": SPEAK_GRAPH, "shot": str(OUT / "firefox-1366-agents-speaking.png")},
|
||||||
|
]
|
||||||
|
data = self.drive(steps, None, "bidi.mjs", os.environ.get("FIREFOX", "firefox"))
|
||||||
|
(OUT / "firefox-speaking.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||||
|
self.assertEqual([error for error in data["errors"] if "/favicon.ico" not in error], [])
|
||||||
|
self.talks(*data["results"])
|
||||||
|
|
||||||
|
def talks(self, speaking: dict, graph: dict) -> None:
|
||||||
|
"""Args:
|
||||||
|
speaking: the SPEAK probe while Stage (s2) is read aloud.
|
||||||
|
graph: the SPEAK_GRAPH probe on #agents/orbit right after it.
|
||||||
|
"""
|
||||||
|
self.assertEqual((speaking["who"], speaking["line"], speaking["chip"], speaking["ring"]), ("Stage speaking", "Disk almost full (e2e-cue-1)", "s2", "speaking"), speaking)
|
||||||
|
self.assertEqual(speaking["faces"], {"speaking": [True, "talk"], "silent": [False, "none"], "keys": ["s2"]}, speaking)
|
||||||
|
self.assertEqual((graph["node"] or "").split(",")[0], "Stage", graph)
|
||||||
|
self.assertEqual((graph["ring"], graph["mouth"], graph["silent"]), ("speaking-node", "talk", "none"), graph)
|
||||||
|
|
||||||
def test_chromium_records_transcribes_and_sends_a_signed_call_with_voice(self):
|
def test_chromium_records_transcribes_and_sends_a_signed_call_with_voice(self):
|
||||||
local = f"http://localhost:{self.port}/"
|
local = f"http://localhost:{self.port}/"
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ VOICE = HUB / "templates" / "voice.js"
|
|||||||
SPACE = HUB / "templates" / "space.js"
|
SPACE = HUB / "templates" / "space.js"
|
||||||
NAV = HUB / "templates" / "nav.js"
|
NAV = HUB / "templates" / "nav.js"
|
||||||
POPUP = HUB / "templates" / "popup.js"
|
POPUP = HUB / "templates" / "popup.js"
|
||||||
|
FACES = HUB / "templates" / "faces.js"
|
||||||
|
|
||||||
|
|
||||||
class FakeScripts:
|
class FakeScripts:
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from hub_support import FACES
|
||||||
|
|
||||||
|
FACES_PROBE = """
|
||||||
|
const F = require(process.argv[1]);
|
||||||
|
let seed = 7;
|
||||||
|
const hex = (digits) => Array.from({length: digits}, () => {
|
||||||
|
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
||||||
|
return (seed >>> 28).toString(16);
|
||||||
|
}).join("");
|
||||||
|
const ids = Array.from({length: 5000}, () => `${hex(8)}-${hex(4)}-${hex(4)}-${hex(4)}-${hex(12)}`);
|
||||||
|
const fixed = ["s1", "s2", "name:Stage", "Operator", "", "名前"];
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
size: F.SIZE,
|
||||||
|
counts: F.COUNTS,
|
||||||
|
combinations: F.COMBINATIONS,
|
||||||
|
picks: ids.map((id) => F.of(id).pick),
|
||||||
|
again: ids.slice(0, 200).every((id) => JSON.stringify(F.of(id)) === JSON.stringify(F.of(id))),
|
||||||
|
fixed: fixed.map((id) => F.of(id)),
|
||||||
|
parts: ids.slice(0, 400).map((id) => F.of(id).parts),
|
||||||
|
}));
|
||||||
|
"""
|
||||||
|
ATTRIBUTES = ["shape", "colour", "eyes", "brows", "mouth", "hair", "accessory"]
|
||||||
|
TAGS = {"ellipse", "rect", "path"}
|
||||||
|
COLOUR = re.compile(r"^#[0-9a-f]{3}(?:[0-9a-f]{3})?$")
|
||||||
|
|
||||||
|
|
||||||
|
def probe() -> dict:
|
||||||
|
done = subprocess.run(["node", "-e", FACES_PROBE, str(FACES)], capture_output=True, text=True, timeout=60, check=True)
|
||||||
|
return json.loads(done.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFacesModule(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.data = probe()
|
||||||
|
cls.rerun = probe()
|
||||||
|
|
||||||
|
def test_seven_independent_attributes_multiply_to_at_least_1000_combinations(self):
|
||||||
|
counts = self.data["counts"]
|
||||||
|
self.assertEqual(list(counts), ATTRIBUTES)
|
||||||
|
self.assertTrue(all(count >= 2 for count in counts.values()), counts)
|
||||||
|
self.assertEqual(self.data["combinations"], math.prod(counts.values()))
|
||||||
|
self.assertGreaterEqual(self.data["combinations"], 1000)
|
||||||
|
|
||||||
|
def test_many_ids_reach_at_least_1000_distinct_faces(self):
|
||||||
|
distinct = {tuple(pick[name] for name in ATTRIBUTES) for pick in self.data["picks"]}
|
||||||
|
self.assertGreaterEqual(len(distinct), 1000)
|
||||||
|
self.assertGreater(len(distinct), len(self.data["picks"]) * 0.9)
|
||||||
|
|
||||||
|
def test_the_same_id_gets_the_same_face_in_every_call_and_every_run(self):
|
||||||
|
self.assertTrue(self.data["again"])
|
||||||
|
self.assertEqual(self.data["fixed"], self.rerun["fixed"])
|
||||||
|
self.assertEqual(self.data["picks"], self.rerun["picks"])
|
||||||
|
self.assertEqual(len({json.dumps(face["pick"]) for face in self.data["fixed"]}), len(self.data["fixed"]))
|
||||||
|
|
||||||
|
def test_different_ids_spread_evenly_over_every_variant_of_every_attribute(self):
|
||||||
|
total = len(self.data["picks"])
|
||||||
|
for name, count in self.data["counts"].items():
|
||||||
|
seen = Counter(pick[name] for pick in self.data["picks"])
|
||||||
|
with self.subTest(attribute=name):
|
||||||
|
self.assertEqual(sorted(seen), list(range(count)))
|
||||||
|
for variant, hits in seen.items():
|
||||||
|
self.assertTrue(0.6 < hits * count / total < 1.4, (variant, hits))
|
||||||
|
|
||||||
|
def test_the_svg_description_has_a_coloured_head_one_mouth_and_no_missing_values(self):
|
||||||
|
for parts in [*self.data["parts"], *(face["parts"] for face in self.data["fixed"])]:
|
||||||
|
with self.subTest(parts=parts[0]):
|
||||||
|
self.assertEqual({tag for tag, _ in parts} - TAGS, set())
|
||||||
|
self.assertRegex(parts[0][1]["fill"], COLOUR)
|
||||||
|
self.assertEqual([attrs.get("class") for _, attrs in parts].count("mouth"), 1)
|
||||||
|
for _, attrs in parts:
|
||||||
|
for key, value in attrs.items():
|
||||||
|
if isinstance(value, str):
|
||||||
|
self.assertTrue(value and "undefined" not in value and "NaN" not in value, (key, value))
|
||||||
|
else:
|
||||||
|
self.assertIsInstance(value, (int, float), (key, value))
|
||||||
|
self.assertTrue(math.isfinite(value) and -1 <= value <= self.data["size"] + 1, (key, value))
|
||||||
|
|
||||||
|
def test_faces_draw_no_circle_because_graph_node_styles_target_every_circle_in_a_node(self):
|
||||||
|
tags = {tag for parts in self.data["parts"] for tag, _ in parts}
|
||||||
|
self.assertNotIn("circle", tags)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from hub_case import HubCase
|
from hub_case import HubCase
|
||||||
from hub_support import AMBIENCE, NAV, POPUP, SPACE, VOICE
|
from hub_support import AMBIENCE, FACES, NAV, POPUP, SPACE, VOICE
|
||||||
from kvb_orchestrator import exchange
|
from kvb_orchestrator import exchange
|
||||||
|
|
||||||
|
|
||||||
@@ -63,9 +63,9 @@ class TestHubPage(HubCase):
|
|||||||
self.assertEqual((state["reviews"][0]["title"], state["reviews"][0]["notes"]), ("<script>alert(1)</script>", "</pre><script>alert(2)</script>"))
|
self.assertEqual((state["reviews"][0]["title"], state["reviews"][0]["notes"]), ("<script>alert(1)</script>", "</pre><script>alert(2)</script>"))
|
||||||
self.assertEqual((state["register"][0]["title"], state["radio"][0]["from"]), ("<script>alert(3)</script>", "<img src=x>"))
|
self.assertEqual((state["register"][0]["title"], state["radio"][0]["from"]), ("<script>alert(3)</script>", "<img src=x>"))
|
||||||
|
|
||||||
def test_the_index_scripts_parse_and_embed_the_ambience_voice_nav_popup_and_space_modules(self):
|
def test_the_index_scripts_parse_and_embed_the_ambience_voice_nav_popup_faces_and_space_modules(self):
|
||||||
html = self.index()
|
html = self.index()
|
||||||
for module in (AMBIENCE, VOICE, NAV, POPUP, SPACE):
|
for module in (AMBIENCE, VOICE, NAV, POPUP, FACES, SPACE):
|
||||||
with self.subTest(module=module.name):
|
with self.subTest(module=module.name):
|
||||||
self.assertIn(module.read_text(encoding="utf-8").strip(), html)
|
self.assertIn(module.read_text(encoding="utf-8").strip(), html)
|
||||||
scripts = re.findall(r'<script nonce="[^"]+">(.*?)</script>', html, re.DOTALL)
|
scripts = re.findall(r'<script nonce="[^"]+">(.*?)</script>', html, re.DOTALL)
|
||||||
@@ -74,7 +74,7 @@ class TestHubPage(HubCase):
|
|||||||
page.write_text("\n;\n".join(scripts), encoding="utf-8")
|
page.write_text("\n;\n".join(scripts), encoding="utf-8")
|
||||||
done = subprocess.run(["node", "--check", str(page)], capture_output=True, text=True, timeout=60, check=False)
|
done = subprocess.run(["node", "--check", str(page)], capture_output=True, text=True, timeout=60, check=False)
|
||||||
self.assertEqual(done.returncode, 0, done.stderr)
|
self.assertEqual(done.returncode, 0, done.stderr)
|
||||||
for module in (AMBIENCE, VOICE, NAV, POPUP, SPACE):
|
for module in (AMBIENCE, VOICE, NAV, POPUP, FACES, SPACE):
|
||||||
with self.subTest(module=module.name):
|
with self.subTest(module=module.name):
|
||||||
done = subprocess.run(["node", "--check", str(module)], capture_output=True, text=True, timeout=60, check=False)
|
done = subprocess.run(["node", "--check", str(module)], capture_output=True, text=True, timeout=60, check=False)
|
||||||
self.assertEqual(done.returncode, 0, done.stderr)
|
self.assertEqual(done.returncode, 0, done.stderr)
|
||||||
|
|||||||
Reference in new issue
Block a user