Add a Tasks tab that lists the kvb-org-rasci register next to the inbox and the heavy-job queue, in list, board and RASCI matrix views, each task with its roles, WSJF, open work and the merge flow of its worktree. - Task priority pins and WSJF estimates are signed with the operator's passkey and applied by the runner through rasci.py; the hub verifies the signature itself before it queues a job or logs the change, the runner verifies it again and runs only the checked argv, from /, with a pinned PATH and a 30 s process-group limit. - The runner refuses task changes into a council that is a symlink, not owned by its uid or given by a relative path, and while the installed council.py, nofollow.py or rasci.py differ from the tree. - Only tasks the hub can sign whole are editable (task slug and length). - Page lookups keyed by register text use Object.hasOwn, so a status or party named like an Object.prototype member cannot break the views. - The inbox screen renders through the tasks screen, so its task filter shows with "Wartet auf Operator" pressed. Tests: 604 unit tests OK, e2e 15/15 OK (Chromium and Firefox). 🎯 Fix: 93 % 💥 Break: 5 % 💸 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": []}})
|
||
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}]})
|
||
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()
|