tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1184Shell 70Rust 62Markdown 501503 Sutichaykuna

⎇ 0cd3e62822cc6e6748517894fd65a9a738d0fa35 scripts/test-atuq-medios.py Python · 250 siq'ikuna · 11.9 KB Ñawpa willay Pi imata llamkarqan ch'uya

atuq: un vídeo se abre en el reproductor de la distro, no en una pestaña 928d5eb11 · Sergio ·

1#!/usr/bin/env python3
2"""¿Un medio se abre en el reproductor de la distro y no en una pestaña? (SDD 26 §6.6)
3
4    python3 scripts/test-atuq-medios.py                     # el vídeo sale por mpv
5    python3 scripts/test-atuq-medios.py --negative-control   # SIN mpv: la navegación vuelve al navegador
6
7── QUÉ SE EJERCITA ───────────────────────────────────────────────────────────────────────────
8Un servidor HTTP sirve un audio real (`audio/wav`); el navegador navega a él; la extensión ve por la
9cabecera que es un medio, **cancela la navegación** y le pasa la URL al host, que lanza el reproductor
10de la distro. Se comprueban las tres cosas: que la extensión lo detectó y canceló, que el host
11contestó con el **pid** del proceso, y que **el reproductor de verdad abrió esa URL** — su propia
12salida aparece en el log, porque hereda la del navegador.
13
14── EL CONTROL NEGATIVO PRUEBA LA PROMESA QUE MÁS IMPORTA ─────────────────────────────────────
15`--negative-control` borra `/usr/bin/mpv` dentro de la jaula y exige que la navegación **vuelva al
16navegador**. La decisión de cancelar es síncrona y la respuesta del host llega después, así que el
17único modo de fallo inaceptable es quedarse sin vídeo Y sin pestaña. Sin este control, una extensión
18que cancelara siempre se vería idéntica a una que funciona — hasta el día que falte el reproductor.
19
20REQUISITOS: el rootfs de un escritorio (el mínimo de `atuq-nested.sh` NO trae `mpv`), y `atuq` +
21`puriy-costura` sellados.
22"""
23import http.server
24import os
25import shutil
26import socket
27import struct
28import subprocess
29import sys
30import tempfile
31import threading
32
33ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
34RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/sway-rootfs")
35ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
36HAMMER = os.path.join(ROOT, "target/release/takana")
37STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
38MPV = "/usr/bin/mpv"
39
40
41def fatal(msg):
42    print(f"✗ {msg}", file=sys.stderr)
43    raise SystemExit(1)
44
45
46def artefacto(receta, nombre, escotilla=None):
47    forzado = os.environ.get(escotilla) if escotilla else None
48    if forzado:
49        if not os.path.isdir(forzado):
50            fatal(f"{escotilla}={forzado} no existe")
51        print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
52        return forzado
53    r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
54                       cwd=ROOT, capture_output=True, text=True)
55    if r.returncode != 0:
56        fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
57    h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
58    d = os.path.join(STORE, f"{h}-{nombre}")
59    if not os.path.isdir(d):
60        fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes")
61    return d
62
63
64def wav(segundos=0.3, hz=8000):
65    """Un WAV real, hecho a mano: sin esto habría que traer un binario de prueba al
66    repo, y un fichero opaco en un test es una dependencia que nadie revisa."""
67    n = int(segundos * hz)
68    datos = b"\x00" * n
69    cab = (b"RIFF" + (36 + len(datos)).to_bytes(4, "little") + b"WAVEfmt "
70           + (16).to_bytes(4, "little") + (1).to_bytes(2, "little") + (1).to_bytes(2, "little")
71           + hz.to_bytes(4, "little") + hz.to_bytes(4, "little")
72           + (1).to_bytes(2, "little") + (8).to_bytes(2, "little")
73           + b"data" + len(datos).to_bytes(4, "little"))
74    return cab + datos
75
76
77AUDIO = wav()
78
79
80class Servidor(http.server.SimpleHTTPRequestHandler):
81    def log_message(self, *a):
82        pass
83
84    def do_GET(self):
85        if self.path.startswith("/audio.wav"):
86            datos, tipo = AUDIO, "audio/wav"
87        elif self.path.startswith("/ir.html"):
88            # ⚠ NO se navega al medio desde la línea de comandos, y la razón está
89            # medida: una URL en el argumento se pide en el ARRANQUE y compite con la
90            # inicialización de la extensión — la misma carrera que ya apareció en el
91            # guardián de `sct`, sólo que acá hay una sola carga y no hay segunda
92            # oportunidad. Esta página espera tres segundos y recién entonces va al
93            # medio, que además es lo que hace una persona: seguir un enlace.
94            datos = (b'<!doctype html><html><head><meta charset="utf-8">'
95                     b'<meta http-equiv="refresh" content="3;url=/audio.wav">'
96                     b"</head><body>yendo al medio</body></html>")
97            tipo = "text/html; charset=utf-8"
98        else:
99            datos, tipo = b"<html><body>no soy un medio</body></html>", "text/html"
100        self.send_response(200)
101        self.send_header("Content-Type", tipo)
102        self.send_header("Content-Length", str(len(datos)))
103        self.send_header("Cache-Control", "no-store")
104        self.end_headers()
105        self.wfile.write(datos)
106
107
108def puerto_libre():
109    s = socket.socket()
110    s.bind(("127.0.0.1", 0))
111    p = s.getsockname()[1]
112    s.close()
113    return p
114
115
116def main():
117    negativo = "--negative-control" in sys.argv[1:]
118    if negativo:
119        print("== MODO CONTROL NEGATIVO: sin mpv; la navegación tiene que VOLVER al navegador")
120    if not os.path.isdir(RFS):
121        fatal(f"no está el rootfs en {RFS} — hidratá un perfil de escritorio (trae `mpv`)")
122    if not os.path.isfile(os.path.join(RFS, MPV.lstrip("/"))):
123        fatal(f"{RFS} no trae {MPV}: este guardián necesita el reproductor de la imagen")
124    atuq = artefacto("atuq", "atuq", "ATUQ_DIR")
125    costura = artefacto("puriy-costura", "puriy-costura")
126    print(f"artefactos: {os.path.basename(atuq)}\n            {os.path.basename(costura)}")
127
128    puerto = puerto_libre()
129    servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Servidor)
130    threading.Thread(target=servidor.serve_forever, daemon=True).start()
131    url = f"http://127.0.0.1:{puerto}/audio.wav"
132    partida = f"http://127.0.0.1:{puerto}/ir.html"
133
134    tmp = tempfile.mkdtemp(prefix="atuq-medios-")
135    salida = os.path.join(tmp, "salida")
136    os.makedirs(os.path.join(salida, "perfil"))
137    # La configuración del reproductor la pone el TEST, no el producto: sin salida de
138    # vídeo ni de audio (no hay pantalla ni tarjeta en la jaula) y con un tope de
139    # duración para que termine solo. Meter esto en el host sería cablear el arnés
140    # dentro del artefacto.
141    conf = os.path.join(salida, "hogar", ".config", "mpv")
142    os.makedirs(conf)
143    with open(os.path.join(conf, "mpv.conf"), "w") as fh:
144        # `log-file` es la evidencia: la salida del reproductor ya NO va al stdout del
145        # host —eso era el bug que este guardián encontró, ver §6.6— y Gecko no vuelca
146        # el stderr del host al del navegador, así que hay que pedirle al propio
147        # reproductor que deje su rastro donde se pueda leer.
148        fh.write("vo=null\nao=null\nlength=0.2\nmsg-level=all=info\nlog-file=/salida/mpv.log\n")
149    try:
150        with open(os.path.join(salida, "perfil", "user.js"), "w") as fh:
151            fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
152            fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
153
154        borrar = f"rm -f {MPV}" if negativo else "true"
155        guion = f"""
156        mkdir -p /root /salida/hogar
157        [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
158        export HOME=/salida/hogar
159        {borrar}
160        timeout 50 /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{partida}' \
161            > /salida/consola.log 2>&1
162        exit 0
163        """
164        subprocess.run([
165            "bwrap",
166            "--overlay-src", ALPINE, "--overlay-src", RFS,
167            "--overlay-src", atuq, "--overlay-src", costura,
168            "--tmp-overlay", "/",
169            "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
170            "--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
171            "--bind", salida, "/salida",
172            "--uid", "0", "--gid", "0",
173            "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
174            "--setenv", "XDG_RUNTIME_DIR", "/run",
175            "--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1",
176            "--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1",
177            "--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1",
178            "--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1",
179            "--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1",
180            "--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1",
181            "sh", "-c", guion,
182        ], check=False)
183
184        log = open(os.path.join(salida, "consola.log"), errors="replace").read()
185        lineas = [l[len("MEDIOS "):] for l in log.splitlines() if l.startswith("MEDIOS ")]
186        for l in lineas:
187            print("   " + l)
188        if not lineas:
189            destino = os.path.join(ROOT, "work", "atuq-medios.log")
190            os.makedirs(os.path.dirname(destino), exist_ok=True)
191            open(destino, "w").write(log)
192            fatal(f"la extensión no dijo nada — log en {destino}")
193
194        detecto = any(l.startswith("MEDIO ") for l in lineas)
195        cancelo = any(l.startswith("CANCELADA ") for l in lineas)
196        abierto = [l for l in lineas if l.startswith("ABIERTO ")]
197        volvio = [l for l in lineas if l.startswith("VUELVE AL NAVEGADOR")]
198        sin_repro = [l for l in lineas if l.startswith("SIN REPRODUCTOR")]
199
200        if not detecto:
201            fatal("la extensión no reconoció el `audio/wav` como medio")
202
203        if negativo:
204            if abierto:
205                fatal(f"sin mpv dijo que abrió algo: {abierto}")
206            if not sin_repro:
207                fatal("sin mpv, el host tenía que fallar DICIÉNDOLO y la extensión decirlo")
208            if not volvio:
209                fatal("sin reproductor, la navegación NO volvió al navegador —"
210                      " el usuario se quedaría sin vídeo y sin pestaña")
211            print("\n✓ control negativo: sin reproductor no se finge nada y la navegación vuelve"
212                  " al navegador")
213            return 0
214
215        if not cancelo:
216            fatal("la extensión no canceló la navegación: el vídeo seguiría en una pestaña")
217        if not abierto:
218            fatal("el host no confirmó haber abierto el reproductor")
219        if MPV not in abierto[0]:
220            fatal(f"abrió algo que no es el reproductor de la imagen: {abierto[0]}")
221        if url not in abierto[0]:
222            fatal(f"abrió otra URL: {abierto[0]}")
223        if volvio:
224            fatal(f"volvió al navegador habiendo reproductor: {volvio}")
225
226        # Y la prueba de que el reproductor CORRIÓ de verdad, y no que hubo un `spawn`:
227        # su propio log. Su salida ya no va al stdout del host (eso era el bug) y Gecko
228        # no vuelca el stderr del host al del navegador, así que la evidencia se le pide
229        # al reproductor.
230        ruta_mpv = os.path.join(salida, "mpv.log")
231        if not os.path.isfile(ruta_mpv):
232            destino = os.path.join(ROOT, "work", "atuq-medios.log")
233            os.makedirs(os.path.dirname(destino), exist_ok=True)
234            open(destino, "w").write(log)
235            fatal(f"el reproductor no dejó su log: no llegó a correr — consola en {destino}")
236        propio = open(ruta_mpv, errors="replace").read()
237        if url not in propio:
238            fatal(f"el reproductor corrió pero no abrió esa URL — su log dice:\n{propio[:400]}")
239        for l in [x for x in propio.splitlines() if "Playing" in x or "AO:" in x][:3]:
240            print("   mpv: " + l.strip()[:110])
241
242        print("\n✓ una navegación a un medio se CANCELA y la abre el reproductor de la distro")
243        print("✓ y el reproductor corrió de verdad: su propia salida lo dice, no un `spawn` a ciegas")
244        return 0
245    finally:
246        servidor.shutdown()
247        shutil.rmtree(tmp, ignore_errors=True)
248
249
250sys.exit(main())