Deutschlandfunk stayed silent: its signed URL answers 302 to f111.rndfnk.com, an origin the signed media-src never listed, so the browser blocked the redirected load and reported only the station's own URL in securitypolicyviolation. The hub now resolves each https stream's redirect chain server side when a settings save adds or changes it and stores the extra origins as the stream's via list. media-src lists them next to the signed origins. A CDN host of three or more labels widens to its siblings (*.rndfnk.com); an IP, a two-label host and a name under a country code's generic second level stay exact. Hardening: - Only the signed URL itself may lie at a non-global address; every redirect hop dials a global address only, checked on the address actually dialed (NAT64 and IPv4-compatible IPv6 count by the embedded IPv4 address). - A resolve answers within three socket timeouts even for a server that drips its headers; a late probe goes on in a daemon thread and a url still in flight is not probed twice. - A failed resolution stores via null and retries on a save ten minutes later; the radio page says so instead of blaming the CSP. - A save whose settings changed while it was resolved fails instead of overwriting them (compare and set under the settings lock). - A new stream URL at an already signed origin takes the passkey too. - Settings written before via existed load as via [] and never dial. Residuals: - The passkey proof is spent before the resolve, so a save racing another one fails and the operator signs again. - A save that hits a due retry blocks up to twelve seconds and may reload the page. - A page loaded before the upgrade throws on the missing via until it reloads. - A private multi-tenant suffix such as github.io still widens. - getaddrinfo has no timeout; it runs inside the daemon thread. Operator step after install: remove Deutschlandfunk in Radio, Sender bearbeiten, save, add it again and save with the passkey. 🎯 Fix: 92 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: – 🛡️ Security: 100 % 🏆 Score: 88 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1167 lines
77 KiB
Python
1167 lines
77 KiB
Python
from __future__ import annotations
|
||
|
||
import array
|
||
import base64
|
||
import contextlib
|
||
import http.server
|
||
import json
|
||
import math
|
||
import os
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
import unittest
|
||
from collections.abc import Callable
|
||
from pathlib import Path
|
||
from unittest import mock
|
||
|
||
HERE = Path(__file__).resolve().parent
|
||
sys.path.insert(0, str(HERE.parent))
|
||
|
||
from hub_case import HubCase
|
||
from hub_engines import wav_bytes
|
||
from hub_support import ASK, HOST_STATE, RUNNER, iso, verifier
|
||
from kvb_orchestrator import skill_scripts
|
||
from test_hub_cleanup import DONE_TIP, FAKE_PKGMGR, host
|
||
from test_hub_offices import COUNCIL_SCRIPT
|
||
from test_hub_offices import host as office_host
|
||
|
||
OUT = Path(os.environ.get("E2E_OUT", "/tmp/orchestrator-phase4-shots"))
|
||
WIDTHS = ((1920, 1080), (1366, 768), (390, 844))
|
||
VIEWS = ("orbit", "chord", "swimlane", "tree", "3d")
|
||
TASK_VIEWS = ("list", "board", "rasci")
|
||
SCREENS = ["stream", "stream/inbox", "stream/rules", "stream/radio", *(f"{tab}/{view}" for tab in ("agents", "artifacts") for view in VIEWS), "agents/hierarchy", *(f"tasks/{view}" for view in TASK_VIEWS)]
|
||
SUBS = ["Inbox", "Regeln", "Radio"]
|
||
TABS = ["Stream", "Agents", "Artifacts", "Tasks"]
|
||
HELPERS = """
|
||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||
const until = async (check, ms) => {
|
||
const end = Date.now() + ms;
|
||
while (Date.now() < end) {
|
||
const found = check();
|
||
if (found) {
|
||
return found;
|
||
}
|
||
await sleep(200);
|
||
}
|
||
return null;
|
||
};
|
||
const all = (selector, root) => [...(root || document).querySelectorAll(selector)];
|
||
const text = (node) => (node ? node.textContent.trim() : null);
|
||
const name = (node) => (node ? node.getAttribute("aria-label") || text(node) : null);
|
||
"""
|
||
PROBE = """(async () => {@HELPERS@
|
||
await until(() => !document.querySelector("#main .space-note") || !text(document.querySelector("#main .space-note")).startsWith("Lade"), 8000);
|
||
const badge = (button) => {
|
||
const found = button.querySelector(".badge");
|
||
return found && !found.hidden ? text(found) : null;
|
||
};
|
||
const icons = all("svg.bi").filter((svg) => !svg.closest(".sprite"));
|
||
const banner = document.getElementById("mayday");
|
||
return {
|
||
hash: location.hash,
|
||
title: document.title,
|
||
h1: text(document.querySelector("h1")),
|
||
header: all("header.top button").map((button) => button.id),
|
||
tabs: all("#tabs > .tab-group > button").map(name),
|
||
subs: all("#tabs .subtabs button").map(name),
|
||
badges: Object.fromEntries(all("#tabs button").map((button) => [button.id, badge(button)])),
|
||
banner: banner.hidden ? null : text(banner),
|
||
rail: Boolean(document.getElementById("rail")),
|
||
views: all('#main [aria-label="Ansicht"] button').map(name),
|
||
layouts: all('#main [aria-label="Anordnung"] button').map(name),
|
||
chips: all('#main [aria-label="Kategorien"] button').map(name),
|
||
menus: all("#main details.menu > summary").map(name),
|
||
time: text(document.querySelector("#time > summary")),
|
||
replay: Boolean(document.querySelector("#time .replay input[type=range]")),
|
||
custom: Boolean(document.querySelector('#main details.menu input[type="datetime-local"]')),
|
||
toolbar: all('#main [role="toolbar"] > *').map((node) => name(node.tagName === "DETAILS" ? node.querySelector("summary") : node.querySelector("button") || node)),
|
||
toAll: all("#to-all input[type=radio]").map((input) => [input.value, input.checked]),
|
||
inbox: all("#main .inbox-list > li").length,
|
||
icons: icons.length,
|
||
unlabelled: icons.map((svg) => svg.parentElement).filter((owner) => !text(owner) && !(owner.getAttribute("title") && owner.getAttribute("aria-label"))).map((owner) => owner.outerHTML.slice(0, 160)),
|
||
broken: all("svg.bi use").filter((use) => !document.getElementById(use.getAttribute("href").slice(1))).map((use) => use.getAttribute("href")),
|
||
states: all("#main span.state").map(name),
|
||
deletes: all('#main button[aria-label="Löschen"], #main button[aria-label="Alle gemergten löschen"]').map((button) => [name(button), button.disabled, button.title]),
|
||
spawn: all("#main .spawn-tree li").map((item) => [item.dataset.kind, item.dataset.running || null]),
|
||
marks: all("#main svg.chart .node").length,
|
||
note: text(document.querySelector("#main .space-note")),
|
||
canvas: Boolean(document.querySelector("#main .graph3d canvas")),
|
||
org: all("#main .org-chart .org-row[data-office]").map((row) => [row.dataset.office, text(row.querySelector(".org-holder"))]),
|
||
toggles: all("#main .org-toggle").map((button) => [name(button), button.getAttribute("aria-expanded")]),
|
||
order: all("#main .agent-order li").map((item) => item.dataset.name),
|
||
taskFilters: all('#main [aria-label="Aufgabenfilter"] button').map((button) => [button.dataset.filter, button.getAttribute("aria-pressed"), text(button.querySelector(".count"))]),
|
||
taskRows: all("#main .task-row").map((row) => [row.dataset.task, row.classList.contains("open-work")]),
|
||
columns: all("#main .task-column").map((column) => [column.dataset.column, all(".task-card", column).map((card) => card.dataset.task)]),
|
||
matrix: [all("#main table.matrix thead th").length, all("#main table.matrix tbody tr").length, all("#main table.matrix td .letter").map(text).join("")],
|
||
flows: all("#main .flow").map((flow) => flow.dataset.flow),
|
||
flowEdges: all("#main svg.chart path.flow").map((path) => path.dataset.side),
|
||
openWork: all("#main .open-work").length,
|
||
wide: document.documentElement.scrollWidth > window.innerWidth + 1,
|
||
};
|
||
})()""".replace("@HELPERS@", HELPERS)
|
||
ORG_OPEN = """(async () => {@HELPERS@
|
||
const row = await until(() => document.querySelector('#main .org-row[data-office="identity/oidc"]'), 8000);
|
||
if (!row) {
|
||
return {error: text(document.getElementById("main"))};
|
||
}
|
||
const toggle = all("#main .org-toggle").find((button) => name(button) === "Zuklappen: identity");
|
||
toggle.focus();
|
||
return {focused: document.activeElement.dataset.focus, offices: all("#main .org-row[data-office]").map((entry) => entry.dataset.office)};
|
||
})()""".replace("@HELPERS@", HELPERS)
|
||
ORG_FOLDED = """(async () => {@HELPERS@
|
||
const toggle = document.activeElement;
|
||
const folded = {focused: toggle.dataset.focus, expanded: toggle.getAttribute("aria-expanded"), label: name(toggle), hidden: document.getElementById(toggle.getAttribute("aria-controls")).hidden, seen: all("#main .org-row[data-office]").filter((entry) => entry.offsetParent !== null).map((entry) => entry.dataset.office)};
|
||
toggle.click();
|
||
await sleep(200);
|
||
const before = all("#main .agent-order li").map((item) => item.dataset.name);
|
||
all("#main .agent-order li")[1].querySelector('[data-focus^="up:"]').focus();
|
||
return {...folded, again: toggle.getAttribute("aria-expanded"), before};
|
||
})()""".replace("@HELPERS@", HELPERS)
|
||
ORDER_MOVED = """(async () => {@HELPERS@
|
||
const after = all("#main .agent-order li").map((item) => item.dataset.name);
|
||
const focused = document.activeElement.dataset.focus || null;
|
||
const save = all("#main .agent-order button").find((button) => text(button) === "Reihenfolge speichern");
|
||
const disabled = save.disabled;
|
||
save.click();
|
||
const toast = await until(() => all("#toasts .toast").find((box) => text(box).startsWith("Priorität")), 8000);
|
||
return {after, focused, disabled, toast: text(toast)};
|
||
})()""".replace("@HELPERS@", HELPERS)
|
||
OPEN_TAKEOVER = """(async () => {@HELPERS@
|
||
document.querySelector('#main [data-focus="takeover:identity/oidc"]').click();
|
||
await sleep(400);
|
||
return {title: text(document.querySelector(".sheet-head .panel-title")), options: all(".sheet-box select option").map((option) => option.value), work: document.querySelector('.sheet-box input[aria-label="Arbeit"]').value, focused: document.activeElement.tagName};
|
||
})()""".replace("@HELPERS@", HELPERS)
|
||
CONFIRM_TAKEOVER = """(async () => {@HELPERS@
|
||
all(".sheet-box button").find((button) => name(button) === "Übergeben").click();
|
||
const done = await until(() => document.querySelector(".sheet-box [data-result]"), 30000);
|
||
return {result: done ? done.dataset.result : null, text: text(done), toasts: all("#toasts .toast").map(text)};
|
||
})()""".replace("@HELPERS@", HELPERS)
|
||
AFTER_TAKEOVER = """(async () => {@HELPERS@
|
||
document.querySelector(".sheet-head button").click();
|
||
const row = await until(() => {
|
||
const found = document.querySelector('#main .org-row[data-office="identity/oidc"] .org-holder');
|
||
return found && !text(found).endsWith("Stage") ? found : null;
|
||
}, 15000);
|
||
return {holder: text(row)};
|
||
})()""".replace("@HELPERS@", HELPERS)
|
||
INBOX_CHIP = """(async () => {@HELPERS@
|
||
const chip = (id) => document.querySelector(`#main [aria-label="Aufgabenfilter"] button[data-filter="${id}"]`);
|
||
chip("operator").click();
|
||
await until(() => location.hash === "#stream/inbox" && document.querySelector("#main .inbox-list"), 4000);
|
||
const inbox = {hash: location.hash, items: all("#main .inbox-list > li").length, counted: Number(text(chip("operator").querySelector(".count"))), pressed: chip("operator").getAttribute("aria-pressed"), current: all("#tabs button[aria-current=page]").map(name)};
|
||
chip("freigabe").click();
|
||
await until(() => location.hash.startsWith("#tasks/"), 4000);
|
||
await sleep(300);
|
||
return {inbox, back: {hash: location.hash, rows: all("#main .task-row").map((row) => [row.dataset.task, text(row.querySelector(".pill"))])}};
|
||
})()""".replace("@HELPERS@", HELPERS)
|
||
PIN = """(async () => {@HELPERS@
|
||
const row = await until(() => all("#main .task-row").find((item) => item.dataset.task === "work/docs"), 8000);
|
||
if (!row) {
|
||
return {error: text(document.getElementById("main"))};
|
||
}
|
||
row.querySelector('button[aria-label="Priorität"]').click();
|
||
await sleep(400);
|
||
const field = document.querySelector('.sheet-box input[aria-label="Priorität"]');
|
||
field.value = "2";
|
||
field.dispatchEvent(new Event("input", {bubbles: true}));
|
||
const preview = all(".sheet-box .task-edit .command code").map(text);
|
||
all(".sheet-box button").find((button) => name(button) === "Anheften").click();
|
||
const done = await until(() => document.querySelector(".sheet-box [data-jobs]"), 20000);
|
||
return {title: text(document.querySelector(".sheet-head .panel-title")), preview, jobs: done ? done.dataset.jobs : null, toasts: all("#toasts .toast").map(text)};
|
||
})()""".replace("@HELPERS@", HELPERS)
|
||
ACTIONS = """(async () => {@HELPERS@
|
||
const press = async (key) => {
|
||
document.dispatchEvent(new KeyboardEvent("keydown", {key, bubbles: true}));
|
||
await sleep(300);
|
||
return location.hash;
|
||
};
|
||
const found = {keys: [await press("2"), await press("3"), await press("4"), await press("1")]};
|
||
document.getElementById("settings-open").click();
|
||
await sleep(300);
|
||
found.ambience = all(".sheet-box form > section:first-child button").map((button) => button.id);
|
||
found.speech = all('.sheet-box [aria-label="Klassen"] input').map((input) => [input.parentElement.textContent.trim(), input.checked, input.disabled]);
|
||
found.settingsUnlabelled = all(".sheet-box svg.bi").map((svg) => svg.parentElement).filter((owner) => !text(owner) && !(owner.getAttribute("title") && owner.getAttribute("aria-label"))).length;
|
||
document.querySelector(".sheet-head button").click();
|
||
document.getElementById("palette-open").click();
|
||
await sleep(300);
|
||
found.palette = all(".palette-list li > span:first-child").map(text);
|
||
document.dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", bubbles: true}));
|
||
await sleep(200);
|
||
document.querySelector('#main [aria-label="Kategorien"] button:nth-child(2)').click();
|
||
await sleep(900);
|
||
document.querySelector('#main button.linkish[title$="öffnen"]').click();
|
||
await sleep(500);
|
||
found.drawer = all("#drawer input[type=radio]").map((input) => [input.value, input.checked]);
|
||
found.drawerUnlabelled = all("#drawer svg.bi").map((svg) => svg.parentElement).filter((owner) => !text(owner) && !(owner.getAttribute("title") && owner.getAttribute("aria-label"))).length;
|
||
return found;
|
||
})()""".replace("@HELPERS@", HELPERS)
|
||
PICK_3D = """(async () => {@HELPERS@
|
||
const list = await until(() => document.querySelector("#main .space-list"), 8000);
|
||
if (!list) {
|
||
return {error: text(document.querySelector("#main .graph"))};
|
||
}
|
||
list.open = true;
|
||
const chip = all("#main .space-list [data-node]").find((node) => node.dataset.node.startsWith("@PREFIX@"));
|
||
if (!chip) {
|
||
return {error: all("#main .space-list [data-node]").map((node) => node.dataset.node)};
|
||
}
|
||
chip.click();
|
||
await sleep(600);
|
||
return {panel: text(document.querySelector("#main aside .panel-title")), label: chip.textContent};
|
||
})()""".replace("@HELPERS@", HELPERS)
|
||
CALL = """(async () => {@HELPERS@
|
||
document.querySelector('#main [aria-label="Kategorien"] button[aria-label="Funk"]').click();
|
||
const someone = await until(() => document.querySelector('#main button.linkish[title$="öffnen"]'), 8000);
|
||
someone.click();
|
||
await sleep(500);
|
||
document.querySelector('#drawer input[type=radio][value="call"]').click();
|
||
const field = document.querySelector("#drawer textarea");
|
||
field.value = "E2E call ping";
|
||
field.dispatchEvent(new Event("input", {bubbles: true}));
|
||
document.querySelector('#drawer button[type="submit"]').click();
|
||
const entry = await until(() => all("#main .entry").find((row) => row.textContent.includes("Gelesen") && row.textContent.includes("E2E call ping")), 20000);
|
||
return {to: text(someone), receipt: entry ? text(entry) : null, sent: all("#main .entry").filter((row) => row.textContent.includes("E2E call ping")).length};
|
||
})()""".replace("@HELPERS@", HELPERS)
|
||
OPEN_DELETE = """(async () => {@HELPERS@
|
||
const card = await until(() => all("#main details.repo").find((details) => details.textContent.includes("github.com/me/demo")), 8000);
|
||
if (!card) {
|
||
return {error: text(document.getElementById("main"))};
|
||
}
|
||
card.open = true;
|
||
await sleep(300);
|
||
const every = document.querySelector('#main button[aria-label="Alle gemergten löschen"]');
|
||
const buttons = all('button[aria-label="Löschen"]', card);
|
||
const states = all("span.state", card).map(name);
|
||
buttons.find((button) => !button.disabled).click();
|
||
await sleep(500);
|
||
return {states, every: [every.disabled, every.title], buttons: buttons.map((button) => [button.disabled, button.title]), title: text(document.querySelector(".sheet-head .panel-title")), dry: all('.sheet-box [aria-label="Trockenlauf"] pre').map(text)};
|
||
})()""".replace("@HELPERS@", HELPERS)
|
||
CONFIRM = """(async () => {@HELPERS@
|
||
all(".sheet-box button").find((button) => name(button) === "Löschen").click();
|
||
const done = await until(() => document.querySelector(".sheet-box [data-result]"), 30000);
|
||
const job = document.querySelector(".sheet-box [data-cleanup]");
|
||
return {job: job ? job.dataset.cleanup : null, result: done ? done.dataset.result : null, text: text(done), toasts: all("#toasts .toast").map(text)};
|
||
})()""".replace("@HELPERS@", HELPERS)
|
||
AFTER = """(async () => {@HELPERS@
|
||
document.querySelector(".sheet-head button").click();
|
||
const past = await until(() => all("#main details.repo").find((details) => details.textContent.includes("Zuletzt gelöscht")), 15000);
|
||
return {past: past ? [past.querySelector("[data-result]").dataset.result, name(past.querySelector("span.state"))] : null, branches: all("#main details.repo").filter((details) => details !== past).flatMap((details) => all(".branch-name strong", details)).map(text)};
|
||
})()""".replace("@HELPERS@", HELPERS)
|
||
OVERLAP = """
|
||
const shown = (node) => node.getClientRects().length > 0 && node.getBoundingClientRect().width > 0;
|
||
const words = (label) => [...label.childNodes].filter((node) => node.nodeType === 3).map((node) => node.textContent).join("").trim();
|
||
const called = (node) => node.getAttribute("aria-label") || (node.closest("label") ? words(node.closest("label")) : "") || text(node) || node.type;
|
||
const clashes = (root) => {
|
||
const boxes = all("input, select, textarea, button", root).filter(shown).map((node) => ({node, box: node.getBoundingClientRect()}));
|
||
const found = [];
|
||
boxes.forEach((a, index) => boxes.slice(index + 1).forEach((b) => {
|
||
const x = Math.min(a.box.right, b.box.right) - Math.max(a.box.left, b.box.left);
|
||
const y = Math.min(a.box.bottom, b.box.bottom) - Math.max(a.box.top, b.box.top);
|
||
if (x > 0.5 && y > 0.5) {
|
||
found.push(`${called(a.node)} × ${called(b.node)}`);
|
||
}
|
||
}));
|
||
const outside = boxes.filter(({box}) => box.right > root.getBoundingClientRect().right + 0.5).map(({node}) => called(node));
|
||
return {found, outside};
|
||
};
|
||
"""
|
||
RULES_OVERLAP = """(async () => {@HELPERS@@CLASHES@
|
||
const open = document.querySelector(".sheet-head button");
|
||
if (open) {
|
||
open.click();
|
||
}
|
||
const rules = await until(() => document.querySelector("#main .rule-form"), 8000);
|
||
const kind = rules.querySelector("select");
|
||
const found = {};
|
||
for (const value of ["decision", "review", "ask"]) {
|
||
kind.value = value;
|
||
kind.dispatchEvent(new Event("change", {bubbles: true}));
|
||
await sleep(150);
|
||
found[value] = {...clashes(rules), labels: all("label", rules).filter(shown).map(words)};
|
||
}
|
||
return found;
|
||
})()""".replace("@HELPERS@", HELPERS).replace("@CLASHES@", OVERLAP)
|
||
SETTINGS_OVERLAP = """(async () => {@HELPERS@@CLASHES@
|
||
document.getElementById("settings-open").click();
|
||
const settings = await until(() => document.querySelector(".sheet-box form.settings"), 8000);
|
||
all("button", settings).find((button) => text(button) === "+ Sender").click();
|
||
const theme = all("select", settings).find((select) => [...select.options].some((option) => option.value === "custom"));
|
||
theme.value = "custom";
|
||
theme.dispatchEvent(new Event("change", {bubbles: true}));
|
||
await sleep(250);
|
||
const place = all("section", settings).find((section) => text(section.querySelector("h3")) === "Ort");
|
||
place.scrollIntoView({block: "start"});
|
||
return clashes(settings);
|
||
})()""".replace("@HELPERS@", HELPERS).replace("@CLASHES@", OVERLAP)
|
||
MEDIA_LOG = """() => {
|
||
const keep = (entry) => {
|
||
try {
|
||
const list = JSON.parse(sessionStorage.getItem("e2e-media") || "[]");
|
||
list.push({...entry, page: Math.round(performance.timeOrigin)});
|
||
sessionStorage.setItem("e2e-media", JSON.stringify(list));
|
||
} catch (error) {
|
||
return;
|
||
}
|
||
};
|
||
const play = HTMLMediaElement.prototype.play;
|
||
HTMLMediaElement.prototype.play = function () {
|
||
window.e2eAudio = this;
|
||
const src = this.getAttribute("src") || "";
|
||
const result = play.call(this);
|
||
result.then(() => keep({play: "ok", src}), (error) => keep({play: error.name, src}));
|
||
return result;
|
||
};
|
||
document.addEventListener("securitypolicyviolation", (event) => keep({csp: event.effectiveDirective, blocked: event.blockedURI}));
|
||
}"""
|
||
MEDIA = """
|
||
const log = () => JSON.parse(sessionStorage.getItem("e2e-media") || "[]");
|
||
const sample = () => (window.e2eAudio ? {paused: window.e2eAudio.paused, time: window.e2eAudio.currentTime, error: window.e2eAudio.error ? window.e2eAudio.error.code : null, src: window.e2eAudio.getAttribute("src")} : null);
|
||
const advancing = async () => {
|
||
const first = sample();
|
||
await sleep(1500);
|
||
const second = sample();
|
||
return {first, second, playing: Boolean(first && second && !second.paused && second.time - first.time > 0.5)};
|
||
};
|
||
"""
|
||
ADD_STATION = """(async () => {@HELPERS@
|
||
const clock = (date) => `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
||
document.getElementById("settings-open").click();
|
||
const form = await until(() => document.querySelector(".sheet-box form.settings"), 8000);
|
||
all("button", form).find((button) => text(button) === "+ Sender").click();
|
||
await sleep(200);
|
||
const row = all(".stream-row", form).pop();
|
||
const [label, address, start, end] = all("input.field", row);
|
||
const set = (input, value) => {
|
||
input.value = value;
|
||
input.dispatchEvent(new Event("input", {bubbles: true}));
|
||
};
|
||
set(label, "@NAME@");
|
||
set(address, "@STATION@");
|
||
set(start, clock(new Date(Date.now() - 3600000)));
|
||
set(end, clock(new Date(Date.now() + 3600000)));
|
||
all('.days input[type="checkbox"]', row).forEach((box) => {
|
||
box.checked = true;
|
||
});
|
||
all("button", form).find((button) => button.type === "submit").click();
|
||
await until(() => all("#toasts .toast").map(text).find((toast) => toast.startsWith("Neuer Sender")), 8000);
|
||
return {toasts: all("#toasts .toast").map(text)};
|
||
})()""".replace("@HELPERS@", HELPERS)
|
||
STATION_NAME = "E2E Ton mit einem sehr langen Sendernamen, der in keine Zeile passt"
|
||
RADIO_STATE = """(async () => {@HELPERS@@MEDIA@
|
||
await until(() => log().some((entry) => entry.play), 8000);
|
||
return {log: log(), media: await advancing(), toasts: all("#toasts .toast").map(text)};
|
||
})()""".replace("@HELPERS@", HELPERS).replace("@MEDIA@", MEDIA)
|
||
RADIO_VIEW = """(async () => {@HELPERS@@MEDIA@
|
||
location.hash = "#stream/radio";
|
||
const row = await until(() => document.querySelector('#main [data-station="0"]'), 8000);
|
||
if (!row) {
|
||
const initial = JSON.parse(document.getElementById("state").textContent);
|
||
return {error: "no station row", log: log(), wrapped: !String(HTMLMediaElement.prototype.play).includes("[native code]"), streams: initial.settings.streams, media: initial.media, now: new Date().toString()};
|
||
}
|
||
const label = row.querySelector(".station-name");
|
||
const style = getComputedStyle(label);
|
||
return {
|
||
state: row.dataset.state,
|
||
note: text(row.querySelector(".station-note")),
|
||
title: label.title,
|
||
line: [style.whiteSpace, style.textOverflow, label.scrollWidth > label.clientWidth, label.getBoundingClientRect().height < 2 * parseFloat(style.lineHeight)],
|
||
media: await advancing(),
|
||
log: log(),
|
||
toasts: all("#toasts .toast").map(text),
|
||
};
|
||
})()""".replace("@HELPERS@", HELPERS).replace("@MEDIA@", MEDIA)
|
||
RADIO_CLICK = """(async () => {@HELPERS@@MEDIA@
|
||
const row = document.querySelector('#main [data-station="0"]');
|
||
if (!row) {
|
||
return {error: "no station row", log: log(), media: await advancing()};
|
||
}
|
||
const play = row.querySelector('button[data-action="play"]');
|
||
const clicked = Boolean(sample() && sample().paused && play);
|
||
if (clicked) {
|
||
play.click();
|
||
}
|
||
await until(() => sample() && !sample().paused, 6000);
|
||
const media = await advancing();
|
||
const now = document.querySelector('#main [data-station="0"]');
|
||
return {clicked, state: now.dataset.state, note: text(now.querySelector(".station-note")), media, log: log()};
|
||
})()""".replace("@HELPERS@", HELPERS).replace("@MEDIA@", MEDIA)
|
||
SPEAKING = """
|
||
const caption = () => {
|
||
const node = document.querySelector("#main .radio-caption");
|
||
return node && !node.hidden ? node : null;
|
||
};
|
||
const ring = (chip) => (chip ? getComputedStyle(chip.querySelector(".speaker-face"), "::after").animationName : null);
|
||
const cue = async (word) => {
|
||
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"});
|
||
const found = await until(caption, 25000);
|
||
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)};
|
||
};
|
||
"""
|
||
SPEAK = """(async () => {@HELPERS@@SPEAKING@
|
||
return cue("@CUE@");
|
||
})()""".replace("@HELPERS@", HELPERS).replace("@SPEAKING@", SPEAKING)
|
||
SPEAK_GRAPH = """(async () => {@HELPERS@@SPEAKING@
|
||
location.hash = "#agents/orbit";
|
||
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};
|
||
})()""".replace("@HELPERS@", HELPERS).replace("@SPEAKING@", SPEAKING)
|
||
SPEAK_END = """(async () => {@HELPERS@@SPEAKING@
|
||
const ended = Boolean(await until(() => !document.querySelector("#main svg .node") || !document.querySelector("#main svg .node.speaking"), 20000));
|
||
location.hash = "#stream/radio";
|
||
await until(() => document.querySelector("#main .radio-caption"), 6000);
|
||
await sleep(300);
|
||
return {ended, caption: Boolean(caption()), chips: all("#main .speaker.speaking").length};
|
||
})()""".replace("@HELPERS@", HELPERS).replace("@SPEAKING@", SPEAKING)
|
||
COMPOSE = """(async () => {@HELPERS@
|
||
const form = await until(() => document.querySelector("#main form.composer"), 8000);
|
||
if (!form) {
|
||
return {error: "no composer"};
|
||
}
|
||
const [channel, recipient] = all("select", form);
|
||
await until(() => [...recipient.options].some((option) => option.value === "@TO@"), 8000);
|
||
channel.value = "call";
|
||
channel.dispatchEvent(new Event("change", {bubbles: true}));
|
||
recipient.value = "@TO@";
|
||
const record = all("button", form).find((button) => button.hasAttribute("aria-pressed"));
|
||
record.click();
|
||
await sleep(1500);
|
||
const recording = record.getAttribute("aria-pressed");
|
||
record.click();
|
||
const field = form.querySelector("textarea");
|
||
await until(() => field.value && form.querySelector('.voice-note input[type="checkbox"]'), 60000);
|
||
const replay = form.querySelector(".voice-note audio");
|
||
const attach = form.querySelector('.voice-note input[type="checkbox"]');
|
||
return {
|
||
channels: all("option", channel).map((option) => option.value),
|
||
recipients: all("option", recipient).map((option) => option.value),
|
||
recording,
|
||
text: field.value,
|
||
replay: replay ? replay.getAttribute("src") : null,
|
||
attach: attach ? [text(attach.closest("label")), attach.checked] : null,
|
||
status: text(form.querySelector(".composer-status")),
|
||
toasts: all("#toasts .toast").map(text),
|
||
clashes: (() => {@CLASHES@
|
||
return clashes(form);
|
||
})(),
|
||
};
|
||
})()""".replace("@HELPERS@", HELPERS).replace("@CLASHES@", OVERLAP)
|
||
COMPOSE_SEND = """(async () => {@HELPERS@
|
||
const form = document.querySelector("#main form.composer");
|
||
const field = form.querySelector("textarea");
|
||
field.value = `${field.value} bitte`;
|
||
field.dispatchEvent(new Event("input", {bubbles: true}));
|
||
form.querySelector('.voice-note input[type="checkbox"]').checked = @ATTACH@;
|
||
const before = all("#toasts .toast").length;
|
||
form.querySelector('button[type="submit"]').click();
|
||
const sent = await until(() => all("#toasts .toast").slice(before).map(text).find((toast) => toast.startsWith("Call an")), 20000);
|
||
return {sent, left: field.value, voice: Boolean(form.querySelector(".voice-note audio"))};
|
||
})()""".replace("@HELPERS@", HELPERS)
|
||
CLEAR_MAYDAY = """(async () => {@HELPERS@
|
||
window.confirm = () => true;
|
||
const button = await until(() => all('#main [role="toolbar"] button').find((node) => name(node) === "Mayday aufheben" && !node.disabled), 8000);
|
||
if (!button) {
|
||
return {error: "no enabled Mayday aufheben button"};
|
||
}
|
||
button.click();
|
||
const done = await until(() => all("#toasts .toast").map(text).find((toast) => toast.startsWith("Mayday aufheben")), 15000);
|
||
return {done, toasts: all("#toasts .toast").map(text)};
|
||
})()""".replace("@HELPERS@", HELPERS)
|
||
|
||
|
||
def tone(seconds: int, rate: int) -> bytes:
|
||
frames = array.array("h", (int(6000 * math.sin(2 * math.pi * 440 * n / rate)) for n in range(seconds * rate)))
|
||
return wav_bytes(1, 2, rate, frames.tobytes())
|
||
|
||
|
||
class Station(http.server.BaseHTTPRequestHandler):
|
||
def do_GET(self) -> None:
|
||
body = self.server.body
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "audio/wav")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
with contextlib.suppress(OSError):
|
||
self.wfile.write(body)
|
||
|
||
def log_message(self, *args: object) -> None:
|
||
return
|
||
|
||
|
||
LABELS = """
|
||
const unlabelled = (root) => all("svg.bi", root).filter((svg) => !svg.closest(".sprite")).map((svg) => svg.parentElement).filter((owner) => !text(owner) && !(owner.getAttribute("title") && owner.getAttribute("aria-label"))).map((owner) => owner.outerHTML.slice(0, 160));
|
||
const wide = () => document.documentElement.scrollWidth > window.innerWidth + 1;
|
||
const rowOf = (words) => all("#main .inbox-list > li").find((li) => li.textContent.includes(words));
|
||
const press = (root, label) => all("button", root).find((button) => name(button) === label).click();
|
||
const out = () => document.querySelector("#main .console .console-out");
|
||
const atEnd = () => out().scrollTop + out().clientHeight >= out().scrollHeight - 12;
|
||
const line = () => text(document.querySelector("#main .console .console-status"));
|
||
"""
|
||
RUN_START = """(async () => {@HELPERS@@LABELS@
|
||
const row = await until(() => rowOf("Zeile"), 8000);
|
||
if (!row) {
|
||
return {error: text(document.getElementById("main"))};
|
||
}
|
||
press(row, "Ausführen");
|
||
const toast = await until(() => all("#toasts .toast.wait").find((box) => box.textContent.includes("Ausführen")), 8000);
|
||
const queued = await until(() => out() && line(), 15000);
|
||
await until(() => out().textContent.includes("Zeile 60"), 20000);
|
||
return {toast: text(toast), queued, running: line(), overflows: out().scrollHeight > out().clientHeight, followed: atEnd()};
|
||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||
RUN_HELD = """(async () => {@HELPERS@@LABELS@
|
||
out().scrollTop = 0;
|
||
await sleep(300);
|
||
await until(() => out().textContent.includes("Zeile 80"), 20000);
|
||
await sleep(300);
|
||
return {held: out().scrollTop, running: line()};
|
||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||
RUN_DONE = """(async () => {@HELPERS@@LABELS@
|
||
out().scrollTop = out().scrollHeight;
|
||
await until(() => document.querySelector("#main .console").dataset.status === "done", 20000);
|
||
await sleep(500);
|
||
const lines = out().textContent.trim().split("\\n");
|
||
return {done: line(), last: lines[lines.length - 1], lines: lines.length, followed: atEnd(), actions: all(".item-actions button", out().closest("li")).map(name)};
|
||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||
LAYOUT = """(async () => {@HELPERS@@LABELS@
|
||
await until(() => document.querySelector("#main .console") && rowOf("Regelwunsch"), 8000);
|
||
const rule = rowOf("Regelwunsch");
|
||
return {
|
||
kinds: all("#main .inbox-list > li").map((li) => text(li.querySelector(".pill"))),
|
||
splits: all("#main .deny-split").map((group) => all("button", group).map(name)),
|
||
console: line(),
|
||
followed: atEnd(),
|
||
fits: out().getBoundingClientRect().right <= window.innerWidth,
|
||
dry: text(rule.querySelector(".dry-run-count")),
|
||
fields: all(".rule-request dt", rule).map(text),
|
||
accept: all("button", rule).filter((button) => name(button) === "Annehmen").length,
|
||
unlabelled: unlabelled(document),
|
||
wide: wide(),
|
||
};
|
||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||
DENY_SHEET = """(async () => {@HELPERS@@LABELS@
|
||
const row = await until(() => rowOf("git push"), 8000);
|
||
press(row, "Ablehnen über …");
|
||
const box = await until(() => document.querySelector(".sheet-box"), 4000);
|
||
return {
|
||
title: text(box.querySelector(".panel-title")),
|
||
channels: all(".deny-channels input[type=radio]", box).map((input) => [input.value, input.checked]),
|
||
hints: all(".deny-channel small", box).map(text),
|
||
reason: Boolean(box.querySelector("textarea")),
|
||
unlabelled: unlabelled(box),
|
||
wide: wide(),
|
||
};
|
||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||
DENY = """(async () => {@HELPERS@@LABELS@
|
||
const row = await until(() => rowOf("Bash: make deploy"), 8000);
|
||
press(row, "Ablehnen über …");
|
||
const box = await until(() => document.querySelector(".sheet-box"), 4000);
|
||
box.querySelector('input[value="call"]').click();
|
||
box.querySelector("textarea").value = "erst das Gate grün machen";
|
||
press(box.querySelector(".actions"), "Ablehnen");
|
||
const undo = await until(() => all("#toasts .toast.wait").find((toast) => toast.textContent.includes("Ablehnung")), 8000);
|
||
return {undo: text(undo), sheet: !document.getElementById("sheet").hidden};
|
||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||
DENIED = """(async () => {@HELPERS@@LABELS@
|
||
return {gone: Boolean(await until(() => !rowOf("Bash: make deploy"), 20000))};
|
||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||
ACCEPT = """(async () => {@HELPERS@@LABELS@
|
||
press(rowOf("Regelwunsch"), "Annehmen");
|
||
const toast = await until(() => all("#toasts .toast").find((box) => box.textContent.includes("Angelegt: Regel")), 15000);
|
||
await until(() => !rowOf("Regelwunsch"), 8000);
|
||
return {toast: text(toast), left: Boolean(rowOf("Regelwunsch"))};
|
||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||
SLOW = "seq -f 'Zeile %g' 1 60; sleep 4; seq -f 'Zeile %g' 61 80; sleep 4; echo fertig"
|
||
|
||
|
||
def labelled(probe: dict) -> dict:
|
||
return {key: probe[key] for key in ("unlabelled", "broken")}
|
||
|
||
|
||
@unittest.skipUnless(shutil.which(os.environ.get("CHROMIUM", "chromium")), "no Chromium")
|
||
class TestOrchestratorE2E(HubCase):
|
||
def setUp(self):
|
||
super().setUp()
|
||
OUT.mkdir(parents=True, exist_ok=True)
|
||
now = time.time()
|
||
self.root = self.write_tree()
|
||
feature = f"{self.root}/worktrees/github.com/me/demo/feature"
|
||
self.claim("s1", "Skills session")
|
||
self.write_council({
|
||
"work/s2/claim.json": {"office": "work/s2", "session": "s2", "runtime": "claude", "name": "Stage", "since": iso(now - 7200), "beat": now - 2000, "work": "x"},
|
||
"chair/claim.json": {"office": "chair", "session": "s1", "runtime": "claude", "name": "Skills session", "since": iso(now - 7200), "beat": now, "work": "allocate"},
|
||
"identity/oidc/claim.json": {"office": "identity/oidc", "session": "s2", "runtime": "claude", "name": "Stage", "since": iso(now - 7200), "beat": now - 2000, "work": "oidc line"},
|
||
"rasci.json": {
|
||
"work/stage": {"title": "Stage line", "status": "running", "worker": "Stage (this session)", "roles": {"Stage (this session)": "RA"}},
|
||
"work/feature": {
|
||
"title": "Merge-Flow im Hub zeigen", "status": "running", "worker": "Skills session", "objective": "Der Operator sieht, wohin ein Branch fließt", "workdir": feature, "prio": 1,
|
||
"roles": {"Skills session": "RA", "Operator": "C", "Docs": "I"}, "key_results": [{"text": "Flow im Tree", "met": True}, {"text": "Flow in Tasks", "met": False}],
|
||
"estimates": {"Skills": {"value": 8, "complexity": 3, "at": iso(now - 600)}},
|
||
},
|
||
"work/docs": {"title": "README nachziehen", "status": "blocked", "worker": "Docs", "branch": "main", "roles": {"Docs": "R", "Maintainer": "A", "Skills": "S"}, "estimates": {"Docs": {"value": 3, "complexity": 2}}},
|
||
"work/old": {"title": "Alte Linie", "status": "done", "worker": "Stage", "end": iso(now - 86400), "roles": {"Stage": "RA"}},
|
||
"operator/push": {"title": "git push", "workdir": "/repo", "status": "done", "roles": {"Operator": "R"}},
|
||
},
|
||
"queue.json": {"seq": 3, "entries": [{"id": "gate-3", "kind": "gate", "task": "work/feature", "workdir": feature, "session": "s1", "name": "Skills", "status": "waiting", "enqueued": now - 120, "cmd": "make test"}]},
|
||
"radio-silence.json": {"since": now - 60, "until": now + 3600, "by": "Docs", "text": "disk full"},
|
||
"news.jsonl": self.jsonl([
|
||
{"ts": now - 600, "from": "Skills", "to": ["Stage"], "text": "rebase please", "class": "post", "v": 2},
|
||
{"ts": now - 500, "from": "Stage", "to": ["Skills"], "text": "gate red", "class": "call", "v": 2},
|
||
{"ts": now - 400, "from": "Maintainer", "to": [], "text": "main is green", "class": "newsletter", "v": 2},
|
||
{"ts": now - 300, "from": "Maintainer", "to": [], "text": "rotate keys", "class": "security", "v": 2},
|
||
{"ts": now - 60, "from": "Docs", "to": [], "text": "MAYDAY disk full", "class": "mayday", "v": 2},
|
||
]),
|
||
})
|
||
self.subagents(now)
|
||
self.request("feat: orchestrator", "")
|
||
self.create_ask(ASK)
|
||
self.base = f"http://{self.host}/"
|
||
|
||
def subagents(self, now: float) -> None:
|
||
def line(ts: float) -> dict:
|
||
return {"type": "user", "sessionId": "s1", "timestamp": iso(ts), "message": {"role": "user", "content": "x"}}
|
||
|
||
folder = self.projects / "-repo" / "s1" / "subagents"
|
||
folder.mkdir(parents=True)
|
||
(self.projects / "-repo" / "s1.jsonl").write_text(self.jsonl([line(now - 3600), line(now - 30)]), encoding="utf-8")
|
||
for ident, finished in (("abc", True), ("def", False)):
|
||
(folder / f"agent-{ident}.meta.json").write_text(json.dumps({"agentType": "Explore", "description": f"look {ident}", "spawnDepth": 1, "finished": finished}), encoding="utf-8")
|
||
(folder / f"agent-{ident}.jsonl").write_text(self.jsonl([line(now - 1800), line(now - 60)]), encoding="utf-8")
|
||
|
||
def drive(self, steps: list[dict], authenticator: dict | None, driver: str = "cdp.mjs", binary: str = os.environ.get("CHROMIUM", "chromium"), init: str | None = None) -> dict:
|
||
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
|
||
json.dump({"steps": steps, "authenticator": authenticator, "init": init}, handle)
|
||
self.addCleanup(os.unlink, handle.name)
|
||
done = subprocess.run(["node", str(HERE / driver), binary, handle.name], capture_output=True, text=True, timeout=900, check=False)
|
||
self.assertEqual(done.returncode, 0, done.stderr[-2000:])
|
||
return json.loads(done.stdout)
|
||
|
||
def beside(self, work: Callable[[], None]) -> None:
|
||
stop = threading.Event()
|
||
|
||
def loop() -> None:
|
||
while not stop.wait(0.2):
|
||
work()
|
||
|
||
thread = threading.Thread(target=loop, daemon=True)
|
||
thread.start()
|
||
self.addCleanup(thread.join, 10)
|
||
self.addCleanup(stop.set)
|
||
|
||
def shot(self, name: str) -> str:
|
||
return str(OUT / f"chromium-{name}.png")
|
||
|
||
def authenticator(self) -> dict:
|
||
signer = self.pin()
|
||
private = "".join(line for line in signer.pem.read_text(encoding="utf-8").splitlines() if not line.startswith("-----"))
|
||
return {"credentialId": base64.b64encode(signer.raw_id).decode(), "privateKey": private, "rpId": "localhost"}
|
||
|
||
def speaks(self, cues: tuple[str, ...]) -> None:
|
||
hub = self.httpd.hub
|
||
hub.settings.put({**hub.settings.get(), "speech": {"on": True, "classes": ["mayday", "pan-pan", "security"], "muted": []}}, hub.settings.get())
|
||
spoken = tone(6, 8000)
|
||
|
||
def synthesize(body: bytes) -> bytes:
|
||
word = json.loads(body)["text"]
|
||
if word in cues:
|
||
row = {"ts": time.time(), "from": "Stage", "to": [], "text": f"Platte fast voll ({word})\nzweite Zeile", "class": "security", "v": 2}
|
||
with (self.council / "news.jsonl").open("a", encoding="utf-8") as handle:
|
||
handle.write(self.jsonl([row]))
|
||
return spoken
|
||
|
||
patch = mock.patch.object(hub.speech, "synthesize", synthesize)
|
||
patch.start()
|
||
self.addCleanup(patch.stop)
|
||
|
||
def station(self) -> str:
|
||
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Station)
|
||
httpd.body = tone(30, 22050)
|
||
thread = threading.Thread(target=httpd.serve_forever, args=(0.05,), daemon=True)
|
||
thread.start()
|
||
self.addCleanup(httpd.server_close)
|
||
self.addCleanup(thread.join, 10)
|
||
self.addCleanup(httpd.shutdown)
|
||
return f"http://127.0.0.1:{httpd.server_address[1]}/tone.wav"
|
||
|
||
def check_overlaps(self, browser: str, driver: str, binary: str) -> None:
|
||
steps = []
|
||
for width, height in WIDTHS:
|
||
steps.append({"width": width, "height": height, "url": f"{self.base}#stream/rules", "wait": 900, "probe": RULES_OVERLAP, "shot": str(OUT / f"{browser}-{width}-rules-form.png")})
|
||
steps.append({"width": width, "height": height, "url": None, "wait": 0, "probe": SETTINGS_OVERLAP, "shot": str(OUT / f"{browser}-{width}-settings-form.png")})
|
||
data = self.drive(steps, None, driver, binary)
|
||
(OUT / f"{browser}-overlap.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||
hidden = {"decision": ("Werkzeug (Glob)", "Verzeichnis (Glob)"), "review": ("Werkzeug (Glob)",), "ask": ()}
|
||
for index, (width, _) in enumerate(WIDTHS):
|
||
rules, settings = data["results"][2 * index : 2 * index + 2]
|
||
with self.subTest(browser=browser, width=width):
|
||
self.assertNotIn("error", rules)
|
||
self.assertNotIn("error", settings)
|
||
for kind, found in rules.items():
|
||
self.assertEqual((found["found"], found["outside"]), ([], []), kind)
|
||
self.assertEqual([label for label in hidden[kind] if label in found["labels"]], [], kind)
|
||
self.assertIn("Verzeichnis (Glob)", rules["review"]["labels"])
|
||
self.assertEqual(settings, {"found": [], "outside": []})
|
||
|
||
def check_radio(self, browser: str, driver: str, binary: str) -> dict:
|
||
"""Args:
|
||
driver: cdp.mjs saves the station through the settings form and
|
||
signs its new origin with a virtual passkey; bidi.mjs has no
|
||
virtual authenticator, so the station is saved before the page
|
||
loads.
|
||
"""
|
||
station = self.station()
|
||
signs = driver == "cdp.mjs"
|
||
if signs:
|
||
first = {"width": 1366, "height": 768, "url": f"http://localhost:{self.port}/#stream", "wait": 900, "probe": ADD_STATION.replace("@STATION@", station).replace("@NAME@", STATION_NAME), "shot": None, "gesture": True}
|
||
else:
|
||
hub = self.httpd.hub
|
||
hub.settings.put({**hub.settings.get(), "streams": [{"name": STATION_NAME, "url": station, "from": "00:00", "to": "24:00", "days": list(range(7)), "on": True, "via": []}]}, hub.settings.get())
|
||
first = {"width": 1366, "height": 768, "url": f"{self.base}#stream", "wait": 900, "probe": None, "shot": None}
|
||
steps = [
|
||
first,
|
||
{"width": 390, "height": 844, "url": None, "wait": 3000, "probe": RADIO_VIEW, "shot": str(OUT / f"{browser}-390-radio-station.png")},
|
||
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": RADIO_CLICK, "shot": str(OUT / f"{browser}-1366-radio-playing.png"), "gesture": True},
|
||
]
|
||
data = self.drive(steps, self.authenticator() if signs else None, driver, binary, MEDIA_LOG)
|
||
(OUT / f"{browser}-radio.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||
saved, view, click = data["results"]
|
||
if signs:
|
||
self.assertTrue(any(toast.startswith("Neuer Sender") for toast in saved["toasts"]), saved)
|
||
self.assertNotIn("error", view)
|
||
self.assertEqual(view["title"], STATION_NAME)
|
||
self.assertEqual(view["line"], ["nowrap", "ellipsis", True, True])
|
||
blocked = [entry for entry in view["log"] if entry.get("play") == "NotAllowedError"]
|
||
if not view["media"]["playing"]:
|
||
self.assertTrue(blocked, view)
|
||
self.assertEqual(view["state"], "blocked", view)
|
||
self.assertIn("Klick", view["note"])
|
||
self.assertTrue(any("blockiert" in toast for toast in view["toasts"]), view["toasts"])
|
||
self.assertTrue(click["clicked"], click)
|
||
self.assertNotIn("error", click)
|
||
self.assertTrue(click["media"]["playing"], click)
|
||
self.assertEqual(click["state"], "playing", click)
|
||
self.assertEqual([entry for entry in click["log"] if entry.get("csp") == "media-src"], [])
|
||
return data
|
||
|
||
def test_chromium_form_controls_never_overlap(self):
|
||
self.check_overlaps("chromium", "cdp.mjs", os.environ.get("CHROMIUM", "chromium"))
|
||
|
||
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
|
||
def test_firefox_form_controls_never_overlap(self):
|
||
self.check_overlaps("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"))
|
||
|
||
def test_chromium_plays_a_saved_station(self):
|
||
self.check_radio("chromium", "cdp.mjs", os.environ.get("CHROMIUM", "chromium"))
|
||
|
||
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
|
||
def test_firefox_plays_a_saved_station(self):
|
||
self.check_radio("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"))
|
||
|
||
def test_chromium_walks_every_screen_at_three_widths(self):
|
||
steps = [{"width": width, "height": height, "url": f"{self.base}#{screen}", "wait": 900, "probe": PROBE, "shot": self.shot(f"{width}-{screen.replace('/', '-')}")} for width, height in WIDTHS for screen in SCREENS]
|
||
steps += [{"width": 1366, "height": 768, "url": f"{self.base}#{old}", "wait": 600, "probe": PROBE, "shot": None} for old in ("graph", "graph/chord", "tree")]
|
||
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#stream", "wait": 900, "probe": ACTIONS, "shot": None})
|
||
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#artifacts/3d", "wait": 900, "probe": PICK_3D.replace("@PREFIX@", "branch:"), "shot": self.shot("1366-artifacts-3d-picked")})
|
||
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#agents/3d", "wait": 900, "probe": PICK_3D.replace("@PREFIX@", "s2"), "shot": self.shot("1366-agents-3d-picked")})
|
||
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#artifacts/3d", "wait": 900, "probe": PROBE, "shot": self.shot("1366-artifacts-3d-reduced-motion"), "media": [{"name": "prefers-reduced-motion", "value": "reduce"}]})
|
||
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#tasks/list", "wait": 900, "probe": INBOX_CHIP, "shot": self.shot("1366-tasks-freigabe")})
|
||
data = self.drive(steps, None)
|
||
(OUT / "chromium-walk.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||
self.assertEqual(data["errors"], [])
|
||
probes = data["results"][: len(WIDTHS) * len(SCREENS)]
|
||
for probe, step in zip(probes, steps, strict=False):
|
||
with self.subTest(url=step["url"], width=step["width"]):
|
||
self.assertNotIn("error", probe)
|
||
self.assertEqual((probe["title"], probe["h1"]), ("(2) Agent Orchestrator", "Agent Orchestrator"))
|
||
self.assertEqual(probe["header"], ["palette-open", "settings-open"])
|
||
self.assertEqual((probe["tabs"], probe["subs"]), (TABS, SUBS))
|
||
self.assertEqual(probe["badges"], {"tab-stream": None, "tab-inbox": "2", "tab-rules": None, "tab-radio": None, "tab-agents": "1", "tab-artifacts": None, "tab-tasks": None})
|
||
self.assertEqual(probe["banner"], "MAYDAY · Docs: disk full")
|
||
self.assertFalse(probe["rail"])
|
||
self.assertEqual((probe["time"], probe["replay"]), ("24 h ▾", True))
|
||
self.assertGreater(probe["icons"], 8)
|
||
self.assertEqual(labelled(probe), {"unlabelled": [], "broken": []})
|
||
self.assertFalse(probe["wide"], "the page scrolls sideways")
|
||
by_hash = {probe["hash"]: probe for probe in probes}
|
||
stream = by_hash["#stream"]
|
||
self.assertEqual([probe["chips"] for probe in probes if probe["hash"] == "#stream"], [["Für dich", "Funk", "Arbeit", "Transkripte"]] * len(WIDTHS))
|
||
self.assertEqual((stream["menus"], stream["custom"]), (["Filter"], True))
|
||
self.assertEqual(by_hash["#stream/inbox"]["inbox"], 2)
|
||
for tab, own in (("agents", ["Hierarchie"]), ("artifacts", [])):
|
||
with self.subTest(tab=tab):
|
||
self.assertEqual(by_hash[f"#{tab}/orbit"]["views"], ["Orbit", "Chord", "Swimlane", "Tree", "3D", *own])
|
||
self.assertEqual(by_hash[f"#{tab}/3d"]["layouts"], ["Kraft", "Zeit", "Spawn-Tiefe"])
|
||
self.assertTrue(by_hash[f"#{tab}/3d"]["canvas"], by_hash[f"#{tab}/3d"]["note"])
|
||
self.assertRegex(by_hash[f"#{tab}/3d"]["note"], r"^\d+ Knoten · \d+ Kanten · Kraft$")
|
||
agents = by_hash["#agents/orbit"]
|
||
self.assertEqual(agents["toolbar"], ["Funk an alle", "Mayday aufheben", "Priorität", "Tag/Nacht"])
|
||
self.assertEqual(agents["toAll"], [["newsletter", True], ["broadcast", False], ["pan-pan", False], ["security", False], ["mayday", False]])
|
||
hierarchy = by_hash["#agents/hierarchy"]
|
||
self.assertEqual([office for office, _ in hierarchy["org"]], ["chair", "identity/oidc", "work/s1", "work/s2"])
|
||
self.assertEqual(hierarchy["toggles"], [["Zuklappen: Operator", "true"], ["Zuklappen: Chair", "true"], ["Zuklappen: identity", "true"], ["Zuklappen: Präsidenten", "true"]])
|
||
self.assertEqual(hierarchy["toolbar"], agents["toolbar"])
|
||
self.assertGreaterEqual(len(hierarchy["order"]), 2)
|
||
self.assertEqual(by_hash["#agents/tree"]["spawn"][0], ["operator", None])
|
||
self.assertIn(["twig", "true"], by_hash["#agents/tree"]["spawn"])
|
||
self.assertIn(["twig", "false"], by_hash["#agents/tree"]["spawn"])
|
||
artifacts = by_hash["#artifacts/tree"]
|
||
self.assertEqual(artifacts["deletes"], [["Alle gemergten löschen", True, "Alle gemergten löschen gesperrt: Secret Service locked"], ["Löschen", True, "Löschen gesperrt: Secret Service locked"]])
|
||
for state in ("gemergt durch #12 in main", "Upstream weg", "geschützt", "1 Commits ungepusht", "sauber", "nicht committet: 0 gestaged, 0 geändert, 1 neu", "gc überspringt: ignored files beyond known caches: notes.txt"):
|
||
with self.subTest(state=state):
|
||
self.assertIn(state, artifacts["states"])
|
||
for view in ("orbit", "chord"):
|
||
with self.subTest(view=view):
|
||
self.assertGreaterEqual(by_hash[f"#artifacts/{view}"]["marks"], 4)
|
||
self.assertEqual((artifacts["flows"], artifacts["openWork"]), (["file", "invalid"], 5))
|
||
for state in ("offene Arbeit: nicht committet, Aufgabe offen", "offene Arbeit: ungepusht, Aufgabe offen", "offene Arbeit: nicht gemergt", "3 Worktrees mit offener Arbeit"):
|
||
with self.subTest(state=state):
|
||
self.assertIn(state, artifacts["states"])
|
||
self.assertEqual(by_hash["#artifacts/chord"]["flowEdges"], ["pull", "push"])
|
||
order = ["work/feature", "work/docs", "work/stage", "queue/gate-3", "work/old", "operator/push"]
|
||
listed = by_hash["#tasks/list"]
|
||
self.assertEqual(listed["views"], ["Liste", "Board", "RASCI"])
|
||
self.assertEqual(listed["taskFilters"], [["alle", "true", "6"], ["offen", "false", "4"], ["arbeit", "false", "4"], ["operator", "false", "2"], ["freigabe", "false", "1"], ["beendet", "false", "2"]])
|
||
self.assertEqual(listed["taskRows"], [[task, task not in ("work/old", "operator/push")] for task in order])
|
||
self.assertEqual(listed["flows"], ["file", "file"])
|
||
self.assertEqual(by_hash["#tasks/board"]["columns"], [["queued", ["queue/gate-3"]], ["running", ["work/feature", "work/stage"]], ["blocked", ["work/docs"]], ["done", ["work/old", "operator/push"]], ["stopped", []]])
|
||
self.assertEqual(by_hash["#tasks/rasci"]["matrix"], [6, 6, "CIRARASRARAR"])
|
||
self.assertEqual([probe["taskFilters"][3][1] for probe in probes if probe["hash"] == "#stream/inbox"], ["true"] * len(WIDTHS))
|
||
extra = data["results"][len(probes) :]
|
||
self.assertEqual([probe["hash"] for probe in extra[:3]], ["#agents/orbit", "#agents/chord", "#artifacts/tree"])
|
||
actions = extra[3]
|
||
self.assertNotIn("error", actions)
|
||
self.assertEqual(actions["keys"], ["#agents/chord", "#artifacts/tree", "#tasks/rasci", "#stream"])
|
||
self.assertEqual(actions["ambience"], ["sky-look", "music-toggle", "leitstand", "notify"])
|
||
self.assertEqual((actions["settingsUnlabelled"], actions["drawerUnlabelled"]), (0, 0))
|
||
self.assertEqual([name for name, _, _ in actions["speech"]], ["Post", "Call", "Newsletter", "Broadcast", "MAYDAY", "PAN-PAN", "SECURITE"])
|
||
self.assertEqual([entry[1:] for entry in actions["speech"] if entry[0] == "MAYDAY"], [[True, True]])
|
||
for tool in ("Funk an alle …", "Mayday aufheben", "Priorität …", "Abend", "Regeln …", "Einstellungen …", "Himmel ansehen", "Leitstand umschalten", "Benachrichtigungen aktivieren"):
|
||
with self.subTest(tool=tool):
|
||
self.assertTrue(any(tool in entry for entry in actions["palette"]), actions["palette"])
|
||
self.assertEqual(actions["drawer"], [["post", True], ["call", False]])
|
||
artifact_pick, agent_pick, calm, chips = extra[4:]
|
||
self.assertEqual(artifact_pick["panel"], artifact_pick["label"])
|
||
self.assertEqual(agent_pick["panel"], agent_pick["label"])
|
||
self.assertRegex(calm["note"], r"Kraft · ohne Bewegung \(prefers-reduced-motion\)$")
|
||
self.assertEqual({key: value for key, value in chips["inbox"].items() if key != "counted"}, {"hash": "#stream/inbox", "items": chips["inbox"]["counted"], "pressed": "true", "current": ["Inbox"]})
|
||
self.assertGreaterEqual(chips["inbox"]["items"], 1)
|
||
self.assertEqual(chips["back"], {"hash": "#tasks/list", "rows": [["queue/gate-3", "Queue-Job"]]})
|
||
|
||
def test_chromium_sends_a_call_from_the_agent_panel_and_shows_its_receipt(self):
|
||
handled = []
|
||
|
||
def answer() -> None:
|
||
for script, args in self.scripts.calls[len(handled) :]:
|
||
handled.append(script)
|
||
if script.endswith("radio.py") and args[:2] == ["send", "call"]:
|
||
to, text, now = args[args.index("--to") + 1], args[-1], time.time()
|
||
rows = [{"ts": now, "from": "Operator", "to": [to], "text": text, "class": "call", "v": 2}, {"ts": now + 0.5, "from": to, "to": ["Operator"], "text": text, "class": "receipt", "v": 2, "call": now}]
|
||
with (self.council / "news.jsonl").open("a", encoding="utf-8") as handle:
|
||
handle.write(self.jsonl(rows))
|
||
|
||
self.beside(answer)
|
||
local = f"http://localhost:{self.port}/"
|
||
data = self.drive([{"width": 1366, "height": 768, "url": f"{local}#stream", "wait": 900, "probe": CALL, "shot": self.shot("1366-call-receipt")}], self.authenticator())
|
||
self.assertEqual(data["errors"], [])
|
||
result = data["results"][0]
|
||
self.assertNotIn("error", result)
|
||
self.assertIsNotNone(result["receipt"], result)
|
||
self.assertIn("Gelesen", result["receipt"])
|
||
|
||
def test_chromium_shows_who_speaks_while_a_message_is_read(self):
|
||
self.speaks(("e2e-cue-1", "e2e-cue-2"))
|
||
steps = [
|
||
{"width": 1366, "height": 768, "url": f"{self.base}#stream/radio", "wait": 900, "probe": SPEAK.replace("@CUE@", "e2e-cue-1"), "shot": self.shot("1366-radio-speaking"), "gesture": True},
|
||
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": SPEAK_GRAPH, "shot": self.shot("1366-agents-speaking")},
|
||
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": SPEAK_END, "shot": None},
|
||
{"width": 390, "height": 844, "url": None, "wait": 0, "probe": SPEAK.replace("@CUE@", "e2e-cue-2"), "shot": self.shot("390-radio-speaking-reduced-motion"), "gesture": True, "media": [{"name": "prefers-reduced-motion", "value": "reduce"}]},
|
||
]
|
||
data = self.drive(steps, None)
|
||
(OUT / "chromium-speaking.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||
self.assertEqual(data["errors"], [])
|
||
speaking, graph, ended, calm = data["results"]
|
||
self.assertEqual((speaking["who"], speaking["line"], speaking["chip"], speaking["ring"]), ("Stage spricht", "Platte fast voll (e2e-cue-1)", "s2", "speaking"), speaking)
|
||
self.assertEqual((graph["node"] or "").split(",")[0], "Stage", graph)
|
||
self.assertEqual(graph["ring"], "speaking-node")
|
||
self.assertEqual(ended, {"ended": True, "caption": False, "chips": 0})
|
||
self.assertEqual((calm["who"], calm["line"], calm["ring"]), ("Stage spricht", "Platte fast voll (e2e-cue-2)", "none"), calm)
|
||
|
||
def test_chromium_records_transcribes_and_sends_a_signed_call_with_voice(self):
|
||
local = f"http://localhost:{self.port}/"
|
||
steps = [
|
||
{"width": 1366, "height": 768, "url": f"{local}#stream/radio", "wait": 1200, "probe": COMPOSE.replace("@TO@", "Stage"), "shot": self.shot("1366-radio-composer"), "gesture": True},
|
||
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": COMPOSE_SEND.replace("@ATTACH@", "false"), "shot": None},
|
||
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": COMPOSE.replace("@TO@", "Stage"), "shot": None, "gesture": True},
|
||
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": COMPOSE_SEND.replace("@ATTACH@", "true"), "shot": self.shot("1366-radio-composer-sent")},
|
||
]
|
||
data = self.drive(steps, self.authenticator())
|
||
(OUT / "chromium-composer.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||
self.assertEqual(data["errors"], [])
|
||
composed, plain, again, sent = data["results"]
|
||
self.assertNotIn("error", composed)
|
||
self.assertEqual(composed["channels"], ["post", "call", "newsletter", "broadcast", "pan-pan", "security", "mayday"])
|
||
self.assertIn("Stage", composed["recipients"])
|
||
self.assertEqual(composed["recording"], "true")
|
||
self.assertEqual(composed["text"], "hello from the mic second line", composed)
|
||
self.assertRegex(composed["replay"] or "", r"^/api/voice/[0-9a-f]{16}\.wav$")
|
||
self.assertEqual(composed["attach"], ["Aufnahme anhängen", False])
|
||
self.assertEqual(composed["clashes"], {"found": [], "outside": []})
|
||
self.assertEqual(again["attach"], ["Aufnahme anhängen", False])
|
||
note = again["replay"].removeprefix("/api/voice/").removesuffix(".wav")
|
||
for result in (plain, sent):
|
||
self.assertEqual((result["left"], result["voice"]), ("", False), result)
|
||
self.assertTrue(result["sent"].startswith("Call an Stage: gesendet"), result)
|
||
calls = [args for script, args in self.scripts.calls if script == skill_scripts.RADIO]
|
||
self.assertEqual(len(calls), 2, self.scripts.calls)
|
||
self.assertEqual([call[:4] for call in calls], [["send", "call", "--to", "Stage"]] * 2)
|
||
self.assertTrue(calls[0][-1].endswith("] hello from the mic second line bitte"), calls[0])
|
||
self.assertTrue(calls[1][-1].endswith(f"] hello from the mic second line bitte voice: {HOST_STATE}/voice/{note}.wav"), calls[1])
|
||
status, _, body = self.get(f"/api/voice/{note}.wav")
|
||
self.assertEqual((status, body[:4]), (200, b"RIFF"))
|
||
|
||
def test_chromium_clears_the_mayday_only_after_the_passkey_signs(self):
|
||
local = f"http://localhost:{self.port}/"
|
||
data = self.drive([{"width": 1366, "height": 768, "url": f"{local}#agents/orbit", "wait": 1200, "probe": CLEAR_MAYDAY, "shot": self.shot("1366-mayday-cleared")}], self.authenticator())
|
||
self.assertEqual(data["errors"], [])
|
||
result = data["results"][0]
|
||
self.assertNotIn("error", result)
|
||
self.assertTrue((result["done"] or "").startswith("Mayday aufheben: erledigt"), result)
|
||
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["clear", "--force", "--session", "Operator"])])
|
||
|
||
def test_chromium_deletes_a_merged_branch_after_dry_run_and_signature(self):
|
||
tree = json.loads(self.tree_file.read_text(encoding="utf-8"))
|
||
tree["delete_ready"], tree["delete_reason"] = True, ""
|
||
self.tree_file.write_text(json.dumps(tree), encoding="utf-8")
|
||
demo = f"{self.root}/repos/github.com/me/demo"
|
||
Path(demo).mkdir(parents=True)
|
||
fake = self.outside / "bin" / "pkgmgr"
|
||
fake.parent.mkdir()
|
||
fake.write_text(FAKE_PKGMGR.format(python=sys.executable), encoding="utf-8")
|
||
fake.chmod(0o755)
|
||
worktree = f"{self.root}/worktrees/github.com/me/demo/feature-done"
|
||
(fake.parent / "plan.json").write_text(json.dumps([{"branch": "feature/done", "worktree": worktree, "local_sha": DONE_TIP, "head": DONE_TIP, "state": "merged-clean", "skip": None}]), encoding="utf-8")
|
||
following = json.loads(json.dumps(tree))
|
||
following["repos"][0]["items"] = [item for item in following["repos"][0]["items"] if item["branch"] != "feature/done"]
|
||
(fake.parent / "next-tree.json").write_text(json.dumps(following), encoding="utf-8")
|
||
authenticator = self.authenticator()
|
||
trust = verifier.Trust(approvers=self.approvers, replay=self.replay / "cleanup.json", origin=self.origin)
|
||
runner = host.Cleanup(trust, self.tree_file, fake)
|
||
jobs, results = self.cleanup / "jobs", self.cleanup / "results"
|
||
self.beside(lambda: any(jobs.iterdir()) and host.cycle(jobs, results, runner))
|
||
local = f"http://localhost:{self.port}/"
|
||
steps = [
|
||
{"width": 1366, "height": 768, "url": f"{local}#artifacts/tree", "wait": 1200, "probe": OPEN_DELETE, "shot": self.shot("1366-delete-dry-run")},
|
||
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": CONFIRM, "shot": self.shot("1366-delete-result")},
|
||
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": AFTER, "shot": self.shot("1366-delete-tree-after")},
|
||
]
|
||
data = self.drive(steps, authenticator)
|
||
(OUT / "chromium-delete.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||
self.assertEqual(data["errors"], [])
|
||
opened, confirmed, after = data["results"]
|
||
self.assertNotIn("error", opened)
|
||
self.assertEqual(opened["every"], [False, "Alle gemergten löschen: 1 Branches nach Trockenlauf und Signatur"])
|
||
self.assertEqual(opened["buttons"], [[False, "Löschen: feature/done lokal entfernen, nach Trockenlauf und Signatur"]])
|
||
self.assertEqual(opened["title"], "feature/done löschen")
|
||
self.assertEqual(len(opened["dry"]), 1)
|
||
self.assertTrue(opened["dry"][0].startswith("feature/done @ 5b1e6fd3a7c0: merged-clean, gemergt durch #12"), opened["dry"])
|
||
self.assertIn("bleibt: remote-branch (nur lokal)", opened["dry"][0])
|
||
self.assertEqual((confirmed["result"], confirmed["text"]), ("done", "feature/donegelöscht"), confirmed)
|
||
self.assertEqual(confirmed["toasts"], ["Löschen: erledigtfeature/done: gelöscht"])
|
||
self.assertEqual(after["past"], ["done", "gelöscht"])
|
||
self.assertNotIn("feature/done", after["branches"])
|
||
written = [json.loads(path.read_text(encoding="utf-8")) for path in results.glob("*.json")]
|
||
self.assertEqual([[(item["branch"], item["status"]) for item in result["items"]] for result in written], [[("feature/done", "done")]])
|
||
calls = [json.loads(line)["argv"] for line in (fake.parent / "calls.jsonl").read_text(encoding="utf-8").splitlines()]
|
||
self.assertEqual(calls, [
|
||
["branch", "cleanup", "--json", "--local", "--branch", "feature/done"],
|
||
["branch", "cleanup", "--apply", "--json", "--local", "--branch", "feature/done"],
|
||
["branch", "tree", "--format", "json", "--output", str(self.tree_file)],
|
||
])
|
||
|
||
def test_chromium_folds_the_hierarchy_by_keyboard_reorders_agents_and_hands_an_office_over_with_a_signature(self):
|
||
signer = self.pin()
|
||
trust = verifier.Trust(approvers=self.approvers, replay=self.replay / "offices.json", origin=self.origin)
|
||
runner = office_host.Offices(trust, self.council, COUNCIL_SCRIPT, self.outside / "home")
|
||
jobs, results = self.offices / "jobs", self.offices / "results"
|
||
self.beside(lambda: any(jobs.iterdir()) and office_host.cycle(jobs, results, runner))
|
||
private = "".join(line for line in signer.pem.read_text(encoding="utf-8").splitlines() if not line.startswith("-----"))
|
||
authenticator = {"credentialId": base64.b64encode(signer.raw_id).decode(), "privateKey": private, "rpId": "localhost"}
|
||
local = f"http://localhost:{self.port}/"
|
||
steps = [
|
||
{"width": 1366, "height": 768, "url": f"{local}#agents/hierarchy", "wait": 1200, "probe": ORG_OPEN, "shot": self.shot("1366-hierarchy-open")},
|
||
{"width": 1366, "height": 768, "url": None, "wait": 0, "keys": ["Enter"], "probe": ORG_FOLDED, "shot": None},
|
||
{"width": 1366, "height": 768, "url": None, "wait": 0, "keys": ["Enter"], "probe": ORDER_MOVED, "shot": self.shot("1366-hierarchy-order")},
|
||
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": OPEN_TAKEOVER, "shot": self.shot("1366-hierarchy-takeover")},
|
||
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": CONFIRM_TAKEOVER, "shot": self.shot("1366-hierarchy-takeover-result")},
|
||
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": AFTER_TAKEOVER, "shot": self.shot("1366-hierarchy-after")},
|
||
]
|
||
data = self.drive(steps, authenticator)
|
||
(OUT / "chromium-hierarchy.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||
self.assertEqual(data["errors"], [])
|
||
opened, folded, moved, sheet, confirmed, after = data["results"]
|
||
self.assertNotIn("error", opened)
|
||
self.assertEqual(opened["focused"], "toggle:dimension:identity")
|
||
self.assertEqual((folded["focused"], folded["expanded"], folded["label"], folded["hidden"], folded["again"]), ("toggle:dimension:identity", "false", "Aufklappen: identity", True, "true"))
|
||
self.assertNotIn("identity/oidc", folded["seen"])
|
||
before = folded["before"]
|
||
self.assertEqual(moved["after"], [before[1], before[0], *before[2:]])
|
||
self.assertEqual((moved["focused"], moved["disabled"]), (f"down:{before[1]}", False))
|
||
self.assertTrue(moved["toast"].startswith("Priorität"), moved)
|
||
self.assertIn(("rasci.py", ["agents", "order", ",".join(moved["after"])]), [(Path(script).name, args) for script, args in self.scripts.calls])
|
||
self.assertEqual((sheet["title"], sheet["options"], sheet["work"], sheet["focused"]), ("identity/oidc: Übergeben", ["s1"], "oidc line", "SELECT"))
|
||
self.assertEqual((confirmed["result"], confirmed["text"]), ("done", "identity/oidcerledigt"), confirmed)
|
||
self.assertIn("Übergeben: erledigtidentity/oidc: erledigt", confirmed["toasts"])
|
||
self.assertNotIn("error", after)
|
||
claim = json.loads((self.council / "identity" / "oidc" / "claim.json").read_text(encoding="utf-8"))
|
||
self.assertEqual((claim["session"], claim["work"]), ("s1", "oidc line"))
|
||
written = [json.loads(path.read_text(encoding="utf-8")) for path in results.glob("*.json")]
|
||
self.assertEqual([[(item["office"], item["action"], item["status"]) for item in result["items"]] for result in written], [[("identity/oidc", "takeover", "done")]])
|
||
|
||
def inbox_work(self) -> None:
|
||
stage = {"title": "Stage line", "status": "running", "worker": "Stage (this session)", "roles": {"Stage (this session)": "RA"}}
|
||
greet = {"title": SLOW, "workdir": str(self.outside), "description": "zählt", "check": "manual", "status": "queued", "roles": {"Operator": "R", "Skills session": "A"}}
|
||
push = {"title": "git push", "workdir": "/repo", "description": "ship", "check": "manual", "status": "queued", "roles": {"Operator": "R", "Skills session": "A"}}
|
||
self.write_council({"rasci.json": {"work/stage": stage, "operator/greet": greet, "operator/push": push}})
|
||
arguments = {"kind": "ask", "tool": "Bash", "pattern": "make deploy", "scope": "/repo*", "expires": int(time.time()) + 7 * 86400, "agent": "Skills", "why": "deploy runs after every green gate"}
|
||
self.assertFalse(self.tool("rule_request", arguments)["isError"])
|
||
|
||
def layout(self, probe: dict) -> None:
|
||
self.assertNotIn("error", probe)
|
||
self.assertEqual(sorted(probe["kinds"]), ["Ask", "Operator", "Operator", "Regelwunsch", "Review"])
|
||
self.assertIn(["Ablehnen", "Ablehnen über …"], probe["splits"])
|
||
self.assertEqual(len(probe["splits"]), 4)
|
||
self.assertEqual(probe["fields"], ["Art", "Werkzeug (Glob)", "Muster (Glob)", "Verzeichnis (Glob)", "Läuft ab am"])
|
||
self.assertEqual(probe["dry"], "Trockenlauf: Die Regel hätte 1 von 1 Asks der letzten 7 Tage freigegeben.")
|
||
self.assertEqual(probe["accept"], 1)
|
||
self.assertTrue(probe["fits"] and probe["followed"], probe)
|
||
self.assertEqual((probe["unlabelled"], probe["wide"]), ([], False))
|
||
|
||
def test_chromium_runs_a_command_into_its_console_denies_by_call_and_accepts_a_rule(self):
|
||
self.inbox_work()
|
||
signer = self.pin()
|
||
trust = ["--approvers", str(self.approvers), "--replay", str(self.replay / "runner.json")]
|
||
|
||
def run_jobs() -> None:
|
||
if any(job["status"] == "queued" for job in self.httpd.hub.jobs.listed()):
|
||
subprocess.run([sys.executable, str(RUNNER), "--port", str(self.port), "--interval", "0.1", *trust, "--once"], capture_output=True, timeout=120, check=False)
|
||
|
||
self.beside(run_jobs)
|
||
private = "".join(line for line in signer.pem.read_text(encoding="utf-8").splitlines() if not line.startswith("-----"))
|
||
authenticator = {"credentialId": base64.b64encode(signer.raw_id).decode(), "privateKey": private, "rpId": "localhost"}
|
||
inbox = f"http://localhost:{self.port}/#stream/inbox"
|
||
steps = [
|
||
{"width": 1366, "height": 768, "url": inbox, "wait": 1200, "probe": RUN_START, "shot": self.shot("1366-inbox-console-running")},
|
||
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": RUN_HELD, "shot": self.shot("1366-inbox-console-scrolled-up")},
|
||
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": RUN_DONE, "shot": self.shot("1366-inbox-console-done")},
|
||
]
|
||
for width, height in WIDTHS:
|
||
fresh = f"http://localhost:{self.port}/?{width}#stream/inbox"
|
||
steps.append({"width": width, "height": height, "url": fresh, "wait": 1200, "probe": LAYOUT, "shot": self.shot(f"{width}-inbox-console")})
|
||
steps.append({"width": width, "height": height, "url": None, "wait": 0, "probe": DENY_SHEET, "shot": self.shot(f"{width}-inbox-deny-sheet")})
|
||
steps += [
|
||
{"width": 1366, "height": 768, "url": inbox, "wait": 1200, "probe": DENY, "shot": self.shot("1366-inbox-deny-undo")},
|
||
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": DENIED, "shot": None},
|
||
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": ACCEPT, "shot": self.shot("1366-inbox-rule-accepted")},
|
||
]
|
||
data = self.drive(steps, authenticator)
|
||
(OUT / "chromium-inbox.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||
self.assertEqual(data["errors"], [])
|
||
started, held, done, *rest = data["results"]
|
||
self.assertNotIn("error", started)
|
||
self.assertIn("wird in", started["toast"])
|
||
self.assertTrue(started["running"].startswith("läuft"), started)
|
||
self.assertTrue(started["overflows"] and started["followed"], started)
|
||
self.assertLess(held["held"], 50, held)
|
||
self.assertEqual((done["last"], done["lines"], done["followed"]), ("fertig", 81, True))
|
||
self.assertRegex(done["done"], r"^erledigtExit-Code 0 nach \d+ s$")
|
||
self.assertIn("Schließen", done["actions"])
|
||
layouts, sheets = rest[0:6:2], rest[1:6:2]
|
||
for probe, sheet, (width, _) in zip(layouts, sheets, WIDTHS, strict=True):
|
||
with self.subTest(width=width):
|
||
self.layout(probe)
|
||
self.assertTrue(probe["console"].startswith("erledigtExit-Code 0"), probe)
|
||
self.assertEqual(sheet["title"], "Ablehnen")
|
||
self.assertEqual(sheet["channels"], [["post", True], ["call", False], ["newsletter", False], ["broadcast", False], ["pan-pan", False], ["security", False]])
|
||
self.assertEqual(sheet["hints"][:3], ["an Skills, sobald sein Zug endet", "an Skills, sofort, weckt ihn", "an alle, sobald ihr Zug endet"])
|
||
self.assertEqual((sheet["reason"], sheet["unlabelled"], sheet["wide"]), (True, [], False))
|
||
denied, gone, accepted = rest[6:]
|
||
self.assertTrue(denied["undo"].startswith("Ablehnung (Call): Bash: make deploy"), denied)
|
||
self.assertFalse(denied["sheet"])
|
||
self.assertTrue(gone["gone"])
|
||
self.assertEqual((accepted["toast"], accepted["left"]), ("Angelegt: Regel: ask Bash make deploy in /repo*", False))
|
||
ask = self.httpd.hub.asks.listed()[0]
|
||
self.assertEqual((ask["status"], ask["reason"], ask["channel"]), ("denied", "erst das Gate grün machen", "call"))
|
||
calls = [args for script, args in self.scripts.calls if script.endswith("radio.py")]
|
||
self.assertEqual(calls[-1][:4], ["send", "call", "--to", "Skills session"])
|
||
self.assertTrue(calls[-1][-1].endswith("Reason: erst das Gate grün machen"), calls[-1])
|
||
self.assertEqual([(rule["kind"], rule["pattern"], rule["proof"] is not None) for rule in self.httpd.hub.rules.listed()], [("ask", "make deploy", True)])
|
||
self.assertEqual([(job["status"], job["code"]) for job in self.httpd.hub.jobs.listed()], [("done", 0)])
|
||
|
||
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
|
||
def test_firefox_draws_the_inbox_console_and_the_rule_request_at_three_widths(self):
|
||
self.inbox_work()
|
||
now = time.time()
|
||
fields = {"task": "operator/greet", "command": SLOW, "directory": str(self.outside), "sha256": "", "status": "done", "proof": None}
|
||
output = "".join(f"Zeile {number}\n" for number in range(1, 81)) + "fertig\n"
|
||
self.httpd.hub.jobs.add({**fields, "code": 0, "output": output, "created_ts": now - 20, "started": now - 19, "finished": now - 10, "dismissed": False})
|
||
shots = [(width, height, OUT / f"firefox-{width}-inbox-console.png") for width, height in WIDTHS]
|
||
steps = [{"width": width, "height": height, "url": f"{self.base}#stream/inbox", "wait": 1200, "probe": LAYOUT, "shot": str(shot)} for width, height, shot in shots]
|
||
data = self.drive(steps, None, "bidi.mjs", os.environ.get("FIREFOX", "firefox"))
|
||
(OUT / "firefox-inbox.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||
self.assertEqual([error for error in data["errors"] if "(style-src-elem)" not in error and "/favicon.ico" not in error], [])
|
||
for probe, (width, _, shot) in zip(data["results"], shots, strict=True):
|
||
with self.subTest(width=width):
|
||
self.layout(probe)
|
||
self.assertEqual(probe["console"], "erledigtExit-Code 0 nach 9 s")
|
||
self.assertTrue(shot.is_file() and shot.stat().st_size > 1000, shot)
|
||
|
||
def test_chromium_pins_a_task_priority_after_signature_and_the_runner_applies_it(self):
|
||
signer = self.pin()
|
||
private = "".join(line for line in signer.pem.read_text(encoding="utf-8").splitlines() if not line.startswith("-----"))
|
||
authenticator = {"credentialId": base64.b64encode(signer.raw_id).decode(), "privateKey": private, "rpId": "localhost"}
|
||
local = f"http://localhost:{self.port}/"
|
||
data = self.drive([{"width": 1366, "height": 768, "url": f"{local}#tasks/list", "wait": 1200, "probe": PIN, "shot": self.shot("1366-tasks-priority-signed")}], authenticator)
|
||
(OUT / "chromium-task-priority.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||
self.assertEqual(data["errors"], [])
|
||
pinned = data["results"][0]
|
||
self.assertNotIn("error", pinned)
|
||
self.assertEqual((pinned["title"], pinned["preview"][0]), ("work/docs", "rasci.py set work/docs --prio 2"))
|
||
self.assertEqual(pinned["toasts"], ["Priorität work/docs: an den Runner übergebenrasci.py set work/docs --prio 2"])
|
||
jobs = self.runner_jobs()
|
||
self.assertEqual([(job["id"], job["kind"], job["argv"]) for job in jobs], [(pinned["jobs"], "task", ["set", "work/docs", "--prio", "2"])])
|
||
self.run_runner()
|
||
register = json.loads((self.council / "rasci.json").read_text(encoding="utf-8"))
|
||
self.assertEqual((register["work/docs"]["prio"], register["work/feature"]["prio"]), (2, 1))
|
||
self.assertEqual([(row["task"], row["prio"]) for row in self.snapshot()["tasks"][:2]], [("work/feature", 1), ("work/docs", 2)])
|
||
|
||
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
|
||
def test_firefox_walks_every_screen_at_three_widths(self):
|
||
shots = [(width, height, screen, OUT / f"firefox-{width}-{screen.replace('/', '-')}.png") for width, height in WIDTHS for screen in SCREENS]
|
||
for *_, shot in shots:
|
||
shot.unlink(missing_ok=True)
|
||
steps = [{"width": width, "height": height, "url": f"{self.base}#{screen}", "wait": 900, "probe": PROBE, "shot": str(shot)} for width, height, screen, shot in shots]
|
||
data = self.drive(steps, None, "bidi.mjs", os.environ.get("FIREFOX", "firefox"))
|
||
(OUT / "firefox-walk.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||
blocked = [error for error in data["errors"] if error.startswith("Content-Security-Policy")]
|
||
self.assertEqual([error for error in data["errors"] if error not in blocked], [])
|
||
# 3d-force-graph injects its own <style>, which the unchanged CSP blocks; the page carries those rules itself.
|
||
self.assertEqual([error for error in blocked if "(style-src-elem)" not in error and "/favicon.ico" not in error], [])
|
||
for probe, (width, _, screen, shot) in zip(data["results"], shots, strict=True):
|
||
with self.subTest(width=width, screen=screen):
|
||
self.assertNotIn("error", probe)
|
||
self.assertEqual(probe["hash"], f"#{screen}")
|
||
self.assertEqual((probe["tabs"], probe["subs"]), (TABS, SUBS))
|
||
self.assertEqual(labelled(probe), {"unlabelled": [], "broken": []})
|
||
self.assertFalse(probe["wide"], "the page scrolls sideways")
|
||
self.assertTrue(shot.is_file() and shot.stat().st_size > 1000, shot)
|
||
if screen.endswith("/3d"):
|
||
self.assertTrue(probe["canvas"], probe["note"])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|